@zgeoff/atc 2.13.0 → 2.15.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.
Files changed (35) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/client/boot-daemon.ts +47 -21
  4. package/src/client/daemon-client.ts +5 -22
  5. package/src/client/format-protocol-mismatch.ts +37 -0
  6. package/src/client/index.ts +54 -1
  7. package/src/daemon/build-imp-provider.ts +70 -23
  8. package/src/daemon/build-report-trail-entry.ts +4 -2
  9. package/src/daemon/build-report-view.ts +41 -0
  10. package/src/daemon/build-scoped-context.ts +5 -0
  11. package/src/daemon/daemon-connection.ts +42 -16
  12. package/src/daemon/daemon.ts +8 -1
  13. package/src/daemon/hooks.ts +5 -17
  14. package/src/daemon/imp-client-port.ts +83 -27
  15. package/src/daemon/imp-provider.ts +5 -11
  16. package/src/daemon/materialize-workspace.ts +5 -2
  17. package/src/daemon/read-imp-token.ts +30 -0
  18. package/src/daemon/sessions.ts +18 -3
  19. package/src/mcp/mcp-tools.ts +32 -1
  20. package/src/mcp/reconnecting-caller.ts +1 -0
  21. package/src/mcp/require-daemon-features.ts +1 -0
  22. package/src/mcp/run-tool.ts +5 -0
  23. package/src/mcp-server.ts +3 -14
  24. package/src/protocol/daemon-features.ts +3 -0
  25. package/src/protocol/line-decoder.ts +34 -0
  26. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  27. package/src/protocol/request-param-schemas.ts +6 -0
  28. package/src/report.ts +1 -1
  29. package/src/run-bridge-tap.ts +2 -2
  30. package/src/statusline.ts +1 -1
  31. package/src/store/fleet-entry.ts +4 -1
  32. package/src/store/run-migrations.ts +9 -0
  33. package/src/store/state-store.ts +119 -15
  34. package/src/store/trail-entry.ts +3 -0
  35. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
@@ -48,6 +48,10 @@ interface EventsTable {
48
48
  // The id a remote session's reporter gave a report row, unique so a
49
49
  // resent report lands once; null on every other row.
50
50
  report_id: string | null;
51
+
52
+ // A report row's whole text, which detail previews; null on every other
53
+ // row, and on a report row written before the column existed.
54
+ report_text: string | null;
51
55
  }
52
56
 
53
57
  interface SpawnHistoryTable {
@@ -437,6 +441,11 @@ const MIGRATIONS: Record<string, Migration> = {
437
441
  .execute();
438
442
  },
439
443
  },
444
+ '023_add_events_report_text': {
445
+ async up(db: Kysely<StateStoreSchema>) {
446
+ await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
447
+ },
448
+ },
440
449
  };
441
450
 
