@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.
- package/package.json +1 -1
- package/src/cli.ts +301 -198
- package/src/daemon/registry.ts +160 -130
- package/src/daemon/supervise.ts +50 -11
- package/src/instance/ccmsg-config.d.ts +10 -9
- package/src/instance/config.ts +374 -310
- package/src/instance/identity.ts +3 -3
- package/src/instance/instance.ts +63 -25
- package/src/instance/paths.ts +24 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +18 -61
- package/src/mesh/wire.ts +1 -29
- package/src/transcript/items/document.ts +8 -32
- package/src/mesh/probe.ts +0 -105
package/src/daemon/registry.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
type
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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: [
|
|
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
|
|
289
|
-
const taken =
|
|
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
|
|
299
|
-
|
|
300
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
409
|
-
//
|
|
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:
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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:
|
|
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;
|
package/src/daemon/supervise.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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<
|
|
198
|
+
this.#listener = Bun.listen<ControlState>({
|
|
186
199
|
unix: path,
|
|
187
200
|
socket: {
|
|
188
201
|
open(socket) {
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 する関数。
|