Skip to content

feat(S4): agent-graph canvas + analysis cache/replay - #5

Merged
manjula25 merged 19 commits into
mainfrom
feature/caresync-s4-agent-graph-cache
Jul 5, 2026
Merged

feat(S4): agent-graph canvas + analysis cache/replay#5
manjula25 merged 19 commits into
mainfrom
feature/caresync-s4-agent-graph-cache

Conversation

@manjula25

Copy link
Copy Markdown
Collaborator

Summary

  • Native Canvas agent graph (no chart library, GD10) animates the S3 four-agent orchestration through IDLE→INIT→DISPATCH→ANALYZING→SYNTHESIZING→COMPLETE, with per-agent color identity consistent from graph node → feed box → task citation.
  • Per-patient analysis cache (SQLite analysis_cache): default "Run Analysis" replays the last successful run deterministically with zero model calls; an explicit "Run live" trigger forces a fresh orchestrator run and re-caches (GD2). Cached and live runs emit byte-identical SSE, so the client has one render path for both.
  • Cache-replay enforces the same clinical-scope guard and audit trail as a live read — including a code-review-caught fix in this PR: successful replays weren't writing a success audit row (only denials were), which is now fixed test-first.

Test plan

  • cd apps/api && npx jest --runInBand — 90/90 (parallel workers flake on shared-HAPI contention, a pre-existing env issue, not new in this PR)
  • cd apps/web && npm test — 69/69
  • cd apps/web && npx playwright test — 7/7 (3 new S4 specs + 4 existing, no regressions)
  • npm run build + npm run lint for both apps — clean
  • verification-before-completiondocs/plans/caresync-ai/verification.md (every AC mapped to its proving artifact with evidence-strength labels)
  • code-review (Standards + Spec axes) → docs/plans/caresync-ai/review.md; the one confirmed defect (replay success-audit gap) fixed and re-verified

Full detail: docs/superpowers/specs/feature-caresync-s4-agent-graph-cache/2026-07-05-changelog.md, docs/plans/caresync-ai/verification.md, docs/plans/caresync-ai/review.md.

🤖 Generated with Claude Code

