@copilotkit/sqlite-runner 1.82.0 → 2.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/sqlite-runner.cjs +137 -20
- package/dist/sqlite-runner.cjs.map +1 -1
- package/dist/sqlite-runner.d.cts +45 -1
- package/dist/sqlite-runner.d.cts.map +1 -1
- package/dist/sqlite-runner.d.mts +45 -1
- package/dist/sqlite-runner.d.mts.map +1 -1
- package/dist/sqlite-runner.mjs +133 -16
- package/dist/sqlite-runner.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/sqlite-ownership.test.ts +748 -0
- package/src/sqlite-runner.ts +224 -19
package/src/sqlite-runner.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AgentRunner,
|
|
3
|
+
ThreadAccessDeniedError,
|
|
3
4
|
ThreadAlreadyRunningError,
|
|
4
5
|
finalizeRunEvents,
|
|
5
|
-
|
|
6
|
+
ɵresolveThreadOwner,
|
|
7
|
+
} from "@copilotkit/runtime";
|
|
6
8
|
import type {
|
|
7
9
|
AgentRunnerConnectRequest,
|
|
8
10
|
AgentRunnerIsRunningRequest,
|
|
9
11
|
AgentRunnerRunRequest,
|
|
10
12
|
AgentRunnerStopRequest,
|
|
11
|
-
|
|
13
|
+
LocalThreadEndpointScope,
|
|
14
|
+
} from "@copilotkit/runtime";
|
|
12
15
|
import type { Observable } from "rxjs";
|
|
13
16
|
import { ReplaySubject } from "rxjs";
|
|
14
17
|
import type {
|
|
@@ -20,7 +23,23 @@ import type {
|
|
|
20
23
|
import { EventType, compactEvents } from "@ag-ui/client";
|
|
21
24
|
import Database from "better-sqlite3";
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 2: adds `thread_owners`. Also written as the `version` of each stored run,
|
|
28
|
+
* so runs stored from now on are tagged 2.
|
|
29
|
+
*/
|
|
30
|
+
const SCHEMA_VERSION = 2;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Owner of a legacy thread: one whose runs were stored before owners were
|
|
34
|
+
* recorded (schema 1), so it has runs but no `thread_owners` row. Before the
|
|
35
|
+
* upgrade, every caller that held a thread id could reach that thread. A
|
|
36
|
+
* legacy thread keeps exactly that: it stays a capability, reachable by id for
|
|
37
|
+
* every caller and every identity, and continuing it never claims it. Only a
|
|
38
|
+
* thread that has no runs yet is claimed, by its first run, so no new thread
|
|
39
|
+
* can become legacy. The value holds a NUL character, which no request
|
|
40
|
+
* identity can hold.
|
|
41
|
+
*/
|
|
42
|
+
const LEGACY_THREAD_OWNER = "\u0000legacy";
|
|
24
43
|
|
|
25
44
|
interface AgentRunRecord {
|
|
26
45
|
id: number;
|
|
@@ -45,12 +64,20 @@ interface ActiveConnectionContext {
|
|
|
45
64
|
stopRequested?: boolean;
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
// Active connections for streaming events and stop support
|
|
49
|
-
const ACTIVE_CONNECTIONS = new Map<string, ActiveConnectionContext>();
|
|
50
|
-
|
|
51
67
|
export class SqliteAgentRunner extends AgentRunner {
|
|
52
68
|
private db: any;
|
|
53
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Live runs of this runner, by thread id, for streaming and stop. Kept per
|
|
72
|
+
* instance: owners are checked against this instance's database, so a live
|
|
73
|
+
* run of another runner, which may hold the same thread id for a different
|
|
74
|
+
* owner, must never be bridged, stopped or torn down from here.
|
|
75
|
+
*/
|
|
76
|
+
private readonly activeConnections = new Map<
|
|
77
|
+
string,
|
|
78
|
+
ActiveConnectionContext
|
|
79
|
+
>();
|
|
80
|
+
|
|
54
81
|
constructor(options: SqliteAgentRunnerOptions = {}) {
|
|
55
82
|
super();
|
|
56
83
|
const dbPath = options.dbPath ?? ":memory:";
|
|
@@ -70,6 +97,29 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
70
97
|
|
|
71
98
|
this.db = new Database(dbPath);
|
|
72
99
|
this.initializeSchema();
|
|
100
|
+
this.warnIfLegacyThreads();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Logs once, when the database opens, how many legacy threads it holds
|
|
105
|
+
* (see {@link LEGACY_THREAD_OWNER}), so operators know those threads are
|
|
106
|
+
* still reachable by id for every caller.
|
|
107
|
+
*/
|
|
108
|
+
private warnIfLegacyThreads(): void {
|
|
109
|
+
const row = this.db
|
|
110
|
+
.prepare(
|
|
111
|
+
"SELECT COUNT(DISTINCT thread_id) AS count FROM agent_runs WHERE thread_id NOT IN (SELECT thread_id FROM thread_owners)",
|
|
112
|
+
)
|
|
113
|
+
.get() as { count: number } | undefined;
|
|
114
|
+
const count = row?.count ?? 0;
|
|
115
|
+
if (count === 0) return;
|
|
116
|
+
console.warn(
|
|
117
|
+
`[CopilotKit] SqliteAgentRunner: ${count} thread${count === 1 ? " was" : "s were"} ` +
|
|
118
|
+
"stored before thread owners were recorded. Anyone who holds the id of " +
|
|
119
|
+
"one of them can still read, continue and stop it, as before the upgrade, " +
|
|
120
|
+
"whatever identifyUser returns. Threads created from now on belong to the " +
|
|
121
|
+
"identity whose run created them.",
|
|
122
|
+
);
|
|
73
123
|
}
|
|
74
124
|
|
|
75
125
|
private initializeSchema(): void {
|
|
@@ -97,6 +147,15 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
97
147
|
)
|
|
98
148
|
`);
|
|
99
149
|
|
|
150
|
+
// The identity that owns each thread, recorded by the thread's first run.
|
|
151
|
+
this.db.exec(`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS thread_owners (
|
|
153
|
+
thread_id TEXT PRIMARY KEY,
|
|
154
|
+
owner_id TEXT NOT NULL,
|
|
155
|
+
created_at INTEGER NOT NULL
|
|
156
|
+
)
|
|
157
|
+
`);
|
|
158
|
+
|
|
100
159
|
// Create indexes for efficient queries
|
|
101
160
|
this.db.exec(`
|
|
102
161
|
CREATE INDEX IF NOT EXISTS idx_thread_id ON agent_runs(thread_id);
|
|
@@ -226,13 +285,100 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
226
285
|
};
|
|
227
286
|
}
|
|
228
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Owner of a thread: the recorded owner, {@link LEGACY_THREAD_OWNER} for a
|
|
290
|
+
* thread whose runs predate owner records, or `undefined` for an unknown
|
|
291
|
+
* thread.
|
|
292
|
+
*
|
|
293
|
+
* The owner record and the run check are read by one statement, so they
|
|
294
|
+
* come from one snapshot of the database. With two reads, another runner on
|
|
295
|
+
* the same file could claim the thread and store its first run in between,
|
|
296
|
+
* and this call would see runs but no owner and take that thread for a
|
|
297
|
+
* legacy one, open to every caller.
|
|
298
|
+
*/
|
|
299
|
+
private getThreadOwner(threadId: string): string | undefined {
|
|
300
|
+
const row = this.db
|
|
301
|
+
.prepare(
|
|
302
|
+
"SELECT (SELECT owner_id FROM thread_owners WHERE thread_id = ?) AS owner_id, EXISTS (SELECT 1 FROM agent_runs WHERE thread_id = ?) AS has_runs",
|
|
303
|
+
)
|
|
304
|
+
.get(threadId, threadId) as
|
|
305
|
+
| { owner_id: string | null; has_runs: number }
|
|
306
|
+
| undefined;
|
|
307
|
+
if (row?.owner_id != null) return row.owner_id;
|
|
308
|
+
return row?.has_runs ? LEGACY_THREAD_OWNER : undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* True when a call acting as `ownerId` may reach an existing thread: its
|
|
313
|
+
* owner, or anyone for a legacy thread.
|
|
314
|
+
*/
|
|
315
|
+
private isOwnedBy(threadId: string, ownerId: string | undefined): boolean {
|
|
316
|
+
const owner = this.getThreadOwner(threadId);
|
|
317
|
+
return (
|
|
318
|
+
owner === LEGACY_THREAD_OWNER || owner === ɵresolveThreadOwner(ownerId)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* False when the thread is held by an owner other than `scope`'s. A legacy
|
|
324
|
+
* thread is held by nobody, so it is accessible to every caller.
|
|
325
|
+
*/
|
|
326
|
+
isThreadAccessible(
|
|
327
|
+
threadId: string,
|
|
328
|
+
scope: LocalThreadEndpointScope,
|
|
329
|
+
): boolean {
|
|
330
|
+
const owner = this.getThreadOwner(threadId);
|
|
331
|
+
return (
|
|
332
|
+
owner === undefined ||
|
|
333
|
+
owner === LEGACY_THREAD_OWNER ||
|
|
334
|
+
owner === ɵresolveThreadOwner(scope.ownerId)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Records `owner` as the owner of a new thread, and returns true when the
|
|
340
|
+
* run may go on as `owner`: `owner` holds the thread afterward, or the
|
|
341
|
+
* thread is legacy, which stays unowned. The read, the insert and the
|
|
342
|
+
* re-read run in one IMMEDIATE transaction, so when two runners or
|
|
343
|
+
* processes on one database file claim a new thread at once, exactly one
|
|
344
|
+
* wins and the other reads the winner's row.
|
|
345
|
+
*/
|
|
346
|
+
private claimThread(threadId: string, owner: string): boolean {
|
|
347
|
+
return this.db
|
|
348
|
+
.transaction(() => {
|
|
349
|
+
const current = this.getThreadOwner(threadId);
|
|
350
|
+
if (current === LEGACY_THREAD_OWNER) return true;
|
|
351
|
+
if (current === undefined) {
|
|
352
|
+
this.db
|
|
353
|
+
.prepare(
|
|
354
|
+
"INSERT INTO thread_owners (thread_id, owner_id, created_at) VALUES (?, ?, ?) ON CONFLICT(thread_id) DO NOTHING",
|
|
355
|
+
)
|
|
356
|
+
.run(threadId, owner, Date.now());
|
|
357
|
+
}
|
|
358
|
+
return this.getThreadOwner(threadId) === owner;
|
|
359
|
+
})
|
|
360
|
+
.immediate();
|
|
361
|
+
}
|
|
362
|
+
|
|
229
363
|
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
|
|
364
|
+
// A thread belongs to the identity of its first run.
|
|
365
|
+
const owner = ɵresolveThreadOwner(request.ownerId);
|
|
366
|
+
if (!this.isThreadAccessible(request.threadId, { ownerId: owner })) {
|
|
367
|
+
throw new ThreadAccessDeniedError();
|
|
368
|
+
}
|
|
369
|
+
|
|
230
370
|
// Check if thread is already running in database
|
|
231
371
|
const runState = this.getRunState(request.threadId);
|
|
232
372
|
if (runState.isRunning) {
|
|
233
373
|
throw new ThreadAlreadyRunningError();
|
|
234
374
|
}
|
|
235
375
|
|
|
376
|
+
// The check above is per process. Another process on the same database
|
|
377
|
+
// file can claim the thread in between, so the claim itself decides.
|
|
378
|
+
if (!this.claimThread(request.threadId, owner)) {
|
|
379
|
+
throw new ThreadAccessDeniedError();
|
|
380
|
+
}
|
|
381
|
+
|
|
236
382
|
// Mark thread as running in database
|
|
237
383
|
this.setRunState(request.threadId, true, request.input.runId);
|
|
238
384
|
|
|
@@ -260,20 +406,21 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
260
406
|
|
|
261
407
|
// Get or create subject for this thread's connections
|
|
262
408
|
const nextSubject = new ReplaySubject<BaseEvent>(Infinity);
|
|
263
|
-
const prevConnection =
|
|
409
|
+
const prevConnection = this.activeConnections.get(request.threadId);
|
|
264
410
|
const prevSubject = prevConnection?.subject;
|
|
265
411
|
|
|
266
412
|
// Create a subject for run() return value
|
|
267
413
|
const runSubject = new ReplaySubject<BaseEvent>(Infinity);
|
|
268
414
|
|
|
269
415
|
// Update the active connection for this thread
|
|
270
|
-
|
|
416
|
+
const runConnection: ActiveConnectionContext = {
|
|
271
417
|
subject: nextSubject,
|
|
272
418
|
agent: request.agent,
|
|
273
419
|
runSubject,
|
|
274
420
|
currentEvents: currentRunEvents,
|
|
275
421
|
stopRequested: false,
|
|
276
|
-
}
|
|
422
|
+
};
|
|
423
|
+
this.activeConnections.set(request.threadId, runConnection);
|
|
277
424
|
|
|
278
425
|
// Helper function to run the agent and handle errors
|
|
279
426
|
const runAgent = async () => {
|
|
@@ -351,7 +498,7 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
351
498
|
},
|
|
352
499
|
});
|
|
353
500
|
|
|
354
|
-
const connection =
|
|
501
|
+
const connection = this.activeConnections.get(request.threadId);
|
|
355
502
|
ensureStoppedRunStarted(connection?.stopRequested ?? false);
|
|
356
503
|
const appendedEvents = finalizeRunEvents(currentRunEvents, {
|
|
357
504
|
stopRequested: connection?.stopRequested ?? false,
|
|
@@ -385,9 +532,16 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
385
532
|
runSubject.complete();
|
|
386
533
|
nextSubject.complete();
|
|
387
534
|
|
|
388
|
-
|
|
389
|
-
} catch {
|
|
390
|
-
const connection =
|
|
535
|
+
this.activeConnections.delete(request.threadId);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
const connection = this.activeConnections.get(request.threadId);
|
|
538
|
+
if (!connection?.stopRequested) {
|
|
539
|
+
console.error(
|
|
540
|
+
"Sqlite agent run failed",
|
|
541
|
+
{ threadId: request.threadId, runId: request.input.runId },
|
|
542
|
+
error,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
391
545
|
ensureStoppedRunStarted(connection?.stopRequested ?? false);
|
|
392
546
|
const appendedEvents = finalizeRunEvents(currentRunEvents, {
|
|
393
547
|
stopRequested: connection?.stopRequested ?? false,
|
|
@@ -424,7 +578,7 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
424
578
|
runSubject.complete();
|
|
425
579
|
nextSubject.complete();
|
|
426
580
|
|
|
427
|
-
|
|
581
|
+
this.activeConnections.delete(request.threadId);
|
|
428
582
|
}
|
|
429
583
|
};
|
|
430
584
|
|
|
@@ -439,8 +593,33 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
439
593
|
});
|
|
440
594
|
}
|
|
441
595
|
|
|
442
|
-
// Start the agent execution immediately (not lazily)
|
|
443
|
-
|
|
596
|
+
// Start the agent execution immediately (not lazily). A failure while
|
|
597
|
+
// storing or finishing the run must not leave the thread marked running
|
|
598
|
+
// in the database, where it would refuse every later run, even after a
|
|
599
|
+
// restart.
|
|
600
|
+
runAgent().catch((error) => {
|
|
601
|
+
console.error(
|
|
602
|
+
"Failed to finish sqlite agent run",
|
|
603
|
+
{ threadId: request.threadId, runId: request.input.runId },
|
|
604
|
+
error,
|
|
605
|
+
);
|
|
606
|
+
try {
|
|
607
|
+
const state = this.getRunState(request.threadId);
|
|
608
|
+
if (
|
|
609
|
+
state.currentRunId === null ||
|
|
610
|
+
state.currentRunId === request.input.runId
|
|
611
|
+
) {
|
|
612
|
+
this.setRunState(request.threadId, false);
|
|
613
|
+
}
|
|
614
|
+
} catch (stateError) {
|
|
615
|
+
console.error("Failed to reset sqlite run state", stateError);
|
|
616
|
+
}
|
|
617
|
+
if (this.activeConnections.get(request.threadId) === runConnection) {
|
|
618
|
+
this.activeConnections.delete(request.threadId);
|
|
619
|
+
}
|
|
620
|
+
runSubject.complete();
|
|
621
|
+
nextSubject.complete();
|
|
622
|
+
});
|
|
444
623
|
|
|
445
624
|
// Return the run subject (only agent events, no injected messages)
|
|
446
625
|
return runSubject.asObservable();
|
|
@@ -449,6 +628,12 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
449
628
|
connect(request: AgentRunnerConnectRequest): Observable<BaseEvent> {
|
|
450
629
|
const connectionSubject = new ReplaySubject<BaseEvent>(Infinity);
|
|
451
630
|
|
|
631
|
+
// A thread owned by another identity replays like an unknown one.
|
|
632
|
+
if (!this.isOwnedBy(request.threadId, request.ownerId)) {
|
|
633
|
+
connectionSubject.complete();
|
|
634
|
+
return connectionSubject.asObservable();
|
|
635
|
+
}
|
|
636
|
+
|
|
452
637
|
// Load historic runs from database
|
|
453
638
|
const historicRuns = this.getHistoricRuns(request.threadId);
|
|
454
639
|
|
|
@@ -471,7 +656,7 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
471
656
|
}
|
|
472
657
|
|
|
473
658
|
// Bridge active run to connection if exists
|
|
474
|
-
const activeConnection =
|
|
659
|
+
const activeConnection = this.activeConnections.get(request.threadId);
|
|
475
660
|
const runState = this.getRunState(request.threadId);
|
|
476
661
|
|
|
477
662
|
if (
|
|
@@ -502,6 +687,10 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
502
687
|
}
|
|
503
688
|
|
|
504
689
|
isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean> {
|
|
690
|
+
// Another identity's thread reports "not running".
|
|
691
|
+
if (!this.isOwnedBy(request.threadId, request.ownerId)) {
|
|
692
|
+
return Promise.resolve(false);
|
|
693
|
+
}
|
|
505
694
|
const runState = this.getRunState(request.threadId);
|
|
506
695
|
return Promise.resolve(runState.isRunning);
|
|
507
696
|
}
|
|
@@ -511,8 +700,19 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
511
700
|
if (!runState.isRunning) {
|
|
512
701
|
return Promise.resolve(false);
|
|
513
702
|
}
|
|
703
|
+
// A run on another identity's thread reports "no active run".
|
|
704
|
+
if (!this.isOwnedBy(request.threadId, request.ownerId)) {
|
|
705
|
+
return Promise.resolve(false);
|
|
706
|
+
}
|
|
707
|
+
// A run-scoped stop never stops a newer run on the thread.
|
|
708
|
+
if (
|
|
709
|
+
request.runId !== undefined &&
|
|
710
|
+
runState.currentRunId !== request.runId
|
|
711
|
+
) {
|
|
712
|
+
return Promise.resolve(false);
|
|
713
|
+
}
|
|
514
714
|
|
|
515
|
-
const connection =
|
|
715
|
+
const connection = this.activeConnections.get(request.threadId);
|
|
516
716
|
const agent = connection?.agent;
|
|
517
717
|
|
|
518
718
|
if (!connection || !agent) {
|
|
@@ -532,7 +732,12 @@ export class SqliteAgentRunner extends AgentRunner {
|
|
|
532
732
|
} catch (error) {
|
|
533
733
|
console.error("Failed to abort sqlite agent run", error);
|
|
534
734
|
connection.stopRequested = false;
|
|
535
|
-
|
|
735
|
+
// Restore the run's id too, or a later stop by run id would refuse it.
|
|
736
|
+
this.setRunState(
|
|
737
|
+
request.threadId,
|
|
738
|
+
true,
|
|
739
|
+
runState.currentRunId ?? undefined,
|
|
740
|
+
);
|
|
536
741
|
return Promise.resolve(false);
|
|
537
742
|
}
|
|
538
743
|
}
|