@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.
Files changed (44) hide show
  1. package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
  4. package/dist/index.d.ts +293 -121
  5. package/dist/index.js +1819 -786
  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 +2573 -897
  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 +99 -24
  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-BHA2SU3H.js.map +0 -1
  44. package/src/image-history.ts +0 -149
@@ -20,12 +20,7 @@
20
20
  // hardening, retries) behind this SAME `ControlRpc` interface — design for that,
21
21
  // do not duplicate.
22
22
 
23
- import {
24
- AgentError,
25
- ControlRequest,
26
- ControlResponse,
27
- ErrorCode,
28
- } from "@opengeni/agent-proto";
23
+ import { AgentError, ControlRequest, ControlResponse, ErrorCode } from "@opengeni/agent-proto";
29
24
  import type { CapabilityUnavailableReason } from "@opengeni/contracts";
30
25
 
31
26
  // Re-export the contracts reason union locally so callers of the mapping below
@@ -93,6 +88,15 @@ export class SelfhostedControlError extends Error {
93
88
  readonly draining: boolean;
94
89
  readonly agentOffline: boolean;
95
90
  readonly osNotFound: boolean;
91
+ /** The reply exceeded the transport's max payload (a distinguishing flag so the
92
+ * fault renderer can name the size wall + recovery instead of a generic OS error). */
93
+ readonly payloadTooLarge: boolean;
94
+ /** The transport synthesized this fault PRE-SEND — it KNOWS the request never
95
+ * reached the machine (no connection, or no responder on the subject), so the op
96
+ * provably did not execute. Only such a fault is safe to re-issue for ANY op kind
97
+ * (a post-send timeout is ambiguous — at-least-once — and must not be re-sent
98
+ * unless the op is read-only). Never set for an agent-returned error. */
99
+ readonly neverSent: boolean;
96
100
  readonly detail: Record<string, string>;
97
101
 
98
102
  constructor(input: {
@@ -104,6 +108,8 @@ export class SelfhostedControlError extends Error {
104
108
  draining?: boolean;
105
109
  agentOffline?: boolean;
106
110
  osNotFound?: boolean;
111
+ payloadTooLarge?: boolean;
112
+ neverSent?: boolean;
107
113
  detail?: Record<string, string>;
108
114
  }) {
109
115
  super(input.message);
@@ -114,6 +120,8 @@ export class SelfhostedControlError extends Error {
114
120
  this.draining = input.draining ?? false;
115
121
  this.agentOffline = input.agentOffline ?? false;
116
122
  this.osNotFound = input.osNotFound ?? false;
123
+ this.payloadTooLarge = input.payloadTooLarge ?? false;
124
+ this.neverSent = input.neverSent ?? false;
117
125
  this.detail = input.detail ?? {};
118
126
  }
119
127
  }
@@ -133,6 +141,9 @@ export class SelfhostedControlError extends Error {
133
141
  * real "file does not exist"), no machine-liveness
134
142
  * reason. (This is the ONLY NotFound; it is NOT the
135
143
  * box-gone NotFound that licenses a cold restore.)
144
+ * - PAYLOAD_TOO_LARGE → the reply exceeded the transport's max payload;
145
+ * non-retryable, with actionable copy (the sizes + a
146
+ * redirect-to-file workaround).
136
147
  * - OS / UNSUPPORTED / STREAM / PROTOCOL / UNSPECIFIED → op-level error, no
137
148
  * reason, non-retryable.
138
149
  */
@@ -141,12 +152,17 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
141
152
  const detail = err.detail ?? {};
142
153
  switch (err.code) {
143
154
  case ErrorCode.ERROR_CODE_AGENT_OFFLINE:
155
+ // AGENT_OFFLINE is ONLY ever synthesized by our transport (the agent never
156
+ // sends it). The synthesis marks `neverSent` on the pre-send paths (no NATS
157
+ // connection, or no responder on the subject) — the op provably never reached
158
+ // the machine — via the reserved detail key `NEVER_SENT_DETAIL_KEY`.
144
159
  return new SelfhostedControlError({
145
160
  message: message || "the enrolled agent is offline",
146
161
  code: err.code,
147
162
  reason: "agent_offline",
148
163
  retryable: false,
149
164
  agentOffline: true,
165
+ neverSent: detail[NEVER_SENT_DETAIL_KEY] === "1",
150
166
  detail,
151
167
  });
152
168
  case ErrorCode.ERROR_CODE_TIMEOUT:
@@ -168,14 +184,33 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
168
184
  detail,
169
185
  });
170
186
  case ErrorCode.ERROR_CODE_DRAINING:
187
+ // A pre-admission backpressure rejection (the machine's bounded host-work
188
+ // pool is full, or it is shutting down): the op NEVER started, so it is safe
189
+ // to retry (SelfhostedSession.call retries it a bounded number of times).
190
+ // Replace the agent's internal "N in flight" phrasing with human-language,
191
+ // actionable copy; call() appends the retry count when it finally surfaces.
171
192
  return new SelfhostedControlError({
172
- message: message || "the agent is draining and cannot accept new work",
193
+ message: drainingMessage(0),
173
194
  code: err.code,
174
195
  reason: null,
175
196
  retryable: true,
176
197
  draining: true,
177
198
  detail,
178
199
  });
200
+ case ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE:
201
+ // The op's reply exceeded the control transport's negotiated max payload and
202
+ // could not be published (a huge file read, an unbounded exec dump, a full-res
203
+ // screenshot). Not retryable — the same op reproduces the same oversized reply.
204
+ // Surface the actual sizes + a concrete workaround (write to a file, read in
205
+ // ranges) instead of the raw byte-count message.
206
+ return new SelfhostedControlError({
207
+ message: payloadTooLargeMessage(detail),
208
+ code: err.code,
209
+ reason: null,
210
+ retryable: false,
211
+ payloadTooLarge: true,
212
+ detail,
213
+ });
179
214
  case ErrorCode.ERROR_CODE_FENCED:
180
215
  return new SelfhostedControlError({
181
216
  message: message || "a stale op was fenced by the epoch guard; re-resolve and retry",
@@ -211,14 +246,97 @@ export function agentErrorToControlError(err: AgentError): SelfhostedControlErro
211
246
  }
212
247
  }
213
248
 
249
+ // ── Actionable, human-language error copy ─────────────────────────────────────
250
+ // These build the messages the model / API caller sees for the failure modes that
251
+ // have a concrete workaround. They live here (next to the mapping) so the phrasing
252
+ // is single-sourced and unit-testable, and deliberately avoid the wire's internal
253
+ // vocabulary ("in flight", "negotiated max payload", raw byte counts alone).
254
+
255
+ /**
256
+ * PAYLOAD_TOO_LARGE copy: state the actual sizes (from the agent's `detail` map —
257
+ * `encoded_bytes` / `max_payload`) and tell the caller how to get the data anyway:
258
+ * redirect the output to a file and read it back in ranges/chunks.
259
+ */
260
+ export function payloadTooLargeMessage(detail: Record<string, string>): string {
261
+ const encoded = detail.encoded_bytes;
262
+ const max = detail.max_payload;
263
+ const sizes =
264
+ encoded && max
265
+ ? `The result was ${encoded} bytes, over the machine link's ${max}-byte per-message limit. `
266
+ : "The result was larger than the machine link can deliver in a single message. ";
267
+ return (
268
+ `${sizes}Redirect the command's output to a file (for example ` +
269
+ "`<command> > /tmp/out.log 2>&1`) and then read that file back in ranges or chunks " +
270
+ "instead of returning it all at once."
271
+ );
272
+ }
273
+
274
+ /**
275
+ * DRAINING copy: the machine is at its concurrent-work capacity. `retries` is the
276
+ * number of times `SelfhostedSession.call` already re-tried before giving up (0 at
277
+ * the mapping layer, the final count when it surfaces after exhausting retries).
278
+ */
279
+ export function drainingMessage(retries: number): string {
280
+ const retried =
281
+ retries > 0 ? ` It was retried ${retries} time${retries === 1 ? "" : "s"} first.` : "";
282
+ return (
283
+ "The machine is at its concurrent-work capacity and could not accept this command." +
284
+ retried +
285
+ " Try again shortly, or reduce the number of commands you run in parallel."
286
+ );
287
+ }
288
+
289
+ /** Rebuild a DRAINING error with the final retry count folded into its message —
290
+ * used by `SelfhostedSession.call` when the bounded DRAINING retries are exhausted. */
291
+ export function drainingExhaustedError(
292
+ base: SelfhostedControlError,
293
+ retries: number,
294
+ ): SelfhostedControlError {
295
+ return new SelfhostedControlError({
296
+ message: drainingMessage(retries),
297
+ code: base.code,
298
+ reason: base.reason,
299
+ retryable: base.retryable,
300
+ draining: base.draining,
301
+ // Carry the retry count in `detail` so the fault renderer can state it.
302
+ detail: { ...base.detail, retries: String(retries) },
303
+ });
304
+ }
305
+
306
+ /**
307
+ * exec-deadline copy: the command was terminated at the exec time limit. Advise
308
+ * running long jobs in the background and polling their output rather than blocking
309
+ * the turn on one long command. Surfaced on a timed-out exec result's stderr.
310
+ */
311
+ export function execDeadlineHint(seconds: number): string {
312
+ return (
313
+ `The command was terminated at the ${seconds}-second execution limit. ` +
314
+ "Run long jobs in the background (for example " +
315
+ "`nohup <command> > /tmp/job.log 2>&1 &`, or start them in a terminal session) " +
316
+ "and poll the output file instead of blocking on a single command."
317
+ );
318
+ }
319
+
320
+ /** The reserved `detail` key the transport sets to "1" when it KNOWS a synthesized
321
+ * AGENT_OFFLINE fault occurred pre-send (no connection / no responder) — so the op
322
+ * provably never reached the machine. `agentErrorToControlError` reads it into
323
+ * `SelfhostedControlError.neverSent`. Reserved (never sent by the agent). */
324
+ export const NEVER_SENT_DETAIL_KEY = "_never_sent";
325
+
214
326
  /** Build a synthesized AGENT_OFFLINE `AgentError` — the control plane uses this
215
- * when no agent responds on the subject at all. */
216
- export function offlineAgentError(message = "no agent responded (offline)"): AgentError {
327
+ * when no agent responds on the subject at all. `neverSent` marks that the request
328
+ * provably never reached the machine (no connection / no responder), which makes it
329
+ * safe to re-issue for ANY op kind; leave it false for an AMBIGUOUS transport error
330
+ * (a connection torn mid-request may have delivered the op). */
331
+ export function offlineAgentError(
332
+ message = "no agent responded (offline)",
333
+ neverSent = false,
334
+ ): AgentError {
217
335
  return {
218
336
  code: ErrorCode.ERROR_CODE_AGENT_OFFLINE,
219
337
  message,
220
338
  retryable: false,
221
- detail: {},
339
+ detail: neverSent ? { [NEVER_SENT_DETAIL_KEY]: "1" } : {},
222
340
  };
223
341
  }
224
342
 
@@ -288,24 +406,33 @@ function isRequestTimeoutError(err: unknown): boolean {
288
406
  */
289
407
  export class NatsControlRpc implements ControlRpc {
290
408
  private readonly connect: () => Promise<NatsRequestConnection | null>;
291
- private connection: NatsRequestConnection | null | undefined;
409
+ private connection: NatsRequestConnection | undefined;
410
+ private connecting: Promise<NatsRequestConnection | null> | undefined;
292
411
 
293
412
  constructor(connect: () => Promise<NatsRequestConnection | null>) {
294
413
  this.connect = connect;
295
414
  }
296
415
 
297
416
  private async resolveConnection(): Promise<NatsRequestConnection | null> {
298
- if (this.connection === undefined) {
299
- try {
300
- this.connection = await this.connect();
301
- } catch {
302
- // A connect failure is an OFFLINE condition, not a crash. Cache null so a
303
- // later request retries the factory (set undefined to allow retry).
304
- this.connection = null;
305
- return null;
306
- }
417
+ if (this.connection) {
418
+ return this.connection;
307
419
  }
308
- return this.connection ?? null;
420
+ // Share one in-flight dial across concurrent callers, but cache only a real
421
+ // connection. A transient null/throw must be retried by the next request;
422
+ // pinning null here would make a recovered NATS bus look offline until the
423
+ // API/worker process restarted.
424
+ this.connecting ??= this.connect()
425
+ .then((connection) => {
426
+ if (connection) {
427
+ this.connection = connection;
428
+ }
429
+ return connection;
430
+ })
431
+ .catch(() => null)
432
+ .finally(() => {
433
+ this.connecting = undefined;
434
+ });
435
+ return this.connecting;
309
436
  }
310
437
 
311
438
  async request(
@@ -316,7 +443,8 @@ export class NatsControlRpc implements ControlRpc {
316
443
  const conn = await this.resolveConnection();
317
444
  if (!conn) {
318
445
  // No NATS configured / not reachable → the agent is unaddressable → offline.
319
- return offlineControlResponse(req.requestId);
446
+ // The request was never published (no connection) → NEVER SENT: safe to re-issue.
447
+ return offlineControlResponse(req.requestId, true);
320
448
  }
321
449
  const payload = ControlRequest.encode(req).finish();
322
450
  try {
@@ -325,11 +453,13 @@ export class NatsControlRpc implements ControlRpc {
325
453
  } catch (err) {
326
454
  // Re-allow a future request to re-dial if the cached conn was torn down.
327
455
  if (isNoRespondersError(err)) {
328
- // No subscriber on the subject at all → the machine is offline.
329
- return offlineControlResponse(req.requestId);
456
+ // No subscriber on the subject at all → the request reached no responder and
457
+ // was NOT delivered → NEVER SENT (provably not executed): safe to re-issue.
458
+ return offlineControlResponse(req.requestId, true);
330
459
  }
331
460
  if (isRequestTimeoutError(err)) {
332
- // A responder may exist but the request timed out → a transient blip.
461
+ // A responder may exist but the request timed out → a transient blip. AMBIGUOUS
462
+ // (the op may have run and only the reply was lost) — never marked never-sent.
333
463
  return timeoutControlResponse(req.requestId);
334
464
  }
335
465
  // Any other transport error → treat as offline (never a NotFound). The op
@@ -340,9 +470,11 @@ export class NatsControlRpc implements ControlRpc {
340
470
  }
341
471
  }
342
472
 
343
- /** A `ControlResponse` carrying a synthesized AGENT_OFFLINE error. */
344
- export function offlineControlResponse(requestId: string): ControlResponse {
345
- return { requestId, error: offlineAgentError(), result: undefined };
473
+ /** A `ControlResponse` carrying a synthesized AGENT_OFFLINE error. `neverSent` marks
474
+ * a pre-send synthesis (no connection / no responder) — the op provably never
475
+ * reached the machine, so it is safe to re-issue for any op kind. */
476
+ export function offlineControlResponse(requestId: string, neverSent = false): ControlResponse {
477
+ return { requestId, error: offlineAgentError(undefined, neverSent), result: undefined };
346
478
  }
347
479
 
348
480
  /** A `ControlResponse` carrying a synthesized TIMEOUT error (→ reconnecting). */
@@ -0,0 +1,226 @@
1
+ // The in-band fault renderer for Connected Machine (selfhosted) control faults.
2
+ //
3
+ // The failure-visibility doctrine: every fault the model sees at the point of
4
+ // impact must carry FOUR fields — what happened, which layer, what was preserved,
5
+ // what to try — with a retry verdict that is actually correct for the fault class.
6
+ //
7
+ // Why this exists: a thrown `SelfhostedControlError` reaches the model already
8
+ // wrapped by the SDK's generic tool-error function as
9
+ // "An error occurred while running the tool. Please try again. Error: …". That
10
+ // "Please try again" is actively wrong for a machine that is offline, a consent
11
+ // that has not been granted, or a reply that was too large — the model retries a
12
+ // fault that a retry cannot fix, and must parse prose to recover which layer
13
+ // failed. The typed `SelfhostedControlError` flags carry the truth; this renders
14
+ // them into the four structural fields so the capability tools can surface a
15
+ // legible fault as their result instead of the misleading wrapper.
16
+
17
+ import { errorCodeToJSON, ErrorCode } from "@opengeni/agent-proto";
18
+ import { SelfhostedControlError } from "./control-rpc";
19
+
20
+ /**
21
+ * A stable, low-cardinality fault-class string for a `SelfhostedControlError` — the
22
+ * out-of-band twin of the in-band renderer's class taxonomy (single-sourced here).
23
+ * Used for the `machine.op.*` event class + the metrics label + the infra filter.
24
+ */
25
+ export function selfhostedFaultClass(error: SelfhostedControlError): string {
26
+ if (error.agentOffline || error.code === ErrorCode.ERROR_CODE_AGENT_OFFLINE) return "offline";
27
+ if (error.draining) return "draining";
28
+ if (error.payloadTooLarge || error.code === ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE)
29
+ return "payload_too_large";
30
+ if (error.reason === "agent_reconnecting") return "reconnecting";
31
+ if (error.reason === "consent_required") return "consent";
32
+ if (error.osNotFound || error.code === ErrorCode.ERROR_CODE_NOT_FOUND) return "not_found";
33
+ if (error.fenced || error.code === ErrorCode.ERROR_CODE_FENCED) return "fenced";
34
+ switch (error.code) {
35
+ case ErrorCode.ERROR_CODE_OS:
36
+ return "os";
37
+ case ErrorCode.ERROR_CODE_STREAM:
38
+ return "stream";
39
+ case ErrorCode.ERROR_CODE_PROTOCOL:
40
+ return "protocol";
41
+ default:
42
+ return "unspecified";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The fault classes that count as an INFRASTRUCTURE fault (a `machine.op.failed`
48
+ * event). A semantic miss the model asked about — a missing path (`not_found`), a
49
+ * consent gate (`consent`), a nonzero exit (never a fault at all) — is an OUTCOME,
50
+ * not an infra fault, and is DELIBERATELY excluded; `fenced` is a routing/session
51
+ * condition, not a machine fault. (Doctrine two-planes / the team ruling.)
52
+ */
53
+ export const SELFHOSTED_INFRASTRUCTURE_FAULT_CLASSES: ReadonlySet<string> = new Set([
54
+ "offline",
55
+ "draining",
56
+ "payload_too_large",
57
+ "reconnecting",
58
+ "os",
59
+ "stream",
60
+ "protocol",
61
+ ]);
62
+
63
+ /** The four mandatory field labels (exported so tests can assert their presence). */
64
+ export const FAULT_FIELD_WHAT_HAPPENED = "What happened:";
65
+ export const FAULT_FIELD_WHICH_LAYER = "Which layer:";
66
+ export const FAULT_FIELD_WHAT_PRESERVED = "What was preserved:";
67
+ export const FAULT_FIELD_WHAT_TO_TRY = "What to try:";
68
+
69
+ interface FaultFields {
70
+ headline: string;
71
+ happened: string;
72
+ layer: string;
73
+ preserved: string;
74
+ tryNext: string;
75
+ }
76
+
77
+ /** Assemble the four-field block. The typed wire code is folded into "what
78
+ * happened" so the model has both the plain language and the precise code. */
79
+ function assemble(error: SelfhostedControlError, f: FaultFields): string {
80
+ const code = errorCodeToJSON(error.code);
81
+ return [
82
+ `[connected machine] ${f.headline}`,
83
+ `${FAULT_FIELD_WHAT_HAPPENED} ${f.happened} (control code ${code}).`,
84
+ `${FAULT_FIELD_WHICH_LAYER} ${f.layer}`,
85
+ `${FAULT_FIELD_WHAT_PRESERVED} ${f.preserved}`,
86
+ `${FAULT_FIELD_WHAT_TO_TRY} ${f.tryNext}`,
87
+ ].join("\n");
88
+ }
89
+
90
+ /**
91
+ * Render a `SelfhostedControlError` into the doctrine's four in-band fields with a
92
+ * correct retry verdict. The fault CLASS is read from the typed flags (never the
93
+ * message prose), so each class gets the right "which layer" and "what to try":
94
+ *
95
+ * - PAYLOAD_TOO_LARGE → the command ran but its reply was too big to return; the
96
+ * fix is to bound the output, not to retry.
97
+ * - DRAINING → pre-admission backpressure; nothing ran; safe to retry shortly.
98
+ * - CONSENT_REQUIRED → a machine-side consent gate; retrying cannot help.
99
+ * - AGENT_OFFLINE → the machine link is down. If the transport KNOWS the request
100
+ * never left (`neverSent`), nothing ran; otherwise the effect is ambiguous and
101
+ * the model must check before re-running. Either way "try again" is wrong until
102
+ * the machine reconnects.
103
+ * - agent_reconnecting (TIMEOUT) → a transient link blip AFTER send: ambiguous, so
104
+ * a state-changing command must be checked before re-running (at-least-once).
105
+ * - NOT_FOUND → a command/filesystem miss, not a link fault.
106
+ * - FENCED → the active machine session changed (a swap); re-run.
107
+ * - default (OS/UNSUPPORTED/STREAM/PROTOCOL) → the command/op failed on the machine.
108
+ */
109
+ export function renderSelfhostedFault(error: SelfhostedControlError): string {
110
+ const detail = error.detail;
111
+
112
+ if (error.payloadTooLarge || error.code === ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE) {
113
+ // The STREAMING retention-ceiling overflow (op-stream OP_OVERFLOW) is a
114
+ // different truth than the legacy reply-size wall: the machine STOPPED the
115
+ // command at the ceiling (it did not run to completion), and none of its
116
+ // output was returned. Same correct next action, honest distinct fields.
117
+ if (detail.failure_code === "OP_OVERFLOW") {
118
+ const retained = detail.retained_bytes
119
+ ? `after retaining ${detail.retained_bytes} bytes of output`
120
+ : "when its output exceeded what the machine can retain for delivery";
121
+ return assemble(error, {
122
+ headline: "the command produced more output than the machine link can deliver",
123
+ happened: `the machine stopped the command ${retained} — its output-retention ceiling was reached`,
124
+ layer:
125
+ "the machine link's output-retention ceiling — the command was terminated at the ceiling, not by its own logic",
126
+ preserved:
127
+ "no output was returned, and the command did NOT run to completion — any side effects up to the stop point may have applied.",
128
+ tryNext:
129
+ "re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it " +
130
+ "back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`.",
131
+ });
132
+ }
133
+ const sizes =
134
+ detail.encoded_bytes && detail.max_payload
135
+ ? `its ${detail.encoded_bytes}-byte reply exceeded the machine link's ${detail.max_payload}-byte per-message limit`
136
+ : "its reply was larger than the machine link can deliver in one message";
137
+ return assemble(error, {
138
+ headline: "the command's output was too large to return",
139
+ happened: `the command ran on the machine, but ${sizes}`,
140
+ layer: "the machine link's per-message size limit — the command itself executed",
141
+ preserved: "nothing was returned; the oversized reply was dropped whole.",
142
+ tryNext:
143
+ "re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it " +
144
+ "back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`.",
145
+ });
146
+ }
147
+
148
+ if (error.draining) {
149
+ const retried = detail.retries ? ` after ${detail.retries} retries` : "";
150
+ return assemble(error, {
151
+ headline: "the machine is at its concurrent-work capacity",
152
+ happened: `the machine rejected this command at its admission gate${retried} because its work pool is full`,
153
+ layer: "the machine's host-work admission — the machine is online, just saturated",
154
+ preserved: "nothing ran; the command was rejected before it started.",
155
+ tryNext: "try again shortly, or reduce the number of commands you run in parallel.",
156
+ });
157
+ }
158
+
159
+ if (error.reason === "consent_required") {
160
+ return assemble(error, {
161
+ headline: "the operation needs consent that has not been granted",
162
+ happened: "the machine rejected the operation because the required consent is not granted",
163
+ layer: "the machine's consent gate — not your command",
164
+ preserved: "nothing ran; the operation was rejected.",
165
+ tryNext:
166
+ "the consent must be granted on the machine itself; retrying will keep failing until it is.",
167
+ });
168
+ }
169
+
170
+ if (error.agentOffline || error.code === ErrorCode.ERROR_CODE_AGENT_OFFLINE) {
171
+ const preserved = error.neverSent
172
+ ? "nothing ran; the command never reached the machine."
173
+ : "unknown — the link dropped and the command may or may not have run.";
174
+ const tryNext = error.neverSent
175
+ ? "the machine appears offline; check that it is powered on and connected. Retrying will not help until it reconnects."
176
+ : "check the machine, and check whether the command already took effect before re-running it.";
177
+ return assemble(error, {
178
+ headline: "the machine is unreachable",
179
+ happened: "the enrolled machine did not respond; it appears offline",
180
+ layer: "the machine link — the machine itself, not your command",
181
+ preserved,
182
+ tryNext,
183
+ });
184
+ }
185
+
186
+ if (error.reason === "agent_reconnecting") {
187
+ return assemble(error, {
188
+ headline: "the machine link blipped",
189
+ happened: "the machine did not respond in time; the link may be reconnecting",
190
+ layer: "the machine link (transport) — not your command",
191
+ preserved: "unknown — the command may or may not have run (its reply was lost).",
192
+ tryNext:
193
+ "wait a moment and try again; if this was a state-changing command, check whether it " +
194
+ "already took effect before re-running it.",
195
+ });
196
+ }
197
+
198
+ if (error.osNotFound || error.code === ErrorCode.ERROR_CODE_NOT_FOUND) {
199
+ return assemble(error, {
200
+ headline: "the path or reference was not found on the machine",
201
+ happened: `the machine reported: ${error.message}`,
202
+ layer: "the command / filesystem on the machine — not the machine link",
203
+ preserved: "not applicable; the operation did not apply.",
204
+ tryNext: "check the path or reference exists on the machine, then re-run.",
205
+ });
206
+ }
207
+
208
+ if (error.fenced || error.code === ErrorCode.ERROR_CODE_FENCED) {
209
+ return assemble(error, {
210
+ headline: "the active machine session changed",
211
+ happened: "the machine session was swapped underneath the command",
212
+ layer: "the session-routing layer — not your command",
213
+ preserved: "nothing ran on this session.",
214
+ tryNext: "re-run the command; it will target the machine's current session.",
215
+ });
216
+ }
217
+
218
+ // OS / UNSUPPORTED / STREAM / PROTOCOL / UNSPECIFIED — a command/op-level failure.
219
+ return assemble(error, {
220
+ headline: "the command failed on the machine",
221
+ happened: `the machine reported: ${error.message}`,
222
+ layer: "the command / operating system on the machine",
223
+ preserved: "not applicable; the command did not complete successfully.",
224
+ tryNext: "read the message above and adjust the command; a blind retry is unlikely to help.",
225
+ });
226
+ }
@@ -0,0 +1,54 @@
1
+ // The transport-agnostic per-op observation seam for the Connected Machine
2
+ // (selfhosted) control path — the out-of-band audience of the failure-visibility
3
+ // doctrine (op outcomes + healed faults land in metrics + `machine.*` events so
4
+ // problems are learnable over time).
5
+ //
6
+ // It is DELIBERATELY transport-agnostic (op-shaped, not request/reply-shaped): the
7
+ // op-engine's future op-stream client (ENGINE-INTEGRATION.md step 6) emits through
8
+ // this SAME interface, so the metrics + `machine.*` events keep flowing unchanged
9
+ // across the transport swap. The runtime leaf only INVOKES the observer; the
10
+ // worker/api adapt it to the concrete sinks (a metrics hook, a durable session
11
+ // event), so the leaf stays agent-loop-free and db-free.
12
+
13
+ import type { ErrorCode } from "@opengeni/agent-proto";
14
+ import type { SelfhostedUnavailableReason } from "./control-rpc";
15
+
16
+ /**
17
+ * One completed control-op observation. `outcome` is the terminal result AFTER any
18
+ * in-call retries: `ok` (the op succeeded) or `failed` (a terminal fault surfaced).
19
+ * `healed` marks a success that only landed after ≥1 retry — the doctrine's
20
+ * "healed and invisible in-band, always out-of-band" breadcrumb (drives
21
+ * `machine.op.recovered`). Every field is derivable at the `SelfhostedSession.call`
22
+ * exit points with no extra round-trip.
23
+ */
24
+ export interface SelfhostedOpObservation {
25
+ /** The control-op kind (`ControlRequest["op"].$case`): exec / fsRead / ping / … */
26
+ op: string;
27
+ outcome: "ok" | "failed";
28
+ /** True when `outcome==="ok"` only after ≥1 in-call retry (draining/timeout/never-sent). */
29
+ healed: boolean;
30
+ /** Total in-call retries before this outcome (summed across the retry classes). */
31
+ retries: number;
32
+ /** Wall time from the first attempt to the terminal outcome, in ms. */
33
+ durationMs: number;
34
+ /** The typed wire code — present on `failed`. */
35
+ code?: ErrorCode;
36
+ /** The negotiated capability reason (agent_offline / agent_reconnecting / …), if any. */
37
+ reason?: SelfhostedUnavailableReason | null;
38
+ /** Whether a failed fault provably never reached the machine (a pre-send synthesis). */
39
+ neverSent?: boolean;
40
+ /** The enrolled machine (agent) id the op addressed — for machine-scoped fan-out. */
41
+ machineId?: string;
42
+ /** A stable fault-class string (`selfhostedFaultClass`): the fault class on a
43
+ * `failed` op, or the class of retries that a `healed` op recovered from. Absent
44
+ * on a clean success. Drives the `machine.op.*` event mapping + its infra filter. */
45
+ faultClass?: string;
46
+ /** The reply size in bytes when known — set on a PAYLOAD_TOO_LARGE fault (from the
47
+ * agent's `encoded_bytes` detail). The op-stream client fills the success-reply
48
+ * size once it owns the framed transport; today's request/reply does not surface it. */
49
+ replyBytes?: number;
50
+ }
51
+
52
+ /** A fire-and-forget observer of completed control ops. It MUST NOT throw (the
53
+ * session guards the call) and MUST NOT block the op — it is a telemetry tap. */
54
+ export type SelfhostedOpObserver = (observation: SelfhostedOpObservation) => void;