@ccmsg/cli 0.12.0 → 0.14.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 +2 -2
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +17 -17
- package/src/auth/records.ts +64 -42
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/daemon/supervise.ts +15 -7
- package/src/dispatch/dispatch.ts +4 -4
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +43 -16
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/mesh/instances.ts +5 -5
- package/src/mesh/mesh.ts +11 -11
- package/src/mesh/relay.ts +8 -8
- package/src/messaging/delivery.ts +126 -27
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +72 -18
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +73 -11
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +43 -26
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/messaging/direct.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
331
|
+
* we speak (DESIGN §6.5 condition 2). */
|
|
285
332
|
async #target(sid: Sid): Promise<HarnessTarget | undefined> {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
|
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
|
|
612
|
+
// "route (a) does not apply here" (DESIGN §6.5 conditions 2 and 3).
|
|
547
613
|
return undefined;
|
|
548
614
|
}
|
|
549
615
|
}
|
package/src/messaging/inbox.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
7
|
type InboxMessage,
|
|
8
|
+
type InboxRemovedReason,
|
|
14
9
|
type Sid,
|
|
15
10
|
type Timestamp,
|
|
16
11
|
} from "@ccmsg/protocol";
|
|
@@ -44,8 +39,24 @@ type Record_ =
|
|
|
44
39
|
export class Inbox {
|
|
45
40
|
readonly #held = new Map<Sid, InboxMessage[]>();
|
|
46
41
|
|
|
42
|
+
/** The appends already asked for, as one chain. */
|
|
43
|
+
#written: Promise<void> = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
/** Told whenever a message leaves, and why. Every way out passes through
|
|
46
|
+
* here — handed over, timed out, dropped for a newer one — so whoever states
|
|
47
|
+
* removals on the topic has one place to hear about them rather than a
|
|
48
|
+
* reading of its own per way (DESIGN §6.7). Absent until somebody asks: a
|
|
49
|
+
* replay at startup reaches conclusions about a file, with nobody yet
|
|
50
|
+
* subscribed for them to be news to. */
|
|
51
|
+
#onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
|
|
52
|
+
|
|
47
53
|
constructor(private readonly file: string) {}
|
|
48
54
|
|
|
55
|
+
/** Hear about messages leaving. */
|
|
56
|
+
onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
|
|
57
|
+
this.#onRemoved = told;
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
/** Replay the file, drop what has expired, and write back what is left.
|
|
50
61
|
*
|
|
51
62
|
* The rewrite is the only whole-file write, and it happens before anything
|
|
@@ -87,28 +98,53 @@ export class Inbox {
|
|
|
87
98
|
* Answers whether the oldest was dropped to make room, which is the whole of
|
|
88
99
|
* `inbox_full`: the message is held either way, and what the sender is told
|
|
89
100
|
* differs because something of theirs is now gone. */
|
|
90
|
-
hold(
|
|
101
|
+
async hold(
|
|
102
|
+
sid: Sid,
|
|
103
|
+
message: InboxMessage,
|
|
104
|
+
now: Timestamp = Date.now(),
|
|
105
|
+
): Promise<{ evicted: boolean }> {
|
|
91
106
|
this.#expire(now, sid);
|
|
92
107
|
const held = this.#held.get(sid) ?? [];
|
|
93
108
|
this.#held.set(sid, held);
|
|
94
109
|
held.push(message);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
// Awaited rather than left to land: what the sender is told is that the
|
|
111
|
+
// message is held, and it is not held until the line is on disk.
|
|
112
|
+
await this.#append({ v: "add", sid, message });
|
|
113
|
+
// What is over the limit is what the session holds now, not what it held
|
|
114
|
+
// when the line was written: a delivery or an expiry for this session
|
|
115
|
+
// leaves a different list in its place while the append is in flight, and
|
|
116
|
+
// deciding against the list from before would drop a message out of one
|
|
117
|
+
// nobody is holding and tell a watcher a delivered message was dropped.
|
|
118
|
+
const standing = this.#held.get(sid) ?? [];
|
|
119
|
+
if (standing.length <= INBOX_MAX_PER_SID) return { evicted: false };
|
|
120
|
+
const oldest = standing.shift();
|
|
121
|
+
if (oldest !== undefined) {
|
|
122
|
+
await this.#append({ v: "dropped", sid, mid: oldest.mid });
|
|
123
|
+
this.#onRemoved?.(oldest.mid, "dropped");
|
|
124
|
+
}
|
|
99
125
|
return { evicted: true };
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
/** Note that messages reached their session, which is what takes them out of
|
|
103
129
|
* the inbox (DESIGN §6.7). */
|
|
104
|
-
delivered(sid: Sid, mids: readonly string[]): void {
|
|
130
|
+
async delivered(sid: Sid, mids: readonly string[]): Promise<void> {
|
|
105
131
|
const held = this.#held.get(sid);
|
|
106
132
|
if (held === undefined || mids.length === 0) return;
|
|
107
133
|
const gone = new Set(mids);
|
|
108
134
|
const left = held.filter((message) => !gone.has(message.mid));
|
|
109
135
|
if (left.length === 0) this.#held.delete(sid);
|
|
110
136
|
else this.#held.set(sid, left);
|
|
111
|
-
|
|
137
|
+
const written: Promise<void>[] = [];
|
|
138
|
+
for (const mid of mids) {
|
|
139
|
+
written.push(this.#append({ v: "delivered", sid, mid }));
|
|
140
|
+
this.#onRemoved?.(mid, "delivered");
|
|
141
|
+
}
|
|
142
|
+
await Promise.all(written);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Settle once every line asked for so far is on disk. */
|
|
146
|
+
async flush(): Promise<void> {
|
|
147
|
+
await this.#written;
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
/** Every session something is waiting for. What reads it is the offer of
|
|
@@ -154,14 +190,32 @@ export class Inbox {
|
|
|
154
190
|
if (only !== undefined && sid !== only) continue;
|
|
155
191
|
const left = held.filter((message) => now - message.sent_at <= INBOX_RETENTION_MS);
|
|
156
192
|
if (left.length === held.length) continue;
|
|
193
|
+
const gone = new Set(left.map((message) => message.mid));
|
|
157
194
|
if (left.length === 0) this.#held.delete(sid);
|
|
158
195
|
else this.#held.set(sid, left);
|
|
196
|
+
for (const message of held) {
|
|
197
|
+
if (!gone.has(message.mid)) this.#onRemoved?.(message.mid, "expired");
|
|
198
|
+
}
|
|
159
199
|
}
|
|
160
200
|
}
|
|
161
201
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
202
|
+
/** One line, behind the lines asked for before it.
|
|
203
|
+
*
|
|
204
|
+
* A message arriving is an ordinary event of a running instance, so the
|
|
205
|
+
* append does not hold the instance still while it lands (DR-0015). The
|
|
206
|
+
* chain is what keeps the file in the order the verbs happened: a delivery
|
|
207
|
+
* written before the add it answers would replay as a message nobody was
|
|
208
|
+
* ever holding. */
|
|
209
|
+
#append(record: Record_): Promise<void> {
|
|
210
|
+
const written = this.#written.then(async () => {
|
|
211
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
212
|
+
await appendFile(this.file, `${JSON.stringify(record)}\n`);
|
|
213
|
+
});
|
|
214
|
+
// The chain carries the order, not the outcome: a line that could not be
|
|
215
|
+
// written is answered to whoever asked for it, and the ones behind it still
|
|
216
|
+
// go.
|
|
217
|
+
this.#written = written.catch(() => {});
|
|
218
|
+
return written;
|
|
165
219
|
}
|
|
166
220
|
|
|
167
221
|
#compact(): void {
|
package/src/messaging/notify.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
InstanceId,
|
|
3
|
+
Mid,
|
|
3
4
|
Notification,
|
|
4
5
|
NotifySendArgs,
|
|
5
6
|
NotifySendResult,
|
|
@@ -48,7 +49,10 @@ export class Notify implements UpstreamResource {
|
|
|
48
49
|
* caller otherwise, so a session notifying about itself says only the text. */
|
|
49
50
|
send = (input: HandlerInput): NotifySendResult => {
|
|
50
51
|
const args = input.args as unknown as NotifySendArgs;
|
|
51
|
-
|
|
52
|
+
// What it answers travels with it: a notification is shown while the
|
|
53
|
+
// session's own account of the same answer is still being written, and the
|
|
54
|
+
// `mid` is what tells a reader holding both that they are one thing.
|
|
55
|
+
this.#announce(args.sid ?? this.#caller(input), args.text, Date.now(), args.reply_to);
|
|
52
56
|
return {};
|
|
53
57
|
};
|
|
54
58
|
|
|
@@ -92,11 +96,12 @@ export class Notify implements UpstreamResource {
|
|
|
92
96
|
return [];
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
#announce(sid: Sid, text: string, now: Timestamp = Date.now()): Timestamp {
|
|
99
|
+
#announce(sid: Sid, text: string, now: Timestamp = Date.now(), reply_to?: Mid): Timestamp {
|
|
96
100
|
const notification: Notification = {
|
|
97
101
|
sid,
|
|
98
102
|
sid_label: this.deps.label(sid),
|
|
99
103
|
text,
|
|
104
|
+
...(reply_to === undefined ? {} : { reply_to }),
|
|
100
105
|
sent_at: now,
|
|
101
106
|
};
|
|
102
107
|
// A notification is an occurrence, so nothing folds it away and a watcher
|
package/src/sessions/dump.ts
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
DumpPreset,
|
|
5
5
|
InstanceId,
|
|
6
6
|
SessionDumpFile,
|
|
7
|
+
SessionDumpFormat,
|
|
7
8
|
SessionDumpWriteArgs,
|
|
8
9
|
SessionDumpWriteResult,
|
|
10
|
+
Timestamp,
|
|
9
11
|
} from "@ccmsg/protocol";
|
|
10
12
|
import { OpError } from "../dispatch/index.ts";
|
|
11
13
|
import {
|
|
12
14
|
bounded,
|
|
13
|
-
|
|
15
|
+
document as render,
|
|
16
|
+
type Item,
|
|
14
17
|
ledger,
|
|
15
|
-
located,
|
|
16
18
|
select,
|
|
17
19
|
selection,
|
|
18
20
|
within,
|
|
19
21
|
} from "../transcript/items/index.ts";
|
|
20
22
|
import type { TranscriptFiles } from "../transcript/index.ts";
|
|
23
|
+
import { classified } from "../transcript/scan.ts";
|
|
21
24
|
|
|
22
25
|
/** Where dumps land: one directory under this instance's own state, named
|
|
23
26
|
* after the config home it answers for like every other per-instance path
|
|
@@ -50,16 +53,19 @@ export interface DumpDeps {
|
|
|
50
53
|
* `message.user.in` is the brief its parent gave it — so one selection carries
|
|
51
54
|
* unchanged down a chain of agents, which is what makes the ledger's agent ids
|
|
52
55
|
* a way to descend rather than just a list. */
|
|
53
|
-
export function dumpWrite(
|
|
56
|
+
export async function dumpWrite(
|
|
57
|
+
args: SessionDumpWriteArgs,
|
|
58
|
+
deps: DumpDeps,
|
|
59
|
+
): Promise<SessionDumpWriteResult> {
|
|
54
60
|
bounded(args);
|
|
55
61
|
const preset = presetFor(args.preset, deps.presets);
|
|
56
|
-
const file = deps.files.locate(
|
|
62
|
+
const file = await deps.files.locate(
|
|
57
63
|
args.sid,
|
|
58
64
|
args.agent_id === undefined ? {} : { agent_id: args.agent_id },
|
|
59
65
|
);
|
|
60
66
|
let text: string;
|
|
61
67
|
try {
|
|
62
|
-
text =
|
|
68
|
+
text = await readFile(file, "utf8");
|
|
63
69
|
} catch {
|
|
64
70
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
65
71
|
}
|
|
@@ -76,11 +82,12 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
76
82
|
deps.presets,
|
|
77
83
|
);
|
|
78
84
|
const { items, entries } = select(
|
|
79
|
-
within(
|
|
85
|
+
within(await classified(text, await deps.files.subjectOf(file)), args),
|
|
80
86
|
keep,
|
|
81
87
|
);
|
|
82
88
|
const ids = ledger(items);
|
|
83
89
|
const written_at = Date.now();
|
|
90
|
+
const format = args.format ?? "items";
|
|
84
91
|
// The file repeats what it was asked for. A dump outlives the request that
|
|
85
92
|
// made it and is read by whoever was handed the path, so it has to say on
|
|
86
93
|
// its own what it is a dump of and what was left out — which is why the
|
|
@@ -93,12 +100,27 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
93
100
|
items,
|
|
94
101
|
ids,
|
|
95
102
|
};
|
|
103
|
+
const body =
|
|
104
|
+
format === "items"
|
|
105
|
+
? `${JSON.stringify(document, undefined, 2)}\n`
|
|
106
|
+
: format === "records"
|
|
107
|
+
? sourceLines(text, items)
|
|
108
|
+
: render(document, {
|
|
109
|
+
instance: deps.self,
|
|
110
|
+
...(args.since_at === undefined ? {} : { since: moment(args.since_at) }),
|
|
111
|
+
...(args.until_at === undefined ? {} : { until: moment(args.until_at) }),
|
|
112
|
+
...(args.since_uuid === undefined ? {} : { since: args.since_uuid }),
|
|
113
|
+
...(args.until_uuid === undefined ? {} : { until: args.until_uuid }),
|
|
114
|
+
});
|
|
96
115
|
const dir = join(deps.stateDir, DUMPS);
|
|
97
|
-
|
|
116
|
+
await mkdir(dir, { recursive: true });
|
|
98
117
|
const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
|
|
99
|
-
const path = join(dir, `${named}-${written_at}${
|
|
100
|
-
|
|
101
|
-
|
|
118
|
+
const path = join(dir, `${named}-${written_at}${suffix(format)}`);
|
|
119
|
+
await writeFile(path, body);
|
|
120
|
+
// What is counted is the selection, whatever the file ended up holding: that
|
|
121
|
+
// is what the caller asked for and what it reads the answer against, and a
|
|
122
|
+
// count that moved with the rendering would answer a different question each
|
|
123
|
+
// time (contract, `SessionDumpWriteResult`).
|
|
102
124
|
return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
|
|
103
125
|
}
|
|
104
126
|
|
|
@@ -116,3 +138,43 @@ function presetFor(
|
|
|
116
138
|
if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
|
|
117
139
|
return found;
|
|
118
140
|
}
|
|
141
|
+
|
|
142
|
+
/** The records the selected items were read from, as the file holds them.
|
|
143
|
+
*
|
|
144
|
+
* Taken out of the bytes by each item's own address rather than re-serialized,
|
|
145
|
+
* so what a tool reading the harness's format gets is the harness's own lines
|
|
146
|
+
* — nothing is added around them and nothing inside them is changed. Several
|
|
147
|
+
* items out of one record share that address, so the record is written once
|
|
148
|
+
* and the line count is not the item count (contract, `SessionDumpFormat`).
|
|
149
|
+
*
|
|
150
|
+
* One dump is one transcript — the session's, or one agent's when the request
|
|
151
|
+
* names one — so no line here has to say which file it came from. */
|
|
152
|
+
function sourceLines(text: string, items: readonly Item[]): string {
|
|
153
|
+
const bytes = Buffer.from(text, "utf8");
|
|
154
|
+
const seen = new Set<number>();
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
const at = item.source.offset;
|
|
158
|
+
if (seen.has(at)) continue;
|
|
159
|
+
seen.add(at);
|
|
160
|
+
lines.push(
|
|
161
|
+
bytes
|
|
162
|
+
.subarray(at, at + item.source.bytes)
|
|
163
|
+
.toString("utf8")
|
|
164
|
+
.trimEnd(),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What a file of this format is called. Whoever is handed the path opens it
|
|
171
|
+
* in whatever reads that kind of file, and a name saying `.json` for prose
|
|
172
|
+
* would send them to the wrong one. */
|
|
173
|
+
function suffix(format: SessionDumpFormat): string {
|
|
174
|
+
return format === "items" ? DUMP_SUFFIX : format === "records" ? ".jsonl" : ".md";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A bound as the heading states it. */
|
|
178
|
+
function moment(at: Timestamp): string {
|
|
179
|
+
return new Date(at).toISOString();
|
|
180
|
+
}
|