Archived
docs(relay): add worktree-autodetect relay packet
Assessment of auto-detecting worktrees created outside PI WEB. Worktree discovery is already derived per request with no cache and no registry, so the gap is that the browser never re-lists. Recommends the reduced scope: filter prunable worktrees, add a non-disruptive topology refresh, and call it from the existing browser-resume path. No watchers, timers, processes, or push channels. Packet is repo-only and outside the published files allowlist, so no changeset is needed.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# Charter — relay "worktree-autodetect"
|
||||
|
||||
## Relay identity
|
||||
|
||||
- **Name:** `worktree-autodetect`
|
||||
- **Root:** `.pi-web/relays/worktree-autodetect/` in worktree `/srv/dev/pi-web-worktrees/worktree-autodetect`
|
||||
- **Branch:** `feat/worktree-autodetect` (based on `main`)
|
||||
|
||||
## Goal / finish line
|
||||
|
||||
Worktrees created or removed outside PI WEB become visible in the browser workspace
|
||||
list **with no user action of any kind**, on the next natural browser resume, on both
|
||||
local and remote machines.
|
||||
|
||||
Concretely, the relay is finished when all of the following are true:
|
||||
|
||||
1. `discoverGitWorktrees` no longer reports worktrees whose checkout directory is gone
|
||||
(`prunable` in `git worktree list --porcelain`), so worktrees deleted outside PI WEB
|
||||
stop appearing as selectable ghost workspaces.
|
||||
2. `WorkspaceController` can re-list the workspaces of the selected project and apply the
|
||||
result **without disturbing the current selection, session, or scroll state** when the
|
||||
selected workspace still exists, and without silently yanking the user out of a
|
||||
workspace that vanished while they were working in it.
|
||||
3. `PiWebApp` calls that refresh from the existing browser-resume path
|
||||
(`refreshAfterBrowserResume`) and the existing plugin-facing `refreshAppData` path.
|
||||
No new timer, no new watcher, no new process, no new WebSocket channel.
|
||||
4. Tests cover: prunable parsing/filtering, refresh-preserves-selection,
|
||||
refresh-when-selected-workspace-disappeared, and the resume wiring.
|
||||
5. `npm run verify` is green, and a changeset exists describing the user-visible behavior.
|
||||
|
||||
**Explicitly out of scope** (decided in leg 0, do not re-open without the human):
|
||||
|
||||
- Filesystem watchers on `.git/worktrees` or anywhere else.
|
||||
- Polling timers for worktree discovery.
|
||||
- Any server→browser push channel for workspace topology.
|
||||
- Instant (sub-second) detection while the browser tab already has focus.
|
||||
- Auto-*adopting* anything into `projects.json`. Worktrees are derived, never registered;
|
||||
nothing is being adopted, and no project registry write is part of this work.
|
||||
|
||||
## Sizing
|
||||
|
||||
**One leg = one vertical slice that leaves the tree green and committed.**
|
||||
|
||||
A leg is done when its slice is implemented, its tests are written and passing, the
|
||||
narrowest meaningful checks are run (`npm test -- --run <file>`, plus `npm run typecheck`
|
||||
if exported types changed), and the work is committed. Do not carry uncommitted work
|
||||
across a handoff.
|
||||
|
||||
Expected shape is three legs (see `plan.md`). If a leg turns out bigger than one slice,
|
||||
split it and hand off the remainder rather than doing "just a bit more".
|
||||
|
||||
## Task selection policy
|
||||
|
||||
1. Take the explicit **next leg** named in `status.md`.
|
||||
2. If `status.md` does not name one, take the next unfinished slice in `plan.md` in order.
|
||||
3. If neither is clear, or the next slice would change the design rather than implement it,
|
||||
**stop and raise the intervention signal**. Do not redesign inside a leg.
|
||||
|
||||
## Handover protocol
|
||||
|
||||
Before handing off, in this order:
|
||||
|
||||
1. Make the work durable: source + tests written, checks run, changes **committed** with a
|
||||
Conventional Commit message.
|
||||
2. Update `status.md`: current position, last completed leg, next leg to run, next task,
|
||||
relevant context for the next runner, blockers.
|
||||
3. Append a concise entry to `log.md`: what you did, decisions and why, artifacts changed,
|
||||
exact checks run and their results, handing-off vs stopping.
|
||||
4. Then `spawn_session` **once**, with a prompt starting:
|
||||
|
||||
```text
|
||||
Relay "worktree-autodetect" leg <N> begins now.
|
||||
|
||||
You are the next runner in this Relay method chain.
|
||||
|
||||
Read:
|
||||
- .pi-web/relays/worktree-autodetect/charter.md
|
||||
- .pi-web/relays/worktree-autodetect/status.md
|
||||
|
||||
Do not read log.md end-to-end. Use it only for targeted lookup if status.md or charter.md points you there.
|
||||
|
||||
Run one leg according to the charter. Before handing off, update status.md, append log.md, make work durable, then either spawn the next leg once or stop with a clear intervention note.
|
||||
```
|
||||
|
||||
## Intervention signal
|
||||
|
||||
**Stop, do not spawn**, and write a clearly marked `## BLOCKED` section at the top of
|
||||
`status.md` plus a log entry, if any of these happen:
|
||||
|
||||
- The next task is ambiguous, or doing it would require a design decision not in this charter.
|
||||
- You are tempted to add a watcher, a timer, a new process, or a new push channel. That
|
||||
means the design boundary is being crossed — get the human.
|
||||
- Refresh-on-resume cannot be made to preserve selection without visible UI churn
|
||||
(list reordering, chat scroll jump, session reload, terminal teardown). This is the
|
||||
main known risk; it is a stop, not a workaround.
|
||||
- Filtering `prunable` would remove a workspace the user could plausibly still want
|
||||
(for example a temporarily unmounted network path) and you cannot bound that safely.
|
||||
- `npm run verify` fails for a reason you did not introduce.
|
||||
|
||||
## Reading discipline
|
||||
|
||||
Read to orient: `charter.md`, then `status.md`, then only the files `status.md` names.
|
||||
|
||||
Do **not** read `log.md` end-to-end; use it only for targeted lookup when pointed there.
|
||||
Do **not** read the sibling worktrees `/srv/dev/pi-web-worktrees/worktree-create-ui` or
|
||||
`/srv/dev/pi-web-worktrees/model-questions-ux` — they are separate, parallel efforts. Per
|
||||
the human's decision, assume they contribute nothing to this relay; this relay owns the
|
||||
workspace-topology refresh seam outright.
|
||||
|
||||
Relevant source surface, small enough to read directly when your leg touches it:
|
||||
|
||||
- `src/server/workspaces/gitWorktreeDiscovery.ts` (39 lines)
|
||||
- `src/server/workspaces/workspaceService.ts` (44 lines)
|
||||
- `src/client/src/controllers/workspaceController.ts` (~105 lines)
|
||||
- `src/client/src/appShell/browserResumeController.ts` + its test
|
||||
- `src/client/src/components/PiWebApp.ts` — only `refreshAfterBrowserResume`
|
||||
(~line 432) and `refreshAppData` (~line 485). Do not read this 2300-line file whole.
|
||||
|
||||
## Project conventions that apply
|
||||
|
||||
- **Changesets:** this is user-visible. Add a `.changeset/*.md` fragment
|
||||
(see `.agents/skills/changeset-changelog/SKILL.md`). Never hand-edit `CHANGELOG.md`.
|
||||
- **Skills:** use `.agents/skills/code-quality-architecture/SKILL.md` when writing
|
||||
production code and `.agents/skills/testing-guide/SKILL.md` when writing tests.
|
||||
- **Session daemon:** this design deliberately touches **no** sessiond code, no session
|
||||
runtime ownership, and no daemon protocol. **No manual session daemon restart is
|
||||
required.** Changes land on the autoreloading `pi-web-web-ui-dev.service` path only.
|
||||
If a leg finds itself editing `src/server/sessiond.ts`, that is the intervention signal.
|
||||
- **Client URL conventions:** no new endpoints are added; the existing
|
||||
`workspacesApi.workspaces()` request path is reused unchanged.
|
||||
- **No `npm install`** — `node_modules` here is a symlink to the main checkout.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Log — relay "worktree-autodetect"
|
||||
|
||||
Append-only. One entry per leg. Do not read end-to-end unless `status.md` points you here.
|
||||
|
||||
---
|
||||
|
||||
## Leg 0 — Assessment, design, packet creation
|
||||
|
||||
**Runner:** assessment/design session
|
||||
**Outcome:** recommendation = **reduced scope**. Packet created. Relay parked pending
|
||||
human approval. No production code written.
|
||||
|
||||
### Feature request
|
||||
|
||||
> "auto detect and show new worktrees, even when created outside of pi-web."
|
||||
|
||||
With the user's framing: nice-to-have, not a must, expects it may not be feasible, and
|
||||
**must require zero user intervention**.
|
||||
|
||||
### What I found in the codebase
|
||||
|
||||
The single most important finding reframed the whole feature:
|
||||
|
||||
**Worktree discovery is already fully dynamic. There is no cache and no registry.**
|
||||
|
||||
`WorkspaceService.list()` (`src/server/workspaces/workspaceService.ts`, 44 lines) calls
|
||||
`isGitRepository()` then `discoverGitWorktrees()` — which shells out to
|
||||
`git worktree list --porcelain` — on **every single** `GET /projects/:projectId/workspaces`
|
||||
request. I grepped for any cache/memo in that path: there is none. Workspace ids are
|
||||
derived by hashing `${project.id}:${worktree.path}`, so they are stable across calls
|
||||
without being stored anywhere.
|
||||
|
||||
And `projects.json` (`src/server/storage/projectStore.ts`) stores only
|
||||
`{ id, name, path, createdAt }` per **project**. Workspaces are never persisted.
|
||||
|
||||
Two consequences, both of which delete a large chunk of the anticipated problem:
|
||||
|
||||
1. **A worktree created outside PI WEB is already detected.** The server has no stale
|
||||
state to invalidate. The gap is not detection at all — it is that **the browser never
|
||||
re-asks**. `WorkspaceController` fetches workspaces in `selectProject()` and in
|
||||
`refreshProjectWorkspaces()`, and the only caller of the latter is the
|
||||
workspace-*deletion* flow. So the list is fetched on project selection and then frozen
|
||||
for the lifetime of that selection.
|
||||
2. **"Auto-adoption" is a non-question.** The brief asked whether zero-intervention
|
||||
detection implies zero-intervention adoption, and whether a discovered worktree should
|
||||
be a distinct "discovered" state. Since worktrees are derived and nothing is written to
|
||||
a registry, there is nothing to adopt and no state to distinguish. A new worktree is
|
||||
simply a member of the derived list. This was the design's biggest apparent risk and it
|
||||
evaporated on inspection.
|
||||
|
||||
Measured cost of the discovery itself: **~2ms**. 20 sequential
|
||||
`git worktree list --porcelain` runs on this repo took 42ms total.
|
||||
|
||||
I also found the delivery mechanism already built and already debounced:
|
||||
`src/client/src/appShell/browserResumeController.ts` listens to window `focus` and
|
||||
document `visibilitychange`, batches signals per animation frame, and collapses
|
||||
concurrent refreshes through `TrailingRefreshCoordinator`. It drives
|
||||
`PiWebApp.refreshAfterBrowserResume()`, which today refreshes the selected session,
|
||||
machine activities, and workspace-deletion runs. Workspace topology is conspicuously
|
||||
absent from that list.
|
||||
|
||||
And remote machines need no work: `GET /projects/:projectId/workspaces` is already in
|
||||
`FEDERATED_HTTP_ROUTES` (`src/shared/federatedRoutes.ts:25`) and `workspacesApi.workspaces`
|
||||
already takes a `machineId` and routes via `machinePrefix`.
|
||||
|
||||
### The inverse case, verified against real git
|
||||
|
||||
I built a throwaway repo in `/tmp/wtprobe` and checked what git actually reports.
|
||||
|
||||
- `.git/worktrees/` does not exist until the first linked worktree is added, then gains
|
||||
one directory per worktree.
|
||||
- After `rm -rf`ing a worktree's directory **without** `git worktree remove`,
|
||||
`git worktree list --porcelain` **still lists it**, with an extra line:
|
||||
`prunable gitdir file points to non-existent location`.
|
||||
- A locked worktree gets a bare valueless `locked` line.
|
||||
|
||||
The current parser ignores both keys. So **PI WEB today shows worktrees that no longer
|
||||
exist as normal, selectable workspaces** — a real bug, present regardless of whether the
|
||||
detection feature is built. Selecting one produces a workspace whose path does not exist.
|
||||
|
||||
### Options compared
|
||||
|
||||
**A. `git worktree list` on a timer (server or client poll).**
|
||||
Rejected. It is the obvious answer and it is the wrong one. A timer runs forever to catch
|
||||
an event that happens a few times a week, and it must run per project, per machine, or it
|
||||
does not actually satisfy "no intervention". For a nice-to-have, a permanent background
|
||||
cost to serve a rare event is exactly the trade the user warned against. It also has no
|
||||
natural interval: fast enough to feel automatic is wasteful, slow enough to be cheap is
|
||||
not noticeably better than the resume trigger, which is free.
|
||||
|
||||
**B. Watch `.git/worktrees/` with `fs.watch`/inotify.**
|
||||
This was the most interesting candidate and the one I most wanted to work. The watch
|
||||
target is genuinely small and precise — one directory in the main repo, one entry per
|
||||
worktree, written by git itself. That is far better than watching filesystems for new
|
||||
directories.
|
||||
|
||||
Rejected anyway, on cost and correctness:
|
||||
|
||||
- **Lifecycle ownership is the real problem, not the watcher.** A watcher must be created
|
||||
and destroyed as projects are added/removed, and it must live somewhere long-lived. The
|
||||
web/API process autoreloads (`pi-web-web-ui-dev.service`), so watchers there churn
|
||||
constantly. The natural long-lived home is the session daemon — but that would drag a
|
||||
purely presentational concern into session runtime ownership and, per `AGENTS.md`, make
|
||||
every change to it require a manual daemon restart. For a nice-to-have, that is a
|
||||
disproportionate architectural commitment.
|
||||
- **The directory does not exist until the first worktree exists**, so a repo with no
|
||||
linked worktrees needs a watch on `.git/` itself to catch `worktrees/` being created —
|
||||
a noisier target that fires on every ref update, index write, and fetch.
|
||||
- **It only fixes the local case.** Remote machines would need the event pushed over the
|
||||
machine transport, which means a new workspace-topology realtime event type, publishing
|
||||
it from the daemon, adding it to `FEDERATED_WEBSOCKET_ROUTES` plumbing, parsing it in
|
||||
`sessionSocket.ts`, and handling it in `PiWebApp`. That is a meaningful new protocol
|
||||
surface for a feature the user called optional.
|
||||
- **Environment caveats are real.** `fs.watch` is unreliable on Docker bind mounts on
|
||||
macOS/Windows (the repo ships `docker/compose.yml` with bind-mounted checkouts) and on
|
||||
network filesystems, and inotify watch limits are a known operational failure mode. So
|
||||
the "instant" promise would be silently broken for a subset of users — worse than an
|
||||
honest "updates when you come back to the tab".
|
||||
- **It still would not be enough.** `fs.watch` on `.git/worktrees` catches creation but
|
||||
the removal case still needs the `prunable` fix, because `rm -rf` of the *checkout*
|
||||
does not touch `.git/worktrees/<name>` at all — I verified the metadata directory
|
||||
survives. So the watcher does not even subsume the cheaper fix.
|
||||
|
||||
**C. Piggyback on events that already happen.** ← chosen
|
||||
The refresh already exists, is already debounced, already covers remote machines, and
|
||||
costs one ~2ms request per tab refocus. Marginal cost is as close to zero as this feature
|
||||
can get, and the code surface is ~60 production lines.
|
||||
|
||||
### Recommendation: reduced scope
|
||||
|
||||
Do the cheap 90%:
|
||||
|
||||
1. **Fix the inverse case** — filter `prunable` worktrees out of the workspace list.
|
||||
Read-only; PI WEB must not run `git worktree prune` as a side effect of listing.
|
||||
2. **Add a non-disruptive topology refresh** to `WorkspaceController` that re-lists the
|
||||
selected project's workspaces without touching selection or session state.
|
||||
3. **Call it from the existing resume path** — no new timer, watcher, process, or channel.
|
||||
|
||||
**Scope boundary, stated plainly:** detection is **resume-scoped, not instant**. A worktree
|
||||
created in another terminal while the PI WEB tab already has focus is not noticed until the
|
||||
tab is refocused or becomes visible again. That is the honest limit, and it should be
|
||||
documented rather than papered over.
|
||||
|
||||
Within that boundary it is genuinely zero-intervention: no button, no config, no opt-in,
|
||||
works on local and remote machines, works for creation and removal.
|
||||
|
||||
### The risk that could kill it
|
||||
|
||||
`WorkspaceController.selectWorkspace()` calls `clearActiveSession()` and
|
||||
`resetWorkspaceScopedState()`. If a background refresh routes through it, the user's chat
|
||||
is torn down every time they refocus the tab. The refresh must apply the list through
|
||||
`applyProjectWorkspaces` only. `ProjectActivityOwnershipCoordinator` is the existing
|
||||
precedent for background topology hydration that deliberately leaves selection alone, and
|
||||
is the model to follow. Leg 2 proves this with tests before leg 3 wires the trigger — and
|
||||
if it cannot be made non-disruptive, that is an explicit stop.
|
||||
|
||||
### What would change the recommendation
|
||||
|
||||
- If the user says instant-while-focused is actually required, option B comes back on the
|
||||
table — but with the session daemon commitment, the new realtime event type, and the
|
||||
Docker/network-filesystem caveats accepted as the price.
|
||||
- If a future need arises for reliable server-pushed workspace topology for another reason,
|
||||
the watcher becomes incremental rather than a feature-specific cost, and the ledger flips.
|
||||
|
||||
### Artifacts created
|
||||
|
||||
- `.pi-web/relays/worktree-autodetect/charter.md`
|
||||
- `.pi-web/relays/worktree-autodetect/status.md`
|
||||
- `.pi-web/relays/worktree-autodetect/plan.md`
|
||||
- `.pi-web/relays/worktree-autodetect/log.md` (this file)
|
||||
|
||||
### Checks run
|
||||
|
||||
None — no code was changed in this leg.
|
||||
|
||||
### Human decisions received at the end of leg 0
|
||||
|
||||
All three open questions were answered, and the reduced scope was approved:
|
||||
|
||||
1. **Latency** — resume-scoped detection is acceptable. No timer, no watcher.
|
||||
2. **Removed worktrees** — yes, hide worktrees whose directory is gone.
|
||||
3. **Sibling overlap** — assume the other session does nothing; this relay owns the
|
||||
refresh seam outright and should not design for sharing.
|
||||
|
||||
The human also asked for the `selectWorkspace` risk to be explained concretely. The
|
||||
worked failure mode is now recorded inline in `plan.md` → Leg 2, because the buggy version
|
||||
is the one that looks correct: re-resolving the selection after a refresh via
|
||||
`selectPreferredWorkspace` + `selectWorkspace` (mirroring `selectProject`) tears down the
|
||||
session on **every** browser resume, since `selectWorkspace` has no already-selected guard
|
||||
and always runs `clearActiveSession()` (closing the session socket mid-stream and dropping
|
||||
buffered deltas) plus `resetWorkspaceScopedState()` (clearing chat, file tree, open file,
|
||||
git status, open diff, terminal selection).
|
||||
|
||||
### Handing off?
|
||||
|
||||
**Yes.** Packet committed, `status.md` un-parked with the decisions recorded, leg 1
|
||||
dispatched via `spawn_session`.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Implementation plan — worktree-autodetect (reduced scope)
|
||||
|
||||
Three legs. Each is a vertical slice: source + tests + checks + commit.
|
||||
|
||||
The order is deliberate: server truth first, then client application of that truth,
|
||||
then the trigger that makes it zero-intervention.
|
||||
|
||||
---
|
||||
|
||||
## Leg 1 — Stop reporting removed worktrees (the inverse case)
|
||||
|
||||
**Why first:** it is independently valuable, has zero UI risk, and is the only part of
|
||||
the feature that is a straight bug fix. Today a worktree deleted with `rm -rf` outside
|
||||
PI WEB stays in the workspace list forever as a selectable ghost.
|
||||
|
||||
**Files**
|
||||
|
||||
- `src/server/workspaces/gitWorktreeDiscovery.ts`
|
||||
- new `src/server/workspaces/gitWorktreeDiscovery.test.ts`
|
||||
|
||||
**Work**
|
||||
|
||||
1. Extend the porcelain parser to read the valueless `prunable` and `locked` keys.
|
||||
`git worktree list --porcelain` emits `prunable <reason>` for a linked worktree whose
|
||||
checkout directory no longer exists, and a bare `locked` line for a locked one.
|
||||
Verified in leg 0 against real git.
|
||||
2. Surface `prunable` on `GitWorktreeInfo`, and filter prunable entries out of what
|
||||
`discoverGitWorktrees` returns — or return them and filter in `WorkspaceService`,
|
||||
whichever keeps the parser honest and the policy visible. Prefer: parser reports
|
||||
facts, `workspaceService` decides policy.
|
||||
3. Do **not** run `git worktree prune`. Read-only. PI WEB must not mutate the user's
|
||||
repo metadata as a side effect of listing.
|
||||
4. Keep the main worktree unconditionally: never filter the entry whose path equals
|
||||
`project.path`, so a project can never end up with an empty workspace list.
|
||||
|
||||
**Tests** (pure parser tests, no git process needed — inject or fake the exec boundary)
|
||||
|
||||
- parses `prunable` with a reason and `locked` without a value
|
||||
- a prunable linked worktree is excluded from the workspace list
|
||||
- a locked worktree is still included
|
||||
- the main worktree survives even if git somehow marks it prunable
|
||||
|
||||
**Checks:** `npm test -- --run src/server/workspaces/gitWorktreeDiscovery.test.ts`,
|
||||
plus the workspaceService/app.projects tests if they touch the shape, plus
|
||||
`npm run typecheck` (`GitWorktreeInfo` is exported).
|
||||
|
||||
---
|
||||
|
||||
## Leg 2 — Non-disruptive workspace topology refresh in the client
|
||||
|
||||
**Why second:** this is the risky part, and it must be provably non-disruptive before
|
||||
anything starts calling it automatically.
|
||||
|
||||
**Files**
|
||||
|
||||
- `src/client/src/controllers/workspaceController.ts`
|
||||
- new `src/client/src/controllers/workspaceController.test.ts`
|
||||
|
||||
**Work**
|
||||
|
||||
1. Add a method — suggested name `refreshSelectedProjectTopology()` — that re-lists the
|
||||
selected project's workspaces and applies them via the existing
|
||||
`applyProjectWorkspaces` path.
|
||||
2. **Selection invariants it must hold:**
|
||||
- If the currently selected workspace is still present, do **not** call
|
||||
`selectWorkspace`, do **not** clear the active session, do **not** reset
|
||||
workspace-scoped state. Only `workspaces` / `workspacesByProjectId` change.
|
||||
|
||||
**Read this before writing the method — the wrong version looks correct.** The
|
||||
tempting shape, mirroring `selectProject()` six lines above it, is: refresh the list,
|
||||
then "re-resolve the selection to be safe" via
|
||||
`selectPreferredWorkspace(...)` + `await this.selectWorkspace(...)`. That is the bug.
|
||||
`selectWorkspace` has **no already-selected guard**, so even when it re-picks the very
|
||||
same workspace it unconditionally runs:
|
||||
- `sessions.clearActiveSession()` → `socket.close()` (closes the session WebSocket
|
||||
mid-stream), `clearPendingUpdates()`, `streamWatermark = undefined` (buffered deltas
|
||||
dropped), and `setState({ selectedSession: undefined, messages: [] })` (chat empties);
|
||||
- `setState({ ...resetWorkspaceScopedState() })` → clears `sessions`, `fileTree`,
|
||||
`expandedDirs`, `selectedFilePath`, `selectedFileContent`, `gitStatus`,
|
||||
`selectedDiffPath`, `selectedDiff`, `selectedStagedDiff`, `selectedTerminalId`.
|
||||
|
||||
Because leg 3 calls this from `refreshAfterBrowserResume`, that would fire on **every**
|
||||
alt-tab back into PI WEB — not only when a worktree actually changed — blanking the
|
||||
chat, collapsing the file tree, and closing any open diff every time, and losing stream
|
||||
deltas that arrive while the socket is down. Applying the list via
|
||||
`applyProjectWorkspaces` alone is sufficient for the feature; `handleWorkspaceChange`
|
||||
early-returns when the selected workspace id is unchanged, so a fresh-but-equal list
|
||||
causes no downstream churn on its own.
|
||||
- If nothing is selected, just apply the list.
|
||||
- If the selected workspace **disappeared**, do not silently jump. Leave the
|
||||
selection as-is and let the existing deletion path own recovery; the user is
|
||||
currently working there and a surprise switch is worse than a stale label.
|
||||
If leg 2 finds this cannot be left alone safely, that is the intervention signal.
|
||||
3. Guard against machine/project changing mid-flight, exactly like `selectProject` does
|
||||
(compare `selectedMachineId` and `selectedProject?.id` before applying).
|
||||
4. Swallow-and-report errors the way sibling background refreshes do (`console.warn`,
|
||||
not `setState({ error })`) — a background topology refresh must never paint an error
|
||||
banner over a working session.
|
||||
|
||||
**Tests** (controller-layer, fake `api.workspaces`)
|
||||
|
||||
- a newly appeared worktree lands in `workspaces` and `workspacesByProjectId`
|
||||
- the selected workspace is preserved; `sessions.clearActiveSession` is **not** called
|
||||
- a stale response for a project the user has since left is discarded
|
||||
- a rejected request does not set `state.error`
|
||||
|
||||
**Checks:** `npm test -- --run src/client/src/controllers/workspaceController.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Leg 3 — Wire it to the existing resume path, document, changeset
|
||||
|
||||
**Why last:** only after leg 2 proves the refresh is inert.
|
||||
|
||||
**Files**
|
||||
|
||||
- `src/client/src/components/PiWebApp.ts` — `refreshAfterBrowserResume` (~432) and
|
||||
`refreshAppData` (~485). Touch only these two methods.
|
||||
- possibly `src/client/src/components/PiWebApp.*.test.ts` (a focused new test file is fine)
|
||||
- `docs/` — one short paragraph where workspaces/worktrees are explained; follow
|
||||
`.agents/skills/documentation-guide/SKILL.md` and do **not** grow `README.md`
|
||||
- `.changeset/*.md`
|
||||
|
||||
**Work**
|
||||
|
||||
1. Add `this.workspaces.refreshSelectedProjectTopology()` to the `Promise.all` in
|
||||
`refreshAfterBrowserResume` and to `refreshAppData`.
|
||||
- `BrowserResumeController` already debounces per animation frame and collapses
|
||||
concurrent requests through `TrailingRefreshCoordinator`, so no extra throttling
|
||||
is needed. Verified in leg 0.
|
||||
- This inherits remote-machine support for free: `api.workspaces(projectId, machineId)`
|
||||
already routes through the machine proxy, and `/projects/:projectId/workspaces` is
|
||||
already in `FEDERATED_HTTP_ROUTES`.
|
||||
2. Optionally also refresh on realtime-socket reconnect (`connectRealtime`'s
|
||||
`onReconnect`), which is the same class of natural event. Only if it costs nothing.
|
||||
Add the call directly; this relay owns the seam and is not coordinating with any
|
||||
other branch.
|
||||
3. Document the behavior honestly: detection happens when the tab regains focus /
|
||||
becomes visible, not instantly.
|
||||
4. Add the changeset (`npm run changeset`, or write the fragment directly).
|
||||
|
||||
**Checks:** the new/affected client tests, then **`npm run verify`** — this is the final
|
||||
leg and the change is cross-cutting.
|
||||
|
||||
---
|
||||
|
||||
## Cost ledger (accepted in leg 0)
|
||||
|
||||
| Cost | Amount |
|
||||
|---|---|
|
||||
| New processes | 0 |
|
||||
| New watchers (inotify/fs.watch) | 0 |
|
||||
| New timers | 0 |
|
||||
| New endpoints / push channels | 0 |
|
||||
| Extra request per browser resume, per selected project | 1 (~2ms of `git worktree list` server-side) |
|
||||
| Production lines changed | ~60 |
|
||||
| New test files | 3 |
|
||||
|
||||
## Known risks
|
||||
|
||||
- **UI churn on refresh.** Mitigated by leg 2's invariants and its tests. This is the
|
||||
one that can kill the feature; it is an explicit intervention trigger.
|
||||
- **Latency expectation.** Detection is resume-scoped. A user staring at an already-focused
|
||||
tab while a worktree appears in another window sees nothing until they refocus. This is
|
||||
an accepted, documented limit — not a bug to fix with a timer.
|
||||
- **Overlap with the sibling `worktree-create-ui` effort.** Settled by the human: assume
|
||||
that session does nothing. This relay owns the refresh seam; build it here without
|
||||
designing for reuse, and do not read that worktree.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Status — relay "worktree-autodetect"
|
||||
|
||||
## ✅ APPROVED — relay is live
|
||||
|
||||
The human approved the reduced scope in leg 0 and answered every open question. There are
|
||||
no outstanding decisions. Run leg 1.
|
||||
|
||||
## Current position
|
||||
|
||||
Leg 0 (assessment/design) is complete. No production code has been written.
|
||||
|
||||
The recommendation is **reduced scope**: detection piggybacked on browser resume, plus a
|
||||
fix for the inverse (removed-worktree) case. No watchers, no timers, no new processes,
|
||||
no new push channel. Rationale is in `log.md` leg 0; the implementation breakdown is in
|
||||
`plan.md`.
|
||||
|
||||
## Leg tracking
|
||||
|
||||
- **Last completed leg:** 0 (assessment and packet creation)
|
||||
- **Next leg to run:** 1
|
||||
|
||||
## Next task — leg 1
|
||||
|
||||
Stop reporting worktrees whose checkout directory has been removed outside PI WEB.
|
||||
|
||||
See `plan.md` → "Leg 1". Summary: teach the `git worktree list --porcelain` parser about
|
||||
the `prunable` and `locked` keys, exclude prunable linked worktrees from the workspace
|
||||
list, never filter the main worktree, never mutate the repo (no `git worktree prune`).
|
||||
|
||||
Files: `src/server/workspaces/gitWorktreeDiscovery.ts`, `src/server/workspaces/workspaceService.ts`,
|
||||
new `src/server/workspaces/gitWorktreeDiscovery.test.ts`.
|
||||
|
||||
## Relevant context for the next runner
|
||||
|
||||
Facts established in leg 0 — trust these, they were verified against the running code and
|
||||
real git; do not re-derive them:
|
||||
|
||||
- **Worktrees are already derived, never registered.** `WorkspaceService.list()` shells out
|
||||
to `git worktree list --porcelain` on **every** `GET /projects/:projectId/workspaces`
|
||||
request. There is no server-side cache and no invalidation to design. A worktree created
|
||||
outside PI WEB is *already* discovered — the gap is purely that the browser never re-asks.
|
||||
- **`projects.json` holds projects only, not workspaces.** So "auto-adoption" is a
|
||||
non-problem: nothing needs to be written to a registry, and there is no adopt-vs-visible
|
||||
distinction to design. This collapsed most of the feature's apparent complexity.
|
||||
- **Cost of discovery is ~2ms** (measured: 20 sequential `git worktree list --porcelain`
|
||||
runs in 42ms on this repo).
|
||||
- **`prunable` is real and load-bearing.** After `rm -rf`ing a worktree directory without
|
||||
`git worktree remove`, `git worktree list --porcelain` still lists it, with an added
|
||||
`prunable gitdir file points to non-existent location` line. PI WEB currently shows this
|
||||
as a normal selectable workspace. `locked` appears as a bare valueless line.
|
||||
- **The resume path already exists and is already debounced.**
|
||||
`src/client/src/appShell/browserResumeController.ts` listens to window `focus` and
|
||||
document `visibilitychange`, batches per animation frame, and collapses concurrent
|
||||
requests via `TrailingRefreshCoordinator`. It calls
|
||||
`PiWebApp.refreshAfterBrowserResume()` (~line 432), which already refreshes the selected
|
||||
session, machine activities, and workspace-deletion runs. Workspace topology is the one
|
||||
thing missing from that list.
|
||||
- **Remote machines come for free.** `workspacesApi.workspaces(projectId, machineId)`
|
||||
routes through `machinePrefix`, and `GET /projects/:projectId/workspaces` is already in
|
||||
`FEDERATED_HTTP_ROUTES` in `src/shared/federatedRoutes.ts`. No transport work needed.
|
||||
- **The danger is `selectWorkspace`.** It calls `clearActiveSession()` and
|
||||
`resetWorkspaceScopedState()`. A refresh must apply the new list via
|
||||
`applyProjectWorkspaces` **only**, and must not route through `selectWorkspace` when the
|
||||
selection is still valid. `ProjectActivityOwnershipCoordinator` is the existing precedent
|
||||
for background topology hydration that does not disturb selection — read it if leg 2
|
||||
needs a model.
|
||||
- **No session daemon involvement.** Nothing in this design touches `src/server/sessiond.ts`,
|
||||
session runtime ownership, or the daemon protocol. **No manual sessiond restart needed.**
|
||||
|
||||
## Progress documentation expected of each runner
|
||||
|
||||
- Commit the slice (Conventional Commit message).
|
||||
- Update this file: current position, leg tracking, next task, blockers.
|
||||
- Append to `log.md`: what, why, artifacts, exact checks run and results.
|
||||
- Add the `.changeset/*.md` fragment no later than leg 3.
|
||||
|
||||
## Decisions settled by the human (do not re-open)
|
||||
|
||||
1. **Latency: resume-scoped is acceptable.** Detection on tab refocus/visibility is the
|
||||
agreed behavior. Do not add a timer or a watcher to shorten it.
|
||||
2. **Removed worktrees: hide them.** Filtering `prunable` worktrees out of the workspace
|
||||
list is wanted and approved.
|
||||
3. **Sibling overlap: assume the other session does nothing.** This relay owns the
|
||||
workspace-topology refresh seam outright. Build it here, do not design for sharing,
|
||||
and do not read `/srv/dev/pi-web-worktrees/worktree-create-ui`. If that branch later
|
||||
merges something overlapping, resolving it is that branch's problem, not this one's.
|
||||
|
||||
## Blockers
|
||||
|
||||
None. Leg 1 is clear to run.
|
||||
Reference in New Issue
Block a user