@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.
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- 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 +31 -3
- 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 +366 -227
- 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
|
@@ -41,3 +41,13 @@ export declare function createPostgresClient(url: string, options?: PostgresClie
|
|
|
41
41
|
export declare function assertLogicalReplication(sql: Sql): Promise<void>;
|
|
42
42
|
/** Separate, bounded pool for read projections; inherits the Tenant connection credentials. */
|
|
43
43
|
export declare function createPostgresReadClient(source: PostgresClient): PostgresClient;
|
|
44
|
+
/**
|
|
45
|
+
* One connection of its own for `LISTEN` (the control bus, `control.ts`), with `source`'s
|
|
46
|
+
* database and credentials. It never idles out. `onClose` runs when the connection ends for
|
|
47
|
+
* any reason; the caller listens again, which connects again. A `LISTEN` needs a session of
|
|
48
|
+
* its own: behind a pooler in transaction mode it would not hear a thing.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createPostgresListenClient(source: PostgresClient, handlers: {
|
|
51
|
+
onNotify: (channel: string, payload: string) => void;
|
|
52
|
+
onClose: () => void;
|
|
53
|
+
}): PostgresClient;
|
|
@@ -40,9 +40,9 @@ export async function assertLogicalReplication(sql) {
|
|
|
40
40
|
if (!row.replicates)
|
|
41
41
|
throw new Error("The Runtime's Postgres role cannot replicate: grant it REPLICATION (ALTER ROLE … REPLICATION). See DEPLOYMENT.md.");
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/** Where `source` connects, as whom: for clients that share its database and credentials. */
|
|
44
|
+
function connectionOf(source) {
|
|
45
|
+
return {
|
|
46
46
|
host: source.options.host.length === 1
|
|
47
47
|
? source.options.host[0]
|
|
48
48
|
: source.options.host
|
|
@@ -54,6 +54,12 @@ export function createPostgresReadClient(source) {
|
|
|
54
54
|
pass: source.options.pass ?? "",
|
|
55
55
|
ssl: source.options.ssl,
|
|
56
56
|
path: source.options.path,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Separate, bounded pool for read projections; inherits the Tenant connection credentials. */
|
|
60
|
+
export function createPostgresReadClient(source) {
|
|
61
|
+
const options = {
|
|
62
|
+
...connectionOf(source),
|
|
57
63
|
max: 4,
|
|
58
64
|
idle_timeout: 30,
|
|
59
65
|
connect_timeout: 2,
|
|
@@ -69,3 +75,24 @@ export function createPostgresReadClient(source) {
|
|
|
69
75
|
const pool = postgres(options);
|
|
70
76
|
return pool;
|
|
71
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* One connection of its own for `LISTEN` (the control bus, `control.ts`), with `source`'s
|
|
80
|
+
* database and credentials. It never idles out. `onClose` runs when the connection ends for
|
|
81
|
+
* any reason; the caller listens again, which connects again. A `LISTEN` needs a session of
|
|
82
|
+
* its own: behind a pooler in transaction mode it would not hear a thing.
|
|
83
|
+
*/
|
|
84
|
+
export function createPostgresListenClient(source, handlers) {
|
|
85
|
+
const options = {
|
|
86
|
+
...connectionOf(source),
|
|
87
|
+
max: 1,
|
|
88
|
+
idle_timeout: 0,
|
|
89
|
+
max_lifetime: null,
|
|
90
|
+
connect_timeout: 10,
|
|
91
|
+
fetch_types: false,
|
|
92
|
+
onnotice() { },
|
|
93
|
+
onnotify: handlers.onNotify,
|
|
94
|
+
onclose: () => handlers.onClose(),
|
|
95
|
+
connection: { application_name: "nylorun-control" },
|
|
96
|
+
};
|
|
97
|
+
return postgres(options);
|
|
98
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Sql } from "postgres";
|
|
2
|
+
import type { ControlSignal, FollowSignalsOptions, SignalFollower } from "../types.js";
|
|
3
|
+
import type { Database, Queryable } from "./db.js";
|
|
4
|
+
/** The channel every signal is notified on. */
|
|
5
|
+
export declare const CONTROL_CHANNEL = "nylorun_control";
|
|
6
|
+
/** Puts `signal` on the bus in the transaction `db` runs: a row, notified at commit. */
|
|
7
|
+
export declare function writeSignal(db: Queryable, signal: ControlSignal): Promise<void>;
|
|
8
|
+
/** Deletes the signals written before `before`; returns how many. */
|
|
9
|
+
export declare function pruneSignals(db: Queryable, before: Date): Promise<number>;
|
|
10
|
+
/**
|
|
11
|
+
* Follows the bus on `source`'s database (`SessionStore.followSignals`). Resolves once the
|
|
12
|
+
* start is fixed; the first LISTEN may still be retrying, and the poll covers it meanwhile.
|
|
13
|
+
*/
|
|
14
|
+
export declare function followControlSignals(source: Sql, db: Database, onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control bus on Postgres (blueprint D21, D48): how one process tells the others with the
|
|
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
|
|
5
|
+
* internal goes through S2, which only serves API listeners.
|
|
6
|
+
*
|
|
7
|
+
* - **Write.** `writeSignal` inserts a `control_signals` row and calls `pg_notify` on
|
|
8
|
+
* `nylorun_control`, in the caller's transaction. Postgres sends the notification to every
|
|
9
|
+
* listening session when the transaction commits and drops it on rollback, so a signal
|
|
10
|
+
* exists exactly when what it announces committed.
|
|
11
|
+
* - **Follow.** `followControlSignals` holds one connection of its own that LISTENs (a pooled
|
|
12
|
+
* connection cannot). Each notification reads back the signals of the last
|
|
13
|
+
* `WINDOW_SECONDS` and delivers those this follower has not delivered yet. So does a poll
|
|
14
|
+
* every `pollMs`, and every LISTEN after a reconnect: Postgres does not queue notifications
|
|
15
|
+
* for a session that is not connected, and the rows are what catches up. Ids are never read
|
|
16
|
+
* as a cursor: they are allocated at insert and commit out of order, so a later id can be
|
|
17
|
+
* visible first.
|
|
18
|
+
* - **Start.** A follower delivers the signals written from its start on (the database's
|
|
19
|
+
* clock), never older ones.
|
|
20
|
+
* - **Prune.** A row outlives its notification only for the read-back; the Tenant sweep
|
|
21
|
+
* deletes old ones (`pruneSignals`).
|
|
22
|
+
*/
|
|
23
|
+
import { gte, lt, sql } from "drizzle-orm";
|
|
24
|
+
import { createPostgresListenClient } from "./connect.js";
|
|
25
|
+
import { controlSignals } from "./schema.js";
|
|
26
|
+
/** The channel every signal is notified on. */
|
|
27
|
+
export const CONTROL_CHANNEL = "nylorun_control";
|
|
28
|
+
/** How far back a read-back looks. Longer than the poll, so a missed notification is caught. */
|
|
29
|
+
const WINDOW_SECONDS = 120;
|
|
30
|
+
const POLL_MS = 5000;
|
|
31
|
+
const RETRY_MIN_MS = 100;
|
|
32
|
+
const RETRY_MAX_MS = 5000;
|
|
33
|
+
const SIGNAL = {
|
|
34
|
+
id: controlSignals.id,
|
|
35
|
+
kind: controlSignals.kind,
|
|
36
|
+
sessionId: controlSignals.sessionId,
|
|
37
|
+
turnId: controlSignals.turnId,
|
|
38
|
+
generation: controlSignals.generation,
|
|
39
|
+
};
|
|
40
|
+
/** Puts `signal` on the bus in the transaction `db` runs: a row, notified at commit. */
|
|
41
|
+
export async function writeSignal(db, signal) {
|
|
42
|
+
const [row] = await db
|
|
43
|
+
.insert(controlSignals)
|
|
44
|
+
.values({
|
|
45
|
+
kind: signal.type,
|
|
46
|
+
sessionId: signal.type === "session.cancel" ? signal.sessionId : null,
|
|
47
|
+
turnId: signal.type === "session.cancel" ? (signal.turnId ?? null) : null,
|
|
48
|
+
generation: signal.type === "sessions.reset" ? signal.generation : null,
|
|
49
|
+
})
|
|
50
|
+
.returning({ id: controlSignals.id });
|
|
51
|
+
// The payload is only the id: a follower reads the rows back either way.
|
|
52
|
+
await db.execute(sql `SELECT pg_notify(${CONTROL_CHANNEL}, ${String(row.id)})`);
|
|
53
|
+
}
|
|
54
|
+
/** Deletes the signals written before `before`; returns how many. */
|
|
55
|
+
export async function pruneSignals(db, before) {
|
|
56
|
+
const deleted = await db
|
|
57
|
+
.delete(controlSignals)
|
|
58
|
+
.where(lt(controlSignals.createdAt, before.toISOString()))
|
|
59
|
+
.returning({ id: controlSignals.id });
|
|
60
|
+
return deleted.length;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Follows the bus on `source`'s database (`SessionStore.followSignals`). Resolves once the
|
|
64
|
+
* start is fixed; the first LISTEN may still be retrying, and the poll covers it meanwhile.
|
|
65
|
+
*/
|
|
66
|
+
export async function followControlSignals(source, db, onSignal, options = {}) {
|
|
67
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
68
|
+
const onError = options.onError ?? (() => { });
|
|
69
|
+
// Drizzle leaves timestamps to its column types, so a raw read of one is text.
|
|
70
|
+
const [start] = await db.execute(sql `SELECT clock_timestamp()::text AS now`);
|
|
71
|
+
const since = start.now;
|
|
72
|
+
/** Ids delivered, with when (this process's clock), until no read-back can return them. */
|
|
73
|
+
const delivered = new Map();
|
|
74
|
+
let stopped = false;
|
|
75
|
+
let retry;
|
|
76
|
+
let delay = RETRY_MIN_MS;
|
|
77
|
+
let draining;
|
|
78
|
+
let again = false;
|
|
79
|
+
const client = createPostgresListenClient(source, {
|
|
80
|
+
onNotify: (channel) => {
|
|
81
|
+
if (channel === CONTROL_CHANNEL)
|
|
82
|
+
drain();
|
|
83
|
+
},
|
|
84
|
+
// The connection ended (Postgres restarted, the network dropped): listen again, which
|
|
85
|
+
// connects again, and read back what was missed meanwhile.
|
|
86
|
+
onClose: () => relisten(),
|
|
87
|
+
});
|
|
88
|
+
function relisten() {
|
|
89
|
+
if (stopped || retry)
|
|
90
|
+
return;
|
|
91
|
+
retry = setTimeout(() => {
|
|
92
|
+
retry = undefined;
|
|
93
|
+
void listen();
|
|
94
|
+
}, delay);
|
|
95
|
+
retry.unref();
|
|
96
|
+
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
|
97
|
+
}
|
|
98
|
+
async function listen() {
|
|
99
|
+
if (stopped)
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
await client.unsafe(`LISTEN ${CONTROL_CHANNEL}`);
|
|
103
|
+
delay = RETRY_MIN_MS;
|
|
104
|
+
drain();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (stopped)
|
|
108
|
+
return;
|
|
109
|
+
onError(error);
|
|
110
|
+
relisten();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function drain() {
|
|
114
|
+
if (stopped)
|
|
115
|
+
return;
|
|
116
|
+
if (draining) {
|
|
117
|
+
again = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
draining = (async () => {
|
|
121
|
+
do {
|
|
122
|
+
again = false;
|
|
123
|
+
try {
|
|
124
|
+
const rows = await db
|
|
125
|
+
.select(SIGNAL)
|
|
126
|
+
.from(controlSignals)
|
|
127
|
+
.where(gte(controlSignals.createdAt, sql `greatest(${since}::timestamptz, clock_timestamp() - make_interval(secs => ${WINDOW_SECONDS}))`))
|
|
128
|
+
.orderBy(controlSignals.id);
|
|
129
|
+
for (const row of rows)
|
|
130
|
+
deliver(row);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!stopped)
|
|
134
|
+
onError(error);
|
|
135
|
+
}
|
|
136
|
+
forgetOld();
|
|
137
|
+
} while (again && !stopped);
|
|
138
|
+
})().finally(() => {
|
|
139
|
+
draining = undefined;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function deliver(row) {
|
|
143
|
+
if (stopped || delivered.has(row.id))
|
|
144
|
+
return;
|
|
145
|
+
delivered.set(row.id, Date.now());
|
|
146
|
+
const signal = signalOf(row);
|
|
147
|
+
if (!signal)
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
onSignal(signal);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
onError(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A row a read-back can still return is younger than the window: keep twice that. */
|
|
157
|
+
function forgetOld() {
|
|
158
|
+
const before = Date.now() - 2 * WINDOW_SECONDS * 1000;
|
|
159
|
+
for (const [id, at] of delivered)
|
|
160
|
+
if (at < before)
|
|
161
|
+
delivered.delete(id);
|
|
162
|
+
}
|
|
163
|
+
const poll = setInterval(drain, pollMs);
|
|
164
|
+
poll.unref();
|
|
165
|
+
await listen();
|
|
166
|
+
return {
|
|
167
|
+
async close() {
|
|
168
|
+
stopped = true;
|
|
169
|
+
clearInterval(poll);
|
|
170
|
+
if (retry)
|
|
171
|
+
clearTimeout(retry);
|
|
172
|
+
await draining;
|
|
173
|
+
await client.end({ timeout: 0 });
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function signalOf(row) {
|
|
178
|
+
if (row.kind === "session.cancel" && row.sessionId !== null)
|
|
179
|
+
return {
|
|
180
|
+
type: "session.cancel",
|
|
181
|
+
sessionId: row.sessionId,
|
|
182
|
+
...(row.turnId !== null ? { turnId: row.turnId } : {}),
|
|
183
|
+
};
|
|
184
|
+
if (row.kind === "sessions.reset" && row.generation !== null)
|
|
185
|
+
return { type: "sessions.reset", generation: row.generation };
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE "nylorun"."control_signals" (
|
|
2
|
+
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "nylorun"."control_signals_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
|
3
|
+
"kind" text NOT NULL,
|
|
4
|
+
"session_id" text COLLATE "C",
|
|
5
|
+
"turn_id" text COLLATE "C",
|
|
6
|
+
"generation" integer,
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
|
|
8
|
+
CONSTRAINT "control_signals_kind_check" CHECK ("nylorun"."control_signals"."kind" IN ('session.cancel', 'sessions.reset'))
|
|
9
|
+
);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE INDEX "control_signals_created_at" ON "nylorun"."control_signals" USING btree ("created_at");
|
|
@@ -106,6 +106,13 @@
|
|
|
106
106
|
"when": 1791218903443,
|
|
107
107
|
"tag": "0014_action_endpoints_removed",
|
|
108
108
|
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "7",
|
|
113
|
+
"when": 1791296131735,
|
|
114
|
+
"tag": "0015_control_signals",
|
|
115
|
+
"breakpoints": true
|
|
109
116
|
}
|
|
110
117
|
]
|
|
111
118
|
}
|
|
@@ -3478,6 +3478,126 @@ export declare const definitionFileUses: import("drizzle-orm/pg-core").PgTableWi
|
|
|
3478
3478
|
};
|
|
3479
3479
|
dialect: 'pg';
|
|
3480
3480
|
}>;
|
|
3481
|
+
/**
|
|
3482
|
+
* The control bus (D21, D48): signals between the processes that have the Tenant open,
|
|
3483
|
+
* written in the transaction that commits what they announce and notified on
|
|
3484
|
+
* `nylorun_control` at commit (`control.ts`). A row outlives its notification so a process
|
|
3485
|
+
* whose listener was down catches up; the Tenant sweep deletes old ones. `created_at` is the
|
|
3486
|
+
* insert's own clock (`clock_timestamp()`), not its transaction's start.
|
|
3487
|
+
*/
|
|
3488
|
+
export declare const controlSignals: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
3489
|
+
name: "control_signals";
|
|
3490
|
+
schema: "nylorun";
|
|
3491
|
+
columns: {
|
|
3492
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
3493
|
+
name: "id";
|
|
3494
|
+
tableName: "control_signals";
|
|
3495
|
+
dataType: "number";
|
|
3496
|
+
columnType: "PgBigInt53";
|
|
3497
|
+
data: number;
|
|
3498
|
+
driverParam: string | number;
|
|
3499
|
+
notNull: true;
|
|
3500
|
+
hasDefault: true;
|
|
3501
|
+
isPrimaryKey: true;
|
|
3502
|
+
isAutoincrement: false;
|
|
3503
|
+
hasRuntimeDefault: false;
|
|
3504
|
+
enumValues: undefined;
|
|
3505
|
+
baseColumn: never;
|
|
3506
|
+
identity: "always";
|
|
3507
|
+
generated: undefined;
|
|
3508
|
+
}, {}, {}>;
|
|
3509
|
+
kind: import("drizzle-orm/pg-core").PgColumn<{
|
|
3510
|
+
name: "kind";
|
|
3511
|
+
tableName: "control_signals";
|
|
3512
|
+
dataType: "string";
|
|
3513
|
+
columnType: "PgText";
|
|
3514
|
+
data: string;
|
|
3515
|
+
driverParam: string;
|
|
3516
|
+
notNull: true;
|
|
3517
|
+
hasDefault: false;
|
|
3518
|
+
isPrimaryKey: false;
|
|
3519
|
+
isAutoincrement: false;
|
|
3520
|
+
hasRuntimeDefault: false;
|
|
3521
|
+
enumValues: [string, ...string[]];
|
|
3522
|
+
baseColumn: never;
|
|
3523
|
+
identity: undefined;
|
|
3524
|
+
generated: undefined;
|
|
3525
|
+
}, {}, {}>;
|
|
3526
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
3527
|
+
name: "sessionId";
|
|
3528
|
+
tableName: "control_signals";
|
|
3529
|
+
dataType: "custom";
|
|
3530
|
+
columnType: "PgCustomColumn";
|
|
3531
|
+
data: string;
|
|
3532
|
+
driverParam: string;
|
|
3533
|
+
notNull: false;
|
|
3534
|
+
hasDefault: false;
|
|
3535
|
+
isPrimaryKey: false;
|
|
3536
|
+
isAutoincrement: false;
|
|
3537
|
+
hasRuntimeDefault: false;
|
|
3538
|
+
enumValues: undefined;
|
|
3539
|
+
baseColumn: never;
|
|
3540
|
+
identity: undefined;
|
|
3541
|
+
generated: undefined;
|
|
3542
|
+
}, {}, {
|
|
3543
|
+
pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
|
|
3544
|
+
}>;
|
|
3545
|
+
turnId: import("drizzle-orm/pg-core").PgColumn<{
|
|
3546
|
+
name: "turnId";
|
|
3547
|
+
tableName: "control_signals";
|
|
3548
|
+
dataType: "custom";
|
|
3549
|
+
columnType: "PgCustomColumn";
|
|
3550
|
+
data: string;
|
|
3551
|
+
driverParam: string;
|
|
3552
|
+
notNull: false;
|
|
3553
|
+
hasDefault: false;
|
|
3554
|
+
isPrimaryKey: false;
|
|
3555
|
+
isAutoincrement: false;
|
|
3556
|
+
hasRuntimeDefault: false;
|
|
3557
|
+
enumValues: undefined;
|
|
3558
|
+
baseColumn: never;
|
|
3559
|
+
identity: undefined;
|
|
3560
|
+
generated: undefined;
|
|
3561
|
+
}, {}, {
|
|
3562
|
+
pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
|
|
3563
|
+
}>;
|
|
3564
|
+
generation: import("drizzle-orm/pg-core").PgColumn<{
|
|
3565
|
+
name: "generation";
|
|
3566
|
+
tableName: "control_signals";
|
|
3567
|
+
dataType: "number";
|
|
3568
|
+
columnType: "PgInteger";
|
|
3569
|
+
data: number;
|
|
3570
|
+
driverParam: string | number;
|
|
3571
|
+
notNull: false;
|
|
3572
|
+
hasDefault: false;
|
|
3573
|
+
isPrimaryKey: false;
|
|
3574
|
+
isAutoincrement: false;
|
|
3575
|
+
hasRuntimeDefault: false;
|
|
3576
|
+
enumValues: undefined;
|
|
3577
|
+
baseColumn: never;
|
|
3578
|
+
identity: undefined;
|
|
3579
|
+
generated: undefined;
|
|
3580
|
+
}, {}, {}>;
|
|
3581
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
3582
|
+
name: "createdAt";
|
|
3583
|
+
tableName: "control_signals";
|
|
3584
|
+
dataType: "string";
|
|
3585
|
+
columnType: "PgTimestampString";
|
|
3586
|
+
data: string;
|
|
3587
|
+
driverParam: string;
|
|
3588
|
+
notNull: true;
|
|
3589
|
+
hasDefault: true;
|
|
3590
|
+
isPrimaryKey: false;
|
|
3591
|
+
isAutoincrement: false;
|
|
3592
|
+
hasRuntimeDefault: false;
|
|
3593
|
+
enumValues: undefined;
|
|
3594
|
+
baseColumn: never;
|
|
3595
|
+
identity: undefined;
|
|
3596
|
+
generated: undefined;
|
|
3597
|
+
}, {}, {}>;
|
|
3598
|
+
};
|
|
3599
|
+
dialect: 'pg';
|
|
3600
|
+
}>;
|
|
3481
3601
|
/**
|
|
3482
3602
|
* Each sandbox's lifecycle stream (`nylorun.sandbox-event/1`), numbered from 0 per sandbox id.
|
|
3483
3603
|
* Written only by the record module; not published to the stream relay.
|
|
@@ -435,6 +435,26 @@ export const definitionFileUses = nylorun.table("definition_file_uses", {
|
|
|
435
435
|
primaryKey({ name: "definition_file_uses_pkey", columns: [t.agentId, t.manifestHash, t.sha256] }),
|
|
436
436
|
index("definition_file_uses_sha256").on(t.sha256),
|
|
437
437
|
]);
|
|
438
|
+
/**
|
|
439
|
+
* The control bus (D21, D48): signals between the processes that have the Tenant open,
|
|
440
|
+
* written in the transaction that commits what they announce and notified on
|
|
441
|
+
* `nylorun_control` at commit (`control.ts`). A row outlives its notification so a process
|
|
442
|
+
* whose listener was down catches up; the Tenant sweep deletes old ones. `created_at` is the
|
|
443
|
+
* insert's own clock (`clock_timestamp()`), not its transaction's start.
|
|
444
|
+
*/
|
|
445
|
+
export const controlSignals = nylorun.table("control_signals", {
|
|
446
|
+
id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
|
|
447
|
+
kind: text().notNull(),
|
|
448
|
+
sessionId: textC(),
|
|
449
|
+
turnId: textC(),
|
|
450
|
+
generation: integer(),
|
|
451
|
+
createdAt: timestamp({ withTimezone: true, mode: "string" })
|
|
452
|
+
.notNull()
|
|
453
|
+
.default(sql `clock_timestamp()`),
|
|
454
|
+
}, (t) => [
|
|
455
|
+
index("control_signals_created_at").on(t.createdAt),
|
|
456
|
+
check("control_signals_kind_check", sql `${t.kind} IN ('session.cancel', 'sessions.reset')`),
|
|
457
|
+
]);
|
|
438
458
|
// ---------------------------------------------------------------------------
|
|
439
459
|
// nylorun_streams: the record
|
|
440
460
|
/**
|
|
@@ -52,6 +52,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
52
52
|
import { and, count, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
|
|
53
53
|
import { appendEvent, appendSandboxEvent } from "../../record/index.js";
|
|
54
54
|
import { OwnershipLostError, PrincipalRoleConflict } from "../ownership.js";
|
|
55
|
+
import { followControlSignals, pruneSignals, writeSignal } from "./control.js";
|
|
55
56
|
import { database, driverError } from "./db.js";
|
|
56
57
|
import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
|
|
57
58
|
import { createPostgresRecordReader } from "./record.js";
|
|
@@ -175,6 +176,11 @@ class PostgresSessionStore {
|
|
|
175
176
|
return { ok: false, schemaVersion: 0, expectedSchemaVersion };
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
followSignals(onSignal, options) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
throw new Error("SessionStore is closed");
|
|
182
|
+
return followControlSignals(this.sql, this.db, onSignal, options);
|
|
183
|
+
}
|
|
178
184
|
/** Rejects new transactions and waits for running ones. Does not end the pool. */
|
|
179
185
|
async close() {
|
|
180
186
|
this.closed = true;
|
|
@@ -583,6 +589,15 @@ class PostgresTx {
|
|
|
583
589
|
definitions: row.definitions,
|
|
584
590
|
};
|
|
585
591
|
}
|
|
592
|
+
// --- control bus (D21) ---------------------------------------------------
|
|
593
|
+
async signal(signal) {
|
|
594
|
+
this.check();
|
|
595
|
+
await writeSignal(this.db, signal);
|
|
596
|
+
}
|
|
597
|
+
async pruneSignals(before) {
|
|
598
|
+
this.check();
|
|
599
|
+
return pruneSignals(this.db, before);
|
|
600
|
+
}
|
|
586
601
|
// --- basin generations --------------------------------------------------
|
|
587
602
|
async basinGenerations() {
|
|
588
603
|
this.check();
|
package/dist/store/types.d.ts
CHANGED
|
@@ -261,6 +261,39 @@ export interface SessionStore {
|
|
|
261
261
|
record(): RecordReader;
|
|
262
262
|
/** Reachability and schema check for Tenant status and `/ready`. Never throws. */
|
|
263
263
|
health(): Promise<StoreHealth>;
|
|
264
|
+
/**
|
|
265
|
+
* Follows the control bus (D21): calls `onSignal` once for each signal committed after the
|
|
266
|
+
* returned promise resolves, on every process that follows. Delivery is by notification,
|
|
267
|
+
* backed by a poll of recent signals (every `pollMs`, and each time the listener connects
|
|
268
|
+
* again) that catches the notifications a dropped connection missed. `onSignal` must not
|
|
269
|
+
* throw; what it throws goes to `onError`, as do listener failures.
|
|
270
|
+
*/
|
|
271
|
+
followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
|
|
272
|
+
close(): Promise<void>;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
|
|
276
|
+
* Signals are not events: they are never recorded in a session's log or streamed, and a
|
|
277
|
+
* lost one costs latency, never correctness. They never go through S2.
|
|
278
|
+
*/
|
|
279
|
+
export type ControlSignal = {
|
|
280
|
+
/** The process running the session's advance aborts it. */
|
|
281
|
+
type: "session.cancel";
|
|
282
|
+
sessionId: string;
|
|
283
|
+
/** The cancelled turn: an advance of another turn keeps running. Absent: any advance. */
|
|
284
|
+
turnId?: string;
|
|
285
|
+
} | {
|
|
286
|
+
/** The Tenant's sessions were reset and it moved to basin generation `generation`. */
|
|
287
|
+
type: "sessions.reset";
|
|
288
|
+
generation: number;
|
|
289
|
+
};
|
|
290
|
+
export interface FollowSignalsOptions {
|
|
291
|
+
/** How often recent signals are read back. Default 5 s. */
|
|
292
|
+
pollMs?: number;
|
|
293
|
+
onError?: (error: unknown) => void;
|
|
294
|
+
}
|
|
295
|
+
export interface SignalFollower {
|
|
296
|
+
/** Stops following and ends the listener's connection. */
|
|
264
297
|
close(): Promise<void>;
|
|
265
298
|
}
|
|
266
299
|
export interface StoreHealth {
|
|
@@ -393,6 +426,13 @@ export interface Tx {
|
|
|
393
426
|
/** Links of one workflow session joined to their existing agent sessions, ordered by agent session id. */
|
|
394
427
|
linkedSessions<S extends SessionDoc = SessionDoc>(workflowSessionId: string): Promise<LinkedSession<S>[]>;
|
|
395
428
|
counts(): Promise<StoreCounts>;
|
|
429
|
+
/**
|
|
430
|
+
* Puts `signal` on the control bus: a row, and a notification Postgres sends every
|
|
431
|
+
* following process when this transaction commits (and never on rollback).
|
|
432
|
+
*/
|
|
433
|
+
signal(signal: ControlSignal): Promise<void>;
|
|
434
|
+
/** Deletes signals written before `before`. Returns how many. */
|
|
435
|
+
pruneSignals(before: Date): Promise<number>;
|
|
396
436
|
basinGenerations(): Promise<BasinGenerations>;
|
|
397
437
|
/** Forgets a retired generation once its basin is deleted. */
|
|
398
438
|
forgetRetiredGeneration(generation: number): Promise<void>;
|
package/dist/streams/types.d.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* | Stream | Contents | Written by | Read by |
|
|
8
8
|
* | --- | --- | --- | --- |
|
|
9
9
|
* | `sessions/<sessionId>` | every event of the session, in sequence | the stream relay, from the record | history and session SSE |
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* S2 serves API listeners only (history, SSE, AG-UI, A2A): nothing inside the Runtime signals
|
|
12
|
+
* through it or waits on it (D48). Signals between processes go over the control bus on
|
|
13
|
+
* Postgres (`store/postgres/control.ts`).
|
|
11
14
|
*
|
|
12
15
|
* **Basin generations.** A session id is unique within its Tenant's basin generation. A
|
|
13
16
|
* Tenant reset, the only path that frees ids, moves the Tenant to the next generation, so
|
|
@@ -104,38 +107,12 @@ export interface DurableStreams {
|
|
|
104
107
|
listStreams(tenantId: string, prefix: string): Promise<string[]>;
|
|
105
108
|
close(): Promise<void>;
|
|
106
109
|
/**
|
|
107
|
-
* Resolves when the backing service answers, rejects otherwise (
|
|
108
|
-
* `
|
|
110
|
+
* Resolves when the backing service answers, rejects otherwise (the Tenant's status,
|
|
111
|
+
* `streamsStatus` in `tenant/streams.ts`; never readiness, D48). Absent for in-process implementations, which are
|
|
109
112
|
* always reachable.
|
|
110
113
|
*/
|
|
111
114
|
probe?(signal: AbortSignal): Promise<void>;
|
|
112
115
|
}
|
|
113
116
|
export declare const SESSION_STREAM_PREFIX = "sessions/";
|
|
114
|
-
/** `session.cancel` and `sessions.reset` signals for every process with the Tenant open. */
|
|
115
|
-
export declare const CONTROL_STREAM = "tenant/control";
|
|
116
117
|
/** The stream of a session: `sessions/<sessionId>`. */
|
|
117
118
|
export declare function sessionStream(sessionId: string): string;
|
|
118
|
-
/**
|
|
119
|
-
* Signals are not events: they are appended directly, never recorded, and a
|
|
120
|
-
* lost signal costs latency, not correctness.
|
|
121
|
-
*/
|
|
122
|
-
/** Ends the advance of `sessionId` on the process running it. */
|
|
123
|
-
export interface SessionCancelSignal {
|
|
124
|
-
type: "session.cancel";
|
|
125
|
-
sessionId: string;
|
|
126
|
-
/**
|
|
127
|
-
* The cancelled turn. Only an advance of this turn stops, so a signal delivered late (an
|
|
128
|
-
* append retried after S2 returns) never stops a later turn. Absent: any advance stops.
|
|
129
|
-
*/
|
|
130
|
-
turnId?: string;
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* The Tenant was reset and moved to basin generation `generation`. Appended to the old
|
|
134
|
-
* generation's `tenant/control`: each process switches its readers to the new basin and
|
|
135
|
-
* ends the streams of sessions the reset deleted.
|
|
136
|
-
*/
|
|
137
|
-
export interface SessionsResetSignal {
|
|
138
|
-
type: "sessions.reset";
|
|
139
|
-
generation?: number;
|
|
140
|
-
}
|
|
141
|
-
export type ControlSignal = SessionCancelSignal | SessionsResetSignal;
|
package/dist/streams/types.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* | Stream | Contents | Written by | Read by |
|
|
8
8
|
* | --- | --- | --- | --- |
|
|
9
9
|
* | `sessions/<sessionId>` | every event of the session, in sequence | the stream relay, from the record | history and session SSE |
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* S2 serves API listeners only (history, SSE, AG-UI, A2A): nothing inside the Runtime signals
|
|
12
|
+
* through it or waits on it (D48). Signals between processes go over the control bus on
|
|
13
|
+
* Postgres (`store/postgres/control.ts`).
|
|
11
14
|
*
|
|
12
15
|
* **Basin generations.** A session id is unique within its Tenant's basin generation. A
|
|
13
16
|
* Tenant reset, the only path that frees ids, moves the Tenant to the next generation, so
|
|
@@ -39,10 +42,8 @@
|
|
|
39
42
|
* encode them as they need.
|
|
40
43
|
*/
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
42
|
-
// Stream names
|
|
45
|
+
// Stream names
|
|
43
46
|
export const SESSION_STREAM_PREFIX = "sessions/";
|
|
44
|
-
/** `session.cancel` and `sessions.reset` signals for every process with the Tenant open. */
|
|
45
|
-
export const CONTROL_STREAM = "tenant/control";
|
|
46
47
|
/** The stream of a session: `sessions/<sessionId>`. */
|
|
47
48
|
export function sessionStream(sessionId) {
|
|
48
49
|
if (!sessionId)
|
package/dist/tenant/auth.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tenant request authentication and authorization: a bearer token resolves to an application
|
|
3
|
-
* principal or a trusted issuer's token
|
|
4
|
-
* (
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* principal or a trusted issuer's token. Anything else is `401` with a `Bearer` challenge
|
|
4
|
+
* (protocol 9, `resource-server.ts`): `credential_required` without one, `credential_invalid`
|
|
5
|
+
* for one the Tenant refuses, its reason only logged. An application principal may act for a
|
|
6
|
+
* subject (`Nylorun-Subject`, `Nylorun-Scopes`); an issuer token names its subject itself.
|
|
7
|
+
* `requireScopes` limits them to the routes their scopes allow, decided from the route alone.
|
|
7
8
|
*
|
|
8
9
|
* Browsers: a request with `Origin` may carry an issuer token (CORS is the operator's proxy's),
|
|
9
10
|
* never an application key, which is a server secret.
|
|
@@ -21,6 +22,11 @@ export interface KeyAccess {
|
|
|
21
22
|
}
|
|
22
23
|
/** Every route that does not say otherwise is a Runtime API route. */
|
|
23
24
|
export declare const RUNTIME_KEYS: KeyAccess;
|
|
25
|
+
/**
|
|
26
|
+
* The `resource_metadata` a route's challenges point at: a Runtime API route's, when the Runtime
|
|
27
|
+
* trusts issuers. The Management API is no OAuth resource.
|
|
28
|
+
*/
|
|
29
|
+
export declare function challengeMetadata(ctx: TenantContext, request: IncomingMessage, keys: KeyAccess): string | undefined;
|
|
24
30
|
export declare function authenticate(ctx: TenantContext, request: IncomingMessage, keys?: KeyAccess): Promise<AuthScope>;
|
|
25
31
|
/**
|
|
26
32
|
* What a subject needs for a route: any one of the scopes, nothing grants it (`never`), or
|
|
@@ -30,9 +36,13 @@ export declare function authenticate(ctx: TenantContext, request: IncomingMessag
|
|
|
30
36
|
export type SubjectAccess = readonly SubjectScope[] | "never" | "any";
|
|
31
37
|
/**
|
|
32
38
|
* A subject or a token caller reaches a route only with one of the scopes it declares:
|
|
33
|
-
* `403 scope_required`, decided from the route alone before anything is read.
|
|
39
|
+
* `403 scope_required`, decided from the route alone before anything is read. A token caller
|
|
40
|
+
* also gets the OAuth challenge (`error="insufficient_scope"` and the scopes that would do), so
|
|
41
|
+
* its client can ask its identity provider for them; a route no scope reaches has none.
|
|
34
42
|
*/
|
|
35
|
-
export declare function requireScopes(scope: AuthScope, access: SubjectAccess
|
|
43
|
+
export declare function requireScopes(scope: AuthScope, access: SubjectAccess,
|
|
44
|
+
/** The route's `resource_metadata` (`challengeMetadata`), for a token caller's challenge. */
|
|
45
|
+
resourceMetadata?: string): void;
|
|
36
46
|
export declare function accessOf(scope: AuthScope): SessionAccess | undefined;
|
|
37
47
|
/** The subject whose sessions the request is limited to, if it acts for one. */
|
|
38
48
|
export declare function ownerOf(scope: AuthScope): string | undefined;
|