diff --git a/codev/plans/272-the-sidebar-tree-needs-every-w.md b/codev/plans/272-the-sidebar-tree-needs-every-w.md new file mode 100644 index 000000000..9ceb75046 --- /dev/null +++ b/codev/plans/272-the-sidebar-tree-needs-every-w.md @@ -0,0 +1,251 @@ +# PIR Plan: Every workspace is a named top-level node in the sidebar tree + +## Understanding + +Issue #272 asks for a three-level sidebar tree — **workspace → architect → builders** — covering +*every* workspace Codev knows about. Spec 250 shipped levels 2 and 3. Level 1 exists but is wrong +in two independent ways, and both were verified against the live database rather than reasoned +about. + +### Verified starting state (2026-08-31, `~/.t3/dev/state.sqlite`) + +``` +$ sqlite3 ~/.t3/dev/state.sqlite "select project_id, title, workspace_root from projection_projects;" +afab56f9-5696-4aa7-858e-bd65444ce157|codev:/Users/chris/dev/codev-1455|/Users/chris/dev/codev-1455 + +$ sqlite3 ~/.t3/dev/state.sqlite \ + "select thread_id, project_id, title, coalesce(nullif(codev_role,''),'(none)') from projection_threads;" +2e2bd2c7-...|afab56f9-...|architect-lan|(none) +``` + +One project, titled with a prefixed absolute path. One thread, with `codev_role` empty — that +second fact is **#271**, not this issue. + +### Root cause 1 — the title is a path with a prefix + +`packages/codev/src/agent-farm/thread-backend.ts:913` writes the project title: + +```ts +projectId = await createProject(dispatcher, journal, { + title: `codev:${config.workspaceRoot}`, + workspaceRoot: config.workspaceRoot, +}); +``` + +That string is what the sidebar heading renders, and the path from title to pixel is short and has +no cleanup step in it: + +- `packages/client-runtime/src/state/projectGrouping.ts:323` (fork) — a single-member group's + `label` **is** `representative.title`, verbatim. +- `apps/web/src/sidebarProjectGrouping.ts:105` (fork) — `displayName: group.label`. +- `apps/web/src/components/Sidebar.tsx:4015` (fork) — the heading renders + `projectDisplayNameByKey.get(projectKey) ?? "Project"`. + +So the fix belongs in Codev, at the one place the string is written. No fork change is needed for +this half. + +### Root cause 2 — the project row is created by the *connect*, and only for the connecting workspace + +The project is created inside `initialiseThreadBackend` → the block at +`thread-backend.ts:901-940`, reached only when `ensureThreadBackendReady()` runs for +that specific workspace. Two consequences: + +- A registered Codev workspace nobody has spawned into has no project row, so it is absent from the + tree — indistinguishable from a workspace that does not exist. +- Tower's existing sweep does not close the gap. `tower-server.ts:825-886` enumerates workspaces + from `architect ∪ builders` in `global.db`, **not** from `known_workspaces`. The broader list + Codev actually keeps is `getKnownWorkspacePaths()` at + `packages/codev/src/agent-farm/servers/tower-instances.ts:212` (`known_workspaces` ∪ + `terminal_sessions` ∪ the in-memory cache). + +### Root cause 3 — an empty project draws no heading at all (fork) + +Even with the project row present, the tree would still not show it. `buildCodevSidebarOrder` +(`apps/web/src/components/Sidebar.logic.ts:1054-1113`) derives project headings **from +architects**: it walks `hierarchy.architects`, keys each by `projectKeyOf`, and sets +`startsProject` on the first subtree of each project. A project with zero architect threads +contributes zero entries, so `Sidebar.tsx:3993` never emits a heading for it. + +This is the part of #272 that cannot be fixed from the Codev repo. It is a fork change. + +### Relationship to #271 + +#271 (`codev_role` arrives empty) is level **2** of the tree, and PR #274 is open against it. Its +files (`commands/workspace-add-architect.ts`, `commands/status.ts`, four test files) do not overlap +with anything below, so the two can land in either order. The consequence for verification is +stated in the Test Plan: level 1 is verifiable on its own; the full three-level shape is not +visible until #274 merges. + +## Proposed Change + +Three changes, in two repositories. + +### A. Name the project after the workspace directory (Codev repo) + +`title: displayNameForWorkspace(config.workspaceRoot)` instead of the `codev:`-prefixed path. + +The name is the **shortest unique trailing path segments** across the known workspace set, minimum +one segment. `/Users/chris/dev/codev-1455` → `codev-1455`. If two known workspaces are both called +`api`, both become `backend/api` and `mobile/api` rather than two identical rows. This is a small +pure function with its own tests; it is not `basename` with a comment promising to handle +collisions later. + +### B. Reconcile every known workspace into a project row (Codev repo) + +A new module, `packages/codev/src/agent-farm/workspace-projection.ts`, and a Tower sweep that calls +it at startup and every 30s: + +1. **Enumerate.** `getKnownWorkspacePaths()`, then drop: paths containing `/.builders/` (the filter + `servers/v2-routes.ts:138` already applies), paths that no longer exist on disk, and paths with + no `.codev/` directory. A stale `known_workspaces` row for a deleted checkout must not mint a + project — the list in the live database today contains several. +2. **Group by server.** For each surviving root, `readThreadBackendConfig(root)`. Roots with no + `threads` config are skipped — no server is named, so there is nowhere to project them. + Remaining roots group by `(serverUrl, bootstrapToken)`. +3. **One connection per group, not one per workspace.** `createProject` + (`packages/porch-driver/src/thread.ts:88`) needs a dispatcher, not a per-workspace engine. Doing + this by calling `ensureThreadBackendReady` per workspace would open one live WebSocket engine + per known workspace — roughly 30 sockets against one server, held forever — to write one row + each. +4. **Ensure.** Read the shell snapshot once (`GET /api/orchestration/shell`, the request + `activeProjectForWorkspace` at `thread-backend.ts:493` already makes) and compare on + `canonicalWorkspaceKey`, the same normalisation that lookup uses. Missing → `project.create`. + Present → leave it alone, **except** the one repair below. +5. **Repair the legacy titles, and nothing else.** If an existing project's title is exactly + `codev:`, issue `project.meta.update` with the new name. Any other title + is left untouched, because a project a human renamed in the t3code UI must not be renamed back + on the next sweep. `project.meta.update` is already in the vendored contract + (`packages/types/src/t3/generated/types.d.ts:25`), so this needs a small + `updateProjectMeta` helper in `porch-driver/src/thread.ts` and no contract change. + +A failure anywhere in a group is logged and the sweep moves on. This is a background reconciler; +an unreachable server is a "not yet", not a Tower fault. + +### C. Draw a heading for a project with no architects (t3code fork) + +`buildCodevSidebarOrder` gains an optional `projectKeys: readonly string[]` option — the project +keys the sidebar knows about, in its own order. After the architect-derived entries, it emits +`{ kind: "empty-project", projectKey }` for every known key that no entry started. +`Sidebar.tsx` renders that entry with the **same** heading markup as `startsProject`, extracted +into one helper so the two cannot drift apart, carrying the same +`data-testid="sidebar-codev-project-heading"`. + +The `!hasCodevHierarchy` early return at `Sidebar.logic.ts:1058` is left alone: with no Codev +thread anywhere in the sidebar, the flat upstream presentation stays exactly as it is. The +consequence is stated plainly — if *no* workspace has an architect, no workspace headings appear. +That is the upstream behaviour we are deliberately not changing out from under a non-Codev user. + +### D. The fork-commit tail (Codev repo) + +`pin.contractSource` is `fork`, so a fork HEAD ahead of `pin.commit` makes +`t3-server.mjs verify` exit `1` (`FORK_AHEAD_OF_CONTRACT`) and turns the suite red. Change C is a +fork commit, so `tools/t3-codegen/REFRESH.md` steps 3-8 are part of this work, not a follow-up: +move `pin.commit`/`commitDate`, regenerate, re-export `tools/t3-fork/patches/`, and re-run the four +evidence collectors that name a fork commit. The contract closure (`packages/contracts/src`) is not +touched by a Sidebar-only change, so the generated artifacts are expected to come out byte-identical +apart from `source-hash.json` — expected, and checked rather than assumed. + +## Files to Change + +### Codev repo (this worktree) + +- `packages/codev/src/agent-farm/workspace-projection.ts` — **new.** Enumeration, the display-name + function, and the reconciler. Pure core, injected I/O, so it is testable without a server. +- `packages/codev/src/agent-farm/thread-backend.ts:913` — title becomes the display name. +- `packages/porch-driver/src/thread.ts:~105` — new `updateProjectMeta(dispatcher, journal, {...})` + emitting `project.meta.update`, alongside `createProject`. +- `packages/codev/src/agent-farm/servers/tower-server.ts:~886` — start the reconciler after + `markBootComplete()`, and stop it on shutdown next to `threadAdoptionSweeper`. +- `packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts` — **new.** +- `packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts` — **new.** +- `packages/types/src/t3/pin.json` — `commit` / `commitDate` move. +- `packages/types/src/t3/generated/*` — regenerated. +- `tools/t3-fork/patches/*.patch` — re-exported. +- `tools/t3-fork/FORK.md` — phase log entry for the new fork commit. +- `codev/research/250-*.json` — the four evidence runs, re-run. + +### t3code fork (`/Users/chris/dev/t3code-codev`, branch `codev`) + +- `apps/web/src/components/Sidebar.logic.ts:1054-1113` — `projectKeys` option, `empty-project` + entry kind. +- `apps/web/src/components/Sidebar.tsx:3993-4020` — heading helper, rendered for both entry kinds. +- `apps/web/src/components/Sidebar.logic.test.ts` — ordering and the "every known project appears + exactly once" property. + +The fork checkout is clean at `2f64a1b0e` with one worktree and no other writer; PR #274 touches +the Codev repo only, so there is no contention for it. + +## Risks & Alternatives Considered + +- **Risk: the reconciler mints projects for junk paths.** `known_workspaces` today holds + `/Users/chris/dev` (a parent directory), several deleted checkouts, and `.builders/` worktrees. + Mitigation: the three-way filter in B.1 (no `.builders/`, must exist, must have `.codev/`), tested + against a fixture list drawn from the real table. +- **Risk: renaming fights a human.** A sweep that enforces a computed title would undo any rename + done in the t3code UI, every 30s, silently. Mitigation: repair only the exact legacy + `codev:` string. Consequence, stated rather than hidden: a project created after + this ships and later made ambiguous by a *new* same-basename workspace does not get retro-renamed. +- **Risk: 30 sockets.** Rejected the obvious implementation (call `ensureThreadBackendReady` per + known workspace, which already creates the project) precisely because it holds one live engine per + workspace forever. One connection per server group instead. +- **Risk: empty headings for non-Codev projects.** In codev-hierarchy mode, change C draws a heading + for every known project with no architects, including an upstream t3code project. Gated behind + `hasCodevHierarchy`, so a sidebar with no Codev threads is untouched. Alternative considered and + rejected: a `codevManaged` column on `projection_projects` — a new fork customization on the + persistence path, adding rebase surface to solve a problem this user does not currently have. +- **Risk: the REFRESH tail (D) is long and needs a live fork server.** It is mechanical but it is + not free. If it turns out to be blocked, the fork change cannot ship half-done — a fork commit + without the pin move leaves the suite red — so that would be raised, not worked around. +- **Alternative rejected: derive the display name in the fork's web layer** from `workspaceRoot` + instead of fixing the stored title. It would leave `codev:/Users/...` in the project switcher, in + page titles, and in the database, and it puts a Codev-specific rule in upstream-shaped code. +- **Alternative rejected: create a placeholder thread per workspace** so the existing + architect-driven heading logic finds something. It invents an agent that does not exist. + +## Test Plan + +### Unit (Codev repo, `pnpm -w test`) + +- `displayNameForWorkspace` / the set-wide namer: `/Users/chris/dev/codev-1455` → `codev-1455`; + two `.../api` roots → `backend/api` and `mobile/api`; a root of `/` degrades to something + non-empty rather than throwing. +- The enumerator drops `.builders/` paths, non-existent paths, and paths with no `.codev/`, using a + fixture list taken from the real `known_workspaces` rows. +- The reconciler, against a fake dispatcher + fake shell snapshot: creates only the missing + projects; opens **one** connection for N workspaces sharing a server; renames a project titled + `codev:`; leaves a project titled `My Project` alone; a group whose connect throws does not + stop the other group. +- `thread-backend.ts` create path writes the bare name — asserted against the dispatched + `project.create` payload, not against a helper's return value. +- Each new test is confirmed to fail with the change reverted before it is trusted. + +### Unit (fork, `pnpm --filter web test`) + +- `buildCodevSidebarOrder` emits one `empty-project` entry per known project key with no architect, + none for a key that already started a project, and every input thread still appears exactly once. +- The existing `Sidebar.logic.test.ts` suite stays green. + +### Contract + +- `node tools/t3-server/t3-server.mjs verify` exits `0` after the pin move. +- `packages/codev/src/__tests__/spec-146-t3-contract.test.ts` green with both `T3CODE_ROOT` and + `T3CODE_FORK_ROOT` exported. + +### Manual — this is the `dev-approval` gate + +This repo has no `worktree` block in `.codev/config.json`, so `afx dev` does not apply. The gate is +verified by running the fork: + +1. `node tools/t3-server/t3-server.mjs start-fork` and open the web UI. +2. Confirm the pre-existing project renders as **`codev-1455`**, not + `codev:/Users/chris/dev/codev-1455`. +3. `sqlite3 ~/.t3/dev/state.sqlite "select title, workspace_root from projection_projects;"` — one + row per real Codev workspace, each titled with its directory name, and **no** row for a + `.builders/` path or a deleted checkout. +4. Confirm a workspace with no agent at all appears as a heading with nothing under it. +5. Narrow the sidebar and confirm the headings stay distinguishable — the truncation complaint in + the issue is the reason the name is a name and not a path. + +Level 2 (architects under a workspace) needs **#271 / PR #274** merged. Until then step 4's heading +is the shape that is checkable, and the full `dvarr → architect/main → builder/air-12` render is +not. That is a stated limit of this verification, not a claim that it passed. diff --git a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml new file mode 100644 index 000000000..0c1fd6de0 --- /dev/null +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -0,0 +1,131 @@ +id: '272' +title: the-sidebar-tree-needs-every-w +protocol: pir +phase: verified +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: approved + requested_at: '2026-08-31T16:54:09.121Z' + request: + question: >- + Fixing #272 requires a t3code fork commit (an empty project draws no sidebar heading today), and any fork commit + obliges the full REFRESH.md tail. Take that scope, or narrow it? + choices: + - label: 'Full scope: both repos plus the REFRESH tail' + consequence: >- + Codev gets the workspace name and a Tower reconciler; the fork gets a heading for a project with zero + architects. Then pin.commit moves, the contract is regenerated, patches are re-exported and the four + spec-250 evidence collectors are re-run against a live fork server. Delivers the issue as written. + recommended: true + - label: 'Codev repo only: name the workspaces, defer the empty-heading' + consequence: >- + Project titles become codev-1455 rather than codev:/Users/chris/dev/codev-1455, and a project row exists for + every known workspace. But a workspace with no architect still draws nothing on screen, so half of the issue + stays open and needs a second PR carrying the REFRESH tail anyway. + - label: 'Fork only: heading first, names later' + consequence: >- + Empty workspaces appear, still labelled with prefixed absolute paths. Pays the REFRESH tail now and leaves + the complaint that a sidebar of full paths does not scan. + terminalExcerpt: >- + $ sqlite3 ~/.t3/dev/state.sqlite "select title, workspace_root from projection_projects;" + + codev:/Users/chris/dev/codev-1455|/Users/chris/dev/codev-1455 + + + Sidebar.logic.ts:1072 for (const subtree of hierarchy.architects) { <- headings come from architects; zero + architects, zero headings + approved_at: '2026-08-31T17:19:28.435Z' + approval: + authorization: flag-only + approved_at: '2026-08-31T17:19:28.435Z' + machine: chriss-MacBook-Pro.local + caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) + dev-approval: + status: approved + requested_at: '2026-08-31T18:11:31.881Z' + request: + question: >- + Approve dev-approval for #272? Four builders are blocked until this merges, and the change is verified live in + both repos. + choices: + - label: Approve, go to PR + consequence: >- + Unblocks 273, 278, 260 and 242: main starts naming fork commit 26b4c2dc09f0, so criterion 8b stops failing + on every shared suite run. Codev names each workspace after its directory and reconciles a project per known + workspace; the fork draws a heading for a workspace nobody has spawned into. + recommended: true + - label: Open the running worktree first + consequence: >- + Costs a fork server start and a Vite dev server. The same evidence is already recorded: + tools/t3-fork/issue-272-projection.mjs passed 8 of 8 against a live server, and the sidebar screenshot at + 1440x900 shows the idle-workspace heading. + terminalExcerpt: >- + porch done 272 + build PASS (60.3s) + tests PASS (435.8s) + + issue-272-projection.mjs, against a live fork server: + ok a workspace with no project gets one named after its directory - title=codev-1455 + ok the legacy codev: title is rewritten to the directory name - title=dvarr + ok a title a human chose is left alone - title=Entriq (do not rename) + ok two workspaces sharing a directory name come out distinguishable - backend/api / mobile/api + ok no project for a builder worktree, a deleted checkout, or a non-workspace - 5 rows for 5 workspaces + ok the rename kept the project id rather than creating a second row + ok a second pass writes nothing - created=0 renamed=0 + + spec-250-hierarchy.spec.ts: 10 passed, including the new empty-project test in a browser. + + + Known, NOT mine: spec-250-approval.spec.ts times out intermittently in openThread. A/B with my fixture reverted + to HEAD still failed, at a third different test. Documented, not skipped. + approved_at: '2026-08-31T21:58:51.040Z' + approval: + authorization: flag-only + approved_at: '2026-08-31T21:58:51.040Z' + machine: chriss-MacBook-Pro.local + caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) + pr: + status: approved + requested_at: '2026-08-31T22:10:40.784Z' + request: + question: 'Merge PR #305 for issue #272? Both consultation lanes returned APPROVE and the one substantive finding is fixed.' + choices: + - label: Approve the pr gate + consequence: >- + I merge with --merge (not squash), record it with porch, and the Fixes #272 in the body closes the issue. + Workspaces become named top-level sidebar nodes and a workspace nobody has spawned into finally renders. + recommended: true + - label: Review the diff first + consequence: >- + Nothing is lost; the gate stays pending. The four spots worth your eye are listed under 'Things to Look At' + in the PR body, and the live 8-of-8 evidence is in codev/research/272-workspace-projection-evidence.json. + terminalExcerpt: |- + claude VERDICT: APPROVE (4 non-blocking items) + opencode VERDICT: APPROVE KEY_ISSUES: None + + Finding 1 FIXED, not deferred: isCodevWorkspaceDirectory was injected away by every + test AND by the live harness, so the production filter had no coverage. Testing it + surfaced a real defect - existsSync accepted a .codev that is a FILE. Now requires a + directory; the assertion fails with the guard reverted. 33 tests in that file, was 28. + + Items 2-4 (sweeper interval untested, token re-exchanged every 30s, WARN every 30s on + a down server) are real, documented in the review, and need issues - that call is yours. + approved_at: '2026-08-31T22:12:14.438Z' + approval: + authorization: flag-only + approved_at: '2026-08-31T22:12:14.438Z' + machine: chriss-MacBook-Pro.local + caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-31T16:47:04.075Z' +updated_at: '2026-08-31T22:12:22.730Z' +pr_history: + - phase: review + pr_number: 305 + branch: builder/pir-272 + created_at: '2026-08-31T22:05:41.124Z' +pr_ready_for_human: false diff --git a/codev/research/272-workspace-projection-evidence.json b/codev/research/272-workspace-projection-evidence.json new file mode 100644 index 000000000..b581bbf78 --- /dev/null +++ b/codev/research/272-workspace-projection-evidence.json @@ -0,0 +1,90 @@ +{ + "issue": 272, + "recordedAt": "2026-08-31", + "serverUrl": "http://127.0.0.1:3809", + "forkRoot": "/Users/chris/dev/t3code-codev", + "projects": [ + { + "id": "a3fc2415-2e60-425f-8d44-b7bf5d60a389", + "title": "Entriq (do not rename)", + "workspaceRoot": "/var/folders/vz/jm6y7wsx7jx93kvhrqtk7sbw0000gn/T/issue-272-yY9xnW/entriq" + }, + { + "id": "4cc07826-1cda-4d8a-86e5-928e4507c941", + "title": "backend/api", + "workspaceRoot": "/var/folders/vz/jm6y7wsx7jx93kvhrqtk7sbw0000gn/T/issue-272-yY9xnW/backend/api" + }, + { + "id": "6eb11428-88e8-4776-ad68-e5bba22e8c81", + "title": "codev-1455", + "workspaceRoot": "/var/folders/vz/jm6y7wsx7jx93kvhrqtk7sbw0000gn/T/issue-272-yY9xnW/codev-1455" + }, + { + "id": "03c01a84-fac0-4b3e-9aa2-70f21be250fb", + "title": "dvarr", + "workspaceRoot": "/var/folders/vz/jm6y7wsx7jx93kvhrqtk7sbw0000gn/T/issue-272-yY9xnW/dvarr" + }, + { + "id": "72b6b890-0c8f-477d-be34-a9779d4dd92e", + "title": "mobile/api", + "workspaceRoot": "/var/folders/vz/jm6y7wsx7jx93kvhrqtk7sbw0000gn/T/issue-272-yY9xnW/mobile/api" + } + ], + "firstPass": { + "workspaces": 5, + "servers": 1, + "created": 3, + "renamed": 1, + "failures": [] + }, + "secondPass": { + "workspaces": 5, + "servers": 1, + "created": 0, + "renamed": 0, + "failures": [] + }, + "claims": [ + { + "name": "the sweep reported no failures", + "held": true, + "detail": "none" + }, + { + "name": "a workspace with no project gets one named after its directory", + "held": true, + "detail": "title=codev-1455" + }, + { + "name": "the legacy codev: title is rewritten to the directory name", + "held": true, + "detail": "title=dvarr" + }, + { + "name": "a title a human chose is left alone", + "held": true, + "detail": "title=Entriq (do not rename)" + }, + { + "name": "two workspaces sharing a directory name come out distinguishable", + "held": true, + "detail": "backend/api / mobile/api" + }, + { + "name": "no project is created for a builder worktree, a deleted checkout, or a non-workspace", + "held": true, + "detail": "5 rows for 5 workspaces" + }, + { + "name": "the rename kept the project id rather than creating a second row", + "held": true, + "detail": "1 row(s) for that root" + }, + { + "name": "a second pass writes nothing", + "held": true, + "detail": "created=0 renamed=0 failures=0" + } + ], + "passed": true +} diff --git a/codev/resources/arch.md b/codev/resources/arch.md index aea1b848f..74b33e4fb 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -110,6 +110,16 @@ tail -f ~/.agent-farm/tower.log 9. **Tower API Authentication**: Tower's local HTTP + WebSocket API enforces request authentication (advisory GHSA-xvjp-7748-v88v). Every route outside the narrow public-route allowlist (`isPublicRoute` in `agent-farm/utils/server-utils.ts`) requires the shared local key (`~/.agent-farm/local-key`), sent as the `codev-tower-key` HTTP header or a `Sec-WebSocket-Protocol` subprotocol, and fails closed with 401 (the server also accepts the legacy `codev-web-key` header for one release). Any new Tower route must decide public-vs-keyed — a wrong allowlist entry either breaks a pre-auth path (health/version probes, the served HTML shells + static assets) or exposes a data route. The key is delivered to browser shells via same-origin serve-time injection; those shell responses omit `Access-Control-Allow-Origin` so the injected key is not cross-origin readable. +10. **The toolchain's Node, not your shell's**: `porch` and `afx` run under nvm **Node 20** + (`~/.nvm/versions/node/v20.19.2`), not whatever a shell happens to be on. Every native module in + a worktree must match that ABI (`NODE_MODULE_VERSION` 115). This matters because + `better-sqlite3` publishes **no Node-20 prebuild for darwin/arm64**: an install run under a + newer Node succeeds by *downloading* a mismatched binary, and only a from-source + `node-gyp rebuild --release` under Node 20 produces a loadable one. `pnpm install`, + `pnpm rebuild` and `npm_config_build_from_source` are all no-ops once a build output exists — + check the binary's mtime to tell a real rebuild from a silent one. The four native packages are + listed in `pnpm-workspace.yaml`'s `onlyBuiltDependencies`. + ## Agent Farm Internals This section provides comprehensive documentation of how the Agent Farm (`afx`) system works internally. Agent Farm is the most complex component of Codev, enabling parallel AI-assisted development through the architect-builder pattern. @@ -2140,6 +2150,31 @@ consult -m claude spec 42 - **Other AI coding assistants**: Via AGENTS.md standard - **Consult CLI**: For multi-agent consultation (installed with @cluesmith/codev) +### Codev workspaces are projected into t3code as projects (#272) + +t3code's model is Project → Thread; Codev's unit is a workspace. The mapping is one project per +workspace, keyed on `workspaceRoot`, and it is maintained from two places: + +- **The connect path.** `initialiseThreadBackend` creates the project when a workspace's + thread backend first connects (`agent-farm/thread-backend.ts`). It knows one workspace, so it + titles the project with that directory's **leaf name**. +- **A Tower sweep**, `agent-farm/workspace-projection.ts` + `-sweep.ts`, started after + `markBootComplete()`. It enumerates `getKnownWorkspacePaths()` — which is wider than the + `architect ∪ builders` set the thread-adoption sweeper uses, and is the point: a registered + workspace nobody has spawned into has neither row. It creates the missing projects and repairs + titles. + +Two properties worth knowing before changing it. **The title is the sidebar heading**, verbatim — +a single-member project group's label *is* its title, through `projectGrouping.ts` and +`sidebarProjectGrouping.ts` with no cleanup step between. And the sweep opens **one connection per +server**, not per workspace: reads are plain HTTP and the socket is opened lazily, so a pass with +nothing to do never opens one. Doing it per workspace via `ensureThreadBackendReady` would hold one +live engine socket per known workspace for the life of the process. + +Only two titles are ever rewritten, both machine-written: the legacy `codev:` form +and the workspace's own leaf name. Anything else is somebody's decision and is left alone — a sweep +that enforced a computed title would undo a rename made in the UI, silently, every 30s. + ### Forge Concept Commands (Spec 589) All interactions with the repository hosting platform (GitHub by default) are routed through **forge concept commands** — configurable external processes that produce JSON on stdout. This abstraction enables non-GitHub repository support. diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index df54ea9aa..1d230f44e 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -752,6 +752,26 @@ so it survives review. Pin the constant to the highest migration block in a test ## Debugging and Root Cause Analysis +- [From #272] **A green in your own shell is not a green in the harness — verify under the runtime + the harness runs, not the one your shell has.** A `dev-approval` gate failed nine times over four + hours while every run the builder drove passed. Same tree, same cwd, same env: the builder's shell + was on Homebrew Node 26 and `porch` runs nvm Node 20, and one native binary compiled for the wrong + ABI produced **729 failures across 49 files**. Two general habits fall out. First, when a check + fails for one party and passes for another, stop diffing the *inputs* and diff the *loader* — + capture the live process ancestry (`ps -o ppid=` up the chain, `lsof -a -p -d cwd`) rather + than trusting either party's belief about where and how it runs; that single capture overturned + two confident, mutually-agreed diagnoses in a row. Second, when hundreds of tests across dozens of + unrelated files fail at once, the cause is one shared dependency, not hundreds of defects — group + the error text before reading any individual failure. + +- [From #272] **A repair that produced no output repaired nothing — check the artifact's + mtime, not the command's exit code.** `pnpm install --frozen-lockfile`, `pnpm rebuild ` and + `npm_config_build_from_source=true pnpm rebuild ` each exited 0, printed nothing, and left a + native binary untouched, because a satisfied lockfile plus an existing build output means the + install script is never re-run. Three "successful" repairs in a row while the file's timestamp sat + two months in the past. Exit 0 from a build tool means "I had nothing to do" as often as it means + "I did it". + - [Demoted from the hot tier, #250] **When stuck (2 failed hypotheses or ~30 min), get an outside model's perspective and build a minimal repro — captured raw data beats guessing.** Still true; demoted rather than deleted when the hot tier's slot was needed for "a test that cannot fail is diff --git a/codev/reviews/272-the-sidebar-tree-needs-every-w.md b/codev/reviews/272-the-sidebar-tree-needs-every-w.md new file mode 100644 index 000000000..afcbd0f23 --- /dev/null +++ b/codev/reviews/272-the-sidebar-tree-needs-every-w.md @@ -0,0 +1,187 @@ +# PIR Review: Every workspace is a named top-level node in the sidebar tree + +Fixes #272 + +## Summary + +The sidebar tree showed one project per path string — titled `codev:/Users/chris/dev/codev-1455` +— and only for workspaces something had already been spawned into. This names each project after +its own directory and adds a Tower sweep that reconciles a project row for every workspace Codev +knows about, so a workspace nobody has started work in appears as a heading with nothing under it +rather than not appearing at all. The fork change is what makes that heading render: the tree's +project level was derived entirely from architects, so a project with none drew nothing. + +## Files Changed + +- `packages/codev/src/agent-farm/workspace-projection.ts` (+382 / -0) — new; the decision +- `packages/codev/src/agent-farm/workspace-projection-sweep.ts` (+144 / -0) — new; the wiring +- `packages/codev/src/agent-farm/thread-backend.ts` (+151 / -12) — leaf-name title, `readProjectRows`, `openProjectGateway` +- `packages/codev/src/agent-farm/servers/tower-server.ts` (+37 / -0) — sweep lifecycle +- `packages/porch-driver/src/thread.ts` (+25 / -0) — `updateProjectMeta` +- `packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts` (+411 / -0) — new +- `packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts` (+163 / -0) — new +- `packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts` (+43 / -0) — seeds a project with no threads +- `packages/codev/src/__tests__/e2e/spec-250-hierarchy.spec.ts` (+53 / -0) — the empty-heading test +- `tools/t3-fork/issue-272-projection.mjs` (+297 / -0) — new; live verification against a fork server +- `codev/research/272-workspace-projection-evidence.json` (+90 / -0) — its recorded output +- `codev/plans/272-the-sidebar-tree-needs-every-w.md` (+251 / -0) +- `codev/state/pir-272_thread.md` (+248 / -0) + +**In the fork** (`pseudoseed/t3code@codev`), commit `26b4c2dc0`: `Sidebar.logic.ts`, +`Sidebar.logic.test.ts`, `Sidebar.tsx`. Readable in `tools/t3-fork/patches/0035-*.patch` without +access to the private repository. + +The pin move and regenerated contract that commit obliged shipped separately as **PR #291**, because +four builders were blocked on `criterion 8b` while `pin.json` named a fork commit that was no longer +checked out. That is why they are not in this diff. + +## Commits + +- `59020b24d` [PIR #272] feat: every known workspace is a project row, named after its directory +- `d07b066b1` [PIR #272] test: the rules without a server, and the wire with one +- `3c25fe8b5` [PIR #272] chore: move pin.commit, regenerate, and re-run the evidence +- `cb513dfd4` [PIR #272] test: the browser draws the empty heading, and the doc path stops shipping a username +- `8074a0317` [PIR #272] docs: the ABI mismatch that failed the gate 8 times + +## Test Results + +- `npm run build`: ✓ pass +- `npm test`: ✓ pass — 7499 passed / 58 skipped (388 files), plus 180 in `codev-v2`. 35 new. +- `porch check 272` under **Node 20**, the runtime porch actually uses: ✓ build 17.6s, ✓ tests 227.1s +- Fork unit tests: `Sidebar.logic.test.ts` **125 passed** (8 new) +- Fork e2e: `spec-250-hierarchy.spec.ts` **10 passed**, including the empty-workspace heading + rendered in a browser +- Live, against a running fork server (`tools/t3-fork/issue-272-projection.mjs`): **8 of 8 claims** + — `codev-1455` created and named; `codev:` rewritten in place keeping its project id; + `Entriq (do not rename)` untouched; `backend/api` and `mobile/api` distinguishable; nothing minted + for a builder worktree, a deleted checkout, or a non-workspace; a second pass writes nothing +- Every new assertion was confirmed to fail with its change reverted + +Manual verification at the `dev-approval` gate: build 17.6s, tests 216.7s, both green. + +## Architecture Updates + +Two facts routed to the **COLD** tier; neither displaced a hot entry. + +- `codev/resources/arch.md` § Integration Points — a new subsection on the workspace→project + projection: the two writers (connect path and sweep), why the sweep enumerates + `getKnownWorkspacePaths()` rather than `architect ∪ builders`, that the project title **is** the + sidebar heading verbatim, that it opens one connection per *server*, and which two titles it will + rewrite. +- `codev/resources/arch.md` § Invariants & Constraints, new #10 — porch and afx run nvm **Node 20**, + so every native module in a worktree must match ABI 115; `better-sqlite3` ships no Node-20 + prebuild for darwin/arm64, so only a from-source rebuild produces a loadable one. + +Nothing was promoted to `arch-critical.md`: it is at its 10-fact cap, the hot tier already carries +the t3code-fork fact this builds on, and neither addition is worth displacing an existing entry. +The Node/ABI fact is repo-specific rather than a cross-cutting decision rule, which the +`update-arch-docs` skill routes to cold. + +## Lessons Learned Updates + +Two entries added to `codev/resources/lessons-learned.md` § Debugging and Root Cause Analysis, +both COLD: + +- **A green in your own shell is not a green in the harness.** When a check fails for one party and + passes for another, stop diffing the inputs and diff the *loader* — capture live process ancestry + rather than trusting either party's belief about how it runs. And when hundreds of tests across + dozens of unrelated files fail at once, the cause is one shared dependency, not hundreds of + defects. +- **A repair that produced no output repaired nothing — check the artifact's mtime, not the exit + code.** Three build commands exited 0, printed nothing, and left the binary untouched. + +Nothing promoted to `lessons-critical.md`, for the same cap-and-displacement reason. + +## Things to Look At During PR Review + +- **`isMachineWrittenTitle` is the safety boundary of a sweep that runs every 30s.** It permits + exactly two strings — the legacy `codev:` form and the workspace's leaf name. If it were + loosened, the sweep would start overwriting titles humans chose. The leaf-name case is deliberate + and is what lets a project created by a spawn converge on a set-unique name. +- **`CodevSidebarEntry` gained a case that carries no thread.** Six call sites did `entry.thread`, + including `orderedActiveThreads`, which is what shift-range-select and jump-hint labels are + assigned from — `undefined` there is a crash one row away. `codevOrderedThreads` / + `codevEntryThread` are the answer. An optional `thread?: T` was the alternative and is worse: + `entry.thread.id` would keep compiling and fail at runtime on the one kind that has no thread. +- **`projectGroups`, not `projectKeys`.** The sidebar's list is *logical* projects while + `projectKeyOf` returns a physical `environmentId:projectId`. A flat key list draws a second empty + heading beside a group whose other member holds the architects, same name on both. +- **`readProjectRows` was split out of `activeProjectForWorkspace` rather than copied.** The old + reader dropped `title`, so a reconciler could tell a project existed and never what it was called. + A second copy of that request would also be a second place for the transport rules to drift — + which is how that call skipped `assertTransportSafe` once already. + +- **The fork's committed screenshots are now one case out of date, deliberately.** + `docs/codev/spec-250/phase-7/*.png` in the fork depict the tree before this change and do not + show the empty-workspace heading. Re-shooting them means another fork commit, which obliges the + whole `REFRESH.md` tail again — a second pin move, regeneration, patch re-export and four + evidence re-runs — for pictures. They still accurately depict what spec 250 delivered. The + current render is in this branch at + `packages/codev/test-results/spec-250-screenshots/phase-7/` (untracked), and + `spec-250-hierarchy.spec.ts` asserts the heading's presence, position and `data-codev-project-empty` + attribute, so the behaviour is pinned by a test rather than by an image. + +### Consultation findings and what I did with them + +Both reviewers returned **APPROVE** (claude, opencode — this repo runs a 2-way pass per +`.codev/config.json`, not the 3-way the protocol prompt describes). No `REQUEST_CHANGES`. +Claude raised four non-blocking items: + +1. **`isCodevWorkspaceDirectory` was substituted by every test and by the live harness — FIXED.** + The unit tests inject `isCodevWorkspace` and `issue-272-projection.mjs:211` injects its own + predicate, so the filter that actually decides "deleted checkout" and "not a workspace" in Tower + was never executed by anything claiming to cover it. That is exactly the repo's own hot-tier + lesson about a test supplying the boundary it means to check. Five tests added against a real + filesystem — and writing them surfaced a second, real defect: `existsSync(join(path, '.codev'))` + accepted a `.codev` that is a **file**. `codev init` creates a directory; the check now requires + one. Confirmed to fail with the guard reverted. +2. **`createWorkspaceProjectionSweeper` has no test, and `options.deps` exists for tests not + written — acknowledged, not fixed here.** The sweeper is interval plumbing over + `reconcileWorkspaceProjects`, which is covered; the untested part is `start`/`stop`/overlap + behaviour. Worth a follow-up rather than widening this PR. +3. **The sweep re-exchanges the bootstrap token every 30s per server — real, and a behaviour + change worth knowing.** A pairing-issued one-time token is now spent on the first tick, before + anyone spawns. The constraint is pre-documented on `ThreadBackendConfig.bootstrapToken`, but this + makes an unbounded desktop seed effectively mandatory for any workspace carrying a `threads` + config. Not changed here because caching a credential across processes is a storage decision. +4. **A down server logs a WARN every 30s, ~2,880 lines/day — real.** Logging on state change is the + fix; deferred as a follow-up rather than folded in. + +Items 2-4 need issues; I did not open them because that call is the architect's. + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-272` → **Review Diff** +- **Run the fork stack**: `node tools/t3-server/t3-server.mjs start-fork`, then from the fork's + `apps/web`: `T3CODE_SINGLE_ORIGIN_DEV=1 T3CODE_PORT=3811 PORT=5733 npx vp dev` + +What to verify: + +- A project renders as `codev-1455`, not `codev:/Users/chris/dev/codev-1455` +- `sqlite3 "select title, workspace_root from projection_projects;"` — one row per real Codev + workspace, none for a `.builders/` path or a deleted checkout +- A workspace with no agent appears as a heading with nothing under it +- At a narrow width the headings stay distinguishable — the truncation complaint in the issue is + why the name is a name and not a path + +Reproduce the live check directly: + +``` +export T3_NODE=$HOME/.nvm/versions/node/v20.19.2/bin/node +export T3CODE_FORK_ROOT=/Users/chris/dev/t3code-codev T3_HARNESS_PORT=3809 +node tools/t3-fork/issue-272-projection.mjs +``` + +**Run anything in this worktree with nvm Node 20 first in `PATH`.** A green under a newer Node says +nothing about what porch will do — see the thread log. + +## Flaky Tests + +None skipped. One documented, not skipped: + +- `packages/codev/src/__tests__/e2e/spec-250-approval.spec.ts` — intermittent 30s timeout in the + `openThread` helper waiting for a `sidebar-row-card`. Across five runs it failed at **four + different tests** (lines 180, 213, 261, 305) and passed 6/6 once. Confirmed **not** caused by this + change: an A/B with this branch's fixture edits reverted to HEAD still failed, at a third + different test. Left enabled rather than skipped — skipping would remove coverage of the approval + path to hide a timing artifact this PR did not introduce. diff --git a/codev/state/pir-272_thread.md b/codev/state/pir-272_thread.md new file mode 100644 index 000000000..73def088f --- /dev/null +++ b/codev/state/pir-272_thread.md @@ -0,0 +1,248 @@ +# pir-272 — thread + +## 2026-08-31 — plan phase + +Issue #272: the sidebar tree needs every workspace as a named top-level node. + +### What I verified rather than assumed + +Read the live projection database (`~/.t3/dev/state.sqlite`). One project row, +titled `codev:/Users/chris/dev/codev-1455`. One thread, `codev_role` empty. +That second fact is #271, not mine — PR #274 is open on it. + +Traced the title from write to pixel and it is a straight line with no cleanup +step in it: + +- codev repo, `thread-backend.ts:913` writes `codev:${workspaceRoot}` +- fork, `projectGrouping.ts:323` — a single-member group's label IS the title +- fork, `sidebarProjectGrouping.ts:105` — displayName = label +- fork, `Sidebar.tsx:4015` — the heading renders displayName + +So half of #272 is a one-line write in this repo. No fork change for the title. + +### The part that surprised me + +The project is NOT created on first thread creation, as the issue says. It is +created by `initialiseThreadBackend` when `ensureThreadBackendReady` runs for +that specific workspace (`thread-backend.ts:901-940`). The practical symptom is +the same; the fix location is not. + +And Tower's existing sweep (`tower-server.ts:825`) enumerates workspaces from +`architect ∪ builders`, not from `known_workspaces`. `getKnownWorkspacePaths()` +(`tower-instances.ts:212`) is the wider list. + +### The part that makes this two repos + +`buildCodevSidebarOrder` (`Sidebar.logic.ts:1054`) derives project headings from +ARCHITECTS — it walks `hierarchy.architects` and sets `startsProject` on the +first subtree per project. A project with zero architects contributes zero +entries, so creating the project row alone would change nothing on screen. Level 1 +of the tree cannot be fixed from the Codev repo. + +### Cost I did not expect to be in scope + +`pin.contractSource` is `fork`, so any fork commit puts HEAD ahead of +`pin.commit` and `t3-server.mjs verify` exits 1. REFRESH.md steps 3-8 (pin move, +regenerate, re-export patches, re-run four evidence collectors) are therefore +part of this work, not a follow-up. + +### Design calls made in the plan + +- Name = shortest unique trailing path segments across the known set, not bare + basename. Two `.../api` workspaces must not render as two identical rows. +- One connection per SERVER, not per workspace. The obvious implementation — + call `ensureThreadBackendReady` per known workspace, which already creates the + project — holds one live engine socket per workspace forever, ~30 against one + server. +- Rename only the exact legacy `codev:` string. A sweep that + enforces a computed title would undo a human's rename every 30s, silently. +- Filter the enumeration three ways: no `.builders/`, must exist, must have + `.codev/`. The real `known_workspaces` table holds `/Users/chris/dev` and + several deleted checkouts. + +### Coordination + +Fork checkout is clean at `2f64a1b0e`, one worktree, no other writer. PR #274 +(air-271) touches the Codev repo only — `workspace-add-architect.ts`, +`status.ts`, four tests — none of which I touch. No contention either way. + +Plan committed, `plan-approval` gate pending. + +## 2026-08-31 — implement phase + +Plan approved at full scope. Both repos plus the REFRESH tail. + +### Codev repo + +`workspace-projection.ts` is the decision, `workspace-projection-sweep.ts` is the +wiring, and the split is deliberate: the first imports nothing heavier than the +workspace key helper, so every rule in it is testable without a database, a +filesystem or a server. + +Three things I changed my mind about while writing it: + +1. **The issue's diagnosis is slightly off, and it matters for where the fix + goes.** The project is not created on first thread creation — it is created by + `initialiseThreadBackend`, the thread-backend connect, and only for the + workspace being connected. Same symptom, different location. +2. **`isLegacyCodevTitle` became `isMachineWrittenTitle`.** The connect path + writes the LEAF name; it knows one workspace and cannot see a collision with + another. If the sweep only rewrote the `codev:` form, a project created by a + spawn would sit on an ambiguous `api` forever while its sibling also read + `api`. So a leaf name is recognised as machine-written too, and deepening it to + `backend/api` is a refinement of the same name rather than the loss of somebody + else's choice. +3. **`readProjectRows` had to be split out of `activeProjectForWorkspace`.** The + old reader kept `id` and `workspaceRoot` and dropped `title` — a reconciler + reading that list can tell a project exists and can never tell what it is + called, so every legacy title would have survived every sweep in silence. + +### Fork + +`CodevSidebarEntry` gained an `empty-project` case, and that is what made the +change bigger than it looks: six existing call sites did `entry.thread`, including +`orderedActiveThreads`, which is the list shift-range-select and the jump-hint +labels are assigned from. `undefined` in that list is a crash one row away rather +than a wrong row. `codevOrderedThreads` / `codevEntryThread` are the answer, +exported rather than inlined. An optional `thread?: T` field was the alternative +and is worse: `entry.thread.id` would keep compiling and fail at runtime on the +one kind that has no thread. + +The option is `projectGroups`, not `projectKeys`. The sidebar's project list is +LOGICAL projects while `projectKeyOf` returns the physical +`environmentId:projectId` — a flat key list draws a second empty heading beside a +group whose other member holds the architects, same name on both. + +### REFRESH tail + +- `pin.commit` → `26b4c2dc09f0fe2f6798e9b781df6722603b5bfe`, regenerated. Output is + byte-identical apart from the sha, which is what a Sidebar-only commit should + produce — checked rather than assumed. 0 unrepresented. +- Patches re-exported: 35 now, one more than before. +- Evidence re-run: criterion 8b passed, hierarchy wire 8/8, rebase drill (still 3 + conflicting files, the same 3), upstream movement, `collect --check` exit 0. + +### Notes for whoever runs this next + +- **Ports.** `3799` is held by the MAIN workspace's harness server, started by + another session. `T3_HARNESS_PORT=3809` is what I used; the harness refuses to + kill what it cannot prove it owns, and that is correct. +- **`T3_NODE`** must point at a Node 22 binary — + `~/.nvm/versions/node/v22.22.2/bin/node`. Without it every server-starting tool + exits 3 with `NO_INTERPRETER`, which reads like a failure and is not one. +- **The parked file.** `/Users/chris/dev/t3code-codev/tools/lan-serve.mjs` is + untracked and `verify` refuses a dirty checkout. Same as air-271: parked to the + scratchpad with its sha256 recorded, restored byte-identical afterwards. Do not + delete it — it is what the iPad reaches the app through. +- **Tower runs the globally installed package.** The live `~/.t3/dev` projection + will not change from this branch until `pnpm -w run local-install` and a Tower + restart, and a Tower restart kills every builder session. So the gate evidence + is a live run against the harness fork server + (`tools/t3-fork/issue-272-projection.mjs`), not a Tower restart. + +### The shared suite lock + +`vitest-global-setup.ts` holds a **loopback port** (13999) as the suite mutex, so it is +shared across every builder worktree on this machine, not just this one. While I was +running, `.builders/bugfix-273` held it for ~12 minutes and my `npm test` sat waiting +with the message "Another Vitest run owns shared Tower state". The wait budget is 900 s, +so a run survives that; it does not survive two of them. + +Do not kill the lock holder. It belongs to another builder. + +### `spec-250-approval.spec.ts` is intermittently red, and it is not this change + +`openThread` waits 5 s for a `sidebar-row-card`, clicks the sidebar toggle, then waits +30 s. Across five runs it timed out at FOUR DIFFERENT tests (180, 213, 261, 305) and +passed 6 of 6 once. A deterministic break fails the same test every time. + +My first guess was machine load from the concurrent sibling suite. **That was wrong** — +it failed again on a quiet box. So I ran a controlled A/B: reverted my +`spec-250-fork-stack.ts` and `spec-250-hierarchy.spec.ts` edits to HEAD, changed nothing +else, re-ran. **It still failed**, at a third different test. My fixture edit is not the +cause. + +Change C is not plausibly the cause either: every one of these failures is `openThread` +waiting for a row, and `spec-250-hierarchy.spec.ts` — which renders far more rows through +the same sidebar — is 10 of 10 green, including the new empty-project test. + +NOT skipped. Skipping would remove coverage of the approval path to hide a timing artifact +I did not introduce; it is documented under Flaky Tests in the review instead. + +### The parked fork file: resolved, not restored + +The architect had already copied `tools/lan-serve.mjs` out to +`/Users/chris/dev/lan-serve/lan-serve.mjs` before I parked it, and the script defaults its +dist path absolutely, so it runs from outside the repo. I compared: sha1 +`3e1743325d5ca1a4b0a10398fdae80d3d342297a`, 5972 bytes, `cmp` identical. **Nothing was +restored into the fork**, so the checkout stays clean permanently rather than returning to +`DIRTY_FORK_CHECKOUT` for every builder. Filed as #278. + +## 2026-08-31 — the dev-approval gate failed 8 times, and none of it was the diff + +Recorded because the next builder in this repo will hit it and the failure names +nothing useful. + +**Symptom.** `porch approve 272 dev-approval` failed 8 times at 202-240s. porch +truncates a failed check's output to 5 lines ending in `...`, so the assertion was +never printed. Every hypothesis below was wrong, and each took a ~4 minute run to +disprove. + +Ruled out, in order: lock contention with sibling builders; the `apps/streamdeck` +husk (real, moved aside, not the cause); `pr-create` env vars (stderr from a +PASSING test, not a check); `PROJECT_ID`/`PROJECT_TITLE`; `CODEV_ARCHITECT_NAME` +(refuted by reading `vitest-global-setup.ts:30` — `scrubCodevNamespace` deletes +every `CODEV_*` but four opt-ins, so it cannot reach a test). + +**Actual cause: a native ABI mismatch, invisible until you look.** + +`better_sqlite3.node` in this worktree was ABI 147 (node 26). porch and afx run +node **20.19.2**, which needs ABI 115. 729 tests across 49 files failed, all of +them that one binary. + +It passed for me and failed for the architect because **my shell is Homebrew node +26 and porch runs nvm node 20** — same tree, same cwd, same env, different loader. +Every green I reported for hours was true under node 26 and meaningless under 20. + +**Why the obvious repairs did nothing.** `pnpm install --frozen-lockfile` (666ms), +`pnpm rebuild better-sqlite3`, and `npm_config_build_from_source=true pnpm rebuild` +were all silent no-ops — pnpm saw a satisfied lockfile and an existing build +output, so it never re-ran the install script. The binary's mtime stayed Jul 24 +through all three, which is how I caught it. + +**And there is no node-20 prebuild for this platform:** + + prebuild-install warn install No prebuilt binaries found + (target=20.19.2 runtime=node arch=arm64 libc= platform=darwin) + +better-sqlite3's install script is `prebuild-install || node-gyp rebuild --release`. +Under node 26 the download SUCCEEDS and yields ABI 147. Under node 20 it finds +nothing and falls through to a compile. So whichever node provisions the worktree +decides which you get, and only the compile produces ABI 115 here. + +**The repair, if you hit this:** + + export PATH="$HOME/.nvm/versions/node/v20.19.2/bin:$PATH" + cd node_modules/.pnpm/better-sqlite3@/node_modules/better-sqlite3 + npx --yes node-gyp rebuild --release + +Then `porch check 272` with node 20 first in PATH — not your shell's node. That is +the only verdict that predicts what porch will do. + +**Verify your work under the loader porch uses, not the one your shell has.** A +green from `npm test` in your own shell is not evidence about the gate. + +### Still open, deliberately not fixed here + +Three other native packages are declared in `onlyBuiltDependencies` — `esbuild`, +`node-pty`, `protobufjs` — and they were provisioned the same way. `better-sqlite3` +is only the one loaded on nearly every path, so it is the one that announced +itself. The suite passing means nothing currently exercised hits the others. +Filed as #304 rather than widened into this project. + +### Origin + +The worktree arrived with NO `node_modules` — the first `pnpm -w run build` failed +on `tsdown: command not found` at 11:37. I repaired it with `pnpm install` under +the node my shell had. The spawn producing an uninstalled worktree is the root +event; the wrong-node install was the consequence. diff --git a/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts b/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts index 2c447457e..a5d02ca35 100644 --- a/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts +++ b/packages/codev/src/__tests__/e2e/spec-250-fork-stack.ts @@ -30,8 +30,9 @@ */ import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; /** Scopes Codev asks for, plus the one that mints a browser's pairing credential. */ const SEED_SCOPES = [ @@ -92,6 +93,15 @@ export type ForkStack = ForkStackReady | ForkStackUnavailable; export interface SeededHierarchy { readonly projectId: string; readonly projectTitle: string; + /** + * A project with no threads at all (issue #272). + * + * The tree draws its heading from the project list rather than from an + * architect, which is the whole of change C: a workspace nobody has spawned + * into must not render the same as a workspace that does not exist. + */ + readonly idleProjectId: string; + readonly idleProjectTitle: string; /** * Phase 8. Two gated builders, because the panel has THREE states and only * one of them is "no gate": a builder carrying #128's structured request, and @@ -444,6 +454,33 @@ export async function seedHierarchy(stack: ForkStackReady): Promise): Promise => { await client.call("orchestration.dispatchCommand", { type: "thread.create", @@ -585,6 +622,8 @@ export async function seedHierarchy(stack: ForkStackReady): Promise { + const fixture = seeded; + if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); + await openSidebar(page); + + const idle = page + .getByTestId("sidebar-codev-project-heading") + .filter({ hasText: fixture.idleProjectTitle }); + await expect(idle).toHaveCount(1); + // Marked as empty, so this assertion cannot be satisfied by the populated + // project's heading drifting into the filter. + await expect(idle).toHaveAttribute("data-codev-project-empty", "true"); + + // Below the populated project's run, and with no rows of its own between it + // and the bottom of the tree. An empty heading that swallowed the next + // project's rows would look like a populated one. + const populated = page + .getByTestId("sidebar-codev-project-heading") + .filter({ hasText: fixture.projectTitle }); + const idleBox = await idle.boundingBox(); + const populatedBox = await populated.boundingBox(); + expect(idleBox).not.toBeNull(); + expect(populatedBox).not.toBeNull(); + expect(populatedBox!.y).toBeLessThan(idleBox!.y); + + // Every architect subtree sits above it, which is what "after the architect + // run" means once it is pixels rather than an array index. + for (const box of await page.getByTestId("sidebar-codev-architect").all()) { + const architectBox = await box.boundingBox(); + expect(architectBox).not.toBeNull(); + expect(architectBox!.y).toBeLessThan(idleBox!.y); + } +}); + test("the architect row says it is an architect", async ({ page }) => { const fixture = seeded; if (fixture === null) throw new Error("unreachable: seeded in beforeAll"); diff --git a/packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts b/packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts new file mode 100644 index 000000000..cb618b4d0 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts @@ -0,0 +1,163 @@ +/** + * Issue #272 — the project title is a name, and the wire carries it. + * + * Three seams, and none of them is the function that computes the name (that is + * covered in `issue-272-workspace-projection.test.ts`): + * + * the READ `readProjectRows` against a real HTTP server, because what is under + * test is what a shell snapshot actually looks like — including the + * `title` field the reconciler compares against, which the previous + * reader dropped on the floor. + * the WRITE `project.meta.update` as it goes onto the wire, dispatched through a + * fake transport. A rename that sends the wrong command type or fills + * in fields nobody asked to change would pass every test above it. + * the WIRING a source guard on the one call site that cannot be driven without a + * live server, in the pattern `issue-227-thread-seams.test.ts` uses for + * the same reason. The behavioural proof of that line is the live run + * at the dev-approval gate; this is the durable half. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve as resolvePath } from 'node:path'; +import { DispatchJournal } from '../../../../porch-driver/src/commands.js'; +import { createProject, updateProjectMeta } from '../../../../porch-driver/src/thread.js'; +import { readProjectRows } from '../thread-backend.js'; + +const source = (rel: string): string => + readFileSync(resolvePath(import.meta.dirname, rel), 'utf-8'); + +let server: Server | undefined; +const dirs: string[] = []; + +afterEach(async () => { + if (server) await new Promise((res) => server!.close(() => res())); + server = undefined; + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +async function serveShell(body: unknown): Promise { + server = createServer((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); + }); + await new Promise((res) => server!.listen(0, '127.0.0.1', () => res())); + const address = server!.address() as { port: number }; + return `http://127.0.0.1:${address.port}`; +} + +function journal(): DispatchJournal { + const dir = mkdtempSync(join(tmpdir(), 'issue-272-')); + dirs.push(dir); + return new DispatchJournal(join(dir, 'commands.jsonl')); +} + +describe('readProjectRows', () => { + it('carries the title, which is the field the reconciler compares', () => { + // The reader this replaced kept only `id` and `workspaceRoot`. A reconciler + // reading that list can tell a project exists and can never tell what it is + // called — so every legacy title would have survived every sweep, silently. + return serveShell({ + projects: [ + { id: 'p-1', title: 'codev:/w/alpha', workspaceRoot: '/w/alpha' }, + { id: 'p-2', title: 'beta', workspaceRoot: '/w/beta' }, + ], + threads: [], + }).then(async (base) => { + expect(await readProjectRows(base, 'tok')).toEqual({ + kind: 'ok', + projects: [ + { id: 'p-1', title: 'codev:/w/alpha', workspaceRoot: '/w/alpha' }, + { id: 'p-2', title: 'beta', workspaceRoot: '/w/beta' }, + ], + }); + }); + }); + + it('drops a row with no workspace root rather than giving it an empty one', async () => { + const base = await serveShell({ + projects: [{ id: 'p-1', title: 'nowhere' }, { id: 'p-2', title: 'beta', workspaceRoot: '/w/beta' }], + threads: [], + }); + const read = await readProjectRows(base, 'tok'); + // An empty root would match the next row that also failed to decode, and the + // reconciler would then think a workspace already had a project. + expect(read).toEqual({ kind: 'ok', projects: [{ id: 'p-2', title: 'beta', workspaceRoot: '/w/beta' }] }); + }); + + it('says it could not tell rather than reporting an empty project list', async () => { + server = createServer((_req, res) => { + res.statusCode = 503; + res.end(''); + }); + await new Promise((res) => server!.listen(0, '127.0.0.1', () => res())); + const address = server!.address() as { port: number }; + const read = await readProjectRows(`http://127.0.0.1:${address.port}`, 'tok'); + // `{ kind: 'ok', projects: [] }` here would make the reconciler create a project + // for every workspace it knows, against a server that already has them. + expect(read.kind).toBe('unknown'); + }); +}); + +describe('the project commands on the wire', () => { + it('project.create carries the bare name as the title', async () => { + const sent: Array> = []; + await createProject( + { call: async (_method, payload) => void sent.push(payload as Record) }, + journal(), + { title: 'codev-1455', workspaceRoot: '/Users/chris/dev/codev-1455' }, + ); + expect(sent[0]).toMatchObject({ + type: 'project.create', + title: 'codev-1455', + workspaceRoot: '/Users/chris/dev/codev-1455', + }); + }); + + it('project.meta.update sends the title and nothing else', async () => { + const sent: Array> = []; + await updateProjectMeta( + { call: async (_method, payload) => void sent.push(payload as Record) }, + journal(), + { projectId: 'p-1', title: 'codev-1455' }, + ); + const payload = sent[0]!; + expect(payload).toMatchObject({ type: 'project.meta.update', projectId: 'p-1', title: 'codev-1455' }); + // Absent means "leave unchanged". Filling these in from a snapshot would turn + // every rename into an overwrite of whatever the helper last read. + expect(payload).not.toHaveProperty('workspaceRoot'); + expect(payload).not.toHaveProperty('defaultModelSelection'); + expect(payload).not.toHaveProperty('scripts'); + }); + + it('omits the title entirely when no rename was asked for', async () => { + const sent: Array> = []; + await updateProjectMeta( + { call: async (_method, payload) => void sent.push(payload as Record) }, + journal(), + { projectId: 'p-1' }, + ); + // A `title: undefined` on the payload is not the same as an absent one: the + // command schema requires a non-empty string when the field is present. + expect(sent[0]).not.toHaveProperty('title'); + }); +}); + +describe('the connect path names the project after its directory', () => { + /** + * A source guard, for the reason `issue-227-thread-seams.test.ts` gives: this call + * site sits inside `initialiseThreadBackend`, past a token exchange, a WebSocket + * ticket and an upgrade, and cannot be reached without a live server. The live run + * at the dev-approval gate is the behavioural proof; what a reader can check here + * is that the line still says what it said. + */ + it('passes the leaf name, not the prefixed absolute path', () => { + const src = source('../thread-backend.ts'); + expect(src).toContain('title: workspaceLeafName(config.workspaceRoot)'); + // The exact string this issue is about. It renders verbatim as the sidebar + // heading, because a single-member project group's label IS its title. + expect(src).not.toContain('title: `codev:${config.workspaceRoot}`'); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts b/packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts new file mode 100644 index 000000000..c6034f4ce --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts @@ -0,0 +1,475 @@ +/** + * Issue #272 — every workspace is a named project row. + * + * The rules live in `workspace-projection.ts` and nothing here reaches a server, a + * database or (except where the filter is the subject) a filesystem. What is being + * tested is the DECISION: which paths are workspaces, what they are called, which + * titles may be rewritten, and what a sweep does when one server is down. + * + * The fixture paths in the enumeration tests are taken from the real + * `known_workspaces` table on 2026-08-31 — a parent directory, several deleted + * checkouts and three `.builders/` worktrees. Every filter below exists because that + * table contains a row it would otherwise let through. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isCodevWorkspaceDirectory } from '../workspace-projection-sweep.js'; +import { + codevWorkspaceRoots, + isMachineWrittenTitle, + planWorkspaceProjects, + reconcileWorkspaceProjects, + workspaceDisplayNames, + workspaceLeafName, + type ProjectRow, + type WorkspaceProjectGateway, + type WorkspaceProjectServer, +} from '../workspace-projection.js'; + +describe('workspaceLeafName', () => { + it('names a workspace after its own directory', () => { + expect(workspaceLeafName('/Users/chris/dev/codev-1455')).toBe('codev-1455'); + }); + + it('ignores a trailing slash rather than producing an empty name', () => { + expect(workspaceLeafName('/Users/chris/dev/dvarr/')).toBe('dvarr'); + }); + + it('answers with something non-empty for a root with no segments', () => { + // `project.create` requires a non-empty title. A blank one would be refused by + // the server, which reports the failure somewhere far from the cause. + expect(workspaceLeafName('/')).not.toBe(''); + }); +}); + +describe('workspaceDisplayNames', () => { + it('uses the bare directory name when it is unambiguous', () => { + const names = workspaceDisplayNames([ + '/Users/chris/dev/codev-1455', + '/Users/chris/dev/dvarr', + '/Users/chris/dev/entriq', + ]); + expect(names.get('/Users/chris/dev/codev-1455')).toBe('codev-1455'); + expect(names.get('/Users/chris/dev/dvarr')).toBe('dvarr'); + expect(names.get('/Users/chris/dev/entriq')).toBe('entriq'); + }); + + it('deepens both sides of a collision, and only that collision', () => { + const names = workspaceDisplayNames([ + '/Users/chris/dev/backend/api', + '/Users/chris/dev/mobile/api', + '/Users/chris/dev/codev-1455', + ]); + expect(names.get('/Users/chris/dev/backend/api')).toBe('backend/api'); + expect(names.get('/Users/chris/dev/mobile/api')).toBe('mobile/api'); + // The uninvolved workspace keeps its one segment: an ambiguous pair elsewhere is + // not a reason to make every other row longer. + expect(names.get('/Users/chris/dev/codev-1455')).toBe('codev-1455'); + }); + + it('keeps deepening until the names actually separate', () => { + const names = workspaceDisplayNames(['/a/one/svc/api', '/a/two/svc/api']); + expect(names.get('/a/one/svc/api')).toBe('one/svc/api'); + expect(names.get('/a/two/svc/api')).toBe('two/svc/api'); + }); + + it('terminates when two roots cannot be separated at any depth', () => { + // Two spellings of one path. `canonicalWorkspaceKey` should have collapsed these + // upstream; if it did not, the namer must stop rather than spin forever. + const names = workspaceDisplayNames(['/a/api', '/a/api/']); + expect(names.get('/a/api')).toBe(names.get('/a/api/')); + }); +}); + +describe('codevWorkspaceRoots', () => { + const isWorkspace = (path: string): boolean => + ['/Users/chris/dev/codev-1455', '/Users/chris/dev/dvarr', '/Users/chris/dev/entriq'].includes( + path, + ); + + it('drops builder worktrees, non-workspaces, and deleted checkouts', () => { + const roots = codevWorkspaceRoots( + [ + '/Users/chris/dev/codev-1455', + '/Users/chris/dev/codev-1455/.builders/pir-272', + '/Users/chris/dev/dvarr', + '/Users/chris/dev/dvarr/.builders/pir-180', + // A parent directory a terminal was once opened in. It exists; it is not a + // workspace, and a sidebar heading for it is a heading for something no + // Codev command would accept. + '/Users/chris/dev', + // A checkout that has been deleted. Its row outlived it. + '/Users/chris/dev/codev_new', + ], + isWorkspace, + ); + expect(roots).toEqual(['/Users/chris/dev/codev-1455', '/Users/chris/dev/dvarr']); + }); + + it('collapses two spellings of one workspace into one root', () => { + const roots = codevWorkspaceRoots( + ['/Users/chris/dev/entriq', '/Users/chris/dev/entriq/'], + (path) => isWorkspace(path.replace(/\/$/, '')), + ); + // Two roots here would become two projects for one directory — the failure the + // canonical key exists to prevent, arriving through a different door. + expect(roots).toHaveLength(1); + }); + +}); + +describe('isMachineWrittenTitle', () => { + const row = (title: string, workspaceRoot: string): ProjectRow => ({ + id: 'p-1', + title, + workspaceRoot, + }); + + it('recognises the legacy prefixed-path title', () => { + expect( + isMachineWrittenTitle( + row('codev:/Users/chris/dev/codev-1455', '/Users/chris/dev/codev-1455'), + ), + ).toBe(true); + }); + + it('recognises the leaf name the connect path writes', () => { + expect(isMachineWrittenTitle(row('codev-1455', '/Users/chris/dev/codev-1455'))).toBe(true); + }); + + it('leaves a title a human chose alone', () => { + expect(isMachineWrittenTitle(row('Codev', '/Users/chris/dev/codev-1455'))).toBe(false); + expect(isMachineWrittenTitle(row('My Project', '/Users/chris/dev/codev-1455'))).toBe(false); + }); + + it('does not claim a codev-prefixed title naming some other path', () => { + // The prefix alone is not the signal. A title has to name the project's OWN + // workspace root to be one this code wrote. + expect( + isMachineWrittenTitle(row('codev:/somewhere/else', '/Users/chris/dev/codev-1455')), + ).toBe(false); + }); +}); + +describe('planWorkspaceProjects', () => { + const names = new Map([ + ['/w/alpha', 'alpha'], + ['/w/beta', 'beta'], + ]); + + it('creates a project for a workspace the server does not have', () => { + expect( + planWorkspaceProjects({ roots: ['/w/alpha'], names, projects: [] }), + ).toEqual([{ kind: 'create', workspaceRoot: '/w/alpha', title: 'alpha' }]); + }); + + it('renames a legacy title and leaves a correct one alone', () => { + const actions = planWorkspaceProjects({ + roots: ['/w/alpha', '/w/beta'], + names, + projects: [ + { id: 'p-alpha', title: 'codev:/w/alpha', workspaceRoot: '/w/alpha' }, + { id: 'p-beta', title: 'beta', workspaceRoot: '/w/beta' }, + ], + }); + expect(actions).toEqual([{ kind: 'rename', projectId: 'p-alpha', title: 'alpha' }]); + }); + + it('deepens a leaf title the connect path wrote when the set makes it ambiguous', () => { + const ambiguous = new Map([ + ['/w/backend/api', 'backend/api'], + ['/w/mobile/api', 'mobile/api'], + ]); + const actions = planWorkspaceProjects({ + roots: ['/w/backend/api', '/w/mobile/api'], + names: ambiguous, + projects: [ + { id: 'p-1', title: 'api', workspaceRoot: '/w/backend/api' }, + { id: 'p-2', title: 'api', workspaceRoot: '/w/mobile/api' }, + ], + }); + expect(actions).toEqual([ + { kind: 'rename', projectId: 'p-1', title: 'backend/api' }, + { kind: 'rename', projectId: 'p-2', title: 'mobile/api' }, + ]); + }); + + it('never renames a title a human chose, however wrong it looks', () => { + const actions = planWorkspaceProjects({ + roots: ['/w/alpha'], + names, + projects: [{ id: 'p-alpha', title: 'Alpha (do not touch)', workspaceRoot: '/w/alpha' }], + }); + expect(actions).toEqual([]); + }); + + it('leaves a project for a workspace it did not enumerate entirely alone', () => { + // "I did not enumerate it" is not "it should not exist". Nothing here deletes. + const actions = planWorkspaceProjects({ + roots: ['/w/alpha'], + names, + projects: [ + { id: 'p-alpha', title: 'alpha', workspaceRoot: '/w/alpha' }, + { id: 'p-other', title: 'codev:/w/gone', workspaceRoot: '/w/gone' }, + ], + }); + expect(actions).toEqual([]); + }); + + it('matches a project stored under a different spelling of the same path', () => { + const actions = planWorkspaceProjects({ + roots: ['/w/alpha'], + names, + projects: [{ id: 'p-alpha', title: 'alpha', workspaceRoot: '/w/alpha/' }], + }); + // A second `project.create` here would be refused by the server, and the refusal + // would read as "the server was named and could not be used". + expect(actions).toEqual([]); + }); +}); + +describe('reconcileWorkspaceProjects', () => { + const server: WorkspaceProjectServer = { serverUrl: 'http://t3', bootstrapToken: 'seed' }; + + function fakeGateway(projects: ProjectRow[]) { + const created: Array<{ workspaceRoot: string; title: string }> = []; + const renamed: Array<{ projectId: string; title: string }> = []; + let closed = 0; + const gateway: WorkspaceProjectGateway = { + readProjects: async () => projects, + createProject: async (workspaceRoot, title) => { + created.push({ workspaceRoot, title }); + }, + renameProject: async (projectId, title) => { + renamed.push({ projectId, title }); + }, + close: () => { + closed += 1; + }, + }; + return { gateway, created, renamed, closed: () => closed }; + } + + it('opens ONE gateway for many workspaces sharing a server', async () => { + const fake = fakeGateway([]); + const openGateway = vi.fn(async () => fake.gateway); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha', '/w/beta', '/w/gamma'], + isCodevWorkspace: () => true, + serverFor: () => server, + openGateway, + log: () => {}, + }); + // The rejected implementation — `ensureThreadBackendReady` per root — would have + // opened three, and held all three for the life of the process. + expect(openGateway).toHaveBeenCalledTimes(1); + expect(result.servers).toBe(1); + expect(fake.created).toEqual([ + { workspaceRoot: '/w/alpha', title: 'alpha' }, + { workspaceRoot: '/w/beta', title: 'beta' }, + { workspaceRoot: '/w/gamma', title: 'gamma' }, + ]); + }); + + it('opens one gateway per distinct server', async () => { + const one = fakeGateway([]); + const two = fakeGateway([]); + const openGateway = vi.fn(async (target: WorkspaceProjectServer) => + target.serverUrl === 'http://one' ? one.gateway : two.gateway, + ); + await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha', '/w/beta'], + isCodevWorkspace: () => true, + serverFor: (root) => + root === '/w/alpha' + ? { serverUrl: 'http://one', bootstrapToken: 's' } + : { serverUrl: 'http://two', bootstrapToken: 's' }, + openGateway, + log: () => {}, + }); + expect(openGateway).toHaveBeenCalledTimes(2); + expect(one.created).toEqual([{ workspaceRoot: '/w/alpha', title: 'alpha' }]); + expect(two.created).toEqual([{ workspaceRoot: '/w/beta', title: 'beta' }]); + }); + + it('skips a workspace that names no server without calling it a failure', async () => { + const fake = fakeGateway([]); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha', '/w/pty-only'], + isCodevWorkspace: () => true, + serverFor: (root) => (root === '/w/alpha' ? server : null), + openGateway: async () => fake.gateway, + log: () => {}, + }); + expect(result.failures).toEqual([]); + expect(fake.created).toEqual([{ workspaceRoot: '/w/alpha', title: 'alpha' }]); + }); + + it('reports a half-configured workspace as a failure rather than skipping it', async () => { + const fake = fakeGateway([]); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha', '/w/broken'], + isCodevWorkspace: () => true, + serverFor: (root) => { + if (root === '/w/broken') throw new Error('Incomplete "threads" config'); + return server; + }, + openGateway: async () => fake.gateway, + log: () => {}, + }); + // A mistake is not a decision to stay on PTY, and must not be spelled like one. + expect(result.failures).toEqual(['/w/broken: Incomplete "threads" config']); + expect(fake.created).toEqual([{ workspaceRoot: '/w/alpha', title: 'alpha' }]); + }); + + it('keeps reconciling the other servers when one is unreachable', async () => { + const reachable = fakeGateway([]); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha', '/w/beta'], + isCodevWorkspace: () => true, + serverFor: (root) => + root === '/w/alpha' + ? { serverUrl: 'http://down', bootstrapToken: 's' } + : { serverUrl: 'http://up', bootstrapToken: 's' }, + openGateway: async (target) => { + if (target.serverUrl === 'http://down') throw new Error('ECONNREFUSED'); + return reachable.gateway; + }, + log: () => {}, + }); + expect(result.failures).toEqual(['http://down: ECONNREFUSED']); + // One unreachable server must not hide every workspace behind every other one. + expect(reachable.created).toEqual([{ workspaceRoot: '/w/beta', title: 'beta' }]); + }); + + it('closes the gateway even when the pass fails halfway through', async () => { + const fake = fakeGateway([]); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha'], + isCodevWorkspace: () => true, + serverFor: () => server, + openGateway: async () => ({ + ...fake.gateway, + createProject: async () => { + throw new Error('refused'); + }, + close: fake.gateway.close, + }), + log: () => {}, + }); + expect(result.failures).toEqual(['http://t3: refused']); + expect(fake.closed()).toBe(1); + }); + + it('does not let one unreadable path stop the whole enumeration', async () => { + const fake = fakeGateway([]); + await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/locked', '/w/alpha'], + isCodevWorkspace: (path) => { + if (path === '/w/locked') throw new Error('EACCES'); + return true; + }, + serverFor: () => server, + openGateway: async () => fake.gateway, + log: () => {}, + }); + // A path that cannot be examined is not a workspace this pass can project, and + // it is not a reason to project none of the others either. + expect(fake.created).toEqual([{ workspaceRoot: '/w/alpha', title: 'alpha' }]); + }); + + it('reconciles nothing and says why when the workspace list cannot be read', async () => { + const openGateway = vi.fn(async () => fakeGateway([]).gateway); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => { + throw new Error('database is locked'); + }, + isCodevWorkspace: () => true, + serverFor: () => server, + openGateway, + log: () => {}, + }); + // "I could not read the workspaces" must never be recorded as "there were none". + expect(result.failures).toEqual(['could not list known workspaces: database is locked']); + expect(result.workspaces).toBe(0); + expect(openGateway).not.toHaveBeenCalled(); + }); + + it('writes nothing at all when every workspace is already correct', async () => { + const fake = fakeGateway([{ id: 'p-alpha', title: 'alpha', workspaceRoot: '/w/alpha' }]); + const result = await reconcileWorkspaceProjects({ + knownWorkspacePaths: () => ['/w/alpha'], + isCodevWorkspace: () => true, + serverFor: () => server, + openGateway: async () => fake.gateway, + log: () => {}, + }); + // The steady state, and the reason the gateway's socket is lazy: a pass with no + // actions must not open one. + expect(result).toMatchObject({ created: 0, renamed: 0, failures: [] }); + expect(fake.created).toEqual([]); + expect(fake.renamed).toEqual([]); + }); +}); + +/** + * The PRODUCTION predicate, against a real filesystem. + * + * Every test above injects `isCodevWorkspace`, and so does + * `tools/t3-fork/issue-272-projection.mjs`. So the filter that actually decides + * "deleted checkout" and "not a workspace" in Tower was substituted by every check + * that claimed to cover it — a test that supplies the boundary itself cannot tell + * you the boundary exists. Raised by the review consultation; this closes it. + */ +describe('isCodevWorkspaceDirectory', () => { + const made: string[] = []; + const dir = (): string => { + const d = mkdtempSync(join(tmpdir(), 'issue-272-fs-')); + made.push(d); + return d; + }; + + afterEach(() => { + for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it('accepts a directory carrying .codev', () => { + const root = dir(); + mkdirSync(join(root, '.codev')); + expect(isCodevWorkspaceDirectory(root)).toBe(true); + }); + + it('rejects a directory with no .codev', () => { + // The real `known_workspaces` table holds `/Users/chris/dev` — a parent a + // terminal was once opened in. A heading for it is a heading for something no + // Codev command would accept. + expect(isCodevWorkspaceDirectory(dir())).toBe(false); + }); + + it('rejects a path that does not exist', () => { + // A deleted checkout whose row outlived it. This is the case the sweep must + // not mint a permanent sidebar heading for. + const root = dir(); + rmSync(root, { recursive: true, force: true }); + expect(isCodevWorkspaceDirectory(root)).toBe(false); + }); + + it('rejects a FILE, even one named like a workspace', () => { + // `existsSync` alone would accept this; the `statSync().isDirectory()` guard is + // what refuses it, and nothing else in the suite exercises that line. + const root = dir(); + const file = join(root, 'not-a-dir'); + writeFileSync(file, ''); + expect(isCodevWorkspaceDirectory(file)).toBe(false); + }); + + it('rejects a path whose .codev is a file rather than a directory', () => { + // `codev init` creates a directory. A stray file of that name is not a + // workspace, and treating it as one would put an unopenable heading in the tree. + const root = dir(); + writeFileSync(join(root, '.codev'), ''); + expect(isCodevWorkspaceDirectory(root)).toBe(false); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 5382bbf64..3927346f0 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -81,6 +81,10 @@ import { type ThreadAdoptionSweeper, } from '../thread-subscriptions.js'; import { requestThreadBackend } from '../thread-backend.js'; +import { + createWorkspaceProjectionSweeper, + type WorkspaceProjectionSweeper, +} from '../workspace-projection-sweep.js'; import { tryGetThreadEngine } from '../thread-runtime.js'; import { ApprovalOperationStore } from '../lib/approval-operations.js'; import { normalizeWorkspacePath } from '../utils/workspace-path.js'; @@ -101,6 +105,14 @@ let t3codeSessionCache: T3codeSessionCache | null = null; */ let threadAdoptionSweeper: ThreadAdoptionSweeper | null = null; +/** + * The workspace projection sweeper, so shutdown can stop its interval (issue #272). + * + * Module-scoped for the same reason as the two above: shutdown is a module-level + * function here. + */ +let workspaceProjectionSweeper: WorkspaceProjectionSweeper | null = null; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -270,6 +282,11 @@ async function gracefulShutdown(signal: string): Promise { // reads like a t3code problem. threadAdoptionSweeper?.stop(); threadAdoptionSweeper = null; + // Same reason again: its pass reads global.db and talks to a t3code server, and a + // pass landing after the database is closed would log a projection failure that + // reads like a t3code problem. + workspaceProjectionSweeper?.stop(); + workspaceProjectionSweeper = null; shutdownAgentRoutes(); // 7. Tear down instance module (Spec 0105 Phase 3) @@ -899,6 +916,26 @@ async function bootSequence(): Promise { // API, and none of it is a prerequisite for a correct response. markBootComplete(); + /* + * Issue #272: every known Codev workspace is a project row, named after its + * directory. + * + * After `markBootComplete`, deliberately. This reaches a t3code server over the + * network, and a server that is down would otherwise hold the API closed for as + * long as its connect takes — for a sweep whose whole output is what a sidebar + * looks like. Nothing below the gate is a prerequisite for a correct response. + * + * `getKnownWorkspacePaths`, not the `architect UNION builders` query the thread + * adoption sweeper uses. That narrower list is the point of the issue: a + * registered workspace nobody has spawned into has neither an architect row nor a + * builder row, and it is exactly the workspace that was missing from the tree. + */ + workspaceProjectionSweeper = createWorkspaceProjectionSweeper({ + knownWorkspacePaths: getKnownWorkspacePaths, + log, + }); + workspaceProjectionSweeper.start(); + // Issue #1227: run the stricter husk sweep once at startup too, same // ordering requirement as killOrphanedShellpers (must run after // reconciliation so a reconnected session's shellper is registered). diff --git a/packages/codev/src/agent-farm/thread-backend.ts b/packages/codev/src/agent-farm/thread-backend.ts index 6f0b93023..f0168797d 100644 --- a/packages/codev/src/agent-farm/thread-backend.ts +++ b/packages/codev/src/agent-farm/thread-backend.ts @@ -11,6 +11,7 @@ * silently, the second throws. A server that was named and could not be reached must * never be spelled the same way as a server that was never named. */ +import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { DispatchJournal } from '@cluesmith/porch-driver/commands'; @@ -30,6 +31,8 @@ import { } from './thread-runtime.js'; import { logger } from './utils/logger.js'; import { configLayerPaths, loadConfig } from '../lib/config.js'; +import { workspaceLeafName } from './workspace-projection.js'; +import type { ProjectRow, WorkspaceProjectGateway } from './workspace-projection.js'; /** * How long a socket may sit connected-but-not-upgraded before it is called a failure. @@ -490,12 +493,27 @@ export type ProjectLookup = | { readonly kind: 'none' } | { readonly kind: 'unknown'; readonly detail: string }; -export async function activeProjectForWorkspace( +/** + * Every project the server holds, or why they could not be read. + * + * Split out of `activeProjectForWorkspace` because the workspace-projection sweep + * needs the whole list rather than one match, and a second copy of this request + * would be a second place for the transport rules to drift — the bare `fetch` this + * request used to be is exactly how it skipped `assertTransportSafe` once already. + * + * `unknown` carries the reason and is never spelled like an empty list: a caller + * that reconciles against "no projects" would create one for every workspace it + * knows. + */ +export type ProjectRowsRead = + | { readonly kind: 'ok'; readonly projects: readonly ProjectRow[] } + | { readonly kind: 'unknown'; readonly detail: string }; + +export async function readProjectRows( serverUrl: string, accessToken: string, - workspaceRoot: string, timeoutMs: number = DEFAULT_SOCKET_UPGRADE_TIMEOUT_MS, -): Promise { +): Promise { let body: unknown; try { // Through the client, not a second hand-built request (issue #227 item 4). @@ -522,10 +540,35 @@ export async function activeProjectForWorkspace( } catch (err) { return { kind: 'unknown', detail: err instanceof Error ? err.message : String(err) }; } - const projects = (body as { projects?: ReadonlyArray<{ id?: unknown; workspaceRoot?: unknown }> }).projects; + const projects = (body as { + projects?: ReadonlyArray<{ id?: unknown; title?: unknown; workspaceRoot?: unknown }>; + }).projects; if (!Array.isArray(projects)) { return { kind: 'unknown', detail: 'the shell snapshot carried no projects array' }; } + const rows: ProjectRow[] = []; + for (const project of projects) { + // A row missing either field is dropped rather than defaulted. A project whose + // workspace root did not decode cannot be matched against a workspace, and giving + // it an empty one would let it match the next row that also failed to decode. + if (typeof project.id !== 'string' || typeof project.workspaceRoot !== 'string') continue; + rows.push({ + id: project.id, + title: typeof project.title === 'string' ? project.title : '', + workspaceRoot: project.workspaceRoot, + }); + } + return { kind: 'ok', projects: rows }; +} + +export async function activeProjectForWorkspace( + serverUrl: string, + accessToken: string, + workspaceRoot: string, + timeoutMs: number = DEFAULT_SOCKET_UPGRADE_TIMEOUT_MS, +): Promise { + const read = await readProjectRows(serverUrl, accessToken, timeoutMs); + if (read.kind === 'unknown') return { kind: 'unknown', detail: read.detail }; // t3code compares normalised paths, and so does this. `/var` and `/private/var` // are the same directory on macOS and a string compare calls them different, which // would report `none` for a project that exists — the answer that leads straight @@ -533,14 +576,92 @@ export async function activeProjectForWorkspace( // The same canonicalisation the engine map keys on, not a second copy of the rule: // two spellings of one workspace here would answer `none` for a project that exists. const target = canonicalWorkspaceKey(workspaceRoot); - const match = projects.find( - (project) => - typeof project.workspaceRoot === 'string' && canonicalWorkspaceKey(project.workspaceRoot) === target, + const match = read.projects.find( + (project) => canonicalWorkspaceKey(project.workspaceRoot) === target, ); - if (!match || typeof match.id !== 'string') return { kind: 'none' }; + if (match === undefined) return { kind: 'none' }; return { kind: 'found', projectId: match.id }; } +/** + * A `WorkspaceProjectGateway` backed by a real t3code server. + * + * READS OVER HTTP, WRITES OVER A SOCKET, AND THE SOCKET IS LAZY. A sweep that finds + * nothing to do — which is every sweep after the first — must not open a WebSocket, + * exchange a ticket and tear it all down again every interval. So the dispatcher is + * connected on the first write and not before, and `close` is a no-op when there was + * no write. + * + * The bootstrap token is exchanged once per gateway. That exchange is the one + * operation here with a documented constraint attached: a pairing-issued one-time + * token would be spent by it. The same constraint `ThreadBackendConfig.bootstrapToken` + * already states, arrived at from a second direction. + */ +export async function openProjectGateway( + server: { readonly serverUrl: string; readonly bootstrapToken: string }, + timeoutMs: number = DEFAULT_SOCKET_UPGRADE_TIMEOUT_MS, +): Promise { + const auth = await import('@cluesmith/t3-client/auth'); + const access = await auth.exchangeBootstrapToken(server.serverUrl, server.bootstrapToken, { + clientLabel: 'codev-afx', + }); + const accessToken = access.access_token; + + // The journal is per-server rather than per-workspace, because these commands are + // not a workspace's work: `project.create` for ten workspaces is one server's + // reconciliation, and splitting it across ten journals would make recovery read + // ten files to replay one sweep. + const journal = new DispatchJournal( + join(homedir(), '.agent-farm', 'workspace-projection.jsonl'), + ); + + let connection: Awaited> | undefined; + const writer = async (): Promise> => { + if (connection !== undefined) return connection; + connection = await connectDispatcher( + { + serverUrl: server.serverUrl, + bootstrapToken: server.bootstrapToken, + // The gateway writes projects and never threads, so it names no workspace of + // its own. Each command carries the workspace root it is about. + workspaceRoot: homedir(), + }, + timeoutMs, + () => { + // A close mid-sweep surfaces as the next dispatch failing, which the sweep + // records against this server. Nothing to reconnect here: the gateway lives + // for one sweep and the next one opens a fresh connection. + }, + accessToken, + ); + return connection; + }; + + const { createProject, updateProjectMeta } = await import('@cluesmith/porch-driver/thread'); + + return { + async readProjects() { + const read = await readProjectRows(server.serverUrl, accessToken, timeoutMs); + if (read.kind === 'unknown') { + throw new Error(`could not read projects from ${server.serverUrl}: ${read.detail}`); + } + return read.projects; + }, + async createProject(workspaceRoot: string, title: string) { + const { dispatcher } = await writer(); + await createProject(dispatcher, journal, { title, workspaceRoot }); + }, + async renameProject(projectId: string, title: string) { + const { dispatcher } = await writer(); + await updateProjectMeta(dispatcher, journal, { projectId, title }); + }, + close() { + connection?.close(); + connection = undefined; + }, + }; +} + /** * Register the production thread engine and spawn factory if this workspace is * configured for thread-backed spawns. @@ -910,7 +1031,19 @@ async function initialiseThreadBackend( } else { try { projectId = await createProject(dispatcher, journal, { - title: `codev:${config.workspaceRoot}`, + // The workspace's own directory name, because this string IS the sidebar + // heading: a single-member project group's label is its title, verbatim. + // It used to be `codev:`, which does not scan in a column + // and truncates to the same leading characters at every narrow width. + // + // The LEAF, not the set-wide unique name. This path knows one workspace, + // so it cannot see a collision with another; the sweep in + // `workspace-projection.ts` sees the whole set and deepens this to + // `backend/api` when it has to. It is allowed to, because a leaf name is + // recognised there as machine-written — which is what makes a project + // created by a spawn converge on the unique name instead of sitting on an + // ambiguous one forever. + title: workspaceLeafName(config.workspaceRoot), workspaceRoot: config.workspaceRoot, }); } catch (err) { diff --git a/packages/codev/src/agent-farm/workspace-projection-sweep.ts b/packages/codev/src/agent-farm/workspace-projection-sweep.ts new file mode 100644 index 000000000..71f812492 --- /dev/null +++ b/packages/codev/src/agent-farm/workspace-projection-sweep.ts @@ -0,0 +1,152 @@ +/** + * The interval that runs `reconcileWorkspaceProjects` inside Tower (issue #272). + * + * Separate from `workspace-projection.ts` on purpose. That module is the decision — + * which paths are workspaces, what they are called, which titles may be rewritten — + * and it imports nothing heavier than the workspace key helper, so its rules can be + * tested without a database, a filesystem or a server. This file is the wiring: it + * is where `global.db`, `.codev/`, the thread-backend config and a real t3code + * connection are named. + */ +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import { + openProjectGateway, + readThreadBackendConfig, +} from './thread-backend.js'; +import { + reconcileWorkspaceProjects, + type WorkspaceProjectionDeps, + type WorkspaceProjectionResult, +} from './workspace-projection.js'; + +/** + * 30 s, and the interval is doing less work than it looks like. + * + * A pass that finds nothing to do is one token exchange and one HTTP GET per + * configured server — no WebSocket, because the gateway opens one lazily and a pass + * with no actions never writes. The reason it repeats at all is that the workspace + * set changes without any Codev command running: a `codev init` elsewhere, a + * terminal opened in a new checkout. + */ +const DEFAULT_SWEEP_MS = 30_000; + +export interface WorkspaceProjectionSweeper { + /** One pass, awaited. Exposed so a caller can run it without an interval. */ + sweep(): Promise; + start(): void; + stop(): void; +} + +export interface WorkspaceProjectionSweeperOptions { + /** Every path Codev has recorded. Tower passes `getKnownWorkspacePaths`. */ + knownWorkspacePaths: () => readonly string[]; + log: (level: 'INFO' | 'WARN' | 'ERROR', message: string) => void; + intervalMs?: number; + /** Overridden only by tests that must not reach a real server. */ + deps?: Partial; +} + +/** + * Does this path exist, and is it a Codev workspace? + * + * `.codev/` is the marker, because it is the directory `codev init` creates and the + * one the config resolver reads. A path with a `codev/` (our own instance + * directory) but no `.codev/` is a repository that has never been initialised, and + * a sidebar heading for it would be a heading for a workspace no Codev command + * would accept. + */ +export function isCodevWorkspaceDirectory(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false; + } catch { + // A path that cannot be stat'd is gone, or is behind a permission we do not + // have. Either way there is nothing here to project, and this is the ordinary + // case for a `known_workspaces` row pointing at a deleted checkout. + return false; + } + // `.codev` must be a DIRECTORY, not merely present. `codev init` creates a + // directory; a stray file of that name is not a workspace, and `existsSync` alone + // accepts it. Found by testing this predicate for the first time — every other + // check in the suite injects a substitute for it. + try { + return statSync(join(path, '.codev')).isDirectory(); + } catch { + return false; + } +} + +export function createWorkspaceProjectionSweeper( + options: WorkspaceProjectionSweeperOptions, +): WorkspaceProjectionSweeper { + let timer: NodeJS.Timeout | undefined; + let running = false; + + const deps: WorkspaceProjectionDeps = { + knownWorkspacePaths: options.knownWorkspacePaths, + isCodevWorkspace: isCodevWorkspaceDirectory, + serverFor: (workspaceRoot) => { + // Throws on a half-configured workspace, and that throw is wanted: the sweep + // records it against the workspace rather than skipping it, because + // "serverUrl without bootstrapToken" is a mistake and not a decision to stay + // on PTY. `readThreadBackendConfig` draws that line already. + const config = readThreadBackendConfig(workspaceRoot); + if (config === null) return null; + return { serverUrl: config.serverUrl, bootstrapToken: config.bootstrapToken }; + }, + openGateway: (server) => openProjectGateway(server), + log: options.log, + ...options.deps, + }; + + async function sweep(): Promise { + const result = await reconcileWorkspaceProjects(deps); + for (const failure of result.failures) { + options.log( + 'WARN', + `Workspace projection sweep: ${failure}. No workspace is lost by this — the next pass retries.`, + ); + } + return result; + } + + return { + sweep, + start() { + if (timer) return; + const run = (): void => { + // ONE PASS AT A TIME. A slow or unreachable server makes a pass outlast its + // interval, and overlapping passes would both read the same snapshot and + // both decide to create the same project — which the server refuses, so the + // second one would be reported as a failure of a sweep that was working. + if (running) return; + running = true; + void sweep() + .catch((error: unknown) => { + options.log( + 'ERROR', + `Workspace projection sweep could not begin: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }) + .finally(() => { + running = false; + }); + }; + timer = setInterval(run, options.intervalMs ?? DEFAULT_SWEEP_MS); + // Never the reason a process stays alive. + timer.unref?.(); + // Immediately, the way the thread adoption sweeper does. The first pass is the + // one that matters most: it is what puts every already-registered workspace in + // the sidebar after a Tower start, and waiting a full interval for it would + // leave the tree looking exactly as broken as before for 30 s. + run(); + }, + stop() { + if (!timer) return; + clearInterval(timer); + timer = undefined; + }, + }; +} diff --git a/packages/codev/src/agent-farm/workspace-projection.ts b/packages/codev/src/agent-farm/workspace-projection.ts new file mode 100644 index 000000000..fdf0465f8 --- /dev/null +++ b/packages/codev/src/agent-farm/workspace-projection.ts @@ -0,0 +1,382 @@ +/** + * Every Codev workspace is a project row, and every project row is named after its + * directory (issue #272). + * + * ## What was wrong + * + * A project was created by `initialiseThreadBackend` — the thread-backend CONNECT — + * and only for the workspace being connected. So a registered workspace nobody had + * spawned into had no project row, and the sidebar cannot draw a heading for a + * project that does not exist. "No agent here yet" and "this workspace does not + * exist" were spelled the same way. + * + * Its title was `codev:`, which is what the sidebar heading renders + * verbatim (a single-member project group's label IS its title). A column of + * absolute paths does not scan, and at a narrow width every workspace truncates to + * the same leading characters. + * + * ## The shape of the fix + * + * A sweep, not a hook on spawn. The set of workspaces Codev knows about changes + * without any spawn happening — a `codev init`, a terminal opened somewhere new — + * and a reconciler that re-reads the world is self-healing in a way a one-shot is + * not. + * + * ## One connection per SERVER, not per workspace + * + * The obvious implementation is `ensureThreadBackendReady(root)` for every known + * root: it already creates the project. It also installs a live engine and holds a + * WebSocket open for the life of the process — so it would open roughly one socket + * per known workspace, against what is usually one server, to write one row each. + * + * So roots are grouped by the server they name, and the gateway below is asked for + * one per group. Reading is plain HTTP; the socket is opened only when there is + * actually something to write, which in the steady state is never. + * + * ## The core is pure + * + * `planWorkspaceProjects` decides; the sweep performs. The decision is where the + * rules live — which paths are workspaces, what they are called, which titles may + * be rewritten — and none of it needs a server to test. + */ +import { canonicalWorkspaceKey } from './workspace-key.js'; + +/** The title form this code wrote before #272. See `isMachineWrittenTitle`. */ +export const LEGACY_TITLE_PREFIX = 'codev:'; + +/** A project as the server reports it. The three fields a reconciler reads. */ +export interface ProjectRow { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; +} + +export type WorkspaceProjectAction = + | { readonly kind: 'create'; readonly workspaceRoot: string; readonly title: string } + | { readonly kind: 'rename'; readonly projectId: string; readonly title: string }; + +/** + * The display name for one workspace root: its own directory name. + * + * Collisions are only visible across the whole set, and `workspaceDisplayNames` is + * what resolves them. This is the one-root answer it starts from, and the fallback + * for a root with no segments at all (`/`) — because `project.create` requires a + * non-empty title, and a blank one would be refused by the server rather than + * reported here. + */ +export function workspaceLeafName(workspaceRoot: string): string { + const segments = trailingSegments(workspaceRoot); + const leaf = segments[segments.length - 1]; + if (leaf !== undefined) return leaf; + return workspaceRoot.trim() || 'workspace'; +} + +/** + * Name every workspace by the shortest trailing path segments that make it unique. + * + * `/Users//dev/codev-1455` is `codev-1455`. Two workspaces both called `api` + * become `backend/api` and `mobile/api` — because two rows reading `api` in a + * sidebar is the same failure as a column of identical path prefixes, arrived at + * from the other direction. + * + * Deepening is per COLLIDING GROUP, not global: one ambiguous pair does not push + * every other workspace to two segments. A group that cannot grow any further (two + * spellings of one directory, which `canonicalWorkspaceKey` should already have + * collapsed) stops rather than looping. + * + * Keyed by the root exactly as it was passed in, so a caller can look up what it + * handed over without re-canonicalising. + */ +export function workspaceDisplayNames(roots: readonly string[]): Map { + const segmentsByRoot = new Map(); + for (const root of roots) segmentsByRoot.set(root, trailingSegments(root)); + + const depthByRoot = new Map(); + for (const root of roots) depthByRoot.set(root, 1); + + const nameFor = (root: string): string => { + const segments = segmentsByRoot.get(root) ?? []; + if (segments.length === 0) return root.trim() || 'workspace'; + const depth = Math.min(depthByRoot.get(root) ?? 1, segments.length); + return segments.slice(segments.length - depth).join('/'); + }; + + // Bounded by the deepest path rather than by a guessed constant: each pass either + // deepens at least one group or stops, and no group can deepen past its own length. + const maxDepth = Math.max(1, ...roots.map((root) => (segmentsByRoot.get(root) ?? []).length)); + for (let pass = 0; pass < maxDepth; pass += 1) { + const byName = new Map(); + for (const root of roots) { + const name = nameFor(root); + const existing = byName.get(name); + if (existing) existing.push(root); + else byName.set(name, [root]); + } + let deepened = false; + for (const group of byName.values()) { + if (group.length < 2) continue; + const canGrow = group.filter( + (root) => (depthByRoot.get(root) ?? 1) < (segmentsByRoot.get(root) ?? []).length, + ); + // Every member is already at full depth: these are two spellings of one path, + // and deepening again would spin without ever separating them. + if (canGrow.length === 0) continue; + for (const root of group) depthByRoot.set(root, (depthByRoot.get(root) ?? 1) + 1); + deepened = true; + } + if (!deepened) break; + } + + return new Map(roots.map((root) => [root, nameFor(root)])); +} + +/** + * Which known paths are Codev workspaces worth projecting. + * + * Three filters, and each one is here because the real `known_workspaces` table + * contains a case it lets through: + * + * - `/.builders/` paths are builder worktrees, not workspaces. They are already + * filtered out of Tower's v2 workspace list for the same reason. + * - A path that no longer exists is a deleted checkout whose row was never cleaned + * up. Minting a project for it would put a permanent heading in the sidebar for a + * directory nobody can open. + * - A path with no `.codev/` is not a Codev workspace. The table holds parent + * directories a terminal was once opened in. + * + * De-duplicated on `canonicalWorkspaceKey`, because two spellings of one workspace + * would otherwise become two projects — the exact failure that key exists to + * prevent. The first spelling seen wins, and the result is sorted so a sweep's plan + * does not depend on table order. + */ +export function codevWorkspaceRoots( + paths: readonly string[], + isCodevWorkspace: (path: string) => boolean, +): string[] { + const seen = new Set(); + const roots: string[] = []; + for (const path of paths) { + if (path.includes('/.builders/')) continue; + if (!isCodevWorkspace(path)) continue; + const key = canonicalWorkspaceKey(path); + if (seen.has(key)) continue; + seen.add(key); + roots.push(path); + } + return roots.sort(); +} + +/** + * Is this title one this code wrote, and therefore safe to replace? + * + * A sweep that enforced a computed title would undo a rename a human made in the + * t3code UI, silently, every time it ran. So only two titles are ever rewritten, + * and both are strings this codebase produces: + * + * - `codev:` — the legacy form. The path is + * compared canonically rather than by string, so a project written under `/var` + * and stored under `/private/var` is still recognised as ours, while `codev:` in + * front of an unrelated path is not. + * - the workspace's own leaf name — what the thread-backend connect path writes, + * which knows one workspace and so cannot see a collision with another. Letting + * the sweep deepen that to `backend/api` is a refinement of the same name, not + * the loss of somebody's choice; without it, a project created by a spawn would + * never converge on the unique name and two sidebar rows would read `api`. + * + * Anything else is somebody's decision and is left alone. + */ +export function isMachineWrittenTitle(project: ProjectRow): boolean { + if (project.title === workspaceLeafName(project.workspaceRoot)) return true; + if (!project.title.startsWith(LEGACY_TITLE_PREFIX)) return false; + const claimed = project.title.slice(LEGACY_TITLE_PREFIX.length); + if (claimed === '') return false; + return canonicalWorkspaceKey(claimed) === canonicalWorkspaceKey(project.workspaceRoot); +} + +/** + * What one server needs done, given the workspaces pointed at it. + * + * Pure. Creates the missing projects and rewrites the legacy titles, and touches + * nothing else — a project the server has for a workspace this sweep does not know + * about is left entirely alone, because "I did not enumerate it" is not "it should + * not exist". + */ +export function planWorkspaceProjects(input: { + readonly roots: readonly string[]; + readonly names: ReadonlyMap; + readonly projects: readonly ProjectRow[]; +}): WorkspaceProjectAction[] { + const projectByKey = new Map(); + for (const project of input.projects) { + const key = canonicalWorkspaceKey(project.workspaceRoot); + if (!projectByKey.has(key)) projectByKey.set(key, project); + } + + const actions: WorkspaceProjectAction[] = []; + for (const root of input.roots) { + const title = input.names.get(root) ?? workspaceLeafName(root); + const existing = projectByKey.get(canonicalWorkspaceKey(root)); + if (existing === undefined) { + actions.push({ kind: 'create', workspaceRoot: root, title }); + continue; + } + if (existing.title !== title && isMachineWrittenTitle(existing)) { + actions.push({ kind: 'rename', projectId: existing.id, title }); + } + } + return actions; +} + +/** + * The server side of a sweep, injected so the decision above can be tested without one. + * + * `readProjects` is a read and must be cheap; the write methods are allowed to open a + * connection lazily, which is what keeps a sweep with nothing to do off the socket + * entirely. `close` is called once per group per sweep whether or not anything was + * written. + */ +export interface WorkspaceProjectGateway { + readProjects(): Promise; + createProject(workspaceRoot: string, title: string): Promise; + renameProject(projectId: string, title: string): Promise; + close(): void; +} + +/** The server a group of workspaces shares. Two roots with the same pair share a gateway. */ +export interface WorkspaceProjectServer { + readonly serverUrl: string; + readonly bootstrapToken: string; +} + +export interface WorkspaceProjectionDeps { + /** Every path Codev has recorded, unfiltered. */ + knownWorkspacePaths: () => readonly string[]; + /** Does this path exist and carry a `.codev/` directory? */ + isCodevWorkspace: (path: string) => boolean; + /** + * The server this workspace is thread-backed by, or `null` when it names none. + * + * A workspace with no server configured is skipped rather than failed: there is + * nowhere to project it to, and that is a configuration, not a fault. A config + * that THROWS (half-configured) is a fault and is reported as one. + */ + serverFor: (workspaceRoot: string) => WorkspaceProjectServer | null; + openGateway: (server: WorkspaceProjectServer) => Promise; + log: (level: 'INFO' | 'WARN', message: string) => void; +} + +export interface WorkspaceProjectionResult { + readonly workspaces: number; + readonly servers: number; + readonly created: number; + readonly renamed: number; + /** One sentence per server group that could not be reconciled. Never thrown. */ + readonly failures: readonly string[]; +} + +/** + * Reconcile every known Codev workspace into a project row on its own server. + * + * Never throws. This runs on an interval inside Tower, and a server that is down is + * a "not yet" — the next sweep tries again. A failure in one group does not stop the + * others, because one unreachable server must not hide every other workspace. + */ +export async function reconcileWorkspaceProjects( + deps: WorkspaceProjectionDeps, +): Promise { + let paths: readonly string[]; + try { + paths = deps.knownWorkspacePaths(); + } catch (err) { + // NOT an empty list. "I could not read the workspaces" reconciles nothing and + // must not be recorded as "there were none to reconcile". + return { + workspaces: 0, + servers: 0, + created: 0, + renamed: 0, + failures: [`could not list known workspaces: ${describe(err)}`], + }; + } + + const roots = codevWorkspaceRoots(paths, (path) => { + try { + return deps.isCodevWorkspace(path); + } catch { + return false; + } + }); + const names = workspaceDisplayNames(roots); + + const failures: string[] = []; + const groups = new Map(); + for (const root of roots) { + let server: WorkspaceProjectServer | null; + try { + server = deps.serverFor(root); + } catch (err) { + failures.push(`${root}: ${describe(err)}`); + continue; + } + if (server === null) continue; + const key = `${server.serverUrl} ${server.bootstrapToken}`; + const existing = groups.get(key); + if (existing) existing.roots.push(root); + else groups.set(key, { server, roots: [root] }); + } + + let created = 0; + let renamed = 0; + for (const group of groups.values()) { + let gateway: WorkspaceProjectGateway; + try { + gateway = await deps.openGateway(group.server); + } catch (err) { + failures.push(`${group.server.serverUrl}: ${describe(err)}`); + continue; + } + try { + const actions = planWorkspaceProjects({ + roots: group.roots, + names, + projects: await gateway.readProjects(), + }); + for (const action of actions) { + if (action.kind === 'create') { + await gateway.createProject(action.workspaceRoot, action.title); + created += 1; + deps.log( + 'INFO', + `Workspace projection: created project "${action.title}" for ${action.workspaceRoot}`, + ); + } else { + await gateway.renameProject(action.projectId, action.title); + renamed += 1; + deps.log( + 'INFO', + `Workspace projection: renamed project ${action.projectId} to "${action.title}"`, + ); + } + } + } catch (err) { + failures.push(`${group.server.serverUrl}: ${describe(err)}`); + } finally { + try { + gateway.close(); + } catch { + // A gateway that cannot be closed has already reported whatever went wrong + // through the failure above; a throw here would lose it. + } + } + } + + return { workspaces: roots.length, servers: groups.size, created, renamed, failures }; +} + +function trailingSegments(workspaceRoot: string): string[] { + return workspaceRoot.split('/').filter((segment) => segment !== '' && segment !== '.'); +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/porch-driver/src/thread.ts b/packages/porch-driver/src/thread.ts index bc270665a..6ef5bf147 100644 --- a/packages/porch-driver/src/thread.ts +++ b/packages/porch-driver/src/thread.ts @@ -104,6 +104,31 @@ export async function createProject( return projectId; } +export interface UpdateProjectMetaOptions { + readonly projectId: string; + /** Absent leaves the stored title alone; `project.meta.update` treats every field that way. */ + readonly title?: string; +} + +/** + * Change a project's metadata in place. + * + * Only the fields present are sent, because `project.meta.update` reads an absent + * field as "leave unchanged" — a helper that filled the rest in from a snapshot + * would turn every rename into a full overwrite of whatever it last read. + */ +export async function updateProjectMeta( + dispatcher: CommandDispatcher, + journal: DispatchJournal, + options: UpdateProjectMetaOptions, +): Promise { + await dispatchCommand(dispatcher, journal, { + type: 'project.meta.update', + projectId: options.projectId, + ...(options.title === undefined ? {} : { title: options.title }), + }); +} + export interface CreateThreadOptions { readonly projectId: string; readonly title: string; diff --git a/tools/t3-fork/issue-272-projection.mjs b/tools/t3-fork/issue-272-projection.mjs new file mode 100644 index 000000000..d4d4511a2 --- /dev/null +++ b/tools/t3-fork/issue-272-projection.mjs @@ -0,0 +1,297 @@ +/** + * Issue #272 — the workspace projection, against a live fork server. + * + * ## Why this exists rather than another unit test + * + * The unit suite drives `reconcileWorkspaceProjects` with a fake gateway, which + * proves the DECISION and nothing about the wire. The gateway itself — a bootstrap + * exchange, an HTTP snapshot read, a lazily-opened socket carrying `project.create` + * and `project.meta.update` — is exactly the part a fake substitutes away, and + * spec 250's own review calls that shape "the costumes": a value asserted at both + * ends and never once carried end to end by the code a human actually runs. + * + * So this runs the REAL gateway against a real fork server and reads the rows back + * out of the projection database with sqlite, not through the code under test. + * + * ## What it asserts + * + * 1. A workspace with no project gets one, titled with its directory name — not + * `codev:`, which is the string the sidebar heading renders. + * 2. A project already carrying the legacy title is RENAMED in place, keeping its + * id. A second row for one workspace root would be refused by the server. + * 3. A title a human chose is left alone. This is the one that keeps the sweep + * safe to run every 30 s. + * 4. Two workspaces whose directories share a name come out distinguishable. + * 5. A second pass writes nothing. A reconciler that is not idempotent is a + * reconciler that fights the server forever. + * + * ## Usage + * + * export T3_NODE=/absolute/path/to/node + * export T3CODE_FORK_ROOT=/path/to/fork T3_HARNESS_PORT= + * node tools/t3-fork/issue-272-projection.mjs [--out ] + * + * Exit 0 when every claim held, 1 when one did not, 3 when it could not tell — + * a missing checkout, a server that would not start, an unreadable database. An + * "I could not tell" spelled like a pass is the failure this whole protocol is + * written against. + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, '..', '..'); +const SERVER = join(REPO, 'tools', 't3-server', 't3-server.mjs'); +const RUNTIME_DB = join( + REPO, 'tools', 't3-server', '.runtime', 'data', 'userdata', 'state.sqlite', +); + +const UNDETERMINED = 3; +const FAILED = 1; + +function die(code, message) { + console.error(`[issue-272-projection] ${message}`); + process.exit(code); +} + +function sh(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', ...options }); +} + +/** + * A workspace on disk, with a `threads` config naming the server. + * + * `.codev/config.local.json` rather than `config.json`: it is the layer the loader + * treats as per-engineer, and writing the token into the committed file in a + * fixture would model a configuration nobody should have. + */ +function makeWorkspace(root, name, serverUrl, token) { + const dir = join(root, name); + mkdirSync(join(dir, '.codev'), { recursive: true }); + writeFileSync( + join(dir, '.codev', 'config.local.json'), + `${JSON.stringify({ threads: { serverUrl, bootstrapToken: token } }, null, 2)}\n`, + ); + return dir; +} + +function projectRows() { + if (!existsSync(RUNTIME_DB)) die(UNDETERMINED, `no projection database at ${RUNTIME_DB}`); + const out = sh('sqlite3', [ + RUNTIME_DB, + 'select project_id, title, workspace_root from projection_projects order by title;', + ]); + return out + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => { + const [id, title, workspaceRoot] = line.split('|'); + return { id, title, workspaceRoot }; + }); +} + +const claims = []; +function claim(name, held, detail) { + claims.push({ name, held, detail }); + console.log(`[issue-272-projection] ${held ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); +} + +async function main() { + const outFlag = process.argv.indexOf('--out'); + const outPath = outFlag === -1 ? null : process.argv[outFlag + 1]; + + let bootstrapToken; + try { + // A server left behind by an earlier run is not a reason to refuse: `start-fork` + // wants an empty data directory anyway, and the assertions about which rows + // exist are only meaningful on one. `stop` on an idle port is not a failure. + try { + sh(process.execPath, [SERVER, 'stop'], { stdio: 'inherit' }); + } catch { + // Nothing was running, or it is not ours. `start-fork` decides which. + } + sh(process.execPath, [SERVER, 'start-fork'], { stdio: 'inherit' }); + const ready = sh(process.execPath, [SERVER, 'ready']); + // `ready` prints JSON after its log lines. Parsing from the first brace is + // what `spec-250-fork-stack.ts` does, for the same reason. + const parsed = JSON.parse(ready.slice(ready.indexOf('{'))); + bootstrapToken = typeof parsed.token === 'string' ? parsed.token : null; + if (!bootstrapToken) die(UNDETERMINED, `the fork server started but printed no pairing token:\n${ready}`); + } catch (err) { + die(UNDETERMINED, `could not start the fork server: ${err.message}`); + } + + const port = process.env.T3_HARNESS_PORT ?? '3799'; + const serverUrl = `http://127.0.0.1:${port}`; + const scratch = mkdtempSync(join(tmpdir(), 'issue-272-')); + + /** + * A FRESH credential per gateway, because the harness's is one-time. + * + * `start-fork` issues a pairing-issued bootstrap token and the server consumes + * it on the first exchange. Production configures a desktop bootstrap seed, + * issued unbounded, precisely so a gateway can exchange on every sweep — the + * constraint `ThreadBackendConfig.bootstrapToken` documents. This fixture has + * the other kind, so it mints one per gateway rather than pretending a spent + * token is a server fault. + */ + const SEED_SCOPES = [ + 'orchestration:read', 'orchestration:operate', 'terminal:operate', + 'review:write', 'relay:read', 'access:write', + ].join(' '); + const exchange = await fetch(`${serverUrl}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: bootstrapToken, + subject_token_type: 'urn:t3:params:oauth:token-type:environment-bootstrap', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + scope: SEED_SCOPES, + client_label: 'issue-272-projection', + client_device_type: 'bot', + }), + }); + if (!exchange.ok) die(UNDETERMINED, `the bootstrap exchange failed with ${exchange.status}`); + const { access_token: accessToken } = await exchange.json(); + + const mintToken = async () => { + const response = await fetch(`${serverUrl}/api/auth/pairing-token`, { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ label: 'issue-272-projection' }), + }); + if (!response.ok) die(UNDETERMINED, `could not mint a credential: ${response.status}`); + const { credential } = await response.json(); + return credential; + }; + + try { + const { reconcileWorkspaceProjects } = await import( + '../../packages/codev/dist/agent-farm/workspace-projection.js' + ); + const { openProjectGateway } = await import( + '../../packages/codev/dist/agent-farm/thread-backend.js' + ); + + const plain = makeWorkspace(scratch, 'codev-1455', serverUrl, bootstrapToken); + const legacy = makeWorkspace(scratch, 'dvarr', serverUrl, bootstrapToken); + const named = makeWorkspace(scratch, 'entriq', serverUrl, bootstrapToken); + const backendApi = makeWorkspace(join(scratch, 'backend'), 'api', serverUrl, bootstrapToken); + const mobileApi = makeWorkspace(join(scratch, 'mobile'), 'api', serverUrl, bootstrapToken); + const roots = [plain, legacy, named, backendApi, mobileApi]; + + // The two rows a sweep must find already there: one wearing the legacy title + // this issue is about, one wearing a name a human chose. They are seeded + // through the same gateway, because a row hand-written into sqlite would not + // have gone through the decider that owns `project.created`. + const seed = await openProjectGateway({ serverUrl, bootstrapToken: await mintToken() }); + try { + await seed.createProject(legacy, `codev:${legacy}`); + await seed.createProject(named, 'Entriq (do not rename)'); + } finally { + seed.close(); + } + + const log = []; + const deps = { + knownWorkspacePaths: () => [ + ...roots, + // The three cases the real `known_workspaces` table contains and a sweep + // must drop: a builder worktree, a deleted checkout, and a directory that + // is not a Codev workspace at all. + join(plain, '.builders', 'pir-272'), + join(scratch, 'deleted-checkout'), + scratch, + ], + isCodevWorkspace: (path) => existsSync(join(path, '.codev')), + serverFor: () => ({ serverUrl, bootstrapToken }), + openGateway: async () => openProjectGateway({ serverUrl, bootstrapToken: await mintToken() }), + log: (level, message) => log.push(`${level} ${message}`), + }; + + const first = await reconcileWorkspaceProjects(deps); + const after = projectRows(); + const byRoot = new Map(after.map((row) => [row.workspaceRoot, row])); + + claim( + 'the sweep reported no failures', + first.failures.length === 0, + first.failures.join('; ') || 'none', + ); + claim( + 'a workspace with no project gets one named after its directory', + byRoot.get(plain)?.title === 'codev-1455', + `title=${byRoot.get(plain)?.title}`, + ); + claim( + 'the legacy codev: title is rewritten to the directory name', + byRoot.get(legacy)?.title === 'dvarr', + `title=${byRoot.get(legacy)?.title}`, + ); + claim( + 'a title a human chose is left alone', + byRoot.get(named)?.title === 'Entriq (do not rename)', + `title=${byRoot.get(named)?.title}`, + ); + claim( + 'two workspaces sharing a directory name come out distinguishable', + byRoot.get(backendApi)?.title === 'backend/api' + && byRoot.get(mobileApi)?.title === 'mobile/api', + `${byRoot.get(backendApi)?.title} / ${byRoot.get(mobileApi)?.title}`, + ); + claim( + 'no project is created for a builder worktree, a deleted checkout, or a non-workspace', + after.length === roots.length, + `${after.length} rows for ${roots.length} workspaces`, + ); + // The rename keeps the row. A second `project.create` for one workspace root + // is refused by the server (`requireActiveProjectWorkspaceRootAbsent`), so a + // "rename" that actually re-created would have failed the sweep above — but + // asserting the id directly says so rather than inferring it. + claim( + 'the rename kept the project id rather than creating a second row', + after.filter((row) => row.workspaceRoot === legacy).length === 1, + `${after.filter((row) => row.workspaceRoot === legacy).length} row(s) for that root`, + ); + + const second = await reconcileWorkspaceProjects(deps); + claim( + 'a second pass writes nothing', + second.created === 0 && second.renamed === 0 && second.failures.length === 0, + `created=${second.created} renamed=${second.renamed} failures=${second.failures.length}`, + ); + + const passed = claims.every((entry) => entry.held); + if (outPath !== undefined && outPath !== null) { + const record = { + issue: 272, + recordedAt: new Date().toISOString().slice(0, 10), + serverUrl, + forkRoot: process.env.T3CODE_FORK_ROOT ?? null, + projects: after, + firstPass: first, + secondPass: second, + claims, + passed, + }; + writeFileSync(resolve(REPO, outPath), `${JSON.stringify(record, null, 2)}\n`); + console.log(`[issue-272-projection] evidence written to ${outPath}`); + } + if (!passed) process.exit(FAILED); + } finally { + rmSync(scratch, { recursive: true, force: true }); + try { + sh(process.execPath, [SERVER, 'stop'], { stdio: 'inherit' }); + } catch { + // Reported by the stop command itself; a throw here would replace a real + // result with a teardown message. + } + } +} + +await main();