@ccmsg/cli 0.13.0 → 0.14.1

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.
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { chmodSync, unlinkSync } from "node:fs";
3
- import { readdir, readFile } from "node:fs/promises";
2
+ import { unlinkSync } from "node:fs";
3
+ import { chmod, readdir, readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { type InboxMessage, renderDirectDelivery, type Sid } from "@ccmsg/protocol";
6
6
  import { HARNESS, HARNESSES } from "../harness/index.ts";
@@ -39,7 +39,7 @@ export class DisabledDirectRoute implements DirectRoute {
39
39
 
40
40
  /** The `peerProtocol` generation this speaks. One value, because one is what
41
41
  * has been read off a running harness (2.1.263); any other generation is a
42
- * protocol nobody here has seen, which is condition 1 of DESIGN §6.5. */
42
+ * protocol nobody here has seen, which is condition 2 of DESIGN §6.5. */
43
43
  export const PEER_PROTOCOL = 1;
44
44
 
45
45
  /** How long one attempt has to reach the point where the harness holds our
@@ -52,7 +52,7 @@ export const PEER_PROTOCOL = 1;
52
52
  export const DIRECT_ACK_MS = 2_000;
53
53
 
54
54
  /** How long the status inbox is watched for word about this message before the
55
- * send is taken to have landed (DESIGN §6.5 condition 3).
55
+ * send is taken to have landed (DESIGN §6.5 condition 4).
56
56
  *
57
57
  * Provisional. What is known from the harness (2.1.263) is where the receipt
58
58
  * is raised, not how long it takes to arrive: the receiving session decides a
@@ -63,6 +63,16 @@ export const DIRECT_ACK_MS = 2_000;
63
63
  * answer. Nothing measured stands behind the number itself. */
64
64
  export const DIRECT_STATUS_MS = 250;
65
65
 
66
+ /** How long reading `sessions/` has to answer which file names this session
67
+ * (DESIGN §6.5 condition 1).
68
+ *
69
+ * The other two budgets cover what the route does after the target is found,
70
+ * and none of them covers the finding: a directory of small JSON files on the
71
+ * local disk answers in a moment, and a read that has not come back by now is
72
+ * one route (a) is better off not waiting for. Route (b) carries the message
73
+ * either way (DESIGN §6.5). */
74
+ export const DIRECT_SCAN_MS = 1_000;
75
+
66
76
  /** What the receiving session says about a message it did not simply take
67
77
  * (harness 2.1.263, `peer_message_status`).
68
78
  *
@@ -78,7 +88,7 @@ export const DIRECT_STATUS_MS = 250;
78
88
  const REFUSING = new Set(["refused", "denied", "dropped", "expired", "held"]);
79
89
 
80
90
  /** The socket this daemon offers so the receiving session can say what became
81
- * of a message (DESIGN §6.5 condition 3).
91
+ * of a message (DESIGN §6.5 condition 4).
82
92
  *
83
93
  * It lives in the directory the target's own socket is in, and not in this
84
94
  * instance's state directory, because the receiving harness vets the address it
@@ -105,7 +115,7 @@ class StatusInbox {
105
115
  * bound. Binding fails on a directory we cannot write, which costs the
106
116
  * route its status channel and nothing else: the message still goes, and
107
117
  * what the session says about it is simply not heard. */
108
- address(): string | undefined {
118
+ async address(): Promise<string | undefined> {
109
119
  if (this.#server !== undefined) return `uds:${this.#path}`;
110
120
  try {
111
121
  this.#server = Bun.listen({
@@ -125,7 +135,7 @@ class StatusInbox {
125
135
  // Same-uid by construction (A2 / A4), and stated rather than left to the
126
136
  // umask: what can be written here is what a session is told about.
127
137
  try {
128
- chmodSync(this.#path, 0o600);
138
+ await chmod(this.#path, 0o600);
129
139
  } catch {
130
140
  // The socket is bound and usable; a mode we could not set is not a
131
141
  // reason to give up the channel.
@@ -211,12 +221,14 @@ export interface SocketRouteOptions {
211
221
  readonly ackMs?: number;
212
222
  /** How long a receipt has to arrive before the message counts as taken. */
213
223
  readonly statusMs?: number;
224
+ /** How long reading `sessions/` has to name the target. */
225
+ readonly scanMs?: number;
214
226
  }
215
227
 
216
228
  /** Route (a) against the harness's messaging socket (DESIGN §6.5).
217
229
  *
218
230
  * The path is `sessions/<pid>.json` of this instance's own config home, which
219
- * is also the answer to condition 2: a key beside it that this uid can read is
231
+ * is also the answer to condition 3: a key beside it that this uid can read is
220
232
  * exactly the same-uid, same-config-home boundary the instance already stands
221
233
  * on (A2 / A4). Nothing here searches another config home, and a session this
222
234
  * instance cannot see a state file for is simply not reachable this way.
@@ -228,15 +240,30 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
228
240
  readonly #sessionsDir: string;
229
241
  readonly #ackMs: number;
230
242
  readonly #statusMs: number;
243
+ readonly #scanMs: number;
231
244
  /** One status inbox per directory sessions' sockets live in. A host has one
232
245
  * such directory in practice; the map is what keeps that from being an
233
- * assumption. */
234
- readonly #inboxes = new Map<string, StatusInbox>();
246
+ * assumption.
247
+ *
248
+ * The binding rather than the bound socket, because binding waits: two sends
249
+ * to sessions of the same directory run side by side, and a map holding only
250
+ * finished inboxes would have both of them bind one, with the loser left
251
+ * listening on a socket nothing can close. */
252
+ readonly #inboxes = new Map<string, Promise<StatusInbox | undefined>>();
253
+
254
+ /** The channels that finished binding, so letting the route go takes their
255
+ * sockets down in the same turn rather than one after it. */
256
+ readonly #bound = new Set<StatusInbox>();
257
+
258
+ /** Whether the route has been let go. A binding that finishes after that has
259
+ * nobody to close it later, so it closes itself. */
260
+ #closed = false;
235
261
 
236
262
  constructor(options: SocketRouteOptions) {
237
263
  this.#sessionsDir = join(options.configHome, "sessions");
238
264
  this.#ackMs = options.ackMs ?? DIRECT_ACK_MS;
239
265
  this.#statusMs = options.statusMs ?? DIRECT_STATUS_MS;
266
+ this.#scanMs = options.scanMs ?? DIRECT_SCAN_MS;
240
267
  }
241
268
 
242
269
  /** One send, and what the session made of it.
@@ -253,8 +280,8 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
253
280
  if (target === undefined) return "unavailable";
254
281
  const token = await this.#token(target.pid);
255
282
  if (token === undefined) return "unavailable";
256
- const inbox = this.#inbox(target.socketPath);
257
- const from = inbox?.address();
283
+ const inbox = await this.#inbox(target.socketPath);
284
+ const from = await inbox?.address();
258
285
  const watching = inbox === undefined ? undefined : inbox.status(message.mid, this.#statusMs);
259
286
  const written = await write(target.socketPath, frames(sid, token, message, from), this.#ackMs);
260
287
  if (written !== "delivered") return written;
@@ -263,35 +290,49 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
263
290
  }
264
291
 
265
292
  close(): void {
266
- for (const inbox of this.#inboxes.values()) inbox.close();
293
+ // A binding still in flight closes itself when it lands, which is what
294
+ // `#closed` is read for; what is already bound goes now, so the sockets are
295
+ // gone by the time this returns.
296
+ this.#closed = true;
297
+ for (const inbox of this.#bound) inbox.close();
298
+ this.#bound.clear();
267
299
  this.#inboxes.clear();
268
300
  }
269
301
 
270
302
  /** The receipt channel for a target, bound beside its own socket. Absent
271
303
  * when nothing could be bound there, which leaves the route working and its
272
304
  * refusals unheard. */
273
- #inbox(socketPath: string): StatusInbox | undefined {
305
+ async #inbox(socketPath: string): Promise<StatusInbox | undefined> {
274
306
  const directory = dirname(socketPath);
275
307
  const held = this.#inboxes.get(directory);
276
- if (held !== undefined) return held;
277
- const inbox = new StatusInbox(directory);
278
- if (inbox.address() === undefined) return undefined;
279
- this.#inboxes.set(directory, inbox);
308
+ if (held !== undefined) return await held;
309
+ const opening = (async (): Promise<StatusInbox | undefined> => {
310
+ const inbox = new StatusInbox(directory);
311
+ if ((await inbox.address()) === undefined) return undefined;
312
+ if (this.#closed) {
313
+ inbox.close();
314
+ return undefined;
315
+ }
316
+ this.#bound.add(inbox);
317
+ return inbox;
318
+ })();
319
+ this.#inboxes.set(directory, opening);
320
+ const inbox = await opening;
321
+ // Nothing bound is not remembered: the directory may be writable by the
322
+ // time the next message goes that way, and a failure kept here would be
323
+ // the route without its receipts for the life of the instance.
324
+ if (inbox === undefined && this.#inboxes.get(directory) === opening) {
325
+ this.#inboxes.delete(directory);
326
+ }
280
327
  return inbox;
281
328
  }
282
329
 
283
330
  /** The state file naming this session, if it names a socket of a generation
284
- * we speak (DESIGN §6.5 conditions 1). */
331
+ * we speak (DESIGN §6.5 condition 2). */
285
332
  async #target(sid: Sid): Promise<HarnessTarget | undefined> {
286
- let names: string[];
287
- try {
288
- names = await readdir(this.#sessionsDir);
289
- } catch {
290
- return undefined;
291
- }
292
- for (const name of names) {
293
- if (!/^\d+\.json$/.test(name)) continue;
294
- const row = await readJson(join(this.#sessionsDir, name));
333
+ const rows = await this.#rows(/^\d+\.json$/);
334
+ if (rows === undefined) return undefined;
335
+ for (const row of rows) {
295
336
  if (row === undefined || row["sessionId"] !== sid) continue;
296
337
  const pid = row["pid"];
297
338
  const socketPath = row["messagingSocketPath"];
@@ -303,7 +344,7 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
303
344
  return undefined;
304
345
  }
305
346
 
306
- /** The `peerToken` the harness wrote for this session (DESIGN §6.5 condition 2).
347
+ /** The `peerToken` the harness wrote for this session (DESIGN §6.5 condition 3).
307
348
  *
308
349
  * Found by the pid the key is named after rather than by rebuilding the rest
309
350
  * of the name: the digest in `<pid>.<digest>.key` is stated to be over the
@@ -311,20 +352,45 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
311
352
  * against a running harness, and a name we cannot rebuild is still a name we
312
353
  * can recognise. */
313
354
  async #token(pid: number): Promise<string | undefined> {
314
- const key = new RegExp(`^${pid}\\.[0-9a-f]+\\.key$`);
355
+ const rows = await this.#rows(new RegExp(`^${pid}\\.[0-9a-f]+\\.key$`));
356
+ if (rows === undefined) return undefined;
357
+ for (const document of rows) {
358
+ const token = document?.["peerToken"];
359
+ if (typeof token === "string" && token !== "") return token;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ /** The files of `sessions/` whose names this pattern admits, read at once and
365
+ * answered in the order the directory listed them.
366
+ *
367
+ * At once because the files are independent and what is being looked for is
368
+ * one of them: read in turn, the search costs the sum of every file and
369
+ * grows with the number of sessions on the host, and a `message.send` waits
370
+ * out the lot (DR-0015). The order is kept because it is what decides the
371
+ * answer — the first name that matches is the one the caller takes.
372
+ *
373
+ * Nothing when the directory cannot be read, and nothing when the reads have
374
+ * not finished inside `DIRECT_SCAN_MS`: both are route (a) not applying, and
375
+ * route (b) carries the message. */
376
+ async #rows(pattern: RegExp): Promise<(Record<string, unknown> | undefined)[] | undefined> {
315
377
  let names: string[];
316
378
  try {
317
379
  names = await readdir(this.#sessionsDir);
318
380
  } catch {
319
381
  return undefined;
320
382
  }
321
- for (const name of names) {
322
- if (!key.test(name)) continue;
323
- const document = await readJson(join(this.#sessionsDir, name));
324
- const token = document?.["peerToken"];
325
- if (typeof token === "string" && token !== "") return token;
383
+ const wanted = names.filter((name) => pattern.test(name));
384
+ const late = Promise.withResolvers<undefined>();
385
+ const deadline = setTimeout(() => late.resolve(undefined), this.#scanMs);
386
+ try {
387
+ return await Promise.race([
388
+ Promise.all(wanted.map((name) => readJson(join(this.#sessionsDir, name)))),
389
+ late.promise,
390
+ ]);
391
+ } finally {
392
+ clearTimeout(deadline);
326
393
  }
327
- return undefined;
328
394
  }
329
395
  }
330
396
 
@@ -477,7 +543,7 @@ function frames(sid: Sid, token: string, message: InboxMessage, from?: string):
477
543
  }
478
544
 
479
545
  /** Connect and write, and answer whether the harness holds our bytes (DESIGN §6.5
480
- * condition 3).
546
+ * condition 4).
481
547
  *
482
548
  * That is the whole of what this can decide. The connection carries nothing
483
549
  * back — a real send measured zero bytes on it — so waiting here for an answer
@@ -520,7 +586,7 @@ async function write(path: string, payload: string, ackMs: number): Promise<Dire
520
586
  });
521
587
  } catch {
522
588
  // No socket at the path, or nothing listening on it: the session ended and
523
- // took its socket with it, or never had one (DESIGN §6.5 condition 1).
589
+ // took its socket with it, or never had one (DESIGN §6.5 condition 2).
524
590
  return "unavailable";
525
591
  }
526
592
 
@@ -543,7 +609,7 @@ async function readJson(path: string): Promise<Record<string, unknown> | undefin
543
609
  return document as Record<string, unknown>;
544
610
  } catch {
545
611
  // Missing, unreadable by this uid, or half written — all of them are
546
- // "route (a) does not apply here" (DESIGN §6.5 conditions 1 and 2).
612
+ // "route (a) does not apply here" (DESIGN §6.5 conditions 2 and 3).
547
613
  return undefined;
548
614
  }
549
615
  }
@@ -1,19 +1,15 @@
1
- import {
2
- appendFileSync,
3
- mkdirSync,
4
- readFileSync,
5
- renameSync,
6
- unlinkSync,
7
- writeFileSync,
8
- } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { appendFile, mkdir } from "node:fs/promises";
9
3
  import { dirname, join } from "node:path";
10
4
  import {
11
5
  INBOX_MAX_PER_SID,
12
6
  INBOX_RETENTION_MS,
13
- type InboxMessage,
7
+ InboxMessage,
14
8
  type InboxRemovedReason,
15
- type Sid,
9
+ isValid,
10
+ Sid,
16
11
  type Timestamp,
12
+ validationErrors,
17
13
  } from "@ccmsg/protocol";
18
14
 
19
15
  export const INBOX_FILE = "inbox.jsonl";
@@ -45,6 +41,9 @@ type Record_ =
45
41
  export class Inbox {
46
42
  readonly #held = new Map<Sid, InboxMessage[]>();
47
43
 
44
+ /** The appends already asked for, as one chain. */
45
+ #written: Promise<void> = Promise.resolve();
46
+
48
47
  /** Told whenever a message leaves, and why. Every way out passes through
49
48
  * here — handed over, timed out, dropped for a newer one — so whoever states
50
49
  * removals on the topic has one place to hear about them rather than a
@@ -53,7 +52,12 @@ export class Inbox {
53
52
  * subscribed for them to be news to. */
54
53
  #onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
55
54
 
56
- constructor(private readonly file: string) {}
55
+ constructor(
56
+ private readonly file: string,
57
+ /** Where a line the replay could not keep is named. Absent in the tests
58
+ * that are about the holding rather than about what is said of it. */
59
+ private readonly log: (message: string, fields: Record<string, unknown>) => void = () => {},
60
+ ) {}
57
61
 
58
62
  /** Hear about messages leaving. */
59
63
  onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
@@ -82,6 +86,7 @@ export class Inbox {
82
86
  // The last line of a file the daemon was killed while writing.
83
87
  continue;
84
88
  }
89
+ if (record.v === "add" && !this.#stateable(record.sid, record.message)) continue;
85
90
  this.#replay(record);
86
91
  }
87
92
  this.#expire(now);
@@ -101,16 +106,28 @@ export class Inbox {
101
106
  * Answers whether the oldest was dropped to make room, which is the whole of
102
107
  * `inbox_full`: the message is held either way, and what the sender is told
103
108
  * differs because something of theirs is now gone. */
104
- hold(sid: Sid, message: InboxMessage, now: Timestamp = Date.now()): { evicted: boolean } {
109
+ async hold(
110
+ sid: Sid,
111
+ message: InboxMessage,
112
+ now: Timestamp = Date.now(),
113
+ ): Promise<{ evicted: boolean }> {
105
114
  this.#expire(now, sid);
106
115
  const held = this.#held.get(sid) ?? [];
107
116
  this.#held.set(sid, held);
108
117
  held.push(message);
109
- this.#append({ v: "add", sid, message });
110
- if (held.length <= INBOX_MAX_PER_SID) return { evicted: false };
111
- const oldest = held.shift();
118
+ // Awaited rather than left to land: what the sender is told is that the
119
+ // message is held, and it is not held until the line is on disk.
120
+ await this.#append({ v: "add", sid, message });
121
+ // What is over the limit is what the session holds now, not what it held
122
+ // when the line was written: a delivery or an expiry for this session
123
+ // leaves a different list in its place while the append is in flight, and
124
+ // deciding against the list from before would drop a message out of one
125
+ // nobody is holding and tell a watcher a delivered message was dropped.
126
+ const standing = this.#held.get(sid) ?? [];
127
+ if (standing.length <= INBOX_MAX_PER_SID) return { evicted: false };
128
+ const oldest = standing.shift();
112
129
  if (oldest !== undefined) {
113
- this.#append({ v: "dropped", sid, mid: oldest.mid });
130
+ await this.#append({ v: "dropped", sid, mid: oldest.mid });
114
131
  this.#onRemoved?.(oldest.mid, "dropped");
115
132
  }
116
133
  return { evicted: true };
@@ -118,17 +135,24 @@ export class Inbox {
118
135
 
119
136
  /** Note that messages reached their session, which is what takes them out of
120
137
  * the inbox (DESIGN §6.7). */
121
- delivered(sid: Sid, mids: readonly string[]): void {
138
+ async delivered(sid: Sid, mids: readonly string[]): Promise<void> {
122
139
  const held = this.#held.get(sid);
123
140
  if (held === undefined || mids.length === 0) return;
124
141
  const gone = new Set(mids);
125
142
  const left = held.filter((message) => !gone.has(message.mid));
126
143
  if (left.length === 0) this.#held.delete(sid);
127
144
  else this.#held.set(sid, left);
145
+ const written: Promise<void>[] = [];
128
146
  for (const mid of mids) {
129
- this.#append({ v: "delivered", sid, mid });
147
+ written.push(this.#append({ v: "delivered", sid, mid }));
130
148
  this.#onRemoved?.(mid, "delivered");
131
149
  }
150
+ await Promise.all(written);
151
+ }
152
+
153
+ /** Settle once every line asked for so far is on disk. */
154
+ async flush(): Promise<void> {
155
+ await this.#written;
132
156
  }
133
157
 
134
158
  /** Every session something is waiting for. What reads it is the offer of
@@ -152,6 +176,30 @@ export class Inbox {
152
176
  return highest;
153
177
  }
154
178
 
179
+ /** Whether a line read back is a message the contract can state.
180
+ *
181
+ * The file outlives the contract that wrote it, and what is held is answered
182
+ * to a person as rows of the `inbox` topic — so a single line the contract
183
+ * has since outgrown, replayed as if it were current, is a frame the reader
184
+ * refuses and a whole view lost for it. An instance states only what the
185
+ * contract can say, about its own file as much as about anything else.
186
+ *
187
+ * Dropped rather than mended: the message is the sender's words and the
188
+ * contract is what says how they are spelled, so there is nothing here that
189
+ * could write a spelling the contract would accept without inventing it. It
190
+ * leaves through the compaction that follows the replay, which writes back
191
+ * only what is held; nothing is appended and no removal is stated, since a
192
+ * replay has nobody subscribed to hear one and no `mid` the contract would
193
+ * take to name it by. */
194
+ #stateable(sid: unknown, message: unknown): boolean {
195
+ const why = isValid(Sid, sid)
196
+ ? validationErrors(InboxMessage, message)
197
+ : [`sid: ${JSON.stringify(sid)} is no session id`];
198
+ if (why.length === 0) return true;
199
+ this.log("dropped an inbox record the contract cannot state", { sid, why });
200
+ return false;
201
+ }
202
+
155
203
  #replay(record: Record_): void {
156
204
  if (record.v === "add") {
157
205
  const held = this.#held.get(record.sid) ?? [];
@@ -183,9 +231,23 @@ export class Inbox {
183
231
  }
184
232
  }
185
233
 
186
- #append(record: Record_): void {
187
- mkdirSync(dirname(this.file), { recursive: true });
188
- appendFileSync(this.file, `${JSON.stringify(record)}\n`);
234
+ /** One line, behind the lines asked for before it.
235
+ *
236
+ * A message arriving is an ordinary event of a running instance, so the
237
+ * append does not hold the instance still while it lands (DR-0015). The
238
+ * chain is what keeps the file in the order the verbs happened: a delivery
239
+ * written before the add it answers would replay as a message nobody was
240
+ * ever holding. */
241
+ #append(record: Record_): Promise<void> {
242
+ const written = this.#written.then(async () => {
243
+ await mkdir(dirname(this.file), { recursive: true });
244
+ await appendFile(this.file, `${JSON.stringify(record)}\n`);
245
+ });
246
+ // The chain carries the order, not the outcome: a line that could not be
247
+ // written is answered to whoever asked for it, and the ones behind it still
248
+ // go.
249
+ this.#written = written.catch(() => {});
250
+ return written;
189
251
  }
190
252
 
191
253
  #compact(): void {
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type {
4
4
  DumpPreset,
@@ -12,16 +12,15 @@ import type {
12
12
  import { OpError } from "../dispatch/index.ts";
13
13
  import {
14
14
  bounded,
15
- classify,
16
15
  document as render,
17
16
  type Item,
18
17
  ledger,
19
- located,
20
18
  select,
21
19
  selection,
22
20
  within,
23
21
  } from "../transcript/items/index.ts";
24
22
  import type { TranscriptFiles } from "../transcript/index.ts";
23
+ import { classified } from "../transcript/scan.ts";
25
24
 
26
25
  /** Where dumps land: one directory under this instance's own state, named
27
26
  * after the config home it answers for like every other per-instance path
@@ -54,16 +53,19 @@ export interface DumpDeps {
54
53
  * `message.user.in` is the brief its parent gave it — so one selection carries
55
54
  * unchanged down a chain of agents, which is what makes the ledger's agent ids
56
55
  * a way to descend rather than just a list. */
57
- export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
56
+ export async function dumpWrite(
57
+ args: SessionDumpWriteArgs,
58
+ deps: DumpDeps,
59
+ ): Promise<SessionDumpWriteResult> {
58
60
  bounded(args);
59
61
  const preset = presetFor(args.preset, deps.presets);
60
- const file = deps.files.locate(
62
+ const file = await deps.files.locate(
61
63
  args.sid,
62
64
  args.agent_id === undefined ? {} : { agent_id: args.agent_id },
63
65
  );
64
66
  let text: string;
65
67
  try {
66
- text = readFileSync(file, "utf8");
68
+ text = await readFile(file, "utf8");
67
69
  } catch {
68
70
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
71
  }
@@ -80,7 +82,7 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
80
82
  deps.presets,
81
83
  );
82
84
  const { items, entries } = select(
83
- within(classify(located(text), deps.files.subjectOf(file)), args),
85
+ within(await classified(text, await deps.files.subjectOf(file)), args),
84
86
  keep,
85
87
  );
86
88
  const ids = ledger(items);
@@ -111,10 +113,10 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
111
113
  ...(args.until_uuid === undefined ? {} : { until: args.until_uuid }),
112
114
  });
113
115
  const dir = join(deps.stateDir, DUMPS);
114
- mkdirSync(dir, { recursive: true });
116
+ await mkdir(dir, { recursive: true });
115
117
  const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
116
118
  const path = join(dir, `${named}-${written_at}${suffix(format)}`);
117
- writeFileSync(path, body);
119
+ await writeFile(path, body);
118
120
  // What is counted is the selection, whatever the file ended up holding: that
119
121
  // is what the caller asked for and what it reads the answer against, and a
120
122
  // count that moved with the rendering would answer a different question each
@@ -1,7 +1,8 @@
1
- import { readFileSync, statSync } from "node:fs";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import type { ForkOrigin, Sid } from "@ccmsg/protocol";
4
4
  import { readRecord, type TranscriptFiles } from "../transcript/index.ts";
5
+ import { breathe, due } from "../transcript/scan.ts";
5
6
 
6
7
  /** How large a transcript may be and still be swept.
7
8
  *
@@ -36,18 +37,21 @@ const SWEEP_MAX_BYTES = 64 * 1024 * 1024;
36
37
  * Absent covers a session that is no fork, one whose ancestor file is gone,
37
38
  * and that undecidable pair. Nothing left on disk tells them apart, and none
38
39
  * of them has a seam to place. */
39
- export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undefined {
40
+ export async function forkOrigin(
41
+ sid: Sid,
42
+ files: TranscriptFiles,
43
+ ): Promise<ForkOrigin | undefined> {
40
44
  const file = files.session(sid);
41
- const ours = recordIds(file);
45
+ const ours = await recordIds(file);
42
46
  const head = ours?.[0];
43
47
  if (ours === undefined || head === undefined) return undefined;
44
48
  const mine = new Set(ours);
45
49
 
46
50
  const dir = dirname(file);
47
51
  let best: { sid: Sid; copied: number } | undefined;
48
- for (const candidate of files.all()) {
52
+ for (const candidate of await files.all()) {
49
53
  if (candidate.file === file || dirname(candidate.file) !== dir) continue;
50
- const theirs = recordIds(candidate.file);
54
+ const theirs = await recordIds(candidate.file);
51
55
  if (theirs === undefined || theirs[0] !== head) continue;
52
56
  const copied = run(ours, new Set(theirs));
53
57
  const back = run(theirs, mine);
@@ -55,7 +59,7 @@ export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undef
55
59
  // than their copy; an equal one says the records cannot tell, and creation
56
60
  // order is what is left.
57
61
  if (copied === 0 || back > copied) continue;
58
- if (back === copied && !older(candidate.file, file)) continue;
62
+ if (back === copied && !(await older(candidate.file, file))) continue;
59
63
  // Sibling forks of one ancestor share a prefix too, so several files can
60
64
  // match; the longest run is the nearest ancestor and the true seam.
61
65
  if (best === undefined || copied > best.copied) best = { sid: candidate.sid, copied };
@@ -81,28 +85,31 @@ function run(ids: readonly string[], other: ReadonlySet<string>): number {
81
85
  * A creation time of zero is a filesystem that does not record one, which is
82
86
  * not an ancient file: two of those are simply not ordered, and the pair they
83
87
  * belong to gets no answer. */
84
- function older(candidate: string, file: string): boolean {
85
- const theirs = bornAt(candidate);
86
- const ours = bornAt(file);
88
+ async function older(candidate: string, file: string): Promise<boolean> {
89
+ const theirs = await bornAt(candidate);
90
+ const ours = await bornAt(file);
87
91
  if (theirs === undefined || ours === undefined) return false;
88
92
  return theirs < ours;
89
93
  }
90
94
 
91
95
  /** Every record id in a file, in order. Undefined for a file too large to
92
96
  * sweep or one that could not be read. */
93
- function recordIds(file: string): string[] | undefined {
97
+ async function recordIds(file: string): Promise<string[] | undefined> {
94
98
  let text: string;
95
99
  try {
96
- if (statSync(file).size > SWEEP_MAX_BYTES) return undefined;
97
- text = readFileSync(file, "utf8");
100
+ if ((await stat(file)).size > SWEEP_MAX_BYTES) return undefined;
101
+ text = await readFile(file, "utf8");
98
102
  } catch {
99
103
  return undefined;
100
104
  }
101
105
  const ids: string[] = [];
106
+ let read = 0;
102
107
  for (const line of text.split("\n")) {
103
108
  // Most of a transcript's bytes sit in a handful of very large records, and
104
109
  // parsing one to learn it carries no id is the cost this avoids.
105
110
  if (line === "" || !line.includes('"uuid"')) continue;
111
+ read += 1;
112
+ if (due(read)) await breathe();
106
113
  const uuid = readRecord(line)?.uuid;
107
114
  if (uuid !== undefined) ids.push(uuid);
108
115
  }
@@ -117,9 +124,9 @@ function recordIds(file: string): string[] | undefined {
117
124
  * millisecond the contract states instants in either — two transcripts written
118
125
  * moments apart share one, and the whole use of this value is telling which
119
126
  * came first. */
120
- function bornAt(file: string): number | undefined {
127
+ async function bornAt(file: string): Promise<number | undefined> {
121
128
  try {
122
- const born = statSync(file).birthtimeMs;
129
+ const born = (await stat(file)).birthtimeMs;
123
130
  return born > 0 ? born : undefined;
124
131
  } catch {
125
132
  return undefined;
@@ -122,9 +122,11 @@ export function sessionHandlers(deps: SessionOpsDeps) {
122
122
  * cycle, happen where the config is read. */
123
123
  "dump.presets.read": (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
124
124
 
125
- "session.fork.origin.read": (input: HandlerInput): SessionForkOriginReadResult => {
125
+ "session.fork.origin.read": async (
126
+ input: HandlerInput,
127
+ ): Promise<SessionForkOriginReadResult> => {
126
128
  const args = input.args as unknown as SessionForkOriginReadArgs;
127
- const origin = forkOrigin(args.sid, deps.files);
129
+ const origin = await forkOrigin(args.sid, deps.files);
128
130
  return origin === undefined ? {} : { origin };
129
131
  },
130
132
 
@@ -135,7 +137,7 @@ export function sessionHandlers(deps: SessionOpsDeps) {
135
137
  return { removed: deps.forget(args.sid) };
136
138
  },
137
139
 
138
- "transcript.read": (input: HandlerInput): TranscriptReadResult => {
140
+ "transcript.read": async (input: HandlerInput): Promise<TranscriptReadResult> => {
139
141
  const args = input.args as unknown as TranscriptReadArgs;
140
142
  if (!sees(args.sid, viewer(input))) {
141
143
  // The role sets the visible range, not the permission (DESIGN §2.2): outside
@@ -144,8 +146,8 @@ export function sessionHandlers(deps: SessionOpsDeps) {
144
146
  // the caller was not entitled to ask.
145
147
  throw new OpError("not_found", `no transcript is known for ${args.sid}`);
146
148
  }
147
- const file = deps.files.locate(args.sid, args);
148
- return readSlice(args.sid, file, args.before, args.max_bytes);
149
+ const file = await deps.files.locate(args.sid, args);
150
+ return await readSlice(args.sid, file, args.before, args.max_bytes);
149
151
  },
150
152
 
151
153
  /** The same transcript, as the items it was read into.
@@ -153,12 +155,12 @@ export function sessionHandlers(deps: SessionOpsDeps) {
153
155
  * The role narrows it the way it narrows the raw read: what a role may see
154
156
  * is one rule whatever is being read, and a caller that cannot see a
155
157
  * session cannot see it in either vocabulary. */
156
- "transcript.items.read": (input: HandlerInput): TranscriptItemsReadResult => {
158
+ "transcript.items.read": async (input: HandlerInput): Promise<TranscriptItemsReadResult> => {
157
159
  const args = input.args as unknown as TranscriptItemsReadArgs;
158
160
  if (!sees(args.sid, viewer(input))) {
159
161
  throw new OpError("not_found", `no transcript is known for ${args.sid}`);
160
162
  }
161
- return itemsRead(args, { files: deps.files, presets: deps.presets });
163
+ return await itemsRead(args, { files: deps.files, presets: deps.presets });
162
164
  },
163
165
  };
164
166
  }