Skip to content

Project Scope, Deletion & Worktree Registry

dazeb edited this page Sep 17, 2026 · 2 revisions

Project Scope, Deletion & Worktree Registry

Three Electron-free modules in src/core/ form the trust boundary between “a node somewhere on the canvas” and “a real folder, repo, or shell on a machine”:

  • project-scope.ts — validates that an incoming file / git / PTY request belongs to a known project (an entry in the workspace index) before any host capability is exercised.
  • project-deletion.ts + WorkspaceStore.deleteProject — remove a project from disk before destroying its live terminal sessions, and report exactly what could not be cleaned up.
  • worktree-store.ts — an epoch-guarded in-memory registry of the git worktrees (including the main checkout) that exist for a project, fed by a polling git worktree list.

The scope validators are explicitly shared between the Electron main process and the Server Edition handlers; per the file header, “One implementation, two consumers — the drift between them was the critical.” That makes this page the reference for how server-side confinement is supposed to behave.

Files at a glance

File Responsibility
src/core/project-scope.ts ProjectScope / RemoteScope / ScopeResult; isKnownProjectCwd, resolveGitScope, resolveFileScope, resolvePtyScope
src/core/workspace-store.ts WorkspaceStore: index ownership, per-project revs, node/link persistence, deleteProject two-phase commit, pending-cleanup bookkeeping
src/core/project-deletion.ts deleteProjectAndDestroyTerminals, ProjectDeletionStore, TerminalCleanupFailure
src/core/project-deletion.test.ts Pins the ordering and partial-failure guarantees
src/core/worktree-store.ts WorktreeStore, ReconcileResult
src/core/worktree-store.test.ts Pins stale/duplicate poll rejection and has / at lookup
src/renderer/src/state/projects.ts, src/renderer/src/state/workspace.ts Renderer-side project/workspace state that drives these requests (file listing only)

1. Resolving which project an operation belongs to

The identity of a “known project”

