@nylorun/runtime 0.21.1-beta → 0.23.0-beta

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 (84) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -3
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +77 -83
  5. package/dist/adapters/sandbox/local.js +1 -1
  6. package/dist/adapters/sandbox/virtual.js +1 -1
  7. package/dist/adapters/streams/s2.js +1 -13
  8. package/dist/api/components.d.ts +42 -2
  9. package/dist/core/flow-host.d.ts +1 -19
  10. package/dist/core/flow-host.js +8 -37
  11. package/dist/execution/types.d.ts +10 -8
  12. package/dist/execution/types.js +5 -7
  13. package/dist/gates/egress-credentials.d.ts +48 -0
  14. package/dist/gates/egress-credentials.js +147 -0
  15. package/dist/gates/egress.d.ts +3 -0
  16. package/dist/gates/egress.js +14 -1
  17. package/dist/gates/tenant-vaults.d.ts +4 -0
  18. package/dist/gates/tenant-vaults.js +2 -0
  19. package/dist/harness/calls.d.ts +2 -0
  20. package/dist/harness/calls.js +1 -0
  21. package/dist/harness/executors.js +1 -0
  22. package/dist/harness/main.js +4 -1
  23. package/dist/harness/pod.d.ts +2 -0
  24. package/dist/harness/pod.js +36 -1
  25. package/dist/harness-api/start.d.ts +2 -0
  26. package/dist/harness-api/start.js +4 -2
  27. package/dist/harness-api/ws-server.js +2 -2
  28. package/dist/host/execution.d.ts +8 -6
  29. package/dist/host/gates.js +11 -0
  30. package/dist/host/main.js +10 -16
  31. package/dist/host/stack-config.d.ts +6 -0
  32. package/dist/host/stack-config.js +6 -0
  33. package/dist/infra/execution.d.ts +18 -1
  34. package/dist/infra/execution.js +28 -4
  35. package/dist/infra/index.d.ts +1 -1
  36. package/dist/keys/egress-ca.d.ts +15 -0
  37. package/dist/keys/egress-ca.js +66 -0
  38. package/dist/keys/keys.d.ts +8 -1
  39. package/dist/keys/keys.js +6 -0
  40. package/dist/keys/x509.d.ts +29 -0
  41. package/dist/keys/x509.js +160 -0
  42. package/dist/management-openapi.json +195 -9
  43. package/dist/openapi.json +2 -1
  44. package/dist/retry.d.ts +21 -0
  45. package/dist/retry.js +34 -0
  46. package/dist/sandbox/join.d.ts +2 -0
  47. package/dist/sandbox/join.js +14 -10
  48. package/dist/sandbox/manager.d.ts +2 -0
  49. package/dist/sandbox/manager.js +1 -1
  50. package/dist/sandbox/pods/reconcile.js +3 -3
  51. package/dist/sandbox/tools.d.ts +3 -1
  52. package/dist/sandbox/tools.js +4 -2
  53. package/dist/sandbox/types.d.ts +5 -0
  54. package/dist/store/postgres/control.js +8 -1
  55. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  56. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  57. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  58. package/dist/store/postgres/schema.d.ts +265 -4
  59. package/dist/store/postgres/schema.js +35 -4
  60. package/dist/store/postgres/store.js +165 -52
  61. package/dist/store/types.d.ts +96 -39
  62. package/dist/streams/relay/core.js +1 -13
  63. package/dist/tenant/advance.js +6 -7
  64. package/dist/tenant/commands.js +1 -2
  65. package/dist/tenant/context.d.ts +19 -11
  66. package/dist/tenant/control.d.ts +8 -1
  67. package/dist/tenant/control.js +2 -0
  68. package/dist/tenant/runtime.js +23 -3
  69. package/dist/tenant/sandboxes.d.ts +2 -1
  70. package/dist/tenant/sandboxes.js +23 -19
  71. package/dist/tenant/scheduler.d.ts +7 -0
  72. package/dist/tenant/scheduler.js +38 -7
  73. package/dist/tenant/session-streams.d.ts +0 -1
  74. package/dist/tenant/session-streams.js +1 -13
  75. package/dist/tenant/sessions.js +26 -0
  76. package/dist/tenant/streams.js +14 -29
  77. package/dist/tenant/sweep.d.ts +39 -12
  78. package/dist/tenant/sweep.js +157 -58
  79. package/dist/tenant/transcript.d.ts +8 -8
  80. package/dist/vault/service.d.ts +34 -0
  81. package/dist/vault/service.js +148 -15
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +4 -3
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * Business code changes state only inside `ctx.store.tx(async (t) => …)` and follows the
7
7
  * seam rules: events through `t.event(...)` (the relay appends them to Durable Streams after
8
- * commit), and advances through `await t.wake(id, { reason, dedupeKey }, ctx.wake)`, which
9
- * writes the wake to the outbox in the transaction and delivers it after commit. It never
8
+ * commit), and advances through `await t.wake(id, { reason, dedupeKey })`, which writes the
9
+ * wake to the outbox in the transaction; the store delivers it after commit (`ctx.wake`,
10
+ * wired with `SessionStore.deliverTo`). It never
10
11
  * publishes or notifies itself; `runtime.ts` wires the streams with `wireStreams()`. No
