@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
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.21.1-beta",
5
+ "version": "0.23.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 4,
8
8
  "max": 10,
@@ -97,6 +97,7 @@
97
97
  "definition_files_missing",
98
98
  "credential_rejected",
99
99
  "mcp_preview_failed",
100
+ "credential_conflict",
100
101
  "internal_error"
101
102
  ]
102
103
  },
@@ -2113,7 +2114,9 @@
2113
2114
  "type": "string",
2114
2115
  "enum": [
2115
2116
  "bearer",
2116
- "headers"
2117
+ "headers",
2118
+ "environment_secret",
2119
+ "environment_variable"
2117
2120
  ]
2118
2121
  },
2119
2122
  "binding": {
@@ -2121,11 +2124,41 @@
2121
2124
  "properties": {
2122
2125
  "url": {
2123
2126
  "type": "string"
2127
+ },
2128
+ "secretName": {
2129
+ "type": "string"
2130
+ },
2131
+ "allowedHosts": {
2132
+ "type": "array",
2133
+ "items": {
2134
+ "type": "string"
2135
+ }
2136
+ },
2137
+ "inject": {
2138
+ "type": "object",
2139
+ "properties": {
2140
+ "header": {
2141
+ "type": "string",
2142
+ "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"
2143
+ },
2144
+ "format": {
2145
+ "type": "string",
2146
+ "minLength": 1
2147
+ }
2148
+ },
2149
+ "required": [
2150
+ "header",
2151
+ "format"
2152
+ ],
2153
+ "additionalProperties": false
2154
+ },
2155
+ "variableName": {
2156
+ "type": "string"
2157
+ },
2158
+ "variableValue": {
2159
+ "type": "string"
2124
2160
  }
2125
2161
  },
2126
- "required": [
2127
- "url"
2128
- ],
2129
2162
  "additionalProperties": false
2130
2163
  },
2131
2164
  "headerNames": {
@@ -2269,6 +2302,85 @@
2269
2302
  "headers"
2270
2303
  ],
2271
2304
  "additionalProperties": false
2305
+ },
2306
+ {
2307
+ "type": "object",
2308
+ "properties": {
2309
+ "type": {
2310
+ "type": "string",
2311
+ "enum": [
2312
+ "environment_secret"
2313
+ ]
2314
+ },
2315
+ "secretName": {
2316
+ "type": "string",
2317
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
2318
+ },
2319
+ "secretValue": {
2320
+ "type": "string",
2321
+ "minLength": 1,
2322
+ "maxLength": 8192
2323
+ },
2324
+ "allowedHosts": {
2325
+ "type": "array",
2326
+ "items": {
2327
+ "type": "string"
2328
+ },
2329
+ "minItems": 1,
2330
+ "maxItems": 32
2331
+ },
2332
+ "inject": {
2333
+ "type": "object",
2334
+ "properties": {
2335
+ "header": {
2336
+ "type": "string",
2337
+ "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"
2338
+ },
2339
+ "format": {
2340
+ "type": "string",
2341
+ "minLength": 1
2342
+ }
2343
+ },
2344
+ "required": [
2345
+ "header",
2346
+ "format"
2347
+ ],
2348
+ "additionalProperties": false
2349
+ }
2350
+ },
2351
+ "required": [
2352
+ "type",
2353
+ "secretName",
2354
+ "secretValue",
2355
+ "allowedHosts"
2356
+ ],
2357
+ "additionalProperties": false
2358
+ },
2359
+ {
2360
+ "type": "object",
2361
+ "properties": {
2362
+ "type": {
2363
+ "type": "string",
2364
+ "enum": [
2365
+ "environment_variable"
2366
+ ]
2367
+ },
2368
+ "variableName": {
2369
+ "type": "string",
2370
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,127}$"
2371
+ },
2372
+ "variableValue": {
2373
+ "type": "string",
2374
+ "minLength": 1,
2375
+ "maxLength": 8192
2376
+ }
2377
+ },
2378
+ "required": [
2379
+ "type",
2380
+ "variableName",
2381
+ "variableValue"
2382
+ ],
2383
+ "additionalProperties": false
2272
2384
  }
2273
2385
  ]
2274
2386
  }
@@ -2302,7 +2414,9 @@
2302
2414
  "type": "string",
2303
2415
  "enum": [
2304
2416
  "bearer",
2305
- "headers"
2417
+ "headers",
2418
+ "environment_secret",
2419
+ "environment_variable"
2306
2420
  ]
2307
2421
  },
