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