@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,108 @@
|
|
|
1
|
+
import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
/** The right to be the instance for one config home (§8.3 step 2).
|
|
6
|
+
*
|
|
7
|
+
* A handle, not state (§3.6): it says who is running right now and means
|
|
8
|
+
* nothing once the process is gone. */
|
|
9
|
+
export interface Lock {
|
|
10
|
+
release(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whoever holds it, when we do not. */
|
|
14
|
+
export interface Held {
|
|
15
|
+
readonly pid: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How many rounds of "somebody else's file is stale, drop it and contend
|
|
19
|
+
* again" one acquisition may run. Each round either wins, names a live holder
|
|
20
|
+
* or removes one dead file, so reaching this means the file is being recreated
|
|
21
|
+
* as fast as it is removed, or is something no starter can read or unlink. A
|
|
22
|
+
* loop is the wrong answer to either; the caller is told instead. */
|
|
23
|
+
const ROUNDS = 100;
|
|
24
|
+
|
|
25
|
+
/** Take the lock, or report who has it.
|
|
26
|
+
*
|
|
27
|
+
* The lock file is created by linking a file that already names its pid, so it
|
|
28
|
+
* exists only in the finished state: a starter that finds it never reads an
|
|
29
|
+
* empty file and never mistakes a lock being taken for a stale one. `link` is
|
|
30
|
+
* what excludes — it fails when the name is there — so two processes racing
|
|
31
|
+
* for the same config home cannot both win.
|
|
32
|
+
*
|
|
33
|
+
* What that does not settle is a file left by a process that died without
|
|
34
|
+
* releasing it: the file names its pid, so the next starter asks the OS whether
|
|
35
|
+
* that pid is still there and takes over the file when it is not. Asking is
|
|
36
|
+
* signal 0, which tests for the process without touching it. */
|
|
37
|
+
export function acquireLock(file: string): Lock | Held {
|
|
38
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
39
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
40
|
+
const staged = `${file}.${process.pid}.${randomUUID()}`;
|
|
41
|
+
writeFileSync(staged, `${process.pid}\n`);
|
|
42
|
+
try {
|
|
43
|
+
linkSync(staged, file);
|
|
44
|
+
return { release: () => release(file) };
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw cause;
|
|
47
|
+
} finally {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(staged);
|
|
50
|
+
} catch {
|
|
51
|
+
// The link is what matters; the staging name is scratch either way.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const holder = readHolder(file);
|
|
55
|
+
if (holder !== undefined && alive(holder)) return { pid: holder };
|
|
56
|
+
// Nobody is behind the file: drop it and contend again, so two starters
|
|
57
|
+
// finding the same stale lock still produce one winner.
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(file);
|
|
60
|
+
} catch {
|
|
61
|
+
// Another starter got there first; the next attempt sees its file.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`${file} could neither be taken nor cleared`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether a lock outcome is the lock itself rather than someone else's. */
|
|
68
|
+
export function isHeldByUs(outcome: Lock | Held): outcome is Lock {
|
|
69
|
+
return "release" in outcome;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function release(file: string): void {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(file);
|
|
75
|
+
} catch {
|
|
76
|
+
// Already gone.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The pid the lock file names, whether or not it is still there. Asking who
|
|
81
|
+
* holds a config home is the same question a starter asks, so it is the same
|
|
82
|
+
* file that answers it. */
|
|
83
|
+
export function lockHolder(file: string): number | undefined {
|
|
84
|
+
return readHolder(file);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readHolder(file: string): number | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const pid = Number(readFileSync(file, "utf8").trim());
|
|
90
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a pid names a process that is still there. Signal 0 asks the OS
|
|
97
|
+
* without touching it, which is how both the stale lock and the orphaned
|
|
98
|
+
* socket of a previous run are told from a live one. */
|
|
99
|
+
export function alive(pid: number): boolean {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
} catch (cause) {
|
|
104
|
+
// EPERM means the process exists and belongs to somebody else, which
|
|
105
|
+
// cannot happen here (A4, single uid) but still means "there".
|
|
106
|
+
return (cause as NodeJS.ErrnoException).code === "EPERM";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The instance's log: one writer, and every line on disk before the call
|
|
5
|
+
* returns (§3.6).
|
|
6
|
+
*
|
|
7
|
+
* The reason to read a log is to find out why a process stopped, so the line
|
|
8
|
+
* that matters most is the last one written before it did. A buffered writer
|
|
9
|
+
* is the one that loses exactly that line, so this one appends synchronously
|
|
10
|
+
* and holds nothing — the cost is a write per line, on a file that takes a
|
|
11
|
+
* line per lifecycle event rather than per request. */
|
|
12
|
+
export class Log {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly file: string,
|
|
15
|
+
/** Mirrored to stderr so a foreground run shows what it is doing. */
|
|
16
|
+
private readonly echo: boolean = true,
|
|
17
|
+
) {
|
|
18
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
write(message: string, fields: Record<string, unknown> = {}): void {
|
|
22
|
+
const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
|
|
23
|
+
if (this.echo) process.stderr.write(`${line}\n`);
|
|
24
|
+
try {
|
|
25
|
+
appendFileSync(this.file, `${line}\n`);
|
|
26
|
+
} catch {
|
|
27
|
+
// A log that cannot be written is not a reason to stop serving.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Every path one instance uses, decided in one place (daemon-v2 §8.1).
|
|
6
|
+
*
|
|
7
|
+
* All of them are derived from the config home, because the config home is
|
|
8
|
+
* what an instance is (A2): two instances differ in exactly that, so deriving
|
|
9
|
+
* from it is what keeps their sockets, state and logs apart without anyone
|
|
10
|
+
* configuring the separation. */
|
|
11
|
+
export interface InstancePaths {
|
|
12
|
+
/** The one config home this instance answers for (M6). */
|
|
13
|
+
readonly configHome: string;
|
|
14
|
+
/** What distinguishes this instance's files from another instance's. */
|
|
15
|
+
readonly key: string;
|
|
16
|
+
/** The one file a person edits, shared by every instance on this host: it
|
|
17
|
+
* carries the defaults and the list of config homes, so it is not derived
|
|
18
|
+
* from the config home the way the rest of these are. */
|
|
19
|
+
readonly configFile: string;
|
|
20
|
+
readonly stateDir: string;
|
|
21
|
+
/** The address clients connect to. A symlink to whichever `socketReal` is
|
|
22
|
+
* currently serving, so a client's path outlives the process behind it. */
|
|
23
|
+
readonly socket: string;
|
|
24
|
+
/** The path this process actually binds, named after its pid.
|
|
25
|
+
*
|
|
26
|
+
* Bun unlinks the path it listened on when the listener stops (measured
|
|
27
|
+
* against Bun 1.3.13), so binding the stable path directly would mean a
|
|
28
|
+
* departing instance deleting the address its successor had already taken
|
|
29
|
+
* over (§8.5). Binding a path of its own leaves it deleting only its own. */
|
|
30
|
+
readonly socketReal: string;
|
|
31
|
+
/** Where both of the above live, so the orphan sweep has one directory. */
|
|
32
|
+
readonly socketDir: string;
|
|
33
|
+
/** Where the agent plugins this instance hands out are laid down, one
|
|
34
|
+
* directory per agent. They live with the state because they are derived
|
|
35
|
+
* from the binary: losing them costs an `install` and nothing else. */
|
|
36
|
+
readonly pluginsDir: string;
|
|
37
|
+
readonly pidFile: string;
|
|
38
|
+
readonly lockFile: string;
|
|
39
|
+
readonly logFile: string;
|
|
40
|
+
/** Where this instance's own id is kept (§3.6). It lives with the state
|
|
41
|
+
* because moving an instance is moving that directory: the id has to travel
|
|
42
|
+
* with it, since everything the instance issued is keyed by it. */
|
|
43
|
+
readonly instanceIdFile: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `sun_path` on macOS, the shorter of the two platforms this runs on
|
|
47
|
+
* (measured in `sys/un.h`, 104 there and 108 on Linux). A path at or past it
|
|
48
|
+
* cannot be bound at all, so it is checked rather than discovered as a
|
|
49
|
+
* bind failure. */
|
|
50
|
+
const MAX_SOCKET_PATH = 104;
|
|
51
|
+
|
|
52
|
+
/** The stable address, and the name of the path one process binds. */
|
|
53
|
+
export const SOCKET_NAME = "daemon.sock";
|
|
54
|
+
|
|
55
|
+
export function realSocketName(pid: number): string {
|
|
56
|
+
return `daemon.${pid}.sock`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The real socket names this pattern produces, for the sweep that removes the
|
|
60
|
+
* ones whose process is gone. */
|
|
61
|
+
export const REAL_SOCKET = /^daemon\.(\d+)\.sock$/;
|
|
62
|
+
|
|
63
|
+
export type Env = Record<string, string | undefined>;
|
|
64
|
+
|
|
65
|
+
/** The config home this process belongs to.
|
|
66
|
+
*
|
|
67
|
+
* `CLAUDE_CONFIG_DIR` is what the harness itself reads, so a session and the
|
|
68
|
+
* instance it talks to agree on which one they mean without ccmsg naming it
|
|
69
|
+
* separately. Nothing searches for another one (M6). */
|
|
70
|
+
export function resolveConfigHome(env: Env = process.env): string {
|
|
71
|
+
const named = env["CLAUDE_CONFIG_DIR"];
|
|
72
|
+
if (named !== undefined && named !== "" && isAbsolute(named)) return named;
|
|
73
|
+
return join(home(env), ".claude");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve everything, given the environment.
|
|
77
|
+
*
|
|
78
|
+
* The three categories follow the XDG base directories, with the instance key
|
|
79
|
+
* as the directory under each: config is what a person edits, state is what
|
|
80
|
+
* survives a restart but costs only convenience if lost (the spec names logs
|
|
81
|
+
* and history there), and the socket / pid / lock are handles that live with
|
|
82
|
+
* the state so a temporary directory sweep cannot take the socket out from
|
|
83
|
+
* under a running instance. */
|
|
84
|
+
export function resolvePaths(env: Env = process.env): InstancePaths {
|
|
85
|
+
const configHome = resolveConfigHome(env);
|
|
86
|
+
const key = instanceKey(configHome);
|
|
87
|
+
const configDir = resolveConfigDir(env);
|
|
88
|
+
const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
|
|
89
|
+
const socketDir = socketDirFor(stateDir, key);
|
|
90
|
+
return {
|
|
91
|
+
configHome,
|
|
92
|
+
key,
|
|
93
|
+
configFile: join(configDir, "config.json"),
|
|
94
|
+
stateDir,
|
|
95
|
+
socketDir,
|
|
96
|
+
socket: join(socketDir, SOCKET_NAME),
|
|
97
|
+
socketReal: join(socketDir, realSocketName(process.pid)),
|
|
98
|
+
pluginsDir: join(stateDir, "plugins"),
|
|
99
|
+
pidFile: join(stateDir, "daemon.pid"),
|
|
100
|
+
lockFile: join(stateDir, "daemon.lock"),
|
|
101
|
+
logFile: join(stateDir, "daemon.log"),
|
|
102
|
+
instanceIdFile: join(stateDir, "instance.id"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where the shared config file lives.
|
|
107
|
+
*
|
|
108
|
+
* No instance segment, unlike the state: config is what a person edits, and
|
|
109
|
+
* one file listing every config home is what lets them add an instance without
|
|
110
|
+
* already knowing the key ccmsg would derive for it. */
|
|
111
|
+
export function resolveConfigDir(env: Env = process.env): string {
|
|
112
|
+
const direct = env["CCMSG_CONFIG_DIR"];
|
|
113
|
+
if (direct !== undefined && direct !== "") return direct;
|
|
114
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
115
|
+
const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(home(env), ".config");
|
|
116
|
+
return join(base, "ccmsg");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where things that belong to no single instance keep their state — the
|
|
120
|
+
* supervisor's log above all. The instance segment is what a per-instance state
|
|
121
|
+
* directory adds to this, so this is that path without it. */
|
|
122
|
+
export function resolveStateRoot(env: Env = process.env): string {
|
|
123
|
+
const direct = env["CCMSG_STATE_DIR"];
|
|
124
|
+
if (direct !== undefined && direct !== "") return direct;
|
|
125
|
+
const xdg = env["XDG_STATE_HOME"];
|
|
126
|
+
const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(home(env), ".local", "state");
|
|
127
|
+
return join(base, "ccmsg");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where the supervisor answers the commands addressed to it.
|
|
131
|
+
*
|
|
132
|
+
* One socket for the host rather than one per instance, because the supervisor
|
|
133
|
+
* is one process for the host: the config homes it looks after are what a
|
|
134
|
+
* request names, not what it connects to. It sits with the state for the reason
|
|
135
|
+
* an instance's socket does — a temporary directory sweep must not take the
|
|
136
|
+
* address out from under a running process — and falls back to the same short
|
|
137
|
+
* per-uid directory when the state path would not fit in `sun_path`. */
|
|
138
|
+
export function resolveSupervisorSocket(env: Env = process.env): string {
|
|
139
|
+
const beside = join(resolveStateRoot(env), SUPERVISOR_SOCKET);
|
|
140
|
+
if (Buffer.byteLength(beside) < MAX_SOCKET_PATH) return beside;
|
|
141
|
+
return join("/tmp", `ccmsg-${String(process.getuid?.() ?? 0)}`, SUPERVISOR_SOCKET);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const SUPERVISOR_SOCKET = "supervise.sock";
|
|
145
|
+
|
|
146
|
+
/** The shared config file, for a caller that has no instance to resolve. */
|
|
147
|
+
export function resolveConfigFile(env: Env = process.env): string {
|
|
148
|
+
return join(resolveConfigDir(env), "config.json");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A name for one config home that is readable and cannot collide.
|
|
152
|
+
*
|
|
153
|
+
* The readable half is the config home's own last segment, which is what a
|
|
154
|
+
* person recognises; the digest is what keeps two homes of the same name under
|
|
155
|
+
* different parents from sharing a socket. */
|
|
156
|
+
export function instanceKey(configHome: string): string {
|
|
157
|
+
const digest = createHash("sha256").update(configHome).digest("hex").slice(0, 8);
|
|
158
|
+
const name = basename(configHome).replace(/[^A-Za-z0-9._-]/g, "-") || "home";
|
|
159
|
+
return `${name}-${digest}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The home directory, from the environment the caller handed in, so resolving
|
|
163
|
+
* paths is a function of that environment and a test can resolve for a home
|
|
164
|
+
* that is not this process's. */
|
|
165
|
+
function home(env: Env): string {
|
|
166
|
+
const named = env["HOME"];
|
|
167
|
+
return named !== undefined && isAbsolute(named) ? named : homedir();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The three-step fallback: the app's own variable, then XDG, then the spec's
|
|
171
|
+
* default. The app variable names the directory itself, so a test can put an
|
|
172
|
+
* instance somewhere disposable; the other two get the app and instance
|
|
173
|
+
* segments appended. A relative path in an XDG variable is invalid per the
|
|
174
|
+
* spec and is ignored. */
|
|
175
|
+
function appDir(
|
|
176
|
+
env: Env,
|
|
177
|
+
appVar: string,
|
|
178
|
+
xdgVar: string,
|
|
179
|
+
fallback: readonly string[],
|
|
180
|
+
key: string,
|
|
181
|
+
): string {
|
|
182
|
+
const direct = env[appVar];
|
|
183
|
+
if (direct !== undefined && direct !== "") return direct;
|
|
184
|
+
const xdg = env[xdgVar];
|
|
185
|
+
const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(home(env), ...fallback);
|
|
186
|
+
return join(base, "ccmsg", key);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Where the sockets go: beside the state they belong to, unless the longest
|
|
190
|
+
* name that directory would hold does not fit in `sun_path`.
|
|
191
|
+
*
|
|
192
|
+
* The length is judged on the widest real path rather than on the stable one,
|
|
193
|
+
* because the real path is what gets bound and it is the longer of the two.
|
|
194
|
+
* The fallback is a per-uid directory under `/tmp`, short by construction and
|
|
195
|
+
* reached only by a deeply nested state directory. */
|
|
196
|
+
function socketDirFor(stateDir: string, key: string): string {
|
|
197
|
+
const widest = join(stateDir, realSocketName(9_999_999));
|
|
198
|
+
if (Buffer.byteLength(widest) < MAX_SOCKET_PATH) return stateDir;
|
|
199
|
+
return join("/tmp", `ccmsg-${process.getuid?.() ?? 0}`, key);
|
|
200
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { alive } from "./lock.ts";
|
|
4
|
+
import type { InstancePaths } from "./paths.ts";
|
|
5
|
+
import { REAL_SOCKET } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/** Point the stable address at the socket this process bound.
|
|
8
|
+
*
|
|
9
|
+
* Through a temporary name and a rename, because that is the only way to
|
|
10
|
+
* replace a symlink without a moment where the address does not exist:
|
|
11
|
+
* `symlink` itself refuses an existing name, and unlinking first would leave a
|
|
12
|
+
* window in which a client finds nothing rather than finding the predecessor.
|
|
13
|
+
*
|
|
14
|
+
* Called after the listener is up, so the address never names a socket that is
|
|
15
|
+
* not yet accepting. */
|
|
16
|
+
export function publishSocket(paths: InstancePaths): void {
|
|
17
|
+
const temporary = `${paths.socket}.${process.pid}.new`;
|
|
18
|
+
removeQuietly(temporary);
|
|
19
|
+
symlinkSync(basename(paths.socketReal), temporary);
|
|
20
|
+
renameSync(temporary, paths.socket);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Remove the real sockets of runs that are gone.
|
|
24
|
+
*
|
|
25
|
+
* A departing instance takes its own path with it, so what this finds is what
|
|
26
|
+
* a killed one left: the file names the pid that bound it, and the OS is asked
|
|
27
|
+
* whether that pid is still there — the same question the lock asks of the
|
|
28
|
+
* same kind of leftover.
|
|
29
|
+
*
|
|
30
|
+
* The stable address is never swept. It may already point at a successor, and
|
|
31
|
+
* one pointing at a socket that is gone is the "the unix socket refuses"
|
|
32
|
+
* a client reads as this instance having finished leaving (§8.5). */
|
|
33
|
+
export function sweepOrphanSockets(paths: InstancePaths): void {
|
|
34
|
+
let names: string[];
|
|
35
|
+
try {
|
|
36
|
+
names = readdirSync(paths.socketDir);
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
const match = REAL_SOCKET.exec(name);
|
|
42
|
+
if (match?.[1] === undefined) continue;
|
|
43
|
+
const pid = Number(match[1]);
|
|
44
|
+
if (pid === process.pid || alive(pid)) continue;
|
|
45
|
+
removeQuietly(join(paths.socketDir, name));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The socket directory, which is the state directory unless the address would
|
|
50
|
+
* not fit there. Its own mode, because a directory under `/tmp` is not private
|
|
51
|
+
* by construction the way one under the state directory is. */
|
|
52
|
+
export function prepareSocketDir(paths: InstancePaths): void {
|
|
53
|
+
mkdirSync(paths.socketDir, { recursive: true, mode: 0o700 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function removeQuietly(file: string): void {
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(file);
|
|
59
|
+
} catch {
|
|
60
|
+
// Not there, which is the state this wanted.
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/kv/index.ts
ADDED
package/src/kv/merge.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { LAST_LIVE_RETENTION_MS, type Timestamp } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** One key's state: the value it holds, or the record that it was removed.
|
|
4
|
+
*
|
|
5
|
+
* A removal is kept rather than dropped because two instances mirror this
|
|
6
|
+
* store between themselves: an absence says nothing about whether a key was
|
|
7
|
+
* never written or was deleted, so the deletion is a value of its own until it
|
|
8
|
+
* is old enough that no mirror can still be carrying the write it undid. */
|
|
9
|
+
export interface Held {
|
|
10
|
+
/** Absent exactly when the entry is a removal, which is the contract's own
|
|
11
|
+
* rule for an entry (`KvEntry`). */
|
|
12
|
+
value?: unknown;
|
|
13
|
+
updated_at: Timestamp;
|
|
14
|
+
deleted?: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How long a removal is remembered.
|
|
18
|
+
*
|
|
19
|
+
* The same window `last_live` keeps a session that stopped being seen: both
|
|
20
|
+
* bound how long an instance that was away may be gone and still be told what
|
|
21
|
+
* happened while it was, and having them differ would state two answers to one
|
|
22
|
+
* question about the same mesh. */
|
|
23
|
+
export const TOMBSTONE_RETENTION_MS = LAST_LIVE_RETENTION_MS;
|
|
24
|
+
|
|
25
|
+
/** Whether a removal is old enough to forget. */
|
|
26
|
+
export function expired(held: Held, now: Timestamp): boolean {
|
|
27
|
+
return held.deleted === true && now - held.updated_at > TOMBSTONE_RETENTION_MS;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Which of two states of one key stands.
|
|
31
|
+
*
|
|
32
|
+
* The later `updated_at` wins, which is the whole of what the contract
|
|
33
|
+
* promises about a key two instances disagree on (`KvReadArgs`). A removal is
|
|
34
|
+
* a write like any other and wins or loses by the same instant, which is what
|
|
35
|
+
* keeps a delete from being undone by an older write that arrives after it.
|
|
36
|
+
*
|
|
37
|
+
* Two instants that are equal settle only as far as the contract does. A
|
|
38
|
+
* removal beats a value, which both sides decide alike whichever of them is
|
|
39
|
+
* asking; two different values written at the same millisecond are left as
|
|
40
|
+
* each side holds them, because the only rules that would converge them —
|
|
41
|
+
* preferring the local one, or ordering the values themselves — are either not
|
|
42
|
+
* symmetric or not the contract's. The next write to the key settles it. */
|
|
43
|
+
export function mergeHeld(local: Held | undefined, remote: Held | undefined): Held | undefined {
|
|
44
|
+
if (local === undefined) return remote;
|
|
45
|
+
if (remote === undefined) return local;
|
|
46
|
+
if (local.updated_at !== remote.updated_at) {
|
|
47
|
+
return local.updated_at > remote.updated_at ? local : remote;
|
|
48
|
+
}
|
|
49
|
+
return remote.deleted === true ? remote : local;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A namespace as it stands once a mirror of it has arrived. Neither side is
|
|
53
|
+
* changed; what both hold afterwards is this. */
|
|
54
|
+
export function mergeNamespace(
|
|
55
|
+
local: ReadonlyMap<string, Held>,
|
|
56
|
+
remote: ReadonlyMap<string, Held>,
|
|
57
|
+
now: Timestamp = Date.now(),
|
|
58
|
+
): Map<string, Held> {
|
|
59
|
+
const merged = new Map<string, Held>();
|
|
60
|
+
for (const key of new Set([...local.keys(), ...remote.keys()])) {
|
|
61
|
+
const held = mergeHeld(local.get(key), remote.get(key));
|
|
62
|
+
if (held === undefined || expired(held, now)) continue;
|
|
63
|
+
merged.set(key, held);
|
|
64
|
+
}
|
|
65
|
+
return merged;
|
|
66
|
+
}
|
package/src/kv/store.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
KvDeleteArgs,
|
|
5
|
+
KvDeleteResult,
|
|
6
|
+
InstanceId,
|
|
7
|
+
KvEntry,
|
|
8
|
+
KvReadArgs,
|
|
9
|
+
KvReadResult,
|
|
10
|
+
KvWriteArgs,
|
|
11
|
+
KvWriteResult,
|
|
12
|
+
Timestamp,
|
|
13
|
+
} from "@ccmsg/protocol";
|
|
14
|
+
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
15
|
+
import { topicParam, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
16
|
+
import { expired, type Held } from "./merge.ts";
|
|
17
|
+
|
|
18
|
+
export const KV_DIR = "kv";
|
|
19
|
+
|
|
20
|
+
/** The values clients keep here, and the topic that shows them changing.
|
|
21
|
+
*
|
|
22
|
+
* Written down, unlike almost everything else this instance holds (§3.6): a
|
|
23
|
+
* value here was typed by a person and exists nowhere else — the theme a
|
|
24
|
+
* browser is showing is a copy of it, not its source — so losing it on a
|
|
25
|
+
* restart loses what they set. It is not a derived value, which is what M4
|
|
26
|
+
* forbids persisting.
|
|
27
|
+
*
|
|
28
|
+
* One file per namespace, whole-file: a namespace holds a handful of small
|
|
29
|
+
* values, and the whole of it is what both a snapshot and a reload state. The
|
|
30
|
+
* write lands through a temporary and a rename, so a kill leaves either the
|
|
31
|
+
* previous namespace or the new one. */
|
|
32
|
+
export class KvStore implements UpstreamResource {
|
|
33
|
+
readonly #namespaces = new Map<string, Map<string, Held>>();
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly dir: string,
|
|
37
|
+
private readonly self: InstanceId,
|
|
38
|
+
private readonly publish: (topic: string, data: unknown) => void,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
read(args: KvReadArgs): KvReadResult {
|
|
42
|
+
const held = this.#load(args.ns).get(args.key);
|
|
43
|
+
// A removal that is still remembered is a key the namespace does not hold.
|
|
44
|
+
if (held === undefined || held.deleted === true) {
|
|
45
|
+
throw new OpError("not_found", `${args.ns} holds no ${args.key}`);
|
|
46
|
+
}
|
|
47
|
+
return { value: held.value, updated_at: held.updated_at };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Writes one value, unless what the key holds is newer.
|
|
51
|
+
*
|
|
52
|
+
* The instant decides, not the order of arrival: a caller states when the
|
|
53
|
+
* write it is reporting happened, and one that happened while this instance
|
|
54
|
+
* was unreachable must not displace what was written since. The answer is
|
|
55
|
+
* what the key carries now — equal to what the caller stated when its write
|
|
56
|
+
* stands, and later than it when an existing value did. */
|
|
57
|
+
write(args: KvWriteArgs, now: Timestamp = Date.now()): KvWriteResult {
|
|
58
|
+
const entries = this.#load(args.ns);
|
|
59
|
+
const updatedAt = args.updated_at ?? now;
|
|
60
|
+
const held = entries.get(args.key);
|
|
61
|
+
// Older than what the key holds, so it does not displace it. A write that
|
|
62
|
+
// arrived at the same instant does stand: two calls a millisecond apart
|
|
63
|
+
// are not a disagreement between instances, and the second is the newer.
|
|
64
|
+
if (held !== undefined && held.updated_at > updatedAt) {
|
|
65
|
+
return { updated_at: held.updated_at };
|
|
66
|
+
}
|
|
67
|
+
entries.set(args.key, { value: args.value, updated_at: updatedAt });
|
|
68
|
+
this.#persist(args.ns, entries);
|
|
69
|
+
this.publish(`kv:${args.ns}`, {
|
|
70
|
+
entries: [{ key: args.key, value: args.value, updated_at: updatedAt }],
|
|
71
|
+
});
|
|
72
|
+
return { updated_at: updatedAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A key that was not there is no error: the caller wanted the namespace
|
|
76
|
+
* without it, and it is. The removal is still announced, because a subscriber
|
|
77
|
+
* that has the entry has to be told it is gone. */
|
|
78
|
+
delete(args: KvDeleteArgs, now: Timestamp = Date.now()): KvDeleteResult {
|
|
79
|
+
const entries = this.#load(args.ns);
|
|
80
|
+
const before = entries.get(args.key);
|
|
81
|
+
// A removal older than what the key holds undoes nothing, which is the
|
|
82
|
+
// same rule a write is held to.
|
|
83
|
+
if (before !== undefined && before.updated_at > now) return {};
|
|
84
|
+
entries.set(args.key, { updated_at: now, deleted: true });
|
|
85
|
+
this.#persist(args.ns, entries);
|
|
86
|
+
// A removal is announced only when something was there to remove: a
|
|
87
|
+
// subscriber holding no entry has nothing to be told is gone.
|
|
88
|
+
if (before !== undefined && before.deleted !== true) {
|
|
89
|
+
this.publish(`kv:${args.ns}`, {
|
|
90
|
+
entries: [{ key: args.key, updated_at: now, deleted: true }],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Nothing to start or stop: the values are here whether anyone is watching
|
|
97
|
+
* or not, and the file they live in is read the first time the namespace is
|
|
98
|
+
* touched. */
|
|
99
|
+
start(): void {}
|
|
100
|
+
stop(): void {}
|
|
101
|
+
|
|
102
|
+
/** Every entry the namespace holds. A snapshot never carries a removal, since
|
|
103
|
+
* what is not there is simply absent from a whole list. */
|
|
104
|
+
snapshot(topic: string, _conn: Requester): readonly TopicValue[] {
|
|
105
|
+
const ns = topicParam(topic);
|
|
106
|
+
if (ns === undefined) return [];
|
|
107
|
+
const entries: KvEntry[] = [...this.#load(ns)]
|
|
108
|
+
.filter(([, held]) => held.deleted !== true)
|
|
109
|
+
.map(([key, held]) => ({ key, value: held.value, updated_at: held.updated_at }));
|
|
110
|
+
return [{ instance: this.self, data: { entries } }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#load(ns: string, now: Timestamp = Date.now()): Map<string, Held> {
|
|
114
|
+
const known = this.#namespaces.get(ns);
|
|
115
|
+
if (known !== undefined) return forget(known, now);
|
|
116
|
+
const entries = new Map<string, Held>();
|
|
117
|
+
let text: string;
|
|
118
|
+
try {
|
|
119
|
+
text = readFileSync(this.#file(ns), "utf8");
|
|
120
|
+
} catch {
|
|
121
|
+
this.#namespaces.set(ns, entries);
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(text);
|
|
127
|
+
} catch {
|
|
128
|
+
// A file a kill damaged states nothing this instance can act on, and
|
|
129
|
+
// refusing every read of the namespace would be worse than starting it
|
|
130
|
+
// empty: the next write replaces the file.
|
|
131
|
+
parsed = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
134
|
+
for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
|
|
135
|
+
if (typeof held !== "object" || held === null) continue;
|
|
136
|
+
const fields = held as Record<string, unknown>;
|
|
137
|
+
const updatedAt = fields["updated_at"];
|
|
138
|
+
if (typeof updatedAt !== "number") continue;
|
|
139
|
+
entries.set(
|
|
140
|
+
key,
|
|
141
|
+
fields["deleted"] === true
|
|
142
|
+
? { updated_at: updatedAt, deleted: true }
|
|
143
|
+
: { value: fields["value"], updated_at: updatedAt },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.#namespaces.set(ns, entries);
|
|
148
|
+
return forget(entries, now);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#persist(ns: string, entries: Map<string, Held>): void {
|
|
152
|
+
mkdirSync(this.dir, { recursive: true });
|
|
153
|
+
const file = this.#file(ns);
|
|
154
|
+
const body: Record<string, Held> = {};
|
|
155
|
+
for (const [key, held] of entries) body[key] = held;
|
|
156
|
+
const temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
|
|
157
|
+
writeFileSync(temporary, JSON.stringify(body));
|
|
158
|
+
try {
|
|
159
|
+
renameSync(temporary, file);
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
unlinkSync(temporary);
|
|
162
|
+
throw cause;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The namespace's file. A namespace is an identifier, so its name is a file
|
|
167
|
+
* name that cannot reach out of this directory. */
|
|
168
|
+
#file(ns: string): string {
|
|
169
|
+
return join(this.dir, `${ns}.json`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Drop the removals nothing can still be carrying an older write for.
|
|
174
|
+
*
|
|
175
|
+
* Done where the namespace is read rather than on a clock of its own: a timer
|
|
176
|
+
* would have this instance touching a store nobody is asking about, and a
|
|
177
|
+
* removal that outlives its window until the next read is one no read can see
|
|
178
|
+
* anyway. The file keeps it until the namespace is next written, which is the
|
|
179
|
+
* only moment the file is rewritten at all. */
|
|
180
|
+
function forget(entries: Map<string, Held>, now: Timestamp): Map<string, Held> {
|
|
181
|
+
for (const [key, held] of entries) {
|
|
182
|
+
if (expired(held, now)) entries.delete(key);
|
|
183
|
+
}
|
|
184
|
+
return entries;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function kvHandlers(store: KvStore) {
|
|
188
|
+
return {
|
|
189
|
+
kv_read: (input: HandlerInput): KvReadResult => store.read(input.args as unknown as KvReadArgs),
|
|
190
|
+
kv_write: (input: HandlerInput): KvWriteResult =>
|
|
191
|
+
store.write(input.args as unknown as KvWriteArgs),
|
|
192
|
+
kv_delete: (input: HandlerInput): KvDeleteResult =>
|
|
193
|
+
store.delete(input.args as unknown as KvDeleteArgs),
|
|
194
|
+
};
|
|
195
|
+
}
|