@nylorun/runtime 0.21.0-beta → 0.22.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 (66) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -2
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/adapters/sandbox/local.js +1 -1
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/api/components.d.ts +42 -2
  7. package/dist/core/flow-host.d.ts +2 -3
  8. package/dist/core/flow-host.js +10 -7
  9. package/dist/execution/types.d.ts +13 -5
  10. package/dist/execution/types.js +13 -5
  11. package/dist/gates/egress-credentials.d.ts +48 -0
  12. package/dist/gates/egress-credentials.js +147 -0
  13. package/dist/gates/egress.d.ts +3 -0
  14. package/dist/gates/egress.js +14 -1
  15. package/dist/gates/tenant-vaults.d.ts +4 -0
  16. package/dist/gates/tenant-vaults.js +2 -0
  17. package/dist/harness/calls.d.ts +2 -0
  18. package/dist/harness/calls.js +1 -0
  19. package/dist/harness/executors.js +1 -0
  20. package/dist/harness/main.js +4 -1
  21. package/dist/harness/pod.d.ts +2 -0
  22. package/dist/harness/pod.js +36 -1
  23. package/dist/harness-api/start.d.ts +2 -0
  24. package/dist/harness-api/start.js +4 -2
  25. package/dist/harness-api/ws-server.js +2 -2
  26. package/dist/host/create-host.d.ts +1 -1
  27. package/dist/host/create-host.js +2 -1
  28. package/dist/host/gates.js +11 -0
  29. package/dist/host/http.d.ts +0 -1
  30. package/dist/host/http.js +0 -6
  31. package/dist/host/stack-config.d.ts +2 -0
  32. package/dist/host/stack-config.js +42 -73
  33. package/dist/keys/egress-ca.d.ts +15 -0
  34. package/dist/keys/egress-ca.js +66 -0
  35. package/dist/keys/keys.d.ts +8 -1
  36. package/dist/keys/keys.js +6 -0
  37. package/dist/keys/x509.d.ts +29 -0
  38. package/dist/keys/x509.js +160 -0
  39. package/dist/management-openapi.json +195 -9
  40. package/dist/openapi.json +2 -1
  41. package/dist/sandbox/join.d.ts +2 -0
  42. package/dist/sandbox/join.js +8 -3
  43. package/dist/sandbox/manager.d.ts +2 -0
  44. package/dist/sandbox/manager.js +1 -1
  45. package/dist/sandbox/tools.d.ts +3 -1
  46. package/dist/sandbox/tools.js +4 -2
  47. package/dist/sandbox/types.d.ts +5 -0
  48. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  49. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  50. package/dist/store/postgres/schema.d.ts +105 -2
  51. package/dist/store/postgres/schema.js +17 -1
  52. package/dist/store/postgres/store.js +54 -4
  53. package/dist/store/types.d.ts +46 -5
  54. package/dist/tenant/advance.js +1 -4
  55. package/dist/tenant/commands.js +5 -5
  56. package/dist/tenant/context.d.ts +10 -7
  57. package/dist/tenant/runtime.js +2 -1
  58. package/dist/tenant/sessions.js +26 -0
  59. package/dist/tenant/sweep.d.ts +14 -0
  60. package/dist/tenant/sweep.js +69 -10
  61. package/dist/tenant/transcript.d.ts +8 -8
  62. package/dist/vault/service.d.ts +34 -0
  63. package/dist/vault/service.js +148 -15
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +3 -3
@@ -1048,14 +1048,14 @@ export declare const vaultCredentials: import("drizzle-orm/pg-core").PgTableWith
1048
1048
  tableName: "vault_credentials";
1049
1049
  dataType: "string";
1050
1050
  columnType: "PgText";
1051
- data: "bearer" | "headers" | "model";
1051
+ data: "bearer" | "environment_secret" | "environment_variable" | "headers" | "model";
1052
1052
  driverParam: string;
1053
1053
  notNull: true;
1054
1054
  hasDefault: false;
1055
1055
  isPrimaryKey: false;
1056
1056
  isAutoincrement: false;
