@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.
@@ -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 cluster as the topic mechanism sees it: what
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
- if (Array.isArray(stated)) this.#auth.merge(stated);
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 ? {} : { cluster: this.#mesh }),
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 cluster shares (DR-0001 §2.6).
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 Promise.resolve(
718
- handleAdmin(
719
- { auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
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 cluster
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 instance-local op.
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 cluster last said; one it does not hold is looked for in 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` and the inbox are written as
1001
- // they change rather than at exit, so there is nothing held back to flush;
1002
- // the log's writer is synchronous for the same reason (DESIGN §2.5).
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
  }
@@ -1,15 +1,24 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
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 every line on disk before the call
5
- * returns (DESIGN §2.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
- * The reason to read a log is to find out why a process stopped, so the line
8
- * that matters most is the last one written before it did. A buffered writer
9
- * is the one that loses exactly that line, so this one appends synchronously
10
- * and holds nothing the cost is a write per line, on a file that takes a
11
- * line per lifecycle event rather than per request. */
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
- try {
25
- appendFileSync(this.file, `${line}\n`);
26
- } catch {
27
- // A log that cannot be written is not a reason to stop serving.
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 { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
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
- this.#persist(args.ns, entries);
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 file they live in is read the first time the namespace is
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
- if (known !== undefined) return forget(known, now);
116
- const entries = new Map<string, Held>();
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
- #persist(ns: string, entries: Map<string, Held>): void {
152
- mkdirSync(this.dir, { recursive: true });
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 temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
157
- writeFileSync(temporary, JSON.stringify(body));
158
- try {
159
- renameSync(temporary, file);
160
- } catch (cause) {
161
- unlinkSync(temporary);
162
- throw cause;
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
  }
@@ -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) => ({ name: param.name, default: param.default })),
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
  }
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
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(config: LauncherConfig, path: string): string | undefined {
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 statSync(path).isDirectory();
31
+ return (await stat(path)).isDirectory();
29
32
  } catch {
30
33
  return false;
31
34
  }
@@ -1,4 +1,4 @@
1
- import { readdirSync } from "node:fs";
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({ path, ...(children === undefined ? {} : { children: sorted(children) }) });
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 readdirSync(dir, { withFileTypes: true });
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.
@@ -8,16 +8,16 @@ export interface MeshView {
8
8
  instances(): InstanceInfo[];
9
9
  }
10
10
 
11
- /** The cluster as one instance sees it, itself included.
11
+ /** The mesh as one instance sees it, itself included.
12
12
  *
13
- * An instance with no mesh is a cluster of one and says so: it is reached at
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
- * cluster would be one instance stating two views of itself. */
20
- export function clusterView(
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 clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
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