Skip to content

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).

LocationRole today
plugin/lib/services/dashboard/server.ts#buildStateAssembles 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#makeAppHono 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_HTMLThe 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.tsPrecedent: 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.

packages/ts/dashboard-client/ is a small, dependency-light TypeScript package the SPA imports for all dashboard-API access. It exports:

  • Generated typesDashboardState, RefreshResult, and their nested shapes (TaskRow, LeaseRow, MilestoneRow, Summary, …), generated from a single shared zod contract, not hand-authored.
  • A typed fetch clientcreateDashboardClient({ baseUrl }) returning getState(): Promise<DashboardState> and refresh(): Promise<RefreshResult>, using a relative /api base by default (per D-0013-dashboard-app, the server serves both SPA and API same-origin). No fetch call 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.

  1. Author the shared contract. Add plugin/lib/services/dashboard/contract.ts exporting zod/v4 schemas — dashboardState and refreshResult — that exactly describe today’s buildState payload (the eight top-level keys, the per-task lease/stale_inflight augmentation, the summary counts, and lease/error row shapes). Use zod/v4 (already shipped via the zod/v4 subpath, per plugin/lib/services/report/schema.ts) so z.toJSONSchema is available with no new dependency. Export z.infer aliases for in-substrate use.
  2. Type the server against the contract. Change buildState’s return type from Record<string, unknown> to the contract’s inferred DashboardState type (and the refresh path to RefreshResult). 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.
  3. Write the generator plugin/lib/services/dashboard/gen-client.ts: import the contract, emit packages/ts/dashboard-client/src/types.ts from it (TypeScript source generated from the zod schemas — emit via a zod→TS step or by writing the z.infer-equivalent declarations; the generator owns the format), with a banner marking the file generated + a --check flag that regenerates to a temp buffer and diffs against the committed file, exiting non-zero on mismatch.
  4. Author the client package. Create packages/ts/dashboard-client/package.json (name @sdlc/dashboard-client, type module, Bun-built), tsconfig.json, moon.yml (a generate task running the generator; a check task running it with --check), src/types.ts (generated), and a hand-written src/index.ts exporting createDashboardClient (the typed fetch wrapper over getState/refresh, relative /api base default) re-exporting the generated types.
  5. Wire the repeatable step. Add a just gen-dashboard-client recipe and a dashboard-client:generate moon task so a dev regenerates with one command; add the --check invocation to the moon check graph so stale generated types fail the gate (mirroring the --check drift pattern of D-0007-deterministic-op-substrate’s generated adapters and D-0010 site assembly).
  6. Prove idempotency. Run the generate step twice; confirm the second run produces no diff and git status is clean. Run --check against the committed output and confirm exit 0; mutate the contract, confirm --check exits non-zero.
LocationKindChange
plugin/lib/services/dashboard/contract.tsnewShared 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.tsnewGenerator: 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.tsmodifyType 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.jsonnewPackage manifest @sdlc/dashboard-client (Bun, type: module)
packages/ts/dashboard-client/tsconfig.jsonnewPackage tsconfig
packages/ts/dashboard-client/moon.ymlnewmoon project: generate task (runs generator), check task (generator --check)
packages/ts/dashboard-client/src/types.tsnewGenerated type module (request/response types); regenerated by gen-client.ts, never hand-edited
packages/ts/dashboard-client/src/index.tsnewHand-written createDashboardClient fetch wrapper (relative /api base default); re-exports generated types
justfilemodifyAdd gen-dashboard-client recipe shelling the generator
  • AC-1: packages/ts/dashboard-client/ type-checks (bunx tsc --noEmit / moon run dashboard-client:check) and its exported DashboardState type structurally matches the live GET /api/state payload — a fixture captured from buildState parses against the contract’s dashboardState zod schema with no error.
  • AC-2: The package exports createDashboardClient(...) whose getState() returns Promise<DashboardState> and refresh() returns Promise<RefreshResult>; a consumer can import { createDashboardClient } from "@sdlc/dashboard-client" and get full types with no fetch call in the consumer.
  • AC-3: One command regenerates the client — moon run dashboard-client:generate (or just gen-dashboard-client) — and a second consecutive run leaves git status clean (idempotent).
  • AC-4: The generator’s --check mode exits 0 against the committed src/types.ts and exits non-zero after the contract is edited without regenerating — i.e. an API-shape change without a regenerate is detectable in the check gate.
  • AC-5: server.ts’s buildState is typed against the shared contract — a divergence between the contract and the assembled payload is a TypeScript error (the seam cannot drift silently), and bun test for the dashboard service still passes with the runtime payload unchanged.
  • AC-6: packages/ts/dashboard-client/ contains no import from plugin/lib/** — the seam is the generated types + the JSON API, per S-0008-apps-consume-substrate-through-published-surfaces.
  • 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 is T-CW4K; this task only delivers the packages/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 buildState shape; extending it for the new sources rides with T-UUMK’s API work and a contract bump.
  • Building the SPA into the plugin (web-dist/ + drift gate) — that is T-JDEV.
  • Generating a client for any other substrate API (CLI/MCP/HTTP op adapters) — this task is dashboard-API-scoped only.
  • T-RVMG (hard; in depends_on:) — stands up the packages/ts/ tree and the moon workspace this package and its moon.yml task 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.

Captured by /sdlc:task-work on 2026-06-28. PR: pending.

  • 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 real buildState fixture and parses it against the contract’s dashboardState/refreshResult schemas with no error.
  • AC-2: auto — plugin/lib/services/dashboard/tests/client-smoke.test.ts imports { createDashboardClient } from the bare specifier @sdlc/dashboard-client, stubs fetch, and asserts typed getState(): Promise<DashboardState> / refresh(): Promise<RefreshResult> against the default relative /api base; no fetch in the consumer.
  • AC-3: agent-manual — ran just gen-dashboard-client twice; git status stayed clean after the second run (deterministic output, no timestamps).
  • AC-4: agent-manual — gen-client.ts --check exited 0 against the committed src/types.ts; mutating the contract made it exit 1 (and moon run dashboard-client:check exit 1); restoring the contract returned it to exit 0.
  • AC-5: auto — buildState now returns the contract’s DashboardState (refresh path RefreshResult); bun test plugin/lib/services/dashboard/ passes 12/12 with the runtime payload byte-identical (type-level change only).
  • AC-6: auto — grep over packages/ts/dashboard-client/src/** shows the only imports are ./types.ts; no plugin/lib, @lib, or zod import. The generated types.ts is standalone.
  • The zod/v4 + z.toJSONSchema precedent in plugin/lib/services/report/schema.ts gave 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 new dashboard-client project; moon run dashboard-client:generate / :check worked with no .moon/workspace.yml edit.
  • 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.
  • Step 3a captured the quality baseline against the main checkout’s working tree (keyed by the origin/main SHA), but Steps 5a/5b then push verify+start commits to origin/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 clean origin/main checkout. Gap: task-work’s baseline capture should reflect the branch’s actual base (post-start origin/main), e.g. capture after Step 5b or against an origin/main tree, 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-PID sdlc dashboard list table row that the quality runner’s normalizer does not mask, so it surfaces as a non-deterministic new-drift: bun test finding 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/Edit gaps 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 runtime acceptEdits/bypassPermissions mode (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)

T-RVMG


← Back to Tasks