-
Notifications
You must be signed in to change notification settings - Fork 0
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 pollinggit 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.
| 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) |
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, aPtyCreateRequest) 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.
project-scope.ts#L34-L52. Never accepts an arbitrary cwd.
- Falsy target →
{ kind: 'none', reason: 'no target' }. -
target.remote→ scan projects with aremoteand 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 }. - Otherwise
target.cwdmust be non-empty and exactly a known project cwd →{ kind: 'local', cwd, projectId }, else'cwd is not a known project'.
project-scope.ts#L58-L81. Path-based containment with an optional hint:
-
hint.projectIdnarrows candidates to that one project (the shim sends the project id explicitly); -
hint.cwdnarrows candidates to the project with that cwd — and is applied afterprojectId, socwdwins when both are present; - with no hint, all projects that have a
cwdare 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' }.
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.”
- Local matching is exact string equality on
cwd— no symlink resolution, no case folding, no trailing-slash tolerance (unlikeresolveFileScope, 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
resolveGitScopeandresolvePtyScope;isKnownProjectCwdis the local counterpart. - Any refusal is a plain
{ kind: 'none' | ok: false, reason }value, never a throw — callers decide the error surface.
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 }"]
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.deleteProjectis the commit point. If it throws (for exampleEACCES: workspace.json),destroyTerminalis not called at all — pinned byproject-deletion.test.ts#L21-L36. -
Partial failure is tolerated. One failing
destroyTerminaldoes not stop the loop; successful ids are recorded so the pending list shrinks, and the failed ids are returned ascleanupPendingIds(project-deletion.test.ts#L38-L...). -
Reporting surface —
DurableCleanupResult(shared/types) is{ committed: true, cleanupPendingIds: string[] }. A duplicate failure oncompleteTerminalCleanupconservatively 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.
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
Notes:
-
originalIndexis captured before mutation, so a failedsaveIndexcan restore exactly the previous workspace index. - The
pendingTerminalCleanuplist 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
pendingTerminalNodeCleanupandterminalTombstonesentries — once the project is gone, per-node tombstones are meaningless. - An unknown project id does not throw:
stageProjectFileRemovalis skipped (stagedRemovalisnull), the filtered index is a no-op, but anypendingTerminalIdspassed in are still recorded as pending cleanup.
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
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.
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 viaversionOf(). -
worktrees: GitWorktree[]— the accepted list (GitWorktreefromshared/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 (
1after2) 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/atbehave 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.
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
- 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
cwdis always allowed byresolvePtyScope; 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.
-
saveNodesis tombstone-aware, so persistence cannot undo a node close. -
WorktreeStore.reconcileis strictly monotonic; equal versions are treated as stale.
Extension points
- New capability class → add a
resolveXScopevalidator inproject-scope.tsrather 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
resolveGitScopeandresolvePtyScope; factoring it into one shared matcher is the natural refactor. - New agent preset → registering it in
shared/agents/config.tsautomatically widens theresolvePtyScopecommand allowlist (the literaldrukremains hard-coded). - Alternative deletion backend → implement the four-method
ProjectDeletionStoresurface; no other coupling is required. - Alternative worktree source → any producer can call
reconcilewith 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance