@zgeoff/atc 2.6.0 → 2.7.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.
@@ -4,12 +4,28 @@
4
4
  async function sendLine(sock: string, line: string): Promise<void> {
5
5
  const resolvers = Promise.withResolvers<void>();
6
6
 
7
+ const bytes = new TextEncoder().encode(line);
8
+
9
+ let written = 0;
10
+
7
11
  await Bun.connect({
8
12
  unix: sock,
9
13
  socket: {
14
+ // A write returns the bytes the socket accepted and drops the rest, so
15
+ // a line larger than the socket buffer goes out across drain calls.
10
16
  open(s) {
11
- s.write(line);
12
- s.end();
17
+ written += s.write(bytes.subarray(written));
18
+
19
+ if (written >= bytes.length) {
20
+ s.end();
21
+ }
22
+ },
23
+ drain(s) {
24
+ written += s.write(bytes.subarray(written));
25
+
26
+ if (written >= bytes.length) {
27
+ s.end();
28
+ }
13
29
  },
14
30
  close() {
15
31
  resolvers.resolve();
@@ -0,0 +1,11 @@
1
+ import type { MessageID } from './message-id';
2
+
3
+ /**
4
+ * Trusts a string as an atc message id. This is the one point in the
5
+ * codebase where a plain string becomes a `MessageID`, and it adds no
6
+ * runtime check: the caller asserts the string identifies an atc message.
7
+ */
8
+ export function toMessageID(id: string): MessageID {
9
+ // oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a MessageID
10
+ return id as MessageID;
11
+ }
@@ -0,0 +1,9 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+ import type { SessionID } from '../shared/session-id';
3
+
4
+ // Which session a message belongs to: the atc id it was sent to, or the
5
+ // agent session id that survives a restore's re-minted atc id.
6
+ export interface MessageOwner {
7
+ readonly atcID: SessionID;
8
+ readonly agentSessionID?: AgentSessionID;
9
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+ import type { MessageID } from '../shared/message-id';
3
+ import type { SessionID } from '../shared/session-id';
4
+
5
+ export type MessageStatus = 'accepted' | 'delivered' | 'answered';
6
+
7
+ export interface MessageRecord {
8
+ readonly id: MessageID;
9
+ readonly atcID: SessionID;
10
+ readonly agentSessionID?: AgentSessionID;
11
+ readonly from: string;
12
+ readonly text: string;
13
+ readonly status: MessageStatus;
14
+ readonly sentAt: number;
15
+ readonly deliveredAt?: number;
16
+ readonly answeredAt?: number;
17
+ readonly answer?: string;
18
+ }
@@ -2,6 +2,7 @@ import { sql } from 'kysely';
2
2
  import type { Generated, Kysely } from 'kysely';
3
3
  import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
4
4
  import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
5
+ import type { MessageStatus } from './message-record';
5
6
 
6
7
  interface FleetTable {
7
8
  agent_session_id: string;
@@ -38,6 +39,19 @@ interface PrefsTable {
38
39
  value: string;
39
40
  }
40
41
 
42
+ interface MessagesTable {
43
+ id: string;
44
+ atc_id: string;
45
+ agent_session_id: string | null;
46
+ sender: string;
47
+ text: string;
48
+ status: MessageStatus;
49
+ sent_at: number;
50
+ delivered_at: number | null;
51
+ answered_at: number | null;
52
+ answer: string | null;
53
+ }
54
+
41
55
  /**
42
56
  * The state store's schema: what the query builder and the migration
43
57
  * ladder both build against.
@@ -47,13 +61,14 @@ export interface StateStoreSchema {
47
61
  events: EventsTable;
48
62
  spawn_history: SpawnHistoryTable;
49
63
  prefs: PrefsTable;
64
+ messages: MessagesTable;
50
65
  }
51
66
 
52
67
  // Every shape the fleet table has shipped with: the oldest carries only
53
68
  // agent_session_id under its Claude-era name plus name and cwd, and each
54
69
  // later step adds one column the daemon grew to depend on. events later gains
55
- // columns of its own, while spawn_history and prefs have carried one shape
56
- // since they were added.
70
+ // columns of its own, while spawn_history, prefs, and messages have carried one
71
+ // shape since they were added.
57
72
  const MIGRATIONS: Record<string, Migration> = {
58
73
  '001_create_initial_schema': {
59
74
  async up(db: Kysely<StateStoreSchema>) {
@@ -166,6 +181,41 @@ const MIGRATIONS: Record<string, Migration> = {
166
181
  );
167
182
  },
168
183
  },
184
+ '011_create_messages': {
185
+ async up(db: Kysely<StateStoreSchema>) {
186
+ await db.schema
187
+ .createTable('messages')
188
+ .ifNotExists()
189
+ .addColumn('id', 'text', (c) => c.primaryKey())
190
+ .addColumn('atc_id', 'text', (c) => c.notNull())
191
+ .addColumn('agent_session_id', 'text')
192
+ .addColumn('sender', 'text', (c) => c.notNull())
193
+ .addColumn('text', 'text', (c) => c.notNull())
194
+ .addColumn('status', 'text', (c) => c.notNull())
195
+ .addColumn('sent_at', 'integer', (c) => c.notNull())
196
+ .addColumn('delivered_at', 'integer')
197
+ .addColumn('answered_at', 'integer')
198
+ .addColumn('answer', 'text')
199
+ .execute();
200
+ },
201
+ },
202
+ '012_index_messages_by_owner': {
203
+ async up(db: Kysely<StateStoreSchema>) {
204
+ await db.schema
205
+ .createIndex('messages_atc_id_status_sent_at')
206
+ .ifNotExists()
207
+ .on('messages')
208
+ .columns(['atc_id', 'status', 'sent_at'])
209
+ .execute();
210
+
211
+ await db.schema
212
+ .createIndex('messages_agent_session_id_status_sent_at')
213
+ .ifNotExists()
214
+ .on('messages')
215
+ .columns(['agent_session_id', 'status', 'sent_at'])
216
+ .execute();
217
+ },
218
+ },
169
219
  };
170
220
 
171
221
  const PROVIDER: MigrationProvider = {
@@ -1,16 +1,21 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
4
+ import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
4
5
  import { toAgentID } from '../agents/agent-adapter';
5
6
  import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
6
7
  import type { HookEvent } from '../daemon/hooks';
7
8
  import type { AgentSessionID } from '../shared/agent-session-id';
9
+ import type { MessageID } from '../shared/message-id';
8
10
  import type { SessionID } from '../shared/session-id';
9
11
  import { toAgentSessionID } from '../shared/to-agent-session-id';
12
+ import { toMessageID } from '../shared/to-message-id';
10
13
  import { toSessionID } from '../shared/to-session-id';
11
14
  import { BunSqliteDriver } from './bun-sqlite-driver';
12
15
  import { parseFleetEntry } from './fleet-entry';
13
16
  import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
17
+ import type { MessageOwner } from './message-owner';
18
+ import type { MessageRecord } from './message-record';
14
19
  import { runMigrations } from './run-migrations';
15
20
  import type { StateStoreSchema } from './run-migrations';
16
21
 
@@ -32,8 +37,8 @@ export interface StoredEvent {
32
37
 
33
38
  /**
34
39
  * Daemon state in one SQLite store: the restorable fleet, the hook-event
35
- * trail that events.read and lastActivityAt read, and the spawn-directory
36
- * history. The statusline contract
40
+ * trail that events.read and lastActivityAt read, the spawn-directory
41
+ * history, and the per-session message inbox. The statusline contract
37
42
  * file (status.json) stays a plain file because reporters inside wrangled
38
43
  * sessions read it without speaking to the daemon. An existing fleet.json
39
44
  * seeds the fleet table once, so upgrading never loses a restorable fleet.
@@ -293,6 +298,115 @@ export class StateStore {
293
298
  .execute();
294
299
  }
295
300
 
301
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
302
+ async writeMessage(record: MessageRecord): Promise<void> {
303
+ await this.db
304
+ .insertInto('messages')
305
+ .values({
306
+ id: record.id,
307
+ atc_id: record.atcID,
308
+ agent_session_id: record.agentSessionID ?? null,
309
+ sender: record.from,
310
+ text: record.text,
311
+ status: record.status,
312
+ sent_at: record.sentAt,
313
+ delivered_at: record.deliveredAt ?? null,
314
+ answered_at: record.answeredAt ?? null,
315
+ answer: record.answer ?? null,
316
+ })
317
+ .execute();
318
+ }
319
+
320
+ async collectPendingMessages(owner: MessageOwner): Promise<MessageRecord[]> {
321
+ const rows = await this.db
322
+ .selectFrom('messages')
323
+ .selectAll()
324
+ .where('status', '=', 'accepted')
325
+ .where((eb) => buildOwnerFilter(eb, owner))
326
+ .orderBy('sent_at', 'asc')
327
+ .orderBy(sql`rowid`, 'asc')
328
+ .execute();
329
+
330
+ return rows.map((row) => toMessageRecord(row));
331
+ }
332
+
333
+ async findMessage(id: MessageID, owner: MessageOwner): Promise<MessageRecord | null> {
334
+ const row = await this.db
335
+ .selectFrom('messages')
336
+ .selectAll()
337
+ .where('id', '=', id)
338
+ .where((eb) => buildOwnerFilter(eb, owner))
339
+ .executeTakeFirst();
340
+
341
+ return row === undefined ? null : toMessageRecord(row);
342
+ }
343
+
344
+ async findMessageByID(id: MessageID): Promise<MessageRecord | null> {
345
+ const row = await this.db
346
+ .selectFrom('messages')
347
+ .selectAll()
348
+ .where('id', '=', id)
349
+ .executeTakeFirst();
350
+
351
+ return row === undefined ? null : toMessageRecord(row);
352
+ }
353
+
354
+ async updateMessageDelivered(
355
+ id: MessageID,
356
+ owner: MessageOwner,
357
+ at: number,
358
+ ): Promise<MessageRecord | null> {
359
+ const row = await this.db
360
+ .updateTable('messages')
361
+ .set({ status: 'delivered', delivered_at: at })
362
+ .where('id', '=', id)
363
+ .where('status', '=', 'accepted')
364
+ .where((eb) => buildOwnerFilter(eb, owner))
365
+ .returningAll()
366
+ .executeTakeFirst();
367
+
368
+ return row === undefined ? null : toMessageRecord(row);
369
+ }
370
+
371
+ async updateMessageAnswered(
372
+ id: MessageID,
373
+ owner: MessageOwner,
374
+ answer: string,
375
+ at: number,
376
+ ): Promise<MessageRecord | null> {
377
+ const row = await this.db
378
+ .updateTable('messages')
379
+ .set({ status: 'answered', answered_at: at, answer })
380
+ .where('id', '=', id)
381
+ .where('status', 'in', ['accepted', 'delivered'])
382
+ .where((eb) => buildOwnerFilter(eb, owner))
383
+ .returningAll()
384
+ .executeTakeFirst();
385
+
386
+ return row === undefined ? null : toMessageRecord(row);
387
+ }
388
+
389
+ // Messages sent before the agent reported its session id carry no agent
390
+ // session id; this stamps them once it is known, and moves every message
391
+ // from a previous agent session id to a changed one.
392
+ async updateMessageOwner(
393
+ atcID: SessionID,
394
+ previous: AgentSessionID | undefined,
395
+ next: AgentSessionID,
396
+ ): Promise<void> {
397
+ await this.db
398
+ .updateTable('messages')
399
+ .set({ agent_session_id: next })
400
+ .where((eb) => {
401
+ const unstamped = eb.and([eb('atc_id', '=', atcID), eb('agent_session_id', 'is', null)]);
402
+
403
+ return previous === undefined
404
+ ? unstamped
405
+ : eb.or([unstamped, eb('agent_session_id', '=', previous)]);
406
+ })
407
+ .execute();
408
+ }
409
+
296
410
  async stop(): Promise<void> {
297
411
  await this.db.destroy();
298
412
 
@@ -361,3 +475,31 @@ function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
361
475
 
362
476
  return events;
363
477
  }
478
+
479
+ function buildOwnerFilter(
480
+ eb: ExpressionBuilder<StateStoreSchema, 'messages'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
481
+ owner: MessageOwner,
482
+ ): Expression<SqlBool> {
483
+ const byAtcID = eb('atc_id', '=', owner.atcID);
484
+
485
+ return owner.agentSessionID === undefined
486
+ ? byAtcID
487
+ : eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
488
+ }
489
+
490
+ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
491
+ return {
492
+ id: toMessageID(row.id),
493
+ atcID: toSessionID(row.atc_id),
494
+ ...(row.agent_session_id === null
495
+ ? {}
496
+ : { agentSessionID: toAgentSessionID(row.agent_session_id) }),
497
+ from: row.sender,
498
+ text: row.text,
499
+ status: row.status,
500
+ sentAt: row.sent_at,
501
+ ...(row.delivered_at === null ? {} : { deliveredAt: row.delivered_at }),
502
+ ...(row.answered_at === null ? {} : { answeredAt: row.answered_at }),
503
+ ...(row.answer === null ? {} : { answer: row.answer }),
504
+ };
505
+ }
package/src/tap.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { DaemonClient } from './client/daemon-client';
3
+ import { DaemonError } from './protocol/daemon-error';
4
+ import { daemonSocketPath } from './shared/config';
5
+ import { getBuild } from './shared/get-build';
6
+
7
+ const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
+ message: z.string(),
9
+ from: z.string(),
10
+ text: z.string(),
11
+ sentAt: z.number(),
12
+ });
13
+
14
+ interface InboxMessage {
15
+ readonly message: string;
16
+ readonly from: string;
17
+ readonly text: string;
18
+ readonly sentAt: number;
19
+ }
20
+
21
+ /**
22
+ * Streams one session's inbox to stdout, one NDJSON line per message, and
23
+ * acks each message once its line is written. It talks to the daemon that
24
+ * is already running and never boots one: a tap runs inside the session it
25
+ * serves, so a restart from here would take the whole fleet down. Exits 1
26
+ * with a hint when no daemon listens or the session cannot be tapped, and
27
+ * 0 once the daemon closes the connection or ends the subscription because
28
+ * another tap replaced it or the session was removed.
29
+ */
30
+ export async function runTap(session: string): Promise<void> {
31
+ let client: DaemonClient;
32
+
33
+ try {
34
+ client = await DaemonClient.open(daemonSocketPath);
35
+ } catch {
36
+ console.error(`atc tap: no daemon at ${daemonSocketPath} — start atc first`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const closed = Promise.withResolvers<void>();
41
+ let written: Promise<void> = Promise.resolve();
42
+
43
+ client.onClose = () => {
44
+ closed.resolve();
45
+ };
46
+
47
+ client.onEvent = (event) => {
48
+ if (event.ev === 'InboxClosed' && event['s'] === session) {
49
+ closed.resolve();
50
+
51
+ return;
52
+ }
53
+
54
+ if (event.ev !== 'InboxMessage' || event['s'] !== session) {
55
+ return;
56
+ }
57
+
58
+ const parsed = INBOX_MESSAGE_SCHEMA.safeParse(event);
59
+
60
+ if (!parsed.success) {
61
+ return;
62
+ }
63
+
64
+ const msg: InboxMessage = {
65
+ message: parsed.data.message,
66
+ from: parsed.data.from,
67
+ text: parsed.data.text,
68
+ sentAt: parsed.data.sentAt,
69
+ };
70
+
71
+ const previous = written;
72
+
73
+ written = (async () => {
74
+ await previous;
75
+ await ackInboxMessage(client, session, msg);
76
+ })();
77
+ };
78
+
79
+ try {
80
+ await client.sendHello(getBuild());
81
+ await client.sendRequest('session.tap', { session });
82
+ } catch (error) {
83
+ console.error(`atc tap: ${formatError(error)}`);
84
+ client.stop();
85
+ process.exit(1);
86
+ }
87
+
88
+ await closed.promise;
89
+ await written;
90
+
91
+ process.exit(0);
92
+ }
93
+
94
+ interface RequestSender {
95
+ readonly sendRequest: DaemonClient['sendRequest'];
96
+ }
97
+
98
+ async function ackInboxMessage(
99
+ client: RequestSender,
100
+ session: string,
101
+ msg: InboxMessage,
102
+ ): Promise<void> {
103
+ const line = `${JSON.stringify({ id: msg.message, from: msg.from, text: msg.text, sentAt: msg.sentAt })}\n`;
104
+
105
+ try {
106
+ await Bun.write(Bun.stdout, line);
107
+ } catch {
108
+ process.exit(1);
109
+ }
110
+
111
+ try {
112
+ await client.sendRequest('message.ack', { session, message: msg.message });
113
+ } catch (error) {
114
+ console.error(`atc tap: ${formatError(error)}`);
115
+ }
116
+ }
117
+
118
+ function formatError(error: unknown): string {
119
+ return error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
120
+ }