@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +26 -0
  3. package/src/agents/claude-adapter.ts +6 -1
  4. package/src/agents/codex-adapter.ts +3 -1
  5. package/src/agents/gateway-adapter.ts +6 -0
  6. package/src/agents/grok-adapter.ts +3 -1
  7. package/src/agents/parse-claude-transcript-line.ts +107 -0
  8. package/src/cli.ts +46 -0
  9. package/src/client/daemon-client.ts +13 -1
  10. package/src/daemon/answer-byte-cap.ts +1 -0
  11. package/src/daemon/build-fleet-events.ts +35 -0
  12. package/src/daemon/build-session-message-event.ts +29 -0
  13. package/src/daemon/daemon-connection.ts +337 -0
  14. package/src/daemon/daemon.ts +333 -6
  15. package/src/daemon/event-signal.ts +54 -0
  16. package/src/daemon/hooks.ts +13 -4
  17. package/src/daemon/load-transcript-page.ts +101 -0
  18. package/src/daemon/mint-message-id.ts +12 -0
  19. package/src/daemon/parse-report.ts +35 -0
  20. package/src/daemon/session-runtime.ts +9 -0
  21. package/src/daemon/sessions.ts +51 -10
  22. package/src/daemon/tap-registry.ts +67 -0
  23. package/src/mcp-server.ts +164 -1
  24. package/src/protocol/decode-cursor.ts +29 -0
  25. package/src/protocol/encode-cursor.ts +12 -0
  26. package/src/protocol/request-param-schemas.ts +30 -0
  27. package/src/report.ts +32 -0
  28. package/src/shared/message-id.ts +7 -0
  29. package/src/shared/report-kinds.ts +3 -0
  30. package/src/shared/report.ts +18 -2
  31. package/src/shared/to-message-id.ts +11 -0
  32. package/src/shared/truncate-to-bytes.ts +21 -0
  33. package/src/store/fleet-entry.ts +20 -0
  34. package/src/store/message-owner.ts +9 -0
  35. package/src/store/message-record.ts +18 -0
  36. package/src/store/run-migrations.ts +94 -3
  37. package/src/store/state-store.ts +272 -4
  38. package/src/tap.ts +120 -0
@@ -1,26 +1,38 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
- import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
3
3
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
4
4
  import type { EventMsg } from '../protocol/protocol';
5
5
  import type { HooksConfig } from '../shared/collect-hooks';
6
6
  import type { SessionID } from '../shared/session-id';
7
+ import { truncateToBytes } from '../shared/truncate-to-bytes';
8
+ import type { MessageOwner } from '../store/message-owner';
9
+ import type { MessageRecord } from '../store/message-record';
7
10
  import { StateStore } from '../store/state-store';
11
+ import { ANSWER_BYTE_CAP } from './answer-byte-cap';
8
12
  import { AttachRegistry } from './attach-registry';
13
+ import { buildFleetEvents } from './build-fleet-events';
9
14
  import { buildSessionEvent } from './build-session-event';
15
+ import { buildSessionMessageEvent } from './build-session-message-event';
10
16
  import { DaemonConnection } from './daemon-connection';
11
- import type { DaemonContext, OutputClient } from './daemon-connection';
17
+ import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
18
+ import { EventSignal } from './event-signal';
12
19
  import { startHookServer } from './hooks';
20
+ import type { HookEvent } from './hooks';
21
+ import { loadTranscriptPage } from './load-transcript-page';
13
22
  import { makeHookRunner } from './make-hook-runner';
14
23
  import type { HookScope } from './make-hook-runner';
24
+ import { mintMessageID } from './mint-message-id';
25
+ import { parseReport } from './parse-report';
15
26
  import { PermissionRegistry } from './permission-registry';
16
27
  import { restoreFleet } from './restore-fleet';
17
28
  import { runEjectHandoff } from './run-eject-handoff';
18
29
  import { ScreenModel } from './screen-model';
19
30
  import { SessionRuntime } from './session-runtime';
20
31
  import { SessionManager } from './sessions';
21
- import type { SessionDescriptor, SessionState } from './sessions';
32
+ import type { Session, SessionDescriptor, SessionState } from './sessions';
22
33
  import { startEventsServer } from './start-events-server';
23
34
  import { startHeadlessTurn } from './start-headless-turn';
35
+ import { TapRegistry } from './tap-registry';
24
36
 