1057
1057
  hasRuntimeDefault: false;
1058
- enumValues: ["bearer", "headers", "model"];
1058
+ enumValues: ["bearer", "headers", "environment_secret", "environment_variable", "model"];
1059
1059
  baseColumn: never;
1060
1060
  identity: undefined;
1061
1061
  generated: undefined;
@@ -3340,6 +3340,109 @@ export declare const controlSignals: import("drizzle-orm/pg-core").PgTableWithCo
3340
3340
  };
3341
3341
  dialect: 'pg';
3342
3342
  }>;
3343
+ /**
3344
+ * The wake outbox (architecture §12.3): one row per wake a committed transaction asked for
3345
+ * (`Tx.wake`), written in that transaction, so a commit never loses its wake. The row is
3346
+ * deleted once Durable Session Execution has accepted the wake; until then the Tenant sweep
3347
+ * delivers it again, with the same idempotency key (`dedupe_key`, or `wake:<id>` without
3348
+ * one). `created_at` is the insert's own clock (`clock_timestamp()`).
3349
+ */
3350
+ export declare const wakes: import("drizzle-orm/pg-core").PgTableWithColumns<{
3351
+ name: "wakes";
3352
+ schema: "nylorun";
3353
+ columns: {
3354
+ id: import("drizzle-orm/pg-core").PgColumn<{
3355
+ name: "id";
3356
+ tableName: "wakes";
3357
+ dataType: "custom";
3358
+ columnType: "PgCustomColumn";
3359
+ data: string;
3360
+ driverParam: string;
3361
+ notNull: true;
3362
+ hasDefault: false;
3363
+ isPrimaryKey: true;
3364
+ isAutoincrement: false;
3365
+ hasRuntimeDefault: false;
3366
+ enumValues: undefined;
3367
+ baseColumn: never;
3368
+ identity: undefined;
3369
+ generated: undefined;
3370
+ }, {}, {
3371
+ pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
3372
+ }>;
3373
+ sessionId: import("drizzle-orm/pg-core").PgColumn<{
3374
+ name: "sessionId";
3375
+ tableName: "wakes";
3376
+ dataType: "custom";
3377
+ columnType: "PgCustomColumn";
3378
+ data: string;
3379
+ driverParam: string;
3380
+ notNull: true;
3381
+ hasDefault: false;
3382
+ isPrimaryKey: false;
3383
+ isAutoincrement: false;
3384
+ hasRuntimeDefault: false;
3385
+ enumValues: undefined;
3386
+ baseColumn: never;
3387
+ identity: undefined;
3388
+ generated: undefined;
3389
+ }, {}, {
3390
+ pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
3391
+ }>;
3392
+ reason: import("drizzle-orm/pg-core").PgColumn<{
3393
+ name: "reason";
3394
+ tableName: "wakes";
3395
+ dataType: "string";
3396
+ columnType: "PgText";
3397
+ data: string;
3398
+ driverParam: string;
3399
+ notNull: true;
3400
+ hasDefault: false;
3401
+ isPrimaryKey: false;
3402
+ isAutoincrement: false;
3403
+ hasRuntimeDefault: false;
3404
+ enumValues: [string, ...string[]];
3405
+ baseColumn: never;
3406
+ identity: undefined;
3407
+ generated: undefined;
3408
+ }, {}, {}>;
3409
+ dedupeKey: import("drizzle-orm/pg-core").PgColumn<{
3410
+ name: "dedupeKey";
3411
+ tableName: "wakes";
3412
+ dataType: "string";
3413
+ columnType: "PgText";
3414
+ data: string;
3415
+ driverParam: string;
3416
+ notNull: false;
3417
+ hasDefault: false;
3418
+ isPrimaryKey: false;
3419
+ isAutoincrement: false;
3420
+ hasRuntimeDefault: false;
3421
+ enumValues: [string, ...string[]];
3422
+ baseColumn: never;
3423
+ identity: undefined;
3424
+ generated: undefined;
3425
+ }, {}, {}>;
3426
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
3427
+ name: "createdAt";
3428
+ tableName: "wakes";
3429
+ dataType: "date";
3430
+ columnType: "PgTimestamp";
3431
+ data: Date;
3432
+ driverParam: string;
3433
+ notNull: true;
3434
+ hasDefault: true;
3435
+ isPrimaryKey: false;
3436
+ isAutoincrement: false;
3437
+ hasRuntimeDefault: false;
3438
+ enumValues: undefined;
3439
+ baseColumn: never;
3440
+ identity: undefined;
3441
+ generated: undefined;
3442
+ }, {}, {}>;
3443
+ };
3444
+ dialect: 'pg';
3445
+ }>;
3343
3446
  /**
3344
3447
  * Each sandbox's lifecycle stream (`nylorun.sandbox-event/1`), numbered from 0 per sandbox id.
3345
3448
  * Written only by the record module; not published to the stream relay.
@@ -129,7 +129,7 @@ export const vaultCredentials = nylorun.table("vault_credentials", {
129
129
  id: textC().primaryKey(),
130
130
  vaultId: text().notNull(),
131
131
  name: text().notNull(),
132
- type: text({ enum: ["bearer", "headers", "model"] }).notNull(),
132
+ type: text({ enum: ["bearer", "headers", "environment_secret", "environment_variable", "model"] }).notNull(),
133
133
  /** JSON text of the non-secret binding: url (and via, identity, header names), or provider and model. */
