@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.
- package/.turbo/turbo-build.log +6 -5
- package/.turbo/turbo-test.log +15169 -0
- package/CHANGELOG.md +18 -0
- package/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/dist-3KMQR4IO.js +27092 -0
- package/dist/index.d.ts +485 -29
- package/dist/index.js +2839 -2114
- package/dist/isolate-5MISBSUK.js +733 -0
- package/dist/isolate-5R6762YA.js +605 -0
- package/dist/isolate-KUZ5NOPG.js +727 -0
- package/dist/isolate-LOL3T7RA.js +729 -0
- package/dist/isolate-N22X4TCE.js +740 -0
- package/dist/isolate-T7WXM7IL.js +1490 -0
- package/dist/isolate-TCWTUVG4.js +1532 -0
- package/dist/isolate-WFOLANOB.js +768 -0
- package/package.json +22 -3
- package/scripts/migrate-to-engine.mjs +556 -0
- package/src/config.ts +106 -1
- package/src/harness.ts +226 -91
- package/src/index.ts +5 -0
- package/src/isolate/bindings.ts +206 -0
- package/src/isolate/bundler.ts +179 -0
- package/src/isolate/index.ts +10 -0
- package/src/isolate/polyfills.ts +796 -0
- package/src/isolate/run-code-tool.ts +220 -0
- package/src/isolate/runtime.ts +286 -0
- package/src/isolate/type-stubs.ts +196 -0
- package/src/memory.ts +129 -198
- package/src/reminder-store.ts +3 -237
- package/src/secrets-store.ts +2 -91
- package/src/state.ts +11 -1302
- package/src/storage/engine.ts +106 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/memory-engine.ts +588 -0
- package/src/storage/postgres-engine.ts +139 -0
- package/src/storage/schema.ts +145 -0
- package/src/storage/sql-dialect.ts +963 -0
- package/src/storage/sqlite-engine.ts +99 -0
- package/src/storage/store-adapters.ts +100 -0
- package/src/todo-tools.ts +1 -136
- package/src/upload-store.ts +1 -0
- package/src/vfs/bash-manager.ts +120 -0
- package/src/vfs/bash-tool.ts +59 -0
- package/src/vfs/create-bash-fs.ts +32 -0
- package/src/vfs/edit-file-tool.ts +72 -0
- package/src/vfs/index.ts +5 -0
- package/src/vfs/poncho-fs-adapter.ts +267 -0
- package/src/vfs/protected-fs.ts +177 -0
- package/src/vfs/read-file-tool.ts +103 -0
- package/src/vfs/write-file-tool.ts +49 -0
- package/test/harness.test.ts +30 -36
- package/test/isolate-vfs.test.ts +453 -0
- package/test/isolate.test.ts +252 -0
- package/test/state.test.ts +4 -27
- package/test/storage-engine.test.ts +250 -0
- package/test/vfs.test.ts +242 -0
- 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
|
+
}
|