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 +19 -4
- package/dist/module.json +1 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.d.ts +8 -1
- package/dist/runtime/server/background-jobs/sqlite-provider.js +248 -11
- package/dist/runtime/server/db/migrate.js +10 -2
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.d.ts +7 -0
- package/dist/runtime/server/db/migrations/018_background_admission_cancels.js +18 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.d.ts +7 -0
- package/dist/runtime/server/db/migrations/019_background_generation_history.js +25 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.d.ts +4 -0
- package/dist/runtime/server/db/migrations/020_background_generation_receipts.js +19 -0
- package/dist/runtime/server/db/schema.d.ts +15 -0
- package/dist/runtime/server/sync/history-gc-policy.d.ts +8 -0
- package/dist/runtime/server/sync/history-gc-policy.js +7 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +13 -1
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +669 -96
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
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,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',
|
|
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',
|
|
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
|
|
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
|
|
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,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
|
+
}
|