or3-provider-sqlite 0.0.9 → 0.0.11

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/README.md CHANGED
@@ -5,7 +5,7 @@ SQLite sync and workspace store provider for OR3 Chat. Provides a lightweight, s
5
5
  ## What it provides
6
6
 
7
7
  - **AuthWorkspaceStore** (`sqlite`) — user identity mapping, workspace CRUD, role resolution
8
- - **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, and cursor tracking
8
+ - **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, cursor tracking, and canonical background-generation history on synchronous SQLite runtimes
9
9
  - **ConnectStore** (`sqlite`) — durable, atomic device enrollment and connected-computer records for OR3 Connect
10
10
  - **WebhookStore** (`sqlite`) — durable webhook registrations and delivery logs (local, Bun, and Turso runtimes)
11
11
  - **Admin stores** (`sqlite`) — workspace access/lifecycle, workspace settings, user search, and deployment-admin grants (local, Bun, and Turso runtimes)
@@ -32,7 +32,7 @@ runtime.
32
32
  | Runtime | `OR3_SQLITE_DRIVER` | Required configuration | Extra install |
33
33
  |---|---|---|---|
34
34
  | Local Node (default) | `better-sqlite3` | `OR3_SQLITE_DB_PATH` | `better-sqlite3` |
35
- | Bun | `bun` | `OR3_SQLITE_DB_PATH` | None; uses built-in `bun:sqlite` |
35
+ | Bun | `bun` | `OR3_SQLITE_DB_PATH` | None for sync; uses built-in `bun:sqlite` |
36
36
  | Turso/libSQL | `turso` | `OR3_SQLITE_TURSO_URL`, `OR3_SQLITE_TURSO_AUTH_TOKEN` | `libsql` |
37
37
  | Cloudflare D1 | `d1` | `OR3_SQLITE_D1_BINDING` (defaults to `DB`) | None; uses the Worker binding |
38
38
 
@@ -51,6 +51,16 @@ OR3_SQLITE_DRIVER=bun
51
51
  OR3_SQLITE_DB_PATH=/data/or3-sync.db
52
52
  ```
53
53
 
54
+ OR3 Chat's source launcher (`bun run dev:ssr`) selects Bun when this provider
55
+ uses the Bun driver, including when only the auth workspace store is active.
56
+ Use the Bun version pinned in the host's `package.json`. The launcher checks
57
+ the built-in driver and required native bindings before Nuxt starts. Run a
58
+ built Bun-configured server with `bun .output/server/index.mjs`.
59
+
60
+ Basic Auth still uses a separate `better-sqlite3` database; its native binding
61
+ must also be compatible with Bun. The managed Cloud image uses Node and the
62
+ default `better-sqlite3` sync driver.
63
+
54
64
  ### Turso/libSQL
55
65
 
56
66
  ```bash
@@ -131,6 +141,12 @@ On server startup, the Nitro plugin:
131
141
  Registration is skipped when `auth.enabled` is `false`, or when neither SQLite
132
142
  sync, SQLite Connect, nor SQLite background jobs are selected (local-only mode).
133
143
 
144
+ SQLite background jobs also persist mixed-runtime tool handoffs. Browser-only
145
+ calls are atomically claimed and settled in `execution_json`; parked calls are
146
+ excluded from worker claims until their browser result makes the job runnable.
147
+ Parked handoffs have a separate global and per-user admission cap, so they do
148
+ not consume active worker slots or grow without bound.
149
+
134
150
  ### Schema
135
151
 
136
152
  Ordered migrations create and evolve all tables:
@@ -142,6 +158,13 @@ Ordered migrations create and evolve all tables:
142
158
  - **009_or3_connect**: single-use device authorizations and connected computers
143
159
  - **017_background_jobs**: durable job status, workflow snapshots, cancellation,
144
160
  inactivity timestamps, and worker leases
161
+ - **018–020**: durable pre-admission cancellation, reasoning/generation history
162
+ phases, and idempotent canonical generation-write receipts
163
+
164
+ Background-job claims reset text and iteration progress back to the durable
165
+ checkpoint when reclaiming an expired lease, matching the memory/Convex recovery
166
+ contract. The provider implements `findJobByIdempotencyKey`, which lets the
167
+ server cancel an admission before the client has received its job ID.
145
168
 
