Fleet Mission Control — Architecture¶
Technical reference for the FLT-13/FLT-14 cockpit: an ASP.NET Core minimal API
backend (src/FleetMissionControl.Api/) and an Angular 22 PWA frontend
(frontend/). Phase 1 shipped read-only; a gated command plane (launch/cancel
run_agent + design/artifact runs, availability toggle) landed on top of it
without changing that read posture — every GET under /api is still open
behind the tailnet perimeter, and every write requires the fleet bearer or a
loopback-minted cockpit session.
Every claim below is traceable to source in this repo as of commit b5930a9
(FLT-131: unavailable-mode placement-skip + host status UX, the tip of
origin/master when this documentation pass was cut into worktree
wt/WT-6022/fmc-docs). File:line references point at that state. See
making-of.md for how the system got here commit by commit,
and ../plain/OVERVIEW.md for the non-technical
version of everything on this page.
Contents¶
- Containers and data flow
- Key request path: dispatch an agent run by repo name
- Live update sequence (SignalR)
- Responsive posture selection
- The write surface: sessions, bearer, and why the browser never holds the key
- Session tree (cards) — FLT-122
- Repo registry / pick-by-name — FLT-120
- Artifact run — FLT-19 / FLT-125
- Unavailable-mode host status — FLT-130/131
- Runtime backend origin (embedded hosts)
- NSwag / OpenAPI generation path
- Solution layout
- CI gates
- C4 diagrams
- Not covered here
1. Containers and data flow¶
Source: docs/diagrams/architecture.mmd (keep both in sync).
flowchart LR
subgraph Browser["Browser / IDE WebView host"]
PWA["Angular 22 PWA<br/>standalone components + signals<br/>frontend/src/app/"]
end
subgraph Backend["FleetMissionControl.Api - net10.0 minimal API"]
REST["/api/* REST endpoints<br/>Program.cs + Endpoints/*.cs"]
HUB["/hubs/fleet SignalR hub<br/>Hubs/FleetHub.cs"]
POLL["FleetPollingService<br/>BackgroundService, 5s PeriodicTimer"]
CARDREG["CardRegistry<br/>pure projection: queue+runs -> session tree"]
CAPROUTE["CapabilityRouter + TierLadder<br/>pure: quota-aware model/tier routing"]
AVAILGATE["AvailabilityGate<br/>pure: placement-skip"]
ARTSTORE["ArtifactStore<br/>local .bin + .meta.json, magic-byte sniff, GC"]
REPOREG["RepoRegistryReader<br/>signed per-PC inventory -> pick-by-name"]
DISPATCHER["AgentRunDispatcher<br/>HMAC-signs + writes DispatchJob"]
end
subgraph Sources["On-disk / remote sources (OneDrive-backed, tailnet-shared)"]
DISPATCHDIR["fleet-dispatch file queues<br/>{pc}/inbox,processing,outbox,archive"]
AVAILDIR["availability/{pc}.json + {pc}.override.json<br/>listener-published + MC-written"]
REPOINV["repo-inventory/{pc}.json<br/>signed scanner inventory (external, not built here)"]
GOV["usage-governor state.json"]
LEDGER["agy-ledger.jsonl<br/>FLT-15 quota ledger"]
JIRA["Jira REST - FLT board<br/>cached 60s"]
end
subgraph Gen["Build-time client generation"]
OPENAPI["openapi/FleetMissionControl.Api.json<br/>committed, emitted by dotnet build"]
NSWAG["NSwag -> fleet-api.generated.ts<br/>npm run generate:client"]
end
PWA -- "REST via generated Client" --> REST
PWA -- "WebSocket (@microsoft/signalr)" --> HUB
REST -- "reads Latest snapshot / recent runs / cards" --> POLL
REST -- "GET /api/cards" --> CARDREG
REST -- "POST /design-runs(/plan)" --> CAPROUTE
REST -- "POST /agent-runs, /design-runs" --> DISPATCHER
REST -- "GET /api/repos, resolve repoId" --> REPOREG
REST -- "artifact view/download/upload" --> ARTSTORE
HUB -- "Clients.All.*(evt) every 5s poll + instant dispatch push" --> POLL
DISPATCHER -- "reads fresh at enqueue time" --> AVAILGATE
POLL -- "GetSnapshotAsync / GetRecentRuns" --> DISPATCHDIR
POLL -- "GetAvailability(pc)" --> AVAILDIR
POLL -- "governor.Read()" --> GOV
POLL -- "quota.Read()" --> LEDGER
DISPATCHER -- "writes signed DispatchJob" --> DISPATCHDIR
REPOREG -- "reads signed inventory" --> REPOINV
REST -- "writes override (unsigned)" --> AVAILDIR
REST -- "GET /api/board" --> JIRA
REST -- "app.MapOpenApi() serves /openapi/v1.json" --> OPENAPI
OPENAPI --> NSWAG
NSWAG -- "checked in, CI fails on drift" --> PWA
Every route registered (Program.cs, plus Endpoints/DesignEndpoints.cs
and Endpoints/AvailabilityEndpoints.cs):
| Route | Auth | Handler | What it does |
|---|---|---|---|
GET /api/workers |
open | FleetPollingService.Latest ?? FleetDispatchReader.GetSnapshotAsync |
Listener status + queue depth + per-host Availability (Program.cs:86-89) |
GET /api/runs |
open | FleetDispatchReader.GetRecentRuns, enriched with artifact links |
Recent dispatch results; limit clamped 1-200 (Program.cs:91-104) |
GET /api/governor |
open | GovernorReader.Read |
Usage-governor tier snapshot (GREEN/YELLOW/RED/BLACKOUT) (Program.cs:106-108) |
GET /api/quota |
open | QuotaReader.Read |
Per-pool quota gauges from the FLT-15 agy ledger (Program.cs:110-112) |
GET /api/board |
open | JiraBoardService.GetBoardAsync |
FLT board tickets, 60s cache (Program.cs:114-117) |
GET /api/cards |
open | CardRegistry.Snapshot(queue, runs) |
Session-tree projection, roots first (Program.cs:122-130) |
GET /api/repos |
open | RepoRegistryReader.GetSnapshot |
Pick-by-name registry, empty until a scanner runs (Program.cs:135-138) |
POST /api/auth/session |
loopback-only | CockpitSessionService.Mint |
Mints a 12h cockpit write token (Program.cs:147-170) |
POST /api/agent-runs |
bearer/session | AgentRunDispatcher.SendAsync |
Launch a run_agent job; resolves repoId first (Program.cs:172-240) |
POST /api/agent-runs/{jobId}/cancel |
bearer/session | AgentRunDispatcher.TryCancel |
Delete a still-queued job's inbox file (Program.cs:242-309) |
POST /api/design-runs/plan |
bearer/session | CapabilityRouter.Plan |
Routing preview, no dispatch (DesignEndpoints.cs:22-43) |
POST /api/design-runs |
bearer/session | route + AgentRunDispatcher.SendDesign |
Launch an artifact-run job (DesignEndpoints.cs:45-150) |
POST /api/artifacts |
per-job upload token | ArtifactStore.Save |
Artifact upload from an executing run (DesignEndpoints.cs:157-244) |
GET /api/artifacts/{id}, /view, /download |
open | ArtifactStore.Get/OpenContent |
Artifact metadata + sandboxed view + download (DesignEndpoints.cs:248-282) |
POST /api/hosts/{pc}/availability |
bearer/session | AvailabilityWriter.Set |
The three-state availability toggle (AvailabilityEndpoints.cs) |
GET /api/meta/hub-events |
open | inline lambda | Forces hub payload DTOs into OpenAPI (Program.cs:315-318) |
/hubs/fleet (SignalR) |
open (reads) | app.MapHub<FleetHub> |
Live push channel (Program.cs:320) |
The hub (Hubs/FleetHub.cs:10-16) declares IFleetClient with four
server→client methods — WorkerHeartbeat, RunUpdated, GovernorTier,
QuotaUpdated — and no client→server methods; reads stay push-only even
though the write surface below it has grown a full command plane.
Binding: Kestrel listens on localhost plus any interface in the Tailscale
CGNAT range 100.64.0.0/10 (Program.cs:14-20, TailnetAddresses() at
Program.cs:324-340). Response headers on every request set
X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy: same-site,
Referrer-Policy: no-referrer, a frame-ancestors 'none' CSP, and
Cache-Control: no-store on /api/* (Program.cs:60-78) — ZAP-flagged
hardening, not incidental.
2. Key request path: dispatch an agent run by repo name¶
FLT-120 replaced the dispatch form's free-text absolute-path box with a
pick-by-name flow: the operator chooses a repo by NAME from a fleet-wide
registry, and the API resolves (repoId, targetPc) to an absolute path
server-side — never a caller-typed path. This sequence also shows FLT-130's
placement-skip gate, which sits on the same choke point every dispatch goes
through (AgentRunDispatcher.Enqueue).
Source: docs/diagrams/dispatch-by-repo-name-sequence.mmd.
sequenceDiagram
participant UI as command-panel.component.ts
participant Sess as CockpitSessionService
participant API as POST /api/agent-runs
participant Repos as RepoRegistryReader
participant Val as AgentRunValidator
participant Disp as AgentRunDispatcher
participant Gate as AvailabilityGate
participant Auth as FleetApiAuth
participant FS as dispatch/{target}/inbox
participant Hub as FleetHub (/hubs/fleet)
UI->>Sess: POST /api/auth/session (loopback-only, once)
Sess-->>UI: short-lived session token (X-Fleet-Auth)
UI->>API: {target, run:{agent, repoId, worktreePath?, ticket, task}}
API->>Repos: Resolve(repoId, target, worktreePath?)
alt repo/worktree not indexed on target
Repos-->>API: null
API-->>UI: 400 "{repo|worktree} not indexed on {target} — request a scan"
else resolved
Repos-->>API: AbsPath
API->>API: run.Repo = AbsPath (server-side overwrite, before validation)
API->>Val: Validate(run)
alt invalid (bad agent/ticket/branch/path shape)
Val-->>API: error string
API-->>UI: 400 {error}
else valid
Val-->>API: null
API->>Disp: SendAsync(normalized, target)
Disp->>Disp: TierLadder.Classify(task) -> isHeavy
Disp->>Gate: IsEligible(GetAvailability(target), isHeavy)
alt host unavailable for this job class
Gate-->>Disp: false
Disp-->>API: throw AgentRunRejectedException
API-->>UI: 400 "'{target}' is unavailable (...) — pick another target or Force available"
else eligible
Gate-->>Disp: true
Disp->>Disp: build DispatchJob (SchemaVersion 2, JobClass=heavy_cpu)
Disp->>Auth: Sign(job, SigningKey) — HMAC-SHA256, canonical '|'-joined string
Disp->>FS: write temp -> atomic rename {jobId}.json
Disp-->>API: AgentRunAccepted {jobId, target, agent, ticket}
API->>Hub: Clients.All.RunUpdated({status:"dispatched", ...})
Hub-->>UI: instant push — run appears in the stream now, not next 5s poll
API-->>UI: 202 Accepted + receipt
end
end
end
Two things worth calling out that the diagram compresses:
- Registry-only, no fallback. Council gate 2026-07-18 explicitly rejected
a live filesystem browser on the write surface (a browse-any-path endpoint
is attack surface) and rejected falling back to a caller-typed
RepoonceRepoIdis present (Program.cs:184-199,RepoRegistryReader.cs:152-174). A miss is always a 400, never a silent degrade. - The availability gate is read fresh, every enqueue, not cached — the
whole point is "is this host mid-game/stream right now"
(
AgentRunDispatcher.cs:146-159). It is a parallel gate to the quota/tier system: it never becomes a governor tier and never touchesCapabilityRouter's model picking (AvailabilityGate.cs:6-13).
3. Live update sequence (SignalR)¶
FleetPollingService is a BackgroundService that polls every 5 seconds via
a PeriodicTimer (Services/FleetPollingService.cs:20,30-44) — not a
FileSystemWatcher, because the dispatch root lives on OneDrive where
watcher events are unreliable (comment at FleetPollingService.cs:11). Each
poll diffs against in-memory state and only pushes a hub event when something
actually changed:
WorkerHeartbeatfires unconditionally every poll (FleetPollingService.cs:50).GovernorTierfires only whengov.Tierchanged (FleetPollingService.cs:52-57).QuotaUpdatedfires only when the serialized quota snapshot changed (FleetPollingService.cs:59-66).RunUpdatedfires per-run when"{Status}|{FinishedAt}"changed, oldest-first, suppressed entirely on the first poll so startup doesn't replay history as "updates" (FleetPollingService.cs:68-82).
POST /api/agent-runs and POST /api/design-runs also push an instant
RunUpdated{status:"dispatched"} the moment a job is accepted (§2 above),
and POST /api/agent-runs/{jobId}/cancel pushes the terminal cancelled
event directly — the poller will never observe a result for a job whose
inbox file was removed before pickup, so that push is the only terminal
signal for a cancelled run (Program.cs:262-281).
Source: docs/diagrams/live-update-sequence.mmd.
sequenceDiagram
participant Poller as FleetPollingService
participant Reader as FleetDispatchReader
participant Hub as FleetHub (/hubs/fleet)
participant Live as fleet-live.service.ts
participant Store as FleetStore (signals)
loop every 5s (PeriodicTimer)
Poller->>Reader: GetRecentRuns()
Reader-->>Poller: RunSummary[]
Poller->>Poller: diff "{Status}|{FinishedAt}" vs _lastRunState
alt run state changed and _primed
Poller->>Hub: Clients.All.RunUpdated(RunUpdatedEvent)
Hub-->>Live: "RunUpdated" event over WebSocket
Live->>Store: applyRunUpdate(evt.run)
Store-->>Live: signal write triggers OnPush re-render
else nothing changed, or first poll (not yet primed)
Poller->>Poller: stay quiet
end
end
Note over Live,Hub: on reconnect (hub.onreconnected), Live re-runs loadInitial() over REST —<br/>events missed while disconnected are not replayed by the hub itself.
FleetLiveService.start() also runs two independent setInterval REST
refreshes that the hub does not cover: the Jira board every 60s
(BOARD_REFRESH_MS) and the card session tree every 60s (CARDS_REFRESH_MS)
— GET /api/cards has no push yet; it is REST-polled at the same cadence as
the board (fleet-live.service.ts:16-19,42-43,122-129).
4. Responsive posture selection¶
Source: frontend/src/app/components/status-wall.component.ts. The layout is
driven by container queries against .cockpit-container's own
inline-size, not the viewport — plus one
@media (horizontal-viewport-segments: 2) block for a genuinely
folded/spanned device. All three CSS rules target the same .cockpit grid
and are resolved by normal CSS cascade (last matching rule of equal
specificity wins), which is why source order matters here.
The nuance that was an actual shipped bug (fixed in 236a73f, see
making-of.md): the horizontal-viewport-segments media
query only matches when Device Posture is folded — the device bent to a
book/tent/laptop angle. An unfolded, flat foldable (e.g. a Pixel Fold used as
a tablet) reports posture continuous and exactly one segment, so that
media query never matches for it. Handling "Fold unfolded" is entirely the
job of the width-based container query breakpoint, not the segments block —
the component's own doc comment (status-wall.component.ts:17-49) states
this explicitly. The dual-pane breakpoint floor is 599px, aligned to
Google's large-screen width classes (compact <600dp, medium 600-840dp,
expanded ≥840dp) rather than a hardcoded device-specific pixel number.
Source: docs/diagrams/responsive-posture.mmd.
flowchart TD
Start(["Resolve .cockpit layout"]) --> Segments{"UA matches<br/>@media (horizontal-viewport-segments: 2)?"}
Segments -- "Yes: Device Posture = folded<br/>(book/tent/laptop angle, genuinely spanned)" --> Split["Segmented split layout<br/>columns from env(viewport-segment-width 0/1 0)<br/>wins cascade: declared last in source order"]
Segments -- "No: flat / continuous posture<br/>(includes an UNFOLDED Fold - single segment)" --> Width{"container-query width<br/>of .cockpit-container<br/>(inline-size)"}
Width -- ">= 1100px" --> ThreePane["3-pane: workers+queue | command+artifact+stream | gate+telemetry<br/>default .cockpit grid, no @container rule matches"]
Width -- "600px - 1099px<br/>(unfolded Pixel Fold lands here)" --> DualPane["Dual-pane: stream+gate on top row,<br/>fleet column drops below<br/>@container cockpit (max-width: 1099px)"]
Width -- "< 600px" --> CardStack["Card stack, single column:<br/>gate first, then fleet, then stream<br/>@container cockpit (max-width: 599px)"]
The three-pane center column at desktop width now stacks the command panel,
the artifact-run panel, and the run stream in that order
(status-wall.component.ts:80-86) — the write surface lives beside the read
surface it feeds, not in a separate view.
A separate, independent capability check covers notch/Dynamic-Island-class
devices: env(safe-area-inset-*) on the header bar, cockpit content edges,
and the fixed toast, gated by viewport-fit=cover in the viewport meta tag
(frontend/src/index.html).
5. The write surface: sessions, bearer, and why the browser never holds the key¶
Phase 1 shipped read-only by design; every write endpoint added since carries
.RequireFleetAuth() (Security/FleetApiAuth.cs). The reasoning is explicit
in the source comments and worth restating because it shapes every write
endpoint's design:
- The fleet bearer is equivalent to the fleet key. A write ultimately
signs a
run_agentjob, andrun_agent'svalidateCommandis arbitrary shell — the same privilege as dispatch'spwshkind. An unauthenticated write endpoint would be unauthenticated RCE on every PC in the fleet (FleetApiAuth.cs:14-18). - The bearer must never reach the browser.
CockpitSessionServicemints a short-lived (12h), 32-random-byte session token instead — minted only to loopback callers, gated on both the remote IP being loopback and theHostheader being a loopback name (the DNS-rebinding defence:CockpitSessionService.cs:8-31,64-71). Only the token's SHA-256 is stored server-side; a memory dump of the store yields nothing usable. Restarting the API revokes every session. - Both the bearer and a session token ride the same
X-Fleet-Authheader and grant the same write authority, but a session-authed request is only honored from loopback (FleetApiAuth.cs:135-149) — a session token arriving from a remote address can only be a stolen token, since sessions are minted loopback-only. The bearer stays remote-usable for legitimate server-to-server/CLI callers. - The artifact upload endpoint is authenticated separately again, by a
per-job upload token issued at dispatch time (
DesignRunRegistry), never the fleet bearer — the executing model-driven agent must never hold fleet-wide write authority (DesignEndpoints.cs:152-171).
The frontend mirrors this: CommandService.state is unknown → ready /
readonly / disabled, and every write-capable component
(command-panel.component.ts, design-panel.component.ts) refuses to render
its form until that verdict is known — showing a form during unknown would
display command authority the viewer may not actually have.
6. Session tree (cards) — FLT-122¶
GET /api/cards projects the same data /api/workers (queue) and
/api/runs already expose into first-class Card objects, grouped into a
tree by ParentCardId — no new store, no new live probe
(Services/CardRegistry.cs:1-15, Program.cs:119-130). CardId is the
identity (the dispatch job id); JiraKey is a reference only — many cards
can share one ticket (retries, re-dispatches, spawned children).
- Origin classification (
CardRegistry.ClassifyOrigin): a card with aParentCardIdis alwaysspawn, regardless of lane. OtherwiseLane"cockpit"(the only lane this API itself writes) ismanual; a lane substring"routine"/"spawn"is a best-effort heuristic; everything else isauto. - State classification (
CardRegistry.ClassifyState):dispatched→ Agent Working,cancelled→ Done,failed/timed_out→ Needs Human Review (even though a crashed non-agent shell run never sets the contract's ownneedsHumanReviewflag), otherwise the contract's own flag decides. - Tree construction (
CardRegistry.BuildTree) has a safety net: a card whose parent doesn't resolve (unknown/expired/cyclic) still surfaces as its own root — a session tree is never allowed to silently drop a card. - Documented gaps, not fabricated fields:
Model,Tier,GovClass,Priority,WtId,LeaseId,SessionId,SpendToDate,TaintLevelare all defined on theCardrecord but have no data source in the current wire contracts yet (Contracts/CardContracts.cs:1-23,78-102) — they stay nullable and documented so a future producer only has to start setting the field, not add one.
The frontend renders this as session-tree-row.component.ts: a recursive
row (caret only when the node has children, origin chip, state chip,
harness/target meta line), starting expanded by default — a session tree
that hides work by default defeats its own purpose
(session-tree-row.component.ts:105-108).
7. Repo registry / pick-by-name — FLT-120¶
Council-approved shape (2026-07-18, unanimous build-first): registry only,
no live filesystem browser — a browse-any-path endpoint on the write
surface was explicitly rejected as attack surface. This repo owns the
reader + resolver + UI; a per-PC scanner that populates the registry is an
explicit, out-of-scope TODO with its own frozen contract:
../repo-registry-contract.md.
RepoRegistryReader.GetAllEntries()reads every{PC}.jsonunderFleetPaths.RepoInventoryDir, verifies its HMAC-SHA256 signature with the same fleet-dispatch keyAgentRunDispatchersigns jobs with, and rejects a file whosePcfield doesn't match its own filename — the same trust-confusion class as the peers-dir loopback-poisoning bug this fleet already hit once (FLT-56) (RepoRegistryReader.cs:9-23,106-113). No key loaded → every file is treated as unverifiable and skipped, fail-closed, not fail-open (RepoRegistryReader.cs:91-99).GetSnapshot()groups entries byRepoIdintoRepoOptionrows (one per PC) — theGET /api/repospayload the dropdown renders.Resolve(repoId, targetPc, worktreePath?)returns an absolute path ornull. A worktree path is only ever returned if it matches one the scanner actually reported for that repo+PC — never a caller-supplied path passed through (RepoRegistryReader.cs:152-174).- Zero scanners running is the correct, conservative default:
GET /api/reposreturns{repos: [], registryPopulated: false}, the dropdown shows "none indexed on {target}", and the form's submit stays disabled (there is norepoIdto pick) — not a bug, not an error condition.
RepoId derivation (scanner-side contract, see the linked doc for the exact
canonical-string/HMAC scheme): normalized host/owner/repo from the git
remote when one exists, else the main checkout's folder name. A worktree is
never its own top-level entry — it lives in its parent repo's
Worktrees list, so pick-by-name treats "which worktree of this repo" as a
sub-choice, not a second repo.
8. Artifact run — FLT-19 / FLT-125¶
What started as "design run" (FLT-19: figma/stitch/claude-design/image)
grew into "artifact run" (FLT-125: + pptx/docx/xlsx/html/pdf) — a rename of
the user-facing concept only. The wire lane
(CapabilityRouter.LaneName = "design", AgentRunDispatcher.SendDesign,
the DesignRunRequest/DesignJobPayload contract) was deliberately kept
unchanged, so no fleet-dispatch listener needed to change
(CapabilityRouter.cs:24-29).
Routing (CapabilityRouter.Plan, design doc:
../DESIGN-capability-routing.md),
precedence order:
- Capability hard constraint.
claude-design⇒ must route toclaude. A contradicting explicit override throwsCapabilityMismatchException(structured 400), never a silent remap. - Explicit override — may pin model, tier, or both.
- Auto (default ~95% of runs):
TierLadderclassification (the council-approved model-escalation ladder, keyword+hints classifier, deliberately not model-based so classification itself never spends quota) + a quota-aware model pick among tier-adequate candidates.
Never-auto-loosen: quota pressure steers the model among tier-adequate
candidates; it never lowers the tier. A ceiling task with every
ceiling-capable pool locked returns status=blocked + needsHuman=true, not
a silent downgrade, and there is no server-side queue — the cockpit re-plans
via POST /api/design-runs/plan instead (CapabilityRouter.cs:199-224).
Concrete model identifiers ride the wire verbatim into the CLI's model
flag (CapabilityRouter.ConcreteModel) — they must be identifiers each CLI
actually accepts; an earlier invented label made codex reject the run and
agy fail silently (exit 0, nothing on stdout) (CapabilityRouter.cs:45-58).
Artifact persistence (Services/ArtifactStore.cs) is deliberately
local to the API host, not the OneDrive dispatch dir — the dispatch dir
is for job envelopes, not binary blobs, and sync lag would 404 the "View"
click (council 2026-07-17). Write ordering is load-bearing: bytes → atomic
rename → meta sidecar, so a crash can never leave metadata pointing at
missing bytes. Hardening: magic-byte sniffing per kind (declared
content-type is never trusted), filename sanitized before
Content-Disposition and never used in storage paths, a 20 MB per-artifact
cap, server-derived disposition (view only for html/svg; everything else,
including the FLT-125 OOXML/pdf types, is download), GUID-validated ids
before any path use, and a TTL (14 days) + 2 GB total-budget GC sweep
(ArtifactStore.cs:8-29,313-357).
FLT-125 FLOOR (deliberately not built): attaching an artifact to its
Jira ticket would be an external network call to Atlassian — explicitly out
of scope. The artifact stays local-only, surfaced via the run-stream chip.
JiraBoardService is read-only; there is no attach path in this codebase
today. A TODO(FLT-125-followup) comment marks the spot
(DesignEndpoints.cs:211-216).
The run-stream chip renders two independent things when present: a "view
{filename}" chip (html/svg only, opens the CSP-sandboxed viewer in a new tab
— the embedded WebView2 cockpits can't render it inline) and a "⬇
{filename}" download chip (the only chip pptx/docx/xlsx/pdf artifacts get,
since they have no inline viewer) (run-stream.component.ts:54-66).
9. Unavailable-mode host status — FLT-130/131¶
Andrew games (Steam) and streams (OBS) on fleet PCs. FLT-130/131 is two halves of one feature: fleet-dispatch's listener (not this repo) detects gaming/streaming and publishes it; Mission Control is a consumer of that detected half and the sole writer of the manual-override half.
- Read path:
FleetDispatchReader.GetAvailability(pc)reads{DispatchRoot}/availability/{pc}.jsonintoHostAvailability— null-safe: an absent file (a listener that hasn't upgraded yet) means "no restriction" everywhere downstream, never "unavailable" (FleetDispatchReader.cs:196-207,Contracts/AvailabilityModels.cs:14-38). It rides ontoWorkerStatus.Availabilityin the existingGET /api/workerssnapshot andWorkerHeartbeatpush — no new read endpoint. - Placement-skip gate:
AvailabilityGate.IsEligible(availability, isHeavyJob)is a pure, parallel gate consulted fresh at every enqueue (AgentRunDispatcher.cs:146-159). It fails open on a missing heartbeat (most PCs, day one of this feature) and onAvailable=true; once a host has declared itself unavailable,BlockLeveldecides:allblocks every placement,heavyblocks only ceiling-tier jobs, anything else (including an unrecognized value from a newer listener) never blocks — a conservative unknown-value default here would turn a forward-compatible field addition into a fleet-wide outage (AvailabilityGate.cs:20-40). "Heavy" forrun_agentreusesTierLadder.Classify(...).Tier == Ceiling(the same signal the design lane already trusts); design jobs use the tierCapabilityRouter.Planalready computed. - Write path:
AvailabilityWriter.Set(pc, request, now)validates and atomically writes{DispatchRoot}/availability/{pc}.override.json(Services/AvailabilityWriter.cs).autoclears with no expiry;unavailableis sticky unless adurationMinutesis given;availablealways carries a TTL (default 45 min, capped at 24h). Unsigned — unlikeDispatchJob— because a poisoned override file can only ever make one host look wrongly available/unavailable, never trigger code execution (AvailabilityWriter.cs:14-19). - UI:
worker-list.component.tsrenders a per-host availability line (unavailable · gaming Elden Ring, or a client-sideavailable in 04:23countdown while inside a per-reason grace window — gaming 5 min, streaming 10 min, a heuristic tuned client-side becauseHostAvailabilitycarries no expiry for auto-detected states) plus the three-state toggle (Auto | Force unavailable [+ duration] | Force available).command-panel.component.ts's target dropdown greys an unavailable host with its reason but never removes it — the backend gate is the real enforcement; this is advisory so an operator isn't surprised by a 400 after filling in the whole form (command-panel.component.ts:149-160).
Job-signing protocol v2, landed mid-feature in coordination with
fleet-dispatch: DispatchJob.SchemaVersion bumped 1→2, and the signer now
appends JobClass (heavy_cpu for run_agent, heavy_gpu for
design/artifact runs) as the new trailing canonical-string field — a v2
listener rejects v1-signed jobs (AgentRunDispatcher.cs:41-91).
10. Runtime backend origin (embedded hosts)¶
Added so IDE host surfaces (VS Code webview, Antigravity, VS 2026 WebView2)
can point the built cockpit bundle at a different backend origin than the one
it was served from, without the host monkey-patching fetch/XHR/WebSocket
globals (frontend/src/app/core/runtime-config.ts):
runtime-config.tsreadswindow.__FLEET_CONFIG__?.apiBaseUrl, defaulting to''(same-origin) when absent.app.config.tsprovides that value into the NSwag-generatedFLEET_API_BASE_URLinjection token viauseFactory: getRuntimeApiBaseUrl.fleet-live.service.tsbuilds the hub URL by hand as`${base}/hubs/fleet`(stripping any trailing slash), because the hub connection isn't part of the generated OpenAPI client.
Default web/PWA behavior is unchanged: no host sets window.__FLEET_CONFIG__,
so apiBaseUrl resolves to '' and both REST calls and the hub URL stay
same-origin, matching the dev-proxy behavior (frontend/proxy.conf.json
forwards /api, /hubs, /openapi to :5170).
11. NSwag / OpenAPI generation path¶
frontend/nswag.json reads ../openapi/FleetMissionControl.Api.json
(emitted by dotnet build src/FleetMissionControl.Api) and generates an
Angular-template TypeScript client at src/app/api/fleet-api.generated.ts,
with baseUrlTokenName: "FLEET_API_BASE_URL". GET /api/meta/hub-events
exists solely to force the four hub payload DTOs into the OpenAPI document
so NSwag generates their TS types too — the frontend hand-writes only the
four hub method names (Program.cs:315-318).
Two independent drift gates exist in CI (.github/workflows/ci.yml):
- Backend job:
git diff --exit-code -- openapi/afterdotnet build. - Frontend job:
npm run check:drift(generate:clientthengit diff --exit-codeon the generated TS file).
12. Solution layout¶
| Path | What lives here |
|---|---|
src/FleetMissionControl.Api/ |
The backend: Program.cs (route table + Kestrel binding), Contracts/ (wire DTOs), Services/ (pure logic + IO readers/writers), Endpoints/ (grouped route maps), Hubs/ (SignalR), Security/ (auth) |
tests/FleetMissionControl.Api.Tests/ |
xUnit backend tests — one file per service/gate, matching the pure/static design (see §13) |
frontend/src/app/ |
The Angular cockpit: components/ (standalone, OnPush), core/ (services/store/interceptors), api/fleet-api.generated.ts (NSwag output, committed) |
openapi/FleetMissionControl.Api.json |
Committed OpenAPI document; regenerated by dotnet build, drift-gated in CI |
extensions/vscode/ |
VS Code extension embedding the built cockpit bundle in a webview |
extensions/vs2026/ |
VS 2026 (Visual Studio) extension embedding the cockpit in a WebView2 tool window |
docs/ |
This two-track documentation set, plus the frozen contracts (repo-registry-contract.md, DESIGN-capability-routing.md) and design briefs (BRIEF-*.md) that predate it |
.github/workflows/ci.yml |
The CI gate pack — see §13 |
FleetMissionControl.slnx currently references only the API project and its
test project; the frontend and both IDE extensions build via their own
toolchains (npm/vsce), driven by separate CI jobs rather than dotnet
build.
13. CI gates¶
Seven jobs, all on self-hosted home-ci Windows runners (hosted Ubuntu has a
billing-block history for this account) — see
getting-started.md to run the equivalent
checks locally before pushing:
| Job | Gate |
|---|---|
backend |
dotnet build -warnaserror, dotnet test, OpenAPI drift (git diff --exit-code -- openapi/), vulnerable-package scan (fails on any known-vulnerable direct/transitive package) |
frontend |
Generated-client drift (npm run check:drift), ng lint, ng build, ng test, the standalone vitest stack (npm run test:vitest, kept in sync with ng test on purpose — the Stryker mutation job drives this config directly), npm audit --omit=dev --audit-level=high |
extensions |
VS Code extension lint, typecheck, unit tests, package (vsce package) against a real frontend build artifact |
inspectcode |
JetBrains InspectCode SAST — fails on any WARNING-or-above finding |
security |
Semgrep (--config auto) via the semgrep/semgrep Docker image (no native Windows binary) — fails on any finding |
churn |
Runs the backend test suite 5× and fails if any run's pass/fail signature differs from the first — a flake gate |
mutation |
Backend Stryker (dotnet-stryker) and frontend Stryker (npm run test:mutation) — both gated at ≥80% mutation score |
Every actions/* step is pinned to a full commit SHA (a mutable @v4 tag is
a Semgrep-blocked supply-chain finding on a self-hosted, non-ephemeral
runner).
14. C4 diagrams¶
Rendered to committed SVG via D2 (d2 was available
in the environment this doc was written in; sources are plain text, so
regenerating them needs no special tooling beyond the d2 binary):
- System Context — Andrew, Fleet Mission Control, and every external
system it talks to (fleet-dispatch, usage-governor, the agy ledger, Jira,
the fleet PCs, and the not-yet-built repo-inventory scanner).
Source:
../diagrams/c4-context.d2→../diagrams/c4-context.svg. - Containers — the PWA, the API, its three always-relevant internal
components (poller, dispatcher, artifact store), and the two IDE
extensions that embed the PWA, against the same external systems.
Source:
../diagrams/c4-container.d2→../diagrams/c4-container.svg.
Regenerate after an architecture change:
d2 docs/diagrams/c4-context.d2 docs/diagrams/c4-context.svg
d2 docs/diagrams/c4-container.d2 docs/diagrams/c4-container.svg
15. Not covered here¶
This document does not cover the individual UI panel components' full
markup/rendering logic (gate-panel, governor-panel, quota-panel,
toast — all under frontend/src/app/components/) beyond their place in
the component tree and what's covered in §5-§9 above. Each was opened to
confirm its selector and that it is wired into status-wall.component.ts's
template, but a line-by-line description of every panel's markup is out of
scope for this pass. See references.md for links to every
library and pattern in play, and making-of.md for the
build history behind each section above.