@ccmsg/cli 0.12.0 → 0.13.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/auth.ts +1 -1
- package/src/auth/records.ts +1 -1
- package/src/cli.ts +38 -5
- package/src/daemon/snapshot.ts +3 -3
- package/src/dispatch/dispatch.ts +4 -4
- package/src/instance/instance.ts +6 -6
- 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 +103 -21
- package/src/messaging/inbox.ts +26 -2
- package/src/messaging/notify.ts +7 -2
- package/src/sessions/dump.ts +62 -2
- package/src/sessions/registry.ts +3 -3
- package/src/topics/egress.ts +2 -2
- package/src/topics/topics.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.23.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/auth.ts
CHANGED
|
@@ -488,7 +488,7 @@ export class Auth {
|
|
|
488
488
|
* Only its issuer can say, because only the issuer holds the secret that
|
|
489
489
|
* signed it — and the six digits are held beside that secret. So the digits
|
|
490
490
|
* travel there unjudged: an instance that decided them itself would let
|
|
491
|
-
* somebody spread guesses across the
|
|
491
|
+
* somebody spread guesses across the mesh without any of them counting
|
|
492
492
|
* against the URL (contract, `AuthResolveArgs`). Nothing is spent here.
|
|
493
493
|
*
|
|
494
494
|
* The claims come back from the issuer having been checked and consumed, and
|
package/src/auth/records.ts
CHANGED
|
@@ -127,7 +127,7 @@ export class AuthRecords {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
/** Write one record of this instance's own, and tell the
|
|
130
|
+
/** Write one record of this instance's own, and tell the mesh.
|
|
131
131
|
*
|
|
132
132
|
* A local write always displaces what the key holds. Last-write-wins settles
|
|
133
133
|
* a disagreement between instances; this is not one — the writer is the
|
package/src/cli.ts
CHANGED
|
@@ -434,7 +434,7 @@ const ROOT: Command = {
|
|
|
434
434
|
{
|
|
435
435
|
name: "dump",
|
|
436
436
|
summary: "セッション (か配下の worker 1 体) の transcript を型ごとの表示で書き出す",
|
|
437
|
-
usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>]",
|
|
437
|
+
usage: "ccmsg dump <sid>[/agent-<id>] [--preset <名前>] [--types <選択>] [--format <形式>]",
|
|
438
438
|
options: [
|
|
439
439
|
["--preset <名前>", "instance が持つ選択 (ccmsg dump presets で一覧)"],
|
|
440
440
|
["--types <選択>", "型をカンマ区切りで。prefix 可、-で除外、@名前で preset 展開"],
|
|
@@ -444,6 +444,10 @@ const ROOT: Command = {
|
|
|
444
444
|
],
|
|
445
445
|
["--until <at|ago|uuid>", "上限。同上"],
|
|
446
446
|
["--max-chars <n>", "1 アイテムの本文をこの文字数で切る (既定は切らない)"],
|
|
447
|
+
[
|
|
448
|
+
"--format <形式>",
|
|
449
|
+
"instance に書かせる形式: items (型付き JSON) / records (元の jsonl) / text (markdown)",
|
|
450
|
+
],
|
|
447
451
|
["--json", "markdown ではなく dump file の中身をそのまま出す"],
|
|
448
452
|
["--out <path>", "標準出力ではなくこの path に書く"],
|
|
449
453
|
],
|
|
@@ -1181,7 +1185,7 @@ function agents(args: readonly string[]): Promise<unknown> {
|
|
|
1181
1185
|
});
|
|
1182
1186
|
}
|
|
1183
1187
|
|
|
1184
|
-
/** Read the current value of a
|
|
1188
|
+
/** Read the current value of a mesh topic and answer with it.
|
|
1185
1189
|
*
|
|
1186
1190
|
* One entry per instance, carrying the topic's payload exactly as the contract
|
|
1187
1191
|
* defines it: a whole value per instance is not something to merge into one
|
|
@@ -1221,7 +1225,11 @@ async function topic(
|
|
|
1221
1225
|
*
|
|
1222
1226
|
* `--json` hands over the file as it stands, for a reader that is a program. */
|
|
1223
1227
|
async function dump(args: readonly string[]): Promise<unknown> {
|
|
1224
|
-
const parsed = options(
|
|
1228
|
+
const parsed = options(
|
|
1229
|
+
args,
|
|
1230
|
+
["preset", "types", "since", "until", "out", "max-chars", "format"],
|
|
1231
|
+
["json"],
|
|
1232
|
+
);
|
|
1225
1233
|
const subject = parsed.rest[0];
|
|
1226
1234
|
if (subject === undefined) {
|
|
1227
1235
|
throw new CommandError(
|
|
@@ -1234,6 +1242,11 @@ async function dump(args: readonly string[]): Promise<unknown> {
|
|
|
1234
1242
|
...dumpArgs(subject, parsed.named),
|
|
1235
1243
|
})) as unknown as SessionDumpWriteResult;
|
|
1236
1244
|
const body = readFileSync(written.path, "utf8");
|
|
1245
|
+
// A format the instance was asked for is a file it wrote for that purpose,
|
|
1246
|
+
// so it is handed over as it stands: rendering it again here would be this
|
|
1247
|
+
// command deciding what a caller already decided.
|
|
1248
|
+
const format = parsed.named.get("format");
|
|
1249
|
+
if (format !== undefined) return await handed(body, parsed.named.get("out"));
|
|
1237
1250
|
// What the heading states is where the cut fell, not the words it was asked
|
|
1238
1251
|
// for in: a dump read next week cannot work out what "10 minutes ago" was.
|
|
1239
1252
|
const since = spelled(resolved("since", parsed.named.get("since")));
|
|
@@ -1269,6 +1282,16 @@ async function dump(args: readonly string[]): Promise<unknown> {
|
|
|
1269
1282
|
* two apart so that a sid stays a validated sid, and the joined spelling is
|
|
1270
1283
|
* the CLI's own convenience — it is how the file the agent's records live in
|
|
1271
1284
|
* is named, which is what makes the two halves tellable apart by eye. */
|
|
1285
|
+
/** Write out what the instance wrote, wherever the caller wanted it. */
|
|
1286
|
+
async function handed(body: string, out: string | undefined): Promise<undefined> {
|
|
1287
|
+
if (out === undefined) {
|
|
1288
|
+
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
1289
|
+
return undefined;
|
|
1290
|
+
}
|
|
1291
|
+
await Bun.write(out, body);
|
|
1292
|
+
return undefined;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1272
1295
|
export function dumpArgs(
|
|
1273
1296
|
subject: string,
|
|
1274
1297
|
named: ReadonlyMap<string, string> = new Map(),
|
|
@@ -1278,8 +1301,13 @@ export function dumpArgs(
|
|
|
1278
1301
|
const agent = at === -1 ? undefined : subject.slice(at + AGENT_MARK.length);
|
|
1279
1302
|
const types = named.get("types");
|
|
1280
1303
|
const preset = named.get("preset");
|
|
1304
|
+
const format = named.get("format");
|
|
1305
|
+
if (format !== undefined && format !== "items" && format !== "records" && format !== "text") {
|
|
1306
|
+
throw new CommandError("invalid_args", "--format は items / records / text のどれかです");
|
|
1307
|
+
}
|
|
1281
1308
|
return {
|
|
1282
1309
|
sid,
|
|
1310
|
+
...(format === undefined ? {} : { format }),
|
|
1283
1311
|
...(agent === undefined || agent === "" ? {} : { agent_id: agent }),
|
|
1284
1312
|
...(preset === undefined ? {} : { preset }),
|
|
1285
1313
|
...(types === undefined
|
|
@@ -1418,16 +1446,21 @@ function reply(args: readonly string[]): Promise<unknown> {
|
|
|
1418
1446
|
* line on the page they are watching, and the machine saying it aloud. Both
|
|
1419
1447
|
* run whatever the tool itself decided to do with it. */
|
|
1420
1448
|
export async function notify(args: readonly string[], read?: Read): Promise<unknown> {
|
|
1421
|
-
const parsed = options(args, ["sid", "about"], ["hook"]);
|
|
1449
|
+
const parsed = options(args, ["sid", "about", "reply-to"], ["hook"]);
|
|
1422
1450
|
if (parsed.flags.has("hook")) return await pushed(await hookEvent(read));
|
|
1423
1451
|
const [text] = parsed.rest;
|
|
1424
1452
|
if (text === undefined) {
|
|
1425
|
-
throw new CommandError(
|
|
1453
|
+
throw new CommandError(
|
|
1454
|
+
"invalid_args",
|
|
1455
|
+
"使い方: ccmsg notify <text> [--about <sid>] [--reply-to <mid>] | --hook",
|
|
1456
|
+
);
|
|
1426
1457
|
}
|
|
1427
1458
|
const about = parsed.named.get("about");
|
|
1459
|
+
const answering = parsed.named.get("reply-to");
|
|
1428
1460
|
return await announce(parsed.named.get("sid"), {
|
|
1429
1461
|
text,
|
|
1430
1462
|
...(about === undefined ? {} : { sid: about }),
|
|
1463
|
+
...(answering === undefined ? {} : { reply_to: answering as NotifySendArgs["reply_to"] }),
|
|
1431
1464
|
});
|
|
1432
1465
|
}
|
|
1433
1466
|
|
package/src/daemon/snapshot.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { CommandError } from "./link.ts";
|
|
|
6
6
|
*
|
|
7
7
|
* The pair rather than the payload alone, because two instances state the same
|
|
8
8
|
* topic name: a payload with the name of its author taken off could not be told
|
|
9
|
-
* from the other's, and which instance said it is half of what a
|
|
9
|
+
* from the other's, and which instance said it is half of what a mesh read
|
|
10
10
|
* answers. */
|
|
11
11
|
export interface Snapshot {
|
|
12
12
|
readonly instance: InstanceId;
|
|
@@ -31,7 +31,7 @@ interface Frame {
|
|
|
31
31
|
readonly data?: unknown;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** What a greeting answered, as the two fields a
|
|
34
|
+
/** What a greeting answered, as the two fields a mesh read needs of it. */
|
|
35
35
|
interface Greeted {
|
|
36
36
|
readonly instance?: unknown;
|
|
37
37
|
readonly instances?: unknown;
|
|
@@ -92,7 +92,7 @@ export async function snapshots(
|
|
|
92
92
|
return [...held].map(([instance, data]) => ({ instance, data }));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** The instances a greeting named, as the set a read of a
|
|
95
|
+
/** The instances a greeting named, as the set a read of a mesh topic waits
|
|
96
96
|
* for.
|
|
97
97
|
*
|
|
98
98
|
* `all` is every instance the answering one can currently reach, itself
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -14,11 +14,11 @@ import { type DispatchResult, failure, OpError, reply } from "./result.ts";
|
|
|
14
14
|
/** What dispatch needs from the instance around it. */
|
|
15
15
|
export interface DispatchDeps {
|
|
16
16
|
/** This instance's own id, compared with the destination of an
|
|
17
|
-
* `
|
|
17
|
+
* `owner_instance` op to decide whether the op is ours to run. */
|
|
18
18
|
readonly self: InstanceId;
|
|
19
19
|
/** The capabilities this instance has, as `hello` reports them. */
|
|
20
20
|
readonly capabilities: ReadonlySet<Capability>;
|
|
21
|
-
/** The instance that owns the subject of an `
|
|
21
|
+
/** The instance that owns the subject of an `owner_instance` op, or
|
|
22
22
|
* `undefined` when no other instance owns it and we answer ourselves.
|
|
23
23
|
* The routing table behind this is the `peers` topic (DESIGN §7.3). */
|
|
24
24
|
readonly resolveInstance: (op: OpName, frame: Record<string, unknown>) => InstanceId | undefined;
|
|
@@ -97,7 +97,7 @@ export async function dispatch(
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// 6. an
|
|
100
|
+
// 6. an owner_instance op whose subject belongs elsewhere goes to mesh.
|
|
101
101
|
//
|
|
102
102
|
// A request that has already been here is dropped before that: a cycle in
|
|
103
103
|
// the routing would otherwise send it round the same instances until every
|
|
@@ -108,7 +108,7 @@ export async function dispatch(
|
|
|
108
108
|
if (Array.isArray(hops) && hops.includes(deps.self)) {
|
|
109
109
|
return failure(requestId, "instance_unreachable", `${op} came back to ${deps.self}`);
|
|
110
110
|
}
|
|
111
|
-
if (attrs.locality === "
|
|
111
|
+
if (attrs.locality === "owner_instance") {
|
|
112
112
|
const asked = fields["to_instance"];
|
|
113
113
|
const target = typeof asked === "string" ? asked : deps.resolveInstance(op, fields);
|
|
114
114
|
if (target !== undefined && target !== deps.self) {
|
package/src/instance/instance.ts
CHANGED
|
@@ -423,7 +423,7 @@ export class Instance {
|
|
|
423
423
|
: { terminal_gateway: config.upstream.terminal_gateway }),
|
|
424
424
|
}),
|
|
425
425
|
]);
|
|
426
|
-
// The mesh is the rest of the
|
|
426
|
+
// The mesh is the rest of the mesh as the topic mechanism sees it: what
|
|
427
427
|
// the peers have stated, and where a local subscription has to travel to
|
|
428
428
|
// (DESIGN §7.4). An instance without one has no other instance to hear from.
|
|
429
429
|
this.#topics = new Topics(this.self, this.#capabilities, this.#mesh);
|
|
@@ -561,7 +561,7 @@ export class Instance {
|
|
|
561
561
|
this.#delivery = new Delivery({
|
|
562
562
|
self: this.self,
|
|
563
563
|
sessions: this.#sessions,
|
|
564
|
-
...(this.#mesh === undefined ? {} : {
|
|
564
|
+
...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
|
|
565
565
|
inbox,
|
|
566
566
|
direct: this.#direct,
|
|
567
567
|
publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
|
|
@@ -604,7 +604,7 @@ export class Instance {
|
|
|
604
604
|
});
|
|
605
605
|
this.#topics.attach("kv", kv);
|
|
606
606
|
|
|
607
|
-
// The credentials, tokens and removals the
|
|
607
|
+
// The credentials, tokens and removals the mesh shares (DR-0001 §2.6).
|
|
608
608
|
// Written down beside the store and for the same reason: none of it is
|
|
609
609
|
// derived from anything else this instance holds (DESIGN §2.5).
|
|
610
610
|
const records = new AuthRecords({
|
|
@@ -860,7 +860,7 @@ export class Instance {
|
|
|
860
860
|
|
|
861
861
|
/** A link came up or went down. Told to every client when it changes what
|
|
862
862
|
* the instance would answer about the host link, and to nobody when the set
|
|
863
|
-
* of reachable peers moved without changing that — a five-peer
|
|
863
|
+
* of reachable peers moved without changing that — a five-peer mesh
|
|
864
864
|
* losing one is not this host going offline. */
|
|
865
865
|
#linkMoved(): void {
|
|
866
866
|
const network = this.network;
|
|
@@ -945,11 +945,11 @@ export class Instance {
|
|
|
945
945
|
: await this.#mesh.forward(decided.to, decided.frame, stated ?? callerOfIdentity(identity));
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
-
/** Which instance owns the subject of an
|
|
948
|
+
/** Which instance owns the subject of an owner_instance op.
|
|
949
949
|
*
|
|
950
950
|
* The subject is the session an op names, and an op that names none is about
|
|
951
951
|
* this instance and stays here. A session this instance holds is its own
|
|
952
|
-
* whatever the
|
|
952
|
+
* whatever the mesh last said; one it does not hold is looked for in the
|
|
953
953
|
* routing table the `peers` topic is (DESIGN §7.3). */
|
|
954
954
|
#owner(fields: Record<string, unknown>): InstanceId | undefined {
|
|
955
955
|
const sid = fields["sid"];
|
package/src/mesh/instances.ts
CHANGED
|
@@ -8,16 +8,16 @@ export interface MeshView {
|
|
|
8
8
|
instances(): InstanceInfo[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** The
|
|
11
|
+
/** The mesh as one instance sees it, itself included.
|
|
12
12
|
*
|
|
13
|
-
* An instance with no mesh is a
|
|
13
|
+
* An instance with no mesh is a mesh of one and says so: it is reached at
|
|
14
14
|
* whatever it serves, which is a row without an endpoint when that is the unix
|
|
15
15
|
* socket alone rather than no row at all (contract, `InstanceInfo`).
|
|
16
16
|
*
|
|
17
17
|
* The same answer feeds `hello` and the topic, from here rather than from two
|
|
18
18
|
* places: a greeting and a subscription that disagreed about who is in the
|
|
19
|
-
*
|
|
20
|
-
export function
|
|
19
|
+
* mesh would be one instance stating two views of itself. */
|
|
20
|
+
export function meshView(
|
|
21
21
|
self: InstanceId,
|
|
22
22
|
endpoint: Endpoint | undefined,
|
|
23
23
|
mesh: MeshView | undefined,
|
|
@@ -56,7 +56,7 @@ export class Instances implements UpstreamResource {
|
|
|
56
56
|
) {}
|
|
57
57
|
|
|
58
58
|
view(): InstanceInfo[] {
|
|
59
|
-
return
|
|
59
|
+
return meshView(this.deps.self, this.deps.endpoint, this.deps.mesh);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** State the view, which the mesh asks for whenever a link moves. A frame
|
package/src/mesh/mesh.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type SettledIdentity,
|
|
19
19
|
} from "../dispatch/index.ts";
|
|
20
20
|
import type { TopicValue } from "../topics/index.ts";
|
|
21
|
-
import { AUTH_TOPIC,
|
|
21
|
+
import { AUTH_TOPIC, isMeshTopic, Relay } from "./relay.ts";
|
|
22
22
|
import {
|
|
23
23
|
ALLOWED_ALGS,
|
|
24
24
|
EphemeralKey,
|
|
@@ -388,7 +388,7 @@ export class Mesh {
|
|
|
388
388
|
* whether it can be reached right now (DESIGN §7.5).
|
|
389
389
|
*
|
|
390
390
|
* A peer no handshake has settled yet is listed without an id. The operator
|
|
391
|
-
* configured that endpoint, so it is an entry of the
|
|
391
|
+
* configured that endpoint, so it is an entry of the mesh whether or not
|
|
392
392
|
* anything has answered there — and leaving it out would hide exactly the
|
|
393
393
|
* peer whose link is down, which is the one a reader is looking for. */
|
|
394
394
|
instances(): InstanceInfo[] {
|
|
@@ -408,7 +408,7 @@ export class Mesh {
|
|
|
408
408
|
|
|
409
409
|
/** Whether any peer is currently out of reach.
|
|
410
410
|
*
|
|
411
|
-
* What separates "no instance in the
|
|
411
|
+
* What separates "no instance in the mesh knows this session" from "an
|
|
412
412
|
* instance that might know it cannot be asked" — the one distinction DESIGN §6.6
|
|
413
413
|
* says rests on the mesh's connection state and on nothing else. */
|
|
414
414
|
anyUnreachable(): boolean {
|
|
@@ -417,7 +417,7 @@ export class Mesh {
|
|
|
417
417
|
|
|
418
418
|
/** Which instance should answer for a session this instance does not hold.
|
|
419
419
|
*
|
|
420
|
-
* A session the
|
|
420
|
+
* A session the mesh has named belongs to the instance its `peers` row
|
|
421
421
|
* states. One nobody has named while a peer is out of reach is answered with
|
|
422
422
|
* that peer: forwarding there fails and the caller is told
|
|
423
423
|
* `instance_unreachable`, which is what DESIGN §6.6 asks for in place of deciding
|
|
@@ -532,7 +532,7 @@ export class Mesh {
|
|
|
532
532
|
*
|
|
533
533
|
* The caller the envelope names, on the link it arrived over. A request that
|
|
534
534
|
* names none is dispatched as the link itself, whose role is `instance` —
|
|
535
|
-
* which the attribute table already answers, since no
|
|
535
|
+
* which the attribute table already answers, since no owner_instance op is
|
|
536
536
|
* open to an instance. */
|
|
537
537
|
caller(conn: Requester, caller: CallerIdentity | undefined): Requester {
|
|
538
538
|
const link = this.#linkOf.get(conn);
|
|
@@ -553,12 +553,12 @@ export class Mesh {
|
|
|
553
553
|
|
|
554
554
|
/** The current value of a relayed topic, one entry per instance that has
|
|
555
555
|
* stated one. Handed to a fresh local subscriber beside this instance's own
|
|
556
|
-
* snapshot, so it opens on the
|
|
556
|
+
* snapshot, so it opens on the mesh rather than on us. */
|
|
557
557
|
snapshot(topic: string): readonly TopicValue[] {
|
|
558
558
|
return this.relay.snapshot(topic);
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
/** A local subscriber appeared on a
|
|
561
|
+
/** A local subscriber appeared on a mesh topic, or the last one left.
|
|
562
562
|
*
|
|
563
563
|
* The subscription travels: what a subscriber asks of this instance, this
|
|
564
564
|
* instance asks of every peer, and the frames come back unchanged (DESIGN §7.4).
|
|
@@ -567,7 +567,7 @@ export class Mesh {
|
|
|
567
567
|
// `auth.records` is never given up and never asked for on demand: every
|
|
568
568
|
// instance holds the whole set whether or not anything local is watching
|
|
569
569
|
// it, the way `peers` is also the routing table (DESIGN §7.4, DR-0001 §2.6).
|
|
570
|
-
if (topic === AUTH_TOPIC || !
|
|
570
|
+
if (topic === AUTH_TOPIC || !isMeshTopic(topic)) return;
|
|
571
571
|
if (wanted) {
|
|
572
572
|
if (this.#demanded.has(topic)) return;
|
|
573
573
|
this.#demanded.add(topic);
|
|
@@ -593,7 +593,7 @@ export class Mesh {
|
|
|
593
593
|
topic,
|
|
594
594
|
// The instance asks on behalf of whoever subscribed to it, and what they
|
|
595
595
|
// have in common is that they are this deployment's people rather than
|
|
596
|
-
// any one session: a
|
|
596
|
+
// any one session: a mesh topic is the same value for all of them
|
|
597
597
|
// (DESIGN §6.2), so there is nothing narrower to name.
|
|
598
598
|
//
|
|
599
599
|
// `auth.records` is the exception, and the one topic no person may hear:
|
|
@@ -1090,12 +1090,12 @@ export class Mesh {
|
|
|
1090
1090
|
//
|
|
1091
1091
|
// The far end has no other way to learn this instance is going: it would
|
|
1092
1092
|
// keep the link, keep answering `reachable`, and keep routing
|
|
1093
|
-
// `
|
|
1093
|
+
// `owner_instance` ops here until its own heartbeat gave up minutes later,
|
|
1094
1094
|
// where the disconnection of DESIGN §7.5 is supposed to be immediate. Which side
|
|
1095
1095
|
// dialled a link is decided by the glare rule from a comparison of
|
|
1096
1096
|
// endpoint strings (mesh-peer-auth §8.1), so which of a peer's links this instance
|
|
1097
1097
|
// accepted is not something either end chose — leaving those open makes a
|
|
1098
|
-
// clean stop look like a silent one to whichever half of the
|
|
1098
|
+
// clean stop look like a silent one to whichever half of the mesh the
|
|
1099
1099
|
// comparison put on this side.
|
|
1100
1100
|
//
|
|
1101
1101
|
// An accepted socket is transport's to release (DESIGN §8.5 step 5), and left to
|
package/src/mesh/relay.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@ccmsg/protocol";
|
|
10
10
|
import { Elements, type TopicValue } from "../topics/index.ts";
|
|
11
11
|
|
|
12
|
-
/** The rows of sessions the whole
|
|
12
|
+
/** The rows of sessions the whole mesh is seen through.
|
|
13
13
|
*
|
|
14
14
|
* They are `element`-granular, and an element topic is relayable only when its
|
|
15
15
|
* elements say whose they are: a row here names the instance that holds the
|
|
@@ -19,13 +19,13 @@ import { Elements, type TopicValue } from "../topics/index.ts";
|
|
|
19
19
|
* such name — an `inbox` frame belongs to a session, not to an instance. */
|
|
20
20
|
const ROW_TOPICS: readonly string[] = ["peers", "agents"];
|
|
21
21
|
|
|
22
|
-
/** The topics a subscriber sees the whole
|
|
22
|
+
/** The topics a subscriber sees the whole mesh on.
|
|
23
23
|
*
|
|
24
24
|
* A per-instance whole is relayable by construction (DESIGN §6.2): a frame replaces
|
|
25
25
|
* its own instance's entries and leaves every other instance's alone, so
|
|
26
26
|
* several instances can state the same topic name without colliding. The rows
|
|
27
27
|
* above are relayable for the same reason read one element at a time. */
|
|
28
|
-
export const
|
|
28
|
+
export const MESH_TOPICS: readonly string[] = [
|
|
29
29
|
...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
|
|
30
30
|
...ROW_TOPICS,
|
|
31
31
|
];
|
|
@@ -37,8 +37,8 @@ export const CLUSTER_TOPICS: readonly string[] = [
|
|
|
37
37
|
* the set this instance holds rather than held here (DR-0001 §2.6). */
|
|
38
38
|
export const AUTH_TOPIC = "auth.records";
|
|
39
39
|
|
|
40
|
-
export function
|
|
41
|
-
return
|
|
40
|
+
export function isMeshTopic(topic: string): boolean {
|
|
41
|
+
return MESH_TOPICS.includes(topic);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Whether what a frame of this topic carries is rows to be merged rather than
|
|
@@ -64,7 +64,7 @@ export interface RelayDeps {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** What the peers said, held on this instance so that losing a peer does not
|
|
67
|
-
* empty the
|
|
67
|
+
* empty the mesh view (DESIGN §7.5).
|
|
68
68
|
*
|
|
69
69
|
* Two things live here and nowhere else: the last whole value each instance
|
|
70
70
|
* stated per topic, and whether that instance can be reached right now. The
|
|
@@ -99,7 +99,7 @@ export class Relay {
|
|
|
99
99
|
* unchanged — recomputing it would put the same judgement in two places
|
|
100
100
|
* (DESIGN §7.4). */
|
|
101
101
|
accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
|
|
102
|
-
if (!
|
|
102
|
+
if (!isMeshTopic(topic)) return;
|
|
103
103
|
this.#sweep();
|
|
104
104
|
const held = this.#held.get(instance) ?? new Map<string, unknown>();
|
|
105
105
|
this.#held.set(instance, held);
|
|
@@ -174,7 +174,7 @@ export class Relay {
|
|
|
174
174
|
return values;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
/** Which instance a session belongs to, read from the
|
|
177
|
+
/** Which instance a session belongs to, read from the mesh values the
|
|
178
178
|
* peers stated (DESIGN §7.3).
|
|
179
179
|
*
|
|
180
180
|
* `peers` names every session an instance holds, connected and lost alike,
|
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
CallerIdentity,
|
|
3
3
|
CandidateSession,
|
|
4
4
|
InboxMessage,
|
|
5
|
+
InboxRemoved,
|
|
6
|
+
InboxRemovedReason,
|
|
5
7
|
InstanceId,
|
|
6
8
|
MessageSendArgs,
|
|
7
9
|
MessageSendResult,
|
|
@@ -21,7 +23,12 @@ import {
|
|
|
21
23
|
OpError,
|
|
22
24
|
type Requester,
|
|
23
25
|
} from "../dispatch/index.ts";
|
|
24
|
-
import
|
|
26
|
+
import {
|
|
27
|
+
PEOPLE,
|
|
28
|
+
type PublishOutcome,
|
|
29
|
+
type TopicValue,
|
|
30
|
+
type UpstreamResource,
|
|
31
|
+
} from "../topics/index.ts";
|
|
25
32
|
import type { DirectRoute } from "./direct.ts";
|
|
26
33
|
import type { Inbox } from "./inbox.ts";
|
|
27
34
|
|
|
@@ -38,15 +45,15 @@ export interface SessionLookup {
|
|
|
38
45
|
peerRows(): PeerInfo[];
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
/** The rest of the
|
|
48
|
+
/** The rest of the mesh, for a message addressed outside this instance.
|
|
42
49
|
*
|
|
43
|
-
* `message.send` is
|
|
50
|
+
* `message.send` is an `any_instance` op — any instance may be asked — but a message
|
|
44
51
|
* reaches a session through the session's own connections, which are held by
|
|
45
52
|
* the instance it greeted. So the op is answered here by carrying it there
|
|
46
|
-
* (DESIGN §2.2 step 6 is about `
|
|
53
|
+
* (DESIGN §2.2 step 6 is about `owner_instance` ops; this is the same forwarding for
|
|
47
54
|
* the one op whose subject is elsewhere while its op is not). */
|
|
48
|
-
export interface
|
|
49
|
-
/** Which instance holds this session, or nothing when the
|
|
55
|
+
export interface MeshReach {
|
|
56
|
+
/** Which instance holds this session, or nothing when the mesh has not
|
|
50
57
|
* named it. */
|
|
51
58
|
ownerOf(sid: Sid): InstanceId | undefined;
|
|
52
59
|
/** Whether an instance that might hold it cannot be asked right now. */
|
|
@@ -64,14 +71,19 @@ export interface DeliveryDeps {
|
|
|
64
71
|
readonly sessions: SessionLookup;
|
|
65
72
|
/** Absent on an instance with no mesh, where every session it can name is
|
|
66
73
|
* its own. */
|
|
67
|
-
readonly
|
|
74
|
+
readonly mesh?: MeshReach;
|
|
68
75
|
readonly inbox: Inbox;
|
|
69
76
|
/** Route (a). Off until it is confirmed against a running harness, which is
|
|
70
77
|
* condition 0 of DESIGN §6.5 and is why this is handed in rather than built here. */
|
|
71
78
|
readonly direct: DirectRoute;
|
|
72
79
|
/** The one way a value reaches subscribers (DESIGN §6.1), narrowed to the session a
|
|
73
80
|
* message is for. */
|
|
74
|
-
readonly publish: (
|
|
81
|
+
readonly publish: (
|
|
82
|
+
topic: string,
|
|
83
|
+
data: unknown,
|
|
84
|
+
instance: InstanceId,
|
|
85
|
+
to: Sid | typeof PEOPLE,
|
|
86
|
+
) => PublishOutcome;
|
|
75
87
|
/** How many of that session's connections are listening on `inbox`. */
|
|
76
88
|
readonly listeners: (topic: string, to: Sid) => number;
|
|
77
89
|
}
|
|
@@ -97,6 +109,14 @@ export class Delivery implements UpstreamResource {
|
|
|
97
109
|
|
|
98
110
|
constructor(private readonly deps: DeliveryDeps) {
|
|
99
111
|
this.#counter = deps.inbox.lastCounter(`${deps.self}/`);
|
|
112
|
+
// Every way out of the inbox is stated to the watchers, and the inbox is
|
|
113
|
+
// the one that knows about all of them — handed over, timed out, dropped
|
|
114
|
+
// for a newer message. Read from there rather than published beside each
|
|
115
|
+
// call that removes something, so a way out nobody thought of here is
|
|
116
|
+
// still a removal somebody watching sees (DESIGN §6.7).
|
|
117
|
+
deps.inbox.onRemoved((mid, reason) => {
|
|
118
|
+
this.#removed([mid], reason);
|
|
119
|
+
});
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/** `message.send`. The op fails only for a sid nobody knows; every other
|
|
@@ -123,47 +143,87 @@ export class Delivery implements UpstreamResource {
|
|
|
123
143
|
if (direct === "refused") {
|
|
124
144
|
// Turned away for now, which is neither delivered nor undeliverable: it
|
|
125
145
|
// waits in the inbox and is offered again (DESIGN §6.8).
|
|
126
|
-
this
|
|
146
|
+
this.#hold(to, message);
|
|
127
147
|
return { delivered: false, reason: "throttled" };
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
if (this.deps.listeners(INBOX, to) > 0) {
|
|
131
151
|
if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
|
|
152
|
+
// Route (b) is the topic itself, so the message never waits anywhere:
|
|
153
|
+
// the watchers are told both halves at once — it arrived, and it is
|
|
154
|
+
// gone because the session has it — since a view built from frames
|
|
155
|
+
// alone would otherwise show it waiting for good.
|
|
156
|
+
this.#watchers(to, [message]);
|
|
157
|
+
this.#removed([message.mid], "delivered");
|
|
132
158
|
return { delivered: true };
|
|
133
159
|
}
|
|
134
160
|
// The session is listening but is behind on what it has already been
|
|
135
161
|
// offered, which is the same standing as route (a) turning the message
|
|
136
162
|
// away: it waits in the inbox and is offered again (DESIGN §6.8).
|
|
137
|
-
this
|
|
163
|
+
this.#hold(to, message);
|
|
138
164
|
return { delivered: false, reason: "throttled" };
|
|
139
165
|
}
|
|
140
166
|
|
|
141
|
-
const { evicted } = this
|
|
167
|
+
const { evicted } = this.#hold(to, message);
|
|
142
168
|
return this.#undelivered(to, evicted ? "inbox_full" : this.#reason(state));
|
|
143
169
|
};
|
|
144
170
|
|
|
171
|
+
/** Hold a message the session could not take, and say so to the watchers.
|
|
172
|
+
*
|
|
173
|
+
* Stated before it is held, so that a message dropped to make room for it
|
|
174
|
+
* reads in the order the two happened: a removal of something the watcher
|
|
175
|
+
* has, rather than of something it is about to be told about. */
|
|
176
|
+
#hold(to: Sid, message: InboxMessage): { evicted: boolean } {
|
|
177
|
+
this.#watchers(to, [message]);
|
|
178
|
+
return this.deps.inbox.hold(to, message);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What is waiting, as somebody looking at it from outside reads it.
|
|
182
|
+
*
|
|
183
|
+
* A person holds every session's inbox in one subscription, so the rows they
|
|
184
|
+
* are answered with name their recipient; the session's own do not, since
|
|
185
|
+
* its subscription is already the recipient (contract, `InboxMessage.to`). */
|
|
186
|
+
#watchers(to: Sid, messages: readonly InboxMessage[]): void {
|
|
187
|
+
if (messages.length === 0) return;
|
|
188
|
+
this.deps.publish(
|
|
189
|
+
INBOX,
|
|
190
|
+
messages.map((message) => ({ ...message, to })),
|
|
191
|
+
this.deps.self,
|
|
192
|
+
PEOPLE,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A message has left an inbox, which only a watcher has anything left to do
|
|
197
|
+
* with: the session it was for either has it or never will, and neither is
|
|
198
|
+
* something to tell it on the topic it receives messages on. */
|
|
199
|
+
#removed(mids: readonly Mid[], reason: InboxRemovedReason): void {
|
|
200
|
+
if (mids.length === 0) return;
|
|
201
|
+
const gone: InboxRemoved[] = mids.map((mid) => ({ mid, removed: true, reason }));
|
|
202
|
+
this.deps.publish(INBOX, gone, this.deps.self, PEOPLE);
|
|
203
|
+
}
|
|
204
|
+
|
|
145
205
|
/** A session this instance does not hold: carried to the instance that does,
|
|
146
|
-
* or named as one the
|
|
206
|
+
* or named as one the mesh cannot answer for right now.
|
|
147
207
|
*
|
|
148
|
-
* Nothing when the
|
|
208
|
+
* Nothing when the mesh has no such session anywhere and every instance
|
|
149
209
|
* could be asked — which is the only case `session_not_found` covers (DESIGN §6.6).
|
|
150
210
|
* While an instance is out of reach the sid may well be its, so the sender is
|
|
151
211
|
* told the reason rather than that the session does not exist. The message is
|
|
152
212
|
* not held here either: the inbox that would offer it again is the one on the
|
|
153
213
|
* instance that owns the session (DESIGN §6.7). */
|
|
154
214
|
async #elsewhere(to: Sid, input: HandlerInput): Promise<MessageSendResult | undefined> {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
const owner =
|
|
215
|
+
const reach = this.deps.mesh;
|
|
216
|
+
if (reach === undefined) return undefined;
|
|
217
|
+
const owner = reach.ownerOf(to);
|
|
158
218
|
if (owner === undefined || owner === this.deps.self) {
|
|
159
|
-
return
|
|
219
|
+
return reach.anyUnreachable()
|
|
160
220
|
? { delivered: false, reason: "instance_unreachable" }
|
|
161
221
|
: undefined;
|
|
162
222
|
}
|
|
163
223
|
// The sender, as the owning instance will run the op as: the identity the
|
|
164
224
|
// connection greeted with, which is the same thing `message.send` reads to
|
|
165
225
|
// decide who a message is from (DESIGN §6.5).
|
|
166
|
-
const answer = await
|
|
226
|
+
const answer = await reach.forward(owner, input.args, callerOf(input));
|
|
167
227
|
if (answer.kind === "reply") {
|
|
168
228
|
const { ok: _ok, request_id: _id, ...body } = answer.response;
|
|
169
229
|
return body as unknown as MessageSendResult;
|
|
@@ -243,11 +303,23 @@ export class Delivery implements UpstreamResource {
|
|
|
243
303
|
*
|
|
244
304
|
* A message an offer over route (a) has claimed is left out: it is on its way
|
|
245
305
|
* on the other route, and the session subscribing while that runs must not
|
|
246
|
-
* make it two messages.
|
|
306
|
+
* make it two messages.
|
|
307
|
+
*
|
|
308
|
+
* **A person's subscription is a view, not a delivery.** They are answered
|
|
309
|
+
* with everything waiting anywhere on this instance, each row naming its
|
|
310
|
+
* recipient, and the inbox is left exactly as it was — a person is not who
|
|
311
|
+
* any of it was addressed to, and a view that consumed what it looked at
|
|
312
|
+
* would deliver messages to nobody by being opened. Nothing is left out of
|
|
313
|
+
* it either: a message an offer has claimed is still waiting until that
|
|
314
|
+
* offer says otherwise, and that is what somebody watching wants to see. */
|
|
247
315
|
snapshot(topic: string, conn: Requester): readonly TopicValue[] {
|
|
248
316
|
const identity = conn.identity;
|
|
249
|
-
|
|
250
|
-
|
|
317
|
+
if (topic !== INBOX || identity.state !== "settled") return [];
|
|
318
|
+
const sid = identity.sid;
|
|
319
|
+
// A settled connection that names no session is somebody watching: the
|
|
320
|
+
// topic table lets nobody but a session and a person subscribe here, so
|
|
321
|
+
// the sid is the whole of the difference and no role is read for it (M1).
|
|
322
|
+
if (sid === undefined) return [{ instance: this.deps.self, data: this.#waiting() }];
|
|
251
323
|
const claimed = this.#claimed.get(sid);
|
|
252
324
|
const held = this.deps.inbox
|
|
253
325
|
.undelivered(sid)
|
|
@@ -259,6 +331,16 @@ export class Delivery implements UpstreamResource {
|
|
|
259
331
|
return [{ instance: this.deps.self, data: held }];
|
|
260
332
|
}
|
|
261
333
|
|
|
334
|
+
/** Everything still undelivered on this instance, oldest first within each
|
|
335
|
+
* session, each row naming who it is for. */
|
|
336
|
+
#waiting(): InboxMessage[] {
|
|
337
|
+
const rows: InboxMessage[] = [];
|
|
338
|
+
for (const sid of this.deps.inbox.sids()) {
|
|
339
|
+
for (const message of this.deps.inbox.undelivered(sid)) rows.push({ ...message, to: sid });
|
|
340
|
+
}
|
|
341
|
+
return rows;
|
|
342
|
+
}
|
|
343
|
+
|
|
262
344
|
/** The reason a message is waiting, named from the classification alone
|
|
263
345
|
* (DESIGN §6.6). `preparing` is the live session with nowhere to put it: it is there,
|
|
264
346
|
* route (a) did not carry it, and nothing of its is listening yet.
|
package/src/messaging/inbox.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
INBOX_MAX_PER_SID,
|
|
12
12
|
INBOX_RETENTION_MS,
|
|
13
13
|
type InboxMessage,
|
|
14
|
+
type InboxRemovedReason,
|
|
14
15
|
type Sid,
|
|
15
16
|
type Timestamp,
|
|
16
17
|
} from "@ccmsg/protocol";
|
|
@@ -44,8 +45,21 @@ type Record_ =
|
|
|
44
45
|
export class Inbox {
|
|
45
46
|
readonly #held = new Map<Sid, InboxMessage[]>();
|
|
46
47
|
|
|
48
|
+
/** Told whenever a message leaves, and why. Every way out passes through
|
|
49
|
+
* here — handed over, timed out, dropped for a newer one — so whoever states
|
|
50
|
+
* removals on the topic has one place to hear about them rather than a
|
|
51
|
+
* reading of its own per way (DESIGN §6.7). Absent until somebody asks: a
|
|
52
|
+
* replay at startup reaches conclusions about a file, with nobody yet
|
|
53
|
+
* subscribed for them to be news to. */
|
|
54
|
+
#onRemoved?: (mid: string, reason: InboxRemovedReason) => void;
|
|
55
|
+
|
|
47
56
|
constructor(private readonly file: string) {}
|
|
48
57
|
|
|
58
|
+
/** Hear about messages leaving. */
|
|
59
|
+
onRemoved(told: (mid: string, reason: InboxRemovedReason) => void): void {
|
|
60
|
+
this.#onRemoved = told;
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/** Replay the file, drop what has expired, and write back what is left.
|
|
50
64
|
*
|
|
51
65
|
* The rewrite is the only whole-file write, and it happens before anything
|
|
@@ -95,7 +109,10 @@ export class Inbox {
|
|
|
95
109
|
this.#append({ v: "add", sid, message });
|
|
96
110
|
if (held.length <= INBOX_MAX_PER_SID) return { evicted: false };
|
|
97
111
|
const oldest = held.shift();
|
|
98
|
-
if (oldest !== undefined)
|
|
112
|
+
if (oldest !== undefined) {
|
|
113
|
+
this.#append({ v: "dropped", sid, mid: oldest.mid });
|
|
114
|
+
this.#onRemoved?.(oldest.mid, "dropped");
|
|
115
|
+
}
|
|
99
116
|
return { evicted: true };
|
|
100
117
|
}
|
|
101
118
|
|
|
@@ -108,7 +125,10 @@ export class Inbox {
|
|
|
108
125
|
const left = held.filter((message) => !gone.has(message.mid));
|
|
109
126
|
if (left.length === 0) this.#held.delete(sid);
|
|
110
127
|
else this.#held.set(sid, left);
|
|
111
|
-
for (const mid of mids)
|
|
128
|
+
for (const mid of mids) {
|
|
129
|
+
this.#append({ v: "delivered", sid, mid });
|
|
130
|
+
this.#onRemoved?.(mid, "delivered");
|
|
131
|
+
}
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
/** Every session something is waiting for. What reads it is the offer of
|
|
@@ -154,8 +174,12 @@ export class Inbox {
|
|
|
154
174
|
if (only !== undefined && sid !== only) continue;
|
|
155
175
|
const left = held.filter((message) => now - message.sent_at <= INBOX_RETENTION_MS);
|
|
156
176
|
if (left.length === held.length) continue;
|
|
177
|
+
const gone = new Set(left.map((message) => message.mid));
|
|
157
178
|
if (left.length === 0) this.#held.delete(sid);
|
|
158
179
|
else this.#held.set(sid, left);
|
|
180
|
+
for (const message of held) {
|
|
181
|
+
if (!gone.has(message.mid)) this.#onRemoved?.(message.mid, "expired");
|
|
182
|
+
}
|
|
159
183
|
}
|
|
160
184
|
}
|
|
161
185
|
|
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
|
@@ -4,13 +4,17 @@ 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
|
classify,
|
|
16
|
+
document as render,
|
|
17
|
+
type Item,
|
|
14
18
|
ledger,
|
|
15
19
|
located,
|
|
16
20
|
select,
|
|
@@ -81,6 +85,7 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
81
85
|
);
|
|
82
86
|
const ids = ledger(items);
|
|
83
87
|
const written_at = Date.now();
|
|
88
|
+
const format = args.format ?? "items";
|
|
84
89
|
// The file repeats what it was asked for. A dump outlives the request that
|
|
85
90
|
// made it and is read by whoever was handed the path, so it has to say on
|
|
86
91
|
// its own what it is a dump of and what was left out — which is why the
|
|
@@ -93,12 +98,27 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
93
98
|
items,
|
|
94
99
|
ids,
|
|
95
100
|
};
|
|
101
|
+
const body =
|
|
102
|
+
format === "items"
|
|
103
|
+
? `${JSON.stringify(document, undefined, 2)}\n`
|
|
104
|
+
: format === "records"
|
|
105
|
+
? sourceLines(text, items)
|
|
106
|
+
: render(document, {
|
|
107
|
+
instance: deps.self,
|
|
108
|
+
...(args.since_at === undefined ? {} : { since: moment(args.since_at) }),
|
|
109
|
+
...(args.until_at === undefined ? {} : { until: moment(args.until_at) }),
|
|
110
|
+
...(args.since_uuid === undefined ? {} : { since: args.since_uuid }),
|
|
111
|
+
...(args.until_uuid === undefined ? {} : { until: args.until_uuid }),
|
|
112
|
+
});
|
|
96
113
|
const dir = join(deps.stateDir, DUMPS);
|
|
97
114
|
mkdirSync(dir, { recursive: true });
|
|
98
115
|
const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
|
|
99
|
-
const path = join(dir, `${named}-${written_at}${
|
|
100
|
-
const body = `${JSON.stringify(document, undefined, 2)}\n`;
|
|
116
|
+
const path = join(dir, `${named}-${written_at}${suffix(format)}`);
|
|
101
117
|
writeFileSync(path, body);
|
|
118
|
+
// What is counted is the selection, whatever the file ended up holding: that
|
|
119
|
+
// is what the caller asked for and what it reads the answer against, and a
|
|
120
|
+
// count that moved with the rendering would answer a different question each
|
|
121
|
+
// time (contract, `SessionDumpWriteResult`).
|
|
102
122
|
return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
|
|
103
123
|
}
|
|
104
124
|
|
|
@@ -116,3 +136,43 @@ function presetFor(
|
|
|
116
136
|
if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
|
|
117
137
|
return found;
|
|
118
138
|
}
|
|
139
|
+
|
|
140
|
+
/** The records the selected items were read from, as the file holds them.
|
|
141
|
+
*
|
|
142
|
+
* Taken out of the bytes by each item's own address rather than re-serialized,
|
|
143
|
+
* so what a tool reading the harness's format gets is the harness's own lines
|
|
144
|
+
* — nothing is added around them and nothing inside them is changed. Several
|
|
145
|
+
* items out of one record share that address, so the record is written once
|
|
146
|
+
* and the line count is not the item count (contract, `SessionDumpFormat`).
|
|
147
|
+
*
|
|
148
|
+
* One dump is one transcript — the session's, or one agent's when the request
|
|
149
|
+
* names one — so no line here has to say which file it came from. */
|
|
150
|
+
function sourceLines(text: string, items: readonly Item[]): string {
|
|
151
|
+
const bytes = Buffer.from(text, "utf8");
|
|
152
|
+
const seen = new Set<number>();
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
const at = item.source.offset;
|
|
156
|
+
if (seen.has(at)) continue;
|
|
157
|
+
seen.add(at);
|
|
158
|
+
lines.push(
|
|
159
|
+
bytes
|
|
160
|
+
.subarray(at, at + item.source.bytes)
|
|
161
|
+
.toString("utf8")
|
|
162
|
+
.trimEnd(),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What a file of this format is called. Whoever is handed the path opens it
|
|
169
|
+
* in whatever reads that kind of file, and a name saying `.json` for prose
|
|
170
|
+
* would send them to the wrong one. */
|
|
171
|
+
function suffix(format: SessionDumpFormat): string {
|
|
172
|
+
return format === "items" ? DUMP_SUFFIX : format === "records" ? ".jsonl" : ".md";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A bound as the heading states it. */
|
|
176
|
+
function moment(at: Timestamp): string {
|
|
177
|
+
return new Date(at).toISOString();
|
|
178
|
+
}
|
package/src/sessions/registry.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
23
23
|
import { within } from "../files/index.ts";
|
|
24
24
|
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { meshView } from "../mesh/instances.ts";
|
|
26
26
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
27
27
|
import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
28
28
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
@@ -315,8 +315,8 @@ export class Sessions implements UpstreamResource {
|
|
|
315
315
|
instance: this.deps.self,
|
|
316
316
|
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
317
317
|
// The same view the `instances` topic carries, worked out in one place
|
|
318
|
-
// so a greeting and a subscription cannot state two different
|
|
319
|
-
instances:
|
|
318
|
+
// so a greeting and a subscription cannot state two different meshs.
|
|
319
|
+
instances: meshView(this.deps.self, this.deps.endpoint, this.deps.mesh),
|
|
320
320
|
capabilities: [...this.deps.capabilities],
|
|
321
321
|
version: this.deps.version,
|
|
322
322
|
started_at: this.deps.startedAt,
|
package/src/topics/egress.ts
CHANGED
|
@@ -6,9 +6,9 @@ import type { Requester } from "../dispatch/index.ts";
|
|
|
6
6
|
* list cannot see a change arrive sooner than the display draws it, so frames
|
|
7
7
|
* closer together than a few display frames are spent on nothing, while a wait
|
|
8
8
|
* long enough to be read as lag starts around a quarter of a second. Towards
|
|
9
|
-
* the
|
|
9
|
+
* the mesh: a relayed frame waits once per hop, so the delay a subscriber
|
|
10
10
|
* sees is this value times the hops between it and the instance that produced
|
|
11
|
-
* the value — at 100 ms a two-hop
|
|
11
|
+
* the value — at 100 ms a two-hop mesh still answers inside the window a
|
|
12
12
|
* person reads as immediate, which a longer period would leave.
|
|
13
13
|
*
|
|
14
14
|
* It is not a poll. Nothing is looked at when the period elapses: the timer is
|
package/src/topics/topics.ts
CHANGED
|
Binary file
|