@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,38 @@
|
|
|
1
|
+
import { BoundedWorkerClient } from "./bounded-worker-client.js";
|
|
2
|
+
export const CHAT_READ_METHODS = {
|
|
3
|
+
timeline: ["listEvents", "listSessionEvents", "listAllSessionEvents", "listMessageTurnTimings", "scanMessageTurnTimings", "listTraceEvents", "isPayloadAttachedToTraceNode", "countEventsByType", "getLatestEventSequence", "getLatestStreamId"],
|
|
4
|
+
history: ["listProductHistoryEntries", "getProductHistoryCoverage"],
|
|
5
|
+
navigation: ["unreadCountsPage", "sessionIndexPage"],
|
|
6
|
+
};
|
|
7
|
+
/** Read traffic owns a separate connection and queue from admission and output commits. */
|
|
8
|
+
export class AsyncChatReadQueries {
|
|
9
|
+
path;
|
|
10
|
+
payloadRootDir;
|
|
11
|
+
client;
|
|
12
|
+
closed = false;
|
|
13
|
+
restarts = 0;
|
|
14
|
+
restartAfter = 0;
|
|
15
|
+
timeline;
|
|
16
|
+
navigation;
|
|
17
|
+
history;
|
|
18
|
+
constructor(path, payloadRootDir) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.payloadRootDir = payloadRootDir;
|
|
21
|
+
this.client = this.startWorker();
|
|
22
|
+
const group = (name) => Object.fromEntries(CHAT_READ_METHODS[name].map(method => [method, (...args) => this.getClient().request({ group: name, method, args }, { priority: "background", fairnessKey: typeof args[0] === "string" ? args[0] : args[0]?.piboSessionId })]));
|
|
23
|
+
this.timeline = group("timeline");
|
|
24
|
+
this.history = group("history");
|
|
25
|
+
this.navigation = group("navigation");
|
|
26
|
+
}
|
|
27
|
+
startWorker() {
|
|
28
|
+
this.restartAfter = Date.now() + 1000;
|
|
29
|
+
return new BoundedWorkerClient(new URL("./chat-read-worker.js", import.meta.url), { maxPending: 64, reservedControlRequests: 2, reservedControlBytes: 16384, maxPendingBytes: 1024 * 1024, maxMessageBytes: 4 * 1024 * 1024, maxAgeMs: 2000, workerOptions: { workerData: { path: this.path, payloadRootDir: this.payloadRootDir } } });
|
|
30
|
+
}
|
|
31
|
+
getClient() { if (!this.closed && this.client.status().closed && this.client.status().exited && Date.now() >= this.restartAfter) {
|
|
32
|
+
this.restarts++;
|
|
33
|
+
this.client = this.startWorker();
|
|
34
|
+
} return this.client; }
|
|
35
|
+
maintenance(action) { return this.getClient().request({ group: "maintenance", method: action, args: [] }, { priority: "control" }); }
|
|
36
|
+
status() { return { ...this.client.status(), restarts: this.restarts }; }
|
|
37
|
+
async close() { this.closed = true; await this.client.close(); }
|
|
38
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { BoundedWorkerClient, StorageUnavailableError } from "./bounded-worker-client.js";
|
|
2
|
+
/** Admission/output writer; bulk reads must use a separate worker connection. */
|
|
3
|
+
export class AsyncChatStorage {
|
|
4
|
+
writerClient;
|
|
5
|
+
reader;
|
|
6
|
+
workerUrl;
|
|
7
|
+
workerOptions;
|
|
8
|
+
closed = false;
|
|
9
|
+
writerRestarts = 0;
|
|
10
|
+
readerRestarts = 0;
|
|
11
|
+
writerRestartAfter = 0;
|
|
12
|
+
readerRestartAfter = 0;
|
|
13
|
+
constructor(path, payloadRootDir, options = {}) {
|
|
14
|
+
if (path === ":memory:")
|
|
15
|
+
throw new Error("Worker storage requires a file-backed database.");
|
|
16
|
+
// Admission carries the bounded message plus its event projection; account for both in IPC.
|
|
17
|
+
this.workerOptions = { maxMessageBytes: 4 * 1024 * 1024, reservedControlRequests: 8, reservedControlBytes: 256 * 1024, admissionWindowMs: 1, ...options, workerOptions: { ...options.workerOptions, workerData: { path, payloadRootDir } } };
|
|
18
|
+
this.workerUrl = new URL("./chat-storage-worker.js", import.meta.url);
|
|
19
|
+
this.writerClient = this.startWriter();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A crashed worker, or one that breached its execution deadline, must not disable durable
|
|
23
|
+
* admission permanently. Replacement waits for the failed worker to exit and is bounded to one
|
|
24
|
+
* per second, so two writers never own the same SQLite file. Recovery is safe without replay
|
|
25
|
+
* protection because callers reconcile the same client transaction ID and admission is
|
|
26
|
+
* idempotent by request key.
|
|
27
|
+
*/
|
|
28
|
+
get writer() {
|
|
29
|
+
const status = this.writerClient.status();
|
|
30
|
+
if (!this.closed && status.closed && status.exited && Date.now() >= this.writerRestartAfter) {
|
|
31
|
+
this.writerRestarts++;
|
|
32
|
+
this.writerClient = this.startWriter();
|
|
33
|
+
}
|
|
34
|
+
return this.writerClient;
|
|
35
|
+
}
|
|
36
|
+
get readerClient() {
|
|
37
|
+
const status = this.reader?.status();
|
|
38
|
+
if (!this.reader || (!this.closed && status.closed && status.exited && Date.now() >= this.readerRestartAfter)) {
|
|
39
|
+
if (this.reader)
|
|
40
|
+
this.readerRestarts++;
|
|
41
|
+
this.reader = this.startReader();
|
|
42
|
+
}
|
|
43
|
+
return this.reader;
|
|
44
|
+
}
|
|
45
|
+
startWriter() {
|
|
46
|
+
this.writerRestartAfter = Date.now() + 1000;
|
|
47
|
+
return new BoundedWorkerClient(this.workerUrl, this.workerOptions);
|
|
48
|
+
}
|
|
49
|
+
startReader() {
|
|
50
|
+
this.readerRestartAfter = Date.now() + 1000;
|
|
51
|
+
return new BoundedWorkerClient(this.workerUrl, this.workerOptions);
|
|
52
|
+
}
|
|
53
|
+
resolveRoom(roomId, required = false) {
|
|
54
|
+
return this.writer.request({ type: "resolveRoom", roomId, required });
|
|
55
|
+
}
|
|
56
|
+
admit(input, session, text, durableCommand) {
|
|
57
|
+
if (durableCommand && Buffer.byteLength(text) > 1024 * 1024)
|
|
58
|
+
return Promise.reject(Object.assign(new Error("Message exceeds the durable command byte limit."), { code: "command_too_large" }));
|
|
59
|
+
return this.writer.request({ type: "admit", input, session, text, durableCommand }, { fairnessKey: input.roomId, priority: durableCommand?.delivery === "steer" ? "control" : "admission" });
|
|
60
|
+
}
|
|
61
|
+
append(input) {
|
|
62
|
+
return this.writer.request({ type: "append", input });
|
|
63
|
+
}
|
|
64
|
+
find(roomId, actorId, clientTxnId) {
|
|
65
|
+
if (this.closed)
|
|
66
|
+
return Promise.reject(new StorageUnavailableError("storage_closed", "Storage worker is closed."));
|
|
67
|
+
return this.readerClient.request({ type: "find", roomId, actorId, clientTxnId }, { priority: "admission" });
|
|
68
|
+
}
|
|
69
|
+
ingestUser(input) {
|
|
70
|
+
return this.writer.request({ type: "ingestUser", input });
|
|
71
|
+
}
|
|
72
|
+
ingestOutput(input) {
|
|
73
|
+
return this.writer.request({ type: "ingestOutput", input }, { priority: "output", fairnessKey: input.roomId });
|
|
74
|
+
}
|
|
75
|
+
cancelPendingCommands(sessionId) { return this.writer.request({ type: "cancelPendingCommands", sessionId }, { priority: "control" }); }
|
|
76
|
+
commandReceiptPage(sessionId) { return this.writer.request({ type: "commandReceiptPage", sessionId }, { priority: "control" }); }
|
|
77
|
+
commandReceipts(sessionId) { return this.writer.request({ type: "commandReceipts", sessionId }, { priority: "control" }); }
|
|
78
|
+
commandReceipt(id) { return this.writer.request({ type: "commandReceipt", id }, { priority: "control" }); }
|
|
79
|
+
claimCommand(owner, leaseMs) { return this.writer.request({ type: "claimCommand", owner, leaseMs }, { priority: "background" }); }
|
|
80
|
+
transitionCommand(id, owner, token, state, error) { return this.writer.request({ type: "transitionCommand", id, owner, token, state, error }, { priority: "control" }); }
|
|
81
|
+
heartbeatCommand(id, owner, token, leaseMs) { return this.writer.request({ type: "heartbeatCommand", id, owner, token, leaseMs }, { priority: "control" }); }
|
|
82
|
+
status() {
|
|
83
|
+
const writer = this.writer.status();
|
|
84
|
+
const reader = this.reader?.status();
|
|
85
|
+
return { ready: writer.ready && (!reader || reader.ready), closed: writer.closed && (!reader || reader.closed), restarts: { writer: this.writerRestarts, reader: this.readerRestarts }, writer, reader };
|
|
86
|
+
}
|
|
87
|
+
async close() {
|
|
88
|
+
this.closed = true;
|
|
89
|
+
await Promise.all([this.writerClient.close(), this.reader?.close()]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { BoundedWorkerClient } from "./bounded-worker-client.js";
|
|
2
|
+
export class AsyncTelemetryMaintenance {
|
|
3
|
+
client;
|
|
4
|
+
constructor(path) { this.client = new BoundedWorkerClient(new URL("./telemetry-maintenance-worker.js", import.meta.url), { maxPending: 8, maxPendingBytes: 64 * 1024, maxMessageBytes: 16 * 1024, maxAgeMs: 2000, workerOptions: { workerData: { path } } }); }
|
|
5
|
+
command(action, cutoff) { return this.client.request({ action, cutoff }, { priority: "control" }); }
|
|
6
|
+
preview(cutoff) { return this.client.request({ action: "preview", cutoff }, { priority: "background" }); }
|
|
7
|
+
status() { return this.client.status(); }
|
|
8
|
+
close() { return this.client.close(); }
|
|
9
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
export class StorageUnavailableError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "StorageUnavailableError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Count bounded structured data without creating a second serialized payload. */
|
|
11
|
+
export function boundedMessageBytes(value, maximum) {
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
let nodes = 0;
|
|
14
|
+
const stack = [{ value }];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
while (stack.length) {
|
|
17
|
+
const frame = stack.pop();
|
|
18
|
+
const item = frame.value;
|
|
19
|
+
if (frame.leave) {
|
|
20
|
+
seen.delete(item);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (++nodes > 100_000)
|
|
24
|
+
throw new StorageUnavailableError("storage_payload_limit", "Storage message has too many fields.");
|
|
25
|
+
if (typeof item === "string")
|
|
26
|
+
bytes += Buffer.byteLength(item, "utf8") + 8;
|
|
27
|
+
else if (item === null || item === undefined || typeof item === "boolean" || typeof item === "number")
|
|
28
|
+
bytes += 8;
|
|
29
|
+
else if (ArrayBuffer.isView(item))
|
|
30
|
+
bytes += item.byteLength + 16;
|
|
31
|
+
else if (item instanceof ArrayBuffer)
|
|
32
|
+
bytes += item.byteLength + 16;
|
|
33
|
+
else if (typeof item === "object") {
|
|
34
|
+
if (seen.has(item))
|
|
35
|
+
throw new StorageUnavailableError("storage_payload_limit", "Storage messages must be acyclic.");
|
|
36
|
+
seen.add(item);
|
|
37
|
+
stack.push({ value: item, leave: true });
|
|
38
|
+
bytes += 16;
|
|
39
|
+
for (const key in item) {
|
|
40
|
+
if (!Object.hasOwn(item, key))
|
|
41
|
+
continue;
|
|
42
|
+
const child = item[key];
|
|
43
|
+
bytes += Buffer.byteLength(key, "utf8") + 8;
|
|
44
|
+
stack.push({ value: child });
|
|
45
|
+
if (stack.length > 100_000 || bytes > maximum)
|
|
46
|
+
throw new StorageUnavailableError("storage_payload_limit", "Storage message exceeds its budget.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
throw new StorageUnavailableError("storage_payload_limit", "Unsupported storage message value.");
|
|
51
|
+
if (bytes > maximum)
|
|
52
|
+
throw new StorageUnavailableError("storage_payload_limit", "Storage message exceeds its byte budget; use payload references.");
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
/** One in-flight RPC keeps the worker's hidden message queue bounded. */
|
|
57
|
+
export class BoundedWorkerClient {
|
|
58
|
+
worker;
|
|
59
|
+
queue = [];
|
|
60
|
+
inFlight;
|
|
61
|
+
pendingBytes = 0;
|
|
62
|
+
nextId = 0;
|
|
63
|
+
dispatchSequence = 0;
|
|
64
|
+
dispatchTimer;
|
|
65
|
+
lastServed = new Map();
|
|
66
|
+
ready = false;
|
|
67
|
+
closed = false;
|
|
68
|
+
exited = false;
|
|
69
|
+
termination;
|
|
70
|
+
startupTimer;
|
|
71
|
+
completed = 0;
|
|
72
|
+
rejected = 0;
|
|
73
|
+
lastResponseAt;
|
|
74
|
+
workerIdentity;
|
|
75
|
+
maximum;
|
|
76
|
+
constructor(url, options = {}) {
|
|
77
|
+
this.maximum = {
|
|
78
|
+
maxPending: options.maxPending ?? 128,
|
|
79
|
+
admissionWindowMs: options.admissionWindowMs ?? 0,
|
|
80
|
+
reservedControlRequests: Math.min(options.reservedControlRequests ?? 0, (options.maxPending ?? 128) - 1),
|
|
81
|
+
reservedControlBytes: Math.min(options.reservedControlBytes ?? 0, (options.maxPendingBytes ?? 8 * 1024 * 1024) - 1),
|
|
82
|
+
maxPendingBytes: options.maxPendingBytes ?? 8 * 1024 * 1024,
|
|
83
|
+
maxMessageBytes: options.maxMessageBytes ?? 1024 * 1024,
|
|
84
|
+
maxAgeMs: options.maxAgeMs ?? 500,
|
|
85
|
+
agingMs: options.agingMs ?? 100,
|
|
86
|
+
startupTimeoutMs: options.startupTimeoutMs ?? 10_000,
|
|
87
|
+
};
|
|
88
|
+
for (const [key, limit] of Object.entries(this.maximum))
|
|
89
|
+
if (!Number.isFinite(limit) || (key.startsWith("reservedControl") || key === "admissionWindowMs" ? limit < 0 : limit <= 0))
|
|
90
|
+
throw new Error("Storage budgets must be positive finite numbers.");
|
|
91
|
+
this.worker = new Worker(url, { resourceLimits: { maxOldGenerationSizeMb: 256 }, ...options.workerOptions });
|
|
92
|
+
this.worker.unref();
|
|
93
|
+
this.startupTimer = setTimeout(() => this.fail(new StorageUnavailableError("storage_worker_failed", "Storage worker startup timed out.")), this.maximum.startupTimeoutMs);
|
|
94
|
+
this.worker.on("message", (message) => {
|
|
95
|
+
this.lastResponseAt = performance.now();
|
|
96
|
+
if (message.worker)
|
|
97
|
+
this.workerIdentity = message.worker;
|
|
98
|
+
if (message.ready) {
|
|
99
|
+
clearTimeout(this.startupTimer);
|
|
100
|
+
this.ready = true;
|
|
101
|
+
this.workerIdentity = message.worker;
|
|
102
|
+
this.pump();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const pending = this.inFlight;
|
|
106
|
+
if (!pending || message.id !== pending.id)
|
|
107
|
+
return;
|
|
108
|
+
this.inFlight = undefined;
|
|
109
|
+
this.pendingBytes -= pending.bytes;
|
|
110
|
+
clearTimeout(pending.timer);
|
|
111
|
+
if (!pending.settled) {
|
|
112
|
+
pending.settled = true;
|
|
113
|
+
if (message.error) {
|
|
114
|
+
this.rejected++;
|
|
115
|
+
pending.reject(Object.assign(new Error(message.error.message ?? "Storage operation failed."), { code: message.error.code ?? "storage_operation_failed" }));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
try {
|
|
119
|
+
boundedMessageBytes(message.value, this.maximum.maxMessageBytes);
|
|
120
|
+
this.completed++;
|
|
121
|
+
pending.resolve(message.value);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.rejected++;
|
|
125
|
+
pending.reject(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.pump();
|
|
130
|
+
});
|
|
131
|
+
this.worker.on("error", () => this.fail(new StorageUnavailableError("storage_worker_failed", "Storage worker failed; in-flight commit state must be reconciled.")));
|
|
132
|
+
this.worker.on("exit", () => { this.exited = true; if (!this.closed)
|
|
133
|
+
this.fail(new StorageUnavailableError("storage_worker_failed", "Storage worker exited.")); });
|
|
134
|
+
}
|
|
135
|
+
request(command, options = {}) {
|
|
136
|
+
if (this.closed)
|
|
137
|
+
return Promise.reject(new StorageUnavailableError("storage_closed", "Storage worker is closed."));
|
|
138
|
+
let bytes;
|
|
139
|
+
try {
|
|
140
|
+
bytes = boundedMessageBytes(command, this.maximum.maxMessageBytes);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return Promise.reject(error);
|
|
144
|
+
}
|
|
145
|
+
if (this.queue.length + Number(Boolean(this.inFlight)) >= this.maximum.maxPending - (options.priority === "control" ? 0 : this.maximum.reservedControlRequests) || this.pendingBytes + bytes > this.maximum.maxPendingBytes - (options.priority === "control" ? 0 : this.maximum.reservedControlBytes)) {
|
|
146
|
+
this.rejected++;
|
|
147
|
+
return Promise.reject(new StorageUnavailableError("storage_overloaded", "Storage capacity exhausted; retry with the same transaction ID."));
|
|
148
|
+
}
|
|
149
|
+
const age = Math.min(options.timeoutMs ?? this.maximum.maxAgeMs, this.maximum.maxAgeMs);
|
|
150
|
+
if (!Number.isFinite(age) || age <= 0)
|
|
151
|
+
return Promise.reject(new StorageUnavailableError("storage_deadline", "Storage deadline elapsed."));
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const now = performance.now();
|
|
154
|
+
const pending = {
|
|
155
|
+
id: ++this.nextId, command, bytes, queuedAt: now, deadline: now + age,
|
|
156
|
+
priority: options.priority === "control" ? -1 : options.priority === "background" ? 2 : options.priority === "output" ? 1 : 0,
|
|
157
|
+
fairnessKey: `${options.priority ?? "admission"}:${options.fairnessKey?.slice(0, 256) ?? "default"}`,
|
|
158
|
+
resolve: value => resolve(value), reject, settled: false,
|
|
159
|
+
timer: setTimeout(() => {
|
|
160
|
+
if (pending.settled)
|
|
161
|
+
return;
|
|
162
|
+
pending.settled = true;
|
|
163
|
+
this.rejected++;
|
|
164
|
+
if (this.inFlight === pending) {
|
|
165
|
+
reject(new StorageUnavailableError("storage_unknown", "Storage response deadline elapsed; reconcile the same transaction ID before retrying."));
|
|
166
|
+
this.fail(new StorageUnavailableError("storage_worker_failed", "Storage worker exceeded its execution deadline."));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const index = this.queue.indexOf(pending);
|
|
170
|
+
if (index >= 0) {
|
|
171
|
+
this.queue.splice(index, 1);
|
|
172
|
+
this.pendingBytes -= pending.bytes;
|
|
173
|
+
}
|
|
174
|
+
reject(new StorageUnavailableError("storage_deadline", "Storage queue deadline elapsed before execution."));
|
|
175
|
+
}
|
|
176
|
+
}, age),
|
|
177
|
+
};
|
|
178
|
+
this.pendingBytes += bytes;
|
|
179
|
+
this.queue.push(pending);
|
|
180
|
+
this.schedulePump(options.priority === "control");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
status() {
|
|
184
|
+
return { ready: this.ready, closed: this.closed, exited: this.exited, queued: this.queue.length, inFlight: Boolean(this.inFlight), pendingBytes: this.pendingBytes, oldestAgeMs: Math.max(0, ...this.queue.map(entry => performance.now() - entry.queuedAt), this.inFlight ? performance.now() - this.inFlight.queuedAt : 0), lastResponseAgeMs: this.lastResponseAt === undefined ? undefined : performance.now() - this.lastResponseAt, completed: this.completed, rejected: this.rejected, worker: this.workerIdentity, limits: { ...this.maximum } };
|
|
185
|
+
}
|
|
186
|
+
async close() {
|
|
187
|
+
this.fail(new StorageUnavailableError("storage_closed", "Storage worker closed; reconcile any in-flight transaction."), false);
|
|
188
|
+
await (this.termination ??= this.worker.terminate());
|
|
189
|
+
}
|
|
190
|
+
fail(error, terminateWorker = true) {
|
|
191
|
+
if (this.closed)
|
|
192
|
+
return;
|
|
193
|
+
this.closed = true;
|
|
194
|
+
clearTimeout(this.startupTimer);
|
|
195
|
+
if (this.dispatchTimer)
|
|
196
|
+
clearTimeout(this.dispatchTimer);
|
|
197
|
+
this.dispatchTimer = undefined;
|
|
198
|
+
for (const pending of [...this.queue, ...(this.inFlight ? [this.inFlight] : [])]) {
|
|
199
|
+
clearTimeout(pending.timer);
|
|
200
|
+
if (!pending.settled) {
|
|
201
|
+
pending.settled = true;
|
|
202
|
+
pending.reject(pending === this.inFlight ? new StorageUnavailableError("storage_unknown", "Storage worker stopped during execution; reconcile the transaction ID.") : error);
|
|
203
|
+
this.rejected++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.queue.length = 0;
|
|
207
|
+
this.lastServed.clear();
|
|
208
|
+
this.inFlight = undefined;
|
|
209
|
+
this.pendingBytes = 0;
|
|
210
|
+
if (terminateWorker)
|
|
211
|
+
this.termination ??= this.worker.terminate();
|
|
212
|
+
}
|
|
213
|
+
schedulePump(control) {
|
|
214
|
+
if (control || !this.maximum.admissionWindowMs) {
|
|
215
|
+
this.pump();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (this.dispatchTimer || this.inFlight || !this.ready || this.closed)
|
|
219
|
+
return;
|
|
220
|
+
// Collect a bounded burst before choosing its Room; short control RPCs bypass this window.
|
|
221
|
+
this.dispatchTimer = setTimeout(() => { this.dispatchTimer = undefined; this.pump(); }, this.maximum.admissionWindowMs);
|
|
222
|
+
this.dispatchTimer.unref?.();
|
|
223
|
+
}
|
|
224
|
+
pump() {
|
|
225
|
+
if (!this.ready || this.closed || this.inFlight || !this.queue.length)
|
|
226
|
+
return;
|
|
227
|
+
if (this.dispatchTimer)
|
|
228
|
+
clearTimeout(this.dispatchTimer);
|
|
229
|
+
this.dispatchTimer = undefined;
|
|
230
|
+
const now = performance.now();
|
|
231
|
+
let index = 0;
|
|
232
|
+
const score = (entry) => entry.priority - Math.floor((now - entry.queuedAt) / this.maximum.agingMs);
|
|
233
|
+
for (let i = 1; i < this.queue.length; i++) {
|
|
234
|
+
const candidate = this.queue[i], current = this.queue[index];
|
|
235
|
+
if (score(candidate) < score(current) || (score(candidate) === score(current) && (this.lastServed.get(candidate.fairnessKey) ?? 0) < (this.lastServed.get(current.fairnessKey) ?? 0)))
|
|
236
|
+
index = i;
|
|
237
|
+
}
|
|
238
|
+
const pending = this.queue.splice(index, 1)[0];
|
|
239
|
+
this.inFlight = pending;
|
|
240
|
+
this.lastServed.delete(pending.fairnessKey);
|
|
241
|
+
this.lastServed.set(pending.fairnessKey, ++this.dispatchSequence);
|
|
242
|
+
const liveKeys = new Set([...this.queue.map(item => item.fairnessKey), pending.fairnessKey]);
|
|
243
|
+
for (const key of this.lastServed.keys()) {
|
|
244
|
+
if (this.lastServed.size <= this.maximum.maxPending)
|
|
245
|
+
break;
|
|
246
|
+
if (!liveKeys.has(key))
|
|
247
|
+
this.lastServed.delete(key);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
this.worker.postMessage({ id: pending.id, command: pending.command, deadline: pending.deadline, maxResultBytes: this.maximum.maxMessageBytes });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
this.fail(new StorageUnavailableError("storage_worker_failed", "Storage IPC failed."));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
export const CHAT_READ_PROJECTION_SCHEMA = `
|
|
2
|
+
CREATE INDEX IF NOT EXISTS idx_event_log_timing_sequence ON event_log(session_id,session_sequence,stream_id) WHERE type IN ('message_queued','message_steered','message_started','message_finished','session_error','thinking_finished','assistant_message');
|
|
3
|
+
CREATE INDEX IF NOT EXISTS idx_event_log_semantic_sequence ON event_log(session_id,session_sequence DESC,stream_id DESC) WHERE type NOT IN ('assistant_delta','thinking_delta','tool_execution_updated');
|
|
4
|
+
CREATE TABLE IF NOT EXISTS chat_trace_revisions(session_id TEXT PRIMARY KEY,revision INTEGER NOT NULL);
|
|
5
|
+
CREATE TRIGGER IF NOT EXISTS chat_trace_insert AFTER INSERT ON event_log WHEN NEW.session_id IS NOT NULL BEGIN
|
|
6
|
+
INSERT INTO chat_trace_revisions VALUES(NEW.session_id,1) ON CONFLICT(session_id) DO UPDATE SET revision=revision+1;
|
|
7
|
+
END;
|
|
8
|
+
CREATE TRIGGER IF NOT EXISTS chat_trace_update AFTER UPDATE ON event_log WHEN NEW.session_id IS NOT NULL BEGIN
|
|
9
|
+
INSERT INTO chat_trace_revisions VALUES(NEW.session_id,1) ON CONFLICT(session_id) DO UPDATE SET revision=revision+1;
|
|
10
|
+
UPDATE chat_trace_revisions SET revision=revision+1 WHERE session_id=OLD.session_id AND OLD.session_id IS NOT NEW.session_id;
|
|
11
|
+
END;
|
|
12
|
+
CREATE TRIGGER IF NOT EXISTS chat_trace_delete AFTER DELETE ON event_log WHEN OLD.session_id IS NOT NULL BEGIN
|
|
13
|
+
INSERT INTO chat_trace_revisions VALUES(OLD.session_id,1) ON CONFLICT(session_id) DO UPDATE SET revision=revision+1;
|
|
14
|
+
END;
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_event_log_unread_stream ON event_log(stream_id) WHERE (retention_class='chat_message' AND type IN ('user.message.accepted','assistant_message')) OR type='session_error';
|
|
16
|
+
CREATE TABLE IF NOT EXISTS chat_unread_index(stream_id INTEGER PRIMARY KEY,session_id TEXT NOT NULL);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_chat_unread_session_stream ON chat_unread_index(session_id,stream_id);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS chat_unread_counts(session_id TEXT PRIMARY KEY,unread_count INTEGER NOT NULL DEFAULT 0);
|
|
19
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_count_insert AFTER INSERT ON chat_unread_index BEGIN
|
|
20
|
+
INSERT INTO chat_unread_counts(session_id,unread_count) VALUES(NEW.session_id,CASE WHEN NEW.stream_id>COALESCE((SELECT last_read_stream_id FROM app_session_read_state WHERE session_id=NEW.session_id),0) THEN 1 ELSE 0 END)
|
|
21
|
+
ON CONFLICT(session_id) DO UPDATE SET unread_count=unread_count+excluded.unread_count;
|
|
22
|
+
END;
|
|
23
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_count_delete AFTER DELETE ON chat_unread_index BEGIN
|
|
24
|
+
UPDATE chat_unread_counts SET unread_count=MAX(0,unread_count-CASE WHEN OLD.stream_id>COALESCE((SELECT last_read_stream_id FROM app_session_read_state WHERE session_id=OLD.session_id),0) THEN 1 ELSE 0 END) WHERE session_id=OLD.session_id;
|
|
25
|
+
END;
|
|
26
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_event_insert AFTER INSERT ON event_log WHEN NEW.session_id IS NOT NULL AND ((NEW.retention_class='chat_message' AND NEW.type IN ('user.message.accepted','assistant_message')) OR NEW.type='session_error') BEGIN
|
|
27
|
+
INSERT OR IGNORE INTO chat_unread_index VALUES(NEW.stream_id,NEW.session_id);
|
|
28
|
+
END;
|
|
29
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_event_delete AFTER DELETE ON event_log BEGIN DELETE FROM chat_unread_index WHERE stream_id=OLD.stream_id; END;
|
|
30
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_event_update AFTER UPDATE ON event_log BEGIN
|
|
31
|
+
DELETE FROM chat_unread_index WHERE stream_id=OLD.stream_id;
|
|
32
|
+
INSERT OR IGNORE INTO chat_unread_index SELECT NEW.stream_id,NEW.session_id WHERE NEW.session_id IS NOT NULL AND ((NEW.retention_class='chat_message' AND NEW.type IN ('user.message.accepted','assistant_message')) OR NEW.type='session_error');
|
|
33
|
+
END;
|
|
34
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_mark_insert AFTER INSERT ON app_session_read_state BEGIN
|
|
35
|
+
INSERT INTO chat_unread_counts(session_id,unread_count) SELECT NEW.session_id,COUNT(*) FROM chat_unread_index WHERE session_id=NEW.session_id AND stream_id>NEW.last_read_stream_id
|
|
36
|
+
ON CONFLICT(session_id) DO UPDATE SET unread_count=excluded.unread_count;
|
|
37
|
+
END;
|
|
38
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_mark_update AFTER UPDATE OF last_read_stream_id ON app_session_read_state BEGIN
|
|
39
|
+
INSERT INTO chat_unread_counts(session_id,unread_count) SELECT NEW.session_id,COUNT(*) FROM chat_unread_index WHERE session_id=NEW.session_id AND stream_id>NEW.last_read_stream_id
|
|
40
|
+
ON CONFLICT(session_id) DO UPDATE SET unread_count=excluded.unread_count;
|
|
41
|
+
END;
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS chat_unread_mark_delete AFTER DELETE ON app_session_read_state BEGIN
|
|
43
|
+
INSERT INTO chat_unread_counts(session_id,unread_count) SELECT OLD.session_id,COUNT(*) FROM chat_unread_index WHERE session_id=OLD.session_id
|
|
44
|
+
ON CONFLICT(session_id) DO UPDATE SET unread_count=excluded.unread_count;
|
|
45
|
+
END;
|
|
46
|
+
CREATE TABLE IF NOT EXISTS chat_history_index (
|
|
47
|
+
message_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, event_sequence INTEGER NOT NULL,
|
|
48
|
+
source_stream_id INTEGER, role TEXT NOT NULL, created_at TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_chat_history_index_session_event ON chat_history_index(session_id,event_sequence DESC,message_id DESC);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_chat_history_index_session_created ON chat_history_index(session_id,created_at,message_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_chat_history_index_source ON chat_history_index(source_stream_id);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS chat_history_counts (session_id TEXT PRIMARY KEY,message_count INTEGER NOT NULL DEFAULT 0,revision INTEGER NOT NULL DEFAULT 0);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS chat_read_backfill (id INTEGER PRIMARY KEY CHECK(id=1),cursor INTEGER NOT NULL DEFAULT 0,target INTEGER NOT NULL,event_cursor INTEGER NOT NULL DEFAULT 0,event_target INTEGER NOT NULL,paused INTEGER NOT NULL DEFAULT 0);
|
|
55
|
+
INSERT OR IGNORE INTO chat_read_backfill(id,target,event_target) SELECT 1,COALESCE(MAX(rowid),0),(SELECT COALESCE(MAX(stream_id),0) FROM event_log) FROM chat_messages;
|
|
56
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_count_insert AFTER INSERT ON chat_history_index BEGIN
|
|
57
|
+
INSERT INTO chat_history_counts(session_id,message_count,revision) VALUES(NEW.session_id,1,1)
|
|
58
|
+
ON CONFLICT(session_id) DO UPDATE SET message_count=message_count+1,revision=revision+1;
|
|
59
|
+
END;
|
|
60
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_count_delete AFTER DELETE ON chat_history_index BEGIN
|
|
61
|
+
UPDATE chat_history_counts SET message_count=MAX(0,message_count-1),revision=revision+1 WHERE session_id=OLD.session_id;
|
|
62
|
+
END;
|
|
63
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_message_insert AFTER INSERT ON chat_messages BEGIN
|
|
64
|
+
INSERT OR IGNORE INTO chat_history_index(message_id,session_id,event_sequence,source_stream_id,role,created_at)
|
|
65
|
+
VALUES(NEW.id,NEW.session_id,COALESCE((SELECT session_sequence FROM event_log WHERE stream_id=NEW.source_stream_id),NEW.sequence),NEW.source_stream_id,NEW.role,NEW.created_at);
|
|
66
|
+
END;
|
|
67
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_message_update AFTER UPDATE ON chat_messages BEGIN
|
|
68
|
+
DELETE FROM chat_history_index WHERE message_id=OLD.id;
|
|
69
|
+
INSERT INTO chat_history_index(message_id,session_id,event_sequence,source_stream_id,role,created_at)
|
|
70
|
+
VALUES(NEW.id,NEW.session_id,COALESCE((SELECT session_sequence FROM event_log WHERE stream_id=NEW.source_stream_id),NEW.sequence),NEW.source_stream_id,NEW.role,NEW.created_at);
|
|
71
|
+
END;
|
|
72
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_message_delete AFTER DELETE ON chat_messages BEGIN
|
|
73
|
+
DELETE FROM chat_history_index WHERE message_id=OLD.id;
|
|
74
|
+
END;
|
|
75
|
+
CREATE TRIGGER IF NOT EXISTS chat_history_event_sequence AFTER UPDATE OF session_sequence ON event_log
|
|
76
|
+
WHEN OLD.session_sequence IS NOT NEW.session_sequence BEGIN
|
|
77
|
+
UPDATE chat_history_index SET event_sequence=NEW.session_sequence WHERE source_stream_id=NEW.stream_id;
|
|
78
|
+
UPDATE chat_history_counts SET revision=revision+1 WHERE session_id=NEW.session_id;
|
|
79
|
+
END;
|
|
80
|
+
`;
|
|
81
|
+
/** Resumable bounded backfill; concurrent product writes maintain their rows through triggers. */
|
|
82
|
+
export class ChatReadProjectionStore {
|
|
83
|
+
db;
|
|
84
|
+
constructor(db) {
|
|
85
|
+
this.db = db;
|
|
86
|
+
}
|
|
87
|
+
status() {
|
|
88
|
+
const r = this.db.prepare("SELECT cursor,target,event_cursor,event_target,paused FROM chat_read_backfill WHERE id=1").get();
|
|
89
|
+
return { ...r, paused: Boolean(r.paused), historyComplete: r.cursor >= r.target, unreadComplete: r.event_cursor >= r.event_target, complete: r.cursor >= r.target && r.event_cursor >= r.event_target };
|
|
90
|
+
}
|
|
91
|
+
setPaused(paused) { this.db.prepare("UPDATE chat_read_backfill SET paused=? WHERE id=1").run(paused ? 1 : 0); }
|
|
92
|
+
step(limit = 128, maxMs = 4) {
|
|
93
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 512)
|
|
94
|
+
throw Error("Backfill row budget must be 1..512");
|
|
95
|
+
const state = this.status();
|
|
96
|
+
if (state.paused || state.complete)
|
|
97
|
+
return { ...state, processed: 0 };
|
|
98
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
99
|
+
try {
|
|
100
|
+
const current = this.status();
|
|
101
|
+
if (current.paused || current.complete) {
|
|
102
|
+
this.db.exec("COMMIT");
|
|
103
|
+
return { ...current, processed: 0 };
|
|
104
|
+
}
|
|
105
|
+
if (current.historyComplete) {
|
|
106
|
+
const rows = this.db.prepare("SELECT stream_id,session_id FROM event_log INDEXED BY idx_event_log_unread_stream WHERE stream_id>? AND stream_id<=? AND ((retention_class='chat_message' AND type IN ('user.message.accepted','assistant_message')) OR type='session_error') ORDER BY stream_id LIMIT ?").all(current.event_cursor, current.event_target, limit);
|
|
107
|
+
const insert = this.db.prepare("INSERT OR IGNORE INTO chat_unread_index VALUES(?,?)");
|
|
108
|
+
let processed = 0;
|
|
109
|
+
const started = performance.now();
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
if (row.session_id)
|
|
112
|
+
insert.run(row.stream_id, row.session_id);
|
|
113
|
+
processed++;
|
|
114
|
+
if (performance.now() - started >= maxMs)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const cursor = processed === rows.length && rows.length < limit ? current.event_target : rows[processed - 1].stream_id;
|
|
118
|
+
this.db.prepare("UPDATE chat_read_backfill SET event_cursor=? WHERE id=1").run(cursor);
|
|
119
|
+
this.db.exec("COMMIT");
|
|
120
|
+
return { ...this.status(), processed };
|
|
121
|
+
}
|
|
122
|
+
const rows = this.db.prepare("SELECT rowid AS cursor,id FROM chat_messages WHERE rowid>? AND rowid<=? ORDER BY rowid LIMIT ?").all(current.cursor, current.target, limit);
|
|
123
|
+
const insert = this.db.prepare(`INSERT OR IGNORE INTO chat_history_index(message_id,session_id,event_sequence,source_stream_id,role,created_at)
|
|
124
|
+
SELECT m.id,m.session_id,COALESCE(e.session_sequence,m.sequence),m.source_stream_id,m.role,m.created_at FROM chat_messages m LEFT JOIN event_log e ON e.stream_id=m.source_stream_id WHERE m.id=?`);
|
|
125
|
+
let processed = 0;
|
|
126
|
+
const started = performance.now();
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
insert.run(row.id);
|
|
129
|
+
processed++;
|
|
130
|
+
if (performance.now() - started >= maxMs)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
const cursor = processed === rows.length && rows.length < limit ? current.target : rows[processed - 1].cursor;
|
|
134
|
+
this.db.prepare("UPDATE chat_read_backfill SET cursor=? WHERE id=1").run(cursor);
|
|
135
|
+
this.db.exec("COMMIT");
|
|
136
|
+
return { ...this.status(), processed };
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
this.db.exec("ROLLBACK");
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export const CHAT_NAVIGATION_REVISION_SCHEMA = `
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_navigation_cursor ON sessions(room_id,id) WHERE deleted_at IS NULL;
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_navigation_read ON sessions(room_id,last_activity_at DESC,created_at DESC) WHERE deleted_at IS NULL;
|
|
147
|
+
CREATE TABLE IF NOT EXISTS chat_navigation_clock(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL);
|
|
148
|
+
INSERT OR IGNORE INTO chat_navigation_clock VALUES(1,0);
|
|
149
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_session_insert AFTER INSERT ON sessions BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
150
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_session_delete AFTER DELETE ON sessions BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
151
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_session_update AFTER UPDATE ON sessions
|
|
152
|
+
WHEN OLD.title IS NOT NEW.title OR OLD.profile IS NOT NEW.profile OR OLD.parent_id IS NOT NEW.parent_id OR OLD.origin_id IS NOT NEW.origin_id OR OLD.metadata_json IS NOT NEW.metadata_json OR OLD.deleted_at IS NOT NEW.deleted_at OR OLD.active_model_json IS NOT NEW.active_model_json OR OLD.room_id IS NOT NEW.room_id
|
|
153
|
+
BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
154
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_binding_insert AFTER INSERT ON session_runtime_bindings BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
155
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_binding_update AFTER UPDATE ON session_runtime_bindings
|
|
156
|
+
WHEN OLD.runtime_instance_id IS NOT NEW.runtime_instance_id OR OLD.native_session_id IS NOT NEW.native_session_id OR OLD.revision IS NOT NEW.revision
|
|
157
|
+
BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
158
|
+
CREATE TRIGGER IF NOT EXISTS chat_navigation_binding_delete AFTER DELETE ON session_runtime_bindings BEGIN UPDATE chat_navigation_clock SET revision=revision+1 WHERE id=1; END;
|
|
159
|
+
`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChatReadStateService } from "../apps/chat/data/read-state-service.js";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { ChatReadProjectionStore } from "./chat-read-projections.js";
|
|
4
|
+
import { parentPort, workerData, threadId } from "node:worker_threads";
|
|
5
|
+
import { PiboDataStore } from "./pibo-store.js";
|
|
6
|
+
import { boundedMessageBytes } from "./bounded-worker-client.js";
|
|
7
|
+
import { CHAT_READ_METHODS } from "./async-chat-reads.js";
|
|
8
|
+
import { ChatTimelineQueryService } from "../apps/chat/data/timeline-query-service.js";
|
|
9
|
+
import { ChatHistoryQueryService } from "../apps/chat/data/history-query-service.js";
|
|
10
|
+
if (!parentPort)
|
|
11
|
+
throw Error("Chat reads require a worker");
|
|
12
|
+
const store = new PiboDataStore(workerData.path, { payloadRootDir: workerData.payloadRootDir, readOnly: true });
|
|
13
|
+
store.db.exec("PRAGMA busy_timeout=10");
|
|
14
|
+
// Timeline hydration must match the in-process default. A smaller worker budget silently replaces
|
|
15
|
+
// message bodies with their preview and StoredChatEvent carries no payload reference to recover them,
|
|
16
|
+
// so the same query would answer with less content than the fallback it replaced. The IPC bound stays
|
|
17
|
+
// owned by maxMessageBytes plus the bounded page-halving recovery in the event stream replay loop.
|
|
18
|
+
const services = { navigation: new ChatReadStateService(store), timeline: new ChatTimelineQueryService(store), history: new ChatHistoryQueryService(store, 2 * 1024 * 1024) };
|
|
19
|
+
let operations = 0;
|
|
20
|
+
const maintenanceDb = new DatabaseSync(workerData.path);
|
|
21
|
+
maintenanceDb.exec("PRAGMA busy_timeout=10; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL");
|
|
22
|
+
const maintenance = new ChatReadProjectionStore(maintenanceDb);
|
|
23
|
+
let maintenanceFailures = 0;
|
|
24
|
+
let maintenanceDelay = 25;
|
|
25
|
+
let maintenanceTimer;
|
|
26
|
+
function scheduleMaintenance() {
|
|
27
|
+
if (maintenanceTimer)
|
|
28
|
+
return;
|
|
29
|
+
const state = maintenance.status();
|
|
30
|
+
if (state.complete || state.paused)
|
|
31
|
+
return;
|
|
32
|
+
maintenanceTimer = setTimeout(() => { maintenanceTimer = undefined; try {
|
|
33
|
+
maintenance.step();
|
|
34
|
+
maintenanceDelay = 25;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
maintenanceFailures++;
|
|
38
|
+
maintenanceDelay = Math.min(1000, maintenanceDelay * 2);
|
|
39
|
+
} scheduleMaintenance(); }, maintenanceDelay);
|
|
40
|
+
maintenanceTimer.unref();
|
|
41
|
+
}
|
|
42
|
+
scheduleMaintenance();
|
|
43
|
+
parentPort.on("message", (request) => {
|
|
44
|
+
try {
|
|
45
|
+
if (performance.now() >= request.deadline)
|
|
46
|
+
throw Object.assign(Error("Read deadline elapsed"), { code: "storage_deadline" });
|
|
47
|
+
const { group, method, args } = request.command;
|
|
48
|
+
if (String(group) === "maintenance") {
|
|
49
|
+
if (method === "pause")
|
|
50
|
+
maintenance.setPaused(true);
|
|
51
|
+
else if (method === "resume") {
|
|
52
|
+
maintenance.setPaused(false);
|
|
53
|
+
scheduleMaintenance();
|
|
54
|
+
}
|
|
55
|
+
else if (method !== "status")
|
|
56
|
+
throw Error("Unsupported maintenance operation");
|
|
57
|
+
parentPort.postMessage({ id: request.id, value: { ...maintenance.status(), failures: maintenanceFailures } });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!Object.hasOwn(services, group) || !CHAT_READ_METHODS[group].includes(method))
|
|
61
|
+
throw Error("Unsupported read operation");
|
|
62
|
+
const service = services[group];
|
|
63
|
+
const value = service[method].apply(service, args);
|
|
64
|
+
boundedMessageBytes(value, request.maxResultBytes);
|
|
65
|
+
operations++;
|
|
66
|
+
parentPort.postMessage({ id: request.id, value, worker: { pid: process.pid, threadId, operations, readOnly: true, maintenance: { ...maintenance.status(), failures: maintenanceFailures } } });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" && error.code.startsWith("storage_") ? error.code : "storage_read_failed";
|
|
70
|
+
parentPort.postMessage({ id: request.id, error: { code, message: "Bounded chat read failed; retry the page." } });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
parentPort.postMessage({ ready: true, worker: { pid: process.pid, threadId, operations, readOnly: true, maintenance: { ...maintenance.status(), failures: maintenanceFailures } } });
|