@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. 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 === "&" ? "&amp;" : char === "<" ? "&lt;" : "&gt;",
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
+ }