@oceanum/oceanumlab 4.6.0

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.
Files changed (83) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +116 -0
  3. package/lib/DatameshUI.d.ts +5 -0
  4. package/lib/DatameshUI.js +8 -0
  5. package/lib/DatameshUI.js.map +1 -0
  6. package/lib/DatameshWidget.d.ts +59 -0
  7. package/lib/DatameshWidget.js +573 -0
  8. package/lib/DatameshWidget.js.map +1 -0
  9. package/lib/DatasourceItem.d.ts +12 -0
  10. package/lib/DatasourceItem.js +47 -0
  11. package/lib/DatasourceItem.js.map +1 -0
  12. package/lib/aiLoop.d.ts +60 -0
  13. package/lib/aiLoop.js +85 -0
  14. package/lib/aiLoop.js.map +1 -0
  15. package/lib/chatRouter.d.ts +64 -0
  16. package/lib/chatRouter.js +225 -0
  17. package/lib/chatRouter.js.map +1 -0
  18. package/lib/constants.d.ts +14 -0
  19. package/lib/constants.js +15 -0
  20. package/lib/constants.js.map +1 -0
  21. package/lib/conversationPin.d.ts +68 -0
  22. package/lib/conversationPin.js +120 -0
  23. package/lib/conversationPin.js.map +1 -0
  24. package/lib/handler.d.ts +8 -0
  25. package/lib/handler.js +36 -0
  26. package/lib/handler.js.map +1 -0
  27. package/lib/index.d.ts +16 -0
  28. package/lib/index.js +273 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/kernelHandoff.d.ts +61 -0
  31. package/lib/kernelHandoff.js +236 -0
  32. package/lib/kernelHandoff.js.map +1 -0
  33. package/lib/notebookContext.d.ts +48 -0
  34. package/lib/notebookContext.js +118 -0
  35. package/lib/notebookContext.js.map +1 -0
  36. package/lib/notebookHost.d.ts +6 -0
  37. package/lib/notebookHost.js +72 -0
  38. package/lib/notebookHost.js.map +1 -0
  39. package/lib/notebookRun.d.ts +37 -0
  40. package/lib/notebookRun.js +70 -0
  41. package/lib/notebookRun.js.map +1 -0
  42. package/lib/progress.d.ts +43 -0
  43. package/lib/progress.js +103 -0
  44. package/lib/progress.js.map +1 -0
  45. package/lib/sse.d.ts +26 -0
  46. package/lib/sse.js +112 -0
  47. package/lib/sse.js.map +1 -0
  48. package/package.json +151 -0
  49. package/schema/datamesh-connect.json +35 -0
  50. package/src/DatameshUI.tsx +15 -0
  51. package/src/DatameshWidget.tsx +909 -0
  52. package/src/DatasourceItem.tsx +124 -0
  53. package/src/__tests__/aiLoop.spec.ts +304 -0
  54. package/src/__tests__/chatRouter.spec.ts +315 -0
  55. package/src/__tests__/conversationPin.spec.ts +236 -0
  56. package/src/__tests__/kernelHandoff.spec.ts +106 -0
  57. package/src/__tests__/notebookContext.spec.ts +97 -0
  58. package/src/__tests__/notebookHost.spec.ts +195 -0
  59. package/src/__tests__/notebookRun.spec.ts +95 -0
  60. package/src/__tests__/notebookSnapshot.spec.ts +62 -0
  61. package/src/__tests__/oceanumlab.spec.ts +9 -0
  62. package/src/__tests__/sse.spec.ts +304 -0
  63. package/src/aiLoop.ts +140 -0
  64. package/src/chatRouter.ts +329 -0
  65. package/src/constants.ts +16 -0
  66. package/src/conversationPin.ts +148 -0
  67. package/src/global.d.ts +5 -0
  68. package/src/handler.ts +46 -0
  69. package/src/index.ts +327 -0
  70. package/src/kernelHandoff.ts +284 -0
  71. package/src/notebookContext.ts +144 -0
  72. package/src/notebookHost.ts +97 -0
  73. package/src/notebookRun.ts +104 -0
  74. package/src/progress.ts +121 -0
  75. package/src/sse.ts +127 -0
  76. package/src/svg.d.ts +4 -0
  77. package/style/base.css +5 -0
  78. package/style/icons/oceanum-dark.svg +1 -0
  79. package/style/icons/oceanum-light.svg +1 -0
  80. package/style/icons/oceanum.png +0 -0
  81. package/style/icons/oceanum.svg +1 -0
  82. package/style/index.css +477 -0
  83. package/style/index.js +1 -0