146
169
  Additional migrations (007–008, 010–016) evolve device-cursor ownership,
147
170
  upload intents, and Connect credential/lifecycle hardening and rate limits.
@@ -151,16 +174,24 @@ All tables use snake_case aligned with the sync wire format.
151
174
  ### Sync semantics
152
175
 
153
176
  - **Push**: validates ops (including a 256 KB serialized payload ceiling per operation) → checks `op_id` idempotency → allocates contiguous `server_version` block → writes change_log → applies LWW to materialized tables → upserts tombstones for deletes
154
- - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
155
- - **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`
177
+ - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination, optional table filtering, `oldestRetainedVersion`, and `requiresSnapshot` when the cursor is behind retained history
178
+ - **Snapshot**: captures canonical live rows and current tombstones at one `highWatermark`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`. Notification live rows are filtered to the session user.
156
179
  - **Cursor**: forward-only per-device cursor tracking
157
180
  - **Retention safety**: tombstone and `change_log` GC is enabled only under the explicit `snapshot-v1` capability and deletes old revisions acknowledged by every registered device
181
+ - **Notifications**: push forces `payload.user_id` to the session user; foreign notification writes are rejected; pull/snapshot omit other users' rows
158
182
 
159
- LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
183
+ LWW conflict resolution: incoming wins when `clock` is higher, then when clocks are equal and `hlc` is lexicographically greater, then when `op_id` is greater. Tombstones use the same `(clock, hlc, op_id)` tuple. LWW losers return `applied: false` with the current winner as either `{ kind: 'put', payload, revision }` or `{ kind: 'delete', revision, serverDeletedAt? }`. Idempotent `op_id` retries return `replayed: true` and, if superseded, the current winner. A replay does not allocate another server version.
160
184
 
161
185
  Local, Bun, and Turso runtimes use `BEGIN IMMEDIATE` transactions. D1 uses its
162
186
  native atomic batch API for grouped writes.
163
187
 
188
+ Canonical background generation history is advertised by the local, Bun, and
189
+ Turso adapters. It writes admission rows, contiguous change-log versions,
190
+ materialized state, and the receipt in one transaction, then commits a terminal
191
+ snapshot only if its generation still owns the assistant row. D1 does not
192
+ advertise this versioned capability, so background chat admission fails before
193
+ model execution there; ordinary sync and other D1 features are unchanged.
194
+
164
195
  ### Workspace store
165
196
 
166
197
  - `getOrCreateUser` — maps `(provider, provider_user_id)` to internal user (idempotent)
@@ -220,3 +251,17 @@ bun run build # build for distribution
220
251
  `OR3_CONNECT_ENABLED=false` or switch to better-sqlite3, Bun, or Turso.
221
252
  - **`OR3_SQLITE_DRIVER=bun requires Bun`** — the Bun driver only runs under
222
253
  the Bun runtime with its built-in `bun:sqlite`.
254
+
255
+ ### Atomic workspace settings
256
+
257
+ The workspace settings store implements `compareAndSet(workspaceId, key, expectedValue, nextValue)` atomically. A `null` expected value means the key must be absent; a conflict returns `false` without overwriting it. The host uses this for concurrent plugin setup saves and persistent AI spend reservations.
258
+
259
+
260
+ ### Testing local changes in OR3 Chat
261
+
262
+ With this repository beside `or3-chat`, run `bun install` here once, then
263
+ `bun run dev:ssr` from Chat. Chat's dev wrapper rebuilds the local provider and
264
+ prints its selected path; restart it after provider edits. Missing repositories
265
+ or failed builds fall back to installed packages with a warning.
266
+ `OR3_LOCAL_PROVIDERS=false` disables local selection. Production builds use the
267
+ installed package, so local development does not publish these changes.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
3
  "configKey": "or3-provider-sqlite",
4
- "version": "0.0.9",
4
+ "version": "0.0.11",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -323,6 +323,22 @@ class SqliteWorkspaceSettingsStore {
323
323
  })
324
324
  ).execute();
325
325
  }
