@rynx-ai/daemon 0.1.11-beta.25 → 0.1.11-beta.26
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/bundled-plugins/plugins/lark/package.json +1 -1
- package/dist/daemon-server.js +4 -1
- package/dist/db.js +80 -1
- package/dist/plugin-host-rpc.d.ts +13 -2
- package/dist/plugin-host-rpc.js +172 -12
- package/dist/session-fork-store.d.ts +1 -0
- package/dist/session-fork-store.js +58 -20
- package/dist/session-log-store.d.ts +1 -0
- package/dist/session-log-store.js +17 -15
- package/dist/session-resource-store.d.ts +20 -6
- package/dist/session-resource-store.js +361 -66
- package/package.json +9 -9
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type UserContentPart } from "@rynx-ai/core";
|
|
1
|
+
import { type SessionImageFileReference, type UserContentPart } from "@rynx-ai/core";
|
|
2
2
|
import { type RemoteRuntimeSessionResourceDeleteParams, type RemoteRuntimeSessionResourceDeleteResult, type RemoteRuntimeSessionResourceGetParams, type RemoteRuntimeSessionResourceGetResult, type RemoteRuntimeSessionResourcePolicyResult, type RemoteRuntimeSessionResourceReadParams, type RemoteRuntimeSessionResourceReadResult, type RemoteRuntimeSessionResourceUploadBeginParams, type RemoteRuntimeSessionResourceUploadBeginResult, type RemoteRuntimeSessionResourceUploadChunkParams, type RemoteRuntimeSessionResourceUploadChunkResult, type RemoteRuntimeSessionResourceUploadCommitParams, type RemoteRuntimeSessionResourceUploadCommitResult } from "@rynx-ai/protocol/remote-runtime-rpc";
|
|
3
|
-
import { type MachineSessionPreparedInput, type MachineSessionResourcePort } from "@rynx-ai/server";
|
|
3
|
+
import { type MachineSessionPreparedInput, type MachineSessionResourcePort, type MachineSessionRunExecutionSelection, type MachineSessionRunExecutionSnapshot } from "@rynx-ai/server";
|
|
4
4
|
export declare class SqliteSessionResourceStore implements MachineSessionResourcePort {
|
|
5
5
|
private readonly root;
|
|
6
6
|
constructor(root?: string);
|
|
@@ -9,18 +9,32 @@ export declare class SqliteSessionResourceStore implements MachineSessionResourc
|
|
|
9
9
|
writeUploadChunk(input: RemoteRuntimeSessionResourceUploadChunkParams): Promise<RemoteRuntimeSessionResourceUploadChunkResult>;
|
|
10
10
|
commitUpload(input: RemoteRuntimeSessionResourceUploadCommitParams): Promise<RemoteRuntimeSessionResourceUploadCommitResult>;
|
|
11
11
|
getResource(input: RemoteRuntimeSessionResourceGetParams): Promise<RemoteRuntimeSessionResourceGetResult>;
|
|
12
|
+
resolveCommittedLocalPath(sessionId: string, resourceId: string): string;
|
|
12
13
|
readResource(input: RemoteRuntimeSessionResourceReadParams): Promise<RemoteRuntimeSessionResourceReadResult>;
|
|
13
14
|
deleteResource(input: RemoteRuntimeSessionResourceDeleteParams): Promise<RemoteRuntimeSessionResourceDeleteResult>;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
materializeGeneratedImage(input: {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
sourceId: string;
|
|
18
|
+
result: string;
|
|
19
|
+
}): Promise<SessionImageFileReference>;
|
|
20
|
+
prepareInput(sessionId: string, content: UserContentPart[], clientMessageId?: string, operationExecution?: {
|
|
21
|
+
selection: MachineSessionRunExecutionSelection;
|
|
22
|
+
snapshot: MachineSessionRunExecutionSnapshot;
|
|
23
|
+
}): Promise<MachineSessionPreparedInput>;
|
|
24
|
+
markMessageInjecting(sessionId: string, clientMessageId: string, identity?: {
|
|
25
|
+
responseId: string;
|
|
26
|
+
messageItemId: string;
|
|
27
|
+
}): void;
|
|
28
|
+
markMessageInjected(sessionId: string, clientMessageId: string, identity?: {
|
|
29
|
+
responseId: string;
|
|
30
|
+
}): void;
|
|
17
31
|
markMessageOutcomeUnknown(sessionId: string, clientMessageId: string, reason: string): void;
|
|
18
32
|
markMessageFailedNotStarted(sessionId: string, clientMessageId: string, reason: string): void;
|
|
19
33
|
deleteSession(sessionId: string): void;
|
|
20
34
|
private prepareMessageOperation;
|
|
21
35
|
private bindOperationResources;
|
|
22
36
|
private transitionOperation;
|
|
23
|
-
private
|
|
37
|
+
private finalizeStoredFile;
|
|
24
38
|
private reconcileUploadOffset;
|
|
25
39
|
private rowByUpload;
|
|
26
40
|
private requireUpload;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
2
|
+
import { chmodSync, closeSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { rynxHome, } from "@rynx-ai/core";
|
|
5
|
-
import { defaultSessionResourcePolicy, SESSION_RESOURCE_MAX_IMAGE_PIXELS, SESSION_RESOURCE_MAX_IMAGE_SIDE, SESSION_RESOURCE_MAX_MESSAGE_BYTES, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, } from "@rynx-ai/protocol/remote-runtime-rpc";
|
|
5
|
+
import { defaultSessionResourcePolicy, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_IMAGE_PIXELS, SESSION_RESOURCE_MAX_IMAGE_SIDE, SESSION_RESOURCE_MAX_MESSAGE_BYTES, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, } from "@rynx-ai/protocol/remote-runtime-rpc";
|
|
6
6
|
import { MachineSessionServiceFailure, } from "@rynx-ai/server";
|
|
7
7
|
import { db } from "./db.js";
|
|
8
8
|
import { transaction } from "./sqlite.js";
|
|
@@ -26,7 +26,8 @@ export class SqliteSessionResourceStore {
|
|
|
26
26
|
WHERE session_id = ? AND client_upload_id = ?`)
|
|
27
27
|
.get(input.sessionId, input.clientUploadId);
|
|
28
28
|
if (existing) {
|
|
29
|
-
if (existing.
|
|
29
|
+
if (existing.kind !== resourceKind(input.type) ||
|
|
30
|
+
existing.filename !== filename ||
|
|
30
31
|
existing.media_type !== input.mediaType ||
|
|
31
32
|
existing.byte_length !== input.byteLength) {
|
|
32
33
|
throw conflict("clientUploadId was already used for different content");
|
|
@@ -44,23 +45,27 @@ export class SqliteSessionResourceStore {
|
|
|
44
45
|
: { resource: toMetadata(refreshed) }),
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
if (input.type === "input_image" && input.byteLength > SESSION_RESOURCE_MAX_IMAGE_BYTES) {
|
|
49
|
+
throw invalid("Image exceeds the Session resource policy");
|
|
50
|
+
}
|
|
47
51
|
const staged = db()
|
|
48
|
-
.prepare(`SELECT COUNT(*) AS count,
|
|
52
|
+
.prepare(`SELECT COUNT(*) AS count,
|
|
53
|
+
COALESCE(SUM(CASE WHEN kind = 'image' THEN byte_length ELSE 0 END), 0) AS bytes
|
|
49
54
|
FROM session_resources
|
|
50
55
|
WHERE session_id = ? AND state IN ('uploading', 'staged')`)
|
|
51
56
|
.get(input.sessionId);
|
|
52
57
|
if (staged.count >= MAX_STAGED_RESOURCES_PER_SESSION ||
|
|
53
|
-
staged.bytes + input.byteLength > SESSION_RESOURCE_MAX_MESSAGE_BYTES) {
|
|
54
|
-
throw unavailable("Session staged
|
|
58
|
+
(input.type === "input_image" && staged.bytes + input.byteLength > SESSION_RESOURCE_MAX_MESSAGE_BYTES)) {
|
|
59
|
+
throw unavailable("Session staged resource quota exceeded");
|
|
55
60
|
}
|
|
56
61
|
const resourceId = `res_${randomUUID()}`;
|
|
57
62
|
const uploadId = `upl_${randomUUID()}`;
|
|
58
63
|
const createdAt = Date.now();
|
|
59
64
|
ensurePrivateDirectory(this.sessionDirectory(input.sessionId));
|
|
60
65
|
db().prepare(`INSERT INTO session_resources
|
|
61
|
-
(id, session_id, client_upload_id, upload_id, filename, media_type,
|
|
66
|
+
(id, session_id, client_upload_id, upload_id, kind, filename, media_type,
|
|
62
67
|
byte_length, uploaded_bytes, state, created_at)
|
|
63
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'uploading', ?)`).run(resourceId, input.sessionId, input.clientUploadId, uploadId, filename, input.mediaType, input.byteLength, createdAt);
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 'uploading', ?)`).run(resourceId, input.sessionId, input.clientUploadId, uploadId, resourceKind(input.type), filename, input.mediaType, input.byteLength, createdAt);
|
|
64
69
|
return { uploadId, resourceId, offset: 0, complete: false };
|
|
65
70
|
}
|
|
66
71
|
async writeUploadChunk(input) {
|
|
@@ -123,23 +128,25 @@ export class SqliteSessionResourceStore {
|
|
|
123
128
|
const partial = this.partialPath(row);
|
|
124
129
|
const final = this.finalPath(row);
|
|
125
130
|
if (!existsSync(partial) && existsSync(final)) {
|
|
126
|
-
|
|
127
|
-
return { resource: this.finalizeUpload(row, bytes) };
|
|
128
|
-
}
|
|
129
|
-
const bytes = readFileSync(partial);
|
|
130
|
-
const image = inspectImage(bytes, row.media_type);
|
|
131
|
-
if (image.width > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
|
|
132
|
-
image.height > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
|
|
133
|
-
image.width * image.height > SESSION_RESOURCE_MAX_IMAGE_PIXELS) {
|
|
134
|
-
throw invalid("Image dimensions exceed the Session resource policy");
|
|
131
|
+
return { resource: await this.finalizeStoredFile(row, final) };
|
|
135
132
|
}
|
|
133
|
+
const inspected = row.kind === "image"
|
|
134
|
+
? inspectImage(readFileSync(partial), imageMediaType(row.media_type))
|
|
135
|
+
: undefined;
|
|
136
|
+
validateImageDimensions(inspected);
|
|
136
137
|
renameSync(partial, final);
|
|
137
138
|
chmodSync(final, 0o600);
|
|
138
|
-
return { resource: this.
|
|
139
|
+
return { resource: await this.finalizeStoredFile(row, final, inspected) };
|
|
139
140
|
}
|
|
140
141
|
async getResource(input) {
|
|
141
142
|
return { resource: toMetadata(this.requireResource(input.sessionId, input.resourceId)) };
|
|
142
143
|
}
|
|
144
|
+
resolveCommittedLocalPath(sessionId, resourceId) {
|
|
145
|
+
const row = this.requireResource(sessionId, resourceId);
|
|
146
|
+
if (row.state !== "committed")
|
|
147
|
+
throw unavailable("Resource is not committed");
|
|
148
|
+
return this.finalPath(row);
|
|
149
|
+
}
|
|
143
150
|
async readResource(input) {
|
|
144
151
|
const row = this.requireResource(input.sessionId, input.resourceId);
|
|
145
152
|
if (row.state === "uploading")
|
|
@@ -174,7 +181,56 @@ export class SqliteSessionResourceStore {
|
|
|
174
181
|
db().prepare("DELETE FROM session_resources WHERE id = ?").run(row.id);
|
|
175
182
|
return { deleted: true };
|
|
176
183
|
}
|
|
177
|
-
async
|
|
184
|
+
async materializeGeneratedImage(input) {
|
|
185
|
+
const parsed = decodeGeneratedImage(input.result);
|
|
186
|
+
const clientUploadId = `gen_${createHash("sha256")
|
|
187
|
+
.update(`${input.sessionId}\0${input.sourceId}`)
|
|
188
|
+
.digest("hex")}`;
|
|
189
|
+
const begun = await this.beginUpload({
|
|
190
|
+
sessionId: input.sessionId,
|
|
191
|
+
clientUploadId,
|
|
192
|
+
type: "input_image",
|
|
193
|
+
filename: `generated-image.${imageExtension(parsed.mediaType)}`,
|
|
194
|
+
mediaType: parsed.mediaType,
|
|
195
|
+
byteLength: parsed.bytes.byteLength,
|
|
196
|
+
});
|
|
197
|
+
const expectedSha256 = createHash("sha256").update(parsed.bytes).digest("hex");
|
|
198
|
+
if (begun.complete && begun.resource?.sha256 !== expectedSha256) {
|
|
199
|
+
throw conflict("Generated image source changed after it was materialized");
|
|
200
|
+
}
|
|
201
|
+
if (!begun.complete) {
|
|
202
|
+
if (begun.offset > 0) {
|
|
203
|
+
const row = this.requireUpload(input.sessionId, begun.uploadId);
|
|
204
|
+
const existing = readFileSync(this.partialPath(row));
|
|
205
|
+
if (existing.byteLength !== begun.offset ||
|
|
206
|
+
!existing.equals(parsed.bytes.subarray(0, begun.offset))) {
|
|
207
|
+
throw conflict("Generated image source changed while it was being materialized");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let offset = begun.offset;
|
|
211
|
+
while (offset < parsed.bytes.byteLength) {
|
|
212
|
+
const chunk = parsed.bytes.subarray(offset, Math.min(offset + SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, parsed.bytes.byteLength));
|
|
213
|
+
const written = await this.writeUploadChunk({
|
|
214
|
+
sessionId: input.sessionId,
|
|
215
|
+
uploadId: begun.uploadId,
|
|
216
|
+
offset,
|
|
217
|
+
data: chunk.toString("base64"),
|
|
218
|
+
});
|
|
219
|
+
offset = written.offset;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const metadata = begun.complete && begun.resource
|
|
223
|
+
? begun.resource
|
|
224
|
+
: (await this.commitUpload({
|
|
225
|
+
sessionId: input.sessionId,
|
|
226
|
+
uploadId: begun.uploadId,
|
|
227
|
+
})).resource;
|
|
228
|
+
if (metadata.type !== "input_image")
|
|
229
|
+
throw conflict("Generated image resource changed type");
|
|
230
|
+
const { type: _type, state: _state, sha256: _sha256, createdAt: _createdAt, ...reference } = metadata;
|
|
231
|
+
return reference;
|
|
232
|
+
}
|
|
233
|
+
async prepareInput(sessionId, content, clientMessageId, operationExecution) {
|
|
178
234
|
const runtimeContent = [];
|
|
179
235
|
const canonicalContent = [];
|
|
180
236
|
const resourceIds = [];
|
|
@@ -187,55 +243,118 @@ export class SqliteSessionResourceStore {
|
|
|
187
243
|
title ||= part.text.trim();
|
|
188
244
|
continue;
|
|
189
245
|
}
|
|
246
|
+
if ("path" in part && part.path !== undefined) {
|
|
247
|
+
if (!isAbsolute(part.path))
|
|
248
|
+
throw invalid("File path must be absolute");
|
|
249
|
+
const stats = statSync(part.path);
|
|
250
|
+
if (!stats.isFile())
|
|
251
|
+
throw invalid("File path must reference a regular file");
|
|
252
|
+
const canonical = {
|
|
253
|
+
...part,
|
|
254
|
+
byteLength: part.byteLength ?? stats.size,
|
|
255
|
+
};
|
|
256
|
+
canonicalContent.push(canonical);
|
|
257
|
+
runtimeContent.push({
|
|
258
|
+
type: part.type === "input_image" ? "local_image" : "local_file",
|
|
259
|
+
path: part.path,
|
|
260
|
+
resource: canonical,
|
|
261
|
+
});
|
|
262
|
+
title ||= part.filename ?? basename(part.path);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
190
265
|
const row = this.requireResource(sessionId, part.resourceId);
|
|
191
266
|
if (row.state === "uploading")
|
|
192
|
-
throw unavailable("
|
|
193
|
-
const resource =
|
|
194
|
-
if (resource.
|
|
267
|
+
throw unavailable("Resource upload is incomplete");
|
|
268
|
+
const resource = toResourcePart(row);
|
|
269
|
+
if (resource.type !== part.type ||
|
|
270
|
+
resource.mediaType !== part.mediaType ||
|
|
195
271
|
(part.byteLength !== undefined && part.byteLength !== resource.byteLength)) {
|
|
196
|
-
throw conflict("
|
|
272
|
+
throw conflict("Resource reference metadata does not match the stored resource");
|
|
197
273
|
}
|
|
198
|
-
|
|
274
|
+
if (row.kind === "image")
|
|
275
|
+
totalImageBytes += row.byte_length;
|
|
199
276
|
resourceIds.push(row.id);
|
|
200
277
|
canonicalContent.push(resource);
|
|
201
278
|
runtimeContent.push({
|
|
202
|
-
type: "local_image",
|
|
279
|
+
type: resource.type === "input_image" ? "local_image" : "local_file",
|
|
203
280
|
path: this.finalPath(row),
|
|
204
|
-
resource,
|
|
281
|
+
resource: resource,
|
|
205
282
|
});
|
|
206
283
|
title ||= row.filename;
|
|
207
284
|
}
|
|
208
285
|
if (runtimeContent.length === 0 || totalImageBytes > SESSION_RESOURCE_MAX_MESSAGE_BYTES) {
|
|
209
286
|
throw invalid("Message content is empty or exceeds the image byte limit");
|
|
210
287
|
}
|
|
211
|
-
if (!runtimeContent.some((part) => part.type
|
|
212
|
-
throw invalid("Message content must contain text or
|
|
288
|
+
if (!runtimeContent.some((part) => part.type !== "text" || part.text.trim())) {
|
|
289
|
+
throw invalid("Message content must contain text or a file");
|
|
213
290
|
}
|
|
214
291
|
let operationState;
|
|
215
292
|
if (clientMessageId) {
|
|
216
|
-
const requestHash = hashMessageContent(canonicalContent);
|
|
217
|
-
|
|
293
|
+
const requestHash = hashMessageContent(canonicalContent, operationExecution?.selection);
|
|
294
|
+
const operation = this.prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds, operationExecution?.snapshot);
|
|
295
|
+
operationState = operation.state;
|
|
296
|
+
return {
|
|
297
|
+
input: {
|
|
298
|
+
content: runtimeContent,
|
|
299
|
+
clientMessageId,
|
|
300
|
+
},
|
|
301
|
+
title: title || "File",
|
|
302
|
+
operationState,
|
|
303
|
+
...(operation.responseId ? { responseId: operation.responseId } : {}),
|
|
304
|
+
...(operation.messageItemId ? { messageItemId: operation.messageItemId } : {}),
|
|
305
|
+
...(operation.execution ? { operationExecution: operation.execution } : {}),
|
|
306
|
+
};
|
|
218
307
|
}
|
|
219
308
|
return {
|
|
220
309
|
input: {
|
|
221
310
|
content: runtimeContent,
|
|
222
311
|
...(clientMessageId ? { clientMessageId } : {}),
|
|
223
312
|
},
|
|
224
|
-
title: title || "
|
|
313
|
+
title: title || "File",
|
|
225
314
|
...(operationState ? { operationState } : {}),
|
|
226
315
|
};
|
|
227
316
|
}
|
|
228
|
-
markMessageInjecting(sessionId, clientMessageId) {
|
|
229
|
-
|
|
317
|
+
markMessageInjecting(sessionId, clientMessageId, identity) {
|
|
318
|
+
const current = db().prepare(`SELECT state, response_id, message_item_id
|
|
319
|
+
FROM session_message_operations
|
|
320
|
+
WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
|
|
321
|
+
if (!current ||
|
|
322
|
+
(identity && current.response_id && current.response_id !== identity.responseId) ||
|
|
323
|
+
(identity && current.message_item_id && current.message_item_id !== identity.messageItemId)) {
|
|
324
|
+
throw conflict("Message operation identity changed");
|
|
325
|
+
}
|
|
326
|
+
const result = db().prepare(`UPDATE session_message_operations
|
|
327
|
+
SET state = 'injecting', error = NULL,
|
|
328
|
+
response_id = COALESCE(response_id, ?),
|
|
329
|
+
message_item_id = COALESCE(message_item_id, ?),
|
|
330
|
+
updated_at = ?
|
|
331
|
+
WHERE session_id = ? AND client_message_id = ?
|
|
332
|
+
AND state IN ('prepared', 'failed_not_started')`).run(identity?.responseId ?? null, identity?.messageItemId ?? null, Date.now(), sessionId, clientMessageId);
|
|
333
|
+
if (result.changes === 0 && current.state !== "injecting" && current.state !== "mirrored") {
|
|
334
|
+
throw conflict("Message operation state changed");
|
|
335
|
+
}
|
|
230
336
|
}
|
|
231
|
-
markMessageInjected(sessionId, clientMessageId) {
|
|
232
|
-
|
|
337
|
+
markMessageInjected(sessionId, clientMessageId, identity) {
|
|
338
|
+
const current = db().prepare(`SELECT state, response_id
|
|
339
|
+
FROM session_message_operations
|
|
340
|
+
WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
|
|
341
|
+
if (!current ||
|
|
342
|
+
(identity && current.response_id && current.response_id !== identity.responseId)) {
|
|
343
|
+
throw conflict("Message operation identity changed");
|
|
344
|
+
}
|
|
345
|
+
const result = db().prepare(`UPDATE session_message_operations
|
|
346
|
+
SET state = 'injected', error = NULL,
|
|
347
|
+
response_id = COALESCE(response_id, ?),
|
|
348
|
+
updated_at = ?
|
|
349
|
+
WHERE session_id = ? AND client_message_id = ? AND state = 'injecting'`).run(identity?.responseId ?? null, Date.now(), sessionId, clientMessageId);
|
|
350
|
+
if (result.changes !== 1)
|
|
351
|
+
throw conflict("Message operation state changed");
|
|
233
352
|
}
|
|
234
353
|
markMessageOutcomeUnknown(sessionId, clientMessageId, reason) {
|
|
235
354
|
this.transitionOperation(sessionId, clientMessageId, ["injecting"], "outcome_unknown", reason);
|
|
236
355
|
}
|
|
237
356
|
markMessageFailedNotStarted(sessionId, clientMessageId, reason) {
|
|
238
|
-
this.transitionOperation(sessionId, clientMessageId, ["prepared", "injecting"], "failed_not_started", reason);
|
|
357
|
+
this.transitionOperation(sessionId, clientMessageId, ["prepared", "injecting", "mirrored"], "failed_not_started", reason);
|
|
239
358
|
}
|
|
240
359
|
deleteSession(sessionId) {
|
|
241
360
|
const rows = db()
|
|
@@ -251,31 +370,45 @@ export class SqliteSessionResourceStore {
|
|
|
251
370
|
})();
|
|
252
371
|
rmSync(this.sessionDirectory(sessionId), { recursive: true, force: true });
|
|
253
372
|
}
|
|
254
|
-
prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds) {
|
|
373
|
+
prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds, execution) {
|
|
255
374
|
const conn = db();
|
|
256
375
|
return transaction(conn, () => {
|
|
257
|
-
const existing = conn.prepare(`SELECT request_hash, state
|
|
376
|
+
const existing = conn.prepare(`SELECT request_hash, state, response_id, message_item_id, execution_snapshot
|
|
258
377
|
FROM session_message_operations
|
|
259
378
|
WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
|
|
260
379
|
if (existing) {
|
|
380
|
+
const storedExecution = parseOperationExecution(existing.execution_snapshot);
|
|
261
381
|
if (existing.request_hash !== requestHash) {
|
|
262
382
|
throw conflict("clientMessageId was already used for different content");
|
|
263
383
|
}
|
|
264
384
|
if (existing.state === "failed_not_started") {
|
|
385
|
+
const retryExecution = storedExecution ?? execution;
|
|
265
386
|
this.bindOperationResources(conn, sessionId, clientMessageId, resourceIds);
|
|
266
387
|
conn.prepare(`UPDATE session_message_operations
|
|
267
|
-
SET state = 'prepared', error = NULL,
|
|
268
|
-
|
|
269
|
-
|
|
388
|
+
SET state = 'prepared', error = NULL,
|
|
389
|
+
execution_snapshot = COALESCE(execution_snapshot, ?), updated_at = ?
|
|
390
|
+
WHERE session_id = ? AND client_message_id = ?`).run(execution ? JSON.stringify(execution) : null, Date.now(), sessionId, clientMessageId);
|
|
391
|
+
return {
|
|
392
|
+
state: "prepared",
|
|
393
|
+
...(existing.response_id ? { responseId: existing.response_id } : {}),
|
|
394
|
+
...(existing.message_item_id ? { messageItemId: existing.message_item_id } : {}),
|
|
395
|
+
...(retryExecution ? { execution: retryExecution } : {}),
|
|
396
|
+
};
|
|
270
397
|
}
|
|
271
|
-
return
|
|
398
|
+
return {
|
|
399
|
+
state: existing.state,
|
|
400
|
+
...(existing.response_id ? { responseId: existing.response_id } : {}),
|
|
401
|
+
...(existing.message_item_id ? { messageItemId: existing.message_item_id } : {}),
|
|
402
|
+
...(storedExecution ? { execution: storedExecution } : {}),
|
|
403
|
+
};
|
|
272
404
|
}
|
|
273
405
|
const now = Date.now();
|
|
274
406
|
conn.prepare(`INSERT INTO session_message_operations
|
|
275
|
-
(session_id, client_message_id, request_hash, state,
|
|
276
|
-
|
|
407
|
+
(session_id, client_message_id, request_hash, state, execution_snapshot,
|
|
408
|
+
created_at, updated_at)
|
|
409
|
+
VALUES (?, ?, ?, 'prepared', ?, ?, ?)`).run(sessionId, clientMessageId, requestHash, execution ? JSON.stringify(execution) : null, now, now);
|
|
277
410
|
this.bindOperationResources(conn, sessionId, clientMessageId, resourceIds);
|
|
278
|
-
return "prepared";
|
|
411
|
+
return { state: "prepared", ...(execution ? { execution } : {}) };
|
|
279
412
|
})();
|
|
280
413
|
}
|
|
281
414
|
bindOperationResources(conn, sessionId, clientMessageId, resourceIds) {
|
|
@@ -318,12 +451,12 @@ export class SqliteSessionResourceStore {
|
|
|
318
451
|
throw conflict("Message operation state changed");
|
|
319
452
|
}
|
|
320
453
|
}
|
|
321
|
-
|
|
322
|
-
const digest =
|
|
454
|
+
async finalizeStoredFile(row, file, inspected) {
|
|
455
|
+
const digest = await hashFile(file);
|
|
323
456
|
db().prepare(`UPDATE session_resources
|
|
324
457
|
SET state = 'staged', uploaded_bytes = byte_length, sha256 = ?,
|
|
325
458
|
width = ?, height = ?
|
|
326
|
-
WHERE id = ? AND state = 'uploading'`).run(digest, inspected
|
|
459
|
+
WHERE id = ? AND state = 'uploading'`).run(digest, inspected?.width ?? null, inspected?.height ?? null, row.id);
|
|
327
460
|
return toMetadata(this.requireResource(row.session_id, row.id));
|
|
328
461
|
}
|
|
329
462
|
reconcileUploadOffset(row) {
|
|
@@ -369,7 +502,7 @@ export class SqliteSessionResourceStore {
|
|
|
369
502
|
return join(this.sessionDirectory(row.session_id), `${row.id}.part`);
|
|
370
503
|
}
|
|
371
504
|
finalPath(row) {
|
|
372
|
-
return join(this.sessionDirectory(row.session_id), `${row.id}.${
|
|
505
|
+
return join(this.sessionDirectory(row.session_id), `${row.id}.${resourceExtension(row)}`);
|
|
373
506
|
}
|
|
374
507
|
sweepExpiredStagedResources(now = Date.now()) {
|
|
375
508
|
const rows = db()
|
|
@@ -383,17 +516,25 @@ export class SqliteSessionResourceStore {
|
|
|
383
516
|
}
|
|
384
517
|
}
|
|
385
518
|
}
|
|
386
|
-
function
|
|
387
|
-
if (row.state === "uploading" ||
|
|
388
|
-
!row.sha256 ||
|
|
389
|
-
!row.width ||
|
|
390
|
-
!row.height) {
|
|
519
|
+
function toResourcePart(row) {
|
|
520
|
+
if (row.state === "uploading" || !row.sha256) {
|
|
391
521
|
throw unavailable("Resource upload is incomplete");
|
|
392
522
|
}
|
|
523
|
+
if (row.kind === "file") {
|
|
524
|
+
return {
|
|
525
|
+
type: "input_file",
|
|
526
|
+
resourceId: row.id,
|
|
527
|
+
mediaType: row.media_type,
|
|
528
|
+
filename: row.filename,
|
|
529
|
+
byteLength: row.byte_length,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (!row.width || !row.height)
|
|
533
|
+
throw unavailable("Image upload is incomplete");
|
|
393
534
|
return {
|
|
394
535
|
type: "input_image",
|
|
395
536
|
resourceId: row.id,
|
|
396
|
-
mediaType: row.media_type,
|
|
537
|
+
mediaType: imageMediaType(row.media_type),
|
|
397
538
|
filename: row.filename,
|
|
398
539
|
byteLength: row.byte_length,
|
|
399
540
|
width: row.width,
|
|
@@ -401,7 +542,7 @@ function toImagePart(row) {
|
|
|
401
542
|
};
|
|
402
543
|
}
|
|
403
544
|
function toMetadata(row) {
|
|
404
|
-
const part =
|
|
545
|
+
const part = toResourcePart(row);
|
|
405
546
|
if (row.state === "uploading")
|
|
406
547
|
throw unavailable("Resource upload is incomplete");
|
|
407
548
|
return {
|
|
@@ -411,13 +552,38 @@ function toMetadata(row) {
|
|
|
411
552
|
createdAt: row.created_at,
|
|
412
553
|
};
|
|
413
554
|
}
|
|
414
|
-
function hashMessageContent(content) {
|
|
415
|
-
|
|
416
|
-
.update(JSON.stringify(content.map((part) => part.type === "input_text"
|
|
555
|
+
function hashMessageContent(content, execution) {
|
|
556
|
+
const canonicalContent = content.map((part) => part.type === "input_text"
|
|
417
557
|
? { type: part.type, text: part.text }
|
|
418
|
-
:
|
|
558
|
+
: "resourceId" in part
|
|
559
|
+
? { type: part.type, resourceId: part.resourceId }
|
|
560
|
+
: { type: part.type, path: part.path });
|
|
561
|
+
const hasExecutionSelection = execution && (Object.prototype.hasOwnProperty.call(execution, "model") ||
|
|
562
|
+
Object.prototype.hasOwnProperty.call(execution, "reasoningEffort"));
|
|
563
|
+
return createHash("sha256")
|
|
564
|
+
.update(JSON.stringify(hasExecutionSelection
|
|
565
|
+
? { content: canonicalContent, execution }
|
|
566
|
+
: canonicalContent))
|
|
419
567
|
.digest("hex");
|
|
420
568
|
}
|
|
569
|
+
function parseOperationExecution(value) {
|
|
570
|
+
if (!value)
|
|
571
|
+
return undefined;
|
|
572
|
+
try {
|
|
573
|
+
const parsed = JSON.parse(value);
|
|
574
|
+
if ((parsed.model !== null && typeof parsed.model !== "string") ||
|
|
575
|
+
(parsed.reasoningEffort !== null && typeof parsed.reasoningEffort !== "string")) {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
model: parsed.model,
|
|
580
|
+
reasoningEffort: parsed.reasoningEffort,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
421
587
|
function inspectImage(bytes, declared) {
|
|
422
588
|
const sniffed = sniffMediaType(bytes);
|
|
423
589
|
if (sniffed !== declared)
|
|
@@ -426,14 +592,22 @@ function inspectImage(bytes, declared) {
|
|
|
426
592
|
? validatePngContainer(bytes)
|
|
427
593
|
: sniffed === "image/jpeg"
|
|
428
594
|
? validateJpegContainer(bytes)
|
|
429
|
-
:
|
|
595
|
+
: sniffed === "image/webp"
|
|
596
|
+
? validateWebpContainer(bytes)
|
|
597
|
+
: sniffed === "image/gif"
|
|
598
|
+
? validateGifContainer(bytes)
|
|
599
|
+
: validateSvgContainer(bytes);
|
|
430
600
|
if (!structurallyValid)
|
|
431
601
|
throw invalid("Image file is incomplete or malformed");
|
|
432
602
|
const dimensions = sniffed === "image/png"
|
|
433
603
|
? pngDimensions(bytes)
|
|
434
604
|
: sniffed === "image/jpeg"
|
|
435
605
|
? jpegDimensions(bytes)
|
|
436
|
-
:
|
|
606
|
+
: sniffed === "image/webp"
|
|
607
|
+
? webpDimensions(bytes)
|
|
608
|
+
: sniffed === "image/gif"
|
|
609
|
+
? gifDimensions(bytes)
|
|
610
|
+
: svgDimensions(bytes);
|
|
437
611
|
if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) {
|
|
438
612
|
throw invalid("Image dimensions could not be decoded");
|
|
439
613
|
}
|
|
@@ -591,8 +765,67 @@ function sniffMediaType(bytes) {
|
|
|
591
765
|
bytes.toString("ascii", 0, 4) === "RIFF" &&
|
|
592
766
|
bytes.toString("ascii", 8, 12) === "WEBP")
|
|
593
767
|
return "image/webp";
|
|
768
|
+
if (bytes.length >= 10 &&
|
|
769
|
+
(bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a"))
|
|
770
|
+
return "image/gif";
|
|
771
|
+
try {
|
|
772
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).trimStart();
|
|
773
|
+
if (/^(?:<\?xml[^>]*>\s*)?(?:<!--[^]*?-->\s*)*<svg\b/i.test(text)) {
|
|
774
|
+
return "image/svg+xml";
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// Not UTF-8 SVG.
|
|
779
|
+
}
|
|
594
780
|
return undefined;
|
|
595
781
|
}
|
|
782
|
+
function validateGifContainer(bytes) {
|
|
783
|
+
return bytes.length >= 14 && bytes.at(-1) === 0x3b;
|
|
784
|
+
}
|
|
785
|
+
function gifDimensions(bytes) {
|
|
786
|
+
if (bytes.length < 10)
|
|
787
|
+
return undefined;
|
|
788
|
+
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
|
789
|
+
}
|
|
790
|
+
function svgText(bytes) {
|
|
791
|
+
try {
|
|
792
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function validateSvgContainer(bytes) {
|
|
799
|
+
const text = svgText(bytes)?.trim();
|
|
800
|
+
if (!text)
|
|
801
|
+
return false;
|
|
802
|
+
const withoutProlog = text
|
|
803
|
+
.replace(/^<\?xml[^>]*>\s*/i, "")
|
|
804
|
+
.replace(/^(?:<!--[^]*?-->\s*)*/i, "");
|
|
805
|
+
return /^<svg\b/i.test(withoutProlog) && (/<\/svg>\s*$/i.test(text) || /\/\>\s*$/.test(text));
|
|
806
|
+
}
|
|
807
|
+
function svgDimensions(bytes) {
|
|
808
|
+
const open = svgText(bytes)?.match(/<svg\b([^>]*)>/i)?.[1];
|
|
809
|
+
if (open === undefined)
|
|
810
|
+
return undefined;
|
|
811
|
+
const width = numericSvgLength(open.match(/\bwidth\s*=\s*["']([^"']+)["']/i)?.[1]);
|
|
812
|
+
const height = numericSvgLength(open.match(/\bheight\s*=\s*["']([^"']+)["']/i)?.[1]);
|
|
813
|
+
if (width && height)
|
|
814
|
+
return { width, height };
|
|
815
|
+
const viewBox = open.match(/\bviewBox\s*=\s*["']([^"']+)["']/i)?.[1]
|
|
816
|
+
?.trim().split(/[\s,]+/).map(Number);
|
|
817
|
+
if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
|
|
818
|
+
return { width: Math.ceil(viewBox[2]), height: Math.ceil(viewBox[3]) };
|
|
819
|
+
}
|
|
820
|
+
return { width: width ?? 300, height: height ?? 150 };
|
|
821
|
+
}
|
|
822
|
+
function numericSvgLength(value) {
|
|
823
|
+
const match = value?.trim().match(/^(\d+(?:\.\d+)?)(?:px)?$/i);
|
|
824
|
+
if (!match)
|
|
825
|
+
return undefined;
|
|
826
|
+
const number = Number(match[1]);
|
|
827
|
+
return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined;
|
|
828
|
+
}
|
|
596
829
|
function pngDimensions(bytes) {
|
|
597
830
|
if (bytes.toString("ascii", 12, 16) !== "IHDR")
|
|
598
831
|
return undefined;
|
|
@@ -666,13 +899,75 @@ function decodeBase64(value) {
|
|
|
666
899
|
}
|
|
667
900
|
return Buffer.from(value, "base64");
|
|
668
901
|
}
|
|
669
|
-
function
|
|
670
|
-
return
|
|
902
|
+
function resourceKind(type) {
|
|
903
|
+
return type === "input_image" ? "image" : "file";
|
|
904
|
+
}
|
|
905
|
+
function imageMediaType(mediaType) {
|
|
906
|
+
switch (mediaType) {
|
|
907
|
+
case "image/png":
|
|
908
|
+
case "image/jpeg":
|
|
909
|
+
case "image/webp":
|
|
910
|
+
case "image/gif":
|
|
911
|
+
case "image/svg+xml":
|
|
912
|
+
return mediaType;
|
|
913
|
+
default:
|
|
914
|
+
throw invalid("Image media type is invalid");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function resourceExtension(row) {
|
|
918
|
+
if (row.kind === "image") {
|
|
919
|
+
switch (imageMediaType(row.media_type)) {
|
|
920
|
+
case "image/png": return "png";
|
|
921
|
+
case "image/jpeg": return "jpg";
|
|
922
|
+
case "image/webp": return "webp";
|
|
923
|
+
case "image/gif": return "gif";
|
|
924
|
+
case "image/svg+xml": return "svg";
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
const extension = extname(row.filename).slice(1).toLowerCase();
|
|
928
|
+
return /^[a-z0-9][a-z0-9._-]{0,15}$/.test(extension) ? extension : "bin";
|
|
929
|
+
}
|
|
930
|
+
function decodeGeneratedImage(result) {
|
|
931
|
+
const dataUrl = /^data:(image\/[a-z0-9+.-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(result);
|
|
932
|
+
const encoded = dataUrl?.[2] ?? result;
|
|
933
|
+
const bytes = decodeBase64(encoded);
|
|
934
|
+
if (bytes.byteLength === 0 || bytes.byteLength > SESSION_RESOURCE_MAX_IMAGE_BYTES) {
|
|
935
|
+
throw invalid("Generated image exceeds the Session image resource policy");
|
|
936
|
+
}
|
|
937
|
+
const mediaType = sniffMediaType(bytes);
|
|
938
|
+
if (!mediaType || (dataUrl && imageMediaType(dataUrl[1].toLowerCase()) !== mediaType)) {
|
|
939
|
+
throw invalid("Generated image bytes are invalid");
|
|
940
|
+
}
|
|
941
|
+
return { bytes, mediaType };
|
|
942
|
+
}
|
|
943
|
+
function imageExtension(mediaType) {
|
|
944
|
+
switch (mediaType) {
|
|
945
|
+
case "image/png": return "png";
|
|
946
|
+
case "image/jpeg": return "jpg";
|
|
947
|
+
case "image/webp": return "webp";
|
|
948
|
+
case "image/gif": return "gif";
|
|
949
|
+
case "image/svg+xml": return "svg";
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function validateImageDimensions(image) {
|
|
953
|
+
if (!image)
|
|
954
|
+
return;
|
|
955
|
+
if (image.width > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
|
|
956
|
+
image.height > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
|
|
957
|
+
image.width * image.height > SESSION_RESOURCE_MAX_IMAGE_PIXELS) {
|
|
958
|
+
throw invalid("Image dimensions exceed the Session resource policy");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async function hashFile(path) {
|
|
962
|
+
const hash = createHash("sha256");
|
|
963
|
+
for await (const chunk of createReadStream(path))
|
|
964
|
+
hash.update(chunk);
|
|
965
|
+
return hash.digest("hex");
|
|
671
966
|
}
|
|
672
967
|
function safeFilename(value) {
|
|
673
968
|
const name = basename(value).replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
674
969
|
if (!name || name === "." || name === "..")
|
|
675
|
-
throw invalid("
|
|
970
|
+
throw invalid("Resource filename is invalid");
|
|
676
971
|
return name;
|
|
677
972
|
}
|
|
678
973
|
function ensurePrivateDirectory(path) {
|