25
37
  export interface DaemonOptions {
26
38
  readonly socketPath: string;
@@ -67,6 +79,7 @@ export interface DaemonOptions {
67
79
  // single revive waits for that signal before moving on regardless, so a
68
80
  // session that never reports cannot stall the rest. Zero waits forever.
69
81
  readonly restoreBootTimeoutMs?: number;
82
+ readonly tapGraceMs?: number;
70
83
 
71
84
  // Called after a client-requested quit has stopped the daemon; the real
72
85
  // entrypoint exits the process, tests leave it unset.
@@ -77,6 +90,10 @@ export interface DaemonHandle {
77
90
  readonly stop: () => Promise<void>;
78
91
  }
79
92
 
93
+ // How long a started Claude session may go without a tap before a message to
94
+ // it is refused.
95
+ const TAP_GRACE_MS = 15_000;
96
+
80
97
  /**
81
98
  * The daemon: owns the sessions, the client-protocol listener, and the
82
99
  * reporter listener. Protocol requests are NDJSON lines, one response per
@@ -130,6 +147,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
130
147
  };
131
148
 
132
149
  const registry = new PermissionRegistry();
150
+ const eventSignal = new EventSignal();
133
151
 
134
152
  registry.onRequested = (req) => {
135
153
  emitEvent(
@@ -159,6 +177,81 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
159
177
  const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
160
178
 
161
179
  const attachments = new AttachRegistry<OutputClient>();
180
+ const taps = new TapRegistry<TapClient>();
181
+
182
+ // Writes accepted messages one at a time so the store's insertion order is
183
+ // the order of their sent times, which the inbox drains by.
184
+ let lastMessageWrite: Promise<void> = Promise.resolve();
185
+
186
+ // Hands one pending message to the session's tap, once, and reports whether
187
+ // it did. The event goes to the tap connection alone: it never reaches
188
+ // other clients, the events socket, or hooks.
189
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
190
+ const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
191
+ const tap = taps.claimDelivery(sessionID, record.id);
192
+
193
+ if (tap === null) {
194
+ return false;
195
+ }
196
+
197
+ tap.sendEvent({
198
+ v: PROTOCOL_V,
199
+ ev: 'InboxMessage',
200
+ s: sessionID,
201
+ message: record.id,
202
+ from: record.from,
203
+ text: record.text,
204
+ sentAt: record.sentAt,
205
+ });
206
+
207
+ return true;
208
+ };
209
+
210
+ // Reads the backlog from the store before sending anything, so a tap's
211
+ // ok response is always queued ahead of its first message. It sends one
212
+ // unclaimed message per call and the tap's ack calls it again, so the
213
+ // backlog never outgrows the connection's outbound queue.
214
+ const drainInbox = async (sessionID: SessionID) => {
215
+ const s = mgr.sessions.find((x) => x.id === sessionID);
216
+
217
+ if (s === undefined) {
218
+ return;
219
+ }
220
+
221
+ try {
222
+ const pending = await store.collectPendingMessages(buildMessageOwner(s));
223
+
224
+ for (const record of pending) {
225
+ if (sendInboxMessage(sessionID, record)) {
226
+ return;
227
+ }
228
+ }
229
+ } catch {}
230
+ };
231
+
232
+ const applyReport = async (e: HookEvent) => {
233
+ const report = parseReport(e.payload);
234
+
235
+ if (report === null) {
236
+ return;
237
+ }
238
+
239
+ const s = mgr.sessions.find((x) => x.id === e.atcId);
240
+ const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
241
+
242
+ try {
243
+ const answered = await store.updateMessageAnswered(
244
+ report.message,
245
+ owner,
246
+ truncateToBytes(report.answer, ANSWER_BYTE_CAP),
247
+ Date.now(),
248
+ );
249
+
250
+ if (answered !== null) {
251
+ emitEvent(buildSessionMessageEvent(e.atcId, answered), findHookScope(e.atcId));
252
+ }
253
+ } catch {}
254
+ };
162
255
 
163
256
  // The screen tier of the detector stack: once a session's output has
164
257
  // quiesced, judge the serialized screen and flip running/needs_you.
@@ -369,6 +462,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
369
462
 
370
463
  if (kind === 'removed') {
371
464
  attachments.removeSession(s.id);
465
+
466
+ emitInboxClosed(taps.removeSession(s.id), s.id, 'removed');
372
467
  runtimes.get(s.id)?.dispose();
373
468
  runtimes.delete(s.id);
374
469
  }
@@ -382,13 +477,33 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
382
477
  }
383
478
  };
384
479
 
480
+ const recordHookEvent = async (e: HookEvent, ev: Readonly<AdapterEvent> | null) => {
481
+ await store.recordEvent(e, ev);
482
+
483
+ eventSignal.emit();
484
+ };
485
+
385
486
  const reporter = startHookServer((e) => {
487
+ if (e.event === 'Report') {
488
+ void applyReport(e);
489
+
490
+ return;
491
+ }
492
+
493
+ const previousAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
494
+ const ev = mgr.applyHook(e);
495
+
386
496
  if (e.event !== 'Statusline') {
387
- void store.recordEvent(e);
497
+ void recordHookEvent(e, ev);
388
498
  }
389
499
 
390
- const kind = mgr.applyHook(e);
500
+ const kind = ev?.kind ?? null;
391
501
  const runtime = runtimes.get(e.atcId);
502
+ const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
503
+
504
+ if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
505
+ void store.updateMessageOwner(e.atcId, previousAgentSessionID, currentAgentSessionID);
506
+ }
392
507
 
393
508
  if (kind === 'ended') {
394
509
  runtime?.pendingEject?.();
@@ -397,6 +512,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
397
512
  // A revived session announcing itself is the cue a staggered restore
398
513
  // waits on before booting the next one.
399
514
  if (kind === 'started') {
515
+ if (runtime !== undefined) {
516
+ runtime.startedAt = Date.now();
517
+ }
518
+
400
519
  runtime?.bootWaiter?.();
401
520
  const started = mgr.sessions.find((s) => s.id === e.atcId);
402
521
 
@@ -522,6 +641,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
522
641
 
523
642
  if (runtime !== undefined) {
524
643
  runtime.dims = { cols, rows };
644
+ runtime.startedAt = null;
645
+ runtime.tapAttached = false;
525
646
 
526
647
  runtime.screen ??= new ScreenModel(cols, rows);
527
648
  }
@@ -636,6 +757,193 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
636
757
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
637
758
  restoreFleet: (cols, rows) =>
638
759
  restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
760
+ readSessionRecord: async (id) => {
761
+ const s = mgr.sessions.find((x) => x.id === id);
762
+
763
+ if (s === undefined) {
764
+ return 'missing';
765
+ }
766
+
767
+ // Every session-derived field is taken before the await: a kill or a
768
+ // hook may change the session meanwhile.
769
+ const session = getDescriptor(mgr, id);
770
+ const prompt = s.prompt ?? null;
771
+ const pending = s.state === 'needs_you' ? { message: s.lastMsg } : null;
772
+ const result = s.result ?? null;
773
+ const createdAt = s.createdAt;
774
+
775
+ const lastEventAt = await store.loadLastActivityAt(s.id, s.agentSessionID);
776
+
777
+ return {
778
+ session,
779
+ prompt,
780
+ lastActivityAt: lastEventAt ?? createdAt,
781
+ pending,
782
+ result,
783
+ };
784
+ },
785
+ loadSessionTranscript: async (id, from, limit) => {
786
+ const s = mgr.sessions.find((x) => x.id === id);
787
+
788
+ if (s === undefined) {
789
+ return 'missing';
790
+ }
791
+
792
+ const parseLine = mgr.findAdapter(s.agent)?.parseTranscriptLine;
793
+
794
+ if (parseLine === undefined) {
795
+ return 'unsupported';
796
+ }
797
+
798
+ const path = s.transcriptPath ?? '';
799
+
800
+ if (path === '') {
801
+ return { path, page: { rows: [], offset: 0, more: false } };
802
+ }
803
+
804
+ // A page stays far under the protocol's 1 MiB line cap.
805
+ const page = await loadTranscriptPage({ path, from, limit, maxBytes: 262_144, parseLine });
806
+
807
+ return { path, page };
808
+ },
809
+ readEvents: async (afterID, limit, waitMs) => {
810
+ const deadline = Date.now() + waitMs;
811
+
812
+ // A wake for an event the read leaves out (a heartbeat) loops back to
813
+ // wait out the rest of the window.
814
+ for (;;) {
815
+ const generation = eventSignal.generation;
816
+
817
+ const rows =
818
+ afterID === null
819
+ ? await store.collectLatestEvents(limit)
820
+ : await store.collectEventsAfter(afterID, limit);
821
+
822
+ const remaining = deadline - Date.now();
823
+
824
+ if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
825
+ return buildFleetEvents(rows, mgr.collectDescriptors());
826
+ }
827
+
828
+ await eventSignal.waitForNext(generation, remaining);
829
+ }
830
+ },
831
+ writeSessionMessage: async (sessionID, from, text) => {
832
+ const s = mgr.sessions.find((x) => x.id === sessionID);
833
+
834
+ if (s === undefined) {
835
+ return 'missing';
836
+ }
837
+
838
+ if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
839
+ return 'dead';
840
+ }
841
+
842
+ if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
843
+ return 'unsupported';
844
+ }
845
+
846
+ const runtime = runtimes.get(sessionID);
847
+
848
+ if (
849
+ runtime !== undefined &&
850
+ runtime.startedAt !== null &&
851
+ !runtime.tapAttached &&
852
+ Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
853
+ ) {
854
+ return 'no_tap';
855
+ }
856
+
857
+ const previousWrite = lastMessageWrite;
858
+ const written = Promise.withResolvers<void>();
859
+
860
+ lastMessageWrite = written.promise;
861
+
862
+ await previousWrite;
863
+
864
+ const record: MessageRecord = {
865
+ id: mintMessageID(),
866
+ atcID: sessionID,
867
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
868
+ from,
869
+ text,
870
+ status: 'accepted',
871
+ sentAt: Date.now(),
872
+ };
873
+
874
+ try {
875
+ await store.writeMessage(record);
876
+ } finally {
877
+ written.resolve();
878
+ }
879
+
880
+ emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
881
+
882
+ await drainInbox(sessionID);
883
+
884
+ return record;
885
+ },
886
+ attachTap: (client, sessionID) => {
887
+ const s = mgr.sessions.find((x) => x.id === sessionID);
888
+
889
+ if (s === undefined) {
890
+ return 'missing';
891
+ }
892
+
893
+ if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
894
+ return 'unsupported';
895
+ }
896
+
897
+ emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
898
+
899
+ const runtime = runtimes.get(sessionID);
900
+
901
+ if (runtime !== undefined) {
902
+ runtime.tapAttached = true;
903
+ }
904
+
905
+ void drainInbox(sessionID);
906
+
907
+ return 'ok';
908
+ },
909
+ readMessage: async (messageID) => {
910
+ const record = await store.findMessageByID(messageID);
911
+
912
+ if (record === null) {
913
+ return null;
914
+ }
915
+
916
+ const owner = mgr.sessions.find(
917
+ (x) =>
918
+ x.id === record.atcID ||
919
+ (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
920
+ );
921
+
922
+ return { session: owner?.id ?? record.atcID, record };
923
+ },
924
+ ackMessage: async (client, sessionID, messageID) => {
925
+ const s = mgr.sessions.find((x) => x.id === sessionID);
926
+
927
+ if (s === undefined || !taps.isTap(sessionID, client)) {
928
+ return 'not_tapping';
929
+ }
930
+
931
+ const owner = buildMessageOwner(s);
932
+
933
+ const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
934
+
935
+ void drainInbox(sessionID);
936
+
937
+ if (delivered !== null) {
938
+ emitEvent(buildSessionMessageEvent(sessionID, delivered), findHookScope(sessionID));
939
+
940
+ return delivered;
941
+ }
942
+
943
+ const current = await store.findMessage(messageID, owner);
944
+
945
+ return current ?? 'unknown';
946
+ },
639
947
  };
640
948
 
641
949
  try {
@@ -651,7 +959,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
651
959
  clients.add(socket.data);
652
960
  },
653
961
  data(socket, buf) {
654
- socket.data.applyChunk(buf.toString());
962
+ socket.data.applyChunk(socket.data.decodeChunk(buf));
655
963
  },
656
964
  drain(socket) {
657
965
  socket.data.drain();
@@ -659,6 +967,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
659
967
  close(socket) {
660
968
  clients.delete(socket.data);
661
969
  ctx.detachClient(socket.data);
970
+ taps.detachAll(socket.data);
662
971
  },
663
972
  error() {},
664
973
  },
@@ -675,6 +984,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
675
984
  }
676
985
 
677
986
  runtimes.clear();
987
+ eventSignal.dispose();
678
988
 
679
989
  await store.stop();
680
990
 
@@ -688,6 +998,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
688
998
  return { stop: stopDaemon };
689
999
  }
690
1000
 
1001
+ // Tells a tap its subscription is over so the `atc tap` process behind it
1002
+ // exits instead of idling on a session it no longer serves.
1003
+ function emitInboxClosed(
1004
+ tap: TapClient | null,
1005
+ sessionID: SessionID,
1006
+ reason: 'replaced' | 'removed',
1007
+ ): void {
1008
+ tap?.sendEvent({ v: PROTOCOL_V, ev: 'InboxClosed', s: sessionID, reason });
1009
+ }
1010
+
1011
+ function buildMessageOwner(s: Session): MessageOwner {
1012
+ return {
1013
+ atcID: s.id,
1014
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
1015
+ };
1016
+ }
1017
+
691
1018
  function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
692
1019
  const s = mgr.sessions.find((x) => x.id === id);
693
1020
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wakes long-polling readers when the event trail grows. A reader notes the
3
+ * signal's count before it queries and waits from that count, so an event
4
+ * recorded between the query and the wait is never missed.
5
+ */
6
+ export class EventSignal {
7
+ generation = 0;
8
+
9
+ disposed = false;
10
+
11
+ private readonly waiters = new Set<() => void>();
12
+
13
+ emit(): void {
14
+ this.generation += 1;
15
+
16
+ this.drainWaiters();
17
+ }
18
+
19
+ waitForNext(since: number, timeoutMs: number): Promise<void> {
20
+ if (this.disposed || this.generation !== since || timeoutMs <= 0) {
21
+ return Promise.resolve();
22
+ }
23
+
24
+ const deferred = Promise.withResolvers<void>();
25
+
26
+ const onWake = () => {
27
+ clearTimeout(timer);
28
+
29
+ this.waiters.delete(onWake);
30
+ deferred.resolve();
31
+ };
32
+
33
+ const timer = setTimeout(onWake, timeoutMs);
34
+
35
+ this.waiters.add(onWake);
36
+
37
+ return deferred.promise;
38
+ }
39
+
40
+ dispose(): void {
41
+ this.disposed = true;
42
+ this.generation += 1;
43
+
44
+ this.drainWaiters();
45
+ }
46
+
47
+ private drainWaiters(): void {
48
+ for (const wake of this.waiters) {
49
+ wake();
50
+ }
51
+
52
+ this.waiters.clear();
53
+ }
54
+ }
@@ -10,19 +10,28 @@ export interface HookEvent {
10
10
  payload: Record<string, unknown>;
11
11
  }
12
12
 
13
+ // Per-connection read state: the unterminated tail of the line in progress,
14
+ // and a streaming decoder so a multi-byte character split across two reads
15
+ // decodes whole.
16
+ interface HookConnection {
17
+ pending: string;
18
+ readonly decoder: TextDecoder;
19
+ }
20
+
13
21
  export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
14
22
  try {
15
23
  unlinkSync(path);
16
24
  } catch {}
17
25
 
18
- return Bun.listen<string>({
26
+ return Bun.listen<HookConnection>({
19
27
  unix: path,
20
28
  socket: {
21
29
  data(socket, buf) {
22
- const buffered = (socket.data ?? '') + buf.toString();
30
+ const conn = socket.data;
31
+ const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
23
32
  const lines = buffered.split('\n');
24
33
 
25
- socket.data = lines.pop() ?? '';
34
+ conn.pending = lines.pop() ?? '';
26
35
 
27
36
  for (const line of lines) {
28
37
  if (line.trim() === '') {
@@ -48,7 +57,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
48
57
  }
49
58
  },
50
59
  open(socket) {
51
- socket.data = '';
60
+ socket.data = { pending: '', decoder: new TextDecoder() };
52
61
  },
53
62
  error() {},
54
63
  },
@@ -0,0 +1,101 @@
1
+ import type { TranscriptRow } from '../agents/agent-adapter';
2
+
3
+ export interface TranscriptPosition {
4
+ readonly path: string;
5
+ readonly offset: number;
6
+ }
7
+
8
+ export interface TranscriptPage {
9
+ readonly rows: TranscriptRow[];
10
+ readonly offset: number;
11
+ readonly more: boolean;
12
+ }
13
+
14
+ interface TranscriptPageRequest {
15
+ readonly path: string;
16
+ readonly from: TranscriptPosition | null;
17
+ readonly limit: number;
18
+ readonly maxBytes: number;
19
+ readonly parseLine: (line: string) => TranscriptRow | null;
20
+ }
21
+
22
+ export async function loadTranscriptPage(req: TranscriptPageRequest): Promise<TranscriptPage> {
23
+ const file = Bun.file(req.path);
24
+
25
+ if (!(await file.exists())) {
26
+ return {
27
+ rows: [],
28
+ offset: req.from?.path === req.path ? req.from.offset : 0,
29
+ more: false,
30
+ };
31
+ }
32
+
33
+ const size = file.size;
34
+
35
+ // One read window; a line longer than this is skipped unparsed.
36
+ const windowBytes = 1_048_576;
37
+
38
+ // A cursor from another file, or past the end of a replaced one, starts over.
39
+ let offset =
40
+ req.from !== null && req.from.path === req.path && req.from.offset <= size
41
+ ? req.from.offset
42
+ : 0;
43
+
44
+ const rows: TranscriptRow[] = [];
45
+
46
+ const decoder = new TextDecoder();
47
+
48
+ let bytes = 0;
49
+ let skipping = false;
50
+
51
+ while (offset < size) {
52
+ const chunk = await file.slice(offset, Math.min(offset + windowBytes, size)).bytes();
53
+
54
+ let lineStart = 0;
55
+ let newline = chunk.indexOf(0x0a, lineStart);
56
+
57
+ if (newline === -1) {
58
+ // A trailing partial line is left for the next read. A short read means the
59
+ // file shrank underneath us, so it ends the scan rather than skipping a line.
60
+ if (chunk.length < Math.min(windowBytes, size - offset) || offset + chunk.length >= size) {
61
+ break;
62
+ }
63
+
64
+ offset += chunk.length;
65
+ skipping = true;
66
+ continue;
67
+ }
68
+
69
+ while (newline !== -1) {
70
+ const row = skipping
71
+ ? null
72
+ : req.parseLine(decoder.decode(chunk.subarray(lineStart, newline)));
73
+
74
+ skipping = false;
75
+
76
+ if (row !== null) {
77
+ const rowBytes = Buffer.byteLength(JSON.stringify(row));
78
+
79
+ if (rows.length > 0 && bytes + rowBytes > req.maxBytes) {
80
+ return { rows, offset: offset + lineStart, more: true };
81
+ }
82
+
83
+ rows.push(row);
84
+
85
+ bytes += rowBytes;
86
+ }
87
+
88
+ lineStart = newline + 1;
89
+
90
+ if (rows.length >= req.limit) {
91
+ return { rows, offset: offset + lineStart, more: true };
92
+ }
93
+
94
+ newline = chunk.indexOf(0x0a, lineStart);
95
+ }
96
+
97
+ offset += lineStart;
98
+ }
99
+
100
+ return { rows, offset, more: false };
101
+ }
@@ -0,0 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { MessageID } from '../shared/message-id';
3
+
4
+ /**
5
+ * A fresh atc message id: a random uuid behind an `m-` prefix. The id is a
6
+ * persisted primary key, so it is random rather than counted and stays
7
+ * unique across daemon restarts.
8
+ */
9
+ export function mintMessageID(): MessageID {
10
+ // oxlint-disable-next-line no-unsafe-type-assertion -- this is where atc mints a message id
11
+ return `m-${randomUUID()}` as MessageID;
12
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import type { MessageID } from '../shared/message-id';
3
+ import { REPORT_KINDS } from '../shared/report-kinds';
4
+ import type { ReportKind } from '../shared/report-kinds';
5
+ import { toMessageID } from '../shared/to-message-id';
6
+
7
+ export interface Report {
8
+ readonly kind: ReportKind;
9
+ readonly message: MessageID;
10
+ readonly answer: string;
11
+ }
12
+
13
+ const REPORT_SCHEMA = z.object({
14
+ kind: z.enum(REPORT_KINDS),
15
+ message: z.string().min(1),
16
+ answer: z.string(),
17
+ });
18
+
19
+ /**
20
+ * Parses a Report envelope's payload into the report its kind discriminates,
21
+ * or null when it matches no known kind.
22
+ */
23
+ export function parseReport(payload: Readonly<Record<string, unknown>>): Report | null {
24
+ const parsed = REPORT_SCHEMA.safeParse(payload);
25
+
26
+ if (!parsed.success) {
27
+ return null;
28
+ }
29
+
30
+ return {
31
+ kind: parsed.data.kind,
32
+ message: toMessageID(parsed.data.message),
33
+ answer: parsed.data.answer,
34
+ };
35
+ }