326
+ /** Atomically write only when the stored value still equals the expected value. */
327
+ async compareAndSet(workspaceId, key, expectedValue, nextValue) {
328
+ const now = nowEpoch();
329
+ if (expectedValue === null) {
330
+ const result2 = await this.db.insertInto("admin_workspace_settings").values({
331
+ id: globalThis.crypto.randomUUID(),
332
+ workspace_id: workspaceId,
333
+ key,
334
+ value: nextValue,
335
+ updated_at: now
336
+ }).onConflict((oc) => oc.columns(["workspace_id", "key"]).doNothing()).executeTakeFirst();
337
+ return Number(result2.numInsertedOrUpdatedRows ?? 0) === 1;
338
+ }
339
+ const result = await this.db.updateTable("admin_workspace_settings").set({ value: nextValue, updated_at: now }).where("workspace_id", "=", workspaceId).where("key", "=", key).where("value", "=", expectedValue).executeTakeFirst();
340
+ return Number(result.numUpdatedRows ?? 0) === 1;
341
+ }
326
342
  }
327
343
  class SqliteAdminUserStore {
328
344
  get db() {
@@ -1,8 +1,10 @@
1
- import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate } from '~~/server/utils/background-jobs/types';
1
+ import type { BackgroundJob, BackgroundJobExecution, BackgroundJobProvider, CreateJobParams, JobUpdate, AdmissionCancellationResult, GenerationHistoryPhase, TerminalGenerationSnapshot } from '~~/server/utils/background-jobs/types';
2
2
  export declare class SqliteBackgroundJobProvider implements BackgroundJobProvider {
3
3
  readonly name = "sqlite";
4
4
  createJob(params: CreateJobParams): Promise<string>;
5
5
  getJob(jobId: string, userId: string): Promise<BackgroundJob | null>;
6
+ findJobByIdempotencyKey(idempotencyKey: string, userId: string): Promise<BackgroundJob | null>;
7
+ cancelAdmission(userId: string, admissionId: string): Promise<AdmissionCancellationResult>;
6
8
  updateJob(jobId: string, update: JobUpdate): Promise<void>;
7
9
  completeJob(jobId: string, finalContent: string, leaseOwner?: string): Promise<void>;
8
10
  failJob(jobId: string, error: string, leaseOwner?: string): Promise<void>;
@@ -14,6 +16,13 @@ export declare class SqliteBackgroundJobProvider implements BackgroundJobProvide
14
16
  claimNextJob(leaseOwner: string, now: number, leaseExpiresAt: number): Promise<BackgroundJob | null>;
15
17
  renewJobLease(jobId: string, leaseOwner: string, _now: number, leaseExpiresAt: number): Promise<boolean>;
16
18
  updateJobExecution(jobId: string, execution: BackgroundJobExecution, leaseOwner: string): Promise<boolean>;
19
+ claimClientToolCall(jobId: string, userId: string, callId: string, claimToken: string, claimExpiresAt: number): Promise<BackgroundJob | null>;
20
+ settleClientToolCall(jobId: string, userId: string, callId: string, claimToken: string, execution: BackgroundJobExecution, toolCalls: BackgroundJob['tool_calls']): Promise<boolean>;
21
+ saveTerminalSnapshot(jobId: string, snapshot: TerminalGenerationSnapshot, leaseOwner?: string): Promise<boolean>;
22
+ setHistoryPhase(jobId: string, phase: GenerationHistoryPhase, options?: {
23
+ from?: GenerationHistoryPhase[];
24
+ }): Promise<boolean>;
25
+ getPendingHistoryJobs(limit: number): Promise<BackgroundJob[]>;
17
26
  cleanupExpired(): Promise<number>;
18
27
  getActiveJobCount(): Promise<number>;
19
28
  }
@@ -19,6 +19,10 @@ function toJob(row) {
19
19
  kind: row.kind ?? void 0,
20
20
  status: row.status,
21
21
  content: row.content,
22
+ reasoning: row.reasoning ?? "",
23
+ generationId: row.generation_id ?? void 0,
24
+ historyPhase: row.history_phase ?? void 0,
25
+ syncProviderId: row.sync_provider_id ?? void 0,
22
26
  chunksReceived: row.chunks_received,
23
27
  startedAt: row.started_at,
24
28
  lastActivityAt: row.last_activity_at,
@@ -54,6 +58,24 @@ function leaseLost() {
54
58
  error.name = "BackgroundJobLeaseLostError";
55
59
  return error;
56
60
  }
61
+ const ADMISSION_CANCEL_TTL_MS = 10 * 60 * 1e3;
62
+ function admissionCancelled(admissionId) {
63
+ const error = new Error(
64
+ `Background admission ${admissionId} was cancelled`
65
+ );
66
+ error.name = "AdmissionCancelledError";
67
+ return error;
68
+ }
69
+ async function isAdmissionCancelled(userId, admissionId, now) {
70
+ const row = await one(
71
+ `SELECT 1 AS hit FROM background_admission_cancels
72
+ WHERE user_id = ? AND admission_id = ? AND expires_at > ?`,
73
+ userId,
74
+ admissionId,
75
+ now
76
+ );
77
+ return row !== void 0;
78
+ }
57
79
  function insertParameters(id, params, now) {
58
80
  return [
59
81
  id,
@@ -62,6 +84,11 @@ function insertParameters(id, params, now) {
62
84
  params.messageId,
63
85
  params.model,
64
86
  params.kind ?? null,
87
+ params.initialContent ?? "",
88
+ params.initialReasoning ?? "",
89
+ params.generationId ?? null,
90
+ params.historyPhase ?? "ready",
91
+ params.syncProviderId ?? null,
65
92
  now,
66
93
  now,
67
94
  json(params.tool_calls),
@@ -72,16 +99,28 @@ function insertParameters(id, params, now) {
72
99
  }
73
100
  const INSERT_JOB = `INSERT INTO background_jobs (
74
101
  id, user_id, thread_id, message_id, model, kind, status, content,
102
+ reasoning, generation_id, history_phase, sync_provider_id,
75
103
  chunks_received, started_at, last_activity_at, tool_calls_json,
76
104
  workflow_state_json, execution_json, idempotency_key, attempts
77
- ) VALUES (?, ?, ?, ?, ?, ?, 'streaming', '', 0, ?, ?, ?, ?, ?, ?, 0)`;
105
+ ) VALUES (?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0)`;
78
106
  const INSERT_JOB_IF_CAPACITY = `INSERT INTO background_jobs (
79
107
  id, user_id, thread_id, message_id, model, kind, status, content,
108
+ reasoning, generation_id, history_phase, sync_provider_id,
80
109
  chunks_received, started_at, last_activity_at, tool_calls_json,
81
110
  workflow_state_json, execution_json, idempotency_key, attempts
82
- ) SELECT ?, ?, ?, ?, ?, ?, 'streaming', '', 0, ?, ?, ?, ?, ?, ?, 0
83
- WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming') < ?
84
- AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?) < ?
111
+ ) SELECT ?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0
112
+ WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming'
113
+ AND json_extract(execution_json, '$.clientToolCall') IS NULL) < ?
114
+ AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?
115
+ AND json_extract(execution_json, '$.clientToolCall') IS NULL) < ?
116
+ AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming'
117
+ AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL) < ?
118
+ AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?
119
+ AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL) < ?
120
+ AND NOT EXISTS (
121
+ SELECT 1 FROM background_admission_cancels
122
+ WHERE user_id = ? AND admission_id = ? AND expires_at > ?
123
+ )
85
124
  ON CONFLICT(idempotency_key) DO NOTHING
