@nylorun/runtime 0.21.1-beta → 0.23.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -3
- package/dist/adapters/execution/restate.d.ts +15 -3
- package/dist/adapters/execution/restate.js +77 -83
- package/dist/adapters/sandbox/local.js +1 -1
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.js +1 -13
- package/dist/api/components.d.ts +42 -2
- package/dist/core/flow-host.d.ts +1 -19
- package/dist/core/flow-host.js +8 -37
- package/dist/execution/types.d.ts +10 -8
- package/dist/execution/types.js +5 -7
- 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/execution.d.ts +8 -6
- package/dist/host/gates.js +11 -0
- package/dist/host/main.js +10 -16
- package/dist/host/stack-config.d.ts +6 -0
- package/dist/host/stack-config.js +6 -0
- package/dist/infra/execution.d.ts +18 -1
- package/dist/infra/execution.js +28 -4
- package/dist/infra/index.d.ts +1 -1
- 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/retry.d.ts +21 -0
- package/dist/retry.js +34 -0
- package/dist/sandbox/join.d.ts +2 -0
- package/dist/sandbox/join.js +14 -10
- package/dist/sandbox/manager.d.ts +2 -0
- package/dist/sandbox/manager.js +1 -1
- package/dist/sandbox/pods/reconcile.js +3 -3
- 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/control.js +8 -1
- package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
- package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +265 -4
- package/dist/store/postgres/schema.js +35 -4
- package/dist/store/postgres/store.js +165 -52
- package/dist/store/types.d.ts +96 -39
- package/dist/streams/relay/core.js +1 -13
- package/dist/tenant/advance.js +6 -7
- package/dist/tenant/commands.js +1 -2
- package/dist/tenant/context.d.ts +19 -11
- package/dist/tenant/control.d.ts +8 -1
- package/dist/tenant/control.js +2 -0
- package/dist/tenant/runtime.js +23 -3
- package/dist/tenant/sandboxes.d.ts +2 -1
- package/dist/tenant/sandboxes.js +23 -19
- package/dist/tenant/scheduler.d.ts +7 -0
- package/dist/tenant/scheduler.js +38 -7
- package/dist/tenant/session-streams.d.ts +0 -1
- package/dist/tenant/session-streams.js +1 -13
- package/dist/tenant/sessions.js +26 -0
- package/dist/tenant/streams.js +14 -29
- package/dist/tenant/sweep.d.ts +39 -12
- package/dist/tenant/sweep.js +157 -58
- 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 +4 -3
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Every `tx` is one READ COMMITTED transaction on a pooled connection (Drizzle's
|
|
17
17
|
* `transaction` on postgres.js `begin`). Tables are addressed by schema-qualified names,
|
|
18
|
-
* never through `search_path`. Commit listeners and
|
|
18
|
+
* never through `search_path`. Commit listeners and outbox deliveries run after `COMMIT`
|
|
19
19
|
* returns and never reject the committed `tx`. Nested `tx` calls are detected with
|
|
20
20
|
* `AsyncLocalStorage` and rejected, and a `Tx` rejects every call once its callback settles.
|
|
21
21
|
* A failed statement rejects `tx` with the driver's error (`driverError`), not Drizzle's
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
52
52
|
import { randomUUID } from "node:crypto";
|
|
53
|
-
import { and, count, eq, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
|
|
53
|
+
import { and, count, eq, getTableColumns, gte, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql, } from "drizzle-orm";
|
|
54
54
|
import { appendEvent, appendSandboxEvent } from "../../record/index.js";
|
|
55
55
|
import { OwnershipLostError, PrincipalRoleConflict } from "../ownership.js";
|
|
56
56
|
import { followControlSignals, pruneSignals, writeSignal } from "./control.js";
|
|
@@ -58,7 +58,7 @@ import { database, driverError } from "./db.js";
|
|
|
58
58
|
import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
|
|
59
59
|
import { createPostgresRecordReader } from "./record.js";
|
|
60
60
|
import { postgresRecordWriter, postgresSandboxRecordWriter } from "./record-writer.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
|
+
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, sandboxSignals, wakes, } from "./schema.js";
|
|
62
62
|
export function createPostgresSessionStore(options) {
|
|
63
63
|
return new PostgresSessionStore(options);
|
|
64
64
|
}
|
|
@@ -88,6 +88,9 @@ class PostgresSessionStore {
|
|
|
88
88
|
active = new AsyncLocalStorage();
|
|
89
89
|
listeners = new Set();
|
|
90
90
|
inflight = new Set();
|
|
91
|
+
/** Deliveries of committed outbox requests still running (`deliverTo`). */
|
|
92
|
+
deliveries = new Set();
|
|
93
|
+
deliver;
|
|
91
94
|
now;
|
|
92
95
|
onError;
|
|
93
96
|
closed = false;
|
|
@@ -111,7 +114,7 @@ class PostgresSessionStore {
|
|
|
111
114
|
let t;
|
|
112
115
|
// Wrapped so `begin` does not treat an array result as queries to await.
|
|
113
116
|
const run = this.db.transaction(async (db) => {
|
|
114
|
-
t = new PostgresTx(db, this.tenantId, this.now
|
|
117
|
+
t = new PostgresTx(db, this.tenantId, this.now);
|
|
115
118
|
try {
|
|
116
119
|
return { value: await this.active.run(this, () => fn(t)) };
|
|
117
120
|
}
|
|
@@ -144,20 +147,51 @@ class PostgresSessionStore {
|
|
|
144
147
|
}
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await callback();
|
|
150
|
-
}
|
|
151
|
-
catch (error) {
|
|
152
|
-
this.onError(error);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
150
|
+
if (t.outbox.length > 0)
|
|
151
|
+
this.send(t.outbox);
|
|
155
152
|
return result.value;
|
|
156
153
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
deliverTo(deliver) {
|
|
155
|
+
this.deliver = deliver;
|
|
156
|
+
}
|
|
157
|
+
async delivered() {
|
|
158
|
+
while (this.deliveries.size > 0)
|
|
159
|
+
await Promise.allSettled([...this.deliveries]);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Hands a commit's outbox requests to the delivery, one after the other, without the caller
|
|
163
|
+
* waiting: a delivered one's row is deleted, and one that was not is due at the sweep's next
|
|
164
|
+
* pass.
|
|
165
|
+
*/
|
|
166
|
+
send(committed) {
|
|
167
|
+
const deliver = this.deliver;
|
|
168
|
+
if (!deliver)
|
|
169
|
+
return;
|
|
170
|
+
const run = (async () => {
|
|
171
|
+
for (const { kind, id, request } of committed) {
|
|
172
|
+
let sent = false;
|
|
173
|
+
try {
|
|
174
|
+
sent = await deliver(request);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
this.onError(error);
|
|
178
|
+
}
|
|
179
|
+
const table = OUTBOX[kind];
|
|
180
|
+
try {
|
|
181
|
+
if (sent === false)
|
|
182
|
+
await this.db.update(table).set({ retryAt: sql `clock_timestamp()` }).where(eq(table.id, id));
|
|
183
|
+
else
|
|
184
|
+
await this.db.delete(table).where(eq(table.id, id));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
// The row stays as it was: the sweep sends it again, under the same key.
|
|
188
|
+
this.onError(error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
this.deliveries.add(run);
|
|
193
|
+
void run.finally(() => this.deliveries.delete(run));
|
|
194
|
+
}
|
|
161
195
|
onCommit(listener) {
|
|
162
196
|
this.listeners.add(listener);
|
|
163
197
|
return () => this.listeners.delete(listener);
|
|
@@ -186,10 +220,14 @@ class PostgresSessionStore {
|
|
|
186
220
|
throw new Error("SessionStore is closed");
|
|
187
221
|
return followControlSignals(this.sql, this.db, onSignal, options);
|
|
188
222
|
}
|
|
189
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Rejects new transactions and waits for running ones, and for the deliveries of committed
|
|
225
|
+
* outbox requests. Does not end the pool.
|
|
226
|
+
*/
|
|
190
227
|
async close() {
|
|
191
228
|
this.closed = true;
|
|
192
229
|
await Promise.allSettled([...this.inflight]);
|
|
230
|
+
await this.delivered();
|
|
193
231
|
}
|
|
194
232
|
}
|
|
195
233
|
function storedSession(row) {
|
|
@@ -262,26 +300,49 @@ const SIGNING_KEY_STAMP = {
|
|
|
262
300
|
/** The audit columns a row has (`ord` only orders them). */
|
|
263
301
|
const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
|
|
264
302
|
const SESSION_TABLES = [sessions, commands, effects, links, wakes];
|
|
265
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* The outbox's tables, by the kind of request each holds. Their delivery columns (`id`,
|
|
305
|
+
* `created_at`, `attempts`, `retry_at`, `parked_at`) are the same.
|
|
306
|
+
*/
|
|
307
|
+
const OUTBOX = {
|
|
308
|
+
wake: wakes,
|
|
309
|
+
sandbox: sandboxSignals,
|
|
310
|
+
};
|
|
311
|
+
/** A wake outbox row as it is delivered: its own dedupe key, or one made from the row id. */
|
|
266
312
|
function outboxWake(id, reason, dedupeKey) {
|
|
267
313
|
return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
|
|
268
314
|
}
|
|
315
|
+
/** A sandbox signal outbox row as it is delivered: a reconcile is keyed by the row. */
|
|
316
|
+
function outboxSignal(id, row) {
|
|
317
|
+
return row.kind === "arm"
|
|
318
|
+
? { kind: "arm", timer: row.timer, at: row.at }
|
|
319
|
+
: { kind: "reconcile", key: `signal:${id}` };
|
|
320
|
+
}
|
|
321
|
+
/** When an outbox row of `table` is due, after a grace of `graceMs` (the database's clock). */
|
|
322
|
+
function dueAt(table, graceMs) {
|
|
323
|
+
const grace = sql `make_interval(secs => ${Math.max(0, graceMs) / 1000})`;
|
|
324
|
+
return sql `coalesce(${table.retryAt}, ${table.createdAt} + ${grace})`;
|
|
325
|
+
}
|
|
326
|
+
/** Due rows of `table`, soonest first: their epoch seconds, to merge two tables' rows. */
|
|
327
|
+
const DUE = (table, graceMs) => ({
|
|
328
|
+
due: dueAt(table, graceMs),
|
|
329
|
+
dueSeconds: sql `extract(epoch from ${dueAt(table, graceMs)})::float8`,
|
|
330
|
+
});
|
|
269
331
|
/** The inserted row's `column` (`ON CONFLICT … DO UPDATE`). */
|
|
270
332
|
const excluded = (column) => sql.raw(`excluded.${column}`);
|
|
271
333
|
class PostgresTx {
|
|
272
334
|
db;
|
|
273
335
|
tenantId;
|
|
274
336
|
now;
|
|
275
|
-
deleteWake;
|
|
276
337
|
closed = false;
|
|
277
338
|
events = [];
|
|
278
339
|
generations = [];
|
|
279
|
-
|
|
280
|
-
|
|
340
|
+
/** The outbox requests this transaction wrote, delivered after it commits. */
|
|
341
|
+
outbox = [];
|
|
342
|
+
constructor(db, tenantId, now) {
|
|
281
343
|
this.db = db;
|
|
282
344
|
this.tenantId = tenantId;
|
|
283
345
|
this.now = now;
|
|
284
|
-
this.deleteWake = deleteWake;
|
|
285
346
|
}
|
|
286
347
|
check() {
|
|
287
348
|
if (this.closed)
|
|
@@ -352,12 +413,8 @@ class PostgresTx {
|
|
|
352
413
|
this.generations.push(generation);
|
|
353
414
|
return structuredClone(event);
|
|
354
415
|
}
|
|
355
|
-
afterCommit(fn) {
|
|
356
|
-
this.check();
|
|
357
|
-
this.callbacks.push(fn);
|
|
358
|
-
}
|
|
359
416
|
// --- wake outbox ---------------------------------------------------------
|
|
360
|
-
async wake(sessionId, wake
|
|
417
|
+
async wake(sessionId, wake) {
|
|
361
418
|
this.check();
|
|
362
419
|
const id = randomUUID();
|
|
363
420
|
await this.db.insert(wakes).values({
|
|
@@ -366,34 +423,90 @@ class PostgresTx {
|
|
|
366
423
|
reason: wake.reason,
|
|
367
424
|
dedupeKey: wake.dedupeKey ?? null,
|
|
368
425
|
});
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return;
|
|
374
|
-
await this.deleteWake(id);
|
|
426
|
+
this.outbox.push({
|
|
427
|
+
kind: "wake",
|
|
428
|
+
id,
|
|
429
|
+
request: { kind: "wake", sessionId, wake: outboxWake(id, wake.reason, wake.dedupeKey ?? null) },
|
|
375
430
|
});
|
|
376
431
|
}
|
|
377
|
-
async
|
|
432
|
+
async signalSandbox(sandboxId, signal) {
|
|
378
433
|
this.check();
|
|
379
|
-
const
|
|
380
|
-
|
|
434
|
+
const id = randomUUID();
|
|
435
|
+
const row = {
|
|
436
|
+
kind: signal.kind,
|
|
437
|
+
timer: signal.kind === "arm" ? signal.timer : null,
|
|
438
|
+
at: signal.kind === "arm" ? signal.at : null,
|
|
439
|
+
};
|
|
440
|
+
await this.db.insert(sandboxSignals).values({ id, sandboxId, ...row });
|
|
441
|
+
this.outbox.push({
|
|
442
|
+
kind: "sandbox",
|
|
443
|
+
id,
|
|
444
|
+
request: { kind: "sandbox", sandboxId, signal: outboxSignal(id, row) },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
async pendingOutbox(graceMs, limit) {
|
|
448
|
+
this.check();
|
|
449
|
+
// Compared on the database's clock, which wrote `created_at` and `retry_at`.
|
|
450
|
+
const now = sql `clock_timestamp()`;
|
|
451
|
+
const wake = DUE(wakes, graceMs);
|
|
452
|
+
const signal = DUE(OUTBOX.sandbox, graceMs);
|
|
453
|
+
const wakeRows = await this.db
|
|
454
|
+
.select({ ...getTableColumns(wakes), dueSeconds: wake.dueSeconds })
|
|
381
455
|
.from(wakes)
|
|
382
|
-
.where(
|
|
383
|
-
.orderBy(
|
|
456
|
+
.where(and(isNull(wakes.parkedAt), lte(wake.due, now)))
|
|
457
|
+
.orderBy(wake.due, wakes.id)
|
|
384
458
|
.limit(limit);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
459
|
+
const signalRows = await this.db
|
|
460
|
+
.select({ ...getTableColumns(sandboxSignals), dueSeconds: signal.dueSeconds })
|
|
461
|
+
.from(sandboxSignals)
|
|
462
|
+
.where(and(isNull(sandboxSignals.parkedAt), lte(signal.due, now)))
|
|
463
|
+
.orderBy(signal.due, sandboxSignals.id)
|
|
464
|
+
.limit(limit);
|
|
465
|
+
const rows = [
|
|
466
|
+
...wakeRows.map((row) => ({
|
|
467
|
+
kind: "wake",
|
|
468
|
+
id: row.id,
|
|
469
|
+
request: {
|
|
470
|
+
kind: "wake",
|
|
471
|
+
sessionId: row.sessionId,
|
|
472
|
+
wake: outboxWake(row.id, row.reason, row.dedupeKey),
|
|
473
|
+
},
|
|
474
|
+
createdAt: row.createdAt.toISOString(),
|
|
475
|
+
attempts: row.attempts,
|
|
476
|
+
due: row.dueSeconds,
|
|
477
|
+
})),
|
|
478
|
+
...signalRows.map((row) => ({
|
|
479
|
+
kind: "sandbox",
|
|
480
|
+
id: row.id,
|
|
481
|
+
request: { kind: "sandbox", sandboxId: row.sandboxId, signal: outboxSignal(row.id, row) },
|
|
482
|
+
createdAt: row.createdAt.toISOString(),
|
|
483
|
+
attempts: row.attempts,
|
|
484
|
+
due: row.dueSeconds,
|
|
485
|
+
})),
|
|
486
|
+
];
|
|
487
|
+
rows.sort((a, b) => a.due - b.due || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
488
|
+
return rows.slice(0, limit).map(({ due: _due, ...row }) => row);
|
|
489
|
+
}
|
|
490
|
+
async deleteOutbox(rows) {
|
|
491
|
+
this.check();
|
|
492
|
+
for (const kind of ["wake", "sandbox"]) {
|
|
493
|
+
const ids = rows.filter((row) => row.kind === kind).map((row) => row.id);
|
|
494
|
+
if (ids.length > 0)
|
|
495
|
+
await this.db.delete(OUTBOX[kind]).where(inArray(OUTBOX[kind].id, ids));
|
|
496
|
+
}
|
|
391
497
|
}
|
|
392
|
-
async
|
|
498
|
+
async failOutbox(row, retry) {
|
|
393
499
|
this.check();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
await this.db
|
|
500
|
+
const table = OUTBOX[row.kind];
|
|
501
|
+
const secs = Math.max(0, retry.retryInMs) / 1000;
|
|
502
|
+
await this.db
|
|
503
|
+
.update(table)
|
|
504
|
+
.set({
|
|
505
|
+
attempts: sql `${table.attempts} + 1`,
|
|
506
|
+
retryAt: sql `clock_timestamp() + make_interval(secs => ${secs})`,
|
|
507
|
+
...(retry.park ? { parkedAt: sql `clock_timestamp()` } : {}),
|
|
508
|
+
})
|
|
509
|
+
.where(eq(table.id, row.id));
|
|
397
510
|
}
|
|
398
511
|
// --- ownership -----------------------------------------------------------
|
|
399
512
|
async takeOwnership(sessionId, claim) {
|
|
@@ -470,12 +583,11 @@ class PostgresTx {
|
|
|
470
583
|
.orderBy(sessions.id);
|
|
471
584
|
return rows.map((row) => storedSession(row));
|
|
472
585
|
}
|
|
473
|
-
async orphanedSessions(now, limit) {
|
|
586
|
+
async orphanedSessions(now, limit, after) {
|
|
474
587
|
this.check();
|
|
475
588
|
const rows = await this.sessions()
|
|
476
|
-
.where(and(inArray(sessions.status, OPEN_SESSION), or(isNull(sessions.owner), isNull(sessions.ownerExpiresAt), lte(sessions.ownerExpiresAt, now))))
|
|
477
|
-
|
|
478
|
-
.orderBy(sql `CASE WHEN ${sessions.owner} IS NULL THEN NULL ELSE ${sessions.ownerExpiresAt} END ASC NULLS FIRST`, sessions.id)
|
|
589
|
+
.where(and(inArray(sessions.status, OPEN_SESSION), or(isNull(sessions.owner), isNull(sessions.ownerExpiresAt), lte(sessions.ownerExpiresAt, now)), after === undefined ? undefined : gt(sessions.id, after)))
|
|
590
|
+
.orderBy(sessions.id)
|
|
479
591
|
.limit(limit);
|
|
480
592
|
return rows.map((row) => storedSession(row));
|
|
481
593
|
}
|
|
@@ -1120,6 +1232,7 @@ class PostgresTx {
|
|
|
1120
1232
|
await db.delete(sandboxes);
|
|
1121
1233
|
await db.delete(sandboxResources);
|
|
1122
1234
|
await db.delete(sandboxEvents);
|
|
1235
|
+
await db.delete(sandboxSignals);
|
|
1123
1236
|
}
|
|
1124
1237
|
if (scope === "all") {
|
|
1125
1238
|
await db.delete(definitions);
|
package/dist/store/types.d.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* 1. **One transaction per `tx` call, READ COMMITTED or stronger.** Nothing a
|
|
11
11
|
* transaction wrote is visible to others before it commits, and nothing is
|
|
12
12
|
* kept when `fn` throws: document writes, event sequences, record rows,
|
|
13
|
-
* and
|
|
14
|
-
*
|
|
13
|
+
* and outbox requests are all discarded, and `tx` rejects with the error `fn`
|
|
14
|
+
* threw.
|
|
15
15
|
* 2. **Session-scoped writes lock the session row first.** `lockSession` takes
|
|
16
16
|
* a row lock (`SELECT … FOR UPDATE`) held until the transaction ends. Effect
|
|
17
17
|
* intent and outcome, checkpoint settlement and event
|
|
@@ -25,18 +25,22 @@
|
|
|
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
|
-
*
|
|
28
|
+
* S2 call, and no `fetch`, runs inside a transaction. What a transaction asks of
|
|
29
|
+
* Durable Session Execution (a wake, a pod sandbox's reconcile or timer) is written
|
|
30
|
+
* to the outbox in the transaction (`Tx.wake`, `Tx.signalSandbox`) and delivered
|
|
31
|
+
* after commit; what it tells the other processes goes on the control bus
|
|
32
|
+
* (`Tx.signal`); and events are delivered to commit listeners after commit (seam
|
|
33
|
+
* rule 1 and 2).
|
|
31
34
|
* 5. **No nested transactions.** Calling `store.tx` from inside `fn` rejects.
|
|
32
35
|
* A `Tx` must not be used after its `tx` call settles.
|
|
33
36
|
* 6. **Post-commit order.** After a commit, the store first calls every commit
|
|
34
|
-
* listener once with the transaction's events (in allocation order), then
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* again
|
|
37
|
+
* listener once with the transaction's events (in allocation order), then hands
|
|
38
|
+
* the transaction's outbox requests to the delivery (`deliverTo`), in the order
|
|
39
|
+
* they were written, without the caller waiting. Listener and delivery failures
|
|
40
|
+
* are reported to the store's error hook; they never reject `tx`, because the
|
|
41
|
+
* commit stands. A request whose delivery fails, or that a crash cuts off, stays
|
|
42
|
+
* in the outbox, which the Tenant sweep delivers again. Nothing else runs after
|
|
43
|
+
* commit: a step that is not written down is lost to a crash.
|
|
40
44
|
* 7. **Documents are values.** `get` and queries return fresh copies; mutating
|
|
41
45
|
* them changes nothing until `put`.
|
|
42
46
|
* 8. **Ownership columns are store-managed.** `owner`, `epoch` and
|
|
@@ -58,7 +62,7 @@
|
|
|
58
62
|
*/
|
|
59
63
|
import type { KeyRole } from "@nylorun/core/compatibility";
|
|
60
64
|
import type { RecordReader } from "../streams/relay/types.js";
|
|
61
|
-
import type { Wake } from "../execution/types.js";
|
|
65
|
+
import type { SandboxSignal, Wake } from "../execution/types.js";
|
|
62
66
|
import type { EventPayload, EventType, LiveEvent, SandboxEvent, SandboxEventPayload, SandboxEventType, SandboxKind, SandboxPodDesired, SandboxPodObserved, SessionEventOf } from "@nylorun/core/contracts";
|
|
63
67
|
import type { SandboxManifest } from "@nylorun/core/define";
|
|
64
68
|
import type { HostEffect } from "@nylorun/harness/run";
|
|
@@ -272,6 +276,14 @@ export interface SessionStore {
|
|
|
272
276
|
* throw; what it throws goes to `onError`, as do listener failures.
|
|
273
277
|
*/
|
|
274
278
|
followSignals(onSignal: (signal: ControlSignal) => void, options?: FollowSignalsOptions): Promise<SignalFollower>;
|
|
279
|
+
/**
|
|
280
|
+
* Sets where committed outbox requests go (`Tx.wake`, `Tx.signalSandbox`): after each
|
|
281
|
+
* commit the store hands them to `deliver` without waiting for it, so a slow execution
|
|
282
|
+
* never holds up the caller. Without one, they wait in the outbox for the Tenant sweep.
|
|
283
|
+
*/
|
|
284
|
+
deliverTo(deliver: OutboxDelivery | undefined): void;
|
|
285
|
+
/** Resolves once every delivery started so far has settled (`close` waits for them too). */
|
|
286
|
+
delivered(): Promise<void>;
|
|
275
287
|
close(): Promise<void>;
|
|
276
288
|
}
|
|
277
289
|
/** A wake as the outbox delivers it: its `dedupeKey` is the request's idempotency key. */
|
|
@@ -279,18 +291,38 @@ export type OutboxWake = Wake & {
|
|
|
279
291
|
dedupeKey: string;
|
|
280
292
|
};
|
|
281
293
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
294
|
+
* What a committed transaction asked of Durable Session Execution, as the outbox delivers it:
|
|
295
|
+
* a wake (`Tx.wake`), or a pod sandbox's reconcile or timer (`Tx.signalSandbox`; a reconcile
|
|
296
|
+
* carries its idempotency key, `signal:<row id>`).
|
|
285
297
|
*/
|
|
286
|
-
export type
|
|
287
|
-
|
|
288
|
-
export interface PendingWake {
|
|
289
|
-
id: string;
|
|
298
|
+
export type OutboxRequest = {
|
|
299
|
+
kind: "wake";
|
|
290
300
|
sessionId: string;
|
|
291
301
|
wake: OutboxWake;
|
|
302
|
+
} | {
|
|
303
|
+
kind: "sandbox";
|
|
304
|
+
sandboxId: string;
|
|
305
|
+
signal: SandboxSignal;
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Hands an outbox request to Durable Session Execution (`SessionStore.deliverTo`). Resolving
|
|
309
|
+
* means the execution accepted it; `false` means it was not delivered and stays in the
|
|
310
|
+
* outbox, due at the sweep's next pass, as after a rejection, which also goes to the store's
|
|
311
|
+
* error hook.
|
|
312
|
+
*/
|
|
313
|
+
export type OutboxDelivery = (request: OutboxRequest) => boolean | void | Promise<boolean | void>;
|
|
314
|
+
/** An outbox row: which table (`kind`) and its id. */
|
|
315
|
+
export type OutboxRef = {
|
|
316
|
+
kind: OutboxRequest["kind"];
|
|
317
|
+
id: string;
|
|
318
|
+
};
|
|
319
|
+
/** An outbox request not yet delivered (`Tx.pendingOutbox`). */
|
|
320
|
+
export interface PendingRequest extends OutboxRef {
|
|
321
|
+
request: OutboxRequest;
|
|
292
322
|
/** ISO time it was written. */
|
|
293
323
|
createdAt: string;
|
|
324
|
+
/** The sweep's failed deliveries of it so far (`failOutbox`). */
|
|
325
|
+
attempts: number;
|
|
294
326
|
}
|
|
295
327
|
/**
|
|
296
328
|
* A signal on the control bus (D21, D48): one process tells the others with the Tenant open.
|
|
@@ -307,6 +339,15 @@ export type ControlSignal = {
|
|
|
307
339
|
/** The Tenant's sessions were reset and it moved to basin generation `generation`. */
|
|
308
340
|
type: "sessions.reset";
|
|
309
341
|
generation: number;
|
|
342
|
+
} | {
|
|
343
|
+
/**
|
|
344
|
+
* Pod sandbox `sandboxId`'s host epoch moved to `epoch` (a join, a stop, a reset, a
|
|
345
|
+
* deletion, a loss): every process closes its connections hosting it at an older epoch
|
|
346
|
+
* (`HarnessApiServer.revokeHost`).
|
|
347
|
+
*/
|
|
348
|
+
type: "host.revoked";
|
|
349
|
+
sandboxId: string;
|
|
350
|
+
epoch: number;
|
|
310
351
|
};
|
|
311
352
|
export interface FollowSignalsOptions {
|
|
312
353
|
/** How often recent signals are read back. Default 5 s. */
|
|
@@ -327,7 +368,7 @@ export interface SessionStoreOptions {
|
|
|
327
368
|
tenantId: string;
|
|
328
369
|
/** Clock for event `time`. Defaults to `() => new Date()`. */
|
|
329
370
|
now?: () => Date;
|
|
330
|
-
/** Receives listener and
|
|
371
|
+
/** Receives listener and outbox delivery failures, which never reject `tx`. */
|
|
331
372
|
onError?: (error: unknown) => void;
|
|
332
373
|
}
|
|
333
374
|
/** The transaction handle. Valid only inside its `tx` callback. */
|
|
@@ -366,28 +407,43 @@ export interface Tx {
|
|
|
366
407
|
* cursor is final.
|
|
367
408
|
*/
|
|
368
409
|
event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>): Promise<SessionEventOf<T>>;
|
|
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
|
-
*/
|
|
374
|
-
afterCommit(fn: () => void | Promise<void>): void;
|
|
375
410
|
/**
|
|
376
411
|
* Asks for an advance of `sessionId` once this transaction commits. The request is a row
|
|
377
412
|
* 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
|
|
379
|
-
*
|
|
380
|
-
* once
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
* `dedupeKey`: the wake's own, or
|
|
384
|
-
* twice still causes one advance.
|
|
413
|
+
* caused it: a commit never loses its wake. After commit the store hands it to the
|
|
414
|
+
* delivery (`SessionStore.deliverTo`, `DurableExecution.wake` through the Tenant) without
|
|
415
|
+
* waiting, and deletes the row once that resolves to anything but `false`. A request the
|
|
416
|
+
* delivery declines or rejects is due at the sweep's next pass; one a crash cut off, or
|
|
417
|
+
* still being sent, is due once it is older than the sweep's grace (`pendingOutbox`). The
|
|
418
|
+
* delivery gets the request's idempotency key as `dedupeKey`: the wake's own, or
|
|
419
|
+
* `wake:<row id>` without one, so a request delivered twice still causes one advance.
|
|
420
|
+
*/
|
|
421
|
+
wake(sessionId: string, wake: Wake): Promise<void>;
|
|
422
|
+
/**
|
|
423
|
+
* Asks for a pod sandbox's reconcile, or sets one of its timers, once this transaction
|
|
424
|
+
* commits (`DurableExecution.sandbox`): a row of the sandbox signal outbox, delivered as a
|
|
425
|
+
* wake's is. A reconcile is delivered with the key `signal:<row id>`, so one delivered
|
|
426
|
+
* twice runs once.
|
|
427
|
+
*/
|
|
428
|
+
signalSandbox(sandboxId: string, signal: SandboxSignal): Promise<void>;
|
|
429
|
+
/**
|
|
430
|
+
* Outbox requests that are due, by the database's clock: written more than `graceMs` ago,
|
|
431
|
+
* or whose delivery failed and whose retry time has come (`failOutbox`), and not parked.
|
|
432
|
+
* Oldest first, a retried one by its retry time, so a request that keeps failing never
|
|
433
|
+
* holds the head of the queue.
|
|
385
434
|
*/
|
|
386
|
-
|
|
387
|
-
/** Outbox requests written before `before`, oldest first. */
|
|
388
|
-
pendingWakes(before: Date, limit: number): Promise<PendingWake[]>;
|
|
435
|
+
pendingOutbox(graceMs: number, limit: number): Promise<PendingRequest[]>;
|
|
389
436
|
/** Deletes delivered outbox requests. */
|
|
390
|
-
|
|
437
|
+
deleteOutbox(rows: readonly OutboxRef[]): Promise<void>;
|
|
438
|
+
/**
|
|
439
|
+
* Records a failed delivery of outbox request `row`: one more attempt, due again `retryInMs`
|
|
440
|
+
* from now (the database's clock). With `park` it is never due again: it stays in the
|
|
441
|
+
* outbox, with when it was parked, for an operator to send again or delete.
|
|
442
|
+
*/
|
|
443
|
+
failOutbox(row: OutboxRef, retry: {
|
|
444
|
+
retryInMs: number;
|
|
445
|
+
park: boolean;
|
|
446
|
+
}): Promise<void>;
|
|
391
447
|
/**
|
|
392
448
|
* Locks the session and takes ownership when it has no owner or the owner's
|
|
393
449
|
* lease ended at or before `now`; then increments `epoch` and sets the lease
|
|
@@ -416,9 +472,10 @@ export interface Tx {
|
|
|
416
472
|
sessionsWithStatus<T extends SessionDoc = SessionDoc>(statuses: readonly (SessionStatus | (string & {}))[]): Promise<StoredSession<T>[]>;
|
|
417
473
|
/**
|
|
418
474
|
* Sessions `running` or `runnable` with no owner or an owner whose lease
|
|
419
|
-
* ended at or before `now`,
|
|
475
|
+
* ended at or before `now`, by id, the first `limit` after `after` (a page:
|
|
476
|
+
* pass the last id of the previous one). The sweep re-wakes these.
|
|
420
477
|
*/
|
|
421
|
-
orphanedSessions<T extends SessionDoc = SessionDoc>(now: Date, limit: number): Promise<StoredSession<T>[]>;
|
|
478
|
+
orphanedSessions<T extends SessionDoc = SessionDoc>(now: Date, limit: number, after?: string): Promise<StoredSession<T>[]>;
|
|
422
479
|
/** Sessions, optionally of one agent and one owner (`ownerUserId`), by id. */
|
|
423
480
|
listSessions<T extends SessionDoc = SessionDoc>(filter?: {
|
|
424
481
|
agentId?: string;
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* The relay never writes the record and never deletes from S2.
|
|
24
24
|
*/
|
|
25
|
+
import { sleep } from "../../retry.js";
|
|
25
26
|
import { basinOf } from "../basin.js";
|
|
26
27
|
import { sessionStream } from "../types.js";
|
|
27
28
|
const RETRY_MIN_MS = 50;
|
|
@@ -351,16 +352,3 @@ export function createStreamRelay(options) {
|
|
|
351
352
|
};
|
|
352
353
|
}
|
|
353
354
|
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
354
|
-
function sleep(ms, signal) {
|
|
355
|
-
return new Promise((resolve) => {
|
|
356
|
-
if (signal.aborted)
|
|
357
|
-
return resolve();
|
|
358
|
-
const done = () => {
|
|
359
|
-
clearTimeout(timer);
|
|
360
|
-
signal.removeEventListener("abort", done);
|
|
361
|
-
resolve();
|
|
362
|
-
};
|
|
363
|
-
const timer = setTimeout(done, ms);
|
|
364
|
-
signal.addEventListener("abort", done, { once: true });
|
|
365
|
-
});
|
|
366
|
-
}
|
package/dist/tenant/advance.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { runDurable, runFlowDurable, } from "@nylorun/harness/run";
|
|
43
43
|
import { aggregateWaits, cancelSiblingWork, isWorkflowManifest, wakeLinkedWorkflow, } from "../core/flow-host.js";
|
|
44
44
|
import { isOwnershipLost, ownedTx } from "../store/ownership.js";
|
|
45
|
+
import { endAdvance } from "./scheduler.js";
|
|
45
46
|
import { isGateToolEffect, recoversModelCalls, recoversToolCalls } from "./effects.js";
|
|
46
47
|
import { slimModelEffects } from "./slim.js";
|
|
47
48
|
import { applyUpdates, leanState, transcriptShadow, withTranscript, } from "./history.js";
|
|
@@ -169,10 +170,7 @@ export async function advance(ctx, id, signal) {
|
|
|
169
170
|
heartbeat.stop();
|
|
170
171
|
dropRunGrant(ctx, lease);
|
|
171
172
|
signal.removeEventListener("abort", forward);
|
|
172
|
-
|
|
173
|
-
ctx.work.running.delete(id);
|
|
174
|
-
ctx.work.runningTurns.delete(id);
|
|
175
|
-
}
|
|
173
|
+
endAdvance(ctx.work, id, controller);
|
|
176
174
|
// Best effort: a release that fails leaves a lease that simply expires.
|
|
177
175
|
if (release)
|
|
178
176
|
await ctx.store
|
|
@@ -395,7 +393,6 @@ async function wakeWorkflowOf(ctx, t, agent, turnId, type, payload) {
|
|
|
395
393
|
error: type === "turn.failed"
|
|
396
394
|
? String(payload.message ?? "")
|
|
397
395
|
: undefined,
|
|
398
|
-
schedule: ctx.wake,
|
|
399
396
|
});
|
|
400
397
|
else if (type === "turn.cancelled")
|
|
401
398
|
await wakeLinkedWorkflow({
|
|
@@ -404,7 +401,6 @@ async function wakeWorkflowOf(ctx, t, agent, turnId, type, payload) {
|
|
|
404
401
|
turnId,
|
|
405
402
|
cancelled: true,
|
|
406
403
|
error: "Agent turn was cancelled",
|
|
407
|
-
schedule: ctx.wake,
|
|
408
404
|
});
|
|
409
405
|
}
|
|
410
406
|
/**
|
|
@@ -473,7 +469,10 @@ async function settle(ctx, lease, s, result, updates = [], cursor) {
|
|
|
473
469
|
await slimModelEffects(t, id, s.activeTurnId);
|
|
474
470
|
await t.put("sessions", id, current);
|
|
475
471
|
const segment = finished.segment + 1;
|
|
476
|
-
await t.wake(id, {
|
|
472
|
+
await t.wake(id, {
|
|
473
|
+
reason: "rollover",
|
|
474
|
+
dedupeKey: `rollover:${s.activeTurnId}:${segment}`,
|
|
475
|
+
});
|
|
477
476
|
return siblings;
|
|
478
477
|
}
|
|
479
478
|
const flow = isWorkflowManifest(current.manifest);
|
package/dist/tenant/commands.js
CHANGED
|
@@ -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
|
-
await t.wake(id, commandWake
|
|
234
|
+
await t.wake(id, commandWake);
|
|
235
235
|
event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
|
|
236
236
|
}
|
|
237
237
|
await t.put("sessions", id, s);
|
|
@@ -244,7 +244,6 @@ export async function command(ctx, id, input, scope) {
|
|
|
244
244
|
turnId: cancelledTurnId,
|
|
245
245
|
cancelled: true,
|
|
246
246
|
error: "Agent turn was cancelled",
|
|
247
|
-
schedule: ctx.wake,
|
|
248
247
|
});
|
|
249
248
|
const response = {
|
|
250
249
|
status: "accepted",
|