@ccmsg/cli 0.10.1 → 0.11.1

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
 
@@ -163,9 +188,16 @@ const STARTING_PRESETS = [
163
188
  },
164
189
  {
165
190
  name: "journal",
166
- description: "日記用。人との往復と worker の答え、思考は要点だけ",
191
+ description: "日記用。人との往復と worker の答え、中断などの合図、思考は要点だけ",
167
192
  opts: {
168
- types: ["message.user", "message.parent", "message.sub.in", "message.team.in", "thinking"],
193
+ types: [
194
+ "message.user",
195
+ "message.parent",
196
+ "message.sub.in",
197
+ "message.team.in",
198
+ "notice",
199
+ "thinking",
200
+ ],
169
201
  },
170
202
  },
171
203
  {
@@ -183,13 +215,6 @@ const STARTING_PRESETS = [
183
215
  /** What `daemon add` takes: the config home the instance answers for, and the
184
216
  * two settings a person would otherwise open the file to write. */
185
217
  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
218
  readonly harness?: Harness;
194
219
  readonly port?: number;
195
220
  }
@@ -285,25 +310,23 @@ export async function add(env: Env, dir: string, options: AddOptions = {}): Prom
285
310
  const harness = options.harness ?? harnessOf(where);
286
311
  const home = configHome(where, harness);
287
312
  const paths = resolvePaths(env);
288
- const all = await loadAll(paths.configDir);
289
- const taken = all.instances.find((one) => one.dir === home);
313
+ const held = await known(env);
314
+ const taken = held.instances.find((one) => one.dir === home);
290
315
  if (taken !== undefined) {
291
316
  throw new CommandError("file_exists", `${home} は既に ${taken.name} として登録されています`);
292
317
  }
293
- const cluster = clusterFor(all.clusters, options.cluster);
294
318
  // The id the state directory already holds, or a new one written there now:
295
319
  // a config home that was registered before keeps the id everything it issued
296
320
  // is keyed by, and a fresh one gets its id here rather than at its first
297
321
  // 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
322
+ const id = instanceIdentity(targetFor(env, home).paths.instanceIdFile);
323
+ // Every instance listens, because an instance is an entry of the mesh (§7.1)
324
+ // and a mesh is reached over the entry: what `--port` settles is which
302
325
  // address, not whether there is one.
303
326
  const port =
304
327
  options.port ??
305
328
  (await freePort(
306
- all.instances.flatMap((one) =>
329
+ held.instances.flatMap((one) =>
307
330
  one.config.entry === undefined ? [] : [one.config.entry.port],
308
331
  ),
309
332
  ));
@@ -315,55 +338,63 @@ export async function add(env: Env, dir: string, options: AddOptions = {}): Prom
315
338
  join(paths.instancesDir, instanceFileName(id)),
316
339
  instanceTemplate(name, home, harness, port),
317
340
  );
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
- };
341
+ // The loopback address, because that is the one this host is certainly
342
+ // reached at. A proxy in front of it is a deployment fact nothing here can
343
+ // see, so an operator who has one edits this row (§8.2).
344
+ saveEndpoints(paths.configDir, [
345
+ ...readEndpointRows(paths.configDir).filter((row) => row.id !== id),
346
+ { id, endpoint: `http://127.0.0.1:${String(port)}/` as EndpointRow["endpoint"] },
347
+ ]);
348
+ saveSupervisor(paths.configDir, [
349
+ ...readSupervised(paths.configDir).filter((one) => one !== id),
350
+ id,
351
+ ]);
352
+ const settled = await reload(env);
353
+ const written = configOf(settled.satisfied, home);
354
+ if (written === undefined) {
355
+ throw new CommandError(
356
+ "internal_error",
357
+ `${home} を書きましたが設定が通りませんでした: ${settled.problems
358
+ .map((one) => `${one.file}: ${one.msg}`)
359
+ .join("; ")}`,
360
+ );
361
+ }
362
+ return rowOf(env, written);
334
363
  }