2308
2422
  "binding": {
@@ -2310,11 +2424,41 @@
2310
2424
  "properties": {
2311
2425
  "url": {
2312
2426
  "type": "string"
2427
+ },
2428
+ "secretName": {
2429
+ "type": "string"
2430
+ },
2431
+ "allowedHosts": {
2432
+ "type": "array",
2433
+ "items": {
2434
+ "type": "string"
2435
+ }
2436
+ },
2437
+ "inject": {
2438
+ "type": "object",
2439
+ "properties": {
2440
+ "header": {
2441
+ "type": "string",
2442
+ "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"
2443
+ },
2444
+ "format": {
2445
+ "type": "string",
2446
+ "minLength": 1
2447
+ }
2448
+ },
2449
+ "required": [
2450
+ "header",
2451
+ "format"
2452
+ ],
2453
+ "additionalProperties": false
2454
+ },
2455
+ "variableName": {
2456
+ "type": "string"
2457
+ },
2458
+ "variableValue": {
2459
+ "type": "string"
2313
2460
  }
2314
2461
  },
2315
- "required": [
2316
- "url"
2317
- ],
2318
2462
  "additionalProperties": false
2319
2463
  },
2320
2464
  "headerNames": {
@@ -2463,6 +2607,48 @@
2463
2607
  "headers"
2464
2608
  ],
2465
2609
  "additionalProperties": false
2610
+ },
2611
+ {
2612
+ "type": "object",
2613
+ "properties": {
2614
+ "type": {
2615
+ "type": "string",
2616
+ "enum": [
2617
+ "environment_secret"
2618
+ ]
2619
+ },
2620
+ "secretValue": {
2621
+ "type": "string",
2622
+ "minLength": 1,
2623
+ "maxLength": 8192
2624
+ }
2625
+ },
2626
+ "required": [
2627
+ "type",
2628
+ "secretValue"
2629
+ ],
2630
+ "additionalProperties": false
2631
+ },
2632
+ {
2633
+ "type": "object",
2634
+ "properties": {
2635
+ "type": {
2636
+ "type": "string",
2637
+ "enum": [
2638
+ "environment_variable"
2639
+ ]
2640
+ },
2641
+ "variableValue": {
2642
+ "type": "string",
2643
+ "minLength": 1,
2644
+ "maxLength": 8192
2645
+ }
2646
+ },
2647
+ "required": [
2648
+ "type",
2649
+ "variableValue"
2650
+ ],
2651
+ "additionalProperties": false
2466
2652
  }
2467
2653
  ]
2468
2654
  }
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.21.1-beta",
5
+ "version": "0.23.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 4,
8
8
  "max": 10,
@@ -6338,6 +6338,7 @@
6338
6338
  "definition_files_missing",
6339
6339
  "credential_rejected",
6340
6340
  "mcp_preview_failed",
6341
+ "credential_conflict",
6341
6342
  "internal_error"
6342
6343
  ]
6343
6344
  },
@@ -0,0 +1,21 @@
1
+ /** Waits `ms`, or until `signal` aborts; resolves either way and never rejects. */
2
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
3
+ export interface RetryOptions {
4
+ /** The first wait after a failure. Doubled after each, up to `maxMs`. */
5
+ minMs: number;
6
+ maxMs: number;
7
+ /** Attempts, the first included, before giving up. Default: no limit. */
8
+ attempts?: number;
9
+ /** Gives up when the next wait would end later than this long after the first attempt. */
10
+ deadlineMs?: number;
11
+ /** Gives up when it aborts, after the attempt or wait under way. */
12
+ signal?: AbortSignal;
13
+ /** Called with each failure that is retried, and how many attempts have failed so far. */
14
+ onError?: (error: unknown, failures: number) => void;
15
+ }
16
+ /**
17
+ * Runs `attempt` until it resolves, waiting between failures with exponential backoff
18
+ * (`minMs`, doubling to `maxMs`). Rejects with the last failure once `attempts`, `deadlineMs`
19
+ * or `signal` ends the retries.
20
+ */
21
+ export declare function retry<T>(attempt: () => Promise<T>, options: RetryOptions): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Waiting and retrying, shared by the Runtime's background loops and its calls to other
3
+ * services. Nothing here logs or decides what is worth retrying: callers do.
4
+ */
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ /** Waits `ms`, or until `signal` aborts; resolves either way and never rejects. */
7
+ export function sleep(ms, signal) {
8
+ return delay(ms, undefined, signal ? { signal } : undefined).catch(() => undefined);
9
+ }
10
+ /**
11
+ * Runs `attempt` until it resolves, waiting between failures with exponential backoff
12
+ * (`minMs`, doubling to `maxMs`). Rejects with the last failure once `attempts`, `deadlineMs`
13
+ * or `signal` ends the retries.
14
+ */
15
+ export async function retry(attempt, options) {
16
+ const deadline = options.deadlineMs === undefined ? Infinity : Date.now() + options.deadlineMs;
17
+ let wait = options.minMs;
18
+ for (let failures = 1;; failures += 1) {
19
+ try {
20
+ return await attempt();
21
+ }
22
+ catch (error) {
23
+ if (failures >= (options.attempts ?? Infinity) ||
24
+ Date.now() + wait > deadline ||
25
+ options.signal?.aborted)
26
+ throw error;
27
+ options.onError?.(error, failures);
28
+ await sleep(wait, options.signal);
29
+ if (options.signal?.aborted)
30
+ throw error;
31
+ wait = Math.min(wait * 2, options.maxMs);
32
+ }
33
+ }
34
+ }
@@ -15,6 +15,8 @@ export interface HostJoinAnswer {
15
15
  readonly epoch: number;
16
16
  /** ISO time the tokens expire. */
17
17
  readonly expiresAt: string;
18
+ /** The egress CA certificate (PEM) the sandbox trusts (R2c). */
19
+ readonly caCertificate: string;
18
20
  }
