@opengeni/api-router 0.2.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 (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. package/src/sandbox/viewer.ts +993 -0
@@ -0,0 +1,255 @@
1
+ // apps/api/src/sandbox/machines.ts — the M10 Machines-DASHBOARD service (dossier
2
+ // §10.7). Builds the `MachinesResponse` the dashboard renders: the workspace's
3
+ // enrolled selfhosted machines, each enriched with
4
+ // * STATE — the M3 liveness (online/reconnecting/offline) overlaid with the
5
+ // enrollment-derived consent/display reasons (consent_required /
6
+ // display_unavailable) — a real ControlRpc ping (the subject IS the registry),
7
+ // reusing the M7 fleet probe;
8
+ // * METRICS — the latest machine_metrics_latest row (or null before a first
9
+ // heartbeat), projected to the contract's MetricSample;
10
+ // * sharedSessionCount — the lease refcount (how many sessions share this one
11
+ // whole machine, the maxSandboxes:1 disclosure).
12
+ // PLUS, when a session context is supplied, the session's synthetic Modal group
13
+ // box (isSessionGroup:true) + the active-sandbox pointer (activeSandboxId/Epoch).
14
+ //
15
+ // This is workspace-scoped (perm enrollments:read) and flag-gated upstream
16
+ // (sandboxSelfhostedEnabled). It deliberately does NOT depend on a FleetContext
17
+ // (which is session-coupled): the pure workspace dashboard works without a
18
+ // session; an in-session view passes the optional session to add the group box +
19
+ // active pointer.
20
+
21
+ import type { Settings } from "@opengeni/config";
22
+ import {
23
+ getSession,
24
+ listEnrollments,
25
+ listSandboxes,
26
+ readActiveSandbox,
27
+ readLease,
28
+ readMachineMetricsLatestForWorkspace,
29
+ type Database,
30
+ type EnrollmentRecord,
31
+ type MachineMetricsRow,
32
+ } from "@opengeni/db";
33
+ import type { EventBus } from "@opengeni/events";
34
+ import {
35
+ MachineView,
36
+ MetricSample,
37
+ type MachinesResponse,
38
+ } from "@opengeni/contracts";
39
+ import {
40
+ NatsControlRpc,
41
+ selfhostedLiveness,
42
+ SelfhostedSession,
43
+ type ControlRpc,
44
+ type NatsRequestConnection,
45
+ } from "@opengeni/runtime/sandbox";
46
+ import { relayConfigFromSettings } from "@opengeni/core";
47
+
48
+ export type MachinesServices = {
49
+ db: Database;
50
+ settings: Settings;
51
+ bus?: EventBus;
52
+ };
53
+
54
+ const PROBE_TIMEOUT_MS = 5_000;
55
+
56
+ function controlRpc(bus: EventBus | undefined): ControlRpc {
57
+ return new NatsControlRpc(async (): Promise<NatsRequestConnection | null> => {
58
+ if (!bus) {
59
+ return null;
60
+ }
61
+ return bus.getRequestConnection();
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Project a stored `machine_metrics_latest` row to the contract `MetricSample`.
67
+ * The DB carries `gpuUtilPercent` + `gpuMemUsedBytes`/`gpuMemTotalBytes`; the wire
68
+ * `MetricSample` exposes the single `gpuUtilPct` + `gpuMemBytes` (USED bytes — the
69
+ * "how much VRAM is in use" the dashboard reads). A null any-numeric stays null
70
+ * (the not-reported contract); the byte/load fields default to 0 when a sample
71
+ * carried no value (the agent reports 0 == not-reported for those).
72
+ */
73
+ export function metricRowToSample(row: MachineMetricsRow): MetricSample {
74
+ return MetricSample.parse({
75
+ cpuPct: row.cpuPercent ?? 0,
76
+ load1: row.load1 ?? 0,
77
+ load5: row.load5 ?? 0,
78
+ load15: row.load15 ?? 0,
79
+ memUsedBytes: row.memUsedBytes ?? 0,
80
+ memTotalBytes: row.memTotalBytes ?? 0,
81
+ diskUsedBytes: row.diskUsedBytes ?? 0,
82
+ diskTotalBytes: row.diskTotalBytes ?? 0,
83
+ gpuUtilPct: row.gpuUtilPercent,
84
+ gpuMemBytes: row.gpuMemUsedBytes,
85
+ runQueue: row.contention ?? 0,
86
+ sampledAt: row.sampledAt,
87
+ });
88
+ }
89
+
90
+ /** Probe an enrolled machine's liveness — a real ControlRpc ping mapped through
91
+ * `selfhostedLiveness` (the enrollment status/consent/display + lastSeenAt
92
+ * disambiguate a probe-miss into reconnecting vs offline). Mirrors the M7 fleet
93
+ * probe. A non-active enrollment is offline without a probe. */
94
+ async function probeEnrollment(
95
+ services: MachinesServices,
96
+ workspaceId: string,
97
+ enrollment: EnrollmentRecord,
98
+ ): Promise<{ state: "online" | "reconnecting" | "offline"; consented: boolean; hasDisplay: boolean }> {
99
+ const { settings, bus } = services;
100
+ let probeResponded = false;
101
+ if (enrollment.status === "active") {
102
+ const session = new SelfhostedSession({
103
+ workspaceId,
104
+ agentId: enrollment.id,
105
+ controlRpc: controlRpc(bus),
106
+ relay: relayConfigFromSettings(settings),
107
+ timeoutMs: PROBE_TIMEOUT_MS,
108
+ });
109
+ try {
110
+ probeResponded = await session.ping();
111
+ } catch {
112
+ probeResponded = false;
113
+ }
114
+ }
115
+ const derived = selfhostedLiveness({
116
+ enrollment: {
117
+ status: enrollment.status,
118
+ exposure: enrollment.exposure,
119
+ allowScreenControl: enrollment.allowScreenControl,
120
+ hasDisplay: enrollment.hasDisplay,
121
+ lastSeenAt: enrollment.lastSeenAt,
122
+ },
123
+ probeResponded,
124
+ });
125
+ return { state: derived.state, consented: derived.consented, hasDisplay: derived.hasDisplay };
126
+ }
127
+
128
+ /**
129
+ * Resolve the dashboard STATE of a machine. State reflects REACHABILITY + the
130
+ * VIEW plane only: an online machine with no display → `display_unavailable` (no
131
+ * desktop stream, but compute — exec/fs/git/terminal — still works); otherwise
132
+ * the liveness state (online/reconnecting/offline). It deliberately does NOT fold
133
+ * in screen-control consent: a displayed machine can be VIEWED (read-only) and
134
+ * used for compute regardless of `allowScreenControl` — only INPUT (ComputerUse /
135
+ * an interactive stream) needs that consent, which is a per-capability concern
136
+ * carried by the separate `allowScreenControl` field (surfaced in the viewer's
137
+ * Take-control affordance), NOT a blocking machine state. This mirrors the
138
+ * view/control split in the selfhosted capability negotiation so the dashboard
139
+ * pill, the dock, and the "Run on" picker agree (a machine is never wrongly
140
+ * un-selectable just because its input isn't consented).
141
+ */
142
+ function machineStateFor(
143
+ liveness: "online" | "reconnecting" | "offline",
144
+ hasDisplay: boolean,
145
+ ): MachinesResponse["machines"][number]["state"] {
146
+ if (liveness !== "online") {
147
+ return liveness;
148
+ }
149
+ if (!hasDisplay) {
150
+ return "display_unavailable";
151
+ }
152
+ return "online";
153
+ }
154
+
155
+ /**
156
+ * Build the Machines dashboard response for a workspace. When `sessionId` is
157
+ * supplied (an in-session view) the session's synthetic Modal group box is
158
+ * prepended (`isSessionGroup:true`) and the active-sandbox pointer is echoed;
159
+ * without it (the pure workspace dashboard) `activeSandboxId` is null and only
160
+ * the enrolled machines are listed.
161
+ */
162
+ export async function listMachines(
163
+ services: MachinesServices,
164
+ input: { workspaceId: string; sessionId?: string | null },
165
+ ): Promise<MachinesResponse> {
166
+ const { db } = services;
167
+ const { workspaceId } = input;
168
+
169
+ // The session's active pointer (in-session view only). Absent session → the
170
+ // default null pointer (the workspace dashboard has no "active" machine).
171
+ let activeSandboxId: string | null = null;
172
+ let activeEpoch = 0;
173
+ let session: Awaited<ReturnType<typeof getSession>> | null = null;
174
+ if (input.sessionId) {
175
+ session = await getSession(db, workspaceId, input.sessionId);
176
+ if (session) {
177
+ const pointer = await readActiveSandbox(db, workspaceId, input.sessionId);
178
+ activeSandboxId = pointer?.activeSandboxId ?? null;
179
+ activeEpoch = pointer?.activeEpoch ?? 0;
180
+ }
181
+ }
182
+
183
+ const machines: MachineView[] = [];
184
+
185
+ // The session's own Modal group box (synthetic): the default/home sandbox a
186
+ // null active pointer routes to. Only present in an in-session view.
187
+ if (session) {
188
+ const groupActive = activeSandboxId === null;
189
+ machines.push(MachineView.parse({
190
+ sandboxId: session.sandboxGroupId,
191
+ enrollmentId: null,
192
+ name: "session sandbox",
193
+ kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
194
+ state: "online",
195
+ active: groupActive,
196
+ isSessionGroup: true,
197
+ // The Modal group box is a cloud Linux box; its precise OS/arch is not
198
+ // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
199
+ os: "linux",
200
+ arch: "x86_64",
201
+ hasDisplay: false,
202
+ allowScreenControl: false,
203
+ sharedSessionCount: 1,
204
+ lastSeenAt: null,
205
+ metrics: null,
206
+ }));
207
+ }
208
+
209
+ // The workspace's enrolled selfhosted machines. One bulk metrics read joined
210
+ // onto the machines (no N+1). Each machine is probed for liveness.
211
+ const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
212
+ listSandboxes(db, workspaceId),
213
+ listEnrollments(db, workspaceId),
214
+ readMachineMetricsLatestForWorkspace(db, workspaceId),
215
+ ]);
216
+ const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
217
+
218
+ for (const sandbox of sandboxes) {
219
+ if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
220
+ continue;
221
+ }
222
+ const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
223
+ if (!enrollment) {
224
+ continue;
225
+ }
226
+ const probe = await probeEnrollment(services, workspaceId, enrollment);
227
+ const state = machineStateFor(probe.state, probe.hasDisplay);
228
+
229
+ // sharedSessionCount = the lease refcount for this machine's group. The
230
+ // selfhosted sandbox id IS the lease group key (maxSandboxes:1, N sessions
231
+ // share via refcount). No lease yet → 0 sessions sharing.
232
+ const lease = await readLease(db, workspaceId, sandbox.id);
233
+ const sharedSessionCount = lease?.refcount ?? 0;
234
+
235
+ const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
236
+ machines.push(MachineView.parse({
237
+ sandboxId: sandbox.id,
238
+ enrollmentId: enrollment.id,
239
+ name: sandbox.name,
240
+ kind: "selfhosted",
241
+ state,
242
+ active: activeSandboxId === sandbox.id,
243
+ isSessionGroup: false,
244
+ os: enrollment.os,
245
+ arch: enrollment.arch,
246
+ hasDisplay: enrollment.hasDisplay,
247
+ allowScreenControl: enrollment.allowScreenControl,
248
+ sharedSessionCount,
249
+ lastSeenAt: enrollment.lastSeenAt,
250
+ metrics: metricsRow ? metricRowToSample(metricsRow) : null,
251
+ }));
252
+ }
253
+
254
+ return { activeSandboxId, activeEpoch, machines };
255
+ }
@@ -0,0 +1,289 @@
1
+ // apps/api/src/sandbox/metrics-ingestion.ts — the M10 metrics INGESTION consumer
2
+ // (dossier §10.7 + §10.6) + the connect-Hello DISPLAY-REFRESH consumer. The
3
+ // enrolled agent piggybacks a `MetricsSample` on its ~5s heartbeat (an
4
+ // `AgentEvent` published one-way on `agent.<ws>.<id>.events`) and publishes a
5
+ // `Hello` (its live self-description) on `agent.<ws>.<id>.hello` on every connect
6
+ // /reconnect. This module owns the two agent→control-plane inbound consumers:
7
+ //
8
+ // `agent.*.*.events` (heartbeat) →
9
+ // 1. touchEnrollmentLastSeen — the liveness cursor (online/reconnecting/offline
10
+ // derivation + the M3 probe disambiguation).
11
+ // 2. ingestMachineMetricsSample — UPSERT machine_metrics_latest (the "now" row)
12
+ // + APPEND a machine_metrics_series row downsampled to ~1/min.
13
+ // A GOING-OFFLINE event is not a metrics point — liveness flips via the lease/
14
+ // probe path; we skip it here (no-op).
15
+ //
16
+ // `agent.*.*.hello` (connect) →
17
+ // refreshEnrollmentDisplay — reconcile `enrollments.has_display` to the LIVE
18
+ // capability the Hello reports. `has_display` was previously FROZEN at the
19
+ // enroll-time offer snapshot; a machine that GAINS a display later (a Mac that
20
+ // grants Screen Recording, a box whose Xvfb starts) or LOSES one never
21
+ // re-surfaced. Consuming the Hello's `capabilities.desktop` / `display` makes
22
+ // `has_display` track reality (both directions), which the desktop-capability
23
+ // gate (packages/runtime capabilities.ts) keys off.
24
+ //
25
+ // Both consumers are BEST-EFFORT and fail-soft: a decode/DB error for one message
26
+ // is logged + swallowed (the bus subscription already swallows handler throws) so
27
+ // a metrics blip / a display-refresh write failure never tears down the consumer,
28
+ // back-pressures the agent, or breaks its connect.
29
+
30
+ import {
31
+ getEnrollment,
32
+ ingestMachineMetricsSample,
33
+ setEnrollmentHasDisplay,
34
+ touchEnrollmentLastSeen,
35
+ type Database,
36
+ type MachineMetricsSample,
37
+ } from "@opengeni/db";
38
+ import type { EventBus } from "@opengeni/events";
39
+ import type { Observability } from "@opengeni/observability";
40
+ import { AgentEvent, Hello, type MetricsSample } from "@opengeni/agent-proto";
41
+
42
+ /** The wildcard subject the agent event plane publishes heartbeats on. */
43
+ export const AGENT_EVENTS_SUBJECT = "agent.*.*.events";
44
+
45
+ /** The wildcard subject the agent publishes its connect Hello on. */
46
+ export const AGENT_HELLO_SUBJECT = "agent.*.*.hello";
47
+
48
+ /**
49
+ * Parse `agent.<ws>.<id>.<tail>` → `{ workspaceId, agentId }`, requiring the
50
+ * expected tail token. Returns null for a subject that does not match the shape
51
+ * (defensive — the subscription pattern already constrains it).
52
+ */
53
+ function parseAgentSubject(subject: string, tail: "events" | "hello"): { workspaceId: string; agentId: string } | null {
54
+ const parts = subject.split(".");
55
+ if (parts.length !== 4 || parts[0] !== "agent" || parts[3] !== tail) {
56
+ return null;
57
+ }
58
+ return { workspaceId: parts[1]!, agentId: parts[2]! };
59
+ }
60
+
61
+ /** Parse `agent.<ws>.<id>.events` → `{ workspaceId, agentId }` (heartbeat plane). */
62
+ export function parseAgentEventSubject(subject: string): { workspaceId: string; agentId: string } | null {
63
+ return parseAgentSubject(subject, "events");
64
+ }
65
+
66
+ /** Parse `agent.<ws>.<id>.hello` → `{ workspaceId, agentId }` (connect plane). */
67
+ export function parseAgentHelloSubject(subject: string): { workspaceId: string; agentId: string } | null {
68
+ return parseAgentSubject(subject, "hello");
69
+ }
70
+
71
+ /**
72
+ * Project a wire `MetricsSample` (proto, ms-stamped, GPU as a repeated list) to
73
+ * the DB `MachineMetricsSample`. The proto byte/count fields are protobuf-encoded
74
+ * as decimal strings (uint64) on the TS side (ts-proto `string`); coerce to
75
+ * numbers. The DB carries a single `gpuUtilPercent` + `gpuMemUsedBytes`/Total —
76
+ * we take the FIRST GPU (the dashboard surfaces the primary accelerator); absent
77
+ * GPUs stay null (the not-reported contract). A zero on a non-GPU field is the
78
+ * agent's "not reported" (we keep it null-friendly via `nullIfZero` only for the
79
+ * GPU plane; cpu/mem/disk 0 is a legitimate reading the dashboard shows as 0).
80
+ */
81
+ export function wireSampleToDbSample(wire: MetricsSample): MachineMetricsSample {
82
+ const num = (v: string | number): number => (typeof v === "number" ? v : Number(v));
83
+ const firstGpu = wire.gpus[0];
84
+ return {
85
+ cpuPercent: wire.cpuPercent,
86
+ load1: wire.load1,
87
+ load5: wire.load5,
88
+ load15: wire.load15,
89
+ memUsedBytes: num(wire.memUsedBytes),
90
+ memTotalBytes: num(wire.memTotalBytes),
91
+ diskUsedBytes: num(wire.diskUsedBytes),
92
+ diskTotalBytes: num(wire.diskTotalBytes),
93
+ gpuUtilPercent: firstGpu ? firstGpu.utilPercent : null,
94
+ gpuMemUsedBytes: firstGpu ? num(firstGpu.memUsedBytes) : null,
95
+ gpuMemTotalBytes: firstGpu ? num(firstGpu.memTotalBytes) : null,
96
+ contention: wire.runQueue,
97
+ // The sample carries its own wall-clock stamp (epoch ms); fall back to now on
98
+ // a missing/zero stamp so a series row is never NULL-dated.
99
+ sampledAt: wire.sampledAtMs && Number(wire.sampledAtMs) > 0 ? new Date(Number(wire.sampledAtMs)) : new Date(),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Ingest ONE decoded heartbeat for an enrolled machine. Resolves the enrollment's
105
+ * accountId (needed for the RLS-scoped writes) from the enrollment row; an
106
+ * unknown/cross-workspace agentId is ignored (no row → no write). Touches
107
+ * last-seen + upserts latest + downsamples the series.
108
+ */
109
+ export async function ingestHeartbeat(
110
+ db: Database,
111
+ input: { workspaceId: string; agentId: string; sample: MetricsSample },
112
+ ): Promise<{ ingested: boolean; seriesAppended: boolean }> {
113
+ // The enrollment row is the source of the accountId (the RLS principal) and the
114
+ // existence check. A revoked machine still reports its accountId, so we ingest
115
+ // (the dashboard shows its last sample); a truly unknown id is a no-op.
116
+ const enrollment = await getEnrollment(db, input.workspaceId, input.agentId);
117
+ if (!enrollment) {
118
+ return { ingested: false, seriesAppended: false };
119
+ }
120
+ const sample = wireSampleToDbSample(input.sample);
121
+ await touchEnrollmentLastSeen(db, {
122
+ accountId: enrollment.accountId,
123
+ workspaceId: input.workspaceId,
124
+ enrollmentId: input.agentId,
125
+ });
126
+ const result = await ingestMachineMetricsSample(db, {
127
+ accountId: enrollment.accountId,
128
+ workspaceId: input.workspaceId,
129
+ enrollmentId: input.agentId,
130
+ sample,
131
+ });
132
+ return { ingested: true, seriesAppended: result.seriesAppended };
133
+ }
134
+
135
+ /**
136
+ * Decode a raw `AgentEvent` payload + ingest it (the per-message handler). A
137
+ * heartbeat carrying a metrics sample is ingested; a going-offline (or a
138
+ * heartbeat without metrics) is a no-op. Decode failures are reported + swallowed.
139
+ */
140
+ export async function handleAgentEventPayload(
141
+ db: Database,
142
+ observability: Observability | undefined,
143
+ payload: Uint8Array,
144
+ subject: string,
145
+ ): Promise<void> {
146
+ const ids = parseAgentEventSubject(subject);
147
+ if (!ids) {
148
+ return;
149
+ }
150
+ let event: AgentEvent;
151
+ try {
152
+ event = AgentEvent.decode(payload);
153
+ } catch (error) {
154
+ observability?.warn?.("Failed to decode an agent event for metrics ingestion", {
155
+ subject,
156
+ error: error instanceof Error ? error.message : String(error),
157
+ });
158
+ return;
159
+ }
160
+ if (event.event?.$case !== "heartbeat") {
161
+ return; // going-offline / unknown → not a metrics point.
162
+ }
163
+ const metrics = event.event.heartbeat.metrics;
164
+ if (!metrics) {
165
+ return; // a heartbeat without a sample → liveness already touched elsewhere.
166
+ }
167
+ try {
168
+ await ingestHeartbeat(db, { workspaceId: ids.workspaceId, agentId: ids.agentId, sample: metrics });
169
+ } catch (error) {
170
+ observability?.warn?.("Failed to ingest a machine metrics heartbeat", {
171
+ subject,
172
+ error: error instanceof Error ? error.message : String(error),
173
+ });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Start the metrics-ingestion consumer: subscribe `agent.*.*.events` and ingest
179
+ * every heartbeat. Gated by sandboxSelfhostedEnabled (the caller checks the flag;
180
+ * a disabled deployment never starts the consumer). Returns the unsubscribe fn.
181
+ */
182
+ export function startMetricsIngestion(deps: {
183
+ db: Database;
184
+ bus: EventBus;
185
+ observability?: Observability;
186
+ }): () => void {
187
+ return deps.bus.subscribeAgentEvents(AGENT_EVENTS_SUBJECT, (payload, subject) =>
188
+ handleAgentEventPayload(deps.db, deps.observability, payload, subject),
189
+ );
190
+ }
191
+
192
+ // ── Connect-Hello display refresh ─────────────────────────────────────────────
193
+
194
+ /**
195
+ * The LIVE display presence the agent's Hello reports: a desktop framebuffer is
196
+ * available (`capabilities.desktop`, which the agent sets true only when a display
197
+ * probes AND it can stream it) OR a `Display` detail is present. An unset
198
+ * Capabilities (or a headless machine) → false. This is what `has_display` should
199
+ * track, replacing the enroll-time snapshot.
200
+ */
201
+ export function helloReportsDisplay(hello: Hello): boolean {
202
+ const caps = hello.capabilities;
203
+ if (!caps) {
204
+ return false;
205
+ }
206
+ return caps.desktop === true || caps.display != null;
207
+ }
208
+
209
+ /**
210
+ * Reconcile `enrollments.has_display` to the display presence a Hello reports.
211
+ * Resolves the enrollment (the accountId is the RLS principal + the existence
212
+ * check + the current value). A no-change Hello short-circuits BEFORE issuing any
213
+ * write (and the DB writer is itself change-guarded as a backstop), so a steady
214
+ * state never churns. An unknown/cross-workspace agentId is a no-op.
215
+ */
216
+ export async function refreshEnrollmentDisplay(
217
+ db: Database,
218
+ input: { workspaceId: string; agentId: string; hasDisplay: boolean },
219
+ ): Promise<{ updated: boolean }> {
220
+ const enrollment = await getEnrollment(db, input.workspaceId, input.agentId);
221
+ if (!enrollment) {
222
+ return { updated: false };
223
+ }
224
+ if (enrollment.hasDisplay === input.hasDisplay) {
225
+ // Unchanged — do not even issue the UPDATE (no churn on a steady-state Hello).
226
+ return { updated: false };
227
+ }
228
+ return await setEnrollmentHasDisplay(db, {
229
+ accountId: enrollment.accountId,
230
+ workspaceId: input.workspaceId,
231
+ enrollmentId: input.agentId,
232
+ hasDisplay: input.hasDisplay,
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Decode a raw `Hello` payload + refresh the enrollment's display cursor (the
238
+ * per-message handler for the hello plane). Decode failures + write failures are
239
+ * reported + swallowed — a display refresh must NEVER break the agent's connect.
240
+ */
241
+ export async function handleHelloPayload(
242
+ db: Database,
243
+ observability: Observability | undefined,
244
+ payload: Uint8Array,
245
+ subject: string,
246
+ ): Promise<void> {
247
+ const ids = parseAgentHelloSubject(subject);
248
+ if (!ids) {
249
+ return;
250
+ }
251
+ let hello: Hello;
252
+ try {
253
+ hello = Hello.decode(payload);
254
+ } catch (error) {
255
+ observability?.warn?.("Failed to decode an agent Hello for display refresh", {
256
+ subject,
257
+ error: error instanceof Error ? error.message : String(error),
258
+ });
259
+ return;
260
+ }
261
+ try {
262
+ await refreshEnrollmentDisplay(db, {
263
+ workspaceId: ids.workspaceId,
264
+ agentId: ids.agentId,
265
+ hasDisplay: helloReportsDisplay(hello),
266
+ });
267
+ } catch (error) {
268
+ observability?.warn?.("Failed to refresh an enrollment's display from a Hello", {
269
+ subject,
270
+ error: error instanceof Error ? error.message : String(error),
271
+ });
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Start the Hello display-refresh consumer: subscribe `agent.*.*.hello` and
277
+ * reconcile `has_display` to the live capability the agent reports on every
278
+ * connect. Gated by sandboxSelfhostedEnabled (the caller checks the flag). Returns
279
+ * the unsubscribe fn.
280
+ */
281
+ export function startHelloIngestion(deps: {
282
+ db: Database;
283
+ bus: EventBus;
284
+ observability?: Observability;
285
+ }): () => void {
286
+ return deps.bus.subscribeAgentEvents(AGENT_HELLO_SUBJECT, (payload, subject) =>
287
+ handleHelloPayload(deps.db, deps.observability, payload, subject),
288
+ );
289
+ }