@zgeoff/atc 2.10.3 → 2.11.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.
@@ -50,6 +50,10 @@ interface MessagesTable {
50
50
  delivered_at: number | null;
51
51
  answered_at: number | null;
52
52
  answer: string | null;
53
+
54
+ // The turn whose final reply is the answer; null for a message answered
55
+ // without one.
56
+ turn_id: string | null;
53
57
  }
54
58
 
55
59
  /**
@@ -67,8 +71,8 @@ export interface StateStoreSchema {
67
71
  // Every shape the fleet table has shipped with: the oldest carries only
68
72
  // agent_session_id under its Claude-era name plus name and cwd, and each
69
73
  // later step adds one column the daemon grew to depend on. events later gains
70
- // columns of its own, while spawn_history, prefs, and messages have carried one
71
- // shape since they were added.
74
+ // columns of its own, as does messages, while spawn_history and prefs have
75
+ // carried one shape since they were added.
72
76
  const MIGRATIONS: Record<string, Migration> = {
73
77
  '001_create_initial_schema': {
74
78
  async up(db: Kysely<StateStoreSchema>) {
@@ -216,6 +220,18 @@ const MIGRATIONS: Record<string, Migration> = {
216
220
  .execute();
217
221
  },
218
222
  },
223
+ '013_add_messages_turn_id': {
224
+ async up(db: Kysely<StateStoreSchema>) {
225
+ await db.schema.alterTable('messages').addColumn('turn_id', 'text').execute();
226
+
227
+ await db.schema
228
+ .createIndex('messages_turn_id')
229
+ .ifNotExists()
230
+ .on('messages')
231
+ .column('turn_id')
232
+ .execute();
233
+ },
234
+ },
219
235
  };
220
236
 
221
237
  const PROVIDER: MigrationProvider = {
@@ -23,6 +23,16 @@ import type { TrailEntry } from './trail-entry';
23
23
  // Spelled as the partial index's predicate so SQLite can match them.
24
24
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
25
25
 
26
+ /**
27
+ * One session's slice of the event trail: rows under its atc id, plus rows
28
+ * under its agent session id, since a restore re-mints the atc id while the
29
+ * agent session id carries on.
30
+ */
31
+ export interface EventScope {
32
+ readonly atcID: SessionID;
33
+ readonly agentSessionID: AgentSessionID | undefined;
34
+ }
35
+
26
36
  export interface StoredEvent {
27
37
  readonly id: number;
28
38
 
@@ -238,12 +248,17 @@ export class StateStore {
238
248
  .execute();
239
249
  }
240
250
 
241
- async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
251
+ async collectEventsAfter(
252
+ afterID: number,
253
+ limit: number,
254
+ scope: EventScope | null = null,
255
+ ): Promise<StoredEvent[]> {
242
256
  const rows = await this.db
243
257
  .selectFrom('events')
244
258
  .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
245
259
  .where('id', '>', afterID)
246
260
  .where(TRAIL_FILTER)
261
+ .where((eb) => buildScopeMatch(eb, scope))
247
262
  .orderBy('id', 'asc')
248
263
  .limit(limit)
249
264
  .execute();
@@ -251,11 +266,15 @@ export class StateStore {
251
266
  return buildStoredEvents(rows);
252
267
  }
253
268
 
254
- async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
269
+ async collectLatestEvents(
270
+ limit: number,
271
+ scope: EventScope | null = null,
272
+ ): Promise<StoredEvent[]> {
255
273
  const rows = await this.db
256
274
  .selectFrom('events')
257
275
  .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
258
276
  .where(TRAIL_FILTER)
277
+ .where((eb) => buildScopeMatch(eb, scope))
259
278
  .orderBy('id', 'desc')
260
279
  .limit(limit)
261
280
  .execute();
@@ -375,6 +394,31 @@ export class StateStore {
375
394
  return row === undefined ? null : toMessageRecord(row);
376
395
  }
377
396
 
397
+ // The other messages of the same session the given message's turn
398
+ // answered, oldest first; none when the message has no turn.
399
+ async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
400
+ if (record.turn === undefined) {
401
+ return [];
402
+ }
403
+
404
+ const owner: MessageOwner =
405
+ record.agentSessionID === undefined
406
+ ? { atcID: record.atcID }
407
+ : { atcID: record.atcID, agentSessionID: record.agentSessionID };
408
+
409
+ const rows = await this.db
410
+ .selectFrom('messages')
411
+ .select('id')
412
+ .where('turn_id', '=', record.turn)
413
+ .where('id', '!=', record.id)
414
+ .where((eb) => buildOwnerFilter(eb, owner))
415
+ .orderBy('sent_at', 'asc')
416
+ .orderBy(sql`rowid`, 'asc')
417
+ .execute();
418
+
419
+ return rows.map((row) => toMessageID(row.id));
420
+ }
421
+
378
422
  async updateMessageDelivered(
379
423
  id: MessageID,
380
424
  owner: MessageOwner,
@@ -392,22 +436,30 @@ export class StateStore {
392
436
  return row === undefined ? null : toMessageRecord(row);
393
437
  }
394
438
 
395
- async updateMessageAnswered(
396
- id: MessageID,
439
+ // Answers every given message the owner holds unanswered in one statement,
440
+ // so a reader sees all of one turn's messages answered or none of them.
441
+ // Returns the messages it answered, oldest first.
442
+ async updateMessagesAnswered(
443
+ ids: readonly MessageID[],
397
444
  owner: MessageOwner,
398
445
  answer: string,
399
446
  at: number,
400
- ): Promise<MessageRecord | null> {
401
- const row = await this.db
447
+ turn: string | null = null,
448
+ ): Promise<MessageRecord[]> {
449
+ if (ids.length === 0) {
450
+ return [];
451
+ }
452
+
453
+ const rows = await this.db
402
454
  .updateTable('messages')
403
- .set({ status: 'answered', answered_at: at, answer })
404
- .where('id', '=', id)
455
+ .set({ status: 'answered', answered_at: at, answer, turn_id: turn })
456
+ .where('id', 'in', ids)
405
457
  .where('status', 'in', ['accepted', 'delivered'])
406
458
  .where((eb) => buildOwnerFilter(eb, owner))
407
459
  .returningAll()
408
- .executeTakeFirst();
460
+ .execute();
409
461
 
410
- return row === undefined ? null : toMessageRecord(row);
462
+ return rows.map((row) => toMessageRecord(row)).toSorted((a, b) => a.sentAt - b.sentAt);
411
463
  }
412
464
 
413
465
  // Messages sent before the agent reported its session id carry no agent
@@ -482,6 +534,20 @@ export class StateStore {
482
534
  }
483
535
  }
484
536
 
537
+ function buildScopeMatch(
538
+ eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
539
+ scope: EventScope | null,
540
+ ): Expression<SqlBool> {
541
+ if (scope === null) {
542
+ return eb.lit(true);
543
+ }
544
+
545
+ return eb.or([
546
+ eb('atc_id', '=', scope.atcID),
547
+ ...(scope.agentSessionID === undefined ? [] : [eb('session_id', '=', scope.agentSessionID)]),
548
+ ]);
549
+ }
550
+
485
551
  interface EventRow {
486
552
  readonly id: number;
487
553
  readonly ts: string;
@@ -562,5 +628,6 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
562
628
  ...(row.delivered_at === null ? {} : { deliveredAt: row.delivered_at }),
563
629
  ...(row.answered_at === null ? {} : { answeredAt: row.answered_at }),
564
630
  ...(row.answer === null ? {} : { answer: row.answer }),
631
+ ...(row.turn_id === null ? {} : { turn: row.turn_id }),
565
632
  };
566
633
  }