@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,170 @@
1
+ import { MAX_FRAME_BYTES } from "@ccmsg/protocol";
2
+ import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
3
+ import { createDriver, type FrameHandler } from "./driver.ts";
4
+ import { LineReader, WriteQueue } from "./framing.ts";
5
+ import { type EntryPolicy, OPEN } from "./entry.ts";
6
+ import type { Listener } from "./listener.ts";
7
+
8
+ /** What the upgrade hands the socket: whether it was let in as a peer. */
9
+ interface UpgradeData {
10
+ readonly mesh: boolean;
11
+ }
12
+
13
+ interface WsState {
14
+ conn: BaseConn;
15
+ reader: LineReader;
16
+ queue: WriteQueue<string>;
17
+ }
18
+
19
+ export interface WsOptions {
20
+ readonly hostname?: string;
21
+ /** 0 asks the kernel for a free port; the bound one is on `address`. */
22
+ readonly port: number;
23
+ readonly path?: string;
24
+ readonly conns: ConnRegistry;
25
+ readonly handle: FrameHandler;
26
+ readonly entry?: EntryPolicy;
27
+ /** `mesh` is set when the handshake was let in as a peer rather than on the
28
+ * entry token, so whoever holds the connection can keep it to the one
29
+ * exchange that can prove what it is. */
30
+ readonly onConn?: (conn: Conn, info: { readonly mesh: boolean }) => void;
31
+ /** An HTTP request that is not the upgrade, answered by whoever wants it.
32
+ *
33
+ * It shares this listener rather than opening a second one: a producer that
34
+ * posts to this instance reaches it at the address it already has, and the
35
+ * entry check of §3.1 runs before this is asked, so a route cannot be
36
+ * reached by anyone the WebSocket could not be. Answering `undefined` leaves
37
+ * the request to the upgrade, which refuses it. */
38
+ readonly route?: (request: Request) => Promise<Response | undefined>;
39
+ }
40
+
41
+ /** Accept the webui, and later mesh peers, over WebSocket.
42
+ *
43
+ * A WS message is a message, not a stream, but it carries the same
44
+ * newline-delimited framing as the unix socket: one line is one frame, whether
45
+ * a message holds one line or several. Backpressure differs from UDS — a send
46
+ * is either buffered whole by Bun or dropped whole — and the queue absorbs
47
+ * that difference here (§3.1). */
48
+ export function serveWs(options: WsOptions): Listener {
49
+ const path = options.path ?? "/ws";
50
+ const entry = options.entry ?? OPEN;
51
+ // The per-connection state is made in `open`, where the socket to write to
52
+ // exists, so the upgrade carries nothing and the socket keeps no data of its
53
+ // own.
54
+ const states = new WeakMap<object, WsState>();
55
+ const server = Bun.serve<UpgradeData, never>({
56
+ hostname: options.hostname ?? "127.0.0.1",
57
+ port: options.port,
58
+ async fetch(request, srv) {
59
+ if (entry.allowRequest?.(request, srv.requestIP(request)?.address) === false) {
60
+ return new Response("Forbidden", { status: 403 });
61
+ }
62
+ const routed = await options.route?.(request);
63
+ if (routed !== undefined) return routed;
64
+ if (!entryPath(new URL(request.url).pathname, path)) {
65
+ return new Response("Not Found", { status: 404 });
66
+ }
67
+ // The handshake's own check, asked after the routes so a route carrying
68
+ // its own secret is not also asked for the entry token.
69
+ const decision = entry.allowUpgrade?.(request) ?? { ok: true as const };
70
+ if (!decision.ok) return new Response(decision.reason, { status: 401 });
71
+ const selected = decision.protocol;
72
+ if (
73
+ srv.upgrade(request, {
74
+ data: { mesh: decision.mesh === true },
75
+ ...(selected === undefined
76
+ ? {}
77
+ : { headers: { "sec-websocket-protocol": selected } satisfies Record<string, string> }),
78
+ })
79
+ ) {
80
+ return undefined;
81
+ }
82
+ return new Response("Expected a WebSocket upgrade", { status: 426 });
83
+ },
84
+ websocket: {
85
+ // The line limit is enforced by the framing both transports share, which
86
+ // answers `bad_request` and keeps the connection. This cap only bounds
87
+ // one message's memory, so it sits above the line limit — a message at
88
+ // or under it always reaches the framing and gets that answer.
89
+ maxPayloadLength: MAX_FRAME_BYTES + 64 * 1024,
90
+ open(ws) {
91
+ const queue = new WriteQueue<string>({
92
+ encode: (line) => line,
93
+ // 0 means the message was dropped; anything else means it was sent
94
+ // or is buffered by Bun and will be, so re-queuing it would send it
95
+ // twice.
96
+ write: (chunk) => (ws.send(chunk) === 0 ? chunk : undefined),
97
+ });
98
+ const conn = new BaseConn(options.conns.nextId(), {
99
+ send: (line) => {
100
+ queue.push(line);
101
+ },
102
+ close: (code, reason) => {
103
+ if (code === undefined) ws.close();
104
+ else ws.close(code, reason);
105
+ },
106
+ });
107
+ const driver = createDriver(conn, options.handle);
108
+ states.set(ws, { conn, queue, reader: new LineReader(driver) });
109
+ options.conns.add(conn);
110
+ const data = ws.data as UpgradeData | undefined;
111
+ options.onConn?.(conn, { mesh: data?.mesh === true });
112
+ },
113
+ message(ws, message) {
114
+ const state = states.get(ws);
115
+ if (state === undefined) return;
116
+ const bytes = typeof message === "string" ? new TextEncoder().encode(message) : message;
117
+ state.reader.push(bytes);
118
+ // A message need not end in a newline, and one message is one frame on
119
+ // this transport, so the last line is complete even without it.
120
+ state.reader.push(NEWLINE);
121
+ },
122
+ drain(ws) {
123
+ states.get(ws)?.queue.drain();
124
+ },
125
+ close(ws) {
126
+ const state = states.get(ws);
127
+ if (state === undefined) return;
128
+ options.conns.remove(state.conn);
129
+ state.conn.closed();
130
+ },
131
+ },
132
+ });
133
+
134
+ return {
135
+ kind: "ws",
136
+ address: `${server.hostname}:${server.port}`,
137
+ async close() {
138
+ // Bounded rather than simply awaited. Measured against Bun 1.3.13: once
139
+ // this server has closed a WebSocket itself — which the mesh does, to
140
+ // drop the loser of a glare, a link gone silent, or a peer speaking out
141
+ // of turn — `stop` never settles, while the address is in fact given up
142
+ // within a millisecond and can be bound again. Waiting on the promise
143
+ // would hang the stop order at its last step for a listener that is
144
+ // already down, so the wait is capped and the address is what is trusted.
145
+ await Promise.race([server.stop(true), Bun.sleep(STOP_DEADLINE_MS)]);
146
+ },
147
+ };
148
+ }
149
+
150
+ /** Whether a request's path is this listener's entry.
151
+ *
152
+ * Matched at the end rather than whole, so a proxy that puts the instance under
153
+ * a prefix of its own passes the request through untouched and an alias URL
154
+ * reaches the same door (DR-0001 §2.7). The boundary before it has to be a
155
+ * separator, or `/notws` would answer for `/ws`. The mesh's own routes are not
156
+ * matched this way: they stay under the configured endpoint, which is what
157
+ * keeps two instances on one origin from answering for each other's keys.
158
+ *
159
+ * The prefix is not read: which instance a proxy meant is settled by which
160
+ * address it forwarded to, and a person's connection names itself by
161
+ * authenticating rather than by the path it arrived on. */
162
+ export function entryPath(pathname: string, path: string): boolean {
163
+ return pathname === path || pathname.endsWith(`/${path.replace(/^\//, "")}`);
164
+ }
165
+
166
+ /** How long the stop above waits before trusting the address over the promise.
167
+ * Two orders of magnitude above the millisecond the release was measured at. */
168
+ const STOP_DEADLINE_MS = 250;
169
+
170
+ const NEWLINE = new Uint8Array([0x0a]);
@@ -0,0 +1,125 @@
1
+ import type { LlmRequestInfo, Sid, Timestamp } from "@ccmsg/protocol";
2
+
3
+ /** One request the gateway forwarded, in this contract's spelling, before the
4
+ * instance decides whether its series is the session's main one.
5
+ *
6
+ * `main` is a verdict about a session's several series, so it needs the other
7
+ * series to be made and cannot be read off one event (§3.5 renames, it does not
8
+ * derive). `instance` is stamped by whoever publishes, since an event says
9
+ * nothing about which instance received it. */
10
+ export type LlmRequestObservation = Omit<LlmRequestInfo, "main" | "instance">;
11
+
12
+ /** An answer the gateway saw close. Only the two fields the sessions domain
13
+ * reads: it says inference for that session has stopped running, and when. */
14
+ export interface LlmResponseObservation {
15
+ readonly sid: Sid;
16
+ readonly at: Timestamp;
17
+ }
18
+
19
+ /** One item of a posted batch, as this instance reads it.
20
+ *
21
+ * `ignored` is a kind the gateway sends that nothing here reads — kept apart
22
+ * from `undefined`, which is an item that could not be understood at all. The
23
+ * difference is the whole value of the log line: a batch of ignorable items is
24
+ * the gateway working, a batch of unreadable ones is a schema that moved. */
25
+ export type GatewayItem =
26
+ | { readonly kind: "request"; readonly info: LlmRequestObservation }
27
+ | { readonly kind: "response"; readonly info: LlmResponseObservation }
28
+ | { readonly kind: "ignored" };
29
+
30
+ /** Kinds the gateway posts beside the two above: a keepalive it wants replayed
31
+ * into a session, and its keepalive strategy being held off for one. Neither
32
+ * is read here. They are named rather than reached as "not a request", so a
33
+ * kind the gateway grows still arrives as unreadable and shows up in the log. */
34
+ const IGNORED = new Set(["cache_keepalive", "keepalive_paused"]);
35
+
36
+ /** The fields whose name is the same on both sides, and whose value is already
37
+ * this contract's unit — a count of seconds, or an instant in Unix ms. */
38
+ const SAME_NAME_NUMBERS = [
39
+ "cache_ttl_secs",
40
+ "cache_expires_at",
41
+ "cache_count",
42
+ "next_keepalive_at",
43
+ "cache_until_count",
44
+ "cache_breakeven_count",
45
+ ] as const;
46
+
47
+ /** The instants the gateway names without the suffix this contract requires of
48
+ * every field that is a point in time. Renamed here, at the boundary, so
49
+ * nothing downstream sees the gateway's spelling (§3.5). */
50
+ const RENAMED_INSTANTS = [
51
+ ["cache_since", "cache_since_at"],
52
+ ["cache_until", "cache_until_at"],
53
+ ["cache_breakeven_until", "cache_breakeven_until_at"],
54
+ ] as const;
55
+
56
+ const SAME_NAME_STRINGS = ["keepalive", "ns", "model", "credential", "origin"] as const;
57
+
58
+ /** Read one posted item.
59
+ *
60
+ * Nothing here throws: one item a batch could not be understood must not cost
61
+ * the items beside it, so an unusable one is `undefined` and the caller drops
62
+ * it. Every field is taken only when it is the type this contract states —
63
+ * which is also what keeps a value the gateway might one day send in another
64
+ * shape (an ISO instant, a string status) from travelling as itself. */
65
+ export function parseGatewayItem(value: unknown): GatewayItem | undefined {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
67
+ const raw = value as Record<string, unknown>;
68
+ const kind = raw["type"];
69
+ if (typeof kind === "string" && IGNORED.has(kind)) return { kind: "ignored" };
70
+ // A client that named no session gives an event with no row to put it on.
71
+ // The gateway states the field as null rather than leaving it out, and this
72
+ // is the ordinary case of a call made by something other than a session — so
73
+ // it is passed over rather than counted as an event that could not be read.
74
+ if (raw["session_id"] === null) return { kind: "ignored" };
75
+ if (kind === "response") {
76
+ const info = responseOf(raw);
77
+ return info === undefined ? undefined : { kind: "response", info };
78
+ }
79
+ // The forwarding notice is the one kind that carries no mark, because it
80
+ // existed before the others did. So it is a request by position, and only
81
+ // when it names no kind at all: an item that names one and is not handled
82
+ // above must not be read as a request whose fields happen to line up.
83
+ if (kind !== undefined) return undefined;
84
+ const info = requestOf(raw);
85
+ return info === undefined ? undefined : { kind: "request", info };
86
+ }
87
+
88
+ function requestOf(raw: Record<string, unknown>): LlmRequestObservation | undefined {
89
+ const at = raw["ts"];
90
+ const sid = raw["session_id"];
91
+ if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
92
+ const info: Record<string, unknown> = { received_at: at, sid };
93
+ const prefix = raw["prefix"];
94
+ if (typeof prefix === "string" && prefix !== "") info["prefix"] = prefix;
95
+ for (const field of SAME_NAME_NUMBERS) {
96
+ const num = raw[field];
97
+ if (typeof num === "number" && Number.isFinite(num)) info[field] = num;
98
+ }
99
+ for (const [there, here] of RENAMED_INSTANTS) {
100
+ const num = raw[there];
101
+ if (isInstant(num)) info[here] = num;
102
+ }
103
+ for (const field of SAME_NAME_STRINGS) {
104
+ const text = raw[field];
105
+ if (typeof text === "string" && text !== "") info[field] = text;
106
+ }
107
+ const paused = raw["cache_paused"];
108
+ if (typeof paused === "boolean") info["cache_paused"] = paused;
109
+ const status = raw["status"];
110
+ if (typeof status === "number" && Number.isInteger(status)) info["status"] = status;
111
+ return info as unknown as LlmRequestObservation;
112
+ }
113
+
114
+ function responseOf(raw: Record<string, unknown>): LlmResponseObservation | undefined {
115
+ const at = raw["ts"];
116
+ const sid = raw["session_id"];
117
+ if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
118
+ return { sid, at };
119
+ }
120
+
121
+ /** A number that can be an instant on this wire. Rejecting a non-number is
122
+ * what stops an ISO string from reaching a `*_at` field. */
123
+ function isInstant(value: unknown): value is Timestamp {
124
+ return typeof value === "number" && Number.isFinite(value);
125
+ }
@@ -0,0 +1,275 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ import type {
5
+ Capability,
6
+ InstanceId,
7
+ LlmStatsReadArgs,
8
+ LlmStatsReadResult,
9
+ LlmUsageReadArgs,
10
+ LlmUsageReadResult,
11
+ Sid,
12
+ Timestamp,
13
+ } from "@ccmsg/protocol";
14
+ import type { HandlerInput } from "../dispatch/index.ts";
15
+ import { ConfigError, type UpstreamConfig } from "../instance/config.ts";
16
+ import type { Env } from "../instance/paths.ts";
17
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
18
+ import { parseGatewayItem } from "./events.ts";
19
+ import { LlmRequests } from "./requests.ts";
20
+ import { readStats } from "./stats.ts";
21
+ import { LlmStatus } from "./status.ts";
22
+ import { readUsage } from "./usage.ts";
23
+ import { handleWebhook, SOURCE_NAME, type WebhookSource } from "./webhook.ts";
24
+
25
+ /** Where the gateway's own endpoints live under its address (DR-0006). */
26
+ const STATUS_PATH = "/llm-gateway/status";
27
+ const USAGE_PATH = "/llm-gateway/usage";
28
+ const STATS_PATH = "/llm-gateway/stats";
29
+
30
+ /** What the config says this instance can reach of the gateway, resolved.
31
+ *
32
+ * Both halves are independent: an instance can be posted to without being able
33
+ * to ask anything back, and the other way round. Each grants its own
34
+ * capability, so a client is told which of the two it has rather than
35
+ * discovering it by subscribing. */
36
+ export interface GatewaySetup {
37
+ /** Present when a webhook source is configured, with the secret it must
38
+ * present already read. */
39
+ readonly source?: { readonly name: string; readonly token: string };
40
+ /** Present when the gateway's address is configured. The three are one
41
+ * decision — the address — and are resolved together so a client is told
42
+ * about all three at once rather than discovering each by asking. */
43
+ readonly statusUrl?: string;
44
+ readonly usageUrl?: string;
45
+ readonly statsUrl?: string;
46
+ }
47
+
48
+ /** Read the upstream section (§8.2).
49
+ *
50
+ * A setting that is there and cannot be honoured ends the start rather than
51
+ * leaving the feature it asked for silently off (DV-Q9): an operator who named
52
+ * a webhook source wants the route, and an instance that came up without it
53
+ * looks identical to one nobody is posting to. A section that is absent is not
54
+ * broken — it states that this instance has no gateway, which costs the rows
55
+ * one attribute and nothing else (§5.2). */
56
+ export function gatewaySetup(config: UpstreamConfig, file: string, env: Env): GatewaySetup {
57
+ const name = config.gateway_webhook_source;
58
+ const url = config.gateway_url;
59
+ return {
60
+ ...(name === undefined
61
+ ? {}
62
+ : { source: { name: sourceName(file, name), token: token(file, config, env, name) } }),
63
+ ...(url === undefined
64
+ ? {}
65
+ : {
66
+ statusUrl: endpoint(file, url, STATUS_PATH),
67
+ usageUrl: endpoint(file, url, USAGE_PATH),
68
+ statsUrl: endpoint(file, url, STATS_PATH),
69
+ }),
70
+ };
71
+ }
72
+
73
+ /** The capabilities the setup grants. `llm_events` says request activity
74
+ * arrives to be pushed; the other three say the gateway can be asked for its
75
+ * report, its quota and its spend. */
76
+ export function gatewayCapabilities(setup: GatewaySetup): Capability[] {
77
+ return [
78
+ ...(setup.source === undefined ? [] : (["llm_events"] as const)),
79
+ ...(setup.statusUrl === undefined ? [] : (["llm_status"] as const)),
80
+ ...(setup.usageUrl === undefined ? [] : (["llm_usage"] as const)),
81
+ ...(setup.statsUrl === undefined ? [] : (["llm_stats"] as const)),
82
+ ];
83
+ }
84
+
85
+ /** The two ops that ask the gateway a question and answer with what it said.
86
+ *
87
+ * They are handlers rather than resources: neither has a current value to hold
88
+ * or a topic to push on, so each read is one question asked because a person
89
+ * asked it. */
90
+ export function gatewayHandlers(setup: GatewaySetup, fetcher?: typeof fetch) {
91
+ const call = fetcher === undefined ? {} : { fetch: fetcher };
92
+ const usageUrl = setup.usageUrl;
93
+ const statsUrl = setup.statsUrl;
94
+ return {
95
+ ...(usageUrl === undefined
96
+ ? {}
97
+ : {
98
+ llm_usage_read: (input: HandlerInput): Promise<LlmUsageReadResult> =>
99
+ readUsage({ url: usageUrl, ...call }, input.args as unknown as LlmUsageReadArgs),
100
+ }),
101
+ ...(statsUrl === undefined
102
+ ? {}
103
+ : {
104
+ llm_stats_read: (input: HandlerInput): Promise<LlmStatsReadResult> =>
105
+ readStats({ url: statsUrl, ...call }, input.args as unknown as LlmStatsReadArgs),
106
+ }),
107
+ };
108
+ }
109
+
110
+ export interface GatewayDeps {
111
+ readonly self: InstanceId;
112
+ readonly setup: GatewaySetup;
113
+ readonly publish: (topic: string, data: unknown) => void;
114
+ /** The gateway saw something happen for a session, which is an input of the
115
+ * sessions domain (§5.1) rather than of either topic. */
116
+ readonly onActivity?: () => void;
117
+ readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
118
+ /** Replaces the outward read in tests. */
119
+ readonly fetch?: typeof fetch;
120
+ readonly settleMs?: number;
121
+ }
122
+
123
+ /** What this instance takes from the llm-gateway.
124
+ *
125
+ * One direction each. The gateway posts what it saw the moment it saw it,
126
+ * because it runs as more than one process and a subscription would only ever
127
+ * have reached whichever one it connected to; this instance asks for the
128
+ * service report, because that is a document with a current value rather than
129
+ * a stream of occurrences. Both arrive as events and neither is polled (M3):
130
+ * the report is re-read when a posted event says an upstream refused, which is
131
+ * this instance being told the one thing that changes it. */
132
+ export class Gateway {
133
+ readonly requests: LlmRequests;
134
+ readonly status: LlmStatus | undefined;
135
+ readonly #source: WebhookSource | undefined;
136
+
137
+ constructor(private readonly deps: GatewayDeps) {
138
+ this.requests = new LlmRequests({
139
+ self: deps.self,
140
+ publish: deps.publish,
141
+ ...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
142
+ });
143
+ this.status =
144
+ deps.setup.statusUrl === undefined
145
+ ? undefined
146
+ : new LlmStatus({
147
+ self: deps.self,
148
+ url: deps.setup.statusUrl,
149
+ publish: deps.publish,
150
+ ...(deps.log === undefined ? {} : { log: deps.log }),
151
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
152
+ ...(deps.settleMs === undefined ? {} : { settleMs: deps.settleMs }),
153
+ });
154
+ this.#source =
155
+ deps.setup.source === undefined
156
+ ? undefined
157
+ : {
158
+ name: deps.setup.source.name,
159
+ token: deps.setup.source.token,
160
+ handle: (items) => this.take(items),
161
+ };
162
+ }
163
+
164
+ /** The resource behind `llm_status`. A stand-in that states nothing when the
165
+ * gateway's address is not configured — the topic's capability is absent
166
+ * then, so nothing reaches it, and the attachment stays unconditional. */
167
+ get statusResource(): UpstreamResource {
168
+ return this.status ?? SILENT;
169
+ }
170
+
171
+ /** When the gateway last saw inference for a session (§5.1). */
172
+ activeAt(sid: Sid, now?: Timestamp): Timestamp | undefined {
173
+ return this.requests.activeAt(sid, now);
174
+ }
175
+
176
+ /** One HTTP request that reached this instance's entry, answered if it is
177
+ * the webhook and left alone if it is not. */
178
+ route(request: Request): Promise<Response | undefined> {
179
+ return handleWebhook(request, this.#source, this.deps.log);
180
+ }
181
+
182
+ /** Stop what is pending. Called from the stop order (§8.5). */
183
+ close(): void {
184
+ this.status?.stop();
185
+ }
186
+
187
+ /** One posted batch. Every item is read on its own: a kind this instance
188
+ * does not use is passed over, and one it cannot read at all is counted, so
189
+ * a schema that moved shows up as a number instead of as silence. */
190
+ private take(items: readonly unknown[]): void {
191
+ let unreadable = 0;
192
+ for (const value of items) {
193
+ const item = parseGatewayItem(value);
194
+ if (item === undefined) {
195
+ unreadable += 1;
196
+ continue;
197
+ }
198
+ if (item.kind === "request") {
199
+ this.requests.record(item.info);
200
+ this.status?.noteRequestStatus(item.info.status);
201
+ } else if (item.kind === "response") {
202
+ this.requests.note(item.info.sid, item.info.at);
203
+ }
204
+ }
205
+ if (unreadable > 0) {
206
+ this.deps.log?.("dropped items a gateway delivery could not be read as", { unreadable });
207
+ }
208
+ }
209
+ }
210
+
211
+ /** A resource with nothing behind it: subscribing to its topic starts nothing
212
+ * and states nothing. */
213
+ const SILENT: UpstreamResource = {
214
+ start(): void {},
215
+ stop(): void {},
216
+ snapshot(): readonly TopicValue[] {
217
+ return [];
218
+ },
219
+ };
220
+
221
+ function sourceName(file: string, name: string): string {
222
+ if (!SOURCE_NAME.test(name)) {
223
+ throw new ConfigError(
224
+ file,
225
+ "upstream.gateway_webhook_source must be lowercase letters, digits and dashes",
226
+ );
227
+ }
228
+ return name;
229
+ }
230
+
231
+ /** One of the gateway's endpoints under the configured address. */
232
+ function endpoint(file: string, url: string, path: string): string {
233
+ let base: URL;
234
+ try {
235
+ base = new URL(url);
236
+ } catch {
237
+ throw new ConfigError(file, `upstream.gateway_url must be a URL, got ${url}`);
238
+ }
239
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
240
+ throw new ConfigError(file, "upstream.gateway_url must be an http:// or https:// address");
241
+ }
242
+ return `${base.origin}${base.pathname.replace(/\/+$/, "")}${path}`;
243
+ }
244
+
245
+ /** The secret the gateway presents.
246
+ *
247
+ * It is the gateway that writes the file and this instance that reads it, so
248
+ * the default is the path the gateway defaults to (DR-0012) rather than one of
249
+ * this instance's own — a path per config home would have to be configured on
250
+ * both sides to mean the same thing. */
251
+ function token(file: string, config: UpstreamConfig, env: Env, source: string): string {
252
+ const path = config.gateway_webhook_token_file ?? defaultTokenFile(env, source);
253
+ let raw: string;
254
+ try {
255
+ raw = readFileSync(path, "utf8");
256
+ } catch (cause) {
257
+ throw new ConfigError(
258
+ file,
259
+ `upstream.gateway_webhook_source is set but its token file ${path} could not be read (${String(cause)})`,
260
+ );
261
+ }
262
+ const token = raw.trim();
263
+ if (token === "") throw new ConfigError(file, `the webhook token file ${path} is empty`);
264
+ return token;
265
+ }
266
+
267
+ export function defaultTokenFile(env: Env, source: string): string {
268
+ const xdg = env["XDG_DATA_HOME"];
269
+ const home = env["HOME"];
270
+ const base =
271
+ xdg !== undefined && isAbsolute(xdg)
272
+ ? xdg
273
+ : join(home !== undefined && isAbsolute(home) ? home : homedir(), ".local", "share");
274
+ return join(base, "ccmsg", `webhook-${source}.token`);
275
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./events.ts";
2
+ export * from "./gateway.ts";
3
+ export * from "./json.ts";
4
+ export * from "./requests.ts";
5
+ export * from "./stats.ts";
6
+ export * from "./status.ts";
7
+ export * from "./usage.ts";
8
+ export * from "./webhook.ts";
@@ -0,0 +1,81 @@
1
+ import type { Timestamp } from "@ccmsg/protocol";
2
+
3
+ /** What every read of an upstream document is built from (§3.5).
4
+ *
5
+ * The gateway's three documents are read the same way — a field is taken only
6
+ * at the type this contract states for it, and anything else is absent — so the
7
+ * readers live here once rather than once per document. */
8
+
9
+ export interface FetchOptions {
10
+ /** How long the read is given before it is one that is not coming. */
11
+ readonly timeoutMs: number;
12
+ /** Cap on the document, since the whole of it is held to be parsed. */
13
+ readonly maxBytes: number;
14
+ /** Replaces the outward read in tests. */
15
+ readonly fetch?: typeof fetch;
16
+ }
17
+
18
+ /** One JSON document from the gateway, or a throw saying why not. */
19
+ export async function fetchJson(url: string, options: FetchOptions): Promise<unknown> {
20
+ const call = options.fetch ?? fetch;
21
+ const answer = await call(url, {
22
+ signal: AbortSignal.timeout(options.timeoutMs),
23
+ headers: { accept: "application/json" },
24
+ });
25
+ if (!answer.ok) throw new Error(`the gateway answered ${answer.status}`);
26
+ return JSON.parse(await bounded(answer, options.maxBytes)) as unknown;
27
+ }
28
+
29
+ async function bounded(answer: Response, maxBytes: number): Promise<string> {
30
+ const body = answer.body;
31
+ if (body === null) return "";
32
+ const reader = body.getReader();
33
+ const decoder = new TextDecoder();
34
+ let size = 0;
35
+ let text = "";
36
+ try {
37
+ for (;;) {
38
+ const { done, value } = await reader.read();
39
+ if (done === true) break;
40
+ size += value.byteLength;
41
+ if (size > maxBytes) throw new Error(`the document is over ${maxBytes} bytes`);
42
+ text += decoder.decode(value, { stream: true });
43
+ }
44
+ } finally {
45
+ reader.releaseLock();
46
+ }
47
+ return text + decoder.decode();
48
+ }
49
+
50
+ export function objectOf(value: unknown): Record<string, unknown> | undefined {
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
52
+ return value as Record<string, unknown>;
53
+ }
54
+
55
+ export function oneOf<T extends string>(allowed: readonly T[], value: unknown, fallback: T): T {
56
+ return typeof value === "string" && (allowed as readonly string[]).includes(value)
57
+ ? (value as T)
58
+ : fallback;
59
+ }
60
+
61
+ export function optionalText(name: string, value: unknown): Record<string, string> {
62
+ return typeof value === "string" ? { [name]: value } : {};
63
+ }
64
+
65
+ export function optionalInteger(name: string, value: unknown): Record<string, number> {
66
+ return typeof value === "number" && Number.isInteger(value) ? { [name]: value } : {};
67
+ }
68
+
69
+ export function optionalNumber(name: string, value: unknown): Record<string, number> {
70
+ return typeof value === "number" && Number.isFinite(value) ? { [name]: value } : {};
71
+ }
72
+
73
+ export function optionalFlag(name: string, value: unknown): Record<string, boolean> {
74
+ return typeof value === "boolean" ? { [name]: value } : {};
75
+ }
76
+
77
+ /** An instant, taken only as a number. Rejecting a string is what stops an ISO
78
+ * time from travelling on a field this contract says is Unix ms. */
79
+ export function optionalInstant(name: string, value: unknown): Record<string, Timestamp> {
80
+ return typeof value === "number" && Number.isFinite(value) ? { [name]: value } : {};
81
+ }