A known project is an entry in WorkspaceStore.snapshot().index.projects (src/core/workspace-store.ts#L41-L47):

  • local — identified by its cwd, matched exactly (isKnownProjectCwd, project-scope.ts#L28-L30);
  • remote — identified by the tuple host, user ?? 'root', port ?? 22, path (ProjectRemote).

addProject enforces the local/remote split at creation time: a remote project stores cwd: null and its destination on remote (workspace-store.ts#L58-L66).

There is no “node → project” lookup function. Instead, every capability request carries a target (GitTarget, an absolute path, a PtyCreateRequest) and the validators attribute that target to a project — or refuse it. This is deliberate: the validators are the single choke point whether the caller is Electron IPC or a Socket-served RPC client.

resolveGitScope(store, target)

project-scope.ts#L34-L52. Never accepts an arbitrary cwd.

  1. Falsy target → { kind: 'none', reason: 'no target' }.
  2. target.remote → scan projects with a remote and compare host / user / port / path with the same defaults. No match → 'remote is not a known project'. Match → { kind: 'remote', remote: match.remote, projectId: match.id }.
  3. Otherwise target.cwd must be non-empty and exactly a known project cwd → { kind: 'local', cwd, projectId }, else 'cwd is not a known project'.

resolveFileScope(store, absPath, hint?)

project-scope.ts#L58-L81. Path-based containment with an optional hint:

  • hint.projectId narrows candidates to that one project (the shim sends the project id explicitly);
  • hint.cwd narrows candidates to the project with that cwd — and is applied after projectId, so cwd wins when both are present;
  • with no hint, all projects that have a cwd are candidates.

Containment test: the project root is normalized by stripping trailing slashes (norm), then the path is accepted when absPath === root || absPath.startsWith(root + '/'). Success returns { ok: true, cwd, projectId, path }; otherwise { ok: false, reason: 'path is outside every known project folder' }.

resolvePtyScope(store, req, { allowCommands })

project-scope.ts#L93-L130. This is the PtyCreateRequest gate for the Server bridge; the desktop renderer path never calls it because the renderer is trusted. Three independent checks:

Check Rule Failure reason
Command Only when opts.allowCommands === false: the first whitespace-delimited token must equal the command of an enabled agent preset (agentIds() / agentConfig() from shared/agents/config) or the literal druk editor preset 'command is not an agent or editor preset'
Remote req.remote must match a known remote project on host / user / port / path 'remote is not a known project'
Local cwd An explicit req.cwd must be a known local project cwd. A missing cwd is legitimate — cwd-less projects (Welcome, cloud-restored canvases) start the shell in the process workdir, which inside a per-user space container is the sandbox 'cwd is not a known project'

The allowlist rather than an arbitrary-string check is justified in the source by the fact that node commands auto-run on open, and the permitted presets are “no more capable than the interactive shell a user already gets.”

Boundary conditions worth remembering

  • Local matching is exact string equality on cwd — no symlink resolution, no case folding, no trailing-slash tolerance (unlike resolveFileScope, which normalizes the project root only).
  • Remote matching normalizes the defaults (root, 22) but not the path.
  • The remote tuple comparison is duplicated verbatim in resolveGitScope and resolvePtyScope; isKnownProjectCwd is the local counterpart.
  • Any refusal is a plain { kind: 'none' | ok: false, reason } value, never a throw — callers decide the error surface.

2. Project deletion: commit first, destroy second

The ordering invariant

deleteProjectAndDestroyTerminals (project-deletion.ts#L14-L46) exists to guarantee that irreversible session destruction never happens for a deletion that failed to persist. Its doc comment states it directly: “Commit project removal before permanently destroying its terminal sessions.”

flowchart TD
  A["deleteProjectAndDestroyTerminals(store, destroyTerminal, projectId, liveTerminalIds?)"] --> B["collect terminal node ids from snapshot.projects[projectId]"]
  B --> C["union with liveTerminalIds and store.pendingTerminalIdsForProject(projectId)"]
  C --> D["store.deleteProject(projectId, [...terminalIds])"]
  D -->|throws| D1["abort — destroyTerminal is never called"]
  D -->|commits| E["for each id: destroyTerminal(id) inside try/catch"]
  E --> F["completed[] / failures[]"]
  F --> G["store.completeTerminalCleanup(completed)"]
  G -->|throws| G1["return { committed: true, cleanupPendingIds: all terminalIds }"]
  G -->|succeeds| G2["return { committed: true, cleanupPendingIds: failed ids }"]
Loading

Key nodes:

  • Union of three sources — persisted terminal nodes, caller-supplied liveTerminalIds (sessions the caller knows are running but may not yet be in the snapshot), and ids already recorded as pending for the project from an earlier interrupted run. This is how a crash mid-deletion is resumed instead of leaked.
  • store.deleteProject is the commit point. If it throws (for example EACCES: workspace.json), destroyTerminal is not called at all — pinned by project-deletion.test.ts#L21-L36.
  • Partial failure is tolerated. One failing destroyTerminal does not stop the loop; successful ids are recorded so the pending list shrinks, and the failed ids are returned as cleanupPendingIds (project-deletion.test.ts#L38-L...).
  • Reporting surface — DurableCleanupResult (shared/types) is { committed: true, cleanupPendingIds: string[] }. A duplicate failure on completeTerminalCleanup conservatively re-reports all ids as still pending.

The dependency is intentionally narrow: ProjectDeletionStore is a Pick<WorkspaceStore, 'snapshot' | 'deleteProject' | 'pendingTerminalIdsForProject' | 'completeTerminalCleanup'>, so the function is testable with plain doubles and reusable against any store implementing those four methods.

Inside WorkspaceStore.deleteProject

workspace-store.ts#L125-L179 is a two-phase commit over the index plus a staged project file removal:

sequenceDiagram
  participant Caller
  participant WS as WorkspaceStore
  participant Files as workspace-files
  participant Disk as userDataPath
  Caller->>WS: deleteProject(id, pendingTerminalIds)
  WS->>WS: collect nodeCleanupIds from pendingTerminalNodeCleanup (project id)
  WS->>Files: stageProjectFileRemoval(userDataPath, project)
  Files-->>WS: stagedRemoval (null when the project is unknown)
  WS->>WS: build nextIndex = projects minus id, plus pendingTerminalCleanup entries,<br/>minus that project's node-cleanup and tombstone entries
  WS->>Disk: saveIndex(nextIndex)
  alt saveIndex succeeded
    WS->>Files: stagedRemoval.commit()
    WS->>WS: this.index = nextIndex; this.revs.delete(id)
  else saveIndex threw
    WS->>Disk: saveIndex(originalIndex) rollback
    WS->>Files: stagedRemoval.rollback()
    WS-->>Caller: throw error, or AggregateError('Project deletion and rollback failed')
  end
Loading

Notes:

  • originalIndex is captured before mutation, so a failed saveIndex can restore exactly the previous workspace index.
  • The pendingTerminalCleanup list is appended with { projectId, terminalId } entries, deduplicated against entries already present for the same project/terminal pair.
  • A successfully committed deletion also removes that project's pendingTerminalNodeCleanup and terminalTombstones entries — once the project is gone, per-node tombstones are meaningless.
  • An unknown project id does not throw: stageProjectFileRemoval is skipped (stagedRemoval is null), the filtered index is a no-op, but any pendingTerminalIds passed in are still recorded as pending cleanup.

Bookkeeping state that survives a crash

newerWorkspaceStore keeps three related lists in WorkspaceIndex:

Field Written by Cleared by
pendingTerminalCleanup deleteProject, pendingTerminalIdsForProject consumers completeTerminalCleanup(terminalIds)
pendingTerminalNodeCleanup stageTerminalNodeClose(projectId, terminalId) completeTerminalNodeClose, deleteProject
terminalTombstones stageTerminalNodeClose deleteProject, retireCompletedTerminalTombstones() on a fresh process

stageTerminalNodeClose validates the terminal id with isSafeProjectId and rejects unknown projects before touching disk (workspace-store.ts#L204-L223). The tombstone is the important half: saveNodes filters out any node whose id is tombstoned for that project (workspace-store.ts#L267-L285), which prevents a delayed renderer save from resurrecting a terminal node that was just closed.

stateDiagram-v2
  [*] --> Live
  Live --> Tombstoned: stageTerminalNodeClose writes pendingTerminalNodeCleanup + terminalTombstones
  Tombstoned --> TombstonedPending: completeTerminalNodeClose removes the pending entry (tombstone stays)
  TombstonedPending --> [*]: retireCompletedTerminalTombstones() on next process start
  Tombstoned --> [*]: deleteProject drops the project's tombstones
Loading

retireCompletedTerminalTombstones (workspace-store.ts#L249-L262) only runs safely at startup: a fresh process cannot receive delayed saves from the prior run, so tombstones whose pendingTerminalNodeCleanup entry is gone are filtered out. The method is a no-op when nothing changed.


3. Worktree registry

WorktreeStore (worktree-store.ts#L16-L50) is a small, Electron-free state holder for the git worktrees of a project, including the main checkout that git worktree list reports. It exists because a poller asks git for worktree list asynchronously, and an out-of-order response must never overwrite fresher state.

State

  • version: number — last accepted monotonic poll version, exposed via versionOf().
  • worktrees: GitWorktree[] — the accepted list (GitWorktree from shared/types).

API

Method Behavior
reconcile(worktrees, version) Accepts only when version > this.version; updates both fields and returns { accepted: true, worktrees }. Versions <= the last accepted are dropped and the current list is returned with accepted: false.
get() Current accepted list (initially []).
versionOf() Last accepted version.
has(path) True when path is a tracked worktree (or the main checkout).
at(path) The tracked worktree at path, else undefined.

The tests pin the intended semantics precisely:

  • first poll (version 1) is accepted and stored (worktree-store.test.ts#L12-L19);
  • a stale poll (1 after 2) is dropped, leaving [WT.main] intact (#L21-L27);
  • a poll at the same version is a no-op re-read and dropped (#L29-L34);
  • a newer poll (3) reconciles a grown list (#L36-L41);
  • has / at behave as membership and lookup over the accepted list (#L43-L51).

Consumers. The store “feeds the scoped panel”: the git panel can target the main checkout or a bound worktree path, using has(path) / at(path) to decide whether a requested path is a legitimate scope. The version is supplied by the poller — the store does not generate it, so any producer may feed it as long as versions are monotonic.


Call chains

Server RPC / Electron IPC handler
  └─ resolveGitScope | resolveFileScope | resolvePtyScope   (src/core/project-scope.ts)
       └─ WorkspaceStore.snapshot().index.projects           (src/core/workspace-store.ts)

Project delete handler
  └─ deleteProjectAndDestroyTerminals(store, destroyTerminal, projectId, liveTerminalIds)
       ├─ store.snapshot() / pendingTerminalIdsForProject
       ├─ store.deleteProject ──► stageProjectFileRemoval + saveIndex → disk   (commit point)
       ├─ destroyTerminal(id)  (per id, try/catch)
       └─ store.completeTerminalCleanup(completed)  ──► saveIndex → disk

Terminal node close
  └─ stageTerminalNodeClose → pendingTerminalNodeCleanup + terminalTombstones
     ├─ saveNodes filters tombstoned node ids
     └─ completeTerminalNodeClose → pending entry removed
        retireCompletedTerminalTombstones() at next boot

git worktree poller
  └─ WorktreeStore.reconcile(worktrees, version)   (stale results dropped)
       └─ has(path) / at(path)  →  scoped git panel

Boundary conditions & extension points

Boundary conditions

  • Scope resolution is deny by default: exact cwd for local git/PTY, exact tuple for remote, prefix containment (root + /) for files, and an explicit preset allowlist for server-issued commands.
  • A cwd-less project is a first-class case — a PTY with no cwd is always allowed by resolvePtyScope; only an explicit unknown cwd is refused.
  • Deletion never destroys sessions before persistence succeeds, and never throws away the record of work that still needs cleanup.
  • Deletion of an unknown project id does not raise; it is treated as a filter that removes nothing.
  • saveNodes is tombstone-aware, so persistence cannot undo a node close.
  • WorktreeStore.reconcile is strictly monotonic; equal versions are treated as stale.

Extension points

  • New capability class → add a resolveXScope validator in project-scope.ts rather than inline checks in each handler; this is the mechanism that keeps Electron and Server behavior identical.
  • New remote identity rule → the host/user/port/path comparison appears in both resolveGitScope and resolvePtyScope; factoring it into one shared matcher is the natural refactor.
  • New agent preset → registering it in shared/agents/config.ts automatically widens the resolvePtyScope command allowlist (the literal druk remains hard-coded).
  • Alternative deletion backend → implement the four-method ProjectDeletionStore surface; no other coupling is required.
  • Alternative worktree source → any producer can call reconcile with monotonic versions (poll timers, watchers, manual refresh); the store does not care where the list came from.

Sources: src/core/project-scope.ts, src/core/project-deletion.ts, src/core/project-deletion.test.ts, src/core/worktree-store.ts, src/core/worktree-store.test.ts, src/core/workspace-store.ts, src/core/workspace-store.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally