@opengeni/runtime 0.6.1 → 0.7.1

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 (45) hide show
  1. package/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
  4. package/dist/index.d.ts +296 -123
  5. package/dist/index.js +1907 -809
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2687 -920
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +84 -14
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-DYBF7ZEB.js.map +0 -1
  44. package/src/image-history.ts +0 -149
  45. package/src/screenshot-omitted-card.ts +0 -15
@@ -0,0 +1,468 @@
1
+ // Test doubles for the op-stream exec client (op-stream.ts): an in-memory
2
+ // transport and a scripted FAKE RUNNER that emulates the Rust runner's
3
+ // protocol behavior faithfully enough to exercise every client path —
4
+ // idempotent OpStart (dedup by op id, never re-runs), attach replay from
5
+ // retention, live-emission fault injection (drops / duplicates / reordering),
6
+ // OpQuery status, typed lost states, and DRAINING admission. The runner's own
7
+ // half is proven by its Rust harness (the E-scenarios); this double exists so
8
+ // the CLIENT's reassembly/heal/ack logic is unit-testable with no broker and
9
+ // no binary, deterministically.
10
+ //
11
+ // Emission model (mirrors the real runner): frames live in RETENTION the
12
+ // moment they exist. With `live: false` (the default) the op is already
13
+ // COMPLETE at start — the client's initial attach replays everything (the
14
+ // completed-op collection path, incl. re-dispatch attach-not-rerun). With
15
+ // `live: true` the first attach replays nothing and triggers the LIVE
16
+ // emission with the fault filters applied (drops/dups/reordering); any LATER
17
+ // attach is a heal and replays from retention unfiltered — exactly how the
18
+ // runner's ring/spool heals what the wire mangled.
19
+
20
+ import { blake3 } from "@noble/hashes/blake3";
21
+ import { bytesToHex } from "@noble/hashes/utils";
22
+ import {
23
+ ControlRequest,
24
+ ControlResponse,
25
+ ErrorCode,
26
+ OpAck,
27
+ OpChannel,
28
+ OpFrame,
29
+ OpLostReason,
30
+ OpState,
31
+ type AgentError,
32
+ type OpExit,
33
+ type OpStatus,
34
+ } from "@opengeni/agent-proto";
35
+ import type { ControlRpc } from "./control-rpc";
36
+ import {
37
+ opFrameSubject,
38
+ OpStreamUnavailableError,
39
+ type OpStreamSubscription,
40
+ type OpStreamTransport,
41
+ } from "./op-transport";
42
+
43
+ /**
44
+ * An in-memory `OpStreamTransport`: subscriptions are a subject→handler map,
45
+ * and everything the CLIENT publishes (its acks) is recorded for assertions
46
+ * and mirrored to `onPublish` (the fake runner's ack intake). `available =
47
+ * false` makes both primitives throw `OpStreamUnavailableError` — the
48
+ * legacy-fallback path.
49
+ */
50
+ export class InMemoryOpStreamTransport implements OpStreamTransport {
51
+ available = true;
52
+ readonly published: Array<{ subject: string; payload: Uint8Array }> = [];
53
+ onPublish: ((subject: string, payload: Uint8Array) => void) | undefined;
54
+ private readonly handlers = new Map<string, Set<(payload: Uint8Array) => void>>();
55
+
56
+ async subscribe(
57
+ subject: string,
58
+ onMessage: (payload: Uint8Array) => void,
59
+ ): Promise<OpStreamSubscription> {
60
+ if (!this.available) {
61
+ throw new OpStreamUnavailableError("in-memory transport marked unavailable");
62
+ }
63
+ let set = this.handlers.get(subject);
64
+ if (!set) {
65
+ set = new Set();
66
+ this.handlers.set(subject, set);
67
+ }
68
+ set.add(onMessage);
69
+ return {
70
+ unsubscribe: () => {
71
+ set.delete(onMessage);
72
+ },
73
+ };
74
+ }
75
+
76
+ async publish(subject: string, payload: Uint8Array): Promise<void> {
77
+ if (!this.available) {
78
+ throw new OpStreamUnavailableError("in-memory transport marked unavailable");
79
+ }
80
+ this.published.push({ subject, payload });
81
+ this.onPublish?.(subject, payload);
82
+ }
83
+
84
+ /** Deliver a payload to the subject's subscribers (the runner→client leg). */
85
+ deliver(subject: string, payload: Uint8Array): void {
86
+ for (const handler of this.handlers.get(subject) ?? []) {
87
+ handler(payload);
88
+ }
89
+ }
90
+
91
+ /** Every OpAck the client published, decoded, in publish order. */
92
+ decodedAcks(): OpAck[] {
93
+ return this.published.map(({ payload }) => OpAck.decode(payload));
94
+ }
95
+ }
96
+
97
+ /** One scripted data frame (the exit frame is synthesized from `exit`). */
98
+ export interface FakeOpDataFrame {
99
+ channel: "stdout" | "stderr";
100
+ bytes: Uint8Array | string;
101
+ }
102
+
103
+ export interface FakeOpScript {
104
+ /** Data/progress frames in emission order (seq 1..N; the exit takes N+1). */
105
+ frames: Array<FakeOpDataFrame | "progress">;
106
+ /** Exit overrides (exitCode/timedOut/failureCode/…). Digests and totals are
107
+ * computed from the scripted data frames unless overridden. */
108
+ exit?: Partial<OpExit>;
109
+ /** LIVE mode: the first attach replays nothing and triggers the (faulty)
110
+ * live emission; later attaches replay from retention unfiltered. Default
111
+ * false = the op is COMPLETE at start (pure collection). */
112
+ live?: boolean;
113
+ /** Seqs whose LIVE emission is dropped (retention keeps them). */
114
+ dropLiveSeqs?: ReadonlySet<number>;
115
+ /** Seqs whose LIVE emission is duplicated (delivered twice back-to-back). */
116
+ duplicateLiveSeqs?: ReadonlySet<number>;
117
+ /** Swap each adjacent live pair (1,2 → 2,1) to exercise the stash path. */
118
+ reorderLivePairs?: boolean;
119
+ /** The first N OpStarts answer a DRAINING AgentError (admission pressure). */
120
+ drainingStarts?: number;
121
+ /** Refuse every OpStart with this error (e.g. a PROTOCOL refusal from a
122
+ * runner that does not speak op-stream — the legacy-fallback race). */
123
+ startError?: AgentError;
124
+ }
125
+
126
+ interface FakeOpRun {
127
+ script: FakeOpScript;
128
+ frames: OpFrame[];
129
+ exitSeq: bigint;
130
+ exit: OpExit;
131
+ startCount: number;
132
+ attachCount: number;
133
+ drainingRemaining: number;
134
+ liveEmitted: boolean;
135
+ finalAcked: boolean;
136
+ acks: OpAck[];
137
+ highestGeneration: bigint;
138
+ }
139
+
140
+ const encoder = new TextEncoder();
141
+
142
+ /**
143
+ * The scripted fake runner: a `ControlRpc` that answers the op-stream control
144
+ * ops and emits frames through an `InMemoryOpStreamTransport`. Non-op-stream
145
+ * requests are delegated to `fallback` (a `MockAgentResponder`) so the
146
+ * legacy-fallback path is testable through the same instance.
147
+ */
148
+ export class FakeOpRunner implements ControlRpc {
149
+ private readonly transport: InMemoryOpStreamTransport;
150
+ private readonly fallback: ControlRpc | undefined;
151
+ private readonly workspaceId: string;
152
+ private readonly agentId: string;
153
+ private readonly scripts = new Map<string, FakeOpScript>();
154
+ readonly runs = new Map<string, FakeOpRun>();
155
+ /** Op ids that answer LOST (evicted) on query/attach — bounded-retention
156
+ * collateral. */
157
+ readonly lostOps = new Set<string>();
158
+
159
+ constructor(opts: {
160
+ transport: InMemoryOpStreamTransport;
161
+ workspaceId: string;
162
+ agentId: string;
163
+ /** Handles every non-op-stream ControlRequest (the legacy ops). */
164
+ fallback?: ControlRpc;
165
+ }) {
166
+ this.transport = opts.transport;
167
+ this.workspaceId = opts.workspaceId;
168
+ this.agentId = opts.agentId;
169
+ this.fallback = opts.fallback;
170
+ this.transport.onPublish = (_subject, payload) => this.onAck(payload);
171
+ }
172
+
173
+ /** Script the op the NEXT OpStart with this id will run. */
174
+ script(opId: string, script: FakeOpScript): void {
175
+ this.scripts.set(opId, script);
176
+ }
177
+
178
+ private onAck(payload: Uint8Array): void {
179
+ let ack: OpAck;
180
+ try {
181
+ ack = OpAck.decode(payload);
182
+ } catch {
183
+ return;
184
+ }
185
+ const run = this.runs.get(ack.opId);
186
+ if (!run) {
187
+ return;
188
+ }
189
+ // Generation fencing exactly like the runner: only the highest generation
190
+ // seen has any effect; `final` is honored only when it covers the exit.
191
+ const generation = BigInt(ack.attachGeneration);
192
+ if (generation < run.highestGeneration) {
193
+ return;
194
+ }
195
+ run.acks.push(ack);
196
+ if (ack.final && BigInt(ack.ackedSeq) >= run.exitSeq) {
197
+ run.finalAcked = true;
198
+ }
199
+ }
200
+
201
+ async request(
202
+ subject: string,
203
+ req: ControlRequest,
204
+ opts: { timeoutMs: number },
205
+ ): Promise<ControlResponse> {
206
+ const op = req.op;
207
+ if (!op) {
208
+ return errorResponse(req.requestId, ErrorCode.ERROR_CODE_PROTOCOL, "no op");
209
+ }
210
+ switch (op.$case) {
211
+ case "opStart":
212
+ return this.handleStart(req.requestId);
213
+ case "opAttach":
214
+ return this.handleAttach(
215
+ req.requestId,
216
+ op.opAttach.opId,
217
+ BigInt(op.opAttach.fromSeq),
218
+ op.opAttach.attachGeneration,
219
+ );
220
+ case "opQuery":
221
+ return this.handleQuery(req.requestId, op.opQuery.opId);
222
+ default: {
223
+ if (this.fallback) {
224
+ return this.fallback.request(subject, req, opts);
225
+ }
226
+ return errorResponse(
227
+ req.requestId,
228
+ ErrorCode.ERROR_CODE_UNSUPPORTED,
229
+ `fake runner: unscripted op ${op.$case}`,
230
+ );
231
+ }
232
+ }
233
+ }
234
+
235
+ private handleStart(opId: string): ControlResponse {
236
+ const existing = this.runs.get(opId);
237
+ if (existing) {
238
+ // IDEMPOTENT BY OP ID: a known op answers its current status and NEVER
239
+ // re-runs (the whole point of B1).
240
+ existing.startCount += 1;
241
+ return startResponse(opId, this.statusOf(opId, existing));
242
+ }
243
+ const script = this.scripts.get(opId);
244
+ if (!script) {
245
+ return errorResponse(
246
+ opId,
247
+ ErrorCode.ERROR_CODE_PROTOCOL,
248
+ `fake runner: no script for op ${opId}`,
249
+ );
250
+ }
251
+ if (script.startError) {
252
+ return { requestId: opId, error: script.startError, result: undefined };
253
+ }
254
+ const pending = this.scripts.get(opId) as FakeOpScript;
255
+ if ((pending.drainingStarts ?? 0) > 0) {
256
+ // Admission pressure: count down across retries of the un-admitted op.
257
+ this.scripts.set(opId, { ...pending, drainingStarts: (pending.drainingStarts ?? 0) - 1 });
258
+ return {
259
+ requestId: opId,
260
+ error: {
261
+ code: ErrorCode.ERROR_CODE_DRAINING,
262
+ message: "fake runner: admission pool full",
263
+ retryable: true,
264
+ detail: {},
265
+ },
266
+ result: undefined,
267
+ };
268
+ }
269
+ const run = this.buildRun(opId, this.scripts.get(opId) as FakeOpScript);
270
+ run.startCount = 1;
271
+ this.runs.set(opId, run);
272
+ return startResponse(opId, this.statusOf(opId, run));
273
+ }
274
+
275
+ private handleAttach(
276
+ requestId: string,
277
+ opId: string,
278
+ fromSeq: bigint,
279
+ generation: string,
280
+ ): ControlResponse {
281
+ if (this.lostOps.has(opId)) {
282
+ return statusOnly(requestId, lostStatus(opId));
283
+ }
284
+ const run = this.runs.get(opId);
285
+ if (!run) {
286
+ return statusOnly(requestId, lostStatus(opId));
287
+ }
288
+ run.attachCount += 1;
289
+ const attachGeneration = BigInt(generation);
290
+ if (attachGeneration > run.highestGeneration) {
291
+ run.highestGeneration = attachGeneration;
292
+ }
293
+ const subject = opFrameSubject(this.workspaceId, this.agentId, opId);
294
+ if (run.script.live && !run.liveEmitted) {
295
+ // First attach on a live op: nothing retained yet worth replaying — the
296
+ // child "runs now" and the (faulty) live flow begins.
297
+ this.emitLive(opId, run, subject);
298
+ return statusOnly(requestId, this.statusOf(opId, run));
299
+ }
300
+ // A heal (or completed-op collection): replay from RETENTION — every frame
301
+ // > fromSeq, in order, NO fault injection (the runner's ring/spool holds
302
+ // them all under the credit-only ack policy).
303
+ const replay = run.frames.filter((frame) => BigInt(frame.seq) > fromSeq);
304
+ queueMicrotask(() => {
305
+ for (const frame of replay) {
306
+ this.transport.deliver(subject, OpFrame.encode(frame).finish());
307
+ }
308
+ });
309
+ return statusOnly(requestId, this.statusOf(opId, run));
310
+ }
311
+
312
+ private handleQuery(requestId: string, opId: string): ControlResponse {
313
+ if (this.lostOps.has(opId)) {
314
+ return statusOnly(requestId, lostStatus(opId));
315
+ }
316
+ const run = this.runs.get(opId);
317
+ if (!run) {
318
+ return statusOnly(requestId, lostStatus(opId));
319
+ }
320
+ return statusOnly(requestId, this.statusOf(opId, run));
321
+ }
322
+
323
+ /** The LIVE flow: fault filters applied (drops, duplicates, adjacent
324
+ * reordering); retention still holds every frame for the heal replay. */
325
+ private emitLive(opId: string, run: FakeOpRun, subject: string): void {
326
+ run.liveEmitted = true;
327
+ const { dropLiveSeqs, duplicateLiveSeqs, reorderLivePairs } = run.script;
328
+ let sequence = [...run.frames];
329
+ if (reorderLivePairs) {
330
+ for (let i = 0; i + 1 < sequence.length; i += 2) {
331
+ const a = sequence[i] as OpFrame;
332
+ sequence[i] = sequence[i + 1] as OpFrame;
333
+ sequence[i + 1] = a;
334
+ }
335
+ }
336
+ sequence = sequence.filter((frame) => !dropLiveSeqs?.has(Number(frame.seq)));
337
+ queueMicrotask(() => {
338
+ for (const frame of sequence) {
339
+ const payload = OpFrame.encode(frame).finish();
340
+ this.transport.deliver(subject, payload);
341
+ if (duplicateLiveSeqs?.has(Number(frame.seq))) {
342
+ this.transport.deliver(subject, payload);
343
+ }
344
+ }
345
+ });
346
+ }
347
+
348
+ private statusOf(opId: string, run: FakeOpRun): OpStatus {
349
+ // The fake's ops are instant: COMPLETE the moment their frames exist. A
350
+ // live op that has not yet emitted reads RUNNING with nothing produced;
351
+ // once emission ran (even if the wire dropped frames), the runner-side
352
+ // truth is COMPLETE with the full high watermark — exactly what a silence
353
+ // probe needs to see to detect "the runner is ahead of us".
354
+ const complete = !run.script.live || run.liveEmitted;
355
+ return {
356
+ opId,
357
+ state: complete ? OpState.OP_STATE_COMPLETE : OpState.OP_STATE_RUNNING,
358
+ nextSeq: complete ? (run.exitSeq + 1n).toString() : "1",
359
+ exit: complete ? run.exit : undefined,
360
+ lostReason: OpLostReason.OP_LOST_REASON_UNSPECIFIED,
361
+ };
362
+ }
363
+
364
+ private buildRun(opId: string, script: FakeOpScript): FakeOpRun {
365
+ const frames: OpFrame[] = [];
366
+ const channelBytes: Record<"stdout" | "stderr", Uint8Array[]> = { stdout: [], stderr: [] };
367
+ let seq = 0n;
368
+ for (const spec of script.frames) {
369
+ seq += 1n;
370
+ if (spec === "progress") {
371
+ frames.push({ opId, seq: seq.toString(), body: { $case: "progress", progress: {} } });
372
+ continue;
373
+ }
374
+ const bytes = typeof spec.bytes === "string" ? encoder.encode(spec.bytes) : spec.bytes;
375
+ channelBytes[spec.channel].push(bytes);
376
+ frames.push({
377
+ opId,
378
+ seq: seq.toString(),
379
+ body: {
380
+ $case: "data",
381
+ data: {
382
+ channel:
383
+ spec.channel === "stdout" ? OpChannel.OP_CHANNEL_STDOUT : OpChannel.OP_CHANNEL_STDERR,
384
+ bytes,
385
+ },
386
+ },
387
+ });
388
+ }
389
+ seq += 1n;
390
+ const digests: Record<string, string> = {};
391
+ const totals: Record<string, string> = {};
392
+ for (const channel of ["stdout", "stderr"] as const) {
393
+ const joined = concat(channelBytes[channel]);
394
+ digests[channel] = bytesToHex(blake3(joined));
395
+ totals[channel] = String(joined.byteLength);
396
+ }
397
+ const exit: OpExit = {
398
+ exitCode: 0,
399
+ timedOut: false,
400
+ cancelled: false,
401
+ durationMs: "5",
402
+ digests,
403
+ totals,
404
+ failureCode: "",
405
+ failureDetail: {},
406
+ ...script.exit,
407
+ };
408
+ frames.push({ opId, seq: seq.toString(), body: { $case: "exit", exit } });
409
+ return {
410
+ script,
411
+ frames,
412
+ exitSeq: seq,
413
+ exit,
414
+ startCount: 0,
415
+ attachCount: 0,
416
+ drainingRemaining: script.drainingStarts ?? 0,
417
+ liveEmitted: false,
418
+ finalAcked: false,
419
+ acks: [],
420
+ highestGeneration: 0n,
421
+ };
422
+ }
423
+ }
424
+
425
+ function concat(chunks: Uint8Array[]): Uint8Array {
426
+ const total = chunks.reduce((sum, c) => sum + c.byteLength, 0);
427
+ const out = new Uint8Array(total);
428
+ let offset = 0;
429
+ for (const chunk of chunks) {
430
+ out.set(chunk, offset);
431
+ offset += chunk.byteLength;
432
+ }
433
+ return out;
434
+ }
435
+
436
+ function errorResponse(requestId: string, code: ErrorCode, message: string): ControlResponse {
437
+ return {
438
+ requestId,
439
+ error: { code, message, retryable: false, detail: {} },
440
+ result: undefined,
441
+ };
442
+ }
443
+
444
+ function startResponse(requestId: string, status: OpStatus): ControlResponse {
445
+ return {
446
+ requestId,
447
+ error: undefined,
448
+ result: { $case: "opStart", opStart: { accepted: true, status } },
449
+ };
450
+ }
451
+
452
+ function statusOnly(requestId: string, status: OpStatus): ControlResponse {
453
+ return {
454
+ requestId,
455
+ error: undefined,
456
+ result: { $case: "opStatus", opStatus: status },
457
+ };
458
+ }
459
+
460
+ function lostStatus(opId: string): OpStatus {
461
+ return {
462
+ opId,
463
+ state: OpState.OP_STATE_LOST,
464
+ nextSeq: "0",
465
+ exit: undefined,
466
+ lostReason: OpLostReason.OP_LOST_REASON_EVICTED,
467
+ };
468
+ }
@@ -0,0 +1,151 @@
1
+ // The op-stream TRANSPORT seam (op-stream protocol v1.1, server half).
2
+ //
3
+ // The op-stream client needs two primitives the request/reply `ControlRpc`
4
+ // cannot express: a plain SUBSCRIPTION (runner→server op frames on
5
+ // `agent.<ws>.<id>.op.<op_id>`, fire-and-forget publishes) and a plain PUBLISH
6
+ // (server→runner acks on `agent.<ws>.<id>.ack`). This module defines that seam
7
+ // exactly like control-rpc.ts defines `ControlRpc`: the session leaf speaks
8
+ // ONLY `OpStreamTransport`; the worker/api inject a live NATS-backed
9
+ // implementation (the same lazy-factory discipline — boot never requires a
10
+ // live NATS), and tests inject an in-memory one.
11
+ //
12
+ // The control ops that START/steer an op (OpStart/OpCancel/OpQuery/OpAttach)
13
+ // deliberately do NOT ride this seam — they are ordinary `ControlRequest`s on
14
+ // the existing rpc subject through the existing `ControlRpc`, with its typed
15
+ // offline/timeout synthesis and retry taxonomy.
16
+
17
+ /** The per-op frame subject (runner→server, fire-and-forget). Mirrors the
18
+ * runner's wire constant: `agent.<ws>.<id>.op.<op_id>`. */
19
+ export function opFrameSubject(workspaceId: string, agentId: string, opId: string): string {
20
+ return `agent.${workspaceId}.${agentId}.op.${opId}`;
21
+ }
22
+
23
+ /** The per-agent ack subject (server→runner, fire-and-forget). Mirrors the
24
+ * runner's wire constant: `agent.<ws>.<id>.ack`. */
25
+ export function opAckSubject(workspaceId: string, agentId: string): string {
26
+ return `agent.${workspaceId}.${agentId}.ack`;
27
+ }
28
+
29
+ /** A live subscription handle. `unsubscribe` is idempotent and never throws. */
30
+ export interface OpStreamSubscription {
31
+ unsubscribe(): void;
32
+ }
33
+
34
+ /**
35
+ * The op-stream transport seam. `subscribe` MUST be established before the
36
+ * OpStart that makes frames flow (subscription-before-start is a protocol
37
+ * invariant: no frame is published before the consumer exists on a healthy
38
+ * path; a missed frame is healed via OpAttach replay regardless). `onMessage`
39
+ * is invoked in arrival order; a decode failure inside the handler must be
40
+ * contained by the caller (a torn frame must never kill the subscription).
41
+ */
42
+ export interface OpStreamTransport {
43
+ subscribe(
44
+ subject: string,
45
+ onMessage: (payload: Uint8Array) => void,
46
+ ): Promise<OpStreamSubscription>;
47
+ publish(subject: string, payload: Uint8Array): Promise<void>;
48
+ }
49
+
50
+ /** Thrown when the transport has no live connection: the op-stream path is
51
+ * UNAVAILABLE (never silently degraded). The session catches this pre-start
52
+ * and falls back to the legacy exec — the op provably never started. */
53
+ export class OpStreamUnavailableError extends Error {
54
+ readonly name = "OpStreamUnavailableError";
55
+ }
56
+
57
+ /**
58
+ * The minimal NATS surface the transport needs — mirrors the `nats`
59
+ * `NatsConnection` subscribe/publish WITHOUT importing `nats` into the
60
+ * agent-loop-free runtime leaf (the worker injects the live `@opengeni/events`
61
+ * bus connection, exactly like `NatsRequestConnection` in control-rpc.ts).
62
+ * `subscribe` returns the nats.js Subscription shape: an async iterable of
63
+ * messages plus `unsubscribe()`.
64
+ */
65
+ export interface NatsOpStreamConnection {
66
+ subscribe(subject: string): AsyncIterable<{ data: Uint8Array }> & { unsubscribe(): void };
67
+ publish(subject: string, payload: Uint8Array): void;
68
+ }
69
+
70
+ /**
71
+ * The NATS-backed transport. Lazy memoized factory (identical discipline to
72
+ * `NatsControlRpc`): the connection resolves on first use; a null factory
73
+ * result or a dial failure surfaces `OpStreamUnavailableError` — the caller
74
+ * (the session) falls back to the LEGACY exec path rather than failing the op,
75
+ * so a NATS-less boot or a torn bus never strands a turn on a dead transport.
76
+ */
77
+ export class NatsOpStreamTransport implements OpStreamTransport {
78
+ private readonly connect: () => Promise<NatsOpStreamConnection | null>;
79
+ private connection: NatsOpStreamConnection | undefined;
80
+ private connecting: Promise<NatsOpStreamConnection | null> | undefined;
81
+
82
+ constructor(connect: () => Promise<NatsOpStreamConnection | null>) {
83
+ this.connect = connect;
84
+ }
85
+
86
+ private async resolveConnection(): Promise<NatsOpStreamConnection | null> {
87
+ if (this.connection) {
88
+ return this.connection;
89
+ }
90
+ // Share one in-flight dial; cache only a real connection (a transient
91
+ // null/throw must be retried by the next call — see NatsControlRpc).
92
+ this.connecting ??= this.connect()
93
+ .then((connection) => {
94
+ if (connection) {
95
+ this.connection = connection;
96
+ }
97
+ return connection;
98
+ })
99
+ .catch(() => null)
100
+ .finally(() => {
101
+ this.connecting = undefined;
102
+ });
103
+ return this.connecting;
104
+ }
105
+
106
+ async subscribe(
107
+ subject: string,
108
+ onMessage: (payload: Uint8Array) => void,
109
+ ): Promise<OpStreamSubscription> {
110
+ const conn = await this.resolveConnection();
111
+ if (!conn) {
112
+ throw new OpStreamUnavailableError("op-stream transport has no live connection");
113
+ }
114
+ const subscription = conn.subscribe(subject);
115
+ // Pump the async iterator into the callback. The iterator ends when
116
+ // `unsubscribe()` is called (nats.js closes the iterator); a pump error
117
+ // (torn connection) simply ends delivery — the op-stream client's silence
118
+ // handling (OpQuery + re-attach) owns recovery, not the transport.
119
+ void (async () => {
120
+ try {
121
+ for await (const message of subscription) {
122
+ onMessage(message.data);
123
+ }
124
+ } catch {
125
+ // Delivery ended with the connection; recovery is the client's job.
126
+ }
127
+ })();
128
+ let unsubscribed = false;
129
+ return {
130
+ unsubscribe: () => {
131
+ if (unsubscribed) {
132
+ return;
133
+ }
134
+ unsubscribed = true;
135
+ try {
136
+ subscription.unsubscribe();
137
+ } catch {
138
+ // Idempotent teardown: a torn connection already unsubscribed us.
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ async publish(subject: string, payload: Uint8Array): Promise<void> {
145
+ const conn = await this.resolveConnection();
146
+ if (!conn) {
147
+ throw new OpStreamUnavailableError("op-stream transport has no live connection");
148
+ }
149
+ conn.publish(subject, payload);
150
+ }
151
+ }