nebula-notebook 0.1.1 → 0.2.1
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 +110 -16
- package/dist/assets/errorwidget-Wy5ajPiM.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-B_Bs5koo.js +1 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/index-DeQN03nW.js +81 -0
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/index-g6MjT-VG.js +1 -0
- package/dist/assets/services-shim-BirHVsih.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/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +38 -2
- package/node-server/dist/cluster/kernel-proxy.js +43 -12
- package/node-server/dist/cluster/server-registry.d.ts +17 -0
- package/node-server/dist/cluster/server-registry.js +57 -7
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +86 -8
- package/node-server/dist/fs/fs-service.js +640 -99
- 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/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -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 +198 -8
- package/node-server/dist/kernel/kernel-service.d.ts +130 -2
- package/node-server/dist/kernel/kernel-service.js +834 -64
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +12 -0
- package/node-server/dist/notebook/headless-handler.js +125 -29
- package/node-server/dist/notebook/operation-router.js +7 -2
- package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
- package/node-server/dist/notebook/undoRedoManager.js +49 -18
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -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 +23 -6
- package/node-server/dist/routes/kernel.js +156 -3
- package/node-server/dist/routes/notebook.js +37 -3
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
- package/node-server/dist/scheduler/allocation-service.js +285 -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 +125 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
- package/node-server/dist/scheduler/types.d.ts +118 -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/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +138 -19
- package/node-server/dist/terminal/server.js +155 -23
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +2 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +18 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -40,11 +40,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
40
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
41
|
exports.fsService = exports.FilesystemService = void 0;
|
|
42
42
|
const fs = __importStar(require("fs"));
|
|
43
|
+
const fs_1 = require("fs");
|
|
43
44
|
const path = __importStar(require("path"));
|
|
44
45
|
const os = __importStar(require("os"));
|
|
45
46
|
const crypto = __importStar(require("crypto"));
|
|
46
47
|
const util_1 = require("util");
|
|
48
|
+
const types_1 = require("./types");
|
|
47
49
|
const default_kernel_1 = require("../kernel/default-kernel");
|
|
50
|
+
const registry_1 = require("./notebook-formats/registry");
|
|
51
|
+
const percent_1 = require("./notebook-formats/percent");
|
|
48
52
|
const display_data_1 = require("../output/display-data");
|
|
49
53
|
const NEBULA_DIR = path.join(os.homedir(), '.nebula');
|
|
50
54
|
const USER_CONFIG_PATH = path.join(NEBULA_DIR, 'config.json');
|
|
@@ -345,6 +349,52 @@ class FilesystemService {
|
|
|
345
349
|
writeJsonAtomicSync(targetPath, payload) {
|
|
346
350
|
this.atomicWriteFileSync(targetPath, JSON.stringify(payload, null, 2));
|
|
347
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Async variant of atomicWriteFileSync (write temp, fsync, rename, fsync dir).
|
|
354
|
+
* Used on hot notebook-save paths so large writes never block the event loop.
|
|
355
|
+
*
|
|
356
|
+
* `durable: false` skips both fsyncs while keeping the tmp+rename atomicity.
|
|
357
|
+
* Use it for auxiliary files (history/session/journal/lastsave): on network
|
|
358
|
+
* filesystems (GPFS) each fsync can take seconds, and a save doing ~10 of
|
|
359
|
+
* them was observed at 20-27s. A crash can lose the last few seconds of an
|
|
360
|
+
* auxiliary file, but never corrupt it — the rename is still atomic. The
|
|
361
|
+
* notebook file itself stays fully durable.
|
|
362
|
+
*/
|
|
363
|
+
async atomicWriteFile(targetPath, data, opts = {}) {
|
|
364
|
+
const durable = opts.durable !== false;
|
|
365
|
+
const dir = path.dirname(targetPath);
|
|
366
|
+
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
367
|
+
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
|
368
|
+
const tmpPath = path.join(dir, tmpName);
|
|
369
|
+
const handle = await fs_1.promises.open(tmpPath, 'w', 0o600);
|
|
370
|
+
try {
|
|
371
|
+
await handle.writeFile(data, 'utf-8');
|
|
372
|
+
if (durable)
|
|
373
|
+
await handle.sync();
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
await handle.close();
|
|
377
|
+
}
|
|
378
|
+
await fs_1.promises.rename(tmpPath, targetPath);
|
|
379
|
+
if (!durable)
|
|
380
|
+
return;
|
|
381
|
+
// Best-effort directory fsync
|
|
382
|
+
try {
|
|
383
|
+
const dirHandle = await fs_1.promises.open(dir, 'r');
|
|
384
|
+
try {
|
|
385
|
+
await dirHandle.sync();
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await dirHandle.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// Ignore fsync errors on directory handles
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async writeJsonAtomic(targetPath, payload, opts = {}) {
|
|
396
|
+
await this.atomicWriteFile(targetPath, JSON.stringify(payload, null, 2), opts);
|
|
397
|
+
}
|
|
348
398
|
/**
|
|
349
399
|
* Format file size for display
|
|
350
400
|
*/
|
|
@@ -367,10 +417,10 @@ class FilesystemService {
|
|
|
367
417
|
*/
|
|
368
418
|
getFileType(extension) {
|
|
369
419
|
const ext = extension.toLowerCase();
|
|
370
|
-
if (ext === '.ipynb') {
|
|
420
|
+
if (ext === '.ipynb' || ext === '.qmd') {
|
|
371
421
|
return 'notebook';
|
|
372
422
|
}
|
|
373
|
-
else if (['.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
|
|
423
|
+
else if (['.py', '.r', '.jl', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
|
|
374
424
|
return 'code';
|
|
375
425
|
}
|
|
376
426
|
else if (['.csv', '.tsv', '.xlsx', '.xls'].includes(ext)) {
|
|
@@ -400,6 +450,21 @@ class FilesystemService {
|
|
|
400
450
|
extension: isDir ? '' : path.extname(name),
|
|
401
451
|
};
|
|
402
452
|
}
|
|
453
|
+
/** Async, non-blocking variant of getFileInfo (one `fsp.stat`). Used by
|
|
454
|
+
* listDirectory so a folder's entries stat in parallel off the event loop. */
|
|
455
|
+
async getFileInfoAsync(filePath) {
|
|
456
|
+
const stat = await fs_1.promises.stat(filePath);
|
|
457
|
+
const name = path.basename(filePath);
|
|
458
|
+
const isDir = stat.isDirectory();
|
|
459
|
+
return {
|
|
460
|
+
name,
|
|
461
|
+
path: filePath,
|
|
462
|
+
isDirectory: isDir,
|
|
463
|
+
size: isDir ? 0 : stat.size,
|
|
464
|
+
modified: stat.mtimeMs / 1000,
|
|
465
|
+
extension: isDir ? '' : path.extname(name),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
403
468
|
/**
|
|
404
469
|
* Convert FileInfo to FileInfoResponse for API
|
|
405
470
|
*/
|
|
@@ -436,12 +501,16 @@ class FilesystemService {
|
|
|
436
501
|
/**
|
|
437
502
|
* Get file modification time
|
|
438
503
|
*/
|
|
439
|
-
getFileMtime(filePath) {
|
|
504
|
+
async getFileMtime(filePath) {
|
|
440
505
|
const normalizedPath = this.normalizePath(filePath);
|
|
441
|
-
|
|
506
|
+
let stat;
|
|
507
|
+
try {
|
|
508
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Matches the sync existsSync() behavior: any stat failure reads as missing
|
|
442
512
|
throw new Error(`File not found: ${normalizedPath}`);
|
|
443
513
|
}
|
|
444
|
-
const stat = fs.statSync(normalizedPath);
|
|
445
514
|
return {
|
|
446
515
|
path: normalizedPath,
|
|
447
516
|
mtime: stat.mtimeMs / 1000,
|
|
@@ -450,32 +519,35 @@ class FilesystemService {
|
|
|
450
519
|
/**
|
|
451
520
|
* List contents of a directory
|
|
452
521
|
*/
|
|
453
|
-
listDirectory(dirPath) {
|
|
522
|
+
async listDirectory(dirPath) {
|
|
454
523
|
const normalizedPath = this.normalizePath(dirPath);
|
|
455
|
-
|
|
524
|
+
let stat;
|
|
525
|
+
try {
|
|
526
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
456
529
|
throw new Error(`Path not found: ${normalizedPath}`);
|
|
457
530
|
}
|
|
458
|
-
const stat = fs.statSync(normalizedPath);
|
|
459
531
|
if (!stat.isDirectory()) {
|
|
460
532
|
throw new Error(`Not a directory: ${normalizedPath}`);
|
|
461
533
|
}
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
534
|
+
const entries = await fs_1.promises.readdir(normalizedPath);
|
|
535
|
+
// Stat every visible entry in PARALLEL rather than one blocking statSync at a
|
|
536
|
+
// time. On a network filesystem a serial loop stalls the single event
|
|
537
|
+
// loop for N × stat-latency, delaying every other in-flight request (kernel
|
|
538
|
+
// starts, terminal creation, saves) — the source of the "everything hangs
|
|
539
|
+
// while a folder lists" latency.
|
|
540
|
+
const settled = await Promise.all(entries
|
|
541
|
+
.filter((name) => !name.startsWith('.')) // skip hidden files
|
|
542
|
+
.map(async (name) => {
|
|
470
543
|
try {
|
|
471
|
-
|
|
472
|
-
items.push(this.toFileInfoResponse(info));
|
|
544
|
+
return this.toFileInfoResponse(await this.getFileInfoAsync(path.join(normalizedPath, name)));
|
|
473
545
|
}
|
|
474
546
|
catch {
|
|
475
|
-
//
|
|
476
|
-
continue;
|
|
547
|
+
return null; // skip entries we can't access
|
|
477
548
|
}
|
|
478
|
-
}
|
|
549
|
+
}));
|
|
550
|
+
const items = settled.filter((x) => x !== null);
|
|
479
551
|
// Sort: directories first, then by name
|
|
480
552
|
items.sort((a, b) => {
|
|
481
553
|
if (a.isDirectory !== b.isDirectory) {
|
|
@@ -493,18 +565,22 @@ class FilesystemService {
|
|
|
493
565
|
/**
|
|
494
566
|
* Read a file's contents
|
|
495
567
|
*/
|
|
496
|
-
readFile(filePath) {
|
|
568
|
+
async readFile(filePath) {
|
|
497
569
|
const normalizedPath = this.normalizePath(filePath);
|
|
498
|
-
|
|
570
|
+
let stat;
|
|
571
|
+
try {
|
|
572
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Matches the sync existsSync() behavior: any stat failure reads as missing
|
|
499
576
|
throw new Error(`File not found: ${normalizedPath}`);
|
|
500
577
|
}
|
|
501
|
-
const stat = fs.statSync(normalizedPath);
|
|
502
578
|
if (stat.isDirectory()) {
|
|
503
579
|
throw new Error(`Path is a directory: ${normalizedPath}`);
|
|
504
580
|
}
|
|
505
581
|
const extension = path.extname(normalizedPath).toLowerCase();
|
|
506
582
|
if (extension === '.ipynb') {
|
|
507
|
-
const content = JSON.parse(
|
|
583
|
+
const content = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
508
584
|
return {
|
|
509
585
|
path: normalizedPath,
|
|
510
586
|
type: 'notebook',
|
|
@@ -513,7 +589,7 @@ class FilesystemService {
|
|
|
513
589
|
}
|
|
514
590
|
// Try to read as text
|
|
515
591
|
try {
|
|
516
|
-
const content =
|
|
592
|
+
const content = await fs_1.promises.readFile(normalizedPath, 'utf-8');
|
|
517
593
|
return {
|
|
518
594
|
path: normalizedPath,
|
|
519
595
|
type: 'text',
|
|
@@ -582,6 +658,16 @@ class FilesystemService {
|
|
|
582
658
|
};
|
|
583
659
|
this.writeJsonAtomicSync(normalizedPath, notebook);
|
|
584
660
|
}
|
|
661
|
+
else if (extension === '.qmd') {
|
|
662
|
+
// Text-format notebook template (NOT .py: creating a .py must stay a
|
|
663
|
+
// plain empty script — percent parsing only applies when one is
|
|
664
|
+
// opened as a notebook)
|
|
665
|
+
const adapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
666
|
+
this.atomicWriteFileSync(normalizedPath, adapter.serialize([], {
|
|
667
|
+
kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
|
|
668
|
+
__qmd_language: 'python',
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
585
671
|
else {
|
|
586
672
|
// Create empty file
|
|
587
673
|
fs.writeFileSync(normalizedPath, '', 'utf-8');
|
|
@@ -597,6 +683,31 @@ class FilesystemService {
|
|
|
597
683
|
const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
|
|
598
684
|
return path.join(nebulaDir, `${nameWithoutExt}.history.json`);
|
|
599
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Last-Nebula-save record for text-format notebooks: the canonical cell
|
|
688
|
+
* state (id/type/content) as of the last save THROUGH Nebula. At history
|
|
689
|
+
* load, divergence between this record and the file means the notebook was
|
|
690
|
+
* edited externally (vim, git checkout, ...) — the difference is
|
|
691
|
+
* reconciled into history as a synthesized external-edit operation so
|
|
692
|
+
* undo/redo stays infinite across external edits.
|
|
693
|
+
*/
|
|
694
|
+
getLastSavePath(notebookPath) {
|
|
695
|
+
const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
|
|
696
|
+
return path.join(nebulaDir, `${nameWithoutExt}.lastsave.json`);
|
|
697
|
+
}
|
|
698
|
+
async writeTextNotebookLastSave(notebookPath, serializedText, adapter) {
|
|
699
|
+
try {
|
|
700
|
+
// Record the POST-round-trip identity (parse of what was written), so
|
|
701
|
+
// positional ids (qmd markdown cells) never read as false divergence.
|
|
702
|
+
const canonical = adapter.parse(serializedText).cells;
|
|
703
|
+
await this.writeJsonAtomic(this.getLastSavePath(notebookPath), {
|
|
704
|
+
cells: canonical.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
705
|
+
}, { durable: false });
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
console.warn('[FilesystemService] Failed to write lastsave record:', e);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
600
711
|
/**
|
|
601
712
|
* Get the session state file path for a notebook
|
|
602
713
|
*/
|
|
@@ -632,7 +743,13 @@ class FilesystemService {
|
|
|
632
743
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
633
744
|
const parentDir = path.dirname(normalizedPath);
|
|
634
745
|
const notebookName = path.basename(normalizedPath);
|
|
635
|
-
|
|
746
|
+
// .ipynb sidecar names are extension-stripped (unchanged, pre-existing
|
|
747
|
+
// files keep working). Text-format notebooks keep their full basename so
|
|
748
|
+
// foo.py and foo.ipynb in one directory never share sidecars.
|
|
749
|
+
const ext = path.extname(notebookName).toLowerCase();
|
|
750
|
+
const nameWithoutExt = ext === '.ipynb'
|
|
751
|
+
? path.basename(notebookName, path.extname(notebookName))
|
|
752
|
+
: notebookName;
|
|
636
753
|
const nebulaDir = path.join(parentDir, '.nebula');
|
|
637
754
|
return { nebulaDir, nameWithoutExt };
|
|
638
755
|
}
|
|
@@ -643,6 +760,10 @@ class FilesystemService {
|
|
|
643
760
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
644
761
|
const sessionPath = this.getSessionPath(notebookPath);
|
|
645
762
|
const journalPath = this.getJournalPath(notebookPath);
|
|
763
|
+
const lastSavePath = this.getLastSavePath(notebookPath);
|
|
764
|
+
if (fs.existsSync(lastSavePath)) {
|
|
765
|
+
fs.unlinkSync(lastSavePath);
|
|
766
|
+
}
|
|
646
767
|
if (fs.existsSync(historyPath)) {
|
|
647
768
|
fs.unlinkSync(historyPath);
|
|
648
769
|
}
|
|
@@ -662,9 +783,8 @@ class FilesystemService {
|
|
|
662
783
|
throw new Error(`Path not found: ${normalizedPath}`);
|
|
663
784
|
}
|
|
664
785
|
// For notebooks, also delete history and session files
|
|
665
|
-
const ext = path.extname(normalizedPath).toLowerCase();
|
|
666
786
|
const stat = fs.statSync(normalizedPath);
|
|
667
|
-
if (
|
|
787
|
+
if ((0, registry_1.isNotebookPath)(normalizedPath) && !stat.isDirectory()) {
|
|
668
788
|
this.deleteNotebookMetadata(normalizedPath);
|
|
669
789
|
}
|
|
670
790
|
if (stat.isDirectory()) {
|
|
@@ -699,6 +819,10 @@ class FilesystemService {
|
|
|
699
819
|
// Journal files are ephemeral; remove any stale commit record on rename
|
|
700
820
|
fs.unlinkSync(oldJournal);
|
|
701
821
|
}
|
|
822
|
+
const oldLastSave = this.getLastSavePath(oldPath);
|
|
823
|
+
if (fs.existsSync(oldLastSave)) {
|
|
824
|
+
fs.renameSync(oldLastSave, this.getLastSavePath(newPath));
|
|
825
|
+
}
|
|
702
826
|
}
|
|
703
827
|
/**
|
|
704
828
|
* Rename/move a file or directory
|
|
@@ -713,8 +837,7 @@ class FilesystemService {
|
|
|
713
837
|
throw new Error(`Destination already exists: ${normalizedNew}`);
|
|
714
838
|
}
|
|
715
839
|
// For notebooks, also rename history and session files
|
|
716
|
-
|
|
717
|
-
if (ext === '.ipynb') {
|
|
840
|
+
if ((0, registry_1.isNotebookPath)(normalizedOld)) {
|
|
718
841
|
this.renameNotebookMetadata(normalizedOld, normalizedNew);
|
|
719
842
|
}
|
|
720
843
|
fs.renameSync(normalizedOld, normalizedNew);
|
|
@@ -728,6 +851,14 @@ class FilesystemService {
|
|
|
728
851
|
const destHistory = this.getHistoryPath(destPath);
|
|
729
852
|
const srcSession = this.getSessionPath(srcPath);
|
|
730
853
|
const destSession = this.getSessionPath(destPath);
|
|
854
|
+
const srcLastSave = this.getLastSavePath(srcPath);
|
|
855
|
+
if (fs.existsSync(srcLastSave)) {
|
|
856
|
+
const destNebulaDirForLastSave = path.dirname(destHistory);
|
|
857
|
+
if (!fs.existsSync(destNebulaDirForLastSave)) {
|
|
858
|
+
fs.mkdirSync(destNebulaDirForLastSave, { recursive: true });
|
|
859
|
+
}
|
|
860
|
+
fs.copyFileSync(srcLastSave, this.getLastSavePath(destPath));
|
|
861
|
+
}
|
|
731
862
|
// Create destination .nebula directory if needed
|
|
732
863
|
const destNebulaDir = path.dirname(destHistory);
|
|
733
864
|
if (!fs.existsSync(destNebulaDir)) {
|
|
@@ -758,7 +889,7 @@ class FilesystemService {
|
|
|
758
889
|
}
|
|
759
890
|
fs.copyFileSync(srcPath, destPath);
|
|
760
891
|
// For notebooks, also duplicate history and session files
|
|
761
|
-
if (
|
|
892
|
+
if ((0, registry_1.isNotebookPath)(entry.name)) {
|
|
762
893
|
this.duplicateNotebookMetadata(srcPath, destPath);
|
|
763
894
|
}
|
|
764
895
|
}
|
|
@@ -794,7 +925,7 @@ class FilesystemService {
|
|
|
794
925
|
// Copy the file
|
|
795
926
|
fs.copyFileSync(normalizedPath, newPath);
|
|
796
927
|
// For notebooks, also duplicate history and session files
|
|
797
|
-
if (
|
|
928
|
+
if ((0, registry_1.isNotebookPath)(normalizedPath)) {
|
|
798
929
|
this.duplicateNotebookMetadata(normalizedPath, newPath);
|
|
799
930
|
}
|
|
800
931
|
}
|
|
@@ -959,14 +1090,58 @@ class FilesystemService {
|
|
|
959
1090
|
return result;
|
|
960
1091
|
}
|
|
961
1092
|
/**
|
|
962
|
-
* Read a notebook and convert to internal cell format
|
|
1093
|
+
* Read a notebook and convert to internal cell format (async; hot path for
|
|
1094
|
+
* notebook load — large .ipynb reads must not block the event loop)
|
|
1095
|
+
*/
|
|
1096
|
+
async getNotebookCells(notebookPath) {
|
|
1097
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1098
|
+
let raw;
|
|
1099
|
+
let stat;
|
|
1100
|
+
try {
|
|
1101
|
+
[raw, stat] = await Promise.all([
|
|
1102
|
+
fs_1.promises.readFile(normalizedPath, 'utf-8'),
|
|
1103
|
+
fs_1.promises.stat(normalizedPath),
|
|
1104
|
+
]);
|
|
1105
|
+
}
|
|
1106
|
+
catch (err) {
|
|
1107
|
+
if (err?.code === 'ENOENT') {
|
|
1108
|
+
throw new Error(`Notebook not found: ${normalizedPath}`);
|
|
1109
|
+
}
|
|
1110
|
+
throw err;
|
|
1111
|
+
}
|
|
1112
|
+
return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Sync variant of getNotebookCells. Kept for cold callers that need a
|
|
1116
|
+
* synchronous read (headless cache warm-up, initial-history bootstrap).
|
|
963
1117
|
*/
|
|
964
|
-
|
|
1118
|
+
getNotebookCellsSync(notebookPath) {
|
|
965
1119
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
966
1120
|
if (!fs.existsSync(normalizedPath)) {
|
|
967
1121
|
throw new Error(`Notebook not found: ${normalizedPath}`);
|
|
968
1122
|
}
|
|
969
|
-
const
|
|
1123
|
+
const raw = fs.readFileSync(normalizedPath, 'utf-8');
|
|
1124
|
+
const stat = fs.statSync(normalizedPath);
|
|
1125
|
+
return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Convert raw notebook file content to the internal cell format (CPU only).
|
|
1129
|
+
*/
|
|
1130
|
+
buildNotebookCellsResponse(normalizedPath, raw, mtimeMs) {
|
|
1131
|
+
// Text notebook formats (.py percent / .qmd): adapter path. The .ipynb
|
|
1132
|
+
// body below is untouched.
|
|
1133
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1134
|
+
if (formatAdapter) {
|
|
1135
|
+
const parsed = formatAdapter.parse(raw);
|
|
1136
|
+
return {
|
|
1137
|
+
cells: parsed.cells,
|
|
1138
|
+
metadata: parsed.metadata,
|
|
1139
|
+
kernelspec: parsed.kernelspecName || (0, registry_1.defaultKernelForPath)(normalizedPath),
|
|
1140
|
+
kernelspecSource: parsed.kernelspecName ? 'metadata' : 'default',
|
|
1141
|
+
mtime: mtimeMs / 1000,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
const notebook = JSON.parse(raw);
|
|
970
1145
|
const metadataKernel = notebook.metadata?.kernelspec?.name;
|
|
971
1146
|
const kernelspec = metadataKernel || 'python3';
|
|
972
1147
|
const cells = notebook.cells.map((nbCell, i) => {
|
|
@@ -1001,20 +1176,19 @@ class FilesystemService {
|
|
|
1001
1176
|
}
|
|
1002
1177
|
return cell;
|
|
1003
1178
|
});
|
|
1004
|
-
const stat = fs.statSync(normalizedPath);
|
|
1005
1179
|
return {
|
|
1006
1180
|
cells,
|
|
1007
1181
|
metadata: notebook.metadata || {},
|
|
1008
1182
|
kernelspec,
|
|
1009
1183
|
kernelspecSource: metadataKernel ? 'metadata' : 'default',
|
|
1010
|
-
mtime:
|
|
1184
|
+
mtime: mtimeMs / 1000,
|
|
1011
1185
|
};
|
|
1012
1186
|
}
|
|
1013
1187
|
/**
|
|
1014
1188
|
* Read a notebook and convert to internal cell format, resolving default kernel if needed
|
|
1015
1189
|
*/
|
|
1016
1190
|
async getNotebookCellsWithKernel(notebookPath) {
|
|
1017
|
-
const result = this.getNotebookCells(notebookPath);
|
|
1191
|
+
const result = await this.getNotebookCells(notebookPath);
|
|
1018
1192
|
if (result.kernelspecSource === 'metadata') {
|
|
1019
1193
|
return result;
|
|
1020
1194
|
}
|
|
@@ -1036,36 +1210,66 @@ class FilesystemService {
|
|
|
1036
1210
|
/**
|
|
1037
1211
|
* Save cells to a notebook file
|
|
1038
1212
|
*/
|
|
1039
|
-
saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
|
|
1213
|
+
async saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
|
|
1040
1214
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1215
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1216
|
+
if (formatAdapter) {
|
|
1217
|
+
return await this.saveTextNotebookCells(formatAdapter, normalizedPath, cells, kernelName, notebookMetadata);
|
|
1218
|
+
}
|
|
1041
1219
|
// Load existing notebook metadata if file exists.
|
|
1042
1220
|
// Prefer a fast scan for the top-level metadata object so we avoid
|
|
1043
1221
|
// parsing the full notebook on every save. Fall back to full JSON
|
|
1044
1222
|
// parsing only for smaller notebooks when the fast path cannot find it.
|
|
1045
1223
|
let existingMetadata = {};
|
|
1046
|
-
|
|
1224
|
+
try {
|
|
1225
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1226
|
+
const handle = await fs_1.promises.open(normalizedPath, 'r');
|
|
1227
|
+
let head;
|
|
1047
1228
|
try {
|
|
1048
|
-
const stat = fs.statSync(normalizedPath);
|
|
1049
|
-
const fd = fs.openSync(normalizedPath, 'r');
|
|
1050
1229
|
const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
|
|
1051
|
-
const bytesRead =
|
|
1052
|
-
|
|
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
|
-
}
|
|
1230
|
+
const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
|
|
1231
|
+
head = buf.toString('utf-8', 0, bytesRead);
|
|
1062
1232
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1233
|
+
finally {
|
|
1234
|
+
await handle.close();
|
|
1235
|
+
}
|
|
1236
|
+
const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
|
|
1237
|
+
if (extractedMetadata) {
|
|
1238
|
+
existingMetadata = extractedMetadata;
|
|
1065
1239
|
}
|
|
1240
|
+
else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
|
|
1241
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1242
|
+
existingMetadata = notebook.metadata || {};
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
// Start fresh — file missing or metadata extraction failed
|
|
1066
1247
|
}
|
|
1067
1248
|
kernelName = kernelName || 'python3';
|
|
1068
1249
|
const displayName = kernelName === 'python3' ? 'Python 3' : kernelName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
1250
|
+
// Resolve outputs-unchanged sentinels from the file on disk: the client
|
|
1251
|
+
// elides unchanged outputs (often MBs of base64 images) from autosaves so
|
|
1252
|
+
// slow uplinks aren't saturated. If anything can't be resolved, ask the
|
|
1253
|
+
// client to retry with a full payload rather than guessing.
|
|
1254
|
+
const sentinelCells = cells.filter((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL);
|
|
1255
|
+
const priorOutputs = new Map();
|
|
1256
|
+
if (sentinelCells.length > 0) {
|
|
1257
|
+
try {
|
|
1258
|
+
const existing = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1259
|
+
for (const jc of existing.cells || []) {
|
|
1260
|
+
const nid = jc.metadata?.nebula_id;
|
|
1261
|
+
if (nid)
|
|
1262
|
+
priorOutputs.set(String(nid), { outputs: jc.outputs || [], execution_count: jc.execution_count ?? null });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return { success: false, mtime: 0, needsFull: true };
|
|
1267
|
+
}
|
|
1268
|
+
for (const c of sentinelCells) {
|
|
1269
|
+
if (!priorOutputs.has(c.id))
|
|
1270
|
+
return { success: false, mtime: 0, needsFull: true };
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1069
1273
|
const nbCells = cells.map((cell) => {
|
|
1070
1274
|
const preservedMetadata = cell._metadata || {};
|
|
1071
1275
|
const cellMetadata = {
|
|
@@ -1084,8 +1288,15 @@ class FilesystemService {
|
|
|
1084
1288
|
metadata: cellMetadata,
|
|
1085
1289
|
};
|
|
1086
1290
|
if (cell.type === 'code') {
|
|
1087
|
-
|
|
1088
|
-
|
|
1291
|
+
if (cell.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL) {
|
|
1292
|
+
const prior = priorOutputs.get(cell.id);
|
|
1293
|
+
nbCell.outputs = prior.outputs;
|
|
1294
|
+
nbCell.execution_count = cell.executionCount ?? prior.execution_count;
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
|
|
1298
|
+
nbCell.execution_count = cell.executionCount;
|
|
1299
|
+
}
|
|
1089
1300
|
}
|
|
1090
1301
|
return nbCell;
|
|
1091
1302
|
});
|
|
@@ -1122,11 +1333,9 @@ class FilesystemService {
|
|
|
1122
1333
|
};
|
|
1123
1334
|
// Create parent directory if needed
|
|
1124
1335
|
const parentDir = path.dirname(normalizedPath);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
this.writeJsonAtomicSync(normalizedPath, notebook);
|
|
1129
|
-
const stat = fs.statSync(normalizedPath);
|
|
1336
|
+
await fs_1.promises.mkdir(parentDir, { recursive: true });
|
|
1337
|
+
await this.writeJsonAtomic(normalizedPath, notebook);
|
|
1338
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1130
1339
|
return {
|
|
1131
1340
|
success: true,
|
|
1132
1341
|
mtime: stat.mtimeMs / 1000,
|
|
@@ -1157,25 +1366,25 @@ class FilesystemService {
|
|
|
1157
1366
|
};
|
|
1158
1367
|
// Ensure .nebula directory exists for journal/history/session
|
|
1159
1368
|
const { nebulaDir } = this.getNebulaPaths(notebookPath);
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
this.
|
|
1369
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1370
|
+
// Auxiliary files: atomic but non-durable (no fsync) — see atomicWriteFile.
|
|
1371
|
+
// Only the notebook itself (inside saveNotebookCells) pays for fsync.
|
|
1372
|
+
await this.writeJsonAtomic(journalPath, journal, { durable: false });
|
|
1164
1373
|
if (historyPath) {
|
|
1165
|
-
this.
|
|
1374
|
+
await this.writeJsonAtomic(historyPath, history || [], { durable: false });
|
|
1166
1375
|
}
|
|
1167
1376
|
if (sessionPath) {
|
|
1168
|
-
this.
|
|
1377
|
+
await this.writeJsonAtomic(sessionPath, session || {}, { durable: false });
|
|
1169
1378
|
}
|
|
1170
|
-
const result = this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
|
|
1379
|
+
const result = await this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
|
|
1171
1380
|
const committedJournal = {
|
|
1172
1381
|
...journal,
|
|
1173
1382
|
status: 'commit',
|
|
1174
1383
|
committedAt: Date.now(),
|
|
1175
1384
|
};
|
|
1176
|
-
this.
|
|
1385
|
+
await this.writeJsonAtomic(journalPath, committedJournal, { durable: false });
|
|
1177
1386
|
try {
|
|
1178
|
-
|
|
1387
|
+
await fs_1.promises.unlink(journalPath);
|
|
1179
1388
|
}
|
|
1180
1389
|
catch {
|
|
1181
1390
|
// Ignore cleanup errors; journal can be inspected if needed
|
|
@@ -1183,6 +1392,38 @@ class FilesystemService {
|
|
|
1183
1392
|
return result;
|
|
1184
1393
|
});
|
|
1185
1394
|
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Save cells to a text-format notebook (.py percent / .qmd).
|
|
1397
|
+
* Outputs and execution counts are never serialized — by format design.
|
|
1398
|
+
*/
|
|
1399
|
+
async saveTextNotebookCells(adapter, normalizedPath, cells, kernelName, notebookMetadata) {
|
|
1400
|
+
// Text formats never serialize outputs; neutralize any outputs-unchanged
|
|
1401
|
+
// sentinels so nothing downstream sees the marker string.
|
|
1402
|
+
cells = cells.map((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL ? { ...c, outputs: [] } : c);
|
|
1403
|
+
let existingMetadata = {};
|
|
1404
|
+
try {
|
|
1405
|
+
existingMetadata = adapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
|
|
1406
|
+
}
|
|
1407
|
+
catch {
|
|
1408
|
+
// Start fresh — file missing or metadata extraction failed
|
|
1409
|
+
}
|
|
1410
|
+
const finalMetadata = { ...existingMetadata };
|
|
1411
|
+
if (notebookMetadata) {
|
|
1412
|
+
for (const [key, value] of Object.entries(notebookMetadata)) {
|
|
1413
|
+
if (typeof value === 'object' && value !== null && typeof finalMetadata[key] === 'object' && finalMetadata[key] !== null) {
|
|
1414
|
+
finalMetadata[key] = { ...finalMetadata[key], ...value };
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
finalMetadata[key] = value;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
const text = adapter.serialize(cells, finalMetadata, kernelName);
|
|
1422
|
+
await this.atomicWriteFile(normalizedPath, text);
|
|
1423
|
+
await this.writeTextNotebookLastSave(normalizedPath, text, adapter);
|
|
1424
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1425
|
+
return { success: true, mtime: stat.mtimeMs / 1000 };
|
|
1426
|
+
}
|
|
1186
1427
|
/**
|
|
1187
1428
|
* Get notebook-level metadata
|
|
1188
1429
|
*/
|
|
@@ -1191,6 +1432,15 @@ class FilesystemService {
|
|
|
1191
1432
|
if (!fs.existsSync(normalizedPath)) {
|
|
1192
1433
|
return {};
|
|
1193
1434
|
}
|
|
1435
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1436
|
+
if (formatAdapter) {
|
|
1437
|
+
try {
|
|
1438
|
+
return formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).metadata;
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return {};
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1194
1444
|
try {
|
|
1195
1445
|
const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1196
1446
|
return notebook.metadata || {};
|
|
@@ -1199,17 +1449,91 @@ class FilesystemService {
|
|
|
1199
1449
|
return {};
|
|
1200
1450
|
}
|
|
1201
1451
|
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Async variant of getNotebookMetadata. Nebula-written notebooks put
|
|
1454
|
+
* `metadata` first, so the head-bytes scan avoids reading and parsing the
|
|
1455
|
+
* whole file; externally written notebooks fall back to a full async read.
|
|
1456
|
+
*/
|
|
1457
|
+
async getNotebookMetadataAsync(notebookPath) {
|
|
1458
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1459
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1460
|
+
try {
|
|
1461
|
+
if (formatAdapter) {
|
|
1462
|
+
return formatAdapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
|
|
1463
|
+
}
|
|
1464
|
+
const handle = await fs_1.promises.open(normalizedPath, 'r');
|
|
1465
|
+
let head;
|
|
1466
|
+
try {
|
|
1467
|
+
const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
|
|
1468
|
+
const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
|
|
1469
|
+
head = buf.toString('utf-8', 0, bytesRead);
|
|
1470
|
+
}
|
|
1471
|
+
finally {
|
|
1472
|
+
await handle.close();
|
|
1473
|
+
}
|
|
1474
|
+
const extracted = extractTopLevelObjectField(head, 'metadata');
|
|
1475
|
+
if (extracted) {
|
|
1476
|
+
return extracted;
|
|
1477
|
+
}
|
|
1478
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1479
|
+
return notebook.metadata || {};
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return {};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1202
1485
|
/**
|
|
1203
1486
|
* Update notebook-level metadata without modifying cells
|
|
1204
1487
|
*/
|
|
1205
1488
|
async updateNotebookMetadata(notebookPath, metadataUpdates) {
|
|
1206
1489
|
return await this.withWriteLock(notebookPath, async () => {
|
|
1207
1490
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1208
|
-
|
|
1491
|
+
try {
|
|
1492
|
+
await fs_1.promises.access(normalizedPath);
|
|
1493
|
+
}
|
|
1494
|
+
catch {
|
|
1209
1495
|
return { success: false, error: `Notebook not found: ${normalizedPath}` };
|
|
1210
1496
|
}
|
|
1497
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1498
|
+
if (formatAdapter) {
|
|
1499
|
+
try {
|
|
1500
|
+
const rawText = await fs_1.promises.readFile(normalizedPath, 'utf-8');
|
|
1501
|
+
// A plain .py script (no markers, no header) must not be
|
|
1502
|
+
// Nebula-fied by metadata side effects — kernel attach and
|
|
1503
|
+
// settings writes are skipped until a content save makes the
|
|
1504
|
+
// file a notebook on purpose.
|
|
1505
|
+
if (formatAdapter.name === 'percent' && !(0, percent_1.isPercentNotebookText)(rawText)) {
|
|
1506
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1507
|
+
return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
|
|
1508
|
+
}
|
|
1509
|
+
const parsed = formatAdapter.parse(rawText);
|
|
1510
|
+
const existingMetadata = parsed.metadata;
|
|
1511
|
+
const nextMetadata = { ...existingMetadata };
|
|
1512
|
+
for (const [key, value] of Object.entries(metadataUpdates)) {
|
|
1513
|
+
if (typeof value === 'object' && value !== null && typeof nextMetadata[key] === 'object' && nextMetadata[key] !== null) {
|
|
1514
|
+
nextMetadata[key] = {
|
|
1515
|
+
...nextMetadata[key],
|
|
1516
|
+
...value,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
else {
|
|
1520
|
+
nextMetadata[key] = value;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
|
|
1524
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1525
|
+
return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
|
|
1526
|
+
}
|
|
1527
|
+
await this.atomicWriteFile(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
|
|
1528
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1529
|
+
return { success: true, changed: true, mtime: stat.mtimeMs / 1000 };
|
|
1530
|
+
}
|
|
1531
|
+
catch (e) {
|
|
1532
|
+
return { success: false, error: `Failed to update notebook: ${e}` };
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1211
1535
|
try {
|
|
1212
|
-
const notebook = JSON.parse(
|
|
1536
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1213
1537
|
const existingMetadata = notebook.metadata || {};
|
|
1214
1538
|
const nextMetadata = { ...existingMetadata };
|
|
1215
1539
|
for (const [key, value] of Object.entries(metadataUpdates)) {
|
|
@@ -1224,7 +1548,7 @@ class FilesystemService {
|
|
|
1224
1548
|
}
|
|
1225
1549
|
}
|
|
1226
1550
|
if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
|
|
1227
|
-
const stat =
|
|
1551
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1228
1552
|
return {
|
|
1229
1553
|
success: true,
|
|
1230
1554
|
changed: false,
|
|
@@ -1232,8 +1556,8 @@ class FilesystemService {
|
|
|
1232
1556
|
};
|
|
1233
1557
|
}
|
|
1234
1558
|
notebook.metadata = nextMetadata;
|
|
1235
|
-
this.
|
|
1236
|
-
const stat =
|
|
1559
|
+
await this.writeJsonAtomic(normalizedPath, notebook);
|
|
1560
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1237
1561
|
return {
|
|
1238
1562
|
success: true,
|
|
1239
1563
|
changed: true,
|
|
@@ -1255,7 +1579,7 @@ class FilesystemService {
|
|
|
1255
1579
|
return snapshot;
|
|
1256
1580
|
}
|
|
1257
1581
|
buildInitialHistory(notebookPath) {
|
|
1258
|
-
const { cells } = this.
|
|
1582
|
+
const { cells } = this.getNotebookCellsSync(notebookPath);
|
|
1259
1583
|
return [{
|
|
1260
1584
|
type: 'snapshot',
|
|
1261
1585
|
cells: cells.map(cell => this.stripOutputsForHistorySnapshot(cell)),
|
|
@@ -1271,6 +1595,34 @@ class FilesystemService {
|
|
|
1271
1595
|
if (!fs.existsSync(normalizedPath)) {
|
|
1272
1596
|
return { success: false, error: `Notebook not found: ${normalizedPath}` };
|
|
1273
1597
|
}
|
|
1598
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1599
|
+
if (formatAdapter) {
|
|
1600
|
+
try {
|
|
1601
|
+
const parsed = formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1602
|
+
const nebula = (parsed.metadata.nebula || {});
|
|
1603
|
+
const nextMetadata = {
|
|
1604
|
+
...parsed.metadata,
|
|
1605
|
+
nebula: { ...nebula, agent_permitted: permitted },
|
|
1606
|
+
};
|
|
1607
|
+
this.atomicWriteFileSync(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
|
|
1608
|
+
if (permitted && !this.hasHistory(notebookPath)) {
|
|
1609
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1610
|
+
const nebulaDir = path.dirname(historyPath);
|
|
1611
|
+
if (!fs.existsSync(nebulaDir)) {
|
|
1612
|
+
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1613
|
+
}
|
|
1614
|
+
this.writeJsonAtomicSync(historyPath, this.buildInitialHistory(notebookPath));
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
success: true,
|
|
1618
|
+
status: this.getAgentPermissionStatus(notebookPath),
|
|
1619
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
catch (e) {
|
|
1623
|
+
return { success: false, error: `Failed to set agent permission: ${e}` };
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1274
1626
|
try {
|
|
1275
1627
|
const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1276
1628
|
const metadata = notebook.metadata || {};
|
|
@@ -1294,6 +1646,10 @@ class FilesystemService {
|
|
|
1294
1646
|
return {
|
|
1295
1647
|
success: true,
|
|
1296
1648
|
status: this.getAgentPermissionStatus(notebookPath),
|
|
1649
|
+
// The permission flag was just written INTO the notebook file; the
|
|
1650
|
+
// caller must adopt this mtime or its next autosave will mistake
|
|
1651
|
+
// our own write for an external change ("changed on server").
|
|
1652
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
1297
1653
|
};
|
|
1298
1654
|
}
|
|
1299
1655
|
catch (e) {
|
|
@@ -1306,31 +1662,57 @@ class FilesystemService {
|
|
|
1306
1662
|
*/
|
|
1307
1663
|
getAgentPermissionStatus(notebookPath) {
|
|
1308
1664
|
const metadata = this.getNotebookMetadata(notebookPath);
|
|
1309
|
-
const nebula = (metadata.nebula || {});
|
|
1310
1665
|
const hasHistory = this.hasHistory(notebookPath);
|
|
1666
|
+
return this.deriveAgentPermission(metadata, hasHistory);
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Async variant of getAgentPermissionStatus for hot request paths (it runs
|
|
1670
|
+
* on every agent write operation). Reads metadata via the head-bytes fast
|
|
1671
|
+
* path and checks history without parsing it, so the per-operation gate
|
|
1672
|
+
* never blocks the event loop on a slow network filesystem.
|
|
1673
|
+
*/
|
|
1674
|
+
async getAgentPermissionStatusAsync(notebookPath) {
|
|
1675
|
+
const [metadata, hasHistory] = await Promise.all([
|
|
1676
|
+
this.getNotebookMetadataAsync(notebookPath),
|
|
1677
|
+
this.hasHistoryAsync(notebookPath),
|
|
1678
|
+
]);
|
|
1679
|
+
return this.deriveAgentPermission(metadata, hasHistory);
|
|
1680
|
+
}
|
|
1681
|
+
deriveAgentPermission(metadata, hasHistory) {
|
|
1682
|
+
const nebula = (metadata.nebula || {});
|
|
1311
1683
|
const agentCreated = Boolean(nebula.agent_created);
|
|
1312
1684
|
const agentPermitted = Boolean(nebula.agent_permitted);
|
|
1313
|
-
|
|
1685
|
+
// Tri-state: an explicit user revoke (agent_permitted === false) always wins,
|
|
1686
|
+
// even for agent-created notebooks. Agent-created notebooks are permitted by
|
|
1687
|
+
// default (unless revoked); other notebooks need an explicit user grant.
|
|
1688
|
+
const revoked = nebula.agent_permitted === false;
|
|
1689
|
+
const permitted = agentCreated ? !revoked : agentPermitted;
|
|
1690
|
+
// Agent-created notebooks carry history from creation, so they skip the
|
|
1691
|
+
// separate has-history gate that user-permitted notebooks require.
|
|
1692
|
+
const canModify = permitted && (agentCreated || hasHistory);
|
|
1314
1693
|
return {
|
|
1315
1694
|
agent_created: agentCreated,
|
|
1316
1695
|
agent_permitted: agentPermitted,
|
|
1317
1696
|
has_history: hasHistory,
|
|
1318
1697
|
can_agent_modify: canModify,
|
|
1319
|
-
reason:
|
|
1320
|
-
? 'Agent
|
|
1698
|
+
reason: !permitted
|
|
1699
|
+
? (agentCreated ? 'Agent access revoked' : 'Not permitted for agent modifications')
|
|
1321
1700
|
: canModify
|
|
1322
|
-
? 'User permitted and history enabled'
|
|
1323
|
-
:
|
|
1324
|
-
? 'User permitted but history not enabled'
|
|
1325
|
-
: 'Not permitted for agent modifications',
|
|
1701
|
+
? (agentCreated ? 'Agent created this notebook' : 'User permitted and history enabled')
|
|
1702
|
+
: 'User permitted but history not enabled',
|
|
1326
1703
|
};
|
|
1327
1704
|
}
|
|
1328
1705
|
/**
|
|
1329
1706
|
* Check if a notebook is permitted for agent modifications
|
|
1330
1707
|
*/
|
|
1331
1708
|
isAgentPermitted(notebookPath) {
|
|
1332
|
-
|
|
1333
|
-
|
|
1709
|
+
// Agent-created notebooks are permitted unless explicitly revoked; others
|
|
1710
|
+
// require an explicit grant. A user revoke wins in both cases.
|
|
1711
|
+
const metadata = this.getNotebookMetadata(notebookPath);
|
|
1712
|
+
const nebula = (metadata.nebula || {});
|
|
1713
|
+
const agentCreated = Boolean(nebula.agent_created);
|
|
1714
|
+
const revoked = nebula.agent_permitted === false;
|
|
1715
|
+
return agentCreated ? !revoked : Boolean(nebula.agent_permitted);
|
|
1334
1716
|
}
|
|
1335
1717
|
/**
|
|
1336
1718
|
* Check if a notebook has history tracking enabled
|
|
@@ -1348,6 +1730,24 @@ class FilesystemService {
|
|
|
1348
1730
|
return false;
|
|
1349
1731
|
}
|
|
1350
1732
|
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Async variant of hasHistory that only parses small files: history journals
|
|
1735
|
+
* grow to hundreds of KB, and this check runs on every agent operation — a
|
|
1736
|
+
* file that large necessarily has entries, so its size alone answers.
|
|
1737
|
+
*/
|
|
1738
|
+
async hasHistoryAsync(notebookPath) {
|
|
1739
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1740
|
+
try {
|
|
1741
|
+
const stat = await fs_1.promises.stat(historyPath);
|
|
1742
|
+
if (stat.size > 4096)
|
|
1743
|
+
return true;
|
|
1744
|
+
const history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
|
|
1745
|
+
return Array.isArray(history) && history.length > 0;
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
return false;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1351
1751
|
/**
|
|
1352
1752
|
* Save operation history for a notebook
|
|
1353
1753
|
*/
|
|
@@ -1355,18 +1755,39 @@ class FilesystemService {
|
|
|
1355
1755
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
1356
1756
|
// Create .nebula directory if needed
|
|
1357
1757
|
const nebulaDir = path.dirname(historyPath);
|
|
1358
|
-
|
|
1359
|
-
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1360
|
-
}
|
|
1758
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1361
1759
|
await this.withWriteLock(notebookPath, async () => {
|
|
1362
|
-
this.
|
|
1760
|
+
await this.writeJsonAtomic(historyPath, history);
|
|
1363
1761
|
});
|
|
1364
1762
|
return true;
|
|
1365
1763
|
}
|
|
1366
1764
|
/**
|
|
1367
|
-
* Load operation history for a notebook
|
|
1765
|
+
* Load operation history for a notebook (async; history files can be many MB)
|
|
1766
|
+
*/
|
|
1767
|
+
async loadHistory(notebookPath) {
|
|
1768
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1769
|
+
if (this.hasPendingCommit(notebookPath)) {
|
|
1770
|
+
console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
|
|
1771
|
+
return [];
|
|
1772
|
+
}
|
|
1773
|
+
let history;
|
|
1774
|
+
try {
|
|
1775
|
+
history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
// Missing or unreadable/corrupt history file
|
|
1779
|
+
return [];
|
|
1780
|
+
}
|
|
1781
|
+
// Text-format notebooks can be edited outside Nebula (that is their
|
|
1782
|
+
// point); reconcile any divergence into history so undo/redo stays
|
|
1783
|
+
// infinite across external edits. No-op for .ipynb.
|
|
1784
|
+
return this.reconcileExternalTextEdits(notebookPath, history);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Sync variant of loadHistory. Kept for cold callers that need a
|
|
1788
|
+
* synchronous read (headless undo/redo state bootstrap).
|
|
1368
1789
|
*/
|
|
1369
|
-
|
|
1790
|
+
loadHistorySync(notebookPath) {
|
|
1370
1791
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
1371
1792
|
if (this.hasPendingCommit(notebookPath)) {
|
|
1372
1793
|
console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
|
|
@@ -1375,12 +1796,134 @@ class FilesystemService {
|
|
|
1375
1796
|
if (!fs.existsSync(historyPath)) {
|
|
1376
1797
|
return [];
|
|
1377
1798
|
}
|
|
1799
|
+
let history;
|
|
1378
1800
|
try {
|
|
1379
|
-
|
|
1801
|
+
history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
|
|
1380
1802
|
}
|
|
1381
1803
|
catch {
|
|
1382
1804
|
return [];
|
|
1383
1805
|
}
|
|
1806
|
+
return this.reconcileExternalTextEdits(notebookPath, history);
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Compare the file's current cells against the last-Nebula-save record.
|
|
1810
|
+
* On divergence, append ONE synthesized batch operation describing the
|
|
1811
|
+
* external edit (updateContent / insertCell / deleteCell, source
|
|
1812
|
+
* 'external') — both undo systems replay these, so a single undo step
|
|
1813
|
+
* reverts the external edit and redo reapplies it. Falls back to a plain
|
|
1814
|
+
* snapshot entry if synthesis fails (undo then bottoms out there instead
|
|
1815
|
+
* of corrupting).
|
|
1816
|
+
*/
|
|
1817
|
+
reconcileExternalTextEdits(notebookPath, history) {
|
|
1818
|
+
const adapter = (0, registry_1.getFormatAdapter)(notebookPath);
|
|
1819
|
+
if (!adapter || history.length === 0)
|
|
1820
|
+
return history;
|
|
1821
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1822
|
+
const lastSavePath = this.getLastSavePath(notebookPath);
|
|
1823
|
+
if (!fs.existsSync(normalizedPath) || !fs.existsSync(lastSavePath))
|
|
1824
|
+
return history;
|
|
1825
|
+
let currentCells;
|
|
1826
|
+
try {
|
|
1827
|
+
currentCells = adapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).cells;
|
|
1828
|
+
}
|
|
1829
|
+
catch {
|
|
1830
|
+
return history;
|
|
1831
|
+
}
|
|
1832
|
+
try {
|
|
1833
|
+
const record = JSON.parse(fs.readFileSync(lastSavePath, 'utf-8'));
|
|
1834
|
+
const before = record.cells ?? [];
|
|
1835
|
+
const unchanged = before.length === currentCells.length &&
|
|
1836
|
+
before.every((b, i) => currentCells[i].id === b.id &&
|
|
1837
|
+
currentCells[i].type === b.type &&
|
|
1838
|
+
currentCells[i].content === b.content);
|
|
1839
|
+
if (unchanged)
|
|
1840
|
+
return history;
|
|
1841
|
+
const operations = this.synthesizeExternalEditOps(before, currentCells);
|
|
1842
|
+
if (operations.length === 0)
|
|
1843
|
+
return history;
|
|
1844
|
+
const mtime = fs.statSync(normalizedPath).mtimeMs;
|
|
1845
|
+
const entry = {
|
|
1846
|
+
type: 'batch',
|
|
1847
|
+
operations,
|
|
1848
|
+
source: 'external',
|
|
1849
|
+
operationId: `external-${crypto.randomBytes(6).toString('hex')}`,
|
|
1850
|
+
timestamp: mtime,
|
|
1851
|
+
external: true,
|
|
1852
|
+
};
|
|
1853
|
+
const next = [...history, entry];
|
|
1854
|
+
this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
|
|
1855
|
+
this.writeJsonAtomicSync(lastSavePath, {
|
|
1856
|
+
cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
1857
|
+
});
|
|
1858
|
+
console.log(`[FilesystemService] Reconciled external edit into history for ${notebookPath} (${operations.length} ops)`);
|
|
1859
|
+
return next;
|
|
1860
|
+
}
|
|
1861
|
+
catch (e) {
|
|
1862
|
+
console.warn('[FilesystemService] External-edit reconciliation failed; appending snapshot:', e);
|
|
1863
|
+
try {
|
|
1864
|
+
const snapshot = {
|
|
1865
|
+
type: 'snapshot',
|
|
1866
|
+
cells: currentCells.map((c) => this.stripOutputsForHistorySnapshot(c)),
|
|
1867
|
+
timestamp: fs.statSync(normalizedPath).mtimeMs,
|
|
1868
|
+
source: 'external',
|
|
1869
|
+
};
|
|
1870
|
+
const next = [...history, snapshot];
|
|
1871
|
+
this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
|
|
1872
|
+
this.writeJsonAtomicSync(lastSavePath, {
|
|
1873
|
+
cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
1874
|
+
});
|
|
1875
|
+
return next;
|
|
1876
|
+
}
|
|
1877
|
+
catch {
|
|
1878
|
+
return history;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
synthesizeExternalEditOps(before, after) {
|
|
1883
|
+
const toCell = (c) => ({
|
|
1884
|
+
id: c.id,
|
|
1885
|
+
type: c.type,
|
|
1886
|
+
content: c.content,
|
|
1887
|
+
outputs: [],
|
|
1888
|
+
executionCount: null,
|
|
1889
|
+
isExecuting: false,
|
|
1890
|
+
});
|
|
1891
|
+
const afterById = new Map(after.map((c, i) => [c.id, i]));
|
|
1892
|
+
const beforeById = new Map(before.map((c, i) => [c.id, i]));
|
|
1893
|
+
const ops = [];
|
|
1894
|
+
// Deletions (and type changes) — descending index so replay indices hold
|
|
1895
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
1896
|
+
const b = before[i];
|
|
1897
|
+
const aIdx = afterById.get(b.id);
|
|
1898
|
+
const a = aIdx !== undefined ? after[aIdx] : null;
|
|
1899
|
+
if (!a || a.type !== b.type) {
|
|
1900
|
+
ops.push({ type: 'deleteCell', index: i, cell: toCell(b), source: 'external' });
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
// Insertions (and the insert half of type changes) — ascending index
|
|
1904
|
+
for (let i = 0; i < after.length; i++) {
|
|
1905
|
+
const a = after[i];
|
|
1906
|
+
const bIdx = beforeById.get(a.id);
|
|
1907
|
+
const b = bIdx !== undefined ? before[bIdx] : null;
|
|
1908
|
+
if (!b || b.type !== a.type) {
|
|
1909
|
+
ops.push({
|
|
1910
|
+
type: 'insertCell',
|
|
1911
|
+
index: i,
|
|
1912
|
+
cell: { ...a, outputs: [], executionCount: null, isExecuting: false },
|
|
1913
|
+
source: 'external',
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
else if (b.content !== a.content) {
|
|
1917
|
+
ops.push({
|
|
1918
|
+
type: 'updateContent',
|
|
1919
|
+
cellId: a.id,
|
|
1920
|
+
oldContent: b.content,
|
|
1921
|
+
newContent: a.content,
|
|
1922
|
+
source: 'external',
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
return ops;
|
|
1384
1927
|
}
|
|
1385
1928
|
/**
|
|
1386
1929
|
* Save session state for a notebook
|
|
@@ -1389,11 +1932,9 @@ class FilesystemService {
|
|
|
1389
1932
|
const sessionPath = this.getSessionPath(notebookPath);
|
|
1390
1933
|
// Create .nebula directory if needed
|
|
1391
1934
|
const nebulaDir = path.dirname(sessionPath);
|
|
1392
|
-
|
|
1393
|
-
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1394
|
-
}
|
|
1935
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1395
1936
|
await this.withWriteLock(notebookPath, async () => {
|
|
1396
|
-
this.
|
|
1937
|
+
await this.writeJsonAtomic(sessionPath, session);
|
|
1397
1938
|
});
|
|
1398
1939
|
return true;
|
|
1399
1940
|
}
|