442
451
  const PROVIDER: MigrationProvider = {
@@ -63,6 +63,23 @@ export interface StoredEvent {
63
63
  readonly label?: string;
64
64
  }
65
65
 
66
+ /**
67
+ * One report as the trail holds it. A report recorded before the trail kept
68
+ * whole texts has only its preview, so its text is that preview and
69
+ * `complete` is false.
70
+ */
71
+ export interface StoredReport {
72
+ readonly id: number;
73
+
74
+ // Epoch ms the report arrived.
75
+ readonly at: number;
76
+ readonly atcID: SessionID;
77
+ readonly agentSessionID: AgentSessionID | null;
78
+ readonly label: string;
79
+ readonly text: string;
80
+ readonly complete: boolean;
81
+ }
82
+
66
83
  /**
67
84
  * Daemon state in one SQLite store: the restorable fleet, the event trail
68
85
  * (hook events, message status changes, and reports) that events.read and
@@ -224,12 +241,22 @@ export class StateStore {
224
241
  return recency;
225
242
  }
226
243
 
227
- // Replaces this daemon's rows wholesale and leaves every other daemon's
228
- // rows as they stand. A session another daemon owns, or one whose stored
229
- // ownership epoch has moved past this daemon's, rejects the whole write
230
- // with stale_epoch.
231
- async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
244
+ // Rewrites this daemon's rows for the given entries and drops its rows
245
+ // for the removed sessions, plus any row of its own that an entry
246
+ // replaces by holding the same agent session id. Every other row stays as
247
+ // it stands, so a row the daemon never loaded survives a write. A session
248
+ // another daemon owns, or one whose stored ownership epoch has moved past
249
+ // this daemon's, rejects the whole write with stale_epoch.
250
+ async writeFleet(
251
+ entries: readonly FleetEntry[],
252
+ removed: readonly SessionID[] = [],
253
+ ): Promise<void> {
232
254
  const kept = buildFleetWithoutReplacedRows(entries);
255
+ const sessionIDs = [...entries.map((entry) => entry.sessionID), ...removed];
256
+
257
+ const agentSessionIDs = entries.flatMap((entry) =>
258
+ entry.agentSessionID === undefined ? [] : [entry.agentSessionID],
259
+ );
233
260
 
234
261
  await this.db.transaction().execute(async (trx) => {
235
262
  await requireCurrentOwnership(
@@ -238,17 +265,35 @@ export class StateStore {
238
265
  entries.map((entry) => entry.sessionID),
239
266
  );
240
267
 
241
- await trx
242
- .deleteFrom('fleet')
243
- .where('session_id', 'in', (eb) =>
244
- eb
245
- .selectFrom('session_owner')
246
- .select('session_id')
247
- .where('daemon_id', '=', this.daemonID),
248
- )
249
- .execute();
268
+ const replaced =
269
+ agentSessionIDs.length === 0
270
+ ? []
271
+ : await trx
272
+ .selectFrom('fleet')
273
+ .select(['session_id', 'agent_session_id'])
274
+ .where('agent_session_id', 'in', agentSessionIDs)
275
+ .execute();
276
+
277
+ const dropped = [...new Set([...sessionIDs, ...replaced.map((row) => row.session_id)])];
278
+
279
+ if (dropped.length > 0) {
280
+ await trx
281
+ .deleteFrom('fleet')
282
+ .where('session_id', 'in', dropped)
283
+ .where('session_id', 'in', (eb) =>
284
+ eb
285
+ .selectFrom('session_owner')
286
+ .select('session_id')
287
+ .where('daemon_id', '=', this.daemonID),
288
+ )
289
+ .execute();
250
290
 
251
- await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
291
+ await trx
292
+ .deleteFrom('session_owner')
293
+ .where('daemon_id', '=', this.daemonID)
294
+ .where('session_id', 'in', dropped)
295
+ .execute();
296
+ }
252
297
 
253
298
  const updatedAt = Date.now();
254
299
 
@@ -287,6 +332,38 @@ export class StateStore {
287
332
  })
288
333
  .execute();
289
334
  }
335
+
336
+ // A stored row this write leaves in place follows its replaced parent
337
+ // to the session that replaced it, or to that session's own parent
338
+ // when the replacement is itself a sub-session.
339
+ const survivors = new Map(
340
+ kept.flatMap((entry) =>
341
+ entry.agentSessionID === undefined ? [] : [[entry.agentSessionID, entry] as const],
342
+ ),
343
+ );
344
+
345
+ for (const row of replaced) {
346
+ const survivor =
347
+ row.agent_session_id === null
348
+ ? undefined
349
+ : survivors.get(toAgentSessionID(row.agent_session_id));
350
+
351
+ if (survivor === undefined || survivor.sessionID === row.session_id) {
352
+ continue;
353
+ }
354
+
355
+ await trx
356
+ .updateTable('fleet')
357
+ .set({ parent_session_id: survivor.parent ?? survivor.sessionID })
358
+ .where('parent_session_id', '=', row.session_id)
359
+ .where('session_id', 'in', (eb) =>
360
+ eb
361
+ .selectFrom('session_owner')
362
+ .select('session_id')
363
+ .where('daemon_id', '=', this.daemonID),
364
+ )
365
+ .execute();
366
+ }
290
367
  });
291
368
  }
292
369
 
@@ -347,6 +424,7 @@ export class StateStore {
347
424
  kind: entry.kind,
348
425
  detail: entry.detail,
349
426
  report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
427
+ report_text: entry.kind === 'report' ? entry.text : null,
350
428
  })
351
429
  .onConflict((oc) => oc.column('report_id').doNothing())
352
430
  .executeTakeFirst();
@@ -372,6 +450,32 @@ export class StateStore {
372
450
  return buildStoredEvents(rows);
373
451
  }
374
452
 
453
+ // A row that is not a report, or that lies outside the scope, misses as a
454
+ // row the trail never held does.
455
+ async findReport(id: number, scope: EventScope | null = null): Promise<StoredReport | null> {
456
+ const row = await this.db
457
+ .selectFrom('events')
458
+ .select(['id', 'ts', 'atc_id', 'session_id', 'message', 'detail', 'report_text'])
459
+ .where('id', '=', id)
460
+ .where('kind', '=', 'report')
461
+ .where((eb) => buildScopeMatch(eb, scope))
462
+ .executeTakeFirst();
463
+
464
+ if (row === undefined) {
465
+ return null;
466
+ }
467
+
468
+ return {
469
+ id: row.id,
470
+ at: Date.parse(row.ts),
471
+ atcID: toSessionID(row.atc_id),
472
+ agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
473
+ label: row.message ?? '',
474
+ text: row.report_text ?? row.detail ?? '',
475
+ complete: row.report_text !== null,
476
+ };
477
+ }
478
+
375
479
  async collectLatestEvents(
376
480
  limit: number,
377
481
  scope: EventScope | null = null,
@@ -19,6 +19,9 @@ interface ReportTrailEntry extends TrailEntryBase {
19
19
  readonly kind: 'report';
20
20
  readonly label: string;
21
21
 
22
+ // The report's whole text, which the detail previews.
23
+ readonly text: string;
24
+
22
25
  // The id a remote session's reporter gave the report, so a resent
23
26
  // report is stored once; absent for a report that carries none.
24
27
  readonly reportID?: string;
File without changes