19
21
  /** A refused join, renewal or host connection. */
20
22
  export declare class HostAuthError extends Error {
@@ -24,6 +24,10 @@
24
24
  * and the pod (`tenant/host-token.ts`). `renew` mints a new pair at the same epoch while the
25
25
  * host is current; it is refused once the epoch moved.
26
26
  *
27
+ * The answer also carries the installation's egress CA certificate (R2c, D50): the engine adds it
28
+ * to the trust bundle every command gets, so a host egress-gate terminates TLS for (one an
29
+ * `environment_secret` is bound to) is trusted. Only the certificate: its key stays in keys.
30
+ *
27
31
  * Every refusal answers the same 401 (the reason is logged), so a caller learns nothing about
28
32
  * which check failed.
29
33
  */
@@ -53,13 +57,14 @@ function sameHash(a, b) {
53
57
  const right = Buffer.from(b, "hex");
54
58
  return left.length === right.length && left.length > 0 && timingSafeEqual(left, right);
55
59
  }
56
- function answerOf(grant) {
60
+ function answerOf(grant, caCertificate) {
57
61
  return {
58
62
  hostToken: grant.hostToken,
59
63
  egressToken: grant.egressToken,
60
64
  sandboxId: grant.claims.sandboxId,
61
65
  epoch: grant.claims.epoch,
62
66
  expiresAt: new Date(grant.claims.expiresAt).toISOString(),
67
+ caCertificate,
63
68
  };
64
69
  }
65
70
  export function hostAuthority(ctx) {
@@ -129,6 +134,9 @@ export function hostAuthority(ctx) {
129
134
  const reason = "The sandbox's volume was replaced: its files are gone";
130
135
  await t.updateSandboxPod(sandboxId, { observed: "lost", reason, hostEpoch: epoch }, now);
131
136
  await t.sandboxEvent(sandboxId, "sandbox.lost", { reason });
137
+ // Its Sandbox is deleted; only a reset brings the sandbox back.
138
+ await t.signal({ type: "host.revoked", sandboxId, epoch });
139
+ await t.signalSandbox(sandboxId, { kind: "reconcile" });
132
140
  return { lost: epoch };
133
141
  }
134
142
  await t.updateSandboxPod(sandboxId, {
@@ -143,22 +151,18 @@ export function hostAuthority(ctx) {
143
151
  else
144
152
  await t.sandboxEvent(sandboxId, "sandbox.running", { hostEpoch: epoch });
145
153
  }
154
+ // The previous host's connection (an older epoch) serves nothing more, on any process.
155
+ await t.signal({ type: "host.revoked", sandboxId, epoch });
146
156
  return epoch;
147
157
  });
148
- if (typeof joined === "object") {
149
- ctx.harness.revokeHost(sandboxId, joined.lost);
150
- // Its Sandbox is deleted; only a reset brings the sandbox back.
151
- await ctx.sandboxSignal(sandboxId, { kind: "reconcile" });
158
+ if (typeof joined === "object")
152
159
  return refuse("the volume was replaced: the sandbox is lost");
153
- }
154
- // The previous host's connection (an older epoch) serves nothing more.
155
- ctx.harness.revokeHost(sandboxId, joined);
156
160
  ctx.config.logger.info("sandbox host joined", { sandboxId, epoch: joined });
157
- return answerOf(await mintHostTokens(ctx, { sandboxId, epoch: joined, podUid }));
161
+ return answerOf(await mintHostTokens(ctx, { sandboxId, epoch: joined, podUid }), await ctx.keys.egressCa());
158
162
  },
159
163
  async renew(hostToken) {
160
164
  const claims = await verified(hostToken);
161
- return answerOf(await mintHostTokens(ctx, claims));
165
+ return answerOf(await mintHostTokens(ctx, claims), await ctx.keys.egressCa());
162
166
  },
163
167
  verify: verified,
164
168
  };
@@ -34,6 +34,8 @@ export interface SandboxSessionRef {
34
34
  * so every session attached to it shares the files; absent, the workspace is the session's.
35
35
  */
36
36
  readonly sandboxId?: string;
37
+ /** Variables every command of the call gets (R2c): secret names set to the sentinel, plain values. */
38
+ readonly environment?: Readonly<Record<string, string>>;
37
39
  /**
38
40
  * Who the call's events are claimed for, passed back to `emit`: in a harness, the run that
39
41
  * made the call (its run id).
@@ -285,7 +285,7 @@ export class SandboxManager {
285
285
  try {
286
286
  const outcome = await runSandboxTool(live.handle, toolName, (input ?? {}), signal, (value) => {
287
287
  report = value;
288
- });
288
+ }, session.environment);
289
289
  await this.options.emit(session.id, session.activeTurnId, "sandbox.exec", {
290
290
  tool: toolName,
291
291
  ...report,
@@ -135,7 +135,9 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
135
135
  await t.updateSandboxPod(sandboxId, finalPatch, new Date().toISOString());
136
136
  for (const item of decision.events)
137
137
  await t.sandboxEvent(sandboxId, item.type, item.payload);
138
- return { kind: "written", epoch: epochMoved ? current.pod.hostEpoch + 1 : undefined };
138
+ if (epochMoved)
139
+ await t.signal({ type: "host.revoked", sandboxId, epoch: finalPatch.hostEpoch });
140
+ return { kind: "written" };
139
141
  });
140
142
  if (written.kind === "changed")
141
143
  return { retryAfterMs: 0 };
@@ -143,8 +145,6 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
143
145
  await ctx.sandbox.removeSandbox(sandboxId).catch(() => undefined);
144
146
  return {};
145
147
  }
146
- if (written.kind === "written" && written.epoch !== undefined)
147
- ctx.harness.revokeHost(sandboxId, written.epoch);
148
148
  return {
149
149
  ...(decision.retryAfterMs === undefined ? {} : { retryAfterMs: decision.retryAfterMs }),
150
150
  ...(decision.arm.length > 0 ? { arm: decision.arm } : {}),
@@ -20,4 +20,6 @@ export declare function truncateMiddle(value: string, limit?: number): {
20
20
  text: string;
21
21
  truncated: boolean;
22
22
  };
23
- export declare function runSandboxTool(handle: SandboxHandle, name: SandboxToolName, input: Record<string, any>, signal: AbortSignal, report: (value: SandboxToolReport) => void): Promise<SandboxToolOutcome>;
23
+ export declare function runSandboxTool(handle: SandboxHandle, name: SandboxToolName, input: Record<string, any>, signal: AbortSignal, report: (value: SandboxToolReport) => void,
24
+ /** Variables every command gets (R2c): secret names set to the sentinel, plain values. */
25
+ environment?: Readonly<Record<string, string>>): Promise<SandboxToolOutcome>;
@@ -39,10 +39,12 @@ const failed = (code, message) => ({
39
39
  code,
40
40
  message,
41
41
  });
42
- export async function runSandboxTool(handle, name, input, signal, report) {
42
+ export async function runSandboxTool(handle, name, input, signal, report,
43
+ /** Variables every command gets (R2c): secret names set to the sentinel, plain values. */
44
+ environment) {
43
45
  const workspace = handle.workspace ?? SANDBOX_WORKSPACE;
44
46
  const resolvePath = (path) => resolveSandboxPath(path, workspace);
45
- const exec = (command, timeoutMs = 60_000) => handle.exec({ command, cwd: workspace, timeoutMs }, signal);
47
+ const exec = (command, timeoutMs = 60_000) => handle.exec({ command, cwd: workspace, timeoutMs, ...(environment ? { env: environment } : {}) }, signal);
46
48
  switch (name) {
47
49
  case "bash": {
48
50
  const command = String(input.command);
@@ -36,6 +36,11 @@ export interface ExecRequest {
36
36
  readonly command: string;
37
37
  readonly cwd: string;
38
38
  readonly timeoutMs: number;
39
+ /**
40
+ * Variables this command gets on top of the backend's own (R2c): never a secret. The backend's
41
+ * proxy and trust variables win over them.
42
+ */
43
+ readonly env?: Readonly<Record<string, string>>;
39
44
  }
40
45
  export interface ExecResult {
41
46
  readonly exitCode: number;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The control bus on Postgres (blueprint D21, D48): how one process tells the others with the
3
3
  * Tenant open to act now. `session.cancel` aborts the advance of a cancelled turn;
4
- * `sessions.reset` moves the session streams to the Tenant's new basin generation. Nothing
4
+ * `sessions.reset` moves the session streams to the Tenant's new basin generation;
5
+ * `host.revoked` closes a pod sandbox's host connections of an older epoch. Nothing
5
6
  * internal goes through S2, which only serves API listeners.
6
7
  *
7
8
  * - **Write.** `writeSignal` inserts a `control_signals` row and calls `pg_notify` on
@@ -36,6 +37,8 @@ const SIGNAL = {
36
37
  sessionId: controlSignals.sessionId,
37
38
  turnId: controlSignals.turnId,
38
39
  generation: controlSignals.generation,
40
+ sandboxId: controlSignals.sandboxId,
41
+ epoch: controlSignals.epoch,
39
42
  };
40
43
  /** Puts `signal` on the bus in the transaction `db` runs: a row, notified at commit. */
41
44
  export async function writeSignal(db, signal) {
@@ -46,6 +49,8 @@ export async function writeSignal(db, signal) {
46
49
  sessionId: signal.type === "session.cancel" ? signal.sessionId : null,
47
50
  turnId: signal.type === "session.cancel" ? (signal.turnId ?? null) : null,
48
51
  generation: signal.type === "sessions.reset" ? signal.generation : null,
52
+ sandboxId: signal.type === "host.revoked" ? signal.sandboxId : null,
53
+ epoch: signal.type === "host.revoked" ? signal.epoch : null,
49
54
  })
50
55
  .returning({ id: controlSignals.id });
51
56
  // The payload is only the id: a follower reads the rows back either way.
@@ -183,5 +188,7 @@ function signalOf(row) {
183
188
  };
184
189
  if (row.kind === "sessions.reset" && row.generation !== null)
185
190
  return { type: "sessions.reset", generation: row.generation };
191
+ if (row.kind === "host.revoked" && row.sandboxId !== null && row.epoch !== null)
192
+ return { type: "host.revoked", sandboxId: row.sandboxId, epoch: row.epoch };
186
193
  return undefined;
187
194
  }
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "nylorun"."wakes" ADD COLUMN "attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "nylorun"."wakes" ADD COLUMN "retry_at" timestamp with time zone;--> statement-breakpoint
3
+ ALTER TABLE "nylorun"."wakes" ADD COLUMN "parked_at" timestamp with time zone;
@@ -0,0 +1,18 @@
1
+ CREATE TABLE "nylorun"."sandbox_signals" (
2
+ "id" text COLLATE "C" PRIMARY KEY NOT NULL,
3
+ "sandbox_id" text COLLATE "C" NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "timer" text,
6
+ "at" bigint,
7
+ "created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
8
+ "attempts" integer DEFAULT 0 NOT NULL,
9
+ "retry_at" timestamp with time zone,
10
+ "parked_at" timestamp with time zone,
11
+ CONSTRAINT "sandbox_signals_kind_check" CHECK ("nylorun"."sandbox_signals"."kind" IN ('reconcile', 'arm'))
12
+ );
13
+ --> statement-breakpoint
14
+ ALTER TABLE "nylorun"."control_signals" DROP CONSTRAINT "control_signals_kind_check";--> statement-breakpoint
15
+ ALTER TABLE "nylorun"."control_signals" ADD COLUMN "sandbox_id" text COLLATE "C";--> statement-breakpoint
16
+ ALTER TABLE "nylorun"."control_signals" ADD COLUMN "epoch" integer;--> statement-breakpoint
17
+ CREATE INDEX "sandbox_signals_created_at" ON "nylorun"."sandbox_signals" USING btree ("created_at");--> statement-breakpoint
18
+ ALTER TABLE "nylorun"."control_signals" ADD CONSTRAINT "control_signals_kind_check" CHECK ("nylorun"."control_signals"."kind" IN ('session.cancel', 'sessions.reset', 'host.revoked'));
@@ -127,6 +127,20 @@
127
127
  "when": 1791455573196,
128
128
  "tag": "0017_wake_outbox",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1791529391438,
135
+ "tag": "0018_wake_outbox_retries",
136
+ "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1791530515498,
142
+ "tag": "0019_sandbox_signal_outbox",
143
+ "breakpoints": true
130
144
  }
131
145
  ]
132
146
  }