nebula-notebook 0.1.0 → 0.2.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.
- package/README.md +124 -24
- package/dist/assets/errorwidget-C4r2j2DQ.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-7-YBurka.js +716 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-CFBUnxSZ.css +32 -0
- package/dist/assets/index-CsHoPQy-.js +1 -0
- package/dist/assets/index-D5w21_Z8.js +81 -0
- package/dist/assets/index-Day3QcNs.js +1 -0
- package/dist/assets/services-shim-D6p_A67v.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/cluster/client-registration.js +3 -0
- package/node-server/dist/cluster/kernel-proxy.js +24 -9
- package/node-server/dist/cluster/server-registry.d.ts +8 -0
- package/node-server/dist/cluster/server-registry.js +31 -7
- package/node-server/dist/fs/fs-service.d.ts +55 -7
- package/node-server/dist/fs/fs-service.js +489 -80
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +129 -9
- package/node-server/dist/kernel/kernel-service.d.ts +113 -2
- package/node-server/dist/kernel/kernel-service.js +762 -60
- package/node-server/dist/notebook/cell-hash.d.ts +13 -0
- package/node-server/dist/notebook/cell-hash.js +26 -0
- package/node-server/dist/notebook/headless-handler.d.ts +9 -0
- package/node-server/dist/notebook/headless-handler.js +124 -23
- package/node-server/dist/notebook/operation-router.d.ts +36 -0
- package/node-server/dist/notebook/operation-router.js +224 -9
- package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
- package/node-server/dist/notebook/undoRedoManager.js +10 -2
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +2 -2
- package/node-server/dist/routes/kernel.js +115 -3
- package/node-server/dist/routes/notebook.js +35 -1
- package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
- package/node-server/dist/scheduler/allocation-service.js +169 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +121 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
- package/node-server/dist/scheduler/types.d.ts +117 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/pty-manager.js +8 -0
- package/node-server/dist/terminal/server.js +43 -2
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +1 -3
- package/package.json +3 -5
- package/dist/assets/index-C1h_sArD.css +0 -32
- package/dist/assets/index-CDSTBon8.js +0 -658
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content hash for optimistic concurrency control on agent writes.
|
|
3
|
+
*
|
|
4
|
+
* The operation router records the hash of each cell's content as the agent
|
|
5
|
+
* last saw it (from read results); write operations carry that hash and the
|
|
6
|
+
* applier — the live UI when connected, the headless handler otherwise —
|
|
7
|
+
* rejects the write if the cell's current content no longer matches. This is
|
|
8
|
+
* compare-and-swap at the point of application: immune to autosave latency
|
|
9
|
+
* and to browser/server clock skew (which rule out timestamp-based checks).
|
|
10
|
+
*
|
|
11
|
+
* MUST stay in sync with lib/cellHash.ts (frontend copy).
|
|
12
|
+
*/
|
|
13
|
+
export declare function hashCellContent(content: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hashCellContent = hashCellContent;
|
|
4
|
+
/**
|
|
5
|
+
* Content hash for optimistic concurrency control on agent writes.
|
|
6
|
+
*
|
|
7
|
+
* The operation router records the hash of each cell's content as the agent
|
|
8
|
+
* last saw it (from read results); write operations carry that hash and the
|
|
9
|
+
* applier — the live UI when connected, the headless handler otherwise —
|
|
10
|
+
* rejects the write if the cell's current content no longer matches. This is
|
|
11
|
+
* compare-and-swap at the point of application: immune to autosave latency
|
|
12
|
+
* and to browser/server clock skew (which rule out timestamp-based checks).
|
|
13
|
+
*
|
|
14
|
+
* MUST stay in sync with lib/cellHash.ts (frontend copy).
|
|
15
|
+
*/
|
|
16
|
+
function hashCellContent(content) {
|
|
17
|
+
// FNV-1a, 32-bit, hex — stable, fast, and tiny. Collisions are acceptable:
|
|
18
|
+
// a false "unchanged" requires an adversarial collision; the cost of a
|
|
19
|
+
// false conflict is one extra agent read.
|
|
20
|
+
let hash = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < content.length; i++) {
|
|
22
|
+
hash ^= content.charCodeAt(i);
|
|
23
|
+
hash = Math.imul(hash, 0x01000193);
|
|
24
|
+
}
|
|
25
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
26
|
+
}
|
|
@@ -52,6 +52,8 @@ export declare class HeadlessOperationHandler {
|
|
|
52
52
|
* Persist dirty notebooks to disk.
|
|
53
53
|
*/
|
|
54
54
|
flush(notebookPath?: string): Promise<void>;
|
|
55
|
+
/** In-flight persist per notebook path (single-flight). */
|
|
56
|
+
private persisting;
|
|
55
57
|
private asyncPersist;
|
|
56
58
|
private schedulePersist;
|
|
57
59
|
/**
|
|
@@ -66,6 +68,13 @@ export declare class HeadlessOperationHandler {
|
|
|
66
68
|
* Check if agent has permission to modify this notebook.
|
|
67
69
|
*/
|
|
68
70
|
private checkAgentPermission;
|
|
71
|
+
/**
|
|
72
|
+
* Compare expectedHash/expectedHashes stamped by the operation router
|
|
73
|
+
* against current cell content. Returns a conflict result, or null to
|
|
74
|
+
* proceed. The conflict message carries the current content so the agent
|
|
75
|
+
* can re-apply its intent without an extra read.
|
|
76
|
+
*/
|
|
77
|
+
private checkExpectedHashes;
|
|
69
78
|
/**
|
|
70
79
|
* Apply a notebook operation.
|
|
71
80
|
*/
|
|
@@ -41,9 +41,11 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.HeadlessOperationHandler = void 0;
|
|
43
43
|
const fs = __importStar(require("fs"));
|
|
44
|
+
const cell_hash_1 = require("./cell-hash");
|
|
44
45
|
const path = __importStar(require("path"));
|
|
45
46
|
const os = __importStar(require("os"));
|
|
46
47
|
const uuid_1 = require("uuid");
|
|
48
|
+
const registry_1 = require("../fs/notebook-formats/registry");
|
|
47
49
|
const cell_metadata_1 = require("./cell-metadata");
|
|
48
50
|
const undoRedoManager_1 = require("./undoRedoManager");
|
|
49
51
|
const kernelspec_1 = require("../kernel/kernelspec");
|
|
@@ -79,7 +81,7 @@ class HeadlessOperationHandler {
|
|
|
79
81
|
*/
|
|
80
82
|
getCachedNotebook(notebookPath) {
|
|
81
83
|
if (!this.cache.has(notebookPath)) {
|
|
82
|
-
const result = this.fsService.
|
|
84
|
+
const result = this.fsService.getNotebookCellsSync(notebookPath);
|
|
83
85
|
this.cache.set(notebookPath, {
|
|
84
86
|
cells: result.cells || [],
|
|
85
87
|
metadata: result.metadata || {},
|
|
@@ -108,23 +110,41 @@ class HeadlessOperationHandler {
|
|
|
108
110
|
async flush(notebookPath) {
|
|
109
111
|
const paths = notebookPath ? [notebookPath] : Array.from(this.cache.keys());
|
|
110
112
|
for (const p of paths) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
// A scheduled persist may have already cleared the dirty flag but not
|
|
114
|
+
// finished writing — flush must wait for it, or callers read a stale
|
|
115
|
+
// file. The persist loop keeps writing while dirty, so awaiting the
|
|
116
|
+
// in-flight run covers later mutations too.
|
|
117
|
+
const inFlight = this.persisting.get(p);
|
|
118
|
+
if (inFlight) {
|
|
119
|
+
await inFlight;
|
|
120
|
+
}
|
|
121
|
+
if (this.cache.get(p)?.dirty) {
|
|
113
122
|
await this.asyncPersist(p);
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
notebook
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/** In-flight persist per notebook path (single-flight). */
|
|
127
|
+
persisting = new Map();
|
|
128
|
+
asyncPersist(notebookPath) {
|
|
129
|
+
const existing = this.persisting.get(notebookPath);
|
|
130
|
+
if (existing)
|
|
131
|
+
return existing;
|
|
132
|
+
const run = (async () => {
|
|
133
|
+
const notebook = this.cache.get(notebookPath);
|
|
134
|
+
if (!notebook)
|
|
135
|
+
return;
|
|
136
|
+
// Keep writing while dirty
|
|
137
|
+
while (notebook.dirty) {
|
|
138
|
+
const cells = JSON.parse(JSON.stringify(notebook.cells));
|
|
139
|
+
notebook.dirty = false;
|
|
140
|
+
const history = this.undoRedoManager.getHistory(notebookPath, cells);
|
|
141
|
+
await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
|
|
142
|
+
}
|
|
143
|
+
})().finally(() => {
|
|
144
|
+
this.persisting.delete(notebookPath);
|
|
145
|
+
});
|
|
146
|
+
this.persisting.set(notebookPath, run);
|
|
147
|
+
return run;
|
|
128
148
|
}
|
|
129
149
|
schedulePersist(notebookPath) {
|
|
130
150
|
// Schedule async persist
|
|
@@ -142,6 +162,10 @@ class HeadlessOperationHandler {
|
|
|
142
162
|
else {
|
|
143
163
|
this.cache.clear();
|
|
144
164
|
}
|
|
165
|
+
// Undo/redo state is derived from the persisted history file; when the
|
|
166
|
+
// notebook may have changed underneath us (UI save, external edit
|
|
167
|
+
// reconciliation), rebuild it from disk on next access.
|
|
168
|
+
this.undoRedoManager.clearState(notebookPath);
|
|
145
169
|
}
|
|
146
170
|
/**
|
|
147
171
|
* Check if notebook has unsaved changes.
|
|
@@ -154,22 +178,70 @@ class HeadlessOperationHandler {
|
|
|
154
178
|
*/
|
|
155
179
|
checkAgentPermission(notebookPath, _operationType) {
|
|
156
180
|
const status = this.fsService.getAgentPermissionStatus(notebookPath);
|
|
157
|
-
|
|
181
|
+
// can_agent_modify already encodes the tri-state rule (agent-created notebooks
|
|
182
|
+
// are permitted unless explicitly revoked; user notebooks need permission +
|
|
183
|
+
// history), so an explicit user revoke wins even for agent-created notebooks.
|
|
184
|
+
if (status.can_agent_modify) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// User-permitted but history not yet enabled (soft, fixable) vs. not permitted.
|
|
188
|
+
if (status.agent_permitted && !status.has_history && !status.agent_created) {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted (agent access may have been revoked). Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Compare expectedHash/expectedHashes stamped by the operation router
|
|
201
|
+
* against current cell content. Returns a conflict result, or null to
|
|
202
|
+
* proceed. The conflict message carries the current content so the agent
|
|
203
|
+
* can re-apply its intent without an extra read.
|
|
204
|
+
*/
|
|
205
|
+
checkExpectedHashes(operation, notebookPath) {
|
|
206
|
+
const expectedHash = operation.expectedHash;
|
|
207
|
+
const expectedHashes = operation.expectedHashes;
|
|
208
|
+
if (!expectedHash && !expectedHashes)
|
|
209
|
+
return null;
|
|
210
|
+
if (!notebookPath)
|
|
158
211
|
return null;
|
|
212
|
+
const cells = this.getCells(notebookPath);
|
|
213
|
+
const checks = [];
|
|
214
|
+
if (expectedHash && typeof operation.cellId === 'string') {
|
|
215
|
+
checks.push({ cellId: operation.cellId, expected: expectedHash });
|
|
216
|
+
}
|
|
217
|
+
if (expectedHashes) {
|
|
218
|
+
for (const [cellId, expected] of Object.entries(expectedHashes)) {
|
|
219
|
+
checks.push({ cellId, expected });
|
|
220
|
+
}
|
|
159
221
|
}
|
|
160
|
-
|
|
161
|
-
|
|
222
|
+
for (const { cellId, expected } of checks) {
|
|
223
|
+
const cell = cells.find(c => c.id === cellId);
|
|
224
|
+
if (!cell) {
|
|
162
225
|
return {
|
|
163
226
|
success: false,
|
|
164
|
-
|
|
227
|
+
conflict: true,
|
|
228
|
+
error: `Conflict: cell ${cellId} no longer exists (it changed since you last read it). Re-read the notebook and retry.`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const current = (0, cell_hash_1.hashCellContent)(cell.content ?? '');
|
|
232
|
+
if (current !== expected) {
|
|
233
|
+
const preview = (cell.content ?? '').slice(0, 2000);
|
|
234
|
+
return {
|
|
235
|
+
success: false,
|
|
236
|
+
conflict: true,
|
|
237
|
+
error: `Conflict: cell ${cellId} was modified (likely by the user) after you last read it. ` +
|
|
238
|
+
`Current content:\n${preview}${(cell.content ?? '').length > 2000 ? '\n…(truncated)' : ''}\n` +
|
|
239
|
+
`Re-apply your intent against this content and retry — your next write to this cell is validated against the content shown above, so no re-read is needed.`,
|
|
240
|
+
currentContent: cell.content,
|
|
165
241
|
};
|
|
166
242
|
}
|
|
167
|
-
return null;
|
|
168
243
|
}
|
|
169
|
-
return
|
|
170
|
-
success: false,
|
|
171
|
-
error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted. Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
|
|
172
|
-
};
|
|
244
|
+
return null;
|
|
173
245
|
}
|
|
174
246
|
/**
|
|
175
247
|
* Apply a notebook operation.
|
|
@@ -187,6 +259,13 @@ class HeadlessOperationHandler {
|
|
|
187
259
|
return permissionError;
|
|
188
260
|
}
|
|
189
261
|
}
|
|
262
|
+
// Optimistic concurrency: collaborative agent sessions stamp destructive
|
|
263
|
+
// writes with the content hash the agent last saw (see operation-router).
|
|
264
|
+
// Verify against current content before applying.
|
|
265
|
+
const occConflict = this.checkExpectedHashes(operation, notebookPath);
|
|
266
|
+
if (occConflict) {
|
|
267
|
+
return occConflict;
|
|
268
|
+
}
|
|
190
269
|
try {
|
|
191
270
|
let result;
|
|
192
271
|
switch (opType) {
|
|
@@ -726,6 +805,28 @@ class HeadlessOperationHandler {
|
|
|
726
805
|
error: `Notebook already exists: ${notebookPath}. Use overwrite=true to replace.`,
|
|
727
806
|
};
|
|
728
807
|
}
|
|
808
|
+
// Text notebook formats (.py percent / .qmd): create through the format
|
|
809
|
+
// adapter — agent flags live in the file's own metadata like .ipynb.
|
|
810
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
811
|
+
if (formatAdapter) {
|
|
812
|
+
const dir = path.dirname(normalizedPath);
|
|
813
|
+
if (!fs.existsSync(dir)) {
|
|
814
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
815
|
+
}
|
|
816
|
+
const metadata = {
|
|
817
|
+
kernelspec: { name: kernelName, display_name: kernelDisplayName },
|
|
818
|
+
nebula: { agent_created: true, agent_permitted: true },
|
|
819
|
+
};
|
|
820
|
+
if (formatAdapter.name === 'qmd')
|
|
821
|
+
metadata.__qmd_language = 'python';
|
|
822
|
+
fs.writeFileSync(normalizedPath, formatAdapter.serialize([], metadata), 'utf-8');
|
|
823
|
+
this.invalidate(notebookPath);
|
|
824
|
+
return {
|
|
825
|
+
success: true,
|
|
826
|
+
path: notebookPath,
|
|
827
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
729
830
|
const notebook = {
|
|
730
831
|
nbformat: 4,
|
|
731
832
|
nbformat_minor: 5,
|
|
@@ -12,6 +12,7 @@ import { HeadlessOperationHandler } from './headless-handler';
|
|
|
12
12
|
interface KernelChangedPayload {
|
|
13
13
|
kernelName: string;
|
|
14
14
|
serverId?: string | null;
|
|
15
|
+
mtime?: number;
|
|
15
16
|
}
|
|
16
17
|
type Backend = 'ui' | 'headless';
|
|
17
18
|
interface OperationResult {
|
|
@@ -27,12 +28,15 @@ interface AgentLock {
|
|
|
27
28
|
expiresAt: number;
|
|
28
29
|
notebookPath: string;
|
|
29
30
|
lockedAt: number;
|
|
31
|
+
exclusive: boolean;
|
|
32
|
+
cellHashes: Map<string, string>;
|
|
30
33
|
}
|
|
31
34
|
export declare class OperationRouter {
|
|
32
35
|
private uiConnections;
|
|
33
36
|
private headlessHandler;
|
|
34
37
|
private operationTimeout;
|
|
35
38
|
private agentLocks;
|
|
39
|
+
private preSessionReadHashes;
|
|
36
40
|
setHeadlessHandler(handler: HeadlessOperationHandler): void;
|
|
37
41
|
/**
|
|
38
42
|
* Register a UI connection for a notebook path.
|
|
@@ -58,6 +62,7 @@ export declare class OperationRouter {
|
|
|
58
62
|
startAgentSession(notebookPath: string, agentId: string, metadata?: {
|
|
59
63
|
clientName?: string;
|
|
60
64
|
clientVersion?: string;
|
|
65
|
+
exclusive?: boolean;
|
|
61
66
|
}): {
|
|
62
67
|
success: boolean;
|
|
63
68
|
error?: string;
|
|
@@ -97,6 +102,37 @@ export declare class OperationRouter {
|
|
|
97
102
|
* Apply a notebook operation.
|
|
98
103
|
*/
|
|
99
104
|
applyOperation(operation: Record<string, unknown>): Promise<OperationResult>;
|
|
105
|
+
/**
|
|
106
|
+
* OCC preparation for a destructive write in a collaborative session.
|
|
107
|
+
* Returns an error string to reject the operation, or null to proceed
|
|
108
|
+
* (with `expectedHash`/`expectedHashes` stamped onto the operation).
|
|
109
|
+
*
|
|
110
|
+
* Policy:
|
|
111
|
+
* - updateContent / updateMetadata / deleteCell / deleteCells: the agent
|
|
112
|
+
* must have read the cell this session (hash known), and the applier
|
|
113
|
+
* verifies the content still matches before applying.
|
|
114
|
+
* - executeCell: verified only when the hash is known (running slightly
|
|
115
|
+
* stale content is recoverable; destroying user edits is not).
|
|
116
|
+
* - clearNotebook: requires an exclusive session.
|
|
117
|
+
* - Index-addressed destructive writes are rejected: user edits shift
|
|
118
|
+
* indices, so collaborative writes must address cells by id.
|
|
119
|
+
*/
|
|
120
|
+
private prepareCollaborativeWrite;
|
|
121
|
+
/**
|
|
122
|
+
* Record the session's view of cell content from operation results.
|
|
123
|
+
* Keyed to the notebook's active lock: the lock guarantees a single writing
|
|
124
|
+
* agent per notebook, so reads from any source refreshing it is sound.
|
|
125
|
+
*/
|
|
126
|
+
private recordSessionHashes;
|
|
127
|
+
/**
|
|
128
|
+
* Record hashes for a full-notebook read (used by router.readNotebook,
|
|
129
|
+
* which serves the MCP read_notebook tool outside applyOperation).
|
|
130
|
+
*/
|
|
131
|
+
recordNotebookReadHashes(notebookPath: string, cells: Array<{
|
|
132
|
+
id?: string;
|
|
133
|
+
content?: string;
|
|
134
|
+
}>): void;
|
|
135
|
+
private getPreSessionStore;
|
|
100
136
|
private forwardToUI;
|
|
101
137
|
/**
|
|
102
138
|
* Handle operation response from UI.
|
|
@@ -47,6 +47,8 @@ const path = __importStar(require("path"));
|
|
|
47
47
|
const fs = __importStar(require("fs"));
|
|
48
48
|
const os = __importStar(require("os"));
|
|
49
49
|
const ws_1 = require("ws");
|
|
50
|
+
const cell_hash_1 = require("./cell-hash");
|
|
51
|
+
const registry_1 = require("../fs/notebook-formats/registry");
|
|
50
52
|
const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
51
53
|
const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
|
|
52
54
|
function normalizeNotebookPath(notebookPath) {
|
|
@@ -62,6 +64,11 @@ class OperationRouter {
|
|
|
62
64
|
headlessHandler = null;
|
|
63
65
|
operationTimeout = 30000; // 30 seconds
|
|
64
66
|
agentLocks = new Map(); // path -> lock
|
|
67
|
+
// Content hashes from reads that happen OUTSIDE a session (agents read to
|
|
68
|
+
// orient themselves before calling start_agent_session). Seeds the session's
|
|
69
|
+
// cellHashes on start so the first write doesn't hit "read it first".
|
|
70
|
+
// Keyed per path — the per-notebook lock already serializes writing agents.
|
|
71
|
+
preSessionReadHashes = new Map();
|
|
65
72
|
setHeadlessHandler(handler) {
|
|
66
73
|
this.headlessHandler = handler;
|
|
67
74
|
}
|
|
@@ -93,6 +100,11 @@ class OperationRouter {
|
|
|
93
100
|
pendingRequests: new Map(),
|
|
94
101
|
lastActivityAt: Date.now(),
|
|
95
102
|
});
|
|
103
|
+
// The UI is now the source of truth for this notebook; any headless cache
|
|
104
|
+
// is potentially stale (and would be consulted again if this connection
|
|
105
|
+
// goes stale or closes).
|
|
106
|
+
this.headlessHandler?.invalidate(notebookPath);
|
|
107
|
+
this.headlessHandler?.invalidate(normalizedPath);
|
|
96
108
|
console.log(`[OperationRouter] UI registered for: ${normalizedPath}`);
|
|
97
109
|
}
|
|
98
110
|
/**
|
|
@@ -111,6 +123,10 @@ class OperationRouter {
|
|
|
111
123
|
request.reject(new Error('UI disconnected'));
|
|
112
124
|
}
|
|
113
125
|
this.uiConnections.delete(normalizedPath);
|
|
126
|
+
// Subsequent ops route headless — make sure they reload from disk (which
|
|
127
|
+
// has the UI's autosaves) rather than a cache predating the UI session.
|
|
128
|
+
this.headlessHandler?.invalidate(notebookPath);
|
|
129
|
+
this.headlessHandler?.invalidate(normalizedPath);
|
|
114
130
|
console.log(`[OperationRouter] UI unregistered for: ${normalizedPath}`);
|
|
115
131
|
}
|
|
116
132
|
}
|
|
@@ -125,6 +141,7 @@ class OperationRouter {
|
|
|
125
141
|
type: 'kernelChanged',
|
|
126
142
|
kernelName: payload.kernelName,
|
|
127
143
|
serverId: payload.serverId ?? null,
|
|
144
|
+
mtime: payload.mtime,
|
|
128
145
|
}));
|
|
129
146
|
}
|
|
130
147
|
catch (err) {
|
|
@@ -169,8 +186,12 @@ class OperationRouter {
|
|
|
169
186
|
const existingLock = this.agentLocks.get(normalizedPath);
|
|
170
187
|
if (existingLock) {
|
|
171
188
|
if (existingLock.agentId === agentId) {
|
|
172
|
-
// Same agent re-acquiring lock - refresh timeout
|
|
189
|
+
// Same agent re-acquiring lock - refresh timeout (and allow it to
|
|
190
|
+
// upgrade/downgrade exclusivity)
|
|
173
191
|
existingLock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
|
|
192
|
+
if (metadata?.exclusive !== undefined) {
|
|
193
|
+
existingLock.exclusive = metadata.exclusive;
|
|
194
|
+
}
|
|
174
195
|
console.log(`[OperationRouter] Agent session refreshed for: ${normalizedPath} (agent: ${agentId})`);
|
|
175
196
|
return { success: true, lock: existingLock };
|
|
176
197
|
}
|
|
@@ -185,7 +206,8 @@ class OperationRouter {
|
|
|
185
206
|
};
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
|
-
// Acquire new lock
|
|
209
|
+
// Acquire new lock — seeded with hashes from any pre-session reads, so the
|
|
210
|
+
// standard orient-then-edit agent flow doesn't trip "read it first".
|
|
189
211
|
const now = Date.now();
|
|
190
212
|
const newLock = {
|
|
191
213
|
agentId,
|
|
@@ -194,6 +216,8 @@ class OperationRouter {
|
|
|
194
216
|
expiresAt: now + AGENT_LOCK_TIMEOUT_MS,
|
|
195
217
|
notebookPath: normalizedPath,
|
|
196
218
|
lockedAt: now,
|
|
219
|
+
exclusive: metadata?.exclusive === true,
|
|
220
|
+
cellHashes: new Map(this.preSessionReadHashes.get(normalizedPath) ?? []),
|
|
197
221
|
};
|
|
198
222
|
this.agentLocks.set(normalizedPath, newLock);
|
|
199
223
|
console.log(`[OperationRouter] Agent session started for: ${normalizedPath} (agent: ${agentId}, client: ${metadata?.clientName || 'unknown'})`);
|
|
@@ -297,9 +321,13 @@ class OperationRouter {
|
|
|
297
321
|
const opType = operation.type;
|
|
298
322
|
const agentId = operation.agentId;
|
|
299
323
|
// For createNotebook, route to ANY connected UI (not path-specific)
|
|
300
|
-
// This allows the UI to open the new notebook in a new tab
|
|
324
|
+
// This allows the UI to open the new notebook in a new tab.
|
|
325
|
+
// EXCEPT text notebook formats (.py/.qmd): the UI's create handler only
|
|
326
|
+
// builds Jupyter JSON, so those are always created headless through the
|
|
327
|
+
// format adapters.
|
|
301
328
|
const isCreateNotebook = opType === 'createNotebook';
|
|
302
|
-
const
|
|
329
|
+
const isTextFormatCreate = isCreateNotebook && (0, registry_1.isTextNotebookPath)(operation.notebookPath || '');
|
|
330
|
+
const anyUI = isCreateNotebook && !isTextFormatCreate ? this.getAnyUIConnection() : null;
|
|
303
331
|
const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
|
|
304
332
|
const backend = hasUI ? 'ui' : 'headless';
|
|
305
333
|
// Clean up expired locks
|
|
@@ -321,7 +349,8 @@ class OperationRouter {
|
|
|
321
349
|
const clientName = operation.clientName;
|
|
322
350
|
const clientVersion = operation.clientVersion;
|
|
323
351
|
const lastSessionTimestamp = operation.lastSessionTimestamp;
|
|
324
|
-
const
|
|
352
|
+
const exclusive = operation.exclusive === true;
|
|
353
|
+
const result = this.startAgentSession(notebookPath, reqAgentId, { clientName, clientVersion, exclusive });
|
|
325
354
|
// Also forward to UI for UI state update (badge display)
|
|
326
355
|
if (result.success && hasUI) {
|
|
327
356
|
console.log(` -> Lock acquired, forwarding to UI for badge update`);
|
|
@@ -380,19 +409,197 @@ class OperationRouter {
|
|
|
380
409
|
if (lock && agentId && lock.agentId === agentId) {
|
|
381
410
|
lock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
|
|
382
411
|
}
|
|
412
|
+
// Collaborative sessions: stamp destructive writes with the content hash
|
|
413
|
+
// the agent last saw, to be compared against live content at the applier
|
|
414
|
+
// (the UI when connected — the only place with a freshness-window-free
|
|
415
|
+
// view, since user edits reach the server only on autosave).
|
|
416
|
+
if (isWrite && lock && !lock.exclusive && agentId && lock.agentId === agentId) {
|
|
417
|
+
const occError = this.prepareCollaborativeWrite(lock, operation, opType);
|
|
418
|
+
if (occError) {
|
|
419
|
+
console.log(` -> BLOCKED (OCC): ${occError}`);
|
|
420
|
+
return { success: false, error: occError, conflict: true, backend };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
let result;
|
|
383
424
|
if (hasUI) {
|
|
384
425
|
// For createNotebook, use any available UI connection
|
|
385
426
|
const uiPath = isCreateNotebook && anyUI ? anyUI.path : normalizedPath;
|
|
386
427
|
console.log(` -> Routing to UI (via ${uiPath})`);
|
|
387
|
-
|
|
388
|
-
return { ...result, backend: 'ui' };
|
|
428
|
+
result = { ...(await this.forwardToUI(uiPath, operation)), backend: 'ui' };
|
|
389
429
|
}
|
|
390
430
|
else {
|
|
391
431
|
console.log(` -> Routing to HEADLESS`);
|
|
392
|
-
|
|
393
|
-
|
|
432
|
+
result = { ...(await this.applyHeadless(operation)), backend: 'headless' };
|
|
433
|
+
}
|
|
434
|
+
// Keep the session's view of cell content fresh from reads and the
|
|
435
|
+
// agent's own successful writes.
|
|
436
|
+
if (result.success) {
|
|
437
|
+
this.recordSessionHashes(normalizedPath, opType, operation, result);
|
|
438
|
+
}
|
|
439
|
+
else if (result.conflict && typeof result.currentContent === 'string' && typeof operation.cellId === 'string') {
|
|
440
|
+
// Self-healing conflicts: the error hands the agent the current content,
|
|
441
|
+
// so re-baseline the session to the same content. The agent's corrected
|
|
442
|
+
// retry then validates against what it was just told — without this, the
|
|
443
|
+
// stale stored hash rejects every retry until an explicit re-read.
|
|
444
|
+
const lock2 = this.agentLocks.get(normalizedPath);
|
|
445
|
+
const hashes = (lock2 && lock2.expiresAt > Date.now())
|
|
446
|
+
? lock2.cellHashes
|
|
447
|
+
: this.getPreSessionStore(normalizedPath);
|
|
448
|
+
hashes.set(operation.cellId, (0, cell_hash_1.hashCellContent)(result.currentContent));
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* OCC preparation for a destructive write in a collaborative session.
|
|
454
|
+
* Returns an error string to reject the operation, or null to proceed
|
|
455
|
+
* (with `expectedHash`/`expectedHashes` stamped onto the operation).
|
|
456
|
+
*
|
|
457
|
+
* Policy:
|
|
458
|
+
* - updateContent / updateMetadata / deleteCell / deleteCells: the agent
|
|
459
|
+
* must have read the cell this session (hash known), and the applier
|
|
460
|
+
* verifies the content still matches before applying.
|
|
461
|
+
* - executeCell: verified only when the hash is known (running slightly
|
|
462
|
+
* stale content is recoverable; destroying user edits is not).
|
|
463
|
+
* - clearNotebook: requires an exclusive session.
|
|
464
|
+
* - Index-addressed destructive writes are rejected: user edits shift
|
|
465
|
+
* indices, so collaborative writes must address cells by id.
|
|
466
|
+
*/
|
|
467
|
+
prepareCollaborativeWrite(lock, operation, opType) {
|
|
468
|
+
const requireHash = (cellId) => {
|
|
469
|
+
if (typeof cellId !== 'string' || !cellId) {
|
|
470
|
+
return `${opType} in a collaborative session must address the cell by cell_id (indices shift when the user edits). Re-read the notebook and use ids.`;
|
|
471
|
+
}
|
|
472
|
+
const hash = lock.cellHashes.get(cellId);
|
|
473
|
+
if (!hash) {
|
|
474
|
+
return `You haven't read cell ${cellId} in this session — read it first (read_cell), then retry. In collaborative sessions the user may edit while you work.`;
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
};
|
|
478
|
+
switch (opType) {
|
|
479
|
+
case 'updateContent':
|
|
480
|
+
case 'updateMetadata':
|
|
481
|
+
case 'deleteCell': {
|
|
482
|
+
const cellId = operation.cellId ?? null;
|
|
483
|
+
const err = requireHash(cellId);
|
|
484
|
+
if (err)
|
|
485
|
+
return err;
|
|
486
|
+
operation.expectedHash = lock.cellHashes.get(cellId);
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
case 'deleteCells': {
|
|
490
|
+
const cellIds = Array.isArray(operation.cellIds) ? operation.cellIds : [];
|
|
491
|
+
const hashes = {};
|
|
492
|
+
for (const cellId of cellIds) {
|
|
493
|
+
const err = requireHash(cellId);
|
|
494
|
+
if (err)
|
|
495
|
+
return err;
|
|
496
|
+
hashes[cellId] = lock.cellHashes.get(cellId);
|
|
497
|
+
}
|
|
498
|
+
operation.expectedHashes = hashes;
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
case 'executeCell': {
|
|
502
|
+
const cellId = operation.cellId;
|
|
503
|
+
if (typeof cellId === 'string' && lock.cellHashes.has(cellId)) {
|
|
504
|
+
operation.expectedHash = lock.cellHashes.get(cellId);
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
case 'clearNotebook':
|
|
509
|
+
return 'clearNotebook requires an exclusive session (start_agent_session with exclusive=true) — it would destroy any concurrent user edits.';
|
|
510
|
+
case 'undo':
|
|
511
|
+
case 'redo':
|
|
512
|
+
// Content shifts unpredictably — force re-reads afterwards.
|
|
513
|
+
lock.cellHashes.clear();
|
|
514
|
+
return null;
|
|
515
|
+
default:
|
|
516
|
+
return null; // insert/move/duplicate/outputs/kernel ops: not destructive to user content
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Record the session's view of cell content from operation results.
|
|
521
|
+
* Keyed to the notebook's active lock: the lock guarantees a single writing
|
|
522
|
+
* agent per notebook, so reads from any source refreshing it is sound.
|
|
523
|
+
*/
|
|
524
|
+
recordSessionHashes(normalizedPath, opType, operation, result) {
|
|
525
|
+
const lock = this.agentLocks.get(normalizedPath);
|
|
526
|
+
const hashes = (lock && lock.expiresAt > Date.now())
|
|
527
|
+
? lock.cellHashes
|
|
528
|
+
: this.getPreSessionStore(normalizedPath); // pre-session reads arm OCC too
|
|
529
|
+
const record = (cellId, content) => {
|
|
530
|
+
if (typeof cellId === 'string' && typeof content === 'string') {
|
|
531
|
+
hashes.set(cellId, (0, cell_hash_1.hashCellContent)(content));
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
switch (opType) {
|
|
535
|
+
case 'readCell': {
|
|
536
|
+
const cell = result.cell;
|
|
537
|
+
record(cell?.id, cell?.content);
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 'updateContent':
|
|
541
|
+
record(operation.cellId, operation.content);
|
|
542
|
+
break;
|
|
543
|
+
case 'insertCell': {
|
|
544
|
+
const cell = operation.cell;
|
|
545
|
+
record(result.cellId, cell?.content ?? '');
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case 'insertCells': {
|
|
549
|
+
const cells = operation.cells;
|
|
550
|
+
const insertedIds = result.cellIds;
|
|
551
|
+
if (cells && insertedIds && cells.length === insertedIds.length) {
|
|
552
|
+
insertedIds.forEach((id, i) => record(id, cells[i]?.content ?? ''));
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
case 'deleteCell':
|
|
557
|
+
if (typeof operation.cellId === 'string')
|
|
558
|
+
hashes.delete(operation.cellId);
|
|
559
|
+
break;
|
|
560
|
+
case 'deleteCells':
|
|
561
|
+
if (Array.isArray(operation.cellIds)) {
|
|
562
|
+
for (const id of operation.cellIds) {
|
|
563
|
+
if (typeof id === 'string')
|
|
564
|
+
hashes.delete(id);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
break;
|
|
568
|
+
case 'clearNotebook':
|
|
569
|
+
hashes.clear();
|
|
570
|
+
break;
|
|
394
571
|
}
|
|
395
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Record hashes for a full-notebook read (used by router.readNotebook,
|
|
575
|
+
* which serves the MCP read_notebook tool outside applyOperation).
|
|
576
|
+
*/
|
|
577
|
+
recordNotebookReadHashes(notebookPath, cells) {
|
|
578
|
+
const normalizedPath = normalizeNotebookPath(notebookPath);
|
|
579
|
+
const lock = this.agentLocks.get(normalizedPath);
|
|
580
|
+
const target = (lock && lock.expiresAt > Date.now())
|
|
581
|
+
? lock.cellHashes
|
|
582
|
+
: this.getPreSessionStore(normalizedPath);
|
|
583
|
+
for (const cell of cells) {
|
|
584
|
+
if (typeof cell?.id === 'string' && typeof cell?.content === 'string') {
|
|
585
|
+
target.set(cell.id, (0, cell_hash_1.hashCellContent)(cell.content));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
getPreSessionStore(normalizedPath) {
|
|
590
|
+
let store = this.preSessionReadHashes.get(normalizedPath);
|
|
591
|
+
if (!store) {
|
|
592
|
+
store = new Map();
|
|
593
|
+
this.preSessionReadHashes.set(normalizedPath, store);
|
|
594
|
+
// Bound total memory: keep at most a handful of notebooks' worth.
|
|
595
|
+
if (this.preSessionReadHashes.size > 16) {
|
|
596
|
+
const oldest = this.preSessionReadHashes.keys().next().value;
|
|
597
|
+
if (oldest && oldest !== normalizedPath)
|
|
598
|
+
this.preSessionReadHashes.delete(oldest);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return store;
|
|
602
|
+
}
|
|
396
603
|
async forwardToUI(notebookPath, operation) {
|
|
397
604
|
const conn = this.getResponsiveUIConnection(notebookPath);
|
|
398
605
|
if (!conn) {
|
|
@@ -492,6 +699,9 @@ class OperationRouter {
|
|
|
492
699
|
if (this.getResponsiveUIConnection(normalizedPath)) {
|
|
493
700
|
const result = await this.readFromUI(normalizedPath);
|
|
494
701
|
if (result.success) {
|
|
702
|
+
const data = result.data;
|
|
703
|
+
if (data?.cells)
|
|
704
|
+
this.recordNotebookReadHashes(normalizedPath, data.cells);
|
|
495
705
|
const truncated = this.applyOutputTruncation(result, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
|
|
496
706
|
if (!truncated.backend) {
|
|
497
707
|
truncated.backend = 'ui';
|
|
@@ -505,6 +715,11 @@ class OperationRouter {
|
|
|
505
715
|
}
|
|
506
716
|
else {
|
|
507
717
|
const result = await this.readFromFile(notebookPath, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
|
|
718
|
+
if (result.success) {
|
|
719
|
+
const data = result.data;
|
|
720
|
+
if (data?.cells)
|
|
721
|
+
this.recordNotebookReadHashes(normalizedPath, data.cells);
|
|
722
|
+
}
|
|
508
723
|
if (!result.backend) {
|
|
509
724
|
result.backend = 'headless';
|
|
510
725
|
}
|