@poncho-ai/harness 0.35.0 → 0.36.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 (57) hide show
  1. package/.turbo/turbo-build.log +6 -5
  2. package/.turbo/turbo-test.log +15169 -0
  3. package/CHANGELOG.md +18 -0
  4. package/dist/chunk-MCKGQKYU.js +15 -0
  5. package/dist/dist-3KMQR4IO.js +27092 -0
  6. package/dist/index.d.ts +485 -29
  7. package/dist/index.js +2839 -2114
  8. package/dist/isolate-5MISBSUK.js +733 -0
  9. package/dist/isolate-5R6762YA.js +605 -0
  10. package/dist/isolate-KUZ5NOPG.js +727 -0
  11. package/dist/isolate-LOL3T7RA.js +729 -0
  12. package/dist/isolate-N22X4TCE.js +740 -0
  13. package/dist/isolate-T7WXM7IL.js +1490 -0
  14. package/dist/isolate-TCWTUVG4.js +1532 -0
  15. package/dist/isolate-WFOLANOB.js +768 -0
  16. package/package.json +22 -3
  17. package/scripts/migrate-to-engine.mjs +556 -0
  18. package/src/config.ts +106 -1
  19. package/src/harness.ts +226 -91
  20. package/src/index.ts +5 -0
  21. package/src/isolate/bindings.ts +206 -0
  22. package/src/isolate/bundler.ts +179 -0
  23. package/src/isolate/index.ts +10 -0
  24. package/src/isolate/polyfills.ts +796 -0
  25. package/src/isolate/run-code-tool.ts +220 -0
  26. package/src/isolate/runtime.ts +286 -0
  27. package/src/isolate/type-stubs.ts +196 -0
  28. package/src/memory.ts +129 -198
  29. package/src/reminder-store.ts +3 -237
  30. package/src/secrets-store.ts +2 -91
  31. package/src/state.ts +11 -1302
  32. package/src/storage/engine.ts +106 -0
  33. package/src/storage/index.ts +59 -0
  34. package/src/storage/memory-engine.ts +588 -0
  35. package/src/storage/postgres-engine.ts +139 -0
  36. package/src/storage/schema.ts +145 -0
  37. package/src/storage/sql-dialect.ts +963 -0
  38. package/src/storage/sqlite-engine.ts +99 -0
  39. package/src/storage/store-adapters.ts +100 -0
  40. package/src/todo-tools.ts +1 -136
  41. package/src/upload-store.ts +1 -0
  42. package/src/vfs/bash-manager.ts +120 -0
  43. package/src/vfs/bash-tool.ts +59 -0
  44. package/src/vfs/create-bash-fs.ts +32 -0
  45. package/src/vfs/edit-file-tool.ts +72 -0
  46. package/src/vfs/index.ts +5 -0
  47. package/src/vfs/poncho-fs-adapter.ts +267 -0
  48. package/src/vfs/protected-fs.ts +177 -0
  49. package/src/vfs/read-file-tool.ts +103 -0
  50. package/src/vfs/write-file-tool.ts +49 -0
  51. package/test/harness.test.ts +30 -36
  52. package/test/isolate-vfs.test.ts +453 -0
  53. package/test/isolate.test.ts +252 -0
  54. package/test/state.test.ts +4 -27
  55. package/test/storage-engine.test.ts +250 -0
  56. package/test/vfs.test.ts +242 -0
  57. package/src/kv-store.ts +0 -216
