Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,15 @@ Control direction: `dor` → sidecar JSON-lines net socket → Rust command/even
bridge → `TauriAdapter` `CustomEvent("dormouse:control-request")` → Wall
handler, and back along the same hops.

**A request is routed to the window that owns its `DORMOUSE_SURFACE_ID`**, and
one naming no Surface goes to the focused window. **A Surface no window owns
fails** — `No Dormouse window owns surface '<id>'` — rather than being handed to
a sibling, which would act on the wrong terminal. **A cancel follows its own
request** to the window holding it (`docs/specs/standalone.md` → Routing).
**A request naming a Workspace or a Window is routed to the window holding
it**, whichever window the caller sits in; one naming neither goes to the
window that owns its `DORMOUSE_SURFACE_ID`, and one naming no Surface to the
focused window. A target the registry cannot place — an unknown ref, or a name
two windows use — reaches the caller's own window, which refuses it by name.
**A Surface no window owns fails** — `No Dormouse window owns surface '<id>'` —
rather than being handed to a sibling, which would act on the wrong terminal.
**A cancel follows its own request** to the window holding it
(`docs/specs/standalone.md` → Routing).

### VS Code

Expand Down Expand Up @@ -289,14 +293,18 @@ Invariants:
- Text list output defaults to refs; commands that list handles accept
`--id-format refs|ids|both` (`uuids` is a compatibility alias for `ids`). JSON
list output always includes both refs and stable ids.
- `workspace:<n>` selects a container and is **positional**, so a strip reorder
renumbers it; `workspace:<name>` is the stable handle and **resolves only when
exactly one Workspace carries that name**, else the error lists the
candidates. Both are accepted bare (`2`, `build`), and **a ref that reads as a
number is positional**, never a name. **A Window is `window:<label>` — its
host's own name for it** (`window:main`, `window:ws-2`), and a host with
one Window answers `window:1`; each accepts its own ref bare, and **rejects
every other Window's**, there being nothing it could do with one.
- `workspace:<n>` selects a container and is **stable**: `n` is the number of
the Workspace's registry-minted id (`docs/specs/standalone.md` → "Workspace
registry"), so a strip reorder and a move between Windows rename nothing.
**Must use positional refs only on hosts without a registry (VS Code).**
**Must address unnumbered registry Workspaces as `workspace:<id>`, resolving
exact ids before names**, so legacy snapshots, duplicate names, and numeric
names cannot redirect a ref. `workspace:<name>` **resolves only when exactly one
Workspace carries that name**, else the error lists the candidates. Both are
accepted bare (`2`, `build`), and **a ref that reads as a number is a ref**,
Comment on lines +303 to +304

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Both" counted the two forms that existed before this commit; workspace:<id> is now a third, and it is the one a reader is least likely to guess is accepted bare.

Suggested change
Workspace carries that name**, else the error lists the candidates. Both are
accepted bare (`2`, `build`), and **a ref that reads as a number is a ref**,
Workspace carries that name**, else the error lists the candidates. All three
are accepted bare (`2`, `ws-a`, `build`), and **a ref that reads as a number is a ref**,

never a name. **A Window is `window:<label>` — its host's own name for it**
(`window:main`, `window:ws-2`), and a host with one Window answers
`window:1`; each accepts its own ref bare.
**Every Workspace has a `surface:1`**, so a Surface ref alone
never identifies a Workspace.
- **One Wall answers each request**, resolved in order: the Window's own verbs
Expand Down Expand Up @@ -648,12 +656,12 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d
npm) distributes the bootstrap stub, never a copy of the content. A user-level
`--global` install variant waits until a story needs it.

- **Cross-Window targeting.** `window:<label>` is a listing ref today: a Window
accepts its own and rejects every other's (Handle Model), so no command can
reach a sibling Window's Surfaces. What it would take is a route above the
per-Window router — Rust already owns the window↔Surface map it would consult
(`docs/specs/standalone.md` → Routing) — plus a `--window` flag whose refs
survive a Workspace moving between Windows.
- **Cross-Window listing and moves.** `--workspace` already reaches a sibling
Window's Workspace ([Standalone](#standalone)); the router places a `window`
target too, but no command takes that flag yet. `dor list --all` still lists
the answering Window alone, and no verb moves a Workspace between Windows or
reorders the strip. Both read the registry
(`docs/specs/standalone.md` → "Workspace registry").
- **Cross-Workspace listing in VS Code.** Each Workspace is its own webview
there, so `dor list --all` would have to aggregate at the extension host
rather than in a per-webview control handler; until it does, VS Code refuses
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ A user verb is an intentional action that produces a single observable change.
| `createWorkspace` | Add a Workspace and mount its Wall, which spawns one pane; activate by default, unless `activate: false`. |
| `closeWorkspace` | `kill` each member Surface, then remove the Workspace; the last remaining Workspace cannot be closed. |
| `renameWorkspace` | Update a Workspace's `name`; touches no Session |
| `moveWorkspace` | Reorder a Workspace within its Window; renumbers the positional `workspace:<n>` refs and touches no Session |
| `moveWorkspace` | Reorder a Workspace within its Window; a minted ref renames nothing (a host with no registry still numbers by position) and touches no Session |
| `transferWorkspace` | Move a Workspace to another Window, Surfaces and Sessions intact: `release` each member Session (detached, Process still Live) and resume it there. Kills nothing and archives nothing — not a `closeWorkspace`. |
| `tearOut` | `transferWorkspace` into a Window created for it. A Window whose last Workspace leaves closes itself. |

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Sessions and its notes with it, and killing nothing on the way
(`docs/specs/standalone.md` → Transfer). Leaving is not a close and arriving is
not a create: a Workspace that arrives mounts from the record it brought.

