@opengeni/runtime 0.6.0 → 0.7.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.
- package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- package/src/image-history.ts +0 -149
|
@@ -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
|
+
}
|