134
134
  bindingJson: text().notNull(),
135
135
  expiresAt: text(),
@@ -421,6 +421,22 @@ export const controlSignals = nylorun.table("control_signals", {
421
421
  index("control_signals_created_at").on(t.createdAt),
422
422
  check("control_signals_kind_check", sql `${t.kind} IN ('session.cancel', 'sessions.reset')`),
423
423
  ]);
424
+ /**
425
+ * The wake outbox (architecture §12.3): one row per wake a committed transaction asked for
426
+ * (`Tx.wake`), written in that transaction, so a commit never loses its wake. The row is
427
+ * deleted once Durable Session Execution has accepted the wake; until then the Tenant sweep
428
+ * delivers it again, with the same idempotency key (`dedupe_key`, or `wake:<id>` without
429
+ * one). `created_at` is the insert's own clock (`clock_timestamp()`).
430
+ */
431
+ export const wakes = nylorun.table("wakes", {
432
+ id: textC().primaryKey(),
433
+ sessionId: textC().notNull(),
434
+ reason: text().notNull(),
435
+ dedupeKey: text(),
436
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
437
+ .notNull()
438
+ .default(sql `clock_timestamp()`),
439
+ }, (t) => [index("wakes_created_at").on(t.createdAt)]);
424
440
  // ---------------------------------------------------------------------------
425
441
  // nylorun_streams: the record
426
442
  /**
@@ -49,6 +49,7 @@
49
49
  * `timestamptz`.
50
50
  */
51
51
  import { AsyncLocalStorage } from "node:async_hooks";
52
+ import { randomUUID } from "node:crypto";
52
53
  import { and, count, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
53
54
  import { appendEvent, appendSandboxEvent } from "../../record/index.js";
54
55
  import { OwnershipLostError, PrincipalRoleConflict } from "../ownership.js";
@@ -57,7 +58,7 @@ import { database, driverError } from "./db.js";
57
58
  import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
58
59
  import { createPostgresRecordReader } from "./record.js";
59
60
  import { postgresRecordWriter, postgresSandboxRecordWriter } from "./record-writer.js";
60
- import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, definitions, effects, links, modelBudgets, modelUsage, principals, sandboxes, sandboxEvents, sandboxResources, sessionEvents, sessionLogHeads, sessions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, vaultAudit, vaultCredentials, vaultIdempotency, vaults, } from "./schema.js";
61
+ import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, definitions, effects, links, modelBudgets, modelUsage, principals, sandboxes, sandboxEvents, sandboxResources, sessionEvents, sessionLogHeads, sessions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, vaultAudit, vaultCredentials, vaultIdempotency, vaults, wakes, } from "./schema.js";
61
62
  export function createPostgresSessionStore(options) {
62
63
  return new PostgresSessionStore(options);
63
64
  }
