Skip to content

Commit 2693111

Browse files
authored
feat(runtimed): add RuntimeStateDoc — per-notebook daemon-authoritative state via Automerge sync (#977)
* feat(runtimed): add RuntimeStateDoc — per-notebook daemon-authoritative state via Automerge sync Phase 1 of the runtime state documents architecture. Replaces broadcast- carried state with a read-only Automerge document that the daemon pushes and clients subscribe to via the existing notebook sync connection. New frame type 0x05 (RuntimeStateSync) carries Automerge sync messages for the RuntimeStateDoc alongside the existing notebook doc sync. The daemon writes kernel status, queue state, and env sync drift. Clients receive updates via normal Automerge sync — no broadcasts to drop, no stale state for late joiners. Daemon side: - RuntimeStateDoc added to NotebookRoom, synced per-peer in run_sync_loop_v2 - Dual-writes at all KernelStatus, QueueChanged, and EnvSyncState sites - Change stripping enforces read-only (client mutations silently dropped) - RoomKernel and IOPub task write status/queue transitions WASM side: - Second AutoCommit in NotebookHandle for the state doc - receive_frame routes 0x05 frames, returns RuntimeStateSyncApplied events - generate_runtime_state_sync_reply for the Automerge handshake Frontend: - runtime-state.ts reactive store with useRuntimeState() hook - Frame pipeline handles RuntimeStateSyncApplied, sends sync replies - useSyncExternalStore for zero-overhead React subscriptions * docs: update protocol and plan docs for RuntimeStateDoc (Phase 1 complete) * fix(runtimed): wire state_doc writes for kernel_died and clear_queue The sync functions can't await tokio::RwLock, but the async command processor in notebook_sync_server.rs can. Write error status + cleared queue to RuntimeStateDoc in the QueueCommand::KernelDied and QueueCommand::CellError handlers, right alongside the existing presence updates. * feat(frontend): migrate useDaemonKernel to read state from RuntimeStateDoc Replace broadcast-driven useState for kernel status, queue state, kernel info, and env sync with derived state from useRuntimeState(). - kernelStatus: derived from RuntimeStateDoc with busy throttle preserved - queueState: derived from RuntimeStateDoc queue fields - kernelInfo: derived from RuntimeStateDoc kernel.language + env_source - envSyncState: derived from RuntimeStateDoc env fields Broadcast handler slimmed to event-only cases: execution_started, output, display_update, execution_done, outputs_cleared, comm, comm_sync, env_progress. State broadcasts (kernel_status, queue_changed, kernel_error, env_sync_state) kept as no-op cases to avoid log spam. Removed fetchKernelInfo polling — RuntimeStateDoc sync provides initial state immediately. Disconnect handler calls resetRuntimeState(). Launch/shutdown no longer manually set status — daemon writes arrive via sync. * fix: address Copilot review feedback - Split RuntimeStateDoc constructors: new() for daemon (stable actor + scaffold), new_empty() for clients (random actor, no scaffold). Fixes DuplicateSeqNumber during Automerge sync. - Remove unused last_state dedup cache field. - Reuse existing list ObjIds in set_queue/set_env_sync instead of creating new Automerge objects on every write. - Conditional state_changed_tx notifications — only send when setters actually mutated the doc (18 sites across both files). - Fix lock-then-send pattern in initial RuntimeStateDoc sync to avoid holding write lock across async I/O. - Fix sync reply doc comment (immediate, not debounced). * fix: allow RuntimeStateSync as outgoing frame type + restore runAllCells return type - send_frame_bytes in Tauri app was rejecting 0x05 frames — the WASM client needs to send sync replies so the Automerge handshake converges. - runAllCells accidentally changed to void return — callers in App.tsx check response.result. * feat(ui): show queue indicator in execution counter Differentiate executing vs queued cells in the gutter. Previously both were shown as executing (pulsing stop icon). Now: - Executing: [■] with red pulse (unchanged) - Queued: [⏳] with subtle pulse — proves RuntimeStateDoc queue sync Split executingCellIds/queuedCellIds in App.tsx. Thread isQueued prop through NotebookView → CodeCell → CompactExecutionButton. * feat(ui): replace hourglass emoji with CSS dot + slow breathe animation 6px rounded dot with a 3s ease-in-out opacity cycle (35%→70%). Reads as ambient 'standby LED' — alive but patient. Font-independent, doesn't compete with the red pulse on the executing cell. A column of queued cells looks like a calm queue, not a Christmas tree. * fix(ui): don't re-execute when clicking a queued cell's gutter button * fix(ui): show pointer cursor on execution button when clickable * feat(python): add RuntimeStateDoc support to Python bindings - SharedDocState now holds a RuntimeStateDoc + sync state - sync_task.rs applies RuntimeStateSync frames and sends replies (same encode-inside-lock, send-outside pattern as AutomergeSync) - DocHandle.get_runtime_state() reads from local replica (no round-trip) - PyRuntimeState/PyKernelState/PyEnvState exposed to Python - Session.get_runtime_state() available from Python - collect_outputs polls RuntimeStateDoc queue instead of waiting solely on ExecutionDone broadcasts (falls back to broadcast for KernelError) * fix: address Codex review — bootstrap race, collect_outputs race, teardown reset P1: do_initial_sync now buffers RuntimeStateSync frames during the handshake and replays them into SharedDocState. Fixes Python/full-peer clients starting with an empty RuntimeStateDoc on quiet notebooks. P1: collect_outputs requires the cell to be SEEN in the queue before treating its absence as completion. Prevents returning empty outputs when the RuntimeStateDoc replica hasn't converged yet. P2: resetRuntimeState() called on both daemon:ready re-bootstrap and effect teardown, preventing stale state from a previous notebook.
1 parent 877b5af commit 2693111

File tree

33 files changed

+2088
-465
lines changed

33 files changed

+2088
-465
lines changed

apps/notebook/src/App.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,12 +303,12 @@ function AppContent() {
303303
};
304304
}, [sendCommMessage]);
305305