@@ -0,0 +1,588 @@
1
+ // ---------------------------------------------------------------------------
2
+ // InMemoryEngine – Map-based storage for testing and ephemeral use.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { randomUUID } from "node:crypto";
6
+ import type {
7
+ Conversation,
8
+ ConversationSummary,
9
+ PendingSubagentResult,
10
+ } from "../state.js";
11
+ import type { MainMemory } from "../memory.js";
12
+ import type { TodoItem } from "../todo-tools.js";
13
+ import type { Reminder } from "../reminder-store.js";
14
+ import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internal VFS entry type
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface VfsEntry {
21
+ type: "file" | "directory" | "symlink";
22
+ content: Uint8Array | null;
23
+ symlinkTarget: string | null;
24
+ mimeType: string | null;
25
+ size: number;
26
+ mode: number;
27
+ createdAt: number;
28
+ updatedAt: number;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const DEFAULT_TENANT = "__default__";
36
+ const DEFAULT_OWNER = "local-owner";
37
+
38
+ const normalizeTenant = (tenantId?: string | null): string =>
39
+ tenantId ?? DEFAULT_TENANT;
40
+
41
+ const normalizeTitle = (title?: string): string =>
42
+ title && title.trim().length > 0 ? title.trim() : "New conversation";
43
+
44
+ const parentOf = (p: string): string => {
45
+ const idx = p.lastIndexOf("/");
46
+ return idx <= 0 ? "/" : p.slice(0, idx);
47
+ };
48
+
49
+ const vfsKey = (tenantId: string, path: string) => `${tenantId}\0${path}`;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // InMemoryEngine
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export class InMemoryEngine implements StorageEngine {
56
+ private readonly agentId: string;
57
+
58
+ // Conversation data
59
+ private convs = new Map<string, Conversation>();
60
+ // Memory data
61
+ private mem = new Map<string, MainMemory>();
62
+ // Todos data
63
+ private todoData = new Map<string, TodoItem[]>();
64
+ // Reminders data
65
+ private reminderData = new Map<string, Reminder>();
66
+ // VFS data
67
+ private vfsData = new Map<string, VfsEntry>();
68
+
69
+ constructor(agentId: string) {
70
+ this.agentId = agentId;
71
+ }
72
+
73
+ async initialize(): Promise<void> {}
74
+ async close(): Promise<void> {}
75
+
76
+ // -----------------------------------------------------------------------
77
+ // Conversations
78
+ // -----------------------------------------------------------------------
79
+
80
+ conversations = {
81
+ list: async (
82
+ ownerId?: string,
83
+ tenantId?: string | null,
84
+ ): Promise<ConversationSummary[]> => {
85
+ const tid = normalizeTenant(tenantId);
86
+ const filterTenant = tenantId !== undefined;
87
+ const results: ConversationSummary[] = [];
88
+ for (const c of this.convs.values()) {
89
+ if (filterTenant) {
90
+ const cTid = normalizeTenant(c.tenantId);
91
+ if (cTid !== tid) continue;
92
+ }
93
+ if (ownerId && c.ownerId !== ownerId) continue;
94
+ results.push(this.toSummary(c));
95
+ }
96
+ results.sort((a, b) => b.updatedAt - a.updatedAt);
97
+ return results;
98
+ },
99
+
100
+ get: async (conversationId: string): Promise<Conversation | undefined> => {
101
+ return this.convs.get(conversationId);
102
+ },
103
+
104
+ create: async (
105
+ ownerId?: string,
106
+ title?: string,
107
+ tenantId?: string | null,
108
+ ): Promise<Conversation> => {
109
+ const now = Date.now();
110
+ const conv: Conversation = {
111
+ conversationId: randomUUID(),
112
+ title: normalizeTitle(title),
113
+ messages: [],
114
+ ownerId: ownerId ?? DEFAULT_OWNER,
115
+ tenantId: tenantId === undefined ? null : tenantId,
116
+ createdAt: now,
117
+ updatedAt: now,
118
+ };
119
+ this.convs.set(conv.conversationId, conv);
120
+ return conv;
121
+ },
122
+
123
+ update: async (conversation: Conversation): Promise<void> => {
124
+ conversation.updatedAt = Date.now();
125
+ this.convs.set(conversation.conversationId, conversation);
126
+ },
127
+
128
+ rename: async (
129
+ conversationId: string,
130
+ title: string,
131
+ ): Promise<Conversation | undefined> => {
132
+ const conv = this.convs.get(conversationId);
133
+ if (!conv) return undefined;
134
+ conv.title = normalizeTitle(title);
135
+ conv.updatedAt = Date.now();
136
+ return conv;
137
+ },
138
+
139
+ delete: async (conversationId: string): Promise<boolean> => {
140
+ return this.convs.delete(conversationId);
141
+ },
142
+
143
+ search: async (
144
+ query: string,
145
+ tenantId?: string | null,
146
+ ): Promise<ConversationSummary[]> => {
147
+ const tid = normalizeTenant(tenantId);
148
+ const filterTenant = tenantId !== undefined;
149
+ const lq = query.toLowerCase();
150
+ const results: ConversationSummary[] = [];
151
+ for (const c of this.convs.values()) {
152
+ if (filterTenant) {
153
+ const cTid = normalizeTenant(c.tenantId);
154
+ if (cTid !== tid) continue;
155
+ }
156
+ const blob = JSON.stringify(c).toLowerCase();
157
+ if (c.title.toLowerCase().includes(lq) || blob.includes(lq)) {
158
+ results.push(this.toSummary(c));
159
+ }
160
+ }
161
+ results.sort((a, b) => b.updatedAt - a.updatedAt);
162
+ return results;
163
+ },
164
+
165
+ appendSubagentResult: async (
166
+ conversationId: string,
167
+ result: PendingSubagentResult,
168
+ ): Promise<void> => {
169
+ const conv = this.convs.get(conversationId);
170
+ if (!conv) return;
171
+ conv.pendingSubagentResults = [...(conv.pendingSubagentResults ?? []), result];
172
+ conv.updatedAt = Date.now();
173
+ },
174
+
175
+ clearCallbackLock: async (
176
+ conversationId: string,
177
+ ): Promise<Conversation | undefined> => {
178
+ const conv = this.convs.get(conversationId);
179
+ if (!conv) return undefined;
180
+ conv.runningCallbackSince = undefined;
181
+ return conv;
182
+ },
183
+ };
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Memory
187
+ // -----------------------------------------------------------------------
188
+
189
+ memory = {
190
+ get: async (tenantId?: string | null): Promise<MainMemory> => {
191
+ const tid = normalizeTenant(tenantId);
192
+ return this.mem.get(tid) ?? { content: "", updatedAt: 0 };
193
+ },
194
+
195
+ update: async (
196
+ content: string,
197
+ tenantId?: string | null,
198
+ ): Promise<MainMemory> => {
199
+ const tid = normalizeTenant(tenantId);
200
+ const m: MainMemory = { content, updatedAt: Date.now() };
201
+ this.mem.set(tid, m);
202
+ return m;
203
+ },
204
+ };
205
+
206
+ // -----------------------------------------------------------------------
207
+ // Todos
208
+ // -----------------------------------------------------------------------
209
+
210
+ todos = {
211
+ get: async (conversationId: string): Promise<TodoItem[]> => {
212
+ return this.todoData.get(conversationId) ?? [];
213
+ },
214
+
215
+ set: async (conversationId: string, todos: TodoItem[]): Promise<void> => {
216
+ this.todoData.set(conversationId, todos);
217
+ },
218
+ };
219
+
220
+ // -----------------------------------------------------------------------
221
+ // Reminders
222
+ // -----------------------------------------------------------------------
223
+
224
+ reminders = {
225
+ list: async (tenantId?: string | null): Promise<Reminder[]> => {
226
+ const tid = normalizeTenant(tenantId);
227
+ const filterTenant = tenantId !== undefined;
228
+ const results: Reminder[] = [];
229
+ for (const r of this.reminderData.values()) {
230
+ if (filterTenant) {
231
+ const rTid = normalizeTenant(r.tenantId);
232
+ if (rTid !== tid) continue;
233
+ }
234
+ results.push(r);
235
+ }
236
+ results.sort((a, b) => a.scheduledAt - b.scheduledAt);
237
+ return results;
238
+ },
239
+
240
+ create: async (input: {
241
+ task: string;
242
+ scheduledAt: number;
243
+ timezone?: string;
244
+ conversationId: string;
245
+ ownerId?: string;
246
+ tenantId?: string | null;
247
+ }): Promise<Reminder> => {
248
+ const r: Reminder = {
249
+ id: randomUUID(),
250
+ task: input.task,
251
+ scheduledAt: input.scheduledAt,
252
+ timezone: input.timezone,
253
+ status: "pending",
254
+ createdAt: Date.now(),
255
+ conversationId: input.conversationId,
256
+ ownerId: input.ownerId,
257
+ tenantId: input.tenantId,
258
+ };
259
+ this.reminderData.set(r.id, r);
260
+ return r;
261
+ },
262
+
263
+ cancel: async (id: string): Promise<Reminder> => {
264
+ const r = this.reminderData.get(id);
265
+ if (!r) throw new Error(`Reminder ${id} not found`);
266
+ r.status = "cancelled";
267
+ return r;
268
+ },
269
+
270
+ delete: async (id: string): Promise<void> => {
271
+ this.reminderData.delete(id);
272
+ },
273
+ };
274
+
275
+ // -----------------------------------------------------------------------
276
+ // VFS
277
+ // -----------------------------------------------------------------------
278
+
279
+ vfs = {
280
+ readFile: async (tenantId: string, path: string): Promise<Uint8Array> => {
281
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
282
+ if (!entry) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
283
+ if (entry.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
284
+ if (entry.type === "symlink") {
285
+ const target = this.resolveSymlink(tenantId, path);
286
+ return this.vfs.readFile(tenantId, target);
287
+ }
288
+ return entry.content ?? new Uint8Array();
289
+ },
290
+
291
+ writeFile: async (
292
+ tenantId: string,
293
+ path: string,
294
+ content: Uint8Array,
295
+ mimeType?: string,
296
+ ): Promise<void> => {
297
+ this.ensureParentDirs(tenantId, path);
298
+ const now = Date.now();
299
+ const existing = this.vfsData.get(vfsKey(tenantId, path));
300
+ this.vfsData.set(vfsKey(tenantId, path), {
301
+ type: "file",
302
+ content,
303
+ symlinkTarget: null,
304
+ mimeType: mimeType ?? null,
305
+ size: content.byteLength,
306
+ mode: 0o666,
307
+ createdAt: existing?.createdAt ?? now,
308
+ updatedAt: now,
309
+ });
310
+ },
311
+
312
+ appendFile: async (
313
+ tenantId: string,
314
+ path: string,
315
+ content: Uint8Array,
316
+ ): Promise<void> => {
317
+ const existing = this.vfsData.get(vfsKey(tenantId, path));
318
+ if (existing && existing.type === "file" && existing.content) {
319
+ const merged = new Uint8Array(existing.content.byteLength + content.byteLength);
320
+ merged.set(existing.content);
321
+ merged.set(content, existing.content.byteLength);
322
+ await this.vfs.writeFile(tenantId, path, merged);
323
+ } else {
324
+ await this.vfs.writeFile(tenantId, path, content);
325
+ }
326
+ },
327
+
328
+ deleteFile: async (tenantId: string, path: string): Promise<void> => {
329
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
330
+ if (!entry) throw new Error(`ENOENT: no such file or directory, unlink '${path}'`);
331
+ if (entry.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`);
332
+ this.vfsData.delete(vfsKey(tenantId, path));
333
+ },
334
+
335
+ deleteDir: async (
336
+ tenantId: string,
337
+ path: string,
338
+ recursive?: boolean,
339
+ ): Promise<void> => {
340
+ if (recursive) {
341
+ const prefix = vfsKey(tenantId, path);
342
+ for (const key of [...this.vfsData.keys()]) {
343
+ if (key === prefix || key.startsWith(`${prefix}/`.replace(`${tenantId}\0`, `${tenantId}\0`))) {
344
+ // Check actual path prefix
345
+ const entryPath = key.slice(key.indexOf("\0") + 1);
346
+ if (entryPath === path || entryPath.startsWith(`${path}/`)) {
347
+ this.vfsData.delete(key);
348
+ }
349
+ }
350
+ }
351
+ } else {
352
+ const children = await this.vfs.readdir(tenantId, path);
353
+ if (children.length > 0) {
354
+ throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`);
355
+ }
356
+ this.vfsData.delete(vfsKey(tenantId, path));
357
+ }
358
+ },
359
+
360
+ stat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
361
+ if (path === "/") {
362
+ return { type: "directory", size: 0, mode: 0o755, createdAt: 0, updatedAt: 0 };
363
+ }
364
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
365
+ if (!entry) return undefined;
366
+ return {
367
+ type: entry.type,
368
+ size: entry.size,
369
+ mode: entry.mode,
370
+ mimeType: entry.mimeType ?? undefined,
371
+ symlinkTarget: entry.symlinkTarget ?? undefined,
372
+ createdAt: entry.createdAt,
373
+ updatedAt: entry.updatedAt,
374
+ };
375
+ },
376
+
377
+ readdir: async (tenantId: string, path: string): Promise<VfsDirEntry[]> => {
378
+ const prefix = path === "/" ? "/" : path;
379
+ const results: VfsDirEntry[] = [];
380
+ for (const [key, entry] of this.vfsData) {
381
+ const entryTenant = key.slice(0, key.indexOf("\0"));
382
+ if (entryTenant !== tenantId) continue;
383
+ const entryPath = key.slice(key.indexOf("\0") + 1);
384
+ const entryParent = parentOf(entryPath);
385
+ if (entryParent === prefix) {
386
+ results.push({
387
+ name: entryPath.slice(entryPath.lastIndexOf("/") + 1),
388
+ type: entry.type,
389
+ });
390
+ }
391
+ }
392
+ return results;
393
+ },
394
+
395
+ mkdir: async (
396
+ tenantId: string,
397
+ path: string,
398
+ recursive?: boolean,
399
+ ): Promise<void> => {
400
+ if (recursive) {
401
+ const parts = path.split("/").filter(Boolean);
402
+ let current = "";
403
+ for (const part of parts) {
404
+ current += `/${part}`;
405
+ this.mkdirSingle(tenantId, current);
406
+ }
407
+ } else {
408
+ const pp = parentOf(path);
409
+ if (pp !== "/") {
410
+ const parentEntry = this.vfsData.get(vfsKey(tenantId, pp));
411
+ if (!parentEntry) {
412
+ throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
413
+ }
414
+ }
415
+ this.mkdirSingle(tenantId, path);
416
+ }
417
+ },
418
+
419
+ rename: async (
420
+ tenantId: string,
421
+ oldPath: string,
422
+ newPath: string,
423
+ ): Promise<void> => {
424
+ this.ensureParentDirs(tenantId, newPath);
425
+ const entry = this.vfsData.get(vfsKey(tenantId, oldPath));
426
+ if (!entry) throw new Error(`ENOENT: no such file or directory, rename '${oldPath}'`);
427
+
428
+ // Move the entry
429
+ this.vfsData.delete(vfsKey(tenantId, oldPath));
430
+ this.vfsData.set(vfsKey(tenantId, newPath), { ...entry, updatedAt: Date.now() });
431
+
432
+ // Move children (for directories)
433
+ if (entry.type === "directory") {
434
+ const prefix = `${oldPath}/`;
435
+ for (const [key, childEntry] of [...this.vfsData]) {
436
+ const entryTenant = key.slice(0, key.indexOf("\0"));
437
+ if (entryTenant !== tenantId) continue;
438
+ const entryPath = key.slice(key.indexOf("\0") + 1);
439
+ if (entryPath.startsWith(prefix)) {
440
+ const childNewPath = newPath + entryPath.slice(oldPath.length);
441
+ this.vfsData.delete(key);
442
+ this.vfsData.set(vfsKey(tenantId, childNewPath), childEntry);
443
+ }
444
+ }
445
+ }
446
+ },
447
+
448
+ chmod: async (tenantId: string, path: string, mode: number): Promise<void> => {
449
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
450
+ if (!entry) throw new Error(`ENOENT: no such file or directory, chmod '${path}'`);
451
+ entry.mode = mode;
452
+ entry.updatedAt = Date.now();
453
+ },
454
+
455
+ utimes: async (tenantId: string, path: string, mtime: Date): Promise<void> => {
456
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
457
+ if (!entry) throw new Error(`ENOENT: no such file or directory, utimes '${path}'`);
458
+ entry.updatedAt = mtime.getTime();
459
+ },
460
+
461
+ symlink: async (
462
+ tenantId: string,
463
+ target: string,
464
+ linkPath: string,
465
+ ): Promise<void> => {
466
+ this.ensureParentDirs(tenantId, linkPath);
467
+ const now = Date.now();
468
+ this.vfsData.set(vfsKey(tenantId, linkPath), {
469
+ type: "symlink",
470
+ content: null,
471
+ symlinkTarget: target,
472
+ mimeType: null,
473
+ size: 0,
474
+ mode: 0o777,
475
+ createdAt: now,
476
+ updatedAt: now,
477
+ });
478
+ },
479
+
480
+ readlink: async (tenantId: string, path: string): Promise<string> => {
481
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
482
+ if (!entry || entry.type !== "symlink" || !entry.symlinkTarget) {
483
+ throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
484
+ }
485
+ return entry.symlinkTarget;
486
+ },
487
+
488
+ lstat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
489
+ if (path === "/") {
490
+ return { type: "directory", size: 0, mode: 0o755, createdAt: 0, updatedAt: 0 };
491
+ }
492
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
493
+ if (!entry) return undefined;
494
+ return {
495
+ type: entry.type,
496
+ size: entry.size,
497
+ mode: entry.mode,
498
+ mimeType: entry.mimeType ?? undefined,
499
+ symlinkTarget: entry.symlinkTarget ?? undefined,
500
+ createdAt: entry.createdAt,
501
+ updatedAt: entry.updatedAt,
502
+ };
503
+ },
504
+
505
+ listAllPaths: (tenantId: string): string[] => {
506
+ const paths: string[] = [];
507
+ for (const key of this.vfsData.keys()) {
508
+ const entryTenant = key.slice(0, key.indexOf("\0"));
509
+ if (entryTenant !== tenantId) continue;
510
+ paths.push(key.slice(key.indexOf("\0") + 1));
511
+ }
512
+ return paths;
513
+ },
514
+
515
+ getUsage: async (
516
+ tenantId: string,
517
+ ): Promise<{ fileCount: number; totalBytes: number }> => {
518
+ let fileCount = 0;
519
+ let totalBytes = 0;
520
+ for (const [key, entry] of this.vfsData) {
521
+ const entryTenant = key.slice(0, key.indexOf("\0"));
522
+ if (entryTenant !== tenantId) continue;
523
+ if (entry.type === "file") {
524
+ fileCount++;
525
+ totalBytes += entry.size;
526
+ }
527
+ }
528
+ return { fileCount, totalBytes };
529
+ },
530
+ };
531
+
532
+ // -----------------------------------------------------------------------
533
+ // Private helpers
534
+ // -----------------------------------------------------------------------
535
+
536
+ private toSummary(c: Conversation): ConversationSummary {
537
+ return {
538
+ conversationId: c.conversationId,
539
+ title: c.title,
540
+ updatedAt: c.updatedAt,
541
+ createdAt: c.createdAt,
542
+ ownerId: c.ownerId,
543
+ tenantId: c.tenantId,
544
+ messageCount: c.messages.length,
545
+ hasPendingApprovals: (c.pendingApprovals?.length ?? 0) > 0,
546
+ parentConversationId: c.parentConversationId,
547
+ channelMeta: c.channelMeta,
548
+ };
549
+ }
550
+
551
+ private ensureParentDirs(tenantId: string, path: string): void {
552
+ const parts = path.split("/").filter(Boolean);
553
+ parts.pop(); // don't create the target itself
554
+ let current = "";
555
+ for (const part of parts) {
556
+ current += `/${part}`;
557
+ if (!this.vfsData.has(vfsKey(tenantId, current))) {
558
+ this.mkdirSingle(tenantId, current);
559
+ }
560
+ }
561
+ }
562
+
563
+ private mkdirSingle(tenantId: string, path: string): void {
564
+ const key = vfsKey(tenantId, path);
565
+ if (this.vfsData.has(key)) return; // already exists
566
+ const now = Date.now();
567
+ this.vfsData.set(key, {
568
+ type: "directory",
569
+ content: null,
570
+ symlinkTarget: null,
571
+ mimeType: null,
572
+ size: 0,
573
+ mode: 0o755,
574
+ createdAt: now,
575
+ updatedAt: now,
576
+ });
577
+ }
578
+
579
+ private resolveSymlink(tenantId: string, path: string, depth = 0): string {
580
+ if (depth > 20) throw new Error(`ELOOP: too many levels of symbolic links, open '${path}'`);
581
+ const entry = this.vfsData.get(vfsKey(tenantId, path));
582
+ if (!entry || entry.type !== "symlink" || !entry.symlinkTarget) return path;
583
+ const target = entry.symlinkTarget.startsWith("/")
584
+ ? entry.symlinkTarget
585
+ : `${parentOf(path)}/${entry.symlinkTarget}`;
586
+ return this.resolveSymlink(tenantId, target, depth + 1);
587
+ }
588
+ }