Skip to content

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

  1. Containers and data flow
  2. Key request path: dispatch an agent run by repo name
  3. Live update sequence (SignalR)
  4. Responsive posture selection
  5. The write surface: sessions, bearer, and why the browser never holds the key
  6. Session tree (cards) — FLT-122
  7. Repo registry / pick-by-name — FLT-120
  8. Artifact run — FLT-19 / FLT-125
  9. Unavailable-mode host status — FLT-130/131
  10. Runtime backend origin (embedded hosts)
  11. NSwag / OpenAPI generation path
  12. Solution layout
  13. CI gates
  14. C4 diagrams
  15. 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 Repo once RepoId is 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 touches CapabilityRouter'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:

  • WorkerHeartbeat fires unconditionally every poll (FleetPollingService.cs:50).
  • GovernorTier fires only when gov.Tier changed (FleetPollingService.cs:52-57).
  • QuotaUpdated fires only when the serialized quota snapshot changed (FleetPollingService.cs:59-66).
  • RunUpdated fires 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_agent job, and run_agent's validateCommand is arbitrary shell — the same privilege as dispatch's pwsh kind. 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. CockpitSessionService mints a short-lived (12h), 32-random-byte session token instead — minted only to loopback callers, gated on both the remote IP being loopback and the Host header 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-Auth header 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 unknownready / 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 a ParentCardId is always spawn, regardless of lane. Otherwise Lane "cockpit" (the only lane this API itself writes) is manual; a lane substring "routine"/"spawn" is a best-effort heuristic; everything else is auto.
  • 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 own needsHumanReview flag), 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, TaintLevel are all defined on the Card record 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}.json under FleetPaths.RepoInventoryDir, verifies its HMAC-SHA256 signature with the same fleet-dispatch key AgentRunDispatcher signs jobs with, and rejects a file whose Pc field 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 by RepoId into RepoOption rows (one per PC) — the GET /api/repos payload the dropdown renders.
  • Resolve(repoId, targetPc, worktreePath?) returns an absolute path or null. 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/repos returns {repos: [], registryPopulated: false}, the dropdown shows "none indexed on {target}", and the form's submit stays disabled (there is no repoId to 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:

  1. Capability hard constraint. claude-design ⇒ must route to claude. A contradicting explicit override throws CapabilityMismatchException (structured 400), never a silent remap.
  2. Explicit override — may pin model, tier, or both.
  3. Auto (default ~95% of runs): TierLadder classification (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}.json into HostAvailability — 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 onto WorkerStatus.Availability in the existing GET /api/workers snapshot and WorkerHeartbeat push — 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 on Available=true; once a host has declared itself unavailable, BlockLevel decides: all blocks every placement, heavy blocks 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" for run_agent reuses TierLadder.Classify(...).Tier == Ceiling (the same signal the design lane already trusts); design jobs use the tier CapabilityRouter.Plan already computed.
  • Write path: AvailabilityWriter.Set(pc, request, now) validates and atomically writes {DispatchRoot}/availability/{pc}.override.json (Services/AvailabilityWriter.cs). auto clears with no expiry; unavailable is sticky unless a durationMinutes is given; available always carries a TTL (default 45 min, capped at 24h). Unsigned — unlike DispatchJob — 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.ts renders a per-host availability line (unavailable · gaming Elden Ring, or a client-side available in 04:23 countdown while inside a per-reason grace window — gaming 5 min, streaming 10 min, a heuristic tuned client-side because HostAvailability carries 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):

  1. runtime-config.ts reads window.__FLEET_CONFIG__?.apiBaseUrl, defaulting to '' (same-origin) when absent.
  2. app.config.ts provides that value into the NSwag-generated FLEET_API_BASE_URL injection token via useFactory: getRuntimeApiBaseUrl.
  3. fleet-live.service.ts builds 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/ after dotnet build.
  • Frontend job: npm run check:drift (generate:client then git diff --exit-code on 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.