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