@zgeoff/atc 2.5.1 → 2.6.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.
@@ -2,19 +2,38 @@ import { Database } from 'bun:sqlite';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
4
4
  import { toAgentID } from '../agents/agent-adapter';
5
- import type { AgentID } from '../agents/agent-adapter';
5
+ import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
6
6
  import type { HookEvent } from '../daemon/hooks';
7
7
  import type { AgentSessionID } from '../shared/agent-session-id';
8
+ import type { SessionID } from '../shared/session-id';
8
9
  import { toAgentSessionID } from '../shared/to-agent-session-id';
10
+ import { toSessionID } from '../shared/to-session-id';
9
11
  import { BunSqliteDriver } from './bun-sqlite-driver';
10
12
  import { parseFleetEntry } from './fleet-entry';
11
- import type { FleetEntry } from './fleet-entry';
13
+ import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
12
14
  import { runMigrations } from './run-migrations';
13
15
  import type { StateStoreSchema } from './run-migrations';
14
16
 
17
+ // Spelled as the partial index's predicate so SQLite can match them.
18
+ const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
19
+
20
+ export interface StoredEvent {
21
+ readonly id: number;
22
+
23
+ // Epoch ms of ts.
24
+ readonly at: number;
25
+ readonly atcID: SessionID;
26
+ readonly agentSessionID: AgentSessionID | null;
27
+ readonly kind: string;
28
+
29
+ // The event's detail, else the hook's message.
30
+ readonly detail: string | null;
31
+ }
32
+
15
33
  /**
16
34
  * Daemon state in one SQLite store: the restorable fleet, the hook-event
17
- * debug trail, and the spawn-directory history. The statusline contract
35
+ * trail that events.read and lastActivityAt read, and the spawn-directory
36
+ * history. The statusline contract
18
37
  * file (status.json) stays a plain file because reporters inside wrangled
19
38
  * sessions read it without speaking to the daemon. An existing fleet.json
20
39
  * seeds the fleet table once, so upgrading never loses a restorable fleet.
@@ -72,6 +91,9 @@ export class StateStore {
72
91
  'agent',
73
92
  'exited',
74
93
  'parent',
94
+ 'prompt',
95
+ 'result',
96
+ 'transcript_path',
75
97
  ])
76
98
  .execute();
77
99
 
@@ -87,6 +109,9 @@ export class StateStore {
87
109
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
88
110
  ...(row.exited === 0 ? {} : { exited: true }),
89
111
  ...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
112
+ ...(row.prompt === null ? {} : { prompt: row.prompt }),
113
+ ...(row.result === null ? {} : { result: row.result }),
114
+ ...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
90
115
  });
91
116
  }
92
117
 
@@ -132,6 +157,9 @@ export class StateStore {
132
157
  agent: entry.agent,
133
158
  exited: entry.exited === true ? 1 : 0,
134
159
  parent: entry.parent ?? null,
160
+ prompt: entry.prompt ?? null,
161
+ result: entry.result ?? null,
162
+ transcript_path: entry.transcriptPath ?? null,
135
163
  })
136
164
  .orReplace()
137
165
  .execute();
@@ -139,7 +167,27 @@ export class StateStore {
139
167
  });
140
168
  }
141
169
 
142
- async recordEvent(e: HookEvent): Promise<void> {
170
+ // Rewrites fields on one existing row and leaves every other row as it
171
+ // stands. A session without a row yet is a no-op: the next fleet write
172
+ // inserts it with these fields.
173
+ async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
174
+ const values = {
175
+ ...(fields.result === undefined ? {} : { result: fields.result }),
176
+ ...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
177
+ };
178
+
179
+ if (Object.keys(values).length === 0) {
180
+ return;
181
+ }
182
+
183
+ await this.db
184
+ .updateTable('fleet')
185
+ .set(values)
186
+ .where('agent_session_id', '=', agentSessionID)
187
+ .execute();
188
+ }
189
+
190
+ async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
143
191
  const rawMessage = e.payload['message'];
144
192
  const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
145
193
  const message = typeof rawMessage === 'string' ? rawMessage : null;
@@ -154,10 +202,57 @@ export class StateStore {
154
202
  event: e.event,
155
203
  message,
156
204
  session_id: sessionID,
205
+ kind: ev === null ? null : ev.kind,
206
+ detail: ev?.detail ?? null,
157
207
  })
158
208
  .execute();
159
209
  }
160
210
 
211
+ async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
212
+ const rows = await this.db
213
+ .selectFrom('events')
214
+ .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
215
+ .where('id', '>', afterID)
216
+ .where(TRAIL_FILTER)
217
+ .orderBy('id', 'asc')
218
+ .limit(limit)
219
+ .execute();
220
+
221
+ return buildStoredEvents(rows);
222
+ }
223
+
224
+ async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
225
+ const rows = await this.db
226
+ .selectFrom('events')
227
+ .select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
228
+ .where(TRAIL_FILTER)
229
+ .orderBy('id', 'desc')
230
+ .limit(limit)
231
+ .execute();
232
+
233
+ return buildStoredEvents(rows).toReversed();
234
+ }
235
+
236
+ // Matches the session by either id, since a restore re-mints the atc id
237
+ // while the agent session id carries on.
238
+ async loadLastActivityAt(
239
+ atcID: SessionID,
240
+ agentSessionID: AgentSessionID | undefined,
241
+ ): Promise<number | null> {
242
+ const row = await this.db
243
+ .selectFrom('events')
244
+ .select(sql<string | null>`MAX(ts)`.as('ts'))
245
+ .where((eb) =>
246
+ eb.or([
247
+ eb('atc_id', '=', atcID),
248
+ ...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
249
+ ]),
250
+ )
251
+ .executeTakeFirst();
252
+
253
+ return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
254
+ }
255
+
161
256
  async recordSpawnDir(cwd: string): Promise<void> {
162
257
  await this.db
163
258
  .insertInto('spawn_history')
@@ -235,3 +330,34 @@ export class StateStore {
235
330
  } catch {}
236
331
  }
237
332
  }
333
+
334
+ interface EventRow {
335
+ readonly id: number;
336
+ readonly ts: string;
337
+ readonly atc_id: string;
338
+ readonly session_id: string | null;
339
+ readonly kind: string | null;
340
+ readonly detail: string | null;
341
+ readonly message: string | null;
342
+ }
343
+
344
+ function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
345
+ const events: StoredEvent[] = [];
346
+
347
+ for (const row of rows) {
348
+ if (row.kind === null) {
349
+ continue;
350
+ }
351
+
352
+ events.push({
353
+ id: row.id,
354
+ at: Date.parse(row.ts),
355
+ atcID: toSessionID(row.atc_id),
356
+ agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
357
+ kind: row.kind,
358
+ detail: row.detail ?? row.message,
359
+ });
360
+ }
361
+
362
+ return events;
363
+ }