@ccmsg/protocol 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.
@@ -0,0 +1,494 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response, topicFrame } from "../envelope.ts";
3
+ import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
4
+ import { upstream } from "../upstream.ts";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // quota
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Asks the gateway what quota each credential has left.
11
+ *
12
+ * The instance fetches rather than the client: the gateway is an internal
13
+ * address a browser could not reach anyway, and routing the call keeps that
14
+ * address out of the browser entirely. The reply takes as long as the fetch. */
15
+ export const LlmUsageReadArgs = Type.Object({
16
+ /** Have the gateway ask upstream instead of answering from its cache. Only a
17
+ * probe carries the per-credential limits, and it is the only request that
18
+ * can spend upstream rate limit — an account already limited answers a probe
19
+ * with a refusal that arrives as that credential's error. So this belongs to
20
+ * a deliberate action and never to polling. */
21
+ refresh: Type.Optional(Type.Boolean()),
22
+ });
23
+ export type LlmUsageReadArgs = Static<typeof LlmUsageReadArgs>;
24
+
25
+ /** One rolling quota window of one credential. */
26
+ export const LlmUsageWindow = Type.Object(
27
+ {
28
+ /** Share of the window's quota already spent, from 0 to 1. Values above 1
29
+ * happen and are not clamped. */
30
+ utilization: Type.Number(),
31
+ /** The provider's verdict for the window. An open set, kept as sent: an
32
+ * unknown verdict has to reach the screen as itself rather than be
33
+ * flattened into a wrong one. */
34
+ status: Type.String(),
35
+ /** When the window's counter rolls over. */
36
+ reset_at: Type.Optional(Timestamp),
37
+ /** How long the window is. The provider states it rather than leaving it to
38
+ * be read out of the window's name, because a name can describe a slot
39
+ * whose length differs between providers. Absent means unknown. */
40
+ window_secs: Type.Optional(Type.Integer({ minimum: 0 })),
41
+ /** The reading predates the reset, so it is not current consumption. Without
42
+ * this, a week-old figure over 100% reads as a credential that is out of
43
+ * quota right now. */
44
+ expired: Type.Optional(Type.Boolean()),
45
+ },
46
+ {
47
+ $id: "LlmUsageWindow",
48
+ ...upstream("llm-gateway", "one quota window as the provider states it"),
49
+ },
50
+ );
51
+ export type LlmUsageWindow = Static<typeof LlmUsageWindow>;
52
+
53
+ /** Extra-credit spending, which belongs to the credential rather than to any
54
+ * one window: a credential can be out of credits with every window still
55
+ * allowed. */
56
+ export const LlmUsageOverage = Type.Object(
57
+ {
58
+ status: Type.String(),
59
+ disabled_reason: Type.Optional(Type.String()),
60
+ },
61
+ { $id: "LlmUsageOverage" },
62
+ );
63
+ export type LlmUsageOverage = Static<typeof LlmUsageOverage>;
64
+
65
+ /** One reading of a credential's quota. It can lag the reply that carries it by
66
+ * minutes when the gateway has seen no traffic on that credential, which is why
67
+ * it states when it was taken. */
68
+ export const LlmUsageSnapshot = Type.Object(
69
+ {
70
+ observed_at: Type.Optional(Timestamp),
71
+ overage: Type.Optional(LlmUsageOverage),
72
+ /** Window name to window, names as the provider gives them. A map rather
73
+ * than named fields so a provider that starts reporting a third window
74
+ * appears without a change here. */
75
+ windows: Type.Record(Type.String(), LlmUsageWindow),
76
+ },
77
+ { $id: "LlmUsageSnapshot" },
78
+ );
79
+ export type LlmUsageSnapshot = Static<typeof LlmUsageSnapshot>;
80
+
81
+ /** A named limit the provider enforces beside the rolling windows. */
82
+ export const LlmUsageLimit = Type.Object(
83
+ {
84
+ /** Which limit this is, in the provider's vocabulary. An open set. */
85
+ kind: Type.String(),
86
+ /** Share spent, as a percentage from 0 to 100 — deliberately not the same
87
+ * unit as a window's utilization, because this is the provider's own figure
88
+ * and the wire stays a faithful copy of it. */
89
+ percent: Type.Number(),
90
+ /** The provider's own verdict. An open set. */
91
+ severity: Type.String(),
92
+ /** Absent for a limit with no scheduled reset. */
93
+ resets_at: Type.Optional(Timestamp),
94
+ /** The model family a scoped limit applies to, as a display name. */
95
+ model: Type.Optional(Type.String()),
96
+ /** The provider is currently counting against this limit. Not a statement
97
+ * that it is blocking anything: an idle limit can sit near its ceiling and
98
+ * an active one near zero. */
99
+ is_active: Type.Optional(Type.Boolean()),
100
+ /** How long the limit's period is, as the provider states it rather than as
101
+ * inferred from its kind. Absent means unknown. */
102
+ window_secs: Type.Optional(Type.Integer({ minimum: 0 })),
103
+ },
104
+ { $id: "LlmUsageLimit" },
105
+ );
106
+ export type LlmUsageLimit = Static<typeof LlmUsageLimit>;
107
+
108
+ /** How the credential's own authentication is holding up. Separate from its
109
+ * quota readings: a credential whose login has expired still has its last
110
+ * snapshot, and nothing in that snapshot says why the numbers stopped moving. */
111
+ export const LlmUsageAuth = Type.Object(
112
+ {
113
+ /** An open set. Anything unfamiliar means there is nothing to announce, not
114
+ * that something is wrong. */
115
+ status: Type.String(),
116
+ /** What to do about it, in the gateway's words. Present when the status is
117
+ * not a healthy one. */
118
+ reason: Type.Optional(Type.String()),
119
+ observed_at: Type.Optional(Timestamp),
120
+ /** Where a person can log in again. Absolute: the gateway states a path,
121
+ * since it does not know the address it is published under, and the instance
122
+ * resolves it against the endpoint it fetched — resolving it a second time
123
+ * against a client's own address would point at the wrong host. Present only
124
+ * for a credential a browser can actually re-authenticate. */
125
+ login_url: Type.Optional(Type.String()),
126
+ },
127
+ { $id: "LlmUsageAuth" },
128
+ );
129
+ export type LlmUsageAuth = Static<typeof LlmUsageAuth>;
130
+
131
+ export const LlmUsageCredential = Type.Object(
132
+ {
133
+ name: Type.String(),
134
+ /** What kind of credential it is. An open set. */
135
+ type: Type.Optional(Type.String()),
136
+ /** Whether quota is knowable for this credential at all: observed, not
137
+ * applicable, or dependent on something further upstream. An open set. */
138
+ support: Type.String(),
139
+ /** Absent means nothing is known about the authentication, never that it is
140
+ * healthy. */
141
+ auth: Type.Optional(LlmUsageAuth),
142
+ /** Present when quota is observed for this credential. */
143
+ snapshot: Type.Optional(LlmUsageSnapshot),
144
+ /** In the provider's order. Absent means none were reported, which a client
145
+ * shows as nothing rather than as a fault. */
146
+ limits: Type.Optional(Type.Array(LlmUsageLimit)),
147
+ /** Why the last refresh of this credential failed. Whatever snapshot sits
148
+ * beside it is the last good reading, and its time says how old that is. */
149
+ probe_error: Type.Optional(Type.String()),
150
+ },
151
+ { $id: "LlmUsageCredential" },
152
+ );
153
+ export type LlmUsageCredential = Static<typeof LlmUsageCredential>;
154
+
155
+ export const LlmUsageReadResult = Type.Object({
156
+ /** When the gateway assembled the answer. */
157
+ generated_at: Type.Optional(Timestamp),
158
+ credentials: Type.Array(LlmUsageCredential),
159
+ });
160
+ export type LlmUsageReadResult = Static<typeof LlmUsageReadResult>;
161
+
162
+ export const LlmUsageReadRequest = request("llm_usage_read", LlmUsageReadArgs);
163
+ export const LlmUsageReadResponse = response("llm_usage_read", LlmUsageReadResult);
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // spend
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /** Asks the gateway what the host's credentials have cost, by day. */
170
+ export const LlmStatsReadArgs = Type.Object({
171
+ /** How far back to ask for. The gateway narrows a request wider than its own
172
+ * history, so asking for more than it holds is the supported way to say
173
+ * "everything". Absent leaves the gateway's own default. */
174
+ days: Type.Optional(Type.Integer({ minimum: 1 })),
175
+ });
176
+ export type LlmStatsReadArgs = Static<typeof LlmStatsReadArgs>;
177
+
178
+ /** What one model cost on one day under one credential. Every counter is
179
+ * optional and passed through as sent: which counters exist is the gateway's to
180
+ * decide, and a missing one has to read as "not reported" rather than as a zero
181
+ * something would then add up. */
182
+ export const LlmStatsModelUsage = Type.Object(
183
+ {
184
+ requests: Type.Optional(Type.Integer({ minimum: 0 })),
185
+ input_tokens: Type.Optional(Type.Integer({ minimum: 0 })),
186
+ output_tokens: Type.Optional(Type.Integer({ minimum: 0 })),
187
+ cache_creation_input_tokens: Type.Optional(Type.Integer({ minimum: 0 })),
188
+ cache_read_input_tokens: Type.Optional(Type.Integer({ minimum: 0 })),
189
+ /** Spend in USD. */
190
+ usd: Type.Optional(Type.Number()),
191
+ },
192
+ { $id: "LlmStatsModelUsage" },
193
+ );
194
+ export type LlmStatsModelUsage = Static<typeof LlmStatsModelUsage>;
195
+
196
+ /** One day's spend, by credential and then by model. */
197
+ export const LlmStatsDay = Type.Object(
198
+ {
199
+ credentials: Type.Record(Type.String(), Type.Record(Type.String(), LlmStatsModelUsage)),
200
+ /** The gateway's own total, kept rather than recomputed: it is the
201
+ * authoritative figure, and it can differ from the sum when the gateway
202
+ * counts something it does not break out. */
203
+ total_usd: Type.Optional(Type.Number()),
204
+ },
205
+ { $id: "LlmStatsDay" },
206
+ );
207
+ export type LlmStatsDay = Static<typeof LlmStatsDay>;
208
+
209
+ export const LlmStatsReadResult = Type.Object({
210
+ generated_at: Type.Optional(Timestamp),
211
+ /** Keyed `YYYY-MM-DD` in the gateway's own timezone. A day here means
212
+ * whatever the gateway means by it; nothing reinterprets the dates. */
213
+ days: Type.Record(Type.String(), LlmStatsDay),
214
+ });
215
+ export type LlmStatsReadResult = Static<typeof LlmStatsReadResult>;
216
+
217
+ export const LlmStatsReadRequest = request("llm_stats_read", LlmStatsReadArgs);
218
+ export const LlmStatsReadResponse = response("llm_stats_read", LlmStatsReadResult);
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // live requests
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /** How long a prompt cache window is assumed to last when an event states
225
+ * neither its deadline nor where the request came from. Both sides count down
226
+ * to the same instant, so the assumption has to be shared; whenever the gateway
227
+ * states the real length, that wins over this. */
228
+ export const LLM_PROMPT_CACHE_TTL_MS = 5 * 60 * 1000;
229
+
230
+ /** One request the gateway saw go upstream.
231
+ *
232
+ * The session id is the one the gateway read off the request, which is the same
233
+ * identifier sessions are known by here — that shared key is what lets a client
234
+ * put a countdown on a session. Requests the gateway could not attribute to a
235
+ * session never appear. */
236
+ export const LlmRequestInfo = Type.Object(
237
+ {
238
+ /** When the upstream response headers arrived, which is when the prompt
239
+ * cache starts running down — not when the request was sent. */
240
+ received_at: Timestamp,
241
+ sid: Sid,
242
+ /** The instance whose gateway saw it. */
243
+ instance: InstanceId,
244
+ /** Names the conversation series: a session's subagents travel under the
245
+ * same session id but a different series, and their caches are genuinely
246
+ * separate, so a cache window belongs to the pair and never to the session
247
+ * alone. Absent when the gateway reports none, which collapses those events
248
+ * into one unnamed series per session. Series names are not unique across
249
+ * sessions, which is the other half of why the pair is the key. */
250
+ prefix: Type.Optional(Type.String()),
251
+ /** True for the series the session's own turns keep warm, as opposed to a
252
+ * subagent's. The instance decides it so that every client agrees on which
253
+ * window is the session's. */
254
+ main: Type.Boolean(),
255
+ /** Whose turn issued the request, as the gateway read it. An open set; this
256
+ * is one of the inputs to `main`, and `main` is the verdict clients read. */
257
+ origin: Type.Optional(Type.String()),
258
+ /** When this series' cache goes cold, as the gateway computed it. Absent
259
+ * when the request cached nothing, in which case the window closed as it
260
+ * opened. A keepalive arrives as another event on the same series, so a live
261
+ * window's end keeps moving. */
262
+ cache_expires_at: Type.Optional(Timestamp),
263
+ /** The cache length the gateway asked for. */
264
+ cache_ttl_secs: Type.Optional(Type.Integer({ minimum: 0 })),
265
+ /** The gateway is holding off on keepalives for this series. Stated on
266
+ * every event of a series the strategy covers, so absent means the series is
267
+ * not one it keeps alive — never that keepalives are running. */
268
+ cache_paused: Type.Optional(Type.Boolean()),
269
+ /** The real request that began the current keepalive chain. The same value
270
+ * on the request and on every keepalive's return, which is what makes it the
271
+ * chain's fixed origin while the other instants walk forward. */
272
+ cache_since_at: Type.Optional(Timestamp),
273
+ /** Position in the chain: zero on the real request. */
274
+ cache_count: Type.Optional(Type.Integer({ minimum: 0 })),
275
+ /** When the gateway plans to send the next keepalive. Absent when it plans
276
+ * none. */
277
+ next_keepalive_at: Type.Optional(Timestamp),
278
+ /** Where the chain is projected to end, and how many keepalives that takes.
279
+ * A projection recomputed per event, so the newest event's value replaces
280
+ * the previous one rather than being merged with it. */
281
+ cache_until_at: Type.Optional(Timestamp),
282
+ cache_until_count: Type.Optional(Type.Integer({ minimum: 0 })),
283
+ /** Past this, keeping the cache warm costs more than rebuilding it. Omitted
284
+ * for models whose price the gateway does not know. */
285
+ cache_breakeven_until_at: Type.Optional(Timestamp),
286
+ cache_breakeven_count: Type.Optional(Type.Integer({ minimum: 0 })),
287
+ /** Present only on a keepalive's return trip, carrying how the gateway
288
+ * judged it. Its absence is how an ordinary request is told apart. */
289
+ keepalive: Type.Optional(Type.String()),
290
+ ns: Type.Optional(Type.String()),
291
+ model: Type.Optional(Type.String()),
292
+ credential: Type.Optional(Type.String()),
293
+ status: Type.Optional(Type.Integer()),
294
+ },
295
+ {
296
+ $id: "LlmRequestInfo",
297
+ ...upstream("llm-gateway", "one observed request, renamed and re-united from its event"),
298
+ },
299
+ );
300
+ export type LlmRequestInfo = Static<typeof LlmRequestInfo>;
301
+
302
+ /** When the cache window one request opened closes.
303
+ *
304
+ * The instance prunes by it and clients draw to it, so the arithmetic lives
305
+ * here once instead of once per side. A request that states where it came from
306
+ * but names no deadline is the gateway saying it cached nothing; only an event
307
+ * that states neither falls back to the assumed length. */
308
+ export function llmCacheWindowEndAt(info: {
309
+ received_at: number;
310
+ cache_expires_at?: number;
311
+ origin?: string;
312
+ }): number {
313
+ if (info.cache_expires_at !== undefined) return info.cache_expires_at;
314
+ if (info.origin !== undefined) return info.received_at;
315
+ return info.received_at + LLM_PROMPT_CACHE_TTL_MS;
316
+ }
317
+
318
+ /** The `llm_requests` topic: the newest request per conversation series, always
319
+ * the whole unexpired set rather than the one that just arrived. A client that
320
+ * starts listening mid-window still needs the countdown that began before it
321
+ * was there, and one shape serves both that and the live update. An empty set
322
+ * is a legitimate "no session has a warm cache". */
323
+ export const LlmRequestsFrame = topicFrame("llm_requests", Type.Array(LlmRequestInfo));
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // upstream health
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /** The gateway's display verdict, for one service and for the report as a
330
+ * whole. Nothing recomputes it here: the gateway knows which of the two signals
331
+ * below outweighs the other, and a second opinion would disagree with every
332
+ * other reader of the same report. A value outside this set becomes `unknown`
333
+ * rather than travelling, so a future vocabulary cannot arrive as a word
334
+ * nothing can render. */
335
+ export const LlmStatusSeverity = Type.Union(
336
+ [Type.Literal("ok"), Type.Literal("warning"), Type.Literal("critical"), Type.Literal("unknown")],
337
+ { $id: "LlmStatusSeverity" },
338
+ );
339
+ export type LlmStatusSeverity = Static<typeof LlmStatusSeverity>;
340
+
341
+ /** What the provider's own status page says. A closed vocabulary; anything
342
+ * else, including a page that could not be read, is `unknown`. */
343
+ export const LlmStatusOfficialState = Type.Union(
344
+ [
345
+ Type.Literal("operational"),
346
+ Type.Literal("degraded"),
347
+ Type.Literal("partial_outage"),
348
+ Type.Literal("major_outage"),
349
+ Type.Literal("maintenance"),
350
+ Type.Literal("unknown"),
351
+ ],
352
+ { $id: "LlmStatusOfficialState" },
353
+ );
354
+ export type LlmStatusOfficialState = Static<typeof LlmStatusOfficialState>;
355
+
356
+ /** What the gateway itself saw. Deliberately worded apart from the official
357
+ * vocabulary, so the two signals can never be mistaken for each other. */
358
+ export const LlmStatusObservedState = Type.Union(
359
+ [Type.Literal("reachable"), Type.Literal("failing"), Type.Literal("unknown")],
360
+ { $id: "LlmStatusObservedState" },
361
+ );
362
+ export type LlmStatusObservedState = Static<typeof LlmStatusObservedState>;
363
+
364
+ export const LlmStatusComponent = Type.Object(
365
+ {
366
+ id: Type.Optional(Type.String()),
367
+ name: Type.String(),
368
+ state: LlmStatusOfficialState,
369
+ },
370
+ { $id: "LlmStatusComponent" },
371
+ );
372
+ export type LlmStatusComponent = Static<typeof LlmStatusComponent>;
373
+
374
+ /** One unresolved incident from the provider's status page. Everything but the
375
+ * title is optional — a line with a title alone is still worth showing. All of
376
+ * it is the provider's prose, to be shown as text and never as markup. */
377
+ export const LlmStatusIncident = Type.Object(
378
+ {
379
+ id: Type.Optional(Type.String()),
380
+ name: Type.String(),
381
+ /** The provider's own workflow word. An open set, shown as it stands. */
382
+ state: Type.Optional(Type.String()),
383
+ impact: Type.Optional(Type.String()),
384
+ created_at: Type.Optional(Timestamp),
385
+ updated_at: Type.Optional(Timestamp),
386
+ url: Type.Optional(Type.String()),
387
+ latest_update: Type.Optional(Type.String()),
388
+ /** Says the incident carries no component mapping, so it is shown for
389
+ * reference and did not raise this service's severity. */
390
+ scope: Type.Optional(Type.String()),
391
+ },
392
+ { $id: "LlmStatusIncident" },
393
+ );
394
+ export type LlmStatusIncident = Static<typeof LlmStatusIncident>;
395
+
396
+ export const LlmStatusOfficial = Type.Object(
397
+ {
398
+ state: LlmStatusOfficialState,
399
+ /** How the gateway obtained it. */
400
+ source: Type.Optional(Type.String()),
401
+ /** The human-readable status page. */
402
+ source_url: Type.Optional(Type.String()),
403
+ observed_at: Type.Optional(Timestamp),
404
+ /** The reading is older than the gateway's freshness bound: what is shown
405
+ * is the last success, not a current reading. */
406
+ stale: Type.Optional(Type.Boolean()),
407
+ components: Type.Array(LlmStatusComponent),
408
+ incidents: Type.Array(LlmStatusIncident),
409
+ /** Why the last read failed. The previous good state is kept beside it
410
+ * rather than replaced by the failure. */
411
+ error: Type.Optional(Type.String()),
412
+ },
413
+ { $id: "LlmStatusOfficial" },
414
+ );
415
+ export type LlmStatusOfficial = Static<typeof LlmStatusOfficial>;
416
+
417
+ export const LlmStatusObserved = Type.Object(
418
+ {
419
+ state: LlmStatusObservedState,
420
+ observed_at: Type.Optional(Timestamp),
421
+ /** When the observation stops counting and the state falls back to
422
+ * unknown. */
423
+ expires_at: Type.Optional(Timestamp),
424
+ last_success_at: Type.Optional(Timestamp),
425
+ last_failure: Type.Optional(
426
+ Type.Object({
427
+ at: Type.Optional(Timestamp),
428
+ /** What kind of failure it was. An open set. */
429
+ kind: Type.Optional(Type.String()),
430
+ /** The HTTP status, when the failure had one. */
431
+ status: Type.Optional(Type.Integer()),
432
+ }),
433
+ ),
434
+ },
435
+ { $id: "LlmStatusObserved" },
436
+ );
437
+ export type LlmStatusObserved = Static<typeof LlmStatusObserved>;
438
+
439
+ /** One upstream service, with the two signals kept apart. Both may be absent —
440
+ * a gateway can report a service it has neither read about nor exercised —
441
+ * while the severity is always there, since that is what a display is chosen
442
+ * from. */
443
+ export const LlmStatusService = Type.Object(
444
+ {
445
+ id: Type.String(),
446
+ name: Type.String(),
447
+ severity: LlmStatusSeverity,
448
+ /** Which configured routes draw on this service. */
449
+ routes: Type.Array(Type.String()),
450
+ official: Type.Optional(LlmStatusOfficial),
451
+ observed: Type.Optional(LlmStatusObserved),
452
+ },
453
+ { $id: "LlmStatusService" },
454
+ );
455
+ export type LlmStatusService = Static<typeof LlmStatusService>;
456
+
457
+ /** The worst severity across services, with the breakdown that keeps "one
458
+ * critical among many healthy" from reading as "everything is down". */
459
+ export const LlmStatusOverall = Type.Object(
460
+ {
461
+ severity: LlmStatusSeverity,
462
+ /** Severity to how many services hold it. A map rather than named fields so
463
+ * a future severity needs no change here. */
464
+ service_counts: Type.Record(Type.String(), Type.Integer({ minimum: 0 })),
465
+ },
466
+ { $id: "LlmStatusOverall" },
467
+ );
468
+ export type LlmStatusOverall = Static<typeof LlmStatusOverall>;
469
+
470
+ /** The gateway's report on the services behind it.
471
+ *
472
+ * It has no op of its own: a client subscribes and receives the current report
473
+ * as the snapshot, then a fresh one whenever the gateway reports trouble and
474
+ * the instance re-reads. That moment is exactly when a display has to change
475
+ * and the one moment a client cannot anticipate. */
476
+ export const LlmStatusReport = Type.Object(
477
+ {
478
+ /** The gateway's own version of this document's shape. Passed through
479
+ * rather than gated on: every field degrades on its own, so a newer document
480
+ * arrives as unknowns instead of as nothing. */
481
+ schema_version: Type.Optional(Type.Integer({ minimum: 0 })),
482
+ generated_at: Type.Optional(Timestamp),
483
+ overall: LlmStatusOverall,
484
+ services: Type.Array(LlmStatusService),
485
+ },
486
+ {
487
+ $id: "LlmStatusReport",
488
+ ...upstream("llm-gateway", "the gateway's upstream-service report, renamed only"),
489
+ },
490
+ );
491
+ export type LlmStatusReport = Static<typeof LlmStatusReport>;
492
+
493
+ /** The `llm_status` topic. Whole-value: each frame replaces the last. */
494
+ export const LlmStatusFrame = topicFrame("llm_status", LlmStatusReport);
@@ -0,0 +1,127 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { topicFrame } from "../envelope.ts";
3
+ import { InstanceId, Sid, Timestamp } from "../identifiers.ts";
4
+
5
+ /** A client of one session whose greeting was refused.
6
+ *
7
+ * Overwritten rather than accumulated: such a client reconnects every few
8
+ * seconds, so what is worth reading is that it is still happening, not how
9
+ * often it has tried. The session itself is fine — some other process of the
10
+ * same session is the stale one, and it is invisible otherwise, retrying
11
+ * forever with nothing in sight moving. */
12
+ export const StaleClientInfo = Type.Object(
13
+ {
14
+ last_seen_at: Timestamp,
15
+ /** The build it reported, when it got far enough to report one. */
16
+ version: Type.Optional(Type.String()),
17
+ /** The generation it announced. Absent means it was refused before it could
18
+ * be read as a greeting at all. */
19
+ protocol_version: Type.Optional(Type.Integer({ minimum: 1 })),
20
+ },
21
+ { $id: "StaleClientInfo" },
22
+ );
23
+ export type StaleClientInfo = Static<typeof StaleClientInfo>;
24
+
25
+ /** One connected session.
26
+ *
27
+ * The paths are the host's, so the entry names the instance holding them:
28
+ * `peers` carries every instance's sessions in one list, and two hosts' paths
29
+ * would otherwise be indistinguishable. */
30
+ export const PeerInfo = Type.Object(
31
+ {
32
+ sid: Sid,
33
+ instance: InstanceId,
34
+ repo: Type.String(),
35
+ ws: Type.String(),
36
+ cwd: Type.String(),
37
+ /** Present when the session announced a transcript the instance accepted,
38
+ * which is what decides whether its transcript can be read at all. */
39
+ transcript_path: Type.Optional(Type.String()),
40
+ /** Present when the session announced a repository container the instance
41
+ * accepted. File browsing is rooted here rather than at the working
42
+ * directory, so sibling workspaces are reachable. */
43
+ repo_root: Type.Optional(Type.String()),
44
+ branch: Type.Optional(Type.String()),
45
+ /** When this session first registered with the instance. Stable across its
46
+ * reconnections, and reset when the instance restarts. */
47
+ connected_at: Type.Optional(Timestamp),
48
+ /** The session's most recent request on any of its connections. */
49
+ last_activity_at: Type.Optional(Timestamp),
50
+ /** When a person last put something into this session: a prompt they typed,
51
+ * or a message they sent it. Distinct from the activity above, which every
52
+ * request the session makes on its own re-stamps — this one moves only when
53
+ * a person speaks, which is what an attention-ordered list wants. Absent
54
+ * while none has been found; a client orders such a session after every
55
+ * session that has one rather than treating it as long ago. */
56
+ last_user_input_at: Type.Optional(Timestamp),
57
+ /** Whether the asking session can reach this one with the harness's own
58
+ * cross-session messaging, which does not cross config homes. Computed
59
+ * against the asker, so it never appears on the asker's own entry nor for a
60
+ * person, who has no config home to compare. Absent means one side's config
61
+ * home is unknown: an unflagged peer merely costs a message sent the long
62
+ * way, a wrongly flagged one costs a message that never arrives. */
63
+ send_message: Type.Optional(Type.Boolean()),
64
+ /** The build of the client that last greeted for this session. */
65
+ client_version: Type.Optional(Type.String()),
66
+ /** The generation that client speaks. Always stated: a client that does not
67
+ * announce one is refused, so there is no connected session whose
68
+ * generation is a guess. */
69
+ protocol_version: Type.Integer({ minimum: 1 }),
70
+ /** Set while some client of this session is being refused. */
71
+ stale_client: Type.Optional(StaleClientInfo),
72
+ },
73
+ { $id: "PeerInfo" },
74
+ );
75
+ export type PeerInfo = Static<typeof PeerInfo>;
76
+
77
+ /** One session that was connected when its instance last saw it, and has not
78
+ * come back.
79
+ *
80
+ * The connection fields are a frozen copy of what that session looked like at
81
+ * the last snapshot, not a live reading — by definition it is not connected
82
+ * while it appears here. The model and effort are the exception: they are read
83
+ * back from the transcript's last turn, because what the session must resume as
84
+ * is a property of where it actually stopped, not of when the snapshot was
85
+ * written. An entry leaves this list the moment its session registers again, so
86
+ * a fully recovered host shows none. */
87
+ export const LastLiveSession = Type.Object(
88
+ {
89
+ sid: Sid,
90
+ instance: InstanceId,
91
+ repo: Type.String(),
92
+ ws: Type.String(),
93
+ cwd: Type.String(),
94
+ /** Also where the model and effort below were read from. */
95
+ transcript_path: Type.Optional(Type.String()),
96
+ repo_root: Type.Optional(Type.String()),
97
+ branch: Type.Optional(Type.String()),
98
+ /** The session's own title as of the snapshot, when one was known. Absent
99
+ * means not known, never untitled. */
100
+ title: Type.Optional(Type.String()),
101
+ connected_at: Type.Optional(Timestamp),
102
+ /** The newest instant this session is known to have been alive. */
103
+ last_seen_at: Timestamp,
104
+ /** What its last turn ran as, in the transcript's own spelling. A resume
105
+ * must not quietly switch the session to something else. */
106
+ model: Type.Optional(Type.String()),
107
+ effort: Type.Optional(Type.String()),
108
+ },
109
+ { $id: "LastLiveSession" },
110
+ );
111
+ export type LastLiveSession = Static<typeof LastLiveSession>;
112
+
113
+ /** The `peers` topic.
114
+ *
115
+ * Whole-value per instance: a frame replaces everything previously known from
116
+ * the instance that sent it and leaves other instances' entries alone, which is
117
+ * what lets several instances each state their whole list without colliding.
118
+ *
119
+ * Both lists travel together because a session registering is exactly what
120
+ * moves it from one to the other. */
121
+ export const PeersFrame = topicFrame(
122
+ "peers",
123
+ Type.Object({
124
+ peers: Type.Array(PeerInfo),
125
+ last_live: Type.Array(LastLiveSession),
126
+ }),
127
+ );
@@ -0,0 +1,58 @@
1
+ import { type Static, Type } from "@sinclair/typebox";
2
+ import { request, response } from "../envelope.ts";
3
+ import { Sid, Timestamp } from "../identifiers.ts";
4
+ import { FileKind } from "./files.ts";
5
+
6
+ /** Mints a URL on the sandbox origin for one file.
7
+ *
8
+ * A file served from the instance's own origin cannot be handed its real
9
+ * content type without letting it act as part of the application, so a client
10
+ * that wants to open a file as a page, or download it as itself, asks for a URL
11
+ * on a separate origin instead.
12
+ *
13
+ * The grant widens nothing: the same check the matching read performs runs both
14
+ * when the URL is minted and on every request it serves, so this can fail only
15
+ * the way that read would and can never reach a file the caller could not
16
+ * already read. */
17
+ export const SandboxGrantArgs = Type.Object({
18
+ sid: Sid,
19
+ kind: FileKind,
20
+ path: Type.String({ minLength: 1 }),
21
+ });
22
+ export type SandboxGrantArgs = Static<typeof SandboxGrantArgs>;
23
+
24
+ export const SandboxGrantResult = Type.Object({
25
+ /** Names the grant, and separates its origin from every other grant's. It
26
+ * travels inside a hostname and is therefore seen by every resolver on the
27
+ * way, so it is not a secret and authorizes nothing by itself. */
28
+ gid: Type.String({ minLength: 1 }),
29
+ /** The secret that does authorize a request. Returned so a client can build
30
+ * a sibling URL — a download beside a preview — without minting again. */
31
+ token: Type.String({ minLength: 1 }),
32
+ /** Ready to open as it stands. */
33
+ url: Type.String({ minLength: 1 }),
34
+ /** When the grant stops working. Minting the same scope again returns this
35
+ * same grant with a later expiry, so an open preview keeps working. */
36
+ expires_at: Timestamp,
37
+ });
38
+ export type SandboxGrantResult = Static<typeof SandboxGrantResult>;
39
+
40
+ export const SandboxGrantRequest = request("sandbox_grant", SandboxGrantArgs);
41
+ export const SandboxGrantResponse = response("sandbox_grant", SandboxGrantResult);
42
+
43
+ /** Ends a grant early, as when a preview is closed.
44
+ *
45
+ * Best effort by design. The expiry is what actually bounds a grant's life, so
46
+ * an unknown or already-expired grant still succeeds: there is nothing a caller
47
+ * would do differently, and reporting "no such grant" would make this an
48
+ * existence oracle. */
49
+ export const SandboxRevokeArgs = Type.Object({
50
+ gid: Type.String({ minLength: 1 }),
51
+ });
52
+ export type SandboxRevokeArgs = Static<typeof SandboxRevokeArgs>;
53
+
54
+ export const SandboxRevokeResult = Type.Object({});
55
+ export type SandboxRevokeResult = Static<typeof SandboxRevokeResult>;
56
+
57
+ export const SandboxRevokeRequest = request("sandbox_revoke", SandboxRevokeArgs);
58
+ export const SandboxRevokeResponse = response("sandbox_revoke", SandboxRevokeResult);