Skip to content

BUILD-REPORT-FLT122: card as first-class execution object + session-tree queue

Worktree: wt/WT-6022/FLT-122, off origin/master (which already contains FLT-120 repo-registry + FLT-125 artifact-run — neither was touched or duplicated).

What was built

Backend (C#)

  • src/FleetMissionControl.Api/Contracts/CardContracts.cs (new) — the Card record contract: cardId, jiraKey, title, repoId, resolvedPath, targetPc, harness, model, tier, govClass, priority, wtId, leaseId, sessionId, parentCardId, origin, state, spendToDate, taintLevel, artifacts[]. CardId is the identity; jiraKey is a reference only. Also defines CardState (six lifecycle constants, 1:1 with the Jira workflow) and CardOrigin (manual/spawn/routine/auto), plus CardTreeNode/CardSnapshot for the session tree.
  • src/FleetMissionControl.Api/Services/CardRegistry.cs (new) — pure/static projection service (no I/O, mirrors AgentRunValidator/TierLadder's shape): Project(queue, runs) -> List<Card>, ClassifyState/ClassifyOrigin mapping rules, and BuildTree(cards) -> List<CardTreeNode> that groups by ParentCardId with a safety net so a malformed/cyclic parent chain still surfaces every card (never silently drops one).
  • GET /api/cards (Program.cs) — reuses the same poller-cached snapshot /api/workers reads and the same GetRecentRuns read /api/runs uses; no new store, no new live probe.
  • Additive field extensions (the "add fields where missing" the ticket calls for):
  • AgentRunRequest.ParentCardId (nullable, wire-additive, mirrors how RepoId/WorktreePath were added for FLT-120) — a launcher that spawns a child run stamps the parent's cardId here.
  • QueuedJob.Agent/Title/RepoId/ParentCardId — parsed from the same Payload Ticket was already being parsed from (no new read path).
  • RunSummary.RepoId/ParentCardId/ResolvedPath — same parse, plus ResolvedPath sourced from AgentRunResult.WorktreePath (the run_agent contract's own field).
  • tests/FleetMissionControl.Api.Tests/CardRegistryTests.cs (new, 20 tests) — lifecycle mapping (queue stage -> state, run status -> state, failed-without-flag still routes to Needs Human Review), jiraKey -> many distinct cards, parent/child grouping (1-level, 2-level, multi-child, unresolved-parent-surfaces-as-root, parent-cycle-doesn't-drop-either-card), origin classification (parentCardId wins over lane; lane heuristic otherwise), and the flat case.

Frontend (Angular)

  • session-tree-row.component.ts (new) — the recursive row: caret (only when the node has children), origin chip, state chip, harness/wtId/targetPc meta line, title/summary line, and a cancel button gated on canCancel() && state === 'Ready for Agent'. Renders itself for node().children — a card with no children looks exactly like the old flat queue row.
  • queue-list.component.ts (rewritten) — the Queue section now iterates CardTreeNode[] through fmc-session-tree-row instead of the old flat QueuedJob[]; the Board · FLT section is untouched. Card count includes nested children.
  • fleet-store.service.ts / fleet-live.service.ts — added cards signal + GET /api/cards fetch on load and a 60s periodic refresh (CARDS_REFRESH_MS), same failure-tolerant pattern as the existing board refresh.
  • status-wall.component.ts — wires store.cards() into the queue list; cancelQueued -> cancelQueuedCard(card: Card) using card.cardId/card.targetPc.
  • New specs: session-tree-row.component.spec.ts (8 tests), queue-list.component.spec.ts (6 tests), plus fleet-live.service.spec.ts extended with 4 tests (initial-load cards, rejected getCards, periodic refresh, failed periodic refresh).
  • Client regenerated via npm run generate:client (NSwag) from the rebuilt openapi/FleetMissionControl.Api.jsonCard/CardTreeNode/CardSnapshot/getCards() now in fleet-api.generated.ts.

Build + test output

Backend:

dotnet build FleetMissionControl.slnx --no-restore -warnaserror
  Build succeeded. 0 Warning(s), 0 Error(s)

dotnet test FleetMissionControl.slnx --no-build --logger "console;verbosity=minimal"
  Passed! - Failed: 0, Passed: 394, Skipped: 0, Total: 394, Duration: 4 s

Frontend:

npx ng lint          -> All files pass linting.
npx ng build          -> Application bundle generation complete (no errors).
npx ng test --watch=false
  Test Files  1 failed | 18 passed (19)
       Tests  1 failed | 192 passed (193)
npm run test:vitest   -> identical: 1 failed | 192 passed (193) — same failure, both stacks agree.

The 1 failing test is pre-existing and unrelated to FLT-122: run-stream.component.spec.ts > "renders both view and download chips when a run somehow carries both URLs" expects el.textContent to contain the literal string 'view artifact', but the component (untouched by this change — git diff confirms zero edits to that file or its spec) renders "view {filename}" per FLT-125's chip-shows-real-filename behavior. Verified this fails identically on a clean origin/master checkout (throwaway worktree, removed after verification) — 192/193 passed there too, same single failure. Left unfixed: out of scope for FLT-122 (artifact-run is explicitly off-limits per the ticket), and fixing someone else's stale assertion wasn't asked for.

Manual verification

Started the worktree's API build on an alternate port (Fleet__Port=5171, since the real production instance is live on 5170 — never touched or killed) and ng serve proxied at it. GET /api/cards returned a real projection of ~52 live in-flight/finished fleet-dispatch cards with correct states (Agent Working/Done/Needs Human Review) and origin: "auto" (no cockpit-lane or parent-linked cards exist in the live data yet, so the heuristic correctly falls through to auto). Loaded the cockpit in the Browser pane against this: the left Queue column rendered "Queue · 52" with per-card origin chips, state chips, harness tags, and target arrows; no console errors; the Board section and everything else on the page rendered unaffected. Cleaned up: stopped only the two verification-only processes (PIDs identified by exact port, not a machine-wide kill), reverted the temporary proxy.conf.json edit, confirmed the real production instance on 5170 is untouched and still serving.

Fields left as TODO / no data source (documented in CardContracts.cs, not fabricated)

  • model, tier, govClass, priority — no per-run field carries these; TierLadder classifies task text at dispatch time but the verdict isn't persisted anywhere the reader can see after.
  • wtId, sessionId — worktree-identity's "WT-xxxx" ID and a CCD session ID are never written into the run_agent contract today. (resolvedPath IS sourced — from AgentRunResult.WorktreePath — but that's a raw filesystem path, not the wtId string.)
  • leaseId, spendToDate, taintLevel — no per-job lease/spend/taint concept exists in fleet-dispatch or agy yet (agy's ledger is pool-scoped, not per-card).
  • CardState.ToDo and CardState.AutomatedReview — defined as constants (the six states are all present so a future producer never needs a breaking contract change) but never produced by CardRegistry.Project today: ToDo would need joining against JiraBoardService tickets that have no dispatched job yet (not done this pass — scope discipline); AutomatedReview would need a distinct "checks ran, awaiting merge" signal the run_agent contract doesn't emit (NeedsHumanReview is a single boolean, not a staged verdict).
  • origin beyond manual/spawn is a documented heuristic on the Lane string ("cockpit" -> manual; substring "routine"/"spawn" otherwise; default auto) — spawn from ParentCardId being set is the one origin classification backed by a real causal signal, not a guess, and it always wins over the lane heuristic.
  • No live push for cards yet — GET /api/cards is REST-polled every 60s (same cadence as the board), not pushed over the SignalR hub. First implementation; a CardUpdated hub event would be the natural follow-up.

Deviations from the ticket text

  • ParentCardId was added as a new wire field on AgentRunRequest (additive, mirrors RepoId/WorktreePath from FLT-120) rather than inferred/fabricated — nothing in the current fleet-dispatch contract carries parent/child linkage, so there was no existing signal to project from for that specific relationship. It's a documented, minimal, additive extension consistent with the ticket's own "add fields where missing" instruction, not a scope expansion into autonomy/auto-spawn (FLT-115/FLT-112, untouched).
  • The session-tree grouping (BuildTree) happens server-side in CardRegistry, and GET /api/cards returns the tree pre-grouped (CardSnapshot.Roots) rather than a flat list the frontend groups itself — this was a judgment call to keep the parent/child logic in one well-tested place (backend, xunit) rather than duplicating it in TypeScript.

Files changed

src/FleetMissionControl.Api/Contracts/CardContracts.cs            (new)
src/FleetMissionControl.Api/Contracts/ApiModels.cs                (QueuedJob/RunSummary fields)
src/FleetMissionControl.Api/Contracts/FleetDispatchContracts.cs   (AgentRunRequest.ParentCardId)
src/FleetMissionControl.Api/Services/CardRegistry.cs              (new)
src/FleetMissionControl.Api/Services/FleetDispatchReader.cs       (parse new fields)
src/FleetMissionControl.Api/Program.cs                            (GET /api/cards)
tests/FleetMissionControl.Api.Tests/CardRegistryTests.cs          (new)
openapi/FleetMissionControl.Api.json                              (regenerated)
frontend/src/app/api/fleet-api.generated.ts                       (regenerated, NSwag)
frontend/src/app/components/session-tree-row.component.ts         (new)
frontend/src/app/components/session-tree-row.component.spec.ts    (new)
frontend/src/app/components/queue-list.component.ts               (rewritten)
frontend/src/app/components/queue-list.component.spec.ts          (new)
frontend/src/app/components/status-wall.component.ts              (wiring)
frontend/src/app/core/fleet-store.service.ts                      (cards signal)
frontend/src/app/core/fleet-live.service.ts                       (fetch + refresh cards)
frontend/src/app/core/fleet-live.service.spec.ts                  (extended)