or3-provider-sqlite 0.0.9 → 0.0.10

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)
@@ -142,6 +142,13 @@ Ordered migrations create and evolve all tables:
142
142
  - **009_or3_connect**: single-use device authorizations and connected computers
143
143
  - **017_background_jobs**: durable job status, workflow snapshots, cancellation,
144
144
  inactivity timestamps, and worker leases
145
+ - **018–020**: durable pre-admission cancellation, reasoning/generation history
146
+ phases, and idempotent canonical generation-write receipts
147
+
148
+ Background-job claims reset text and iteration progress back to the durable
149
+ checkpoint when reclaiming an expired lease, matching the memory/Convex recovery
150
+ contract. The provider implements `findJobByIdempotencyKey`, which lets the
151
+ server cancel an admission before the client has received its job ID.
145
152
 
146
153
  Additional migrations (007–008, 010–016) evolve device-cursor ownership,
147
154
  upload intents, and Connect credential/lifecycle hardening and rate limits.
@@ -151,16 +158,24 @@ All tables use snake_case aligned with the sync wire format.
151
158
  ### Sync semantics
152
159
 
153
160
  - **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)`
161
+ - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination, optional table filtering, `oldestRetainedVersion`, and `requiresSnapshot` when the cursor is behind retained history
162
+ - **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
163
  - **Cursor**: forward-only per-device cursor tracking
157
164
  - **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
165
+ - **Notifications**: push forces `payload.user_id` to the session user; foreign notification writes are rejected; pull/snapshot omit other users' rows
158
166
 
159
- LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
167
+ 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 winning payload.
160
168
 
161
169
  Local, Bun, and Turso runtimes use `BEGIN IMMEDIATE` transactions. D1 uses its
162
170
  native atomic batch API for grouped writes.
163
171
 
172
+ Canonical background generation history is advertised by the local, Bun, and
173
+ Turso adapters. It writes admission rows, contiguous change-log versions,
174
+ materialized state, and the receipt in one transaction, then commits a terminal
175
+ snapshot only if its generation still owns the assistant row. D1 does not
176
+ advertise this versioned capability, so background chat admission fails before
177
+ model execution there; ordinary sync and other D1 features are unchanged.
178
+
164
179
  ### Workspace store
165
180
 
166
181
  - `getOrCreateUser` — maps `(provider, provider_user_id)` to internal user (idempotent)
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.10",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -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,11 @@ 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
+ saveTerminalSnapshot(jobId: string, snapshot: TerminalGenerationSnapshot, leaseOwner?: string): Promise<boolean>;
20
+ setHistoryPhase(jobId: string, phase: GenerationHistoryPhase, options?: {
21
+ from?: GenerationHistoryPhase[];
22
+ }): Promise<boolean>;
23
+ getPendingHistoryJobs(limit: number): Promise<BackgroundJob[]>;
17
24
  cleanupExpired(): Promise<number>;
18
25
  getActiveJobCount(): Promise<number>;
19
26
  }
@@ -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,22 @@ 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
111
+ ) SELECT ?, ?, ?, ?, ?, ?, 'streaming', ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0
83
112
  WHERE (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming') < ?
84
113
  AND (SELECT COUNT(*) FROM background_jobs WHERE status = 'streaming' AND user_id = ?) < ?
114
+ AND NOT EXISTS (
115
+ SELECT 1 FROM background_admission_cancels
116
+ WHERE user_id = ? AND admission_id = ? AND expires_at > ?
117
+ )
85
118
  ON CONFLICT(idempotency_key) DO NOTHING
86
119
  RETURNING id`;
87
120
  export class SqliteBackgroundJobProvider {
@@ -97,13 +130,23 @@ export class SqliteBackgroundJobProvider {
97
130
  params.idempotencyKey
98
131
  );
99
132
  if (existing) return existing.id;
133
+ if (await isAdmissionCancelled(
134
+ params.userId,
135
+ params.idempotencyKey,
136
+ now
137
+ )) {
138
+ throw admissionCancelled(params.idempotencyKey);
139
+ }
100
140
  }
