nebula-notebook 0.1.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 (97) hide show
  1. package/README.md +222 -0
  2. package/bin/nebula-notebook.js +33 -0
  3. package/dist/assets/index-C1h_sArD.css +32 -0
  4. package/dist/assets/index-CDSTBon8.js +658 -0
  5. package/dist/favicon.svg +11 -0
  6. package/dist/index.html +73 -0
  7. package/node-server/dist/app.d.ts +5 -0
  8. package/node-server/dist/app.js +38 -0
  9. package/node-server/dist/auth/auth-middleware.d.ts +24 -0
  10. package/node-server/dist/auth/auth-middleware.js +276 -0
  11. package/node-server/dist/auth/auth-service.d.ts +84 -0
  12. package/node-server/dist/auth/auth-service.js +265 -0
  13. package/node-server/dist/auth/index.d.ts +3 -0
  14. package/node-server/dist/auth/index.js +8 -0
  15. package/node-server/dist/cluster/client-registration.d.ts +43 -0
  16. package/node-server/dist/cluster/client-registration.js +217 -0
  17. package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
  18. package/node-server/dist/cluster/cluster-secret.js +90 -0
  19. package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
  20. package/node-server/dist/cluster/kernel-proxy.js +361 -0
  21. package/node-server/dist/cluster/server-registry.d.ts +109 -0
  22. package/node-server/dist/cluster/server-registry.js +217 -0
  23. package/node-server/dist/config/output-limits.d.ts +7 -0
  24. package/node-server/dist/config/output-limits.js +10 -0
  25. package/node-server/dist/discovery/discovery-service.d.ts +198 -0
  26. package/node-server/dist/discovery/discovery-service.js +811 -0
  27. package/node-server/dist/discovery/index.d.ts +5 -0
  28. package/node-server/dist/discovery/index.js +21 -0
  29. package/node-server/dist/discovery/types.d.ts +48 -0
  30. package/node-server/dist/discovery/types.js +24 -0
  31. package/node-server/dist/fs/fs-service.d.ts +218 -0
  32. package/node-server/dist/fs/fs-service.js +1422 -0
  33. package/node-server/dist/fs/index.d.ts +5 -0
  34. package/node-server/dist/fs/index.js +21 -0
  35. package/node-server/dist/fs/types.d.ts +132 -0
  36. package/node-server/dist/fs/types.js +5 -0
  37. package/node-server/dist/index.d.ts +13 -0
  38. package/node-server/dist/index.js +556 -0
  39. package/node-server/dist/kernel/default-kernel.d.ts +5 -0
  40. package/node-server/dist/kernel/default-kernel.js +138 -0
  41. package/node-server/dist/kernel/index.d.ts +7 -0
  42. package/node-server/dist/kernel/index.js +23 -0
  43. package/node-server/dist/kernel/kernel-service.d.ts +290 -0
  44. package/node-server/dist/kernel/kernel-service.js +1714 -0
  45. package/node-server/dist/kernel/kernelspec.d.ts +29 -0
  46. package/node-server/dist/kernel/kernelspec.js +210 -0
  47. package/node-server/dist/kernel/session-store.d.ts +87 -0
  48. package/node-server/dist/kernel/session-store.js +303 -0
  49. package/node-server/dist/kernel/types.d.ts +143 -0
  50. package/node-server/dist/kernel/types.js +17 -0
  51. package/node-server/dist/llm/index.d.ts +5 -0
  52. package/node-server/dist/llm/index.js +21 -0
  53. package/node-server/dist/llm/llm-service.d.ts +77 -0
  54. package/node-server/dist/llm/llm-service.js +454 -0
  55. package/node-server/dist/llm/types.d.ts +40 -0
  56. package/node-server/dist/llm/types.js +15 -0
  57. package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
  58. package/node-server/dist/notebook/cell-metadata.js +76 -0
  59. package/node-server/dist/notebook/headless-handler.d.ts +127 -0
  60. package/node-server/dist/notebook/headless-handler.js +1530 -0
  61. package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
  62. package/node-server/dist/notebook/notebook-websocket.js +103 -0
  63. package/node-server/dist/notebook/operation-router.d.ts +115 -0
  64. package/node-server/dist/notebook/operation-router.js +641 -0
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
  66. package/node-server/dist/notebook/undoRedoManager.js +558 -0
  67. package/node-server/dist/output/display-data.d.ts +14 -0
  68. package/node-server/dist/output/display-data.js +134 -0
  69. package/node-server/dist/resources/resource-service.d.ts +69 -0
  70. package/node-server/dist/resources/resource-service.js +363 -0
  71. package/node-server/dist/routes/auth.d.ts +5 -0
  72. package/node-server/dist/routes/auth.js +61 -0
  73. package/node-server/dist/routes/cluster.d.ts +7 -0
  74. package/node-server/dist/routes/cluster.js +94 -0
  75. package/node-server/dist/routes/fs.d.ts +7 -0
  76. package/node-server/dist/routes/fs.js +392 -0
  77. package/node-server/dist/routes/kernel.d.ts +13 -0
  78. package/node-server/dist/routes/kernel.js +637 -0
  79. package/node-server/dist/routes/llm.d.ts +8 -0
  80. package/node-server/dist/routes/llm.js +105 -0
  81. package/node-server/dist/routes/notebook.d.ts +10 -0
  82. package/node-server/dist/routes/notebook.js +335 -0
  83. package/node-server/dist/routes/python.d.ts +8 -0
  84. package/node-server/dist/routes/python.js +187 -0
  85. package/node-server/dist/routes/resources.d.ts +7 -0
  86. package/node-server/dist/routes/resources.js +77 -0
  87. package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
  88. package/node-server/dist/scripts/show-auth-qr.js +81 -0
  89. package/node-server/dist/terminal/pty-manager.d.ts +100 -0
  90. package/node-server/dist/terminal/pty-manager.js +246 -0
  91. package/node-server/dist/terminal/server.d.ts +19 -0
  92. package/node-server/dist/terminal/server.js +254 -0
  93. package/node-server/dist/terminal/types.d.ts +50 -0
  94. package/node-server/dist/terminal/types.js +9 -0
  95. package/node-server/package.json +45 -0
  96. package/package.json +99 -0
  97. package/scripts/postinstall.cjs +26 -0
