feat(imports): consumer-declared read-only repo snapshots mounted into every spawn #264
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab!264
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "afk/261"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #261
Implements the full read-only-imports feature end-to-end per PRD #260 and the triage brief: a repo's settings declare other lab repos its instances may read; every spawn (all run kinds, by construction through the single
instance.Launchpath) materializes a.git-less snapshot of each import's freshly-fetchedorigin/<default>into the per-run directory outside the worktree, mounted read-only at a host-identical path;/pull-baserefreshes the snapshots in place and digests per-import changes.What landed
Store + migrations —
migrations/{sqlite,postgres}/0021_repo_imports.sql:repo_imports (repo_id, target_repo_id)PK on the pair, consumer FK cascades, target FK carries no delete action as the backstop behind the store-level guard.store.DeleteReporefuses while importers exist with anImportersErrornaming them —forcestructurally cannot bypass it (guard sits below the layer force is read at; pinned by tests).API + UI —
GET/POST/DELETE /api/v1/repos/{id}/imports; self-import and unknown target are 400s from reposvc; delete-refusal maps to 409 naming the importers. New repo-settings Imports section (section-registry pattern, co-located vitest suite; still-cloning candidates stay selectable — the spawn-time refusal is the guard).Materializer —
gitx.Engine.MaterializeSnapshot: fetch → resolve → only then clear-and-extract (git archivestreamed through pure-Go tar extraction). The destination inode is preserved across re-materialization (the dir is bind-mounted into a live container) and a refresh against a dead remote never destroys the existing snapshot. Integration tests with real bare repos: fresh export, upstream change, upstream deletion, idempotent same-commit, inode stability, write-protected previous snapshot, fetch failure, no worktree leaked.Spawn path — parallel materialization into
<state>/instances/<runID>/imports/<name>sits after per-run tree creation and beforeAddWorktree(the claim), so a failing target refuses the spawn withread-only import "<name>": …and nothing is claimed — for an AFK spec the issue stays claimable, never parked (tested: no worktree, no branch, no run row, per-run tree wiped). Container runner renders one-v path:path:robind per import — the mount inventory's first:robind, consuming the contract ADR-0053 reserved. Host runner applies best-effortchmod a-w. Every spawn kind carries imports by construction; there is no per-spawn toggle.Context file — new "Read-only imports" section (name, absolute path, snapshotted commit, and the read-only-snapshot-outside-the-working-repo statement); new golden; the import-less render stays byte-identical to the existing goldens.
/pull-base — re-materializes every spawn-materialized import in place (declared-mid-run targets are skipped: the mount inventory is fixed at spawn) and the injected digest gains one line per import (
unchanged,<old12>..<new12> (N commits),refreshed to <new12>, orrefresh failed — <reason>); a refresh failure is reported and logged, never fatal to the pull; base-up-to-date-but-import-moved still injects a digest. Host-runner write protection is reapplied after refresh.Teardown — snapshots live inside the per-run tree, so
instancehome.Wipe/SweepAllremove them for free; both are hardened to clear write-protected trees (verified through the real Stop path).Docs — CONTEXT.md gains the read-only import term with its avoid-list plus cross-references (Runner mount inventory,
/pull-base, Dev image, Relationships); ADR-0063 records the grant model, materialization, refusal, refresh, and delete-guard decisions, citing ADR-0052 (Containerized runner), ADR-0053 (Per-repo dev images), ADR-0062 (Schedules), ADR-0035.Verification
go build ./...,go vet ./...,gofmt -lclean;go test -count=1 ./...fully green (store tests run both dialect paths; postgres subtests skip withoutLAB_TEST_POSTGRES_DSN, as in CI)npm test982/982 green,npm run lintandnpm run buildcleangolangci-lintwas not available in this environment — deferring to CI for that check🤖 Generated with Claude Code
[autoland] verdict: pass
PASS — validated, awaiting the human's merge confirmation.
Signal relied on:
ci / native (pull_request)— aggregatesuccess, 8m27s, run 360. Not re-run locally (no Go/Node toolchain in the landing session; CI is the repo's required check and it already exercised the full gate: SPA lint/format/test/build,go build -tags ui,go test -tags ui ./..., and the untagged golangci-lint 2.12.2 pass).One fix pushed while landing (
faabf8a, see the comment below): run 358 failed on a single staticcheck QF1001 atinternal/seeder/seeder_test.go:655— the check the PR body flagged as unverifiable in the authoring environment. Rewritten as the equivalent disjunction; run 360 is green.Conventions: Conventional Commits title ✓ ·
Closes #261present and resolving ✓ · no textual conflict againstmain(git merge-treeclean).Diff scope: 38 files, +4311/−95, mapping onto issue #261's acceptance criteria with no drive-by changes. Spot-checked and sound: repo names are
[A-Za-z0-9_-]and globally UNIQUE, soimports/<name>and its<name>.commitsidecar structurally cannot collide or traverse;materializeImportssits beforeAddWorktreeso a target-side failure refuses the spawn with nothing claimed;refreshImportsruns on thecontext.WithoutCancelcontext, so a dropped HTTP connection cannot kill an extraction midway; theUpToDatesemantics change is handled correctly by its one consumer (internal/httpapi/chat.go:464), andgitxguaranteesNewHead == OldHeadon that path sorenderDigest's collapsed branch fires exactly when intended.CONCERNS (non-blocking, 1):
internal/gitx/materialize.go:57-65—MaterializeSnapshotclears the destination before extracting, so a failure during extraction (timeout ongit archive, disk full, I/O error) leaves a partial tree while the<name>.commitsidecar still names the old commit./pull-basethen reportsrefresh failed — <reason>, and bothinternal/pull/pull.go("the snapshot on disk is still the one the run was spawned with") and ADR-0063 ("keeps its still-valid snapshot") state the snapshot is intact — which holds for fetch/resolve failures, the ordering the design deliberately buys, but not for this window. The agent would be told it still holds the old snapshot while actually holding a truncated one.Test coverage matches that boundary:
TestMaterializeSnapshot_fetchFailureLeavesSnapshotIntactpins the pre-clear failure; there is no post-clear equivalent. Narrow window and not a merge blocker, but worth a follow-up — either extract to a sibling directory and swap contents in (keeping the inode), or downgrade the "intact" claim in both the doc comment and the digest wording.Lint fix pushed to
afk/261while landing:faabf8a—chore(seeder): apply De Morgan's law in the ordering assertion (QF1001).Run 358 (
ci / native) failed after 9m7s on exactly one issue:Every other step in that run was green — SPA lint/format/test/build (67 test files),
go build -tags ui, andgo test -tags ui ./...across every package. This was the check the PR body deferred ("golangci-lint was not available in this environment").The one-line change, behaviour-identical:
Run 360 is green (8m27s). Ordinary push to the head branch — no force-push, no rewrite.
Note on
labctl pr logs 264: it could not fetch these logs, reporting "forge log route did not answer the shape lab's log adapter expects" and advising a local repro. The forge actually returns 200 with the full log onattempt/1and 500 onattempt/2— labctl is requesting the wrong attempt, not hitting a response-shape mismatch, and its message misdiagnoses the cause. Compare84f8fc5, which recorded the same attempt/2-has-no-logs behaviour. Worth its own issue.