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.
Files changed (81) hide show
  1. package/README.md +124 -24
  2. package/dist/assets/errorwidget-C4r2j2DQ.js +5 -0
  3. package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
  4. package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
  5. package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
  6. package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
  7. package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
  8. package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
  9. package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
  10. package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
  11. package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
  12. package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
  13. package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
  14. package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
  15. package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
  16. package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
  17. package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
  18. package/dist/assets/index-7-YBurka.js +716 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-CFBUnxSZ.css +32 -0
  21. package/dist/assets/index-CsHoPQy-.js +1 -0
  22. package/dist/assets/index-D5w21_Z8.js +81 -0
  23. package/dist/assets/index-Day3QcNs.js +1 -0
  24. package/dist/assets/services-shim-D6p_A67v.js +33 -0
  25. package/dist/assets/viewlist-uomDf7I7.js +1 -0
  26. package/dist/assets/widgets-X7J3NxEn.css +1 -0
  27. package/dist/index.html +2 -2
  28. package/node-server/dist/cluster/client-registration.js +3 -0
  29. package/node-server/dist/cluster/kernel-proxy.js +24 -9
  30. package/node-server/dist/cluster/server-registry.d.ts +8 -0
  31. package/node-server/dist/cluster/server-registry.js +31 -7
  32. package/node-server/dist/fs/fs-service.d.ts +55 -7
  33. package/node-server/dist/fs/fs-service.js +489 -80
  34. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  35. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  36. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  37. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  38. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  39. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  40. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  41. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  42. package/node-server/dist/idle-exit.d.ts +52 -0
  43. package/node-server/dist/idle-exit.js +83 -0
  44. package/node-server/dist/index.js +129 -9
  45. package/node-server/dist/kernel/kernel-service.d.ts +113 -2
  46. package/node-server/dist/kernel/kernel-service.js +762 -60
  47. package/node-server/dist/notebook/cell-hash.d.ts +13 -0
  48. package/node-server/dist/notebook/cell-hash.js +26 -0
  49. package/node-server/dist/notebook/headless-handler.d.ts +9 -0
  50. package/node-server/dist/notebook/headless-handler.js +124 -23
  51. package/node-server/dist/notebook/operation-router.d.ts +36 -0
  52. package/node-server/dist/notebook/operation-router.js +224 -9
  53. package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
  54. package/node-server/dist/notebook/undoRedoManager.js +10 -2
  55. package/node-server/dist/output/display-data.js +2 -0
  56. package/node-server/dist/routes/cluster.js +2 -2
  57. package/node-server/dist/routes/compute.d.ts +8 -0
  58. package/node-server/dist/routes/compute.js +136 -0
  59. package/node-server/dist/routes/fs.js +2 -2
  60. package/node-server/dist/routes/kernel.js +115 -3
  61. package/node-server/dist/routes/notebook.js +35 -1
  62. package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
  63. package/node-server/dist/scheduler/allocation-service.js +169 -0
  64. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  65. package/node-server/dist/scheduler/job-template.js +85 -0
  66. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  67. package/node-server/dist/scheduler/mock-scheduler.js +121 -0
  68. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  69. package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
  70. package/node-server/dist/scheduler/types.d.ts +117 -0
  71. package/node-server/dist/scheduler/types.js +8 -0
  72. package/node-server/dist/scheduler/util.d.ts +7 -0
  73. package/node-server/dist/scheduler/util.js +20 -0
  74. package/node-server/dist/terminal/pty-manager.js +8 -0
  75. package/node-server/dist/terminal/server.js +43 -2
  76. package/node-server/dist/update-check.d.ts +20 -0
  77. package/node-server/dist/update-check.js +114 -0
  78. package/node-server/package.json +1 -3
  79. package/package.json +3 -5
  80. package/dist/assets/index-C1h_sArD.css +0 -32
  81. 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
- if (!fs.existsSync(normalizedPath)) {
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
- if (!fs.existsSync(normalizedPath)) {
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(fs.readFileSync(normalizedPath, 'utf-8'));
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 = fs.readFileSync(normalizedPath, 'utf-8');
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
- const nameWithoutExt = path.basename(notebookName, path.extname(notebookName));
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 (ext === '.ipynb' && !stat.isDirectory()) {
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
- const ext = path.extname(normalizedOld).toLowerCase();
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 (entry.name.toLowerCase().endsWith('.ipynb')) {
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 (ext.toLowerCase() === '.ipynb') {
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 notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
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: stat.mtimeMs / 1000,
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
- if (fs.existsSync(normalizedPath)) {
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 = fs.readSync(fd, buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
1052
- fs.closeSync(fd);
1053
- const head = buf.toString('utf-8', 0, bytesRead);
1054
- const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
1055
- if (extractedMetadata) {
1056
- existingMetadata = extractedMetadata;
1057
- }
1058
- else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
1059
- const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1060
- existingMetadata = notebook.metadata || {};
1061
- }
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
- catch {
1064
- // Start fresh — metadata extraction failed
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
- if (!fs.existsSync(parentDir)) {
1126
- fs.mkdirSync(parentDir, { recursive: true });
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
- if (!fs.existsSync(nebulaDir)) {
1161
- fs.mkdirSync(nebulaDir, { recursive: true });
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.writeJsonAtomicSync(historyPath, history || []);
1312
+ await this.writeJsonAtomic(historyPath, history || []);
1166
1313
  }
1167
1314
  if (sessionPath) {
1168
- this.writeJsonAtomicSync(sessionPath, session || {});
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.writeJsonAtomicSync(journalPath, committedJournal);
1323
+ await this.writeJsonAtomic(journalPath, committedJournal);
1177
1324
  try {
1178
- fs.unlinkSync(journalPath);
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
- if (!fs.existsSync(normalizedPath)) {
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(fs.readFileSync(normalizedPath, 'utf-8'));
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 = fs.statSync(normalizedPath);
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.writeJsonAtomicSync(normalizedPath, notebook);
1236
- const stat = fs.statSync(normalizedPath);
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.getNotebookCells(notebookPath);
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
- const canModify = agentCreated || (agentPermitted && hasHistory);
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: agentCreated
1320
- ? 'Agent created this notebook'
1584
+ reason: !permitted
1585
+ ? (agentCreated ? 'Agent access revoked' : 'Not permitted for agent modifications')
1321
1586
  : canModify
1322
- ? 'User permitted and history enabled'
1323
- : agentPermitted
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
- const status = this.getAgentPermissionStatus(notebookPath);
1333
- return status.agent_created || status.agent_permitted;
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
- if (!fs.existsSync(nebulaDir)) {
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.writeJsonAtomicSync(historyPath, history);
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
- return JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
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
- if (!fs.existsSync(nebulaDir)) {
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.writeJsonAtomicSync(sessionPath, session);
1805
+ await this.writeJsonAtomic(sessionPath, session);
1397
1806
  });
1398
1807
  return true;
1399
1808
  }