T-JZL4-generate-dashboard-api-client
Status: closed/done · Impact: medium · Complexity: medium
The dashboard SPA (D-0013-dashboard-app) consumes the substrate through the
server’s JSON API, per S-0008-apps-consume-substrate-through-published-surfaces. D-0013
makes “easy binding generation” a v1 success criterion: the frontend must reach
/api/state with no hand-written request plumbing or hand-kept response types.
Today the API has no machine-readable contract — buildState() returns
Record<string, unknown> — so any frontend type would be hand-mirrored and drift
silently. This task gives packages/ts/ a typed client whose request/response
types are generated from a single shared contract, plus a one-command,
idempotent regenerate step that keeps client and server in lock-step.
The dashboard service exposes a working JSON API but ships zero type information for
it; packages/ts/ does not exist yet (it is stood up by T-RVMG).
| Location | Role today |
|---|---|
plugin/lib/services/dashboard/server.ts#buildState | Assembles the /api/state payload as an untyped Record<string, unknown> — keys project_root, generated_at, network_enabled, tasks, active_leases, archived_leases, milestones, summary (plus per-task lease/stale_inflight). No zod schema, no exported types |
plugin/lib/services/dashboard/server.ts#makeApp | Hono app; routes GET /api/state (returns buildState), POST /api/refresh (refresh-from-origin then buildState + a refresh: {ok,message} field), GET / (the INDEX_HTML string), and a * 404 |
plugin/lib/services/dashboard/server.ts#INDEX_HTML | The current UI: a ~180-line HTML string whose inline JS calls fetch('/api/state') and shapes the response by hand — the exact untyped plumbing this task removes for the new SPA |
plugin/lib/services/report/ops/get-schema.ts | Precedent: an op deriving JSON Schema natively from a zod/v4 source via z.toJSONSchema — the in-repo pattern for a machine-readable schema with no extra dependency |
packages/ts/ does not exist yet — T-RVMG creates it; this client is its
first package.
No machine-readable schema exists today. Generating types from the live server is therefore impossible until a contract is authored — so this task’s first move is to define one shared zod contract that both the server payload and the generated client derive from.
Proposed
Section titled “Proposed”packages/ts/dashboard-client/ is a small, dependency-light TypeScript package the
SPA imports for all dashboard-API access. It exports:
- Generated types —
DashboardState,RefreshResult, and their nested shapes (TaskRow,LeaseRow,MilestoneRow,Summary, …), generated from a single shared zod contract, not hand-authored. - A typed fetch client —
createDashboardClient({ baseUrl })returninggetState(): Promise<DashboardState>andrefresh(): Promise<RefreshResult>, using a relative/apibase by default (per D-0013-dashboard-app, the server serves both SPA and API same-origin). Nofetchcall site in the SPA.
The contract is the seam. A new plugin/lib/services/dashboard/contract.ts defines
the /api/state and /api/refresh payloads as zod/v4 schemas; server.ts’s
buildState return is typed against it (the API is the seam, not a substrate
import — packages/ts/ never imports plugin/lib/). A repeatable generate step —
one command (bun run plugin/lib/services/dashboard/gen-client.ts, wired as a
moon task moon run dashboard-client:generate and a just gen-dashboard-client
recipe) — re-derives the package’s generated types from that contract. The step is
idempotent: re-running with no contract change yields no diff, and a --check
mode exits non-zero when the committed generated types are stale, so changing the
API shape without regenerating is a detectable, gateable failure.
Approach
Section titled “Approach”- Author the shared contract. Add
plugin/lib/services/dashboard/contract.tsexportingzod/v4schemas —dashboardStateandrefreshResult— that exactly describe today’sbuildStatepayload (the eight top-level keys, the per-tasklease/stale_inflightaugmentation, thesummarycounts, and lease/error row shapes). Usezod/v4(already shipped via thezod/v4subpath, perplugin/lib/services/report/schema.ts) soz.toJSONSchemais available with no new dependency. Exportz.inferaliases for in-substrate use. - Type the server against the contract. Change
buildState’s return type fromRecord<string, unknown>to the contract’s inferredDashboardStatetype (and the refresh path toRefreshResult). This is a type-level change over the existing object literal — keep the runtime payload byte-identical. The contract thus cannot drift from the server without a TypeScript error. - Write the generator
plugin/lib/services/dashboard/gen-client.ts: import the contract, emitpackages/ts/dashboard-client/src/types.tsfrom it (TypeScript source generated from the zod schemas — emit via azod→TS step or by writing thez.infer-equivalent declarations; the generator owns the format), with a banner marking the file generated + a--checkflag that regenerates to a temp buffer and diffs against the committed file, exiting non-zero on mismatch. - Author the client package. Create
packages/ts/dashboard-client/—package.json(name@sdlc/dashboard-client, typemodule, Bun-built),tsconfig.json,moon.yml(ageneratetask running the generator; achecktask running it with--check),src/types.ts(generated), and a hand-writtensrc/index.tsexportingcreateDashboardClient(the typed fetch wrapper overgetState/refresh, relative/apibase default) re-exporting the generated types. - Wire the repeatable step. Add a
just gen-dashboard-clientrecipe and adashboard-client:generatemoon task so a dev regenerates with one command; add the--checkinvocation to the mooncheckgraph so stale generated types fail the gate (mirroring the--checkdrift pattern of D-0007-deterministic-op-substrate’s generated adapters and D-0010 site assembly). - Prove idempotency. Run the generate step twice; confirm the second run
produces no diff and
git statusis clean. Run--checkagainst the committed output and confirm exit 0; mutate the contract, confirm--checkexits non-zero.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/services/dashboard/contract.ts | new | Shared zod/v4 contract: dashboardState + refreshResult schemas describing the /api/state and /api/refresh payloads; exported z.infer type aliases |
plugin/lib/services/dashboard/gen-client.ts | new | Generator: emits packages/ts/dashboard-client/src/types.ts from contract.ts; --check mode diffs against committed output and exits non-zero on drift |
plugin/lib/services/dashboard/server.ts | modify | Type buildState’s return as the contract’s DashboardState (and the refresh path as RefreshResult); runtime payload unchanged. Import from contract.ts |
packages/ts/dashboard-client/package.json | new | Package manifest @sdlc/dashboard-client (Bun, type: module) |
packages/ts/dashboard-client/tsconfig.json | new | Package tsconfig |
packages/ts/dashboard-client/moon.yml | new | moon project: generate task (runs generator), check task (generator --check) |
packages/ts/dashboard-client/src/types.ts | new | Generated type module (request/response types); regenerated by gen-client.ts, never hand-edited |
packages/ts/dashboard-client/src/index.ts | new | Hand-written createDashboardClient fetch wrapper (relative /api base default); re-exports generated types |
justfile | modify | Add gen-dashboard-client recipe shelling the generator |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
packages/ts/dashboard-client/type-checks (bunx tsc --noEmit/moon run dashboard-client:check) and its exportedDashboardStatetype structurally matches the liveGET /api/statepayload — a fixture captured frombuildStateparses against the contract’sdashboardStatezod schema with no error. - AC-2: The package exports
createDashboardClient(...)whosegetState()returnsPromise<DashboardState>andrefresh()returnsPromise<RefreshResult>; a consumer canimport { createDashboardClient } from "@sdlc/dashboard-client"and get full types with nofetchcall in the consumer. - AC-3: One command regenerates the client —
moon run dashboard-client:generate(orjust gen-dashboard-client) — and a second consecutive run leavesgit statusclean (idempotent). - AC-4: The generator’s
--checkmode exits 0 against the committedsrc/types.tsand exits non-zero after the contract is edited without regenerating — i.e. an API-shape change without a regenerate is detectable in thecheckgate. - AC-5:
server.ts’sbuildStateis typed against the shared contract — a divergence between the contract and the assembled payload is a TypeScript error (the seam cannot drift silently), andbun testfor the dashboard service still passes with the runtime payload unchanged. - AC-6:
packages/ts/dashboard-client/contains noimportfromplugin/lib/**— the seam is the generated types + the JSON API, per S-0008-apps-consume-substrate-through-published-surfaces.
Out of scope
Section titled “Out of scope”- Consuming the client in the full four-source dashboard view — that is
T-UUMK. This task ships the client and a smoke import, not the rendered SPA. - Scaffolding
apps/dashboard(Vite + Vue 3) and the app shell — that isT-CW4K; this task only delivers thepackages/ts/package it will import. - The two net-new data sources (local working state, GitHub PR status) D-0013 adds
to the API — the contract describes today’s
buildStateshape; extending it for the new sources rides withT-UUMK’s API work and a contract bump. - Building the SPA into the plugin (
web-dist/+ drift gate) — that isT-JDEV. - Generating a client for any other substrate API (CLI/MCP/HTTP op adapters) — this task is dashboard-API-scoped only.
Dependencies
Section titled “Dependencies”T-RVMG(hard; independs_on:) — stands up thepackages/ts/tree and the moon workspace this package and itsmoon.ymltask live in. Cannot start before it closes.T-CW4K(soft) — the SPA shell is the client’s first real consumer. This task can ship and verify the client independently (a smoke import satisfies the ACs), but it lands most usefully alongside or just before T-CW4K wires it in.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-06-28. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
bunx tsc --noEmit -p packages/ts/dashboard-client/tsconfig.json(exit 0) plus a bun test (plugin/lib/services/dashboard/tests/contract.test.ts) that captures a realbuildStatefixture and parses it against the contract’sdashboardState/refreshResultschemas with no error. - AC-2: auto —
plugin/lib/services/dashboard/tests/client-smoke.test.tsimports{ createDashboardClient }from the bare specifier@sdlc/dashboard-client, stubsfetch, and asserts typedgetState(): Promise<DashboardState>/refresh(): Promise<RefreshResult>against the default relative/apibase; nofetchin the consumer. - AC-3: agent-manual — ran
just gen-dashboard-clienttwice;git statusstayed clean after the second run (deterministic output, no timestamps). - AC-4: agent-manual —
gen-client.ts --checkexited 0 against the committedsrc/types.ts; mutating the contract made it exit 1 (andmoon run dashboard-client:checkexit 1); restoring the contract returned it to exit 0. - AC-5: auto —
buildStatenow returns the contract’sDashboardState(refresh pathRefreshResult);bun test plugin/lib/services/dashboard/passes 12/12 with the runtime payload byte-identical (type-level change only). - AC-6: auto —
grepoverpackages/ts/dashboard-client/src/**shows the only imports are./types.ts; noplugin/lib,@lib, orzodimport. The generatedtypes.tsis standalone.
What worked
Section titled “What worked”- The
zod/v4+z.toJSONSchemaprecedent inplugin/lib/services/report/schema.tsgave a clean, dependency-free path from a single shared contract to generated types — no new package needed. - The moon
packages/ts/*glob auto-registered the newdashboard-clientproject;moon run dashboard-client:generate/:checkworked with no.moon/workspace.ymledit. - The baseline-gated quality runner cleanly isolated branch drift to zero once the baseline was
captured against the branch’s true base — the substantive gates (
tsc --noEmit,entities audit,docs generate --check) reported no new drift.
Friction and automation gaps
Section titled “Friction and automation gaps”- Step 3a captured the quality baseline against the main checkout’s working tree (keyed by the
origin/mainSHA), but Steps 5a/5b then push verify+start commits toorigin/main, so by Step 7 the worktree’s base no longer matched the baseline’s corpus — the diff flagged ~5 phantom rumdl/summary findings until I re-captured the baseline against a cleanorigin/maincheckout. Gap: task-work’s baseline capture should reflect the branch’s actual base (post-startorigin/main), e.g. capture after Step 5b or against anorigin/maintree, so Step 7’s diff is apples-to-apples without a manual re-capture. → T-BCNP-quality-gate-ignores-summary-and-corpus-lines (linked existing) bun test’s dashboard server test prints a live-PIDsdlc dashboard listtable row that the quality runner’s normalizer does not mask, so it surfaces as a non-deterministicnew-drift: bun testfinding on every run (proven by a base-vs-its-own-baseline control run producing the same line with a different PID) — already tracked by T-BQRU-quality-normalize-ports-pids-timings. → T-BQRU-quality-normalize-ports-pids-timings (linked existing)- The Step 3b preflight permissions probe reported false-positive
Write/Editgaps for the worktree path because the runtime grant is not expressed in the settings files it scans; had to verify write access empirically before proceeding. Gap: the probe could treat a runtimeacceptEdits/bypassPermissionsmode (not only settings-file globs) as a blanket allow, or fall back to an empirical touch-test before declaring a hard file-mutation gap. → T-0AM0-preflight-probe-honors-runtime-edit-grant (spawned, PR #495)
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- T-BCNP-quality-gate-ignores-summary-and-corpus-lines — linked existing; baseline-corpus-shift phantom rumdl/summary drift (fourth observation of its gap; the bullet’s capture-side remedy folds into T-BCNP’s design space).
- T-BQRU-quality-normalize-ports-pids-timings — linked existing; dashboard server’s live-PID
sdlc dashboard listrow not masked bynormalizeFinding(third observation; named tracker is open/ready). - T-0AM0-preflight-probe-honors-runtime-edit-grant (https://github.com/sksizer/dev/pull/495) — spawned; Step 3b preflight probe false-positive Write/Edit gaps from runtime-permission-mode blindness (Upstream-plugin / sdlc-meta).
Depends on
Section titled “Depends on”T-RVMG