@poncho-ai/harness 0.35.0 → 0.36.1
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 +12 -11
- package/CHANGELOG.md +25 -0
- package/dist/index.d.ts +485 -29
- package/dist/index.js +2839 -2114
- package/dist/isolate-TCWTUVG4.js +1532 -0
- package/package.json +23 -4
- 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/.turbo/turbo-lint.log +0 -6
- package/.turbo/turbo-test.log +0 -11931
- package/src/kv-store.ts +0 -216
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared SQL dialect abstraction + SqlStorageEngine base class.
|
|
3
|
+
//
|
|
4
|
+
// SQLite and PostgreSQL engines extend this base, providing only:
|
|
5
|
+
// - a Dialect (placeholder style, types, now(), etc.)
|
|
6
|
+
// - a query executor
|
|
7
|
+
// The base class contains all query logic + migration runner.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import type {
|
|
12
|
+
Conversation,
|
|
13
|
+
ConversationSummary,
|
|
14
|
+
PendingSubagentResult,
|
|
15
|
+
} from "../state.js";
|
|
16
|
+
import type { MainMemory } from "../memory.js";
|
|
17
|
+
import type { TodoItem } from "../todo-tools.js";
|
|
18
|
+
import type { Reminder } from "../reminder-store.js";
|
|
19
|
+
import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
|
|
20
|
+
import { type DialectTag, migrations } from "./schema.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Dialect
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface Dialect {
|
|
27
|
+
tag: DialectTag;
|
|
28
|
+
/** Return a positional parameter placeholder. 1-indexed. */
|
|
29
|
+
param(index: number): string;
|
|
30
|
+
/** BLOB type name */
|
|
31
|
+
blob: string;
|
|
32
|
+
/** JSON type name */
|
|
33
|
+
json: string;
|
|
34
|
+
/** Current-timestamp expression */
|
|
35
|
+
now(): string;
|
|
36
|
+
/** UPSERT conflict clause for a given PK column list */
|
|
37
|
+
upsert(pkCols: string[]): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const sqliteDialect: Dialect = {
|
|
41
|
+
tag: "sqlite",
|
|
42
|
+
param: () => "?",
|
|
43
|
+
blob: "BLOB",
|
|
44
|
+
json: "TEXT",
|
|
45
|
+
now: () => "datetime('now')",
|
|
46
|
+
upsert: (cols) => `ON CONFLICT(${cols.join(", ")}) DO UPDATE SET`,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const postgresDialect: Dialect = {
|
|
50
|
+
tag: "postgresql",
|
|
51
|
+
param: (i) => `$${i}`,
|
|
52
|
+
blob: "BYTEA",
|
|
53
|
+
json: "JSONB",
|
|
54
|
+
now: () => "NOW()",
|
|
55
|
+
upsert: (cols) => `ON CONFLICT(${cols.join(", ")}) DO UPDATE SET`,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Query executor interface (provided by each engine subclass)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export interface QueryRow {
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface QueryExecutor {
|
|
67
|
+
run(sql: string, params?: unknown[]): Promise<void>;
|
|
68
|
+
get<T extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<T | undefined>;
|
|
69
|
+
all<T extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
70
|
+
/** Execute raw SQL (for migrations). */
|
|
71
|
+
exec(sql: string): Promise<void>;
|
|
72
|
+
/** Run multiple statements in a transaction. */
|
|
73
|
+
transaction(fn: () => Promise<void>): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Helpers
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TENANT = "__default__";
|
|
81
|
+
const DEFAULT_OWNER = "local-owner";
|
|
82
|
+
|
|
83
|
+
const normalizeTenant = (tenantId?: string | null): string =>
|
|
84
|
+
tenantId ?? DEFAULT_TENANT;
|
|
85
|
+
|
|
86
|
+
const normalizeTitle = (title?: string): string =>
|
|
87
|
+
title && title.trim().length > 0 ? title.trim() : "New conversation";
|
|
88
|
+
|
|
89
|
+
const parentOf = (p: string): string => {
|
|
90
|
+
const idx = p.lastIndexOf("/");
|
|
91
|
+
return idx <= 0 ? "/" : p.slice(0, idx);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Parameterize a query for the dialect: replaces $1..$N with dialect placeholders. */
|
|
95
|
+
const rewrite = (sql: string, dialect: Dialect): string => {
|
|
96
|
+
if (dialect.tag === "sqlite") {
|
|
97
|
+
// Replace $1, $2, … with ?
|
|
98
|
+
return sql.replace(/\$\d+/g, "?");
|
|
99
|
+
}
|
|
100
|
+
return sql;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// SqlStorageEngine base class
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export abstract class SqlStorageEngine implements StorageEngine {
|
|
108
|
+
protected readonly dialect: Dialect;
|
|
109
|
+
protected readonly agentId: string;
|
|
110
|
+
protected abstract readonly executor: QueryExecutor;
|
|
111
|
+
|
|
112
|
+
constructor(dialect: Dialect, agentId: string) {
|
|
113
|
+
this.dialect = dialect;
|
|
114
|
+
this.agentId = agentId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// -----------------------------------------------------------------------
|
|
118
|
+
// Lifecycle
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async initialize(): Promise<void> {
|
|
122
|
+
await this.onBeforeInit();
|
|
123
|
+
await this.runMigrations();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
abstract close(): Promise<void>;
|
|
127
|
+
|
|
128
|
+
/** Hook for subclass-specific setup (e.g. WAL mode). */
|
|
129
|
+
protected async onBeforeInit(): Promise<void> {}
|
|
130
|
+
|
|
131
|
+
// -----------------------------------------------------------------------
|
|
132
|
+
// Migration runner
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
private async runMigrations(): Promise<void> {
|
|
136
|
+
const e = this.executor;
|
|
137
|
+
|
|
138
|
+
// Fast path: if we know the latest migration version, just check
|
|
139
|
+
// with a single lightweight query instead of CREATE TABLE + SELECT.
|
|
140
|
+
const latestVersion = migrations[migrations.length - 1]?.version ?? 0;
|
|
141
|
+
try {
|
|
142
|
+
const row = await e.get<{ max_v: number | null }>(
|
|
143
|
+
"SELECT MAX(version) as max_v FROM _migrations",
|
|
144
|
+
);
|
|
145
|
+
if (row && (row.max_v ?? 0) >= latestVersion) return; // all up to date
|
|
146
|
+
} catch {
|
|
147
|
+
// _migrations table doesn't exist yet — fall through to create it
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await e.exec(
|
|
151
|
+
`CREATE TABLE IF NOT EXISTS _migrations (
|
|
152
|
+
version INTEGER PRIMARY KEY,
|
|
153
|
+
name TEXT NOT NULL,
|
|
154
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
155
|
+
)`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const row = await e.get<{ max_v: number | null }>(
|
|
159
|
+
"SELECT MAX(version) as max_v FROM _migrations",
|
|
160
|
+
);
|
|
161
|
+
const applied = row?.max_v ?? 0;
|
|
162
|
+
|
|
163
|
+
for (const m of migrations) {
|
|
164
|
+
if (m.version <= applied) continue;
|
|
165
|
+
const stmts = m.up(this.dialect.tag);
|
|
166
|
+
await e.transaction(async () => {
|
|
167
|
+
for (const sql of stmts) {
|
|
168
|
+
await e.exec(sql);
|
|
169
|
+
}
|
|
170
|
+
await e.run(
|
|
171
|
+
rewrite("INSERT INTO _migrations (version, name) VALUES ($1, $2)", this.dialect),
|
|
172
|
+
[m.version, m.name],
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
// Conversations
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
conversations = {
|
|
183
|
+
list: async (
|
|
184
|
+
ownerId?: string,
|
|
185
|
+
tenantId?: string | null,
|
|
186
|
+
): Promise<ConversationSummary[]> => {
|
|
187
|
+
const tid = normalizeTenant(tenantId);
|
|
188
|
+
// When tenantId is undefined (admin), don't filter by tenant
|
|
189
|
+
const filterTenant = tenantId !== undefined;
|
|
190
|
+
const params: unknown[] = [this.agentId];
|
|
191
|
+
let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
|
|
192
|
+
message_count, data
|
|
193
|
+
FROM conversations WHERE agent_id = $1`;
|
|
194
|
+
if (filterTenant) {
|
|
195
|
+
sql += ` AND tenant_id = $2`;
|
|
196
|
+
params.push(tid);
|
|
197
|
+
}
|
|
198
|
+
if (ownerId) {
|
|
199
|
+
sql += ` AND owner_id = $${params.length + 1}`;
|
|
200
|
+
params.push(ownerId);
|
|
201
|
+
}
|
|
202
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
203
|
+
|
|
204
|
+
const rows = await this.executor.all(rewrite(sql, this.dialect), params);
|
|
205
|
+
return rows.map((r) => this.rowToSummary(r));
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
get: async (conversationId: string): Promise<Conversation | undefined> => {
|
|
209
|
+
const row = await this.executor.get<{
|
|
210
|
+
data: unknown;
|
|
211
|
+
tool_result_archive: unknown;
|
|
212
|
+
harness_messages: unknown;
|
|
213
|
+
continuation_messages: unknown;
|
|
214
|
+
}>(
|
|
215
|
+
rewrite("SELECT data, tool_result_archive, harness_messages, continuation_messages FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
216
|
+
[conversationId, this.agentId],
|
|
217
|
+
);
|
|
218
|
+
if (!row) return undefined;
|
|
219
|
+
const conv = this.parseConversation(row.data);
|
|
220
|
+
// Rehydrate heavy fields from separate columns
|
|
221
|
+
if (row.tool_result_archive) {
|
|
222
|
+
conv._toolResultArchive =
|
|
223
|
+
typeof row.tool_result_archive === "string"
|
|
224
|
+
? JSON.parse(row.tool_result_archive)
|
|
225
|
+
: row.tool_result_archive;
|
|
226
|
+
}
|
|
227
|
+
if (row.harness_messages) {
|
|
228
|
+
conv._harnessMessages =
|
|
229
|
+
typeof row.harness_messages === "string"
|
|
230
|
+
? JSON.parse(row.harness_messages)
|
|
231
|
+
: row.harness_messages;
|
|
232
|
+
}
|
|
233
|
+
if (row.continuation_messages) {
|
|
234
|
+
conv._continuationMessages =
|
|
235
|
+
typeof row.continuation_messages === "string"
|
|
236
|
+
? JSON.parse(row.continuation_messages)
|
|
237
|
+
: row.continuation_messages;
|
|
238
|
+
}
|
|
239
|
+
return conv;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
create: async (
|
|
243
|
+
ownerId?: string,
|
|
244
|
+
title?: string,
|
|
245
|
+
tenantId?: string | null,
|
|
246
|
+
): Promise<Conversation> => {
|
|
247
|
+
const id = randomUUID();
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const conv: Conversation = {
|
|
250
|
+
conversationId: id,
|
|
251
|
+
title: normalizeTitle(title),
|
|
252
|
+
messages: [],
|
|
253
|
+
ownerId: ownerId ?? DEFAULT_OWNER,
|
|
254
|
+
tenantId: tenantId === undefined ? null : tenantId,
|
|
255
|
+
createdAt: now,
|
|
256
|
+
updatedAt: now,
|
|
257
|
+
};
|
|
258
|
+
const data = JSON.stringify(conv);
|
|
259
|
+
await this.executor.run(
|
|
260
|
+
rewrite(
|
|
261
|
+
`INSERT INTO conversations (id, agent_id, tenant_id, owner_id, title, data, message_count, created_at, updated_at)
|
|
262
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
263
|
+
this.dialect,
|
|
264
|
+
),
|
|
265
|
+
[
|
|
266
|
+
id,
|
|
267
|
+
this.agentId,
|
|
268
|
+
normalizeTenant(tenantId),
|
|
269
|
+
conv.ownerId,
|
|
270
|
+
conv.title,
|
|
271
|
+
data,
|
|
272
|
+
0,
|
|
273
|
+
new Date(now).toISOString(),
|
|
274
|
+
new Date(now).toISOString(),
|
|
275
|
+
],
|
|
276
|
+
);
|
|
277
|
+
return conv;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
update: async (conversation: Conversation): Promise<void> => {
|
|
281
|
+
conversation.updatedAt = Date.now();
|
|
282
|
+
// Strip heavy internal fields from the data blob — stored in separate columns
|
|
283
|
+
const archive = conversation._toolResultArchive;
|
|
284
|
+
const harnessMessages = conversation._harnessMessages;
|
|
285
|
+
const continuationMessages = conversation._continuationMessages;
|
|
286
|
+
const stripped = { ...conversation };
|
|
287
|
+
delete stripped._toolResultArchive;
|
|
288
|
+
delete stripped._harnessMessages;
|
|
289
|
+
delete stripped._continuationMessages;
|
|
290
|
+
const data = JSON.stringify(stripped);
|
|
291
|
+
const archiveJson = archive ? JSON.stringify(archive) : null;
|
|
292
|
+
const harnessJson = harnessMessages ? JSON.stringify(harnessMessages) : null;
|
|
293
|
+
const continuationJson = continuationMessages ? JSON.stringify(continuationMessages) : null;
|
|
294
|
+
const msgCount = conversation.messages?.length ?? 0;
|
|
295
|
+
await this.executor.run(
|
|
296
|
+
rewrite(
|
|
297
|
+
`UPDATE conversations
|
|
298
|
+
SET data = $1, title = $2, message_count = $3, updated_at = $4, tenant_id = $5, owner_id = $6,
|
|
299
|
+
tool_result_archive = $7, harness_messages = $8, continuation_messages = $9
|
|
300
|
+
WHERE id = $10 AND agent_id = $11`,
|
|
301
|
+
this.dialect,
|
|
302
|
+
),
|
|
303
|
+
[
|
|
304
|
+
data,
|
|
305
|
+
conversation.title,
|
|
306
|
+
msgCount,
|
|
307
|
+
new Date(conversation.updatedAt).toISOString(),
|
|
308
|
+
normalizeTenant(conversation.tenantId),
|
|
309
|
+
conversation.ownerId,
|
|
310
|
+
archiveJson,
|
|
311
|
+
harnessJson,
|
|
312
|
+
continuationJson,
|
|
313
|
+
conversation.conversationId,
|
|
314
|
+
this.agentId,
|
|
315
|
+
],
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
rename: async (
|
|
320
|
+
conversationId: string,
|
|
321
|
+
title: string,
|
|
322
|
+
): Promise<Conversation | undefined> => {
|
|
323
|
+
const conv = await this.conversations.get(conversationId);
|
|
324
|
+
if (!conv) return undefined;
|
|
325
|
+
conv.title = normalizeTitle(title);
|
|
326
|
+
await this.conversations.update(conv);
|
|
327
|
+
return conv;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
delete: async (conversationId: string): Promise<boolean> => {
|
|
331
|
+
const row = await this.executor.get(
|
|
332
|
+
rewrite("SELECT id FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
333
|
+
[conversationId, this.agentId],
|
|
334
|
+
);
|
|
335
|
+
if (!row) return false;
|
|
336
|
+
await this.executor.run(
|
|
337
|
+
rewrite("DELETE FROM conversations WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
338
|
+
[conversationId, this.agentId],
|
|
339
|
+
);
|
|
340
|
+
return true;
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
search: async (
|
|
344
|
+
query: string,
|
|
345
|
+
tenantId?: string | null,
|
|
346
|
+
): Promise<ConversationSummary[]> => {
|
|
347
|
+
const tid = normalizeTenant(tenantId);
|
|
348
|
+
const filterTenant = tenantId !== undefined;
|
|
349
|
+
const pattern = `%${query}%`;
|
|
350
|
+
// SQLite uses positional ? so we can't reuse $2, need separate params
|
|
351
|
+
const params: unknown[] = [this.agentId, pattern, pattern];
|
|
352
|
+
let sql = `SELECT id, title, updated_at, created_at, owner_id, tenant_id,
|
|
353
|
+
message_count, data
|
|
354
|
+
FROM conversations
|
|
355
|
+
WHERE agent_id = $1 AND (title LIKE $2 OR data LIKE $3)`;
|
|
356
|
+
if (filterTenant) {
|
|
357
|
+
sql += ` AND tenant_id = $4`;
|
|
358
|
+
params.push(tid);
|
|
359
|
+
}
|
|
360
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
361
|
+
const rows = await this.executor.all(rewrite(sql, this.dialect), params);
|
|
362
|
+
return rows.map((r) => this.rowToSummary(r));
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
appendSubagentResult: async (
|
|
366
|
+
conversationId: string,
|
|
367
|
+
result: PendingSubagentResult,
|
|
368
|
+
): Promise<void> => {
|
|
369
|
+
const conv = await this.conversations.get(conversationId);
|
|
370
|
+
if (!conv) return;
|
|
371
|
+
conv.pendingSubagentResults = [...(conv.pendingSubagentResults ?? []), result];
|
|
372
|
+
await this.conversations.update(conv);
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
clearCallbackLock: async (
|
|
376
|
+
conversationId: string,
|
|
377
|
+
): Promise<Conversation | undefined> => {
|
|
378
|
+
const conv = await this.conversations.get(conversationId);
|
|
379
|
+
if (!conv) return undefined;
|
|
380
|
+
conv.runningCallbackSince = undefined;
|
|
381
|
+
await this.conversations.update(conv);
|
|
382
|
+
return conv;
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// -----------------------------------------------------------------------
|
|
387
|
+
// Memory
|
|
388
|
+
// -----------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
memory = {
|
|
391
|
+
get: async (tenantId?: string | null): Promise<MainMemory> => {
|
|
392
|
+
const tid = normalizeTenant(tenantId);
|
|
393
|
+
const row = await this.executor.get<{ content: string; updated_at: string }>(
|
|
394
|
+
rewrite(
|
|
395
|
+
"SELECT content, updated_at FROM memory WHERE agent_id = $1 AND tenant_id = $2",
|
|
396
|
+
this.dialect,
|
|
397
|
+
),
|
|
398
|
+
[this.agentId, tid],
|
|
399
|
+
);
|
|
400
|
+
if (!row) return { content: "", updatedAt: 0 };
|
|
401
|
+
return {
|
|
402
|
+
content: row.content,
|
|
403
|
+
updatedAt: new Date(row.updated_at).getTime(),
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
update: async (content: string, tenantId?: string | null): Promise<MainMemory> => {
|
|
408
|
+
const tid = normalizeTenant(tenantId);
|
|
409
|
+
const now = new Date().toISOString();
|
|
410
|
+
await this.executor.run(
|
|
411
|
+
rewrite(
|
|
412
|
+
`INSERT INTO memory (agent_id, tenant_id, content, updated_at)
|
|
413
|
+
VALUES ($1, $2, $3, $4)
|
|
414
|
+
${this.dialect.upsert(["agent_id", "tenant_id"])}
|
|
415
|
+
content = excluded.content, updated_at = excluded.updated_at`,
|
|
416
|
+
this.dialect,
|
|
417
|
+
),
|
|
418
|
+
[this.agentId, tid, content, now],
|
|
419
|
+
);
|
|
420
|
+
return { content, updatedAt: new Date(now).getTime() };
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// -----------------------------------------------------------------------
|
|
425
|
+
// Todos
|
|
426
|
+
// -----------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
todos = {
|
|
429
|
+
get: async (conversationId: string): Promise<TodoItem[]> => {
|
|
430
|
+
const row = await this.executor.get<{ data: string }>(
|
|
431
|
+
rewrite(
|
|
432
|
+
"SELECT data FROM todos WHERE agent_id = $1 AND conversation_id = $2",
|
|
433
|
+
this.dialect,
|
|
434
|
+
),
|
|
435
|
+
[this.agentId, conversationId],
|
|
436
|
+
);
|
|
437
|
+
if (!row) return [];
|
|
438
|
+
return typeof row.data === "string" ? JSON.parse(row.data) : row.data;
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
set: async (conversationId: string, todos: TodoItem[]): Promise<void> => {
|
|
442
|
+
const data = JSON.stringify(todos);
|
|
443
|
+
await this.executor.run(
|
|
444
|
+
rewrite(
|
|
445
|
+
`INSERT INTO todos (agent_id, conversation_id, data)
|
|
446
|
+
VALUES ($1, $2, $3)
|
|
447
|
+
${this.dialect.upsert(["agent_id", "conversation_id"])}
|
|
448
|
+
data = excluded.data`,
|
|
449
|
+
this.dialect,
|
|
450
|
+
),
|
|
451
|
+
[this.agentId, conversationId, data],
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// -----------------------------------------------------------------------
|
|
457
|
+
// Reminders
|
|
458
|
+
// -----------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
reminders = {
|
|
461
|
+
list: async (tenantId?: string | null): Promise<Reminder[]> => {
|
|
462
|
+
const tid = normalizeTenant(tenantId);
|
|
463
|
+
const filterTenant = tenantId !== undefined;
|
|
464
|
+
const params: unknown[] = [this.agentId];
|
|
465
|
+
let sql = "SELECT * FROM reminders WHERE agent_id = $1";
|
|
466
|
+
if (filterTenant) {
|
|
467
|
+
sql += " AND tenant_id = $2";
|
|
468
|
+
params.push(tid);
|
|
469
|
+
}
|
|
470
|
+
sql += " ORDER BY scheduled_at ASC";
|
|
471
|
+
const rows = await this.executor.all(rewrite(sql, this.dialect), params);
|
|
472
|
+
return rows.map((r) => this.rowToReminder(r));
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
create: async (input: {
|
|
476
|
+
task: string;
|
|
477
|
+
scheduledAt: number;
|
|
478
|
+
timezone?: string;
|
|
479
|
+
conversationId: string;
|
|
480
|
+
ownerId?: string;
|
|
481
|
+
tenantId?: string | null;
|
|
482
|
+
}): Promise<Reminder> => {
|
|
483
|
+
const id = randomUUID();
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
const tid = normalizeTenant(input.tenantId);
|
|
486
|
+
await this.executor.run(
|
|
487
|
+
rewrite(
|
|
488
|
+
`INSERT INTO reminders (id, agent_id, tenant_id, owner_id, conversation_id, task, status, scheduled_at, timezone, created_at)
|
|
489
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
490
|
+
this.dialect,
|
|
491
|
+
),
|
|
492
|
+
[
|
|
493
|
+
id,
|
|
494
|
+
this.agentId,
|
|
495
|
+
tid,
|
|
496
|
+
input.ownerId ?? null,
|
|
497
|
+
input.conversationId,
|
|
498
|
+
input.task,
|
|
499
|
+
"pending",
|
|
500
|
+
input.scheduledAt,
|
|
501
|
+
input.timezone ?? null,
|
|
502
|
+
new Date(now).toISOString(),
|
|
503
|
+
],
|
|
504
|
+
);
|
|
505
|
+
return {
|
|
506
|
+
id,
|
|
507
|
+
task: input.task,
|
|
508
|
+
scheduledAt: input.scheduledAt,
|
|
509
|
+
timezone: input.timezone,
|
|
510
|
+
status: "pending",
|
|
511
|
+
createdAt: now,
|
|
512
|
+
conversationId: input.conversationId,
|
|
513
|
+
ownerId: input.ownerId,
|
|
514
|
+
tenantId: input.tenantId,
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
cancel: async (id: string): Promise<Reminder> => {
|
|
519
|
+
await this.executor.run(
|
|
520
|
+
rewrite(
|
|
521
|
+
"UPDATE reminders SET status = 'cancelled' WHERE id = $1 AND agent_id = $2",
|
|
522
|
+
this.dialect,
|
|
523
|
+
),
|
|
524
|
+
[id, this.agentId],
|
|
525
|
+
);
|
|
526
|
+
const row = await this.executor.get(
|
|
527
|
+
rewrite("SELECT * FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
528
|
+
[id, this.agentId],
|
|
529
|
+
);
|
|
530
|
+
if (!row) throw new Error(`Reminder ${id} not found`);
|
|
531
|
+
return this.rowToReminder(row);
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
delete: async (id: string): Promise<void> => {
|
|
535
|
+
await this.executor.run(
|
|
536
|
+
rewrite("DELETE FROM reminders WHERE id = $1 AND agent_id = $2", this.dialect),
|
|
537
|
+
[id, this.agentId],
|
|
538
|
+
);
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// -----------------------------------------------------------------------
|
|
543
|
+
// VFS
|
|
544
|
+
// -----------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
vfs = {
|
|
547
|
+
readFile: async (tenantId: string, path: string): Promise<Uint8Array> => {
|
|
548
|
+
const row = await this.executor.get<{ content: unknown; type: string }>(
|
|
549
|
+
rewrite(
|
|
550
|
+
"SELECT content, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
551
|
+
this.dialect,
|
|
552
|
+
),
|
|
553
|
+
[this.agentId, tenantId, path],
|
|
554
|
+
);
|
|
555
|
+
if (!row) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
556
|
+
if (row.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, read '${path}'`);
|
|
557
|
+
if (row.type === "symlink") {
|
|
558
|
+
// Follow symlink
|
|
559
|
+
const target = await this.resolveSymlink(tenantId, path);
|
|
560
|
+
return this.vfs.readFile(tenantId, target);
|
|
561
|
+
}
|
|
562
|
+
return this.toUint8Array(row.content);
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
writeFile: async (
|
|
566
|
+
tenantId: string,
|
|
567
|
+
path: string,
|
|
568
|
+
content: Uint8Array,
|
|
569
|
+
mimeType?: string,
|
|
570
|
+
): Promise<void> => {
|
|
571
|
+
// Ensure parent directories exist
|
|
572
|
+
await this.ensureParentDirs(tenantId, path);
|
|
573
|
+
const pp = parentOf(path);
|
|
574
|
+
const now = new Date().toISOString();
|
|
575
|
+
await this.executor.run(
|
|
576
|
+
rewrite(
|
|
577
|
+
`INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, content, mime_type, size, mode, created_at, updated_at)
|
|
578
|
+
VALUES ($1, $2, $3, $4, 'file', $5, $6, $7, 438, $8, $9)
|
|
579
|
+
${this.dialect.upsert(["agent_id", "tenant_id", "path"])}
|
|
580
|
+
content = excluded.content, mime_type = excluded.mime_type, size = excluded.size, updated_at = excluded.updated_at, type = 'file'`,
|
|
581
|
+
this.dialect,
|
|
582
|
+
),
|
|
583
|
+
[this.agentId, tenantId, path, pp, content, mimeType ?? null, content.byteLength, now, now],
|
|
584
|
+
);
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
appendFile: async (
|
|
588
|
+
tenantId: string,
|
|
589
|
+
path: string,
|
|
590
|
+
content: Uint8Array,
|
|
591
|
+
): Promise<void> => {
|
|
592
|
+
const existing = await this.executor.get<{ content: unknown }>(
|
|
593
|
+
rewrite(
|
|
594
|
+
"SELECT content FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3 AND type = 'file'",
|
|
595
|
+
this.dialect,
|
|
596
|
+
),
|
|
597
|
+
[this.agentId, tenantId, path],
|
|
598
|
+
);
|
|
599
|
+
if (existing) {
|
|
600
|
+
const prev = this.toUint8Array(existing.content);
|
|
601
|
+
const merged = new Uint8Array(prev.byteLength + content.byteLength);
|
|
602
|
+
merged.set(prev);
|
|
603
|
+
merged.set(content, prev.byteLength);
|
|
604
|
+
await this.vfs.writeFile(tenantId, path, merged);
|
|
605
|
+
} else {
|
|
606
|
+
await this.vfs.writeFile(tenantId, path, content);
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
deleteFile: async (tenantId: string, path: string): Promise<void> => {
|
|
611
|
+
const stat = await this.vfs.stat(tenantId, path);
|
|
612
|
+
if (!stat) throw new Error(`ENOENT: no such file or directory, unlink '${path}'`);
|
|
613
|
+
if (stat.type === "directory") throw new Error(`EISDIR: illegal operation on a directory, unlink '${path}'`);
|
|
614
|
+
await this.executor.run(
|
|
615
|
+
rewrite(
|
|
616
|
+
"DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
617
|
+
this.dialect,
|
|
618
|
+
),
|
|
619
|
+
[this.agentId, tenantId, path],
|
|
620
|
+
);
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
deleteDir: async (
|
|
624
|
+
tenantId: string,
|
|
625
|
+
path: string,
|
|
626
|
+
recursive?: boolean,
|
|
627
|
+
): Promise<void> => {
|
|
628
|
+
if (recursive) {
|
|
629
|
+
// Delete all entries under this path
|
|
630
|
+
await this.executor.run(
|
|
631
|
+
rewrite(
|
|
632
|
+
"DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND (path = $3 OR path LIKE $4)",
|
|
633
|
+
this.dialect,
|
|
634
|
+
),
|
|
635
|
+
[this.agentId, tenantId, path, `${path}/%`],
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
// Check if directory is empty
|
|
639
|
+
const children = await this.vfs.readdir(tenantId, path);
|
|
640
|
+
if (children.length > 0) {
|
|
641
|
+
throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`);
|
|
642
|
+
}
|
|
643
|
+
await this.executor.run(
|
|
644
|
+
rewrite(
|
|
645
|
+
"DELETE FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
646
|
+
this.dialect,
|
|
647
|
+
),
|
|
648
|
+
[this.agentId, tenantId, path],
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
stat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
|
|
654
|
+
if (path === "/") {
|
|
655
|
+
return {
|
|
656
|
+
type: "directory",
|
|
657
|
+
size: 0,
|
|
658
|
+
mode: 0o755,
|
|
659
|
+
createdAt: 0,
|
|
660
|
+
updatedAt: 0,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const row = await this.executor.get<{
|
|
664
|
+
type: string;
|
|
665
|
+
size: number;
|
|
666
|
+
mode: number;
|
|
667
|
+
mime_type: string | null;
|
|
668
|
+
symlink_target: string | null;
|
|
669
|
+
created_at: string;
|
|
670
|
+
updated_at: string;
|
|
671
|
+
}>(
|
|
672
|
+
rewrite(
|
|
673
|
+
"SELECT type, size, mode, mime_type, symlink_target, created_at, updated_at FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
674
|
+
this.dialect,
|
|
675
|
+
),
|
|
676
|
+
[this.agentId, tenantId, path],
|
|
677
|
+
);
|
|
678
|
+
if (!row) return undefined;
|
|
679
|
+
return {
|
|
680
|
+
type: row.type as VfsStat["type"],
|
|
681
|
+
size: row.size,
|
|
682
|
+
mode: row.mode,
|
|
683
|
+
mimeType: row.mime_type ?? undefined,
|
|
684
|
+
symlinkTarget: row.symlink_target ?? undefined,
|
|
685
|
+
createdAt: new Date(row.created_at).getTime(),
|
|
686
|
+
updatedAt: new Date(row.updated_at).getTime(),
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
readdir: async (tenantId: string, path: string): Promise<VfsDirEntry[]> => {
|
|
691
|
+
const normalizedPath = path === "/" ? "/" : path;
|
|
692
|
+
const rows = await this.executor.all<{ path: string; type: string }>(
|
|
693
|
+
rewrite(
|
|
694
|
+
"SELECT path, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND parent_path = $3",
|
|
695
|
+
this.dialect,
|
|
696
|
+
),
|
|
697
|
+
[this.agentId, tenantId, normalizedPath],
|
|
698
|
+
);
|
|
699
|
+
return rows.map((r) => ({
|
|
700
|
+
name: r.path.slice(r.path.lastIndexOf("/") + 1),
|
|
701
|
+
type: r.type as VfsDirEntry["type"],
|
|
702
|
+
}));
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
mkdir: async (
|
|
706
|
+
tenantId: string,
|
|
707
|
+
path: string,
|
|
708
|
+
recursive?: boolean,
|
|
709
|
+
): Promise<void> => {
|
|
710
|
+
if (recursive) {
|
|
711
|
+
const parts = path.split("/").filter(Boolean);
|
|
712
|
+
let current = "";
|
|
713
|
+
for (const part of parts) {
|
|
714
|
+
current += `/${part}`;
|
|
715
|
+
await this.mkdirSingle(tenantId, current);
|
|
716
|
+
}
|
|
717
|
+
} else {
|
|
718
|
+
// Check parent exists
|
|
719
|
+
const pp = parentOf(path);
|
|
720
|
+
if (pp !== "/") {
|
|
721
|
+
const parentStat = await this.vfs.stat(tenantId, pp);
|
|
722
|
+
if (!parentStat) {
|
|
723
|
+
throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
await this.mkdirSingle(tenantId, path);
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
rename: async (
|
|
731
|
+
tenantId: string,
|
|
732
|
+
oldPath: string,
|
|
733
|
+
newPath: string,
|
|
734
|
+
): Promise<void> => {
|
|
735
|
+
await this.ensureParentDirs(tenantId, newPath);
|
|
736
|
+
const newParent = parentOf(newPath);
|
|
737
|
+
// Rename the entry itself
|
|
738
|
+
await this.executor.run(
|
|
739
|
+
rewrite(
|
|
740
|
+
"UPDATE vfs_entries SET path = $1, parent_path = $2, updated_at = $3 WHERE agent_id = $4 AND tenant_id = $5 AND path = $6",
|
|
741
|
+
this.dialect,
|
|
742
|
+
),
|
|
743
|
+
[newPath, newParent, new Date().toISOString(), this.agentId, tenantId, oldPath],
|
|
744
|
+
);
|
|
745
|
+
// Rename children (for directories)
|
|
746
|
+
const prefix = `${oldPath}/`;
|
|
747
|
+
const rows = await this.executor.all<{ path: string }>(
|
|
748
|
+
rewrite(
|
|
749
|
+
"SELECT path FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path LIKE $3",
|
|
750
|
+
this.dialect,
|
|
751
|
+
),
|
|
752
|
+
[this.agentId, tenantId, `${prefix}%`],
|
|
753
|
+
);
|
|
754
|
+
for (const row of rows) {
|
|
755
|
+
const childNewPath = newPath + row.path.slice(oldPath.length);
|
|
756
|
+
const childNewParent = parentOf(childNewPath);
|
|
757
|
+
await this.executor.run(
|
|
758
|
+
rewrite(
|
|
759
|
+
"UPDATE vfs_entries SET path = $1, parent_path = $2 WHERE agent_id = $3 AND tenant_id = $4 AND path = $5",
|
|
760
|
+
this.dialect,
|
|
761
|
+
),
|
|
762
|
+
[childNewPath, childNewParent, this.agentId, tenantId, row.path],
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
chmod: async (tenantId: string, path: string, mode: number): Promise<void> => {
|
|
768
|
+
await this.executor.run(
|
|
769
|
+
rewrite(
|
|
770
|
+
"UPDATE vfs_entries SET mode = $1, updated_at = $2 WHERE agent_id = $3 AND tenant_id = $4 AND path = $5",
|
|
771
|
+
this.dialect,
|
|
772
|
+
),
|
|
773
|
+
[mode, new Date().toISOString(), this.agentId, tenantId, path],
|
|
774
|
+
);
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
utimes: async (tenantId: string, path: string, mtime: Date): Promise<void> => {
|
|
778
|
+
await this.executor.run(
|
|
779
|
+
rewrite(
|
|
780
|
+
"UPDATE vfs_entries SET updated_at = $1 WHERE agent_id = $2 AND tenant_id = $3 AND path = $4",
|
|
781
|
+
this.dialect,
|
|
782
|
+
),
|
|
783
|
+
[mtime.toISOString(), this.agentId, tenantId, path],
|
|
784
|
+
);
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
symlink: async (tenantId: string, target: string, linkPath: string): Promise<void> => {
|
|
788
|
+
await this.ensureParentDirs(tenantId, linkPath);
|
|
789
|
+
const pp = parentOf(linkPath);
|
|
790
|
+
const now = new Date().toISOString();
|
|
791
|
+
await this.executor.run(
|
|
792
|
+
rewrite(
|
|
793
|
+
`INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, symlink_target, size, mode, created_at, updated_at)
|
|
794
|
+
VALUES ($1, $2, $3, $4, 'symlink', $5, 0, 511, $6, $7)`,
|
|
795
|
+
this.dialect,
|
|
796
|
+
),
|
|
797
|
+
[this.agentId, tenantId, linkPath, pp, target, now, now],
|
|
798
|
+
);
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
readlink: async (tenantId: string, path: string): Promise<string> => {
|
|
802
|
+
const row = await this.executor.get<{ symlink_target: string | null; type: string }>(
|
|
803
|
+
rewrite(
|
|
804
|
+
"SELECT symlink_target, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
805
|
+
this.dialect,
|
|
806
|
+
),
|
|
807
|
+
[this.agentId, tenantId, path],
|
|
808
|
+
);
|
|
809
|
+
if (!row || row.type !== "symlink" || !row.symlink_target) {
|
|
810
|
+
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
|
|
811
|
+
}
|
|
812
|
+
return row.symlink_target;
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
lstat: async (tenantId: string, path: string): Promise<VfsStat | undefined> => {
|
|
816
|
+
// Same as stat but doesn't follow symlinks
|
|
817
|
+
if (path === "/") {
|
|
818
|
+
return { type: "directory", size: 0, mode: 0o755, createdAt: 0, updatedAt: 0 };
|
|
819
|
+
}
|
|
820
|
+
const row = await this.executor.get<{
|
|
821
|
+
type: string;
|
|
822
|
+
size: number;
|
|
823
|
+
mode: number;
|
|
824
|
+
mime_type: string | null;
|
|
825
|
+
symlink_target: string | null;
|
|
826
|
+
created_at: string;
|
|
827
|
+
updated_at: string;
|
|
828
|
+
}>(
|
|
829
|
+
rewrite(
|
|
830
|
+
"SELECT type, size, mode, mime_type, symlink_target, created_at, updated_at FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
831
|
+
this.dialect,
|
|
832
|
+
),
|
|
833
|
+
[this.agentId, tenantId, path],
|
|
834
|
+
);
|
|
835
|
+
if (!row) return undefined;
|
|
836
|
+
return {
|
|
837
|
+
type: row.type as VfsStat["type"],
|
|
838
|
+
size: row.size,
|
|
839
|
+
mode: row.mode,
|
|
840
|
+
mimeType: row.mime_type ?? undefined,
|
|
841
|
+
symlinkTarget: row.symlink_target ?? undefined,
|
|
842
|
+
createdAt: new Date(row.created_at).getTime(),
|
|
843
|
+
updatedAt: new Date(row.updated_at).getTime(),
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
listAllPaths: (_tenantId: string): string[] => {
|
|
848
|
+
// Default: return empty. Overridden by SQLite (sync query) and PostgreSQL (cache).
|
|
849
|
+
return [];
|
|
850
|
+
},
|
|
851
|
+
|
|
852
|
+
getUsage: async (
|
|
853
|
+
tenantId: string,
|
|
854
|
+
): Promise<{ fileCount: number; totalBytes: number }> => {
|
|
855
|
+
const row = await this.executor.get<{ cnt: number; total: number }>(
|
|
856
|
+
rewrite(
|
|
857
|
+
"SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as total FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND type = 'file'",
|
|
858
|
+
this.dialect,
|
|
859
|
+
),
|
|
860
|
+
[this.agentId, tenantId],
|
|
861
|
+
);
|
|
862
|
+
return { fileCount: Number(row?.cnt ?? 0), totalBytes: Number(row?.total ?? 0) };
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// -----------------------------------------------------------------------
|
|
867
|
+
// Private helpers
|
|
868
|
+
// -----------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
private parseConversation(data: unknown): Conversation {
|
|
871
|
+
if (typeof data === "string") return JSON.parse(data);
|
|
872
|
+
return data as Conversation;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private rowToSummary(row: QueryRow): ConversationSummary {
|
|
876
|
+
const data = this.parseConversation(row.data);
|
|
877
|
+
const tid = row.tenant_id as string;
|
|
878
|
+
return {
|
|
879
|
+
conversationId: row.id as string,
|
|
880
|
+
title: row.title as string,
|
|
881
|
+
updatedAt: new Date(row.updated_at as string).getTime(),
|
|
882
|
+
createdAt: row.created_at ? new Date(row.created_at as string).getTime() : undefined,
|
|
883
|
+
ownerId: row.owner_id as string,
|
|
884
|
+
tenantId: tid === DEFAULT_TENANT ? null : tid,
|
|
885
|
+
messageCount: row.message_count as number,
|
|
886
|
+
hasPendingApprovals: (data.pendingApprovals?.length ?? 0) > 0,
|
|
887
|
+
parentConversationId: data.parentConversationId,
|
|
888
|
+
channelMeta: data.channelMeta,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private rowToReminder(row: QueryRow): Reminder {
|
|
893
|
+
const tid = row.tenant_id as string;
|
|
894
|
+
return {
|
|
895
|
+
id: row.id as string,
|
|
896
|
+
task: row.task as string,
|
|
897
|
+
scheduledAt: row.scheduled_at as number,
|
|
898
|
+
timezone: (row.timezone as string) ?? undefined,
|
|
899
|
+
status: row.status as Reminder["status"],
|
|
900
|
+
createdAt: new Date(row.created_at as string).getTime(),
|
|
901
|
+
conversationId: row.conversation_id as string,
|
|
902
|
+
ownerId: (row.owner_id as string) ?? undefined,
|
|
903
|
+
tenantId: tid === DEFAULT_TENANT ? null : tid,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
protected toUint8Array(value: unknown): Uint8Array {
|
|
908
|
+
if (value instanceof Uint8Array) return value;
|
|
909
|
+
if (value instanceof Buffer) return new Uint8Array(value);
|
|
910
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
911
|
+
if (typeof value === "string") return new TextEncoder().encode(value);
|
|
912
|
+
return new Uint8Array();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async ensureParentDirs(tenantId: string, path: string): Promise<void> {
|
|
916
|
+
const parts = path.split("/").filter(Boolean);
|
|
917
|
+
// Don't create the file/dir itself, only parents
|
|
918
|
+
parts.pop();
|
|
919
|
+
let current = "";
|
|
920
|
+
for (const part of parts) {
|
|
921
|
+
current += `/${part}`;
|
|
922
|
+
const exists = await this.vfs.stat(tenantId, current);
|
|
923
|
+
if (!exists) {
|
|
924
|
+
await this.mkdirSingle(tenantId, current);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private async mkdirSingle(tenantId: string, path: string): Promise<void> {
|
|
930
|
+
const pp = parentOf(path);
|
|
931
|
+
const now = new Date().toISOString();
|
|
932
|
+
await this.executor.run(
|
|
933
|
+
rewrite(
|
|
934
|
+
`INSERT INTO vfs_entries (agent_id, tenant_id, path, parent_path, type, size, mode, created_at, updated_at)
|
|
935
|
+
VALUES ($1, $2, $3, $4, 'directory', 0, 493, $5, $6)
|
|
936
|
+
${this.dialect.upsert(["agent_id", "tenant_id", "path"])}
|
|
937
|
+
updated_at = excluded.updated_at`,
|
|
938
|
+
this.dialect,
|
|
939
|
+
),
|
|
940
|
+
[this.agentId, tenantId, path, pp, now, now],
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private async resolveSymlink(
|
|
945
|
+
tenantId: string,
|
|
946
|
+
path: string,
|
|
947
|
+
depth = 0,
|
|
948
|
+
): Promise<string> {
|
|
949
|
+
if (depth > 20) throw new Error(`ELOOP: too many levels of symbolic links, open '${path}'`);
|
|
950
|
+
const row = await this.executor.get<{ symlink_target: string | null; type: string }>(
|
|
951
|
+
rewrite(
|
|
952
|
+
"SELECT symlink_target, type FROM vfs_entries WHERE agent_id = $1 AND tenant_id = $2 AND path = $3",
|
|
953
|
+
this.dialect,
|
|
954
|
+
),
|
|
955
|
+
[this.agentId, tenantId, path],
|
|
956
|
+
);
|
|
957
|
+
if (!row || row.type !== "symlink" || !row.symlink_target) return path;
|
|
958
|
+
const target = row.symlink_target.startsWith("/")
|
|
959
|
+
? row.symlink_target
|
|
960
|
+
: `${parentOf(path)}/${row.symlink_target}`;
|
|
961
|
+
return this.resolveSymlink(tenantId, target, depth + 1);
|
|
962
|
+
}
|
|
963
|
+
}
|