86
125
  RETURNING id`;
87
126
  export class SqliteBackgroundJobProvider {
@@ -97,13 +136,26 @@ export class SqliteBackgroundJobProvider {
97
136
  params.idempotencyKey
98
137
  );
99
138
  if (existing) return existing.id;
139
+ if (await isAdmissionCancelled(
140
+ params.userId,
141
+ params.idempotencyKey,
142
+ now
143
+ )) {
144
+ throw admissionCancelled(params.idempotencyKey);
145
+ }
100
146
  }
101
147
  const inserted = await all(
102
148
  INSERT_JOB_IF_CAPACITY,
103
149
  ...insertParameters(id, params, now),
104
150
  config.maxConcurrentJobs,
105
151
  params.userId,
106
- config.maxConcurrentJobsPerUser
152
+ config.maxConcurrentJobsPerUser,
153
+ config.maxConcurrentJobs,
154
+ params.userId,
155
+ config.maxConcurrentJobsPerUser,
156
+ params.userId,
157
+ params.idempotencyKey ?? "",
158
+ now
107
159
  );
108
160
  if (inserted[0]) return inserted[0].id;
109
161
  if (params.idempotencyKey) {
@@ -112,6 +164,13 @@ export class SqliteBackgroundJobProvider {
112
164
  params.idempotencyKey
113
165
  );
114
166
  if (existing) return existing.id;
167
+ if (await isAdmissionCancelled(
168
+ params.userId,
169
+ params.idempotencyKey,
170
+ now
171
+ )) {
172
+ throw admissionCancelled(params.idempotencyKey);
173
+ }
115
174
  }
116
175
  throw new Error("Maximum concurrent background jobs reached");
117
176
  }
@@ -122,13 +181,28 @@ export class SqliteBackgroundJobProvider {
122
181
  "SELECT id FROM background_jobs WHERE idempotency_key = ?"
123
182
  ).get(params.idempotencyKey);
124
183
  if (existing) return existing.id;
184
+ const cancelled = raw.prepare(
185
+ `SELECT 1 AS hit FROM background_admission_cancels
186
+ WHERE user_id = ? AND admission_id = ?
187
+ AND expires_at > ?`
188
+ ).get(
189
+ params.userId,
190
+ params.idempotencyKey,
191
+ now
192
+ );
193
+ if (cancelled) {
194
+ throw admissionCancelled(params.idempotencyKey);
195
+ }
125
196
  }
126
197
  const active = raw.prepare(
127
- `SELECT COUNT(*) AS total,
128
- SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS user_total
198
+ `SELECT
199
+ SUM(CASE WHEN json_extract(execution_json, '$.clientToolCall') IS NULL THEN 1 ELSE 0 END) AS total,
200
+ SUM(CASE WHEN user_id = ? AND json_extract(execution_json, '$.clientToolCall') IS NULL THEN 1 ELSE 0 END) AS user_total,
201
+ SUM(CASE WHEN json_extract(execution_json, '$.clientToolCall') IS NOT NULL THEN 1 ELSE 0 END) AS waiting_total,
202
+ SUM(CASE WHEN user_id = ? AND json_extract(execution_json, '$.clientToolCall') IS NOT NULL THEN 1 ELSE 0 END) AS waiting_user_total
129
203
  FROM background_jobs WHERE status = 'streaming'`
