@nylorun/runtime 0.19.1-beta → 0.20.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 (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +9 -5
  3. package/dist/adapters/streams/s2.js +5 -11
  4. package/dist/api/components.d.ts +171 -169
  5. package/dist/api/http/define.d.ts +3 -2
  6. package/dist/api/http/define.js +10 -11
  7. package/dist/api/openapi.js +31 -3
  8. package/dist/execution/types.d.ts +1 -1
  9. package/dist/host/app.d.ts +3 -0
  10. package/dist/host/app.js +33 -4
  11. package/dist/host/create-host.d.ts +6 -0
  12. package/dist/host/create-host.js +1 -0
  13. package/dist/host/main.js +11 -4
  14. package/dist/host/stack-config.js +1 -1
  15. package/dist/infra/index.d.ts +2 -1
  16. package/dist/infra/index.js +0 -1
  17. package/dist/infra/readiness.d.ts +6 -6
  18. package/dist/infra/readiness.js +2 -5
  19. package/dist/management-openapi.json +430 -107
  20. package/dist/openapi.json +366 -227
  21. package/dist/store/postgres/connect.d.ts +10 -0
  22. package/dist/store/postgres/connect.js +30 -3
  23. package/dist/store/postgres/control.d.ts +14 -0
  24. package/dist/store/postgres/control.js +187 -0
  25. package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
  26. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  27. package/dist/store/postgres/schema.d.ts +120 -0
  28. package/dist/store/postgres/schema.js +20 -0
  29. package/dist/store/postgres/store.js +15 -0
  30. package/dist/store/types.d.ts +40 -0
  31. package/dist/streams/types.d.ts +6 -29
  32. package/dist/streams/types.js +5 -4
  33. package/dist/tenant/auth.d.ts +16 -6
  34. package/dist/tenant/auth.js +41 -17
  35. package/dist/tenant/commands.js +10 -6
  36. package/dist/tenant/context.d.ts +4 -0
  37. package/dist/tenant/control.d.ts +26 -0
  38. package/dist/tenant/control.js +35 -0
  39. package/dist/tenant/ephemeral.js +1 -0
  40. package/dist/tenant/http.d.ts +4 -2
  41. package/dist/tenant/http.js +4 -2
  42. package/dist/tenant/identity-file.d.ts +16 -12
  43. package/dist/tenant/identity-file.js +41 -29
  44. package/dist/tenant/issuers.d.ts +4 -3
  45. package/dist/tenant/issuers.js +22 -16
  46. package/dist/tenant/reset.d.ts +2 -2
  47. package/dist/tenant/reset.js +8 -2
  48. package/dist/tenant/resource-server.d.ts +56 -0
  49. package/dist/tenant/resource-server.js +62 -0
  50. package/dist/tenant/runtime.d.ts +1 -1
  51. package/dist/tenant/runtime.js +7 -1
  52. package/dist/tenant/scheduler.d.ts +1 -1
  53. package/dist/tenant/scheduler.js +1 -1
  54. package/dist/tenant/streams.d.ts +16 -22
  55. package/dist/tenant/streams.js +7 -87
  56. package/dist/tenant/sweep.js +9 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +3 -3
  60. package/dist/streams/control.d.ts +0 -13
  61. package/dist/streams/control.js +0 -25
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The Runtime API as an OAuth 2.1 resource server (protocol 9, Host feature `resource-server`).
3
+ * Nylorun is never the authorization server: the operator's identity providers are, listed in
4
+ * the identity file, and this module only says so in the standard places.
5
+ *
6
+ * - `GET /.well-known/oauth-protected-resource` (RFC 9728): the resource (`NYLORUN_PUBLIC_URL`,
7
+ * else the origin the request reached), every trusted issuer in the file's order, and the
8
+ * scopes their tokens may carry. Without trusted issuers there is none: the Runtime takes no
9
+ * OAuth tokens.
10
+ * - `WWW-Authenticate: Bearer` challenges (OAuth 2.1 §5.3, RFC 6750): a missing credential
11
+ * carries no error code; a rejected one `error="invalid_token"`; a token missing a route's
12
+ * scope `error="insufficient_scope"` with the scopes that would do. On the Runtime API they
13
+ * point at the metadata (`resource_metadata`); the Management API takes management keys only
14
+ * and is no OAuth resource, so its challenge is a bare `Bearer`.
15
+ */
16
+ import type { IncomingMessage } from "node:http";
17
+ import type { TrustedIssuers } from "./issuers.js";
18
+ /** Where the protected resource metadata is served (RFC 9728 §3). */
19
+ export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
20
+ /** What the metadata and the challenges are made of: `TenantConfig`'s fields of the same names. */
21
+ export interface ResourceServerConfig {
22
+ /** The URL clients reach the Runtime API at (`NYLORUN_PUBLIC_URL`, no trailing slash). */
23
+ readonly publicUrl?: string;
24
+ /** The identity file's trusted issuers. */
25
+ readonly issuers?: TrustedIssuers;
26
+ }
27
+ /** RFC 9728 protected resource metadata. */
28
+ export interface ProtectedResourceMetadata {
29
+ readonly resource: string;
30
+ readonly authorization_servers: readonly string[];
31
+ readonly scopes_supported: readonly string[];
32
+ readonly bearer_methods_supported: readonly ["header"];
33
+ readonly resource_name: string;
34
+ }
35
+ /** The metadata, or undefined when the Runtime trusts no issuer. */
36
+ export declare function protectedResourceMetadata(config: ResourceServerConfig, incoming: IncomingMessage): ProtectedResourceMetadata | undefined;
37
+ /**
38
+ * The metadata's URL for a challenge's `resource_metadata`, or undefined when there is none.
39
+ * A resource with a path has its metadata at the origin, the path after the well-known part.
40
+ */
41
+ export declare function protectedResourceMetadataUrl(config: ResourceServerConfig, incoming: IncomingMessage): string | undefined;
42
+ interface Challenge {
43
+ readonly error?: "invalid_token" | "insufficient_scope";
44
+ readonly description?: string;
45
+ readonly scope?: string;
46
+ readonly resourceMetadata?: string | undefined;
47
+ }
48
+ /** A `WWW-Authenticate: Bearer` value. Every parameter value is plain ASCII without quotes. */
49
+ export declare function bearerChallenge(challenge?: Challenge): string;
50
+ /**
51
+ * The `401` for a request without a usable credential: `credential_required` when it sent
52
+ * none (no error code, OAuth 2.1 §5.3.2), `credential_invalid` when the Tenant refused the one
53
+ * it sent. The reason is the Runtime's log's, never the client's.
54
+ */
55
+ export declare function failCredential(kind: "required" | "invalid", resourceMetadata: string | undefined): never;
56
+ export {};
@@ -0,0 +1,62 @@
1
+ import { TOKEN_SCOPES } from "@nylorun/core/contracts";
2
+ import { HttpError } from "./http.js";
3
+ /** Where the protected resource metadata is served (RFC 9728 §3). */
4
+ export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
5
+ /** The origin `incoming` reached, as `@hono/node-server` builds a request's URL. */
6
+ function requestOrigin(incoming) {
7
+ const scheme = incoming.socket?.encrypted ? "https" : "http";
8
+ return `${scheme}://${incoming.headers.host ?? "localhost"}`;
9
+ }
10
+ /** This Runtime API's resource identifier: the public URL, else the request's origin. */
11
+ function resourceOf(config, incoming) {
12
+ return config.publicUrl ?? requestOrigin(incoming);
13
+ }
14
+ /** The metadata, or undefined when the Runtime trusts no issuer. */
15
+ export function protectedResourceMetadata(config, incoming) {
16
+ const issuers = config.issuers?.issuers ?? [];
17
+ if (issuers.length === 0)
18
+ return undefined;
19
+ // `studio` signs a person in to Studio; no Runtime API route asks for it.
20
+ const granted = new Set(issuers.flatMap((issuer) => issuer.config.allowedScopes));
21
+ return {
22
+ resource: resourceOf(config, incoming),
23
+ authorization_servers: issuers.map((issuer) => issuer.config.issuer),
24
+ scopes_supported: TOKEN_SCOPES.filter((scope) => granted.has(scope)),
25
+ bearer_methods_supported: ["header"],
26
+ resource_name: "Nylorun Runtime API",
27
+ };
28
+ }
29
+ /**
30
+ * The metadata's URL for a challenge's `resource_metadata`, or undefined when there is none.
31
+ * A resource with a path has its metadata at the origin, the path after the well-known part.
32
+ */
33
+ export function protectedResourceMetadataUrl(config, incoming) {
34
+ if ((config.issuers?.issuers.length ?? 0) === 0)
35
+ return undefined;
36
+ const url = new URL(resourceOf(config, incoming));
37
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
38
+ return `${url.origin}${PROTECTED_RESOURCE_PATH}${path}`;
39
+ }
40
+ /** A `WWW-Authenticate: Bearer` value. Every parameter value is plain ASCII without quotes. */
41
+ export function bearerChallenge(challenge = {}) {
42
+ const params = [];
43
+ if (challenge.error)
44
+ params.push(`error="${challenge.error}"`);
45
+ if (challenge.description)
46
+ params.push(`error_description="${challenge.description}"`);
47
+ if (challenge.scope)
48
+ params.push(`scope="${challenge.scope}"`);
49
+ if (challenge.resourceMetadata)
50
+ params.push(`resource_metadata="${challenge.resourceMetadata}"`);
51
+ return params.length === 0 ? "Bearer" : `Bearer ${params.join(", ")}`;
52
+ }
53
+ /**
54
+ * The `401` for a request without a usable credential: `credential_required` when it sent
55
+ * none (no error code, OAuth 2.1 §5.3.2), `credential_invalid` when the Tenant refused the one
56
+ * it sent. The reason is the Runtime's log's, never the client's.
57
+ */
58
+ export function failCredential(kind, resourceMetadata) {
59
+ if (kind === "required")
60
+ throw new HttpError(401, "A bearer credential is required", { code: "credential_required" }, { "www-authenticate": bearerChallenge({ resourceMetadata }) });
61
+ throw new HttpError(401, "The credential is not valid here", { code: "credential_invalid" }, { "www-authenticate": bearerChallenge({ error: "invalid_token", resourceMetadata }) });
62
+ }
@@ -108,7 +108,7 @@ export declare class TenantRuntime implements TenantHandle {
108
108
  drain(activeWork: "drain" | "cancel", timeoutMs?: number): Promise<void>;
109
109
  /**
110
110
  * Aborts the advance of `sessionId` if it runs on this process. Cancel calls it after
111
- * committing `cancelled`; the control stream calls it for cancels made elsewhere.
111
+ * committing `cancelled`; the control bus calls it for cancels made elsewhere.
112
112
  */
113
113
  abortLocal(sessionId: string): void;
114
114
  /**
@@ -13,6 +13,7 @@ import { defaultSandboxBackends } from "../sandbox/select.js";
13
13
  import { MemoryExecution } from "../execution/memory.js";
14
14
  import { openError } from "./cause.js";
15
15
  import { MemoryStreams } from "../streams/memory.js";
16
+ import { closeControl, wireControl } from "./control.js";
16
17
  import { createSessionStreams, endAllStreams, } from "./session-streams.js";
17
18
  import { closeStreams, streamsStatus, wireStreams, } from "./streams.js";
18
19
  import { abortAll, abortLocal, createWorkState, drain, waitForIdle, } from "./scheduler.js";
@@ -85,6 +86,7 @@ export class TenantRuntime {
85
86
  mkdirSync(paths.logs, { recursive: true });
86
87
  const store = hooks.store;
87
88
  let wired;
89
+ let control;
88
90
  let detach;
89
91
  let harness;
90
92
  try {
@@ -303,6 +305,8 @@ export class TenantRuntime {
303
305
  ...(hooks.hostRelay ? { hostRelay: true } : {}),
304
306
  ...(hooks.retireGraceMs !== undefined ? { retireGraceMs: hooks.retireGraceMs } : {}),
305
307
  });
308
+ await wireControl(ctx);
309
+ control = ctx.control;
306
310
  // Register the handlers, then arm the sweep: its first pass runs at once and re-wakes
307
311
  // sessions a previous process left runnable or running (takeover handles the rest).
308
312
  const worker = {
@@ -324,6 +328,7 @@ export class TenantRuntime {
324
328
  await detach?.().catch(() => undefined);
325
329
  await harness?.stop().catch(() => undefined);
326
330
  await wired?.close().catch(() => undefined);
331
+ await control?.close().catch(() => undefined);
327
332
  await hooks.reads?.close().catch(() => undefined);
328
333
  await store.close().catch(() => undefined);
329
334
  throw error;
@@ -350,7 +355,7 @@ export class TenantRuntime {
350
355
  }
351
356
  /**
352
357
  * Aborts the advance of `sessionId` if it runs on this process. Cancel calls it after
353
- * committing `cancelled`; the control stream calls it for cancels made elsewhere.
358
+ * committing `cancelled`; the control bus calls it for cancels made elsewhere.
354
359
  */
355
360
  abortLocal(sessionId) {
356
361
  this.ctx.abortLocal(sessionId);
@@ -402,6 +407,7 @@ export class TenantRuntime {
402
407
  return closeStreams(ctx);
403
408
  },
404
409
  ],
410
+ ["control", () => closeControl(ctx)],
405
411
  ["reads", () => ctx.reads?.close()],
406
412
  ["store", () => ctx.store.close()],
407
413
  ];
@@ -9,7 +9,7 @@ export interface WorkState {
9
9
  export declare function createWorkState(): WorkState;
10
10
  /**
11
11
  * Abort the advance of `id` if it runs on this process.
12
- * Cancel calls it after committing `cancelled`, and so does the control stream for cancels made
12
+ * Cancel calls it after committing `cancelled`, and so does the control bus for cancels made
13
13
  * elsewhere. With `turnId`, an advance of another turn keeps running: the signal came late.
14
14
  */
15
15
  export declare function abortLocal(ctx: TenantContext, id: string, kind?: AdvanceAbortKind, turnId?: string): void;
@@ -27,7 +27,7 @@ function abortWith(controller, kind) {
27
27
  }
28
28
  /**
29
29
  * Abort the advance of `id` if it runs on this process.
30
- * Cancel calls it after committing `cancelled`, and so does the control stream for cancels made
30
+ * Cancel calls it after committing `cancelled`, and so does the control bus for cancels made
31
31
  * elsewhere. With `turnId`, an advance of another turn keeps running: the signal came late.
32
32
  */
33
33
  export function abortLocal(ctx, id, kind = "cancel", turnId) {
@@ -8,23 +8,24 @@
8
8
  * otherwise (tests, a Host without S2) this module runs a relay of the Tenant's own commits,
9
9
  * which reconciles the record with the streams when it starts.
10
10
  * - **Basin generations.** The Tenant's session streams live in its current basin generation
11
- * (`streams/basin.ts`). A reset moves it to the next one (`tenantReset`): the old basin gets
12
- * a `sessions.reset` signal, every process moves its readers, and the old basin is deleted
13
- * after a grace period (again when the Tenant opens, until it is gone).
11
+ * (`streams/basin.ts`). A reset moves it to the next one: its transaction puts a
12
+ * `sessions.reset` signal on the control bus (`control.ts`), every process moves its readers
13
+ * (`tenantReset` here, `checkSessionStreams` elsewhere), and the old basin is deleted after a
14
+ * grace period (again when the Tenant opens, until it is gone).
14
15
  * - **Basin.** The current basin is created when the Tenant opens. A missing basin (S2 down
15
16
  * then, or deleted since) is repaired on first use: a failed `ensureTenant` retries in the background with backoff,
16
17
  * and `streamsStatus` reports the basin's state.
17
18
  * - **Readers.** History and session SSE read the session's stream
18
- * (`tenant/session-streams.ts`). This module runs one `tenant/control` reader per Tenant per
19
- * process, on the current basin, which calls `ctx.abortLocal` for each `session.cancel`
20
- * and checks the session streams for each `sessions.reset`.
19
+ * (`tenant/session-streams.ts`). Those are the only readers: S2 serves API listeners, and
20
+ * nothing internal reads or writes it (D48). Signals between processes go over the control
21
+ * bus on Postgres (`control.ts`).
21
22
  *
22
23
  * The caller passes the streams: the Host's S2 streams, or `MemoryStreams` for tests and a
23
24
  * local development Host (not durable).
24
25
  */
25
26
  import type { SessionStore } from "../store/types.js";
26
27
  import { type StreamRelay, type StreamRelayStatus } from "../streams/relay/core.js";
27
- import { type DurableStreams } from "../streams/types.js";
28
+ import type { DurableStreams } from "../streams/types.js";
28
29
  import type { TenantContext } from "./context.js";
29
30
  export interface WireStreamsOptions {
30
31
  tenantId: string;
@@ -56,7 +57,7 @@ export interface StreamsWiring {
56
57
  readonly relay: StreamRelay | undefined;
57
58
  /** The current basin's state (`streamsStatus`). */
58
59
  basin(): BasinStatus;
59
- /** Moves this process's readers and control reader to basin generation `generation`. */
60
+ /** Moves this process's readers to basin generation `generation`. */
60
61
  moveTo(generation: number): void;
61
62
  /** Deletes the basins of retired generations after the grace period. */
62
63
  retire(generations: readonly number[]): void;
@@ -65,23 +66,16 @@ export interface StreamsWiring {
65
66
  }
66
67
  /**
67
68
  * Wires the Tenant to Durable Streams: reads its basin generation, checks the basin, starts
68
- * the Tenant's relay (unless the Host relays), the control reader and the retired basins'
69
- * deletion, and records the handles on `ctx.sessionStreams.wiring`. Call it once, after
70
- * `ctx` is built and before the Tenant serves requests.
69
+ * the Tenant's relay (unless the Host relays) and the retired basins' deletion, and records
70
+ * the handles on `ctx.sessionStreams.wiring`. Call it once, after `ctx` is built and before
71
+ * the Tenant serves requests.
71
72
  */
72
73
  export declare function wireStreams(ctx: TenantContext, options: WireStreamsOptions): Promise<StreamsWiring>;
73
74
  /**
74
- * Appends `session.cancel` for `sessionId` and its cancelled turn to `tenant/control`, so the
75
- * process running that turn's advance aborts it. Call it from `t.afterCommit` after a cancel
76
- * commits. A lost signal costs latency only (the advance checks the Session Store before every
77
- * effect), so failures are logged, never thrown.
78
- */
79
- export declare function signalSessionCancel(ctx: TenantContext, sessionId: string, turnId: string | null): void;
80
- /**
81
- * After a reset deleted the Tenant's sessions and moved it to a new basin generation: tells
82
- * every process with the Tenant open (`sessions.reset` on the old basin's control stream),
83
- * moves this process's readers, and deletes the old basin after the grace period. Failures
84
- * are logged; the periodic check and the next open finish the job.
75
+ * After a reset deleted the Tenant's sessions and moved it to a new basin generation: moves
76
+ * this process's readers at once and deletes the old basin after the grace period. The other
77
+ * processes move on the reset's `sessions.reset` signal (`control.ts`); the periodic check
78
+ * and the next open finish the job.
85
79
  */
86
80
  export declare function tenantReset(ctx: TenantContext): Promise<void>;
87
81
  /** Tenant status of the streams seam (for `GET /v1/tenant`). */
@@ -1,7 +1,5 @@
1
1
  import { basinOf } from "../streams/basin.js";
2
- import { signalCancel, signalSessionsReset } from "../streams/control.js";
3
2
  import { createStreamRelay, } from "../streams/relay/core.js";
4
- import { CONTROL_STREAM, } from "../streams/types.js";
5
3
  import { checkSessionStreams, currentBasin, sleep, } from "./session-streams.js";
6
4
  const RETRY_MIN_MS = 100;
7
5
  const RETRY_MAX_MS = 2000;
@@ -16,9 +14,9 @@ const RETIRE_GRACE_MS = 60_000;
16
14
  const messageOf = (error) => error instanceof Error ? error.message : String(error);
17
15
  /**
18
16
  * Wires the Tenant to Durable Streams: reads its basin generation, checks the basin, starts
19
- * the Tenant's relay (unless the Host relays), the control reader and the retired basins'
20
- * deletion, and records the handles on `ctx.sessionStreams.wiring`. Call it once, after
21
- * `ctx` is built and before the Tenant serves requests.
17
+ * the Tenant's relay (unless the Host relays) and the retired basins' deletion, and records
18
+ * the handles on `ctx.sessionStreams.wiring`. Call it once, after `ctx` is built and before
19
+ * the Tenant serves requests.
22
20
  */
23
21
  export async function wireStreams(ctx, options) {
24
22
  if (ctx.sessionStreams.wiring)
@@ -53,17 +51,6 @@ export async function wireStreams(ctx, options) {
53
51
  },
54
52
  });
55
53
  relay?.start();
56
- // One control reader, on the current basin; moving to a new generation restarts it there.
57
- let control = new AbortController();
58
- const startControl = () => follow(streams, currentBasin(ctx), CONTROL_STREAM, AbortSignal.any([stop.signal, control.signal]), (body) => {
59
- const signal = body;
60
- if (signal?.type === "session.cancel" && typeof signal.sessionId === "string")
61
- ctx.abortLocal(signal.sessionId, typeof signal.turnId === "string" ? signal.turnId : undefined);
62
- else if (signal?.type === "sessions.reset")
63
- void checkSessionStreams(ctx).catch(report("session stream check failed"));
64
- }, report("control stream read failed; retrying"));
65
- // Signals appended from here on reach this process.
66
- await startControl();
67
54
  // Streams whose session was reset end on the `sessions.reset` signal; this catches lost ones.
68
55
  const feedCheck = setInterval(() => void checkSessionStreams(ctx).catch(report("session stream check failed")), FEED_CHECK_MS);
69
56
  feedCheck.unref();
@@ -78,9 +65,6 @@ export async function wireStreams(ctx, options) {
78
65
  return;
79
66
  ctx.sessionStreams.generation = generation;
80
67
  basin.repair();
81
- control.abort();
82
- control = new AbortController();
83
- void startControl();
84
68
  },
85
69
  retire(retired) {
86
70
  for (const generation of retired) {
@@ -153,33 +137,16 @@ function commitSource(store, tenantId) {
153
137
  };
154
138
  }
155
139
  /**
156
- * Appends `session.cancel` for `sessionId` and its cancelled turn to `tenant/control`, so the
157
- * process running that turn's advance aborts it. Call it from `t.afterCommit` after a cancel
158
- * commits. A lost signal costs latency only (the advance checks the Session Store before every
159
- * effect), so failures are logged, never thrown.
160
- */
161
- export function signalSessionCancel(ctx, sessionId, turnId) {
162
- const streams = ctx.sessionStreams.wiring?.streams;
163
- if (!streams)
164
- return;
165
- void signalCancel(streams, currentBasin(ctx), sessionId, turnId ?? undefined).catch((error) => ctx.config.logger.warn("cancel signal failed", {
166
- sessionId,
167
- message: messageOf(error),
168
- }));
169
- }
170
- /**
171
- * After a reset deleted the Tenant's sessions and moved it to a new basin generation: tells
172
- * every process with the Tenant open (`sessions.reset` on the old basin's control stream),
173
- * moves this process's readers, and deletes the old basin after the grace period. Failures
174
- * are logged; the periodic check and the next open finish the job.
140
+ * After a reset deleted the Tenant's sessions and moved it to a new basin generation: moves
141
+ * this process's readers at once and deletes the old basin after the grace period. The other
142
+ * processes move on the reset's `sessions.reset` signal (`control.ts`); the periodic check
143
+ * and the next open finish the job.
175
144
  */
176
145
  export async function tenantReset(ctx) {
177
146
  const wiring = ctx.sessionStreams.wiring;
178
147
  if (!wiring)
179
148
  return;
180
- const previous = currentBasin(ctx);
181
149
  const generations = await ctx.store.tx((t) => t.basinGenerations());
182
- await signalSessionsReset(wiring.streams, previous, generations.current).catch((error) => ctx.config.logger.warn("sessions.reset signal failed", { message: messageOf(error) }));
183
150
  wiring.moveTo(generations.current);
184
151
  wiring.retire(generations.retired);
185
152
  }
@@ -283,50 +250,3 @@ function basinKeeper(streams, basinKey, signal, onError) {
283
250
  status: () => ({ ready, failures, lastError }),
284
251
  };
285
252
  }
286
- /**
287
- * Follows a signal stream from its tail at start, calling `onRecord` for each record, until
288
- * `signal` aborts. Failed reads retry with backoff; a read that ends by itself (the basin was
289
- * deleted) starts again from the new tail. Resolves once the first tail is known (or failed).
290
- */
291
- function follow(streams, basin, stream, signal, onRecord, onError) {
292
- let started;
293
- const ready = new Promise((resolve) => (started = resolve));
294
- void (async () => {
295
- let from;
296
- let delay = RETRY_MIN_MS;
297
- while (!signal.aborted) {
298
- try {
299
- try {
300
- from ??= await streams.tail(basin, stream);
301
- }
302
- finally {
303
- started();
304
- }
305
- for await (const record of streams.read(basin, stream, from, {
306
- signal,
307
- })) {
308
- from = record.seq + 1;
309
- delay = RETRY_MIN_MS;
310
- try {
311
- onRecord(record.body);
312
- }
313
- catch (error) {
314
- onError(error);
315
- }
316
- }
317
- if (signal.aborted)
318
- return;
319
- from = undefined;
320
- }
321
- catch (error) {
322
- if (signal.aborted)
323
- return;
324
- onError(error);
325
- }
326
- await sleep(delay, signal);
327
- delay = Math.min(delay * 2, RETRY_MAX_MS);
328
- }
329
- started();
330
- })();
331
- return ready;
332
- }
@@ -13,7 +13,9 @@
13
13
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
14
14
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
15
15
  * workspaces). Idle MCP connections of the in-process harness are closed.
16
- * 4. **Hooks.** Callbacks registered with `ctx.onSweep`.
16
+ * 4. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
17
+ * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
18
+ * 5. **Hooks.** Callbacks registered with `ctx.onSweep`.
17
19
  *
18
20
  * Every step runs one transaction per session it changes (a linked agent and its workflow
19
21
  * share one, child first), so the sweep follows the lock order in `store/types.ts`. A failing
@@ -22,6 +24,8 @@
22
24
  */
23
25
  import { pendingAgentEffects, reconcilePendingAgentEffect, } from "../core/flow-host.js";
24
26
  const BATCH = 100;
27
+ /** How long a control signal is kept after it was written. */
28
+ const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
25
29
  export async function sweep(ctx) {
26
30
  if (ctx.closing || ctx.closed)
27
31
  return;
@@ -40,6 +44,10 @@ export async function sweep(ctx) {
40
44
  ["pods", () => reconcileStalePods(ctx, now)],
41
45
  // A harness elsewhere closes its own idle MCP connections.
42
46
  ["mcp", async () => ctx.mcp?.sweep()],
47
+ [
48
+ "signals",
49
+ () => ctx.store.tx((t) => t.pruneSignals(new Date(now.getTime() - SIGNAL_RETENTION_MS))),
50
+ ],
43
51
  ...[...ctx.sweepHooks].map((hook) => ["hook", hook]),
44
52
  ];
45
53
  let failure;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.19.1-beta";
1
+ export declare const RUNTIME_VERSION = "0.20.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.19.1-beta";
3
+ export const RUNTIME_VERSION = "0.20.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.19.1-beta",
3
+ "version": "0.20.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,8 +65,8 @@
65
65
  "@hono/node-server": "^2.1.3",
66
66
  "@hono/zod-openapi": "^1.6.3",
67
67
  "@modelcontextprotocol/sdk": "^1.31.0",
68
- "@nylorun/core": "0.15.0-beta",
69
- "@nylorun/harness": "0.23.0-beta",
68
+ "@nylorun/core": "0.16.0-beta",
69
+ "@nylorun/harness": "0.23.1-beta",
70
70
  "@restatedev/restate-sdk": "1.17.2",
71
71
  "@s2-dev/streamstore": "0.28.0",
72
72
  "aws4fetch": "1.0.20",
@@ -1,13 +0,0 @@
1
- /**
2
- * Signals on a basin's `tenant/control` stream (Durable Streams §8). They are appended
3
- * directly, never recorded: a lost signal costs latency, never correctness (the epoch fence
4
- * stops a cancelled turn's effects; readers also check their sessions periodically).
5
- */
6
- import { type DurableStreams } from "./types.js";
7
- /** Appends a `session.cancel` signal: the process running the session's advance aborts it. */
8
- export declare function signalCancel(streams: DurableStreams, basin: string, sessionId: string, turnId?: string): Promise<void>;
9
- /**
10
- * Appends a `sessions.reset` signal to the old generation's basin: every process with the
11
- * Tenant open moves its readers to `generation` and ends the streams of deleted sessions.
12
- */
13
- export declare function signalSessionsReset(streams: DurableStreams, basin: string, generation: number): Promise<void>;
@@ -1,25 +0,0 @@
1
- /**
2
- * Signals on a basin's `tenant/control` stream (Durable Streams §8). They are appended
3
- * directly, never recorded: a lost signal costs latency, never correctness (the epoch fence
4
- * stops a cancelled turn's effects; readers also check their sessions periodically).
5
- */
6
- import { CONTROL_STREAM } from "./types.js";
7
- /** Appends a `session.cancel` signal: the process running the session's advance aborts it. */
8
- export async function signalCancel(streams, basin, sessionId, turnId) {
9
- if (!sessionId)
10
- throw new Error("sessionId is required");
11
- const signal = {
12
- type: "session.cancel",
13
- sessionId,
14
- ...(turnId ? { turnId } : {}),
15
- };
16
- await streams.append(basin, CONTROL_STREAM, [signal]);
17
- }
18
- /**
19
- * Appends a `sessions.reset` signal to the old generation's basin: every process with the
20
- * Tenant open moves its readers to `generation` and ends the streams of deleted sessions.
21
- */
22
- export async function signalSessionsReset(streams, basin, generation) {
23
- const signal = { type: "sessions.reset", generation };
24
- await streams.append(basin, CONTROL_STREAM, [signal]);
25
- }