335
364
 
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: [] };
365
+ /** The mesh as the file holds it right now, for a command that is about to
366
+ * edit it. Read as data rather than through the checks, because a command that
367
+ * adds a row has to be able to fix a file that does not check out yet. */
368
+ function readEndpointRows(configDir: string): EndpointRow[] {
369
+ try {
370
+ const parsed = JSON.parse(readFileSync(join(configDir, ENDPOINTS_FILE), "utf8")) as unknown;
371
+ return Array.isArray(parsed) ? (parsed as EndpointRow[]) : [];
372
+ } catch {
373
+ return [];
357
374
  }
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: [] };
375
+ }
376
+
377
+ function readSupervised(configDir: string): string[] {
378
+ try {
379
+ const parsed = JSON.parse(readFileSync(join(configDir, SUPERVISOR_FILE), "utf8")) as {
380
+ instances?: unknown;
381
+ };
382
+ return Array.isArray(parsed.instances) ? (parsed.instances as string[]) : [];
383
+ } catch {
384
+ return [];
363
385
  }
364
- throw new CommandError(
365
- "invalid_args",
366
- `cluster が ${String(clusters.length)} 個あります。--cluster <id|name> で選んでください (${clusters.map((one) => one.name).join(", ")})`,
386
+ }
387
+
388
+ function saveEndpoints(configDir: string, rows: readonly EndpointRow[]): void {
389
+ mkdirSync(configDir, { recursive: true });
390
+ writeFileSync(join(configDir, ENDPOINTS_FILE), `${JSON.stringify(rows, null, 2)}\n`);
391
+ }
392
+
393
+ function saveSupervisor(configDir: string, ids: readonly string[]): void {
394
+ mkdirSync(configDir, { recursive: true });
395
+ writeFileSync(
396
+ join(configDir, SUPERVISOR_FILE),
397
+ `${JSON.stringify({ instances: ids }, null, 2)}\n`,
367
398
  );
368
399
  }
369
400
 
@@ -378,23 +409,23 @@ export async function remove(
378
409
  ref: string,
379
410
  ): Promise<{ id: string; name: string; dir: string; removed: boolean }> {
380
411
  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);
412
+ const found = (await known(env)).instances.find(
413
+ (one) => one.id === ref || one.name === ref || one.dir === ref,
414
+ );
383
415
  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
- }
416
+ saveSupervisor(
417
+ paths.configDir,
418
+ readSupervised(paths.configDir).filter((one) => one !== found.id),
419
+ );
420
+ saveEndpoints(
421
+ paths.configDir,
422
+ readEndpointRows(paths.configDir).filter((row) => row.id !== found.id),
423
+ );
394
424
  rmSync(join(paths.instancesDir, instanceFileName(found.id)), { force: true });
395
425
  // The state directory stays, its id with it: what the instance issued is
396
426
  // keyed by that id, and re-adding the same config home has to answer to the
397
427
  // same one.
428
+ await reload(env);
398
429
  return { id: found.id, name: found.name, dir: found.dir, removed: true };
399
430
  }
400
431
 
