@marimo-team/frontend 0.25.3-dev79 → 0.25.3-dev81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CellStatus-cD3S0wEA.js → CellStatus-DXroYLOP.js} +1 -1
- package/dist/assets/{ErrorBoundary-DJhsFCsX.js → ErrorBoundary-DaQ9THH_.js} +2 -2
- package/dist/assets/{JsonOutput-Pm6OCP3J.js → JsonOutput-VxzHyItt.js} +1 -1
- package/dist/assets/{MarimoErrorOutput-DlEjLCZ-.js → MarimoErrorOutput-D-hLaDA-.js} +1 -1
- package/dist/assets/{RenderHTML-CfQ6i3QV.js → RenderHTML-DjOEZL25.js} +1 -1
- package/dist/assets/{RunButton-DvuvkgrG.js → RunButton-CuhNTo5_.js} +1 -1
- package/dist/assets/{add-cell-with-ai-By51zSlC.js → add-cell-with-ai-BbVntTh7.js} +1 -1
- package/dist/assets/{add-connection-dialog-D7EYSraq.js → add-connection-dialog-Qf63aKV6.js} +1 -1
- package/dist/assets/{agent-panel-aCWa3Yai.js → agent-panel-DMOfF3qF.js} +1 -1
- package/dist/assets/{ai-model-dropdown-bwTRyFn4.js → ai-model-dropdown-B15l0R62.js} +2 -2
- package/dist/assets/{app-config-button-DGhsM0xw.js → app-config-button-BIMB8qjD.js} +1 -1
- package/dist/assets/{blob-e6VlOmQP.js → blob-De9GR2s6.js} +1 -1
- package/dist/assets/{cell-editor-M8dEnDzk.js → cell-editor-DFNLDjfX.js} +3 -3
- package/dist/assets/{cell-link-DFqfW-Ym.js → cell-link-DR3W2HC7.js} +1 -1
- package/dist/assets/{cells-T9_Y4Jlv.js → cells-styV7IX8.js} +6 -6
- package/dist/assets/{chat-display-DXn2Yrjr.js → chat-display-BzdYdN39.js} +1 -1
- package/dist/assets/{chat-panel-D3iUyZId.js → chat-panel-De3YzkSF.js} +1 -1
- package/dist/assets/{chat-ui-YuJtv3UJ.js → chat-ui-NTNapdtP.js} +1 -1
- package/dist/assets/{code-visibility-CV3S4xuf.js → code-visibility-csc3tgA0.js} +1 -1
- package/dist/assets/{column-preview-C6uXRJry.js → column-preview-DkH-oD8f.js} +1 -1
- package/dist/assets/{command-palette-B5H-9JMB.js → command-palette-OOQHBTnr.js} +1 -1
- package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
- package/dist/assets/components-CJRBUI2s.js +1 -0
- package/dist/assets/{components-DyAF3_Nf.js → components-CNmvC781.js} +1 -1
- package/dist/assets/config-BQT0uYUo.js +1 -0
- package/dist/assets/{config-CjQ4WJnJ.js → config-BbRbT9Ww.js} +1 -1
- package/dist/assets/{datasource-gqFgG9Qd.js → datasource-C3yfeDa2.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BFFxLgR6.js → dependency-graph-panel-CjoA6L0w.js} +1 -1
- package/dist/assets/{documentation-panel-BBpGbT-f.js → documentation-panel-DHWFOiG1.js} +1 -1
- package/dist/assets/{download-C8Gw-D0Y.js → download-BrCpRZKN.js} +1 -1
- package/dist/assets/{edit-page-D5LKeAwW.js → edit-page-DafDcIAI.js} +6 -6
- package/dist/assets/{error-entries-uyqOG5tg.js → error-entries-BkEAmmnD.js} +1 -1
- package/dist/assets/{error-panel-Cli5zPWi.js → error-panel-BhTfPA_i.js} +1 -1
- package/dist/assets/feedback-button-BAcClv7c.js +1 -0
- package/dist/assets/{feedback-button-CvrwBGr7.js → feedback-button-D9InK87G.js} +1 -1
- package/dist/assets/{file-explorer-panel-BSU5ultY.js → file-explorer-panel-BSd_ZbUC.js} +1 -1
- package/dist/assets/{file-name-input-CDrRlIiX.js → file-name-input-RHURglL_.js} +1 -1
- package/dist/assets/{floating-outline-GQD9km0W.js → floating-outline-Cm49rYYe.js} +1 -1
- package/dist/assets/{gallery-page-CxmV1JLF.js → gallery-page-DdMdOtdv.js} +1 -1
- package/dist/assets/{glide-data-editor-B1kpoetH.js → glide-data-editor-eiSlEUyR.js} +1 -1
- package/dist/assets/home-page-DwWvBB0R.js +1 -0
- package/dist/assets/{hooks-BrFYiTJS.js → hooks-C4GjmtQ-.js} +1 -1
- package/dist/assets/index-BdsOMJac.css +2 -0
- package/dist/assets/{index-BU1n9Awq.js → index-jR_KRDMC.js} +8 -8
- package/dist/assets/{layout-CGC8ajpe.js → layout-Bq7wRzdR.js} +3 -3
- package/dist/assets/{logs-panel-CdUc5vtA.js → logs-panel-CAAk72Gb.js} +1 -1
- package/dist/assets/{markdown-renderer-pKc_bFac.js → markdown-renderer-CAyCgogC.js} +1 -1
- package/dist/assets/{name-cell-input-csH3Cy54.js → name-cell-input-DA8hU9XT.js} +1 -1
- package/dist/assets/{outline-panel-DhsHYTuo.js → outline-panel-Cc5oQ4LL.js} +1 -1
- package/dist/assets/{packages-panel-BtkXVz3L.js → packages-panel-DTfKez6I.js} +1 -1
- package/dist/assets/{panel-accordion-state-Bco6nsUI.js → panel-accordion-state-BZHex38W.js} +1 -1
- package/dist/assets/panels-Cnu2Flfw.js +105 -0
- package/dist/assets/{process-output-B2-bT4cq.js → process-output-CrDc_O1D.js} +1 -1
- package/dist/assets/{readonly-code-display-Dsp6vWPh.js → readonly-code-display-BR-_jUj8.js} +1 -1
- package/dist/assets/{readonly-python-code-B81AjzXM.js → readonly-python-code-D7jcy9ds.js} +1 -1
- package/dist/assets/{reveal-component-Cxo3hLGA.js → reveal-component-BWu6kkxZ.js} +1 -1
- package/dist/assets/{run-page-CLP1s8uX.js → run-page-BJq9MULx.js} +1 -1
- package/dist/assets/{runtime-details-state-CznIe40L.js → runtime-details-state-hb7A1jgR.js} +1 -1
- package/dist/assets/{scratchpad-panel-yHIW63VQ.js → scratchpad-panel-OHQ7Z0RY.js} +1 -1
- package/dist/assets/{session-panel-C7-wmZhc.js → session-panel-DmDjaPsN.js} +1 -1
- package/dist/assets/{snippets-panel-BARpOPyS.js → snippets-panel-o9KL5jMi.js} +1 -1
- package/dist/assets/state-BkAqJoW7.js +3 -0
- package/dist/assets/{state-Bye6rCo4.js → state-Os7LSUUZ.js} +1 -1
- package/dist/assets/{terminal-Bhcn_y1N.js → terminal-BI85khrS.js} +1 -1
- package/dist/assets/tracing-AsNGuDsh.js +1 -0
- package/dist/assets/{tracing-panel-UKdh_YXC.js → tracing-panel-CBcmea6L.js} +2 -2
- package/dist/assets/{tree-actions-Cb-nUK1F.js → tree-actions-Cx5EDpA6.js} +1 -1
- package/dist/assets/{useAddCell-Bielffe0.js → useAddCell-uJJYtgwa.js} +1 -1
- package/dist/assets/{useBoolean-CMgj9hnq.js → useBoolean-8wlrTeus.js} +1 -1
- package/dist/assets/useCellActionButton-CmQ-OcPa.js +1 -0
- package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-BEGqRiWi.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
- package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
- package/dist/assets/useNotebookActions-CqXa3U6u.js +1 -0
- package/dist/assets/{useRunCells-BFeZMi7f.js → useRunCells-DAfDmOMC.js} +1 -1
- package/dist/assets/{useSplitCell-DllkNFke.js → useSplitCell-NLULgcq7.js} +1 -1
- package/dist/assets/{vega-component-D7JFthDD.js → vega-component-BKJU3uaB.js} +1 -1
- package/dist/index.html +25 -25
- package/package.json +1 -1
- package/src/components/home/components.tsx +17 -31
- package/src/components/pages/home-page.tsx +141 -89
- package/src/core/host/__tests__/notebook-client.test.ts +368 -350
- package/src/core/host/notebook-client.ts +129 -186
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
- package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
- package/src/core/websocket/transports/notebook.ts +213 -0
- package/src/core/websocket/useNotebookClient.ts +28 -17
- package/src/css/globals.css +3 -0
- package/dist/assets/circle-play-CYIb0qT6.js +0 -1
- package/dist/assets/components-lDm0qi2t.js +0 -1
- package/dist/assets/config-DzdxZb17.js +0 -1
- package/dist/assets/feedback-button-DknbRCN_.js +0 -1
- package/dist/assets/home-page-CZ3hkD3m.js +0 -4
- package/dist/assets/index-evFWHKmM.css +0 -2
- package/dist/assets/link-NuFVR7G9.js +0 -1
- package/dist/assets/panels-D1i80TGn.js +0 -107
- package/dist/assets/state-BRYi026f.js +0 -3
- package/dist/assets/tracing-BW2ZVnXs.js +0 -1
- package/dist/assets/useCellActionButton-Cu34Zs6x.js +0 -1
- package/dist/assets/useNotebookActions-CmqOx-WZ.js +0 -1
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import {
|
|
5
|
+
createNotebookReducer,
|
|
6
|
+
type NotebookEvent,
|
|
7
|
+
NotebookEvents,
|
|
8
|
+
type NotebookTransport,
|
|
9
|
+
StreamStopped,
|
|
10
|
+
} from "@marimo-team/client";
|
|
4
11
|
import type { Attachment, NotebookOutput } from "@marimo-team/client/protocol";
|
|
5
|
-
import { createNotebookReducer } from "@marimo-team/client";
|
|
6
12
|
import { type Atom, atom, type createStore } from "jotai/vanilla";
|
|
13
|
+
import { z } from "zod";
|
|
7
14
|
import {
|
|
8
|
-
type ConnectionStatus,
|
|
9
|
-
ConnectionState,
|
|
10
15
|
ConnectionClosedReason,
|
|
11
|
-
|
|
12
|
-
type
|
|
13
|
-
TransportReadyState,
|
|
16
|
+
ConnectionState,
|
|
17
|
+
type ConnectionStatus,
|
|
14
18
|
} from "./connection";
|
|
15
|
-
import { type NotebookEvent, parseNotebookEvent } from "./events";
|
|
16
19
|
import { attachments } from "./resources/attachments";
|
|
17
20
|
import { type Document, document } from "./resources/document";
|
|
18
21
|
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** The kinds the editor reads, with marimo's own notifications. */
|
|
23
|
+
const TYPES = ["notebook", "cell", "document", "attachment", "marimo"] as const;
|
|
24
|
+
|
|
25
|
+
type EditorEvent = NotebookEvent<(typeof TYPES)[number]>;
|
|
26
|
+
|
|
27
|
+
/** A native marimo notification, passed through without interpretation. */
|
|
28
|
+
const MarimoMessage = z.object({ marimo: z.unknown() });
|
|
25
29
|
|
|
26
30
|
/** A connection to a notebook on a marimo host. */
|
|
27
31
|
export interface NotebookClient {
|
|
@@ -43,28 +47,53 @@ export interface NotebookClient {
|
|
|
43
47
|
reconnect(): Promise<void>;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
/** The transport and application callbacks needed to
|
|
50
|
+
/** The transport and application callbacks needed to follow a notebook. */
|
|
47
51
|
export interface NotebookConnection {
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
url: () => string;
|
|
52
|
-
/** Abort to close the transport and clear the client's state. */
|
|
52
|
+
/** Opens one connection to the notebook's stream. */
|
|
53
|
+
transport: NotebookTransport;
|
|
54
|
+
/** Abort to close the stream and clear the client's state. */
|
|
53
55
|
signal: AbortSignal;
|
|
54
|
-
/** Wait until the host can accept the initial connection. */
|
|
55
|
-
waitUntilReady: () => Promise<void>;
|
|
56
56
|
/** Check whether the host is reachable before an explicit reconnect. */
|
|
57
57
|
checkHealth: () => Promise<boolean>;
|
|
58
|
-
/** Decode wire text without interpreting its event type. */
|
|
59
|
-
decodeJson: (message: string) => unknown;
|
|
60
58
|
/** Handle a native marimo notification. Its payload has not been validated. */
|
|
61
59
|
onNotification: (notification: unknown) => void;
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const disconnected = {
|
|
63
|
+
state: ConnectionState.CLOSED,
|
|
64
|
+
code: ConnectionClosedReason.KERNEL_DISCONNECTED,
|
|
65
|
+
reason: "kernel not found",
|
|
66
|
+
} satisfies ConnectionStatus;
|
|
67
|
+
|
|
68
|
+
const malformed = {
|
|
69
|
+
...disconnected,
|
|
70
|
+
reason: "Failed to decode notebook message",
|
|
71
|
+
} satisfies ConnectionStatus;
|
|
72
|
+
|
|
73
|
+
function isStatus(value: unknown): value is ConnectionStatus {
|
|
74
|
+
return typeof value === "object" && value !== null && "state" in value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The status to show after the stream stopped.
|
|
79
|
+
*
|
|
80
|
+
* A transport says which status its stop means. An event the protocol
|
|
81
|
+
* rejects is malformed. Anything else means the host was not reached.
|
|
82
|
+
*/
|
|
83
|
+
function statusAfter(error: unknown): ConnectionStatus {
|
|
84
|
+
if (error instanceof StreamStopped && isStatus(error.cause)) {
|
|
85
|
+
return error.cause;
|
|
86
|
+
}
|
|
87
|
+
if (error instanceof StreamStopped && error.cause instanceof z.ZodError) {
|
|
88
|
+
return malformed;
|
|
89
|
+
}
|
|
90
|
+
return disconnected;
|
|
64
91
|
}
|
|
65
92
|
|
|
66
93
|
/** Create a client without opening a connection. */
|
|
67
94
|
export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
95
|
+
// `useNotebookSession` copies this into the editor's shared `connectionAtom`,
|
|
96
|
+
// which gates features until MO-8051 derives availability separately.
|
|
68
97
|
const connectionAtom = atom<ConnectionStatus>({
|
|
69
98
|
state: ConnectionState.NOT_STARTED,
|
|
70
99
|
});
|
|
@@ -73,16 +102,18 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
73
102
|
const streamAtom = atom(reducer.initial);
|
|
74
103
|
// A resumed stream is live, but presence waits for this connection's ready.
|
|
75
104
|
const readyAtom = atom(false);
|
|
105
|
+
const presentAtom = atom(
|
|
106
|
+
(get) =>
|
|
107
|
+
get(readyAtom) && get(connectionAtom).state === ConnectionState.OPEN,
|
|
108
|
+
);
|
|
76
109
|
const notebookAtom = atom((get) =>
|
|
77
|
-
get(
|
|
78
|
-
? get(streamAtom).notebook
|
|
79
|
-
: null,
|
|
110
|
+
get(presentAtom) ? get(streamAtom).notebook : null,
|
|
80
111
|
);
|
|
81
112
|
const documentAtom = atom(
|
|
82
113
|
(get) => get(streamAtom).resources.document.current,
|
|
83
114
|
);
|
|
84
115
|
const attachmentsAtom = atom((get) =>
|
|
85
|
-
get(
|
|
116
|
+
get(presentAtom)
|
|
86
117
|
? get(streamAtom).resources.attachments
|
|
87
118
|
: attachments.initial,
|
|
88
119
|
);
|
|
@@ -95,156 +126,97 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
95
126
|
reconnect: async () => reconnect?.(),
|
|
96
127
|
};
|
|
97
128
|
|
|
129
|
+
function setStatus(status: ConnectionStatus) {
|
|
130
|
+
store.set(readyAtom, false);
|
|
131
|
+
store.set(connectionAtom, status);
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
function connect({
|
|
99
|
-
|
|
100
|
-
url,
|
|
135
|
+
transport,
|
|
101
136
|
signal,
|
|
102
|
-
waitUntilReady,
|
|
103
137
|
checkHealth,
|
|
104
138
|
onNotification,
|
|
105
|
-
decodeJson,
|
|
106
|
-
classifyClose,
|
|
107
139
|
}: NotebookConnection) {
|
|
108
140
|
if (signal.aborted) {
|
|
109
141
|
return;
|
|
110
142
|
}
|
|
111
|
-
let
|
|
112
|
-
let terminal = false;
|
|
143
|
+
let events: NotebookEvents<(typeof TYPES)[number]> | undefined;
|
|
113
144
|
let checkingHealth = false;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
let cursor: string | null = null;
|
|
117
|
-
const transport = createTransport(() => {
|
|
118
|
-
const next = new URL(url());
|
|
119
|
-
next.searchParams.delete("cursor");
|
|
120
|
-
if (signal.aborted) {
|
|
121
|
-
return next.toString();
|
|
122
|
-
}
|
|
123
|
-
// A cursor is useful with the state that produced it, which a change
|
|
124
|
-
// of session or notebook invalidates. Authentication may change freely.
|
|
125
|
-
const identity = new URL(next);
|
|
126
|
-
identity.searchParams.delete("access_token");
|
|
127
|
-
identity.searchParams.sort();
|
|
128
|
-
const key = identity.toString();
|
|
129
|
-
const current = store.get(streamAtom);
|
|
130
|
-
// A live stream keeps its state and resumes from the cursor. An
|
|
131
|
-
// incomplete snapshot starts over.
|
|
132
|
-
const state =
|
|
133
|
-
key !== streamKey || current.phase === "snapshot"
|
|
134
|
-
? reducer.initial
|
|
135
|
-
: current;
|
|
136
|
-
if (state.phase === "snapshot") {
|
|
137
|
-
cursor = null;
|
|
138
|
-
}
|
|
139
|
-
streamKey = key;
|
|
140
|
-
store.set(readyAtom, false);
|
|
141
|
-
store.set(streamAtom, state);
|
|
142
|
-
if (cursor !== null) {
|
|
143
|
-
next.searchParams.set("cursor", cursor);
|
|
144
|
-
}
|
|
145
|
-
return next.toString();
|
|
146
|
-
});
|
|
147
|
-
const setStatus = (status: ConnectionStatus) => {
|
|
148
|
-
store.set(readyAtom, false);
|
|
149
|
-
store.set(connectionAtom, status);
|
|
150
|
-
};
|
|
151
|
-
const disconnected = {
|
|
152
|
-
state: ConnectionState.CLOSED,
|
|
153
|
-
code: ConnectionClosedReason.KERNEL_DISCONNECTED,
|
|
154
|
-
reason: "kernel not found",
|
|
155
|
-
} satisfies ConnectionStatus;
|
|
145
|
+
// Whether the replica still holds the state the last cursor describes.
|
|
146
|
+
let resumable = true;
|
|
156
147
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function onOpen() {
|
|
165
|
-
if (signal.aborted || terminal) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
shouldRetry = true;
|
|
169
|
-
setStatus({ state: ConnectionState.OPEN });
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function onClose(event: CloseEvent) {
|
|
173
|
-
if (signal.aborted || terminal) {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
Logger.warn("Notebook connection closed", event.code, event.reason);
|
|
177
|
-
const decision = classifyClose(event);
|
|
178
|
-
setStatus(decision.status);
|
|
179
|
-
if (decision.kind === "terminal") {
|
|
180
|
-
terminal = true;
|
|
181
|
-
transport.close();
|
|
182
|
-
} else if (decision.kind === "retry") {
|
|
183
|
-
retry(event.code, event.reason);
|
|
184
|
-
} else {
|
|
185
|
-
terminal = true;
|
|
148
|
+
/** Mirror one stream into the atoms until it closes. */
|
|
149
|
+
function follow(cursor?: string) {
|
|
150
|
+
if (cursor === undefined) {
|
|
151
|
+
store.set(streamAtom, reducer.initial);
|
|
186
152
|
}
|
|
153
|
+
resumable = true;
|
|
154
|
+
const current = new NotebookEvents(transport, {
|
|
155
|
+
types: TYPES,
|
|
156
|
+
cursor,
|
|
157
|
+
signal,
|
|
158
|
+
// TODO(MO-8051): Gate editing and running on the notebook's operations
|
|
159
|
+
// and request results instead. The stream's status would then only
|
|
160
|
+
// mark the view as stale.
|
|
161
|
+
onStatus: (status) => {
|
|
162
|
+
if (status === "connecting") {
|
|
163
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
164
|
+
} else if (status === "connected") {
|
|
165
|
+
setStatus({ state: ConnectionState.OPEN });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
current.subscribe(
|
|
170
|
+
(event) => apply(current, event),
|
|
171
|
+
(error) => {
|
|
172
|
+
if (!signal.aborted && current === events) {
|
|
173
|
+
Logger.warn("Notebook stream stopped", error);
|
|
174
|
+
const status = statusAfter(error);
|
|
175
|
+
if (status === malformed) {
|
|
176
|
+
stopMalformed(current);
|
|
177
|
+
} else {
|
|
178
|
+
setStatus(status);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
events = current;
|
|
187
184
|
}
|
|
188
185
|
|
|
189
|
-
function
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
setStatus(
|
|
195
|
-
retry();
|
|
186
|
+
function stopMalformed(source: NotebookEvents<string>) {
|
|
187
|
+
source.close();
|
|
188
|
+
// The cursor can already include an event the replica rejected.
|
|
189
|
+
resumable = false;
|
|
190
|
+
store.set(streamAtom, reducer.initial);
|
|
191
|
+
setStatus(malformed);
|
|
196
192
|
}
|
|
197
193
|
|
|
198
|
-
function
|
|
199
|
-
if (
|
|
194
|
+
function apply(source: NotebookEvents<string>, event: EditorEvent) {
|
|
195
|
+
if (event.event === "marimo") {
|
|
196
|
+
const message = MarimoMessage.safeParse(event.data);
|
|
197
|
+
if (message.success) {
|
|
198
|
+
onNotification(message.data.marimo);
|
|
199
|
+
} else {
|
|
200
|
+
Logger.error("Failed to decode notebook message", event);
|
|
201
|
+
stopMalformed(source);
|
|
202
|
+
}
|
|
200
203
|
return;
|
|
201
204
|
}
|
|
202
|
-
let event;
|
|
203
205
|
try {
|
|
204
|
-
|
|
205
|
-
const state = store.get(streamAtom);
|
|
206
|
-
if (
|
|
207
|
-
state.phase === "live" &&
|
|
208
|
-
event.id === undefined &&
|
|
209
|
-
!UNCURSORED.has(event.event)
|
|
210
|
-
) {
|
|
211
|
-
throw new Error("Live notebook event is missing its cursor");
|
|
212
|
-
}
|
|
213
|
-
if (event.event !== "marimo" && event.event !== "unknown") {
|
|
214
|
-
store.set(streamAtom, reducer.reduce(state, event));
|
|
215
|
-
}
|
|
216
|
-
if (event.event === "reset") {
|
|
217
|
-
cursor = null;
|
|
218
|
-
} else if (event.event === "ready" || state.phase === "live") {
|
|
219
|
-
cursor = event.id ?? cursor;
|
|
220
|
-
}
|
|
221
|
-
if (event.event === "ready" || event.event === "reset") {
|
|
222
|
-
store.set(readyAtom, event.event === "ready");
|
|
223
|
-
}
|
|
206
|
+
store.set(streamAtom, reducer.reduce(store.get(streamAtom), event));
|
|
224
207
|
} catch (error) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
terminal = true;
|
|
229
|
-
setStatus({
|
|
230
|
-
...disconnected,
|
|
231
|
-
reason: "Failed to decode notebook message",
|
|
232
|
-
});
|
|
233
|
-
transport.close();
|
|
208
|
+
// An event the replica cannot apply, such as another notebook's.
|
|
209
|
+
Logger.error("Failed to apply notebook event", error);
|
|
210
|
+
stopMalformed(source);
|
|
234
211
|
return;
|
|
235
212
|
}
|
|
236
|
-
if (event.event === "
|
|
237
|
-
|
|
213
|
+
if (event.event === "ready" || event.event === "reset") {
|
|
214
|
+
store.set(readyAtom, event.event === "ready");
|
|
238
215
|
}
|
|
239
216
|
}
|
|
240
217
|
|
|
241
218
|
reconnect = async () => {
|
|
242
|
-
if (
|
|
243
|
-
signal.aborted ||
|
|
244
|
-
checkingHealth ||
|
|
245
|
-
transport.readyState === TransportReadyState.OPEN ||
|
|
246
|
-
transport.readyState === TransportReadyState.CONNECTING
|
|
247
|
-
) {
|
|
219
|
+
if (signal.aborted || checkingHealth || events?.status !== "closed") {
|
|
248
220
|
return;
|
|
249
221
|
}
|
|
250
222
|
checkingHealth = true;
|
|
@@ -255,17 +227,13 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
255
227
|
return;
|
|
256
228
|
}
|
|
257
229
|
if (!healthy) {
|
|
258
|
-
shouldRetry = false;
|
|
259
230
|
setStatus(disconnected);
|
|
260
231
|
return;
|
|
261
232
|
}
|
|
262
|
-
|
|
263
|
-
shouldRetry = true;
|
|
264
|
-
transport.reconnect();
|
|
233
|
+
follow(resumable ? (events.cursor ?? undefined) : undefined);
|
|
265
234
|
} catch (error) {
|
|
266
235
|
if (!signal.aborted) {
|
|
267
236
|
Logger.error("Notebook health check failed", error);
|
|
268
|
-
shouldRetry = false;
|
|
269
237
|
setStatus(disconnected);
|
|
270
238
|
}
|
|
271
239
|
} finally {
|
|
@@ -276,11 +244,6 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
276
244
|
signal.addEventListener(
|
|
277
245
|
"abort",
|
|
278
246
|
() => {
|
|
279
|
-
transport.removeEventListener("open", onOpen);
|
|
280
|
-
transport.removeEventListener("close", onClose);
|
|
281
|
-
transport.removeEventListener("error", onError);
|
|
282
|
-
transport.removeEventListener("message", onMessage);
|
|
283
|
-
transport.close();
|
|
284
247
|
reconnect = undefined;
|
|
285
248
|
store.set(streamAtom, reducer.initial);
|
|
286
249
|
setStatus({ state: ConnectionState.NOT_STARTED });
|
|
@@ -288,28 +251,8 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
288
251
|
{ once: true },
|
|
289
252
|
);
|
|
290
253
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
transport.addEventListener("error", onError);
|
|
294
|
-
transport.addEventListener("message", onMessage);
|
|
295
|
-
|
|
296
|
-
if (transport.readyState === TransportReadyState.CLOSED) {
|
|
297
|
-
setStatus({ state: ConnectionState.CONNECTING });
|
|
298
|
-
void waitUntilReady()
|
|
299
|
-
.then(() => {
|
|
300
|
-
if (!signal.aborted) {
|
|
301
|
-
transport.reconnect();
|
|
302
|
-
}
|
|
303
|
-
})
|
|
304
|
-
.catch((error) => {
|
|
305
|
-
if (!signal.aborted) {
|
|
306
|
-
Logger.error("Healthy connection never made", error);
|
|
307
|
-
terminal = true;
|
|
308
|
-
setStatus(disconnected);
|
|
309
|
-
transport.close();
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
}
|
|
254
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
255
|
+
follow();
|
|
313
256
|
}
|
|
314
257
|
|
|
315
258
|
return { client, connect };
|
|
@@ -5,52 +5,48 @@ import { act, renderHook } from "@testing-library/react";
|
|
|
5
5
|
import { createStore, Provider } from "jotai";
|
|
6
6
|
import type { PropsWithChildren } from "react";
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { NotebookTransport } from "@marimo-team/client";
|
|
8
9
|
import { ConnectionState } from "../types";
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
10
|
+
import { Deferred } from "@/utils/Deferred";
|
|
11
|
+
import { createNotebookTransport } from "../transports/notebook";
|
|
11
12
|
import { useNotebookClient } from "../useNotebookClient";
|
|
12
13
|
import { fixtureMessages } from "../../host/__tests__/fixtures";
|
|
13
14
|
import { agentAttachmentsAtom } from "../../attachments/state";
|
|
14
15
|
import { notebookClientAtom } from "../state";
|
|
15
16
|
|
|
16
|
-
vi.mock("../transports/
|
|
17
|
+
vi.mock("../transports/notebook", () => ({ createNotebookTransport: vi.fn() }));
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}),
|
|
31
|
-
send: vi.fn(),
|
|
32
|
-
addEventListener: events.addEventListener.bind(events),
|
|
33
|
-
removeEventListener: events.removeEventListener.bind(events),
|
|
34
|
-
};
|
|
19
|
+
/** A transport whose connections the test drives. */
|
|
20
|
+
function makeTransport() {
|
|
21
|
+
const opened: {
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
open(): void;
|
|
24
|
+
receive(message: unknown): void;
|
|
25
|
+
}[] = [];
|
|
26
|
+
const transport: NotebookTransport = ({ signal }, handlers) =>
|
|
27
|
+
new Promise((resolve) => {
|
|
28
|
+
opened.push({ signal, open: handlers.open, receive: handlers.message });
|
|
29
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
30
|
+
});
|
|
35
31
|
return {
|
|
36
32
|
transport,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
new MessageEvent("message", { data: JSON.stringify(message) }),
|
|
40
|
-
),
|
|
33
|
+
opened,
|
|
34
|
+
/** Open the latest connection and deliver a host-framed notification. */
|
|
41
35
|
notify: () =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
data: { marimo: { op: "reconnected" } },
|
|
47
|
-
}),
|
|
48
|
-
}),
|
|
49
|
-
),
|
|
36
|
+
opened.at(-1)?.receive({
|
|
37
|
+
event: "marimo",
|
|
38
|
+
data: { marimo: { op: "reconnected" } },
|
|
39
|
+
}),
|
|
50
40
|
};
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
function setup(
|
|
43
|
+
function setup(
|
|
44
|
+
strict = false,
|
|
45
|
+
readiness: {
|
|
46
|
+
waitUntilReady?: () => Promise<void>;
|
|
47
|
+
checkHealth?: () => Promise<boolean>;
|
|
48
|
+
} = {},
|
|
49
|
+
) {
|
|
54
50
|
const store = createStore();
|
|
55
51
|
const options = {
|
|
56
52
|
static: false,
|
|
@@ -60,6 +56,7 @@ function setup(strict = false) {
|
|
|
60
56
|
waitUntilReady: async () => undefined,
|
|
61
57
|
checkHealth: async () => true,
|
|
62
58
|
onNotification: vi.fn(),
|
|
59
|
+
...readiness,
|
|
63
60
|
};
|
|
64
61
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
65
62
|
<Provider store={store}>{children}</Provider>
|
|
@@ -73,19 +70,97 @@ function setup(strict = false) {
|
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
beforeEach(() => {
|
|
76
|
-
vi.mocked(
|
|
73
|
+
vi.mocked(createNotebookTransport).mockReset();
|
|
77
74
|
});
|
|
78
75
|
|
|
79
76
|
describe("useNotebookClient", () => {
|
|
77
|
+
it.each([true, false])(
|
|
78
|
+
"rechecks health after failed startup (healthy: %s)",
|
|
79
|
+
async (healthy) => {
|
|
80
|
+
const fake = makeTransport();
|
|
81
|
+
vi.mocked(createNotebookTransport).mockImplementation(
|
|
82
|
+
({ url }) =>
|
|
83
|
+
async (attempt, handlers) => {
|
|
84
|
+
if (typeof url === "function") {
|
|
85
|
+
await url();
|
|
86
|
+
}
|
|
87
|
+
if (!attempt.signal.aborted) {
|
|
88
|
+
return fake.transport(attempt, handlers);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const waitUntilReady = vi
|
|
93
|
+
.fn()
|
|
94
|
+
.mockRejectedValue(new Error("Startup gave up"));
|
|
95
|
+
const checkHealth = vi.fn().mockResolvedValue(healthy);
|
|
96
|
+
const { result, store, unmount } = setup(false, {
|
|
97
|
+
waitUntilReady,
|
|
98
|
+
checkHealth,
|
|
99
|
+
});
|
|
100
|
+
await act(async () => Promise.resolve());
|
|
101
|
+
expect(store.get(result.current.connectionAtom).state).toBe(
|
|
102
|
+
ConnectionState.CLOSED,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await act(async () => result.current.reconnect());
|
|
106
|
+
expect({
|
|
107
|
+
healthChecks: checkHealth.mock.calls.length,
|
|
108
|
+
initialWaits: waitUntilReady.mock.calls.length,
|
|
109
|
+
connections: fake.opened.length,
|
|
110
|
+
state: store.get(result.current.connectionAtom).state,
|
|
111
|
+
}).toEqual({
|
|
112
|
+
healthChecks: 1,
|
|
113
|
+
initialWaits: 1,
|
|
114
|
+
connections: healthy ? 1 : 0,
|
|
115
|
+
state: healthy ? ConnectionState.CONNECTING : ConnectionState.CLOSED,
|
|
116
|
+
});
|
|
117
|
+
act(() => fake.opened.at(-1)?.open());
|
|
118
|
+
expect(store.get(result.current.connectionAtom).state).toBe(
|
|
119
|
+
healthy ? ConnectionState.OPEN : ConnectionState.CLOSED,
|
|
120
|
+
);
|
|
121
|
+
unmount();
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
it("waits for initial readiness without reviving an unmounted connection", async () => {
|
|
126
|
+
const ready = new Deferred<void>();
|
|
127
|
+
const resolvedUrl = vi.fn();
|
|
128
|
+
vi.mocked(createNotebookTransport).mockImplementation(
|
|
129
|
+
({ url }) =>
|
|
130
|
+
async () => {
|
|
131
|
+
if (typeof url === "function") {
|
|
132
|
+
await url();
|
|
133
|
+
resolvedUrl();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
const { result, store, unmount } = setup(false, {
|
|
138
|
+
waitUntilReady: () => ready.promise,
|
|
139
|
+
});
|
|
140
|
+
await act(async () => Promise.resolve());
|
|
141
|
+
expect(resolvedUrl).not.toHaveBeenCalled();
|
|
142
|
+
expect(store.get(result.current.connectionAtom)).toEqual({
|
|
143
|
+
state: ConnectionState.CONNECTING,
|
|
144
|
+
});
|
|
145
|
+
unmount();
|
|
146
|
+
await act(async () => ready.resolve());
|
|
147
|
+
expect(store.get(result.current.connectionAtom)).toEqual({
|
|
148
|
+
state: ConnectionState.NOT_STARTED,
|
|
149
|
+
});
|
|
150
|
+
expect(store.get(notebookClientAtom)).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
80
153
|
it("exposes host presence to the UI without waiting for kernel-ready", async () => {
|
|
81
|
-
const
|
|
82
|
-
vi.mocked(
|
|
154
|
+
const fake = makeTransport();
|
|
155
|
+
vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
|
|
83
156
|
const { result, store, unmount } = setup();
|
|
84
157
|
await act(async () => Promise.resolve());
|
|
85
158
|
expect(store.get(notebookClientAtom)).toBe(result.current);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
159
|
+
const [connection] = fake.opened;
|
|
160
|
+
act(() => {
|
|
161
|
+
connection.open();
|
|
162
|
+
fixtureMessages("notebook-events.sse").forEach(connection.receive);
|
|
163
|
+
});
|
|
89
164
|
expect(store.get(agentAttachmentsAtom)).toEqual([
|
|
90
165
|
{
|
|
91
166
|
id: "a2",
|
|
@@ -101,49 +176,48 @@ describe("useNotebookClient", () => {
|
|
|
101
176
|
});
|
|
102
177
|
|
|
103
178
|
it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
|
|
104
|
-
const
|
|
105
|
-
vi.mocked(
|
|
179
|
+
const fake = makeTransport();
|
|
180
|
+
vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
|
|
106
181
|
const { rerender, options, unmount } = setup();
|
|
107
182
|
await act(async () => Promise.resolve());
|
|
108
183
|
const onNotification = vi.fn();
|
|
109
184
|
rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
|
|
110
|
-
act(() =>
|
|
185
|
+
act(() => fake.notify());
|
|
111
186
|
expect(options.onNotification).not.toHaveBeenCalled();
|
|
112
187
|
expect(onNotification).toHaveBeenCalledExactlyOnceWith({
|
|
113
188
|
data: { op: "reconnected" },
|
|
114
189
|
});
|
|
115
|
-
expect(
|
|
116
|
-
|
|
190
|
+
expect(createNotebookTransport).toHaveBeenCalledOnce();
|
|
191
|
+
const [{ url }] = vi.mocked(createNotebookTransport).mock.calls[0];
|
|
192
|
+
expect(String(await (url as () => Promise<string>)())).toBe(
|
|
117
193
|
"ws://redirected/ws",
|
|
118
194
|
);
|
|
119
195
|
unmount();
|
|
120
196
|
});
|
|
121
197
|
|
|
122
|
-
it("
|
|
123
|
-
const
|
|
124
|
-
vi.mocked(
|
|
198
|
+
it("closes every stream under Strict Mode and only follows the active lifetime", async () => {
|
|
199
|
+
const fakes: ReturnType<typeof makeTransport>[] = [];
|
|
200
|
+
vi.mocked(createNotebookTransport).mockImplementation(() => {
|
|
125
201
|
const next = makeTransport();
|
|
126
|
-
|
|
202
|
+
fakes.push(next);
|
|
127
203
|
return next.transport;
|
|
128
204
|
});
|
|
129
205
|
const { result, store, options, unmount } = setup(true);
|
|
130
206
|
await act(async () => Promise.resolve());
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
133
|
-
|
|
134
|
-
|
|
207
|
+
expect(fakes).toHaveLength(2);
|
|
208
|
+
expect(fakes[0].opened.every((attempt) => attempt.signal.aborted)).toBe(
|
|
209
|
+
true,
|
|
210
|
+
);
|
|
211
|
+
const [active] = fakes[1].opened;
|
|
212
|
+
act(() => active.open());
|
|
135
213
|
expect(store.get(result.current.connectionAtom)).toEqual({
|
|
136
214
|
state: ConnectionState.OPEN,
|
|
137
215
|
});
|
|
138
|
-
act(() =>
|
|
139
|
-
for (const transport of transports) {
|
|
140
|
-
transport.notify();
|
|
141
|
-
}
|
|
142
|
-
});
|
|
216
|
+
act(() => fakes.forEach((fake) => fake.notify()));
|
|
143
217
|
expect(options.onNotification).toHaveBeenCalledOnce();
|
|
144
218
|
unmount();
|
|
145
|
-
expect(
|
|
219
|
+
expect(active.signal.aborted).toBe(true);
|
|
146
220
|
await result.current.reconnect();
|
|
147
|
-
expect(
|
|
221
|
+
expect(fakes[1].opened).toHaveLength(1);
|
|
148
222
|
});
|
|
149
223
|
});
|