@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.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -3
- package/dist/adapters/execution/restate.d.ts +15 -3
- package/dist/adapters/execution/restate.js +77 -83
- package/dist/adapters/sandbox/local.js +1 -1
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.js +1 -13
- package/dist/api/components.d.ts +42 -2
- package/dist/core/flow-host.d.ts +1 -19
- package/dist/core/flow-host.js +8 -37
- package/dist/execution/types.d.ts +10 -8
- package/dist/execution/types.js +5 -7
- package/dist/gates/egress-credentials.d.ts +48 -0
- package/dist/gates/egress-credentials.js +147 -0
- package/dist/gates/egress.d.ts +3 -0
- package/dist/gates/egress.js +14 -1
- package/dist/gates/tenant-vaults.d.ts +4 -0
- package/dist/gates/tenant-vaults.js +2 -0
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +1 -0
- package/dist/harness/executors.js +1 -0
- package/dist/harness/main.js +4 -1
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +36 -1
- package/dist/harness-api/start.d.ts +2 -0
- package/dist/harness-api/start.js +4 -2
- package/dist/harness-api/ws-server.js +2 -2
- package/dist/host/execution.d.ts +8 -6
- package/dist/host/gates.js +11 -0
- package/dist/host/main.js +10 -16
- package/dist/host/stack-config.d.ts +6 -0
- package/dist/host/stack-config.js +6 -0
- package/dist/infra/execution.d.ts +18 -1
- package/dist/infra/execution.js +28 -4
- package/dist/infra/index.d.ts +1 -1
- package/dist/keys/egress-ca.d.ts +15 -0
- package/dist/keys/egress-ca.js +66 -0
- package/dist/keys/keys.d.ts +8 -1
- package/dist/keys/keys.js +6 -0
- package/dist/keys/x509.d.ts +29 -0
- package/dist/keys/x509.js +160 -0
- package/dist/management-openapi.json +195 -9
- package/dist/openapi.json +2 -1
- package/dist/retry.d.ts +21 -0
- package/dist/retry.js +34 -0
- package/dist/sandbox/join.d.ts +2 -0
- package/dist/sandbox/join.js +14 -10
- package/dist/sandbox/manager.d.ts +2 -0
- package/dist/sandbox/manager.js +1 -1
- package/dist/sandbox/pods/reconcile.js +3 -3
- package/dist/sandbox/tools.d.ts +3 -1
- package/dist/sandbox/tools.js +4 -2
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/store/postgres/control.js +8 -1
- package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
- package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +265 -4
- package/dist/store/postgres/schema.js +35 -4
- package/dist/store/postgres/store.js +165 -52
- package/dist/store/types.d.ts +96 -39
- package/dist/streams/relay/core.js +1 -13
- package/dist/tenant/advance.js +6 -7
- package/dist/tenant/commands.js +1 -2
- package/dist/tenant/context.d.ts +19 -11
- package/dist/tenant/control.d.ts +8 -1
- package/dist/tenant/control.js +2 -0
- package/dist/tenant/runtime.js +23 -3
- package/dist/tenant/sandboxes.d.ts +2 -1
- package/dist/tenant/sandboxes.js +23 -19
- package/dist/tenant/scheduler.d.ts +7 -0
- package/dist/tenant/scheduler.js +38 -7
- package/dist/tenant/session-streams.d.ts +0 -1
- package/dist/tenant/session-streams.js +1 -13
- package/dist/tenant/sessions.js +26 -0
- package/dist/tenant/streams.js +14 -29
- package/dist/tenant/sweep.d.ts +39 -12
- package/dist/tenant/sweep.js +157 -58
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/vault/service.d.ts +34 -0
- package/dist/vault/service.js +148 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
package/dist/tenant/context.d.ts
CHANGED
|
@@ -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 }
|
|
9
|
-
*
|
|
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
|
|
216
|
-
* `
|
|
217
|
-
*
|
|
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`)
|
|
234
|
-
*
|
|
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<
|
|
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`). */
|
package/dist/tenant/control.d.ts
CHANGED
|
@@ -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,
|
|
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 {
|
package/dist/tenant/control.js
CHANGED
|
@@ -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
|
}, {
|
package/dist/tenant/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/tenant/sandboxes.js
CHANGED
|
@@ -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
|
|
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(
|
|
166
|
-
t.
|
|
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(
|
|
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(
|
|
286
|
+
await reconcileAfter(t, id);
|
|
282
287
|
const idleAt = Date.parse(now) + podLifecycleConfig(config).idleMs;
|
|
283
|
-
t.
|
|
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(
|
|
334
|
-
|
|
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(
|
|
368
|
-
|
|
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(
|
|
412
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
516
|
+
return;
|
|
512
517
|
const now = new Date();
|
|
513
518
|
await t.updateSandboxPod(id, { lastActiveAt: now.toISOString() }, now.toISOString());
|
|
514
|
-
|
|
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
|
package/dist/tenant/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/dist/tenant/sessions.js
CHANGED
|
@@ -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
|
}
|
package/dist/tenant/streams.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basinOf } from "../streams/basin.js";
|
|
2
2
|
import { createStreamRelay, } from "../streams/relay/core.js";
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/tenant/sweep.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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"
|
|
19
|
-
/**
|
|
20
|
-
|
|
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
|
+
}>;
|