101
141
  const inserted = await all(
102
142
  INSERT_JOB_IF_CAPACITY,
103
143
  ...insertParameters(id, params, now),
104
144
  config.maxConcurrentJobs,
105
145
  params.userId,
106
- config.maxConcurrentJobsPerUser
146
+ config.maxConcurrentJobsPerUser,
147
+ params.userId,
148
+ params.idempotencyKey ?? "",
149
+ now
107
150
  );
108
151
  if (inserted[0]) return inserted[0].id;
109
152
  if (params.idempotencyKey) {
@@ -112,6 +155,13 @@ export class SqliteBackgroundJobProvider {
112
155
  params.idempotencyKey
113
156
  );
114
157
  if (existing) return existing.id;
158
+ if (await isAdmissionCancelled(
159
+ params.userId,
160
+ params.idempotencyKey,
161
+ now
162
+ )) {
163
+ throw admissionCancelled(params.idempotencyKey);
164
+ }
115
165
  }
116
166
  throw new Error("Maximum concurrent background jobs reached");
117
167
  }
@@ -122,6 +172,18 @@ export class SqliteBackgroundJobProvider {
122
172
  "SELECT id FROM background_jobs WHERE idempotency_key = ?"
123
173
  ).get(params.idempotencyKey);
124
174
  if (existing) return existing.id;
175
+ const cancelled = raw.prepare(
176
+ `SELECT 1 AS hit FROM background_admission_cancels
177
+ WHERE user_id = ? AND admission_id = ?
178
+ AND expires_at > ?`
179
+ ).get(
180
+ params.userId,
181
+ params.idempotencyKey,
182
+ now
183
+ );
184
+ if (cancelled) {
185
+ throw admissionCancelled(params.idempotencyKey);
186
+ }
125
187
  }