@@ -405,8 +436,8 @@ function defaultsTemplate(): string {
405
436
  /** 全 instance に配る値。\`builtin\` は組み込みの既定値 (凍結済み)、\`config\` は
406
437
  * そのコピーなので、書き換えて返す。ここに書いた値を各 instance が受け取る。 */
407
438
  const defaults: Defaults = ({ config }) => {
408
- // mesh の相手はここには書かない。cluster 内の instance は各 TS の endpoint /
409
- // port から、別 host の endpoint は cluster の peers (ccmsg mesh add) から入る。
439
+ // mesh はここには書かない。誰が居てどこで届くかは endpoints.json が正で、
440
+ // この関数は読めるが変えられない。
410
441
 
411
442
  // dump の名前付き選択。prefix は一族を、\`@name\` は他の選択をその場に広げる。
412
443
  config.dump.presets = [
@@ -492,36 +523,35 @@ export function rowFor(target: Target): InstanceRow {
492
523
  };
493
524
  }
494
525
 
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. */
526
+ /** What `daemon list` answers: the instances this host starts, and whether
527
+ * anything answers for each right now. */
500
528
  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;
529
+ return (await known(env)).instances.map((one) => rowOf(env, one));
530
+ }
531
+
532
+ function rowOf(env: Env, one: InstanceSetting): InstanceRow {
533
+ return {
534
+ ...rowFor(targetFor(env, one.dir, one.name, one.id)),
535
+ ...(one.config.entry === undefined ? {} : { port: one.config.entry.port }),
536
+ ...(one.config.endpoint === undefined ? {} : { endpoint: one.config.endpoint }),
537
+ };
517
538
  }
518
539
 
519
540
  /** Ask one instance how it is. A config home with nothing behind it answers the
520
541
  * list's row and nothing more: not running is a state, not a failure. */
521
542
  export async function status(target: Target): Promise<StatusRow> {
543
+ const read = await evaluate(target.paths.configDir);
544
+ const satisfied = read.satisfied ?? applied(target.paths.stateRoot) ?? EMPTY_SATISFIED;
545
+ const own = configOf(satisfied, target.dir);
522
546
  const row = {
523
547
  ...rowFor(target),
524
- config: await loadConfig(target.paths.configDir, target.dir),
548
+ ...(own?.config.entry === undefined ? {} : { port: own.config.entry.port }),
549
+ ...(own?.config.endpoint === undefined ? {} : { endpoint: own.config.endpoint }),
550
+ config: own?.config ?? DEFAULT_CONFIG,
551
+ // What a person has to be told even though the instance is running: an
552
+ // edit that did not check out is not applied, and the only sign of it
553
+ // otherwise is a setting that did not take (§8.3).
554
+ ...(read.problems.length === 0 ? {} : { config_problems: read.problems }),
525
555
  };
526
556
  const conn = await connect(target.paths.socket);
527
557
  if (conn === undefined) return row;
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, unlinkSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { type Env, resolveSupervisorSocket } from "../instance/paths.ts";
4
+ import { WriteQueue } from "../transport/index.ts";
4
5
  import { CommandError, type SuperviseRequest } from "./link.ts";
5
6
  import {
6
7
  awaitGone,
@@ -8,8 +9,8 @@ import {
8
9
  type Child,
9
10
  configHome,
10
11
  harnessFor,
12
+ reload,
11
13
  prepareFor,
12
- registered,
13
14
  rowFor,
14
15
  type SpawnInstance,
15
16
  spawnInstance,
@@ -120,14 +121,26 @@ export class Supervisor {
120
121
  this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
121
122
  }
122
123
 
123
- /** Read which config homes there are.
124
+ /** Read the edited files, check them, write down what held, and look after
125
+ * what it names (§8.2).
126
+ *
127
+ * This is the one thing that writes the applied settings: the children read
128
+ * them and write nothing, so nothing races over the file and there is one
129
+ * answer to "what is running". A config that does not check out leaves the
130
+ * applied one standing and is written to this supervisor's log — the
131
+ * children of a host are not something a typo in one file should take down.
124
132
  *
125
133
  * In `run` rather than in the constructor because the files are TypeScript
126
134
  * 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
135
+ * run yet is holding one that has not read the files yet, which is the same
128
136
  * moment it was already true that nothing had been started. */
129
137
  async #adopt(): Promise<void> {
130
- for (const target of await registered(this.#env)) {
138
+ const settled = await reload(this.#env);
139
+ for (const problem of settled.problems) {
140
+ this.#log({ event: "config refused", file: problem.file, problem: problem.msg });
141
+ }
142
+ for (const one of settled.satisfied.instances) {
143
+ const target = targetFor(this.#env, one.dir, one.name, one.id);
131
144
  this.#units.set(target.dir, new Supervised(target));
132
145
  }
133
146
  }
@@ -182,11 +195,27 @@ export class Supervisor {
182
195
  }
183
196
  const handle = (frame: Record<string, unknown>): Promise<unknown> =>
184
197
  this.handle(frame as unknown as SuperviseRequest);
185
- this.#listener = Bun.listen<{ buffer: string }>({
198
+ this.#listener = Bun.listen<ControlState>({
186
199
  unix: path,
187
200
  socket: {
188
201
  open(socket) {
189
- socket.data = { buffer: "" };
202
+ // Every answer goes through the queue, because `socket.write` takes
203
+ // what fits in the socket buffer and returns a short count for the
204
+ // rest: an answer longer than that — `status --all` on a host with
205
+ // several instances — would otherwise arrive without its newline and
206
+ // leave the caller waiting for a line that never ends.
207
+ const queue = new WriteQueue<Uint8Array>({
208
+ encode: (line) => new TextEncoder().encode(line),
209
+ write(chunk) {
210
+ const written = socket.write(chunk);
211
+ if (written < 0) return undefined; // closing: nothing more will go
212
+ return written === chunk.length ? undefined : chunk.subarray(written);
213
+ },
214
+ flush: () => {
215
+ socket.flush();
216
+ },
217
+ });
218
+ socket.data = { buffer: "", queue };
190
219
  },
191
220
  data(socket, chunk) {
192
221
  socket.data.buffer += new TextDecoder().decode(chunk);
@@ -195,9 +224,12 @@ export class Supervisor {
195
224
  const line = socket.data.buffer.slice(0, at);
196
225
  socket.data.buffer = socket.data.buffer.slice(at + 1);
197
226
  if (line.trim() === "") continue;
198
- void answer(socket, line, handle);
227
+ void answer(socket.data.queue, line, handle);
199
228
  }
200
229
  },
230
+ drain(socket) {
231
+ socket.data.queue.drain();
232
+ },
201
233
  },
202
234
  });
203
235
  chmodSync(path, 0o600);
@@ -477,9 +509,16 @@ export class Supervisor {
477
509
  }
478
510
  }
479
511
 
512
+ /** What one control connection holds: the half-read line, and the answers
513
+ * waiting for a socket that is not taking them all at once. */
514
+ interface ControlState {
515
+ buffer: string;
516
+ queue: WriteQueue<Uint8Array>;
517
+ }
518
+
480
519
  /** Answer one line, in the shape a command reads: the result, or the error. */
481
520
  async function answer(
482
- socket: Bun.Socket<{ buffer: string }>,
521
+ queue: WriteQueue<Uint8Array>,
483
522
  line: string,
484
523
  handle: (frame: Record<string, unknown>) => Promise<unknown>,
485
524
  ): Promise<void> {
@@ -487,18 +526,18 @@ async function answer(
487
526
  try {
488
527
  frame = JSON.parse(line) as Record<string, unknown>;
489
528
  } catch {
490
- socket.write(
529
+ queue.push(
491
530
  `${JSON.stringify({ ok: false, error: { code: "bad_request", msg: "not valid JSON" } })}\n`,
492
531
  );
493
532
  return;
494
533
  }
495
534
  try {
496
- socket.write(`${JSON.stringify({ ok: true, result: await handle(frame) })}\n`);
535
+ queue.push(`${JSON.stringify({ ok: true, result: await handle(frame) })}\n`);
497
536
  } catch (cause) {
498
537
  const error =
499
538
  cause instanceof CommandError
500
539
  ? { code: cause.code, msg: cause.message }
501
540
  : { code: "internal_error", msg: String(cause) };
502
- socket.write(`${JSON.stringify({ ok: false, error })}\n`);
541
+ queue.push(`${JSON.stringify({ ok: false, error })}\n`);
503
542
  }
504
543
  }
@@ -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 する関数。