@ccmsg/cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { InstanceId } from "@ccmsg/protocol";
|
|
2
|
+
import type { Conn } from "./control.ts";
|
|
3
|
+
import { CommandError } from "./link.ts";
|
|
4
|
+
|
|
5
|
+
/** One instance's whole value on a topic, as the frame carried it.
|
|
6
|
+
*
|
|
7
|
+
* The pair rather than the payload alone, because a cluster topic is a whole
|
|
8
|
+
* value *per instance* (§6.2): two instances state the same topic name, and a
|
|
9
|
+
* payload with the name of its author taken off could not be told from the
|
|
10
|
+
* other's. */
|
|
11
|
+
export interface Snapshot {
|
|
12
|
+
readonly instance: InstanceId;
|
|
13
|
+
readonly data: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** How long a read waits for the instances it expects to hear from.
|
|
17
|
+
*
|
|
18
|
+
* It bounds the wait for a peer that is listed as reachable and does not
|
|
19
|
+
* answer — a link that went down between the greeting and the subscribe. The
|
|
20
|
+
* instances that did answer are what the caller gets, so the budget costs a
|
|
21
|
+
* slow peer's entry rather than the whole read. */
|
|
22
|
+
export const SNAPSHOT_BUDGET_MS = 3_000;
|
|
23
|
+
|
|
24
|
+
/** The fields a frame is read by. Named rather than indexed so the reads are
|
|
25
|
+
* field accesses on a shape, which is what they are: the words are the
|
|
26
|
+
* envelope's own (§ the topic frame), and none of them is a judgement. */
|
|
27
|
+
interface Frame {
|
|
28
|
+
readonly ev?: unknown;
|
|
29
|
+
readonly topic?: unknown;
|
|
30
|
+
readonly instance?: unknown;
|
|
31
|
+
readonly data?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a greeting answered, as the two fields a cluster read needs of it. */
|
|
35
|
+
interface Greeted {
|
|
36
|
+
readonly instance?: unknown;
|
|
37
|
+
readonly instances?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Subscribe to a topic, take the value each expected instance states, and
|
|
41
|
+
* leave.
|
|
42
|
+
*
|
|
43
|
+
* A subscription opens with one `snapshot: true` frame per instance that has a
|
|
44
|
+
* value (§6.2), so the current value of a topic is read by subscribing and
|
|
45
|
+
* stopping at the frames rather than by an op of its own. The local instance's
|
|
46
|
+
* frame is deferred behind the acknowledgement and is therefore already on the
|
|
47
|
+
* wire; the relayed ones are what the budget is for.
|
|
48
|
+
*
|
|
49
|
+
* Later frames are folded in the same way a subscriber would fold them: a
|
|
50
|
+
* second frame from an instance replaces that instance's entry and leaves the
|
|
51
|
+
* others alone. Nothing else is waited for — the first frame from every
|
|
52
|
+
* expected instance ends the read. */
|
|
53
|
+
export async function snapshots(
|
|
54
|
+
conn: Conn,
|
|
55
|
+
topic: string,
|
|
56
|
+
expected: readonly InstanceId[],
|
|
57
|
+
budgetMs: number = SNAPSHOT_BUDGET_MS,
|
|
58
|
+
): Promise<Snapshot[]> {
|
|
59
|
+
const ack = await conn.ask({ op: "topic_subscribe", topic });
|
|
60
|
+
if (ack["ok"] !== true) {
|
|
61
|
+
const error = ack["error"] as { code?: string; msg?: string } | undefined;
|
|
62
|
+
throw new CommandError(
|
|
63
|
+
(error?.code as CommandError["code"] | undefined) ?? "internal_error",
|
|
64
|
+
error?.msg ?? `${topic} を購読できませんでした`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const held = new Map<InstanceId, unknown>();
|
|
68
|
+
const wanted = new Set(expected);
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
const OVER = Symbol("over");
|
|
71
|
+
const budget = new Promise<typeof OVER>((resolve) => {
|
|
72
|
+
timer = setTimeout(() => resolve(OVER), budgetMs);
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
while (wanted.size > 0) {
|
|
76
|
+
// Raced rather than cancelled: a frame that never comes is one this
|
|
77
|
+
// waits out, and the connection is closed by the caller either way.
|
|
78
|
+
const next = await Promise.race([conn.next(), budget]);
|
|
79
|
+
if (next === OVER) break;
|
|
80
|
+
const frame: Frame = next;
|
|
81
|
+
if (frame.ev !== "topic" || frame.topic !== topic) continue;
|
|
82
|
+
const from = frame.instance;
|
|
83
|
+
if (typeof from !== "string") continue;
|
|
84
|
+
held.set(from, frame.data);
|
|
85
|
+
wanted.delete(from);
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
return [...held].map(([instance, data]) => ({ instance, data }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The instances a greeting named, as the set a read of a cluster topic waits
|
|
94
|
+
* for.
|
|
95
|
+
*
|
|
96
|
+
* `all` is every instance the answering one can currently reach, itself
|
|
97
|
+
* included: an unreachable peer has nothing to send and waiting for it would
|
|
98
|
+
* spend the whole budget. Without it the read is about this instance alone,
|
|
99
|
+
* which is the one that is certain to answer. */
|
|
100
|
+
export function expectedInstances(greeting: Record<string, unknown>, all: boolean): InstanceId[] {
|
|
101
|
+
const greeted: Greeted = greeting;
|
|
102
|
+
const self = greeted.instance;
|
|
103
|
+
const here = typeof self === "string" ? [self] : [];
|
|
104
|
+
if (!all) return here;
|
|
105
|
+
const listed = greeted.instances;
|
|
106
|
+
if (!Array.isArray(listed)) return here;
|
|
107
|
+
const reachable = listed
|
|
108
|
+
.filter((one): one is { id: string; reachable?: boolean } => {
|
|
109
|
+
if (typeof one !== "object" || one === null) return false;
|
|
110
|
+
const row = one as { id?: unknown; reachable?: unknown };
|
|
111
|
+
return typeof row.id === "string" && row.reachable !== false;
|
|
112
|
+
})
|
|
113
|
+
.map((one) => one.id);
|
|
114
|
+
return [...new Set([...here, ...reachable])];
|
|
115
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { type Env, resolveSupervisorSocket } from "../instance/paths.ts";
|
|
4
|
+
import { CommandError, type SuperviseRequest } from "./link.ts";
|
|
5
|
+
import {
|
|
6
|
+
awaitGone,
|
|
7
|
+
awaitSocket,
|
|
8
|
+
type Child,
|
|
9
|
+
configHome,
|
|
10
|
+
prepareFor,
|
|
11
|
+
registered,
|
|
12
|
+
rowFor,
|
|
13
|
+
type SpawnInstance,
|
|
14
|
+
spawnInstance,
|
|
15
|
+
START_TIMEOUT_MS,
|
|
16
|
+
type StatusRow,
|
|
17
|
+
status as statusOf,
|
|
18
|
+
stop as askToStop,
|
|
19
|
+
type Target,
|
|
20
|
+
targetFor,
|
|
21
|
+
} from "./registry.ts";
|
|
22
|
+
|
|
23
|
+
/** How long a restart waits, and how a run stops counting as a failure.
|
|
24
|
+
*
|
|
25
|
+
* The values are chosen rather than derived, so here is what each one is for:
|
|
26
|
+
*
|
|
27
|
+
* - `minMs` is the first wait. An instance that exits because its predecessor
|
|
28
|
+
* still holds the lock needs only the moment that takes, so the first retry
|
|
29
|
+
* is short enough to be invisible.
|
|
30
|
+
* - `maxMs` bounds a spin. The failure this is really for is a config the
|
|
31
|
+
* instance refuses to start with (DV-Q9): it fails in milliseconds and will
|
|
32
|
+
* fail again identically until somebody edits the file, so the cap is the
|
|
33
|
+
* rate at which the supervisor is willing to say so in its log while waiting
|
|
34
|
+
* for that edit.
|
|
35
|
+
* - `steadyMs` is how long a child has to stay up before its next exit is read
|
|
36
|
+
* as a new failure rather than a continuing one. It is longer than `maxMs`
|
|
37
|
+
* because a child that dies at the cap and is restarted must not have its
|
|
38
|
+
* own restart counted as recovery. */
|
|
39
|
+
export interface Backoff {
|
|
40
|
+
readonly minMs: number;
|
|
41
|
+
readonly maxMs: number;
|
|
42
|
+
readonly steadyMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const BACKOFF: Backoff = { minMs: 500, maxMs: 30_000, steadyMs: 60_000 };
|
|
46
|
+
|
|
47
|
+
export interface SuperviseOptions {
|
|
48
|
+
readonly env?: Env;
|
|
49
|
+
readonly spawn?: SpawnInstance;
|
|
50
|
+
readonly backoff?: Backoff;
|
|
51
|
+
/** Where the supervisor says what it did. */
|
|
52
|
+
readonly log?: (line: Record<string, unknown>) => void;
|
|
53
|
+
/** How long a start waits for the child to be serving before it is reported
|
|
54
|
+
* as having failed. */
|
|
55
|
+
readonly startTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One config home the supervisor looks after, and how it is doing.
|
|
59
|
+
*
|
|
60
|
+
* `wanted` is what separates "the child is gone and should come back" from "the
|
|
61
|
+
* child is gone because it was told to go": a restart loop with no such flag
|
|
62
|
+
* cannot be asked to stop one instance without stopping the supervisor. */
|
|
63
|
+
class Supervised {
|
|
64
|
+
child: Child | undefined;
|
|
65
|
+
wanted = true;
|
|
66
|
+
loop: Promise<void> = Promise.resolve();
|
|
67
|
+
readonly #waiting: ((child: Child) => void)[] = [];
|
|
68
|
+
constructor(readonly target: Target) {}
|
|
69
|
+
|
|
70
|
+
/** The restart loop spawns on its own turn of the event loop, so a caller
|
|
71
|
+
* that just asked for a start has no child to watch yet. This is that child,
|
|
72
|
+
* whenever it arrives: without it, "has it failed?" is asked of a handle that
|
|
73
|
+
* is not there and answered as though it had. */
|
|
74
|
+
took(child: Child): void {
|
|
75
|
+
this.child = child;
|
|
76
|
+
for (const waiting of this.#waiting.splice(0)) waiting(child);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
next(): Promise<Child> {
|
|
80
|
+
const child = this.child;
|
|
81
|
+
if (child !== undefined) return Promise.resolve(child);
|
|
82
|
+
return new Promise((resolve) => this.#waiting.push(resolve));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The foreground supervisor: the instances the shared file lists, kept up, and
|
|
87
|
+
* a socket over which they are asked about.
|
|
88
|
+
*
|
|
89
|
+
* The list is read once, when this starts (DV-Q8). What changes it afterwards
|
|
90
|
+
* is a request — `daemon add` and `daemon remove` tell the supervisor as well
|
|
91
|
+
* as the file — rather than the file being re-read, so the supervisor's idea of
|
|
92
|
+
* which instances there are and the file's cannot silently disagree about
|
|
93
|
+
* anything nobody said out loud. */
|
|
94
|
+
export class Supervisor {
|
|
95
|
+
readonly #env: Env;
|
|
96
|
+
readonly #spawn: SpawnInstance;
|
|
97
|
+
readonly #backoff: Backoff;
|
|
98
|
+
readonly #log: (line: Record<string, unknown>) => void;
|
|
99
|
+
readonly #startTimeoutMs: number;
|
|
100
|
+
readonly #units = new Map<string, Supervised>();
|
|
101
|
+
readonly #waits = new Set<() => void>();
|
|
102
|
+
#listener: ReturnType<typeof Bun.listen> | undefined;
|
|
103
|
+
#leaving = false;
|
|
104
|
+
#left: (() => void) | undefined;
|
|
105
|
+
#ran: Promise<void> | undefined;
|
|
106
|
+
|
|
107
|
+
constructor(options: SuperviseOptions = {}) {
|
|
108
|
+
this.#env = options.env ?? process.env;
|
|
109
|
+
this.#spawn = options.spawn ?? spawnInstance;
|
|
110
|
+
this.#backoff = options.backoff ?? BACKOFF;
|
|
111
|
+
this.#startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
|
|
112
|
+
this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
|
|
113
|
+
for (const target of registered(this.#env)) this.#units.set(target.dir, new Supervised(target));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The config homes this supervisor is looking after right now. */
|
|
117
|
+
get targets(): readonly Target[] {
|
|
118
|
+
return [...this.#units.values()].map((unit) => unit.target);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get socketPath(): string {
|
|
122
|
+
return resolveSupervisorSocket(this.#env);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Run until asked to leave: the socket answering, and one restart loop per
|
|
126
|
+
* config home. One instance failing is not a reason to disturb the others. */
|
|
127
|
+
run(): Promise<void> {
|
|
128
|
+
this.#ran ??= this.#serve();
|
|
129
|
+
return this.#ran;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #serve(): Promise<void> {
|
|
133
|
+
await this.#listen();
|
|
134
|
+
for (const unit of this.#units.values()) this.#keep(unit);
|
|
135
|
+
// What ends the run is being asked to, not the children ending: a
|
|
136
|
+
// supervisor with nothing left to look after is still the process a command
|
|
137
|
+
// connects to.
|
|
138
|
+
await new Promise<void>((resolve) => {
|
|
139
|
+
this.#left = resolve;
|
|
140
|
+
});
|
|
141
|
+
await Promise.all([...this.#units.values()].map((unit) => unit.loop));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The control socket: this host's, and this uid's.
|
|
145
|
+
*
|
|
146
|
+
* 0600 because everything reachable through it is a process this uid runs,
|
|
147
|
+
* and A4 puts the boundary at the uid — the mode is what says so on a
|
|
148
|
+
* filesystem where the directory above may not.
|
|
149
|
+
*
|
|
150
|
+
* A path left by a run that was killed is removed first: a socket file with
|
|
151
|
+
* nobody behind it refuses connections, so binding over it is taking an
|
|
152
|
+
* address nothing holds rather than one somebody is using. Two supervisors
|
|
153
|
+
* racing for it is not a case this settles, because the only starter is an
|
|
154
|
+
* init system that runs one. */
|
|
155
|
+
async #listen(): Promise<void> {
|
|
156
|
+
const path = this.socketPath;
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
158
|
+
try {
|
|
159
|
+
unlinkSync(path);
|
|
160
|
+
} catch {
|
|
161
|
+
// Not there, which is the state this wanted.
|
|
162
|
+
}
|
|
163
|
+
const handle = (frame: Record<string, unknown>): Promise<unknown> =>
|
|
164
|
+
this.handle(frame as unknown as SuperviseRequest);
|
|
165
|
+
this.#listener = Bun.listen<{ buffer: string }>({
|
|
166
|
+
unix: path,
|
|
167
|
+
socket: {
|
|
168
|
+
open(socket) {
|
|
169
|
+
socket.data = { buffer: "" };
|
|
170
|
+
},
|
|
171
|
+
data(socket, chunk) {
|
|
172
|
+
socket.data.buffer += new TextDecoder().decode(chunk);
|
|
173
|
+
let at: number;
|
|
174
|
+
while ((at = socket.data.buffer.indexOf("\n")) >= 0) {
|
|
175
|
+
const line = socket.data.buffer.slice(0, at);
|
|
176
|
+
socket.data.buffer = socket.data.buffer.slice(at + 1);
|
|
177
|
+
if (line.trim() === "") continue;
|
|
178
|
+
void answer(socket, line, handle);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
chmodSync(path, 0o600);
|
|
184
|
+
this.#log({ event: "listening", socket: path });
|
|
185
|
+
await Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One request, and what it answers with. Public so a test can put a request
|
|
189
|
+
* without a socket between it and the answer. */
|
|
190
|
+
async handle(request: SuperviseRequest): Promise<unknown> {
|
|
191
|
+
switch (request.op) {
|
|
192
|
+
case "supervise_start":
|
|
193
|
+
return await this.#over(request, (unit) => this.startOne(unit.target.dir));
|
|
194
|
+
case "supervise_stop":
|
|
195
|
+
return await this.#over(request, (unit) => this.stopOne(unit.target.dir));
|
|
196
|
+
case "supervise_restart":
|
|
197
|
+
return await this.#over(request, (unit) => this.restartOne(unit.target.dir));
|
|
198
|
+
case "supervise_status":
|
|
199
|
+
return await this.#over(request, (unit) => statusOf(unit.target));
|
|
200
|
+
case "supervise_add":
|
|
201
|
+
return await this.addOne(this.#named(request));
|
|
202
|
+
case "supervise_remove":
|
|
203
|
+
return this.removeOne(this.#named(request));
|
|
204
|
+
default:
|
|
205
|
+
throw new CommandError("unknown_op", `知らない要求です: ${String(request.op)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One config home, or every one this supervisor looks after.
|
|
210
|
+
*
|
|
211
|
+
* Over `--all`, one config home refusing is reported beside the others rather
|
|
212
|
+
* than in place of them: the answer is a row per instance, and a stop that
|
|
213
|
+
* failed at the second of five would otherwise leave the caller unable to
|
|
214
|
+
* tell which three it reached. */
|
|
215
|
+
async #over<T>(
|
|
216
|
+
request: SuperviseRequest,
|
|
217
|
+
op: (unit: Supervised) => Promise<T>,
|
|
218
|
+
): Promise<T | (T | { dir: string; error: { code: string; msg: string } })[]> {
|
|
219
|
+
if (request.all !== true) {
|
|
220
|
+
const unit = this.#units.get(this.#named(request));
|
|
221
|
+
if (unit === undefined) {
|
|
222
|
+
throw new CommandError("not_found", `${this.#named(request)} は登録されていません`);
|
|
223
|
+
}
|
|
224
|
+
return await op(unit);
|
|
225
|
+
}
|
|
226
|
+
const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
|
|
227
|
+
// Taken as a list first: each step below waits, and a request arriving in
|
|
228
|
+
// between may add or remove one — what `--all` answers about is the set as
|
|
229
|
+
// it stood when it was asked.
|
|
230
|
+
const units = Array.from(this.#units.values());
|
|
231
|
+
for (const unit of units) {
|
|
232
|
+
try {
|
|
233
|
+
answers.push(await op(unit));
|
|
234
|
+
} catch (cause) {
|
|
235
|
+
if (!(cause instanceof CommandError)) throw cause;
|
|
236
|
+
answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return answers;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#named(request: SuperviseRequest): string {
|
|
243
|
+
const dir = request.dir;
|
|
244
|
+
if (dir === undefined) throw new CommandError("invalid_args", "dir か --all が要ります");
|
|
245
|
+
return dir;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Start one child, and answer once it is serving. */
|
|
249
|
+
async startOne(dir: string): Promise<StatusRow> {
|
|
250
|
+
const unit = this.#units.get(dir);
|
|
251
|
+
if (unit === undefined) throw new CommandError("not_found", `${dir} は登録されていません`);
|
|
252
|
+
if (unit.child !== undefined) {
|
|
253
|
+
throw new CommandError(
|
|
254
|
+
"file_exists",
|
|
255
|
+
`${dir} の instance は既に動いています (pid ${String(unit.child.pid)})`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
unit.wanted = true;
|
|
259
|
+
this.#keep(unit);
|
|
260
|
+
await this.#serving(unit);
|
|
261
|
+
return await statusOf(unit.target);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Stop one child, and leave it stopped.
|
|
265
|
+
*
|
|
266
|
+
* Asked over its own socket rather than signalled, so what runs is the
|
|
267
|
+
* ordered shutdown of §8.5 — the same departure a client sees from an
|
|
268
|
+
* `instance_shutdown`. */
|
|
269
|
+
async stopOne(dir: string): Promise<{ dir: string; stopped: boolean }> {
|
|
270
|
+
const unit = this.#units.get(dir);
|
|
271
|
+
if (unit === undefined) throw new CommandError("not_found", `${dir} は登録されていません`);
|
|
272
|
+
const child = unit.child;
|
|
273
|
+
if (child === undefined) {
|
|
274
|
+
throw new CommandError("instance_unreachable", `${dir} の instance は動いていません`);
|
|
275
|
+
}
|
|
276
|
+
// Said before the request, so the restart loop reads it as a departure it
|
|
277
|
+
// asked for rather than one to recover from.
|
|
278
|
+
unit.wanted = false;
|
|
279
|
+
for (const cancel of new Set(this.#waits)) cancel();
|
|
280
|
+
try {
|
|
281
|
+
await askToStop(unit.target);
|
|
282
|
+
} catch {
|
|
283
|
+
child.kill("SIGTERM");
|
|
284
|
+
}
|
|
285
|
+
await child.exited;
|
|
286
|
+
await unit.loop;
|
|
287
|
+
return { dir, stopped: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async restartOne(dir: string): Promise<StatusRow> {
|
|
291
|
+
const unit = this.#units.get(dir);
|
|
292
|
+
if (unit === undefined) throw new CommandError("not_found", `${dir} は登録されていません`);
|
|
293
|
+
if (unit.child !== undefined) {
|
|
294
|
+
await this.stopOne(dir);
|
|
295
|
+
await awaitGone(unit.target.paths, this.#startTimeoutMs);
|
|
296
|
+
}
|
|
297
|
+
return await this.startOne(dir);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Look after one more config home, and start it.
|
|
301
|
+
*
|
|
302
|
+
* The file is written by the command before this is asked, so what this adds
|
|
303
|
+
* is the running half: a config home that is on the list and has nothing
|
|
304
|
+
* behind it is the state `add` exists to leave behind only when there is no
|
|
305
|
+
* supervisor to tell. */
|
|
306
|
+
async addOne(dir: string): Promise<StatusRow> {
|
|
307
|
+
const home = configHome(dir);
|
|
308
|
+
if (this.#units.has(home)) {
|
|
309
|
+
throw new CommandError("file_exists", `${home} は既に見ています`);
|
|
310
|
+
}
|
|
311
|
+
this.#units.set(home, new Supervised(targetFor(this.#env, home)));
|
|
312
|
+
return await this.startOne(home);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Stop looking after one, without stopping it.
|
|
316
|
+
*
|
|
317
|
+
* The child is left running because a list edit is not a shutdown: a session
|
|
318
|
+
* connected to that instance keeps the instance it is talking to, and
|
|
319
|
+
* `daemon stop` is how one is stopped. What ends is the restarting — when it
|
|
320
|
+
* next goes, nothing brings it back. */
|
|
321
|
+
removeOne(dir: string): { dir: string; removed: boolean } {
|
|
322
|
+
const unit = this.#units.get(dir);
|
|
323
|
+
if (unit === undefined) throw new CommandError("not_found", `${dir} は見ていません`);
|
|
324
|
+
unit.wanted = false;
|
|
325
|
+
this.#units.delete(dir);
|
|
326
|
+
this.#log({ event: "released", dir, pid: unit.child?.pid ?? null });
|
|
327
|
+
return { dir, removed: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The restart loop for one config home. Started per unit rather than once,
|
|
331
|
+
* so a request about one instance never waits on another. */
|
|
332
|
+
#keep(unit: Supervised): void {
|
|
333
|
+
unit.loop = unit.loop.then(async () => {
|
|
334
|
+
let wait = this.#backoff.minMs;
|
|
335
|
+
while (unit.wanted && !this.#leaving) {
|
|
336
|
+
const startedAt = Date.now();
|
|
337
|
+
prepareFor(unit.target);
|
|
338
|
+
const child = this.#spawn(unit.target.dir, this.#env);
|
|
339
|
+
unit.took(child);
|
|
340
|
+
this.#log({ event: "started", dir: unit.target.dir, pid: child.pid });
|
|
341
|
+
const code = await child.exited;
|
|
342
|
+
unit.child = undefined;
|
|
343
|
+
if (!unit.wanted || this.#leaving) {
|
|
344
|
+
this.#log({ event: "stopped", dir: unit.target.dir, code });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// A child that stayed up is a run that ended, not a start that failed,
|
|
348
|
+
// so the next attempt begins at the short wait again.
|
|
349
|
+
wait = Date.now() - startedAt >= this.#backoff.steadyMs ? this.#backoff.minMs : wait;
|
|
350
|
+
this.#log({ event: "restarting", dir: unit.target.dir, code, in_ms: wait });
|
|
351
|
+
await this.#pause(wait);
|
|
352
|
+
wait = Math.min(wait * 2, this.#backoff.maxMs);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Wait for a freshly started child to be serving, or for it to have failed.
|
|
358
|
+
*
|
|
359
|
+
* The socket is the answer either way: an instance that is up has published
|
|
360
|
+
* it, and one that exited never did. */
|
|
361
|
+
async #serving(unit: Supervised): Promise<void> {
|
|
362
|
+
// The child first, because the loop makes the directories as it spawns and a
|
|
363
|
+
// watch cannot attach to a directory that is not there yet: waiting for the
|
|
364
|
+
// socket before the child exists is waiting on nothing but the deadline.
|
|
365
|
+
const child = await unit.next();
|
|
366
|
+
const gone = child.exited.then(
|
|
367
|
+
(code) => new CommandError("internal_error", `起動に失敗しました (exit ${String(code)})`),
|
|
368
|
+
);
|
|
369
|
+
const outcome = await Promise.race([
|
|
370
|
+
awaitSocket(unit.target.paths, this.#startTimeoutMs).then(() => undefined),
|
|
371
|
+
gone,
|
|
372
|
+
]);
|
|
373
|
+
if (outcome instanceof CommandError) throw outcome;
|
|
374
|
+
if (!rowFor(unit.target).running) {
|
|
375
|
+
throw new CommandError("internal_error", `${unit.target.dir} の instance が起動しません`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Wait, unless the supervisor is asked to leave first: a pending restart is
|
|
380
|
+
* not something a shutdown should have to sit through. */
|
|
381
|
+
#pause(ms: number): Promise<void> {
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
this.#waits.delete(cancel);
|
|
385
|
+
resolve();
|
|
386
|
+
}, ms);
|
|
387
|
+
const cancel = (): void => {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
this.#waits.delete(cancel);
|
|
390
|
+
resolve();
|
|
391
|
+
};
|
|
392
|
+
this.#waits.add(cancel);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Stop every child, stop restarting them, and give up the socket.
|
|
397
|
+
*
|
|
398
|
+
* The socket goes first, so a command arriving mid-shutdown is told there is
|
|
399
|
+
* no supervisor rather than being answered by one that is leaving. */
|
|
400
|
+
async stop(): Promise<void> {
|
|
401
|
+
this.#leaving = true;
|
|
402
|
+
this.#listener?.stop(true);
|
|
403
|
+
this.#listener = undefined;
|
|
404
|
+
for (const cancel of new Set(this.#waits)) cancel();
|
|
405
|
+
await Promise.all(
|
|
406
|
+
[...this.#units.values()].map(async (unit) => {
|
|
407
|
+
const child = unit.child;
|
|
408
|
+
if (child === undefined) return;
|
|
409
|
+
try {
|
|
410
|
+
await askToStop(unit.target);
|
|
411
|
+
} catch {
|
|
412
|
+
child.kill("SIGTERM");
|
|
413
|
+
}
|
|
414
|
+
await child.exited;
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
this.#left?.();
|
|
418
|
+
await this.#ran;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Answer one line, in the shape a command reads: the result, or the error. */
|
|
423
|
+
async function answer(
|
|
424
|
+
socket: Bun.Socket<{ buffer: string }>,
|
|
425
|
+
line: string,
|
|
426
|
+
handle: (frame: Record<string, unknown>) => Promise<unknown>,
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
let frame: Record<string, unknown>;
|
|
429
|
+
try {
|
|
430
|
+
frame = JSON.parse(line) as Record<string, unknown>;
|
|
431
|
+
} catch {
|
|
432
|
+
socket.write(
|
|
433
|
+
`${JSON.stringify({ ok: false, error: { code: "bad_request", msg: "not valid JSON" } })}\n`,
|
|
434
|
+
);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
socket.write(`${JSON.stringify({ ok: true, result: await handle(frame) })}\n`);
|
|
439
|
+
} catch (cause) {
|
|
440
|
+
const error =
|
|
441
|
+
cause instanceof CommandError
|
|
442
|
+
? { code: cause.code, msg: cause.message }
|
|
443
|
+
: { code: "internal_error", msg: String(cause) };
|
|
444
|
+
socket.write(`${JSON.stringify({ ok: false, error })}\n`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CallerIdentity, Sid } from "@ccmsg/protocol";
|
|
2
|
+
import type { ConnIdentity } from "./identity.ts";
|
|
3
|
+
|
|
4
|
+
/** A `caller` that cannot be read as an identity. */
|
|
5
|
+
export class CallerError extends Error {}
|
|
6
|
+
|
|
7
|
+
/** The identity a forwarded request names, or nothing when it names none
|
|
8
|
+
* (contract, `CallerIdentity`).
|
|
9
|
+
*
|
|
10
|
+
* The role is read here because it decides which fields the value has to
|
|
11
|
+
* carry — `sid` is present exactly when the role is `session` — and not
|
|
12
|
+
* whether anything is allowed: what the identity may do is decided afterwards,
|
|
13
|
+
* by dispatch, against the attribute table (§3.2). It is the same question
|
|
14
|
+
* `hello` asks of a greeting, whose shape depends on its role in the same way
|
|
15
|
+
* and for the same reason: one schema covers all three roles, so the schema
|
|
16
|
+
* cannot state the rule and the instance does.
|
|
17
|
+
*
|
|
18
|
+
* A violation is refused rather than trimmed. A `caller` naming a role of
|
|
19
|
+
* `user` and a sid describes two different callers, and picking either would
|
|
20
|
+
* run the op as somebody nobody asked for. */
|
|
21
|
+
export function callerOf(frame: unknown): CallerIdentity | CallerError | undefined {
|
|
22
|
+
if (typeof frame !== "object" || frame === null) return undefined;
|
|
23
|
+
const stated = (frame as Record<string, unknown>)["caller"];
|
|
24
|
+
if (stated === undefined) return undefined;
|
|
25
|
+
if (typeof stated !== "object" || stated === null) {
|
|
26
|
+
return new CallerError("a caller is an object naming a role");
|
|
27
|
+
}
|
|
28
|
+
const { role, sid } = stated as { role?: unknown; sid?: unknown };
|
|
29
|
+
if (role !== "session" && role !== "user" && role !== "instance") {
|
|
30
|
+
return new CallerError("a caller names one of the contract's roles");
|
|
31
|
+
}
|
|
32
|
+
if ((role === "session") !== (typeof sid === "string")) {
|
|
33
|
+
return new CallerError("a caller carries a sid when, and only when, it is a session");
|
|
34
|
+
}
|
|
35
|
+
return role === "session" ? { role, sid: sid as Sid } : { role };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How a connection of this instance's own is named to another instance.
|
|
39
|
+
*
|
|
40
|
+
* An anonymous connection names nobody: the ops it can reach before `hello`
|
|
41
|
+
* are answered where they are asked, so there is nothing of it to forward. */
|
|
42
|
+
export function callerOfIdentity(identity: ConnIdentity): CallerIdentity | undefined {
|
|
43
|
+
if (identity.state !== "settled") return undefined;
|
|
44
|
+
return identity.sid === undefined
|
|
45
|
+
? { role: identity.role }
|
|
46
|
+
: { role: identity.role, sid: identity.sid };
|
|
47
|
+
}
|