126
188
  const active = raw.prepare(
127
189
  `SELECT COUNT(*) AS total,
@@ -146,6 +208,89 @@ export class SqliteBackgroundJobProvider {
146
208
  );
147
209
  return row ? toJob(row) : null;
148
210
  }
211
+ async findJobByIdempotencyKey(idempotencyKey, userId) {
212
+ const row = await one(
213
+ "SELECT * FROM background_jobs WHERE idempotency_key = ? AND user_id = ?",
214
+ idempotencyKey,
215
+ userId
216
+ );
217
+ return row ? toJob(row) : null;
218
+ }
219
+ async cancelAdmission(userId, admissionId) {
220
+ const now = Date.now();
221
+ const expiresAt = now + ADMISSION_CANCEL_TTL_MS;
222
+ const upsertMarker = `INSERT INTO background_admission_cancels
223
+ (user_id, admission_id, expires_at)
224
+ VALUES (?, ?, ?)
225
+ ON CONFLICT(user_id, admission_id)
226
+ DO UPDATE SET expires_at = excluded.expires_at`;
227
+ const abortJob = `UPDATE background_jobs SET status = 'aborted',
228
+ completed_at = ?, last_activity_at = ?, error = ?,
229
+ lease_owner = NULL, lease_expires_at = NULL,
230
+ history_phase = CASE
231
+ WHEN generation_id IS NOT NULL
232
+ AND COALESCE(history_phase, 'ready') = 'ready'
233
+ THEN 'finalization_pending'
234
+ ELSE history_phase END
235
+ WHERE id = ? AND status = 'streaming'`;
236
+ if (!isD1Driver()) {
237
+ const raw = getRawDb();
238
+ return raw.transaction(() => {
239
+ raw.prepare(
240
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?"
241
+ ).run(now);
242
+ raw.prepare(upsertMarker).run(userId, admissionId, expiresAt);
243
+ const job2 = raw.prepare(
244
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?"
245
+ ).get(userId, admissionId);
246
+ if (!job2) {
247
+ return { aborted: false, pending: true };
248
+ }
249
+ if (job2.status === "streaming") {
250
+ const changes = raw.prepare(abortJob).run(
251
+ now,
252
+ now,
253
+ "Cancelled by user",
254
+ job2.id
255
+ ).changes;
256
+ return {
257
+ aborted: changes > 0,
258
+ jobId: job2.id,
259
+ pending: false
260
+ };
261
+ }
262
+ return { aborted: false, jobId: job2.id, pending: false };
263
+ }).immediate();
264
+ }
265
+ await run(
266
+ "DELETE FROM background_admission_cancels WHERE expires_at <= ?",
267
+ now
268
+ );
269
+ await run(upsertMarker, userId, admissionId, expiresAt);
270
+ const job = await one(
271
+ "SELECT id, status FROM background_jobs WHERE user_id = ? AND idempotency_key = ?",
272
+ userId,
273
+ admissionId
274
+ );
275
+ if (!job) {
276
+ return { aborted: false, pending: true };
277
+ }
278
+ if (job.status === "streaming") {
279
+ const changes = await run(
280
+ abortJob,
281
+ now,
282
+ now,
283
+ "Cancelled by user",
284
+ job.id
285
+ );
286
+ return {
287
+ aborted: changes > 0,
288
+ jobId: job.id,
289
+ pending: false
290
+ };
291
+ }
292
+ return { aborted: false, jobId: job.id, pending: false };
293
+ }
149
294
  async updateJob(jobId, update) {
150
295
  const sets = ["last_activity_at = ?"];
151
296
  const values = [Date.now()];
@@ -153,6 +298,10 @@ export class SqliteBackgroundJobProvider {
153
298
  sets.push("content = content || ?");
154
299
  values.push(update.contentChunk);
155
300
  }
301
+ if (update.reasoningChunk !== void 0) {
302
+ sets.push("reasoning = reasoning || ?");
303
+ values.push(update.reasoningChunk);
304
+ }
156
305
  if (update.chunksReceived !== void 0) {
157
306
  sets.push("chunks_received = ?");
158
307
  values.push(update.chunksReceived);
@@ -168,8 +317,9 @@ export class SqliteBackgroundJobProvider {
168
317
  values.push(jobId);
169
318
  let where = "id = ? AND status = 'streaming'";
170
319
  if (update.leaseOwner) {
171
- where += " AND lease_owner = ?";
320
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
172
321
  values.push(update.leaseOwner);
322
+ values.push(Date.now());
173
323
  }
174
324
  const changes = await run(
175
325
  `UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
@@ -206,7 +356,12 @@ export class SqliteBackgroundJobProvider {
206
356
  const now = Date.now();
207
357
  return await run(
208
358
  `UPDATE background_jobs SET status = 'aborted', completed_at = ?,
209
- last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
359
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL,
360
+ history_phase = CASE
361
+ WHEN generation_id IS NOT NULL
362
+ AND COALESCE(history_phase, 'ready') = 'ready'
363
+ THEN 'finalization_pending'
364
+ ELSE history_phase END
210
365
  WHERE id = ? AND user_id = ? AND status = 'streaming'`,
211
366
  now,
212
367
  now,
@@ -226,12 +381,25 @@ export class SqliteBackgroundJobProvider {
226
381
  }
227
382
  async claimJob(jobId, leaseOwner, now, leaseExpiresAt) {
228
383
  const row = await one(
229
- `UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
384
+ `UPDATE background_jobs SET
385
+ lease_owner = ?,
386
+ lease_expires_at = ?,
387
+ last_activity_at = ?,
388
+ content = CASE WHEN attempts > 0
389
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
390
+ ELSE content END,
391
+ reasoning = CASE WHEN attempts > 0
392
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
393
+ ELSE reasoning END,
394
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
395
+ attempts = attempts + 1
230
396
  WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
397
+ AND COALESCE(history_phase, 'ready') = 'ready'
231
398
  AND (lease_owner IS NULL OR lease_expires_at <= ?)
232
399
  RETURNING *`,
233
400
  leaseOwner,
234
401
  leaseExpiresAt,
402
+ now,
235
403
  jobId,
236
404
  now
237
405
  );
@@ -239,15 +407,28 @@ export class SqliteBackgroundJobProvider {
239
407
  }
240
408
  async claimNextJob(leaseOwner, now, leaseExpiresAt) {
241
409
  const row = await one(
242
- `UPDATE background_jobs SET lease_owner = ?, lease_expires_at = ?, attempts = attempts + 1
410
+ `UPDATE background_jobs SET
411
+ lease_owner = ?,
412
+ lease_expires_at = ?,
413
+ last_activity_at = ?,
414
+ content = CASE WHEN attempts > 0
415
+ THEN COALESCE(json_extract(execution_json, '$.contentBase'), '')
416
+ ELSE content END,
417
+ reasoning = CASE WHEN attempts > 0
418
+ THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
419
+ ELSE reasoning END,
420
+ chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
421
+ attempts = attempts + 1
243
422
  WHERE id = (
244
423
  SELECT id FROM background_jobs
245
424
  WHERE status = 'streaming' AND execution_json IS NOT NULL
425
+ AND COALESCE(history_phase, 'ready') = 'ready'
246
426
  AND (lease_owner IS NULL OR lease_expires_at <= ?)
247
427
  ORDER BY started_at ASC LIMIT 1
248
428
  ) RETURNING *`,
249
429
  leaseOwner,
250
430
  leaseExpiresAt,
431
+ now,
251
432
  now
252
433
  );
253
434
  return row ? toJob(row) : null;
@@ -255,11 +436,13 @@ export class SqliteBackgroundJobProvider {
255
436
  async renewJobLease(jobId, leaseOwner, _now, leaseExpiresAt) {
256
437
  return await run(
257
438
  `UPDATE background_jobs SET lease_expires_at = ?, last_activity_at = ?
258
- WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
439
+ WHERE id = ? AND status = 'streaming' AND lease_owner = ?
440
+ AND lease_expires_at > ?`,
259
441
  leaseExpiresAt,
260
442
  Date.now(),
261
443
  jobId,
262
- leaseOwner
444
+ leaseOwner,
445
+ Date.now()
263
446
  ) > 0;
264
447
  }
265
448
  async updateJobExecution(jobId, execution, leaseOwner) {
@@ -272,19 +455,73 @@ export class SqliteBackgroundJobProvider {
272
455
  leaseOwner
273
456
  ) > 0;
274
457
  }
458
+ async saveTerminalSnapshot(jobId, snapshot, leaseOwner) {
459
+ const values = [
460
+ snapshot.status,
461
+ snapshot.content,
462
+ snapshot.reasoning,
463
+ json(snapshot.toolCalls),
464
+ snapshot.error ?? null,
465
+ snapshot.completedAt,
466
+ snapshot.completedAt
467
+ ];
468
+ let where = "id = ? AND status = 'streaming'";
469
+ values.push(jobId);
470
+ if (leaseOwner) {
471
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
472
+ values.push(leaseOwner, Date.now());
473
+ }
474
+ return await run(
475
+ `UPDATE background_jobs SET status = ?, content = ?, reasoning = ?,
476
+ tool_calls_json = ?, error = ?, completed_at = ?,
477
+ history_phase = 'finalization_pending',
478
+ last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
479
+ WHERE ${where}`,
480
+ ...values
481
+ ) > 0;
482
+ }
483
+ async setHistoryPhase(jobId, phase, options) {
484
+ const values = [phase, jobId];
485
+ let where = "id = ?";
486
+ if (options?.from && options.from.length > 0) {
487
+ const placeholders = options.from.map(() => "?").join(", ");
488
+ where += ` AND COALESCE(history_phase, 'ready') IN (${placeholders})`;
489
+ values.push(...options.from);
490
+ }
491
+ return await run(`UPDATE background_jobs SET history_phase = ? WHERE ${where}`, ...values) > 0;
492
+ }
493
+ async getPendingHistoryJobs(limit) {
494
+ const rows = await all(
495
+ `SELECT * FROM background_jobs
496
+ WHERE history_phase IN ('admission_pending', 'finalization_pending')
497
+ ORDER BY started_at ASC
498
+ LIMIT ?`,
499
+ Math.max(1, Math.min(100, Math.floor(limit)))
500
+ );
501
+ return rows.map(toJob);
502
+ }
275
503
  async cleanupExpired() {
276
504
  const config = getJobConfig();
277
505
  const now = Date.now();
278
506
  const timedOut = await run(
279
507
  `UPDATE background_jobs SET status = 'error', error = 'Job timed out',
280
- completed_at = ?, lease_owner = NULL, lease_expires_at = NULL
508
+ completed_at = ?, lease_owner = NULL, lease_expires_at = NULL,
509
+ history_phase = CASE
510
+ WHEN generation_id IS NOT NULL
511
+ AND COALESCE(history_phase, 'ready') = 'ready'
512
+ THEN 'finalization_pending'
513
+ ELSE history_phase END
281
514
  WHERE status = 'streaming' AND last_activity_at <= ?`,
282
515
  now,
283
516
  now - config.jobTimeoutMs
284
517
  );
285
518
  const removed = await run(
286
519
  `DELETE FROM background_jobs
287
- WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?`,
520
+ WHERE status != 'streaming' AND completed_at IS NOT NULL AND completed_at <= ?
521
+ AND (
522
+ history_phase IS NULL
523
+ OR history_phase IN ('ready', 'committed', 'superseded')
524
+ )`,
288
525
  now - config.completedJobRetentionMs
289
526
  );
290
527
  return timedOut + removed;
@@ -16,6 +16,9 @@ import * as m014 from "./migrations/014_connect_retention_activation.js";
16
16
  import * as m015 from "./migrations/015_rate_limits.js";
17
17
  import * as m016 from "./migrations/016_connect_runtime_binding.js";
18
18
  import * as m017 from "./migrations/017_background_jobs.js";
19
+ import * as m018 from "./migrations/018_background_admission_cancels.js";
20
+ import * as m019 from "./migrations/019_background_generation_history.js";
21
+ import * as m020 from "./migrations/020_background_generation_receipts.js";
19
22
  const REQUIRED_SCHEMA_TABLES = [
20
23
  "users",
21
24
  "auth_accounts",
@@ -41,7 +44,9 @@ const REQUIRED_SCHEMA_TABLES = [
41
44
  "connect_device_authorizations",
42
45
  "connect_environments",
43
46
  "rate_limits",
44
- "background_jobs"
47
+ "background_jobs",
48
+ "background_admission_cancels",
49
+ "background_generation_receipts"
45
50
  ];
46
51
  const migrations = {
47
52
  "001_init": m001,
@@ -60,7 +65,10 @@ const migrations = {
60
65
  "014_connect_retention_activation": m014,
61
66
  "015_rate_limits": m015,
62
67
  "016_connect_runtime_binding": m016,
63
- "017_background_jobs": m017
68
+ "017_background_jobs": m017,
69
+ "018_background_admission_cancels": m018,
70
+ "019_background_generation_history": m019,
71
+ "020_background_generation_receipts": m020
64
72
  };
65
73
  class StaticMigrationProvider {
66
74
  async getMigrations() {
@@ -0,0 +1,7 @@
1
+ import { type Kysely } from 'kysely';
2
+ /**
3
+ * Durable admission cancellations. Creation and cancellation both check this
4
+ * table so a Stop that arrives before the job row commits cannot launch work.
5
+ */
6
+ export declare function up(db: Kysely<unknown>): Promise<void>;
7
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`
4
+ CREATE TABLE IF NOT EXISTS background_admission_cancels (
5
+ user_id TEXT NOT NULL,
6
+ admission_id TEXT NOT NULL,
7
+ expires_at INTEGER NOT NULL,
8
+ PRIMARY KEY (user_id, admission_id)
9
+ )
10
+ `.execute(db);
11
+ await sql`
12
+ CREATE INDEX IF NOT EXISTS background_admission_cancels_expiry
13
+ ON background_admission_cancels(expires_at)
14
+ `.execute(db);
15
+ }
16
+ export async function down(db) {
17
+ await sql`DROP TABLE IF EXISTS background_admission_cancels`.execute(db);
18
+ }
@@ -0,0 +1,7 @@
1
+ import { type Kysely } from 'kysely';
2
+ /**
3
+ * Generation identity, durable history phase, and accumulated reasoning on
4
+ * background jobs. Reasoning is kept distinct from request reasoning config.
5
+ */
6
+ export declare function up(db: Kysely<unknown>): Promise<void>;
7
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`
4
+ ALTER TABLE background_jobs
5
+ ADD COLUMN reasoning TEXT NOT NULL DEFAULT ''
6
+ `.execute(db);
7
+ await sql`
8
+ ALTER TABLE background_jobs
9
+ ADD COLUMN generation_id TEXT
10
+ `.execute(db);
11
+ await sql`
12
+ ALTER TABLE background_jobs
13
+ ADD COLUMN history_phase TEXT
14
+ `.execute(db);
15
+ await sql`
16
+ ALTER TABLE background_jobs
17
+ ADD COLUMN sync_provider_id TEXT
18
+ `.execute(db);
19
+ }
20
+ export async function down(db) {
21
+ await sql`ALTER TABLE background_jobs DROP COLUMN sync_provider_id`.execute(db);
22
+ await sql`ALTER TABLE background_jobs DROP COLUMN history_phase`.execute(db);
23
+ await sql`ALTER TABLE background_jobs DROP COLUMN generation_id`.execute(db);
24
+ await sql`ALTER TABLE background_jobs DROP COLUMN reasoning`.execute(db);
25
+ }
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Durable replay receipts for canonical background-history transactions. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`
4
+ CREATE TABLE background_generation_receipts (
5
+ id TEXT PRIMARY KEY,
6
+ workspace_id TEXT NOT NULL,
7
+ generation_id TEXT NOT NULL,
8
+ stage TEXT NOT NULL CHECK(stage IN ('admission', 'finalization')),
9
+ fingerprint TEXT NOT NULL,
10
+ outcome TEXT NOT NULL,
11
+ server_version INTEGER,
12
+ created_at INTEGER NOT NULL,
13
+ UNIQUE(workspace_id, generation_id, stage)
14
+ )
15
+ `.execute(db);
16
+ }
17
+ export async function down(db) {
18
+ await sql`DROP TABLE IF EXISTS background_generation_receipts`.execute(db);
19
+ }
@@ -197,6 +197,10 @@ export interface BackgroundJobsTable {
197
197
  kind: string | null;
198
198
  status: 'streaming' | 'complete' | 'error' | 'aborted';
199
199
  content: string;
200
+ reasoning: string;
201
+ generation_id: string | null;
202
+ history_phase: string | null;
203
+ sync_provider_id: string | null;
200
204
  chunks_received: number;
201
205
  started_at: number;
202
206
  last_activity_at: number;
@@ -210,6 +214,16 @@ export interface BackgroundJobsTable {
210
214
  lease_expires_at: number | null;
211
215
  attempts: number;
212
216
  }
217
+ export interface BackgroundGenerationReceiptsTable {
218
+ id: string;
219
+ workspace_id: string;
220
+ generation_id: string;
221
+ stage: 'admission' | 'finalization';
222
+ fingerprint: string;
223
+ outcome: string;
224
+ server_version: number | null;
225
+ created_at: number;
226
+ }
213
227
  export interface AdminUsersTable {
214
228
  user_id: string;
215
229
  created_at: number;
@@ -239,6 +253,7 @@ export interface Or3SqliteDb {
239
253
  connect_environments: ConnectEnvironmentsTable;
240
254
  rate_limits: RateLimitsTable;
241
255
  background_jobs: BackgroundJobsTable;
256
+ background_generation_receipts: BackgroundGenerationReceiptsTable;
242
257
  s_threads: SyncedEntityTable;
243
258
  s_messages: SyncedEntityTable;
244
259
  s_projects: SyncedEntityTable;
@@ -11,3 +11,11 @@ export declare const SYNC_HISTORY_GC_POLICY: Readonly<{
11
11
  reason: "SQLite history GC is guarded by the verified snapshot-v1 retention contract.";
12
12
  }>;
13
13
  export declare function canRunSyncHistoryGc(): boolean;
14
+ export declare function computePullRetention(input: {
15
+ cursor: number;
16
+ oldestLogVersion: number | null;
17
+ highWatermark: number;
18
+ }): {
19
+ oldestRetainedVersion: number;
20
+ requiresSnapshot: boolean;
21
+ };
@@ -6,3 +6,10 @@ export const SYNC_HISTORY_GC_POLICY = Object.freeze({
6
6
  export function canRunSyncHistoryGc() {
7
7
  return SYNC_HISTORY_GC_POLICY.enabled && SYNC_HISTORY_GC_POLICY.snapshotBootstrapVerified;
8
8
  }
9
+ export function computePullRetention(input) {
10
+ const oldestRetainedVersion = input.oldestLogVersion ?? Math.max(0, input.highWatermark) + 1;
11
+ return {
12
+ oldestRetainedVersion,
13
+ requiresSnapshot: oldestRetainedVersion > 0 && input.cursor < Math.max(0, oldestRetainedVersion - 1)
14
+ };
15
+ }