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.cs—RepoWorktreeEntry,RepoInventoryEntry,RepoInventoryFile, field-for-field and declaration-order-for-declaration-order identical tofleet-mission-control'sRepoRegistryContracts.cs(required for the canonical string to serialize byte-identically).src/FleetDispatch.Core/RepoRegistry/RepoRegistrySigner.cs—CanonicalString/Sign/Verify/SerializeFile/DeserializeFile, byte-for-byte mirror ofRepoRegistryReader.CanonicalString/Sign/VerifySignature: sameJsonSerializerOptions(JsonSerializerDefaults.Web), samePc|GeneratedAt|<serialized Repos>join, same HMAC-SHA256-hex-lowercase.src/FleetDispatch.Core/RepoRegistry/RepoScanOptions.cs—ExtraRoots(fromFLEET_REPO_SCAN_EXTRA_ROOTS,;-separated) andGitTimeoutSec(fromFLEET_REPO_SCAN_GIT_TIMEOUT_SEC).src/FleetDispatch.Core/RepoRegistry/RepoRegistryScanner.cs— the scan itself:- Approved roots only:
FleetDispatchPaths.DefaultCwd(%USERPROFILE%\source\repos) plusRepoScanOptions.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-dirper 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'sWorktreeslist viagit worktree list --porcelainon 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 likeC:\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/repofromorigin(handles bothgit@host:owner/repo.gitandhttps://[user@]host/owner/repo[.git]— the two shapes this fleet's GitHub remotes use), else the main checkout's folder name.RepoRegistryScanner.NormalizeRemoteisinternaland unit-tested directly (InternalsVisibleToalready wired toFleetDispatch.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 existingFleetDispatchPaths.cs) — new property, resolves to{shared}\repo-inventorywhere{shared}is the SAME%OneDrive%\PC-Bootstrap\_sharedparentDispatchRootalready uses, overridable viaFLEET_REPO_INVENTORY_DIR— matchesfleet-mission-control'sFleetPaths.RepoInventoryDirresolution 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.keyevery listener already loads (FleetKey.Load), writes{RepoInventoryDir}\{ComputerName}.jsonatomically (AtomicFile.WriteTextAtomic, temp +File.Movein the same directory), reads it back, and verifies it withRepoRegistrySigner.Verifybefore printing success — exits non-zero if the round-trip fails.- Boot + daily timer:
fleet-dispatch servenow also runsRunRepoScanLoopAsyncalongside the existing queue-drain loop — one scan immediately (the boot trigger) then every--repo-scan-poll-hours(default 24).--no-repo-scandisables 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-scanis dispatchable today, with zero new wiring, through the existingpwshescape-hatch kind every listener already runs:fleet-dispatch enqueue --targets PC --kind pwsh --lane WT-xxxx --payload "fleet-dispatch repo-scan". Adding a dedicatedrepo-scanHarnessKind(enqueue validation, steer/cancel semantics, payload shape) would have meant touchingHarnessRegistry,QueueService, andDispatchServerrouting 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 uniformresults/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 exactgithub.com/andrewjonesdev-tools/fleet-mission-controlfrom the contract doc's own worked example.ApprovedRoots_DedupesAndOnlyIncludesConfiguredRoots— a third, unconfigured temp dir is proven absent fromApprovedRoots().ScanAsync_FindsMainRepoUnderRoot_AndSkipsPlainDirectoryScanAsync_NeverScansOutsideApprovedRoots— a real git repo sitting OUTSIDE every configured root never appears in scan output.ScanAsync_AttachesWorktreeToParent_NeverAsTopLevelEntry— realgit worktree add, asserts exactly one top-level entry with the worktree nested underWorktrees.ScanAndWriteAsync_FilenameEqualsPc_AndVerifiesAgainstReaderMirror— the 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 ofRepoRegistryReader.CanonicalString/VerifySignaturekept 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) equalsfile.Pc, the same guardRepoRegistryReaderenforces.CanonicalString_MatchesReaderMirror_FieldOrderAndSeparator— literal-string assertion on the canonical form (Pc|GeneratedAt|[{...}], camelCase field names) plus equality againstReaderMirror.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¶
- Each PC already has
~/.claude/fleet-dispatch.key(the same keyserve/listenuse) — no new secret to distribute. - Land this branch to
master, ship the updatedfleet-dispatch.exe/FleetDispatch.Core.dllto all 4 PCs the same wayFLT-16's CI tooling pack already does. RepoInventoryDirneeds no manual setup — it's a subdirectory of the same%OneDrive%\PC-Bootstrap\_sharedevery PC already reads/writes fordispatch/andpeers/.AtomicFile.WriteTextAtomiccreates it on first write.- Whichever process pattern each PC runs (
listenvsserve) matters: - PCs running
fleet-dispatch serveget the boot + daily-timer scan for free on the next restart of the listener — no extra step. - PCs running
fleet-dispatch listen(queue-drain only, no HTTP server) do NOT get the timer loop, since that loop lives insideCommandServe. For those, either (a) add a Scheduled Task runningfleet-dispatch repo-scandaily (same pattern as any other per-PC cron-ish job in this fleet), or (b) switch that PC toserve. Recommend (a) for boxes deliberately kept onlisten. - 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. - First scan on a fresh PC populates its
{PC}.json— until thenRepoRegistryReaderreports 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 whatRepoId/Namesuch an entry should carry (contract explicitly forbids inventing one). NormalizeRemotedoesn't special-case ahost:portsegment inside anssh://URL (e.g.ssh://git@host:2222/owner/repo.gitwould fold the port into the host component). Not encountered in this fleet (GitHub only, default ports), documented rather than speculatively hardened.DefaultBranchresolves viagit symbolic-ref --short refs/remotes/origin/HEAD, which is null on a repo that's never hadorigin/HEADset 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-scanHarnessKind— the on-demand trigger rides the existingpwshkind (see above). If the fleet later wants uniformfleet-dispatch resultsreporting 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 forlisten-only boxes, left as an operator action.