Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4195746
Carry the whole transcript and its pins across a Workspace move
nedtwigg Sep 10, 2026
e4e3040
Replay what a handed-back Workspace missed, and close two transfer gaps
nedtwigg Sep 11, 2026
967da34
Merge workspaces-durability into workspaces-fidelity
nedtwigg Sep 11, 2026
1c39159
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
3a318fa
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
57b697f
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
006408d
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
b3041f0
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
937fec4
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
5da47d6
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
0aee8a6
Recover source output when transfers fail before content submission
nedtwigg Sep 11, 2026
035da87
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
e487504
Retain transfer replay cuts when PTYs exit before settlement
nedtwigg Sep 11, 2026
9400c8b
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
a389e63
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
aeed37a
Preserve the active transfer when a Workspace is dropped twice
nedtwigg Sep 11, 2026
328b9f7
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
b1d222a
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
c622ad4
Make transfer routing atomic and replay naturally exited buffers
nedtwigg Sep 11, 2026
663be1c
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
6a037c0
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
f8c67f0
Deliver addressed replay and restore handed-back exit status
nedtwigg Sep 11, 2026
9e68e8e
Merge branch 'workspaces-durability' into workspaces-fidelity
nedtwigg Sep 11, 2026
a136c96
Budget combined transfer and shutdown contracts
nedtwigg Sep 11, 2026
363ea2a
Finish replayed command watches for exited transfers
nedtwigg Sep 11, 2026
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
52 changes: 40 additions & 12 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,10 +374,12 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`,

| Sidecar event | Key | Goes to |
|---|---|---|
| `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay |
| `terminal:semanticEvents` | `data.id` | its owner; dropped while the id is mid-transfer — the target re-derives them from the raw replay, feeding both pane state and its `AlertManager` (rationale) |
| `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, overflow dropping the oldest (`held_events_come_back_in_order_and_bounded`) |
| `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed |
| `pty:data` | `data.id` | its owner; the source until the id's mark passes, then dropped until its replay, its bytes being in it |
| `terminal:semanticEvents` | `data.id` | its owner; the source until the id's mark passes, then dropped until its replay — the window receiving the replay re-derives them from it, feeding both pane state and its `AlertManager` (rationale) |
| `terminal:protocolEvents` | `data.id` | its owner; the source until the id's mark passes, then **held** and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, the oldest dropped past it (`held_events_come_back_in_order_and_bounded`) |
| `pty:exit` | `data.id` | its owner, never suppressed |
| `pty:replay` | `data.forWindow`, then `data.id` | the requesting window, including exited buffers; without an address, its owner; never suppressed |
| `pty:marked` | `data.id` | the source still consuming the id, which then falls silent until its replay; otherwise its owner |
| `pty:list` | `data.forWindow` | the window that asked |
| `alert:*` carrying `data.id` | `data.id` | its owner |
| `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 |
Expand Down Expand Up @@ -572,12 +574,16 @@ below reads that record rather than inferring itself from the suppression map.
`transfer_workspace` / `open_workspace_window`. On `Ok` it marks the Workspace
**transferring**: the Wall stays mounted and the notes stay put, nothing is
released, and `getWindowSnapshot` omits it.
2. **Rust** reassigns `terminalIds` to the target and suppresses their output
until each one's replay has been emitted there; the sidecar buffers a chunk
before it emits and Rust's reader is one ordered thread, so a chunk in the gap
is dropped once and replayed once (rationale). It queues the record and nudges
the target with `workspace-arriving` carrying nothing — a new window has no
listener, so its payload is pulled at boot instead.
2. **Rust** reassigns `terminalIds` to the target, keeps routing their output to
the source, and asks the sidecar to stamp a `pty:marked` line per id; at that
line the id's suppression begins, until its replay has been emitted to the
target. The source serializes each buffer at its mark and invokes
`transfer_workspace_content`, which attaches the content to the record and
only then nudges the target with `workspace-arriving` carrying nothing — or,
for a tear-out, builds the new window, whose boot pulls a payload that is
complete (`docs/specs/transport.md` → "Transferring a Workspace";
rationale). **An arrival without content is not drainable**
(`an_arrival_is_drainable_only_once_its_content_landed`).
3. **Target** drains with `take_arrivals` and, per arrival, arms its collector
*before* calling `adopt_ready(workspaceId)` — the hop that removes the whole
"arrived before armed" class of bug (rationale). Rust answers
Expand Down Expand Up @@ -615,8 +621,30 @@ below reads that record rather than inferring itself from the suppression map.
source unsuppressed, drop the record, and emit `workspace-arrival-failed`; the
source clears **transferring** and the Workspace is simply still there. With
both ends gone the shells are reaped rather than left owned by a dead label.
**The gap is lost on a hand-back**: suppressed from the invoke with no replay
to follow, it is the one path nothing recovers.
- **Must change transfer ownership and source routing under one routing lock**,
so output before the mark always reaches the source
(`transfer_ownership_and_source_routing_change_together`).
- **Must reject a repeated move while that Workspace is in flight**, preserving
the first attempt’s content and recovery state. Async continuations act only
on their own attempt (`keeps the first move recoverable when the same tab is
dropped twice` in `standalone/src/workspace-move.test.ts`).
- **A hand-back replays what the marked ids missed.** From an id's mark to the
hand-back every byte went to the target, or nowhere, so `hand_back_arrival`
returns each id the content marked to the source *suppressed* and asks the
sidecar for `outputSince(mark)` scoped to the source (`requestId`
`handback-<workspaceId>`); that replay lifts the suppression and lands in the
existing xterms (`acceptHandBackReplay`), the held protocol events behind it.
**Must record source cuts at `pty:marked`, retaining them through target
replay and natural PTY exit until settlement, and carry replay ids in the failure event**; content
submission and the source invoke reply may both still be pending. **Must discard
cuts on explicit kill and never recreate an exited PTY’s owner on hand-back.**
**Must apply a handed-back PTY’s exit status after its replay**, leaving its
existing pane dead with no running command or active watch
(`settles the replayed watch when a marked buffer belongs to an exited PTY` in
`standalone/src/tauri-adapter.test.ts`). An id the
sidecar never stamped goes straight back: a whole-buffer
replay would paint it twice (`a_hand_back_replays_only_the_marked_ids`;
rationale).
- **`planArrival` never throws into `bootstrap()`.** A refused sole arrival on
the boot path renders a fresh one-pane Workspace, never a blank window.
- **`take_arrivals` does not consume.** The record settles at `adopt_done`, so a
Expand Down
25 changes: 25 additions & 0 deletions docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ ends at `adopt_failed` or at the target's `Destroyed`, not at a timer.

## Arrival queue

**Why the mark is stamped in the stream rather than asked for.** A mark fetched
by request answers at some instant the sidecar chose, while the source's xterm
stands at whatever `pty:data` had reached it — two clocks nothing aligns, so a
serialization taken against a fetched mark either repeats or loses the bytes
between them. A `marked` line written into the same stdout as the data is
ordered with it by construction: the sidecar's reader is one thread, Rust's
reader is one thread, and the webview's event queue is one queue. The one gap
left is the parser's incomplete-sequence buffer, which can hold bytes older
than the mark past it; that tail is the same class of cut the bounded replay
always made, and the target's parser resynchronizes on the next ground byte
(2026-09).



**Why a hand-back replays since the mark, and only the marked ids.** The first
hand-back returned the ids unsuppressed and silent: the source's xterm stood at
the mark, and every byte from there to the hand-back had gone to a target that
never mounted it — dropped while suppressed, or painted in a webview that then
closed. The since-mark replay is the arrival's own second half aimed back at the
source, which is why it rides the same `pty:requestInit` and the same
suppression-lifting `pty:replay` path rather than a new message. An id the
content did not mark has no such gap: the source either saw every byte live or
serialized the whole buffer it still holds, and the sidecar's only answer for
an unmarked id is that whole buffer again (2026-09).

The first build emitted `workspace-arriving` straight at the target. A window
torn out seconds earlier, or one restoring at launch, has no listener yet and is
a perfectly ordinary drop target — the payload went nowhere, and because the
Expand Down
46 changes: 37 additions & 9 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,43 @@ and it turns on three rules:
from a webview unmount**: a Wall unmounts on a reload and on a StrictMode
double-mount, and releasing there would strand every PTY the window still owns.
A move the target never took leaves the Workspace exactly as it was.
- **Suppress until the replay.** The host moves ownership synchronously and
drops the moving PTYs' output until each one's replay has reached the new
owner, so no byte is painted twice and none is lost. It fails open after a
bound rather than silencing a pane forever (rationale).
- **Ask for exactly the moving ids.** `pty:requestInit` names them, and
`list(ids)` follows the same **omitted is not empty** rule `interrupt` carries
— a caller forwarding a computed set that came out empty gets a no-op, not
every PTY in the process. The moving ids include each pane's helper Session,
which no other field names.
- **Split at a mark stamped in the stream, then suppress until the replay.**
The host moves ownership synchronously but keeps every byte flowing to the
source until the sidecar's `pty:marked` line for the id — written behind every
`pty:data` it had sent and ahead of every one after — so the source, on
seeing it, drains xterm's write queue and holds exactly the bytes before the
mark. It serializes the buffer there (`serializeTerminal`,
`@xterm/addon-serialize`) and hands it over as the arrival's *content*; from
the mark the host drops the id's output until the target's replay has reached
it. The target writes the serialized buffer, then the replay of everything
after the mark, so the whole transcript crosses, not the sidecar's bounded
tail, and no byte is painted twice or lost. An id the host never marked is
serialized anyway and replayed whole. A hand-back is the same split kept: the
source still holds the bytes before the mark and receives the host's replay of
everything after it into the same xterm (`docs/specs/standalone.md` →
"Arrival queue"). **Must include retained, naturally exited buffers in explicit
marked requests with `alive: false` and their exit code**, replaying their
since-mark tail; ordinary discovery remains live-only, and explicit kill
discards the buffer (`list` in `standalone/sidecar/pty-core.js`). Suppression fails open after a bound rather than silencing
a pane forever (rationale).
- **Ask for exactly the moving ids, at their marks.** `pty:requestInit` names
them with their marks, and `list(ids, …, marks)` replays `outputSince(mark)`
for a marked id and the whole buffer otherwise; ids follow the same **omitted
is not empty** rule `interrupt` carries — a caller forwarding a computed set
that came out empty gets a no-op, not every PTY in the process. The moving
ids include each pane's helper Session, which no other field names.
- **Pins travel with the buffers.** The source takes each note's marker lines at
the instant it serializes (`snapshotTerminalPins`); the target re-registers
them at those lines once the rebuilt buffer has been parsed
(`restoreTerminalPins`), and the pin's byte-for-byte proof still decides
whether it is trusted (`docs/specs/notepad.md` → Source pins).

Source of truth: `captureTransferContent` in
`lib/src/components/wall/workspace-transfer.ts`; `mark` / `list` in
`standalone/sidecar/pty-core.js`; `standalone/src/workspace-move.ts`. Pinned by
`a mark is ordered in the stream and a since-mark replay is exactly the
remainder` in `standalone/sidecar/pty-core.test.js` and
`standalone/src/workspace-move.test.ts`.

**Cold restore** (neither live PTYs nor a browser-only resume) falls back to saved session state: new PTYs in the saved CWDs under the currently selected Dormouse shell, plus the saved Lath layout. No transcript is replayed ("What is persisted"), and any pane carrying a recovery command auto-runs it. `reconnect.ts` waits 500 ms for the PTY list, and 3 s more where a retry is asked for.

Expand Down
5 changes: 3 additions & 2 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@zxing/browser": "0.2.1",
"@zxing/library": "0.23.0",
"@xterm/addon-fit": "0.12.0-beta.301",
"@xterm/addon-image": "0.10.0-beta.301",
"@xterm/addon-serialize": "0.15.0-beta.301",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
"@xterm/addon-webgl": "0.20.0-beta.300",
"@xterm/xterm": "6.1.0-beta.304",
"@zxing/browser": "0.2.1",
"@zxing/library": "0.23.0",
"clsx": "^2.1.1",
"dor-lib-common": "workspace:*",
"fflate": "0.8.3",
Expand Down
53 changes: 49 additions & 4 deletions lib/src/components/wall/workspace-transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { snapshotNotepadForTransfer, removeSurface } from '../../lib/notepad/notepad-store';
import { snapshotNotepadForTransfer, snapshotTerminalPins, removeSurface } from '../../lib/notepad/notepad-store';
import type { TransferredPin } from '../../lib/notepad/source-link';
import { forgetHelper, getHelper } from '../../lib/helper-terminal';
import { releaseSession } from '../../lib/terminal-registry';
import { releaseSession, serializeTerminal } from '../../lib/terminal-registry';
import type { VolatileNotepadSnapshot } from '../../lib/notepad/types';
import type { PersistedSession, PersistedWorkspace, WorkspaceId } from '../../lib/session-types';
import type { SaveOptions } from '../../lib/session-save';
Expand All @@ -18,8 +19,9 @@ export interface WorkspaceTransferPayload {
workspaceId: WorkspaceId;
/** What the target restores the Workspace from. */
workspace: PersistedWorkspace;
/** The notes riding along; the target hydrates them. Pins do not travel —
* they are markers in xterm instances this release disposes. */
/** The notes riding along; the target hydrates them. Their pins follow in
* the content (`captureTransferContent`), once the buffers they point
* into have been serialized. */
notepad: VolatileNotepadSnapshot;
/** Member Surfaces holding a PTY, **plus each one's helper Session**: exactly
* what changes ownership. A helper is not a member Surface — it has no pane
Expand Down Expand Up @@ -116,3 +118,46 @@ export async function prepareWorkspaceTransfer(
},
};
}

/** One terminal's half of a transfer's content: what the target writes before
* it attaches, and where the host's replay picks up. */
export interface TransferredTerminal {
/** The buffer as the escape stream that rebuilds it; `''` for a Session this
* Window no longer held. */
serialized: string;
/** The sidecar's output position the serialization stands at; absent when
* the host never stamped one, and the target then replays the whole buffer
* behind the serialized one. */
mark?: number;
}

/** The second half of a transfer's payload (`docs/specs/transport.md` →
* "Transferring a Workspace"): captured once every terminal's mark has
* passed, and attached to the arrival the host queued at the invoke. */
export interface WorkspaceTransferContent {
terminals: Record<string, TransferredTerminal>;
pins: TransferredPin[];
}

/**
* Serialize every terminal at its mark, and take its pins at the same instant.
*
* **Only after the host's `marked` line for each id**: everything this Window
* was sent before that line is in the buffer once the write queue drains, and
* everything after it is what the target's since-mark replay carries — the two
* tile the stream with nothing lost and nothing twice. An id with no mark (the
* host never answered for it) is serialized anyway and replayed whole, which
* at worst repeats its tail.
*/
export async function captureTransferContent(
terminalIds: readonly string[],
marks: ReadonlyMap<string, number>,
): Promise<WorkspaceTransferContent> {
const terminals: Record<string, TransferredTerminal> = {};
for (const id of terminalIds) {
const serialized = (await serializeTerminal(id)) ?? '';
const mark = marks.get(id);
terminals[id] = mark === undefined ? { serialized } : { serialized, mark };
}
return { terminals, pins: snapshotTerminalPins(terminalIds) };
}
66 changes: 66 additions & 0 deletions lib/src/lib/notepad/notepad-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const registryMock = vi.hoisted(() => ({ getTerminalInstance: vi.fn<(id: string) => unknown>() }));
vi.mock('../terminal-registry', () => ({ getTerminalInstance: registryMock.getTerminalInstance }));
import type { IMarker } from '@xterm/xterm';
import { FakePtyAdapter, setPlatform } from '../platform';
import type { CwdState } from '../terminal-state';
Expand All @@ -20,7 +23,9 @@ import {
pendingBatchId,
pruneEmptyNote,
removeSurface,
restoreTerminalPins,
registerNotepadSurfaceMetaResolver,
snapshotTerminalPins,
setNoteText,
setOpenNotepadId,
setStagedArchiveDeletions,
Expand Down Expand Up @@ -604,3 +609,64 @@ it('mirrors and resumes a pending batch even after its last note is deleted', as
expect(pendingBatchId('s1')).toBe(batchId);
expect(buildVolatileSnapshot().surfaces[0].notes).toEqual([]);
});

describe('pins travelling with a Workspace', () => {
beforeEach(() => {
registryMock.getTerminalInstance.mockReset();
});

it('snapshots the live pins of the named terminals and re-pins them at the same lines', () => {
const pinned = source('term-1');
const noteId = addTerminalNote('term-1', [{ text: 'twelve chars' }], pinned)!;
addTerminalNote('term-1', [{ text: 'unpinned' }]);
addTerminalNote('term-2', [{ text: 'elsewhere' }], source('term-2'));

const pins = snapshotTerminalPins(['term-1']);
expect(pins).toEqual([{
surfaceId: 'term-1',
noteId,
startLine: 3,
endLine: 3,
startColumn: 0,
endColumn: 12,
shape: pinned.shape,
expectedRawText: pinned.expectedRawText,
}]);

// The target: notes hydrated without sources, a rebuilt buffer, and the
// same absolute lines to point at.
clearAllNotepads();
hydrateNotepadFromVolatile(
{ surfaces: [{ surfaceId: 'term-1', surfaceTitle: '', surfaceKind: 'terminal', cwd: null, terminalId: 'term-1',
notes: [{ id: noteId, createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'twelve chars' }] } }] }], stagedDeletions: {} },
['term-1'],
);
const registered: number[] = [];
registryMock.getTerminalInstance.mockImplementation((id) => id === 'term-1' ? {
cols: 80,
buffer: { active: { type: 'normal', baseY: 0, cursorY: 5, length: 10, getLine: () => undefined } },
registerMarker: (offset: number) => { registered.push(5 + offset); return marker(); },
} : null);
restoreTerminalPins(pins);
expect(registered).toEqual([3, 3]);
expect(getNotes('term-1')[0].source?.terminalId).toBe('term-1');
// Again is a no-op: a note already pinned is not pinned twice.
restoreTerminalPins(pins);
expect(registered).toEqual([3, 3]);
});

it('leaves a note unpinned when its terminal is not here or the lines fall outside the buffer', () => {
const noteId = addTerminalNote('term-1', [{ text: 'x' }])!;
const pin = { surfaceId: 'term-1', noteId, startLine: 30, endLine: 31, startColumn: 0, endColumn: 1, shape: 'linewise' as const, expectedRawText: 'x' };
registryMock.getTerminalInstance.mockReturnValue(null);
restoreTerminalPins([pin]);
expect(getNotes('term-1')[0].source).toBeUndefined();
registryMock.getTerminalInstance.mockReturnValue({
cols: 80,
buffer: { active: { type: 'normal', baseY: 0, cursorY: 0, length: 10, getLine: () => undefined } },
registerMarker: () => marker(),
});
restoreTerminalPins([pin]);
expect(getNotes('term-1')[0].source).toBeUndefined();
});
});
Loading