@@ -0,0 +1,1422 @@
1
+ "use strict";
2
+ /**
3
+ * Filesystem Service - Real filesystem operations
4
+ *
5
+ * Node.js port of the Python FilesystemService.
6
+ */
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.fsService = exports.FilesystemService = void 0;
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
44
+ const os = __importStar(require("os"));
45
+ const crypto = __importStar(require("crypto"));
46
+ const util_1 = require("util");
47
+ const default_kernel_1 = require("../kernel/default-kernel");
48
+ const display_data_1 = require("../output/display-data");
49
+ const NEBULA_DIR = path.join(os.homedir(), '.nebula');
50
+ const USER_CONFIG_PATH = path.join(NEBULA_DIR, 'config.json');
51
+ const PROJECT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', '.nebula-config.json');
52
+ const NOTEBOOK_METADATA_FAST_PATH_BYTES = 64 * 1024;
53
+ const NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES = 8 * 1024 * 1024;
54
+ function readJsonString(source, startIndex) {
55
+ if (source[startIndex] !== '"') {
56
+ return null;
57
+ }
58
+ let i = startIndex + 1;
59
+ let escaped = false;
60
+ while (i < source.length) {
61
+ const ch = source[i];
62
+ if (escaped) {
63
+ escaped = false;
64
+ i++;
65
+ continue;
66
+ }
67
+ if (ch === '\\') {
68
+ escaped = true;
69
+ i++;
70
+ continue;
71
+ }
72
+ if (ch === '"') {
73
+ try {
74
+ return {
75
+ value: JSON.parse(source.slice(startIndex, i + 1)),
76
+ endIndex: i + 1,
77
+ };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ i++;
84
+ }
85
+ return null;
86
+ }
87
+ function findMatchingJsonObjectEnd(source, startIndex) {
88
+ let depth = 0;
89
+ let inString = false;
90
+ let escaped = false;
91
+ for (let i = startIndex; i < source.length; i++) {
92
+ const ch = source[i];
93
+ if (inString) {
94
+ if (escaped) {
95
+ escaped = false;
96
+ }
97
+ else if (ch === '\\') {
98
+ escaped = true;
99
+ }
100
+ else if (ch === '"') {
101
+ inString = false;
102
+ }
103
+ continue;
104
+ }
105
+ if (ch === '"') {
106
+ inString = true;
107
+ continue;
108
+ }
109
+ if (ch === '{') {
110
+ depth++;
111
+ continue;
112
+ }
113
+ if (ch === '}') {
114
+ depth--;
115
+ if (depth === 0) {
116
+ return i;
117
+ }
118
+ }
119
+ }
120
+ return -1;
121
+ }
122
+ function extractTopLevelObjectField(source, fieldName) {
123
+ let depth = 0;
124
+ let inString = false;
125
+ let escaped = false;
126
+ for (let i = 0; i < source.length; i++) {
127
+ const ch = source[i];
128
+ if (inString) {
129
+ if (escaped) {
130
+ escaped = false;
131
+ }
132
+ else if (ch === '\\') {
133
+ escaped = true;
134
+ }
135
+ else if (ch === '"') {
136
+ inString = false;
137
+ }
138
+ continue;
139
+ }
140
+ if (ch === '"') {
141
+ if (depth !== 1) {
142
+ inString = true;
143
+ continue;
144
+ }
145
+ const key = readJsonString(source, i);
146
+ if (!key) {
147
+ return null;
148
+ }
149
+ let j = key.endIndex;
150
+ while (j < source.length && /\s/.test(source[j])) {
151
+ j++;
152
+ }
153
+ if (source[j] !== ':') {
154
+ i = key.endIndex - 1;
155
+ continue;
156
+ }
157
+ if (key.value !== fieldName) {
158
+ i = key.endIndex - 1;
159
+ continue;
160
+ }
161
+ j++;
162
+ while (j < source.length && /\s/.test(source[j])) {
163
+ j++;
164
+ }
165
+ if (source[j] !== '{') {
166
+ return null;
167
+ }
168
+ const endIndex = findMatchingJsonObjectEnd(source, j);
169
+ if (endIndex === -1) {
170
+ return null;
171
+ }
172
+ try {
173
+ return JSON.parse(source.slice(j, endIndex + 1));
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
179
+ if (ch === '{' || ch === '[') {
180
+ depth++;
181
+ }
182
+ else if (ch === '}' || ch === ']') {
183
+ depth--;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+ /**
189
+ * Load root directory from config files if they exist.
190
+ * Priority: env -> user config -> project config.
191
+ */
192
+ function loadNebulaConfig() {
193
+ const envRoot = process.env.NEBULA_WORKDIR || process.env.NEBULA_ROOT;
194
+ if (envRoot) {
195
+ return envRoot;
196
+ }
197
+ try {
198
+ if (fs.existsSync(USER_CONFIG_PATH)) {
199
+ const config = JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf-8'));
200
+ if (config.rootDirectory) {
201
+ return config.rootDirectory;
202
+ }
203
+ }
204
+ }
205
+ catch {
206
+ // Ignore user config errors
207
+ }
208
+ try {
209
+ if (fs.existsSync(PROJECT_CONFIG_PATH)) {
210
+ const config = JSON.parse(fs.readFileSync(PROJECT_CONFIG_PATH, 'utf-8'));
211
+ if (config.rootDirectory) {
212
+ return config.rootDirectory;
213
+ }
214
+ }
215
+ }
216
+ catch {
217
+ // Ignore project config errors
218
+ }
219
+ return null;
220
+ }
221
+ class FilesystemService {
222
+ writeLocks = new Map();
223
+ defaultRoot;
224
+ constructor(defaultRoot) {
225
+ // Priority: explicit arg > config file > home directory
226
+ const configuredRoot = defaultRoot || loadNebulaConfig() || os.homedir();
227
+ this.defaultRoot = this.expandRootDirectory(configuredRoot);
228
+ }
229
+ /**
230
+ * Get the server root directory.
231
+ */
232
+ getRootDirectory() {
233
+ return this.defaultRoot;
234
+ }
235
+ /**
236
+ * Set the server root directory and persist it.
237
+ */
238
+ setRootDirectory(rootDirectory, options) {
239
+ const resolved = this.expandRootDirectory(rootDirectory);
240
+ if (!fs.existsSync(resolved)) {
241
+ throw new Error(`Root directory not found: ${resolved}`);
242
+ }
243
+ if (!fs.statSync(resolved).isDirectory()) {
244
+ throw new Error(`Root directory is not a folder: ${resolved}`);
245
+ }
246
+ this.defaultRoot = resolved;
247
+ if (options?.persist !== false) {
248
+ this.saveRootDirectory(resolved);
249
+ }
250
+ return this.defaultRoot;
251
+ }
252
+ expandRootDirectory(rootDirectory) {
253
+ const trimmed = rootDirectory.trim();
254
+ if (trimmed === '' || trimmed === '~') {
255
+ return os.homedir();
256
+ }
257
+ if (trimmed.startsWith('~/')) {
258
+ return path.join(os.homedir(), trimmed.slice(2));
259
+ }
260
+ return path.resolve(trimmed);
261
+ }
262
+ saveRootDirectory(rootDirectory) {
263
+ if (!fs.existsSync(NEBULA_DIR)) {
264
+ fs.mkdirSync(NEBULA_DIR, { recursive: true, mode: 0o700 });
265
+ }
266
+ const config = { rootDirectory };
267
+ fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
268
+ }
269
+ /**
270
+ * Normalize and expand path
271
+ */
272
+ normalizePath(filePath) {
273
+ if (filePath === '~' || filePath === '') {
274
+ return this.defaultRoot;
275
+ }
276
+ if (filePath.startsWith('~/')) {
277
+ return path.join(this.defaultRoot, filePath.slice(2));
278
+ }
279
+ if (filePath.startsWith('~')) {
280
+ // Handle ~user paths (rare in practice)
281
+ return path.resolve(os.homedir(), '..', filePath.slice(1));
282
+ }
283
+ if (!path.isAbsolute(filePath)) {
284
+ return path.resolve(this.defaultRoot, filePath);
285
+ }
286
+ return path.resolve(filePath);
287
+ }
288
+ /**
289
+ * Serialize write operations per notebook to avoid interleaving writes.
290
+ */
291
+ async withWriteLock(notebookPath, fn) {
292
+ const key = this.normalizePath(notebookPath);
293
+ const previous = this.writeLocks.get(key) || Promise.resolve();
294
+ let release;
295
+ const current = new Promise((resolve) => {
296
+ release = resolve;
297
+ });
298
+ const next = previous.then(() => current);
299
+ this.writeLocks.set(key, next);
300
+ await previous;
301
+ try {
302
+ return await fn();
303
+ }
304
+ finally {
305
+ release();
306
+ if (this.writeLocks.get(key) === next) {
307
+ this.writeLocks.delete(key);
308
+ }
309
+ }
310
+ }
311
+ /**
312
+ * Atomically write a file (write temp, fsync, rename, fsync dir).
313
+ * Prevents partial/corrupt files on interruption.
314
+ */
315
+ atomicWriteFileSync(targetPath, data) {
316
+ const dir = path.dirname(targetPath);
317
+ if (!fs.existsSync(dir)) {
318
+ fs.mkdirSync(dir, { recursive: true });
319
+ }
320
+ const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
321
+ const tmpPath = path.join(dir, tmpName);
322
+ const fd = fs.openSync(tmpPath, 'w', 0o600);
323
+ try {
324
+ fs.writeFileSync(fd, data, 'utf-8');
325
+ fs.fsyncSync(fd);
326
+ }
327
+ finally {
328
+ fs.closeSync(fd);
329
+ }
330
+ fs.renameSync(tmpPath, targetPath);
331
+ // Best-effort directory fsync
332
+ try {
333
+ const dirFd = fs.openSync(dir, 'r');
334
+ try {
335
+ fs.fsyncSync(dirFd);
336
+ }
337
+ finally {
338
+ fs.closeSync(dirFd);
339
+ }
340
+ }
341
+ catch {
342
+ // Ignore fsync errors on directory handles
343
+ }
344
+ }
345
+ writeJsonAtomicSync(targetPath, payload) {
346
+ this.atomicWriteFileSync(targetPath, JSON.stringify(payload, null, 2));
347
+ }
348
+ /**
349
+ * Format file size for display
350
+ */
351
+ formatSize(size) {
352
+ if (size < 1024) {
353
+ return `${size}B`;
354
+ }
355
+ else if (size < 1024 * 1024) {
356
+ return `${(size / 1024).toFixed(1)}KB`;
357
+ }
358
+ else if (size < 1024 * 1024 * 1024) {
359
+ return `${(size / (1024 * 1024)).toFixed(1)}MB`;
360
+ }
361
+ else {
362
+ return `${(size / (1024 * 1024 * 1024)).toFixed(1)}GB`;
363
+ }
364
+ }
365
+ /**
366
+ * Determine file type from extension
367
+ */
368
+ getFileType(extension) {
369
+ const ext = extension.toLowerCase();
370
+ if (ext === '.ipynb') {
371
+ return 'notebook';
372
+ }
373
+ else if (['.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
374
+ return 'code';
375
+ }
376
+ else if (['.csv', '.tsv', '.xlsx', '.xls'].includes(ext)) {
377
+ return 'data';
378
+ }
379
+ else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) {
380
+ return 'image';
381
+ }
382
+ else if (ext === '.pdf') {
383
+ return 'document';
384
+ }
385
+ return 'file';
386
+ }
387
+ /**
388
+ * Get file info for a path
389
+ */
390
+ getFileInfo(filePath) {
391
+ const stat = fs.statSync(filePath);
392
+ const name = path.basename(filePath);
393
+ const isDir = stat.isDirectory();
394
+ return {
395
+ name,
396
+ path: filePath,
397
+ isDirectory: isDir,
398
+ size: isDir ? 0 : stat.size,
399
+ modified: stat.mtimeMs / 1000, // Convert to seconds
400
+ extension: isDir ? '' : path.extname(name),
401
+ };
402
+ }
403
+ /**
404
+ * Convert FileInfo to FileInfoResponse for API
405
+ */
406
+ toFileInfoResponse(info) {
407
+ return {
408
+ id: info.path,
409
+ name: info.name,
410
+ path: info.path,
411
+ isDirectory: info.isDirectory,
412
+ size: this.formatSize(info.size),
413
+ sizeBytes: info.size,
414
+ modified: info.modified,
415
+ extension: info.extension,
416
+ fileType: info.isDirectory ? 'folder' : this.getFileType(info.extension),
417
+ };
418
+ }
419
+ /**
420
+ * Get directory modification time (lightweight check for changes)
421
+ */
422
+ getDirectoryMtime(dirPath) {
423
+ const normalizedPath = this.normalizePath(dirPath);
424
+ if (!fs.existsSync(normalizedPath)) {
425
+ throw new Error(`Path not found: ${normalizedPath}`);
426
+ }
427
+ const stat = fs.statSync(normalizedPath);
428
+ if (!stat.isDirectory()) {
429
+ throw new Error(`Not a directory: ${normalizedPath}`);
430
+ }
431
+ return {
432
+ path: normalizedPath,
433
+ mtime: stat.mtimeMs / 1000,
434
+ };
435
+ }
436
+ /**
437
+ * Get file modification time
438
+ */
439
+ getFileMtime(filePath) {
440
+ const normalizedPath = this.normalizePath(filePath);
441
+ if (!fs.existsSync(normalizedPath)) {
442
+ throw new Error(`File not found: ${normalizedPath}`);
443
+ }
444
+ const stat = fs.statSync(normalizedPath);
445
+ return {
446
+ path: normalizedPath,
447
+ mtime: stat.mtimeMs / 1000,
448
+ };
449
+ }
450
+ /**
451
+ * List contents of a directory
452
+ */
453
+ listDirectory(dirPath) {
454
+ const normalizedPath = this.normalizePath(dirPath);
455
+ if (!fs.existsSync(normalizedPath)) {
456
+ throw new Error(`Path not found: ${normalizedPath}`);
457
+ }
458
+ const stat = fs.statSync(normalizedPath);
459
+ if (!stat.isDirectory()) {
460
+ throw new Error(`Not a directory: ${normalizedPath}`);
461
+ }
462
+ const items = [];
463
+ const entries = fs.readdirSync(normalizedPath);
464
+ for (const name of entries) {
465
+ // Skip hidden files
466
+ if (name.startsWith('.')) {
467
+ continue;
468
+ }
469
+ const fullPath = path.join(normalizedPath, name);
470
+ try {
471
+ const info = this.getFileInfo(fullPath);
472
+ items.push(this.toFileInfoResponse(info));
473
+ }
474
+ catch {
475
+ // Skip files we can't access
476
+ continue;
477
+ }
478
+ }
479
+ // Sort: directories first, then by name
480
+ items.sort((a, b) => {
481
+ if (a.isDirectory !== b.isDirectory) {
482
+ return a.isDirectory ? -1 : 1;
483
+ }
484
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
485
+ });
486
+ return {
487
+ path: normalizedPath,
488
+ parent: normalizedPath === '/' ? null : path.dirname(normalizedPath),
489
+ mtime: stat.mtimeMs / 1000,
490
+ items,
491
+ };
492
+ }
493
+ /**
494
+ * Read a file's contents
495
+ */
496
+ readFile(filePath) {
497
+ const normalizedPath = this.normalizePath(filePath);
498
+ if (!fs.existsSync(normalizedPath)) {
499
+ throw new Error(`File not found: ${normalizedPath}`);
500
+ }
501
+ const stat = fs.statSync(normalizedPath);
502
+ if (stat.isDirectory()) {
503
+ throw new Error(`Path is a directory: ${normalizedPath}`);
504
+ }
505
+ const extension = path.extname(normalizedPath).toLowerCase();
506
+ if (extension === '.ipynb') {
507
+ const content = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
508
+ return {
509
+ path: normalizedPath,
510
+ type: 'notebook',
511
+ content,
512
+ };
513
+ }
514
+ // Try to read as text
515
+ try {
516
+ const content = fs.readFileSync(normalizedPath, 'utf-8');
517
+ return {
518
+ path: normalizedPath,
519
+ type: 'text',
520
+ content,
521
+ };
522
+ }
523
+ catch {
524
+ // Binary file
525
+ return {
526
+ path: normalizedPath,
527
+ type: 'binary',
528
+ content: null,
529
+ message: 'Binary file cannot be displayed',
530
+ };
531
+ }
532
+ }
533
+ /**
534
+ * Write content to a file
535
+ */
536
+ writeFile(filePath, content, fileType = 'text') {
537
+ const normalizedPath = this.normalizePath(filePath);
538
+ // Create parent directories if needed
539
+ const parentDir = path.dirname(normalizedPath);
540
+ if (parentDir && !fs.existsSync(parentDir)) {
541
+ fs.mkdirSync(parentDir, { recursive: true });
542
+ }
543
+ if (fileType === 'notebook') {
544
+ fs.writeFileSync(normalizedPath, JSON.stringify(content, null, 2), 'utf-8');
545
+ }
546
+ else {
547
+ fs.writeFileSync(normalizedPath, content, 'utf-8');
548
+ }
549
+ return true;
550
+ }
551
+ /**
552
+ * Create a new file or directory
553
+ */
554
+ createFile(filePath, isDirectory = false) {
555
+ const normalizedPath = this.normalizePath(filePath);
556
+ if (fs.existsSync(normalizedPath)) {
557
+ throw new Error(`Path already exists: ${normalizedPath}`);
558
+ }
559
+ if (isDirectory) {
560
+ fs.mkdirSync(normalizedPath, { recursive: true });
561
+ }
562
+ else {
563
+ // Create parent directories if needed
564
+ const parentDir = path.dirname(normalizedPath);
565
+ if (parentDir && !fs.existsSync(parentDir)) {
566
+ fs.mkdirSync(parentDir, { recursive: true });
567
+ }
568
+ const extension = path.extname(normalizedPath).toLowerCase();
569
+ if (extension === '.ipynb') {
570
+ // Create empty notebook
571
+ const notebook = {
572
+ cells: [],
573
+ metadata: {
574
+ kernelspec: {
575
+ display_name: 'Python 3',
576
+ language: 'python',
577
+ name: 'python3',
578
+ },
579
+ },
580
+ nbformat: 4,
581
+ nbformat_minor: 5,
582
+ };
583
+ this.writeJsonAtomicSync(normalizedPath, notebook);
584
+ }
585
+ else {
586
+ // Create empty file
587
+ fs.writeFileSync(normalizedPath, '', 'utf-8');
588
+ }
589
+ }
590
+ const info = this.getFileInfo(normalizedPath);
591
+ return { ...info, is_directory: info.isDirectory };
592
+ }
593
+ /**
594
+ * Get the history file path for a notebook
595
+ */
596
+ getHistoryPath(notebookPath) {
597
+ const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
598
+ return path.join(nebulaDir, `${nameWithoutExt}.history.json`);
599
+ }
600
+ /**
601
+ * Get the session state file path for a notebook
602
+ */
603
+ getSessionPath(notebookPath) {
604
+ const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
605
+ return path.join(nebulaDir, `${nameWithoutExt}.session.json`);
606
+ }
607
+ /**
608
+ * Get the journal file path for a notebook (used for crash-safe commits)
609
+ */
610
+ getJournalPath(notebookPath) {
611
+ const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
612
+ return path.join(nebulaDir, `${nameWithoutExt}.commit.json`);
613
+ }
614
+ readJournal(notebookPath) {
615
+ const journalPath = this.getJournalPath(notebookPath);
616
+ if (!fs.existsSync(journalPath))
617
+ return null;
618
+ try {
619
+ return JSON.parse(fs.readFileSync(journalPath, 'utf-8'));
620
+ }
621
+ catch {
622
+ return null;
623
+ }
624
+ }
625
+ hasPendingCommit(notebookPath) {
626
+ const journal = this.readJournal(notebookPath);
627
+ if (!journal)
628
+ return false;
629
+ return journal.status === 'begin';
630
+ }
631
+ getNebulaPaths(notebookPath) {
632
+ const normalizedPath = this.normalizePath(notebookPath);
633
+ const parentDir = path.dirname(normalizedPath);
634
+ const notebookName = path.basename(normalizedPath);
635
+ const nameWithoutExt = path.basename(notebookName, path.extname(notebookName));
636
+ const nebulaDir = path.join(parentDir, '.nebula');
637
+ return { nebulaDir, nameWithoutExt };
638
+ }
639
+ /**
640
+ * Delete notebook-related metadata files (history, session)
641
+ */
642
+ deleteNotebookMetadata(notebookPath) {
643
+ const historyPath = this.getHistoryPath(notebookPath);
644
+ const sessionPath = this.getSessionPath(notebookPath);
645
+ const journalPath = this.getJournalPath(notebookPath);
646
+ if (fs.existsSync(historyPath)) {
647
+ fs.unlinkSync(historyPath);
648
+ }
649
+ if (fs.existsSync(sessionPath)) {
650
+ fs.unlinkSync(sessionPath);
651
+ }
652
+ if (fs.existsSync(journalPath)) {
653
+ fs.unlinkSync(journalPath);
654
+ }
655
+ }
656
+ /**
657
+ * Delete a file or directory
658
+ */
659
+ deleteFile(filePath) {
660
+ const normalizedPath = this.normalizePath(filePath);
661
+ if (!fs.existsSync(normalizedPath)) {
662
+ throw new Error(`Path not found: ${normalizedPath}`);
663
+ }
664
+ // For notebooks, also delete history and session files
665
+ const ext = path.extname(normalizedPath).toLowerCase();
666
+ const stat = fs.statSync(normalizedPath);
667
+ if (ext === '.ipynb' && !stat.isDirectory()) {
668
+ this.deleteNotebookMetadata(normalizedPath);
669
+ }
670
+ if (stat.isDirectory()) {
671
+ fs.rmSync(normalizedPath, { recursive: true, force: true });
672
+ }
673
+ else {
674
+ fs.unlinkSync(normalizedPath);
675
+ }
676
+ return true;
677
+ }
678
+ /**
679
+ * Rename notebook-related metadata files
680
+ */
681
+ renameNotebookMetadata(oldPath, newPath) {
682
+ const oldHistory = this.getHistoryPath(oldPath);
683
+ const oldSession = this.getSessionPath(oldPath);
684
+ const oldJournal = this.getJournalPath(oldPath);
685
+ const newHistory = this.getHistoryPath(newPath);
686
+ const newSession = this.getSessionPath(newPath);
687
+ // Create destination .nebula directory if needed
688
+ const newNebulaDir = path.dirname(newHistory);
689
+ if (!fs.existsSync(newNebulaDir)) {
690
+ fs.mkdirSync(newNebulaDir, { recursive: true });
691
+ }
692
+ if (fs.existsSync(oldHistory)) {
693
+ fs.renameSync(oldHistory, newHistory);
694
+ }
695
+ if (fs.existsSync(oldSession)) {
696
+ fs.renameSync(oldSession, newSession);
697
+ }
698
+ if (fs.existsSync(oldJournal)) {
699
+ // Journal files are ephemeral; remove any stale commit record on rename
700
+ fs.unlinkSync(oldJournal);
701
+ }
702
+ }
703
+ /**
704
+ * Rename/move a file or directory
705
+ */
706
+ renameFile(oldPath, newPath) {
707
+ const normalizedOld = this.normalizePath(oldPath);
708
+ const normalizedNew = this.normalizePath(newPath);
709
+ if (!fs.existsSync(normalizedOld)) {
710
+ throw new Error(`Path not found: ${normalizedOld}`);
711
+ }
712
+ if (fs.existsSync(normalizedNew)) {
713
+ throw new Error(`Destination already exists: ${normalizedNew}`);
714
+ }
715
+ // For notebooks, also rename history and session files
716
+ const ext = path.extname(normalizedOld).toLowerCase();
717
+ if (ext === '.ipynb') {
718
+ this.renameNotebookMetadata(normalizedOld, normalizedNew);
719
+ }
720
+ fs.renameSync(normalizedOld, normalizedNew);
721
+ return this.getFileInfo(normalizedNew);
722
+ }
723
+ /**
724
+ * Duplicate notebook-related metadata files
725
+ */
726
+ duplicateNotebookMetadata(srcPath, destPath) {
727
+ const srcHistory = this.getHistoryPath(srcPath);
728
+ const destHistory = this.getHistoryPath(destPath);
729
+ const srcSession = this.getSessionPath(srcPath);
730
+ const destSession = this.getSessionPath(destPath);
731
+ // Create destination .nebula directory if needed
732
+ const destNebulaDir = path.dirname(destHistory);
733
+ if (!fs.existsSync(destNebulaDir)) {
734
+ fs.mkdirSync(destNebulaDir, { recursive: true });
735
+ }
736
+ if (fs.existsSync(srcHistory)) {
737
+ fs.copyFileSync(srcHistory, destHistory);
738
+ }
739
+ if (fs.existsSync(srcSession)) {
740
+ fs.copyFileSync(srcSession, destSession);
741
+ }
742
+ }
743
+ /**
744
+ * Recursively copy a directory
745
+ */
746
+ copyDirectoryRecursive(src, dest) {
747
+ fs.mkdirSync(dest, { recursive: true });
748
+ const entries = fs.readdirSync(src, { withFileTypes: true });
749
+ for (const entry of entries) {
750
+ const srcPath = path.join(src, entry.name);
751
+ const destPath = path.join(dest, entry.name);
752
+ if (entry.isDirectory()) {
753
+ this.copyDirectoryRecursive(srcPath, destPath);
754
+ }
755
+ else {
756
+ if (entry.name.endsWith('.commit.json')) {
757
+ continue;
758
+ }
759
+ fs.copyFileSync(srcPath, destPath);
760
+ // For notebooks, also duplicate history and session files
761
+ if (entry.name.toLowerCase().endsWith('.ipynb')) {
762
+ this.duplicateNotebookMetadata(srcPath, destPath);
763
+ }
764
+ }
765
+ }
766
+ }
767
+ /**
768
+ * Duplicate a file or directory with _copy suffix
769
+ */
770
+ duplicateFile(filePath) {
771
+ const normalizedPath = this.normalizePath(filePath);
772
+ if (!fs.existsSync(normalizedPath)) {
773
+ throw new Error(`File not found: ${normalizedPath}`);
774
+ }
775
+ const stat = fs.statSync(normalizedPath);
776
+ const isDirectory = stat.isDirectory();
777
+ const parentDir = path.dirname(normalizedPath);
778
+ const ext = isDirectory ? '' : path.extname(normalizedPath);
779
+ const name = path.basename(normalizedPath, ext);
780
+ // Find a unique name
781
+ let newName = `${name}_copy${ext}`;
782
+ let newPath = path.join(parentDir, newName);
783
+ let counter = 2;
784
+ while (fs.existsSync(newPath)) {
785
+ newName = `${name}_copy_${counter}${ext}`;
786
+ newPath = path.join(parentDir, newName);
787
+ counter++;
788
+ }
789
+ if (isDirectory) {
790
+ // Recursively copy directory
791
+ this.copyDirectoryRecursive(normalizedPath, newPath);
792
+ }
793
+ else {
794
+ // Copy the file
795
+ fs.copyFileSync(normalizedPath, newPath);
796
+ // For notebooks, also duplicate history and session files
797
+ if (ext.toLowerCase() === '.ipynb') {
798
+ this.duplicateNotebookMetadata(normalizedPath, newPath);
799
+ }
800
+ }
801
+ return this.toFileInfoResponse(this.getFileInfo(newPath));
802
+ }
803
+ /**
804
+ * Upload a file to a directory
805
+ */
806
+ async uploadFile(destDir, tempFilePath, originalName) {
807
+ const normalizedDir = this.normalizePath(destDir);
808
+ if (!fs.existsSync(normalizedDir)) {
809
+ throw new Error(`Directory not found: ${normalizedDir}`);
810
+ }
811
+ const stat = fs.statSync(normalizedDir);
812
+ if (!stat.isDirectory()) {
813
+ throw new Error(`Not a directory: ${normalizedDir}`);
814
+ }
815
+ // Determine final path
816
+ let finalPath = path.join(normalizedDir, originalName);
817
+ // If file exists, find unique name
818
+ if (fs.existsSync(finalPath)) {
819
+ const ext = path.extname(originalName);
820
+ const name = path.basename(originalName, ext);
821
+ let counter = 1;
822
+ while (fs.existsSync(finalPath)) {
823
+ finalPath = path.join(normalizedDir, `${name}_${counter}${ext}`);
824
+ counter++;
825
+ }
826
+ }
827
+ // Move temp file to final destination
828
+ fs.copyFileSync(tempFilePath, finalPath);
829
+ fs.unlinkSync(tempFilePath);
830
+ return this.toFileInfoResponse(this.getFileInfo(finalPath));
831
+ }
832
+ /**
833
+ * Convert Jupyter source to string
834
+ */
835
+ sourceToString(source) {
836
+ if (Array.isArray(source)) {
837
+ return source.join('');
838
+ }
839
+ return source;
840
+ }
841
+ /**
842
+ * Convert string to Jupyter source format (array of lines)
843
+ */
844
+ stringToSource(content) {
845
+ if (!content) {
846
+ return [];
847
+ }
848
+ const lines = content.split('\n');
849
+ // Add \n back to all lines except the last
850
+ return lines.map((line, i) => (i < lines.length - 1 ? line + '\n' : line));
851
+ }
852
+ /**
853
+ * Convert Jupyter outputs to Nebula format
854
+ */
855
+ convertOutputs(outputs, cellIndex) {
856
+ if (!outputs)
857
+ return [];
858
+ const result = [];
859
+ const timestamp = Date.now();
860
+ for (let i = 0; i < outputs.length; i++) {
861
+ const output = outputs[i];
862
+ if (output.output_type === 'stream') {
863
+ const streamName = output.name || 'stdout';
864
+ const text = this.sourceToString(output.text || '');
865
+ result.push({
866
+ id: `output-${cellIndex}-${result.length}`,
867
+ type: streamName === 'stderr' ? 'stderr' : 'stdout',
868
+ content: text,
869
+ timestamp,
870
+ });
871
+ }
872
+ else if (output.output_type === 'execute_result' || output.output_type === 'display_data') {
873
+ const normalizedOutput = (0, display_data_1.buildDisplayOutput)(output.data || {}, output.metadata);
874
+ if (normalizedOutput) {
875
+ result.push({
876
+ id: `output-${cellIndex}-${result.length}`,
877
+ type: normalizedOutput.type,
878
+ content: normalizedOutput.content,
879
+ timestamp,
880
+ mimeBundle: normalizedOutput.mimeBundle,
881
+ metadata: normalizedOutput.metadata,
882
+ preferredMimeType: normalizedOutput.preferredMimeType,
883
+ });
884
+ }
885
+ }
886
+ else if (output.output_type === 'error') {
887
+ const traceback = output.traceback || [];
888
+ result.push({
889
+ id: `output-${cellIndex}-${result.length}`,
890
+ type: 'error',
891
+ content: traceback.join(''),
892
+ timestamp,
893
+ });
894
+ }
895
+ }
896
+ return result;
897
+ }
898
+ /**
899
+ * Convert Nebula outputs back to Jupyter format
900
+ */
901
+ convertOutputsToJupyter(outputs) {
902
+ const result = [];
903
+ for (const output of outputs) {
904
+ if (output.type !== 'stdout' && output.type !== 'stderr' && output.type !== 'error' && output.mimeBundle) {
905
+ result.push({
906
+ output_type: 'display_data',
907
+ data: (0, display_data_1.convertMimeBundleToJupyter)(output.mimeBundle),
908
+ metadata: output.metadata || {},
909
+ });
910
+ continue;
911
+ }
912
+ if (output.type === 'stdout' || output.type === 'stderr') {
913
+ // Coalesce consecutive same-name streams into one entry (matches Jupyter behavior).
914
+ // This prevents tqdm progress bars from creating hundreds of output entries.
915
+ const prev = result[result.length - 1];
916
+ if (prev && prev.output_type === 'stream' && prev.name === output.type) {
917
+ // Append text to existing stream entry
918
+ const prevText = Array.isArray(prev.text) ? prev.text.join('') : prev.text;
919
+ prev.text = this.stringToSource(prevText + output.content);
920
+ }
921
+ else {
922
+ result.push({
923
+ output_type: 'stream',
924
+ name: output.type,
925
+ text: this.stringToSource(output.content),
926
+ });
927
+ }
928
+ }
929
+ else if (output.type === 'image') {
930
+ result.push({
931
+ output_type: 'display_data',
932
+ data: { 'image/png': output.content },
933
+ metadata: output.metadata || {},
934
+ });
935
+ }
936
+ else if (output.type === 'html') {
937
+ result.push({
938
+ output_type: 'display_data',
939
+ data: { 'text/html': output.content },
940
+ metadata: output.metadata || {},
941
+ });
942
+ }
943
+ else if (output.type === 'display_data') {
944
+ result.push({
945
+ output_type: 'display_data',
946
+ data: { 'text/plain': output.content },
947
+ metadata: output.metadata || {},
948
+ });
949
+ }
950
+ else if (output.type === 'error') {
951
+ result.push({
952
+ output_type: 'error',
953
+ ename: 'Error',
954
+ evalue: '',
955
+ traceback: this.stringToSource(output.content),
956
+ });
957
+ }
958
+ }
959
+ return result;
960
+ }
961
+ /**
962
+ * Read a notebook and convert to internal cell format
963
+ */
964
+ getNotebookCells(notebookPath) {
965
+ const normalizedPath = this.normalizePath(notebookPath);
966
+ if (!fs.existsSync(normalizedPath)) {
967
+ throw new Error(`Notebook not found: ${normalizedPath}`);
968
+ }
969
+ const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
970
+ const metadataKernel = notebook.metadata?.kernelspec?.name;
971
+ const kernelspec = metadataKernel || 'python3';
972
+ const cells = notebook.cells.map((nbCell, i) => {
973
+ let cellType = nbCell.cell_type === 'markdown' ? 'markdown' : 'code';
974
+ const content = this.sourceToString(nbCell.source);
975
+ const cellId = nbCell.metadata?.nebula_id || nbCell.id || `cell-${i}`;
976
+ const outputs = this.convertOutputs(nbCell.outputs, i);
977
+ const cell = {
978
+ id: cellId,
979
+ type: cellType,
980
+ content,
981
+ outputs,
982
+ isExecuting: false,
983
+ executionCount: nbCell.execution_count ?? null,
984
+ };
985
+ // Preserve scrolled state if set
986
+ if (nbCell.metadata?.scrolled !== undefined) {
987
+ cell.scrolled = nbCell.metadata.scrolled;
988
+ }
989
+ if (nbCell.metadata?.scrolled_height !== undefined) {
990
+ cell.scrolledHeight = nbCell.metadata.scrolled_height;
991
+ }
992
+ // Preserve unknown metadata
993
+ const unknownMetadata = {};
994
+ for (const key of Object.keys(nbCell.metadata || {})) {
995
+ if (!['nebula_id', 'scrolled', 'scrolled_height'].includes(key)) {
996
+ unknownMetadata[key] = nbCell.metadata[key];
997
+ }
998
+ }
999
+ if (Object.keys(unknownMetadata).length > 0) {
1000
+ cell._metadata = unknownMetadata;
1001
+ }
1002
+ return cell;
1003
+ });
1004
+ const stat = fs.statSync(normalizedPath);
1005
+ return {
1006
+ cells,
1007
+ metadata: notebook.metadata || {},
1008
+ kernelspec,
1009
+ kernelspecSource: metadataKernel ? 'metadata' : 'default',
1010
+ mtime: stat.mtimeMs / 1000,
1011
+ };
1012
+ }
1013
+ /**
1014
+ * Read a notebook and convert to internal cell format, resolving default kernel if needed
1015
+ */
1016
+ async getNotebookCellsWithKernel(notebookPath) {
1017
+ const result = this.getNotebookCells(notebookPath);
1018
+ if (result.kernelspecSource === 'metadata') {
1019
+ return result;
1020
+ }
1021
+ try {
1022
+ const defaultKernel = await (0, default_kernel_1.getDefaultKernelName)();
1023
+ if (defaultKernel) {
1024
+ return {
1025
+ ...result,
1026
+ kernelspec: defaultKernel,
1027
+ kernelspecSource: 'env-default',
1028
+ };
1029
+ }
1030
+ }
1031
+ catch (err) {
1032
+ console.warn('[FilesystemService] Failed to resolve default kernel:', err);
1033
+ }
1034
+ return result;
1035
+ }
1036
+ /**
1037
+ * Save cells to a notebook file
1038
+ */
1039
+ saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
1040
+ const normalizedPath = this.normalizePath(notebookPath);
1041
+ // Load existing notebook metadata if file exists.
1042
+ // Prefer a fast scan for the top-level metadata object so we avoid
1043
+ // parsing the full notebook on every save. Fall back to full JSON
1044
+ // parsing only for smaller notebooks when the fast path cannot find it.
1045
+ let existingMetadata = {};
1046
+ if (fs.existsSync(normalizedPath)) {
1047
+ try {
1048
+ const stat = fs.statSync(normalizedPath);
1049
+ const fd = fs.openSync(normalizedPath, 'r');
1050
+ const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
1051
+ const bytesRead = fs.readSync(fd, buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
1052
+ fs.closeSync(fd);
1053
+ const head = buf.toString('utf-8', 0, bytesRead);
1054
+ const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
1055
+ if (extractedMetadata) {
1056
+ existingMetadata = extractedMetadata;
1057
+ }
1058
+ else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
1059
+ const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1060
+ existingMetadata = notebook.metadata || {};
1061
+ }
1062
+ }
1063
+ catch {
1064
+ // Start fresh — metadata extraction failed
1065
+ }
1066
+ }
1067
+ kernelName = kernelName || 'python3';
1068
+ const displayName = kernelName === 'python3' ? 'Python 3' : kernelName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1069
+ const nbCells = cells.map((cell) => {
1070
+ const preservedMetadata = cell._metadata || {};
1071
+ const cellMetadata = {
1072
+ ...preservedMetadata,
1073
+ nebula_id: cell.id,
1074
+ };
1075
+ if (cell.scrolled !== undefined) {
1076
+ cellMetadata.scrolled = cell.scrolled;
1077
+ }
1078
+ if (cell.scrolledHeight !== undefined) {
1079
+ cellMetadata.scrolled_height = cell.scrolledHeight;
1080
+ }
1081
+ const nbCell = {
1082
+ cell_type: cell.type,
1083
+ source: this.stringToSource(cell.content),
1084
+ metadata: cellMetadata,
1085
+ };
1086
+ if (cell.type === 'code') {
1087
+ nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
1088
+ nbCell.execution_count = cell.executionCount;
1089
+ }
1090
+ return nbCell;
1091
+ });
1092
+ // Build final metadata
1093
+ const finalMetadata = {
1094
+ ...existingMetadata,
1095
+ kernelspec: {
1096
+ display_name: displayName,
1097
+ language: 'python',
1098
+ name: kernelName,
1099
+ },
1100
+ language_info: {
1101
+ name: 'python',
1102
+ version: '3.11',
1103
+ },
1104
+ };
1105
+ // Merge custom notebook metadata
1106
+ if (notebookMetadata) {
1107
+ for (const [key, value] of Object.entries(notebookMetadata)) {
1108
+ if (typeof value === 'object' && value !== null && typeof finalMetadata[key] === 'object' && finalMetadata[key] !== null) {
1109
+ // Deep merge for dict values
1110
+ finalMetadata[key] = { ...finalMetadata[key], ...value };
1111
+ }
1112
+ else {
1113
+ finalMetadata[key] = value;
1114
+ }
1115
+ }
1116
+ }
1117
+ const notebook = {
1118
+ metadata: finalMetadata,
1119
+ nbformat: 4,
1120
+ nbformat_minor: 5,
1121
+ cells: nbCells,
1122
+ };
1123
+ // Create parent directory if needed
1124
+ const parentDir = path.dirname(normalizedPath);
1125
+ if (!fs.existsSync(parentDir)) {
1126
+ fs.mkdirSync(parentDir, { recursive: true });
1127
+ }
1128
+ this.writeJsonAtomicSync(normalizedPath, notebook);
1129
+ const stat = fs.statSync(normalizedPath);
1130
+ return {
1131
+ success: true,
1132
+ mtime: stat.mtimeMs / 1000,
1133
+ };
1134
+ }
1135
+ /**
1136
+ * Save notebook cells and history in a single crash-safe commit.
1137
+ * Uses a small journal + atomic writes to avoid partial files.
1138
+ */
1139
+ async saveNotebookBundle(notebookPath, cells, kernelName, history, session, notebookMetadata) {
1140
+ return await this.withWriteLock(notebookPath, async () => {
1141
+ const normalizedPath = this.normalizePath(notebookPath);
1142
+ const historyPath = history ? this.getHistoryPath(notebookPath) : undefined;
1143
+ const sessionPath = session ? this.getSessionPath(notebookPath) : undefined;
1144
+ const journalPath = this.getJournalPath(notebookPath);
1145
+ const txId = crypto.randomUUID();
1146
+ const journal = {
1147
+ version: 1,
1148
+ txId,
1149
+ notebookPath: normalizedPath,
1150
+ status: 'begin',
1151
+ startedAt: Date.now(),
1152
+ files: {
1153
+ notebook: normalizedPath,
1154
+ ...(historyPath ? { history: historyPath } : {}),
1155
+ ...(sessionPath ? { session: sessionPath } : {}),
1156
+ },
1157
+ };
1158
+ // Ensure .nebula directory exists for journal/history/session
1159
+ const { nebulaDir } = this.getNebulaPaths(notebookPath);
1160
+ if (!fs.existsSync(nebulaDir)) {
1161
+ fs.mkdirSync(nebulaDir, { recursive: true });
1162
+ }
1163
+ this.writeJsonAtomicSync(journalPath, journal);
1164
+ if (historyPath) {
1165
+ this.writeJsonAtomicSync(historyPath, history || []);
1166
+ }
1167
+ if (sessionPath) {
1168
+ this.writeJsonAtomicSync(sessionPath, session || {});
1169
+ }
1170
+ const result = this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
1171
+ const committedJournal = {
1172
+ ...journal,
1173
+ status: 'commit',
1174
+ committedAt: Date.now(),
1175
+ };
1176
+ this.writeJsonAtomicSync(journalPath, committedJournal);
1177
+ try {
1178
+ fs.unlinkSync(journalPath);
1179
+ }
1180
+ catch {
1181
+ // Ignore cleanup errors; journal can be inspected if needed
1182
+ }
1183
+ return result;
1184
+ });
1185
+ }
1186
+ /**
1187
+ * Get notebook-level metadata
1188
+ */
1189
+ getNotebookMetadata(notebookPath) {
1190
+ const normalizedPath = this.normalizePath(notebookPath);
1191
+ if (!fs.existsSync(normalizedPath)) {
1192
+ return {};
1193
+ }
1194
+ try {
1195
+ const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1196
+ return notebook.metadata || {};
1197
+ }
1198
+ catch {
1199
+ return {};
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Update notebook-level metadata without modifying cells
1204
+ */
1205
+ async updateNotebookMetadata(notebookPath, metadataUpdates) {
1206
+ return await this.withWriteLock(notebookPath, async () => {
1207
+ const normalizedPath = this.normalizePath(notebookPath);
1208
+ if (!fs.existsSync(normalizedPath)) {
1209
+ return { success: false, error: `Notebook not found: ${normalizedPath}` };
1210
+ }
1211
+ try {
1212
+ const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1213
+ const existingMetadata = notebook.metadata || {};
1214
+ const nextMetadata = { ...existingMetadata };
1215
+ for (const [key, value] of Object.entries(metadataUpdates)) {
1216
+ if (typeof value === 'object' && value !== null && typeof nextMetadata[key] === 'object' && nextMetadata[key] !== null) {
1217
+ nextMetadata[key] = {
1218
+ ...nextMetadata[key],
1219
+ ...value,
1220
+ };
1221
+ }
1222
+ else {
1223
+ nextMetadata[key] = value;
1224
+ }
1225
+ }
1226
+ if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
1227
+ const stat = fs.statSync(normalizedPath);
1228
+ return {
1229
+ success: true,
1230
+ changed: false,
1231
+ mtime: stat.mtimeMs / 1000,
1232
+ };
1233
+ }
1234
+ notebook.metadata = nextMetadata;
1235
+ this.writeJsonAtomicSync(normalizedPath, notebook);
1236
+ const stat = fs.statSync(normalizedPath);
1237
+ return {
1238
+ success: true,
1239
+ changed: true,
1240
+ mtime: stat.mtimeMs / 1000,
1241
+ };
1242
+ }
1243
+ catch (e) {
1244
+ return { success: false, error: `Failed to update notebook: ${e}` };
1245
+ }
1246
+ });
1247
+ }
1248
+ stripOutputsForHistorySnapshot(cell) {
1249
+ const snapshot = {
1250
+ ...cell,
1251
+ outputs: [],
1252
+ isExecuting: false,
1253
+ pendingOutputReset: undefined,
1254
+ };
1255
+ return snapshot;
1256
+ }
1257
+ buildInitialHistory(notebookPath) {
1258
+ const { cells } = this.getNotebookCells(notebookPath);
1259
+ return [{
1260
+ type: 'snapshot',
1261
+ cells: cells.map(cell => this.stripOutputsForHistorySnapshot(cell)),
1262
+ timestamp: Date.now(),
1263
+ }];
1264
+ }
1265
+ /**
1266
+ * Set agent permission and ensure newly-permitted notebooks are immediately editable.
1267
+ */
1268
+ async setAgentPermission(notebookPath, permitted) {
1269
+ return await this.withWriteLock(notebookPath, async () => {
1270
+ const normalizedPath = this.normalizePath(notebookPath);
1271
+ if (!fs.existsSync(normalizedPath)) {
1272
+ return { success: false, error: `Notebook not found: ${normalizedPath}` };
1273
+ }
1274
+ try {
1275
+ const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1276
+ const metadata = notebook.metadata || {};
1277
+ const nebula = (metadata.nebula || {});
1278
+ notebook.metadata = {
1279
+ ...metadata,
1280
+ nebula: {
1281
+ ...nebula,
1282
+ agent_permitted: permitted,
1283
+ },
1284
+ };
1285
+ this.writeJsonAtomicSync(normalizedPath, notebook);
1286
+ if (permitted && !this.hasHistory(notebookPath)) {
1287
+ const historyPath = this.getHistoryPath(notebookPath);
1288
+ const nebulaDir = path.dirname(historyPath);
1289
+ if (!fs.existsSync(nebulaDir)) {
1290
+ fs.mkdirSync(nebulaDir, { recursive: true });
1291
+ }
1292
+ this.writeJsonAtomicSync(historyPath, this.buildInitialHistory(notebookPath));
1293
+ }
1294
+ return {
1295
+ success: true,
1296
+ status: this.getAgentPermissionStatus(notebookPath),
1297
+ };
1298
+ }
1299
+ catch (e) {
1300
+ return { success: false, error: `Failed to set agent permission: ${e}` };
1301
+ }
1302
+ });
1303
+ }
1304
+ /**
1305
+ * Derive agent permission status from persisted notebook metadata.
1306
+ */
1307
+ getAgentPermissionStatus(notebookPath) {
1308
+ const metadata = this.getNotebookMetadata(notebookPath);
1309
+ const nebula = (metadata.nebula || {});
1310
+ const hasHistory = this.hasHistory(notebookPath);
1311
+ const agentCreated = Boolean(nebula.agent_created);
1312
+ const agentPermitted = Boolean(nebula.agent_permitted);
1313
+ const canModify = agentCreated || (agentPermitted && hasHistory);
1314
+ return {
1315
+ agent_created: agentCreated,
1316
+ agent_permitted: agentPermitted,
1317
+ has_history: hasHistory,
1318
+ can_agent_modify: canModify,
1319
+ reason: agentCreated
1320
+ ? 'Agent created this notebook'
1321
+ : canModify
1322
+ ? 'User permitted and history enabled'
1323
+ : agentPermitted
1324
+ ? 'User permitted but history not enabled'
1325
+ : 'Not permitted for agent modifications',
1326
+ };
1327
+ }
1328
+ /**
1329
+ * Check if a notebook is permitted for agent modifications
1330
+ */
1331
+ isAgentPermitted(notebookPath) {
1332
+ const status = this.getAgentPermissionStatus(notebookPath);
1333
+ return status.agent_created || status.agent_permitted;
1334
+ }
1335
+ /**
1336
+ * Check if a notebook has history tracking enabled
1337
+ */
1338
+ hasHistory(notebookPath) {
1339
+ const historyPath = this.getHistoryPath(notebookPath);
1340
+ if (!fs.existsSync(historyPath)) {
1341
+ return false;
1342
+ }
1343
+ try {
1344
+ const history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1345
+ return Array.isArray(history) && history.length > 0;
1346
+ }
1347
+ catch {
1348
+ return false;
1349
+ }
1350
+ }
1351
+ /**
1352
+ * Save operation history for a notebook
1353
+ */
1354
+ async saveHistory(notebookPath, history) {
1355
+ const historyPath = this.getHistoryPath(notebookPath);
1356
+ // Create .nebula directory if needed
1357
+ const nebulaDir = path.dirname(historyPath);
1358
+ if (!fs.existsSync(nebulaDir)) {
1359
+ fs.mkdirSync(nebulaDir, { recursive: true });
1360
+ }
1361
+ await this.withWriteLock(notebookPath, async () => {
1362
+ this.writeJsonAtomicSync(historyPath, history);
1363
+ });
1364
+ return true;
1365
+ }
1366
+ /**
1367
+ * Load operation history for a notebook
1368
+ */
1369
+ loadHistory(notebookPath) {
1370
+ const historyPath = this.getHistoryPath(notebookPath);
1371
+ if (this.hasPendingCommit(notebookPath)) {
1372
+ console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
1373
+ return [];
1374
+ }
1375
+ if (!fs.existsSync(historyPath)) {
1376
+ return [];
1377
+ }
1378
+ try {
1379
+ return JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1380
+ }
1381
+ catch {
1382
+ return [];
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Save session state for a notebook
1387
+ */
1388
+ async saveSession(notebookPath, session) {
1389
+ const sessionPath = this.getSessionPath(notebookPath);
1390
+ // Create .nebula directory if needed
1391
+ const nebulaDir = path.dirname(sessionPath);
1392
+ if (!fs.existsSync(nebulaDir)) {
1393
+ fs.mkdirSync(nebulaDir, { recursive: true });
1394
+ }
1395
+ await this.withWriteLock(notebookPath, async () => {
1396
+ this.writeJsonAtomicSync(sessionPath, session);
1397
+ });
1398
+ return true;
1399
+ }
1400
+ /**
1401
+ * Load session state for a notebook
1402
+ */
1403
+ loadSession(notebookPath) {
1404
+ const sessionPath = this.getSessionPath(notebookPath);
1405
+ if (this.hasPendingCommit(notebookPath)) {
1406
+ console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping session load.`);
1407
+ return {};
1408
+ }
1409
+ if (!fs.existsSync(sessionPath)) {
1410
+ return {};
1411
+ }
1412
+ try {
1413
+ return JSON.parse(fs.readFileSync(sessionPath, 'utf-8'));
1414
+ }
1415
+ catch {
1416
+ return {};
1417
+ }
1418
+ }
1419
+ }
1420
+ exports.FilesystemService = FilesystemService;
1421
+ // Global instance with default configuration
1422
+ exports.fsService = new FilesystemService();