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) — theCardrecord contract:cardId, jiraKey, title, repoId, resolvedPath, targetPc, harness, model, tier, govClass, priority, wtId, leaseId, sessionId, parentCardId, origin, state, spendToDate, taintLevel, artifacts[].CardIdis the identity;jiraKeyis a reference only. Also definesCardState(six lifecycle constants, 1:1 with the Jira workflow) andCardOrigin(manual/spawn/routine/auto), plusCardTreeNode/CardSnapshotfor the session tree.src/FleetMissionControl.Api/Services/CardRegistry.cs(new) — pure/static projection service (no I/O, mirrorsAgentRunValidator/TierLadder's shape):Project(queue, runs) -> List<Card>,ClassifyState/ClassifyOriginmapping rules, andBuildTree(cards) -> List<CardTreeNode>that groups byParentCardIdwith 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/workersreads and the sameGetRecentRunsread/api/runsuses; 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 samePayloadTicketwas already being parsed from (no new read path).RunSummary.RepoId/ParentCardId/ResolvedPath— same parse, plusResolvedPathsourced fromAgentRunResult.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 oncanCancel() && state === 'Ready for Agent'. Renders itself fornode().children— a card with no children looks exactly like the old flat queue row.queue-list.component.ts(rewritten) — theQueuesection now iteratesCardTreeNode[]throughfmc-session-tree-rowinstead of the old flatQueuedJob[]; theBoard · FLTsection is untouched. Card count includes nested children.fleet-store.service.ts/fleet-live.service.ts— addedcardssignal +GET /api/cardsfetch on load and a 60s periodic refresh (CARDS_REFRESH_MS), same failure-tolerant pattern as the existing board refresh.status-wall.component.ts— wiresstore.cards()into the queue list;cancelQueued->cancelQueuedCard(card: Card)usingcard.cardId/card.targetPc.- New specs:
session-tree-row.component.spec.ts(8 tests),queue-list.component.spec.ts(6 tests), plusfleet-live.service.spec.tsextended with 4 tests (initial-load cards, rejected getCards, periodic refresh, failed periodic refresh). - Client regenerated via
npm run generate:client(NSwag) from the rebuiltopenapi/FleetMissionControl.Api.json—Card/CardTreeNode/CardSnapshot/getCards()now infleet-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. (resolvedPathIS sourced — fromAgentRunResult.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.ToDoandCardState.AutomatedReview— defined as constants (the six states are all present so a future producer never needs a breaking contract change) but never produced byCardRegistry.Projecttoday: ToDo would need joining againstJiraBoardServicetickets 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 (NeedsHumanReviewis a single boolean, not a staged verdict).originbeyondmanual/spawnis a documented heuristic on theLanestring ("cockpit"-> manual; substring"routine"/"spawn"otherwise; defaultauto) —spawnfromParentCardIdbeing 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/cardsis REST-polled every 60s (same cadence as the board), not pushed over the SignalR hub. First implementation; aCardUpdatedhub event would be the natural follow-up.
Deviations from the ticket text¶
ParentCardIdwas added as a new wire field onAgentRunRequest(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 inCardRegistry, andGET /api/cardsreturns 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)