@zgeoff/atc 2.5.1 → 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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +26 -0
- package/src/agents/claude-adapter.ts +6 -1
- package/src/agents/codex-adapter.ts +3 -1
- package/src/agents/gateway-adapter.ts +6 -0
- package/src/agents/grok-adapter.ts +3 -1
- package/src/agents/parse-claude-transcript-line.ts +107 -0
- package/src/cli.ts +46 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-fleet-events.ts +35 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/daemon-connection.ts +337 -0
- package/src/daemon/daemon.ts +333 -6
- package/src/daemon/event-signal.ts +54 -0
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/load-transcript-page.ts +101 -0
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +35 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/sessions.ts +51 -10
- package/src/daemon/tap-registry.ts +67 -0
- package/src/mcp-server.ts +164 -1
- package/src/protocol/decode-cursor.ts +29 -0
- package/src/protocol/encode-cursor.ts +12 -0
- package/src/protocol/request-param-schemas.ts +30 -0
- package/src/report.ts +32 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +3 -0
- package/src/shared/report.ts +18 -2
- package/src/shared/to-message-id.ts +11 -0
- package/src/shared/truncate-to-bytes.ts +21 -0
- package/src/store/fleet-entry.ts +20 -0
- package/src/store/message-owner.ts +9 -0
- package/src/store/message-record.ts +18 -0
- package/src/store/run-migrations.ts +94 -3
- package/src/store/state-store.ts +272 -4
- package/src/tap.ts +120 -0
|
@@ -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;
|
|
@@ -12,6 +13,9 @@ interface FleetTable {
|
|
|
12
13
|
agent: string;
|
|
13
14
|
exited: number;
|
|
14
15
|
parent: string | null;
|
|
16
|
+
prompt: string | null;
|
|
17
|
+
result: string | null;
|
|
18
|
+
transcript_path: string | null;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
interface EventsTable {
|
|
@@ -21,6 +25,8 @@ interface EventsTable {
|
|
|
21
25
|
event: string;
|
|
22
26
|
message: string | null;
|
|
23
27
|
session_id: string | null;
|
|
28
|
+
kind: string | null;
|
|
29
|
+
detail: string | null;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
interface SpawnHistoryTable {
|
|
@@ -33,6 +39,19 @@ interface PrefsTable {
|
|
|
33
39
|
value: string;
|
|
34
40
|
}
|
|
35
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
|
+
|
|
36
55
|
/**
|
|
37
56
|
* The state store's schema: what the query builder and the migration
|
|
38
57
|
* ladder both build against.
|
|
@@ -42,13 +61,14 @@ export interface StateStoreSchema {
|
|
|
42
61
|
events: EventsTable;
|
|
43
62
|
spawn_history: SpawnHistoryTable;
|
|
44
63
|
prefs: PrefsTable;
|
|
64
|
+
messages: MessagesTable;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
// Every shape the fleet table has shipped with: the oldest carries only
|
|
48
68
|
// agent_session_id under its Claude-era name plus name and cwd, and each
|
|
49
|
-
// later step adds one column the daemon grew to depend on. events
|
|
50
|
-
// spawn_history, and
|
|
51
|
-
//
|
|
69
|
+
// 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.
|
|
52
72
|
const MIGRATIONS: Record<string, Migration> = {
|
|
53
73
|
'001_create_initial_schema': {
|
|
54
74
|
async up(db: Kysely<StateStoreSchema>) {
|
|
@@ -125,6 +145,77 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
125
145
|
await db.schema.alterTable('fleet').addColumn('parent', 'text').execute();
|
|
126
146
|
},
|
|
127
147
|
},
|
|
148
|
+
'008_add_fleet_prompt_result_transcript': {
|
|
149
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
150
|
+
await db.schema.alterTable('fleet').addColumn('prompt', 'text').execute();
|
|
151
|
+
await db.schema.alterTable('fleet').addColumn('result', 'text').execute();
|
|
152
|
+
await db.schema.alterTable('fleet').addColumn('transcript_path', 'text').execute();
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
'009_add_events_kind_detail': {
|
|
156
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
157
|
+
await db.schema.alterTable('events').addColumn('kind', 'text').execute();
|
|
158
|
+
await db.schema.alterTable('events').addColumn('detail', 'text').execute();
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
'010_add_events_trail_indexes': {
|
|
162
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
163
|
+
await db.schema
|
|
164
|
+
.createIndex('events_atc_id_ts')
|
|
165
|
+
.ifNotExists()
|
|
166
|
+
.on('events')
|
|
167
|
+
.columns(['atc_id', 'ts'])
|
|
168
|
+
.execute();
|
|
169
|
+
|
|
170
|
+
await db.schema
|
|
171
|
+
.createIndex('events_session_id_ts')
|
|
172
|
+
.ifNotExists()
|
|
173
|
+
.on('events')
|
|
174
|
+
.columns(['session_id', 'ts'])
|
|
175
|
+
.execute();
|
|
176
|
+
|
|
177
|
+
// Partial, so the trail reads walk only the rows they return. The
|
|
178
|
+
// predicate text must match the reads' filter for SQLite to pick it.
|
|
179
|
+
await sql`CREATE INDEX IF NOT EXISTS events_trail ON events (id) WHERE kind IS NOT NULL AND kind != 'heartbeat'`.execute(
|
|
180
|
+
db,
|
|
181
|
+
);
|
|
182
|
+
},
|
|
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
|
+
},
|
|
128
219
|
};
|
|
129
220
|
|
|
130
221
|
const PROVIDER: MigrationProvider = {
|
package/src/store/state-store.ts
CHANGED
|
@@ -1,20 +1,44 @@
|
|
|
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
|
-
import type { AgentID } from '../agents/agent-adapter';
|
|
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';
|
|
10
|
+
import type { SessionID } from '../shared/session-id';
|
|
8
11
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
12
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
13
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
9
14
|
import { BunSqliteDriver } from './bun-sqlite-driver';
|
|
10
15
|
import { parseFleetEntry } from './fleet-entry';
|
|
11
|
-
import type { FleetEntry } from './fleet-entry';
|
|
16
|
+
import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
|
|
17
|
+
import type { MessageOwner } from './message-owner';
|
|
18
|
+
import type { MessageRecord } from './message-record';
|
|
12
19
|
import { runMigrations } from './run-migrations';
|
|
13
20
|
import type { StateStoreSchema } from './run-migrations';
|
|
14
21
|
|
|
22
|
+
// Spelled as the partial index's predicate so SQLite can match them.
|
|
23
|
+
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
24
|
+
|
|
25
|
+
export interface StoredEvent {
|
|
26
|
+
readonly id: number;
|
|
27
|
+
|
|
28
|
+
// Epoch ms of ts.
|
|
29
|
+
readonly at: number;
|
|
30
|
+
readonly atcID: SessionID;
|
|
31
|
+
readonly agentSessionID: AgentSessionID | null;
|
|
32
|
+
readonly kind: string;
|
|
33
|
+
|
|
34
|
+
// The event's detail, else the hook's message.
|
|
35
|
+
readonly detail: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/**
|
|
16
39
|
* Daemon state in one SQLite store: the restorable fleet, the hook-event
|
|
17
|
-
*
|
|
40
|
+
* trail that events.read and lastActivityAt read, the spawn-directory
|
|
41
|
+
* history, and the per-session message inbox. The statusline contract
|
|
18
42
|
* file (status.json) stays a plain file because reporters inside wrangled
|
|
19
43
|
* sessions read it without speaking to the daemon. An existing fleet.json
|
|
20
44
|
* seeds the fleet table once, so upgrading never loses a restorable fleet.
|
|
@@ -72,6 +96,9 @@ export class StateStore {
|
|
|
72
96
|
'agent',
|
|
73
97
|
'exited',
|
|
74
98
|
'parent',
|
|
99
|
+
'prompt',
|
|
100
|
+
'result',
|
|
101
|
+
'transcript_path',
|
|
75
102
|
])
|
|
76
103
|
.execute();
|
|
77
104
|
|
|
@@ -87,6 +114,9 @@ export class StateStore {
|
|
|
87
114
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
88
115
|
...(row.exited === 0 ? {} : { exited: true }),
|
|
89
116
|
...(row.parent === null ? {} : { parent: toAgentSessionID(row.parent) }),
|
|
117
|
+
...(row.prompt === null ? {} : { prompt: row.prompt }),
|
|
118
|
+
...(row.result === null ? {} : { result: row.result }),
|
|
119
|
+
...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
|
|
90
120
|
});
|
|
91
121
|
}
|
|
92
122
|
|
|
@@ -132,6 +162,9 @@ export class StateStore {
|
|
|
132
162
|
agent: entry.agent,
|
|
133
163
|
exited: entry.exited === true ? 1 : 0,
|
|
134
164
|
parent: entry.parent ?? null,
|
|
165
|
+
prompt: entry.prompt ?? null,
|
|
166
|
+
result: entry.result ?? null,
|
|
167
|
+
transcript_path: entry.transcriptPath ?? null,
|
|
135
168
|
})
|
|
136
169
|
.orReplace()
|
|
137
170
|
.execute();
|
|
@@ -139,7 +172,27 @@ export class StateStore {
|
|
|
139
172
|
});
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
175
|
+
// Rewrites fields on one existing row and leaves every other row as it
|
|
176
|
+
// stands. A session without a row yet is a no-op: the next fleet write
|
|
177
|
+
// inserts it with these fields.
|
|
178
|
+
async updateFleetEntry(agentSessionID: AgentSessionID, fields: FleetEntryUpdate): Promise<void> {
|
|
179
|
+
const values = {
|
|
180
|
+
...(fields.result === undefined ? {} : { result: fields.result }),
|
|
181
|
+
...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (Object.keys(values).length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await this.db
|
|
189
|
+
.updateTable('fleet')
|
|
190
|
+
.set(values)
|
|
191
|
+
.where('agent_session_id', '=', agentSessionID)
|
|
192
|
+
.execute();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
|
|
143
196
|
const rawMessage = e.payload['message'];
|
|
144
197
|
const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
|
|
145
198
|
const message = typeof rawMessage === 'string' ? rawMessage : null;
|
|
@@ -154,10 +207,57 @@ export class StateStore {
|
|
|
154
207
|
event: e.event,
|
|
155
208
|
message,
|
|
156
209
|
session_id: sessionID,
|
|
210
|
+
kind: ev === null ? null : ev.kind,
|
|
211
|
+
detail: ev?.detail ?? null,
|
|
157
212
|
})
|
|
158
213
|
.execute();
|
|
159
214
|
}
|
|
160
215
|
|
|
216
|
+
async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
|
|
217
|
+
const rows = await this.db
|
|
218
|
+
.selectFrom('events')
|
|
219
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
220
|
+
.where('id', '>', afterID)
|
|
221
|
+
.where(TRAIL_FILTER)
|
|
222
|
+
.orderBy('id', 'asc')
|
|
223
|
+
.limit(limit)
|
|
224
|
+
.execute();
|
|
225
|
+
|
|
226
|
+
return buildStoredEvents(rows);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async collectLatestEvents(limit: number): Promise<StoredEvent[]> {
|
|
230
|
+
const rows = await this.db
|
|
231
|
+
.selectFrom('events')
|
|
232
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
233
|
+
.where(TRAIL_FILTER)
|
|
234
|
+
.orderBy('id', 'desc')
|
|
235
|
+
.limit(limit)
|
|
236
|
+
.execute();
|
|
237
|
+
|
|
238
|
+
return buildStoredEvents(rows).toReversed();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Matches the session by either id, since a restore re-mints the atc id
|
|
242
|
+
// while the agent session id carries on.
|
|
243
|
+
async loadLastActivityAt(
|
|
244
|
+
atcID: SessionID,
|
|
245
|
+
agentSessionID: AgentSessionID | undefined,
|
|
246
|
+
): Promise<number | null> {
|
|
247
|
+
const row = await this.db
|
|
248
|
+
.selectFrom('events')
|
|
249
|
+
.select(sql<string | null>`MAX(ts)`.as('ts'))
|
|
250
|
+
.where((eb) =>
|
|
251
|
+
eb.or([
|
|
252
|
+
eb('atc_id', '=', atcID),
|
|
253
|
+
...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
|
|
254
|
+
]),
|
|
255
|
+
)
|
|
256
|
+
.executeTakeFirst();
|
|
257
|
+
|
|
258
|
+
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
259
|
+
}
|
|
260
|
+
|
|
161
261
|
async recordSpawnDir(cwd: string): Promise<void> {
|
|
162
262
|
await this.db
|
|
163
263
|
.insertInto('spawn_history')
|
|
@@ -198,6 +298,115 @@ export class StateStore {
|
|
|
198
298
|
.execute();
|
|
199
299
|
}
|
|
200
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
|
+
|
|
201
410
|
async stop(): Promise<void> {
|
|
202
411
|
await this.db.destroy();
|
|
203
412
|
|
|
@@ -235,3 +444,62 @@ export class StateStore {
|
|
|
235
444
|
} catch {}
|
|
236
445
|
}
|
|
237
446
|
}
|
|
447
|
+
|
|
448
|
+
interface EventRow {
|
|
449
|
+
readonly id: number;
|
|
450
|
+
readonly ts: string;
|
|
451
|
+
readonly atc_id: string;
|
|
452
|
+
readonly session_id: string | null;
|
|
453
|
+
readonly kind: string | null;
|
|
454
|
+
readonly detail: string | null;
|
|
455
|
+
readonly message: string | null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
|
|
459
|
+
const events: StoredEvent[] = [];
|
|
460
|
+
|
|
461
|
+
for (const row of rows) {
|
|
462
|
+
if (row.kind === null) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
events.push({
|
|
467
|
+
id: row.id,
|
|
468
|
+
at: Date.parse(row.ts),
|
|
469
|
+
atcID: toSessionID(row.atc_id),
|
|
470
|
+
agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
|
|
471
|
+
kind: row.kind,
|
|
472
|
+
detail: row.detail ?? row.message,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return events;
|
|
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
|
+
}
|