**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **A Workspace whose Wall has not registered is refused** (`workspace '<ref>' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:<n>` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path.
**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **A Workspace whose Wall has not registered is refused** (`workspace '<ref>' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers `workspace:<n>` refs with it only where they are positional (`docs/specs/dor-cli.md` → "Handle Model"); **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path.

The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`: standalone stores one `PersistedWindow` per window, so a relaunch restores every Workspace ([Session persistence](#session-persistence)).

Expand Down
37 changes: 36 additions & 1 deletion docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,41 @@ which structurally enforces that the install runs in the window the walk tears
down last (`docs/specs/auto-update.md`). Custom commands need no capability
entry. `standalone/scripts/tauri-conf.test.mjs` pins both.

### Workspace registry

**Rust holds the union of every window's Workspaces**, since each webview's
store (`lib/src/lib/workspace-store.ts`) sees only its own. Each window reports
its list on every change, coalesced per microtask, and the union is broadcast as
`dormouse://workspaces` with a monotonic `revision`; a webview drops a snapshot
behind the one it holds.

- **Must mint numbered ids only in Rust**, `workspace-<n>` off one counter, handed to a
webview in blocks (`workspace_reserve_ids`) so a create mints synchronously.
The ref `workspace:<n>` is the id's number, so it never renumbers and never
collides across windows; an unused reservation is a gap, nothing more.
- **Must allow boot and creation when reservation fails**, using opaque UUID
ids until the pool recovers; log the failure. Canonical refs follow
`docs/specs/dor-cli.md` → "Handle Model" (`workspace-store.test.ts`).
Comment on lines +347 to +349

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"until the pool recovers" reads as if the id recovers; it never does. A refilled pool numbers later creates, while the Workspace minted during the outage keeps its UUID — and so its workspace:<uuid> ref — for life, which is exactly what a failed first reservation still creates stable opaque refs and can recover asserts after the second installWorkspaceIdPool.

The pointer is also a bare file name, which AGENTS.md calls out directly ("a bare file name dodges the path lint and rots"); spec-lint passes on it today, so nothing catches the rot. The neighbouring bullet names its test (a_number_with_a_leading_zero_is_a_name), so the full path is the minimum here.

Suggested change
- **Must allow boot and creation when reservation fails**, using opaque UUID
ids until the pool recovers; log the failure. Canonical refs follow
`docs/specs/dor-cli.md` → "Handle Model" (`workspace-store.test.ts`).
- **Must allow boot and creation when reservation fails**, minting an opaque
`workspace-<uuid>` id and logging it. **Never renumbers it**: that Workspace
keeps a `workspace:<id>` ref for life
(`lib/src/lib/workspace-store.test.ts`).

- **The counter is seeded above every id any snapshot on disk names**, and
above every id a window reports, so a fresh id never meets a restored one.
Never below 2: `workspace-1` is a bare Wall's only Workspace.
- **A `dor` request naming a Workspace or Window routes to the window holding
it** (§Routing precedence). A target the registry cannot place — one no
window reports, or a name two windows carry — falls through to the caller's
window, which refuses a name duplicated there and otherwise resolves its own,
so a local Workspace wins. **A target routes as a number only when it reads
as `POSITIONAL_WORKSPACE_REF`** (`dor/src/protocol.ts`); `007` and `0` are
names (`a_number_with_a_leading_zero_is_a_name`).
- **`Destroyed` forgets the window's entries** and broadcasts.

Source of truth: `standalone/src-tauri/src/workspaces.rs`;
`workspace_reserve_ids` / `workspace_report` / `workspace_registry` in
`standalone/src-tauri/src/lib.rs`; `installWorkspaceRegistry` in
`standalone/src/workspace-registry.ts`; `installWorkspaceIdPool` /
`workspaceRefFor` in `lib/src/lib/workspace-store.ts`. Pinned by the tests in
`standalone/src-tauri/src/workspaces.rs` and
`an_explicit_target_routes_to_the_window_holding_it`.

### Routing

Source of truth: `route` in `standalone/src-tauri/src/routing.rs`,
Expand All @@ -345,7 +380,7 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`,
| `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed |
| `pty:list` | `data.forWindow` | the window that asked |
| `alert:*` carrying `data.id` | `data.id` | its owner |
| `dor:controlRequest` | `data.surfaceId` | its owner; no Surface named → the focused window |
| `dor:controlRequest` | `params.workspace`, `params.window`, `data.surfaceId` | in that precedence: the window holding the named Workspace (§Workspace registry), the named window, the caller's Surface's owner; none → the focused window |
| `dor:controlCancel` | `data.requestId` | the window its request went to; unknown → every window |
| `burrow:ask` | `data.params.surfaceId` | its owner; a Surface with no PTY here, or an ask naming none, → every window (§Burrow service) |
| everything else | — | every window |
Expand Down
2 changes: 1 addition & 1 deletion dor/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Filters are ANDed. --command is an exact match against the running command repor

JSON output (--json) always includes both stable ids and refs, and each row carries has_terminal (a PTY) and has_browser (a browser renderer) — gate on those, not on kind, so a Surface that has both still matches. It adds top-level caller_surface_ref/caller_surface_id and focused_surface_ref/focused_surface_id — the calling and focused Surfaces, null when neither is in the list — plus workspace_ref, window_ref, and a host block (app, workspace, cli_js_path, node_path): the identity dump dor identify used to print.

--workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build").
--workspace <ref> lists another Workspace instead, in this window or another: workspace:<n> (a stable number) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build").

--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array plus caller_workspace_ref/focused_workspace_ref, because caller_surface_ref/focused_surface_ref then name a ref several groups share (the _id halves stay unique). Target a row from another Workspace by its stable id, or pass --workspace.

Expand Down
2 changes: 1 addition & 1 deletion dor/src/commands/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const workspaceCommand: Command = {
customUsage: USAGE,
fullDescription: `Manages this Window's Workspaces. Listing them is dor list --workspaces (the overview) and dor list --all (every Workspace's Surfaces); this command only mutates.

A <workspace> target is workspace:<n> — positional, so a strip reorder renumbers it — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build").
A <workspace> target is workspace:<n> — a stable number that a strip reorder or a move between windows never changes — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build"). A target in another window is routed there.

new creates a Workspace in the background and prints its ref: it never moves the user to it, since that is a larger theft than the focus a bare dor split takes. Use dor workspace switch to activate one. Without a name, the Workspace is named "Workspace N".

Expand Down
2 changes: 1 addition & 1 deletion dor/test/snapshots/help/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Filters are ANDed. --command is an exact match against the running command repor

JSON output (--json) always includes both stable ids and refs, and each row carries has_terminal (a PTY) and has_browser (a browser renderer) — gate on those, not on kind, so a Surface that has both still matches. It adds top-level caller_surface_ref/caller_surface_id and focused_surface_ref/focused_surface_id — the calling and focused Surfaces, null when neither is in the list — plus workspace_ref, window_ref, and a host block (app, workspace, cli_js_path, node_path): the identity dump dor identify used to print.

--workspace <ref> lists another Workspace of this Window instead: workspace:<n> (positional) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build").
--workspace <ref> lists another Workspace instead, in this window or another: workspace:<n> (a stable number) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build").

--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array plus caller_workspace_ref/focused_workspace_ref, because caller_surface_ref/focused_surface_ref then name a ref several groups share (the _id halves stay unique). Target a row from another Workspace by its stable id, or pass --workspace.

Expand Down
2 changes: 1 addition & 1 deletion dor/test/snapshots/help/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ USAGE

Manages this Window's Workspaces. Listing them is dor list --workspaces (the overview) and dor list --all (every Workspace's Surfaces); this command only mutates.

A <workspace> target is workspace:<n> — positional, so a strip reorder renumbers it — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build").
A <workspace> target is workspace:<n> — a stable number that a strip reorder or a move between windows never changes — or workspace:<name>, which resolves only when exactly one Workspace carries that name and otherwise fails listing the candidates. Both forms are also accepted bare ("2", "build"). A target in another window is routed there.

new creates a Workspace in the background and prints its ref: it never moves the user to it, since that is a larger theft than the focus a bare dor split takes. Use dor workspace switch to activate one. Without a name, the Workspace is named "Workspace N".

Expand Down
Loading