306-
// Get executing cell IDs from daemon queue state (as Set for NotebookView)
306+
// Split queue state into executing (currently running) and queued (waiting).
307+
// Previously these were merged into one Set — now differentiated for UI.
307308
const executingCellIds = new Set(
308-
queueState.executing
309-
? [queueState.executing, ...queueState.queued]
310-
: queueState.queued,
309+
queueState.executing ? [queueState.executing] : [],
311310
);
311+
const queuedCellIds = new Set(queueState.queued);
312312

313313
// When kernel is running and we know the env source, use it to determine panel type.
314314
// This handles: both-deps (backend picks based on preference), pixi (auto-detected, no metadata).
@@ -1193,6 +1193,7 @@ function AppContent() {
11931193
isLoading={isLoading}
11941194
focusedCellId={focusedCellId}
11951195
executingCellIds={executingCellIds}
1196+
queuedCellIds={queuedCellIds}
11961197
pagePayloads={pagePayloads}
11971198
runtime={runtime}
11981199
searchQuery={globalFind.query}

apps/notebook/src/components/CodeCell.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface CodeCellProps {
9393
language?: SupportedLanguage;
9494
isFocused: boolean;
9595
isExecuting: boolean;
96+
isQueued: boolean;
9697
pagePayload: CellPagePayload | null;
9798
searchQuery?: string;
9899
searchActiveOffset?: number;
@@ -131,6 +132,7 @@ export const CodeCell = memo(function CodeCell({
131132
language = "python",
132133
isFocused,
133134
isExecuting,
135+
isQueued,
134136
pagePayload,
135137
searchQuery,
136138
searchActiveOffset = -1,
@@ -338,6 +340,7 @@ export const CodeCell = memo(function CodeCell({
338340
<CompactExecutionButton
339341
count={cell.execution_count}
340342
isExecuting={isExecuting}
343+
isQueued={isQueued}
341344
onExecute={handleExecute}
342345
onInterrupt={onInterrupt}
343346
/>

apps/notebook/src/components/NotebookView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface NotebookViewProps {
4545
isLoading?: boolean;
4646
focusedCellId: string | null;
4747
executingCellIds: Set<string>;
48+
queuedCellIds: Set<string>;
4849
pagePayloads: Map<string, CellPagePayload>;
4950
runtime?: Runtime;
5051
searchQuery?: string;
@@ -318,6 +319,7 @@ function NotebookViewContent({
318319
isLoading = false,
319320
focusedCellId,
320321
executingCellIds,
322+
queuedCellIds,
321323
pagePayloads,
322324
runtime = "python",
323325
searchQuery,
@@ -487,6 +489,7 @@ function NotebookViewContent({
487489
) => {
488490
const isFocused = cell.id === focusedCellId;
489491
const isExecuting = executingCellIds.has(cell.id);
492+
const isQueued = queuedCellIds.has(cell.id);
490493

491494
// Navigation callbacks — skip cells that are collapsed into a hidden group
492495
const isVisibleCell = (id: string) => {
@@ -554,6 +557,7 @@ function NotebookViewContent({
554557
isFocused={isFocused}
555558
isPreviousCellFromFocused={cell.id === previousCellId}
556559
isExecuting={isExecuting}
560+
isQueued={isQueued}
557561
pagePayload={pagePayload}
558562
searchQuery={searchQuery}
559563
searchActiveOffset={activeSourceOffset}
@@ -652,6 +656,7 @@ function NotebookViewContent({
652656
focusedCellId,
653657
previousCellId,
654658
executingCellIds,
659+
queuedCellIds,
655660
pagePayloads,
656661
runtime,
657662
searchQuery,

apps/notebook/src/hooks/useAutomergeNotebook.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "../lib/notebook-file-ops";
2626
import { subscribeBroadcast } from "../lib/notebook-frame-bus";
2727
import { setNotebookHandle } from "../lib/notebook-metadata";
28+
import { resetRuntimeState } from "../lib/runtime-state";
2829
import { fromTauriEvent } from "../lib/tauri-rx";
2930
import type { DaemonBroadcast, JupyterOutput } from "../types";
3031
import init, { NotebookHandle } from "../wasm/runtimed-wasm/runtimed_wasm.js";
@@ -176,6 +177,7 @@ export function useAutomergeNotebook() {
176177
switchMap(() => {
177178
refreshBlobPort();
178179
resetNotebookCells();
180+
resetRuntimeState();
179181
awaitingInitialSyncRef.current = true;
180182
setIsLoading(true);
181183
return from(
@@ -268,6 +270,7 @@ export function useAutomergeNotebook() {
268270
}
269271

270272
resetNotebookCells();
273+
resetRuntimeState();
271274
setNotebookHandle(null);
272275
handleRef.current?.free();
273276
handleRef.current = null;

0 commit comments

Comments
 (0)