11
12
  * external I/O runs inside a tx.
12
13
  *
@@ -18,7 +19,7 @@ import type { IssuerScope, SubjectScope, TenantEnvelope } from "@nylorun/core/co
18
19
  import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
19
20
  import type { AgentManifest, JsonValue, SandboxManifest } from "@nylorun/core/define";
20
21
  import type { CredentialSelection } from "@nylorun/core/contracts";
21
- import type { SessionStore, Tx } from "../store/types.js";
22
+ import type { OutboxRequest, SessionStore, Tx } from "../store/types.js";
22
23
  import type { FlowLimits } from "../core/limits.js";
23
24
  import type { ModelProvider } from "../core/provider.js";
24
25
  import type { ModelGate } from "../gates/model-gate.js";
@@ -212,13 +213,18 @@ export interface TenantContext {
212
213
  /** How long an advance's ownership lease lasts; the heartbeat renews it. */
213
214
  readonly ownerLeaseMs: number;
214
215
  /**
215
- * Seam: hand a wake to `DurableExecution.wake`, outside any tx. Business code passes it to
216
- * `t.wake` (the outbox), which calls it after commit; the sweep calls it for outbox rows
217
- * left undelivered and for orphaned sessions. Resolves `true` once the execution accepted
218
- * the wake, `false` while the Tenant is closing: the wake is not sent, and its outbox row
219
- * stays for the next sweep.
216
+ * Seam: hand a wake to `DurableExecution.wake`, outside any tx: the sweep's orphan scan,
217
+ * and `send`. Resolves `true` once the execution accepted the wake, `false` while the
218
+ * Tenant is closing: the wake is not sent.
220
219
  */
221
220
  wake(sessionId: string, wake: Wake): Promise<boolean>;
221
+ /**
222
+ * Seam: hand an outbox request to Durable Session Execution (`wake` or `sandboxSignal`),
223
+ * outside any tx. The store calls it after commit for what business code wrote with
224
+ * `t.wake` and `t.signalSandbox` (`SessionStore.deliverTo`); the sweep calls it for rows
225
+ * left undelivered. `false` (the Tenant is closing) leaves the row for the next sweep.
226
+ */
227
+ send(request: OutboxRequest): Promise<boolean>;
222
228
  /**
223
229
  * Seam: abort the advance of a session running in this process, if any. With `turnId`, only
224
230
  * an advance of that turn (a cancel signal names the turn it cancelled).
@@ -230,10 +236,12 @@ export interface TenantContext {
230
236
  */
231
237
  readonly pods?: TenantPods;
232
238
  /**
233
- * Seam: reconcile a pod sandbox, or arm one of its timers (`DurableExecution.sandbox`). Call
234
- * it from `t.afterCommit`. Dropped while the Tenant is closing; the sweep re-sends reconciles.
239
+ * Seam: reconcile a pod sandbox, or arm one of its timers (`DurableExecution.sandbox`),
240
+ * outside any tx. What a transaction causes goes through `t.signalSandbox` instead, which
241
+ * writes it down; a direct call is best effort. `false` while the Tenant is closing (not
242
+ * sent); `true` without sandbox pods, where there is nothing to send.
235
243
  */
236
- sandboxSignal(sandboxId: string, signal: SandboxSignal): Promise<void>;
244
+ sandboxSignal(sandboxId: string, signal: SandboxSignal): Promise<boolean>;
237
245
  /** This Tenant's execution invocations that need an operator, for Tenant status. */
238
246
  readonly stuckInvocations?: () => Promise<StuckInvocation[]>;
239
247
  /** Callbacks the Tenant sweep runs after its own steps (`sweep.ts`). */
@@ -8,9 +8,16 @@
8
8
  * - `sessions.reset` is written by the reset's transaction (`reset.ts`): every process moves
9
9
  * its session streams to the new basin generation and ends those of deleted sessions
10
10
  * (`checkSessionStreams`).
11
+ * - `host.revoked` is written by the transaction that moves a pod sandbox's host epoch
12
+ * (`sandboxes.ts`, `sandbox/join.ts`, `sandbox/pods/reconcile.ts`): every process closes its
13
+ * connections hosting that sandbox at an older epoch, wherever the host connected
14
+ * (`HarnessApiServer.revokeHost`).
11
15
  *
12
16
  * A lost signal costs latency, never correctness: the advance checks the Session Store before
13
- * every effect, and the session streams are checked periodically (`streams.ts`).
17
+ * every effect, the session streams are checked periodically (`streams.ts`), and a revoked
18
+ * host's writes are fenced by its runs' lease epochs and its token by the sandbox's row.
19
+ * Signals are read back from their rows for two minutes, so a process whose listener was
20
+ * down catches up.
14
21
  */
15
22
  import type { TenantContext } from "./context.js";
16
23
  export interface WireControlOptions {
@@ -16,6 +16,8 @@ export async function wireControl(ctx, options = {}) {
16
16
  const follower = await ctx.store.followSignals((signal) => {
17
17
  if (signal.type === "session.cancel")
18
18
  ctx.abortLocal(signal.sessionId, signal.turnId);
19
+ else if (signal.type === "host.revoked")
20
+ ctx.harness.revokeHost(signal.sandboxId, signal.epoch);
19
21
  else
20
22
  void checkSessionStreams(ctx).catch(report("session stream check failed"));
21
23
  }, {
@@ -268,10 +268,14 @@ export class TenantRuntime {
268
268
  abortLocal: (sessionId, turnId) => abortLocal(ctx, sessionId, "cancel", turnId),
269
269
  ...(hooks.pods ? { pods: hooks.pods } : {}),
270
270
  sandboxSignal: async (sandboxId, signal) => {
271
- if (ctx.closing || ctx.closed || !execution.sandbox)
272
- return;
273
- await execution.sandbox(config.tenantId, sandboxId, signal);
271
+ if (ctx.closing || ctx.closed)
272
+ return false;
273
+ await execution.sandbox?.(config.tenantId, sandboxId, signal);
274
+ return true;
274
275
  },
276
+ send: (request) => request.kind === "wake"
277
+ ? ctx.wake(request.sessionId, request.wake)
278
+ : ctx.sandboxSignal(request.sandboxId, request.signal),
275
279
  ...(hooks.execution?.stuckInvocations
276
280
  ? {
277
281
  stuckInvocations: () => hooks.execution.stuckInvocations(config.tenantId),
@@ -283,6 +287,22 @@ export class TenantRuntime {
283
287
  return () => sweepHooks.delete(hook);
284
288
  },
285
289
  };
290
+ // What a commit asks of the execution goes without the request waiting; a send that
291
+ // fails is the sweep's to send again, so it is only a warning here.
292
+ opened.deliverTo(async (request) => {
293
+ try {
294
+ return await ctx.send(request);
295
+ }
296
+ catch (error) {
297
+ config.logger.warn("outbox delivery after commit failed; the sweep sends it again", {
298
+ ...(request.kind === "wake"
299
+ ? { sessionId: request.sessionId, reason: request.wake.reason }
300
+ : { sandboxId: request.sandboxId, signal: request.signal.kind }),
301
+ message: error instanceof Error ? error.message : String(error),
302
+ });
303
+ return false;
304
+ }
305
+ });
286
306
  harnessServer = createHarnessApiServer(ctx, { sandboxPreference: preference });
287
307
  if (mcp && manager)
288
308
  harness = await startInProcessHarness(ctx, { mcp, sandbox: manager }, {
@@ -19,7 +19,8 @@
19
19
  * `sandbox-pods`): an agent-sandbox pod on the Tenant's cluster, created at once, whose engine
20
20
  * runs the turns of the sessions attached to it. Its lifecycle (`sandbox/pods/lifecycle.ts`)
21
21
  * runs in the `Sandbox` object: this module records what is wanted (`desired`, `rev`) and asks
22
- * for a reconcile after the commit (`ctx.sandboxSignal`). Without sandbox pods kind `pod` is
22
+ * for a reconcile in the same transaction (`Tx.signalSandbox`, delivered after the commit),
23
+ * and a host epoch it moves goes on the control bus (`host.revoked`). Without sandbox pods kind `pod` is
23
24
  * `409 sandbox_unavailable`. `POST .../stop` suspends a pod (the volume is kept; the next turn
24
25
  * starts it), `POST .../reset` gives it a new pod on a new volume. An expired pod refuses
25
26
  * turns (`sandbox_expired`) until a `PUT` sets a longer `lifecycle.ttl`; a lost one
@@ -19,7 +19,8 @@
19
19
  * `sandbox-pods`): an agent-sandbox pod on the Tenant's cluster, created at once, whose engine
20
20
  * runs the turns of the sessions attached to it. Its lifecycle (`sandbox/pods/lifecycle.ts`)
21
21
  * runs in the `Sandbox` object: this module records what is wanted (`desired`, `rev`) and asks
22
- * for a reconcile after the commit (`ctx.sandboxSignal`). Without sandbox pods kind `pod` is
22
+ * for a reconcile in the same transaction (`Tx.signalSandbox`, delivered after the commit),
23
+ * and a host epoch it moves goes on the control bus (`host.revoked`). Without sandbox pods kind `pod` is
23
24
  * `409 sandbox_unavailable`. `POST .../stop` suspends a pod (the volume is kept; the next turn
24
25
  * starts it), `POST .../reset` gives it a new pod on a new volume. An expired pod refuses
25
26
  * turns (`sandbox_expired`) until a `PUT` sets a longer `lifecycle.ttl`; a lost one
@@ -161,9 +162,13 @@ function specMismatch(existing, body) {
161
162
  return "storage";
162
163
  return undefined;
163
164
  }
164
- /** Asks for a pod's reconcile once the transaction commits. */
165
- function reconcileAfter(ctx, t, id) {
166
- t.afterCommit(() => ctx.sandboxSignal(id, { kind: "reconcile" }));
165
+ /** Asks for a pod's reconcile once the transaction commits (the sandbox signal outbox). */
166
+ function reconcileAfter(t, id) {
167
+ return t.signalSandbox(id, { kind: "reconcile" });
168
+ }
169
+ /** Tells every process that pod sandbox `id`'s host epoch moved to `epoch`, at commit. */
170
+ function revokeHost(t, id, epoch) {
171
+ return t.signal({ type: "host.revoked", sandboxId: id, epoch });
167
172
  }
168
173
  /** Asks the pod to run (again): a new `rev`, so the service applies it. */
169
174
  async function wantRunning(t, sandbox, now) {
@@ -227,7 +232,7 @@ export async function putSandbox(ctx, id, body, scope) {
227
232
  });
228
233
  if (revive || pod.desired !== "running" || pod.observed === "failed") {
229
234
  await wantRunning(t, current, now);
230
- reconcileAfter(ctx, t, id);
235
+ await reconcileAfter(t, id);
231
236
  }
232
237
  return viewOf(ctx, t, (await t.sandboxResource(id)) ?? current, ownerOf(scope));
233
238
  }
@@ -278,9 +283,9 @@ export async function putSandbox(ctx, id, body, scope) {
278
283
  await t.sandboxEvent(id, "sandbox.created", { kind, labels: row.labels });
279
284
  if (row.pod) {
280
285
  // Created at once; idle from now if no turn comes.
281
- reconcileAfter(ctx, t, id);
286
+ await reconcileAfter(t, id);
282
287
  const idleAt = Date.parse(now) + podLifecycleConfig(config).idleMs;
283
- t.afterCommit(() => ctx.sandboxSignal(id, { kind: "arm", timer: "idle", at: idleAt }));
288
+ await t.signalSandbox(id, { kind: "arm", timer: "idle", at: idleAt });
284
289
  }
285
290
  return viewOf(ctx, t, row, ownerOf(scope));
286
291
  });
@@ -330,8 +335,8 @@ export async function deleteSandbox(ctx, id, scope) {
330
335
  if (sandbox.pod) {
331
336
  const epoch = sandbox.pod.hostEpoch + 1;
332
337
  await t.updateSandboxPod(id, { desired: "deleted", observed: "deleting", rev: sandbox.pod.rev + 1, hostEpoch: epoch }, new Date().toISOString());
333
- reconcileAfter(ctx, t, id);
334
- t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
338
+ await reconcileAfter(t, id);
339
+ await revokeHost(t, id, epoch);
335
340
  }
336
341
  else
337
342
  await t.deleteSandboxResource(id);
@@ -364,8 +369,8 @@ export async function stopSandbox(ctx, id, scope) {
364
369
  const epoch = pod.hostEpoch + 1;
365
370
  await t.updateSandboxPod(id, { desired: "suspended", rev: pod.rev + 1, hostEpoch: epoch }, now);
366
371
  await t.sandboxEvent(id, "sandbox.suspended", { reason: "stop" });
367
- reconcileAfter(ctx, t, id);
368
- t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
372
+ await reconcileAfter(t, id);
373
+ await revokeHost(t, id, epoch);
369
374
  }
370
375
  return viewOf(ctx, t, (await t.sandboxResource(id)) ?? sandbox, ownerOf(scope));
371
376
  });
@@ -408,8 +413,8 @@ export async function resetSandbox(ctx, id, scope) {
408
413
  reason: null,
409
414
  }, now);
410
415
  await t.sandboxEvent(id, "sandbox.reset", { volumeGeneration: volumeGen });
411
- reconcileAfter(ctx, t, id);
412
- t.afterCommit(() => ctx.harness.revokeHost(id, epoch));
416
+ await reconcileAfter(t, id);
417
+ await revokeHost(t, id, epoch);
413
418
  return viewOf(ctx, t, (await t.sandboxResource(id)) ?? sandbox, ownerOf(scope));
414
419
  });
415
420
  }
@@ -480,7 +485,7 @@ export async function checkSandboxTurn(t, session, scope, ctx) {
480
485
  const now = new Date().toISOString();
481
486
  if (pod.desired !== "running" || pod.observed === "failed") {
482
487
  await wantRunning(t, sandbox, now);
483
- reconcileAfter(ctx, t, id);
488
+ await reconcileAfter(t, id);
484
489
  }
485
490
  else
486
491
  await t.updateSandboxPod(id, { lastActiveAt: now }, now);
@@ -505,16 +510,15 @@ export async function detachAllSessions(t) {
505
510
  */
506
511
  export async function touchPodSandbox(ctx, id) {
507
512
  try {
508
- const at = await ctx.store.tx(async (t) => {
513
+ await ctx.store.tx(async (t) => {
509
514
  const sandbox = await t.sandboxResource(id, { lock: true });
510
515
  if (!sandbox?.pod || sandbox.pod.desired !== "running")
511
- return undefined;
516
+ return;
512
517
  const now = new Date();
513
518
  await t.updateSandboxPod(id, { lastActiveAt: now.toISOString() }, now.toISOString());
514
- return now.getTime() + podLifecycleConfig(await readSandboxConfig(t)).idleMs;
519
+ const at = now.getTime() + podLifecycleConfig(await readSandboxConfig(t)).idleMs;
520
+ await t.signalSandbox(id, { kind: "arm", timer: "idle", at });
515
521
  });
516
- if (at !== undefined)
517
- await ctx.sandboxSignal(id, { kind: "arm", timer: "idle", at });
518
522
  }
519
523
  catch (error) {
520
524
  ctx.config.logger.warn("sandbox idle timer not armed", {
@@ -5,8 +5,15 @@ export interface WorkState {
5
5
  readonly running: Map<string, AbortController>;
6
6
  /** The turn each running advance belongs to, by session id (`null`: none). */
7
7
  readonly runningTurns: Map<string, string | null>;
8
+ /** Called once when the last running advance ends (`endAdvance`), then forgotten. */
9
+ readonly idleWaiters: Set<() => void>;
8
10
  }
9
11
  export declare function createWorkState(): WorkState;
12
+ /**
13
+ * Advance `id`'s end on this process: forgets its controller (unless a later advance of the
14
+ * session replaced it) and, when it was the last, settles every `idle` wait.
15
+ */
16
+ export declare function endAdvance(work: WorkState, id: string, controller: AbortController): void;
10
17
  /**
11
18
  * Abort the advance of `id` if it runs on this process.
12
19
  * Cancel calls it after committing `cancelled`, and so does the control bus for cancels made
@@ -10,10 +10,45 @@
10
10
  * advance instead of settling it.
11
11
  */
12
12
  import { randomUUID } from "node:crypto";
13
+ import { sleep } from "../retry.js";
13
14
  import { command } from "./commands.js";
14
15
  import { AdvanceAbort } from "./worker.js";
15
16
  export function createWorkState() {
16
- return { running: new Map(), runningTurns: new Map() };
17
+ return { running: new Map(), runningTurns: new Map(), idleWaiters: new Set() };
18
+ }
19
+ /**
20
+ * Advance `id`'s end on this process: forgets its controller (unless a later advance of the
21
+ * session replaced it) and, when it was the last, settles every `idle` wait.
22
+ */
23
+ export function endAdvance(work, id, controller) {
24
+ if (work.running.get(id) !== controller)
25
+ return;
26
+ work.running.delete(id);
27
+ work.runningTurns.delete(id);
28
+ if (work.running.size > 0)
29
+ return;
30
+ for (const waiter of work.idleWaiters)
31
+ waiter();
32
+ work.idleWaiters.clear();
33
+ }
34
+ /** Resolves once no advance runs on this process, or after `timeoutMs`, whichever is first. */
35
+ async function idle(work, timeoutMs) {
36
+ if (work.running.size === 0)
37
+ return;
38
+ const timer = new AbortController();
39
+ let settle;
40
+ const ended = new Promise((resolve) => (settle = resolve));
41
+ work.idleWaiters.add(settle);
42
+ try {
43
+ await Promise.race([
44
+ ended,
45
+ sleep(Math.max(0, timeoutMs), timer.signal),
46
+ ]);
47
+ }
48
+ finally {
49
+ timer.abort();
50
+ work.idleWaiters.delete(settle);
51
+ }
17
52
  }
18
53
  const MESSAGES = {
19
54
  cancel: "Turn cancelled",
@@ -56,9 +91,7 @@ export async function drain(ctx, activeWork, timeoutMs) {
56
91
  principalId: "drain",
57
92
  });
58
93
  }
59
- const deadline = Date.now() + timeoutMs;
60
- while (ctx.work.running.size > 0 && Date.now() < deadline)
61
- await new Promise((resolve) => setTimeout(resolve, 10));
94
+ await idle(ctx.work, timeoutMs);
62
95
  }
63
96
  /** Abort every advance running on this process; closing the Tenant is a `shutdown`. */
64
97
  export function abortAll(ctx, kind = "shutdown") {
@@ -72,9 +105,7 @@ export function abortAll(ctx, kind = "shutdown") {
72
105
  * (§11.4); the epoch fences anything they still try to write. Returns their session ids.
73
106
  */
74
107
  export async function waitForIdle(ctx, timeoutMs) {
75
- const deadline = Date.now() + timeoutMs;
76
- while (ctx.work.running.size > 0 && Date.now() < deadline)
77
- await new Promise((resolve) => setTimeout(resolve, 10));
108
+ await idle(ctx.work, timeoutMs);
78
109
  const abandoned = [...ctx.work.running.keys()];
79
110
  if (abandoned.length > 0)
80
111
  ctx.config.logger.warn("tenant closed with advances still running", {
@@ -120,5 +120,4 @@ export declare function checkSessionStreams(ctx: TenantContext): Promise<void>;
120
120
  export declare function clearObservers(hub: SessionStreams): void;
121
121
  /** Close: end every session observer. */
122
122
  export declare function endAllStreams(hub: SessionStreams): void;
123
- export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
124
123
  export {};
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { historyCursor, historyResume } from "../reads/cursor.js";
22
22
  import { decodeCursor, encodeCursor } from "../record/index.js";
23
+ import { sleep } from "../retry.js";
23
24
  import { basinOf } from "../streams/basin.js";
24
25
  import { sessionStream, } from "../streams/types.js";
25
26
  import { fail } from "./http.js";
@@ -405,16 +406,3 @@ export function clearObservers(hub) {
405
406
  export function endAllStreams(hub) {
406
407
  clearObservers(hub);
407
408
  }
408
- export function sleep(ms, signal) {
409
- return new Promise((resolve) => {
410
- if (signal.aborted)
411
- return resolve();
412
- const done = () => {
413
- clearTimeout(timer);
414
- signal.removeEventListener("abort", done);
415
- resolve();
416
- };
417
- const timer = setTimeout(done, ms);
418
- signal.addEventListener("abort", done, { once: true });
419
- });
420
- }
@@ -136,7 +136,12 @@ export async function putSession(ctx, id, body, access, options = {}) {
136
136
  return prior;
137
137
  }
138
138
  await ctx.vault.assertAttachment(t, body.ownerUserId, vaultIds, credentialSelections, { opaque });
139
+ const environment = await ctx.vault.sessionEnvironment(t, vaultIds);
140
+ if (environment.conflict)
141
+ fail(409, environment.conflict, { code: "credential_conflict" });
139
142
  if (prior) {
143
+ if (prior.sandboxId !== undefined)
144
+ await assertSandboxSecrets(ctx, t, prior.sandboxId, id, body.ownerUserId, environment.secretHosts);
140
145
  if (sessionIdentity(prior.creation) !== sessionIdentity(body))
141
146
  fail(409, "Session already exists with different creation parameters");
142
147
  prior.vaultIds = vaultIds;
@@ -156,6 +161,8 @@ export async function putSession(ctx, id, body, access, options = {}) {
156
161
  checkPlacement(await readSandboxConfig(t), sandbox.kind);
157
162
  if (sandbox.kind === "pod")
158
163
  await requirePods(ctx.pods, `Sandbox ${sandbox.sandboxId}`);
164
+ if (sandbox.sandboxId !== undefined)
165
+ await assertSandboxSecrets(ctx, t, sandbox.sandboxId, id, body.ownerUserId, environment.secretHosts);
159
166
  const pinned = sandbox.manifest === undefined ? platformTools(definition) : sandbox;
160
167
  const created = {
161
168
  id,
@@ -186,6 +193,25 @@ export async function putSession(ctx, id, body, access, options = {}) {
186
193
  return created;
187
194
  });
188
195
  }
196
+ /**
197
+ * A sandbox's shell secrets must be unambiguous (R2c, D50): egress-gate sets the header from the
198
+ * one `environment_secret` bound to a host among the sessions using the sandbox. So while any of
199
+ * them has secrets, they all have one owner, and no host is bound to two credentials.
200
+ */
201
+ async function assertSandboxSecrets(ctx, t, sandboxId, sessionId, ownerUserId, secretHosts) {
202
+ const conflict = (message) => fail(409, message, { code: "credential_conflict" });
203
+ const mine = Object.keys(secretHosts).length > 0;
204
+ for (const other of await t.sessionsOnSandbox(sandboxId)) {
205
+ if (other.id === sessionId)
206
+ continue;
207
+ const theirs = (await ctx.vault.sessionEnvironment(t, other.vaultIds ?? [])).secretHosts;
208
+ if (other.ownerUserId !== ownerUserId && (mine || Object.keys(theirs).length > 0))
209
+ conflict(`Sandbox ${sandboxId} serves environment_secret credentials of one owner at a time`);
210
+ for (const [host, credentialId] of Object.entries(secretHosts))
211
+ if (theirs[host] !== undefined && theirs[host] !== credentialId)
212
+ conflict(`Two environment_secret credentials would be bound to ${host} in sandbox ${sandboxId}`);
213
+ }
214
+ }
189
215
  function isShare(value) {
190
216
  return typeof value === "object" && value !== null && "session" in value;
191
217
  }
@@ -1,6 +1,7 @@
1
1
  import { basinOf } from "../streams/basin.js";
2
2
  import { createStreamRelay, } from "../streams/relay/core.js";
3
- import { checkSessionStreams, currentBasin, sleep, } from "./session-streams.js";
3
+ import { retry } from "../retry.js";
4
+ import { checkSessionStreams, currentBasin } from "./session-streams.js";
4
5
  const RETRY_MIN_MS = 100;
5
6
  const RETRY_MAX_MS = 2000;
6
7
  /** The longest wait between two `ensureTenant` attempts while the basin is being repaired. */
@@ -92,7 +93,11 @@ export async function wireStreams(ctx, options) {
92
93
  /** Deletes a retired generation's basin, then forgets it. A failure retries on next open. */
93
94
  async function deleteRetired(generation) {
94
95
  try {
95
- await withRetries(() => streams.deleteTenant(basinOf(tenantId, generation)), 5);
96
+ await retry(() => streams.deleteTenant(basinOf(tenantId, generation)), {
97
+ minMs: RETRY_MIN_MS,
98
+ maxMs: RETRY_MAX_MS,
99
+ attempts: 5,
100
+ });
96
101
  await store.tx((t) => t.forgetRetiredGeneration(generation));
97
102
  logger.info("retired stream basin deleted", { generation });
98
103
  }
@@ -176,21 +181,6 @@ async function probe(streams, timeoutMs) {
176
181
  export async function closeStreams(ctx) {
177
182
  await ctx.sessionStreams.wiring?.close();
178
183
  }
179
- // ---------------------------------------------------------------------------
180
- async function withRetries(task, attempts) {
181
- let delay = RETRY_MIN_MS;
182
- for (let attempt = 1;; attempt += 1) {
183
- try {
184
- return await task();
185
- }
186
- catch (error) {
187
- if (attempt >= attempts)
188
- throw error;
189
- await new Promise((resolve) => setTimeout(resolve, delay));
190
- delay = Math.min(delay * 2, RETRY_MAX_MS);
191
- }
192
- }
193
- }
194
184
  function basinKeeper(streams, basinKey, signal, onError) {
195
185
  let ready = false;
196
186
  let readyFor;
@@ -230,20 +220,15 @@ function basinKeeper(streams, basinKey, signal, onError) {
230
220
  repairing = true;
231
221
  ready = false;
232
222
  void (async () => {
233
- let delay = RETRY_MIN_MS;
234
- while (!signal.aborted) {
235
- try {
236
- await ensure();
237
- repairedAt = Date.now();
238
- break;
239
- }
240
- catch (error) {
223
+ await retry(ensure, {
224
+ minMs: RETRY_MIN_MS,
225
+ maxMs: BASIN_RETRY_MAX_MS,
226
+ signal,
227
+ onError: (error) => {
241
228
  if (failures === 1 || failures % 10 === 0)
242
229
  onError(error);
243
- }
244
- await sleep(delay, signal);
245
- delay = Math.min(delay * 2, BASIN_RETRY_MAX_MS);
246
- }
230
+ },
231
+ }).then(() => (repairedAt = Date.now()), () => undefined);
247
232
  repairing = false;
248
233
  })();
249
234
  },
@@ -1,20 +1,47 @@
1
+ import type { PendingRequest } from "../store/types.js";
1
2
  import type { TenantContext } from "./context.js";
2
3
  /**
3
- * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
4
- * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
5
- * twice is harmless either way (the idempotency key).
4
+ * How old an outbox row is before the sweep delivers it, unless its delivery after commit
5
+ * already failed (then it is due at once): longer than that delivery can take with its
6
+ * retries, so the sweep does not send again, and double the load on, an execution that is
7
+ * only slow. Sending one twice is harmless either way (the idempotency key).
8
+ */
9
+ export declare const OUTBOX_GRACE_MS = 30000;
10
+ /**
11
+ * How often the orphan scan, and the stale pod sandbox check, run after the first pass of a
12
+ * Tenant opened on this process.
6
13
  */
7
- export declare const WAKE_GRACE_MS = 2000;
8
- /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
9
14
  export declare const ORPHAN_SCAN_MS = 60000;
15
+ /**
16
+ * How long one pass may spend draining the outbox, and paging through orphaned sessions.
17
+ * A backlog is worked off over several passes rather than holding one (and with it the
18
+ * Tenant's sweep object in the execution) for long.
19
+ */
20
+ export declare const DRAIN_BUDGET_MS = 2000;
10
21
  export declare function sweep(ctx: TenantContext): Promise<void>;
22
+ /** Failed deliveries after which the sweep parks an outbox row (`Tx.failOutbox`). */
23
+ export declare const MAX_OUTBOX_ATTEMPTS = 10;
11
24
  /**
12
- * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
13
- * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
14
- * rows delivered.
25
+ * Deliver the outbox rows that are due (`Tx.pendingOutbox`: older than `graceMs`, or whose
26
+ * delivery failed), oldest first, batch after batch until none is left or `budgetMs` is
27
+ * spent, and delete the delivered ones. Each row is delivered on its own: one that fails
28
+ * (Restate refuses it, or a Runtime that does not know a wake's reason) is tried again later,
29
+ * with backoff, so it never holds back the rows behind it, and after `MAX_OUTBOX_ATTEMPTS`
30
+ * failures it is parked: logged once, at error, and kept in the outbox for an operator. A
31
+ * parked wake's session is still re-woken by the orphan scan while it is runnable, and a
32
+ * parked sandbox signal's pod by the stale pod check. A row the Tenant declines (it is
33
+ * closing) stays as it is, and ends the drain. Never throws for a delivery; returns the rows
34
+ * delivered.
15
35
  */
16
- export declare function deliverPendingWakes(ctx: Pick<TenantContext, "store" | "wake">, before?: Date): Promise<string[]>;
36
+ export declare function deliverOutbox(ctx: Pick<TenantContext, "store" | "send" | "config">, graceMs?: number, budgetMs?: number): Promise<PendingRequest[]>;
17
37
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
18
- export declare function reconcileLinkedAgents(ctx: Pick<TenantContext, "store" | "wake">): Promise<void>;
19
- /** Wake `running`/`runnable` sessions without a live owner. Returns the ids woken. */
20
- export declare function wakeOrphanedSessions(ctx: Pick<TenantContext, "store" | "wake">, now?: Date): Promise<string[]>;
38
+ export declare function reconcileLinkedAgents(ctx: Pick<TenantContext, "store">): Promise<void>;
39
+ /**
40
+ * Wake `running`/`runnable` sessions without a live owner, page after page from the first id
41
+ * after `after`, until none is left or `budgetMs` is spent. Returns the ids woken, and the last
42
+ * id when the budget ran out first (`next`, where the next scan goes on).
43
+ */
44
+ export declare function wakeOrphanedSessions(ctx: Pick<TenantContext, "store" | "wake">, now?: Date, after?: string, budgetMs?: number): Promise<{
45
+ woken: string[];
46
+ next?: string;
47
+ }>;