@ccmsg/cli 0.10.1 → 0.11.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.
@@ -1,23 +1,27 @@
1
- import { existsSync, mkdirSync, rmSync, watch, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
2
2
  import { basename, isAbsolute, join, resolve } from "node:path";
3
3
  import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
4
4
  import { DEFAULT_HARNESS, type Harness, HARNESS, HARNESSES } from "../harness/index.ts";
5
5
  import {
6
- type ClusterInfo,
7
- type ClusterSetting,
6
+ applied,
7
+ type ConfigProblem,
8
8
  CONFIG_FILE,
9
9
  CONFIG_NAME,
10
+ configOf,
11
+ DEFAULT_CONFIG,
12
+ type EndpointRow,
13
+ ENDPOINTS_FILE,
14
+ evaluate,
10
15
  type InstanceConfig,
11
16
  instanceFileName,
12
- loadAll,
13
- loadConfig,
14
- loadInstances,
15
- saveCluster,
16
- saveClusters,
17
+ type InstanceSetting,
18
+ type Satisfied,
19
+ settle,
20
+ SUPERVISOR_FILE,
17
21
  TYPES_FILE,
18
22
  writeConfigTypes,
19
23
  } from "../instance/config.ts";
20
- import { ID, instanceIdentity, newId } from "../instance/identity.ts";
24
+ import { instanceIdentity } from "../instance/identity.ts";
21
25
  import { alive, lockHolder } from "../instance/lock.ts";
22
26
  import { type Env, type InstancePaths, resolvePaths, resolvePathsFor } from "../instance/paths.ts";
23
27
  import { prepareSocketDir } from "../instance/socket.ts";
@@ -52,7 +56,38 @@ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): str
52
56
  * `daemon run` is. */
53
57
  export async function harnessFor(env: Env, dir: string): Promise<Harness> {
54
58
  const path = isAbsolute(dir) ? dir : resolve(dir);
55
- return (await loadConfig(resolvePaths(env).configDir, path)).harness;
59
+ const found = (await known(env)).instances.find((one) => one.dir === path);
60
+ return found?.config.harness ?? DEFAULT_HARNESS;
61
+ }
62
+
63
+ /** What this host runs: the settings that are applied, read again from the
64
+ * files when they check out.
65
+ *
66
+ * Every command goes through the one path — read, check, apply — so what a
67
+ * command acts on is what a start would run. A config that does not check out
68
+ * leaves the applied one standing, which is what keeps a command about one
69
+ * instance working while another instance's file is being edited. */
70
+ export async function known(env: Env): Promise<Satisfied> {
71
+ const paths = resolvePaths(env);
72
+ const read = await evaluate(paths.configDir);
73
+ return read.satisfied ?? applied(paths.stateRoot) ?? EMPTY_SATISFIED;
74
+ }
75
+
76
+ const EMPTY_SATISFIED: Satisfied = {
77
+ endpoints: [],
78
+ supervisor: { instances: [] },
79
+ instances: [],
80
+ };
81
+
82
+ /** Read the files, check them, and write down what holds — the one thing a
83
+ * start, a reload, an `add` and a `remove` all do. */
84
+ export async function reload(env: Env): Promise<{
85
+ satisfied: Satisfied;
86
+ problems: readonly ConfigProblem[];
87
+ }> {
88
+ const paths = resolvePaths(env);
89
+ const settled = await settle(paths.configDir, paths.stateRoot);
90
+ return { satisfied: settled.satisfied, problems: settled.problems };
56
91
  }
57
92
 
58
93
  /** One row of `daemon list`: which config home, and whether anything answers
@@ -60,16 +95,13 @@ export async function harnessFor(env: Env, dir: string): Promise<Harness> {
60
95
  export interface InstanceRow {
61
96
  readonly id: InstanceId;
62
97
  /** The label this instance is listed under: its own file's `name`, which
63
- * defaults to its id. A `daemon run` on a config home no cluster lists has
64
- * none. */
98
+ * defaults to its id. A `daemon run` on a config home nothing states
99
+ * settings for has none. */
65
100
  readonly name?: string;
