@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.
- package/package.json +1 -1
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +16 -16
- package/src/auth/records.ts +63 -41
- package/src/daemon/supervise.ts +15 -7
- 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 +40 -11
- 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/messaging/delivery.ts +28 -11
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +83 -21
- package/src/sessions/dump.ts +11 -9
- 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 +40 -23
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- 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,19 +1,15 @@
|
|
|
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
|
+
InboxMessage,
|
|
14
8
|
type InboxRemovedReason,
|
|
15
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
package/src/sessions/dump.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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,
|
|
@@ -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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/sessions/fork.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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 (
|
|
97
|
-
text =
|
|
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 =
|
|
129
|
+
const born = (await stat(file)).birthtimeMs;
|
|
123
130
|
return born > 0 ? born : undefined;
|
|
124
131
|
} catch {
|
|
125
132
|
return undefined;
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -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": (
|
|
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
|
}
|