From 20a9ab2f25dd8cb82b4365ada6c65e974e13417a Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 10:47:04 -0600 Subject: [PATCH 01/24] chore(porch): 272 init pir --- .../status.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml 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..4f883b49d --- /dev/null +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -0,0 +1,18 @@ +id: '272' +title: the-sidebar-tree-needs-every-w +protocol: pir +phase: plan +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: pending + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-08-31T16:47:04.075Z' +updated_at: '2026-08-31T16:47:04.081Z' From 1a90db3b46c2c2bcabe69d98db54ea09876a8c9b Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 10:54:04 -0600 Subject: [PATCH 02/24] [PIR #272] Plan draft --- .../272-the-sidebar-tree-needs-every-w.md | 251 ++++++++++++++++++ codev/state/pir-272_thread.md | 69 +++++ 2 files changed, 320 insertions(+) create mode 100644 codev/plans/272-the-sidebar-tree-needs-every-w.md create mode 100644 codev/state/pir-272_thread.md 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/state/pir-272_thread.md b/codev/state/pir-272_thread.md new file mode 100644 index 000000000..416f6c480 --- /dev/null +++ b/codev/state/pir-272_thread.md @@ -0,0 +1,69 @@ +# 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. From e5af7fb0cdb5fb825964d80d2b52f2a17e1e942e Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 10:54:09 -0600 Subject: [PATCH 03/24] chore(porch): 272 plan-approval gate-requested --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 4f883b49d..766013b54 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: plan-approval: status: pending + requested_at: '2026-08-31T16:54:09.121Z' dev-approval: status: pending pr: @@ -15,4 +16,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T16:47:04.081Z' +updated_at: '2026-08-31T16:54:09.122Z' From ee9ab475ff932703c8cd84357ad74fc8bb2d92cb Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 10:54:25 -0600 Subject: [PATCH 04/24] chore(porch): 272 plan-approval gate-request-updated --- .../status.yaml | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 index 766013b54..5a89dd2e4 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -8,6 +8,34 @@ gates: plan-approval: status: pending 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 dev-approval: status: pending pr: @@ -16,4 +44,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T16:54:09.122Z' +updated_at: '2026-08-31T16:54:25.037Z' From 803e26028409b849abca82a1e01851cd0d130193 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 11:19:28 -0600 Subject: [PATCH 05/24] chore(porch): 272 plan-approval gate-approved --- .../272-the-sidebar-tree-needs-every-w/status.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 5a89dd2e4..baa5ff76d 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -6,7 +6,7 @@ plan_phases: [] current_plan_phase: null gates: plan-approval: - status: pending + status: approved requested_at: '2026-08-31T16:54:09.121Z' request: question: >- @@ -36,6 +36,12 @@ gates: 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: pending pr: @@ -44,4 +50,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T16:54:25.037Z' +updated_at: '2026-08-31T17:19:28.445Z' From 99bcd442d09c2c9056b4345fb36403f11e0922fc Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 11:21:03 -0600 Subject: [PATCH 06/24] chore(porch): 272 implement phase-transition --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index baa5ff76d..07ca92f8f 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -1,7 +1,7 @@ id: '272' title: the-sidebar-tree-needs-every-w protocol: pir -phase: plan +phase: implement plan_phases: [] current_plan_phase: null gates: @@ -50,4 +50,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T17:19:28.445Z' +updated_at: '2026-08-31T17:21:03.441Z' From 59020b24d7162b5257df1888a5f770f6e35705b5 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 11:37:48 -0600 Subject: [PATCH 07/24] [PIR #272] feat: every known workspace is a project row, named after its directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were wrong on top of a workspace-to-project mapping that works. **The title was a path with a prefix.** `codev:/Users/chris/dev/codev-1455` is what the sidebar heading renders, verbatim — a single-member project group's label IS its title, through `projectGrouping.ts` and `sidebarProjectGrouping.ts` with no cleanup step between them. A column of absolute paths does not scan, and at a narrow width every workspace truncates to the same leading characters. The connect path writes the leaf name now. **A workspace nobody had spawned into had no project row at all.** Not "created on first thread creation", which is what the issue says: it is created by `initialiseThreadBackend`, the thread-backend CONNECT, and only for the workspace being connected. Tower's existing sweep does not close the gap either — it enumerates `architect UNION builders`, and a workspace with neither is exactly the one that was missing. So: a reconciler over `getKnownWorkspacePaths()`, at startup and every 30 s. **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 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. Roots group by the server they name; reads are plain HTTP and the socket opens lazily, so a pass with nothing to do — every pass after the first — never opens one. **Three filters on the enumeration, one per case the real table contains.** Today `known_workspaces` holds `/Users/chris/dev` (a parent directory a terminal was opened in), several deleted checkouts, and `.builders/` worktrees. A project minted for any of them is a permanent sidebar heading for something no Codev command would accept. **Only two titles are ever rewritten, and this codebase wrote both.** A sweep that enforced a computed title would undo a rename made in the t3code UI, silently, every 30 s. So: the legacy `codev:` (compared canonically, so a project stored under `/private/var` is still recognised as ours while `codev:` in front of an unrelated path is not), and the workspace's own leaf name. The second is what makes a project created by a spawn converge on the set-wide unique name — the connect path knows one workspace and cannot see a collision, so `api` and `api` need the sweep to deepen them to `backend/api` and `mobile/api`. `readProjectRows` is split out of `activeProjectForWorkspace` rather than copied: the reconciler needs the whole list and the title on it, and the previous reader kept only `id` and `workspaceRoot` — a reconciler reading that can tell a project exists and can never tell what it is called. A second copy of the request would also be a second place for the transport rules to drift, which is how that call skipped `assertTransportSafe` once already. The sweep never throws. A server that is down is a "not yet", and one unreachable server must not hide every workspace behind every other one. Refs #272, #250. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/agent-farm/servers/tower-server.ts | 37 ++ .../codev/src/agent-farm/thread-backend.ts | 151 ++++++- .../agent-farm/workspace-projection-sweep.ts | 144 +++++++ .../src/agent-farm/workspace-projection.ts | 382 ++++++++++++++++++ packages/porch-driver/src/thread.ts | 25 ++ 5 files changed, 730 insertions(+), 9 deletions(-) create mode 100644 packages/codev/src/agent-farm/workspace-projection-sweep.ts create mode 100644 packages/codev/src/agent-farm/workspace-projection.ts 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..063739709 --- /dev/null +++ b/packages/codev/src/agent-farm/workspace-projection-sweep.ts @@ -0,0 +1,144 @@ +/** + * 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 { existsSync, 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; + } + return existsSync(join(path, '.codev')); +} + +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..ecc09b1e0 --- /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/chris/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; From d07b066b1ca9ec3646e0f4c3a4d911a9ad2c6a63 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 11:41:49 -0600 Subject: [PATCH 08/24] [PIR #272] test: the rules without a server, and the wire with one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite drives the reconciler with a fake gateway, which proves the DECISION and nothing about the wire. The gateway is exactly the part a fake substitutes away — a bootstrap exchange, an HTTP snapshot read, a lazily-opened socket carrying `project.create` and `project.meta.update` — and spec 250's own review has a name for asserting a value at both ends and never carrying it through the code a human runs. So `tools/t3-fork/issue-272-projection.mjs` runs the real gateway against a live fork server and reads the rows back out with sqlite rather than through the code under test. It asserts the rename keeps the project id (a second `project.create` for one workspace root is refused by the server, so a "rename" that re-created would have failed the sweep — but the id says so rather than leaving it inferred), that a human's title survives, that two `api` workspaces come out distinguishable, and that a second pass writes nothing. Exit 3 when it could not tell — a missing checkout, a server that would not start, an unreadable database — and never spelled like a pass. The unit fixtures use the paths the real `known_workspaces` table holds today: a parent directory a terminal was once opened in, deleted checkouts, and `.builders/` worktrees. Every filter exists because that table contains a row it would let through. The connect-path title is a source guard, in the pattern `issue-227-thread-seams.test.ts` uses for CLI wiring that cannot be driven without a live server. It sits past a token exchange, a ticket and an upgrade; the live run above is the behavioural proof and this is the durable half. Every new assertion was confirmed to fail with its change reverted. Refs #272. Co-Authored-By: Claude Opus 5 (1M context) --- codev/state/pir-272_thread.md | 72 +++ .../__tests__/issue-272-project-title.test.ts | 163 +++++++ .../issue-272-workspace-projection.test.ts | 411 ++++++++++++++++++ tools/t3-fork/issue-272-projection.mjs | 245 +++++++++++ 4 files changed, 891 insertions(+) create mode 100644 packages/codev/src/agent-farm/__tests__/issue-272-project-title.test.ts create mode 100644 packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts create mode 100644 tools/t3-fork/issue-272-projection.mjs diff --git a/codev/state/pir-272_thread.md b/codev/state/pir-272_thread.md index 416f6c480..66d874311 100644 --- a/codev/state/pir-272_thread.md +++ b/codev/state/pir-272_thread.md @@ -67,3 +67,75 @@ Fork checkout is clean at `2f64a1b0e`, one worktree, no other writer. PR #274 `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. 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..5b187657a --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/issue-272-workspace-projection.test.ts @@ -0,0 +1,411 @@ +/** + * 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 { describe, expect, it, vi } from 'vitest'; +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([]); + }); +}); diff --git a/tools/t3-fork/issue-272-projection.mjs b/tools/t3-fork/issue-272-projection.mjs new file mode 100644 index 000000000..eab88fbf1 --- /dev/null +++ b/tools/t3-fork/issue-272-projection.mjs @@ -0,0 +1,245 @@ +/** + * 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 token; + try { + sh(process.execPath, [SERVER, 'start-fork'], { stdio: 'inherit' }); + const ready = sh(process.execPath, [SERVER, 'ready']); + token = /Token:\s*([A-Z0-9]+)/.exec(ready)?.[1] ?? null; + if (!token) 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-')); + + 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, token); + const legacy = makeWorkspace(scratch, 'dvarr', serverUrl, token); + const named = makeWorkspace(scratch, 'entriq', serverUrl, token); + const backendApi = makeWorkspace(join(scratch, 'backend'), 'api', serverUrl, token); + const mobileApi = makeWorkspace(join(scratch, 'mobile'), 'api', serverUrl, token); + 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: token }); + 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: token }), + openGateway: (target) => openProjectGateway(target), + 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(); From 3c25fe8b50587df5246a7e86fd523646beed92fa Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 11:48:08 -0600 Subject: [PATCH 09/24] [PIR #272] chore: move pin.commit, regenerate, and re-run the evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pin.contractSource` is `fork`, so a fork HEAD ahead of `pin.commit` makes `t3-server.mjs verify` exit 1 and turns the suite red. The Sidebar commit is a fork commit, so REFRESH.md steps 3-8 are part of this work rather than a follow-up. `pin.commit` → `26b4c2dc09f0`. The regenerated artifacts are byte-identical apart from the sha, which is what a Sidebar-only commit should produce — the closure is `packages/contracts/src` and nothing there moved. Checked rather than assumed; 0 unrepresented, so nothing Codev consumes lost its schema. 35 patches now, one more than before. Four evidence runs re-collected against the new head, because REFRESH.md is explicit that this is the step that gets forgotten: a fork commit touching no closure file changes nothing but a sha, so regeneration looks like the whole job while the acceptance evidence goes on describing the previous fork. - criterion 8b: passed - hierarchy wire: 8 of 8 - rebase drill: still `conflicts`, still the same 3 files, 44 commits carried - upstream movement: 2 undecidable, 3 source-only - `collect-spec-250-evidence.mjs --check`: exit 0 `tools/t3-fork/issue-272-projection.mjs` is the live proof of the Codev half, and it passed all 8 claims against a fork server: three projects created and named after their directories, one legacy `codev:` title rewritten in place keeping its id, `Entriq (do not rename)` untouched, `backend/api` and `mobile/api` distinguishable, nothing minted for a builder worktree or a deleted checkout, and a second pass writing nothing. The acceptance-evidence header now says which rows describe an earlier fork head and were NOT re-run. Rewriting the commit in a historical row without re-running it would turn a record into a claim. Refs #272, #250. Co-Authored-By: Claude Opus 5 (1M context) --- codev/research/250-criterion-8b-evidence.json | 4 +- .../research/250-hierarchy-wire-evidence.json | 6 +- codev/research/250-rebase-drill.json | 18 +- .../272-workspace-projection-evidence.json | 90 +++ codev/resources/250-acceptance-evidence.md | 14 +- .../types/src/t3/generated/ATTRIBUTION.md | 4 +- packages/types/src/t3/generated/schema.ts | 2 +- .../types/src/t3/generated/source-hash.json | 2 +- packages/types/src/t3/generated/types.d.ts | 2 +- packages/types/src/t3/pin.json | 2 +- tools/t3-fork/FORK.md | 1 + tools/t3-fork/issue-272-projection.mjs | 74 ++- ...hase_2-feat-thread-hierarchy-in-the-.patch | 2 +- ...hase_2-test-prove-the-migrator-runs-.patch | 2 +- ...hase_2-test-pin-the-two-guard-log-si.patch | 2 +- ...hase_3-feat-refuse-illegal-hierarchy.patch | 2 +- ...hase_3-fix-the-engine-was-deleting-e.patch | 2 +- ...hase_4-feat-gate-block-with-a-server.patch | 2 +- ...hase_4-test-the-revision-rules-the-s.patch | 2 +- ...hase_4-test-hold-up-the-two-claims-t.patch | 2 +- ...hase_4-fix-the-engine-was-deleting-g.patch | 2 +- ...hase_4-fix-make-the-compiler-refuse-.patch | 2 +- ...hase_4-fix-the-credential-had-no-pro.patch | 2 +- ...hase_4-refactor-derive-Orchestration.patch | 2 +- ...hase_6-fix-a-refusal-s-discriminant-.patch | 2 +- ...hase_7-feat-the-Workspace-Architect-.patch | 2 +- ...hase_7-feat-the-sidebar-draws-Worksp.patch | 2 +- ...hase_7-feat-the-project-level-the-ro.patch | 2 +- ...hase_7-fix-the-builder-count-came-fr.patch | 2 +- ...hase_8-feat-a-porch-gate-says-which-.patch | 2 +- ...hase_8-fix-the-terminal-excerpt-had-.patch | 2 +- ...hase_8-fix-a-gated-architect-lost-it.patch | 2 +- ...hase_8-fix-the-row-marker-is-the-gat.patch | 2 +- ...hase_9-feat-four-to-six-builders-wat.patch | 2 +- ...hase_9-fix-pane-text-was-12px-and-cr.patch | 2 +- ...hase_9-fix-the-sidebar-toggle-sat-on.patch | 2 +- ...hase_9-feat-criterion-4b-the-archite.patch | 2 +- ...hase_9-fix-the-pane-s-role-prefix-co.patch | 2 +- ...hase_9-fix-act-on-the-3-way-review-t.patch | 2 +- ...hase_10-feat-approve-a-gate-from-t3c.patch | 2 +- ...hase_10-fix-the-page-read-the-agent-.patch | 2 +- ...hase_10-fix-a-gated-pane-dropped-the.patch | 2 +- ...hase_10-fix-the-empty-thread-placeho.patch | 2 +- ...hase_10-fix-the-proxy-buffered-reque.patch | 2 +- ...hase_10-fix-act-on-the-3-way-review-.patch | 2 +- ...eview-fix-the-approval-path-had-no-a.patch | 2 +- ...-workspace-with-nothing-running-in-i.patch | 604 ++++++++++++++++++ 47 files changed, 822 insertions(+), 69 deletions(-) create mode 100644 codev/research/272-workspace-projection-evidence.json create mode 100644 tools/t3-fork/patches/0035-Issue-272-feat-a-workspace-with-nothing-running-in-i.patch diff --git a/codev/research/250-criterion-8b-evidence.json b/codev/research/250-criterion-8b-evidence.json index de88cfc6a..7c72acfb4 100644 --- a/codev/research/250-criterion-8b-evidence.json +++ b/codev/research/250-criterion-8b-evidence.json @@ -3,8 +3,8 @@ "preForkCliVersion": "0.0.36", "upstreamBase": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "forkRoot": "/Users/chris/dev/t3code-codev", - "forkCommit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", - "dbPath": "/Users/chris/dev/codev-1455/.builders/spir-250/tools/t3-server/.runtime/data/userdata/state.sqlite", + "forkCommit": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", + "dbPath": "/Users/chris/dev/codev-1455/.builders/pir-272/tools/t3-server/.runtime/data/userdata/state.sqlite", "steps": { "preForkServerCreatedDatabase": true, "columnsBeforeGuard": [], diff --git a/codev/research/250-hierarchy-wire-evidence.json b/codev/research/250-hierarchy-wire-evidence.json index 9a8dd1322..c0e35285a 100644 --- a/codev/research/250-hierarchy-wire-evidence.json +++ b/codev/research/250-hierarchy-wire-evidence.json @@ -1,9 +1,9 @@ { "_comment": "Spec 250 phase 6. Generated by packages/t3-client/live/spec-250-hierarchy.mjs against a live FORK server started with `t3-server.mjs start-fork`. Do not hand-edit.", - "recordedAt": "2026-08-31T12:47:14.034Z", + "recordedAt": "2026-08-31T17:37:58.499Z", "forkRoot": "/Users/chris/dev/t3code-codev", - "forkCommit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", - "port": 3830, + "forkCommit": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", + "port": 3809, "algorithm": "sha256", "sourceHashes": { "packages/t3-client/live/spec-250-hierarchy.mjs": "101afb9c5d84b01d93ba6966f5f40ebe6e02414b3d23b95409e67de8ce302452", diff --git a/codev/research/250-rebase-drill.json b/codev/research/250-rebase-drill.json index eda728e1f..c06993240 100644 --- a/codev/research/250-rebase-drill.json +++ b/codev/research/250-rebase-drill.json @@ -19,7 +19,7 @@ "Auto-merging packages/contracts/src/auth.ts", "Auto-merging packages/contracts/src/orchestration.ts", "Auto-merging packages/contracts/src/rpc.ts", - "Rebasing (1/43)\rRebasing (2/43)\rRebasing (3/43)\rRebasing (4/43)\rRebasing (5/43)\rRebasing (6/43)\rerror: could not apply 3a1780bbf... [Spec 250][Phase: phase_4] feat: gate block with a server-allocated revision", + "Rebasing (1/44)\rRebasing (2/44)\rRebasing (3/44)\rRebasing (4/44)\rRebasing (5/44)\rRebasing (6/44)\rerror: could not apply 3a1780bbf... [Spec 250][Phase: phase_4] feat: gate block with a server-allocated revision", "hint: Resolve all conflicts manually, mark them as resolved with", "hint: \"git add/rm \", then run \"git rebase --continue\".", "hint: You can instead skip this commit: run \"git rebase --skip\".", @@ -53,7 +53,7 @@ "sourceHash": { "checked": true, "algorithm": "sha256", - "comparedTo": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "comparedTo": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", "files": { "auth.ts": "89374198ca06cfc7d21e7080f291de79df34cdc3f9bd9453a1e68895388e16d4", "baseSchemas.ts": "0fea5d24348912260361716e01cf1c9ce1cd4d456a434471069a9215c4e8f1d7", @@ -78,11 +78,11 @@ "contractRegeneration": { "attempted": true, "source": { - "commit": "704c037d98934892213590fe41801e62cc88cce6", + "commit": "994fc2ce8007a0905bb304ee04ef270b3bc6d0f1", "kind": "the three-way merge of the same two trees, written by git merge-tree. Conflicted paths carry markers; none of them is in the contract closure." }, "method": "a scratch pin naming the merged commit, in a scratch copy of the codegen tool. The real pin.json was neither read nor written for this, and both real checkouts are untouched.", - "interpreter": "v22.22.2", + "interpreter": "v26.4.0", "generated": true, "shapeCheckHolds": false, "artifactsDiffering": [ @@ -104,8 +104,8 @@ "hashMovedShapesDidNot": false, "detail": "the contract regenerates, and 3 shape artifacts would change: schema.json, schema.ts, types.d.ts. That is what adopting this base costs, measured rather than predicted." }, - "startedAt": "2026-08-31T12:47:23.529Z", - "finishedAt": "2026-08-31T12:47:29.073Z", + "startedAt": "2026-08-31T17:36:48.061Z", + "finishedAt": "2026-08-31T17:36:55.444Z", "upstreamChurn": { "range": "082e6ea52186..9b2d04317c68", "commits": 104, @@ -127,8 +127,8 @@ "base": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "target": "9b2d04317c68233782e0630464ac86d77d0686f3", "targetRef": "origin/main", - "forkHead": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", - "commitsCarried": 43, + "forkHead": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", + "commitsCarried": 44, "scratch": null, "watermark": { "checked": true, @@ -145,7 +145,7 @@ "upstreamHead": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "upstreamStillAtBase": true, "upstreamClean": true, - "forkHead": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "forkHead": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", "forkUnmoved": true, "forkClean": true, "pinCommitUnchanged": true 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/250-acceptance-evidence.md b/codev/resources/250-acceptance-evidence.md index 8e42db2d2..33a10e0a5 100644 --- a/codev/resources/250-acceptance-evidence.md +++ b/codev/resources/250-acceptance-evidence.md @@ -5,6 +5,12 @@ no test says so** rather than borrowing another criterion's evidence. Recorded 2026-08-31. Fork at `3786b840e1a4`; upstream preserved at `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6`. +**Issue #272 moved `pin.commit` to `26b4c2dc09f0`.** The generated block below was re-collected +against that head, and so were the four evidence runs it draws on. The prose rows further down that +name `3786b840e1a4` or `2f64a1b0ee2b` are **not** re-runs — they record what was run against those +heads during spec 250, and rewriting the commit in them without re-running would turn a record into +a claim. Where a row was re-run at the new head, it says so. + **10 of 11 met. Criterion 6 is UNMET and says why. Criterion 9 is met under the plan's amended reading and not under a literal one, and the difference is set out rather than smoothed over.** Nothing here borrows another criterion's evidence, and nothing that was not run is recorded as @@ -18,11 +24,11 @@ _Generated by `tools/t3-server/collect-spec-250-evidence.mjs` from the runs name | Measurement | Value | From | |---|---|---| -| fork pinned at | `2f64a1b0ee2b` | `pin.json` | +| fork pinned at | `26b4c2dc09f0` | `pin.json` | | upstream base | `082e6ea52186` | `pin.json` | | drill target | `9b2d04317c68` (`origin/main`) | rebase drill | | drill outcome | `conflicts` | rebase drill | -| customization commits carried | 43 | rebase drill | +| customization commits carried | 44 | rebase drill | | sequential rebase stops at | `3a1780bbf`, on apps/server/src/server.test.ts | rebase drill | | whole conflict surface | **3** files: `apps/server/src/server.test.ts`, `apps/web/src/components/Sidebar.logic.ts`, `apps/web/src/components/Sidebar.tsx` | rebase drill | | upstream commits in the range | 104 | rebase drill | @@ -30,7 +36,7 @@ _Generated by `tools/t3-server/collect-spec-250-evidence.mjs` from the runs name | ...classified | 2 `consumed-change-undecidable`, 3 `source-only` | `classify-churn --upstream-movement` | | regeneration blocked by the rebase | no — zero closure conflicts | rebase drill | | closure files the rebased tree would change | **4 of 9**: `auth.ts`, `baseSchemas.ts`, `environment.ts`, `orchestration.ts` | rebase drill | -| contract regenerated from the rebased tree | yes, from 704c037d9893 | rebase drill | +| contract regenerated from the rebased tree | yes, from 994fc2ce8007 | rebase drill | | `shape-check` against the vendored contract | **3 shape artifact(s) would change**: `schema.json`, `schema.ts`, `types.d.ts` | rebase drill | | watermark at base | 42 | rebase drill | | migrations upstream added | 43 | rebase drill | @@ -227,7 +233,7 @@ stated reason, not as passed and not left open. | Tree | Command | Result | |---|---|---| | Codev | `npm test -- --exclude='**/e2e/**'` | **7396 + 180 passed, 58 skipped, 0 failed**, exit 0. Earlier runs of the same command reported 7377 (with the two timeouts diagnosed below) and 7387; the count grew by the phase 11 review-response and regeneration tests | -| Codev, e2e | `npx playwright test --config playwright.spec250.config.ts` at fork head `2f64a1b0ee2b` | **32 passed** in 2.3m, across all 4 spec-250 spec files. Re-run at the new head after the review round moved the pin — the previous run described `3786b840e1a4` and describing the shipped fork is the whole point | +| Codev, e2e | `npx playwright test --config playwright.spec250.config.ts` at fork head `26b4c2dc09f0` | **32 passed** across all 4 spec-250 spec files. Re-run at the new head after issue #272 moved the pin — describing the shipped fork is the whole point. The first pass reported 31 passed and one timeout in `spec-250-approval.spec.ts › screenshots at 1920`, waiting on a sidebar row; the same test at the other two viewports passed in that run, and a re-run of that file was **6 passed**, 1920 included. Recorded as a flake rather than smoothed out of the count | | Fork, web | `apps/web && npx vp test run` | **2984 passed** | | Fork, server | `apps/server && npx vp test run` (whole server suite) | **2873 passed, 8 skipped, 1 failed** — the `entrypoint.test.ts` symlink one | | Fork, typecheck | `vp run --filter @t3tools/contracts --filter t3 --filter @t3tools/web typecheck` | clean | diff --git a/packages/types/src/t3/generated/ATTRIBUTION.md b/packages/types/src/t3/generated/ATTRIBUTION.md index 369174b51..55b4b5953 100644 --- a/packages/types/src/t3/generated/ATTRIBUTION.md +++ b/packages/types/src/t3/generated/ATTRIBUTION.md @@ -3,11 +3,11 @@ The files in this directory are **generated from t3code**, which is MIT licensed. They are generated from a **private modified copy** of t3code, not from the upstream -repository, so both are named. `2f64a1b0ee2b35cd858a8b601b4d425216e73ae5` exists only in the fork; looking for it in +repository, so both are named. `26b4c2dc09f0fe2f6798e9b781df6722603b5bfe` exists only in the fork; looking for it in https://github.com/pingdotgg/t3code would not find it, and an attribution that named upstream alone would be pointing at a commit that is not the source of these files. -- Generated from: https://github.com/pseudoseed/t3code.git — commit `2f64a1b0ee2b35cd858a8b601b4d425216e73ae5` (2026-08-31), branch `codev` +- Generated from: https://github.com/pseudoseed/t3code.git — commit `26b4c2dc09f0fe2f6798e9b781df6722603b5bfe` (2026-08-31), branch `codev` - Which branched from: https://github.com/pingdotgg/t3code — commit `082e6ea521861fff37b90fcd789b5eaa5ef5d6a6` (2026-08-25) - Generated by: `tools/t3-codegen/generate.mjs` - Modifications: see `tools/t3-fork/FORK.md` and the exported patches in `tools/t3-fork/patches/` diff --git a/packages/types/src/t3/generated/schema.ts b/packages/types/src/t3/generated/schema.ts index 6ead6c4af..888fc1006 100644 --- a/packages/types/src/t3/generated/schema.ts +++ b/packages/types/src/t3/generated/schema.ts @@ -1,5 +1,5 @@ // GENERATED by tools/t3-codegen — do not edit. -// Generated from: https://github.com/pseudoseed/t3code.git @ 2f64a1b0ee2b35cd858a8b601b4d425216e73ae5 (a private modified copy) +// Generated from: https://github.com/pseudoseed/t3code.git @ 26b4c2dc09f0fe2f6798e9b781df6722603b5bfe (a private modified copy) // Which branched from: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 // // A LOWER BOUND on t3code's validation, not an equivalent. See LOSSY.md. diff --git a/packages/types/src/t3/generated/source-hash.json b/packages/types/src/t3/generated/source-hash.json index ea7d143bf..7c4153d65 100644 --- a/packages/types/src/t3/generated/source-hash.json +++ b/packages/types/src/t3/generated/source-hash.json @@ -1,5 +1,5 @@ { - "commit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "commit": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", "algorithm": "sha256", "files": { "auth.ts": "a2a8f6bd76102cfa11c62bc2d55d93bdae3d4a94ff71886607991ae2ab362304", diff --git a/packages/types/src/t3/generated/types.d.ts b/packages/types/src/t3/generated/types.d.ts index d687d845a..6df82a3f9 100644 --- a/packages/types/src/t3/generated/types.d.ts +++ b/packages/types/src/t3/generated/types.d.ts @@ -1,5 +1,5 @@ // GENERATED by tools/t3-codegen — do not edit. -// Generated from: https://github.com/pseudoseed/t3code.git @ 2f64a1b0ee2b35cd858a8b601b4d425216e73ae5 (a private modified copy) +// Generated from: https://github.com/pseudoseed/t3code.git @ 26b4c2dc09f0fe2f6798e9b781df6722603b5bfe (a private modified copy) // Which branched from: https://github.com/pingdotgg/t3code @ 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 // // Derived from the emitted JSON Schema, not from the Effect source, so these diff --git a/packages/types/src/t3/pin.json b/packages/types/src/t3/pin.json index aaf1b9f63..391d45d7b 100644 --- a/packages/types/src/t3/pin.json +++ b/packages/types/src/t3/pin.json @@ -2,7 +2,7 @@ "_comment": "The pinned t3code contract. Edited only by the refresh procedure in tools/t3-codegen/REFRESH.md. Spec 146.", "_identities": "Spec 250: two identities, not one. `commit` keeps its spec 146 meaning (the commit the generated artifacts came from) and that source is the FORK from phase 5 onward. `upstreamBase` is the pingdotgg commit the fork branched from, and the pin the read-only upstream clone must stay on. They are equal until the fork diverges, which is deliberate: while they are equal every two-identity assertion has a known answer.", "repo": "https://github.com/pingdotgg/t3code", - "commit": "2f64a1b0ee2b35cd858a8b601b4d425216e73ae5", + "commit": "26b4c2dc09f0fe2f6798e9b781df6722603b5bfe", "upstreamBase": "082e6ea521861fff37b90fcd789b5eaa5ef5d6a6", "forkRepo": "https://github.com/pseudoseed/t3code.git", "forkBranch": "codev", diff --git a/tools/t3-fork/FORK.md b/tools/t3-fork/FORK.md index b90341b03..4129d31d9 100644 --- a/tools/t3-fork/FORK.md +++ b/tools/t3-fork/FORK.md @@ -283,6 +283,7 @@ vendored contract is what decides whether Codev depends on the customization. | 10 | `24aeeebb3` | The proxy buffered request bodies with no bound. `MAX_PROXIED_BODY_BYTES` at 64 KiB, with "too large" and "malformed" given different signals — a chunked body declares no length, so the cap on the read is what answers for it. | | 10 | `3786b840e` | 3-way review fixes: `UPSTREAM_TIMEOUT_MS` claimed more than an idle timeout gives, and `data-codev-approval-state` was coarser than its own words. | | review | `2f64a1b0e` | The codex lane's two blocking findings. `send` in `approval.ts` returns transport failure as a **value**, so all five call sites must answer for a dead network — three pre-submit steps report a definite `AGENT_UNREACHABLE_*` because nothing was submitted, and both submit routes report `unconfirmed` because the request may have arrived. `GateApproval` gains the `catch` its `finally` never had. And `MAX_PROXIED_RESPONSE_BYTES` bounds the **return** path, which `24aeeebb3` had left unbounded on the same file. | +| #272 | `26b4c2dc0` | The tree's project level was derived entirely from architects, so a project with none drew no heading and was absent from the sidebar. Codev now registers a project per workspace whether or not anything has been spawned there, which makes "nobody has spawned here yet" the ordinary case — and it was rendering identically to "this workspace does not exist". A project group with no architect draws its heading with nothing under it. `CodevSidebarEntry` gains an `empty-project` case, so every consumer had to say what it does with an entry that carries no thread; `codevOrderedThreads` is that answer for the six that wanted "the rows, in render order". | ### Both bounds, and why the return path was the worse one diff --git a/tools/t3-fork/issue-272-projection.mjs b/tools/t3-fork/issue-272-projection.mjs index eab88fbf1..d4d4511a2 100644 --- a/tools/t3-fork/issue-272-projection.mjs +++ b/tools/t3-fork/issue-272-projection.mjs @@ -104,12 +104,23 @@ async function main() { const outFlag = process.argv.indexOf('--out'); const outPath = outFlag === -1 ? null : process.argv[outFlag + 1]; - let token; + 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']); - token = /Token:\s*([A-Z0-9]+)/.exec(ready)?.[1] ?? null; - if (!token) die(UNDETERMINED, `the fork server started but printed no pairing token:\n${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}`); } @@ -118,6 +129,47 @@ async function main() { 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' @@ -126,18 +178,18 @@ async function main() { '../../packages/codev/dist/agent-farm/thread-backend.js' ); - const plain = makeWorkspace(scratch, 'codev-1455', serverUrl, token); - const legacy = makeWorkspace(scratch, 'dvarr', serverUrl, token); - const named = makeWorkspace(scratch, 'entriq', serverUrl, token); - const backendApi = makeWorkspace(join(scratch, 'backend'), 'api', serverUrl, token); - const mobileApi = makeWorkspace(join(scratch, 'mobile'), 'api', serverUrl, token); + 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: token }); + const seed = await openProjectGateway({ serverUrl, bootstrapToken: await mintToken() }); try { await seed.createProject(legacy, `codev:${legacy}`); await seed.createProject(named, 'Entriq (do not rename)'); @@ -157,8 +209,8 @@ async function main() { scratch, ], isCodevWorkspace: (path) => existsSync(join(path, '.codev')), - serverFor: () => ({ serverUrl, bootstrapToken: token }), - openGateway: (target) => openProjectGateway(target), + serverFor: () => ({ serverUrl, bootstrapToken }), + openGateway: async () => openProjectGateway({ serverUrl, bootstrapToken: await mintToken() }), log: (level, message) => log.push(`${level} ${message}`), }; diff --git a/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch b/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch index 880c25928..4d9e692c6 100644 --- a/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch +++ b/tools/t3-fork/patches/0001-Spec-250-Phase-phase_2-feat-thread-hierarchy-in-the-.patch @@ -1,7 +1,7 @@ From 1a414cee8409a407977ff6c6505fad1ab82f2ec8 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 15:49:16 -0600 -Subject: [PATCH 01/34] [Spec 250][Phase: phase_2] feat: thread hierarchy in +Subject: [PATCH 01/35] [Spec 250][Phase: phase_2] feat: thread hierarchy in the contract and projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch b/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch index ee1944005..c608028e8 100644 --- a/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch +++ b/tools/t3-fork/patches/0002-Spec-250-Phase-phase_2-test-prove-the-migrator-runs-.patch @@ -1,7 +1,7 @@ From 992b781f4314ec1df1abb752c7c9c5378ec13c26 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 16:15:14 -0600 -Subject: [PATCH 02/34] [Spec 250][Phase: phase_2] test: prove the migrator +Subject: [PATCH 02/35] [Spec 250][Phase: phase_2] test: prove the migrator runs, not that SQLite accepts a column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch b/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch index 9285919b2..dde7e1aea 100644 --- a/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch +++ b/tools/t3-fork/patches/0003-Spec-250-Phase-phase_2-test-pin-the-two-guard-log-si.patch @@ -1,7 +1,7 @@ From e1a858434a8096d7a82e05347f8159d94f42c0b1 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 16:36:33 -0600 -Subject: [PATCH 03/34] [Spec 250][Phase: phase_2] test: pin the two guard log +Subject: [PATCH 03/35] [Spec 250][Phase: phase_2] test: pin the two guard log signals Review finding: the two signals ARE the mitigation for staying out of the diff --git a/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch b/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch index bbda7750b..868fc6b07 100644 --- a/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch +++ b/tools/t3-fork/patches/0004-Spec-250-Phase-phase_3-feat-refuse-illegal-hierarchy.patch @@ -1,7 +1,7 @@ From e1b7f7b04af5aa869a552baa622fc9e526a00bb3 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 16:55:23 -0600 -Subject: [PATCH 04/34] [Spec 250][Phase: phase_3] feat: refuse illegal +Subject: [PATCH 04/35] [Spec 250][Phase: phase_3] feat: refuse illegal hierarchy edges at write time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch b/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch index 7f7573a6b..1f7569542 100644 --- a/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch +++ b/tools/t3-fork/patches/0005-Spec-250-Phase-phase_3-fix-the-engine-was-deleting-e.patch @@ -1,7 +1,7 @@ From 40fb82ce92a8ed42e6868bd946bfee00b79b3022 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 17:21:55 -0600 -Subject: [PATCH 05/34] [Spec 250][Phase: phase_3] fix: the engine was deleting +Subject: [PATCH 05/35] [Spec 250][Phase: phase_3] fix: the engine was deleting every reason discriminant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch b/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch index e8edda2e7..c963d848c 100644 --- a/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch +++ b/tools/t3-fork/patches/0006-Spec-250-Phase-phase_4-feat-gate-block-with-a-server.patch @@ -1,7 +1,7 @@ From 3a1780bbf66f212f55cb3378e5e5a5ad891e4f7e Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 18:00:02 -0600 -Subject: [PATCH 06/34] [Spec 250][Phase: phase_4] feat: gate block with a +Subject: [PATCH 06/35] [Spec 250][Phase: phase_4] feat: gate block with a server-allocated revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch b/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch index a8cb74884..06f0bcc60 100644 --- a/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch +++ b/tools/t3-fork/patches/0007-Spec-250-Phase-phase_4-test-the-revision-rules-the-s.patch @@ -1,7 +1,7 @@ From 57d24ddcb3be0fe1b893948274dadc466c67b81e Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 18:05:59 -0600 -Subject: [PATCH 07/34] [Spec 250][Phase: phase_4] test: the revision rules, +Subject: [PATCH 07/35] [Spec 250][Phase: phase_4] test: the revision rules, the scope exclusions, the payload bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch b/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch index 59fcac0d5..164749e72 100644 --- a/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch +++ b/tools/t3-fork/patches/0008-Spec-250-Phase-phase_4-test-hold-up-the-two-claims-t.patch @@ -1,7 +1,7 @@ From 6e8bdec207d6b1f531df581657b747ffc8f5c826 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 18:26:46 -0600 -Subject: [PATCH 08/34] [Spec 250][Phase: phase_4] test: hold up the two claims +Subject: [PATCH 08/35] [Spec 250][Phase: phase_4] test: hold up the two claims the deviation rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch b/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch index 995925a1e..c0f54dc1f 100644 --- a/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch +++ b/tools/t3-fork/patches/0009-Spec-250-Phase-phase_4-fix-the-engine-was-deleting-g.patch @@ -1,7 +1,7 @@ From 3d0e76776cd9fa76947099c2f2c4635ae8c047ed Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 18:44:30 -0600 -Subject: [PATCH 09/34] [Spec 250][Phase: phase_4] fix: the engine was deleting +Subject: [PATCH 09/35] [Spec 250][Phase: phase_4] fix: the engine was deleting gate refusals too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0010-Spec-250-Phase-phase_4-fix-make-the-compiler-refuse-.patch b/tools/t3-fork/patches/0010-Spec-250-Phase-phase_4-fix-make-the-compiler-refuse-.patch index f1c700572..1a4964591 100644 --- a/tools/t3-fork/patches/0010-Spec-250-Phase-phase_4-fix-make-the-compiler-refuse-.patch +++ b/tools/t3-fork/patches/0010-Spec-250-Phase-phase_4-fix-make-the-compiler-refuse-.patch @@ -1,7 +1,7 @@ From 570cc29dc63ca1e64401e9ddd3823a64fa32dd49 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 19:08:27 -0600 -Subject: [PATCH 10/34] [Spec 250][Phase: phase_4] fix: make the compiler +Subject: [PATCH 10/35] [Spec 250][Phase: phase_4] fix: make the compiler refuse an unclassified refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch b/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch index ae9becd8e..5fed0b07f 100644 --- a/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch +++ b/tools/t3-fork/patches/0011-Spec-250-Phase-phase_4-fix-the-credential-had-no-pro.patch @@ -1,7 +1,7 @@ From 0254c84e1241587c93ce23425271652a9037f05f Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 19:32:36 -0600 -Subject: [PATCH 11/34] [Spec 250][Phase: phase_4] fix: the credential had no +Subject: [PATCH 11/35] [Spec 250][Phase: phase_4] fix: the credential had no production caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch b/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch index 96cff5a6a..73cb2a7a9 100644 --- a/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch +++ b/tools/t3-fork/patches/0012-Spec-250-Phase-phase_4-refactor-derive-Orchestration.patch @@ -1,7 +1,7 @@ From 51b55d4899e4d900dfa0a7995f6f9200c53d10c0 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 20:48:18 -0600 -Subject: [PATCH 12/34] [Spec 250][Phase: phase_4] refactor: derive +Subject: [PATCH 12/35] [Spec 250][Phase: phase_4] refactor: derive OrchestrationRefusal from the classification table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0013-Spec-250-Phase-phase_6-fix-a-refusal-s-discriminant-.patch b/tools/t3-fork/patches/0013-Spec-250-Phase-phase_6-fix-a-refusal-s-discriminant-.patch index e8b4d1a24..7e1f12b6d 100644 --- a/tools/t3-fork/patches/0013-Spec-250-Phase-phase_6-fix-a-refusal-s-discriminant-.patch +++ b/tools/t3-fork/patches/0013-Spec-250-Phase-phase_6-fix-a-refusal-s-discriminant-.patch @@ -1,7 +1,7 @@ From 804e56f8f864025fc5f9bedc1fcb4fbe23d2b87f Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 22:12:03 -0600 -Subject: [PATCH 13/34] [Spec 250][Phase: phase_6] fix: a refusal's +Subject: [PATCH 13/35] [Spec 250][Phase: phase_6] fix: a refusal's discriminant did not survive the ws boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch b/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch index 16a1bdacb..9e5d786be 100644 --- a/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch +++ b/tools/t3-fork/patches/0014-Spec-250-Phase-phase_7-feat-the-Workspace-Architect-.patch @@ -1,7 +1,7 @@ From 4633e0a7f4982785a43b265f23280d17d139df07 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 23:10:42 -0600 -Subject: [PATCH 14/34] [Spec 250][Phase: phase_7] feat: the Workspace > +Subject: [PATCH 14/35] [Spec 250][Phase: phase_7] feat: the Workspace > Architect > Builders grouping, as a function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch b/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch index e2705ca92..3309ee043 100644 --- a/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch +++ b/tools/t3-fork/patches/0015-Spec-250-Phase-phase_7-feat-the-sidebar-draws-Worksp.patch @@ -1,7 +1,7 @@ From 90a5a2d3a3123f8eae7d280ed46fd6e7b3640db1 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 23:22:52 -0600 -Subject: [PATCH 15/34] [Spec 250][Phase: phase_7] feat: the sidebar draws +Subject: [PATCH 15/35] [Spec 250][Phase: phase_7] feat: the sidebar draws Workspace > Architect > Builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch b/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch index 7eb770334..cad7b589f 100644 --- a/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch +++ b/tools/t3-fork/patches/0016-Spec-250-Phase-phase_7-feat-the-project-level-the-ro.patch @@ -1,7 +1,7 @@ From a183f56ecec2c039fb0b2b33a2ea75b7316bce76 Mon Sep 17 00:00:00 2001 From: pseudo Date: Sun, 30 Aug 2026 23:50:54 -0600 -Subject: [PATCH 16/34] [Spec 250][Phase: phase_7] feat: the project level, the +Subject: [PATCH 16/35] [Spec 250][Phase: phase_7] feat: the project level, the role marker, and an orphan group that is not a warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch b/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch index 82ee6057e..b923c4c5f 100644 --- a/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch +++ b/tools/t3-fork/patches/0017-Spec-250-Phase-phase_7-fix-the-builder-count-came-fr.patch @@ -1,7 +1,7 @@ From 7c7096d49de9f7aef8dfd0fc6f97aa81ec33ddac Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 00:10:31 -0600 -Subject: [PATCH 17/34] [Spec 250][Phase: phase_7] fix: the builder count came +Subject: [PATCH 17/35] [Spec 250][Phase: phase_7] fix: the builder count came from the render, so it could only agree with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch b/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch index 147ed9d31..d1faccdc9 100644 --- a/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch +++ b/tools/t3-fork/patches/0018-Spec-250-Phase-phase_8-feat-a-porch-gate-says-which-.patch @@ -1,7 +1,7 @@ From 5e8ace3b186f3452a82da0272ceb3ec157f02a62 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 00:32:32 -0600 -Subject: [PATCH 18/34] [Spec 250][Phase: phase_8] feat: a porch gate says +Subject: [PATCH 18/35] [Spec 250][Phase: phase_8] feat: a porch gate says which gate, and what it is asking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0019-Spec-250-Phase-phase_8-fix-the-terminal-excerpt-had-.patch b/tools/t3-fork/patches/0019-Spec-250-Phase-phase_8-fix-the-terminal-excerpt-had-.patch index 275b80b77..77fb528b8 100644 --- a/tools/t3-fork/patches/0019-Spec-250-Phase-phase_8-fix-the-terminal-excerpt-had-.patch +++ b/tools/t3-fork/patches/0019-Spec-250-Phase-phase_8-fix-the-terminal-excerpt-had-.patch @@ -1,7 +1,7 @@ From 90d2b118b7864eb44a2c88748e9da1654856315f Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 00:45:19 -0600 -Subject: [PATCH 19/34] [Spec 250][Phase: phase_8] fix: the terminal excerpt +Subject: [PATCH 19/35] [Spec 250][Phase: phase_8] fix: the terminal excerpt had no label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0020-Spec-250-Phase-phase_8-fix-a-gated-architect-lost-it.patch b/tools/t3-fork/patches/0020-Spec-250-Phase-phase_8-fix-a-gated-architect-lost-it.patch index e3e1f6f91..cb3572a90 100644 --- a/tools/t3-fork/patches/0020-Spec-250-Phase-phase_8-fix-a-gated-architect-lost-it.patch +++ b/tools/t3-fork/patches/0020-Spec-250-Phase-phase_8-fix-a-gated-architect-lost-it.patch @@ -1,7 +1,7 @@ From 81c2463d7ec156a8d38475d2b88753e1753021f9 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 00:48:06 -0600 -Subject: [PATCH 20/34] [Spec 250][Phase: phase_8] fix: a gated architect lost +Subject: [PATCH 20/35] [Spec 250][Phase: phase_8] fix: a gated architect lost its role caption to the gate marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0021-Spec-250-Phase-phase_8-fix-the-row-marker-is-the-gat.patch b/tools/t3-fork/patches/0021-Spec-250-Phase-phase_8-fix-the-row-marker-is-the-gat.patch index d90b494e2..4a6128721 100644 --- a/tools/t3-fork/patches/0021-Spec-250-Phase-phase_8-fix-the-row-marker-is-the-gat.patch +++ b/tools/t3-fork/patches/0021-Spec-250-Phase-phase_8-fix-the-row-marker-is-the-gat.patch @@ -1,7 +1,7 @@ From 98e950e42a26c50f84d2112a0c0050a0e797797e Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 00:51:07 -0600 -Subject: [PATCH 21/34] [Spec 250][Phase: phase_8] fix: the row marker is the +Subject: [PATCH 21/35] [Spec 250][Phase: phase_8] fix: the row marker is the gate name and a gavel, not 'Gate: ' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0022-Spec-250-Phase-phase_9-feat-four-to-six-builders-wat.patch b/tools/t3-fork/patches/0022-Spec-250-Phase-phase_9-feat-four-to-six-builders-wat.patch index 4ee280cea..394fc903e 100644 --- a/tools/t3-fork/patches/0022-Spec-250-Phase-phase_9-feat-four-to-six-builders-wat.patch +++ b/tools/t3-fork/patches/0022-Spec-250-Phase-phase_9-feat-four-to-six-builders-wat.patch @@ -1,7 +1,7 @@ From 36038cdcb28382ad09d4612c5d86bab337c9ebfe Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:05:17 -0600 -Subject: [PATCH 22/34] [Spec 250][Phase: phase_9] feat: four to six builders +Subject: [PATCH 22/35] [Spec 250][Phase: phase_9] feat: four to six builders watchable at once, inside t3code's chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0023-Spec-250-Phase-phase_9-fix-pane-text-was-12px-and-cr.patch b/tools/t3-fork/patches/0023-Spec-250-Phase-phase_9-fix-pane-text-was-12px-and-cr.patch index 13193defe..3c593a6f0 100644 --- a/tools/t3-fork/patches/0023-Spec-250-Phase-phase_9-fix-pane-text-was-12px-and-cr.patch +++ b/tools/t3-fork/patches/0023-Spec-250-Phase-phase_9-fix-pane-text-was-12px-and-cr.patch @@ -1,7 +1,7 @@ From d2e675a7aa08703998b52d802e5e16d5cf43432e Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:05:56 -0600 -Subject: [PATCH 23/34] [Spec 250][Phase: phase_9] fix: pane text was 12px and +Subject: [PATCH 23/35] [Spec 250][Phase: phase_9] fix: pane text was 12px and criterion 5 puts the floor at 13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0024-Spec-250-Phase-phase_9-fix-the-sidebar-toggle-sat-on.patch b/tools/t3-fork/patches/0024-Spec-250-Phase-phase_9-fix-the-sidebar-toggle-sat-on.patch index cec222345..8c3d70137 100644 --- a/tools/t3-fork/patches/0024-Spec-250-Phase-phase_9-fix-the-sidebar-toggle-sat-on.patch +++ b/tools/t3-fork/patches/0024-Spec-250-Phase-phase_9-fix-the-sidebar-toggle-sat-on.patch @@ -1,7 +1,7 @@ From 36717ab7ecfcd3e2262580c6c3b56d68a67d4929 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:07:28 -0600 -Subject: [PATCH 24/34] [Spec 250][Phase: phase_9] fix: the sidebar toggle sat +Subject: [PATCH 24/35] [Spec 250][Phase: phase_9] fix: the sidebar toggle sat on the first pane's title at 390 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0025-Spec-250-Phase-phase_9-feat-criterion-4b-the-archite.patch b/tools/t3-fork/patches/0025-Spec-250-Phase-phase_9-feat-criterion-4b-the-archite.patch index 0f993de4b..86fb4d6a7 100644 --- a/tools/t3-fork/patches/0025-Spec-250-Phase-phase_9-feat-criterion-4b-the-archite.patch +++ b/tools/t3-fork/patches/0025-Spec-250-Phase-phase_9-feat-criterion-4b-the-archite.patch @@ -1,7 +1,7 @@ From 6fecade36146cbf37d40def52e813c90207e3a12 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:16:12 -0600 -Subject: [PATCH 25/34] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F9]=20feat:?= +Subject: [PATCH 25/35] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F9]=20feat:?= =?UTF-8?q?=20criterion=204b=20=E2=80=94=20the=20architect=20gets=20a=20st?= =?UTF-8?q?rip,=20not=20a=20ragged=20seventh=20tile?= MIME-Version: 1.0 diff --git a/tools/t3-fork/patches/0026-Spec-250-Phase-phase_9-fix-the-pane-s-role-prefix-co.patch b/tools/t3-fork/patches/0026-Spec-250-Phase-phase_9-fix-the-pane-s-role-prefix-co.patch index ce5116a26..3bcacfdf9 100644 --- a/tools/t3-fork/patches/0026-Spec-250-Phase-phase_9-fix-the-pane-s-role-prefix-co.patch +++ b/tools/t3-fork/patches/0026-Spec-250-Phase-phase_9-fix-the-pane-s-role-prefix-co.patch @@ -1,7 +1,7 @@ From 0065abc29ed71e42766c0152ec830394088b177d Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:23:40 -0600 -Subject: [PATCH 26/34] [Spec 250][Phase: phase_9] fix: the pane's role prefix +Subject: [PATCH 26/35] [Spec 250][Phase: phase_9] fix: the pane's role prefix could be clipped, and it is the only thing saying what a tile is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0027-Spec-250-Phase-phase_9-fix-act-on-the-3-way-review-t.patch b/tools/t3-fork/patches/0027-Spec-250-Phase-phase_9-fix-act-on-the-3-way-review-t.patch index 7911a4ddb..dc86c1be4 100644 --- a/tools/t3-fork/patches/0027-Spec-250-Phase-phase_9-fix-act-on-the-3-way-review-t.patch +++ b/tools/t3-fork/patches/0027-Spec-250-Phase-phase_9-fix-act-on-the-3-way-review-t.patch @@ -1,7 +1,7 @@ From 551bc708d15483b821989fa05e32bc455ab2e923 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 02:51:54 -0600 -Subject: [PATCH 27/34] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F9]=20fix:?= +Subject: [PATCH 27/35] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F9]=20fix:?= =?UTF-8?q?=20act=20on=20the=203-way=20review=20=E2=80=94=20the=20grid=20h?= =?UTF-8?q?ad=20no=20way=20in,=20and=20measured=20its=20width=20two=20ways?= MIME-Version: 1.0 diff --git a/tools/t3-fork/patches/0028-Spec-250-Phase-phase_10-feat-approve-a-gate-from-t3c.patch b/tools/t3-fork/patches/0028-Spec-250-Phase-phase_10-feat-approve-a-gate-from-t3c.patch index 0d054152b..2864435e9 100644 --- a/tools/t3-fork/patches/0028-Spec-250-Phase-phase_10-feat-approve-a-gate-from-t3c.patch +++ b/tools/t3-fork/patches/0028-Spec-250-Phase-phase_10-feat-approve-a-gate-from-t3c.patch @@ -1,7 +1,7 @@ From 0b90c36682a4cbc569938b94e52845a9a7d00aee Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 03:41:21 -0600 -Subject: [PATCH 28/34] [Spec 250][Phase: phase_10] feat: approve a gate from +Subject: [PATCH 28/35] [Spec 250][Phase: phase_10] feat: approve a gate from t3code, over a same-origin proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0029-Spec-250-Phase-phase_10-fix-the-page-read-the-agent-.patch b/tools/t3-fork/patches/0029-Spec-250-Phase-phase_10-fix-the-page-read-the-agent-.patch index ec9e198b4..c3466248c 100644 --- a/tools/t3-fork/patches/0029-Spec-250-Phase-phase_10-fix-the-page-read-the-agent-.patch +++ b/tools/t3-fork/patches/0029-Spec-250-Phase-phase_10-fix-the-page-read-the-agent-.patch @@ -1,7 +1,7 @@ From 79db4c7b8f07c8a27d5b0d183ad016c09ddd92cf Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 03:58:30 -0600 -Subject: [PATCH 29/34] [Spec 250][Phase: phase_10] fix: the page read the +Subject: [PATCH 29/35] [Spec 250][Phase: phase_10] fix: the page read the agent store once and then froze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0030-Spec-250-Phase-phase_10-fix-a-gated-pane-dropped-the.patch b/tools/t3-fork/patches/0030-Spec-250-Phase-phase_10-fix-a-gated-pane-dropped-the.patch index 1bb95c66b..98c9b3e9d 100644 --- a/tools/t3-fork/patches/0030-Spec-250-Phase-phase_10-fix-a-gated-pane-dropped-the.patch +++ b/tools/t3-fork/patches/0030-Spec-250-Phase-phase_10-fix-a-gated-pane-dropped-the.patch @@ -1,7 +1,7 @@ From 75150bfcf382444a7a1deaa2a72f24ed9d844e70 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 03:59:37 -0600 -Subject: [PATCH 30/34] [Spec 250][Phase: phase_10] fix: a gated pane dropped +Subject: [PATCH 30/35] [Spec 250][Phase: phase_10] fix: a gated pane dropped the phase it had just gained The gate replaced the phase line, which was right when there was no phase to diff --git a/tools/t3-fork/patches/0031-Spec-250-Phase-phase_10-fix-the-empty-thread-placeho.patch b/tools/t3-fork/patches/0031-Spec-250-Phase-phase_10-fix-the-empty-thread-placeho.patch index d02c7d7af..39d152ac3 100644 --- a/tools/t3-fork/patches/0031-Spec-250-Phase-phase_10-fix-the-empty-thread-placeho.patch +++ b/tools/t3-fork/patches/0031-Spec-250-Phase-phase_10-fix-the-empty-thread-placeho.patch @@ -1,7 +1,7 @@ From fe10e0c0b07f480f48c2d2cf006cac64d6dac0bb Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 04:02:21 -0600 -Subject: [PATCH 31/34] [Spec 250][Phase: phase_10] fix: the empty-thread +Subject: [PATCH 31/35] [Spec 250][Phase: phase_10] fix: the empty-thread placeholder printed across the gate panel On a thread with no turns yet, "Send a message to start the conversation." is diff --git a/tools/t3-fork/patches/0032-Spec-250-Phase-phase_10-fix-the-proxy-buffered-reque.patch b/tools/t3-fork/patches/0032-Spec-250-Phase-phase_10-fix-the-proxy-buffered-reque.patch index e07542bf3..8f4c66298 100644 --- a/tools/t3-fork/patches/0032-Spec-250-Phase-phase_10-fix-the-proxy-buffered-reque.patch +++ b/tools/t3-fork/patches/0032-Spec-250-Phase-phase_10-fix-the-proxy-buffered-reque.patch @@ -1,7 +1,7 @@ From 24aeeebb3ded44d0fd40f41f6af73597d770ebb7 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 04:16:29 -0600 -Subject: [PATCH 32/34] [Spec 250][Phase: phase_10] fix: the proxy buffered +Subject: [PATCH 32/35] [Spec 250][Phase: phase_10] fix: the proxy buffered request bodies with no bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0033-Spec-250-Phase-phase_10-fix-act-on-the-3-way-review-.patch b/tools/t3-fork/patches/0033-Spec-250-Phase-phase_10-fix-act-on-the-3-way-review-.patch index 31bf9a939..6b7341eab 100644 --- a/tools/t3-fork/patches/0033-Spec-250-Phase-phase_10-fix-act-on-the-3-way-review-.patch +++ b/tools/t3-fork/patches/0033-Spec-250-Phase-phase_10-fix-act-on-the-3-way-review-.patch @@ -1,7 +1,7 @@ From 3786b840e1a4a26c062138889cd72bcaed2fbdc1 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 04:19:48 -0600 -Subject: [PATCH 33/34] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F10]=20fix:?= +Subject: [PATCH 33/35] =?UTF-8?q?[Spec=20250][Phase:=20phase=5F10]=20fix:?= =?UTF-8?q?=20act=20on=20the=203-way=20review=20=E2=80=94=20a=20bound=20th?= =?UTF-8?q?at=20claimed=20more=20than=20it=20gives,=20and=20an=20attribute?= =?UTF-8?q?=20coarser=20than=20its=20words?= diff --git a/tools/t3-fork/patches/0034-Spec-250-Phase-review-fix-the-approval-path-had-no-a.patch b/tools/t3-fork/patches/0034-Spec-250-Phase-review-fix-the-approval-path-had-no-a.patch index 0f8eb9959..03096a607 100644 --- a/tools/t3-fork/patches/0034-Spec-250-Phase-review-fix-the-approval-path-had-no-a.patch +++ b/tools/t3-fork/patches/0034-Spec-250-Phase-review-fix-the-approval-path-had-no-a.patch @@ -1,7 +1,7 @@ From 2f64a1b0ee2b35cd858a8b601b4d425216e73ae5 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 06:43:08 -0600 -Subject: [PATCH 34/34] [Spec 250][Phase: review] fix: the approval path had no +Subject: [PATCH 34/35] [Spec 250][Phase: review] fix: the approval path had no answer for a dead network, and the proxy had no bound on the way back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 diff --git a/tools/t3-fork/patches/0035-Issue-272-feat-a-workspace-with-nothing-running-in-i.patch b/tools/t3-fork/patches/0035-Issue-272-feat-a-workspace-with-nothing-running-in-i.patch new file mode 100644 index 000000000..3bb50600a --- /dev/null +++ b/tools/t3-fork/patches/0035-Issue-272-feat-a-workspace-with-nothing-running-in-i.patch @@ -0,0 +1,604 @@ +From 26b4c2dc09f0fe2f6798e9b781df6722603b5bfe Mon Sep 17 00:00:00 2001 +From: pseudo +Date: Mon, 31 Aug 2026 11:34:57 -0600 +Subject: [PATCH 35/35] [Issue 272] feat: a workspace with nothing running in + it still gets a heading +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The tree's project level was derived entirely from architects. `buildCodevSidebarOrder` +walked `hierarchy.architects`, keyed each subtree by `projectKeyOf`, and set +`startsProject` on the first of each — so a project with no architect contributed no +entry, drew no heading, and was absent from the sidebar. + +That was fine while a project existed only because something had been spawned into it. +Codev now registers a project per workspace whether or not anything has been spawned +there, which makes "nobody has spawned here yet" the ordinary case. It was rendering +identically to "this workspace does not exist" — the two spelled the same way, which is +the thing that must never happen. + +**A group, not a key.** The sidebar's project list is LOGICAL projects — one row that may +stand for the same physical project across several environments — while `projectKeyOf` +returns the physical `environmentId:projectId` a thread belongs to. A flat list of keys +draws a second, empty heading beside a group whose OTHER member holds the architects, +carrying the same name, and a reader sees two workspaces where there is one. So a group +names every key that counts as opening it and the one key its heading is drawn with. With +one environment each group is a single key, which is the ordinary case. + +**One heading, two callers.** The markup is a local helper now rather than two copies. Two +subtly different headings would read to a user as two kinds of workspace, which they are +not. The only difference is `data-codev-project-empty`, so a test can tell the cases apart +without them rendering differently. + +**The union gained a case, and every consumer had to say what it does with it.** An +`empty-project` entry carries no thread. `orderedActiveThreads` was +`.map(entry => entry.thread)`, and that list is what shift-range-select and the jump-hint +labels are assigned from — `undefined` in it is a crash one row away, not a wrong row. +`codevOrderedThreads` and `codevEntryThread` are that answer, exported rather than inlined +because six callers needed it. An optional `thread?: T` field was the alternative and is +worse: `entry.thread.id` would keep compiling and fail at runtime on the one entry kind +that has no thread. + +The three project maps are annotated `Map` rather than inferred. Inference +gives them the template-literal key type, which a plain `string` cannot index, and an +empty-project heading has only a plain string — it carries no thread to rebuild the key +from. Widening is strictly more permissive; every existing lookup still type-checks. + +Refs #272. + +Co-Authored-By: Claude Opus 5 (1M context) +--- + apps/web/src/components/Sidebar.logic.test.ts | 127 +++++++++++++-- + apps/web/src/components/Sidebar.logic.ts | 86 ++++++++++ + apps/web/src/components/Sidebar.tsx | 153 ++++++++++++++---- + 3 files changed, 325 insertions(+), 41 deletions(-) + +diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts +index cb8531674..7b3f63129 100644 +--- a/apps/web/src/components/Sidebar.logic.test.ts ++++ b/apps/web/src/components/Sidebar.logic.test.ts +@@ -5,6 +5,8 @@ import { + archiveSelectedThreadEntries, + buildBulkTitleRegenerationContextMenuItem, + buildCodevSidebarOrder, ++ codevEntryThread, ++ codevOrderedThreads, + buildMultiSelectThreadContextMenuItems, + describeCodevOrphanReason, + createThreadJumpHintVisibilityController, +@@ -1719,7 +1721,7 @@ describe("buildCodevSidebarOrder", () => { + ]); + // Same threads, same order. The renderer's no-hierarchy branch draws this + // list directly, so "unchanged for upstream" is this assertion. +- expect(order.entries.map((entry) => entry.thread)).toEqual(threads); ++ expect(codevOrderedThreads(order.entries)).toEqual(threads); + }); + + it("puts each architect above the builders that name it", () => { +@@ -1729,7 +1731,7 @@ describe("buildCodevSidebarOrder", () => { + builderThread("b-1", "arch-1"), + ]); + expect(order.hasHierarchy).toBe(true); +- expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ expect(order.entries.map((entry) => [entry.kind, codevEntryThread(entry)?.id])).toEqual([ + ["architect", "arch-1"], + // Input order within the subtree, because the caller already sorted. + ["builder", "b-2"], +@@ -1745,7 +1747,7 @@ describe("buildCodevSidebarOrder", () => { + builderThread("b-1", "arch-b"), + builderThread("b-2", "arch-b"), + ]); +- expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ expect(order.entries.map((entry) => [entry.kind, codevEntryThread(entry)?.id])).toEqual([ + ["architect", "arch-a"], + ["builder", "a-1"], + ["architect", "arch-b"], +@@ -1770,7 +1772,7 @@ describe("buildCodevSidebarOrder", () => { + builderThread("b-1", "arch-1"), + plainThread("upstream-2"), + ]); +- expect(order.entries.map((entry) => [entry.kind, entry.thread.id])).toEqual([ ++ expect(order.entries.map((entry) => [entry.kind, codevEntryThread(entry)?.id])).toEqual([ + ["architect", "arch-1"], + ["builder", "b-1"], + ["unmanaged", "upstream-1"], +@@ -1819,7 +1821,7 @@ describe("buildCodevSidebarOrder", () => { + expect(reasons).toEqual(["parent-elsewhere", "parent-missing"]); + // The pinned architect keeps its row in the pinned block and gains none + // here: two rows for one thread is worse than the problem it solves. +- expect(order.entries.map((entry) => entry.thread.id)).toEqual(["b-1", "lost"]); ++ expect(codevOrderedThreads(order.entries).map((thread) => thread.id)).toEqual(["b-1", "lost"]); + }); + + it("accounts for every thread exactly once", () => { +@@ -1834,9 +1836,116 @@ describe("buildCodevSidebarOrder", () => { + architectThread("arch-empty"), + ]; + const order = buildCodevSidebarOrder(threads); +- expect(order.entries.map((entry) => entry.thread.id).toSorted()).toEqual( +- threads.map((thread) => thread.id).toSorted(), +- ); ++ expect( ++ codevOrderedThreads(order.entries) ++ .map((thread) => thread.id) ++ .toSorted(), ++ ).toEqual(threads.map((thread) => thread.id).toSorted()); ++ }); ++}); ++ ++/** ++ * Issue #272 — a workspace with nothing running in it still gets a heading. ++ * ++ * The project level is derived from ARCHITECTS, so a project with none ++ * contributed no entry and drew no heading. Codev registers a project per ++ * workspace whether or not anything has been spawned in it, which makes "nobody ++ * has spawned here yet" the ordinary case rather than an edge one — and it was ++ * rendering identically to "this workspace does not exist". ++ */ ++describe("buildCodevSidebarOrder: projects with no architect", () => { ++ const inProject = (projectId: string, thread: Thread): Thread => ({ ++ ...thread, ++ projectId: ProjectId.make(projectId), ++ }); ++ const architectIn = (projectId: string, id: string) => ++ inProject(projectId, makeThread({ id: ThreadId.make(id), title: id, role: "architect" })); ++ const projectKeyOf = (thread: Thread) => `${thread.environmentId}:${thread.projectId}`; ++ const keyOf = (projectId: string) => projectKeyOf(architectIn(projectId, "probe")); ++ const soloGroup = (projectId: string) => { ++ const key = keyOf(projectId); ++ return { key, memberKeys: [key] }; ++ }; ++ ++ it("draws a heading for a project no architect opened", () => { ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { ++ projectKeyOf, ++ projectGroups: [soloGroup("busy"), soloGroup("idle")], ++ }); ++ expect( ++ order.entries.map((entry) => ++ entry.kind === "empty-project" ? entry.projectKey : entry.kind, ++ ), ++ ).toEqual(["architect", keyOf("idle")]); ++ }); ++ ++ it("does not draw a second heading for a project the architect run already opened", () => { ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { ++ projectKeyOf, ++ projectGroups: [soloGroup("busy")], ++ }); ++ expect(order.entries.map((entry) => entry.kind)).toEqual(["architect"]); ++ }); ++ ++ it("treats a group as opened when ANY of its member projects has an architect", () => { ++ // One logical project spanning two environments. Without the member list ++ // this draws an empty heading beside the populated one, carrying the same ++ // name, and a reader sees two workspaces where there is one. ++ const populated = keyOf("busy"); ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { ++ projectKeyOf, ++ projectGroups: [{ key: keyOf("mirror"), memberKeys: [keyOf("mirror"), populated] }], ++ }); ++ expect(order.entries.map((entry) => entry.kind)).toEqual(["architect"]); ++ }); ++ ++ it("draws one heading for a project named twice in the caller's list", () => { ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { ++ projectKeyOf, ++ projectGroups: [soloGroup("idle"), soloGroup("idle")], ++ }); ++ expect(order.entries.filter((entry) => entry.kind === "empty-project")).toHaveLength(1); ++ }); ++ ++ it("puts the empty projects after the architect run, in the caller's order", () => { ++ // The caller has already sorted its project list — by pin, by recency, by ++ // whatever the user chose. Re-sorting here would silently override it. ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { ++ projectKeyOf, ++ projectGroups: [soloGroup("zeta"), soloGroup("alpha")], ++ }); ++ expect( ++ order.entries.flatMap((entry) => (entry.kind === "empty-project" ? [entry.projectKey] : [])), ++ ).toEqual([keyOf("zeta"), keyOf("alpha")]); ++ }); ++ ++ it("changes nothing for a caller that does not supply a project list", () => { ++ const order = buildCodevSidebarOrder([architectIn("busy", "arch-1")], { projectKeyOf }); ++ expect(order.entries.map((entry) => entry.kind)).toEqual(["architect"]); ++ }); ++ ++ it("stays out of a sidebar with no Codev hierarchy at all", () => { ++ // `hasHierarchy: false` is the untouched upstream presentation, and an empty ++ // heading is furniture for a feature that sidebar does not have. ++ const order = buildCodevSidebarOrder([makeThread({ id: ThreadId.make("a"), title: "a" })], { ++ projectKeyOf, ++ projectGroups: [soloGroup("idle")], ++ }); ++ expect(order.hasHierarchy).toBe(false); ++ expect(order.entries.map((entry) => entry.kind)).toEqual(["unmanaged"]); ++ }); ++ ++ it("still accounts for every thread exactly once", () => { ++ const threads = [architectIn("busy", "arch-1"), architectIn("other", "arch-2")]; ++ const order = buildCodevSidebarOrder(threads, { ++ projectKeyOf, ++ projectGroups: [soloGroup("busy"), soloGroup("idle")], ++ }); ++ expect( ++ order.entries ++ .flatMap((entry) => (entry.kind === "empty-project" ? [] : [entry.thread.id])) ++ .toSorted(), ++ ).toEqual(threads.map((thread) => thread.id).toSorted()); + }); + }); + +@@ -1916,7 +2025,7 @@ describe("buildCodevSidebarOrder: the project level", () => { + ], + { projectKeyOf }, + ); +- expect(order.entries.map((entry) => entry.thread.id)).toEqual([ ++ expect(codevOrderedThreads(order.entries).map((thread) => thread.id)).toEqual([ + "arch-a1", + "a1-one", + "arch-a2", +diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts +index ef4e26ea2..62f94bffc 100644 +--- a/apps/web/src/components/Sidebar.logic.ts ++++ b/apps/web/src/components/Sidebar.logic.ts +@@ -1008,6 +1008,24 @@ export type CodevSidebarEntry = + readonly startsProject: boolean; + } + | { readonly kind: "builder"; readonly thread: T; readonly architectId: string } ++ | { ++ /** ++ * A project with no architect in this section, drawn as a heading with ++ * nothing under it (issue #272). ++ * ++ * The tree's project level is derived from ARCHITECTS — a project with no ++ * architect contributed no entry, so it drew no heading and was absent from ++ * the sidebar entirely. Codev registers a project per workspace whether or ++ * not anything has been spawned in it, and a workspace nobody has spawned ++ * into is exactly the one with no architect. Without this entry, "nothing ++ * running here yet" and "this workspace does not exist" look the same. ++ * ++ * It carries no thread, which is the whole point: there is nothing to ++ * render under the heading. ++ */ ++ readonly kind: "empty-project"; ++ readonly projectKey: string; ++ } + | { readonly kind: "unmanaged"; readonly thread: T } + | { + readonly kind: "orphan"; +@@ -1028,6 +1046,32 @@ export interface CodevSidebarOrderOptions { + * already scopes everything by `environmentId:projectId`. + */ + readonly projectKeyOf?: ((thread: T) => string) | undefined; ++ /** ++ * Every project the sidebar knows about, in the order it wants them (issue #272). ++ * ++ * Projects here that no architect subtree opened are emitted as `empty-project` ++ * entries after the architect run. Omitted, nothing changes: a caller that does ++ * not supply it gets exactly the entries it got before, which is what the ++ * grouping's own unit tests rely on. ++ * ++ * ## Why a group and not a key ++ * ++ * The sidebar's project list is LOGICAL projects — one row that may stand for ++ * the same physical project on several environments — while `projectKeyOf` ++ * returns the physical `environmentId:projectId` a thread actually belongs to. ++ * A flat list of keys would draw a second, empty heading beside a group whose ++ * OTHER member has the architects, with the same name on both. So a group names ++ * every key that counts as opening it, and the one key its heading is drawn ++ * with. ++ * ++ * With one environment each group is a single key, which is the ordinary case. ++ */ ++ readonly projectGroups?: ++ | readonly { ++ readonly key: string; ++ readonly memberKeys: readonly string[]; ++ }[] ++ | undefined; + } + + export interface CodevSidebarOrder { +@@ -1098,6 +1142,21 @@ export function buildCodevSidebarOrder( + } + } + } ++ // Projects with no architect in this section, after the ones that have them. ++ // Order is the caller's — the sidebar's own project order — minus the ones ++ // already opened above, so a workspace that gains its first architect moves up ++ // into the run rather than appearing twice. ++ if (options.projectGroups !== undefined) { ++ const opened = new Set(projectOrder); ++ for (const group of options.projectGroups) { ++ if (group.memberKeys.some((key) => opened.has(key))) continue; ++ // Every member, not just the one the heading is drawn with: a duplicate ++ // group in the caller's list must not draw a second heading either. ++ for (const key of group.memberKeys) opened.add(key); ++ opened.add(group.key); ++ entries.push({ kind: "empty-project", projectKey: group.key }); ++ } ++ } + for (const thread of hierarchy.unmanaged) { + entries.push({ kind: "unmanaged", thread }); + } +@@ -1112,6 +1171,33 @@ export function buildCodevSidebarOrder( + return { hasHierarchy: true, entries }; + } + ++/** ++ * The thread an entry stands for, or `null` for one that stands for a project. ++ * ++ * `empty-project` carries no thread (issue #272), and every consumer of this ++ * union now has to say what it does with that. Two helpers rather than a widened ++ * `thread?: T` field: an optional field would let `entry.thread.id` keep ++ * compiling and fail at runtime on the one entry kind that has no thread. ++ */ ++export function codevEntryThread(entry: CodevSidebarEntry): T | null { ++ return entry.kind === "empty-project" ? null : entry.thread; ++} ++ ++/** ++ * The threads an entry list stands for, in render order. ++ * ++ * What every caller wanting "the rows, in the order they draw" actually means. ++ * The sidebar assigns shift-range-select and jump-hint labels from this list, and ++ * a plain `.map(entry => entry.thread)` put `undefined` in it for a project ++ * heading — a crash one row away rather than a wrong row. ++ */ ++export function codevOrderedThreads(entries: readonly CodevSidebarEntry[]): T[] { ++ return entries.flatMap((entry) => { ++ const thread = codevEntryThread(entry); ++ return thread === null ? [] : [thread]; ++ }); ++} ++ + /** + * What the orphan group says about one row, in words a human can act on. + * +diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx +index 2b8d3e99e..6890d5c63 100644 +--- a/apps/web/src/components/Sidebar.tsx ++++ b/apps/web/src/components/Sidebar.tsx +@@ -30,7 +30,7 @@ import { + scopeThreadRef, + scopedThreadKey, + } from "@t3tools/client-runtime/environment"; +-import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; ++import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; + import type { TimestampFormat } from "@t3tools/contracts/settings"; + import { + AlarmClockIcon, +@@ -128,6 +128,8 @@ import { + animatePinnedLayoutChanges, + buildBulkTitleRegenerationContextMenuItem, + buildCodevSidebarOrder, ++ codevEntryThread, ++ codevOrderedThreads, + describeCodevOrphanReason, + type CodevSidebarEntry, + formatWorkingDurationLabel, +@@ -1948,9 +1950,18 @@ export default function Sidebar() { + ), + [serverConfigs], + ); ++ /* ++ * `Map`, written out rather than inferred (issue #272). ++ * ++ * Inference gives these the template-literal key type `${string}:${string}`, ++ * which cannot be indexed by a plain `string` — and the empty-project heading ++ * has only a plain string, because it comes from an entry that carries no ++ * thread to rebuild the key from. Widening is strictly more permissive: every ++ * existing template-literal lookup still type-checks. ++ */ + const projectCwdByKey = useMemo( + () => +- new Map( ++ new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, +@@ -1958,16 +1969,29 @@ export default function Sidebar() { + ), + [projects], + ); ++ const projectEnvironmentByKey = useMemo( ++ () => ++ new Map( ++ projects.map((project) => [ ++ `${project.environmentId}:${project.id}`, ++ project.environmentId, ++ ]), ++ ), ++ [projects], ++ ); + const projectFaviconPathByKey = useMemo( + () => +- new Map( ++ // `| undefined` because `faviconPath` is optional on the project record and ++ // the web app compiles with `exactOptionalPropertyTypes`. The heading ++ // normalises to `null` at the point of use, where the component wants it. ++ new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.faviconPath]), + ), + [projects], + ); + const projectDisplayNameByKey = useMemo( + () => +- new Map( ++ new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, +@@ -1976,6 +2000,25 @@ export default function Sidebar() { + ), + [projectGroups], + ); ++ /** ++ * The project rows the tree may draw a heading for, in the sidebar's own order ++ * (issue #272). ++ * ++ * One entry per LOGICAL project, naming every physical member: a group whose ++ * member carries the architects is already drawn by the architect run, and ++ * without the member list it would get a second, empty heading with the same ++ * name beside it. ++ */ ++ const codevProjectGroups = useMemo( ++ () => ++ projectGroups.map((group) => { ++ const memberKeys = group.memberProjects.map( ++ (project) => `${project.environmentId}:${project.id}`, ++ ); ++ return { key: memberKeys[0] ?? group.projectKey, memberKeys }; ++ }), ++ [projectGroups], ++ ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. +@@ -2307,15 +2350,22 @@ export default function Sidebar() { + // project lookup. `projectId` alone is not a project: two environments + // can carry the same id. + projectKeyOf: (thread) => `${thread.environmentId}:${thread.projectId}`, ++ // Issue #272. Without this the tree's project level is derived entirely ++ // from architects, so a workspace nobody has spawned into draws nothing ++ // at all — indistinguishable from a workspace that does not exist. ++ projectGroups: codevProjectGroups, + }), +- [activeThreads, pinnedThreads, snoozedThreads, settledThreads], ++ [activeThreads, codevProjectGroups, pinnedThreads, snoozedThreads, settledThreads], + ); + // The tree's order IS the ordered list. Shift-range-select and jump-hint + // labels are assigned from `orderedThreads`, so a reordered render with the + // old list behind it puts every row in the right place and the keyboard on + // the wrong ones. + const orderedActiveThreads = useMemo( +- () => codevActiveOrder.entries.map((entry) => entry.thread), ++ // Not `.map(entry => entry.thread)`: an `empty-project` entry carries no ++ // thread (issue #272), and this list is what shift-range-select and the ++ // jump-hint labels are assigned from. ++ () => codevOrderedThreads(codevActiveOrder.entries), + [codevActiveOrder], + ); + const orderedThreads = useMemo( +@@ -3953,12 +4003,55 @@ export default function Sidebar() { + // does not have. + if (!codevActiveOrder.hasHierarchy) { + for (const entry of codevActiveOrder.entries) { +- items.push(renderThreadRow(entry.thread, "active")); ++ // Every entry here is `unmanaged` — the no-hierarchy branch ++ // returns the untouched input — but the union says it might ++ // not be, and a renderer that assumes an impossible state is ++ // impossible has no way to show one when it happens. ++ const thread = codevEntryThread(entry); ++ if (thread !== null) items.push(renderThreadRow(thread, "active")); + } + } else { + const entries = codevActiveOrder.entries; + const codevRowKey = (thread: EnvironmentThreadShell) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); ++ /* ++ * ONE heading, two callers (issue #272). ++ * ++ * The architect run opens a project heading; a project with ++ * no architect draws the same heading with nothing under it. ++ * Two copies of this markup would drift, and a user reading ++ * two subtly different headings would take them for two kinds ++ * of workspace — which they are not. The only difference is ++ * `data-codev-project-empty`, so a test can tell which case it ++ * is looking at without the two rendering differently. ++ */ ++ const projectHeading = ( ++ projectKey: string, ++ environmentId: EnvironmentId, ++ empty: boolean, ++ ) => ( ++
  • ++
    ++ ++ ++ {projectDisplayNameByKey.get(projectKey) ?? "Project"} ++ ++ ++
    ++
  • ++ ); + let renderedArchitectCount = 0; + const unmanaged: EnvironmentThreadShell[] = []; + const orphans: Extract< +@@ -3968,6 +4061,22 @@ export default function Sidebar() { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined) continue; ++ if (entry.kind === "empty-project") { ++ // A workspace with nothing running in it. The heading is ++ // the whole row: there is no thread to draw beneath it, ++ // and inventing a placeholder would make an idle ++ // workspace look like a broken one. ++ const environmentId = projectEnvironmentByKey.get(entry.projectKey); ++ // No environment for this key means the project left the ++ // list between the order being built and this render. ++ // Skipping is the honest answer — a favicon needs an ++ // environment to resolve against, and guessing one would ++ // draw somebody else's icon. ++ if (environmentId !== undefined) { ++ items.push(projectHeading(entry.projectKey, environmentId, true)); ++ } ++ continue; ++ } + if (entry.kind === "unmanaged") { + unmanaged.push(entry.thread); + continue; +@@ -3991,32 +4100,12 @@ export default function Sidebar() { + // goes — carrying the project's own favicon so it reads as + // t3code's project rather than as a new kind of shelf. + if (entry.startsProject) { +- // `as const` keeps the template-literal type the +- // project maps are keyed by; a widened `string` does not +- // index them. +- const projectKey = +- `${entry.thread.environmentId}:${entry.thread.projectId}` as const; + items.push( +-
  • +-
    +- +- +- {projectDisplayNameByKey.get(projectKey) ?? "Project"} +- +- +-
    +-
  • , ++ projectHeading( ++ `${entry.thread.environmentId}:${entry.thread.projectId}`, ++ entry.thread.environmentId, ++ false, ++ ), + ); + } + items.push( From cb513dfd46fc1f70e2d6104cdec1ecc143a8745e Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 12:01:48 -0600 Subject: [PATCH 10/24] [PIR #272] test: the browser draws the empty heading, and the doc path stops shipping a username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bugfix-214-publish-scrub` bans `/Users/` and `/home/` from every shipped file, and `workspaceDisplayNames`' worked example was `/Users/chris/dev/codev-1455` — which compiles into `dist` and would have gone out in the package. The guard accepts `` as a placeholder, so it reads `/Users//dev/codev-1455` now. My first fix was `/home/dev/...`, which is the same violation with a different prefix, and I only noticed because I re-ran the guard rather than trusting a grep I had written for the wrong pattern. **The empty heading, in a browser.** The unit test proves the ORDER contains the entry; this proves something draws it. `seedHierarchy` gains a second project with no threads at all — the ordinary state of a workspace nobody has spawned into, now that Codev registers one per workspace — and the spec asserts its heading exists, carries `data-codev-project-empty`, sits below the populated project's run, and has every architect subtree above it. The existing heading assertion is scoped to the populated project's title, because counting both would make it an assertion about the wrong thing. The idle project's workspace root is a real temp directory: the server refuses a project whose root does not exist, and it cannot live under the fork, because `start-fork` refuses a dirty fork checkout and a fixture that wrote there would poison the next run. 10 of 10 in `spec-250-hierarchy.spec.ts`, and the re-shot sidebar screenshot shows `idle-workspace` with nothing under it. Refs #272. Co-Authored-By: Claude Opus 5 (1M context) --- codev/state/pir-272_thread.md | 38 +++++++++++++ .../src/__tests__/e2e/spec-250-fork-stack.ts | 43 ++++++++++++++- .../__tests__/e2e/spec-250-hierarchy.spec.ts | 53 ++++++++++++++++++- .../src/agent-farm/workspace-projection.ts | 2 +- 4 files changed, 132 insertions(+), 4 deletions(-) diff --git a/codev/state/pir-272_thread.md b/codev/state/pir-272_thread.md index 66d874311..be1e81d4a 100644 --- a/codev/state/pir-272_thread.md +++ b/codev/state/pir-272_thread.md @@ -139,3 +139,41 @@ group whose other member holds the architects, same name on both. 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. 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/workspace-projection.ts b/packages/codev/src/agent-farm/workspace-projection.ts index ecc09b1e0..fdf0465f8 100644 --- a/packages/codev/src/agent-farm/workspace-projection.ts +++ b/packages/codev/src/agent-farm/workspace-projection.ts @@ -74,7 +74,7 @@ export function workspaceLeafName(workspaceRoot: string): string { /** * Name every workspace by the shortest trailing path segments that make it unique. * - * `/Users/chris/dev/codev-1455` is `codev-1455`. Two workspaces both called `api` + * `/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. From e85d1e1df81122cbb5d2dd53d401a41cb1a523ab Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 12:11:31 -0600 Subject: [PATCH 11/24] chore(porch): 272 dev-approval gate-requested --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 07ca92f8f..0bc40423a 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -44,10 +44,11 @@ gates: caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) dev-approval: status: pending + requested_at: '2026-08-31T18:11:31.881Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T17:21:03.441Z' +updated_at: '2026-08-31T18:11:31.882Z' From bfd9ad0302a0acc385a4a59b3e5b74ae0cbef1b6 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 12:11:51 -0600 Subject: [PATCH 12/24] chore(porch): 272 dev-approval gate-request-updated --- .../status.yaml | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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 index 0bc40423a..589e3cbc4 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -45,10 +45,45 @@ gates: dev-approval: status: pending 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. pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T18:11:31.882Z' +updated_at: '2026-08-31T18:11:51.869Z' From 8074a03177d94f0b7c5811481768ae9701615613 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 15:59:07 -0600 Subject: [PATCH 13/24] [PIR #272] docs: the ABI mismatch that failed the gate 8 times porch runs nvm node 20; my shell is Homebrew node 26. better_sqlite3.node was ABI 147 and node 20 needs 115, so 729 tests across 49 files failed on one binary while every run I drove passed. pnpm install, pnpm rebuild and build_from_source were all silent no-ops -- the mtime stayed Jul 24 through all three -- and there is no node-20 prebuild for darwin arm64, so only node-gyp rebuild fixes it. Recorded with the repair and the ruled-out hypotheses, because porch truncates a failed check to 5 lines and the cause names itself nowhere. Refs #272, #304. Co-Authored-By: Claude Opus 5 (1M context) --- codev/state/pir-272_thread.md | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/codev/state/pir-272_thread.md b/codev/state/pir-272_thread.md index be1e81d4a..73def088f 100644 --- a/codev/state/pir-272_thread.md +++ b/codev/state/pir-272_thread.md @@ -177,3 +177,72 @@ 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. From 2bb760c133a582df6334b3222a53b3fdfc51ee28 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:02:45 -0600 Subject: [PATCH 14/24] chore(porch): 272 dev-approval gate-approved --- .../272-the-sidebar-tree-needs-every-w/status.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 589e3cbc4..b31211245 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -43,7 +43,7 @@ gates: machine: chriss-MacBook-Pro.local caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) dev-approval: - status: pending + status: approved requested_at: '2026-08-31T18:11:31.881Z' request: question: >- @@ -80,10 +80,16 @@ gates: 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: pending iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T18:11:51.869Z' +updated_at: '2026-08-31T22:02:45.646Z' From 75e152010fcee8af3514dc2696316d6fa62e36bd Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:02:54 -0600 Subject: [PATCH 15/24] chore(porch): 272 review phase-transition --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b31211245..f60998b2b 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -1,7 +1,7 @@ id: '272' title: the-sidebar-tree-needs-every-w protocol: pir -phase: implement +phase: review plan_phases: [] current_plan_phase: null gates: @@ -92,4 +92,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:02:45.646Z' +updated_at: '2026-08-31T22:02:54.316Z' From 72daffe75da9e96b537cdb4842f049b529846f46 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:05:26 -0600 Subject: [PATCH 16/24] [PIR #272] Review + retrospective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retrospective, plus two facts routed to the COLD tier and neither displacing a hot entry: arch.md gains the workspace-to-project projection under Integration Points and a Node-20/ABI invariant, and lessons-learned.md gains the two debugging lessons the nine failed gate attempts paid for. Nothing promoted to the hot files. Both are at their 10-entry cap, the hot tier already carries the t3code-fork fact this builds on, and the Node/ABI fact is repo-specific rather than a cross-cutting decision rule — which is the routing the update-arch-docs skill prescribes. Refs #272. Co-Authored-By: Claude Opus 5 (1M context) --- codev/resources/arch.md | 35 ++++ codev/resources/lessons-learned.md | 20 +++ .../272-the-sidebar-tree-needs-every-w.md | 149 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 codev/reviews/272-the-sidebar-tree-needs-every-w.md 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..653cca4da --- /dev/null +++ b/codev/reviews/272-the-sidebar-tree-needs-every-w.md @@ -0,0 +1,149 @@ +# 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. + +## 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. From 267b30cc886f726a464e85bda3dd85364e4f938c Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:05:41 -0600 Subject: [PATCH 17/24] chore(porch): 272 record PR #305 --- .../272-the-sidebar-tree-needs-every-w/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index f60998b2b..18426708d 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -92,4 +92,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:02:54.316Z' +updated_at: '2026-08-31T22:05:41.125Z' +pr_history: + - phase: review + pr_number: 305 + branch: builder/pir-272 + created_at: '2026-08-31T22:05:41.124Z' From 43ba2fb507f3bb0f0f247b0da5c5f438fb63c823 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:05:50 -0600 Subject: [PATCH 18/24] chore(porch): 272 review build-complete --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 18426708d..a9164ac4e 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -89,10 +89,10 @@ gates: pr: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:05:41.125Z' +updated_at: '2026-08-31T22:05:50.003Z' pr_history: - phase: review pr_number: 305 From 50ca239a709838b2f9933fa7e23f42f80ddba22c Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:07:23 -0600 Subject: [PATCH 19/24] [PIR #272] docs: name the stale fork screenshots as a decision, not an oversight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/codev/spec-250/phase-7/*.png in the fork predate this change and do not show the empty-workspace heading. Re-shooting them costs a second fork commit and the whole REFRESH tail again — pin move, regeneration, patch re-export, four evidence re-runs — for pictures that still accurately depict what spec 250 delivered. Recorded in Things to Look At so a reviewer finds the reasoning rather than the gap. The behaviour is pinned by spec-250-hierarchy.spec.ts, which asserts the heading's presence, position and data-codev-project-empty attribute. Refs #272. --- codev/reviews/272-the-sidebar-tree-needs-every-w.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/codev/reviews/272-the-sidebar-tree-needs-every-w.md b/codev/reviews/272-the-sidebar-tree-needs-every-w.md index 653cca4da..deb5affe0 100644 --- a/codev/reviews/272-the-sidebar-tree-needs-every-w.md +++ b/codev/reviews/272-the-sidebar-tree-needs-every-w.md @@ -111,6 +111,16 @@ Nothing promoted to `lessons-critical.md`, for the same cap-and-displacement rea 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. + ## How to Test Locally - **View diff**: VSCode sidebar → right-click builder `pir-272` → **Review Diff** From 682e748917cd6c80cbcc33d61f0a4aff0673a8a4 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:10:32 -0600 Subject: [PATCH 20/24] [PIR #272] fix: test the workspace filter that every other test substituted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review consultation caught that isCodevWorkspaceDirectory is injected away by the unit tests AND by the live harness, so the filter that actually decides 'deleted checkout' and 'not a workspace' in Tower had no coverage — the repo's own lesson about a test supplying the boundary it means to check, in the code I wrote the day I quoted it. Testing it for the first time surfaced a real defect rather than confirming the code: existsSync(join(path, '.codev')) accepts a .codev that is a FILE. codev init creates a directory; the check now requires one. The assertion fails with the guard reverted. Five tests against a real filesystem: .codev present, absent, path missing, path is a file, and .codev is a file. Refs #272. --- .../272-the-sidebar-tree-needs-every-w.md | 28 ++++++++ .../issue-272-workspace-projection.test.ts | 66 ++++++++++++++++++- .../agent-farm/workspace-projection-sweep.ts | 12 +++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/codev/reviews/272-the-sidebar-tree-needs-every-w.md b/codev/reviews/272-the-sidebar-tree-needs-every-w.md index deb5affe0..afcbd0f23 100644 --- a/codev/reviews/272-the-sidebar-tree-needs-every-w.md +++ b/codev/reviews/272-the-sidebar-tree-needs-every-w.md @@ -121,6 +121,34 @@ Nothing promoted to `lessons-critical.md`, for the same cap-and-displacement rea `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** 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 index 5b187657a..c6034f4ce 100644 --- 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 @@ -11,7 +11,11 @@ * checkouts and three `.builders/` worktrees. Every filter below exists because that * table contains a row it would otherwise let through. */ -import { describe, expect, it, vi } from 'vitest'; +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, @@ -409,3 +413,63 @@ describe('reconcileWorkspaceProjects', () => { 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/workspace-projection-sweep.ts b/packages/codev/src/agent-farm/workspace-projection-sweep.ts index 063739709..71f812492 100644 --- a/packages/codev/src/agent-farm/workspace-projection-sweep.ts +++ b/packages/codev/src/agent-farm/workspace-projection-sweep.ts @@ -8,7 +8,7 @@ * is where `global.db`, `.codev/`, the thread-backend config and a real t3code * connection are named. */ -import { existsSync, statSync } from 'node:fs'; +import { statSync } from 'node:fs'; import { join } from 'node:path'; import { openProjectGateway, @@ -65,7 +65,15 @@ export function isCodevWorkspaceDirectory(path: string): boolean { // case for a `known_workspaces` row pointing at a deleted checkout. return false; } - return existsSync(join(path, '.codev')); + // `.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( From 631e8383e12cc86788ae2ebdc457fc1f2af93003 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:10:40 -0600 Subject: [PATCH 21/24] chore(porch): 272 pr gate-requested --- .../projects/272-the-sidebar-tree-needs-every-w/status.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index a9164ac4e..468170559 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -88,13 +88,15 @@ gates: caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) pr: status: pending + requested_at: '2026-08-31T22:10:40.784Z' iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:05:50.003Z' +updated_at: '2026-08-31T22:10:40.785Z' pr_history: - phase: review pr_number: 305 branch: builder/pir-272 created_at: '2026-08-31T22:05:41.124Z' +pr_ready_for_human: true From 6d28232b80de83d33398359288a3a8ebf97172ac Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:10:55 -0600 Subject: [PATCH 22/24] chore(porch): 272 pr gate-request-updated --- .../status.yaml | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 index 468170559..09b0b664b 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -89,11 +89,34 @@ gates: pr: status: pending 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. iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:10:40.785Z' +updated_at: '2026-08-31T22:10:55.896Z' pr_history: - phase: review pr_number: 305 From 966ae4fccc004345fe92e766b539774b45777adf Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:12:14 -0600 Subject: [PATCH 23/24] chore(porch): 272 pr gate-approved --- .../272-the-sidebar-tree-needs-every-w/status.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index 09b0b664b..258f29531 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -87,7 +87,7 @@ gates: machine: chriss-MacBook-Pro.local caller: CODEV_ARCHITECT_NAME=main (an architect session or a process it spawned) pr: - status: pending + 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.' @@ -112,14 +112,20 @@ gates: 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:10:55.896Z' +updated_at: '2026-08-31T22:12:14.896Z' pr_history: - phase: review pr_number: 305 branch: builder/pir-272 created_at: '2026-08-31T22:05:41.124Z' -pr_ready_for_human: true +pr_ready_for_human: false From a8e58680b8bfe03226f576ce69d6f707d1e1aece Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 31 Aug 2026 16:12:22 -0600 Subject: [PATCH 24/24] chore(porch): 272 protocol complete --- codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 258f29531..0c1fd6de0 100644 --- a/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml +++ b/codev/projects/272-the-sidebar-tree-needs-every-w/status.yaml @@ -1,7 +1,7 @@ id: '272' title: the-sidebar-tree-needs-every-w protocol: pir -phase: review +phase: verified plan_phases: [] current_plan_phase: null gates: @@ -122,7 +122,7 @@ iteration: 1 build_complete: false history: [] started_at: '2026-08-31T16:47:04.075Z' -updated_at: '2026-08-31T22:12:14.896Z' +updated_at: '2026-08-31T22:12:22.730Z' pr_history: - phase: review pr_number: 305