@pasko70/pibo 3.4.4 → 3.5.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/dist/agent-runtime/routed-session.js +37 -12
- package/dist/agent-runtimes/pi/adapter.js +1 -0
- package/dist/agent-runtimes/pi/routed-session.js +2 -2
- package/dist/apps/chat/bounded-event-stream.js +98 -0
- package/dist/apps/chat/chat-settings-routes.js +3 -3
- package/dist/apps/chat/data/chat-data-mappers.js +32 -13
- package/dist/apps/chat/data/event-command-service.js +30 -21
- package/dist/apps/chat/data/history-query-service.js +34 -25
- package/dist/apps/chat/data/read-state-service.js +13 -0
- package/dist/apps/chat/data/session-query-service.js +19 -11
- package/dist/apps/chat/data/timeline-query-service.js +19 -7
- package/dist/apps/chat/message-command-dispatcher.js +134 -0
- package/dist/apps/chat/output-compactor.js +9 -0
- package/dist/apps/chat/output-event-policy.js +9 -1
- package/dist/apps/chat/stream.js +21 -3
- package/dist/apps/chat/telemetry-retention-service.js +113 -8
- package/dist/apps/chat/trace-response-cache.js +46 -0
- package/dist/apps/chat/trace-v2.js +12 -6
- package/dist/apps/chat/trace.js +1 -1
- package/dist/apps/chat/web-app.js +608 -281
- package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-BG0n7zLd.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-D6TjFhAm.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-D79vyxSX.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-DFZ8cwh0.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-cOjokPrK.js} +1 -1
- package/dist/apps/chat-ui/assets/index-RMHUTJ62.js +229 -0
- package/dist/apps/chat-ui/assets/{index-BOceJ0jM.css → index-hEkrlRk-.css} +1 -1
- package/dist/apps/chat-ui/index.html +2 -2
- package/dist/apps/chat-vscode-web/assets/index-xacbCyTx.js +44 -0
- package/dist/apps/chat-vscode-web/index.html +1 -1
- package/dist/compute/pool/seeds.js +25 -3
- package/dist/core/events.js +4 -0
- package/dist/core/output-render-sequence.js +2 -0
- package/dist/core/provider-capacity.js +33 -0
- package/dist/core/provider-telemetry.js +21 -6
- package/dist/core/runtime-capacity.js +174 -0
- package/dist/core/runtime-telemetry.js +40 -12
- package/dist/core/session-router.js +79 -2
- package/dist/data/async-chat-reads.js +38 -0
- package/dist/data/async-chat-storage.js +91 -0
- package/dist/data/async-telemetry-maintenance.js +9 -0
- package/dist/data/bounded-worker-client.js +256 -0
- package/dist/data/chat-read-projections.js +159 -0
- package/dist/data/chat-read-worker.js +73 -0
- package/dist/data/chat-storage-worker.js +146 -0
- package/dist/data/ingest-service.js +79 -14
- package/dist/data/message-command-store.js +148 -0
- package/dist/data/payload-store.js +92 -11
- package/dist/data/pibo-store.js +10 -8
- package/dist/data/schema.js +16 -2
- package/dist/data/session-store.js +3 -1
- package/dist/data/storage-backup.js +278 -0
- package/dist/data/telemetry-capture.js +188 -0
- package/dist/data/telemetry-command.js +3 -0
- package/dist/data/telemetry-maintenance-worker.js +40 -0
- package/dist/data/telemetry-maintenance.js +110 -0
- package/dist/data/telemetry-retention.js +16 -7
- package/dist/data/telemetry-worker.js +111 -0
- package/dist/data/telemetry-writer.js +150 -83
- package/dist/data/telemetry.js +5 -0
- package/dist/debug/index.js +52 -0
- package/dist/debug/storage-backup.js +33 -0
- package/dist/debug/telemetry-capture.js +66 -0
- package/dist/gateway/server.js +2 -0
- package/dist/providers/openai-gpt56.js +11 -6
- package/dist/session-ui/terminalRows.js +48 -6
- package/dist/sessions/pibo-data-store.js +1 -0
- package/dist/shared/debug-features.js +4 -0
- package/dist/shared/model-inference-metrics.js +23 -0
- package/dist/shared/trace-event-projection.js +59 -2
- package/dist/shared/trace-history.js +9 -0
- package/dist/shared/trace-live-reducer.js +1 -0
- package/dist/shared/trace-patch-nodes.js +19 -0
- package/dist/web/channel.js +3 -0
- package/dist/web/http.js +36 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
- package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { MessageCommandStore } from "./message-command-store.js";
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { ChatRoomService } from "../apps/chat/data/room-service.js";
|
|
4
|
+
import { ChatSessionQueryService } from "../apps/chat/data/session-query-service.js";
|
|
5
|
+
import { isPiboRoomArchived } from "../apps/chat/types/rooms.js";
|
|
6
|
+
import { PiboDataStore } from "./pibo-store.js";
|
|
7
|
+
import { ChatEventCommandService, chatClientTransactionKey } from "../apps/chat/data/event-command-service.js";
|
|
8
|
+
import { ChatDataIngestService } from "./ingest-service.js";
|
|
9
|
+
import { boundedMessageBytes } from "./bounded-worker-client.js";
|
|
10
|
+
const port = parentPort;
|
|
11
|
+
if (!port)
|
|
12
|
+
throw new Error("Chat storage requires a dedicated worker.");
|
|
13
|
+
const config = workerData;
|
|
14
|
+
const store = new PiboDataStore(config.path, { payloadRootDir: config.payloadRootDir });
|
|
15
|
+
// Lock waiting is bounded independently of SQL execution; retries yield this worker.
|
|
16
|
+
store.db.exec("PRAGMA busy_timeout=10");
|
|
17
|
+
const commands = new ChatEventCommandService(store);
|
|
18
|
+
const ingest = new ChatDataIngestService(store);
|
|
19
|
+
const rooms = new ChatRoomService(store);
|
|
20
|
+
const sessions = new ChatSessionQueryService(store);
|
|
21
|
+
const messageCommands = new MessageCommandStore(store);
|
|
22
|
+
let active = false;
|
|
23
|
+
let operations = 0;
|
|
24
|
+
let busyRetries = 0;
|
|
25
|
+
let lastOperationMs = 0;
|
|
26
|
+
function execute(command) {
|
|
27
|
+
switch (command.type) {
|
|
28
|
+
case "cancelPendingCommands": return messageCommands.cancelPending(command.sessionId);
|
|
29
|
+
case "commandReceiptPage": return { receipts: messageCommands.list(command.sessionId), queue: messageCommands.queueStatus(command.sessionId) };
|
|
30
|
+
case "commandReceipts": return messageCommands.list(command.sessionId);
|
|
31
|
+
case "commandReceipt": return messageCommands.get(command.id);
|
|
32
|
+
case "claimCommand": return messageCommands.claim(command.owner, command.leaseMs);
|
|
33
|
+
case "transitionCommand": return messageCommands.transition(command.id, command.owner, command.token, command.state, command.error);
|
|
34
|
+
case "heartbeatCommand": return messageCommands.heartbeat(command.id, command.owner, command.token, command.leaseMs);
|
|
35
|
+
case "resolveRoom": {
|
|
36
|
+
const room = command.roomId ? rooms.getRoom(command.roomId) : undefined;
|
|
37
|
+
if (room)
|
|
38
|
+
return room;
|
|
39
|
+
if (command.required)
|
|
40
|
+
throw Object.assign(new Error("Room not found"), { code: "room_not_found" });
|
|
41
|
+
return store.transaction(() => rooms.ensureDefaultRoom());
|
|
42
|
+
}
|
|
43
|
+
case "admit": {
|
|
44
|
+
const room = command.input.roomId ? rooms.getRoom(command.input.roomId) : undefined;
|
|
45
|
+
if (!room)
|
|
46
|
+
throw Object.assign(new Error("Room not found"), { code: "room_not_found" });
|
|
47
|
+
if (isPiboRoomArchived(room))
|
|
48
|
+
throw Object.assign(new Error("Archived rooms are read-only"), { code: "room_read_only" });
|
|
49
|
+
const key = command.input.clientTxnId ? chatClientTransactionKey(room.id, command.input.actorId, command.input.clientTxnId) : undefined;
|
|
50
|
+
const commandInput = command.durableCommand ? { sessionId: command.session.id, roomId: room.id, text: command.text, delivery: command.durableCommand.delivery } : undefined;
|
|
51
|
+
const receipt = key && commandInput ? messageCommands.find(key, messageCommands.fingerprint(commandInput)) : undefined;
|
|
52
|
+
const existing = key ? store.eventLog.findByIdempotencyKey(key) : undefined;
|
|
53
|
+
if (existing && commandInput && !receipt)
|
|
54
|
+
throw Object.assign(new Error("Transaction belongs to the legacy admission contract."), { code: "command_conflict" });
|
|
55
|
+
if (existing)
|
|
56
|
+
return { event: commands.findByClientTxn(room.id, command.input.actorId, command.input.clientTxnId), created: false, receipt };
|
|
57
|
+
const preparedCommand = commandInput ? messageCommands.prepare(commandInput) : undefined;
|
|
58
|
+
const createdAt = command.input.createdAt ?? new Date().toISOString();
|
|
59
|
+
const preparedPayload = ingest.prepareUserMessagePayload(command.text, createdAt);
|
|
60
|
+
return store.transaction(() => {
|
|
61
|
+
const concurrent = key ? store.eventLog.findByIdempotencyKey(key) : undefined;
|
|
62
|
+
if (concurrent) {
|
|
63
|
+
const receipt = preparedCommand ? messageCommands.find(key, preparedCommand.fingerprint) : undefined;
|
|
64
|
+
if (preparedCommand && !receipt)
|
|
65
|
+
throw Object.assign(new Error("Transaction belongs to the legacy admission contract."), { code: "command_conflict" });
|
|
66
|
+
return { event: commands.findByClientTxn(room.id, command.input.actorId, command.input.clientTxnId), created: false, receipt };
|
|
67
|
+
}
|
|
68
|
+
const event = commands.appendEvent({ ...command.input, createdAt });
|
|
69
|
+
sessions.upsertSession(command.session, command.durableCommand ? sessions.getSession(command.session.id)?.status ?? "idle" : "idle", command.session.updatedAt, { preserveRuntimeBinding: true });
|
|
70
|
+
ingest.ingestUserMessageAccepted({ session: command.session, roomId: room.id, actorId: command.input.actorId ?? "", text: command.text, clientTxnId: command.input.clientTxnId, eventId: command.durableCommand?.eventId, legacyEvent: event, preparedPayload });
|
|
71
|
+
const receipt = preparedCommand && command.durableCommand ? messageCommands.insert({ key: key ?? `chat:command:${command.durableCommand.eventId}`, ...preparedCommand, sessionId: command.session.id, roomId: room.id, eventId: command.durableCommand.eventId, streamId: event.streamId, delivery: command.durableCommand.delivery }) : undefined;
|
|
72
|
+
return { event, created: true, receipt };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
case "append": return store.transaction(() => {
|
|
76
|
+
const key = command.input.clientTxnId
|
|
77
|
+
? chatClientTransactionKey(command.input.roomId, command.input.actorId, command.input.clientTxnId)
|
|
78
|
+
: command.input.eventId ? `chat:event:${command.input.eventId}` : undefined;
|
|
79
|
+
const existing = key ? store.eventLog.findByIdempotencyKey(key) : undefined;
|
|
80
|
+
const event = commands.appendEvent(command.input);
|
|
81
|
+
return { event, created: !existing };
|
|
82
|
+
});
|
|
83
|
+
case "find": return commands.findByClientTxn(command.roomId, command.actorId, command.clientTxnId);
|
|
84
|
+
case "ingestUser": return ingest.ingestUserMessageAccepted(command.input);
|
|
85
|
+
case "ingestOutput": {
|
|
86
|
+
const result = ingest.ingestOutputEvent(command.input);
|
|
87
|
+
messageCommands.recordOutput(command.input.session.id, "eventId" in command.input.event ? command.input.event.eventId : undefined, command.input.event.type);
|
|
88
|
+
const row = store.db.prepare("SELECT created_at, event_id FROM event_log WHERE stream_id = ?").get(result.streamId);
|
|
89
|
+
if (!row)
|
|
90
|
+
throw new Error(`Missing output event ${result.streamId} after ingest.`);
|
|
91
|
+
return { ...result, stored: { createdAt: row.created_at, eventId: row.event_id ?? String(result.streamId) } };
|
|
92
|
+
}
|
|
93
|
+
case "status": return { operations, busyRetries, lastOperationMs, pid: process.pid, synchronous: store.db.prepare("PRAGMA synchronous").get(), journalMode: store.db.prepare("PRAGMA journal_mode").get() };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function respond(request, response) {
|
|
97
|
+
port.postMessage({ id: request.id, ...response, worker: workerStatus() });
|
|
98
|
+
active = false;
|
|
99
|
+
}
|
|
100
|
+
function workerStatus() {
|
|
101
|
+
return { pid: process.pid, operations, busyRetries, lastOperationMs, busyTimeoutMs: 10 };
|
|
102
|
+
}
|
|
103
|
+
function attempt(request) {
|
|
104
|
+
if (performance.now() >= request.deadline) {
|
|
105
|
+
respond(request, { error: { code: "storage_deadline", message: "Storage execution deadline elapsed before commit." } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const start = performance.now();
|
|
109
|
+
try {
|
|
110
|
+
const value = execute(request.command);
|
|
111
|
+
lastOperationMs = performance.now() - start;
|
|
112
|
+
operations++;
|
|
113
|
+
boundedMessageBytes(value, request.maxResultBytes);
|
|
114
|
+
respond(request, { value });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const message = error instanceof Error ? error.message : "";
|
|
118
|
+
if (/database is (?:locked|busy)/i.test(message) && performance.now() + 15 < request.deadline) {
|
|
119
|
+
busyRetries++;
|
|
120
|
+
setTimeout(() => attempt(request), 5 + Math.floor(Math.random() * 10));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const domainCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
124
|
+
if (domainCode === "room_not_found" || domainCode === "room_read_only" || (domainCode.startsWith("storage_") || domainCode.startsWith("command_")) || domainCode === "pibo_output_identity_collision") {
|
|
125
|
+
respond(request, { error: { code: domainCode, message } });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
respond(request, { error: { code: /database is (?:locked|busy)/i.test(message) ? "storage_busy" : "storage_operation_failed", message: "Storage operation failed; reconcile the transaction ID before retrying." } });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
port.on("message", (request) => {
|
|
132
|
+
if (active) {
|
|
133
|
+
port.postMessage({ id: request.id, error: { code: "storage_overloaded", message: "Storage worker already owns a request." } });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
active = true;
|
|
137
|
+
attempt(request);
|
|
138
|
+
});
|
|
139
|
+
port.postMessage({
|
|
140
|
+
ready: true,
|
|
141
|
+
worker: {
|
|
142
|
+
...workerStatus(),
|
|
143
|
+
journalMode: store.db.prepare("PRAGMA journal_mode").get(),
|
|
144
|
+
synchronous: store.db.prepare("PRAGMA synchronous").get(),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
@@ -17,6 +17,8 @@ export class PiboOutputIdentityCollisionError extends Error {
|
|
|
17
17
|
export function outputPersistenceErrorIsRetryable(error) {
|
|
18
18
|
if (error instanceof PiboOutputIdentityCollisionError)
|
|
19
19
|
return false;
|
|
20
|
+
if (error && typeof error === "object" && "code" in error && error.code === "pibo_output_identity_collision")
|
|
21
|
+
return false;
|
|
20
22
|
if (error instanceof AggregateError) {
|
|
21
23
|
return error.errors.length === 0 || error.errors.some(outputPersistenceErrorIsRetryable);
|
|
22
24
|
}
|
|
@@ -43,10 +45,11 @@ export class ChatDataIngestService {
|
|
|
43
45
|
duplicate: true,
|
|
44
46
|
};
|
|
45
47
|
}
|
|
48
|
+
const now = input.legacyEvent?.createdAt ?? new Date().toISOString();
|
|
49
|
+
const preparedPayload = input.preparedPayload ?? this.prepareUserMessagePayload(input.text, now);
|
|
46
50
|
return this.store.transaction(() => {
|
|
47
|
-
|
|
48
|
-
this.store.
|
|
49
|
-
const payloadRef = this.writeTextPayloadIfLarge(input.text, now, "chat_message");
|
|
51
|
+
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, firstMessagePreview: input.text, lastActivityAt: now, preserveRuntimeBinding: true });
|
|
52
|
+
const payloadRef = preparedPayload ? this.store.payloads.commitPreparedPayload(preparedPayload).id : undefined;
|
|
50
53
|
const event = this.store.eventLog.appendEvent({
|
|
51
54
|
sessionId: input.session.id,
|
|
52
55
|
sessionSequence: this.nextEventSequence(input.session.id),
|
|
@@ -79,6 +82,7 @@ export class ChatDataIngestService {
|
|
|
79
82
|
roomId: input.roomId,
|
|
80
83
|
sequence: this.nextMessageSequence(input.session.id),
|
|
81
84
|
role: "user",
|
|
85
|
+
turnId: input.eventId,
|
|
82
86
|
actorId: input.actorId,
|
|
83
87
|
status: "complete",
|
|
84
88
|
createdAt: now,
|
|
@@ -91,10 +95,13 @@ export class ChatDataIngestService {
|
|
|
91
95
|
inlineText: payloadRef ? undefined : input.text,
|
|
92
96
|
}),
|
|
93
97
|
});
|
|
94
|
-
this.upsertNavigation(input.session, input.roomId, previewText(input.text), now, "running");
|
|
98
|
+
this.upsertNavigation(input.session, input.roomId, previewText(input.text), now, input.eventId ? undefined : "running");
|
|
95
99
|
return { streamId: event.streamId, messageId, duplicate: false };
|
|
96
100
|
});
|
|
97
101
|
}
|
|
102
|
+
prepareUserMessagePayload(text, createdAt) {
|
|
103
|
+
return this.prepareTextPayloadIfLarge(text, createdAt, "chat_message");
|
|
104
|
+
}
|
|
98
105
|
ingestOutputEvent(input) {
|
|
99
106
|
const event = input.event;
|
|
100
107
|
const idempotencyKey = outputIdempotencyKey(event);
|
|
@@ -140,13 +147,17 @@ export class ChatDataIngestService {
|
|
|
140
147
|
};
|
|
141
148
|
}
|
|
142
149
|
}
|
|
150
|
+
const now = input.createdAt ?? new Date().toISOString();
|
|
151
|
+
const payload = payloadForOutputEvent(event);
|
|
152
|
+
const preparedPayload = payload ? this.preparePayloadIfLarge(payload.value, payload.contentType, now, retentionClassForOutputEvent(event)) : undefined;
|
|
143
153
|
return this.store.transaction(() => {
|
|
144
|
-
|
|
154
|
+
if (event.type === "compaction_end" && !event.aborted && !event.errorMessage && !event.compactionStats) {
|
|
155
|
+
event.compactionStats = this.compactionStats(input.session.id, event.result);
|
|
156
|
+
}
|
|
145
157
|
if (input.roomId) {
|
|
146
|
-
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, lastActivityAt: now, status: outputSessionStatus(event) });
|
|
158
|
+
this.store.sessions.upsertSession({ session: input.session, roomId: input.roomId, lastActivityAt: now, status: outputSessionStatus(event), preserveRuntimeBinding: true });
|
|
147
159
|
}
|
|
148
|
-
const
|
|
149
|
-
const payloadRef = payload ? this.writePayloadIfLarge(payload.value, payload.contentType, now, retentionClassForOutputEvent(event)) : undefined;
|
|
160
|
+
const payloadRef = preparedPayload ? this.store.payloads.commitPreparedPayload(preparedPayload).id : undefined;
|
|
150
161
|
const storedEvent = this.store.eventLog.appendEvent({
|
|
151
162
|
sessionId: input.session.id,
|
|
152
163
|
sessionSequence: this.nextEventSequence(input.session.id),
|
|
@@ -233,6 +244,34 @@ export class ChatDataIngestService {
|
|
|
233
244
|
return { streamId: storedEvent.streamId, duplicate: false, messageId, observationId };
|
|
234
245
|
});
|
|
235
246
|
}
|
|
247
|
+
compactionStats(sessionId, result) {
|
|
248
|
+
const boundary = this.store.db.prepare(`
|
|
249
|
+
SELECT COALESCE(MAX(session_sequence), 0) AS sequence
|
|
250
|
+
FROM event_log
|
|
251
|
+
WHERE session_id = ?
|
|
252
|
+
AND type = 'compaction_end'
|
|
253
|
+
AND COALESCE(json_extract(attributes_json, '$.aborted'), 0) = 0
|
|
254
|
+
AND json_extract(attributes_json, '$.errorMessage') IS NULL
|
|
255
|
+
`).get(sessionId);
|
|
256
|
+
const segmentWhere = `session_id = ? AND type = 'tool_execution_finished' AND session_sequence > ?`;
|
|
257
|
+
const count = this.store.db.prepare(`SELECT COUNT(*) AS count FROM event_log WHERE ${segmentWhere}`)
|
|
258
|
+
.get(sessionId, boundary.sequence);
|
|
259
|
+
const maxRow = this.store.db.prepare(`
|
|
260
|
+
SELECT attributes_json
|
|
261
|
+
FROM event_log
|
|
262
|
+
WHERE ${segmentWhere}
|
|
263
|
+
AND json_type(attributes_json, '$.toolMetrics.outputTokens') IN ('integer', 'real')
|
|
264
|
+
ORDER BY json_extract(attributes_json, '$.toolMetrics.outputTokens') DESC
|
|
265
|
+
LIMIT 1
|
|
266
|
+
`).get(sessionId, boundary.sequence);
|
|
267
|
+
const maxToolOutput = maxRow ? toolMetricsFromAttributes(maxRow.attributes_json) : undefined;
|
|
268
|
+
return compactObject({
|
|
269
|
+
toolCallCount: count.count,
|
|
270
|
+
maxToolOutputTokens: maxToolOutput?.outputTokens,
|
|
271
|
+
maxToolOutputTokenBasis: maxToolOutput?.tokenBasis,
|
|
272
|
+
compactionTokens: compactionTokenCount(result),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
236
275
|
nextEventSequence(sessionId) {
|
|
237
276
|
const row = this.store.db.prepare("SELECT COALESCE(MAX(session_sequence), 0) + 1 AS next_sequence FROM event_log WHERE session_id = ?").get(sessionId);
|
|
238
277
|
return row.next_sequence;
|
|
@@ -241,22 +280,22 @@ export class ChatDataIngestService {
|
|
|
241
280
|
const row = this.store.db.prepare("SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
242
281
|
return row.next_sequence;
|
|
243
282
|
}
|
|
244
|
-
|
|
283
|
+
prepareTextPayloadIfLarge(text, createdAt, retentionClass) {
|
|
245
284
|
if (Buffer.byteLength(text, "utf8") <= INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES)
|
|
246
285
|
return undefined;
|
|
247
|
-
return this.store.payloads.
|
|
286
|
+
return this.store.payloads.preparePayload({
|
|
248
287
|
value: text,
|
|
249
288
|
contentType: "text/plain; charset=utf-8",
|
|
250
289
|
retentionClass,
|
|
251
290
|
createdAt,
|
|
252
|
-
})
|
|
291
|
+
});
|
|
253
292
|
}
|
|
254
|
-
|
|
293
|
+
preparePayloadIfLarge(value, contentType, createdAt, retentionClass) {
|
|
255
294
|
const bytes = Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
|
|
256
295
|
const threshold = typeof value === "string" ? INLINE_MESSAGE_PAYLOAD_THRESHOLD_BYTES : INLINE_JSON_PAYLOAD_THRESHOLD_BYTES;
|
|
257
296
|
if (bytes <= threshold)
|
|
258
297
|
return undefined;
|
|
259
|
-
return this.store.payloads.
|
|
298
|
+
return this.store.payloads.preparePayload({ value, contentType, retentionClass, createdAt });
|
|
260
299
|
}
|
|
261
300
|
upsertNavigation(session, roomId, lastMessagePreview, now, status) {
|
|
262
301
|
this.store.navigation.upsertSession({
|
|
@@ -275,6 +314,32 @@ export class ChatDataIngestService {
|
|
|
275
314
|
});
|
|
276
315
|
}
|
|
277
316
|
}
|
|
317
|
+
function toolMetricsFromAttributes(attributesJson) {
|
|
318
|
+
try {
|
|
319
|
+
const attributes = JSON.parse(attributesJson);
|
|
320
|
+
if (!isRecord(attributes) || !isRecord(attributes.toolMetrics))
|
|
321
|
+
return undefined;
|
|
322
|
+
const outputTokens = nonNegativeFiniteNumber(attributes.toolMetrics.outputTokens);
|
|
323
|
+
const tokenBasis = typeof attributes.toolMetrics.tokenBasis === "string"
|
|
324
|
+
? attributes.toolMetrics.tokenBasis
|
|
325
|
+
: undefined;
|
|
326
|
+
return { outputTokens, tokenBasis };
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function compactionTokenCount(result) {
|
|
333
|
+
if (!isRecord(result))
|
|
334
|
+
return undefined;
|
|
335
|
+
return nonNegativeFiniteNumber(result.tokensBefore);
|
|
336
|
+
}
|
|
337
|
+
function nonNegativeFiniteNumber(value) {
|
|
338
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
339
|
+
}
|
|
340
|
+
function isRecord(value) {
|
|
341
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
342
|
+
}
|
|
278
343
|
function deterministicId(prefix, value) {
|
|
279
344
|
return `${prefix}_${createHash("sha256").update(value).digest("hex").slice(0, 32)}`;
|
|
280
345
|
}
|
|
@@ -435,7 +500,7 @@ function specificAttributesForOutputEvent(event) {
|
|
|
435
500
|
if (event.type === "session_error")
|
|
436
501
|
return { error: event.error, ...(event.errorDetails ? { errorDetails: event.errorDetails } : {}) };
|
|
437
502
|
if (event.type === "compaction_start" || event.type === "compaction_end")
|
|
438
|
-
return { compactionIndex: event.compactionIndex, reason: event.reason, aborted: "aborted" in event ? event.aborted : undefined, errorMessage: "errorMessage" in event ? event.errorMessage : undefined };
|
|
503
|
+
return { compactionIndex: event.compactionIndex, reason: event.reason, aborted: "aborted" in event ? event.aborted : undefined, errorMessage: "errorMessage" in event ? event.errorMessage : undefined, compactionStats: "compactionStats" in event ? event.compactionStats : undefined };
|
|
439
504
|
return {};
|
|
440
505
|
}
|
|
441
506
|
function observationKindForOutputEvent(event) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
export const MESSAGE_COMMAND_SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS message_commands (
|
|
4
|
+
id TEXT PRIMARY KEY, request_key TEXT NOT NULL UNIQUE, fingerprint TEXT NOT NULL,
|
|
5
|
+
session_id TEXT NOT NULL, room_id TEXT NOT NULL, event_id TEXT NOT NULL,
|
|
6
|
+
stream_id INTEGER NOT NULL, payload_ref TEXT NOT NULL REFERENCES payloads(id), payload_bytes INTEGER NOT NULL,
|
|
7
|
+
delivery TEXT NOT NULL CHECK(delivery IN ('queue','steer')),
|
|
8
|
+
state TEXT NOT NULL CHECK(state IN ('accepted','waiting_slot','initializing','session_queue','running','completed','failed','interrupted')),
|
|
9
|
+
owner TEXT, token INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, error TEXT,
|
|
11
|
+
UNIQUE(session_id,event_id)
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS message_commands_dispatch_order ON message_commands(session_id,state,stream_id,delivery);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS message_commands_recent ON message_commands(session_id,stream_id DESC);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS message_commands_pending ON message_commands(state,created_at,id);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS message_commands_session ON message_commands(session_id,state,created_at,id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS message_commands_lease ON message_commands(lease_until) WHERE owner IS NOT NULL;
|
|
18
|
+
CREATE TABLE IF NOT EXISTS message_dispatch_clock (id INTEGER PRIMARY KEY CHECK(id=1), sequence INTEGER NOT NULL);
|
|
19
|
+
INSERT OR IGNORE INTO message_dispatch_clock VALUES (1,0);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS message_dispatch_rooms (room_id TEXT PRIMARY KEY, sequence INTEGER NOT NULL);
|
|
21
|
+
|
|
22
|
+
`;
|
|
23
|
+
export const MESSAGE_COMMAND_LIMITS = Object.freeze({
|
|
24
|
+
queue: { count: 1000, roomCount: 256, sessionCount: 64, bytes: 64 * 1024 * 1024, roomBytes: 16 * 1024 * 1024, sessionBytes: 4 * 1024 * 1024, ageMs: 60 * 60 * 1000, roomAgeMs: 15 * 60 * 1000, sessionAgeMs: 10 * 60 * 1000 },
|
|
25
|
+
steer: { count: 64, roomCount: 16, sessionCount: 4, bytes: 4 * 1024 * 1024, roomBytes: 1024 * 1024, sessionBytes: 1024 * 1024, ageMs: 60 * 1000, roomAgeMs: 60 * 1000, sessionAgeMs: 60 * 1000 },
|
|
26
|
+
dispatch: { queue: 10, roomQueue: 5, steer: 2, roomSteer: 1 },
|
|
27
|
+
});
|
|
28
|
+
const active = "'accepted','waiting_slot','initializing','session_queue','running'";
|
|
29
|
+
/** Durable receipts are independent of optional trace/telemetry retention. Only the storage worker owns this store. */
|
|
30
|
+
export class MessageCommandStore {
|
|
31
|
+
store;
|
|
32
|
+
constructor(store) {
|
|
33
|
+
this.store = store;
|
|
34
|
+
}
|
|
35
|
+
fingerprint(input) {
|
|
36
|
+
if (Buffer.byteLength(input.text) > 1024 * 1024)
|
|
37
|
+
throw domainError("command_too_large", "Message exceeds the durable command byte limit.");
|
|
38
|
+
return createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
prepare(input) {
|
|
41
|
+
return {
|
|
42
|
+
fingerprint: this.fingerprint(input),
|
|
43
|
+
payload: this.store.payloads.preparePayload({ value: input.text, contentType: "text/plain", retentionClass: "message_command" }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
find(key, fingerprint) {
|
|
47
|
+
const row = this.store.db.prepare("SELECT * FROM message_commands WHERE request_key = ?").get(key);
|
|
48
|
+
if (row && fingerprint && row.fingerprint !== fingerprint)
|
|
49
|
+
throw domainError("command_conflict", "Client transaction ID is already bound to different message content, delivery, or session.");
|
|
50
|
+
return row && receipt(row);
|
|
51
|
+
}
|
|
52
|
+
list(sessionId) {
|
|
53
|
+
const pending = this.store.db.prepare(`SELECT * FROM message_commands WHERE session_id=? AND state IN (${active},'interrupted') ORDER BY stream_id DESC LIMIT 70`).all(sessionId);
|
|
54
|
+
const terminal = this.store.db.prepare("SELECT * FROM message_commands WHERE session_id=? AND state IN ('completed','failed') ORDER BY stream_id DESC LIMIT 64").all(sessionId);
|
|
55
|
+
return [...pending, ...terminal].sort((a, b) => b.stream_id - a.stream_id).map(receipt);
|
|
56
|
+
}
|
|
57
|
+
queueStatus(sessionId) {
|
|
58
|
+
const rows = this.store.db.prepare(`SELECT state,delivery,payload_bytes,created_at FROM message_commands WHERE session_id=? AND state IN (${active}) LIMIT 69`).all(sessionId);
|
|
59
|
+
const summarize = (delivery) => {
|
|
60
|
+
const scoped = rows.filter(row => row.delivery === delivery), limits = MESSAGE_COMMAND_LIMITS[delivery];
|
|
61
|
+
return { count: scoped.length, bytes: scoped.reduce((sum, row) => sum + row.payload_bytes, 0), oldestWaitMs: Math.max(0, ...scoped.filter(row => row.state === "accepted" || row.state === "waiting_slot").map(row => Date.now() - row.created_at)), limits: { count: limits.sessionCount, bytes: limits.sessionBytes, waitMs: limits.sessionAgeMs } };
|
|
62
|
+
};
|
|
63
|
+
return { queue: summarize("queue"), steer: summarize("steer") };
|
|
64
|
+
}
|
|
65
|
+
get(id) {
|
|
66
|
+
const row = this.store.db.prepare("SELECT * FROM message_commands WHERE id = ?").get(id);
|
|
67
|
+
return row && receipt(row);
|
|
68
|
+
}
|
|
69
|
+
insert(input) {
|
|
70
|
+
const prior = this.find(input.key, input.fingerprint);
|
|
71
|
+
if (prior)
|
|
72
|
+
return prior;
|
|
73
|
+
const limit = MESSAGE_COMMAND_LIMITS[input.delivery];
|
|
74
|
+
const rows = this.store.db.prepare(`SELECT session_id, room_id, payload_bytes, created_at, state FROM message_commands WHERE state IN (${active}) AND delivery=? LIMIT ?`).all(input.delivery, limit.count + 1);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const exceeds = (scope, count, bytes, ageMs) => scope.length >= count
|
|
77
|
+
|| scope.reduce((n, r) => n + r.payload_bytes, input.payload.byteSize) > bytes
|
|
78
|
+
|| scope.some(r => (r.state === "accepted" || r.state === "waiting_slot") && now - r.created_at >= ageMs);
|
|
79
|
+
if (exceeds(rows, limit.count, limit.bytes, limit.ageMs)
|
|
80
|
+
|| exceeds(rows.filter(r => r.room_id === input.roomId), limit.roomCount, limit.roomBytes, limit.roomAgeMs)
|
|
81
|
+
|| exceeds(rows.filter(r => r.session_id === input.sessionId), limit.sessionCount, limit.sessionBytes, limit.sessionAgeMs)) {
|
|
82
|
+
throw domainError("command_overloaded", "Durable message queue count, byte or wait-age capacity reached; retry the same transaction later.");
|
|
83
|
+
}
|
|
84
|
+
const id = `cmd_${randomUUID()}`;
|
|
85
|
+
const payload = this.store.payloads.commitPreparedPayload(input.payload);
|
|
86
|
+
this.store.db.prepare(`INSERT INTO message_commands (id,request_key,fingerprint,session_id,room_id,event_id,stream_id,payload_ref,payload_bytes,delivery,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,'accepted',?,?)`).run(id, input.key, input.fingerprint, input.sessionId, input.roomId, input.eventId, input.streamId, payload.id, payload.byteSize, input.delivery, now, now);
|
|
87
|
+
return this.get(id);
|
|
88
|
+
}
|
|
89
|
+
claim(owner, leaseMs) {
|
|
90
|
+
const limits = MESSAGE_COMMAND_LIMITS.dispatch;
|
|
91
|
+
const candidateSql = `WITH occupied AS (
|
|
92
|
+
SELECT room_id,delivery FROM message_commands WHERE owner IS NOT NULL AND state IN (${active})
|
|
93
|
+
) SELECT c.* FROM message_commands c LEFT JOIN message_dispatch_rooms r ON r.room_id=c.room_id
|
|
94
|
+
WHERE c.state='accepted'
|
|
95
|
+
AND (SELECT count(*) FROM occupied o WHERE o.delivery=c.delivery) < CASE c.delivery WHEN 'steer' THEN ${limits.steer} ELSE ${limits.queue} END
|
|
96
|
+
AND (SELECT count(*) FROM occupied o WHERE o.delivery=c.delivery AND o.room_id=c.room_id) < CASE c.delivery WHEN 'steer' THEN ${limits.roomSteer} ELSE ${limits.roomQueue} END
|
|
97
|
+
AND NOT EXISTS (SELECT 1 FROM message_commands p WHERE p.session_id=c.session_id AND p.state IN (${active},'interrupted') AND p.stream_id<c.stream_id AND (c.delivery='queue' OR p.delivery='steer'))
|
|
98
|
+
ORDER BY CASE c.delivery WHEN 'steer' THEN 0 ELSE 1 END,COALESCE(r.sequence,0),c.stream_id LIMIT 1`;
|
|
99
|
+
// Idle polling must not take the SQLite writer lock. The claim transaction rechecks.
|
|
100
|
+
if (!this.store.db.prepare(candidateSql).get() && !this.store.db.prepare("SELECT 1 FROM message_commands WHERE owner IS NOT NULL AND lease_until <= ? LIMIT 1").get(Date.now()))
|
|
101
|
+
return undefined;
|
|
102
|
+
const row = this.store.transaction(() => {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
// Never replay an expired command that may already have reached a provider or tool.
|
|
105
|
+
this.store.db.prepare(`UPDATE message_commands SET state=CASE WHEN state='waiting_slot' THEN 'accepted' ELSE 'interrupted' END, error=CASE WHEN state='waiting_slot' THEN NULL ELSE 'Runtime ownership expired; execution requires reconciliation.' END, owner = NULL,lease_until=0,updated_at=? WHERE id IN (SELECT id FROM message_commands WHERE owner IS NOT NULL AND lease_until <= ? ORDER BY lease_until LIMIT 100)`).run(now, now);
|
|
106
|
+
const candidate = this.store.db.prepare(candidateSql).get();
|
|
107
|
+
if (!candidate)
|
|
108
|
+
return undefined;
|
|
109
|
+
this.store.db.prepare("UPDATE message_dispatch_clock SET sequence=sequence+1 WHERE id=1").run();
|
|
110
|
+
this.store.db.prepare("INSERT INTO message_dispatch_rooms(room_id,sequence) SELECT ?,sequence FROM message_dispatch_clock WHERE id=1 ON CONFLICT(room_id) DO UPDATE SET sequence=excluded.sequence").run(candidate.room_id);
|
|
111
|
+
this.store.db.prepare("UPDATE message_commands SET state='waiting_slot',owner = ?,token=token+1,lease_until=?,updated_at=? WHERE id=?").run(owner, now + leaseMs, now, candidate.id);
|
|
112
|
+
return this.store.db.prepare("SELECT * FROM message_commands WHERE id=?").get(candidate.id);
|
|
113
|
+
});
|
|
114
|
+
if (!row)
|
|
115
|
+
return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const text = Buffer.from(this.store.payloads.readPayloadBytesBounded(row.payload_ref, 1024 * 1024)).toString("utf8");
|
|
118
|
+
return { ...receipt(row), token: row.token, text, delivery: row.delivery };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
this.transition(row.id, owner, row.token, "failed", "Durable message payload is unavailable or corrupt.");
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
cancelPending(sessionId) {
|
|
126
|
+
return Number(this.store.db.prepare("UPDATE message_commands SET state='failed',error='Cancelled before runtime dispatch.',owner = NULL,lease_until=0,updated_at=? WHERE session_id=? AND state IN ('accepted','waiting_slot')").run(Date.now(), sessionId).changes);
|
|
127
|
+
}
|
|
128
|
+
transition(id, owner, token, state, error) {
|
|
129
|
+
return Number(this.store.db.prepare(`UPDATE message_commands SET state=?,error=?,updated_at=?,owner = CASE WHEN ? IN ('completed','failed','interrupted') THEN NULL ELSE owner END WHERE id=? AND owner = ? AND token=? AND lease_until>? AND state IN (${active})`).run(state, error?.slice(0, 500) ?? null, Date.now(), state, id, owner, token, Date.now()).changes) === 1;
|
|
130
|
+
}
|
|
131
|
+
heartbeat(id, owner, token, leaseMs) {
|
|
132
|
+
return Number(this.store.db.prepare(`UPDATE message_commands SET lease_until=? WHERE id=? AND owner = ? AND token=? AND lease_until>? AND state IN (${active})`).run(Date.now() + leaseMs, id, owner, token, Date.now()).changes) === 1;
|
|
133
|
+
}
|
|
134
|
+
recordOutput(sessionId, eventId, type) {
|
|
135
|
+
if (!eventId)
|
|
136
|
+
return;
|
|
137
|
+
const states = { message_queued: "session_queue", message_started: "running", message_finished: "completed", session_error: "failed", message_steered: "completed" };
|
|
138
|
+
const state = states[type];
|
|
139
|
+
if (!state)
|
|
140
|
+
return;
|
|
141
|
+
const eligible = state === "session_queue" ? "'initializing','waiting_slot'" : state === "running" ? "'initializing','waiting_slot','session_queue'" : `${active},'interrupted'`;
|
|
142
|
+
this.store.db.prepare(`UPDATE message_commands SET state=?,error=NULL,updated_at=?,owner = CASE WHEN ? IN ('completed','failed') THEN NULL ELSE owner END WHERE session_id=? AND event_id=? AND state IN (${eligible})`).run(state, Date.now(), state, sessionId, eventId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function receipt(row) {
|
|
146
|
+
return { id: row.id, sessionId: row.session_id, roomId: row.room_id, eventId: row.event_id, streamId: row.stream_id, state: row.state, createdAt: row.created_at, updatedAt: row.updated_at, ...(row.error ? { error: row.error } : {}) };
|
|
147
|
+
}
|
|
148
|
+
function domainError(code, message) { return Object.assign(new Error(message), { code }); }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { pipeline } from "node:stream";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
-
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createGunzip, gunzipSync, gzipSync } from "node:zlib";
|
|
4
|
+
import { createReadStream, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, extname, join, resolve } from "node:path";
|
|
5
6
|
import { piboHomePath } from "../core/pibo-home.js";
|
|
6
7
|
const MAX_SYNC_PAYLOAD_GZIP_BYTES = 512 * 1024;
|
|
@@ -23,30 +24,55 @@ export class PiboPayloadMetadataConflictError extends Error {
|
|
|
23
24
|
export class PayloadStore {
|
|
24
25
|
db;
|
|
25
26
|
rootDir;
|
|
26
|
-
constructor(db, rootDir = piboHomePath("payloads")) {
|
|
27
|
+
constructor(db, rootDir = piboHomePath("payloads"), readOnly = false) {
|
|
27
28
|
this.db = db;
|
|
28
29
|
this.rootDir = rootDir === ":memory:" ? rootDir : resolve(rootDir);
|
|
29
|
-
if (this.rootDir !== ":memory:")
|
|
30
|
+
if (!readOnly && this.rootDir !== ":memory:")
|
|
30
31
|
mkdirSync(this.rootDir, { recursive: true });
|
|
31
32
|
}
|
|
32
33
|
writePayload(input) {
|
|
34
|
+
return this.commitPreparedPayload(this.preparePayload(input));
|
|
35
|
+
}
|
|
36
|
+
/** Performs hashing, compression and atomic file publication before a DB transaction begins. */
|
|
37
|
+
preparePayload(input) {
|
|
33
38
|
const contentType = input.contentType ?? defaultContentType(input.value);
|
|
34
39
|
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
35
40
|
const bytes = payloadToBytes(input.value, contentType);
|
|
36
41
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
37
42
|
const existing = this.findByIdentity(sha256, contentType, input.retentionClass);
|
|
38
43
|
if (existing) {
|
|
39
|
-
|
|
40
|
-
return this.getPayload(existing.id) ?? existing;
|
|
44
|
+
return { ...existing, refCount: 1, status: "staged" };
|
|
41
45
|
}
|
|
42
46
|
const shouldCompress = bytes.byteLength <= MAX_SYNC_PAYLOAD_GZIP_BYTES;
|
|
43
47
|
const encoding = shouldCompress ? "gzip" : "identity";
|
|
44
48
|
const bytesToStore = shouldCompress ? gzipSync(bytes) : bytes;
|
|
45
|
-
const compressedByteSize = shouldCompress ? bytesToStore.byteLength :
|
|
49
|
+
const compressedByteSize = shouldCompress ? bytesToStore.byteLength : undefined;
|
|
46
50
|
const relativePath = buildRelativePayloadPath(sha256, contentType, input.retentionClass, encoding);
|
|
47
51
|
const absolutePath = this.rootDir === ":memory:" ? relativePath : join(this.rootDir, relativePath);
|
|
48
52
|
writePayloadFile(absolutePath, bytesToStore);
|
|
49
|
-
|
|
53
|
+
return {
|
|
54
|
+
id: input.id ?? `payload_${randomUUID()}`,
|
|
55
|
+
sha256,
|
|
56
|
+
storageKind: "file",
|
|
57
|
+
storagePath: relativePath,
|
|
58
|
+
contentType,
|
|
59
|
+
encoding,
|
|
60
|
+
byteSize: bytes.byteLength,
|
|
61
|
+
compressedByteSize,
|
|
62
|
+
previewText: previewTextFromValue(input.value),
|
|
63
|
+
retentionClass: input.retentionClass,
|
|
64
|
+
refCount: 1,
|
|
65
|
+
status: "staged",
|
|
66
|
+
createdAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Links a previously published payload file with only bounded metadata SQL. */
|
|
70
|
+
commitPreparedPayload(prepared) {
|
|
71
|
+
const existing = this.findByIdentity(prepared.sha256, prepared.contentType, prepared.retentionClass);
|
|
72
|
+
if (existing) {
|
|
73
|
+
this.db.prepare("UPDATE payloads SET ref_count = ref_count + 1 WHERE id = ?").run(existing.id);
|
|
74
|
+
return this.getPayload(existing.id) ?? existing;
|
|
75
|
+
}
|
|
50
76
|
this.db.prepare(`
|
|
51
77
|
INSERT INTO payloads (
|
|
52
78
|
id,
|
|
@@ -64,10 +90,10 @@ export class PayloadStore {
|
|
|
64
90
|
created_at,
|
|
65
91
|
last_verified_at
|
|
66
92
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
67
|
-
`).run(id, sha256, "file",
|
|
68
|
-
const stored = this.getPayload(id);
|
|
93
|
+
`).run(prepared.id, prepared.sha256, "file", prepared.storagePath ?? null, prepared.contentType, prepared.encoding, prepared.byteSize, prepared.compressedByteSize ?? null, prepared.previewText ?? null, prepared.retentionClass, 1, "committed", prepared.createdAt, prepared.createdAt);
|
|
94
|
+
const stored = this.getPayload(prepared.id);
|
|
69
95
|
if (!stored)
|
|
70
|
-
throw new Error(`Failed to persist payload \"${id}\"`);
|
|
96
|
+
throw new Error(`Failed to persist payload \"${prepared.id}\"`);
|
|
71
97
|
return stored;
|
|
72
98
|
}
|
|
73
99
|
getPayload(id) {
|
|
@@ -88,6 +114,61 @@ export class PayloadStore {
|
|
|
88
114
|
return bytes;
|
|
89
115
|
throw new Error(`Unsupported payload encoding \"${payload.encoding}\"`);
|
|
90
116
|
}
|
|
117
|
+
openPayloadStream(id) {
|
|
118
|
+
const payload = this.getPayload(id);
|
|
119
|
+
if (!payload?.storagePath)
|
|
120
|
+
throw new Error("Payload not found");
|
|
121
|
+
if (payload.encoding !== "identity" && payload.encoding !== "gzip")
|
|
122
|
+
throw new Error("Unsupported payload encoding");
|
|
123
|
+
const source = createReadStream(this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath));
|
|
124
|
+
if (payload.encoding === "identity")
|
|
125
|
+
return source;
|
|
126
|
+
const decoded = createGunzip();
|
|
127
|
+
pipeline(source, decoded, () => { }); // Pipeline closes both ends on cancellation or read failure.
|
|
128
|
+
return decoded;
|
|
129
|
+
}
|
|
130
|
+
/** Read a bounded uncompressed range without materializing the entire payload. */
|
|
131
|
+
async readPayloadRange(id, offset, limit) {
|
|
132
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1)
|
|
133
|
+
throw new RangeError("Invalid payload range");
|
|
134
|
+
const payload = this.getPayload(id);
|
|
135
|
+
if (!payload?.storagePath)
|
|
136
|
+
throw new Error("Payload not found");
|
|
137
|
+
if (offset >= payload.byteSize)
|
|
138
|
+
return Buffer.alloc(0);
|
|
139
|
+
const path = this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath);
|
|
140
|
+
const end = Math.min(payload.byteSize, offset + limit);
|
|
141
|
+
const source = createReadStream(path, payload.encoding === "identity" ? { start: offset, end: end - 1 } : {});
|
|
142
|
+
if (payload.encoding !== "identity" && payload.encoding !== "gzip") {
|
|
143
|
+
source.destroy();
|
|
144
|
+
throw new Error("Unsupported payload encoding");
|
|
145
|
+
}
|
|
146
|
+
const stream = payload.encoding === "gzip" ? source.pipe(createGunzip()) : source;
|
|
147
|
+
const forwardError = (error) => stream.destroy(error);
|
|
148
|
+
if (stream !== source)
|
|
149
|
+
source.on("error", forwardError);
|
|
150
|
+
let position = payload.encoding === "identity" ? offset : 0;
|
|
151
|
+
const parts = [];
|
|
152
|
+
try {
|
|
153
|
+
for await (const value of stream) {
|
|
154
|
+
const bytes = Buffer.from(value);
|
|
155
|
+
const from = Math.max(0, offset - position), to = Math.min(bytes.length, end - position);
|
|
156
|
+
if (to > from)
|
|
157
|
+
parts.push(Buffer.from(bytes.subarray(from, to)));
|
|
158
|
+
position += bytes.length;
|
|
159
|
+
if (position >= end)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
stream.destroy();
|
|
165
|
+
source.destroy();
|
|
166
|
+
}
|
|
167
|
+
const result = Buffer.concat(parts);
|
|
168
|
+
if (result.length !== end - offset)
|
|
169
|
+
throw new Error("Incomplete payload range");
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
91
172
|
readPayloadText(id) {
|
|
92
173
|
return Buffer.from(this.readPayloadBytes(id)).toString("utf8");
|
|
93
174
|
}
|