@opengeni/runtime 0.13.0 → 0.13.4
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-65UDUFUO.js → chunk-JGUQSAG5.js} +1887 -172
- package/dist/chunk-JGUQSAG5.js.map +1 -0
- package/dist/{chunk-HAN5D27J.js → chunk-QYXOVIUK.js} +24 -4
- package/dist/chunk-QYXOVIUK.js.map +1 -0
- package/dist/{index-DGUrY_yR.d.ts → index-DU-KXVlx.d.ts} +328 -25
- package/dist/index.d.ts +25 -3
- package/dist/index.js +71 -675
- package/dist/index.js.map +1 -1
- package/dist/mcp-network.d.ts +4 -1
- package/dist/mcp-network.js +5 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +39 -1
- package/package.json +3 -3
- package/src/index.ts +31 -9
- package/src/mcp-network.ts +28 -3
- package/src/sandbox/channel-a.ts +92 -46
- package/src/sandbox/exec-banner.ts +15 -0
- package/src/sandbox/index.ts +360 -145
- package/src/sandbox/provider-errors.ts +175 -0
- package/src/sandbox/routing/backend-resolver.ts +15 -0
- package/src/sandbox/routing/routing-session.ts +730 -36
- package/src/sandbox/select.ts +8 -0
- package/src/sandbox/turn-tool-cancellation.ts +366 -92
- package/src/sandbox/workspace-archive.ts +289 -0
- package/dist/chunk-65UDUFUO.js.map +0 -1
- package/dist/chunk-HAN5D27J.js.map +0 -1
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
import type { ExposedPortEndpoint } from "../stream-port";
|
|
31
31
|
import { SelfhostedControlError } from "../selfhosted/control-rpc";
|
|
32
32
|
import { renderSelfhostedFault } from "../selfhosted/fault-rendering";
|
|
33
|
+
import { isExecSessionLostBanner } from "../channel-a";
|
|
34
|
+
import { parseExecBannerExitCode, parseExecBannerSessionId } from "../exec-banner";
|
|
33
35
|
|
|
34
36
|
/** The per-session active-sandbox pointer the proxy re-reads on every op. Mirror
|
|
35
37
|
* of `@opengeni/db`'s `ActiveSandboxPointer` (structural, so the leaf does not
|
|
@@ -94,8 +96,44 @@ export interface ResolvedActiveBackend {
|
|
|
94
96
|
sandboxId: string | null;
|
|
95
97
|
/** A label for diagnostics ("modal" | "selfhosted" | the sandbox name). */
|
|
96
98
|
kind: string;
|
|
99
|
+
/** Exact durable home-lease epoch for a persistable provider. Absent for
|
|
100
|
+
* connected-machine and other non-persistable route targets. */
|
|
101
|
+
leaseEpoch?: number;
|
|
102
|
+
/** Exact durable provider identity paired with `leaseEpoch`. This is internal
|
|
103
|
+
* routing metadata only; it is never projected to an agent or public API. */
|
|
104
|
+
providerInstanceId?: string;
|
|
105
|
+
/** Active-pointer epoch observed when this route was resolved. The proxy fills
|
|
106
|
+
* this internally even when a resolver omits it. */
|
|
107
|
+
activeEpoch?: number;
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
/** Durable identity assigned to a provider exec that yielded instead of
|
|
111
|
+
* exiting. The UUID is OpenGeni authority; the numeric provider session id is
|
|
112
|
+
* only a locator within the exact copied backend route. */
|
|
113
|
+
export type RoutingRetainedProcess = {
|
|
114
|
+
id: string;
|
|
115
|
+
providerSessionId: number;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Exact copied route identity required to reconstruct an already-durable
|
|
119
|
+
* retained process after the request/runtime object that opened it is gone.
|
|
120
|
+
* The live session is deliberately not caller-supplied: adoption may bind only
|
|
121
|
+
* to this proxy's construction-time default backend, so it cannot resolve or
|
|
122
|
+
* follow the current active pointer. */
|
|
123
|
+
export type RoutingRetainedProcessAdoption = {
|
|
124
|
+
process: RoutingRetainedProcess;
|
|
125
|
+
backend: {
|
|
126
|
+
sandboxId: string | null;
|
|
127
|
+
leaseEpoch?: number;
|
|
128
|
+
providerInstanceId?: string;
|
|
129
|
+
activeEpoch: number;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type RoutingRetainedProcessTerminalProof =
|
|
134
|
+
| { outcome: "exited"; exitCode: number; reason: "provider_exit_banner" }
|
|
135
|
+
| { outcome: "lost"; exitCode: null; reason: "provider_session_lost_banner" };
|
|
136
|
+
|
|
99
137
|
export interface RoutingSandboxSessionDeps {
|
|
100
138
|
/**
|
|
101
139
|
* The DEFAULT backend resolved at construction time (the same shape `resolve()`
|
|
@@ -127,8 +165,67 @@ export interface RoutingSandboxSessionDeps {
|
|
|
127
165
|
maxFenceRetries?: number;
|
|
128
166
|
/** Optional structured-log sink for swap/fence transitions (diagnostics). */
|
|
129
167
|
onTransition?: (event: RoutingTransitionEvent) => void;
|
|
168
|
+
/** Admit a filesystem-writing operation after resolving its exact route but
|
|
169
|
+
* before invoking the provider. A rejection fails closed and is deliberately
|
|
170
|
+
* outside provider fence-retry/error handling, so it can never replay the op
|
|
171
|
+
* against a rival backend. */
|
|
172
|
+
beforeMutation?: (input: { op: string; backend: ResolvedActiveBackend }) => Promise<unknown>;
|
|
173
|
+
/** Mark a mutation physically settled after its provider promise resolves OR
|
|
174
|
+
* rejects. A resolved result is also revalidated against the same durable
|
|
175
|
+
* route and attempt fence before its output is accepted. Settlement rejection
|
|
176
|
+
* leaves a fail-closed blocker and the mutation must never be replayed. */
|
|
177
|
+
afterMutation?: (input: {
|
|
178
|
+
op: string;
|
|
179
|
+
backend: ResolvedActiveBackend;
|
|
180
|
+
admission: unknown;
|
|
181
|
+
outcome: "resolved" | "rejected";
|
|
182
|
+
/** Provider result for a resolved call. Never present for rejection. */
|
|
183
|
+
result?: unknown;
|
|
184
|
+
/** Stable candidate generated before durable promotion is attempted. It is
|
|
185
|
+
* supplied only when the provider returned a positive yielded-session id. */
|
|
186
|
+
retainedProcess?: RoutingRetainedProcess;
|
|
187
|
+
}) => Promise<void>;
|
|
188
|
+
/** Admit one model/user-visible stdin mutation under the already-durable
|
|
189
|
+
* retained process authority. Control polling and helper execs never call it. */
|
|
190
|
+
beforeProcessMutation?: (input: {
|
|
191
|
+
op: string;
|
|
192
|
+
backend: ResolvedActiveBackend;
|
|
193
|
+
process: RoutingRetainedProcess;
|
|
194
|
+
}) => Promise<unknown>;
|
|
195
|
+
/** Physically settle one process-scoped stdin admission. */
|
|
196
|
+
afterProcessMutation?: (input: {
|
|
197
|
+
op: string;
|
|
198
|
+
backend: ResolvedActiveBackend;
|
|
199
|
+
process: RoutingRetainedProcess;
|
|
200
|
+
admission: unknown;
|
|
201
|
+
outcome: "resolved" | "rejected";
|
|
202
|
+
result?: unknown;
|
|
203
|
+
}) => Promise<void>;
|
|
204
|
+
/** Close the durable parent admission/process holder only after exact exit or
|
|
205
|
+
* matching provider-loss proof. Tracking is removed only after this resolves. */
|
|
206
|
+
settleProcess?: (input: {
|
|
207
|
+
backend: ResolvedActiveBackend;
|
|
208
|
+
process: RoutingRetainedProcess;
|
|
209
|
+
proof: RoutingRetainedProcessTerminalProof;
|
|
210
|
+
}) => Promise<void>;
|
|
211
|
+
/** Called only when an operation against the default/home backend throws a
|
|
212
|
+
* non-fence error. Wiring may classify definitive provider disappearance and
|
|
213
|
+
* atomically retire the exact lease epoch. Returning a result makes dispatch
|
|
214
|
+
* throw a typed recovery-required error WITHOUT replaying the operation (the
|
|
215
|
+
* original mutation may have reached the provider). */
|
|
216
|
+
onDefaultBackendError?: (input: {
|
|
217
|
+
op: string;
|
|
218
|
+
error: unknown;
|
|
219
|
+
kind: string;
|
|
220
|
+
backend: ResolvedActiveBackend;
|
|
221
|
+
}) => Promise<DefaultBackendLossResult | null>;
|
|
130
222
|
}
|
|
131
223
|
|
|
224
|
+
export type DefaultBackendLossResult = {
|
|
225
|
+
leaseEpoch: number;
|
|
226
|
+
recovery: "pending" | "degraded" | "unrecoverable" | "superseded";
|
|
227
|
+
};
|
|
228
|
+
|
|
132
229
|
export interface RoutingTransitionEvent {
|
|
133
230
|
type: "resolved" | "fenced-retry" | "epoch-changed";
|
|
134
231
|
fromEpoch: number;
|
|
@@ -146,6 +243,71 @@ export class RoutingUnsupportedError extends Error {
|
|
|
146
243
|
}
|
|
147
244
|
}
|
|
148
245
|
|
|
246
|
+
export class RoutingBackendRecoveryRequiredError extends Error {
|
|
247
|
+
readonly name = "RoutingBackendRecoveryRequiredError";
|
|
248
|
+
readonly retryable: boolean;
|
|
249
|
+
|
|
250
|
+
constructor(
|
|
251
|
+
public readonly op: string,
|
|
252
|
+
public readonly leaseEpoch: number,
|
|
253
|
+
public readonly recovery: DefaultBackendLossResult["recovery"],
|
|
254
|
+
) {
|
|
255
|
+
super(
|
|
256
|
+
`sandbox backend disappeared during ${op}; recovery is ${recovery} at epoch ${leaseEpoch}`,
|
|
257
|
+
);
|
|
258
|
+
this.retryable = recovery === "pending" || recovery === "superseded";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A mutating provider call was admitted but could not be settled against the
|
|
263
|
+
* exact route that admitted it. The provider may have applied the effect, so the
|
|
264
|
+
* proxy rejects the output and explicitly forbids an automatic replay. */
|
|
265
|
+
export class RoutingMutationOutcomeUnknownError extends Error {
|
|
266
|
+
readonly name = "RoutingMutationOutcomeUnknownError";
|
|
267
|
+
readonly retryable = false;
|
|
268
|
+
|
|
269
|
+
constructor(
|
|
270
|
+
public readonly op: string,
|
|
271
|
+
message: string,
|
|
272
|
+
options?: { cause?: unknown },
|
|
273
|
+
) {
|
|
274
|
+
super(message, options);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** An explicit process-aware operation named a numeric provider locator that
|
|
279
|
+
* is not retained by this routing session. Callers must never fall back to the
|
|
280
|
+
* current active pointer for such an operation. */
|
|
281
|
+
export class RoutingRetainedProcessNotFoundError extends Error {
|
|
282
|
+
readonly name = "RoutingRetainedProcessNotFoundError";
|
|
283
|
+
readonly retryable = false;
|
|
284
|
+
|
|
285
|
+
constructor(public readonly providerSessionId: number) {
|
|
286
|
+
super(`retained sandbox process ${providerSessionId} is not tracked on its original route`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
type PendingParentPromotion = Parameters<
|
|
291
|
+
NonNullable<RoutingSandboxSessionDeps["afterMutation"]>
|
|
292
|
+
>[0];
|
|
293
|
+
|
|
294
|
+
type PendingProcessMutationSettlement = Parameters<
|
|
295
|
+
NonNullable<RoutingSandboxSessionDeps["afterProcessMutation"]>
|
|
296
|
+
>[0];
|
|
297
|
+
|
|
298
|
+
type RetainedProcessRecord = {
|
|
299
|
+
process: RoutingRetainedProcess;
|
|
300
|
+
backend: ResolvedActiveBackend;
|
|
301
|
+
durable: boolean;
|
|
302
|
+
pendingParentPromotion: PendingParentPromotion | null;
|
|
303
|
+
pendingMutationSettlement: PendingProcessMutationSettlement | null;
|
|
304
|
+
pendingTerminal: {
|
|
305
|
+
proof: RoutingRetainedProcessTerminalProof;
|
|
306
|
+
result: string;
|
|
307
|
+
} | null;
|
|
308
|
+
settlement: Promise<void> | null;
|
|
309
|
+
};
|
|
310
|
+
|
|
149
311
|
/** Recognize a stale-epoch FENCE error from a backend op so the proxy retries
|
|
150
312
|
* against the re-resolved active sandbox (the existing fenced-retry role). A
|
|
151
313
|
* selfhosted `SelfhostedControlError` carries `.fenced`; a generic fence is
|
|
@@ -165,6 +327,82 @@ function isFenceError(error: unknown): boolean {
|
|
|
165
327
|
return haystack.includes("fenced") || (haystack.includes("epoch") && haystack.includes("super"));
|
|
166
328
|
}
|
|
167
329
|
|
|
330
|
+
function positiveProviderSessionId(value: unknown): number | null {
|
|
331
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function canonicalUuid(value: string): boolean {
|
|
335
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function providerSessionIdFromResult(result: unknown): number | null {
|
|
339
|
+
if (typeof result === "string") {
|
|
340
|
+
return positiveProviderSessionId(parseExecBannerSessionId(result));
|
|
341
|
+
}
|
|
342
|
+
if (!result || typeof result !== "object") return null;
|
|
343
|
+
const record = result as { sessionId?: unknown; session_id?: unknown };
|
|
344
|
+
return (
|
|
345
|
+
positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function providerSessionIdFromArgs(args: unknown): number | null {
|
|
350
|
+
if (!args || typeof args !== "object") return null;
|
|
351
|
+
const record = args as { sessionId?: unknown; session_id?: unknown };
|
|
352
|
+
return (
|
|
353
|
+
positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id)
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function retainedProcessTerminalProof(
|
|
358
|
+
result: string,
|
|
359
|
+
providerSessionId: number,
|
|
360
|
+
): RoutingRetainedProcessTerminalProof | null {
|
|
361
|
+
if (isExecSessionLostBanner(result, providerSessionId)) {
|
|
362
|
+
return {
|
|
363
|
+
outcome: "lost",
|
|
364
|
+
exitCode: null,
|
|
365
|
+
reason: "provider_session_lost_banner",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const exitCode = parseExecBannerExitCode(result);
|
|
369
|
+
return exitCode === null ? null : { outcome: "exited", exitCode, reason: "provider_exit_banner" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatExecResult(result: unknown): string {
|
|
373
|
+
if (typeof result === "string") return result;
|
|
374
|
+
if (!result || typeof result !== "object") {
|
|
375
|
+
throw new Error("sandbox process-control exec returned an invalid result");
|
|
376
|
+
}
|
|
377
|
+
const record = result as {
|
|
378
|
+
output?: unknown;
|
|
379
|
+
stdout?: unknown;
|
|
380
|
+
stderr?: unknown;
|
|
381
|
+
exitCode?: unknown;
|
|
382
|
+
exit_code?: unknown;
|
|
383
|
+
sessionId?: unknown;
|
|
384
|
+
session_id?: unknown;
|
|
385
|
+
};
|
|
386
|
+
const output = [record.output, record.stderr, record.stdout]
|
|
387
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
388
|
+
.join("\n");
|
|
389
|
+
const sessionId =
|
|
390
|
+
positiveProviderSessionId(record.sessionId) ?? positiveProviderSessionId(record.session_id);
|
|
391
|
+
if (sessionId !== null) {
|
|
392
|
+
return `Process running with session ID ${sessionId}\n\nOutput:\n${output}`;
|
|
393
|
+
}
|
|
394
|
+
const exitCode =
|
|
395
|
+
typeof record.exitCode === "number"
|
|
396
|
+
? record.exitCode
|
|
397
|
+
: typeof record.exit_code === "number"
|
|
398
|
+
? record.exit_code
|
|
399
|
+
: null;
|
|
400
|
+
if (exitCode !== null && Number.isSafeInteger(exitCode)) {
|
|
401
|
+
return `Process exited with code ${exitCode}\n\nOutput:\n${output}`;
|
|
402
|
+
}
|
|
403
|
+
throw new Error("sandbox process-control exec reported neither session id nor exit code");
|
|
404
|
+
}
|
|
405
|
+
|
|
168
406
|
/**
|
|
169
407
|
* ONE stable session-shaped object the SDK binds to. Every method re-reads the
|
|
170
408
|
* pointer, resolves the active backend (cached by epoch), and dispatches. A
|
|
@@ -198,6 +436,10 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
198
436
|
// The last-resolved backend, exposed via the `state` getter (a method-free read
|
|
199
437
|
// of the active backend's `state`). Updated on every resolve.
|
|
200
438
|
private lastResolved: ResolvedActiveBackend | undefined;
|
|
439
|
+
/** Provider session ids are scoped to one backend instance. Each entry copies
|
|
440
|
+
* that exact resolved route so pointer movement can never redirect stdin,
|
|
441
|
+
* polling, or process-group helpers to another box. */
|
|
442
|
+
private readonly retainedProcesses = new Map<number, RetainedProcessRecord>();
|
|
201
443
|
|
|
202
444
|
// The native-desktop control-plane ops (self-hosted / macOS). Declared as OPTIONAL
|
|
203
445
|
// INSTANCE fields — NOT prototype methods — because their PRESENCE is the selection
|
|
@@ -233,14 +475,14 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
233
475
|
const def = deps.defaultResolved?.session;
|
|
234
476
|
if (typeof def?.desktopInput === "function" && typeof def?.screenshot === "function") {
|
|
235
477
|
this.desktopInput = (event: unknown) =>
|
|
236
|
-
this.dispatch("desktopInput", async (s) => {
|
|
478
|
+
this.dispatch("desktopInput", true, async (s) => {
|
|
237
479
|
if (!s.desktopInput) {
|
|
238
480
|
throw new RoutingUnsupportedError("desktopInput", this.cached?.kind ?? "unknown");
|
|
239
481
|
}
|
|
240
482
|
return s.desktopInput(event);
|
|
241
483
|
});
|
|
242
484
|
this.screenshot = () =>
|
|
243
|
-
this.dispatch("screenshot", async (s) => {
|
|
485
|
+
this.dispatch("screenshot", false, async (s) => {
|
|
244
486
|
if (!s.screenshot) {
|
|
245
487
|
throw new RoutingUnsupportedError("screenshot", this.cached?.kind ?? "unknown");
|
|
246
488
|
}
|
|
@@ -296,10 +538,14 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
296
538
|
"RoutingSandboxSession.resolveActiveBackend returned the proxy itself as the active backend (re-entrancy) — the resolver must return the underlying box session, not the routing proxy.",
|
|
297
539
|
);
|
|
298
540
|
}
|
|
541
|
+
const routed: ResolvedActiveBackend = {
|
|
542
|
+
...resolved,
|
|
543
|
+
activeEpoch: pointer.activeEpoch,
|
|
544
|
+
};
|
|
299
545
|
this.cachedEpoch = pointer.activeEpoch;
|
|
300
546
|
this.cachedSandboxId = pointer.activeSandboxId;
|
|
301
|
-
this.cached =
|
|
302
|
-
this.lastResolved =
|
|
547
|
+
this.cached = routed;
|
|
548
|
+
this.lastResolved = routed;
|
|
303
549
|
this.deps.onTransition?.({
|
|
304
550
|
type:
|
|
305
551
|
this.cachedEpoch !== undefined && fromEpoch !== pointer.activeEpoch
|
|
@@ -307,10 +553,283 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
307
553
|
: "resolved",
|
|
308
554
|
fromEpoch,
|
|
309
555
|
toEpoch: pointer.activeEpoch,
|
|
310
|
-
sandboxId:
|
|
311
|
-
kind:
|
|
556
|
+
sandboxId: routed.sandboxId,
|
|
557
|
+
kind: routed.kind,
|
|
558
|
+
});
|
|
559
|
+
return routed;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private invalidate(backend: ResolvedActiveBackend): void {
|
|
563
|
+
this.cachedEpoch = undefined;
|
|
564
|
+
this.cachedSandboxId = undefined;
|
|
565
|
+
this.cached = undefined;
|
|
566
|
+
this.deps.onTransition?.({
|
|
567
|
+
type: "fenced-retry",
|
|
568
|
+
fromEpoch: backend.activeEpoch ?? 0,
|
|
569
|
+
toEpoch: 0,
|
|
570
|
+
sandboxId: backend.sandboxId,
|
|
571
|
+
kind: backend.kind,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private registerRetainedProcess(
|
|
576
|
+
process: RoutingRetainedProcess,
|
|
577
|
+
backend: ResolvedActiveBackend,
|
|
578
|
+
): RetainedProcessRecord {
|
|
579
|
+
if (this.retainedProcesses.has(process.providerSessionId)) {
|
|
580
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
581
|
+
"retainProcess",
|
|
582
|
+
`Provider session ${process.providerSessionId} was yielded while that locator was already retained; neither process was rebound`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const record: RetainedProcessRecord = {
|
|
586
|
+
process,
|
|
587
|
+
backend,
|
|
588
|
+
durable: !this.deps.afterMutation,
|
|
589
|
+
pendingParentPromotion: null,
|
|
590
|
+
pendingMutationSettlement: null,
|
|
591
|
+
pendingTerminal: null,
|
|
592
|
+
settlement: null,
|
|
593
|
+
};
|
|
594
|
+
this.retainedProcesses.set(process.providerSessionId, record);
|
|
595
|
+
return record;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private retainedProcess(providerSessionId: number): RetainedProcessRecord {
|
|
599
|
+
const retained = this.retainedProcesses.get(providerSessionId);
|
|
600
|
+
if (!retained) throw new RoutingRetainedProcessNotFoundError(providerSessionId);
|
|
601
|
+
return retained;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Reconstruct one process that was already promoted durably by an earlier
|
|
605
|
+
* request. Adoption never reads the active pointer and never repeats parent
|
|
606
|
+
* promotion. It can bind only to the exact construction-time default backend;
|
|
607
|
+
* callers must reject non-default routes until they can independently rebuild
|
|
608
|
+
* that provider session from its copied durable identity. */
|
|
609
|
+
adoptRetainedProcess(input: RoutingRetainedProcessAdoption): void {
|
|
610
|
+
const providerSessionId = positiveProviderSessionId(input.process.providerSessionId);
|
|
611
|
+
const seed = this.deps.defaultResolved;
|
|
612
|
+
if (
|
|
613
|
+
providerSessionId === null ||
|
|
614
|
+
!canonicalUuid(input.process.id) ||
|
|
615
|
+
!seed ||
|
|
616
|
+
seed.sandboxId !== input.backend.sandboxId ||
|
|
617
|
+
seed.leaseEpoch !== input.backend.leaseEpoch ||
|
|
618
|
+
seed.providerInstanceId !== input.backend.providerInstanceId ||
|
|
619
|
+
!Number.isSafeInteger(input.backend.activeEpoch) ||
|
|
620
|
+
input.backend.activeEpoch < 0
|
|
621
|
+
) {
|
|
622
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
623
|
+
"adoptRetainedProcess",
|
|
624
|
+
`Retained provider session ${input.process.providerSessionId} did not match the exact default backend identity`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const existing = this.retainedProcesses.get(providerSessionId);
|
|
628
|
+
if (existing) {
|
|
629
|
+
if (
|
|
630
|
+
existing.process.id === input.process.id &&
|
|
631
|
+
existing.backend.session === seed.session &&
|
|
632
|
+
existing.backend.sandboxId === input.backend.sandboxId &&
|
|
633
|
+
existing.backend.leaseEpoch === input.backend.leaseEpoch &&
|
|
634
|
+
existing.backend.providerInstanceId === input.backend.providerInstanceId &&
|
|
635
|
+
existing.backend.activeEpoch === input.backend.activeEpoch
|
|
636
|
+
) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
640
|
+
"adoptRetainedProcess",
|
|
641
|
+
`Provider session ${providerSessionId} is already bound to a different retained process identity`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
this.retainedProcesses.set(providerSessionId, {
|
|
645
|
+
process: { ...input.process },
|
|
646
|
+
backend: {
|
|
647
|
+
...seed,
|
|
648
|
+
activeEpoch: input.backend.activeEpoch,
|
|
649
|
+
},
|
|
650
|
+
durable: true,
|
|
651
|
+
pendingParentPromotion: null,
|
|
652
|
+
pendingMutationSettlement: null,
|
|
653
|
+
pendingTerminal: null,
|
|
654
|
+
settlement: null,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private async ensureParentPromotion(record: RetainedProcessRecord): Promise<void> {
|
|
659
|
+
if (record.durable) return;
|
|
660
|
+
const pending = record.pendingParentPromotion;
|
|
661
|
+
if (!pending || !this.deps.afterMutation) {
|
|
662
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
663
|
+
"retainProcess",
|
|
664
|
+
`Retained provider session ${record.process.providerSessionId} has no confirmed durable parent admission`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
await this.deps.afterMutation(pending);
|
|
669
|
+
record.pendingParentPromotion = null;
|
|
670
|
+
record.durable = true;
|
|
671
|
+
} catch (error) {
|
|
672
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
673
|
+
pending.op,
|
|
674
|
+
`Yielded sandbox process ${record.process.providerSessionId} could not confirm its durable promotion; tracking remains pinned to the original backend`,
|
|
675
|
+
{ cause: error },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private async settleRetainedProcess(
|
|
681
|
+
record: RetainedProcessRecord,
|
|
682
|
+
proof: RoutingRetainedProcessTerminalProof,
|
|
683
|
+
result: string,
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
record.pendingTerminal ??= { proof, result };
|
|
686
|
+
const pending = record.pendingTerminal;
|
|
687
|
+
if (
|
|
688
|
+
pending.proof.outcome !== proof.outcome ||
|
|
689
|
+
pending.proof.exitCode !== proof.exitCode ||
|
|
690
|
+
pending.proof.reason !== proof.reason
|
|
691
|
+
) {
|
|
692
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
693
|
+
"settleProcess",
|
|
694
|
+
`Retained provider session ${record.process.providerSessionId} produced conflicting terminal proof`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
record.settlement ??= (async () => {
|
|
698
|
+
await this.ensureParentPromotion(record);
|
|
699
|
+
await this.deps.settleProcess?.({
|
|
700
|
+
backend: record.backend,
|
|
701
|
+
process: record.process,
|
|
702
|
+
proof: pending.proof,
|
|
703
|
+
});
|
|
704
|
+
this.retainedProcesses.delete(record.process.providerSessionId);
|
|
705
|
+
record.pendingTerminal = null;
|
|
706
|
+
})();
|
|
707
|
+
try {
|
|
708
|
+
await record.settlement;
|
|
709
|
+
} catch (error) {
|
|
710
|
+
// A failed DB settlement is not permission to forget the physical process.
|
|
711
|
+
// Keep the exact route and immutable proof so the next control poll retries
|
|
712
|
+
// settlement without issuing a command against a new backend.
|
|
713
|
+
record.settlement = null;
|
|
714
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
715
|
+
"settleProcess",
|
|
716
|
+
`Retained provider session ${record.process.providerSessionId} reached a terminal state but durable settlement failed; tracking remains pinned`,
|
|
717
|
+
{ cause: error },
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private async flushPendingProcessMutation(record: RetainedProcessRecord): Promise<string | null> {
|
|
723
|
+
const pending = record.pendingMutationSettlement;
|
|
724
|
+
if (!pending) return null;
|
|
725
|
+
try {
|
|
726
|
+
await this.deps.afterProcessMutation?.(pending);
|
|
727
|
+
record.pendingMutationSettlement = null;
|
|
728
|
+
} catch (error) {
|
|
729
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
730
|
+
pending.op,
|
|
731
|
+
`Retained-process mutation ${pending.op} still lacks durable physical settlement; no later process mutation was admitted`,
|
|
732
|
+
{ cause: error },
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (pending.outcome === "resolved" && typeof pending.result === "string") {
|
|
736
|
+
const proof = retainedProcessTerminalProof(pending.result, record.process.providerSessionId);
|
|
737
|
+
if (proof) {
|
|
738
|
+
await this.settleRetainedProcess(record, proof, pending.result);
|
|
739
|
+
return pending.result;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async dispatchProcessMutation(args: unknown): Promise<string> {
|
|
746
|
+
const providerSessionId = providerSessionIdFromArgs(args);
|
|
747
|
+
if (providerSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
|
|
748
|
+
const record = this.retainedProcess(providerSessionId);
|
|
749
|
+
const priorTerminal = await this.flushPendingProcessMutation(record);
|
|
750
|
+
if (priorTerminal !== null) return priorTerminal;
|
|
751
|
+
if (record.pendingTerminal) {
|
|
752
|
+
const terminal = record.pendingTerminal;
|
|
753
|
+
await this.settleRetainedProcess(record, terminal.proof, terminal.result);
|
|
754
|
+
return terminal.result;
|
|
755
|
+
}
|
|
756
|
+
await this.ensureParentPromotion(record);
|
|
757
|
+
const op = "writeStdin";
|
|
758
|
+
const admission = await this.deps.beforeProcessMutation?.({
|
|
759
|
+
op,
|
|
760
|
+
backend: record.backend,
|
|
761
|
+
process: record.process,
|
|
312
762
|
});
|
|
313
|
-
|
|
763
|
+
const write = record.backend.session.writeStdin;
|
|
764
|
+
if (!write) throw new RoutingUnsupportedError(op, record.backend.kind);
|
|
765
|
+
let result: string;
|
|
766
|
+
try {
|
|
767
|
+
result = await write.call(record.backend.session, args);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (this.deps.afterProcessMutation) {
|
|
770
|
+
const pending: PendingProcessMutationSettlement = {
|
|
771
|
+
op,
|
|
772
|
+
backend: record.backend,
|
|
773
|
+
process: record.process,
|
|
774
|
+
admission,
|
|
775
|
+
outcome: "rejected",
|
|
776
|
+
};
|
|
777
|
+
try {
|
|
778
|
+
await this.deps.afterProcessMutation(pending);
|
|
779
|
+
} catch (settlementError) {
|
|
780
|
+
record.pendingMutationSettlement = pending;
|
|
781
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
782
|
+
op,
|
|
783
|
+
`Retained-process stdin rejected at the provider but lost durable settlement; it was not replayed`,
|
|
784
|
+
{ cause: settlementError },
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
if (this.deps.afterProcessMutation) {
|
|
791
|
+
const pending: PendingProcessMutationSettlement = {
|
|
792
|
+
op,
|
|
793
|
+
backend: record.backend,
|
|
794
|
+
process: record.process,
|
|
795
|
+
admission,
|
|
796
|
+
outcome: "resolved",
|
|
797
|
+
result,
|
|
798
|
+
};
|
|
799
|
+
try {
|
|
800
|
+
await this.deps.afterProcessMutation(pending);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
record.pendingMutationSettlement = pending;
|
|
803
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
804
|
+
op,
|
|
805
|
+
`Retained-process stdin returned from the provider but lost durable settlement; tracking remains pinned and it was not replayed`,
|
|
806
|
+
{ cause: error },
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const proof = retainedProcessTerminalProof(result, providerSessionId);
|
|
811
|
+
if (proof) await this.settleRetainedProcess(record, proof, result);
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async dispatchProcessControl(args: unknown): Promise<string> {
|
|
816
|
+
const providerSessionId = providerSessionIdFromArgs(args);
|
|
817
|
+
if (providerSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
|
|
818
|
+
const record = this.retainedProcess(providerSessionId);
|
|
819
|
+
const priorTerminal = await this.flushPendingProcessMutation(record);
|
|
820
|
+
if (priorTerminal !== null) return priorTerminal;
|
|
821
|
+
if (record.pendingTerminal) {
|
|
822
|
+
const terminal = record.pendingTerminal;
|
|
823
|
+
await this.settleRetainedProcess(record, terminal.proof, terminal.result);
|
|
824
|
+
return terminal.result;
|
|
825
|
+
}
|
|
826
|
+
await this.ensureParentPromotion(record);
|
|
827
|
+
const write = record.backend.session.writeStdin;
|
|
828
|
+
if (!write) throw new RoutingUnsupportedError("writeStdin", record.backend.kind);
|
|
829
|
+
const result = await write.call(record.backend.session, args);
|
|
830
|
+
const proof = retainedProcessTerminalProof(result, providerSessionId);
|
|
831
|
+
if (proof) await this.settleRetainedProcess(record, proof, result);
|
|
832
|
+
return result;
|
|
314
833
|
}
|
|
315
834
|
|
|
316
835
|
/**
|
|
@@ -326,34 +845,151 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
326
845
|
*/
|
|
327
846
|
private async dispatch<T>(
|
|
328
847
|
op: string,
|
|
848
|
+
mutatesWorkspace: boolean,
|
|
329
849
|
fn: (session: RoutableBackendSession) => Promise<T>,
|
|
330
850
|
): Promise<T> {
|
|
331
851
|
let attempt = 0;
|
|
332
852
|
let lastError: unknown;
|
|
333
853
|
while (attempt <= this.maxFenceRetries) {
|
|
334
854
|
const backend = await this.resolve();
|
|
855
|
+
// Admission failures are NOT provider fence errors and must never enter
|
|
856
|
+
// the retry/rebind loop. If this exact route cannot advance its durable
|
|
857
|
+
// mutation generation, fail before the provider sees the operation.
|
|
858
|
+
const admission = mutatesWorkspace
|
|
859
|
+
? await this.deps.beforeMutation?.({ op, backend })
|
|
860
|
+
: undefined;
|
|
861
|
+
let result: T;
|
|
335
862
|
try {
|
|
336
|
-
|
|
863
|
+
result = await fn(backend.session);
|
|
337
864
|
} catch (error) {
|
|
865
|
+
if (mutatesWorkspace && this.deps.afterMutation) {
|
|
866
|
+
try {
|
|
867
|
+
await this.deps.afterMutation({
|
|
868
|
+
op,
|
|
869
|
+
backend,
|
|
870
|
+
admission,
|
|
871
|
+
outcome: "rejected",
|
|
872
|
+
});
|
|
873
|
+
} catch (settlementError) {
|
|
874
|
+
this.invalidate(backend);
|
|
875
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
876
|
+
op,
|
|
877
|
+
`Mutating sandbox operation "${op}" rejected at the provider but lost its durable physical settlement; its outcome is unknown and it was not replayed`,
|
|
878
|
+
{ cause: settlementError },
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
338
882
|
if (!isFenceError(error)) {
|
|
883
|
+
if (backend.sandboxId === null && this.deps.onDefaultBackendError) {
|
|
884
|
+
const loss = await this.deps.onDefaultBackendError({
|
|
885
|
+
op,
|
|
886
|
+
error,
|
|
887
|
+
kind: backend.kind,
|
|
888
|
+
backend,
|
|
889
|
+
});
|
|
890
|
+
if (loss) {
|
|
891
|
+
// Never replay an operation after provider disappearance. Even a
|
|
892
|
+
// read may race a route change, and a mutation's provider outcome
|
|
893
|
+
// is ambiguous. The next independently-admitted call observes the
|
|
894
|
+
// advanced epoch and elected recovery state.
|
|
895
|
+
this.invalidate(backend);
|
|
896
|
+
throw new RoutingBackendRecoveryRequiredError(op, loss.leaseEpoch, loss.recovery);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
339
899
|
throw error;
|
|
340
900
|
}
|
|
901
|
+
this.invalidate(backend);
|
|
902
|
+
if (mutatesWorkspace) {
|
|
903
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
904
|
+
op,
|
|
905
|
+
`Mutating sandbox operation "${op}" was fenced after provider admission; its outcome is unknown and it was not replayed`,
|
|
906
|
+
{ cause: error },
|
|
907
|
+
);
|
|
908
|
+
}
|
|
341
909
|
// Stale-epoch fence: the active pointer moved mid-op. Drop the cache so
|
|
342
910
|
// the next resolve re-reads the NEW pointer and the op lands on the new
|
|
343
911
|
// active sandbox (the fenced-retry role). Bounded by maxFenceRetries.
|
|
344
912
|
lastError = error;
|
|
345
|
-
this.cachedEpoch = undefined;
|
|
346
|
-
this.cachedSandboxId = undefined;
|
|
347
|
-
this.cached = undefined;
|
|
348
|
-
this.deps.onTransition?.({
|
|
349
|
-
type: "fenced-retry",
|
|
350
|
-
fromEpoch: backend.sandboxId === null ? 0 : 0,
|
|
351
|
-
toEpoch: 0,
|
|
352
|
-
sandboxId: backend.sandboxId,
|
|
353
|
-
kind: backend.kind,
|
|
354
|
-
});
|
|
355
913
|
attempt += 1;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const yieldedSessionId =
|
|
918
|
+
mutatesWorkspace && (op === "exec" || op === "execCommand")
|
|
919
|
+
? providerSessionIdFromResult(result)
|
|
920
|
+
: null;
|
|
921
|
+
const retainedProcess =
|
|
922
|
+
yieldedSessionId === null
|
|
923
|
+
? undefined
|
|
924
|
+
: { id: crypto.randomUUID(), providerSessionId: yieldedSessionId };
|
|
925
|
+
const retainedRecord = retainedProcess
|
|
926
|
+
? this.registerRetainedProcess(retainedProcess, backend)
|
|
927
|
+
: null;
|
|
928
|
+
|
|
929
|
+
if (mutatesWorkspace && this.deps.afterMutation) {
|
|
930
|
+
const settlement: PendingParentPromotion = {
|
|
931
|
+
op,
|
|
932
|
+
backend,
|
|
933
|
+
admission,
|
|
934
|
+
outcome: "resolved",
|
|
935
|
+
result,
|
|
936
|
+
...(retainedProcess ? { retainedProcess } : {}),
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
await this.deps.afterMutation(settlement);
|
|
940
|
+
if (retainedRecord) retainedRecord.durable = true;
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (retainedRecord) retainedRecord.pendingParentPromotion = settlement;
|
|
943
|
+
this.invalidate(backend);
|
|
944
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
945
|
+
op,
|
|
946
|
+
retainedRecord
|
|
947
|
+
? `Mutating sandbox operation "${op}" yielded provider session ${retainedRecord.process.providerSessionId} but lost durable process promotion; exact-backend tracking remains and the operation was not replayed`
|
|
948
|
+
: `Mutating sandbox operation "${op}" returned from the provider but lost its durable route settlement; its outcome is unknown and it was not replayed`,
|
|
949
|
+
{ cause: error },
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Durable process promotion validated this exact route under the same
|
|
955
|
+
// transaction that retained the parent admission. Later pointer movement
|
|
956
|
+
// cannot invalidate or redirect that process, so return its locator and
|
|
957
|
+
// let process-aware methods use the copied backend identity.
|
|
958
|
+
if (retainedRecord) return result;
|
|
959
|
+
|
|
960
|
+
// Reject output produced by a route that changed while the provider call
|
|
961
|
+
// was in flight. Reads can safely retry on the new route. Mutations cannot:
|
|
962
|
+
// their provider effect may already have happened on the old route.
|
|
963
|
+
let current: ActivePointer;
|
|
964
|
+
try {
|
|
965
|
+
current = await this.deps.readPointer();
|
|
966
|
+
} catch (error) {
|
|
967
|
+
if (mutatesWorkspace) {
|
|
968
|
+
this.invalidate(backend);
|
|
969
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
970
|
+
op,
|
|
971
|
+
`Mutating sandbox operation "${op}" returned from the provider but its route could not be revalidated; its outcome is unknown and it was not replayed`,
|
|
972
|
+
{ cause: error },
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
throw error;
|
|
976
|
+
}
|
|
977
|
+
if (
|
|
978
|
+
current.activeEpoch !== backend.activeEpoch ||
|
|
979
|
+
current.activeSandboxId !== backend.sandboxId
|
|
980
|
+
) {
|
|
981
|
+
this.invalidate(backend);
|
|
982
|
+
if (mutatesWorkspace) {
|
|
983
|
+
throw new RoutingMutationOutcomeUnknownError(
|
|
984
|
+
op,
|
|
985
|
+
`Mutating sandbox operation "${op}" completed on a superseded route; its output was rejected and it was not replayed`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
lastError = new Error(`sandbox route changed while "${op}" was in flight`);
|
|
989
|
+
attempt += 1;
|
|
990
|
+
continue;
|
|
356
991
|
}
|
|
992
|
+
return result;
|
|
357
993
|
}
|
|
358
994
|
// Exhausted retries against a relentless swap-storm: surface the fence so the
|
|
359
995
|
// caller (turn) backs off — never loop forever.
|
|
@@ -391,7 +1027,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
391
1027
|
// degrades via the natural fallback or RoutingUnsupportedError.
|
|
392
1028
|
|
|
393
1029
|
async exec(args: unknown): Promise<unknown> {
|
|
394
|
-
return this.dispatch("exec", async (s) => {
|
|
1030
|
+
return this.dispatch("exec", true, async (s) => {
|
|
395
1031
|
if (s.exec) {
|
|
396
1032
|
return s.exec(args);
|
|
397
1033
|
}
|
|
@@ -405,7 +1041,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
405
1041
|
|
|
406
1042
|
async execCommand(args: unknown): Promise<string> {
|
|
407
1043
|
try {
|
|
408
|
-
return await this.dispatch("execCommand", async (s) => {
|
|
1044
|
+
return await this.dispatch("execCommand", true, async (s) => {
|
|
409
1045
|
if (s.execCommand) {
|
|
410
1046
|
return s.execCommand(args);
|
|
411
1047
|
}
|
|
@@ -423,7 +1059,11 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
423
1059
|
}
|
|
424
1060
|
|
|
425
1061
|
async writeStdin(args: unknown): Promise<string> {
|
|
426
|
-
|
|
1062
|
+
const providerSessionId = providerSessionIdFromArgs(args);
|
|
1063
|
+
if (providerSessionId !== null && this.retainedProcesses.has(providerSessionId)) {
|
|
1064
|
+
return await this.dispatchProcessMutation(args);
|
|
1065
|
+
}
|
|
1066
|
+
return this.dispatch("writeStdin", true, async (s) => {
|
|
427
1067
|
if (!s.writeStdin) {
|
|
428
1068
|
throw new RoutingUnsupportedError("writeStdin", this.cached?.kind ?? "unknown");
|
|
429
1069
|
}
|
|
@@ -431,15 +1071,70 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
431
1071
|
});
|
|
432
1072
|
}
|
|
433
1073
|
|
|
1074
|
+
/** Whether a positive provider session locator is still pinned to the exact
|
|
1075
|
+
* backend that yielded it. This synchronous probe is used only to select the
|
|
1076
|
+
* process-aware routing surface; it is not itself durable authority. */
|
|
1077
|
+
hasRetainedProcess(providerSessionId: number): boolean {
|
|
1078
|
+
return (
|
|
1079
|
+
positiveProviderSessionId(providerSessionId) !== null &&
|
|
1080
|
+
this.retainedProcesses.has(providerSessionId)
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** Return only OpenGeni's durable UUID + provider locator. Backend/session
|
|
1085
|
+
* objects remain private so a caller cannot forge route authority from this
|
|
1086
|
+
* diagnostic handoff. */
|
|
1087
|
+
retainedProcessIdentity(providerSessionId: number): RoutingRetainedProcess | null {
|
|
1088
|
+
const record = this.retainedProcesses.get(providerSessionId);
|
|
1089
|
+
return record ? { ...record.process } : null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Model/user-visible stdin is a distinct workspace mutation admission under
|
|
1093
|
+
* the durable retained-process holder. It never re-reads the active pointer. */
|
|
1094
|
+
async writeStdinForProcessMutation(args: unknown): Promise<string> {
|
|
1095
|
+
return await this.dispatchProcessMutation(args);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Cancellation and drain polling are control operations. They stay pinned to
|
|
1099
|
+
* the process backend and may prove terminal state, but never advance the
|
|
1100
|
+
* workspace generation. */
|
|
1101
|
+
async writeStdinForProcessControl(args: unknown): Promise<string> {
|
|
1102
|
+
return await this.dispatchProcessControl(args);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** Run a PID/PGID marker or signal helper on the retained process's exact
|
|
1106
|
+
* backend. The helper is control-plane work and therefore has no workspace
|
|
1107
|
+
* mutation admission of its own. */
|
|
1108
|
+
async execCommandForProcessControl(providerSessionId: number, args: unknown): Promise<string> {
|
|
1109
|
+
const exactProviderSessionId = positiveProviderSessionId(providerSessionId);
|
|
1110
|
+
if (exactProviderSessionId === null) throw new RoutingRetainedProcessNotFoundError(-1);
|
|
1111
|
+
const record = this.retainedProcess(exactProviderSessionId);
|
|
1112
|
+
const priorTerminal = await this.flushPendingProcessMutation(record);
|
|
1113
|
+
if (priorTerminal !== null) return priorTerminal;
|
|
1114
|
+
if (record.pendingTerminal) {
|
|
1115
|
+
const terminal = record.pendingTerminal;
|
|
1116
|
+
await this.settleRetainedProcess(record, terminal.proof, terminal.result);
|
|
1117
|
+
return terminal.result;
|
|
1118
|
+
}
|
|
1119
|
+
await this.ensureParentPromotion(record);
|
|
1120
|
+
if (record.backend.session.execCommand) {
|
|
1121
|
+
return await record.backend.session.execCommand(args);
|
|
1122
|
+
}
|
|
1123
|
+
if (record.backend.session.exec) {
|
|
1124
|
+
return formatExecResult(await record.backend.session.exec(args));
|
|
1125
|
+
}
|
|
1126
|
+
throw new RoutingUnsupportedError("execCommand", record.backend.kind);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
434
1129
|
async cancelExecCommand(opId: string): Promise<boolean> {
|
|
435
|
-
return await this.dispatch("cancelExecCommand", async (session) => {
|
|
1130
|
+
return await this.dispatch("cancelExecCommand", false, async (session) => {
|
|
436
1131
|
if (!session.cancelExecCommand) return false;
|
|
437
1132
|
return await session.cancelExecCommand(opId);
|
|
438
1133
|
});
|
|
439
1134
|
}
|
|
440
1135
|
|
|
441
1136
|
async readFile(args: unknown): Promise<string | Uint8Array> {
|
|
442
|
-
return this.dispatch("readFile", async (s) => {
|
|
1137
|
+
return this.dispatch("readFile", false, async (s) => {
|
|
443
1138
|
if (!s.readFile) {
|
|
444
1139
|
throw new RoutingUnsupportedError("readFile", this.cached?.kind ?? "unknown");
|
|
445
1140
|
}
|
|
@@ -448,7 +1143,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
448
1143
|
}
|
|
449
1144
|
|
|
450
1145
|
async writeFile(args: unknown): Promise<unknown> {
|
|
451
|
-
return this.dispatch("writeFile", async (s) => {
|
|
1146
|
+
return this.dispatch("writeFile", true, async (s) => {
|
|
452
1147
|
if (!s.writeFile) {
|
|
453
1148
|
throw new RoutingUnsupportedError("writeFile", this.cached?.kind ?? "unknown");
|
|
454
1149
|
}
|
|
@@ -457,7 +1152,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
457
1152
|
}
|
|
458
1153
|
|
|
459
1154
|
async listDir(args: unknown): Promise<unknown> {
|
|
460
|
-
return this.dispatch("listDir", async (s) => {
|
|
1155
|
+
return this.dispatch("listDir", false, async (s) => {
|
|
461
1156
|
if (!s.listDir) {
|
|
462
1157
|
throw new RoutingUnsupportedError("listDir", this.cached?.kind ?? "unknown");
|
|
463
1158
|
}
|
|
@@ -466,7 +1161,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
466
1161
|
}
|
|
467
1162
|
|
|
468
1163
|
async pathExists(path: string, runAs?: string): Promise<boolean> {
|
|
469
|
-
return this.dispatch("pathExists", async (s) => {
|
|
1164
|
+
return this.dispatch("pathExists", false, async (s) => {
|
|
470
1165
|
if (!s.pathExists) {
|
|
471
1166
|
throw new RoutingUnsupportedError("pathExists", this.cached?.kind ?? "unknown");
|
|
472
1167
|
}
|
|
@@ -475,7 +1170,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
475
1170
|
}
|
|
476
1171
|
|
|
477
1172
|
async viewImage(args: unknown): Promise<unknown> {
|
|
478
|
-
return this.dispatch("viewImage", async (s) => {
|
|
1173
|
+
return this.dispatch("viewImage", false, async (s) => {
|
|
479
1174
|
if (!s.viewImage) {
|
|
480
1175
|
throw new RoutingUnsupportedError("viewImage", this.cached?.kind ?? "unknown");
|
|
481
1176
|
}
|
|
@@ -484,7 +1179,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
484
1179
|
}
|
|
485
1180
|
|
|
486
1181
|
async materializeEntry(args: unknown): Promise<void> {
|
|
487
|
-
return this.dispatch("materializeEntry", async (s) => {
|
|
1182
|
+
return this.dispatch("materializeEntry", true, async (s) => {
|
|
488
1183
|
if (!s.materializeEntry) {
|
|
489
1184
|
throw new RoutingUnsupportedError("materializeEntry", this.cached?.kind ?? "unknown");
|
|
490
1185
|
}
|
|
@@ -512,16 +1207,15 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
512
1207
|
* direct delegate returns undefined and every lazy turn would die at bind. Return a
|
|
513
1208
|
* LAZY EDITOR PROXY instead: a non-null editor whose async ops resolve the active
|
|
514
1209
|
* backend (establishing the box on first use, via `dispatch`) and delegate to its
|
|
515
|
-
* real editor — mirroring how this proxy defers exec/readFile.
|
|
1210
|
+
* real editor — mirroring how this proxy defers exec/readFile. Even when an
|
|
1211
|
+
* eager editor already exists, returning it directly would bypass per-edit
|
|
1212
|
+
* route resolution and mutation-generation admission, so every editor is
|
|
1213
|
+
* represented by this dispatching proxy. */
|
|
516
1214
|
createEditor(runAs?: string): unknown {
|
|
517
|
-
const eager = (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
|
|
518
|
-
if (eager) {
|
|
519
|
-
return eager;
|
|
520
|
-
}
|
|
521
1215
|
const op =
|
|
522
1216
|
(name: "createFile" | "updateFile" | "deleteFile") =>
|
|
523
1217
|
(operation: unknown, context?: unknown): Promise<unknown> =>
|
|
524
|
-
this.dispatch(`editor.${name}`, async (s) => {
|
|
1218
|
+
this.dispatch(`editor.${name}`, true, async (s) => {
|
|
525
1219
|
const editor = s.createEditor?.(runAs) as
|
|
526
1220
|
| Record<string, (operation: unknown, context?: unknown) => Promise<unknown>>
|
|
527
1221
|
| undefined;
|
|
@@ -538,7 +1232,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
538
1232
|
}
|
|
539
1233
|
|
|
540
1234
|
async resolveExposedPort(port: number): Promise<ExposedPortEndpoint> {
|
|
541
|
-
return this.dispatch("resolveExposedPort", async (s) => {
|
|
1235
|
+
return this.dispatch("resolveExposedPort", false, async (s) => {
|
|
542
1236
|
if (!s.resolveExposedPort) {
|
|
543
1237
|
throw new RoutingUnsupportedError("resolveExposedPort", this.cached?.kind ?? "unknown");
|
|
544
1238
|
}
|
|
@@ -549,7 +1243,7 @@ export class RoutingSandboxSession implements RoutableBackendSession {
|
|
|
549
1243
|
/** Serialize the active backend's session state. Used by the resume-by-id seam
|
|
550
1244
|
* to fold the live box onto the lease. Dispatches to the active backend. */
|
|
551
1245
|
async serializeSessionState(): Promise<unknown> {
|
|
552
|
-
return this.dispatch("serializeSessionState", async (s) => {
|
|
1246
|
+
return this.dispatch("serializeSessionState", false, async (s) => {
|
|
553
1247
|
if (!s.serializeSessionState) {
|
|
554
1248
|
// No-op for a backend with no serializable state (selfhosted state is
|
|
555
1249
|
// re-addressed, not snapshotted) — surface undefined, not an error.
|