@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,359 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CommandError } from "../daemon/link.ts";
|
|
5
|
+
import { ENTRY } from "../daemon/registry.ts";
|
|
6
|
+
import { type Env, resolveStateRoot } from "../instance/paths.ts";
|
|
7
|
+
|
|
8
|
+
/** What the host's init system was asked, and what it said.
|
|
9
|
+
*
|
|
10
|
+
* A function rather than a call to `Bun.spawn` in place, so a test drives
|
|
11
|
+
* register and unregister without `launchctl` or `systemctl` on the real host
|
|
12
|
+
* ever hearing about it. */
|
|
13
|
+
export type Run = (command: readonly string[]) => Promise<RunResult>;
|
|
14
|
+
|
|
15
|
+
export interface RunResult {
|
|
16
|
+
readonly code: number;
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly stderr: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const runCommand: Run = async (command) => {
|
|
22
|
+
const proc = Bun.spawn([...command], { stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
24
|
+
new Response(proc.stdout).text(),
|
|
25
|
+
new Response(proc.stderr).text(),
|
|
26
|
+
proc.exited,
|
|
27
|
+
]);
|
|
28
|
+
return { code, stdout, stderr };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What `service status` answers about the supervisor itself. The instances are
|
|
32
|
+
* added by the caller, which is the one that can ask them. */
|
|
33
|
+
export interface ServiceState {
|
|
34
|
+
readonly registered: boolean;
|
|
35
|
+
readonly running: boolean;
|
|
36
|
+
readonly pid?: number;
|
|
37
|
+
/** What the init system itself says, or `null` when it could not be asked.
|
|
38
|
+
*
|
|
39
|
+
* Beside the two fields above rather than folded into them: those are ccmsg's
|
|
40
|
+
* reading, and this is the host's — a supervisor the file registers but
|
|
41
|
+
* launchd never loaded is a disagreement worth being able to see. */
|
|
42
|
+
readonly service: ServiceReport | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The init system's own account of the supervisor, at the few points that say
|
|
46
|
+
* whether it is up and why it last was not. Every field is `null` when the
|
|
47
|
+
* answer did not carry it. */
|
|
48
|
+
export interface ServiceReport {
|
|
49
|
+
/** The init system's own word for the state, unread: `running`,
|
|
50
|
+
* `not running`, `active`, `failed`. */
|
|
51
|
+
readonly state: string | null;
|
|
52
|
+
readonly loaded: boolean | null;
|
|
53
|
+
readonly running: boolean | null;
|
|
54
|
+
readonly pid: number | null;
|
|
55
|
+
/** What the last run exited with. The reason a supervisor that is not there
|
|
56
|
+
* is not there. */
|
|
57
|
+
readonly last_exit: number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const UNKNOWN: ServiceReport = {
|
|
61
|
+
state: null,
|
|
62
|
+
loaded: null,
|
|
63
|
+
running: null,
|
|
64
|
+
pid: null,
|
|
65
|
+
last_exit: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** One host's way of being told to keep a program running.
|
|
69
|
+
*
|
|
70
|
+
* The two implementations differ in every detail and agree on the shape: a file
|
|
71
|
+
* that describes the program, a command that puts it in front of the init
|
|
72
|
+
* system, and a way to ask what became of it. */
|
|
73
|
+
export interface Service {
|
|
74
|
+
readonly kind: "launchd" | "systemd";
|
|
75
|
+
/** The file that describes the supervisor to the init system. */
|
|
76
|
+
readonly unitFile: string;
|
|
77
|
+
/** What that file says. Exposed so a test reads what would be written. */
|
|
78
|
+
unitText(): string;
|
|
79
|
+
register(run: Run): Promise<ServiceState>;
|
|
80
|
+
unregister(run: Run): Promise<{ unregistered: boolean }>;
|
|
81
|
+
/** Where this host keeps what the supervisor said: a file on one, a command
|
|
82
|
+
* on the other. Two shapes rather than one because they are two different
|
|
83
|
+
* things — launchd is told a path to redirect to, and systemd hands its
|
|
84
|
+
* units' output to the journal, which is read by asking for it. */
|
|
85
|
+
logSource(): LogSource;
|
|
86
|
+
start(run: Run): Promise<ServiceState>;
|
|
87
|
+
stop(run: Run): Promise<ServiceState>;
|
|
88
|
+
state(run: Run): Promise<ServiceState>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type LogSource =
|
|
92
|
+
| { readonly kind: "file"; readonly file: string }
|
|
93
|
+
| { readonly kind: "command"; readonly show: string[]; readonly follow: string[] };
|
|
94
|
+
|
|
95
|
+
/** The program the init system is asked to keep running, and the environment it
|
|
96
|
+
* has to be given.
|
|
97
|
+
*
|
|
98
|
+
* The interpreter and script are this process's own (`ENTRY`'s reasoning), and
|
|
99
|
+
* the environment carries the variables that decide which files ccmsg uses: an
|
|
100
|
+
* init system starts a program with almost nothing set, so a supervisor
|
|
101
|
+
* registered from a shell where these were exported and started without them
|
|
102
|
+
* would quietly manage a different set of instances. */
|
|
103
|
+
function supervisorCommand(): string[] {
|
|
104
|
+
return [process.execPath, ENTRY, "daemon", "supervise"];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CARRIED = [
|
|
108
|
+
"PATH",
|
|
109
|
+
"HOME",
|
|
110
|
+
"CCMSG_CONFIG_DIR",
|
|
111
|
+
"CCMSG_STATE_DIR",
|
|
112
|
+
"XDG_CONFIG_HOME",
|
|
113
|
+
"XDG_STATE_HOME",
|
|
114
|
+
] as const;
|
|
115
|
+
|
|
116
|
+
function carriedEnv(env: Env): Record<string, string> {
|
|
117
|
+
const carried: Record<string, string> = {};
|
|
118
|
+
for (const name of CARRIED) {
|
|
119
|
+
const value = env[name];
|
|
120
|
+
if (value !== undefined && value !== "") carried[name] = value;
|
|
121
|
+
}
|
|
122
|
+
return carried;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Where the supervisor's own output goes.
|
|
126
|
+
*
|
|
127
|
+
* Beside the instances' state rather than in a per-instance directory: the
|
|
128
|
+
* supervisor belongs to no single config home, and `service log` is the one
|
|
129
|
+
* reader of it. On systemd the unit's output goes to the journal instead,
|
|
130
|
+
* which is where a user unit's output is read from and what `service log`
|
|
131
|
+
* asks there. */
|
|
132
|
+
export function serviceLogFile(env: Env = process.env): string {
|
|
133
|
+
return join(resolveStateRoot(env), "service.log");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const LAUNCHD_LABEL = "com.github.kawaz.ccmsg";
|
|
137
|
+
export const SYSTEMD_UNIT = "ccmsg.service";
|
|
138
|
+
|
|
139
|
+
/** The service for this host, or a refusal on a host that has neither. */
|
|
140
|
+
export function serviceFor(env: Env = process.env, platform = process.platform): Service {
|
|
141
|
+
if (platform === "darwin") return new LaunchdService(env);
|
|
142
|
+
if (platform === "linux") return new SystemdService(env);
|
|
143
|
+
throw new CommandError("capability_unavailable", `${platform} には登録先がありません`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class LaunchdService implements Service {
|
|
147
|
+
readonly kind = "launchd" as const;
|
|
148
|
+
readonly unitFile: string;
|
|
149
|
+
readonly #env: Env;
|
|
150
|
+
readonly #domain: string;
|
|
151
|
+
|
|
152
|
+
constructor(env: Env) {
|
|
153
|
+
this.#env = env;
|
|
154
|
+
const home = env["HOME"] ?? homedir();
|
|
155
|
+
this.unitFile = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
156
|
+
this.#domain = `gui/${String(process.getuid?.() ?? 0)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `KeepAlive` rather than `RunAtLoad` alone: launchd restarting the
|
|
160
|
+
* supervisor is the outer half of the same job the supervisor does for its
|
|
161
|
+
* instances, and it is what makes a login survive the supervisor crashing. */
|
|
162
|
+
unitText(): string {
|
|
163
|
+
const log = serviceLogFile(this.#env);
|
|
164
|
+
return [
|
|
165
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
166
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
167
|
+
'<plist version="1.0">',
|
|
168
|
+
"<dict>",
|
|
169
|
+
` <key>Label</key><string>${LAUNCHD_LABEL}</string>`,
|
|
170
|
+
" <key>ProgramArguments</key>",
|
|
171
|
+
" <array>",
|
|
172
|
+
...supervisorCommand().map((arg) => ` <string>${escapeXml(arg)}</string>`),
|
|
173
|
+
" </array>",
|
|
174
|
+
" <key>EnvironmentVariables</key>",
|
|
175
|
+
" <dict>",
|
|
176
|
+
...Object.entries(carriedEnv(this.#env)).map(
|
|
177
|
+
([name, value]) => ` <key>${name}</key><string>${escapeXml(value)}</string>`,
|
|
178
|
+
),
|
|
179
|
+
" </dict>",
|
|
180
|
+
" <key>RunAtLoad</key><true/>",
|
|
181
|
+
" <key>KeepAlive</key><true/>",
|
|
182
|
+
` <key>StandardOutPath</key><string>${escapeXml(log)}</string>`,
|
|
183
|
+
` <key>StandardErrorPath</key><string>${escapeXml(log)}</string>`,
|
|
184
|
+
"</dict>",
|
|
185
|
+
"</plist>",
|
|
186
|
+
"",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async register(run: Run): Promise<ServiceState> {
|
|
191
|
+
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
192
|
+
await run(["launchctl", "bootstrap", this.#domain, this.unitFile]);
|
|
193
|
+
return await this.state(run);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
197
|
+
await run(["launchctl", "bootout", `${this.#domain}/${LAUNCHD_LABEL}`]);
|
|
198
|
+
const existed = existsSync(this.unitFile);
|
|
199
|
+
rmSync(this.unitFile, { force: true });
|
|
200
|
+
return { unregistered: existed };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async start(run: Run): Promise<ServiceState> {
|
|
204
|
+
await run(["launchctl", "kickstart", `${this.#domain}/${LAUNCHD_LABEL}`]);
|
|
205
|
+
return await this.state(run);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async stop(run: Run): Promise<ServiceState> {
|
|
209
|
+
await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${LAUNCHD_LABEL}`]);
|
|
210
|
+
return await this.state(run);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async state(run: Run): Promise<ServiceState> {
|
|
214
|
+
const registered = existsSync(this.unitFile);
|
|
215
|
+
const printed = await run(["launchctl", "print", `${this.#domain}/${LAUNCHD_LABEL}`]);
|
|
216
|
+
// A non-zero exit is launchd saying it has no such service, which is not
|
|
217
|
+
// the same as having nothing to say: the report stays `null` only when the
|
|
218
|
+
// question could not be put, and here it was and the answer was "no".
|
|
219
|
+
if (printed.code !== 0) {
|
|
220
|
+
return { registered, running: false, service: { ...UNKNOWN, loaded: false } };
|
|
221
|
+
}
|
|
222
|
+
// `launchctl print` states the pid only while there is a process; a service
|
|
223
|
+
// that is loaded and not running prints its state without one.
|
|
224
|
+
const pid = field(/^\s*pid = (\d+)$/m, printed.stdout);
|
|
225
|
+
const service: ServiceReport = {
|
|
226
|
+
state: /^\s*state = (.+)$/m.exec(printed.stdout)?.[1]?.trim() ?? null,
|
|
227
|
+
loaded: true,
|
|
228
|
+
running: pid !== null,
|
|
229
|
+
pid,
|
|
230
|
+
last_exit: field(/^\s*last exit (?:code|status) = (-?\d+)$/m, printed.stdout),
|
|
231
|
+
};
|
|
232
|
+
if (pid === null) return { registered, running: false, service };
|
|
233
|
+
return { registered, running: true, pid, service };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Where launchd was told to put the supervisor's output. */
|
|
237
|
+
logSource(): LogSource {
|
|
238
|
+
return { kind: "file", file: serviceLogFile(this.#env) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
class SystemdService implements Service {
|
|
243
|
+
readonly kind = "systemd" as const;
|
|
244
|
+
readonly unitFile: string;
|
|
245
|
+
readonly #env: Env;
|
|
246
|
+
|
|
247
|
+
constructor(env: Env) {
|
|
248
|
+
this.#env = env;
|
|
249
|
+
const home = env["HOME"] ?? homedir();
|
|
250
|
+
const config = env["XDG_CONFIG_HOME"] ?? join(home, ".config");
|
|
251
|
+
this.unitFile = join(config, "systemd", "user", SYSTEMD_UNIT);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `Restart=always` for `KeepAlive`'s reason, and `default.target` because a
|
|
255
|
+
* user service belongs to the user's session rather than to the boot. */
|
|
256
|
+
unitText(): string {
|
|
257
|
+
const environment = Object.entries(carriedEnv(this.#env)).map(
|
|
258
|
+
([name, value]) => `Environment=${name}=${value}`,
|
|
259
|
+
);
|
|
260
|
+
return [
|
|
261
|
+
"[Unit]",
|
|
262
|
+
"Description=ccmsg instance supervisor",
|
|
263
|
+
"",
|
|
264
|
+
"[Service]",
|
|
265
|
+
`ExecStart=${supervisorCommand().join(" ")}`,
|
|
266
|
+
...environment,
|
|
267
|
+
"Restart=always",
|
|
268
|
+
"RestartSec=1",
|
|
269
|
+
"",
|
|
270
|
+
"[Install]",
|
|
271
|
+
"WantedBy=default.target",
|
|
272
|
+
"",
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The journal, which is where a user unit's output goes: systemd is not told
|
|
277
|
+
* a path, so there is no file to tail and the log is asked for instead. */
|
|
278
|
+
logSource(): LogSource {
|
|
279
|
+
const unit = ["journalctl", "--user", "-u", SYSTEMD_UNIT];
|
|
280
|
+
return { kind: "command", show: [...unit, "--no-pager"], follow: [...unit, "-f"] };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async register(run: Run): Promise<ServiceState> {
|
|
284
|
+
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
285
|
+
await run(["systemctl", "--user", "daemon-reload"]);
|
|
286
|
+
await run(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT]);
|
|
287
|
+
return await this.state(run);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
291
|
+
await run(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT]);
|
|
292
|
+
const existed = existsSync(this.unitFile);
|
|
293
|
+
rmSync(this.unitFile, { force: true });
|
|
294
|
+
await run(["systemctl", "--user", "daemon-reload"]);
|
|
295
|
+
return { unregistered: existed };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async start(run: Run): Promise<ServiceState> {
|
|
299
|
+
await run(["systemctl", "--user", "start", SYSTEMD_UNIT]);
|
|
300
|
+
return await this.state(run);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async stop(run: Run): Promise<ServiceState> {
|
|
304
|
+
await run(["systemctl", "--user", "stop", SYSTEMD_UNIT]);
|
|
305
|
+
return await this.state(run);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async state(run: Run): Promise<ServiceState> {
|
|
309
|
+
const registered = existsSync(this.unitFile);
|
|
310
|
+
const shown = await run([
|
|
311
|
+
"systemctl",
|
|
312
|
+
"--user",
|
|
313
|
+
"show",
|
|
314
|
+
SYSTEMD_UNIT,
|
|
315
|
+
"--property=MainPID",
|
|
316
|
+
"--property=ActiveState",
|
|
317
|
+
"--property=LoadState",
|
|
318
|
+
"--property=ExecMainStatus",
|
|
319
|
+
]);
|
|
320
|
+
if (shown.code !== 0) return { registered, running: false, service: null };
|
|
321
|
+
const fields = new Map(
|
|
322
|
+
shown.stdout
|
|
323
|
+
.split("\n")
|
|
324
|
+
.map((line) => line.split("="))
|
|
325
|
+
.filter((pair): pair is [string, string] => pair.length === 2)
|
|
326
|
+
.map(([name, value]) => [name as string, value as string]),
|
|
327
|
+
);
|
|
328
|
+
const pid = Number(fields.get("MainPID") ?? "0");
|
|
329
|
+
const running = fields.get("ActiveState") === "active" && pid > 0;
|
|
330
|
+
const exit = fields.get("ExecMainStatus");
|
|
331
|
+
const service: ServiceReport = {
|
|
332
|
+
state: fields.get("ActiveState") ?? null,
|
|
333
|
+
loaded: fields.get("LoadState") === "loaded",
|
|
334
|
+
running,
|
|
335
|
+
pid: pid > 0 ? pid : null,
|
|
336
|
+
last_exit: exit === undefined || exit === "" ? null : Number(exit),
|
|
337
|
+
};
|
|
338
|
+
return { registered, running, ...(running ? { pid } : {}), service };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Lay the unit down, and make sure the log it names has a directory. */
|
|
343
|
+
function write(file: string, text: string, log: string): void {
|
|
344
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
345
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
346
|
+
writeFileSync(file, text);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** One number a report names, or `null` when the answer did not name it. */
|
|
350
|
+
function field(pattern: RegExp, text: string): number | null {
|
|
351
|
+
const found = pattern.exec(text)?.[1];
|
|
352
|
+
return found === undefined ? null : Number(found);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function escapeXml(text: string): string {
|
|
356
|
+
return text.replace(/[&<>]/g, (char) =>
|
|
357
|
+
char === "&" ? "&" : char === "<" ? "<" : ">",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { SessionState, Timestamp } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** What the harness's own row says, for a session that has one. */
|
|
4
|
+
export interface HarnessPresence {
|
|
5
|
+
/** Its status is `waiting`, so a dialog is open (§5.1). */
|
|
6
|
+
waiting: boolean;
|
|
7
|
+
/** The terminal it runs in, when one could be read. Absent means unknown,
|
|
8
|
+
* which is what makes a live session unmanaged. */
|
|
9
|
+
terminal_id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Everything the classification reads, and nothing else (§5.1). */
|
|
13
|
+
export interface SessionInputs {
|
|
14
|
+
/** A connection of this session is open to us right now. */
|
|
15
|
+
connected: boolean;
|
|
16
|
+
/** Present when the harness's `sessions/` has a row for it. */
|
|
17
|
+
harness?: HarnessPresence;
|
|
18
|
+
/** The last time the gateway saw inference for it (§5.1). Absent on an
|
|
19
|
+
* instance with no gateway configured, and for a session it has not seen. */
|
|
20
|
+
gateway_active_at?: Timestamp;
|
|
21
|
+
/** Its transcript's last turn ended on an API error (§5.1, from the fold).
|
|
22
|
+
* Absent for a session whose transcript nothing is following. */
|
|
23
|
+
api_error_stopped?: boolean;
|
|
24
|
+
/** Present when it is in `last_live`. */
|
|
25
|
+
last_live?: { stopped_at?: Timestamp };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How recently the gateway must have seen a session for that alone to count
|
|
29
|
+
* as being alive. */
|
|
30
|
+
export const GATEWAY_LIVE_WINDOW_MS = 5 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** The classification is the contract's `SessionState`, derived here rather
|
|
33
|
+
* than by whoever displays it (§5.2): a client combining raw values of its own
|
|
34
|
+
* would read two instances' lists by two rules.
|
|
35
|
+
*
|
|
36
|
+
* Pinned is not one of them. A person pins a row and the instance holds the
|
|
37
|
+
* mark beside the classification, but the mark never decides which state the
|
|
38
|
+
* row is in (§5.2).
|
|
39
|
+
*
|
|
40
|
+
* Busy and idle are not among them either, and not by omission: a live session
|
|
41
|
+
* carries how busy it is as an attribute of its row, so an instance with no
|
|
42
|
+
* gateway configured loses that attribute and none of these sections.
|
|
43
|
+
*
|
|
44
|
+
* Undefined is the session no section holds: never seen live and not in
|
|
45
|
+
* `last_live`, which is what a sid nobody has heard of looks like. */
|
|
46
|
+
export function classify(
|
|
47
|
+
inputs: SessionInputs,
|
|
48
|
+
now: Timestamp = Date.now(),
|
|
49
|
+
): SessionState | undefined {
|
|
50
|
+
const live =
|
|
51
|
+
inputs.connected ||
|
|
52
|
+
inputs.harness !== undefined ||
|
|
53
|
+
(inputs.gateway_active_at !== undefined &&
|
|
54
|
+
now - inputs.gateway_active_at <= GATEWAY_LIVE_WINDOW_MS);
|
|
55
|
+
// Waiting only says something about a session that is there to wait: an
|
|
56
|
+
// entry in last_live cannot be holding a dialog open.
|
|
57
|
+
if (live && (inputs.harness?.waiting === true || inputs.api_error_stopped === true)) {
|
|
58
|
+
return "waiting";
|
|
59
|
+
}
|
|
60
|
+
if (live) {
|
|
61
|
+
const reachable = inputs.connected || inputs.harness?.terminal_id !== undefined;
|
|
62
|
+
return reachable ? "live" : "live_unmanaged";
|
|
63
|
+
}
|
|
64
|
+
if (inputs.last_live === undefined) return undefined;
|
|
65
|
+
return inputs.last_live.stopped_at === undefined ? "disappeared" : "paused";
|
|
66
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { InstanceId, SessionDumpWriteArgs, SessionDumpWriteResult } from "@ccmsg/protocol";
|
|
4
|
+
import { OpError } from "../dispatch/index.ts";
|
|
5
|
+
import { readRecord, type TranscriptFiles, type TranscriptRecord } from "../transcript/index.ts";
|
|
6
|
+
|
|
7
|
+
/** Where dumps land: one directory under this instance's own state, named
|
|
8
|
+
* after the config home it answers for like every other per-instance path
|
|
9
|
+
* (§8.1). The caller never supplies a path, so there is none to contain. */
|
|
10
|
+
export const DUMPS = "dumps";
|
|
11
|
+
|
|
12
|
+
export interface DumpDeps {
|
|
13
|
+
readonly self: InstanceId;
|
|
14
|
+
readonly stateDir: string;
|
|
15
|
+
readonly files: TranscriptFiles;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Write a session's dump and answer with where it went.
|
|
19
|
+
*
|
|
20
|
+
* What this adds over reading the transcript is a durable artifact whose path
|
|
21
|
+
* can be handed to a successor session, rather than a payload that would
|
|
22
|
+
* travel out through a client and back in again. */
|
|
23
|
+
export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
|
|
24
|
+
if (args.since_at !== undefined && args.since_uuid !== undefined) {
|
|
25
|
+
throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
|
|
26
|
+
}
|
|
27
|
+
if (args.until_at !== undefined && args.until_uuid !== undefined) {
|
|
28
|
+
throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
|
|
29
|
+
}
|
|
30
|
+
const file = deps.files.session(args.sid);
|
|
31
|
+
let text: string;
|
|
32
|
+
try {
|
|
33
|
+
text = readFileSync(file, "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
36
|
+
}
|
|
37
|
+
const entries = collect(text, args);
|
|
38
|
+
const document = {
|
|
39
|
+
sid: args.sid,
|
|
40
|
+
instance: deps.self,
|
|
41
|
+
source: file,
|
|
42
|
+
generated_at: Date.now(),
|
|
43
|
+
entries,
|
|
44
|
+
};
|
|
45
|
+
const dir = join(deps.stateDir, DUMPS);
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
const path = join(dir, `${args.sid}-${document.generated_at}.json`);
|
|
48
|
+
const body = `${JSON.stringify(document, undefined, 2)}\n`;
|
|
49
|
+
writeFileSync(path, body);
|
|
50
|
+
return {
|
|
51
|
+
path,
|
|
52
|
+
instance: deps.self,
|
|
53
|
+
entries: entries.length,
|
|
54
|
+
bytes: Buffer.byteLength(body),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One record of a dump: what was said, by whom, when.
|
|
59
|
+
*
|
|
60
|
+
* The fields are the record's own, taken from the type that reads a transcript
|
|
61
|
+
* line rather than restated here — a dump reports what was read, and a second
|
|
62
|
+
* spelling of those fields would be a second interpretation of the file. */
|
|
63
|
+
type DumpEntry = Pick<TranscriptRecord, "uuid" | "said_at" | "said_by" | "text" | "thinking">;
|
|
64
|
+
|
|
65
|
+
/** The records within the bounds, in the order the transcript holds them.
|
|
66
|
+
*
|
|
67
|
+
* A record bound cuts at that record's position rather than at its clock, so
|
|
68
|
+
* records sharing an instant stay on their own side of the cut — which is the
|
|
69
|
+
* whole reason the contract offers both kinds of bound. */
|
|
70
|
+
function collect(text: string, args: SessionDumpWriteArgs): DumpEntry[] {
|
|
71
|
+
const entries: DumpEntry[] = [];
|
|
72
|
+
// A lower bound by record starts the dump closed: it opens at the record it
|
|
73
|
+
// names, which is included.
|
|
74
|
+
let open = args.since_uuid === undefined;
|
|
75
|
+
for (const line of text.split("\n")) {
|
|
76
|
+
if (line === "") continue;
|
|
77
|
+
const record = readRecord(line);
|
|
78
|
+
if (record === undefined) continue;
|
|
79
|
+
if (!open) {
|
|
80
|
+
if (record.uuid !== args.since_uuid) continue;
|
|
81
|
+
open = true;
|
|
82
|
+
}
|
|
83
|
+
if (args.since_at !== undefined && (record.said_at ?? 0) < args.since_at) continue;
|
|
84
|
+
if (args.until_at !== undefined && (record.said_at ?? 0) > args.until_at) break;
|
|
85
|
+
// The machinery of in-process agents: their turns interleave into the
|
|
86
|
+
// session's own file, and a successor resuming the session is resuming the
|
|
87
|
+
// session rather than them.
|
|
88
|
+
if (args.no_agent === true && record.sidechain) {
|
|
89
|
+
if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
entries.push({
|
|
93
|
+
...(record.uuid === undefined ? {} : { uuid: record.uuid }),
|
|
94
|
+
...(record.said_at === undefined ? {} : { said_at: record.said_at }),
|
|
95
|
+
...(record.said_by === undefined ? {} : { said_by: record.said_by }),
|
|
96
|
+
...(record.text === undefined ? {} : { text: record.text }),
|
|
97
|
+
...(args.no_thinking === true || record.thinking === undefined
|
|
98
|
+
? {}
|
|
99
|
+
: { thinking: record.thinking }),
|
|
100
|
+
});
|
|
101
|
+
// An upper bound by record is inclusive, so the cut is after it.
|
|
102
|
+
if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
|
|
103
|
+
}
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { ForkOrigin, Sid } from "@ccmsg/protocol";
|
|
4
|
+
import { readRecord, type TranscriptFiles } from "../transcript/index.ts";
|
|
5
|
+
|
|
6
|
+
/** How large a transcript may be and still be swept.
|
|
7
|
+
*
|
|
8
|
+
* Finding a seam means reading two files whole, and the answer decorates a
|
|
9
|
+
* divider. A transcript past this size simply yields no seam, which is the same
|
|
10
|
+
* answer the op already gives for a session that is no fork. */
|
|
11
|
+
const SWEEP_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/** Where a forked session stopped being a copy of its ancestor.
|
|
14
|
+
*
|
|
15
|
+
* Forking duplicates the ancestor's records keeping each record id, and
|
|
16
|
+
* rewrites the session id on every copy — so nothing inside the file marks a
|
|
17
|
+
* copied record, and the seam can only be found by comparing against the
|
|
18
|
+
* sibling transcripts beside it.
|
|
19
|
+
*
|
|
20
|
+
* Sharing a first record id means two files begin with the same record, which
|
|
21
|
+
* happens only by duplication. Which of the pair is the copy is then read out
|
|
22
|
+
* of the records themselves, by walking each file's ids against the other's as
|
|
23
|
+
* a set rather than position by position: the ancestor holds records the fork
|
|
24
|
+
* did not copy — a subagent's turns interleave into the parent and not into
|
|
25
|
+
* what was copied — so the fork's run into the ancestor reaches past where the
|
|
26
|
+
* ancestor's run into the fork stops. The longer run is the copy.
|
|
27
|
+
*
|
|
28
|
+
* Only when the two runs are equal does the copying leave no trace of its
|
|
29
|
+
* direction, and only then is creation order asked for. A filesystem that
|
|
30
|
+
* states no creation time answers nothing there rather than falling back to a
|
|
31
|
+
* time that means something else: a transcript is appended to for as long as
|
|
32
|
+
* its session runs, so every other timestamp a file carries orders the two by
|
|
33
|
+
* when they were last written, which is unrelated to which was copied from
|
|
34
|
+
* which — and on a live ancestor points the wrong way.
|
|
35
|
+
*
|
|
36
|
+
* Absent covers a session that is no fork, one whose ancestor file is gone,
|
|
37
|
+
* and that undecidable pair. Nothing left on disk tells them apart, and none
|
|
38
|
+
* of them has a seam to place. */
|
|
39
|
+
export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undefined {
|
|
40
|
+
const file = files.session(sid);
|
|
41
|
+
const ours = recordIds(file);
|
|
42
|
+
const head = ours?.[0];
|
|
43
|
+
if (ours === undefined || head === undefined) return undefined;
|
|
44
|
+
const mine = new Set(ours);
|
|
45
|
+
|
|
46
|
+
const dir = dirname(file);
|
|
47
|
+
let best: { sid: Sid; copied: number } | undefined;
|
|
48
|
+
for (const candidate of files.all()) {
|
|
49
|
+
if (candidate.file === file || dirname(candidate.file) !== dir) continue;
|
|
50
|
+
const theirs = recordIds(candidate.file);
|
|
51
|
+
if (theirs === undefined || theirs[0] !== head) continue;
|
|
52
|
+
const copied = run(ours, new Set(theirs));
|
|
53
|
+
const back = run(theirs, mine);
|
|
54
|
+
// A run that reaches no further than theirs makes us their ancestor rather
|
|
55
|
+
// than their copy; an equal one says the records cannot tell, and creation
|
|
56
|
+
// order is what is left.
|
|
57
|
+
if (copied === 0 || back > copied) continue;
|
|
58
|
+
if (back === copied && !older(candidate.file, file)) continue;
|
|
59
|
+
// Sibling forks of one ancestor share a prefix too, so several files can
|
|
60
|
+
// match; the longest run is the nearest ancestor and the true seam.
|
|
61
|
+
if (best === undefined || copied > best.copied) best = { sid: candidate.sid, copied };
|
|
62
|
+
}
|
|
63
|
+
if (best === undefined) return undefined;
|
|
64
|
+
// The whole file being copied means no forked turns exist yet, and there is
|
|
65
|
+
// no seam to draw below the last record.
|
|
66
|
+
if (best.copied >= ours.length) return undefined;
|
|
67
|
+
const boundary = ours[best.copied - 1];
|
|
68
|
+
if (boundary === undefined) return undefined;
|
|
69
|
+
return { sid: best.sid, boundary_uuid: boundary, copied: best.copied };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How far into a file's records every id is one the other file also holds. */
|
|
73
|
+
function run(ids: readonly string[], other: ReadonlySet<string>): number {
|
|
74
|
+
let reached = 0;
|
|
75
|
+
while (reached < ids.length && other.has(ids[reached] ?? "")) reached += 1;
|
|
76
|
+
return reached;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether one file was created before the other, when the filesystem says.
|
|
80
|
+
*
|
|
81
|
+
* A creation time of zero is a filesystem that does not record one, which is
|
|
82
|
+
* not an ancient file: two of those are simply not ordered, and the pair they
|
|
83
|
+
* belong to gets no answer. */
|
|
84
|
+
function older(candidate: string, file: string): boolean {
|
|
85
|
+
const theirs = bornAt(candidate);
|
|
86
|
+
const ours = bornAt(file);
|
|
87
|
+
if (theirs === undefined || ours === undefined) return false;
|
|
88
|
+
return theirs < ours;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every record id in a file, in order. Undefined for a file too large to
|
|
92
|
+
* sweep or one that could not be read. */
|
|
93
|
+
function recordIds(file: string): string[] | undefined {
|
|
94
|
+
let text: string;
|
|
95
|
+
try {
|
|
96
|
+
if (statSync(file).size > SWEEP_MAX_BYTES) return undefined;
|
|
97
|
+
text = readFileSync(file, "utf8");
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const ids: string[] = [];
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
// Most of a transcript's bytes sit in a handful of very large records, and
|
|
104
|
+
// parsing one to learn it carries no id is the cost this avoids.
|
|
105
|
+
if (line === "" || !line.includes('"uuid"')) continue;
|
|
106
|
+
const uuid = readRecord(line)?.uuid;
|
|
107
|
+
if (uuid !== undefined) ids.push(uuid);
|
|
108
|
+
}
|
|
109
|
+
return ids;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** When a file came into being, for the one pair the records cannot order.
|
|
113
|
+
*
|
|
114
|
+
* Only the creation time, and only when the filesystem states one: everything
|
|
115
|
+
* else a file carries says when it was last written, which for a transcript is
|
|
116
|
+
* how long its session ran rather than when it began. Not rounded to the
|
|
117
|
+
* millisecond the contract states instants in either — two transcripts written
|
|
118
|
+
* moments apart share one, and the whole use of this value is telling which
|
|
119
|
+
* came first. */
|
|
120
|
+
function bornAt(file: string): number | undefined {
|
|
121
|
+
try {
|
|
122
|
+
const born = statSync(file).birthtimeMs;
|
|
123
|
+
return born > 0 ? born : undefined;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|