Skip to content

FLT-120 — Fleet-wide repo registry + pick-by-name dispatch — build report

Branch: wt/WT-6022/FLT-120 (worktree C:\Users\fives\source\repos\fmc-FLT120, off master 5c415e6). Not merged to master.

What was built

Council-approved shape (2026-07-18, unanimous build-first): registry only, no live filesystem browser. The dispatch form's absolute-path text box is gone; the operator now picks a repo by NAME from a fleet-wide registry, and the API resolves (repoId, targetPc) to an absolute path server-side.

Backend (src/FleetMissionControl.Api)

  • Services/FleetPaths.cs — added RepoInventoryDir (defaults to %OneDrive%\PC-Bootstrap\_shared\repo-inventory, same parent as dispatch//peers/; overridable via Fleet:RepoInventoryDir / FLEET_REPO_INVENTORY_DIR).
  • Contracts/RepoRegistryContracts.cs (new) — RepoWorktreeEntry, RepoInventoryEntry, RepoInventoryFile (the signed per-PC file), RepoPcEntry, RepoOption, RepoRegistrySnapshot.
  • Services/RepoRegistryReader.cs (new) — reads every {PC}.json under RepoInventoryDir, verifies its HMAC signature with the same fleet-dispatch key AgentRunDispatcher signs jobs with, rejects a file whose Pc field doesn't match its own filename (anti-poisoning, same class of bug as the peers-dir loopback poisoning in FLT-56). Exposes:
  • GetSnapshot()RepoRegistrySnapshot grouped by RepoId, one row per PC — the GET /api/repos payload.
  • Resolve(repoId, targetPc, worktreePath?) → 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.
  • No inventory files on disk → empty, RegistryPopulated = false. Never throws, never falls back to free text.
  • Contracts/FleetDispatchContracts.csAgentRunRequest gained two optional fields, RepoId and WorktreePath. Repo stays the frozen wire field into fleet-dispatch's run-agent contract; RepoId/WorktreePath are cockpit-only and resolved away before the job is signed.
  • Program.cs — registered RepoRegistryReader as a singleton; added GET /api/repos (open read, like every other GET); in POST /api/agent-runs, when run.repoId is present the handler resolves it against body.Target before AgentRunValidator.Validate runs and overwrites run.Repo with the resolved path. A miss returns 400 "{repo|worktree} not indexed on {target} — request a scan (repoId '...')" — never a fallback to a caller-typed Repo.

Frontend (frontend/)

  • openapi/FleetMissionControl.Api.json regenerated by dotnet build (OpenApi doc generation is already wired into the csproj); frontend/src/app/api/fleet-api.generated.ts regenerated via npm run generate:client (nswag) — picked up RepoOption/RepoPcEntry/RepoWorktreeEntry/ RepoRegistrySnapshot, getRepos(), and the two new AgentRunRequest fields automatically.
  • core/fleet-store.service.ts — added repos and repoRegistryPopulated signals + setRepos().
  • core/command.service.ts — added loadRepos(): a plain read (no session gate, like getWorkers/getBoard), fills the store; a failed fetch is swallowed (leaves the store as-is — never surfaces as a form error).
  • components/command-panel.component.ts — the free-text repo <input> is replaced with a <select name="repoId"> populated from repos indexed on the currently-selected target PC (reposForTarget()), plus a worktree <select> that appears when the resolved entry reports any worktrees. Changing target resets the repo/worktree pick if it's no longer valid for the new target. A note renders when the registry is empty (repoRegistryPopulated() === false). valid() now requires repoId, not a typed repo string. Launch payload sends repoId + worktreePath instead of repo.
  • Updated specs: command-panel.component.spec.ts (new tests: target-filtered repo list resets on target change, worktree options reset on repo change, launch payload shape) and command.service.spec.ts (new loadRepos describe block: success fills the store, failure leaves it alone).

Docs

  • docs/repo-registry-contract.md (new) — the frozen wire contract for the scanner side (out of scope here — see "Scanner-side TODO" below), including RepoId derivation rules, the exact signing canonical-string/HMAC scheme (byte-for-byte matching RepoRegistryReader.CanonicalString/Sign), atomic-write requirement, and the "zero scanners running" fail-safe behavior.

Build + test output

$ dotnet build -warnaserror
Build succeeded.
    0 Warning(s)
    0 Error(s)

$ dotnet test
Passed!  - Failed:     0, Passed:   332, Skipped:     0, Total:   332, Duration: 3 s - FleetMissionControl.Api.Tests.dll (net10.0)

332 total backend tests (was 322 before this change — 10 new tests in RepoRegistryReaderTests.cs): resolver hit/miss by repoId+pc, cross-PC divergence (same repoId, different absPath per PC), worktree resolution incl. refusing an unreported worktree path, an unsigned inventory file, a PC-spoofing filename mismatch, empty-registry fail-safe, and GetSnapshot() grouping.

Frontend, run for verification (not the stated gate, but exercised to avoid shipping broken specs):

$ npm run test:vitest
 Test Files  17 passed (17)
      Tests  172 passed (172)

$ npx ng build
Application bundle generation complete. [8.565 seconds]

Scanner-side TODO (explicitly out of scope for this repo)

Nothing writes repo-inventory\{PC}.json yet — no scanner exists in fleet-dispatch or fleet-harness today. Until one ships, GET /api/repos returns an empty, unpopulated registry and the cockpit's repo dropdown shows "none indexed on {target}" with the submit button disabled (there is no repoId to pick). This is the deliberate fail-safe default, not a bug.

The full contract — what to scan (approved roots only, never the whole disk), how to derive a stable RepoId across PCs and worktrees, the exact HMAC signing scheme (reusing the existing fleet-dispatch key every listener already holds), and atomic-write requirements — is written up in docs/repo-registry-contract.md. The scanner can be built and shipped independently against that frozen target.

Deviations from the brief

  • The brief asked for a "Mission Control READER + resolver ... and leave a documented contract + TODO for the scanner side" as one of two acceptable framings — that is exactly what was built; no scanner code was added to this repo.
  • AgentRunRequest.Repo was kept as the wire field into the frozen fleet-dispatch run-agent contract (per AgentRunValidator's own doc comments, narrowing it would break every run); the new RepoId/WorktreePath fields are resolved server-side and never reach the signed job payload as such (they're not read downstream — only Repo is).
  • The UI filters the repo dropdown to the currently-selected target PC rather than showing every PC's rows simultaneously with a separate PC picker — this satisfies "target-PC filter" from the brief while reusing the form's existing target selector instead of adding a second one.
  • No integration test hits POST /api/agent-runs directly for the "not indexed" 400 path (no WebApplicationFactory-based endpoint tests exist anywhere in this test project today — the existing pattern tests AgentRunValidator/AgentRunDispatcher as units, which is what RepoRegistryReaderTests.cs mirrors for the resolver).