@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,234 @@
1
+ import {
2
+ type InstanceId,
3
+ llmCacheWindowEndAt,
4
+ type LlmRequestInfo,
5
+ type Sid,
6
+ type Timestamp,
7
+ } from "@ccmsg/protocol";
8
+ import { GATEWAY_LIVE_WINDOW_MS } from "../sessions/index.ts";
9
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
+ import type { LlmRequestObservation } from "./events.ts";
11
+
12
+ export interface LlmRequestsDeps {
13
+ readonly self: InstanceId;
14
+ /** The one way a value reaches subscribers (§6.1). */
15
+ readonly publish: (topic: string, data: unknown) => void;
16
+ /** An event moved when a session was last seen running inference, which is
17
+ * an input of the sessions domain (§5.1) and not of this topic. */
18
+ readonly onActivity?: () => void;
19
+ }
20
+
21
+ /** Series remembered at once. The prune below already holds this near the
22
+ * number active in the last cache window; the cap is what bounds a gateway
23
+ * whose clock runs ahead, whose events would otherwise never expire. */
24
+ const MAX_SERIES = 500;
25
+
26
+ /** Prefixes whose session set is remembered. Sharing is a property of the
27
+ * prefix and stays worth knowing after that series' window closes, but not
28
+ * forever — the least recently seen go first. */
29
+ const MAX_PREFIXES = 2000;
30
+
31
+ /** One series' latest request, and when this session first used it. */
32
+ interface Series {
33
+ info: LlmRequestObservation;
34
+ /** Orders a session's series by when it started using them, which is the
35
+ * tiebreak when it has several the sharing rule does not disqualify. */
36
+ firstSeen: number;
37
+ }
38
+
39
+ /** What the gateway saw go upstream, per conversation series, and when each
40
+ * session was last seen running inference.
41
+ *
42
+ * Keyed on the session and its series rather than on the session alone: a
43
+ * session's subagents travel under its own id with a system prompt of their
44
+ * own, so their cache windows are genuinely separate. Folding them together
45
+ * would restart the session's countdown every time a subagent spoke.
46
+ *
47
+ * The frames carry the whole unexpired set rather than the series that just
48
+ * moved, which is the topic's `per_instance_whole` granularity: a frame is the
49
+ * whole of what this instance knows and replaces its share alone. It is what
50
+ * lets a client that starts listening mid-window draw the countdown that began
51
+ * before it was there. Nothing states an expiry: both sides compute it with the
52
+ * contract's own `llmCacheWindowEndAt`, so a window closes at the same instant
53
+ * here and on the screen. */
54
+ export class LlmRequests implements UpstreamResource {
55
+ readonly #series = new Map<string, Series>();
56
+ /** Prefix to the sessions it has been seen under, counted only as far as the
57
+ * two it takes to prove sharing. */
58
+ readonly #sidsByPrefix = new Map<string, Set<Sid>>();
59
+ /** When each session was last seen running inference: the newest of its
60
+ * requests and its answers. */
61
+ readonly #activeAt = new Map<Sid, Timestamp>();
62
+ #sequence = 0;
63
+
64
+ constructor(private readonly deps: LlmRequestsDeps) {}
65
+
66
+ /** Take one request the gateway forwarded.
67
+ *
68
+ * The newer of the two wins when a series already has one: events are
69
+ * near-ordered in practice, but a redelivery can put an older one after a
70
+ * newer, and a countdown must not walk backwards. */
71
+ record(info: LlmRequestObservation): void {
72
+ this.active(info.sid, info.received_at);
73
+ const key = seriesKey(info.sid, info.prefix);
74
+ const held = this.#series.get(key);
75
+ if (held !== undefined && held.info.received_at >= info.received_at) return;
76
+ this.notePrefix(info);
77
+ // Removed before it is put back so the re-insert moves the series to the
78
+ // end of the map's order, which is what makes the eviction below drop the
79
+ // one seen least recently. `firstSeen` survives that move.
80
+ this.#series.delete(key);
81
+ this.#series.set(key, { info, firstSeen: held?.firstSeen ?? ++this.#sequence });
82
+ while (this.#series.size > MAX_SERIES) {
83
+ const oldest = this.#series.keys().next();
84
+ if (oldest.done === true) break;
85
+ this.#series.delete(oldest.value);
86
+ }
87
+ this.publish();
88
+ }
89
+
90
+ /** Take one answer the gateway saw close. It moves nothing on this topic —
91
+ * the window belongs to the request that opened it — and only says the
92
+ * session was still running inference at that instant. */
93
+ note(sid: Sid, at: Timestamp): void {
94
+ if (this.active(sid, at)) this.deps.onActivity?.();
95
+ }
96
+
97
+ /** When the gateway last saw inference for a session (§5.1). Undefined once
98
+ * that is old enough to say nothing about whether the session is alive. */
99
+ activeAt(sid: Sid, now: Timestamp = Date.now()): Timestamp | undefined {
100
+ const at = this.#activeAt.get(sid);
101
+ if (at === undefined) return undefined;
102
+ return now - at <= GATEWAY_LIVE_WINDOW_MS ? at : undefined;
103
+ }
104
+
105
+ /** Every series whose cache window is still open, each told whether it is
106
+ * its session's main one. Prunes as it goes: a closed window is dropped
107
+ * rather than re-sent forever. */
108
+ entries(now: Timestamp = Date.now()): LlmRequestInfo[] {
109
+ const live: Series[] = [];
110
+ for (const [key, series] of this.#series) {
111
+ if (llmCacheWindowEndAt(series.info) <= now) {
112
+ this.#series.delete(key);
113
+ continue;
114
+ }
115
+ live.push(series);
116
+ }
117
+ const main = this.#elect(live);
118
+ return live.map((series) => ({
119
+ ...series.info,
120
+ instance: this.deps.self,
121
+ main: main.get(series.info.sid) === series,
122
+ }));
123
+ }
124
+
125
+ // --- UpstreamResource (§6.3). There is nothing to start: the events are
126
+ // pushed to this instance whether or not anyone is listening, because the
127
+ // sessions domain reads the same arrivals for a value of its own.
128
+
129
+ start(): void {}
130
+
131
+ stop(): void {}
132
+
133
+ snapshot(): readonly TopicValue[] {
134
+ return [{ instance: this.deps.self, data: this.entries() }];
135
+ }
136
+
137
+ private publish(): void {
138
+ this.deps.publish("llm_requests", this.entries());
139
+ this.deps.onActivity?.();
140
+ }
141
+
142
+ /** Note the session was seen, and say whether that moved it forward. */
143
+ private active(sid: Sid, at: Timestamp): boolean {
144
+ const held = this.#activeAt.get(sid);
145
+ if (held !== undefined && held >= at) return false;
146
+ this.#activeAt.set(sid, at);
147
+ // Sessions the gateway has not seen for longer than the window go: what is
148
+ // left is what any of this can still say something about.
149
+ const floor = at - GATEWAY_LIVE_WINDOW_MS;
150
+ for (const [seen, when] of this.#activeAt) {
151
+ if (when < floor) this.#activeAt.delete(seen);
152
+ }
153
+ return true;
154
+ }
155
+
156
+ private notePrefix(info: LlmRequestObservation): void {
157
+ const prefix = info.prefix;
158
+ if (prefix === undefined) return;
159
+ let sids = this.#sidsByPrefix.get(prefix);
160
+ if (sids === undefined) {
161
+ sids = new Set();
162
+ this.#sidsByPrefix.set(prefix, sids);
163
+ while (this.#sidsByPrefix.size > MAX_PREFIXES) {
164
+ const oldest = this.#sidsByPrefix.keys().next();
165
+ if (oldest.done === true) break;
166
+ this.#sidsByPrefix.delete(oldest.value);
167
+ }
168
+ }
169
+ if (sids.size < 2) sids.add(info.sid);
170
+ }
171
+
172
+ /** Which series is each session's own: the three steps the contract states
173
+ * on `LlmRequestInfo.main`, in that order.
174
+ *
175
+ * Read from what is live right now rather than settled once, so an instance
176
+ * that started while only a subagent was talking corrects itself the moment
177
+ * that prefix appears under a second session. */
178
+ #elect(live: readonly Series[]): Map<Sid, Series> {
179
+ const stated = new Map<Sid, Series>();
180
+ const statedSids = new Set<Sid>();
181
+ for (const series of live) {
182
+ if (series.info.origin === undefined) continue;
183
+ statedSids.add(series.info.sid);
184
+ if (series.info.origin !== "main") continue;
185
+ // The newest rather than the first: a stated main series is legitimately
186
+ // replaced, since a compaction rewrites the system prompt.
187
+ const best = stated.get(series.info.sid);
188
+ if (best === undefined || series.info.received_at > best.info.received_at) {
189
+ stated.set(series.info.sid, series);
190
+ }
191
+ }
192
+
193
+ const elected = new Map<Sid, Series>();
194
+ for (const series of live) {
195
+ if (statedSids.has(series.info.sid)) continue;
196
+ if (this.shared(series.info.prefix)) continue;
197
+ const best = elected.get(series.info.sid);
198
+ if (best === undefined || series.firstSeen < best.firstSeen) {
199
+ elected.set(series.info.sid, series);
200
+ }
201
+ }
202
+ // Step 3. Two sessions opened on the same directory of the same repository
203
+ // produce the same leading system block, so their own series share a prefix
204
+ // and disqualify each other; the separation being given up here has nothing
205
+ // left to separate.
206
+ const fallback = new Map<Sid, Series>();
207
+ for (const series of live) {
208
+ const sid = series.info.sid;
209
+ if (statedSids.has(sid) || elected.has(sid)) continue;
210
+ const best = fallback.get(sid);
211
+ if (best === undefined || series.info.received_at > best.info.received_at) {
212
+ fallback.set(sid, series);
213
+ }
214
+ }
215
+ for (const [sid, series] of fallback) elected.set(sid, series);
216
+ for (const [sid, series] of stated) elected.set(sid, series);
217
+ return elected;
218
+ }
219
+
220
+ private shared(prefix: string | undefined): boolean {
221
+ if (prefix === undefined) return false;
222
+ return (this.#sidsByPrefix.get(prefix)?.size ?? 0) > 1;
223
+ }
224
+ }
225
+
226
+ /** The key of one conversation series.
227
+ *
228
+ * The first half is measured rather than delimited, so there is no character
229
+ * that has to be impossible in a session id for two different pairs to stay
230
+ * apart. A session whose gateway reports no series has one unnamed series,
231
+ * which is what the absent half stands for. */
232
+ function seriesKey(sid: Sid, prefix: string | undefined): string {
233
+ return `${sid.length}:${sid}${prefix ?? ""}`;
234
+ }
@@ -0,0 +1,99 @@
1
+ import type {
2
+ LlmStatsDay,
3
+ LlmStatsModelUsage,
4
+ LlmStatsReadArgs,
5
+ LlmStatsReadResult,
6
+ } from "@ccmsg/protocol";
7
+ import { OpError } from "../dispatch/index.ts";
8
+ import { fetchJson, objectOf, optionalInstant, optionalInteger, optionalNumber } from "./json.ts";
9
+ import { withQuery } from "./usage.ts";
10
+
11
+ /** A year of daily spend across every credential and model is a few hundred
12
+ * kilobytes; this leaves room for a busier host without letting a misconfigured
13
+ * address stream this instance out of memory. */
14
+ const MAX_BYTES = 4 * 1024 * 1024;
15
+
16
+ /** Wider than the quota read's: this document is assembled over a range the
17
+ * caller chose, so a year is legitimately slower than a snapshot. */
18
+ const TIMEOUT_MS = 30_000;
19
+
20
+ export interface StatsDeps {
21
+ /** Where the gateway answers, already joined to its stats path. */
22
+ readonly url: string;
23
+ readonly fetch?: typeof fetch;
24
+ }
25
+
26
+ /** Ask the gateway what the host's credentials have cost, by day. */
27
+ export async function readStats(
28
+ deps: StatsDeps,
29
+ args: LlmStatsReadArgs,
30
+ ): Promise<LlmStatsReadResult> {
31
+ // A window the caller did not name is the gateway's own to choose.
32
+ const url = args.days === undefined ? deps.url : withQuery(deps.url, "days", String(args.days));
33
+ let document: unknown;
34
+ try {
35
+ document = await fetchJson(url, {
36
+ timeoutMs: TIMEOUT_MS,
37
+ maxBytes: MAX_BYTES,
38
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
39
+ });
40
+ } catch (cause) {
41
+ throw new OpError("internal_error", `the gateway's spend could not be read: ${String(cause)}`);
42
+ }
43
+ const stats = statsOf(document);
44
+ if (stats === undefined) {
45
+ throw new OpError("internal_error", "the gateway's spend could not be understood");
46
+ }
47
+ return stats;
48
+ }
49
+
50
+ /** Read the gateway's document as this contract's answer (§3.5). The dates are
51
+ * the gateway's own keys and are not reinterpreted: a day here means whatever
52
+ * it means there. */
53
+ export function statsOf(value: unknown): LlmStatsReadResult | undefined {
54
+ const raw = objectOf(value);
55
+ const rawDays = objectOf(raw?.["days"]);
56
+ if (raw === undefined || rawDays === undefined) return undefined;
57
+ const days: Record<string, LlmStatsDay> = {};
58
+ for (const [date, entry] of Object.entries(rawDays)) {
59
+ const day = dayOf(entry);
60
+ if (day !== undefined) days[date] = day;
61
+ }
62
+ return { ...optionalInstant("generated_at", raw["generated_at"]), days };
63
+ }
64
+
65
+ /** A day whose credentials cannot be read still renders as a day with its
66
+ * total, which says more than a hole in the series would. */
67
+ function dayOf(value: unknown): LlmStatsDay | undefined {
68
+ const raw = objectOf(value);
69
+ if (raw === undefined) return undefined;
70
+ const credentials: Record<string, Record<string, LlmStatsModelUsage>> = {};
71
+ const rawCredentials = objectOf(raw["credentials"]);
72
+ for (const [name, rawModels] of Object.entries(rawCredentials ?? {})) {
73
+ const models = objectOf(rawModels);
74
+ if (models === undefined) continue;
75
+ const byModel: Record<string, LlmStatsModelUsage> = {};
76
+ for (const [model, entry] of Object.entries(models)) {
77
+ const usage = modelUsageOf(entry);
78
+ if (usage !== undefined) byModel[model] = usage;
79
+ }
80
+ credentials[name] = byModel;
81
+ }
82
+ return { credentials, ...optionalNumber("total_usd", raw["total_usd"]) };
83
+ }
84
+
85
+ /** Every counter is optional and absent when it was not reported: a counter a
86
+ * display sums has to be a number or nothing, never a zero something else
87
+ * would then add up. */
88
+ function modelUsageOf(value: unknown): LlmStatsModelUsage | undefined {
89
+ const raw = objectOf(value);
90
+ if (raw === undefined) return undefined;
91
+ return {
92
+ ...optionalInteger("requests", raw["requests"]),
93
+ ...optionalInteger("input_tokens", raw["input_tokens"]),
94
+ ...optionalInteger("output_tokens", raw["output_tokens"]),
95
+ ...optionalInteger("cache_creation_input_tokens", raw["cache_creation_input_tokens"]),
96
+ ...optionalInteger("cache_read_input_tokens", raw["cache_read_input_tokens"]),
97
+ ...optionalNumber("usd", raw["usd"]),
98
+ };
99
+ }
@@ -0,0 +1,281 @@
1
+ import type {
2
+ InstanceId,
3
+ LlmStatusComponent,
4
+ LlmStatusIncident,
5
+ LlmStatusObserved,
6
+ LlmStatusObservedState,
7
+ LlmStatusOfficial,
8
+ LlmStatusOfficialState,
9
+ LlmStatusReport,
10
+ LlmStatusService,
11
+ LlmStatusSeverity,
12
+ } from "@ccmsg/protocol";
13
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
14
+ import {
15
+ fetchJson,
16
+ objectOf,
17
+ oneOf,
18
+ optionalInstant,
19
+ optionalInteger,
20
+ optionalText,
21
+ } from "./json.ts";
22
+
23
+ /** How long the read is given. The gateway answers from a snapshot it keeps,
24
+ * so a read that takes longer than this is one that is not coming. */
25
+ const TIMEOUT_MS = 10_000;
26
+
27
+ /** Cap on the document. A report names the services behind one gateway; past
28
+ * this it is not one, and the whole of it is held in memory to be parsed. */
29
+ const MAX_BYTES = 1024 * 1024;
30
+
31
+ /** How long the trouble a request event reports is left to settle before the
32
+ * report is re-read.
33
+ *
34
+ * A single upstream failure arrives as a burst — several routes are tried and
35
+ * each refusal is its own event — and the gateway is refreshing its own
36
+ * sources on the same trigger. Reading once after the burst gets one answer
37
+ * that has had a moment to become true, instead of one read per refusal. */
38
+ const TROUBLE_SETTLE_MS = 5_000;
39
+
40
+ /** The HTTP statuses that say the trouble is upstream's rather than this
41
+ * request's. Quota and credential refusals are a different report's subject
42
+ * (they reach a client as that credential's error), and a plain 5xx can be
43
+ * synthesised anywhere along the way — the gateway itself only treats an
44
+ * overload as a service signal, and neither does this. */
45
+ function isUpstreamTrouble(status: number | undefined): boolean {
46
+ return status === 529;
47
+ }
48
+
49
+ export interface LlmStatusDeps {
50
+ readonly self: InstanceId;
51
+ /** Where the gateway answers, already joined to its status path. */
52
+ readonly url: string;
53
+ readonly publish: (topic: string, data: unknown) => void;
54
+ readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
55
+ /** Replaces the fetch and the delay in tests. */
56
+ readonly fetch?: typeof fetch;
57
+ readonly settleMs?: number;
58
+ }
59
+
60
+ /** The gateway's report on the services behind it.
61
+ *
62
+ * `per_instance_whole` (§6.2): a frame replaces what this instance last said
63
+ * and leaves other instances' reports alone, because the report is one document
64
+ * the gateway behind this instance assembles and half of it means nothing on
65
+ * its own.
66
+ *
67
+ * It is read at two moments and no others (M3): when someone starts listening,
68
+ * and once after a request event says an upstream refused. There is no poll —
69
+ * a report only changes when something upstream did, and a request event is
70
+ * this instance being told exactly that. */
71
+ export class LlmStatus implements UpstreamResource {
72
+ #report: LlmStatusReport | undefined;
73
+ #reading: Promise<void> | undefined;
74
+ #settling: ReturnType<typeof setTimeout> | undefined;
75
+ #listening = false;
76
+
77
+ constructor(private readonly deps: LlmStatusDeps) {}
78
+
79
+ /** A request the gateway forwarded says an upstream refused it. The read is
80
+ * deferred, and a second refusal inside the same window joins the first
81
+ * rather than starting its own. */
82
+ noteRequestStatus(status: number | undefined): void {
83
+ if (!isUpstreamTrouble(status) || !this.#listening || this.#settling !== undefined) return;
84
+ this.#settling = setTimeout(() => {
85
+ this.#settling = undefined;
86
+ void this.read();
87
+ }, this.deps.settleMs ?? TROUBLE_SETTLE_MS);
88
+ // The instance must be able to leave while this is pending (§8.5).
89
+ this.#settling.unref?.();
90
+ }
91
+
92
+ /** Read the report and state it. Two callers at once share one read: the
93
+ * answer is the same document, and asking the gateway twice for it is what
94
+ * a burst of refusals would otherwise do. */
95
+ async read(): Promise<void> {
96
+ this.#reading ??= this.#read().finally(() => {
97
+ this.#reading = undefined;
98
+ });
99
+ await this.#reading;
100
+ }
101
+
102
+ // --- UpstreamResource (§6.3)
103
+
104
+ start(): void {
105
+ this.#listening = true;
106
+ void this.read();
107
+ }
108
+
109
+ stop(): void {
110
+ this.#listening = false;
111
+ if (this.#settling !== undefined) clearTimeout(this.#settling);
112
+ this.#settling = undefined;
113
+ }
114
+
115
+ /** What is held, if anything has been read. A subscriber that arrives before
116
+ * the first read gets nothing and then the report, rather than an empty
117
+ * report it would show as "every service unknown". */
118
+ snapshot(): readonly TopicValue[] {
119
+ if (this.#report === undefined) return [];
120
+ return [{ instance: this.deps.self, data: this.#report }];
121
+ }
122
+
123
+ async #read(): Promise<void> {
124
+ let document: unknown;
125
+ try {
126
+ document = await fetchJson(this.deps.url, {
127
+ timeoutMs: TIMEOUT_MS,
128
+ maxBytes: MAX_BYTES,
129
+ ...(this.deps.fetch === undefined ? {} : { fetch: this.deps.fetch }),
130
+ });
131
+ } catch (cause) {
132
+ // The last good report is kept: a read that failed says nothing about
133
+ // the services, and replacing what is known with nothing would blank the
134
+ // display over this instance's own trouble.
135
+ this.deps.log?.("could not read the gateway's status", { error: String(cause) });
136
+ return;
137
+ }
138
+ const report = reportOf(document);
139
+ if (report === undefined) {
140
+ this.deps.log?.("the gateway's status could not be understood");
141
+ return;
142
+ }
143
+ this.#report = report;
144
+ this.deps.publish("llm_status", report);
145
+ }
146
+ }
147
+
148
+ const SEVERITIES: readonly LlmStatusSeverity[] = ["ok", "warning", "critical", "unknown"];
149
+ const OFFICIAL_STATES: readonly LlmStatusOfficialState[] = [
150
+ "operational",
151
+ "degraded",
152
+ "partial_outage",
153
+ "major_outage",
154
+ "maintenance",
155
+ "unknown",
156
+ ];
157
+ const OBSERVED_STATES: readonly LlmStatusObservedState[] = ["reachable", "failing", "unknown"];
158
+
159
+ /** Read the gateway's document as this contract's report (§3.5).
160
+ *
161
+ * The gateway already answers in Unix ms under these names, so nothing is
162
+ * converted — but nothing is passed through unread either: every field is
163
+ * taken only at the type the contract states, and each closed vocabulary is
164
+ * checked against its own set. A word outside one arrives as `unknown` rather
165
+ * than as itself, which is what keeps a future vocabulary from reaching a
166
+ * screen as something nothing can draw. The verdict itself is never
167
+ * recomputed: the gateway knows which of its two signals outweighs the other,
168
+ * and a second opinion here would disagree with every other reader of the same
169
+ * report. */
170
+ export function reportOf(value: unknown): LlmStatusReport | undefined {
171
+ const raw = objectOf(value);
172
+ if (raw === undefined) return undefined;
173
+ const overall = objectOf(raw["overall"]);
174
+ const services = raw["services"];
175
+ if (overall === undefined || !Array.isArray(services)) return undefined;
176
+ return {
177
+ ...optionalInteger("schema_version", raw["schema_version"]),
178
+ ...optionalInstant("generated_at", raw["generated_at"]),
179
+ overall: {
180
+ severity: oneOf(SEVERITIES, overall["severity"], "unknown"),
181
+ service_counts: countsOf(overall["service_counts"]),
182
+ },
183
+ services: services.flatMap((service) => serviceOf(service) ?? []),
184
+ };
185
+ }
186
+
187
+ function serviceOf(value: unknown): LlmStatusService | undefined {
188
+ const raw = objectOf(value);
189
+ if (raw === undefined) return undefined;
190
+ const id = raw["id"];
191
+ const name = raw["name"];
192
+ if (typeof id !== "string" || typeof name !== "string") return undefined;
193
+ const routes = raw["routes"];
194
+ const official = officialOf(raw["official"]);
195
+ const observed = observedOf(raw["observed"]);
196
+ return {
197
+ id,
198
+ name,
199
+ severity: oneOf(SEVERITIES, raw["severity"], "unknown"),
200
+ routes: Array.isArray(routes) ? routes.filter((route) => typeof route === "string") : [],
201
+ ...(official === undefined ? {} : { official }),
202
+ ...(observed === undefined ? {} : { observed }),
203
+ };
204
+ }
205
+
206
+ function officialOf(value: unknown): LlmStatusOfficial | undefined {
207
+ const raw = objectOf(value);
208
+ if (raw === undefined) return undefined;
209
+ const components = raw["components"];
210
+ const incidents = raw["incidents"];
211
+ return {
212
+ state: oneOf(OFFICIAL_STATES, raw["state"], "unknown"),
213
+ ...optionalText("source", raw["source"]),
214
+ ...optionalText("source_url", raw["source_url"]),
215
+ ...optionalInstant("observed_at", raw["observed_at"]),
216
+ ...(typeof raw["stale"] === "boolean" ? { stale: raw["stale"] } : {}),
217
+ components: Array.isArray(components) ? components.flatMap((c) => componentOf(c) ?? []) : [],
218
+ incidents: Array.isArray(incidents) ? incidents.flatMap((i) => incidentOf(i) ?? []) : [],
219
+ ...optionalText("error", raw["error"]),
220
+ };
221
+ }
222
+
223
+ function componentOf(value: unknown): LlmStatusComponent | undefined {
224
+ const raw = objectOf(value);
225
+ if (raw === undefined || typeof raw["name"] !== "string") return undefined;
226
+ return {
227
+ ...optionalText("id", raw["id"]),
228
+ name: raw["name"],
229
+ state: oneOf(OFFICIAL_STATES, raw["state"], "unknown"),
230
+ };
231
+ }
232
+
233
+ /** One incident. Everything but its title is optional — a line with a title
234
+ * alone is still worth showing — and all of it is the provider's own prose,
235
+ * carried as text for a display that must never read it as markup. */
236
+ function incidentOf(value: unknown): LlmStatusIncident | undefined {
237
+ const raw = objectOf(value);
238
+ if (raw === undefined || typeof raw["name"] !== "string") return undefined;
239
+ return {
240
+ ...optionalText("id", raw["id"]),
241
+ name: raw["name"],
242
+ ...optionalText("state", raw["state"]),
243
+ ...optionalText("impact", raw["impact"]),
244
+ ...optionalInstant("created_at", raw["created_at"]),
245
+ ...optionalInstant("updated_at", raw["updated_at"]),
246
+ ...optionalText("url", raw["url"]),
247
+ ...optionalText("latest_update", raw["latest_update"]),
248
+ ...optionalText("scope", raw["scope"]),
249
+ };
250
+ }
251
+
252
+ function observedOf(value: unknown): LlmStatusObserved | undefined {
253
+ const raw = objectOf(value);
254
+ if (raw === undefined) return undefined;
255
+ const failure = objectOf(raw["last_failure"]);
256
+ return {
257
+ state: oneOf(OBSERVED_STATES, raw["state"], "unknown"),
258
+ ...optionalInstant("observed_at", raw["observed_at"]),
259
+ ...optionalInstant("expires_at", raw["expires_at"]),
260
+ ...optionalInstant("last_success_at", raw["last_success_at"]),
261
+ ...(failure === undefined
262
+ ? {}
263
+ : {
264
+ last_failure: {
265
+ ...optionalInstant("at", failure["at"]),
266
+ ...optionalText("kind", failure["kind"]),
267
+ ...optionalInteger("status", failure["status"]),
268
+ },
269
+ }),
270
+ };
271
+ }
272
+
273
+ function countsOf(value: unknown): Record<string, number> {
274
+ const raw = objectOf(value);
275
+ if (raw === undefined) return {};
276
+ const counts: Record<string, number> = {};
277
+ for (const [name, count] of Object.entries(raw)) {
278
+ if (typeof count === "number" && Number.isInteger(count) && count >= 0) counts[name] = count;
279
+ }
280
+ return counts;
281
+ }