130
- ).get(params.userId);
131
- if (active.total >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
204
+ ).get(params.userId, params.userId);
205
+ if ((active.total ?? 0) >= config.maxConcurrentJobs || (active.user_total ?? 0) >= config.maxConcurrentJobsPerUser || (active.waiting_total ?? 0) >= config.maxConcurrentJobs || (active.waiting_user_total ?? 0) >= config.maxConcurrentJobsPerUser) {
132
206
  throw new Error(
133
207
  "Maximum concurrent background jobs reached"
134
208
  );
@@ -146,6 +220,89 @@ export class SqliteBackgroundJobProvider {
146
220
  );
147
221
  return row ? toJob(row) : null;
148
222
  }
223
+ async findJobByIdempotencyKey(idempotencyKey, userId) {
224
+ const row = await one(
225
+ "SELECT * FROM background_jobs WHERE idempotency_key = ? AND user_id = ?",
226
+ idempotencyKey,
227
+ userId
228
+ );
229
+ return row ? toJob(row) : null;
230
+ }
231
+ async cancelAdmission(userId, admissionId) {
232
+ const now = Date.now();
233
+ const expiresAt = now + ADMISSION_CANCEL_TTL_MS;
234
+ const upsertMarker = `INSERT INTO background_admission_cancels
235
+ (user_id, admission_id, expires_at)
236
+ VALUES (?, ?, ?)
237
+ ON CONFLICT(user_id, admission_id)
238
+ DO UPDATE SET expires_at = excluded.expires_at`;
239
+ const abortJob = `UPDATE background_jobs SET status = 'aborted',
240
+ completed_at = ?, last_activity_at = ?, error = ?,
241
+ lease_owner = NULL, lease_expires_at = NULL,
242
+ history_phase = CASE
243
+ WHEN generation_id IS NOT NULL
244
+ AND COALESCE(history_phase, 'ready') = 'ready'
245
+ THEN 'finalization_pending'
246
+ ELSE history_phase END
247
+ WHERE id = ? AND status = 'streaming'`;
248
+ if (!isD1Driver()) {
249
+ const raw = getRawDb();
250
+ return raw.transaction(() => {
251
+ raw.prepare(
252
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?"
253
+ ).run(now);
254
+ raw.prepare(upsertMarker).run(userId, admissionId, expiresAt);
255
+ const job2 = raw.prepare(
256
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?"
257
+ ).get(userId, admissionId);
258
+ if (!job2) {
259
+ return { aborted: false, pending: true };
260
+ }
261
+ if (job2.status === "streaming") {
262
+ const changes = raw.prepare(abortJob).run(
263
+ now,
264
+ now,
265
+ "Cancelled by user",
266
+ job2.id
267
+ ).changes;
268
+ return {
269
+ aborted: changes > 0,
270
+ jobId: job2.id,
271
+ pending: false
272
+ };
273
+ }
274
+ return { aborted: false, jobId: job2.id, pending: false };
275
+ }).immediate();
276
+ }
277
+ await run(
278
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?",
279
+ now
280
+ );
281
+ await run(upsertMarker, userId, admissionId, expiresAt);
282
+ const job = await one(
283
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?",
284
+ userId,
285
+ admissionId
286
+ );
287
+ if (!job) {
288
+ return { aborted: false, pending: true };
289
+ }
290
+ if (job.status === "streaming") {
291
+ const changes = await run(
292
+ abortJob,
293
+ now,
294
+ now,
295
+ "Cancelled by user",
296
+ job.id
297
+ );
298
+ return {
299
+ aborted: changes > 0,
300
+ jobId: job.id,
301
+ pending: false
302
+ };
303
+ }
304
+ return { aborted: false, jobId: job.id, pending: false };
305
+ }
149
306
  async updateJob(jobId, update) {
150
307
  const sets = ["last_activity_at = ?"];
151
308
  const values = [Date.now()];
@@ -153,6 +310,10 @@ export class SqliteBackgroundJobProvider {
153
310
  sets.push("content = content || ?");
154
311
  values.push(update.contentChunk);
155
312
  }
313
+ if (update.reasoningChunk !== void 0) {
314
+ sets.push("reasoning = reasoning || ?");
315
+ values.push(update.reasoningChunk);
316
+ }
156
317
  if (update.chunksReceived !== void 0) {
157
318
  sets.push("chunks_received = ?");
158
319
  values.push(update.chunksReceived);
@@ -168,8 +329,9 @@ export class SqliteBackgroundJobProvider {
168
329
  values.push(jobId);
169
330
  let where = "id = ? AND status = 'streaming'";
170
331
  if (update.leaseOwner) {
171
- where += " AND lease_owner = ?";
332
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
172
333
  values.push(update.leaseOwner);
334
+ values.push(Date.now());
173
335
  }
174
336
  const changes = await run(
175
337
  `UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
@@ -206,7 +368,12 @@ export class SqliteBackgroundJobProvider {
206
368
  const now = Date.now();
207
369
  return await run(
208
370
  `UPDATE background_jobs SET status = 'aborted', completed_at = ?,
209
- last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
371
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL,
372
+ history_phase = CASE
373
+ WHEN generation_id IS NOT NULL
374
+ AND COALESCE(history_phase, 'ready') = 'ready'
375
+ THEN 'finalization_pending'
376
+ ELSE history_phase END
210
377
  WHERE id = ? AND user_id = ? AND status = 'streaming'`,
211
378
  now,
212
379
  now,
@@ -226,12 +393,26 @@ export class SqliteBackgroundJobProvider {
226
393
  }
227
394
  async claimJob(jobId, leaseOwner, now, leaseExpiresAt) {
228
395
  const row = await one(
229
- `UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
396
+ `UPDATE background_jobs SET
397
+ lease_owner = ?,
398
+ lease_expires_at = ?,
399
+ last_activity_at = ?,
400
+ content = CASE WHEN attempts > 0
401
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
402
+ ELSE content END,
403
+ reasoning = CASE WHEN attempts > 0
404
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
405
+ ELSE reasoning END,
406
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
407
+ attempts = attempts + 1
230
408
  WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
409
+ AND json_extract(execution_json, '$.clientToolCall') IS NULL
410
+ AND COALESCE(history_phase, 'ready') = 'ready'
231
411
  AND (lease_owner IS NULL OR lease_expires_at <= ?)
232
412
  RETURNING *`,
233
413
  leaseOwner,
234
414
  leaseExpiresAt,
415
+ now,
235
416
  jobId,
236
417
  now
237
418
  );
@@ -239,15 +420,29 @@ export class SqliteBackgroundJobProvider {
239
420
  }
240
421
  async claimNextJob(leaseOwner, now, leaseExpiresAt) {
241
422
  const row = await one(
242
- `UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
423
+ `UPDATE background_jobs SET
424
+ lease_owner = ?,
425
+ lease_expires_at = ?,
426
+ last_activity_at = ?,
427
+ content = CASE WHEN attempts > 0
428
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
429
+ ELSE content END,
430
+ reasoning = CASE WHEN attempts > 0
431
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
432
+ ELSE reasoning END,
433
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
434
+ attempts = attempts + 1
243
435
  WHERE id = (
244
436
  SELECT id FROM background_jobs
245
437
  WHERE status = 'streaming' AND execution_json IS NOT NULL
438
+ AND json_extract(execution_json, '$.clientToolCall') IS NULL
439
+ AND COALESCE(history_phase, 'ready') = 'ready'
246
440
  AND (lease_owner IS NULL OR lease_expires_at <= ?)
247
441
  ORDER BY started_at ASC LIMIT 1
248
442
  ) RETURNING *`,
249
443
  leaseOwner,
250
444
  leaseExpiresAt,
445
+ now,
251
446
  now
252
447
  );
253
448
  return row ? toJob(row) : null;
@@ -255,11 +450,13 @@ export class SqliteBackgroundJobProvider {
255
450
  async renewJobLease(jobId, leaseOwner, _now, leaseExpiresAt) {
256
451
  return await run(
257
452
  `UPDATE background_jobs SET lease_expires_at = ?, last_activity_at = ?
258
- WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
453
+ WHERE id = ? AND status = 'streaming' AND lease_owner = ?
454
+ AND lease_expires_at > ?`,
259
455
  leaseExpiresAt,
260
456
  Date.now(),
261
457
  jobId,
262
- leaseOwner
458
+ leaseOwner,
459
+ Date.now()
263
460
  ) > 0;
264
461
  }
265
462
  async updateJobExecution(jobId, execution, leaseOwner) {
@@ -272,26 +469,127 @@ export class SqliteBackgroundJobProvider {
272
469
  leaseOwner
273
470
  ) > 0;
274
471
  }
472
+ async claimClientToolCall(jobId, userId, callId, claimToken, claimExpiresAt) {
473
+ const now = Date.now();
474
+ const row = await one(
475
+ `UPDATE background_jobs SET
476
+ execution_json = json_set(
477
+ execution_json,
478
+ '$.clientToolCall.claimToken', ?,
479
+ '$.clientToolCall.claimExpiresAt', ?
480
+ ),
481
+ last_activity_at = ?
482
+ WHERE id = ? AND user_id = ? AND status = 'streaming'
483
+ AND json_extract(execution_json, '$.clientToolCall.callId') = ?
484
+ AND (
485
+ json_extract(execution_json, '$.clientToolCall.claimToken') IS NULL
486
+ OR COALESCE(json_extract(execution_json, '$.clientToolCall.claimExpiresAt'), 0) <= ?
487
+ )
488
+ RETURNING *`,
489
+ claimToken,
490
+ claimExpiresAt,
491
+ now,
492
+ jobId,
493
+ userId,
494
+ callId,
495
+ now
496
+ );
497
+ return row ? toJob(row) : null;
498
+ }
499
+ async settleClientToolCall(jobId, userId, callId, claimToken, execution, toolCalls) {
500
+ const now = Date.now();
501
+ return await run(
502
+ `UPDATE background_jobs SET execution_json = ?, tool_calls_json = ?,
503
+ lease_owner = NULL, lease_expires_at = NULL, last_activity_at = ?
504
+ WHERE id = ? AND user_id = ? AND status = 'streaming'
505
+ AND json_extract(execution_json, '$.clientToolCall.callId') = ?
506
+ AND json_extract(execution_json, '$.clientToolCall.claimToken') = ?
507
+ AND COALESCE(json_extract(execution_json, '$.clientToolCall.claimExpiresAt'), 0) > ?`,
508
+ json(execution),
509
+ json(toolCalls),
510
+ now,
511
+ jobId,
512
+ userId,
513
+ callId,
514
+ claimToken,
515
+ now
516
+ ) > 0;
517
+ }
518
+ async saveTerminalSnapshot(jobId, snapshot, leaseOwner) {
519
+ const values = [
520
+ snapshot.status,
521
+ snapshot.content,
522
+ snapshot.reasoning,
523
+ json(snapshot.toolCalls),
524
+ snapshot.error ?? null,
525
+ snapshot.completedAt,
526
+ snapshot.completedAt
527
+ ];
528
+ let where = "id = ? AND status = 'streaming'";
529
+ values.push(jobId);
530
+ if (leaseOwner) {
531
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
532
+ values.push(leaseOwner, Date.now());
533
+ }
534
+ return await run(
535
+ `UPDATE background_jobs SET status = ?, content = ?, reasoning = ?,
536
+ tool_calls_json = ?, error = ?, completed_at = ?,
537
+ history_phase = 'finalization_pending',
538
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
539
+ WHERE ${where}`,
540
+ ...values
541
+ ) > 0;
542
+ }
543
+ async setHistoryPhase(jobId, phase, options) {
544
+ const values = [phase, jobId];
545
+ let where = "id = ?";
546
+ if (options?.from && options.from.length > 0) {
547
+ const placeholders = options.from.map(() => "?").join(", ");
548
+ where += ` AND COALESCE(history_phase, 'ready') IN (${placeholders})`;
549
+ values.push(...options.from);
550
+ }
551
+ return await run(`UPDATE background_jobs SET history_phase = ? WHERE ${where}`, ...values) > 0;
552
+ }
553
+ async getPendingHistoryJobs(limit) {
554
+ const rows = await all(
555
+ `SELECT * FROM background_jobs
556
+ WHERE history_phase IN ('admission_pending', 'finalization_pending')
557
+ ORDER BY started_at ASC
558
+ LIMIT ?`,
559
+ Math.max(1, Math.min(100, Math.floor(limit)))
560
+ );
561
+ return rows.map(toJob);
562
+ }
275
563
  async cleanupExpired() {
276
564
  const config = getJobConfig();
277
565
  const now = Date.now();
278
566
  const timedOut = await run(
279
567
  `UPDATE background_jobs SET status = 'error', error = 'Job timed out',
280
- completed_at = ?, lease_owner = NULL, lease_expires_at = NULL
568
+ completed_at = ?, lease_owner = NULL, lease_expires_at = NULL,
569
+ history_phase = CASE
570
+ WHEN generation_id IS NOT NULL
571
+ AND COALESCE(history_phase, 'ready') = 'ready'
572
+ THEN 'finalization_pending'
573
+ ELSE history_phase END
281
574
  WHERE status = 'streaming' AND last_activity_at <= ?`,
282
575
  now,
283
576
  now - config.jobTimeoutMs
284
577
  );
285
578
  const removed = await run(
286
579
  `DELETE FROM background_jobs
287
- WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?`,
580
+ WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?
581
+ AND (
582
+ history_phase IS NULL
583
+ OR history_phase IN ('ready', 'committed', 'superseded')
584
+ )`,
288
585
  now - config.completedJobRetentionMs
289
586
  );
290
587
  return timedOut + removed;
291
588
  }
292
589
  async getActiveJobCount() {
293
590
  const row = await one(
294
- "SELECT COUNT(*) AS total FROM background_jobs WHERE status = 'streaming'"
591
+ `SELECT COUNT(*) AS total FROM background_jobs WHERE status = 'streaming'
592
+ AND json_extract(execution_json, '$.clientToolCall') IS NULL`
295
593
  );
296
594
  return row?.total ?? 0;
297
595
  }