@nylorun/runtime 0.19.0-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.
- package/CHANGELOG.md +32 -0
- package/README.md +12 -7
- package/dist/adapters/streams/s2.js +5 -11
- package/dist/api/components.d.ts +171 -169
- package/dist/api/http/define.d.ts +3 -2
- package/dist/api/http/define.js +10 -11
- package/dist/api/openapi.js +45 -22
- package/dist/execution/types.d.ts +1 -1
- package/dist/host/app.d.ts +3 -0
- package/dist/host/app.js +33 -4
- package/dist/host/create-host.d.ts +6 -0
- package/dist/host/create-host.js +1 -0
- package/dist/host/main.js +11 -4
- package/dist/host/stack-config.js +1 -1
- package/dist/infra/index.d.ts +2 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/readiness.d.ts +6 -6
- package/dist/infra/readiness.js +2 -5
- package/dist/management-openapi.json +430 -107
- package/dist/openapi.json +761 -630
- package/dist/store/postgres/connect.d.ts +10 -0
- package/dist/store/postgres/connect.js +30 -3
- package/dist/store/postgres/control.d.ts +14 -0
- package/dist/store/postgres/control.js +187 -0
- package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +120 -0
- package/dist/store/postgres/schema.js +20 -0
- package/dist/store/postgres/store.js +15 -0
- package/dist/store/types.d.ts +40 -0
- package/dist/streams/types.d.ts +6 -29
- package/dist/streams/types.js +5 -4
- package/dist/tenant/auth.d.ts +16 -6
- package/dist/tenant/auth.js +41 -17
- package/dist/tenant/commands.js +10 -6
- package/dist/tenant/context.d.ts +4 -0
- package/dist/tenant/control.d.ts +26 -0
- package/dist/tenant/control.js +35 -0
- package/dist/tenant/ephemeral.js +1 -0
- package/dist/tenant/http.d.ts +4 -2
- package/dist/tenant/http.js +4 -2
- package/dist/tenant/identity-file.d.ts +16 -12
- package/dist/tenant/identity-file.js +41 -29
- package/dist/tenant/issuers.d.ts +4 -3
- package/dist/tenant/issuers.js +22 -16
- package/dist/tenant/reset.d.ts +2 -2
- package/dist/tenant/reset.js +8 -2
- package/dist/tenant/resource-server.d.ts +56 -0
- package/dist/tenant/resource-server.js +62 -0
- package/dist/tenant/runtime.d.ts +1 -1
- package/dist/tenant/runtime.js +7 -1
- package/dist/tenant/scheduler.d.ts +1 -1
- package/dist/tenant/scheduler.js +1 -1
- package/dist/tenant/streams.d.ts +16 -22
- package/dist/tenant/streams.js +7 -87
- package/dist/tenant/sweep.js +9 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/streams/control.d.ts +0 -13
- 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
|
+
}
|
package/dist/tenant/runtime.d.ts
CHANGED
|
@@ -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
|
|
111
|
+
* committing `cancelled`; the control bus calls it for cancels made elsewhere.
|
|
112
112
|
*/
|
|
113
113
|
abortLocal(sessionId: string): void;
|
|
114
114
|
/**
|
package/dist/tenant/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/tenant/scheduler.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/tenant/streams.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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`).
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 {
|
|
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
|
|
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)
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
75
|
-
* process
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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`). */
|
package/dist/tenant/streams.js
CHANGED
|
@@ -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)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
157
|
-
* process
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
}
|
package/dist/tenant/sweep.js
CHANGED
|
@@ -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. **
|
|
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.
|
|
1
|
+
export declare const RUNTIME_VERSION = "0.20.0-beta";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/runtime",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
69
|
-
"@nylorun/harness": "0.23.
|
|
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>;
|
package/dist/streams/control.js
DELETED
|
@@ -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
|
-
}
|