@@ -110,7 +111,7 @@ class PostgresSessionStore {
110
111
  let t;
111
112
  // Wrapped so `begin` does not treat an array result as queries to await.
112
113
  const run = this.db.transaction(async (db) => {
113
- t = new PostgresTx(db, this.tenantId, this.now);
114
+ t = new PostgresTx(db, this.tenantId, this.now, this.deleteWake);
114
115
  try {
115
116
  return { value: await this.active.run(this, () => fn(t)) };
116
117
  }
@@ -153,6 +154,10 @@ class PostgresSessionStore {
153
154
  }
154
155
  return result.value;
155
156
  }
157
+ /** Deletes a delivered outbox request, outside any transaction (`Tx.wake`). */
158
+ deleteWake = async (id) => {
159
+ await this.db.delete(wakes).where(eq(wakes.id, id));
160
+ };
156
161
  onCommit(listener) {
157
162
  this.listeners.add(listener);
158
163
  return () => this.listeners.delete(listener);
@@ -256,21 +261,27 @@ const SIGNING_KEY_STAMP = {
256
261
  };
257
262
  /** The audit columns a row has (`ord` only orders them). */
258
263
  const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
259
- const SESSION_TABLES = [sessions, commands, effects, links];
264
+ const SESSION_TABLES = [sessions, commands, effects, links, wakes];
265
+ /** An outbox row as it is delivered: the wake's own dedupe key, or one made from the row id. */
266
+ function outboxWake(id, reason, dedupeKey) {
267
+ return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
268
+ }
260
269
  /** The inserted row's `column` (`ON CONFLICT … DO UPDATE`). */
261
270
  const excluded = (column) => sql.raw(`excluded.${column}`);
262
271
  class PostgresTx {
263
272
  db;
264
273
  tenantId;
265
274
  now;
275
+ deleteWake;
266
276
  closed = false;
267
277
  events = [];
268
278
  generations = [];
269
279
  callbacks = [];
270
- constructor(db, tenantId, now) {
280
+ constructor(db, tenantId, now, deleteWake) {
271
281
  this.db = db;
272
282
  this.tenantId = tenantId;
273
283
  this.now = now;
284
+ this.deleteWake = deleteWake;
274
285
  }
275
286
  check() {
276
287
  if (this.closed)
@@ -345,6 +356,45 @@ class PostgresTx {
345
356
  this.check();
346
357
  this.callbacks.push(fn);
347
358
  }
359
+ // --- wake outbox ---------------------------------------------------------
360
+ async wake(sessionId, wake, deliver) {
361
+ this.check();
362
+ const id = randomUUID();
363
+ await this.db.insert(wakes).values({
364
+ id,
365
+ sessionId,
366
+ reason: wake.reason,
367
+ dedupeKey: wake.dedupeKey ?? null,
368
+ });
369
+ const delivered = outboxWake(id, wake.reason, wake.dedupeKey ?? null);
370
+ // A rejection goes to the store's error hook (as every callback's), and the row stays.
371
+ this.callbacks.push(async () => {
372
+ if ((await deliver(sessionId, delivered)) === false)
373
+ return;
374
+ await this.deleteWake(id);
375
+ });
376
+ }
377
+ async pendingWakes(before, limit) {
378
+ this.check();
379
+ const rows = await this.db
380
+ .select()
381
+ .from(wakes)
382
+ .where(lt(wakes.createdAt, before))
383
+ .orderBy(wakes.createdAt, wakes.id)
384
+ .limit(limit);
385
+ return rows.map((row) => ({
386
+ id: row.id,
387
+ sessionId: row.sessionId,
388
+ wake: outboxWake(row.id, row.reason, row.dedupeKey),
389
+ createdAt: row.createdAt.toISOString(),
390
+ }));
391
+ }
392
+ async deleteWakes(ids) {
393
+ this.check();
394
+ if (ids.length === 0)
395
+ return;
396
+ await this.db.delete(wakes).where(inArray(wakes.id, [...ids]));
397
+ }
348
398
  // --- ownership -----------------------------------------------------------
349
399
  async takeOwnership(sessionId, claim) {
350
400
  this.check();
@@ -25,16 +25,18 @@
25
25
  * generation, `streams/basin.ts`), and the cursor is
26
26
  * `base64url("<sessionId>:<seq>")` (see `record/cursor.ts`).
27
27
  * 4. **No external I/O inside `fn`.** No model, tool, MCP, sandbox, Restate or
28
- * S2 call, and no `fetch`, runs inside a transaction. Wakes go
29
- * through `afterCommit`, and events are delivered to commit listeners after
30
- * commit (seam rule 1 and 2).
28
+ * S2 call, and no `fetch`, runs inside a transaction. A wake is written in the
29
+ * transaction (`Tx.wake`, the wake outbox) and delivered after commit, and events
30
+ * are delivered to commit listeners after commit (seam rule 1 and 2).
31
31
  * 5. **No nested transactions.** Calling `store.tx` from inside `fn` rejects.
32
32
  * A `Tx` must not be used after its `tx` call settles.
33
33
  * 6. **Post-commit order.** After a commit, the store first calls every commit
34
34
  * listener once with the transaction's events (in allocation order), then runs the `afterCommit` callbacks in registration
35
35
  * order and awaits them. Listener and callback failures are reported to the
36
36
  * store's error hook; they never reject `tx`, because the commit stands.
37
- * Recovery from a lost post-commit step is the Tenant sweep's job.
37
+ * Recovery from a lost post-commit step is the Tenant sweep's job: a wake's
38
+ * delivery is a callback too, and its outbox row is what the sweep delivers
39
+ * again (`Tx.wake`).
38
40
  * 7. **Documents are values.** `get` and queries return fresh copies; mutating
39
41
  * them changes nothing until `put`.
40
42
  * 8. **Ownership columns are store-managed.** `owner`, `epoch` and
@@ -56,6 +58,7 @@
56
58
  */
57
59
  import type { KeyRole } from "@nylorun/core/compatibility";
58
60
  import type { RecordReader } from "../streams/relay/types.js";
61
+ import type { Wake } from "../execution/types.js";
59
62
  import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
60
63
  import type { SandboxManifest } from "@nylorun/core/define";
61
64
  import type { HostEffect } from "@nylorun/harness/run";
@@ -271,6 +274,24 @@ export interface SessionStore {
271
274
  followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
272
275
  close(): Promise<void>;
273
276
  }
277
+ /** A wake as the outbox delivers it: its `dedupeKey` is the request's idempotency key. */
278
+ export type OutboxWake = Wake & {
279
+ dedupeKey: string;
280
+ };
281
+ /**
282
+ * Hands an outbox request to Durable Session Execution (`Tx.wake`). Resolving means the
283
+ * execution accepted it; `false` means it was not delivered and stays in the outbox, as a
284
+ * rejection does, without an error.
285
+ */
286
+ export type WakeDelivery = (sessionId: string, wake: OutboxWake) => boolean | void | Promise<boolean | void>;
287
+ /** A wake outbox request not yet delivered (`Tx.pendingWakes`). */
288
+ export interface PendingWake {
289
+ id: string;
290
+ sessionId: string;
291
+ wake: OutboxWake;
292
+ /** ISO time it was written. */
293
+ createdAt: string;
294
+ }
274
295
  /**
275
296
  * A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
276
297
  * Signals are not events: they are never recorded in a session's log or streamed, and a
@@ -345,8 +366,28 @@ export interface Tx {
345
366
  * cursor is final.
346
367
  */
347
368
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>): Promise<SessionEventOf<T>>;
348
- /** Runs `fn` after a successful commit (never on rollback). Used for wakes. */
369
+ /**
370
+ * Runs `fn` after a successful commit (never on rollback): a sandbox signal or a host
371
+ * revocation. Nothing writes it down, so a crash after commit loses it. A wake goes
372
+ * through `wake`, which does.
373
+ */
349
374
  afterCommit(fn: () => void | Promise<void>): void;
375
+ /**
376
+ * Asks for an advance of `sessionId` once this transaction commits. The request is a row
377
+ * of the wake outbox written in this transaction, so it commits, or rolls back, with what
378
+ * caused it: a commit never loses its wake. After commit, in `afterCommit` order, the
379
+ * store calls `deliver` (`DurableExecution.wake`, through the Tenant) and deletes the row
380
+ * once it resolves to anything but `false`. A request `deliver` declines (`false`) or
381
+ * rejects, or one a crash cut off, stays in the outbox until the Tenant sweep delivers it
382
+ * (`pendingWakes`, `deleteWakes`). `deliver` gets the request's idempotency key as
383
+ * `dedupeKey`: the wake's own, or `wake:<row id>` without one, so a request delivered
384
+ * twice still causes one advance.
385
+ */
386
+ wake(sessionId: string, wake: Wake, deliver: WakeDelivery): Promise<void>;
387
+ /** Outbox requests written before `before`, oldest first. */
388
+ pendingWakes(before: Date, limit: number): Promise<PendingWake[]>;
389
+ /** Deletes delivered outbox requests. */
390
+ deleteWakes(ids: readonly string[]): Promise<void>;
350
391
  /**
351
392
  * Locks the session and takes ownership when it has no owner or the owner's
352
393
  * lease ended at or before `now`; then increments `epoch` and sets the lease
@@ -473,10 +473,7 @@ async function settle(ctx, lease, s, result, updates = [], cursor) {
473
473
  await slimModelEffects(t, id, s.activeTurnId);
474
474
  await t.put("sessions", id, current);
475
475
  const segment = finished.segment + 1;
476
- t.afterCommit(() => ctx.wake(id, {
477
- reason: "rollover",
478
- dedupeKey: `rollover:${s.activeTurnId}:${segment}`,
479
- }));
476
+ await t.wake(id, { reason: "rollover", dedupeKey: `rollover:${s.activeTurnId}:${segment}` }, ctx.wake);
480
477
  return siblings;
481
478
  }
482
479
  const flow = isWorkflowManifest(current.manifest);
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The session command service: `message`, `approve`/`respond` and `cancel`,
3
3
  * with per-session idempotency keys. A command locks its session and commits its state
4
- * change, events and wakes in one transaction (the store publishes the events and runs the
5
- * `afterCommit` wakes), then aborts a cancelled advance and cascades workflow cancels to the
6
- * linked agent sessions, one transaction each.
4
+ * change, events and wakes in one transaction (the store publishes the events and delivers the
5
+ * wakes from its outbox after commit, `Tx.wake`), then aborts a cancelled advance and cascades
6
+ * workflow cancels to the linked agent sessions, one transaction each.
7
7
  *
8
- * Wakes (`ctx.wake`, architecture §12.3) carry the command type as the reason and a dedupe
8
+ * Wakes (`t.wake`, architecture §12.3) carry the command type as the reason and a dedupe
9
9
  * key naming the cause: `<type>:<turnId>:<segment>` for `message`, `approve` and `respond`
10
10
  * (every accepted one writes a new checkpoint segment), except that a workflow's `approve` and
11
11
  * `respond` name `<type>:<turnId>:<interactionId>`: a flow resumes in the same segment.
@@ -231,7 +231,7 @@ export async function command(ctx, id, input, scope) {
231
231
  reason: command.type,
232
232
  dedupeKey: `${command.type}:${s.activeTurnId}:${cause}`,
233
233
  };
234
- t.afterCommit(() => ctx.wake(id, commandWake));
234
+ await t.wake(id, commandWake, ctx.wake);
235
235
  event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
236
236
  }
237
237
  await t.put("sessions", id, s);
@@ -5,9 +5,10 @@
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 `t.afterCommit(() => ctx.wake(id, { reason, dedupeKey }))`. It never publishes or notifies
9
- * itself; `runtime.ts` wires the streams with `wireStreams()`. No external I/O runs inside a
10
- * tx.
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
10
+ * publishes or notifies itself; `runtime.ts` wires the streams with `wireStreams()`. No
11
+ * external I/O runs inside a tx.
11
12
  *
12
13
  * `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
13
14
  * under ownership (§10.6); `abortLocal` aborts an advance running on this process.
@@ -211,11 +212,13 @@ export interface TenantContext {
211
212
  /** How long an advance's ownership lease lasts; the heartbeat renews it. */
212
213
  readonly ownerLeaseMs: number;
213
214
  /**
214
- * Seam: request an advance of a session (`DurableExecution.wake`). Call it from
215
- * `t.afterCommit`, never inside a tx. Dropped while the Tenant is closing; the sweep
216
- * re-wakes anything left runnable.
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.
217
220
  */
218
- wake(sessionId: string, wake: Wake): Promise<void>;
221
+ wake(sessionId: string, wake: Wake): Promise<boolean>;
219
222
  /**
220
223
  * Seam: abort the advance of a session running in this process, if any. With `turnId`, only
221
224
  * an advance of that turn (a cancel signal names the turn it cancelled).
@@ -261,8 +261,9 @@ export class TenantRuntime {
261
261
  ownerLeaseMs: config.ownerLeaseMs ?? DEFAULT_OWNER_LEASE_MS,
262
262
  wake: async (sessionId, wake) => {
263
263
  if (ctx.closing || ctx.closed)
264
- return;
264
+ return false;
265
265
  await execution.wake(config.tenantId, sessionId, wake);
266
+ return true;
266
267
  },
267
268
  abortLocal: (sessionId, turnId) => abortLocal(ctx, sessionId, "cancel", turnId),
268
269
  ...(hooks.pods ? { pods: hooks.pods } : {}),
@@ -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,5 +1,19 @@
1
1
  import type { TenantContext } from "./context.js";
2
+ /**
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).
6
+ */
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
+ export declare const ORPHAN_SCAN_MS = 60000;
2
10
  export declare function sweep(ctx: TenantContext): Promise<void>;
11
+ /**
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.
15
+ */
16
+ export declare function deliverPendingWakes(ctx: Pick<TenantContext, "store" | "wake">, before?: Date): Promise<string[]>;
3
17
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
4
18
  export declare function reconcileLinkedAgents(ctx: Pick<TenantContext, "store" | "wake">): Promise<void>;
5
19
  /** Wake `running`/`runnable` sessions without a live owner. Returns the ids woken. */
@@ -1,21 +1,32 @@
1
1
  /**
2
2
  * The Tenant sweep (architecture §12.3, "Timers"): one self-re-arming pass per Tenant, armed
3
3
  * with `DurableExecution.armSweep` when the Tenant opens. It replaces every in-process timer
4
- * and startup scan the Tenant runtime used to keep:
4
+ * and startup scan the Tenant runtime used to keep. It reconciles the Session Store with what
5
+ * Durable Session Execution was asked to do; it never decides when a session advances:
5
6
  *
6
- * 1. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
+ * 1. **Wakes.** Wake outbox rows (`Tx.wake`) are delivered again and deleted: a delivery
8
+ * after commit that failed, or that a crash between the commit and the send cut off. The
9
+ * first pass on this process takes every row, so a previous process's are not left
10
+ * waiting; later passes take the rows older than `WAKE_GRACE_MS`. Each row carries the
11
+ * idempotency key it was first sent with, so a wake the execution did accept before the
12
+ * crash, or one still being sent after its commit, causes no second advance.
13
+ * 2. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
7
14
  * are completed and the workflow is woken (a crash between the two, or data written by an
8
- * older Runtime). One transaction per effect, child session locked before its workflow.
9
- * 2. **Orphaned sessions.** `running` or `runnable` sessions with no owner, or an owner whose
10
- * lease expired, are woken with reason `recover`: a wake lost between commit and send, or
11
- * a Worker that died mid-advance (the advance then takes over, §11.4).
12
- * 3. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
15
+ * older Runtime).
16
+ * 3. **Orphaned sessions.** On this process's first pass for the Tenant, then at most every
17
+ * `ORPHAN_SCAN_MS`: `running` or `runnable` sessions with no owner, or an owner whose lease
18
+ * expired, are woken with reason `recover`. Neither a lost wake (step 1) nor a Worker that
19
+ * died mid-advance (the execution retries that advance, which takes over, §11.4) needs it:
20
+ * it is the backstop for what the execution itself lost or ended without an advance, such
21
+ * as an in-process execution's queue when its process stopped, an advance for a Tenant not
22
+ * open on its Worker, an invocation an operator killed, or Restate's state wiped (§17.11).
23
+ * 4. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
13
24
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
14
25
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
15
26
  * workspaces). Idle MCP connections of the in-process harness are closed.
16
- * 4. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
27
+ * 5. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
17
28
  * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
18
- * 5. **Hooks.** Callbacks registered with `ctx.onSweep`.
29
+ * 6. **Hooks.** Callbacks registered with `ctx.onSweep`.
19
30
  *
20
31
  * Every step runs one transaction per session it changes (a linked agent and its workflow
21
32
  * share one, child first), so the sweep follows the lock order in `store/types.ts`. A failing
@@ -26,13 +37,41 @@ import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-
26
37
  const BATCH = 100;
27
38
  /** How long a control signal is kept after it was written. */
28
39
  const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
40
+ /**
41
+ * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
42
+ * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
43
+ * twice is harmless either way (the idempotency key).
44
+ */
45
+ export const WAKE_GRACE_MS = 2000;
46
+ /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
47
+ export const ORPHAN_SCAN_MS = 60_000;
48
+ /** Each open Tenant's sweep state on this process: when its orphan scan last ran. */
49
+ const passes = new WeakMap();
29
50
  export async function sweep(ctx) {
30
51
  if (ctx.closing || ctx.closed)
31
52
  return;
32
53
  const now = new Date();
54
+ const first = !passes.has(ctx);
55
+ const pass = passes.get(ctx) ?? {};
56
+ passes.set(ctx, pass);
57
+ const scanOrphans = pass.orphansAt === undefined || now.getTime() - pass.orphansAt >= ORPHAN_SCAN_MS;
33
58
  const steps = [
59
+ [
60
+ "wakes",
61
+ () => deliverPendingWakes(ctx, first ? now : new Date(now.getTime() - WAKE_GRACE_MS)),
62
+ ],
34
63
  ["linked", () => reconcileLinkedAgents(ctx)],
35
- ["orphans", () => wakeOrphanedSessions(ctx, now)],
64
+ ...(scanOrphans
65
+ ? [
66
+ [
67
+ "orphans",
68
+ async () => {
69
+ await wakeOrphanedSessions(ctx, now);
70
+ pass.orphansAt = now.getTime();
71
+ },
72
+ ],
73
+ ]
74
+ : []),
36
75
  [
37
76
  "sandboxes",
38
77
  () => ctx.sandbox.sweep({
@@ -68,6 +107,26 @@ export async function sweep(ctx) {
68
107
  if (failure)
69
108
  throw failure.error;
70
109
  }
110
+ /**
111
+ * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
112
+ * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
113
+ * rows delivered.
114
+ */
115
+ export async function deliverPendingWakes(ctx, before = new Date()) {
116
+ const pending = await ctx.store.tx((t) => t.pendingWakes(before, BATCH));
117
+ const delivered = [];
118
+ try {
119
+ for (const row of pending)
120
+ if ((await ctx.wake(row.sessionId, row.wake)) !== false)
121
+ delivered.push(row.id);
122
+ }
123
+ finally {
124
+ // Delivered ones go even when a later one fails, so they are not sent again.
125
+ if (delivered.length > 0)
126
+ await ctx.store.tx((t) => t.deleteWakes(delivered));
127
+ }
128
+ return delivered;
129
+ }
71
130
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
72
131
  export async function reconcileLinkedAgents(ctx) {
73
132
  const { store } = ctx;