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— addedRepoInventoryDir(defaults to%OneDrive%\PC-Bootstrap\_shared\repo-inventory, same parent asdispatch//peers/; overridable viaFleet: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}.jsonunderRepoInventoryDir, verifies its HMAC signature with the same fleet-dispatch keyAgentRunDispatchersigns jobs with, rejects a file whosePcfield doesn't match its own filename (anti-poisoning, same class of bug as the peers-dir loopback poisoning in FLT-56). Exposes:GetSnapshot()→RepoRegistrySnapshotgrouped byRepoId, one row per PC — theGET /api/repospayload.Resolve(repoId, targetPc, worktreePath?)→ 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.- No inventory files on disk → empty,
RegistryPopulated = false. Never throws, never falls back to free text. Contracts/FleetDispatchContracts.cs—AgentRunRequestgained two optional fields,RepoIdandWorktreePath.Repostays the frozen wire field into fleet-dispatch's run-agent contract;RepoId/WorktreePathare cockpit-only and resolved away before the job is signed.Program.cs— registeredRepoRegistryReaderas a singleton; addedGET /api/repos(open read, like every other GET); inPOST /api/agent-runs, whenrun.repoIdis present the handler resolves it againstbody.TargetbeforeAgentRunValidator.Validateruns and overwritesrun.Repowith the resolved path. A miss returns 400"{repo|worktree} not indexed on {target} — request a scan (repoId '...')"— never a fallback to a caller-typedRepo.
Frontend (frontend/)¶
openapi/FleetMissionControl.Api.jsonregenerated bydotnet build(OpenApi doc generation is already wired into the csproj);frontend/src/app/api/fleet-api.generated.tsregenerated vianpm run generate:client(nswag) — picked upRepoOption/RepoPcEntry/RepoWorktreeEntry/RepoRegistrySnapshot,getRepos(), and the two newAgentRunRequestfields automatically.core/fleet-store.service.ts— addedreposandrepoRegistryPopulatedsignals +setRepos().core/command.service.ts— addedloadRepos(): a plain read (no session gate, likegetWorkers/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 requiresrepoId, not a typedrepostring. Launch payload sendsrepoId+worktreePathinstead ofrepo.- 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) andcommand.service.spec.ts(newloadReposdescribe 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), includingRepoIdderivation rules, the exact signing canonical-string/HMAC scheme (byte-for-byte matchingRepoRegistryReader.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.Repowas kept as the wire field into the frozen fleet-dispatch run-agent contract (perAgentRunValidator's own doc comments, narrowing it would break every run); the newRepoId/WorktreePathfields are resolved server-side and never reach the signed job payload as such (they're not read downstream — onlyRepois).- 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-runsdirectly for the "not indexed" 400 path (noWebApplicationFactory-based endpoint tests exist anywhere in this test project today — the existing pattern testsAgentRunValidator/AgentRunDispatcheras units, which is whatRepoRegistryReaderTests.csmirrors for the resolver).