Skip to content

Fix #272: every workspace is a named top-level node in the sidebar tree - #305

Merged
pseudoseed merged 25 commits into
mainfrom
builder/pir-272
Aug 31, 2026
Merged

Fix #272: every workspace is a named top-level node in the sidebar tree#305
pseudoseed merged 25 commits into
mainfrom
builder/pir-272

Conversation

@pseudoseed

@pseudoseed pseudoseed commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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

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:<path> 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 Architects deregister on any session death except a graceful workspace stop (main + main2 lost on pseudoapps) #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:<root> form and the workspace's leaf name. If it were
    loosened, the sweep would start overwriting titles humans chose. The leaf-name case is deliberate
    and is what lets a project created by a spawn converge on a set-unique name.

  • CodevSidebarEntry gained a case that carries no thread. Six call sites did entry.thread,
    including orderedActiveThreads, which is what shift-range-select and jump-hint labels are
    assigned from — undefined there is a crash one row away. codevOrderedThreads /
    codevEntryThread are the answer. An optional thread?: T was the alternative and is worse:
    entry.thread.id would keep compiling and fail at runtime on the one kind that has no thread.

  • projectGroups, not projectKeys. The sidebar's list is logical projects while
    projectKeyOf returns a physical environmentId:projectId. A flat key list draws a second empty
    heading beside a group whose other member holds the architects, same name on both.

  • readProjectRows was split out of activeProjectForWorkspace rather than copied. The old
    reader dropped title, so a reconciler could tell a project existed and never what it was called.
    A second copy of that request would also be a second place for the transport rules to drift —
    which is how that call skipped assertTransportSafe once already.

  • The fork's committed screenshots are now one case out of date, deliberately.
    docs/codev/spec-250/phase-7/*.png in the fork depict the tree before this change and do not
    show the empty-workspace heading. Re-shooting them means another fork commit, which obliges the
    whole REFRESH.md tail again — a second pin move, regeneration, patch re-export and four
    evidence re-runs — for pictures. They still accurately depict what spec 250 delivered. The
    current render is in this branch at
    packages/codev/test-results/spec-250-screenshots/phase-7/ (untracked), and
    spec-250-hierarchy.spec.ts asserts the heading's presence, position and data-codev-project-empty
    attribute, so the behaviour is pinned by a test rather than by an image.

Consultation findings and what I did with them

Both reviewers returned APPROVE (claude, opencode — this repo runs a 2-way pass per
.codev/config.json, not the 3-way the protocol prompt describes). No REQUEST_CHANGES.
Claude raised four non-blocking items:

  1. isCodevWorkspaceDirectory was substituted by every test and by the live harness — FIXED.
    The unit tests inject isCodevWorkspace and issue-272-projection.mjs:211 injects its own
    predicate, so the filter that actually decides "deleted checkout" and "not a workspace" in Tower
    was never executed by anything claiming to cover it. That is exactly the repo's own hot-tier
    lesson about a test supplying the boundary it means to check. Five tests added against a real
    filesystem — and writing them surfaced a second, real defect: existsSync(join(path, '.codev'))
    accepted a .codev that is a file. codev init creates a directory; the check now requires
    one. Confirmed to fail with the guard reverted.
  2. createWorkspaceProjectionSweeper has no test, and options.deps exists for tests not
    written — acknowledged, not fixed here.
    The sweeper is interval plumbing over
    reconcileWorkspaceProjects, which is covered; the untested part is start/stop/overlap
    behaviour. Worth a follow-up rather than widening this PR.
  3. The sweep re-exchanges the bootstrap token every 30s per server — real, and a behaviour
    change worth knowing.
    A pairing-issued one-time token is now spent on the first tick, before
    anyone spawns. The constraint is pre-documented on ThreadBackendConfig.bootstrapToken, but this
    makes an unbounded desktop seed effectively mandatory for any workspace carrying a threads
    config. Not changed here because caching a credential across processes is a storage decision.
  4. A down server logs a WARN every 30s, ~2,880 lines/day — real. Logging on state change is the
    fix; deferred as a follow-up rather than folded in.

Items 2-4 need issues; I did not open them because that call is the architect's.

How to Test Locally

  • View diff: VSCode sidebar → right-click builder pir-272Review 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 <db> "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.

pseudoseed and others added 23 commits August 31, 2026 10:47
…its directory

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:<the project's own workspace root>` (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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`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:<path>` 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) <noreply@anthropic.com>
…h stops shipping a username

`bugfix-214-publish-scrub` bans `/Users/<real>` and `/home/<real>` 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
`<anything>` as a placeholder, so it reads `/Users/<user>/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…n oversight

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.
…ituted

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.
@pseudoseed
pseudoseed merged commit 37e731d into main Aug 31, 2026
9 checks passed
pseudoseed added a commit that referenced this pull request Aug 31, 2026
pseudoseed added a commit that referenced this pull request Aug 31, 2026
chore(porch): pir-272 state commits stranded after #305 merged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The sidebar tree needs every workspace as a top-level node, named, not one project per path string

1 participant