@copilotkit/sqlite-runner 1.82.0 → 1.83.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.
@@ -1,13 +1,16 @@
1
1
  import {
2
2
  AgentRunner,
3
+ ThreadAccessDeniedError,
3
4
  ThreadAlreadyRunningError,
4
5
  finalizeRunEvents,
6
+ ɵresolveThreadOwner,
5
7
  } from "@copilotkit/runtime/v2";
6
8
  import type {
7
9
  AgentRunnerConnectRequest,
8
10
  AgentRunnerIsRunningRequest,
9
11
  AgentRunnerRunRequest,
10
12
  AgentRunnerStopRequest,
13
+ LocalThreadEndpointScope,
11
14
  } from "@copilotkit/runtime/v2";
12
15
  import type { Observable } from "rxjs";
13
16
  import { ReplaySubject } from "rxjs";
@@ -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
- const SCHEMA_VERSION = 1;
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 = ACTIVE_CONNECTIONS.get(request.threadId);
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
- ACTIVE_CONNECTIONS.set(request.threadId, {
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 = ACTIVE_CONNECTIONS.get(request.threadId);
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
- ACTIVE_CONNECTIONS.delete(request.threadId);
389
- } catch {
390
- const connection = ACTIVE_CONNECTIONS.get(request.threadId);
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
- ACTIVE_CONNECTIONS.delete(request.threadId);
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
- runAgent();
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 = ACTIVE_CONNECTIONS.get(request.threadId);
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 = ACTIVE_CONNECTIONS.get(request.threadId);
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
- this.setRunState(request.threadId, true);
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
  }