Skip to content

FLT-126 — repo registry scanner: build report

Branch: wt/WT-6022/FLT-126 (worktree fd-FLT126, off origin/master). Not merged to master.

What was built

The per-PC scanner that populates the fleet-wide repo registry FLT-120's RepoRegistryReader reads, against the frozen contract in fleet-mission-control/docs/repo-registry-contract.md.

  • src/FleetDispatch.Core/RepoRegistry/RepoRegistryContracts.csRepoWorktreeEntry, RepoInventoryEntry, RepoInventoryFile, field-for-field and declaration-order-for-declaration-order identical to fleet-mission-control's RepoRegistryContracts.cs (required for the canonical string to serialize byte-identically).
  • src/FleetDispatch.Core/RepoRegistry/RepoRegistrySigner.csCanonicalString / Sign / Verify / SerializeFile / DeserializeFile, byte-for-byte mirror of RepoRegistryReader.CanonicalString/Sign/VerifySignature: same JsonSerializerOptions(JsonSerializerDefaults.Web), same Pc|GeneratedAt|<serialized Repos> join, same HMAC-SHA256-hex-lowercase.
  • src/FleetDispatch.Core/RepoRegistry/RepoScanOptions.csExtraRoots (from FLEET_REPO_SCAN_EXTRA_ROOTS, ;-separated) and GitTimeoutSec (from FLEET_REPO_SCAN_GIT_TIMEOUT_SEC).
  • src/FleetDispatch.Core/RepoRegistry/RepoRegistryScanner.cs — the scan itself:
  • Approved roots only: FleetDispatchPaths.DefaultCwd (%USERPROFILE%\source\repos) plus RepoScanOptions.ExtraRoots. Depth is exactly one level under each root (the root's immediate subdirectories, plus the root itself if it directly is a repo) — never a recursive disk walk.
  • Main vs. worktree classification: git rev-parse --git-dir --git-common-dir per candidate; equal ⇒ main checkout, different ⇒ linked worktree. A worktree is NEVER emitted as its own top-level entry — it is folded into its main checkout's Worktrees list via git worktree list --porcelain on the main repo, which reports every attached worktree by absolute path regardless of physical location (sibling under the same root, nested .claude/worktrees/<name>, or even a different drive like C:\wt\...). This means the "known worktree roots" requirement in the contract is satisfied structurally, not by scanning extra path patterns.
  • RepoId derivation: normalized host/owner/repo from origin (handles both git@host:owner/repo.git and https://[user@]host/owner/repo[.git] — the two shapes this fleet's GitHub remotes use), else the main checkout's folder name. RepoRegistryScanner.NormalizeRemote is internal and unit-tested directly (InternalsVisibleTo already wired to FleetDispatch.Tests).
  • Orphaned worktrees (a linked worktree whose main checkout isn't itself under an approved root) are logged and dropped, never promoted to a fabricated top-level entry — see TODOs below.
  • FleetDispatchPaths.RepoInventoryDir (edit to existing FleetDispatchPaths.cs) — new property, resolves to {shared}\repo-inventory where {shared} is the SAME %OneDrive%\PC-Bootstrap\_shared parent DispatchRoot already uses, overridable via FLEET_REPO_INVENTORY_DIR — matches fleet-mission-control's FleetPaths.RepoInventoryDir resolution exactly (verified by reading that file; not copied by reference since these are separate repos).
  • CLI (src/FleetDispatch.Cli/Program.cs):
  • fleet-dispatch repo-scan [--json] — runs one scan, signs it with the same ~/.claude/fleet-dispatch.key every listener already loads (FleetKey.Load), writes {RepoInventoryDir}\{ComputerName}.json atomically (AtomicFile.WriteTextAtomic, temp + File.Move in the same directory), reads it back, and verifies it with RepoRegistrySigner.Verify before printing success — exits non-zero if the round-trip fails.
  • Boot + daily timer: fleet-dispatch serve now also runs RunRepoScanLoopAsync alongside the existing queue-drain loop — one scan immediately (the boot trigger) then every --repo-scan-poll-hours (default 24). --no-repo-scan disables it. A failed scan logs and retries next tick; it can never take the listener down (same posture as the queue drain).
  • On-demand trigger: deliberately NOT a new HarnessKind. repo-scan is dispatchable today, with zero new wiring, through the existing pwsh escape-hatch kind every listener already runs: fleet-dispatch enqueue --targets PC --kind pwsh --lane WT-xxxx --payload "fleet-dispatch repo-scan". Adding a dedicated repo-scan HarnessKind (enqueue validation, steer/cancel semantics, payload shape) would have meant touching HarnessRegistry, QueueService, and DispatchServer routing for a command with no payload and no steering need — out of proportion to what the ticket needs. Documented as a TODO below if the fleet wants it as a first-class kind later (mainly for uniform results/status reporting).

Build / test output

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

dotnet test
  Passed!  - Failed: 0, Passed: 352, Skipped: 1, Total: 353
  (the 1 skip is AgentRunLiveAgyTests' pre-existing live-agy e2e skip, unrelated to this change)

New tests (tests/FleetDispatch.Tests/RepoRegistry/, 8 tests, all real git — no fakes for the git shellouts):

  • NormalizeRemote_HandlesScpAndHttpsForms — scp-like, https, and https-with-credentials forms all normalize to the exact github.com/andrewjonesdev-tools/fleet-mission-control from the contract doc's own worked example.
  • ApprovedRoots_DedupesAndOnlyIncludesConfiguredRoots — a third, unconfigured temp dir is proven absent from ApprovedRoots().
  • ScanAsync_FindsMainRepoUnderRoot_AndSkipsPlainDirectory
  • ScanAsync_NeverScansOutsideApprovedRoots — a real git repo sitting OUTSIDE every configured root never appears in scan output.
  • ScanAsync_AttachesWorktreeToParent_NeverAsTopLevelEntry — real git worktree add, asserts exactly one top-level entry with the worktree nested under Worktrees.
  • ScanAndWriteAsync_FilenameEqualsPc_AndVerifiesAgainstReaderMirrorthe acceptance test: writes a real signed file, reads it back, and verifies it two ways: (1) RepoRegistrySigner.Verify (this scanner's own signer) and (2) ReaderMirror.Verify, a verbatim copy of RepoRegistryReader.CanonicalString/VerifySignature kept in the test project (no project reference exists across the two repos — this is the closest available stand-in for "the reader's own check" without touching Mission Control). Also asserts the filename (minus .json) equals file.Pc, the same guard RepoRegistryReader enforces.
  • CanonicalString_MatchesReaderMirror_FieldOrderAndSeparator — literal-string assertion on the canonical form (Pc|GeneratedAt|[{...}], camelCase field names) plus equality against ReaderMirror.CanonicalString.
  • Verify_Fails_WhenSigTampered.

One shared-fixture fix: tests/FleetDispatch.Tests/TestDoubles.cs's TempDir.Dispose() now clears FileAttributes.ReadOnly recursively before Directory.Delete — git-for-windows marks some .git/objects entries read-only, which made every new test that git inits inside a TempDir fail on cleanup with UnauthorizedAccessException. This is a general fixture fix, not FLT-126-specific behavior, and it only loosens permissions inside a test-owned temp directory that is about to be deleted anyway.

Round-trip verification against the real reader (manual, beyond the unit tests)

Ran fleet-dispatch repo-scan for real against this PC's actual %USERPROFILE%\source\repos (pointed at a scratchpad FLEET_REPO_INVENTORY_DIR override, not the shared registry, so this did not touch production data):

repo-scan wrote ...\ANDYGREATROOMPC.json repos=34 verified=True

Inspected the output: fleet-dispatch and fleet-mission-control each appear as exactly ONE top-level entry, with every one of their attached worktrees correctly nested under worktrees[] by absolute path — including sibling dirs under source\repos (e.g. fd-FLT126, this very worktree), nested .claude/worktrees/<name> dirs, and worktrees living on a completely different drive (C:\wt\...). None of those worktree paths leaked out as their own top-level RepoId. This confirms the "known worktree roots" requirement is met structurally (via git worktree list, not via extra path-pattern scanning) even for worktree layouts the contract doc didn't explicitly enumerate.

Deploy note for the 4 PCs

  1. Each PC already has ~/.claude/fleet-dispatch.key (the same key serve/listen use) — no new secret to distribute.
  2. Land this branch to master, ship the updated fleet-dispatch.exe/FleetDispatch.Core.dll to all 4 PCs the same way FLT-16's CI tooling pack already does.
  3. RepoInventoryDir needs no manual setup — it's a subdirectory of the same %OneDrive%\PC-Bootstrap\_shared every PC already reads/writes for dispatch/ and peers/. AtomicFile.WriteTextAtomic creates it on first write.
  4. Whichever process pattern each PC runs (listen vs serve) matters:
  5. PCs running fleet-dispatch serve get the boot + daily-timer scan for free on the next restart of the listener — no extra step.
  6. PCs running fleet-dispatch listen (queue-drain only, no HTTP server) do NOT get the timer loop, since that loop lives inside CommandServe. For those, either (a) add a Scheduled Task running fleet-dispatch repo-scan daily (same pattern as any other per-PC cron-ish job in this fleet), or (b) switch that PC to serve. Recommend (a) for boxes deliberately kept on listen.
  7. Either way, fleet-dispatch enqueue --targets all --kind pwsh --lane WT-xxxx --payload "fleet-dispatch repo-scan" triggers an immediate scan fleet-wide with zero new plumbing.
  8. First scan on a fresh PC populates its {PC}.json — until then RepoRegistryReader reports that PC absent from the registry, which is the documented fail-safe default, not an error.

TODOs / limitations (flagged, not fixed here — out of FLT-126's scope)

  • Orphaned worktrees (a linked worktree whose main checkout is not itself under an approved root) are silently dropped with a log line, never emitted. In practice this fleet keeps every worktree's main checkout under source\repos, so it hasn't been observed, but a scanner run from a PC with a stray worktree pointing at a since-deleted or relocated main checkout will lose visibility into that path. Fixing it cleanly needs a decision on what RepoId/Name such an entry should carry (contract explicitly forbids inventing one).
  • NormalizeRemote doesn't special-case a host:port segment inside an ssh:// URL (e.g. ssh://git@host:2222/owner/repo.git would fold the port into the host component). Not encountered in this fleet (GitHub only, default ports), documented rather than speculatively hardened.
  • DefaultBranch resolves via git symbolic-ref --short refs/remotes/origin/HEAD, which is null on a repo that's never had origin/HEAD set up locally (common right after a shallow or manually-added remote). No network fallback (git remote show origin) was added — deliberate, to keep every git call in this scanner offline and fast.
  • No dedicated repo-scan HarnessKind — the on-demand trigger rides the existing pwsh kind (see above). If the fleet later wants uniform fleet-dispatch results reporting for scan runs specifically, promoting it to a first-class kind is a small, separate follow-up.
  • Only the CORE scan+write path plus the serve-embedded timer were wired. No Windows Scheduled Task was created on this PC — deploy step 4 above documents the option for listen-only boxes, left as an operator action.