@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.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.22.0"
23
+ "@ccmsg/protocol": "1.23.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
package/src/auth/admin.ts CHANGED
@@ -51,7 +51,10 @@ export interface Administered {
51
51
  }
52
52
 
53
53
  /** Run one administrative request. */
54
- export function handleAdmin(at: Administered, request: AdminRequest): DispatchResult {
54
+ export async function handleAdmin(
55
+ at: Administered,
56
+ request: AdminRequest,
57
+ ): Promise<DispatchResult> {
55
58
  const auth = at.auth;
56
59
  try {
57
60
  switch (request.admin) {
@@ -77,7 +80,7 @@ export function handleAdmin(at: Administered, request: AdminRequest): DispatchRe
77
80
  case "passkey_list":
78
81
  return reply(request.request_id, { credentials: auth.list() });
79
82
  case "passkey_remove": {
80
- const { closed } = auth.remove(request.sub);
83
+ const { closed } = await auth.remove(request.sub);
81
84
  return reply(request.request_id, { sub: request.sub, closed });
82
85
  }
83
86
  }
package/src/auth/auth.ts CHANGED
@@ -316,8 +316,8 @@ export class Auth {
316
316
  }
317
317
 
318
318
  /** Remove one person: the tombstones, and every connection they hold. */
319
- remove(sub: Subject): { records: AuthRecord[]; closed: number } {
320
- const records = this.deps.records.remove(sub);
319
+ async remove(sub: Subject): Promise<{ records: AuthRecord[]; closed: number }> {
320
+ const records = await this.deps.records.remove(sub);
321
321
  return { records, closed: this.disconnect(sub) };
322
322
  }
323
323
 
@@ -340,8 +340,8 @@ export class Auth {
340
340
  }
341
341
 
342
342
  /** Take what a peer wrote on `auth.records`, and act on the removals in it. */
343
- merge(records: readonly AuthRecord[]): void {
344
- const { removed } = this.deps.records.merge(records);
343
+ async merge(records: readonly AuthRecord[]): Promise<void> {
344
+ const { removed } = await this.deps.records.merge(records);
345
345
  for (const sub of removed) this.disconnect(sub);
346
346
  }
347
347
 
@@ -479,7 +479,7 @@ export class Auth {
479
479
  ...(from.ip === undefined ? {} : { registered_ip: from.ip }),
480
480
  ...(from.userAgent === undefined ? {} : { registered_user_agent: from.userAgent }),
481
481
  };
482
- this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
482
+ await this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
483
483
  return this.mint(claims.sub);
484
484
  }
485
485
 
@@ -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 cluster without any of them counting
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
@@ -589,7 +589,7 @@ export class Auth {
589
589
  );
590
590
  await this.#spendAnywhere(args.challenge);
591
591
  const at = this.#now();
592
- this.deps.records.write(
592
+ await this.deps.records.write(
593
593
  credentialKey(record.sub, record.credential_id),
594
594
  {
595
595
  ...record,
@@ -654,7 +654,7 @@ export class Auth {
654
654
  // --- tokens (DR-0001 §2.4) ---
655
655
 
656
656
  /** Make a family for this person, minted by this instance. */
657
- mint(sub: Subject): MintedSession {
657
+ async mint(sub: Subject): Promise<MintedSession> {
658
658
  const at = this.#now();
659
659
  const family: TokenFamily = {
660
660
  kind: "token_family",
@@ -664,7 +664,7 @@ export class Auth {
664
664
  refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
665
665
  };
666
666
  const id = randomBytes(8).toString("hex");
667
- this.deps.records.write(familyKey(sub, id), family, at);
667
+ await this.deps.records.write(familyKey(sub, id), family, at);
668
668
  return { session: { sub, access: family.access }, refresh: family.refresh };
669
669
  }
670
670
 
@@ -698,7 +698,7 @@ export class Auth {
698
698
  } satisfies AuthRotateArgs)) as AuthRotateResult;
699
699
  return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
700
700
  }
701
- const rotated = this.rotate(value, from);
701
+ const rotated = await this.rotate(value, from);
702
702
  return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
703
703
  }
704
704
 
@@ -714,7 +714,7 @@ export class Auth {
714
714
  const owner = this.deps.records.owning(value, digestOf(value));
715
715
  if (owner === undefined) return;
716
716
  if (owner.body.iss === this.deps.self) {
717
- this.#failReused(value);
717
+ await this.#failReused(value);
718
718
  return;
719
719
  }
720
720
  try {
@@ -730,10 +730,10 @@ export class Auth {
730
730
 
731
731
  /** Rotate a family this instance minted. The one writer's own operation, and
732
732
  * what `auth.rotate` runs on its behalf. */
733
- rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
733
+ async rotate(value: Base64Url, from: RefreshFrom = {}): Promise<AuthRotateResult> {
734
734
  const held = this.deps.records.byRefresh(value);
735
735
  if (held === undefined) {
736
- this.#failReused(value);
736
+ await this.#failReused(value);
737
737
  throw new OpError("auth_invalid", "この refresh token は使えません");
738
738
  }
739
739
  if (held.body.iss !== this.deps.self) {
@@ -777,7 +777,7 @@ export class Auth {
777
777
  // holds wherever the reused value is presented (contract, `TokenFamily`).
778
778
  retired: retire(held.body, at),
779
779
  };
780
- this.deps.records.write(held.key, rotated, at);
780
+ await this.deps.records.write(held.key, rotated, at);
781
781
  return { sub: rotated.sub, access: rotated.access, refresh: rotated.refresh };
782
782
  }
783
783
 
@@ -789,7 +789,7 @@ export class Auth {
789
789
  * before it past its grace, and any generation this instance rotated away
790
790
  * while it has been running. A value older than what any of those covers
791
791
  * matches nothing and is refused as a stranger. */
792
- #failReused(value: Base64Url): void {
792
+ async #failReused(value: Base64Url): Promise<void> {
793
793
  const digest = digestOf(value);
794
794
  const now = this.#now();
795
795
  for (const held of this.deps.records.families()) {
@@ -805,7 +805,7 @@ export class Auth {
805
805
  this.deps.log?.("a refresh token was reused after it was rotated away", {
806
806
  sub: held.body.sub,
807
807
  });
808
- this.deps.records.fail(held.key);
808
+ await this.deps.records.fail(held.key);
809
809
  // The tokens are gone, and so is what they were holding open: a
810
810
  // connection that outlived the family it was admitted on would be the
811
811
  // stolen token still working.
@@ -932,7 +932,7 @@ export function authHandlers(auth: Auth) {
932
932
  // the URL and the count of tries against it (DR-0001 §2.2).
933
933
  return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
934
934
  },
935
- "auth.rotate": (input: HandlerInput): AuthRotateResult => {
935
+ "auth.rotate": (input: HandlerInput): Promise<AuthRotateResult> => {
936
936
  const args = input.args as unknown as AuthRotateArgs;
937
937
  // The receiving instance's account of the person, taken as stated: it is
938
938
  // the only one that saw them, and `last_refresh` is a hint nothing is
@@ -1,5 +1,6 @@
1
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
3
4
  import type {
4
5
  AuthRecord,
5
6
  AuthTombstone,
@@ -81,9 +82,13 @@ export interface RecordsDeps {
81
82
  * and in the authenticator, and losing a family logs its person out. */
82
83
  export class AuthRecords {
83
84
  readonly #records = new Map<string, AuthRecord>();
84
- #loaded = false;
85
85
 
86
- constructor(private readonly deps: RecordsDeps) {}
86
+ /** The writes already asked for, as one chain. */
87
+ #writing: Promise<void> = Promise.resolve();
88
+
89
+ constructor(private readonly deps: RecordsDeps) {
90
+ this.#read();
91
+ }
87
92
 
88
93
  #now(): Timestamp {
89
94
  return (this.deps.now ?? Date.now)();
@@ -94,7 +99,6 @@ export class AuthRecords {
94
99
  * Answers whether the set moved, which is what decides whether the change is
95
100
  * worth writing down and passing on. */
96
101
  accept(record: AuthRecord): boolean {
97
- this.#load();
98
102
  const held = this.#records.get(record.key);
99
103
  if (held !== undefined && held.updated_at >= record.updated_at) return false;
100
104
  if (this.#refused(record)) return false;
@@ -127,7 +131,7 @@ export class AuthRecords {
127
131
  }
128
132
  }
129
133
 
130
- /** Write one record of this instance's own, and tell the cluster.
134
+ /** Write one record of this instance's own, and tell the mesh.
131
135
  *
132
136
  * A local write always displaces what the key holds. Last-write-wins settles
133
137
  * a disagreement between instances; this is not one — the writer is the
@@ -135,13 +139,18 @@ export class AuthRecords {
135
139
  * millisecond, which a rotation and the mint before it easily do, must not
136
140
  * silently drop the second. So the instant is moved past what is held rather
137
141
  * than compared against it. */
138
- write(key: string, body: AuthRecord["body"], now: Timestamp = this.#now()): boolean {
139
- this.#load();
142
+ async write(
143
+ key: string,
144
+ body: AuthRecord["body"],
145
+ now: Timestamp = this.#now(),
146
+ ): Promise<boolean> {
140
147
  const held = this.#records.get(key);
141
148
  const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
142
149
  const record: AuthRecord = { key, updated_at: at, body };
143
150
  if (!this.accept(record)) return false;
144
- this.#persist();
151
+ // Handed to the peers once it is written down, so no peer holds a record
152
+ // this instance would not have after a restart.
153
+ await this.#persist();
145
154
  this.deps.publish([record]);
146
155
  return true;
147
156
  }
@@ -156,7 +165,7 @@ export class AuthRecords {
156
165
  * This instance is its only writer, so a copy coming back is a copy of an
157
166
  * older state — which is exactly what a failed family looks like from a peer
158
167
  * that has not heard yet, and taking it would undo the failure. */
159
- merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
168
+ async merge(records: readonly AuthRecord[]): Promise<{ changed: number; removed: Subject[] }> {
160
169
  let changed = 0;
161
170
  const removed: Subject[] = [];
162
171
  for (const record of records) {
@@ -165,7 +174,7 @@ export class AuthRecords {
165
174
  changed += 1;
166
175
  if (record.body.kind === "tombstone") removed.push(record.body.sub);
167
176
  }
168
- if (changed > 0) this.#persist();
177
+ if (changed > 0) await this.#persist();
169
178
  return { changed, removed };
170
179
  }
171
180
 
@@ -175,7 +184,7 @@ export class AuthRecords {
175
184
  * lengths of time. A family expires with its refresh token, so the mark over
176
185
  * it only has to outlive the longest one; a credential has no expiry of its
177
186
  * own, so the mark over it has none either (DR-0001 §2.6). */
178
- remove(sub: Subject): AuthRecord[] {
187
+ async remove(sub: Subject): Promise<AuthRecord[]> {
179
188
  const at = this.#now();
180
189
  const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
181
190
  const family: AuthTombstone = {
@@ -189,7 +198,7 @@ export class AuthRecords {
189
198
  { key: familyPrefix(sub), updated_at: at, body: family },
190
199
  ];
191
200
  for (const mark of marks) this.accept(mark);
192
- this.#persist();
201
+ await this.#persist();
193
202
  this.deps.publish(marks);
194
203
  return marks;
195
204
  }
@@ -197,13 +206,11 @@ export class AuthRecords {
197
206
  /** Whether this subject has been removed, which is what a registration for
198
207
  * one has to be refused by. */
199
208
  removed(sub: Subject): boolean {
200
- this.#load();
201
209
  const held = this.#records.get(credentialPrefix(sub));
202
210
  return held?.body.kind === "tombstone";
203
211
  }
204
212
 
205
213
  credentials(): CredentialRecord[] {
206
- this.#load();
207
214
  const found: CredentialRecord[] = [];
208
215
  for (const record of this.#records.values()) {
209
216
  if (record.body.kind === "credential") found.push(record.body);
@@ -228,7 +235,6 @@ export class AuthRecords {
228
235
  }
229
236
 
230
237
  families(): { key: string; body: TokenFamily }[] {
231
- this.#load();
232
238
  const found: { key: string; body: TokenFamily }[] = [];
233
239
  for (const record of this.#records.values()) {
234
240
  if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
@@ -275,12 +281,11 @@ export class AuthRecords {
275
281
  * the key, which is exactly what a revoked family needs. It is kept for the
276
282
  * same seven days a removal's is: past the longest refresh token, there is
277
283
  * nothing left for a returning peer to revive. */
278
- fail(key: string): void {
279
- this.#load();
284
+ async fail(key: string): Promise<void> {
280
285
  const held = this.#records.get(key);
281
286
  if (held === undefined || held.body.kind !== "token_family") return;
282
287
  const at = this.#now();
283
- this.write(key, {
288
+ await this.write(key, {
284
289
  kind: "tombstone",
285
290
  sub: held.body.sub,
286
291
  deleted_at: at,
@@ -306,7 +311,6 @@ export class AuthRecords {
306
311
 
307
312
  /** Every record, for the snapshot a peer's subscription is answered with. */
308
313
  all(): AuthRecord[] {
309
- this.#load();
310
314
  this.#expire();
311
315
  return [...this.#records.values()];
312
316
  }
@@ -330,9 +334,19 @@ export class AuthRecords {
330
334
  }
331
335
  }
332
336
 
333
- #load(): void {
334
- if (this.#loaded) return;
335
- this.#loaded = true;
337
+ /** Settle once every write asked for so far has landed. What a stop waits on
338
+ * before it lets go of the config home (DESIGN §8.5 step 4). */
339
+ async flush(): Promise<void> {
340
+ await this.#writing;
341
+ }
342
+
343
+ /** The file, read as this is built — before the instance is accepting
344
+ * anything, so nobody is waiting on it (DR-0015). Reading it when the first
345
+ * authentication asked would put the read inside the turn that answers it,
346
+ * and the `auth.records` snapshot is answered from what is held rather than
347
+ * from a promise. A file that is not there is an instance nobody has
348
+ * registered against, which is what an empty set says. */
349
+ #read(): void {
336
350
  let text: string;
337
351
  try {
338
352
  text = readFileSync(this.#file(), "utf8");
@@ -356,21 +370,34 @@ export class AuthRecords {
356
370
  this.#expire();
357
371
  }
358
372
 
359
- #persist(): void {
373
+ /** The set as it stands, written whole.
374
+ *
375
+ * The body is taken here, before anything is awaited, so what is written is
376
+ * the set as it was when the change was answered; the writes are chained so
377
+ * that two of them cannot be racing for one file and the older land last
378
+ * (DR-0015). */
379
+ #persist(): Promise<void> {
360
380
  this.#expire();
361
- mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 });
362
- const file = this.#file();
363
- const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
364
- // The set holds tokens, so the file is the instance's own to read: it is
365
- // created with the mode rather than fixed afterwards, so there is no
366
- // instant at which it stands readable by anyone else.
367
- writeFileSync(temporary, JSON.stringify([...this.#records.values()]), { mode: 0o600 });
368
- try {
369
- renameSync(temporary, file);
370
- } catch (cause) {
371
- unlinkSync(temporary);
372
- throw cause;
373
- }
381
+ const body = JSON.stringify([...this.#records.values()]);
382
+ const written = this.#writing.then(async () => {
383
+ await mkdir(this.deps.dir, { recursive: true, mode: 0o700 });
384
+ const file = this.#file();
385
+ const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
386
+ // The set holds tokens, so the file is the instance's own to read: it is
387
+ // created with the mode rather than fixed afterwards, so there is no
388
+ // instant at which it stands readable by anyone else.
389
+ await writeFile(temporary, body, { mode: 0o600 });
390
+ try {
391
+ await rename(temporary, file);
392
+ } catch (cause) {
393
+ await unlink(temporary);
394
+ throw cause;
395
+ }
396
+ });
397
+ // The chain carries the order, not the outcome: a write that failed is
398
+ // answered to its own caller, and the ones behind it still go.
399
+ this.#writing = written.catch(() => {});
400
+ return written;
374
401
  }
375
402
 
376
403
  #file(): string {
@@ -383,11 +410,6 @@ export function recordsDir(stateDir: string): string {
383
410
  return join(stateDir, AUTH_DIR);
384
411
  }
385
412
 
386
- /** The parent of a path, made when a caller wants to write into it. */
387
- export function ensureDir(file: string): void {
388
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
389
- }
390
-
391
413
  /** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
392
414
  export function writes(family: TokenFamily, self: InstanceId): boolean {
393
415
  return family.iss === self;
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 cluster topic and answer with it.
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(args, ["preset", "types", "since", "until", "out", "max-chars"], ["json"]);
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("invalid_args", "使い方: ccmsg notify <text> [--about <sid>] | --hook");
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
 
@@ -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 cluster read
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 cluster read needs of it. */
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 cluster topic waits
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
@@ -275,18 +275,26 @@ export class Supervisor {
275
275
  }
276
276
  return await op(unit);
277
277
  }
278
- const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
279
278
  // Taken as a list first: each step below waits, and a request arriving in
280
279
  // between may add or remove one — what `--all` answers about is the set as
281
280
  // it stood when it was asked.
282
281
  const units = Array.from(this.#units.values());
283
- for (const unit of units) {
284
- try {
285
- answers.push(await op(unit));
286
- } catch (cause) {
287
- if (!(cause instanceof CommandError)) throw cause;
288
- answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
282
+ // Every config home at once. Each is a child process of its own, and a
283
+ // start that waits on one of them serving is not a reason the next one has
284
+ // not been asked to start yet (DR-0015). The answers are still the units in
285
+ // the order they were taken, because that is the list the caller asked
286
+ // about.
287
+ const settled = await Promise.allSettled(units.map((unit) => op(unit)));
288
+ const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
289
+ for (const [index, outcome] of settled.entries()) {
290
+ if (outcome.status === "fulfilled") {
291
+ answers.push(outcome.value);
292
+ continue;
289
293
  }
294
+ const cause: unknown = outcome.reason;
295
+ if (!(cause instanceof CommandError)) throw cause;
296
+ const unit = units[index] as Supervised;
297
+ answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
290
298
  }
291
299
  return answers;
292
300
  }
@@ -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
- * `instance-local` op to decide whether the op is ours to run. */
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 `instance-local` op, or
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 instance-local op whose subject belongs elsewhere goes to mesh.
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 === "instance-local") {
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) {