@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/instance/instance.ts
CHANGED
|
@@ -363,6 +363,11 @@ export class Instance {
|
|
|
363
363
|
readonly #direct: DirectRoute;
|
|
364
364
|
readonly #notify: Notify;
|
|
365
365
|
readonly #translate: Translate | undefined;
|
|
366
|
+
/** Everything that writes to the state directory as it changes rather than
|
|
367
|
+
* at exit (DESIGN §2.5). A stop waits for what they have asked for before it
|
|
368
|
+
* lets go of the config home, so a successor reads what this instance last
|
|
369
|
+
* said (DESIGN §8.5 step 4). */
|
|
370
|
+
readonly #persisted: { flush(): Promise<void> }[] = [];
|
|
366
371
|
/** The person's authentication: who may open a connection, and the records
|
|
367
372
|
* that say so (DR-0001). */
|
|
368
373
|
readonly #auth: Auth;
|
|
@@ -423,7 +428,7 @@ export class Instance {
|
|
|
423
428
|
: { terminal_gateway: config.upstream.terminal_gateway }),
|
|
424
429
|
}),
|
|
425
430
|
]);
|
|
426
|
-
// The mesh is the rest of the
|
|
431
|
+
// The mesh is the rest of the mesh as the topic mechanism sees it: what
|
|
427
432
|
// the peers have stated, and where a local subscription has to travel to
|
|
428
433
|
// (DESIGN §7.4). An instance without one has no other instance to hear from.
|
|
429
434
|
this.#topics = new Topics(this.self, this.#capabilities, this.#mesh);
|
|
@@ -438,7 +443,19 @@ export class Instance {
|
|
|
438
443
|
// what this instance writes travels as its own (DR-0001 §2.6).
|
|
439
444
|
element: (_topic, _instance, data) => {
|
|
440
445
|
const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
|
|
441
|
-
|
|
446
|
+
// Folded in as it arrives: the element callback is not what the peer
|
|
447
|
+
// waits on, and the set is written down before the removals in it are
|
|
448
|
+
// acted on (DR-0015). A set that could not be written is said here and
|
|
449
|
+
// nowhere else — there is no caller to answer, and the records come
|
|
450
|
+
// back from the peers that also hold them.
|
|
451
|
+
if (Array.isArray(stated)) {
|
|
452
|
+
void this.#auth.merge(stated).catch((cause: unknown) => {
|
|
453
|
+
this.log.write("auth records merge failed", {
|
|
454
|
+
instance: this.self,
|
|
455
|
+
cause: String(cause),
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
442
459
|
},
|
|
443
460
|
// The mesh view is this instance's own, so the topic that carries it is
|
|
444
461
|
// restated when that view moves (DESIGN §7.5).
|
|
@@ -549,6 +566,7 @@ export class Instance {
|
|
|
549
566
|
|
|
550
567
|
const inbox = new Inbox(inboxPath(paths.stateDir));
|
|
551
568
|
inbox.load();
|
|
569
|
+
this.#persisted.push(inbox);
|
|
552
570
|
// Route (a) is the harness's own way in (DESIGN §6.5): Claude Code's messaging
|
|
553
571
|
// socket, Codex's thread queue. Which one an instance speaks follows the
|
|
554
572
|
// config home it answers for (DESIGN §4.1), and the flag turns the route off for
|
|
@@ -561,7 +579,7 @@ export class Instance {
|
|
|
561
579
|
this.#delivery = new Delivery({
|
|
562
580
|
self: this.self,
|
|
563
581
|
sessions: this.#sessions,
|
|
564
|
-
...(this.#mesh === undefined ? {} : {
|
|
582
|
+
...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
|
|
565
583
|
inbox,
|
|
566
584
|
direct: this.#direct,
|
|
567
585
|
publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
|
|
@@ -602,9 +620,10 @@ export class Instance {
|
|
|
602
620
|
const kv = new KvStore(join(paths.stateDir, KV_DIR), this.self, (topic, data) => {
|
|
603
621
|
this.#topics.publish(topic, data);
|
|
604
622
|
});
|
|
623
|
+
this.#persisted.push(kv);
|
|
605
624
|
this.#topics.attach("kv", kv);
|
|
606
625
|
|
|
607
|
-
// The credentials, tokens and removals the
|
|
626
|
+
// The credentials, tokens and removals the mesh shares (DR-0001 §2.6).
|
|
608
627
|
// Written down beside the store and for the same reason: none of it is
|
|
609
628
|
// derived from anything else this instance holds (DESIGN §2.5).
|
|
610
629
|
const records = new AuthRecords({
|
|
@@ -615,6 +634,7 @@ export class Instance {
|
|
|
615
634
|
this.#topics.publish("auth.records", { records: written });
|
|
616
635
|
},
|
|
617
636
|
});
|
|
637
|
+
this.#persisted.push(records);
|
|
618
638
|
this.#auth = new Auth({
|
|
619
639
|
self: this.self,
|
|
620
640
|
records,
|
|
@@ -714,11 +734,9 @@ export class Instance {
|
|
|
714
734
|
handle: (frame, conn) => {
|
|
715
735
|
const admin = adminRequestOf(frame);
|
|
716
736
|
if (admin !== undefined) {
|
|
717
|
-
return
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
admin,
|
|
721
|
-
),
|
|
737
|
+
return handleAdmin(
|
|
738
|
+
{ auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
|
|
739
|
+
admin,
|
|
722
740
|
);
|
|
723
741
|
}
|
|
724
742
|
return this.handle(frame, conn);
|
|
@@ -860,7 +878,7 @@ export class Instance {
|
|
|
860
878
|
|
|
861
879
|
/** A link came up or went down. Told to every client when it changes what
|
|
862
880
|
* 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
|
|
881
|
+
* of reachable peers moved without changing that — a five-peer mesh
|
|
864
882
|
* losing one is not this host going offline. */
|
|
865
883
|
#linkMoved(): void {
|
|
866
884
|
const network = this.network;
|
|
@@ -945,11 +963,11 @@ export class Instance {
|
|
|
945
963
|
: await this.#mesh.forward(decided.to, decided.frame, stated ?? callerOfIdentity(identity));
|
|
946
964
|
}
|
|
947
965
|
|
|
948
|
-
/** Which instance owns the subject of an
|
|
966
|
+
/** Which instance owns the subject of an owner_instance op.
|
|
949
967
|
*
|
|
950
968
|
* The subject is the session an op names, and an op that names none is about
|
|
951
969
|
* this instance and stays here. A session this instance holds is its own
|
|
952
|
-
* whatever the
|
|
970
|
+
* whatever the mesh last said; one it does not hold is looked for in the
|
|
953
971
|
* routing table the `peers` topic is (DESIGN §7.3). */
|
|
954
972
|
#owner(fields: Record<string, unknown>): InstanceId | undefined {
|
|
955
973
|
const sid = fields["sid"];
|
|
@@ -997,10 +1015,16 @@ export class Instance {
|
|
|
997
1015
|
// 3. tell the connections, while they can still be told
|
|
998
1016
|
const restarting: RestartingEvent = { ev: "restarting", instance: this.self };
|
|
999
1017
|
for (const conn of this.#conns) conn.send(restarting);
|
|
1000
|
-
// 4. settle what is persisted. `last_live
|
|
1001
|
-
// they change rather than at exit, so
|
|
1002
|
-
//
|
|
1018
|
+
// 4. settle what is persisted. `last_live`, the inbox, the store and the
|
|
1019
|
+
// records are written as they change rather than at exit (DESIGN §2.5), so
|
|
1020
|
+
// what is held back is only what has been asked for and has not landed —
|
|
1021
|
+
// and it has to land before the lock goes, since a successor reads these
|
|
1022
|
+
// files as it starts.
|
|
1003
1023
|
this.log.write("stopping", { instance: this.self });
|
|
1024
|
+
await Promise.allSettled([
|
|
1025
|
+
this.#sessions.flush(),
|
|
1026
|
+
...this.#persisted.map((held) => held.flush()),
|
|
1027
|
+
]);
|
|
1004
1028
|
// 5. let the resources go, the unix socket last. Closing takes the path
|
|
1005
1029
|
// this process bound, and only that one: the stable address is a symlink
|
|
1006
1030
|
// nothing here touches, because a successor may have already pointed it at
|
|
@@ -1016,8 +1040,11 @@ export class Instance {
|
|
|
1016
1040
|
} finally {
|
|
1017
1041
|
// The pid and lock are the observable proof that this process is still
|
|
1018
1042
|
// leaving. Released only after every listener has finished closing, so a
|
|
1019
|
-
// client cannot mistake an unreachable socket for a completed stop
|
|
1043
|
+
// client cannot mistake an unreachable socket for a completed stop — and
|
|
1044
|
+
// after the log's own last lines, this one included, are on disk: what a
|
|
1045
|
+
// reader wants from the log of a stopped instance is how it ended.
|
|
1020
1046
|
remove(this.paths.pidFile);
|
|
1047
|
+
await this.log.flush();
|
|
1021
1048
|
this.lock.release();
|
|
1022
1049
|
}
|
|
1023
1050
|
}
|
package/src/instance/log.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
|
|
4
|
-
/** The instance's log: one writer, and
|
|
5
|
-
*
|
|
5
|
+
/** The instance's log: one writer, and the file in the order the calls were
|
|
6
|
+
* made (DESIGN §2.5).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* line
|
|
8
|
+
* A line is written where anything at all is happening — a lifecycle step, a
|
|
9
|
+
* mesh callback, a session appearing — so the write cannot be the one thing
|
|
10
|
+
* that stops the instance while it lands (DR-0015). Each append is chained onto
|
|
11
|
+
* the one before it, which is what keeps the order the calls stated; what a
|
|
12
|
+
* kill can cost is the last line or two that had not reached the file yet, and
|
|
13
|
+
* for a log that is a cheaper loss than holding the process still for every
|
|
14
|
+
* line.
|
|
15
|
+
*
|
|
16
|
+
* `flush` is for the one moment that loss is avoidable: a stop that is being
|
|
17
|
+
* waited on can wait for its own last line. */
|
|
12
18
|
export class Log {
|
|
19
|
+
/** The appends already asked for, as one chain. */
|
|
20
|
+
#written: Promise<void> = Promise.resolve();
|
|
21
|
+
|
|
13
22
|
constructor(
|
|
14
23
|
private readonly file: string,
|
|
15
24
|
/** Mirrored to stderr so a foreground run shows what it is doing. */
|
|
@@ -21,10 +30,17 @@ export class Log {
|
|
|
21
30
|
write(message: string, fields: Record<string, unknown> = {}): void {
|
|
22
31
|
const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
|
|
23
32
|
if (this.echo) process.stderr.write(`${line}\n`);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
this.#written = this.#written.then(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await appendFile(this.file, `${line}\n`);
|
|
36
|
+
} catch {
|
|
37
|
+
// A log that cannot be written is not a reason to stop serving.
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Settle once every line asked for so far is on disk. */
|
|
43
|
+
async flush(): Promise<void> {
|
|
44
|
+
await this.#written;
|
|
29
45
|
}
|
|
30
46
|
}
|
package/src/kv/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type {
|
|
4
5
|
KvDeleteArgs,
|
|
@@ -28,15 +29,38 @@ export const KV_DIR = "kv";
|
|
|
28
29
|
* One file per namespace, whole-file: a namespace holds a handful of small
|
|
29
30
|
* values, and the whole of it is what both a snapshot and a reload state. The
|
|
30
31
|
* write lands through a temporary and a rename, so a kill leaves either the
|
|
31
|
-
* previous namespace or the new one.
|
|
32
|
+
* previous namespace or the new one.
|
|
33
|
+
*
|
|
34
|
+
* The files are read here, as the store is built — before this instance is
|
|
35
|
+
* accepting anything, so nobody is waiting on the read (DR-0015). Reading them
|
|
36
|
+
* when a namespace was first asked about would put the read inside the turn
|
|
37
|
+
* that answers a `kv.read` or opens a `kv:<ns>` subscription, and a snapshot is
|
|
38
|
+
* answered from what is held rather than from a promise. A namespace with no
|
|
39
|
+
* file starts empty, which is the same thing a namespace written for the first
|
|
40
|
+
* time after this starts from. */
|
|
32
41
|
export class KvStore implements UpstreamResource {
|
|
33
42
|
readonly #namespaces = new Map<string, Map<string, Held>>();
|
|
34
43
|
|
|
44
|
+
/** Per namespace, the writes already asked for, as one chain. */
|
|
45
|
+
readonly #writing = new Map<string, Promise<void>>();
|
|
46
|
+
|
|
35
47
|
constructor(
|
|
36
48
|
private readonly dir: string,
|
|
37
49
|
private readonly self: InstanceId,
|
|
38
50
|
private readonly publish: (topic: string, data: unknown) => void,
|
|
39
|
-
) {
|
|
51
|
+
) {
|
|
52
|
+
let names: string[];
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(this.dir);
|
|
55
|
+
} catch {
|
|
56
|
+
// No directory yet, which is an instance nobody has written a value to.
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
if (!name.endsWith(".json")) continue;
|
|
61
|
+
this.#namespaces.set(name.slice(0, -".json".length), read(join(this.dir, name)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
40
64
|
|
|
41
65
|
read(args: KvReadArgs): KvReadResult {
|
|
42
66
|
const held = this.#load(args.ns).get(args.key);
|
|
@@ -54,7 +78,7 @@ export class KvStore implements UpstreamResource {
|
|
|
54
78
|
* was unreachable must not displace what was written since. The answer is
|
|
55
79
|
* what the key carries now — equal to what the caller stated when its write
|
|
56
80
|
* stands, and later than it when an existing value did. */
|
|
57
|
-
write(args: KvWriteArgs, now: Timestamp = Date.now()): KvWriteResult {
|
|
81
|
+
async write(args: KvWriteArgs, now: Timestamp = Date.now()): Promise<KvWriteResult> {
|
|
58
82
|
const entries = this.#load(args.ns);
|
|
59
83
|
const updatedAt = args.updated_at ?? now;
|
|
60
84
|
const held = entries.get(args.key);
|
|
@@ -65,7 +89,9 @@ export class KvStore implements UpstreamResource {
|
|
|
65
89
|
return { updated_at: held.updated_at };
|
|
66
90
|
}
|
|
67
91
|
entries.set(args.key, { value: args.value, updated_at: updatedAt });
|
|
68
|
-
|
|
92
|
+
// Told to the subscribers once it is written down, so nobody is holding a
|
|
93
|
+
// value this instance would not have after a restart.
|
|
94
|
+
await this.#persist(args.ns, entries);
|
|
69
95
|
this.publish(`kv:${args.ns}`, {
|
|
70
96
|
entries: [{ key: args.key, value: args.value, updated_at: updatedAt }],
|
|
71
97
|
});
|
|
@@ -75,14 +101,14 @@ export class KvStore implements UpstreamResource {
|
|
|
75
101
|
/** A key that was not there is no error: the caller wanted the namespace
|
|
76
102
|
* without it, and it is. The removal is still announced, because a subscriber
|
|
77
103
|
* that has the entry has to be told it is gone. */
|
|
78
|
-
delete(args: KvDeleteArgs, now: Timestamp = Date.now()): KvDeleteResult {
|
|
104
|
+
async delete(args: KvDeleteArgs, now: Timestamp = Date.now()): Promise<KvDeleteResult> {
|
|
79
105
|
const entries = this.#load(args.ns);
|
|
80
106
|
const before = entries.get(args.key);
|
|
81
107
|
// A removal older than what the key holds undoes nothing, which is the
|
|
82
108
|
// same rule a write is held to.
|
|
83
109
|
if (before !== undefined && before.updated_at > now) return {};
|
|
84
110
|
entries.set(args.key, { updated_at: now, deleted: true });
|
|
85
|
-
this.#persist(args.ns, entries);
|
|
111
|
+
await this.#persist(args.ns, entries);
|
|
86
112
|
// A removal is announced only when something was there to remove: a
|
|
87
113
|
// subscriber holding no entry has nothing to be told is gone.
|
|
88
114
|
if (before !== undefined && before.deleted !== true) {
|
|
@@ -94,8 +120,7 @@ export class KvStore implements UpstreamResource {
|
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/** Nothing to start or stop: the values are here whether anyone is watching
|
|
97
|
-
* or not, and the
|
|
98
|
-
* touched. */
|
|
123
|
+
* or not, and the files they live in were read as this was built. */
|
|
99
124
|
start(): void {}
|
|
100
125
|
stop(): void {}
|
|
101
126
|
|
|
@@ -110,57 +135,51 @@ export class KvStore implements UpstreamResource {
|
|
|
110
135
|
return [{ instance: this.self, data: { entries } }];
|
|
111
136
|
}
|
|
112
137
|
|
|
138
|
+
/** Settle once every write asked for so far has landed. What a stop waits on
|
|
139
|
+
* before it lets go of the config home (DESIGN §8.5 step 4). */
|
|
140
|
+
async flush(): Promise<void> {
|
|
141
|
+
await Promise.allSettled(this.#writing.values());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What the namespace holds, dropping the removals nothing can still be
|
|
145
|
+
* carrying an older write for. A name this store read no file for is a
|
|
146
|
+
* namespace with nothing in it. */
|
|
113
147
|
#load(ns: string, now: Timestamp = Date.now()): Map<string, Held> {
|
|
114
|
-
const known = this.#namespaces.get(ns);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let text: string;
|
|
118
|
-
try {
|
|
119
|
-
text = readFileSync(this.#file(ns), "utf8");
|
|
120
|
-
} catch {
|
|
121
|
-
this.#namespaces.set(ns, entries);
|
|
122
|
-
return entries;
|
|
123
|
-
}
|
|
124
|
-
let parsed: unknown;
|
|
125
|
-
try {
|
|
126
|
-
parsed = JSON.parse(text);
|
|
127
|
-
} catch {
|
|
128
|
-
// A file a kill damaged states nothing this instance can act on, and
|
|
129
|
-
// refusing every read of the namespace would be worse than starting it
|
|
130
|
-
// empty: the next write replaces the file.
|
|
131
|
-
parsed = undefined;
|
|
132
|
-
}
|
|
133
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
134
|
-
for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
|
|
135
|
-
if (typeof held !== "object" || held === null) continue;
|
|
136
|
-
const fields = held as Record<string, unknown>;
|
|
137
|
-
const updatedAt = fields["updated_at"];
|
|
138
|
-
if (typeof updatedAt !== "number") continue;
|
|
139
|
-
entries.set(
|
|
140
|
-
key,
|
|
141
|
-
fields["deleted"] === true
|
|
142
|
-
? { updated_at: updatedAt, deleted: true }
|
|
143
|
-
: { value: fields["value"], updated_at: updatedAt },
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
this.#namespaces.set(ns, entries);
|
|
148
|
-
return forget(entries, now);
|
|
148
|
+
const known = this.#namespaces.get(ns) ?? new Map<string, Held>();
|
|
149
|
+
this.#namespaces.set(ns, known);
|
|
150
|
+
return forget(known, now);
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
/** The namespace as it stands, written whole.
|
|
154
|
+
*
|
|
155
|
+
* The body is taken here, before anything is awaited, so what is written is
|
|
156
|
+
* the namespace as it was when the write was answered. Writes to one
|
|
157
|
+
* namespace are chained rather than started side by side: two of them would
|
|
158
|
+
* otherwise be racing for one file, and the older could land last (DR-0015).
|
|
159
|
+
* Namespaces do not wait on each other, having nothing in common but this
|
|
160
|
+
* directory. */
|
|
161
|
+
#persist(ns: string, entries: Map<string, Held>): Promise<void> {
|
|
153
162
|
const file = this.#file(ns);
|
|
154
163
|
const body: Record<string, Held> = {};
|
|
155
164
|
for (const [key, held] of entries) body[key] = held;
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
const written = (this.#writing.get(ns) ?? Promise.resolve()).then(async () => {
|
|
166
|
+
await mkdir(this.dir, { recursive: true });
|
|
167
|
+
const temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
|
|
168
|
+
await writeFile(temporary, JSON.stringify(body));
|
|
169
|
+
try {
|
|
170
|
+
await rename(temporary, file);
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
await unlink(temporary);
|
|
173
|
+
throw cause;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// The chain carries the order, not the outcome: a write that failed is
|
|
177
|
+
// answered to its own caller, and the ones behind it still go.
|
|
178
|
+
this.#writing.set(
|
|
179
|
+
ns,
|
|
180
|
+
written.catch(() => {}),
|
|
181
|
+
);
|
|
182
|
+
return written;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
/** The namespace's file. A namespace is an identifier, so its name is a file
|
|
@@ -170,6 +189,35 @@ export class KvStore implements UpstreamResource {
|
|
|
170
189
|
}
|
|
171
190
|
}
|
|
172
191
|
|
|
192
|
+
/** One namespace's file, as the entries it states.
|
|
193
|
+
*
|
|
194
|
+
* A file a kill damaged states nothing this instance can act on, and refusing
|
|
195
|
+
* every read of the namespace would be worse than starting it empty: the next
|
|
196
|
+
* write replaces the file. */
|
|
197
|
+
function read(file: string): Map<string, Held> {
|
|
198
|
+
const entries = new Map<string, Held>();
|
|
199
|
+
let parsed: unknown;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
202
|
+
} catch {
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
if (typeof parsed !== "object" || parsed === null) return entries;
|
|
206
|
+
for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
|
|
207
|
+
if (typeof held !== "object" || held === null) continue;
|
|
208
|
+
const fields = held as Record<string, unknown>;
|
|
209
|
+
const updatedAt = fields["updated_at"];
|
|
210
|
+
if (typeof updatedAt !== "number") continue;
|
|
211
|
+
entries.set(
|
|
212
|
+
key,
|
|
213
|
+
fields["deleted"] === true
|
|
214
|
+
? { updated_at: updatedAt, deleted: true }
|
|
215
|
+
: { value: fields["value"], updated_at: updatedAt },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
/** Drop the removals nothing can still be carrying an older write for.
|
|
174
222
|
*
|
|
175
223
|
* Done where the namespace is read rather than on a clock of its own: a timer
|
|
@@ -188,9 +236,9 @@ export function kvHandlers(store: KvStore) {
|
|
|
188
236
|
return {
|
|
189
237
|
"kv.read": (input: HandlerInput): KvReadResult =>
|
|
190
238
|
store.read(input.args as unknown as KvReadArgs),
|
|
191
|
-
"kv.write": (input: HandlerInput): KvWriteResult =>
|
|
239
|
+
"kv.write": (input: HandlerInput): Promise<KvWriteResult> =>
|
|
192
240
|
store.write(input.args as unknown as KvWriteArgs),
|
|
193
|
-
"kv.delete": (input: HandlerInput): KvDeleteResult =>
|
|
241
|
+
"kv.delete": (input: HandlerInput): Promise<KvDeleteResult> =>
|
|
194
242
|
store.delete(input.args as unknown as KvDeleteArgs),
|
|
195
243
|
};
|
|
196
244
|
}
|
package/src/launcher/launcher.ts
CHANGED
|
@@ -67,17 +67,20 @@ export class Launcher {
|
|
|
67
67
|
templates: this.config.templates.map((template) => ({
|
|
68
68
|
name: template.name,
|
|
69
69
|
command: template.command,
|
|
70
|
-
params: template.params.map((param) => ({
|
|
70
|
+
params: template.params.map((param) => ({
|
|
71
|
+
name: param.name,
|
|
72
|
+
default: param.default,
|
|
73
|
+
})),
|
|
71
74
|
})),
|
|
72
75
|
};
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
tree(args: DirTreeArgs): DirTreeResult {
|
|
78
|
+
tree(args: DirTreeArgs): Promise<DirTreeResult> {
|
|
76
79
|
return dirTree(this.config, args);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
run(args: LauncherRunArgs): Promise<LauncherRunResult> {
|
|
80
|
-
const cwd = insideRoots(this.config, args.cwd);
|
|
82
|
+
async run(args: LauncherRunArgs): Promise<LauncherRunResult> {
|
|
83
|
+
const cwd = await insideRoots(this.config, args.cwd);
|
|
81
84
|
if (cwd === undefined) {
|
|
82
85
|
// The op states no refusal for a path, so a directory outside the roots
|
|
83
86
|
// is answered as what it is from here: an argument this launcher cannot
|
|
@@ -129,7 +132,7 @@ export function launcherHandlers(launcher: Launcher) {
|
|
|
129
132
|
"launcher.config.read": (): LauncherConfigReadResult => launcher.configRead(),
|
|
130
133
|
"launcher.run": (input: HandlerInput): Promise<LauncherRunResult> =>
|
|
131
134
|
launcher.run(input.args as unknown as LauncherRunArgs),
|
|
132
|
-
"dir.tree": (input: HandlerInput): DirTreeResult =>
|
|
135
|
+
"dir.tree": (input: HandlerInput): Promise<DirTreeResult> =>
|
|
133
136
|
launcher.tree(input.args as unknown as DirTreeArgs),
|
|
134
137
|
};
|
|
135
138
|
}
|
package/src/launcher/roots.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
3
|
import type { LauncherConfig } from "../instance/config.ts";
|
|
4
4
|
import { canonical, within } from "../files/index.ts";
|
|
@@ -13,19 +13,22 @@ import { canonical, within } from "../files/index.ts";
|
|
|
13
13
|
*
|
|
14
14
|
* A root that no longer resolves grants nothing and stops nothing: another
|
|
15
15
|
* configured root may still hold the candidate. */
|
|
16
|
-
export function insideRoots(
|
|
16
|
+
export async function insideRoots(
|
|
17
|
+
config: LauncherConfig,
|
|
18
|
+
path: string,
|
|
19
|
+
): Promise<string | undefined> {
|
|
17
20
|
if (!isAbsolute(path)) return undefined;
|
|
18
|
-
const real = canonical(path);
|
|
19
|
-
if (!isDirectory(real)) return undefined;
|
|
21
|
+
const real = await canonical(path);
|
|
22
|
+
if (!(await isDirectory(real))) return undefined;
|
|
20
23
|
for (const root of config.root_dirs) {
|
|
21
|
-
if (within(real, canonical(root))) return real;
|
|
24
|
+
if (within(real, await canonical(root))) return real;
|
|
22
25
|
}
|
|
23
26
|
return undefined;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
export function isDirectory(path: string): boolean {
|
|
29
|
+
export async function isDirectory(path: string): Promise<boolean> {
|
|
27
30
|
try {
|
|
28
|
-
return
|
|
31
|
+
return (await stat(path)).isDirectory();
|
|
29
32
|
} catch {
|
|
30
33
|
return false;
|
|
31
34
|
}
|
package/src/launcher/tree.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import type { DirTreeArgs, DirTreeEntry, DirTreeResult } from "@ccmsg/protocol";
|
|
4
4
|
import type { LauncherConfig } from "../instance/config.ts";
|
|
@@ -18,29 +18,29 @@ const MAX_DEPTH = 5;
|
|
|
18
18
|
* not hold contributes nothing rather than failing the request: the op states no
|
|
19
19
|
* refusal for a path, and a tree assembled from several roots would otherwise be
|
|
20
20
|
* lost whole because one of them went away. */
|
|
21
|
-
export function dirTree(config: LauncherConfig, args: DirTreeArgs): DirTreeResult {
|
|
21
|
+
export async function dirTree(config: LauncherConfig, args: DirTreeArgs): Promise<DirTreeResult> {
|
|
22
22
|
const depth = Math.min(MAX_DEPTH, args.depth ?? config.depth);
|
|
23
23
|
// A filter of nothing is not a filter: an emptied search box shows the tree
|
|
24
24
|
// rather than hiding all of it.
|
|
25
25
|
const filter = args.filter === undefined || args.filter === "" ? undefined : args.filter;
|
|
26
26
|
const entries: DirTreeEntry[] = [];
|
|
27
27
|
for (const root of args.roots) {
|
|
28
|
-
const real = insideRoots(config, root);
|
|
28
|
+
const real = await insideRoots(config, root);
|
|
29
29
|
if (real === undefined) continue;
|
|
30
|
-
entries.push(...walk(config, real, real, depth, filter));
|
|
30
|
+
entries.push(...(await walk(config, real, real, depth, filter)));
|
|
31
31
|
}
|
|
32
32
|
return { entries: sorted(entries) };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function walk(
|
|
35
|
+
async function walk(
|
|
36
36
|
config: LauncherConfig,
|
|
37
37
|
root: string,
|
|
38
38
|
at: string,
|
|
39
39
|
depth: number,
|
|
40
40
|
filter: string | undefined,
|
|
41
|
-
): DirTreeEntry[] {
|
|
41
|
+
): Promise<DirTreeEntry[]> {
|
|
42
42
|
const entries: DirTreeEntry[] = [];
|
|
43
|
-
for (const dirent of read(at)) {
|
|
43
|
+
for (const dirent of await read(at)) {
|
|
44
44
|
// Design rationale: dot-directories are left out. This answers "where could
|
|
45
45
|
// a session run", and a repository's `.git` is not one of those places —
|
|
46
46
|
// browsing a session's own files is a different op with different rules.
|
|
@@ -49,24 +49,27 @@ function walk(
|
|
|
49
49
|
if (dirent.isSymbolicLink()) {
|
|
50
50
|
// A link is a place to run only if what it points at is one, so it goes
|
|
51
51
|
// through the same containment its target would.
|
|
52
|
-
if (insideRoots(config, path) === undefined) continue;
|
|
52
|
+
if ((await insideRoots(config, path)) === undefined) continue;
|
|
53
53
|
} else if (!dirent.isDirectory()) continue;
|
|
54
54
|
|
|
55
|
-
const children = depth > 1 ? walk(config, root, path, depth - 1, filter) : undefined;
|
|
55
|
+
const children = depth > 1 ? await walk(config, root, path, depth - 1, filter) : undefined;
|
|
56
56
|
if (filter !== undefined) {
|
|
57
57
|
const matches = relative(root, path).includes(filter);
|
|
58
58
|
// An ancestor of a match survives the filter: without it a match several
|
|
59
59
|
// levels down would have nothing to hang from.
|
|
60
60
|
if (!matches && (children === undefined || children.length === 0)) continue;
|
|
61
61
|
}
|
|
62
|
-
entries.push({
|
|
62
|
+
entries.push({
|
|
63
|
+
path,
|
|
64
|
+
...(children === undefined ? {} : { children: sorted(children) }),
|
|
65
|
+
});
|
|
63
66
|
}
|
|
64
67
|
return entries;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
function read(dir: string) {
|
|
70
|
+
async function read(dir: string) {
|
|
68
71
|
try {
|
|
69
|
-
return
|
|
72
|
+
return await readdir(dir, { withFileTypes: true });
|
|
70
73
|
} catch {
|
|
71
74
|
// A directory that cannot be read is still a place to run; what it holds is
|
|
72
75
|
// simply not known, which is the same answer as holding nothing.
|
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
|