nebula-notebook 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -24
- package/dist/assets/errorwidget-C4r2j2DQ.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-7-YBurka.js +716 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-CFBUnxSZ.css +32 -0
- package/dist/assets/index-CsHoPQy-.js +1 -0
- package/dist/assets/index-D5w21_Z8.js +81 -0
- package/dist/assets/index-Day3QcNs.js +1 -0
- package/dist/assets/services-shim-D6p_A67v.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/cluster/client-registration.js +3 -0
- package/node-server/dist/cluster/kernel-proxy.js +24 -9
- package/node-server/dist/cluster/server-registry.d.ts +8 -0
- package/node-server/dist/cluster/server-registry.js +31 -7
- package/node-server/dist/fs/fs-service.d.ts +55 -7
- package/node-server/dist/fs/fs-service.js +489 -80
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +129 -9
- package/node-server/dist/kernel/kernel-service.d.ts +113 -2
- package/node-server/dist/kernel/kernel-service.js +762 -60
- package/node-server/dist/notebook/cell-hash.d.ts +13 -0
- package/node-server/dist/notebook/cell-hash.js +26 -0
- package/node-server/dist/notebook/headless-handler.d.ts +9 -0
- package/node-server/dist/notebook/headless-handler.js +124 -23
- package/node-server/dist/notebook/operation-router.d.ts +36 -0
- package/node-server/dist/notebook/operation-router.js +224 -9
- package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
- package/node-server/dist/notebook/undoRedoManager.js +10 -2
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +2 -2
- package/node-server/dist/routes/kernel.js +115 -3
- package/node-server/dist/routes/notebook.js +35 -1
- package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
- package/node-server/dist/scheduler/allocation-service.js +169 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +121 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
- package/node-server/dist/scheduler/types.d.ts +117 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/pty-manager.js +8 -0
- package/node-server/dist/terminal/server.js +43 -2
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +1 -3
- package/package.json +3 -5
- package/dist/assets/index-C1h_sArD.css +0 -32
- package/dist/assets/index-CDSTBon8.js +0 -658
|
@@ -40,11 +40,14 @@ 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");
|
|
47
48
|
const default_kernel_1 = require("../kernel/default-kernel");
|
|
49
|
+
const registry_1 = require("./notebook-formats/registry");
|
|
50
|
+
const percent_1 = require("./notebook-formats/percent");
|
|
48
51
|
const display_data_1 = require("../output/display-data");
|
|
49
52
|
const NEBULA_DIR = path.join(os.homedir(), '.nebula');
|
|
50
53
|
const USER_CONFIG_PATH = path.join(NEBULA_DIR, 'config.json');
|
|
@@ -345,6 +348,41 @@ class FilesystemService {
|
|
|
345
348
|
writeJsonAtomicSync(targetPath, payload) {
|
|
346
349
|
this.atomicWriteFileSync(targetPath, JSON.stringify(payload, null, 2));
|
|
347
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Async variant of atomicWriteFileSync (write temp, fsync, rename, fsync dir).
|
|
353
|
+
* Used on hot notebook-save paths so large writes never block the event loop.
|
|
354
|
+
*/
|
|
355
|
+
async atomicWriteFile(targetPath, data) {
|
|
356
|
+
const dir = path.dirname(targetPath);
|
|
357
|
+
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
358
|
+
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
|
359
|
+
const tmpPath = path.join(dir, tmpName);
|
|
360
|
+
const handle = await fs_1.promises.open(tmpPath, 'w', 0o600);
|
|
361
|
+
try {
|
|
362
|
+
await handle.writeFile(data, 'utf-8');
|
|
363
|
+
await handle.sync();
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
await handle.close();
|
|
367
|
+
}
|
|
368
|
+
await fs_1.promises.rename(tmpPath, targetPath);
|
|
369
|
+
// Best-effort directory fsync
|
|
370
|
+
try {
|
|
371
|
+
const dirHandle = await fs_1.promises.open(dir, 'r');
|
|
372
|
+
try {
|
|
373
|
+
await dirHandle.sync();
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
await dirHandle.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// Ignore fsync errors on directory handles
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async writeJsonAtomic(targetPath, payload) {
|
|
384
|
+
await this.atomicWriteFile(targetPath, JSON.stringify(payload, null, 2));
|
|
385
|
+
}
|
|
348
386
|
/**
|
|
349
387
|
* Format file size for display
|
|
350
388
|
*/
|
|
@@ -367,10 +405,10 @@ class FilesystemService {
|
|
|
367
405
|
*/
|
|
368
406
|
getFileType(extension) {
|
|
369
407
|
const ext = extension.toLowerCase();
|
|
370
|
-
if (ext === '.ipynb') {
|
|
408
|
+
if (ext === '.ipynb' || ext === '.qmd') {
|
|
371
409
|
return 'notebook';
|
|
372
410
|
}
|
|
373
|
-
else if (['.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
|
|
411
|
+
else if (['.py', '.r', '.jl', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
|
|
374
412
|
return 'code';
|
|
375
413
|
}
|
|
376
414
|
else if (['.csv', '.tsv', '.xlsx', '.xls'].includes(ext)) {
|
|
@@ -436,12 +474,16 @@ class FilesystemService {
|
|
|
436
474
|
/**
|
|
437
475
|
* Get file modification time
|
|
438
476
|
*/
|
|
439
|
-
getFileMtime(filePath) {
|
|
477
|
+
async getFileMtime(filePath) {
|
|
440
478
|
const normalizedPath = this.normalizePath(filePath);
|
|
441
|
-
|
|
479
|
+
let stat;
|
|
480
|
+
try {
|
|
481
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// Matches the sync existsSync() behavior: any stat failure reads as missing
|
|
442
485
|
throw new Error(`File not found: ${normalizedPath}`);
|
|
443
486
|
}
|
|
444
|
-
const stat = fs.statSync(normalizedPath);
|
|
445
487
|
return {
|
|
446
488
|
path: normalizedPath,
|
|
447
489
|
mtime: stat.mtimeMs / 1000,
|
|
@@ -493,18 +535,22 @@ class FilesystemService {
|
|
|
493
535
|
/**
|
|
494
536
|
* Read a file's contents
|
|
495
537
|
*/
|
|
496
|
-
readFile(filePath) {
|
|
538
|
+
async readFile(filePath) {
|
|
497
539
|
const normalizedPath = this.normalizePath(filePath);
|
|
498
|
-
|
|
540
|
+
let stat;
|
|
541
|
+
try {
|
|
542
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// Matches the sync existsSync() behavior: any stat failure reads as missing
|
|
499
546
|
throw new Error(`File not found: ${normalizedPath}`);
|
|
500
547
|
}
|
|
501
|
-
const stat = fs.statSync(normalizedPath);
|
|
502
548
|
if (stat.isDirectory()) {
|
|
503
549
|
throw new Error(`Path is a directory: ${normalizedPath}`);
|
|
504
550
|
}
|
|
505
551
|
const extension = path.extname(normalizedPath).toLowerCase();
|
|
506
552
|
if (extension === '.ipynb') {
|
|
507
|
-
const content = JSON.parse(
|
|
553
|
+
const content = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
508
554
|
return {
|
|
509
555
|
path: normalizedPath,
|
|
510
556
|
type: 'notebook',
|
|
@@ -513,7 +559,7 @@ class FilesystemService {
|
|
|
513
559
|
}
|
|
514
560
|
// Try to read as text
|
|
515
561
|
try {
|
|
516
|
-
const content =
|
|
562
|
+
const content = await fs_1.promises.readFile(normalizedPath, 'utf-8');
|
|
517
563
|
return {
|
|
518
564
|
path: normalizedPath,
|
|
519
565
|
type: 'text',
|
|
@@ -582,6 +628,16 @@ class FilesystemService {
|
|
|
582
628
|
};
|
|
583
629
|
this.writeJsonAtomicSync(normalizedPath, notebook);
|
|
584
630
|
}
|
|
631
|
+
else if (extension === '.qmd') {
|
|
632
|
+
// Text-format notebook template (NOT .py: creating a .py must stay a
|
|
633
|
+
// plain empty script — percent parsing only applies when one is
|
|
634
|
+
// opened as a notebook)
|
|
635
|
+
const adapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
636
|
+
this.atomicWriteFileSync(normalizedPath, adapter.serialize([], {
|
|
637
|
+
kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
|
|
638
|
+
__qmd_language: 'python',
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
585
641
|
else {
|
|
586
642
|
// Create empty file
|
|
587
643
|
fs.writeFileSync(normalizedPath, '', 'utf-8');
|
|
@@ -597,6 +653,31 @@ class FilesystemService {
|
|
|
597
653
|
const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
|
|
598
654
|
return path.join(nebulaDir, `${nameWithoutExt}.history.json`);
|
|
599
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* Last-Nebula-save record for text-format notebooks: the canonical cell
|
|
658
|
+
* state (id/type/content) as of the last save THROUGH Nebula. At history
|
|
659
|
+
* load, divergence between this record and the file means the notebook was
|
|
660
|
+
* edited externally (vim, git checkout, ...) — the difference is
|
|
661
|
+
* reconciled into history as a synthesized external-edit operation so
|
|
662
|
+
* undo/redo stays infinite across external edits.
|
|
663
|
+
*/
|
|
664
|
+
getLastSavePath(notebookPath) {
|
|
665
|
+
const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
|
|
666
|
+
return path.join(nebulaDir, `${nameWithoutExt}.lastsave.json`);
|
|
667
|
+
}
|
|
668
|
+
async writeTextNotebookLastSave(notebookPath, serializedText, adapter) {
|
|
669
|
+
try {
|
|
670
|
+
// Record the POST-round-trip identity (parse of what was written), so
|
|
671
|
+
// positional ids (qmd markdown cells) never read as false divergence.
|
|
672
|
+
const canonical = adapter.parse(serializedText).cells;
|
|
673
|
+
await this.writeJsonAtomic(this.getLastSavePath(notebookPath), {
|
|
674
|
+
cells: canonical.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
console.warn('[FilesystemService] Failed to write lastsave record:', e);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
600
681
|
/**
|
|
601
682
|
* Get the session state file path for a notebook
|
|
602
683
|
*/
|
|
@@ -632,7 +713,13 @@ class FilesystemService {
|
|
|
632
713
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
633
714
|
const parentDir = path.dirname(normalizedPath);
|
|
634
715
|
const notebookName = path.basename(normalizedPath);
|
|
635
|
-
|
|
716
|
+
// .ipynb sidecar names are extension-stripped (unchanged, pre-existing
|
|
717
|
+
// files keep working). Text-format notebooks keep their full basename so
|
|
718
|
+
// foo.py and foo.ipynb in one directory never share sidecars.
|
|
719
|
+
const ext = path.extname(notebookName).toLowerCase();
|
|
720
|
+
const nameWithoutExt = ext === '.ipynb'
|
|
721
|
+
? path.basename(notebookName, path.extname(notebookName))
|
|
722
|
+
: notebookName;
|
|
636
723
|
const nebulaDir = path.join(parentDir, '.nebula');
|
|
637
724
|
return { nebulaDir, nameWithoutExt };
|
|
638
725
|
}
|
|
@@ -643,6 +730,10 @@ class FilesystemService {
|
|
|
643
730
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
644
731
|
const sessionPath = this.getSessionPath(notebookPath);
|
|
645
732
|
const journalPath = this.getJournalPath(notebookPath);
|
|
733
|
+
const lastSavePath = this.getLastSavePath(notebookPath);
|
|
734
|
+
if (fs.existsSync(lastSavePath)) {
|
|
735
|
+
fs.unlinkSync(lastSavePath);
|
|
736
|
+
}
|
|
646
737
|
if (fs.existsSync(historyPath)) {
|
|
647
738
|
fs.unlinkSync(historyPath);
|
|
648
739
|
}
|
|
@@ -662,9 +753,8 @@ class FilesystemService {
|
|
|
662
753
|
throw new Error(`Path not found: ${normalizedPath}`);
|
|
663
754
|
}
|
|
664
755
|
// For notebooks, also delete history and session files
|
|
665
|
-
const ext = path.extname(normalizedPath).toLowerCase();
|
|
666
756
|
const stat = fs.statSync(normalizedPath);
|
|
667
|
-
if (
|
|
757
|
+
if ((0, registry_1.isNotebookPath)(normalizedPath) && !stat.isDirectory()) {
|
|
668
758
|
this.deleteNotebookMetadata(normalizedPath);
|
|
669
759
|
}
|
|
670
760
|
if (stat.isDirectory()) {
|
|
@@ -699,6 +789,10 @@ class FilesystemService {
|
|
|
699
789
|
// Journal files are ephemeral; remove any stale commit record on rename
|
|
700
790
|
fs.unlinkSync(oldJournal);
|
|
701
791
|
}
|
|
792
|
+
const oldLastSave = this.getLastSavePath(oldPath);
|
|
793
|
+
if (fs.existsSync(oldLastSave)) {
|
|
794
|
+
fs.renameSync(oldLastSave, this.getLastSavePath(newPath));
|
|
795
|
+
}
|
|
702
796
|
}
|
|
703
797
|
/**
|
|
704
798
|
* Rename/move a file or directory
|
|
@@ -713,8 +807,7 @@ class FilesystemService {
|
|
|
713
807
|
throw new Error(`Destination already exists: ${normalizedNew}`);
|
|
714
808
|
}
|
|
715
809
|
// For notebooks, also rename history and session files
|
|
716
|
-
|
|
717
|
-
if (ext === '.ipynb') {
|
|
810
|
+
if ((0, registry_1.isNotebookPath)(normalizedOld)) {
|
|
718
811
|
this.renameNotebookMetadata(normalizedOld, normalizedNew);
|
|
719
812
|
}
|
|
720
813
|
fs.renameSync(normalizedOld, normalizedNew);
|
|
@@ -728,6 +821,14 @@ class FilesystemService {
|
|
|
728
821
|
const destHistory = this.getHistoryPath(destPath);
|
|
729
822
|
const srcSession = this.getSessionPath(srcPath);
|
|
730
823
|
const destSession = this.getSessionPath(destPath);
|
|
824
|
+
const srcLastSave = this.getLastSavePath(srcPath);
|
|
825
|
+
if (fs.existsSync(srcLastSave)) {
|
|
826
|
+
const destNebulaDirForLastSave = path.dirname(destHistory);
|
|
827
|
+
if (!fs.existsSync(destNebulaDirForLastSave)) {
|
|
828
|
+
fs.mkdirSync(destNebulaDirForLastSave, { recursive: true });
|
|
829
|
+
}
|
|
830
|
+
fs.copyFileSync(srcLastSave, this.getLastSavePath(destPath));
|
|
831
|
+
}
|
|
731
832
|
// Create destination .nebula directory if needed
|
|
732
833
|
const destNebulaDir = path.dirname(destHistory);
|
|
733
834
|
if (!fs.existsSync(destNebulaDir)) {
|
|
@@ -758,7 +859,7 @@ class FilesystemService {
|
|
|
758
859
|
}
|
|
759
860
|
fs.copyFileSync(srcPath, destPath);
|
|
760
861
|
// For notebooks, also duplicate history and session files
|
|
761
|
-
if (
|
|
862
|
+
if ((0, registry_1.isNotebookPath)(entry.name)) {
|
|
762
863
|
this.duplicateNotebookMetadata(srcPath, destPath);
|
|
763
864
|
}
|
|
764
865
|
}
|
|
@@ -794,7 +895,7 @@ class FilesystemService {
|
|
|
794
895
|
// Copy the file
|
|
795
896
|
fs.copyFileSync(normalizedPath, newPath);
|
|
796
897
|
// For notebooks, also duplicate history and session files
|
|
797
|
-
if (
|
|
898
|
+
if ((0, registry_1.isNotebookPath)(normalizedPath)) {
|
|
798
899
|
this.duplicateNotebookMetadata(normalizedPath, newPath);
|
|
799
900
|
}
|
|
800
901
|
}
|
|
@@ -959,14 +1060,58 @@ class FilesystemService {
|
|
|
959
1060
|
return result;
|
|
960
1061
|
}
|
|
961
1062
|
/**
|
|
962
|
-
* Read a notebook and convert to internal cell format
|
|
1063
|
+
* Read a notebook and convert to internal cell format (async; hot path for
|
|
1064
|
+
* notebook load — large .ipynb reads must not block the event loop)
|
|
963
1065
|
*/
|
|
964
|
-
getNotebookCells(notebookPath) {
|
|
1066
|
+
async getNotebookCells(notebookPath) {
|
|
1067
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1068
|
+
let raw;
|
|
1069
|
+
let stat;
|
|
1070
|
+
try {
|
|
1071
|
+
[raw, stat] = await Promise.all([
|
|
1072
|
+
fs_1.promises.readFile(normalizedPath, 'utf-8'),
|
|
1073
|
+
fs_1.promises.stat(normalizedPath),
|
|
1074
|
+
]);
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
if (err?.code === 'ENOENT') {
|
|
1078
|
+
throw new Error(`Notebook not found: ${normalizedPath}`);
|
|
1079
|
+
}
|
|
1080
|
+
throw err;
|
|
1081
|
+
}
|
|
1082
|
+
return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Sync variant of getNotebookCells. Kept for cold callers that need a
|
|
1086
|
+
* synchronous read (headless cache warm-up, initial-history bootstrap).
|
|
1087
|
+
*/
|
|
1088
|
+
getNotebookCellsSync(notebookPath) {
|
|
965
1089
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
966
1090
|
if (!fs.existsSync(normalizedPath)) {
|
|
967
1091
|
throw new Error(`Notebook not found: ${normalizedPath}`);
|
|
968
1092
|
}
|
|
969
|
-
const
|
|
1093
|
+
const raw = fs.readFileSync(normalizedPath, 'utf-8');
|
|
1094
|
+
const stat = fs.statSync(normalizedPath);
|
|
1095
|
+
return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Convert raw notebook file content to the internal cell format (CPU only).
|
|
1099
|
+
*/
|
|
1100
|
+
buildNotebookCellsResponse(normalizedPath, raw, mtimeMs) {
|
|
1101
|
+
// Text notebook formats (.py percent / .qmd): adapter path. The .ipynb
|
|
1102
|
+
// body below is untouched.
|
|
1103
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1104
|
+
if (formatAdapter) {
|
|
1105
|
+
const parsed = formatAdapter.parse(raw);
|
|
1106
|
+
return {
|
|
1107
|
+
cells: parsed.cells,
|
|
1108
|
+
metadata: parsed.metadata,
|
|
1109
|
+
kernelspec: parsed.kernelspecName || (0, registry_1.defaultKernelForPath)(normalizedPath),
|
|
1110
|
+
kernelspecSource: parsed.kernelspecName ? 'metadata' : 'default',
|
|
1111
|
+
mtime: mtimeMs / 1000,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const notebook = JSON.parse(raw);
|
|
970
1115
|
const metadataKernel = notebook.metadata?.kernelspec?.name;
|
|
971
1116
|
const kernelspec = metadataKernel || 'python3';
|
|
972
1117
|
const cells = notebook.cells.map((nbCell, i) => {
|
|
@@ -1001,20 +1146,19 @@ class FilesystemService {
|
|
|
1001
1146
|
}
|
|
1002
1147
|
return cell;
|
|
1003
1148
|
});
|
|
1004
|
-
const stat = fs.statSync(normalizedPath);
|
|
1005
1149
|
return {
|
|
1006
1150
|
cells,
|
|
1007
1151
|
metadata: notebook.metadata || {},
|
|
1008
1152
|
kernelspec,
|
|
1009
1153
|
kernelspecSource: metadataKernel ? 'metadata' : 'default',
|
|
1010
|
-
mtime:
|
|
1154
|
+
mtime: mtimeMs / 1000,
|
|
1011
1155
|
};
|
|
1012
1156
|
}
|
|
1013
1157
|
/**
|
|
1014
1158
|
* Read a notebook and convert to internal cell format, resolving default kernel if needed
|
|
1015
1159
|
*/
|
|
1016
1160
|
async getNotebookCellsWithKernel(notebookPath) {
|
|
1017
|
-
const result = this.getNotebookCells(notebookPath);
|
|
1161
|
+
const result = await this.getNotebookCells(notebookPath);
|
|
1018
1162
|
if (result.kernelspecSource === 'metadata') {
|
|
1019
1163
|
return result;
|
|
1020
1164
|
}
|
|
@@ -1036,33 +1180,40 @@ class FilesystemService {
|
|
|
1036
1180
|
/**
|
|
1037
1181
|
* Save cells to a notebook file
|
|
1038
1182
|
*/
|
|
1039
|
-
saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
|
|
1183
|
+
async saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
|
|
1040
1184
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1185
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1186
|
+
if (formatAdapter) {
|
|
1187
|
+
return await this.saveTextNotebookCells(formatAdapter, normalizedPath, cells, kernelName, notebookMetadata);
|
|
1188
|
+
}
|
|
1041
1189
|
// Load existing notebook metadata if file exists.
|
|
1042
1190
|
// Prefer a fast scan for the top-level metadata object so we avoid
|
|
1043
1191
|
// parsing the full notebook on every save. Fall back to full JSON
|
|
1044
1192
|
// parsing only for smaller notebooks when the fast path cannot find it.
|
|
1045
1193
|
let existingMetadata = {};
|
|
1046
|
-
|
|
1194
|
+
try {
|
|
1195
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1196
|
+
const handle = await fs_1.promises.open(normalizedPath, 'r');
|
|
1197
|
+
let head;
|
|
1047
1198
|
try {
|
|
1048
|
-
const stat = fs.statSync(normalizedPath);
|
|
1049
|
-
const fd = fs.openSync(normalizedPath, 'r');
|
|
1050
1199
|
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
|
-
}
|
|
1200
|
+
const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
|
|
1201
|
+
head = buf.toString('utf-8', 0, bytesRead);
|
|
1062
1202
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1203
|
+
finally {
|
|
1204
|
+
await handle.close();
|
|
1205
|
+
}
|
|
1206
|
+
const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
|
|
1207
|
+
if (extractedMetadata) {
|
|
1208
|
+
existingMetadata = extractedMetadata;
|
|
1065
1209
|
}
|
|
1210
|
+
else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
|
|
1211
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1212
|
+
existingMetadata = notebook.metadata || {};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
// Start fresh — file missing or metadata extraction failed
|
|
1066
1217
|
}
|
|
1067
1218
|
kernelName = kernelName || 'python3';
|
|
1068
1219
|
const displayName = kernelName === 'python3' ? 'Python 3' : kernelName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
@@ -1122,11 +1273,9 @@ class FilesystemService {
|
|
|
1122
1273
|
};
|
|
1123
1274
|
// Create parent directory if needed
|
|
1124
1275
|
const parentDir = path.dirname(normalizedPath);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
this.writeJsonAtomicSync(normalizedPath, notebook);
|
|
1129
|
-
const stat = fs.statSync(normalizedPath);
|
|
1276
|
+
await fs_1.promises.mkdir(parentDir, { recursive: true });
|
|
1277
|
+
await this.writeJsonAtomic(normalizedPath, notebook);
|
|
1278
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1130
1279
|
return {
|
|
1131
1280
|
success: true,
|
|
1132
1281
|
mtime: stat.mtimeMs / 1000,
|
|
@@ -1157,25 +1306,23 @@ class FilesystemService {
|
|
|
1157
1306
|
};
|
|
1158
1307
|
// Ensure .nebula directory exists for journal/history/session
|
|
1159
1308
|
const { nebulaDir } = this.getNebulaPaths(notebookPath);
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
}
|
|
1163
|
-
this.writeJsonAtomicSync(journalPath, journal);
|
|
1309
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1310
|
+
await this.writeJsonAtomic(journalPath, journal);
|
|
1164
1311
|
if (historyPath) {
|
|
1165
|
-
this.
|
|
1312
|
+
await this.writeJsonAtomic(historyPath, history || []);
|
|
1166
1313
|
}
|
|
1167
1314
|
if (sessionPath) {
|
|
1168
|
-
this.
|
|
1315
|
+
await this.writeJsonAtomic(sessionPath, session || {});
|
|
1169
1316
|
}
|
|
1170
|
-
const result = this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
|
|
1317
|
+
const result = await this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
|
|
1171
1318
|
const committedJournal = {
|
|
1172
1319
|
...journal,
|
|
1173
1320
|
status: 'commit',
|
|
1174
1321
|
committedAt: Date.now(),
|
|
1175
1322
|
};
|
|
1176
|
-
this.
|
|
1323
|
+
await this.writeJsonAtomic(journalPath, committedJournal);
|
|
1177
1324
|
try {
|
|
1178
|
-
|
|
1325
|
+
await fs_1.promises.unlink(journalPath);
|
|
1179
1326
|
}
|
|
1180
1327
|
catch {
|
|
1181
1328
|
// Ignore cleanup errors; journal can be inspected if needed
|
|
@@ -1183,6 +1330,35 @@ class FilesystemService {
|
|
|
1183
1330
|
return result;
|
|
1184
1331
|
});
|
|
1185
1332
|
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Save cells to a text-format notebook (.py percent / .qmd).
|
|
1335
|
+
* Outputs and execution counts are never serialized — by format design.
|
|
1336
|
+
*/
|
|
1337
|
+
async saveTextNotebookCells(adapter, normalizedPath, cells, kernelName, notebookMetadata) {
|
|
1338
|
+
let existingMetadata = {};
|
|
1339
|
+
try {
|
|
1340
|
+
existingMetadata = adapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
|
|
1341
|
+
}
|
|
1342
|
+
catch {
|
|
1343
|
+
// Start fresh — file missing or metadata extraction failed
|
|
1344
|
+
}
|
|
1345
|
+
const finalMetadata = { ...existingMetadata };
|
|
1346
|
+
if (notebookMetadata) {
|
|
1347
|
+
for (const [key, value] of Object.entries(notebookMetadata)) {
|
|
1348
|
+
if (typeof value === 'object' && value !== null && typeof finalMetadata[key] === 'object' && finalMetadata[key] !== null) {
|
|
1349
|
+
finalMetadata[key] = { ...finalMetadata[key], ...value };
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
finalMetadata[key] = value;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const text = adapter.serialize(cells, finalMetadata, kernelName);
|
|
1357
|
+
await this.atomicWriteFile(normalizedPath, text);
|
|
1358
|
+
await this.writeTextNotebookLastSave(normalizedPath, text, adapter);
|
|
1359
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1360
|
+
return { success: true, mtime: stat.mtimeMs / 1000 };
|
|
1361
|
+
}
|
|
1186
1362
|
/**
|
|
1187
1363
|
* Get notebook-level metadata
|
|
1188
1364
|
*/
|
|
@@ -1191,6 +1367,15 @@ class FilesystemService {
|
|
|
1191
1367
|
if (!fs.existsSync(normalizedPath)) {
|
|
1192
1368
|
return {};
|
|
1193
1369
|
}
|
|
1370
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1371
|
+
if (formatAdapter) {
|
|
1372
|
+
try {
|
|
1373
|
+
return formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).metadata;
|
|
1374
|
+
}
|
|
1375
|
+
catch {
|
|
1376
|
+
return {};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1194
1379
|
try {
|
|
1195
1380
|
const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1196
1381
|
return notebook.metadata || {};
|
|
@@ -1205,11 +1390,52 @@ class FilesystemService {
|
|
|
1205
1390
|
async updateNotebookMetadata(notebookPath, metadataUpdates) {
|
|
1206
1391
|
return await this.withWriteLock(notebookPath, async () => {
|
|
1207
1392
|
const normalizedPath = this.normalizePath(notebookPath);
|
|
1208
|
-
|
|
1393
|
+
try {
|
|
1394
|
+
await fs_1.promises.access(normalizedPath);
|
|
1395
|
+
}
|
|
1396
|
+
catch {
|
|
1209
1397
|
return { success: false, error: `Notebook not found: ${normalizedPath}` };
|
|
1210
1398
|
}
|
|
1399
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1400
|
+
if (formatAdapter) {
|
|
1401
|
+
try {
|
|
1402
|
+
const rawText = await fs_1.promises.readFile(normalizedPath, 'utf-8');
|
|
1403
|
+
// A plain .py script (no markers, no header) must not be
|
|
1404
|
+
// Nebula-fied by metadata side effects — kernel attach and
|
|
1405
|
+
// settings writes are skipped until a content save makes the
|
|
1406
|
+
// file a notebook on purpose.
|
|
1407
|
+
if (formatAdapter.name === 'percent' && !(0, percent_1.isPercentNotebookText)(rawText)) {
|
|
1408
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1409
|
+
return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
|
|
1410
|
+
}
|
|
1411
|
+
const parsed = formatAdapter.parse(rawText);
|
|
1412
|
+
const existingMetadata = parsed.metadata;
|
|
1413
|
+
const nextMetadata = { ...existingMetadata };
|
|
1414
|
+
for (const [key, value] of Object.entries(metadataUpdates)) {
|
|
1415
|
+
if (typeof value === 'object' && value !== null && typeof nextMetadata[key] === 'object' && nextMetadata[key] !== null) {
|
|
1416
|
+
nextMetadata[key] = {
|
|
1417
|
+
...nextMetadata[key],
|
|
1418
|
+
...value,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
nextMetadata[key] = value;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
|
|
1426
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1427
|
+
return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
|
|
1428
|
+
}
|
|
1429
|
+
await this.atomicWriteFile(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
|
|
1430
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1431
|
+
return { success: true, changed: true, mtime: stat.mtimeMs / 1000 };
|
|
1432
|
+
}
|
|
1433
|
+
catch (e) {
|
|
1434
|
+
return { success: false, error: `Failed to update notebook: ${e}` };
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1211
1437
|
try {
|
|
1212
|
-
const notebook = JSON.parse(
|
|
1438
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1213
1439
|
const existingMetadata = notebook.metadata || {};
|
|
1214
1440
|
const nextMetadata = { ...existingMetadata };
|
|
1215
1441
|
for (const [key, value] of Object.entries(metadataUpdates)) {
|
|
@@ -1224,7 +1450,7 @@ class FilesystemService {
|
|
|
1224
1450
|
}
|
|
1225
1451
|
}
|
|
1226
1452
|
if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
|
|
1227
|
-
const stat =
|
|
1453
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1228
1454
|
return {
|
|
1229
1455
|
success: true,
|
|
1230
1456
|
changed: false,
|
|
@@ -1232,8 +1458,8 @@ class FilesystemService {
|
|
|
1232
1458
|
};
|
|
1233
1459
|
}
|
|
1234
1460
|
notebook.metadata = nextMetadata;
|
|
1235
|
-
this.
|
|
1236
|
-
const stat =
|
|
1461
|
+
await this.writeJsonAtomic(normalizedPath, notebook);
|
|
1462
|
+
const stat = await fs_1.promises.stat(normalizedPath);
|
|
1237
1463
|
return {
|
|
1238
1464
|
success: true,
|
|
1239
1465
|
changed: true,
|
|
@@ -1255,7 +1481,7 @@ class FilesystemService {
|
|
|
1255
1481
|
return snapshot;
|
|
1256
1482
|
}
|
|
1257
1483
|
buildInitialHistory(notebookPath) {
|
|
1258
|
-
const { cells } = this.
|
|
1484
|
+
const { cells } = this.getNotebookCellsSync(notebookPath);
|
|
1259
1485
|
return [{
|
|
1260
1486
|
type: 'snapshot',
|
|
1261
1487
|
cells: cells.map(cell => this.stripOutputsForHistorySnapshot(cell)),
|
|
@@ -1271,6 +1497,34 @@ class FilesystemService {
|
|
|
1271
1497
|
if (!fs.existsSync(normalizedPath)) {
|
|
1272
1498
|
return { success: false, error: `Notebook not found: ${normalizedPath}` };
|
|
1273
1499
|
}
|
|
1500
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1501
|
+
if (formatAdapter) {
|
|
1502
|
+
try {
|
|
1503
|
+
const parsed = formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1504
|
+
const nebula = (parsed.metadata.nebula || {});
|
|
1505
|
+
const nextMetadata = {
|
|
1506
|
+
...parsed.metadata,
|
|
1507
|
+
nebula: { ...nebula, agent_permitted: permitted },
|
|
1508
|
+
};
|
|
1509
|
+
this.atomicWriteFileSync(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
|
|
1510
|
+
if (permitted && !this.hasHistory(notebookPath)) {
|
|
1511
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1512
|
+
const nebulaDir = path.dirname(historyPath);
|
|
1513
|
+
if (!fs.existsSync(nebulaDir)) {
|
|
1514
|
+
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1515
|
+
}
|
|
1516
|
+
this.writeJsonAtomicSync(historyPath, this.buildInitialHistory(notebookPath));
|
|
1517
|
+
}
|
|
1518
|
+
return {
|
|
1519
|
+
success: true,
|
|
1520
|
+
status: this.getAgentPermissionStatus(notebookPath),
|
|
1521
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
catch (e) {
|
|
1525
|
+
return { success: false, error: `Failed to set agent permission: ${e}` };
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1274
1528
|
try {
|
|
1275
1529
|
const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
|
|
1276
1530
|
const metadata = notebook.metadata || {};
|
|
@@ -1294,6 +1548,10 @@ class FilesystemService {
|
|
|
1294
1548
|
return {
|
|
1295
1549
|
success: true,
|
|
1296
1550
|
status: this.getAgentPermissionStatus(notebookPath),
|
|
1551
|
+
// The permission flag was just written INTO the notebook file; the
|
|
1552
|
+
// caller must adopt this mtime or its next autosave will mistake
|
|
1553
|
+
// our own write for an external change ("changed on server").
|
|
1554
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
1297
1555
|
};
|
|
1298
1556
|
}
|
|
1299
1557
|
catch (e) {
|
|
@@ -1310,27 +1568,37 @@ class FilesystemService {
|
|
|
1310
1568
|
const hasHistory = this.hasHistory(notebookPath);
|
|
1311
1569
|
const agentCreated = Boolean(nebula.agent_created);
|
|
1312
1570
|
const agentPermitted = Boolean(nebula.agent_permitted);
|
|
1313
|
-
|
|
1571
|
+
// Tri-state: an explicit user revoke (agent_permitted === false) always wins,
|
|
1572
|
+
// even for agent-created notebooks. Agent-created notebooks are permitted by
|
|
1573
|
+
// default (unless revoked); other notebooks need an explicit user grant.
|
|
1574
|
+
const revoked = nebula.agent_permitted === false;
|
|
1575
|
+
const permitted = agentCreated ? !revoked : agentPermitted;
|
|
1576
|
+
// Agent-created notebooks carry history from creation, so they skip the
|
|
1577
|
+
// separate has-history gate that user-permitted notebooks require.
|
|
1578
|
+
const canModify = permitted && (agentCreated || hasHistory);
|
|
1314
1579
|
return {
|
|
1315
1580
|
agent_created: agentCreated,
|
|
1316
1581
|
agent_permitted: agentPermitted,
|
|
1317
1582
|
has_history: hasHistory,
|
|
1318
1583
|
can_agent_modify: canModify,
|
|
1319
|
-
reason:
|
|
1320
|
-
? 'Agent
|
|
1584
|
+
reason: !permitted
|
|
1585
|
+
? (agentCreated ? 'Agent access revoked' : 'Not permitted for agent modifications')
|
|
1321
1586
|
: canModify
|
|
1322
|
-
? 'User permitted and history enabled'
|
|
1323
|
-
:
|
|
1324
|
-
? 'User permitted but history not enabled'
|
|
1325
|
-
: 'Not permitted for agent modifications',
|
|
1587
|
+
? (agentCreated ? 'Agent created this notebook' : 'User permitted and history enabled')
|
|
1588
|
+
: 'User permitted but history not enabled',
|
|
1326
1589
|
};
|
|
1327
1590
|
}
|
|
1328
1591
|
/**
|
|
1329
1592
|
* Check if a notebook is permitted for agent modifications
|
|
1330
1593
|
*/
|
|
1331
1594
|
isAgentPermitted(notebookPath) {
|
|
1332
|
-
|
|
1333
|
-
|
|
1595
|
+
// Agent-created notebooks are permitted unless explicitly revoked; others
|
|
1596
|
+
// require an explicit grant. A user revoke wins in both cases.
|
|
1597
|
+
const metadata = this.getNotebookMetadata(notebookPath);
|
|
1598
|
+
const nebula = (metadata.nebula || {});
|
|
1599
|
+
const agentCreated = Boolean(nebula.agent_created);
|
|
1600
|
+
const revoked = nebula.agent_permitted === false;
|
|
1601
|
+
return agentCreated ? !revoked : Boolean(nebula.agent_permitted);
|
|
1334
1602
|
}
|
|
1335
1603
|
/**
|
|
1336
1604
|
* Check if a notebook has history tracking enabled
|
|
@@ -1355,18 +1623,39 @@ class FilesystemService {
|
|
|
1355
1623
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
1356
1624
|
// Create .nebula directory if needed
|
|
1357
1625
|
const nebulaDir = path.dirname(historyPath);
|
|
1358
|
-
|
|
1359
|
-
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1360
|
-
}
|
|
1626
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1361
1627
|
await this.withWriteLock(notebookPath, async () => {
|
|
1362
|
-
this.
|
|
1628
|
+
await this.writeJsonAtomic(historyPath, history);
|
|
1363
1629
|
});
|
|
1364
1630
|
return true;
|
|
1365
1631
|
}
|
|
1366
1632
|
/**
|
|
1367
|
-
* Load operation history for a notebook
|
|
1633
|
+
* Load operation history for a notebook (async; history files can be many MB)
|
|
1368
1634
|
*/
|
|
1369
|
-
loadHistory(notebookPath) {
|
|
1635
|
+
async loadHistory(notebookPath) {
|
|
1636
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1637
|
+
if (this.hasPendingCommit(notebookPath)) {
|
|
1638
|
+
console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
|
|
1639
|
+
return [];
|
|
1640
|
+
}
|
|
1641
|
+
let history;
|
|
1642
|
+
try {
|
|
1643
|
+
history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
|
|
1644
|
+
}
|
|
1645
|
+
catch {
|
|
1646
|
+
// Missing or unreadable/corrupt history file
|
|
1647
|
+
return [];
|
|
1648
|
+
}
|
|
1649
|
+
// Text-format notebooks can be edited outside Nebula (that is their
|
|
1650
|
+
// point); reconcile any divergence into history so undo/redo stays
|
|
1651
|
+
// infinite across external edits. No-op for .ipynb.
|
|
1652
|
+
return this.reconcileExternalTextEdits(notebookPath, history);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Sync variant of loadHistory. Kept for cold callers that need a
|
|
1656
|
+
* synchronous read (headless undo/redo state bootstrap).
|
|
1657
|
+
*/
|
|
1658
|
+
loadHistorySync(notebookPath) {
|
|
1370
1659
|
const historyPath = this.getHistoryPath(notebookPath);
|
|
1371
1660
|
if (this.hasPendingCommit(notebookPath)) {
|
|
1372
1661
|
console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
|
|
@@ -1375,12 +1664,134 @@ class FilesystemService {
|
|
|
1375
1664
|
if (!fs.existsSync(historyPath)) {
|
|
1376
1665
|
return [];
|
|
1377
1666
|
}
|
|
1667
|
+
let history;
|
|
1378
1668
|
try {
|
|
1379
|
-
|
|
1669
|
+
history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
|
|
1380
1670
|
}
|
|
1381
1671
|
catch {
|
|
1382
1672
|
return [];
|
|
1383
1673
|
}
|
|
1674
|
+
return this.reconcileExternalTextEdits(notebookPath, history);
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Compare the file's current cells against the last-Nebula-save record.
|
|
1678
|
+
* On divergence, append ONE synthesized batch operation describing the
|
|
1679
|
+
* external edit (updateContent / insertCell / deleteCell, source
|
|
1680
|
+
* 'external') — both undo systems replay these, so a single undo step
|
|
1681
|
+
* reverts the external edit and redo reapplies it. Falls back to a plain
|
|
1682
|
+
* snapshot entry if synthesis fails (undo then bottoms out there instead
|
|
1683
|
+
* of corrupting).
|
|
1684
|
+
*/
|
|
1685
|
+
reconcileExternalTextEdits(notebookPath, history) {
|
|
1686
|
+
const adapter = (0, registry_1.getFormatAdapter)(notebookPath);
|
|
1687
|
+
if (!adapter || history.length === 0)
|
|
1688
|
+
return history;
|
|
1689
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1690
|
+
const lastSavePath = this.getLastSavePath(notebookPath);
|
|
1691
|
+
if (!fs.existsSync(normalizedPath) || !fs.existsSync(lastSavePath))
|
|
1692
|
+
return history;
|
|
1693
|
+
let currentCells;
|
|
1694
|
+
try {
|
|
1695
|
+
currentCells = adapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).cells;
|
|
1696
|
+
}
|
|
1697
|
+
catch {
|
|
1698
|
+
return history;
|
|
1699
|
+
}
|
|
1700
|
+
try {
|
|
1701
|
+
const record = JSON.parse(fs.readFileSync(lastSavePath, 'utf-8'));
|
|
1702
|
+
const before = record.cells ?? [];
|
|
1703
|
+
const unchanged = before.length === currentCells.length &&
|
|
1704
|
+
before.every((b, i) => currentCells[i].id === b.id &&
|
|
1705
|
+
currentCells[i].type === b.type &&
|
|
1706
|
+
currentCells[i].content === b.content);
|
|
1707
|
+
if (unchanged)
|
|
1708
|
+
return history;
|
|
1709
|
+
const operations = this.synthesizeExternalEditOps(before, currentCells);
|
|
1710
|
+
if (operations.length === 0)
|
|
1711
|
+
return history;
|
|
1712
|
+
const mtime = fs.statSync(normalizedPath).mtimeMs;
|
|
1713
|
+
const entry = {
|
|
1714
|
+
type: 'batch',
|
|
1715
|
+
operations,
|
|
1716
|
+
source: 'external',
|
|
1717
|
+
operationId: `external-${crypto.randomBytes(6).toString('hex')}`,
|
|
1718
|
+
timestamp: mtime,
|
|
1719
|
+
external: true,
|
|
1720
|
+
};
|
|
1721
|
+
const next = [...history, entry];
|
|
1722
|
+
this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
|
|
1723
|
+
this.writeJsonAtomicSync(lastSavePath, {
|
|
1724
|
+
cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
1725
|
+
});
|
|
1726
|
+
console.log(`[FilesystemService] Reconciled external edit into history for ${notebookPath} (${operations.length} ops)`);
|
|
1727
|
+
return next;
|
|
1728
|
+
}
|
|
1729
|
+
catch (e) {
|
|
1730
|
+
console.warn('[FilesystemService] External-edit reconciliation failed; appending snapshot:', e);
|
|
1731
|
+
try {
|
|
1732
|
+
const snapshot = {
|
|
1733
|
+
type: 'snapshot',
|
|
1734
|
+
cells: currentCells.map((c) => this.stripOutputsForHistorySnapshot(c)),
|
|
1735
|
+
timestamp: fs.statSync(normalizedPath).mtimeMs,
|
|
1736
|
+
source: 'external',
|
|
1737
|
+
};
|
|
1738
|
+
const next = [...history, snapshot];
|
|
1739
|
+
this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
|
|
1740
|
+
this.writeJsonAtomicSync(lastSavePath, {
|
|
1741
|
+
cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
1742
|
+
});
|
|
1743
|
+
return next;
|
|
1744
|
+
}
|
|
1745
|
+
catch {
|
|
1746
|
+
return history;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
synthesizeExternalEditOps(before, after) {
|
|
1751
|
+
const toCell = (c) => ({
|
|
1752
|
+
id: c.id,
|
|
1753
|
+
type: c.type,
|
|
1754
|
+
content: c.content,
|
|
1755
|
+
outputs: [],
|
|
1756
|
+
executionCount: null,
|
|
1757
|
+
isExecuting: false,
|
|
1758
|
+
});
|
|
1759
|
+
const afterById = new Map(after.map((c, i) => [c.id, i]));
|
|
1760
|
+
const beforeById = new Map(before.map((c, i) => [c.id, i]));
|
|
1761
|
+
const ops = [];
|
|
1762
|
+
// Deletions (and type changes) — descending index so replay indices hold
|
|
1763
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
1764
|
+
const b = before[i];
|
|
1765
|
+
const aIdx = afterById.get(b.id);
|
|
1766
|
+
const a = aIdx !== undefined ? after[aIdx] : null;
|
|
1767
|
+
if (!a || a.type !== b.type) {
|
|
1768
|
+
ops.push({ type: 'deleteCell', index: i, cell: toCell(b), source: 'external' });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
// Insertions (and the insert half of type changes) — ascending index
|
|
1772
|
+
for (let i = 0; i < after.length; i++) {
|
|
1773
|
+
const a = after[i];
|
|
1774
|
+
const bIdx = beforeById.get(a.id);
|
|
1775
|
+
const b = bIdx !== undefined ? before[bIdx] : null;
|
|
1776
|
+
if (!b || b.type !== a.type) {
|
|
1777
|
+
ops.push({
|
|
1778
|
+
type: 'insertCell',
|
|
1779
|
+
index: i,
|
|
1780
|
+
cell: { ...a, outputs: [], executionCount: null, isExecuting: false },
|
|
1781
|
+
source: 'external',
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
else if (b.content !== a.content) {
|
|
1785
|
+
ops.push({
|
|
1786
|
+
type: 'updateContent',
|
|
1787
|
+
cellId: a.id,
|
|
1788
|
+
oldContent: b.content,
|
|
1789
|
+
newContent: a.content,
|
|
1790
|
+
source: 'external',
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
return ops;
|
|
1384
1795
|
}
|
|
1385
1796
|
/**
|
|
1386
1797
|
* Save session state for a notebook
|
|
@@ -1389,11 +1800,9 @@ class FilesystemService {
|
|
|
1389
1800
|
const sessionPath = this.getSessionPath(notebookPath);
|
|
1390
1801
|
// Create .nebula directory if needed
|
|
1391
1802
|
const nebulaDir = path.dirname(sessionPath);
|
|
1392
|
-
|
|
1393
|
-
fs.mkdirSync(nebulaDir, { recursive: true });
|
|
1394
|
-
}
|
|
1803
|
+
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1395
1804
|
await this.withWriteLock(notebookPath, async () => {
|
|
1396
|
-
this.
|
|
1805
|
+
await this.writeJsonAtomic(sessionPath, session);
|
|
1397
1806
|
});
|
|
1398
1807
|
return true;
|
|
1399
1808
|
}
|