@jini-ai/chat 0.6.17 → 0.6.21
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/CHANGELOG.md +15 -0
- package/dist/core/accepted-input.d.ts +10 -2
- package/dist/core/accepted-input.js +87 -33
- package/dist/core/assistant-client/durable-run-subscription.js +6 -0
- package/dist/core/assistant-client/entry.d.ts +1 -1
- package/dist/core/assistant-client/entry.js +1 -1
- package/dist/core/assistant-client/messages.d.ts +21 -0
- package/dist/core/assistant-client/messages.js +31 -0
- package/dist/core/checkpoint-interval.d.ts +25 -0
- package/dist/core/checkpoint-interval.js +38 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
- package/dist/core/durable-runs/ports.d.ts +25 -1
- package/dist/core/events.d.ts +6 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/run-events/entry.js +15 -2
- package/dist/core/surface-recovery.d.ts +19 -0
- package/dist/core/surface-recovery.js +26 -0
- package/dist/core/tool-events.d.ts +11 -3
- package/dist/core/tool-events.js +75 -43
- package/dist/core/turn-usage.d.ts +1 -1
- package/dist/core/turn-usage.js +5 -3
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
- package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
- package/dist/react/components/Composer.d.ts +3 -3
- package/dist/react/components/Composer.js +5 -4
- package/dist/react/components/ConversationList.js +2 -1
- package/dist/react/components/ConversationTitleEditor.js +2 -1
- package/dist/react/components/ExtEventErrorBoundary.js +2 -1
- package/dist/react/components/McpUiSurfaceCard.js +12 -5
- package/dist/react/components/MessageList.js +2 -1
- package/dist/react/components/MidRunUserMessage.js +1 -1
- package/dist/react/components/ToolCard.js +3 -2
- package/dist/react/components/ToolCard.rules.js +2 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
- package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +22 -3
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +3 -1
- package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
- package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
- package/dist/react/hooks/composer-draft-cache.js +18 -17
- package/dist/react/hooks/useComposer.js +52 -4
- package/dist/react/hooks/useConversation.js +41 -22
- package/dist/react/hooks/useRunStream.js +2 -1
- package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
- package/dist/react/hooks/useSurfaceRecovery.js +84 -0
- package/dist/react/styles/reference.css +4 -5
- package/dist/server/durable-runs/recover.js +3 -3
- package/dist/store/ports.d.ts +17 -3
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- package/dist/store/runtime/chat-history-store.js +6 -0
- package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
- package/dist/store/runtime/checkpoint-delta.js +75 -0
- package/dist/store/runtime/durable-run-store.js +92 -25
- package/dist/store/runtime/events-json.d.ts +17 -0
- package/dist/store/runtime/events-json.js +19 -0
- package/dist/store/runtime/run-guarded-chat-store.js +8 -3
- package/dist/store/runtime/run-ledger.d.ts +2 -2
- package/dist/store/runtime/run-ledger.js +120 -45
- package/dist/store/sql/read-version.d.ts +15 -0
- package/dist/store/sql/read-version.js +21 -0
- package/dist/store/sql/store.d.ts +9 -0
- package/dist/store/sql/store.js +89 -4
- package/dist/store/sqlite/schema.d.ts +2 -2
- package/dist/store/sqlite/schema.js +4 -0
- package/package.json +12 -12
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { dedupeToolUsesById } from "../../core/tool-events.js";
|
|
2
|
+
/** Compare unchanged prefix values; compacting text creates fresh objects, while large tool
|
|
3
|
+
* payloads normally retain their reference. Never parse the saved SQL column here. */
|
|
4
|
+
function sameEvent(left, right) {
|
|
5
|
+
return left === right || JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
+
}
|
|
7
|
+
/** A delta may append events or replace the final coalesced text event. Every earlier event
|
|
8
|
+
* must match; replay reordering, thinking replacement and tool identity deduplication fall back.
|
|
9
|
+
* @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
|
|
10
|
+
export function checkpointDelta({ previous, progress }, _options = {}) {
|
|
11
|
+
if (progress.content.length < previous.content.length)
|
|
12
|
+
return undefined;
|
|
13
|
+
const incoming = progress.events;
|
|
14
|
+
const saved = previous.events;
|
|
15
|
+
if (incoming.length < saved.length)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (dedupeToolUsesById({ events: incoming }).length !== incoming.length)
|
|
18
|
+
return undefined;
|
|
19
|
+
const prefix = saved.slice(0, -1);
|
|
20
|
+
if (!prefix.every((event, index) => sameEvent(event, incoming[index])))
|
|
21
|
+
return undefined;
|
|
22
|
+
const last = saved.at(-1);
|
|
23
|
+
const next = incoming[saved.length - 1];
|
|
24
|
+
const replacing = last !== undefined && !sameEvent(last, next);
|
|
25
|
+
if (replacing && !extendsText({ last, next }))
|
|
26
|
+
return undefined;
|
|
27
|
+
return { ...deltaSuffix({ previous, incoming, replacing }),
|
|
28
|
+
grown: progress.content.length > previous.content.length || hasNewToolProgress({ saved, incoming }) };
|
|
29
|
+
}
|
|
30
|
+
/** SQL counts characters in the removed tail, so non-BMP text never uses JS UTF-16 offsets. */
|
|
31
|
+
function deltaSuffix({ previous, incoming, replacing }, _options = {}) {
|
|
32
|
+
const from = previous.events.length - (replacing ? 1 : 0);
|
|
33
|
+
const suffixEvents = incoming.slice(from);
|
|
34
|
+
const separator = !replacing && from > 0 && suffixEvents.length > 0 ? ',' : '';
|
|
35
|
+
return { removeCharacters: replacing ? previous.tailLength : 1,
|
|
36
|
+
suffix: separator + JSON.stringify(suffixEvents).slice(1) };
|
|
37
|
+
}
|
|
38
|
+
/** Extending text must retain every other field (including any future event metadata). */
|
|
39
|
+
function extendsText({ last, next }, _options = {}) {
|
|
40
|
+
if (last.kind !== 'text' || next?.kind !== 'text')
|
|
41
|
+
return false;
|
|
42
|
+
return next.text.startsWith(last.text) && sameEvent({ ...last, text: next.text }, next);
|
|
43
|
+
}
|
|
44
|
+
/** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
|
|
45
|
+
export function hasNewToolProgress({ saved, incoming }, _options = {}) {
|
|
46
|
+
const keys = new Set(saved.map(event => toolKey({ event })));
|
|
47
|
+
return incoming.some(event => { const key = toolKey({ event }); return key !== '' && !keys.has(key); });
|
|
48
|
+
}
|
|
49
|
+
/** Durable tool identity shared by the full merge and delta progress check. */
|
|
50
|
+
export function toolKey({ event }, _options = {}) {
|
|
51
|
+
if (event.kind === 'tool_use')
|
|
52
|
+
return 'use:' + event.id;
|
|
53
|
+
if (event.kind === 'tool_result')
|
|
54
|
+
return 'result:' + event.toolUseId;
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
/** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
|
|
58
|
+
* SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
|
|
59
|
+
* a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
|
|
60
|
+
* @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
|
|
61
|
+
* with at most 256 UTF-16 units retained beyond the existing projection references.
|
|
62
|
+
*/
|
|
63
|
+
export function checkpointState({ progress, length, contentLength, version }, _options = {}) {
|
|
64
|
+
const last = progress.events.at(-1);
|
|
65
|
+
const fullTail = last === undefined ? '[]' : JSON.stringify(last) + ']';
|
|
66
|
+
// Count SQL characters without allocating an array of code points for a multi-MB tool result.
|
|
67
|
+
let tailLength = 0;
|
|
68
|
+
for (const _character of fullTail)
|
|
69
|
+
tailLength++;
|
|
70
|
+
let tail = fullTail.slice(-256);
|
|
71
|
+
if (/^[\uDC00-\uDFFF]/u.test(tail))
|
|
72
|
+
tail = tail.slice(1);
|
|
73
|
+
return { content: progress.content, events: progress.events, version, length, contentLength, tailLength, tail };
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=checkpoint-delta.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sqliteReadVersion } from '../sql/read-version.js';
|
|
1
2
|
import { acceptedInputEvent, acceptedInputUserEvent } from "../../core/accepted-input.js";
|
|
2
3
|
import { redactUserMessage } from "../../core/user-text-redaction.js";
|
|
3
4
|
import { recoveredRunEvents } from "../../core/index.js";
|
|
@@ -8,7 +9,8 @@ import { RECOVERY_WINDOW_MS } from "../../core/durable-runs/policies.js";
|
|
|
8
9
|
import { createChatHistoryStore } from "./chat-history-store.js";
|
|
9
10
|
import { runLockKey } from "../../core/durable-runs/run-lock.js";
|
|
10
11
|
import { runContentFromEvents } from "../../core/run-events/entry.js";
|
|
11
|
-
import {
|
|
12
|
+
import { appendedEventsJson } from "./events-json.js";
|
|
13
|
+
import { toMessage, validatedEventsJson } from "../sql/store.js";
|
|
12
14
|
export const RUN_SLOT_BUSY = "Another answer in this chat is still running. This turn is queued in your browser.";
|
|
13
15
|
export class RunSlotBusyError extends Error {
|
|
14
16
|
constructor() { super(RUN_SLOT_BUSY); }
|
|
@@ -150,15 +152,6 @@ function continuationEvents({ current, incoming }, _optional = {}) {
|
|
|
150
152
|
}
|
|
151
153
|
return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
|
|
152
154
|
}
|
|
153
|
-
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
154
|
-
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
155
|
-
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
156
|
-
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
157
|
-
* run unchanged on SQLite and PostgreSQL. */
|
|
158
|
-
function appendedEventsJson({ event }, _optional = {}) {
|
|
159
|
-
const item = sql `cast(${JSON.stringify(event)} as text)`;
|
|
160
|
-
return sql `case when events_json like '[_%]' then substr(events_json, 1, length(events_json) - 1) || ',' || ${item} || ']' else '[' || ${item} || ']' end`;
|
|
161
|
-
}
|
|
162
155
|
function sameOwner({ run, required }, _optional = {}) {
|
|
163
156
|
return run.principalId === required.principalId && run.conversationId === required.conversationId;
|
|
164
157
|
}
|
|
@@ -176,10 +169,28 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
176
169
|
.select(eventsColumn.as("events_json"))
|
|
177
170
|
.where(match.column, "=", match.value).where("role", "=", "assistant").executeTakeFirst());
|
|
178
171
|
}
|
|
179
|
-
/**
|
|
180
|
-
async function conversationTranscript({ row },
|
|
172
|
+
/** Conversation projection: callers can omit assistant event payloads; legacy heads need only content. */
|
|
173
|
+
async function conversationTranscript({ row }, { includeEvents = true } = {}) {
|
|
181
174
|
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
|
|
182
|
-
return history.messages({ conversationId: row.conversation_id });
|
|
175
|
+
return history.messages({ conversationId: row.conversation_id }, { includeEvents });
|
|
176
|
+
}
|
|
177
|
+
/** Legacy accepted requests and recovery reconstruction consume prior text only. */
|
|
178
|
+
async function readTranscript({ row, metadata }, options = {}) {
|
|
179
|
+
if (!options.transcript && metadata)
|
|
180
|
+
return [];
|
|
181
|
+
const includeEvents = transcriptEventProjection(options);
|
|
182
|
+
return conversationTranscript({ row }, { includeEvents });
|
|
183
|
+
}
|
|
184
|
+
/** Accepted user receipts split queued inputs and hold folder references. Keep that small
|
|
185
|
+
* public metadata even when recovery omits prior assistant event payloads. */
|
|
186
|
+
function transcriptEventProjection(options) {
|
|
187
|
+
if (options.includeTranscriptEvents === false && options.transcript)
|
|
188
|
+
return 'user';
|
|
189
|
+
return options.includeTranscriptEvents ?? options.transcript === true;
|
|
190
|
+
}
|
|
191
|
+
function currentMessage({ row, transcript }, options = {}) {
|
|
192
|
+
return options.transcript && options.includeTranscriptEvents !== false
|
|
193
|
+
? transcript.find(item => item.id === row.id) : toMessage(row);
|
|
183
194
|
}
|
|
184
195
|
/** One run's head, plus its transcript when that was read (`[]` otherwise). */
|
|
185
196
|
async function readRun({ match }, options = {}) {
|
|
@@ -189,11 +200,12 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
189
200
|
const messageId = row.id;
|
|
190
201
|
const metadata = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", messageId).executeTakeFirst());
|
|
191
202
|
// A legacy row's accepted request is derived from its transcript, so only it reads one here.
|
|
192
|
-
const transcript =
|
|
193
|
-
const message =
|
|
203
|
+
const transcript = await readTranscript({ row, metadata }, options);
|
|
204
|
+
const message = currentMessage({ row, transcript }, options);
|
|
194
205
|
if (!message)
|
|
195
206
|
return null; // Deletion may have cascaded between the two reads.
|
|
196
|
-
return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }),
|
|
207
|
+
return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }),
|
|
208
|
+
transcript: transcript.map(item => item.id === messageId ? message : item) };
|
|
197
209
|
}
|
|
198
210
|
/** Locked read of one message; `transcript` decides whether it is a whole run or only its head. */
|
|
199
211
|
async function lockedRead({ messageId, conversationId }, options = {}) {
|
|
@@ -204,15 +216,70 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
204
216
|
return readRun({ match: { column: "ai_chat_messages.id", value: messageId } }, options);
|
|
205
217
|
});
|
|
206
218
|
}
|
|
207
|
-
async function load({ messageId },
|
|
219
|
+
async function load({ messageId }, { includeTranscriptEvents = true } = {}) {
|
|
208
220
|
const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
|
|
209
221
|
.where("id", "=", messageId).executeTakeFirst());
|
|
210
222
|
if (!binding)
|
|
211
223
|
return null;
|
|
212
|
-
//
|
|
213
|
-
|
|
224
|
+
// Full reads take the message from the transcript. Lean reads select its own events once,
|
|
225
|
+
// omitting prior assistant payloads while retaining accepted user receipts.
|
|
226
|
+
const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true, includeTranscriptEvents, events: !includeTranscriptEvents });
|
|
214
227
|
return read && { ...read.head, transcript: read.transcript };
|
|
215
228
|
}
|
|
229
|
+
/** Single-message read; message identity survives continuation while run_id changes.
|
|
230
|
+
* Legacy request reconstruction needs only transcript content, never its old event columns. */
|
|
231
|
+
async function head({ messageId }, { events = true } = {}) {
|
|
232
|
+
const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
|
|
233
|
+
.where("id", "=", messageId).executeTakeFirst());
|
|
234
|
+
if (!binding)
|
|
235
|
+
return null;
|
|
236
|
+
return (await lockedRead({ messageId, conversationId: binding.conversation_id }, { events }))?.head ?? null;
|
|
237
|
+
}
|
|
238
|
+
/** Read only one owned projection, without a run lock or accepted-request reconstruction.
|
|
239
|
+
* Native database versions follow checkpoint growth, replacements, attempts and settlement. SQL omits
|
|
240
|
+
* content/events on a hit; only changed rows cross into JS as raw JSON, never parsed events.
|
|
241
|
+
* @complexity One indexed row read; O(1) unchanged payload and JS space; changed payload is O(current message bytes).
|
|
242
|
+
*/
|
|
243
|
+
async function poll({ messageId, principalId, workspaceId }, { cursor } = {}) {
|
|
244
|
+
// SQLite has no per-row version: combine this connection's writes with the external-commit
|
|
245
|
+
// generation. Unrelated writes may invalidate the cursor, but equal-length rewrites cannot
|
|
246
|
+
// hide. PostgreSQL supplies the row's own MVCC version. Neither reads the event column.
|
|
247
|
+
const version = kernel.dialect === 'sqlite'
|
|
248
|
+
? sqliteReadVersion({ kernel })
|
|
249
|
+
: sql `cast(ai_chat_messages.xmin as text)`;
|
|
250
|
+
const changed = sql `${version} <> ${cursor ?? ''}`;
|
|
251
|
+
// Run writers persist compact JSON arrays on both engines. SQLite also tolerates malformed
|
|
252
|
+
// legacy rows through its native validator; PG14 has no safe try-cast (do not parse in JS).
|
|
253
|
+
const valid = kernel.dialect === 'sqlite' ? sql `json_valid(events_json)` : sql `1`;
|
|
254
|
+
const row = await kernel.run(db => db.selectFrom('ai_chat_messages')
|
|
255
|
+
.innerJoin('ai_chats', 'ai_chats.id', 'ai_chat_messages.conversation_id')
|
|
256
|
+
.select(['ai_chat_messages.id', 'conversation_id', 'role'])
|
|
257
|
+
.select([
|
|
258
|
+
sql `case when ${changed} then run_id else null end`.as('run_id'),
|
|
259
|
+
sql `case when ${changed} then run_status else null end`.as('run_status'),
|
|
260
|
+
sql `case when ${changed} then agent_id else null end`.as('agent_id'),
|
|
261
|
+
sql `case when ${changed} then agent_name else null end`.as('agent_name'),
|
|
262
|
+
sql `case when ${changed} then attachments_json else null end`.as('attachments_json'),
|
|
263
|
+
sql `case when ${changed} then ai_chat_messages.created_at else 0 end`.as('created_at'),
|
|
264
|
+
sql `case when ${changed} then started_at else null end`.as('started_at'),
|
|
265
|
+
sql `case when ${changed} then ended_at else null end`.as('ended_at')
|
|
266
|
+
])
|
|
267
|
+
.select([version.as('cursor'),
|
|
268
|
+
sql `case when ${changed} then content else '' end`.as('content'),
|
|
269
|
+
sql `case when ${changed} then events_json else null end`.as('events_json'),
|
|
270
|
+
sql `case when ${changed} then ${valid} else null end`.as('events_json_valid')])
|
|
271
|
+
.where('ai_chat_messages.id', '=', messageId).where('role', '=', 'assistant')
|
|
272
|
+
.where('owner_kind', '=', 'user').where('owner_id', '=', principalId).where('scope_id', '=', workspaceId)
|
|
273
|
+
.executeTakeFirst());
|
|
274
|
+
if (!row)
|
|
275
|
+
return null;
|
|
276
|
+
if (row.cursor === cursor)
|
|
277
|
+
return { cursor: row.cursor };
|
|
278
|
+
const metadata = JSON.stringify(toMessage({ ...row, events_json: null }));
|
|
279
|
+
const events = validatedEventsJson({ row });
|
|
280
|
+
const messageJson = events === undefined ? metadata : metadata.slice(0, -1) + ',"events":' + events + '}';
|
|
281
|
+
return { cursor: row.cursor, conversationId: row.conversation_id, messageJson };
|
|
282
|
+
}
|
|
216
283
|
/** Unlocked probe: the message and conversation an attempt id currently names. */
|
|
217
284
|
async function probeRun({ runId }, _options = {}) {
|
|
218
285
|
return kernel.run((db) => db.selectFrom("ai_chat_messages").select(["id", "conversation_id"])
|
|
@@ -267,7 +334,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
267
334
|
const history = await lockOwnedConversation({ required });
|
|
268
335
|
if (!history)
|
|
269
336
|
return null;
|
|
270
|
-
const existing = await load({ messageId: required.messageId }, {});
|
|
337
|
+
const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
271
338
|
if (existing)
|
|
272
339
|
return sameOwner({ run: existing, required }) ? existing : null;
|
|
273
340
|
await assertSlotFree({ conversationId: required.conversationId });
|
|
@@ -282,7 +349,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
282
349
|
recovery_elapsed_ms: 0, attempt_started_at: required.now,
|
|
283
350
|
session_id: null, session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: "[]",
|
|
284
351
|
}).execute());
|
|
285
|
-
return load({ messageId: required.messageId }, {});
|
|
352
|
+
return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
286
353
|
});
|
|
287
354
|
}
|
|
288
355
|
async function writeAttemptMetadata({ values, updates }) {
|
|
@@ -340,18 +407,18 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
340
407
|
const history = await lockOwnedConversation({ required });
|
|
341
408
|
if (!history)
|
|
342
409
|
return null;
|
|
343
|
-
const existing = await load({ messageId: required.messageId }, {});
|
|
410
|
+
const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
344
411
|
if (existing) {
|
|
345
412
|
if (!sameOwner({ run: existing, required }))
|
|
346
413
|
return null;
|
|
347
414
|
await cancel({ runId: existing.runId, reason: "user-stop" }, {});
|
|
348
|
-
return load({ messageId: existing.messageId }, {});
|
|
415
|
+
return load({ messageId: existing.messageId }, { includeTranscriptEvents: false });
|
|
349
416
|
}
|
|
350
417
|
// Stop may beat acceptance or its response. A terminal tombstone for the same logical
|
|
351
418
|
// identity makes any later duplicate acceptance a no-op, without reserving the chat slot.
|
|
352
419
|
await history.appendMessage({ conversationId: required.conversationId, message: { id: required.messageId, role: "assistant", content: "",
|
|
353
420
|
events: [{ kind: "status", label: "Stopped." }], runId: required.runId, runStatus: "canceled", createdAt: required.now, endedAt: required.now } });
|
|
354
|
-
return load({ messageId: required.messageId }, {});
|
|
421
|
+
return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
355
422
|
});
|
|
356
423
|
}
|
|
357
424
|
async function saveAgentSession({ run, sessionId }) {
|
|
@@ -430,6 +497,6 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
430
497
|
await writeAttemptMetadata({ values: { ...row, recovery_deadline: deadline, recovery_elapsed_ms: elapsed }, updates: { recovery_deadline: deadline, recovery_elapsed_ms: elapsed } });
|
|
431
498
|
});
|
|
432
499
|
}
|
|
433
|
-
return { load, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
|
|
500
|
+
return { load, head, poll, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
|
|
434
501
|
}
|
|
435
502
|
//# sourceMappingURL=durable-run-store.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentEvent } from "../../core/index.js";
|
|
2
|
+
/** Replace a compact array's suffix without transferring the stored prefix to JS.
|
|
3
|
+
* SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
|
|
4
|
+
* @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
|
|
5
|
+
export declare function spliceEventsJson({ removeCharacters, suffix }: {
|
|
6
|
+
removeCharacters: number;
|
|
7
|
+
suffix: string;
|
|
8
|
+
}, _options?: {}): import("kysely").RawBuilder<string>;
|
|
9
|
+
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
10
|
+
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
11
|
+
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
12
|
+
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
13
|
+
* run unchanged on SQLite and PostgreSQL. */
|
|
14
|
+
export declare function appendedEventsJson({ event }: {
|
|
15
|
+
event: AgentEvent;
|
|
16
|
+
}, _optional?: {}): import("kysely").RawBuilder<string>;
|
|
17
|
+
//# sourceMappingURL=events-json.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
/** Replace a compact array's suffix without transferring the stored prefix to JS.
|
|
3
|
+
* SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
|
|
4
|
+
* @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
|
|
5
|
+
export function spliceEventsJson({ removeCharacters, suffix }, _options = {}) {
|
|
6
|
+
return sql `substr(events_json, 1, length(events_json) - ${removeCharacters}) || cast(${suffix} as text)`;
|
|
7
|
+
}
|
|
8
|
+
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
9
|
+
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
10
|
+
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
11
|
+
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
12
|
+
* run unchanged on SQLite and PostgreSQL. */
|
|
13
|
+
export function appendedEventsJson({ event }, _optional = {}) {
|
|
14
|
+
const item = JSON.stringify(event);
|
|
15
|
+
const suffix = spliceEventsJson({ removeCharacters: 1, suffix: ',' + item + ']' });
|
|
16
|
+
const empty = '[' + item + ']';
|
|
17
|
+
return sql `case when events_json like '[_%]' then ${suffix} else cast(${empty} as text) end`;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=events-json.js.map
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Bind the server-owned run write fence to an already owner-scoped transcript. */
|
|
2
2
|
export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _optional = {}) {
|
|
3
|
+
async function savedMessage({ conversationId, messageId }, _optional = {}) {
|
|
4
|
+
if (store.message)
|
|
5
|
+
return store.message({ conversationId, messageId }, {});
|
|
6
|
+
// Compatibility for hosts/fakes without the indexed read capability.
|
|
7
|
+
return (await store.messages({ conversationId })).find(message => message.id === messageId) ?? null;
|
|
8
|
+
}
|
|
3
9
|
return {
|
|
4
10
|
...store,
|
|
5
11
|
/*
|
|
@@ -24,7 +30,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
|
|
|
24
30
|
// attempt id or a transport-only failure; returning the stored row fences both without
|
|
25
31
|
// matching presentation text. BYOK/AG-UI remain request-bound.
|
|
26
32
|
if (isDaemonRunId({ runId })) {
|
|
27
|
-
const saved =
|
|
33
|
+
const saved = await savedMessage({ conversationId, messageId: message.id });
|
|
28
34
|
if (saved)
|
|
29
35
|
return saved;
|
|
30
36
|
if (message.runStatus !== "queued" && message.runStatus !== "running")
|
|
@@ -34,8 +40,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
|
|
|
34
40
|
} }, {});
|
|
35
41
|
if (outcome.written)
|
|
36
42
|
return outcome.value;
|
|
37
|
-
|
|
38
|
-
return saved.find((m) => m.id === message.id) ?? null;
|
|
43
|
+
return savedMessage({ conversationId, messageId: message.id });
|
|
39
44
|
},
|
|
40
45
|
};
|
|
41
46
|
}
|
|
@@ -6,8 +6,8 @@ export { runLockKey } from "../../core/durable-runs/run-lock.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* @param store the chat kernel, or the open `chat.db` handle `createChatStoreFactory` also wraps.
|
|
8
8
|
* @param now Injectable epoch-millisecond clock for progress stamps and the default cancel time.
|
|
9
|
-
* @complexity
|
|
10
|
-
*
|
|
9
|
+
* @complexity indexed guards; checkpoints cost O(delta bytes) plus a SQLite scalar receipt read,
|
|
10
|
+
* or O(saved + incoming bytes) on fallback. Reconcile costs O(stuck rows + their event bytes).
|
|
11
11
|
*/
|
|
12
12
|
export declare function createChatRunLedger<DB extends ChatDatabase>({ kernel: hostKernel, isDaemonRunId }: {
|
|
13
13
|
kernel: ChatKernel<DB>;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { dedupeToolUsesById } from "../../core/tool-events.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
import { sqliteReadVersion } from "../sql/read-version.js";
|
|
4
|
+
import { spliceEventsJson } from "./events-json.js";
|
|
5
|
+
import { checkpointDelta, checkpointState, hasNewToolProgress, toolKey } from "./checkpoint-delta.js";
|
|
2
6
|
import { chatKernelTables } from "../sql/boundary.js";
|
|
3
7
|
import { createDurableRunStore } from "./durable-run-store.js";
|
|
4
8
|
/**
|
|
@@ -48,18 +52,6 @@ function readEvents(eventsJson) {
|
|
|
48
52
|
}
|
|
49
53
|
return Array.isArray(events) ? events : [];
|
|
50
54
|
}
|
|
51
|
-
/** Tool identity shared by progress detection and checkpoint merging; "" for non-tool events. */
|
|
52
|
-
function toolKey(event) {
|
|
53
|
-
if (event.kind === "tool_use")
|
|
54
|
-
return `use:${event.id}`;
|
|
55
|
-
if (event.kind === "tool_result")
|
|
56
|
-
return `result:${event.toolUseId}`;
|
|
57
|
-
return "";
|
|
58
|
-
}
|
|
59
|
-
function hasNewToolProgress(current, next) {
|
|
60
|
-
const saved = new Set(current.map(toolKey));
|
|
61
|
-
return next.some((event) => { const id = toolKey(event); return id !== "" && !saved.has(id); });
|
|
62
|
-
}
|
|
63
55
|
/** Retain a longer checkpoint's text and merge non-text replay events under the shared ID rule.
|
|
64
56
|
* @complexity O(b + n) time and space for b serialized bytes across n events. */
|
|
65
57
|
function retainSavedEvents(saved, incoming) {
|
|
@@ -72,9 +64,11 @@ function retainSavedEvents(saved, incoming) {
|
|
|
72
64
|
*
|
|
73
65
|
* The saved `events_json` is parsed ONCE and reused by both the merge and the progress check:
|
|
74
66
|
* a long turn's column is many MB, and a host checkpoints every second (memory cause 3).
|
|
75
|
-
*
|
|
76
|
-
* text
|
|
77
|
-
*
|
|
67
|
+
* Blindly appending only new events is NOT equivalent: storage coalesces text deltas (the last
|
|
68
|
+
* text event grows in place) and the merge puts saved-only tool barriers after replay events.
|
|
69
|
+
* The watch-owned delta may replace that text tail only after an exact incoming write; saved-only
|
|
70
|
+
* barriers disable it. Native versions, SQL lengths and bounded suffix CAS detect concurrent
|
|
71
|
+
* appends and equal-length replacements; any miss uses this authoritative merge, never a ledger cache or freshness stamp.
|
|
78
72
|
* @complexity O(b + n) time and space for b serialized bytes across n events; one parse, one stringify.
|
|
79
73
|
*/
|
|
80
74
|
function planCheckpoint({ current, progress }, _optional = {}) {
|
|
@@ -86,27 +80,30 @@ function planCheckpoint({ current, progress }, _optional = {}) {
|
|
|
86
80
|
const runStatus = current.run_status === "queued" ? "running" : current.run_status;
|
|
87
81
|
return {
|
|
88
82
|
eventsJson,
|
|
83
|
+
exact: events.length === progress.events.length && events.every((event, index) => event === progress.events[index]),
|
|
89
84
|
runStatus,
|
|
90
|
-
grown: progress.content.length > current.content.length || hasNewToolProgress(saved, events),
|
|
85
|
+
grown: progress.content.length > current.content.length || hasNewToolProgress({ saved, incoming: events }),
|
|
91
86
|
unchanged: runStatus === current.run_status && progress.content === current.content && eventsJson === current.events_json,
|
|
92
87
|
};
|
|
93
88
|
}
|
|
94
89
|
/** Preserve tool barriers absent from a replay, with one persisted use per tool ID.
|
|
95
90
|
* @complexity O(n) time and space in the combined saved and incoming event count. */
|
|
96
91
|
function keepToolCheckpoints(saved, incoming) {
|
|
97
|
-
const seen = new Set(incoming.map(toolKey));
|
|
92
|
+
const seen = new Set(incoming.map(event => toolKey({ event })));
|
|
98
93
|
// Deduplicate after merging: a legacy checkpoint or replay can repeat an ID with different
|
|
99
94
|
// serialized input. The shared ID rule owns identity; JSON equality does not.
|
|
100
|
-
return dedupeToolUsesById({ events: [...incoming, ...saved.filter((event) => { const id = toolKey(event); return id !== "" && !seen.has(id); })] });
|
|
95
|
+
return dedupeToolUsesById({ events: [...incoming, ...saved.filter((event) => { const id = toolKey({ event }); return id !== "" && !seen.has(id); })] });
|
|
101
96
|
}
|
|
102
97
|
/**
|
|
103
98
|
* @param store the chat kernel, or the open `chat.db` handle `createChatStoreFactory` also wraps.
|
|
104
99
|
* @param now Injectable epoch-millisecond clock for progress stamps and the default cancel time.
|
|
105
|
-
* @complexity
|
|
106
|
-
*
|
|
100
|
+
* @complexity indexed guards; checkpoints cost O(delta bytes) plus a SQLite scalar receipt read,
|
|
101
|
+
* or O(saved + incoming bytes) on fallback. Reconcile costs O(stuck rows + their event bytes).
|
|
107
102
|
*/
|
|
108
103
|
export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now = () => Date.now() } = {}) {
|
|
109
104
|
const kernel = chatKernelTables({ kernel: hostKernel }, {});
|
|
105
|
+
const version = kernel.dialect === 'sqlite' ? sqliteReadVersion({ kernel }) : sql `cast(ai_chat_messages.xmin as text)`;
|
|
106
|
+
const returnedVersion = kernel.dialect === 'sqlite' ? sql `null` : version;
|
|
110
107
|
async function isSettled(run) {
|
|
111
108
|
const row = await kernel.run((db) => db
|
|
112
109
|
.selectFrom("ai_chat_messages")
|
|
@@ -117,8 +114,99 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
|
|
|
117
114
|
.executeTakeFirst());
|
|
118
115
|
return isTerminal(row?.run_status);
|
|
119
116
|
}
|
|
117
|
+
/** Progress earns a fresh recovery budget. Replayed prefixes/dividers cannot keep a failing
|
|
118
|
+
* attempt alive forever. Kept separate so full and delta writes use exactly the same reset. */
|
|
119
|
+
async function recordProgress({ progress }, _options = {}) {
|
|
120
|
+
await kernel.run((db) => db.updateTable("assistant_run_attempts")
|
|
121
|
+
.set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: now() })
|
|
122
|
+
.where("message_id", "=", progress.messageId).execute());
|
|
123
|
+
}
|
|
124
|
+
/** One guarded delta UPDATE, no saved-column read or parse. A miss is deliberately indistinguishable
|
|
125
|
+
* from an ineligible delta: both must consult the authoritative checkpoint path under this lock. */
|
|
126
|
+
async function writeDelta({ progress, optional }, _options = {}) {
|
|
127
|
+
const { previous, expected } = optional;
|
|
128
|
+
if (!previous || expected !== previous.length)
|
|
129
|
+
return false;
|
|
130
|
+
const delta = checkpointDelta({ previous, progress });
|
|
131
|
+
if (!delta)
|
|
132
|
+
return false;
|
|
133
|
+
const result = await kernel.run((db) => db.updateTable("ai_chat_messages")
|
|
134
|
+
.set({ content: progress.content, events_json: spliceEventsJson(delta),
|
|
135
|
+
run_status: sql `case when run_status = 'queued' then 'running' else run_status end` })
|
|
136
|
+
.where(activeRunPredicate({ run: progress }))
|
|
137
|
+
.where(sql `${version} = ${previous.version}`)
|
|
138
|
+
.where(sql `length(events_json) = ${expected}`)
|
|
139
|
+
// BYOK/AG-UI browser upserts can replace a queued/running projection at the same length.
|
|
140
|
+
// Durable tool appends grow the length; recovery replacements also change run_id.
|
|
141
|
+
.where(sql `substr(events_json, length(events_json) - length(cast(${previous.tail} as text)) + 1) = ${previous.tail}`)
|
|
142
|
+
.where(sql `length(content) = ${previous.contentLength}`)
|
|
143
|
+
.returning([sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")])
|
|
144
|
+
.executeTakeFirst());
|
|
145
|
+
if (!result)
|
|
146
|
+
return false;
|
|
147
|
+
if (delta.grown)
|
|
148
|
+
await recordProgress({ progress });
|
|
149
|
+
await notifyPersisted({ progress, optional, length: Number(result.length), contentLength: Number(result.contentLength), nativeVersion: result.version, exact: true });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/** A saved-only barrier invalidates the receipt. SQLite samples its connection/external-commit
|
|
153
|
+
* generation after ALL writes, while still under the transaction lock. Never capture a later
|
|
154
|
+
* writer's version beside this projection. PG returns the updated row's own MVCC version.
|
|
155
|
+
* @complexity O(1) scalar SQLite read per requested receipt; no payload query or PG extra read.
|
|
156
|
+
*/
|
|
157
|
+
async function notifyPersisted({ progress, optional, length, contentLength, nativeVersion, exact }, _options = {}) {
|
|
158
|
+
if (!optional.onPersisted)
|
|
159
|
+
return;
|
|
160
|
+
if (!exact) {
|
|
161
|
+
optional.onPersisted(undefined);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const receiptVersion = kernel.dialect === 'sqlite'
|
|
165
|
+
? (await kernel.query(sql `select ${version} as version`))[0].version : nativeVersion;
|
|
166
|
+
optional.onPersisted(checkpointState({ progress, length, contentLength, version: receiptVersion }));
|
|
167
|
+
}
|
|
168
|
+
/** Full merge fallback, including identical-checkpoint write elision. One parse/stringify only. */
|
|
169
|
+
async function writeFullCheckpoint({ progress, optional }, _options = {}) {
|
|
170
|
+
const current = await kernel.run((db) => db.selectFrom("ai_chat_messages")
|
|
171
|
+
.select(["content", "events_json", "run_status", sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")])
|
|
172
|
+
.where(activeRunPredicate({ run: progress })).executeTakeFirst());
|
|
173
|
+
if (!current || progress.content.length < current.content.length)
|
|
174
|
+
return false;
|
|
175
|
+
const plan = planCheckpoint({ current, progress });
|
|
176
|
+
// Identical content/events are not progress; accepting without rewriting leaves both the
|
|
177
|
+
// multi-MB column and recovery budget exactly as the guarded UPDATE would have.
|
|
178
|
+
// Under this lock the row was just matched by the update's own predicate, so the update
|
|
179
|
+
// would match (true); returning true without writing preserves that contract.
|
|
180
|
+
if (plan.unchanged) {
|
|
181
|
+
await notifyPersisted({ progress, optional, length: Number(current.length), contentLength: Number(current.contentLength), nativeVersion: current.version, exact: plan.exact });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const result = await kernel.run((db) => db.updateTable("ai_chat_messages")
|
|
185
|
+
.set({ content: progress.content, events_json: plan.eventsJson, run_status: plan.runStatus })
|
|
186
|
+
.where(activeRunPredicate({ run: progress }))
|
|
187
|
+
.returning([sql `length(events_json)`.as("length"), sql `length(content)`.as("contentLength"), returnedVersion.as("version")]).executeTakeFirst());
|
|
188
|
+
if (!result)
|
|
189
|
+
return false;
|
|
190
|
+
if (plan.grown)
|
|
191
|
+
await recordProgress({ progress });
|
|
192
|
+
await notifyPersisted({ progress, optional, length: Number(result.length), contentLength: Number(result.contentLength), nativeVersion: result.version, exact: plan.exact });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
120
195
|
return {
|
|
121
196
|
durable: createDurableRunStore({ kernel, isDaemonRunId }, {}),
|
|
197
|
+
/** One bounded existence read, across owners and engines. Stale active rows conservatively
|
|
198
|
+
* keep the host awake; recovery belongs to the serving boot, never an idle probe.
|
|
199
|
+
* @complexity O(log n) covering-index lookup for n messages; O(1) returned space, no payload reads.
|
|
200
|
+
*/
|
|
201
|
+
async hasActiveRuns(_required = {}, _optional = {}) {
|
|
202
|
+
const row = await kernel.run(db => db.selectFrom("ai_chat_messages").select("id")
|
|
203
|
+
.where("role", "=", "assistant")
|
|
204
|
+
// Keep the fixed literal predicate identical to the partial index. Parameterized IN
|
|
205
|
+
// cannot establish SQLite's partial-index implication at plan time.
|
|
206
|
+
.where(sql `run_status in ('queued', 'running')`)
|
|
207
|
+
.limit(1).executeTakeFirst());
|
|
208
|
+
return row !== undefined;
|
|
209
|
+
},
|
|
122
210
|
unlessSettled({ run, write }, _optional = {}) {
|
|
123
211
|
return kernel.transaction(async () => {
|
|
124
212
|
await kernel.lockKey(runLockKey(run));
|
|
@@ -161,34 +249,21 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
|
|
|
161
249
|
return true;
|
|
162
250
|
});
|
|
163
251
|
},
|
|
164
|
-
async checkpoint(progress,
|
|
165
|
-
|
|
252
|
+
async checkpoint(progress, optional = {}) {
|
|
253
|
+
let persisted = false;
|
|
254
|
+
let receipt;
|
|
255
|
+
// Publish the watch receipt only after COMMIT. A rollback must not advance its CAS base.
|
|
256
|
+
const transactionOptions = optional.onPersisted
|
|
257
|
+
? { ...optional, onPersisted: (value) => { persisted = true; receipt = value; } } : optional;
|
|
258
|
+
const written = await kernel.transaction(async () => {
|
|
166
259
|
await kernel.lockKey(runLockKey(progress));
|
|
167
|
-
|
|
168
|
-
.where(activeRunPredicate({ run: progress })).executeTakeFirst());
|
|
169
|
-
if (!current || progress.content.length < current.content.length)
|
|
170
|
-
return false;
|
|
171
|
-
const { eventsJson, runStatus, grown, unchanged } = planCheckpoint({ current, progress });
|
|
172
|
-
// An identical checkpoint would rewrite the same multi-MB column for nothing. Under this lock
|
|
173
|
-
// the row was just matched by the update's own predicate, so the update would match (true),
|
|
174
|
-
// and identical content and events are never progress (`grown` is false), so skipping it
|
|
175
|
-
// leaves the row and the recovery budget exactly as the write would have.
|
|
176
|
-
if (unchanged)
|
|
260
|
+
if (await writeDelta({ progress, optional: transactionOptions }))
|
|
177
261
|
return true;
|
|
178
|
-
|
|
179
|
-
.updateTable("ai_chat_messages")
|
|
180
|
-
.set({ content: progress.content, events_json: eventsJson, run_status: runStatus })
|
|
181
|
-
.where(activeRunPredicate({ run: progress }))
|
|
182
|
-
.executeTakeFirst());
|
|
183
|
-
if (Number(result.numUpdatedRows) > 0 && grown) {
|
|
184
|
-
// Progress earns a fresh recovery budget. Replayed prefixes and the coordinator's
|
|
185
|
-
// divider are not progress and cannot keep a repeatedly failing attempt alive forever.
|
|
186
|
-
await kernel.run((db) => db.updateTable("assistant_run_attempts")
|
|
187
|
-
.set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: now() })
|
|
188
|
-
.where("message_id", "=", progress.messageId).execute());
|
|
189
|
-
}
|
|
190
|
-
return Number(result.numUpdatedRows) > 0;
|
|
262
|
+
return writeFullCheckpoint({ progress, optional: transactionOptions });
|
|
191
263
|
});
|
|
264
|
+
if (persisted)
|
|
265
|
+
optional.onPersisted?.(receipt);
|
|
266
|
+
return written;
|
|
192
267
|
},
|
|
193
268
|
async reconcileInterrupted(required = {}, optional = {}) {
|
|
194
269
|
const stuck = await kernel.run((db) => db
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RawBuilder } from 'kysely';
|
|
2
|
+
/** SQLite invalidation without scanning message rows. Both same-connection writes and external
|
|
3
|
+
* commits invalidate; the kernel epoch prevents counters reused after reopen from hiding changes.
|
|
4
|
+
* @param required.kernel The existing borrowed connection identity; never opens or changes it.
|
|
5
|
+
* @param optional.mintEpoch Injectable epoch source, called once per live kernel.
|
|
6
|
+
* @returns A SQL scalar with bounded output, without event/content reads or writes.
|
|
7
|
+
* @complexity O(1) time and weak identity space per kernel.
|
|
8
|
+
* @example sqliteReadVersion({ kernel });
|
|
9
|
+
*/
|
|
10
|
+
export declare function sqliteReadVersion({ kernel }: {
|
|
11
|
+
kernel: object;
|
|
12
|
+
}, { mintEpoch }?: {
|
|
13
|
+
mintEpoch?: () => string;
|
|
14
|
+
}): RawBuilder<string>;
|
|
15
|
+
//# sourceMappingURL=read-version.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
// Weak identity metadata only, never row data. Closing/replacing a kernel releases its epoch with GC.
|
|
3
|
+
const epochs = new WeakMap();
|
|
4
|
+
/** SQLite invalidation without scanning message rows. Both same-connection writes and external
|
|
5
|
+
* commits invalidate; the kernel epoch prevents counters reused after reopen from hiding changes.
|
|
6
|
+
* @param required.kernel The existing borrowed connection identity; never opens or changes it.
|
|
7
|
+
* @param optional.mintEpoch Injectable epoch source, called once per live kernel.
|
|
8
|
+
* @returns A SQL scalar with bounded output, without event/content reads or writes.
|
|
9
|
+
* @complexity O(1) time and weak identity space per kernel.
|
|
10
|
+
* @example sqliteReadVersion({ kernel });
|
|
11
|
+
*/
|
|
12
|
+
export function sqliteReadVersion({ kernel }, { mintEpoch = () => globalThis.crypto.randomUUID() } = {}) {
|
|
13
|
+
let epoch = epochs.get(kernel);
|
|
14
|
+
if (epoch === undefined) {
|
|
15
|
+
epoch = mintEpoch();
|
|
16
|
+
epochs.set(kernel, epoch);
|
|
17
|
+
}
|
|
18
|
+
// Connection-wide counters invalidate every row on any chat.db write; concurrent chats can erase the cache savings.
|
|
19
|
+
return sql `${epoch} || ':' || cast(total_changes() as text) || ':' || cast((select data_version from pragma_data_version) as text)`;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=read-version.js.map
|