package/src/index.ts ADDED
@@ -0,0 +1,327 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin,
4
+ ILayoutRestorer,
5
+ ILabStatus
6
+ } from '@jupyterlab/application';
7
+ import { ICommandPalette, Notification } from '@jupyterlab/apputils';
8
+ import { LabIcon } from '@jupyterlab/ui-components';
9
+ import { find } from '@lumino/algorithm';
10
+ import { Widget } from '@lumino/widgets';
11
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
12
+ import { IStateDB } from '@jupyterlab/statedb';
13
+ import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
14
+ import { DatameshConnectWidget } from './DatameshWidget';
15
+ import { DatameshUI } from './DatameshUI';
16
+ import { requestAPI } from './handler';
17
+ import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
18
+ import { reporterFor } from './progress';
19
+ import { ConversationPin } from './conversationPin';
20
+ import { snapshotFromIpynb, snapshotOf } from './notebookContext';
21
+ import { notebookHost } from './notebookHost';
22
+ import { KernelHandoff } from './kernelHandoff';
23
+ import { runChatLoop, STOPPED } from './aiLoop';
24
+ import { MAX_OBSERVE_ROUNDS } from './constants';
25
+
26
+ import '../style/index.css';
27
+
28
+ import oceanumSvg from '../style/icons/oceanum.svg';
29
+
30
+ // Theme-adaptive icon using currentColor (auto-adapts to light/dark themes)
31
+ export const oceanumIcon = new LabIcon({
32
+ name: 'oceanum:icon',
33
+ svgstr: oceanumSvg
34
+ });
35
+
36
+ const PLUGIN_ID = '@oceanum/oceanumlab:datamesh-connect';
37
+
38
+ /**
39
+ * Initialization data for the extension.
40
+ */
41
+ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
42
+ id: PLUGIN_ID,
43
+ autoStart: true,
44
+ requires: [
45
+ ICommandPalette,
46
+ ILayoutRestorer,
47
+ ILabStatus,
48
+ ISettingRegistry,
49
+ IStateDB
50
+ ],
51
+ activate: (
52
+ app: JupyterFrontEnd,
53
+ palette: ICommandPalette,
54
+ restorer: ILayoutRestorer,
55
+ status: ILabStatus,
56
+ settingRegistry: ISettingRegistry
57
+ ) => {
58
+ console.log('Oceanum datamesh connect extension is loaded');
59
+
60
+ //Try to get the datamesh token from the settings
61
+ const updateSettings = (set: ISettingRegistry.ISettings) => {
62
+ const token = set.get('datameshToken');
63
+ if (token && token.user) {
64
+ window.datameshToken = token.user as string;
65
+ requestAPI('env/', {
66
+ method: 'POST',
67
+ body: JSON.stringify({ DATAMESH_TOKEN: token.user })
68
+ }).then(res => console.log(res));
69
+ }
70
+ window.injectToken = set.get('injectToken').user as boolean;
71
+ };
72
+ //Try to get the datamesh token from the envars
73
+
74
+ Promise.all([app.restored, settingRegistry.load(PLUGIN_ID)])
75
+ .then(([, setting]) => {
76
+ // Read the settings
77
+ updateSettings(setting);
78
+
79
+ // Listen for your plugin setting changes using Signal
80
+ setting.changed.connect(updateSettings);
81
+ })
82
+ .catch(reason => {
83
+ console.error(
84
+ `Something went wrong when reading the Oceanumlab settings.\n${reason}`
85
+ );
86
+ });
87
+
88
+ const getCurrentWidget = (): Widget => {
89
+ return app.shell!.currentWidget!;
90
+ };
91
+
92
+ const openDatameshUI = (event: any): void => {
93
+ const widgetId = 'datamesh-ui';
94
+ const openWidget = find(
95
+ app.shell.widgets('main'),
96
+ (widget: Widget, index: number) => {
97
+ return widget.id === widgetId;
98
+ }
99
+ );
100
+ if (openWidget) {
101
+ app.shell.activateById(widgetId);
102
+ return;
103
+ }
104
+
105
+ const datameshUIWidget = new DatameshUI();
106
+ datameshUIWidget.title.label = 'Oceanum Datamesh';
107
+ datameshUIWidget.id = widgetId;
108
+ datameshUIWidget.title.closable = true;
109
+ datameshUIWidget.title.icon = oceanumIcon;
110
+ datameshUIWidget.addClass('datamesh-ui');
111
+ app.shell.add(datameshUIWidget, 'main');
112
+ };
113
+
114
+ const datameshConnectWidget = new DatameshConnectWidget({
115
+ app,
116
+ name: 'Datamesh Connect',
117
+ icon: oceanumIcon,
118
+ openDatameshUI: openDatameshUI,
119
+ commands: app.commands,
120
+ getCurrentWidget
121
+ });
122
+ datameshConnectWidget.id = 'datamesh-connect';
123
+ datameshConnectWidget.title.icon = oceanumIcon;
124
+ datameshConnectWidget.title.caption = 'Datamesh Connect';
125
+
126
+ restorer.add(datameshConnectWidget, 'datamesh-connect');
127
+
128
+ // Rank has been chosen somewhat arbitrarily to give priority to the running
129
+ // sessions widget in the sidebar.
130
+ app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
131
+
132
+ app.commands.addCommand('datamesh-ui:open', {
133
+ execute: (args: any) => {
134
+ openDatameshUI(args);
135
+ }
136
+ });
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Second plugin: Oceanum AI chat integration.
142
+ * Wires ChatRouter → KernelHandoff and exposes the
143
+ * `oceanum-ai:submit-prompt` command.
144
+ */
145
+ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
146
+ id: '@oceanum/oceanumlab:ai-chat',
147
+ autoStart: true,
148
+ requires: [INotebookTracker, ISettingRegistry],
149
+ activate: (
150
+ app: JupyterFrontEnd,
151
+ notebookTracker: INotebookTracker,
152
+ settingRegistry: ISettingRegistry
153
+ ) => {
154
+ console.log('Oceanum AI chat extension is loaded');
155
+
156
+ const SETTINGS_ID = '@oceanum/oceanumlab:datamesh-connect';
157
+
158
+ settingRegistry
159
+ .load(SETTINGS_ID)
160
+ .then(settings => {
161
+ // The notebook the current conversation lives in: what the agent is
162
+ // shown, and where its answers go.
163
+ const pin = new ConversationPin(notebookHost(app, notebookTracker));
164
+
165
+ // What a request carries from the conversation's notebook. An open
166
+ // notebook is read as it stands; a closed one is read from its file
167
+ // rather than opened again, which is left for an answer that has
168
+ // cells to place.
169
+ // The panel the current request read its selected cell from, if the
170
+ // notebook was open: a code answer may replace that cell only there.
171
+ let readFrom: NotebookPanel | null = null;
172
+ const router = new ChatRouter(settings, async () => {
173
+ const open = await pin.current();
174
+ readFrom = open;
175
+ if (open) {
176
+ return snapshotOf(open.content);
177
+ }
178
+ const path = pin.path();
179
+ if (!path) {
180
+ return null;
181
+ }
182
+ try {
183
+ const file = await app.serviceManager.contents.get(path, {
184
+ content: true
185
+ });
186
+ return snapshotFromIpynb(file.content);
187
+ } catch {
188
+ // Gone: an answer's cells will go into a new notebook instead.
189
+ return null;
190
+ }
191
+ });
192
+ // Asked only when an answer has blocks to place: brings the
193
+ // conversation's notebook to the front, opening it again if closed.
194
+ const handoff = new KernelHandoff(
195
+ () => pin.show(),
196
+ () => readFrom
197
+ );
198
+
199
+ // The run in flight, if any. Command args must be JSON, so a signal
200
+ // cannot be passed in; Stop is a second command that reaches it here.
201
+ let current: AbortController | null = null;
202
+
203
+ app.commands.addCommand('oceanum-ai:stop', {
204
+ label: 'Stop the current Oceanum AI response',
205
+ execute: () => {
206
+ current?.abort();
207
+ }
208
+ });
209
+
210
+ app.commands.addCommand('oceanum-ai:new-chat', {
211
+ label: 'Start a new Oceanum AI chat',
212
+ execute: () => {
213
+ // The run in flight belongs to the conversation being thrown away:
214
+ // stop it placing anything more. The panel discards its result.
215
+ // It stops being the current run now, not once it has finished
216
+ // unwinding, so it cannot report progress into the new chat.
217
+ const run = current;
218
+ current = null;
219
+ run?.abort();
220
+ return pin.start();
221
+ }
222
+ });
223
+
224
+ app.commands.addCommand('oceanum-ai:chat-context', {
225
+ label: 'The notebook the current Oceanum AI chat is about',
226
+ // Starts the conversation if nothing has -- so the first message of
227
+ // one nobody started with New chat pins the same way -- and reports
228
+ // its notebook either way.
229
+ execute: () => pin.ensure()
230
+ });
231
+
232
+ app.commands.addCommand('oceanum-ai:submit-prompt', {
233
+ label: 'Submit prompt to Oceanum AI',
234
+ execute: async (args: Record<string, unknown>) => {
235
+ const prompt = args['prompt'] as string | undefined;
236
+ const chatHistory = (args['chatHistory'] as ChatMessage[]) ?? [];
237
+ if (!prompt) {
238
+ return;
239
+ }
240
+ // A caller that skipped 'oceanum-ai:chat-context' still gets a
241
+ // conversation pinned the same way.
242
+ await pin.ensure();
243
+ // Read per prompt, not once at load, so a settings change applies
244
+ // to the next question without a reload.
245
+ const autoRunCode = settings.get('autoRunCode')
246
+ .composite as boolean;
247
+ const iterate = settings.get('iterate').composite as boolean;
248
+
249
+ current?.abort();
250
+ const controller = new AbortController();
251
+ current = controller;
252
+ // This run's progress, heard only while it is the current run.
253
+ const report = reporterFor(() => current === controller);
254
+ try {
255
+ // Cleared in the `finally` below however this ends, so the last
256
+ // phase does not sit on screen after the answer has arrived --
257
+ // or after Stop.
258
+ return await runChatLoop(
259
+ prompt,
260
+ chatHistory,
261
+ {
262
+ route: (p, h, signal) => router.route(p, h, signal, report),
263
+ observe: (p, h, runs, signal) => {
264
+ // The agent's turn again: the notebook's "Running the
265
+ // code…" must not stay up while the agent reads what the
266
+ // code printed.
267
+ report({ phase: 'interpreting' });
268
+ return router.observe(p, h, runs, signal, report);
269
+ },
270
+ place: (response, opts) => {
271
+ // The notebook's turn, not the agent's. Without this the
272
+ // last phase the AGENT reported stays on screen while a
273
+ // cell is running, so the user is told the agent is
274
+ // reading dataset details when what is actually happening
275
+ // is their own code executing. That is a worse claim than
276
+ // the "Thinking…" it replaced, which was vague rather than
277
+ // wrong. And "running" only when there is code that will.
278
+ if (response.blocks.length > 0) {
279
+ const runsCode =
280
+ autoRunCode &&
281
+ response.blocks.some(
282
+ block => block.type === 'code' && block.content.trim()
283
+ );
284
+ report({ phase: runsCode ? 'running' : 'placing' });
285
+ }
286
+ return handoff.inject(response, opts);
287
+ }
288
+ },
289
+ {
290
+ autoRunCode,
291
+ iterate,
292
+ maxRounds: MAX_OBSERVE_ROUNDS,
293
+ signal: controller.signal
294
+ }
295
+ );
296
+ } catch (err) {
297
+ // Anything that failed because the user pressed Stop is not an
298
+ // error to them: the aborted fetch, or its body read.
299
+ if (controller.signal.aborted) {
300
+ return STOPPED;
301
+ }
302
+ if (err instanceof ChatRouterError) {
303
+ Notification.error(err.message, { autoClose: 5000 });
304
+ } else {
305
+ console.error('Oceanum AI: unexpected error', err);
306
+ }
307
+ } finally {
308
+ // However this ended -- answered, failed, or stopped -- the
309
+ // agent is no longer doing anything, so the last phase must not
310
+ // sit on screen claiming otherwise.
311
+ report(null);
312
+ if (current === controller) {
313
+ current = null;
314
+ }
315
+ }
316
+ }
317
+ });
318
+ })
319
+ .catch(reason => {
320
+ console.error(
321
+ `Oceanum AI: could not load settings from ${SETTINGS_ID}.\n${reason}`
322
+ );
323
+ });
324
+ }
325
+ };
326
+
327
+ export default [datamesh_connect_extension, oceanum_ai_extension];
@@ -0,0 +1,284 @@
1
+ import { NotebookActions, NotebookPanel } from '@jupyterlab/notebook';
2
+ import { CodeCell } from '@jupyterlab/cells';
3
+ import type { ISessionContext } from '@jupyterlab/apputils';
4
+ import type * as nbformat from '@jupyterlab/nbformat';
5
+ import { OceanumResponse } from './chatRouter';
6
+ import { harvestOutputs, ObservedRun, RunOutcome } from './notebookRun';
7
+ import type { PlacedResponse } from './aiLoop';
8
+
9
+ export interface InjectOptions {
10
+ /** If true, replace the selected code cell instead of inserting a new one */
11
+ replaceCodeCell?: boolean;
12
+ /** Run each code cell as it is placed. A user setting, not a build constant. */
13
+ autoRun?: boolean;
14
+ /**
15
+ * Stop. Once aborted, no further block is placed or run, and a cell that is
16
+ * executing is interrupted -- the kernel is the half of a round that an
17
+ * aborted fetch cannot reach.
18
+ */
19
+ signal?: AbortSignal;
20
+ }
21
+
22
+ /** Longest wait for a starting kernel before the cell is reported as not run. */
23
+ const KERNEL_START_WAIT_MS = 10000;
24
+
25
+ export class KernelHandoff {
26
+ /**
27
+ * @param _target The conversation's notebook, brought to the front -- and
28
+ * opened again first, if it was closed. Asked only when there is
29
+ * something to place, so an answer with no blocks leaves the tabs alone.
30
+ * @param _readFrom The panel the request read its selected cell from, if
31
+ * any. A code answer replaces the selected cell only in that same panel:
32
+ * one opened since has some other cell selected, and replacing it would
33
+ * overwrite the user's work.
34
+ */
35
+ constructor(
36
+ private _target: () => Promise<NotebookPanel | null>,
37
+ private _readFrom: () => NotebookPanel | null
38
+ ) {}
39
+
40
+ /**
41
+ * Place every block the response carries, in order, and return the message
42
+ * for the chat window.
43
+ *
44
+ * This used to switch on `response.type`, because a response was exactly one
45
+ * of text, code or markdown. It can now carry any number of blocks in any
46
+ * combination (OCE-173) — a markdown table describing a dataset followed by
47
+ * the query that produced it is one response, not two — so the branch became
48
+ * a loop. No blocks is the commonest case and still touches no notebook.
49
+ */
50
+ async inject(
51
+ response: OceanumResponse,
52
+ options: InjectOptions = {}
53
+ ): Promise<PlacedResponse> {
54
+ const runs: ObservedRun[] = [];
55
+ let halted = false;
56
+ if (response.blocks.length === 0) {
57
+ // Nothing to place — just the message for the chat window.
58
+ return { message: response.message, runs };
59
+ }
60
+
61
+ // Every answer goes into the conversation's notebook, whichever tab is in
62
+ // front, and that notebook is brought to the front so the user sees it.
63
+ const notebookPanel = await this._target();
64
+ if (!notebookPanel) {
65
+ return {
66
+ message: response.message + '\n\n(Could not open a notebook)',
67
+ runs
68
+ };
69
+ }
70
+
71
+ const notebook = notebookPanel.content;
72
+ const { sessionContext } = notebookPanel;
73
+
74
+ // The user selected ONE cell, so only the first code block can replace it;
75
+ // everything after is inserted below in order. Captured before the loop
76
+ // because inserting moves `activeCell`.
77
+ const selected = notebook.activeCell;
78
+ let replaceTarget =
79
+ options.replaceCodeCell &&
80
+ notebookPanel === this._readFrom() &&
81
+ selected instanceof CodeCell
82
+ ? selected
83
+ : null;
84
+
85
+ for (const block of response.blocks) {
86
+ if (options.signal?.aborted) {
87
+ break;
88
+ }
89
+ if (block.type === 'code') {
90
+ let cell: CodeCell | null = null;
91
+ // The target can be gone by now: a markdown block placed before this
92
+ // one yielded while it rendered, and the user deleted the cell.
93
+ if (replaceTarget && !replaceTarget.isDisposed) {
94
+ replaceTarget.model.sharedModel.setSource(block.content);
95
+ cell = replaceTarget;
96
+ replaceTarget = null;
97
+ } else {
98
+ NotebookActions.insertBelow(notebook);
99
+ // `insertBelow` makes whatever the user's default cell type is;
100
+ // code goes in a code cell regardless.
101
+ if (
102
+ notebook.activeCell &&
103
+ !(notebook.activeCell instanceof CodeCell)
104
+ ) {
105
+ NotebookActions.changeCellType(notebook, 'code');
106
+ }
107
+ const newCell = notebook.activeCell;
108
+ if (!newCell) {
109
+ continue;
110
+ }
111
+ newCell.model.sharedModel.setSource(block.content);
112
+ cell = newCell instanceof CodeCell ? newCell : null;
113
+ }
114
+
115
+ // Run THIS cell, not the selection: the cell just written need not be
116
+ // the active one (a markdown block placed before it moved the
117
+ // selection), and the user may have several cells selected. Reading
118
+ // the outputs afterwards is what makes the iterate workflow possible
119
+ // -- it is the only place the kernel's answer can be seen. A blank
120
+ // block is placed but not run: `CodeCell.execute` would send nothing
121
+ // and the empty result would read as a kernel failure.
122
+ if (options.autoRun && cell && block.content.trim()) {
123
+ const { outcome, executed } = await this._run(
124
+ notebookPanel,
125
+ cell,
126
+ options.signal
127
+ );
128
+ runs.push({
129
+ code: block.content,
130
+ message: response.message,
131
+ ...outcome
132
+ });
133
+ if (!executed) {
134
+ halted = true;
135
+ }
136
+ }
137
+ } else {
138
+ NotebookActions.insertBelow(notebook);
139
+ if (!notebook.activeCell) {
140
+ continue;
141
+ }
142
+
143
+ // Convert to markdown cell and populate
144
+ NotebookActions.changeCellType(notebook, 'markdown');
145
+ const mdCell = notebook.activeCell;
146
+ if (mdCell) {
147
+ mdCell.model.sharedModel.setSource(block.content);
148
+ // Render the markdown cell
149
+ await NotebookActions.run(notebook, sessionContext);
150
+ }
151
+ }
152
+ }
153
+
154
+ return { message: response.message, runs, halted };
155
+ }
156
+
157
+ /**
158
+ * Execute one cell and report what happened, and whether it happened at all.
159
+ *
160
+ * `runCells` resolves false for a kernel error (the traceback is in the
161
+ * outputs) and for some of the ways a cell can fail to execute at all
162
+ * (pending input, interrupted); it resolves TRUE with nothing run when there
163
+ * is no kernel, when the kernel is terminating, or when the kernel is still
164
+ * starting (`CodeCell.execute` bails silently on a session without a
165
+ * kernel); and it rejects when the kernel is dead. None of the did-not-run
166
+ * paths touch the outputs, so a replaced cell would still show its previous
167
+ * run; they are cleared first so that whatever is there afterwards came from
168
+ * this run. The execution count is cleared with them and only a kernel
169
+ * reply sets it again, so a null count afterwards means nothing ran,
170
+ * whatever `runCells` resolved to; that is reported as an error rather than
171
+ * as a clean, silent run.
172
+ *
173
+ * Pending input is refused BEFORE clearing: clearing the outputs of a cell
174
+ * that is waiting on `input()` disposes the prompt widget, and the kernel
175
+ * then waits forever for an answer nobody can give.
176
+ */
177
+ private async _run(
178
+ notebookPanel: NotebookPanel,
179
+ cell: CodeCell,
180
+ signal?: AbortSignal
181
+ ): Promise<{ outcome: RunOutcome; executed: boolean }> {
182
+ const { content: notebook, sessionContext } = notebookPanel;
183
+ if (sessionContext.kernelDisplayStatus === 'initializing') {
184
+ // A kernel is starting (for a notebook this call just created, say).
185
+ // Bounded: `ready` never resolves if the kernel ends up needing to be
186
+ // selected by hand.
187
+ await waitForReady(sessionContext, signal);
188
+ if (signal?.aborted) {
189
+ return didNotRun('stopped before the kernel was ready');
190
+ }
191
+ }
192
+ // Closing the notebook, or deleting the cell, disposes it and nulls its
193
+ // model; every access below would throw and take the whole reply with it.
194
+ if (cell.isDisposed) {
195
+ return didNotRun('the cell was deleted or its notebook closed');
196
+ }
197
+ if (sessionContext.pendingInput) {
198
+ return didNotRun(
199
+ 'the kernel is waiting for input that must be answered first'
200
+ );
201
+ }
202
+ const interrupt = () => {
203
+ void sessionContext.session?.kernel?.interrupt().catch(() => {
204
+ // Already dead or gone; there is nothing left to interrupt.
205
+ });
206
+ };
207
+ cell.model.sharedModel.transact(() => {
208
+ cell.model.clearExecution();
209
+ }, false);
210
+ signal?.addEventListener('abort', interrupt, { once: true });
211
+ let ran: boolean;
212
+ let failure: string | null = null;
213
+ try {
214
+ ran = await NotebookActions.runCells(notebook, [cell], sessionContext);
215
+ } catch (err) {
216
+ // A dead kernel rejects rather than resolving false. Report it like any
217
+ // other did-not-run so the rounds already placed keep their messages,
218
+ // and log it: a rejection can also be a fault inside JupyterLab's
219
+ // executor, and the chat line alone would hide that.
220
+ console.error('Oceanum AI: cell execution failed', err);
221
+ ran = false;
222
+ failure = err instanceof Error ? err.message : String(err);
223
+ } finally {
224
+ signal?.removeEventListener('abort', interrupt);
225
+ }
226
+ if (cell.isDisposed) {
227
+ return didNotRun('the cell was deleted or its notebook closed');
228
+ }
229
+
230
+ // Only stream and error outputs are wanted; serialising a rendered plot
231
+ // or DataFrame just to discard it is not.
232
+ const outputs: nbformat.IOutput[] = [];
233
+ const model = cell.model.outputs;
234
+ for (let i = 0; i < model.length; i++) {
235
+ const item = model.get(i);
236
+ if (item.type === 'stream' || item.type === 'error') {
237
+ outputs.push(item.toJSON());
238
+ }
239
+ }
240
+ const outcome = harvestOutputs(outputs);
241
+
242
+ if (
243
+ (!ran || cell.model.executionCount === null) &&
244
+ outcome.status === 'ok'
245
+ ) {
246
+ return didNotRun(
247
+ failure ?? 'no kernel available yet, or the execution was interrupted'
248
+ );
249
+ }
250
+ return { outcome, executed: true };
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Resolve when the session context is ready, when Stop is pressed, or after
256
+ * `KERNEL_START_WAIT_MS`, whichever comes first. Stop is raced too so the
257
+ * button answers at once rather than after the kernel, or the timeout.
258
+ */
259
+ function waitForReady(
260
+ sessionContext: ISessionContext,
261
+ signal?: AbortSignal
262
+ ): Promise<void> {
263
+ return new Promise<void>(resolve => {
264
+ const done = () => {
265
+ clearTimeout(timer);
266
+ signal?.removeEventListener('abort', done);
267
+ resolve();
268
+ };
269
+ const timer = setTimeout(done, KERNEL_START_WAIT_MS);
270
+ void sessionContext.ready.then(done);
271
+ signal?.addEventListener('abort', done, { once: true });
272
+ });
273
+ }
274
+
275
+ function didNotRun(reason: string): { outcome: RunOutcome; executed: false } {
276
+ return {
277
+ outcome: {
278
+ status: 'error',
279
+ stdout: '',
280
+ error: `The cell did not execute (${reason}).`
281
+ },
282
+ executed: false
283
+ };
284
+ }