manjula25and others added 19 commits July 5, 2026 01:22
Adds patient_id-keyed analysis_cache table to the shared migrate()
function and a new db/analysisCache.ts module (writeAnalysisCache /
readAnalysisCache) so a validated analysis result (post-citation-gate)
can be persisted and replayed without depending on any Task still
existing. One row per patient, overwritten on re-run (no history
table, per S4 ponytail note).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task A2: POST /:id/analysis now defaults to replaying a patient's
analysis_cache row (zero orchestrator calls, zero HAPI reads/writes) if one
exists, falls back to a live run + cache write on a cold cache, and honors
?live=1 to always force a fresh orchestrator run that overwrites the row.
Replay re-emits the same finding/task/complete SSE events, in the same
phased per-agent order, that a live run produces, so the S4 canvas can't
tell the two paths apart. Threads `db` through createAnalysisRouter (and
apps/api/src/index.ts's wiring) with readAnalysisCache/writeAnalysisCache
as injectable, defaulted params mirroring the existing runAnalysis pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cache-replay branch of POST /:id/analysis skipped getPatientBundle
entirely (by design, to avoid a HAPI call) but that also skipped the only
place role->scope enforcement and audit logging happened for this data
(FhirReadService.guard, private). A role denied 'clinical' scope (e.g.
Social Worker) could read another user's cached clinical analysis with no
audit trail, once anyone had triggered one live run for that patient.
Replay now checks hasScope(actor.role, 'clinical') itself (a local
comparison, no HAPI call) before replaying, writes the same denial audit
entry FhirReadService.guard would write, and returns the same 403 shape the
live path already returns for this role.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…path
Code-quality review of the A2 cache-replay work (c256fe0, 2b68194) flagged
two Important issues:
1. The replay-path scope check hand-rolled hasScope/writeAudit/
ScopeDeniedError inline instead of reusing FhirReadService's existing
guard, risking future drift between the live and replay enforcement of
the same invariant. Added a public `assertScope` on FhirReadService (a
thin delegate to the existing private `guard`, so every other call site
is untouched) and had the route call it, wrapped in the same
try/catch-ScopeDeniedError pattern the live path already uses around
getPatientBundle.
2. The replay branch had no error boundary: an unchecked cast
(`cached.resultJson as AnalysisResultJson`) meant a malformed/legacy
cached row would throw inside replayCachedAnalysis after headers were
already sent, hanging the connection with no `error`/`done` event —
inconsistent with the live path's established try/catch convention.
Wrapped the replay call in the same pattern, emitting the same `error`
SSE event on failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
B1 — pure analysisGraphReducer maps the real per-agent SSE vocabulary
(token/finding/complete/task/done, each tagged with agentId) to graph-level
state (idle→init→dispatch→analyzing→synthesizing→complete) and per-node
status (risk/careGap/sdoh/actionPlanner: pending→analyzing→complete), plus
a thin useAnalysisGraph() hook wrapper for future wiring into
PatientDetail.tsx. Covered by a fixture mirroring replayCachedAnalysis's
phased risk→careGap→sdoh→actionPlanner→done order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ize test indices
Code review on 80b2b95 flagged filename/export-vocabulary mismatch (file said
StateMachine, exports said Graph) and brittle numeric snapshot indices in the
test (silently point at the wrong snapshot if FIXTURE is edited). Renamed the
module/test to analysisGraph(.test).ts to match the Graph-* export names, and
restructured the fixture into labeled {label, action} steps with runFixture
returning snapshots keyed by label instead of array position.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports reference-materials/caresync-ai.html's #agentGraph canvas (5-node
radial layout, bezier edges, particle flow, per-agent color identity) into
a React/Canvas2D component driven by B1's real AnalysisGraphState instead
of the mockup's fake elapsed-time clock. Pure geometry/timing math lives in
agentGraphGeometry.ts (unit tested); AgentGraph.tsx owns the rAF loop, SSR/
null-ctx guards, prefers-reduced-motion static fallback, and per-node
visuals driven by individual agent status (supports real phased
completion, unlike the mockup's single fake timeline). PatientDetail now
dispatches to useAnalysisGraph() from the same streamAnalysis handlers that
already update feed state, and renders AgentGraph above the feeds grid,
closing W03's last recorded mockup-fidelity deviation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ced-motion
Self-review catch: with prefers-reduced-motion active, the component skipped
the rAF loop entirely and only ever painted once at mount (freezing on the
initial idle state) — a repaint on state change was missing, so it never
reflected real progress. Adds a `repaintOnceRef` the mount effect assigns
once the canvas ctx is ready, invoked by a second effect keyed on `state`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s exercise real draw path
Addresses the B2 code-quality review (Changes Requested):
- IMPORTANT 1: the reduced-motion static frame now paints the SETTLED
resting state (completion rings shown, "✓ Analysis complete" text shown,
orchestrator settle-glow faded off) instead of the elapsed≈0 mid-settle
frame the [state] repaint effect used to produce on the `done` transition.
A `settledTiming()` feeds SETTLED_ELAPSED_SEC to every since-transition
animation so the plan's "render final state statically" actually holds.
- MINOR 3: the reduced-motion resize handler now repaints, so a window
resize no longer blanks the canvas until the next state change.
- MINOR 4: extracted the ~125-line draw closure to a module-level
`paintFrame(ctx, W, H, state, timing)` seam — shrinks the mount effect and
makes the draw logic directly unit-testable.
- IMPORTANT 2: canvas tests now stub a no-op 2D context (jsdom's getContext
returns null, so the old tests early-returned before painting and guarded
nothing). The reduced-motion regression test now spies clearRect and
asserts a state change fires an ADDITIONAL paint, plus fillText asserts the
settled checkmark text — both verified to FAIL when their respective fixes
are reverted. Added direct paintFrame unit tests too.
Web suite 56 -> 61 (all green); build + lint clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add an optional { live } flag to streamAnalysis that appends ?live=1,
and a secondary "Run live" button in PatientDetail alongside the default
cache-first "Run Analysis". Both drive the identical graph + feeds path;
a minimal mode note (derived from the pressed button) surfaces
cached replay vs live run for the demo narrative.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e; add aria-live
The client only knows which button was pressed, not the backend outcome —
a default "Run Analysis" press on a cold cache is served by a live run +
cache-write, so "cached replay" would assert an unconfirmable outcome.
Reword to "requested: cached" / "requested: live" and add aria-live="polite"
so the status change is announced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers C2: AgentGraph <canvas> renders above the feeds grid (idle), the
default Run Analysis button requests without ?live=1 while Run live requests
with ?live=1, both modes render graph->feeds->tasks identically (GD2 same
UI treatment), and the analysis-mode indicator reflects the pressed button.
Reduced motion disabled so the animated canvas path runs. SSE intercepted
(no OPENAI_API_KEY); backend cache guarantees are covered by A2 Supertest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the S4 subagent-driven execution: per-task commits, the
review-caught fixes folded in (clinical-scope enforcement on cache
replay, reduced-motion settled-final-frame + real canvas tests, honest
intent-not-outcome mode label), and the C1/C2 verification results
(API 88/88, web 68/68, E2E 7/7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whole-slice review found a Critical bug: analysisGraphReducer's `start`
action was a one-way idle→init transition (no-op from any other state).
After a completed run (graphState 'complete', all nodes 'complete'), a
second run's `start` returned state unchanged, so every subsequent event's
first-event / pending-node checks failed and the AgentGraph canvas stayed
frozen on "Analysis complete" for the entire second run while the feeds
re-animated — a visible "the graph is lying" bug in exactly the B3
run-then-run-live compare flow.
`start` now unconditionally resets to a fresh {graphState:'init', all nodes
'pending'} state (fresh nodes object, not the shared initial constant's
reference), so any run — first or Nth — animates from scratch. Existing
idle→init behavior is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort cache write
Whole-slice review found two integration issues in the cache-aware route:
1. Narration parity gap: the live path streamed per-agent `token` narration
but AnalysisResultJson never captured it, so replayCachedAnalysis emitted
none — a cached replay showed blank reasoning prose where a live run showed
streamed narration, breaking the "same UI treatment" / "cache is real prior
output" guarantee (C2/GD2). Now each agent's accumulated SAFE (GD11-redacted,
as-emitted) narration is stored and re-emitted as a byte-identical
`{ agentId, text }` token event, in phased order before that agent's
findings — so a replay is indistinguishable from the live run that made it.
2. A cache-write failure sank an otherwise-successful run: writeCache sat in
the main try, so a persistence throw after the stream + HAPI Task writes
already succeeded flipped the run to the `error`/no-`done` path (hanging the
client graph in `synthesizing`). Made it best-effort: log and continue to
`done`; worst case the next non-live view re-runs live instead of replaying.
Tests: (f) live→replay narration is byte-identical per agent + persisted;
(g) a throwing writeCache still emits `done`, no `error`, Task still created.
Full API suite 90/90 (serial; +2), tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ity)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
assertScope only writes an audit row on denial; the cache-replay path
called it directly but never wrote a success row afterward, unlike
every other clinical read in FhirReadService. A default "Run Analysis"
against a cached patient served full clinical findings with zero
audit_log trail. Add the missing writeAudit call, mirroring
getPatientBundle's guard-then-audit pattern, and a regression
assertion in test (a) mirroring test (d)'s existing denial check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
verification.md maps every S4 acceptance criterion to its proving
artifact with evidence-strength labels and re-confirms the whole-slice
review's earlier fixes hold. review.md runs Standards + Spec axes on
e8a9309...HEAD and records the audit-gap fix. issues.md and
implementation-plan.md's S4 checkboxes were stale despite the work
being done (same drift pattern S3's verification caught) — corrected
to [x]. tasks/todo.md records the post-review fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@manjula25