@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,140 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import type { WorkspaceFolder } from "@ccmsg/protocol";
|
|
5
|
+
|
|
6
|
+
/** The folders a session's editor workspace names, read where the editor
|
|
7
|
+
* writes them.
|
|
8
|
+
*
|
|
9
|
+
* Not folded out of the transcript, because the transcript does not carry them:
|
|
10
|
+
* the harness records what was said and done in a session, and no record of any
|
|
11
|
+
* kind names an editor workspace. The old daemon read them the same way, from
|
|
12
|
+
* the workspace file beside the session's working directory, and that file is
|
|
13
|
+
* the only place the folders are stated.
|
|
14
|
+
*
|
|
15
|
+
* A session that has no workspace file names no folders, which is what an empty
|
|
16
|
+
* list means to the contract and admits no `workspace` path at all. */
|
|
17
|
+
export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
|
|
18
|
+
if (cwd === undefined || !isAbsolute(cwd)) return [];
|
|
19
|
+
const folders: WorkspaceFolder[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
for (const file of workspaceFiles(cwd)) {
|
|
22
|
+
for (const spec of specs(file)) {
|
|
23
|
+
// Relative to the workspace file, which is how an editor reads them.
|
|
24
|
+
const real = directory(resolve(dirname(file), spec.path));
|
|
25
|
+
if (real === undefined || overbroad(real) || seen.has(real)) continue;
|
|
26
|
+
seen.add(real);
|
|
27
|
+
folders.push({ name: spec.name ?? basename(real), path: real });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return folders;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The workspace files directly beside the session's working directory, in a
|
|
34
|
+
* fixed order so the same directory always states its folders the same way. */
|
|
35
|
+
function workspaceFiles(cwd: string): string[] {
|
|
36
|
+
let entries: string[];
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(cwd);
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return entries
|
|
43
|
+
.filter((name) => name.endsWith(WORKSPACE_SUFFIX))
|
|
44
|
+
.sort()
|
|
45
|
+
.map((name) => resolve(cwd, name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What one workspace file declares: the `folders` array, and of each entry the
|
|
49
|
+
* path it names and the name it may give that path. */
|
|
50
|
+
function specs(file: string): { path: string; name?: string }[] {
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(uncommented(readFileSync(file, "utf8")));
|
|
54
|
+
} catch {
|
|
55
|
+
// Written by hand and half-saved, or not a workspace file after all.
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
59
|
+
const declared = (parsed as Record<string, unknown>)["folders"];
|
|
60
|
+
if (!Array.isArray(declared)) return [];
|
|
61
|
+
const specs: { path: string; name?: string }[] = [];
|
|
62
|
+
for (const entry of declared) {
|
|
63
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
64
|
+
const row = entry as Record<string, unknown>;
|
|
65
|
+
const path = row["path"];
|
|
66
|
+
if (typeof path !== "string" || path.length === 0) continue;
|
|
67
|
+
const name = row["name"];
|
|
68
|
+
specs.push({
|
|
69
|
+
path,
|
|
70
|
+
...(typeof name === "string" && name.length > 0 ? { name } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return specs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A workspace file is JSON with comments and trailing commas, which the
|
|
77
|
+
* editors that write it accept. Stripped rather than parsed by a second
|
|
78
|
+
* grammar: what survives is the JSON the file already is. Strings are tracked
|
|
79
|
+
* so that a `//` inside a path is not mistaken for a comment. */
|
|
80
|
+
function uncommented(text: string): string {
|
|
81
|
+
let out = "";
|
|
82
|
+
let inString = false;
|
|
83
|
+
let escaped = false;
|
|
84
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
85
|
+
const char = text[i] as string;
|
|
86
|
+
if (inString) {
|
|
87
|
+
out += char;
|
|
88
|
+
if (escaped) escaped = false;
|
|
89
|
+
else if (char === "\\") escaped = true;
|
|
90
|
+
else if (char === '"') inString = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (char === '"') {
|
|
94
|
+
inString = true;
|
|
95
|
+
out += char;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const next = text[i + 1];
|
|
99
|
+
if (char === "/" && next === "/") {
|
|
100
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
101
|
+
out += "\n";
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (char === "/" && next === "*") {
|
|
105
|
+
const end = text.indexOf("*/", i + 2);
|
|
106
|
+
i = end < 0 ? text.length : end + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out += char;
|
|
110
|
+
}
|
|
111
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What the filesystem calls a folder that is one. A path naming a file, or
|
|
115
|
+
* nothing at all, names no folder and is dropped. */
|
|
116
|
+
function directory(path: string): string | undefined {
|
|
117
|
+
try {
|
|
118
|
+
const real = realpathSync(path);
|
|
119
|
+
return statSync(real).isDirectory() ? real : undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether admitting a folder would admit far more than a workspace.
|
|
126
|
+
*
|
|
127
|
+
* The root and the home directory and anything above them are refused: a
|
|
128
|
+
* workspace file naming one of those turns the `workspace` surface into the
|
|
129
|
+
* whole filesystem, and the folders are an allowlist rather than a hint. */
|
|
130
|
+
function overbroad(real: string): boolean {
|
|
131
|
+
if (real === sep || dirname(real) === real) return true;
|
|
132
|
+
const home = directory(homedir());
|
|
133
|
+
return home !== undefined && (real === home || home.startsWith(withSep(real)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function withSep(path: string): string {
|
|
137
|
+
return path.endsWith(sep) ? path : path + sep;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const WORKSPACE_SUFFIX = ".code-workspace";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TopicSubscribeArgs, TopicSubscribeResult } from "@ccmsg/protocol";
|
|
2
|
+
import { type HandlerInput, OpError } from "../dispatch/index.ts";
|
|
3
|
+
import type { SubscribeOutcome, Topics } from "./topics.ts";
|
|
4
|
+
|
|
5
|
+
/** The two ops that reach the topic mechanism. They carry no logic: dispatch
|
|
6
|
+
* has already validated and allowed the call, so each one names a topic, hands
|
|
7
|
+
* it to `Topics`, and turns the outcome into the contract's own answer. */
|
|
8
|
+
export function topicHandlers(topics: Topics) {
|
|
9
|
+
return {
|
|
10
|
+
topic_subscribe: (input: HandlerInput): TopicSubscribeResult => {
|
|
11
|
+
const topic = topicOf(input);
|
|
12
|
+
answer(topics.subscribe(input.conn, topic), topic);
|
|
13
|
+
return { topic };
|
|
14
|
+
},
|
|
15
|
+
topic_unsubscribe: (input: HandlerInput): TopicSubscribeResult => {
|
|
16
|
+
const topic = topicOf(input);
|
|
17
|
+
answer(topics.unsubscribe(input.conn, topic), topic);
|
|
18
|
+
return { topic };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Safe to read: the op's schema accepted the frame before the handler ran. */
|
|
24
|
+
function topicOf(input: HandlerInput): string {
|
|
25
|
+
return (input.args as unknown as TopicSubscribeArgs).topic;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function answer(outcome: SubscribeOutcome, topic: string): void {
|
|
29
|
+
if (outcome === "ok") return;
|
|
30
|
+
throw new OpError(outcome, refusal(outcome, topic));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function refusal(outcome: Exclude<SubscribeOutcome, "ok">, topic: string): string {
|
|
34
|
+
switch (outcome) {
|
|
35
|
+
case "topic_unknown":
|
|
36
|
+
return `no such topic: ${topic}`;
|
|
37
|
+
case "forbidden":
|
|
38
|
+
return `${topic} is not open to this connection`;
|
|
39
|
+
case "capability_unavailable":
|
|
40
|
+
return `${topic} needs a capability this instance does not have`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Capability,
|
|
3
|
+
type InstanceId,
|
|
4
|
+
type Sid,
|
|
5
|
+
TOPIC_ATTRIBUTES,
|
|
6
|
+
type TopicAttributes,
|
|
7
|
+
type TopicKind,
|
|
8
|
+
topicGranularity,
|
|
9
|
+
topicKind,
|
|
10
|
+
} from "@ccmsg/protocol";
|
|
11
|
+
import type { Requester } from "../dispatch/index.ts";
|
|
12
|
+
|
|
13
|
+
/** What a subscribe decided. `ok` and the three refusals the contract names
|
|
14
|
+
* for the op, so the caller turns an outcome into an error without deciding
|
|
15
|
+
* anything of its own. */
|
|
16
|
+
export type SubscribeOutcome = "ok" | "topic_unknown" | "forbidden" | "capability_unavailable";
|
|
17
|
+
|
|
18
|
+
/** One value on a topic, and the instance that produced it. */
|
|
19
|
+
export interface TopicValue {
|
|
20
|
+
readonly instance: InstanceId;
|
|
21
|
+
readonly data: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whoever owns the values behind a kind of topic, driven by whether anyone is
|
|
25
|
+
* listening.
|
|
26
|
+
*
|
|
27
|
+
* Subscription is the only thing that starts or stops it (daemon-v2 §6.3):
|
|
28
|
+
* `start` runs when a topic goes from no subscribers to one, `stop` when it
|
|
29
|
+
* goes back to none. Both are given the full topic name, because the resource
|
|
30
|
+
* is per name — one tail per `transcript:<sid>`, not one per kind.
|
|
31
|
+
*
|
|
32
|
+
* `snapshot` is asked for the current value, because the owner is where the
|
|
33
|
+
* current value lives (§3.3): the topic mechanism sees changes go past and a
|
|
34
|
+
* change is not a value. For a topic whose frames are elements or an append,
|
|
35
|
+
* the last change is one message or one chunk, while the current value is
|
|
36
|
+
* every message or the tail as it now stands — only the owner can say it.
|
|
37
|
+
* It answers one entry per originating instance, and none at all for a topic
|
|
38
|
+
* with no value to state.
|
|
39
|
+
*
|
|
40
|
+
* The subscribing connection is handed to `snapshot` because one topic's
|
|
41
|
+
* current value differs by who is asking: `inbox` names one topic for the
|
|
42
|
+
* instance, and what is on it for a session is what was said to that session
|
|
43
|
+
* (§4.3). Owners whose value is the same for everyone ignore the argument. */
|
|
44
|
+
export interface UpstreamResource {
|
|
45
|
+
start(topic: string): void;
|
|
46
|
+
stop(topic: string): void;
|
|
47
|
+
snapshot(topic: string, conn: Requester): readonly TopicValue[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The rest of the cluster, as the topic mechanism sees it (§7.4).
|
|
51
|
+
*
|
|
52
|
+
* Two things, both about topics that carry a whole value per instance: what
|
|
53
|
+
* the other instances have already stated, and whether anyone here is
|
|
54
|
+
* listening — because what a subscriber asks of this instance is what this
|
|
55
|
+
* instance asks of its peers. The mesh implements it; an instance without one
|
|
56
|
+
* has no other instance to hear from. */
|
|
57
|
+
export interface RemoteTopics {
|
|
58
|
+
snapshot(topic: string): readonly TopicValue[];
|
|
59
|
+
demand(topic: string, wanted: boolean): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One instance's topics: subscribers, the way a value reaches them, and the
|
|
63
|
+
* suppression every topic shares.
|
|
64
|
+
*
|
|
65
|
+
* There is no class per topic. A topic is a name, a set of connections and the
|
|
66
|
+
* form of the last frame sent under it, so the whole of daemon-v2 §6.1 is this
|
|
67
|
+
* one object and "this topic has no suppression" cannot happen (M5). What it
|
|
68
|
+
* does not hold is the current value: that belongs to whoever owns it (§3.3),
|
|
69
|
+
* and is asked for when a subscriber needs it. */
|
|
70
|
+
export class Topics {
|
|
71
|
+
/** Subscribers per topic name. A connection appears in as many sets as it
|
|
72
|
+
* has subscriptions, and leaves all of them when it closes (§6.3). */
|
|
73
|
+
readonly #subscribers = new Map<string, Set<Requester>>();
|
|
74
|
+
/** The form of the last frame sent, per topic name and then per originating
|
|
75
|
+
* instance — the only thing suppression needs, and the only thing kept.
|
|
76
|
+
* Per instance because a whole value from one instance does not replace
|
|
77
|
+
* another's (§6.2), so neither does it make the other a repeat. */
|
|
78
|
+
readonly #lastSent = new Map<string, Map<InstanceId, string>>();
|
|
79
|
+
readonly #upstream = new Map<TopicKind, UpstreamResource>();
|
|
80
|
+
/** The connections a close listener has already been registered on. Weak
|
|
81
|
+
* because the entry says nothing once the connection is gone. */
|
|
82
|
+
readonly #closers = new WeakSet<Requester>();
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly self: InstanceId,
|
|
86
|
+
private readonly capabilities: ReadonlySet<Capability>,
|
|
87
|
+
private readonly remote?: RemoteTopics,
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
/** Bind the resource that feeds a kind of topic. */
|
|
91
|
+
attach(kind: TopicKind, resource: UpstreamResource): void {
|
|
92
|
+
this.#upstream.set(kind, resource);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The one way a value reaches subscribers (§6.1).
|
|
96
|
+
*
|
|
97
|
+
* `instance` is where the value was produced: this instance for a value of
|
|
98
|
+
* our own, and the originating peer for one mesh relayed to us, which the
|
|
99
|
+
* frame carries onward unchanged (§7.4).
|
|
100
|
+
*
|
|
101
|
+
* `to` narrows the frame to the connections of one session. It exists for
|
|
102
|
+
* `inbox`, whose topic name is one for the instance while its value belongs
|
|
103
|
+
* to a session: without it, delivering to one session would push the message
|
|
104
|
+
* to every subscriber. It changes who receives the frame and nothing else —
|
|
105
|
+
* the frame, and the suppression before it, are the same ones every topic
|
|
106
|
+
* goes through (M5). */
|
|
107
|
+
publish(topic: string, data: unknown, instance: InstanceId = this.self, to?: Sid): void {
|
|
108
|
+
const kind = topicKind(topic);
|
|
109
|
+
if (kind === undefined) return;
|
|
110
|
+
if (replaces(topic)) {
|
|
111
|
+
// The suppression, written once for every topic it applies to (M5). The
|
|
112
|
+
// contract's granularity is the whole of the rule, and only a frame that
|
|
113
|
+
// replaces the value it repeats can be dropped for repeating it: a delta
|
|
114
|
+
// is an occurrence — an inbox message offered again, a `kv` entry
|
|
115
|
+
// restated — and dropping it would lose the offer, not a duplicate.
|
|
116
|
+
const wire = serialize(data);
|
|
117
|
+
const sent = this.#sent(topic);
|
|
118
|
+
if (sent.get(instance) === wire) return;
|
|
119
|
+
sent.set(instance, wire);
|
|
120
|
+
}
|
|
121
|
+
const frame = this.#frame(topic, instance, data, false);
|
|
122
|
+
for (const conn of this.#subscribers.get(topic) ?? []) {
|
|
123
|
+
if (holds(conn, to)) conn.send(frame);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
subscribe(conn: Requester, topic: string): SubscribeOutcome {
|
|
128
|
+
const kind = topicKind(topic);
|
|
129
|
+
if (kind === undefined) return "topic_unknown";
|
|
130
|
+
// Read at the declared type rather than at the literal one the table
|
|
131
|
+
// infers, so the two checks below are the table's rule and not this
|
|
132
|
+
// topic's own row.
|
|
133
|
+
const attrs: TopicAttributes = TOPIC_ATTRIBUTES[kind];
|
|
134
|
+
const identity = conn.identity;
|
|
135
|
+
// Who may hear a topic is the same question the op table answers for ops,
|
|
136
|
+
// asked of the topic table (§11.2). A connection with no settled identity
|
|
137
|
+
// has no role to compare, and cannot reach the op that gets here anyway.
|
|
138
|
+
if (identity.state !== "settled" || !attrs.roles.includes(identity.role)) return "forbidden";
|
|
139
|
+
if (attrs.capability !== undefined && !this.capabilities.has(attrs.capability)) {
|
|
140
|
+
return "capability_unavailable";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let subscribers = this.#subscribers.get(topic);
|
|
144
|
+
if (subscribers === undefined) {
|
|
145
|
+
subscribers = new Set();
|
|
146
|
+
this.#subscribers.set(topic, subscribers);
|
|
147
|
+
// Before the connection joins, so a value the resource produces while
|
|
148
|
+
// starting is held rather than pushed as a change to a subscriber that
|
|
149
|
+
// has not had its snapshot yet.
|
|
150
|
+
this.#upstream.get(kind)?.start(topic);
|
|
151
|
+
// The subscription travels with the same trigger the local resource has:
|
|
152
|
+
// one listener starts it, none stops it (§6.3, §7.4).
|
|
153
|
+
this.remote?.demand(topic, true);
|
|
154
|
+
}
|
|
155
|
+
if (!subscribers.has(conn)) {
|
|
156
|
+
subscribers.add(conn);
|
|
157
|
+
// One listener for the connection rather than one per subscription: a
|
|
158
|
+
// client that subscribes and unsubscribes as it moves between views does
|
|
159
|
+
// so any number of times on one connection, and a listener registered
|
|
160
|
+
// per subscription would be kept for every one of them until it closed.
|
|
161
|
+
// What the single listener releases is every subscription still held,
|
|
162
|
+
// which is what a close means (§6.3).
|
|
163
|
+
if (!this.#closers.has(conn)) {
|
|
164
|
+
this.#closers.add(conn);
|
|
165
|
+
conn.onClose(() => this.dropAll(conn));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The owner states the current value. A topic with no owner attached yet
|
|
169
|
+
// answers nothing, as does one with no value to state (§6.2, event), and
|
|
170
|
+
// in both cases the subscriber starts at the next thing that happens.
|
|
171
|
+
for (const value of this.#upstream.get(kind)?.snapshot(topic, conn) ?? []) {
|
|
172
|
+
conn.deferSend(this.#frame(topic, value.instance, value.data, true));
|
|
173
|
+
}
|
|
174
|
+
// What the other instances last stated, under their own names. A whole
|
|
175
|
+
// value per instance means the subscriber folds these beside ours instead
|
|
176
|
+
// of choosing between them (§6.2), and an instance that has gone is still
|
|
177
|
+
// among them until its value is given up (§7.5).
|
|
178
|
+
for (const value of this.remote?.snapshot(topic) ?? []) {
|
|
179
|
+
conn.deferSend(this.#frame(topic, value.instance, value.data, true));
|
|
180
|
+
}
|
|
181
|
+
return "ok";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Drop one subscription. Repeating it changes nothing, which is what lets
|
|
185
|
+
* the close listener and an explicit unsubscribe both end a subscription. */
|
|
186
|
+
unsubscribe(conn: Requester, topic: string): SubscribeOutcome {
|
|
187
|
+
const kind = topicKind(topic);
|
|
188
|
+
if (kind === undefined) return "topic_unknown";
|
|
189
|
+
const subscribers = this.#subscribers.get(topic);
|
|
190
|
+
if (subscribers === undefined || !subscribers.delete(conn)) return "ok";
|
|
191
|
+
if (subscribers.size > 0) return "ok";
|
|
192
|
+
this.#subscribers.delete(topic);
|
|
193
|
+
// Nothing is listening, so the resource stops. What it last sent is
|
|
194
|
+
// forgotten with it: comparing against a frame from before the resource
|
|
195
|
+
// stopped would suppress the first frame after it starts again.
|
|
196
|
+
this.#lastSent.delete(topic);
|
|
197
|
+
this.#upstream.get(kind)?.stop(topic);
|
|
198
|
+
this.remote?.demand(topic, false);
|
|
199
|
+
return "ok";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Drop every subscription one connection holds.
|
|
203
|
+
*
|
|
204
|
+
* What shutdown calls (§8.5 step 2) before it tells the connections
|
|
205
|
+
* anything: a resource runs while it has a listener (§6.3), so taking the
|
|
206
|
+
* listeners away is what stops the upstream watches — through the same
|
|
207
|
+
* `unsubscribe` a closing connection goes through, rather than a second way
|
|
208
|
+
* to release the same thing. */
|
|
209
|
+
dropAll(conn: Requester): void {
|
|
210
|
+
const held: string[] = [];
|
|
211
|
+
for (const [topic, subscribers] of this.#subscribers) {
|
|
212
|
+
if (subscribers.has(conn)) held.push(topic);
|
|
213
|
+
}
|
|
214
|
+
for (const topic of held) this.unsubscribe(conn, topic);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** How many connections hold a subscription to a topic, counting only those
|
|
218
|
+
* of one session when `to` names one.
|
|
219
|
+
*
|
|
220
|
+
* What delivery asks before it publishes: a message reaches its session
|
|
221
|
+
* through this topic or it does not reach it at all, so whether anyone is
|
|
222
|
+
* listening for that session decides between handing it over and holding it
|
|
223
|
+
* (§4.2). */
|
|
224
|
+
subscriberCount(topic: string, to?: Sid): number {
|
|
225
|
+
let count = 0;
|
|
226
|
+
for (const conn of this.#subscribers.get(topic) ?? []) {
|
|
227
|
+
if (holds(conn, to)) count += 1;
|
|
228
|
+
}
|
|
229
|
+
return count;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#sent(topic: string): Map<InstanceId, string> {
|
|
233
|
+
const sent = this.#lastSent.get(topic) ?? new Map<InstanceId, string>();
|
|
234
|
+
this.#lastSent.set(topic, sent);
|
|
235
|
+
return sent;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The wire shape of a topic frame, built here and nowhere else so snapshot
|
|
239
|
+
* and change cannot drift apart (§11.1). */
|
|
240
|
+
#frame(topic: string, instance: InstanceId, data: unknown, snapshot: boolean): object {
|
|
241
|
+
return snapshot
|
|
242
|
+
? { ev: "topic", topic, snapshot: true, instance, data }
|
|
243
|
+
: { ev: "topic", topic, instance, data };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What a scoped topic names: the sid of `session_status:<sid>`, the session
|
|
248
|
+
* of `transcript:<sid>`, the namespace of `kv:<ns>`. Which kind of topic it is
|
|
249
|
+
* has already been decided by whoever holds the name; this reads the parameter
|
|
250
|
+
* out of it, in one place for every owner that is per name (§6.3). */
|
|
251
|
+
export function topicParam(topic: string): string | undefined {
|
|
252
|
+
const separator = topic.indexOf(":");
|
|
253
|
+
if (separator < 0) return undefined;
|
|
254
|
+
const param = topic.slice(separator + 1);
|
|
255
|
+
return param.length > 0 ? param : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Whether a connection is one of the session's, for a frame addressed to a
|
|
259
|
+
* session. A connection with no sid settled holds none, so a person watching
|
|
260
|
+
* the topic does not receive what was said to someone else. */
|
|
261
|
+
function holds(conn: Requester, to: Sid | undefined): boolean {
|
|
262
|
+
if (to === undefined) return true;
|
|
263
|
+
const identity = conn.identity;
|
|
264
|
+
return identity.state === "settled" && identity.sid === to;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether a frame on this topic replaces the value it carries, which is the
|
|
268
|
+
* question suppression asks (§6.1).
|
|
269
|
+
*
|
|
270
|
+
* The two whole-value granularities do: a payload equal to the last one leaves
|
|
271
|
+
* the subscriber holding what it already holds. The others do not — an
|
|
272
|
+
* `element` frame adds or restates one entry, an `append` frame carries a
|
|
273
|
+
* chunk, an `event` frame is an occurrence — so two equal frames are two
|
|
274
|
+
* things happening, and the second is news. */
|
|
275
|
+
function replaces(topic: string): boolean {
|
|
276
|
+
const granularity = topicGranularity(topic);
|
|
277
|
+
return granularity === "whole" || granularity === "per_instance_whole";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The comparison behind suppression, in one place for every topic (M5).
|
|
281
|
+
*
|
|
282
|
+
* A topic payload is the JSON the frame will carry, so its serialized form is
|
|
283
|
+
* exactly what a subscriber would receive: comparing that answers "would this
|
|
284
|
+
* frame tell the subscriber anything new" without walking the value, and it
|
|
285
|
+
* cannot disagree with what goes on the wire. It is sensitive to key order,
|
|
286
|
+
* which is not a defect here — the values come from a domain that builds each
|
|
287
|
+
* topic's payload in one place, so the same value serializes the same way. */
|
|
288
|
+
function serialize(data: unknown): string {
|
|
289
|
+
return JSON.stringify(data ?? null);
|
|
290
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import type { Sid } from "@ccmsg/protocol";
|
|
4
|
+
import { OpError } from "../dispatch/index.ts";
|
|
5
|
+
|
|
6
|
+
/** Where the harness keeps transcripts under a config home: one directory per
|
|
7
|
+
* working directory, one `<sid>.jsonl` in it. */
|
|
8
|
+
const PROJECTS = "projects";
|
|
9
|
+
const SUFFIX = ".jsonl";
|
|
10
|
+
|
|
11
|
+
/** A session id as the harness names files by. Validated before it is joined
|
|
12
|
+
* to a path, so a sid is a name rather than a route: no separator and no dot
|
|
13
|
+
* can appear in it, which makes traversal unrepresentable rather than
|
|
14
|
+
* unlikely. */
|
|
15
|
+
const SID = /^[0-9a-fA-F-]{8,64}$/;
|
|
16
|
+
|
|
17
|
+
/** The agent id and run id shapes the harness writes under a session's own
|
|
18
|
+
* directory, and the name a teammate is addressed by. Each is validated on the
|
|
19
|
+
* same footing as a sid, for the same reason: all three name a file. */
|
|
20
|
+
const AGENT_ID = /^a[A-Za-z0-9_-]{5,120}$/;
|
|
21
|
+
const RUN_ID = /^wf_[0-9a-f]{8}-[0-9a-f]{3}$/;
|
|
22
|
+
const TEAMMATE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
23
|
+
|
|
24
|
+
/** Which transcript an op means.
|
|
25
|
+
*
|
|
26
|
+
* The caller never supplies a path: a sid resolves to the file the session
|
|
27
|
+
* announced, or to the one under this instance's own config home, and the
|
|
28
|
+
* three optional names below resolve to files under that session's own
|
|
29
|
+
* directory. Only the config home this instance answers for is ever looked in
|
|
30
|
+
* (M6) — nothing searches for another one. */
|
|
31
|
+
export interface TranscriptFilesDeps {
|
|
32
|
+
readonly configHome: string;
|
|
33
|
+
/** Where a connected session said its transcript is (§5.1). A session that
|
|
34
|
+
* never greeted has none, and the walk below answers for it. */
|
|
35
|
+
readonly announced: (sid: Sid) => string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TranscriptFiles {
|
|
39
|
+
constructor(private readonly deps: TranscriptFilesDeps) {}
|
|
40
|
+
|
|
41
|
+
/** The session's own transcript. */
|
|
42
|
+
session(sid: Sid): string {
|
|
43
|
+
const announced = this.deps.announced(sid);
|
|
44
|
+
if (announced !== undefined && isFile(announced)) return announced;
|
|
45
|
+
const found = this.find(sid);
|
|
46
|
+
if (found === undefined) throw new OpError("not_found", `no transcript is held for ${sid}`);
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The transcript an op's arguments name: the session's own, or one of the
|
|
51
|
+
* agents that ran below it.
|
|
52
|
+
*
|
|
53
|
+
* `agent_id` and `teammate` are two ways of naming the same kind of file and
|
|
54
|
+
* cannot be combined — a request carrying both names two files and is a
|
|
55
|
+
* caller's mistake rather than a choice this makes for them. */
|
|
56
|
+
locate(sid: Sid, names: AgentNames = {}): string {
|
|
57
|
+
const file = this.session(sid);
|
|
58
|
+
if (names.agent_id !== undefined && names.teammate !== undefined) {
|
|
59
|
+
throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
|
|
60
|
+
}
|
|
61
|
+
if (names.agent_id === undefined && names.teammate === undefined) {
|
|
62
|
+
if (names.run_id !== undefined) {
|
|
63
|
+
throw new OpError("invalid_args", "run_id names the run an agent_id belongs to");
|
|
64
|
+
}
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
const under = agentsDir(file, names.run_id);
|
|
68
|
+
if (names.agent_id !== undefined) {
|
|
69
|
+
return existing(join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`));
|
|
70
|
+
}
|
|
71
|
+
return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A teammate's transcript, found by the name it is addressed by.
|
|
75
|
+
*
|
|
76
|
+
* The name a teammate carries in conversation is not its filename, so the
|
|
77
|
+
* directory's own records are read for it rather than the name being
|
|
78
|
+
* substituted into a path. */
|
|
79
|
+
private teammate(under: string, wanted: string): string {
|
|
80
|
+
let names: string[];
|
|
81
|
+
try {
|
|
82
|
+
names = readdirSync(under);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new OpError("not_found", `no agent has run under this session`);
|
|
85
|
+
}
|
|
86
|
+
for (const each of names) {
|
|
87
|
+
if (!each.endsWith(".meta.json")) continue;
|
|
88
|
+
let document: unknown;
|
|
89
|
+
try {
|
|
90
|
+
document = JSON.parse(readFileSync(join(under, each), "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const named = (document as { name?: unknown } | null)?.name;
|
|
95
|
+
if (named !== wanted) continue;
|
|
96
|
+
return existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
|
|
97
|
+
}
|
|
98
|
+
throw new OpError("not_found", `no teammate of this session is addressed as ${wanted}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every transcript under this instance's config home, newest first.
|
|
102
|
+
*
|
|
103
|
+
* The one enumeration a search and a fork sweep both start from. It states
|
|
104
|
+
* the file and what a `stat` already said about it, so neither has to stat
|
|
105
|
+
* again to decide whether to open it. */
|
|
106
|
+
all(): TranscriptFile[] {
|
|
107
|
+
const found: TranscriptFile[] = [];
|
|
108
|
+
const projects = join(this.deps.configHome, PROJECTS);
|
|
109
|
+
for (const project of names(projects)) {
|
|
110
|
+
const dir = join(projects, project);
|
|
111
|
+
for (const entry of names(dir)) {
|
|
112
|
+
if (!entry.endsWith(SUFFIX)) continue;
|
|
113
|
+
const sid = entry.slice(0, -SUFFIX.length);
|
|
114
|
+
if (!SID.test(sid)) continue;
|
|
115
|
+
const file = join(dir, entry);
|
|
116
|
+
const stat = statOf(file);
|
|
117
|
+
if (stat === undefined) continue;
|
|
118
|
+
found.push({
|
|
119
|
+
sid,
|
|
120
|
+
file,
|
|
121
|
+
project,
|
|
122
|
+
size: stat.size,
|
|
123
|
+
created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
|
|
124
|
+
updated_at: Math.round(stat.mtimeMs),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
found.sort((a, b) => b.updated_at - a.updated_at || a.file.localeCompare(b.file));
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private find(sid: Sid): string | undefined {
|
|
133
|
+
if (!SID.test(sid)) return undefined;
|
|
134
|
+
const projects = join(this.deps.configHome, PROJECTS);
|
|
135
|
+
for (const project of names(projects)) {
|
|
136
|
+
const file = join(projects, project, `${sid}${SUFFIX}`);
|
|
137
|
+
if (isFile(file)) return file;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AgentNames {
|
|
144
|
+
readonly agent_id?: string;
|
|
145
|
+
readonly run_id?: string;
|
|
146
|
+
readonly teammate?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One transcript on disk, with what the enumeration already learned of it. */
|
|
150
|
+
export interface TranscriptFile {
|
|
151
|
+
readonly sid: Sid;
|
|
152
|
+
readonly file: string;
|
|
153
|
+
/** The project directory's name, which is the working directory flattened.
|
|
154
|
+
* A lossy spelling — separators and dots all become dashes — so it prefilters
|
|
155
|
+
* a search and never decides it. */
|
|
156
|
+
readonly project: string;
|
|
157
|
+
readonly size: number;
|
|
158
|
+
readonly created_at: number;
|
|
159
|
+
readonly updated_at: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Where the agents of one session write, beside its own transcript. */
|
|
163
|
+
function agentsDir(sessionFile: string, runId?: string): string {
|
|
164
|
+
const dir = join(dirname(sessionFile), basename(sessionFile, SUFFIX), "subagents");
|
|
165
|
+
if (runId === undefined) return dir;
|
|
166
|
+
return join(dir, "workflows", name(runId, RUN_ID, "run_id"));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One path segment, checked against the shape its kind has. The check runs
|
|
170
|
+
* before the join, so a name that could leave the directory never becomes part
|
|
171
|
+
* of a path at all. */
|
|
172
|
+
function name(value: string, shape: RegExp, field: string): string {
|
|
173
|
+
if (!shape.test(value)) throw new OpError("invalid_args", `${field} is not a name of that kind`);
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function existing(file: string): string {
|
|
178
|
+
if (!isFile(file)) throw new OpError("not_found", "no transcript is held for that agent");
|
|
179
|
+
return file;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function names(dir: string): string[] {
|
|
183
|
+
try {
|
|
184
|
+
return readdirSync(dir);
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function statOf(file: string) {
|
|
191
|
+
try {
|
|
192
|
+
const stat = statSync(file);
|
|
193
|
+
return stat.isFile() ? stat : undefined;
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isFile(file: string): boolean {
|
|
200
|
+
return statOf(file) !== undefined;
|
|
201
|
+
}
|