66
- /** Which cluster this row was read through. An instance in two clusters is
67
- * one instance and one process, listed once under each of them. */
68
- readonly cluster_id?: string;
69
- readonly cluster_name?: string;
70
101
  readonly dir: string;
71
- /** Where peers reach it, as its file states or as its entry implies. */
102
+ /** The address it binds, and the one its peers dial (§7.1). */
72
103
  readonly port?: number;
104
+ readonly endpoint?: string;
73
105
  readonly running: boolean;
74
106
  readonly pid?: number;
75
107
  }
@@ -77,6 +109,9 @@ export interface InstanceRow {
77
109
  /** One row of `daemon status`: the list's row, plus what the instance itself
78
110
  * says when there is one to ask. */
79
111
  export interface StatusRow extends InstanceRow {
112
+ /** What was wrong with the files, where the applied settings are older than
113
+ * what is written. */
114
+ readonly config_problems?: readonly ConfigProblem[];
80
115
  /** What this config home's instance is configured with, after the shared
81
116
  * file's defaults and its own entry are merged (§8.2).
82
117
  *
@@ -95,37 +130,27 @@ export interface StatusRow extends InstanceRow {
95
130
 
96
131
  /** Everything one command needs to reach one config home. */
97
132
  export interface Target {
98
- /** The label this instance is listed under, where a cluster lists it. */
133
+ /** The label this instance is listed under, where it is registered. */
99
134
  readonly name?: string;
100
135
  /** Its id, which is what its file is called. */
101
136
  readonly id?: string;
102
- /** The clusters it belongs to, as the host writes them down. */
103
- readonly clusters?: readonly ClusterInfo[];
104
137
  readonly dir: string;
105
138
  readonly paths: InstancePaths;
106
139
  }
107
140
 
108
- export function targetFor(
109
- env: Env,
110
- dir: string,
111
- name?: string,
112
- id?: string,
113
- clusters?: readonly ClusterInfo[],
114
- ): Target {
141
+ export function targetFor(env: Env, dir: string, name?: string, id?: string): Target {
115
142
  return {
116
143
  ...(name === undefined ? {} : { name }),
117
144
  ...(id === undefined ? {} : { id }),
118
- ...(clusters === undefined ? {} : { clusters }),
119
145
  dir,
120
146
  paths: resolvePathsFor(dir, env),
121
147
  };
122
148
  }
123
149
 
124
- /** The config homes this host's clusters list, each once. */
150
+ /** The config homes this host starts, in the order the supervisor lists them. */
125
151
  export async function registered(env: Env): Promise<Target[]> {
126
- const paths = resolvePaths(env);
127
- return (await loadInstances(paths.configDir)).map((entry) =>
128
- targetFor(env, entry.dir, entry.name, entry.id, entry.clusters),
152
+ return (await known(env)).instances.map((entry) =>
153
+ targetFor(env, entry.dir, entry.name, entry.id),
129
154
  );
130
155
  }
131
156
 
@@ -183,13 +208,6 @@ const STARTING_PRESETS = [
183
208
  /** What `daemon add` takes: the config home the instance answers for, and the
184
209
  * two settings a person would otherwise open the file to write. */
185
210
  export interface AddOptions {
186
- /** Which cluster the instance joins, by id or by name. With none said: the
187
- * one cluster there is, a new one where there is none, and a refusal where
188
- * there are several — the last because which management unit an instance
189
- * belongs to is not something to guess at. An id nothing answers to is a
190
- * cluster this host has not met yet and is made under that id, which is how
191
- * a second host joins one. */
192
- readonly cluster?: string;
193
211
  readonly harness?: Harness;
194
212
  readonly port?: number;
195
213
  }
@@ -285,25 +303,23 @@ export async function add(env: Env, dir: string, options: AddOptions = {}): Prom
285
303
  const harness = options.harness ?? harnessOf(where);
286
304
  const home = configHome(where, harness);
287
305
  const paths = resolvePaths(env);
288
- const all = await loadAll(paths.configDir);
289
- const taken = all.instances.find((one) => one.dir === home);
306
+ const held = await known(env);
307
+ const taken = held.instances.find((one) => one.dir === home);
290
308
  if (taken !== undefined) {
291
309
  throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
292
310
  }
293
- const cluster = clusterFor(all.clusters, options.cluster);
294
311
  // The id the state directory already holds, or a new one written there now:
295
312
  // a config home that was registered before keeps the id everything it issued
296
313
  // is keyed by, and a fresh one gets its id here rather than at its first
297
314
  // start (DR-0001 §2.1).
298
- const target = targetFor(env, home);
299
- const id = instanceIdentity(target.paths.instanceIdFile);
300
- // Every instance listens, because an instance is in the mesh of its cluster
301
- // (§7.1) and a mesh is reached over the entry: what `--port` settles is which
315
+ const id = instanceIdentity(targetFor(env, home).paths.instanceIdFile);
316
+ // Every instance listens, because an instance is an entry of the mesh (§7.1)
317
+ // and a mesh is reached over the entry: what `--port` settles is which
302
318
  // address, not whether there is one.
303
319
  const port =
304
320
  options.port ??
305
321
  (await freePort(
306
- all.instances.flatMap((one) =>
322
+ held.instances.flatMap((one) =>
307
323
  one.config.entry === undefined ? [] : [one.config.entry.port],
308
324
  ),
309
325
  ));
@@ -315,55 +331,63 @@ export async function add(env: Env, dir: string, options: AddOptions = {}): Prom
315
331
  join(paths.instancesDir, instanceFileName(id)),
316
332
  instanceTemplate(name, home, harness, port),
317
333
  );
318
- saveCluster(paths.configDir, {
319
- ...cluster,
320
- instances: cluster.instances.includes(id) ? cluster.instances : [...cluster.instances, id],
321
- });
322
- saveClusters(
323
- paths.configDir,
324
- all.clusters.some((one) => one.id === cluster.id)
325
- ? all.clusters.map((one) => one.id)
326
- : [...all.clusters.map((one) => one.id), cluster.id],
327
- );
328
- return {
329
- ...rowFor(targetFor(env, home, name, id)),
330
- cluster_id: cluster.id,
331
- cluster_name: cluster.name,
332
- port,
333
- };
334
+ // The loopback address, because that is the one this host is certainly
335
+ // reached at. A proxy in front of it is a deployment fact nothing here can
336
+ // see, so an operator who has one edits this row (§8.2).
337
+ saveEndpoints(paths.configDir, [
338
+ ...readEndpointRows(paths.configDir).filter((row) => row.id !== id),
339
+ { id, endpoint: `http://127.0.0.1:${String(port)}/` as EndpointRow["endpoint"] },
340
+ ]);
341
+ saveSupervisor(paths.configDir, [
342
+ ...readSupervised(paths.configDir).filter((one) => one !== id),
343
+ id,
344
+ ]);
345
+ const settled = await reload(env);
346
+ const written = configOf(settled.satisfied, home);
347
+ if (written === undefined) {
348
+ throw new CommandError(
349
+ "internal_error",
350
+ `${home} を書きましたが設定が通りませんでした: ${settled.problems
351
+ .map((one) => `${one.file}: ${one.msg}`)
352
+ .join("; ")}`,
353
+ );
354
+ }
355
+ return rowOf(env, written);
334
356
  }
335
357
 
336
- /** The cluster a command is about.
337
- *
338
- * Named or not, the answer has to be one cluster: a command that acted on "the
339
- * clusters" would be deciding for a person which management unit a thing
340
- * belongs to. An id this host has not met is a cluster that exists elsewhere —
341
- * a cluster spans hosts so it is written down under that id rather than
342
- * refused, which is what lets a second host join one. */
343
- export function clusterFor(
344
- clusters: readonly ClusterSetting[],
345
- named: string | undefined,
346
- ): ClusterSetting {
347
- if (named !== undefined) {
348
- const found = clusters.find((one) => one.id === named || one.name === named);
349
- if (found !== undefined) return found;
350
- if (!ID.test(named)) {
351
- throw new CommandError(
352
- "not_found",
353
- `${named} という cluster はありません (新しく作るなら id を渡してください)`,
354
- );
355
- }
356
- return { id: named, name: named, peers: [], instances: [] };
358
+ /** The mesh as the file holds it right now, for a command that is about to
359
+ * edit it. Read as data rather than through the checks, because a command that
360
+ * adds a row has to be able to fix a file that does not check out yet. */
361
+ function readEndpointRows(configDir: string): EndpointRow[] {
362
+ try {
363
+ const parsed = JSON.parse(readFileSync(join(configDir, ENDPOINTS_FILE), "utf8")) as unknown;
364
+ return Array.isArray(parsed) ? (parsed as EndpointRow[]) : [];
365
+ } catch {
366
+ return [];
357
367
  }
358
- const only = clusters[0];
359
- if (clusters.length === 1 && only !== undefined) return only;
360
- if (clusters.length === 0) {
361
- const id = newId();
362
- return { id, name: id, peers: [], instances: [] };
368
+ }
369
+
370
+ function readSupervised(configDir: string): string[] {
371
+ try {
372
+ const parsed = JSON.parse(readFileSync(join(configDir, SUPERVISOR_FILE), "utf8")) as {
373
+ instances?: unknown;
374
+ };
375
+ return Array.isArray(parsed.instances) ? (parsed.instances as string[]) : [];
376
+ } catch {
377
+ return [];
363
378
  }
364
- throw new CommandError(
365
- "invalid_args",
366
- `cluster が ${String(clusters.length)} 個あります。--cluster <id|name> で選んでください (${clusters.map((one) => one.name).join(", ")})`,
379
+ }
380
+
381
+ function saveEndpoints(configDir: string, rows: readonly EndpointRow[]): void {
382
+ mkdirSync(configDir, { recursive: true });
383
+ writeFileSync(join(configDir, ENDPOINTS_FILE), `${JSON.stringify(rows, null, 2)}\n`);
384
+ }
385
+
386
+ function saveSupervisor(configDir: string, ids: readonly string[]): void {
387
+ mkdirSync(configDir, { recursive: true });
388
+ writeFileSync(
389
+ join(configDir, SUPERVISOR_FILE),
390
+ `${JSON.stringify({ instances: ids }, null, 2)}\n`,
367
391
  );
368
392
  }
369
393
 
@@ -378,23 +402,23 @@ export async function remove(
378
402
  ref: string,
379
403
  ): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
380
404
  const paths = resolvePaths(env);
381
- const all = await loadAll(paths.configDir);
382
- const found = all.instances.find((one) => one.id === ref || one.name === ref || one.dir === ref);
405
+ const found = (await known(env)).instances.find(
406
+ (one) => one.id === ref || one.name === ref || one.dir === ref,
407
+ );
383
408
  if (found === undefined) throw new CommandError("not_found", `${ref} は登録されていません`);
384
- // Out of every cluster that listed it: a person removing an instance is
385
- // removing it from this host, and leaving it in the second cluster would
386
- // leave the supervisor starting it.
387
- for (const cluster of all.clusters) {
388
- if (!cluster.instances.includes(found.id)) continue;
389
- saveCluster(paths.configDir, {
390
- ...cluster,
391
- instances: cluster.instances.filter((one) => one !== found.id),
392
- });
393
- }
409
+ saveSupervisor(
410
+ paths.configDir,
411
+ readSupervised(paths.configDir).filter((one) => one !== found.id),
412
+ );
413
+ saveEndpoints(
414
+ paths.configDir,
415
+ readEndpointRows(paths.configDir).filter((row) => row.id !== found.id),
416
+ );
394
417
  rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
395
418
  // The state directory stays, its id with it: what the instance issued is
396
419
  // keyed by that id, and re-adding the same config home has to answer to the
397
420
  // same one.
421
+ await reload(env);
398
422
  return { id: found.id, name: found.name, dir: found.dir, removed: true };
399
423
  }
400
424
 
@@ -405,8 +429,8 @@ function defaultsTemplate(): string {
405
429
  /** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
406
430
  * そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
407
431
  const defaults: Defaults = ({ config }) => {
408
- // mesh の相手はここには書かない。cluster 内の instance は各 TS の endpoint /
409
- // port から、別 host の endpoint は cluster の peers (ccmsg mesh add) から入る。
432
+ // mesh はここには書かない。誰が居てどこで届くかは endpoints.json が正で、
433
+ // この関数は読めるが変えられない。
410
434
 
411
435
  // dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
412
436
  config.dump.presets = [
@@ -492,36 +516,35 @@ export function rowFor(target: Target): InstanceRow {
492
516
  };
493
517
  }
494
518
 
495
- /** What `daemon list` answers: one row per instance per cluster it is in.
496
- *
497
- * By cluster because that is the unit a person manages — which mesh, which
498
- * authentication records — and an instance in two of them is in both listings,
499
- * as the same id with the same process. */
519
+ /** What `daemon list` answers: the instances this host starts, and whether
520
+ * anything answers for each right now. */
500
521
  export async function list(env: Env): Promise<InstanceRow[]> {
501
- const all = await loadAll(resolvePaths(env).configDir);
502
- const rows: InstanceRow[] = [];
503
- for (const cluster of all.clusters) {
504
- for (const id of cluster.instances) {
505
- const found = all.instances.find((one) => one.id === id);
506
- if (found === undefined) continue;
507
- const target = targetFor(env, found.dir, found.name, found.id, found.clusters);
508
- rows.push({
509
- ...rowFor(target),
510
- cluster_id: cluster.id,
511
- cluster_name: cluster.name,
512
- ...(found.config.entry === undefined ? {} : { port: found.config.entry.port }),
513
- });
514
- }
515
- }
516
- return rows;
522
+ return (await known(env)).instances.map((one) => rowOf(env, one));
523
+ }
524
+
525
+ function rowOf(env: Env, one: InstanceSetting): InstanceRow {
526
+ return {
527
+ ...rowFor(targetFor(env, one.dir, one.name, one.id)),
528
+ ...(one.config.entry === undefined ? {} : { port: one.config.entry.port }),
529
+ ...(one.config.endpoint === undefined ? {} : { endpoint: one.config.endpoint }),
530
+ };
517
531
  }
518
532
 
519
533
  /** Ask one instance how it is. A config home with nothing behind it answers the
520
534
  * list's row and nothing more: not running is a state, not a failure. */
521
535
  export async function status(target: Target): Promise<StatusRow> {
536
+ const read = await evaluate(target.paths.configDir);
537
+ const satisfied = read.satisfied ?? applied(target.paths.stateRoot) ?? EMPTY_SATISFIED;
538
+ const own = configOf(satisfied, target.dir);
522
539
  const row = {
523
540
  ...rowFor(target),
524
- config: await loadConfig(target.paths.configDir, target.dir),
541
+ ...(own?.config.entry === undefined ? {} : { port: own.config.entry.port }),
542
+ ...(own?.config.endpoint === undefined ? {} : { endpoint: own.config.endpoint }),
543
+ config: own?.config ?? DEFAULT_CONFIG,
544
+ // What a person has to be told even though the instance is running: an
545
+ // edit that did not check out is not applied, and the only sign of it
546
+ // otherwise is a setting that did not take (§8.3).
547
+ ...(read.problems.length === 0 ? {} : { config_problems: read.problems }),
525
548
  };
526
549
  const conn = await connect(target.paths.socket);
527
550
  if (conn === undefined) return row;
@@ -8,8 +8,8 @@ import {
8
8
  type Child,
9
9
  configHome,
10
10
  harnessFor,
11
+ reload,
11
12
  prepareFor,
12
- registered,
13
13
  rowFor,
14
14
  type SpawnInstance,
15
15
  spawnInstance,
@@ -120,14 +120,26 @@ export class Supervisor {
120
120
  this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
121
121
  }
122
122
 
123
- /** Read which config homes there are.
123
+ /** Read the edited files, check them, write down what held, and look after
124
+ * what it names (§8.2).
125
+ *
126
+ * This is the one thing that writes the applied settings: the children read
127
+ * them and write nothing, so nothing races over the file and there is one
128
+ * answer to "what is running". A config that does not check out leaves the
129
+ * applied one standing and is written to this supervisor's log — the
130
+ * children of a host are not something a typo in one file should take down.
124
131
  *
125
132
  * In `run` rather than in the constructor because the files are TypeScript
126
133
  * and reading one is an import: a caller holding a supervisor that has not
127
- * run yet is holding one that has not read the list yet, which is the same
134
+ * run yet is holding one that has not read the files yet, which is the same
128
135
  * moment it was already true that nothing had been started. */
129
136
  async #adopt(): Promise<void> {
130
- for (const target of await registered(this.#env)) {
137
+ const settled = await reload(this.#env);
138
+ for (const problem of settled.problems) {
139
+ this.#log({ event: "config refused", file: problem.file, problem: problem.msg });
140
+ }
141
+ for (const one of settled.satisfied.instances) {
142
+ const target = targetFor(this.#env, one.dir, one.name, one.id);
131
143
  this.#units.set(target.dir, new Supervised(target));
132
144
  }
133
145
  }
@@ -7,6 +7,12 @@
7
7
  /** ある config home が動かすもの。 */
8
8
  export type Harness = "claude" | "codex";
9
9
 
10
+ /** mesh の 1 行。endpoint は公開 base URL で、末尾の `/` まで含める。 */
11
+ export interface Endpoint {
12
+ id: string;
13
+ endpoint: string;
14
+ }
15
+
10
16
  /** どこで待ち受け、誰からを受けるか。 */
11
17
  export interface Entry {
12
18
  host: string;
@@ -65,12 +71,12 @@ export interface Dump {
65
71
  }
66
72
 
67
73
  /** 1 instance 分の設定。`config_v2.ts` が返すのも、`instances/instance-<id>.ts` が
68
- * `dir` を足して返すのも、これ。
69
- *
70
- * mesh の相手はここに書かない。この host の instance は `instances/*.ts` の
71
- * port から、別 host の endpoint は `peers.json` (`ccmsg mesh add`) から入る。 */
74
+ * `dir` を足して返すのも、これ。 */
72
75
  export interface Config {
73
76
  harness: Harness;
77
+ /** mesh の全 instance (自分も含む)。`endpoints.json` が正で、読むのは自由だが
78
+ * 違う物を返したら config error。自分がどれかは自分の id の行。 */
79
+ endpoints: Endpoint[];
74
80
  /** 無ければ unix socket だけで serve する。 */
75
81
  entry?: Entry;
76
82
  upstream: Upstream;
@@ -84,11 +90,6 @@ export interface InstanceConfig extends Config {
84
90
  dir: string;
85
91
  /** 人向けのラベル。書かなければ id。ファイル名は id なので、変えても何も動かない。 */
86
92
  name?: string;
87
- /** peer と人がこの instance に届く URL (末尾 `/`)。reverse proxy の後ろに
88
- * 居る instance は、待ち受ける address と届く URL が別で、互いに導けない。
89
- * mesh の一覧にはこの値が載る (= probe が確定する self、handshake の
90
- * `iss` / `aud`、人に見せる URL)。書かなければ待ち受ける address になる。 */
91
- endpoint?: string;
92
93
  }
93
94
 
94
95
  /** `config_v2.ts` が default export する関数。