@nylorun/runtime 0.36.1-beta → 0.38.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +10 -0
  3. package/dist/api/a2a/card.d.ts +10 -4
  4. package/dist/api/a2a/card.js +18 -6
  5. package/dist/api/a2a/endpoint.d.ts +5 -2
  6. package/dist/api/a2a/endpoint.js +31 -9
  7. package/dist/api/a2a/extensions.d.ts +41 -0
  8. package/dist/api/a2a/extensions.js +92 -0
  9. package/dist/api/a2a/list.d.ts +60 -0
  10. package/dist/api/a2a/list.js +169 -0
  11. package/dist/api/a2a/protocol.d.ts +105 -9
  12. package/dist/api/a2a/protocol.js +169 -18
  13. package/dist/api/a2a/push.d.ts +76 -0
  14. package/dist/api/a2a/push.js +305 -0
  15. package/dist/api/a2a/routes.d.ts +12 -6
  16. package/dist/api/a2a/routes.js +409 -112
  17. package/dist/api/a2a/tasks.d.ts +72 -4
  18. package/dist/api/a2a/tasks.js +305 -27
  19. package/dist/api/components.d.ts +538 -0
  20. package/dist/api/components.js +3 -1
  21. package/dist/api/http/app.js +2 -0
  22. package/dist/api/http/routes/artifacts.js +19 -0
  23. package/dist/api/http/routes/turns.d.ts +18 -0
  24. package/dist/api/http/routes/turns.js +67 -0
  25. package/dist/api/inbound-files.d.ts +12 -1
  26. package/dist/api/inbound-files.js +44 -0
  27. package/dist/api/openapi.js +7 -2
  28. package/dist/harness/image-engine.d.ts +14 -0
  29. package/dist/harness/image-engine.js +16 -0
  30. package/dist/harness/pod.d.ts +2 -0
  31. package/dist/harness/pod.js +5 -0
  32. package/dist/harness-api/ws-server.js +7 -1
  33. package/dist/host/config-for.d.ts +2 -0
  34. package/dist/host/config-for.js +1 -0
  35. package/dist/host/main.js +1 -0
  36. package/dist/host/stack-config.d.ts +16 -0
  37. package/dist/host/stack-config.js +30 -0
  38. package/dist/management-openapi.json +37 -2
  39. package/dist/openapi.json +1485 -738
  40. package/dist/reads/types.d.ts +19 -0
  41. package/dist/sandbox/join.d.ts +7 -1
  42. package/dist/sandbox/join.js +5 -1
  43. package/dist/sandbox/pods/builtin.d.ts +18 -0
  44. package/dist/sandbox/pods/builtin.js +33 -0
  45. package/dist/sandbox/pods/client.d.ts +5 -4
  46. package/dist/sandbox/pods/spec.js +14 -1
  47. package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
  48. package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
  49. package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
  50. package/dist/store/postgres/drizzle/0042_worker_engine_image.sql +1 -0
  51. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  52. package/dist/store/postgres/reads.js +41 -1
  53. package/dist/store/postgres/schema.d.ts +1314 -533
  54. package/dist/store/postgres/schema.js +94 -0
  55. package/dist/store/postgres/store.js +135 -3
  56. package/dist/store/postgres/turns.d.ts +62 -0
  57. package/dist/store/postgres/turns.js +153 -0
  58. package/dist/store/types.d.ts +82 -3
  59. package/dist/tenant/runtime.js +7 -5
  60. package/dist/tenant/session-model.d.ts +11 -1
  61. package/dist/tenant/session-model.js +34 -5
  62. package/dist/tenant/sessions.d.ts +2 -1
  63. package/dist/tenant/sessions.js +8 -2
  64. package/dist/tenant/sweep.d.ts +3 -0
  65. package/dist/tenant/sweep.js +39 -11
  66. package/dist/tenant/types.d.ts +23 -0
  67. package/dist/tuning.d.ts +9 -0
  68. package/dist/tuning.js +11 -0
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/package.json +3 -3
@@ -171,6 +171,8 @@ export const agents = nylorun.table("agents", {
171
171
  volumeId: text(),
172
172
  /** The workload image by digest, from the first ready pod on (Q14): later pods run it. */
173
173
  imageDigest: textC(),
174
+ /** The engine the image carries (ADR-99): `{ version, digest? }`, from a join of its engine. */
175
+ engineImage: jsonText(),
174
176
  /**
175
177
  * The disk lease (R2d step 3): `{ kind, version?, startedAt, until }` while a snapshot, a
176
178
  * restore or a seed copies the disk; turns are refused until it ends or `until` passes.
@@ -495,6 +497,46 @@ export const modelBudgets = nylorun.table("model_budgets", {
495
497
  limitTokens: bigint({ mode: "number" }),
496
498
  updatedAt: text().notNull(),
497
499
  }, (t) => [primaryKey({ name: "model_budgets_pkey", columns: [t.scope, t.scopeId] })]);
500
+ /**
501
+ * The turn index (A2A design §9, Host feature `turns`): one row per turn, written by `Tx.event`
502
+ * in the transaction of each event that changes the turn (`store/postgres/turns.ts`), so it
503
+ * agrees with the session's log. The session's Agent, owner and A2A context are copied in when
504
+ * the turn starts. `txid` is the transaction that last changed the row: the change feed reads
505
+ * rows in its order, only below every transaction still running (`pg_snapshot_xmin`), as the
506
+ * model call export does. `firstSeq` and `lastSeq` bound the turn's lifecycle events in the log.
507
+ */
508
+ export const turns = nylorun.table("turns", {
509
+ sessionId: textC().notNull(),
510
+ turnId: textC().notNull(),
511
+ agentId: textC().notNull(),
512
+ /** The session owner (`ownerUserId`); null for an application's own session. */
513
+ ownerUserId: textC(),
514
+ /** `info.a2a.contextId` of an A2A context's session. */
515
+ a2aContextId: textC(),
516
+ status: text({ enum: ["running", "paused", "completed", "failed", "cancelled"] }).notNull(),
517
+ /** ISO times: of the turn's first event, its end, and its last status change. */
518
+ startedAt: textC().notNull(),
519
+ endedAt: textC(),
520
+ changedAt: textC().notNull(),
521
+ /** The end facts' `totals` (`{steps, tokens, costUsd}`), once it ended with them. */
522
+ totals: jsonText(),
523
+ /**
524
+ * While `paused`: what its first open interaction asks for, as profile v2 names it
525
+ * (`question`, `approval` or `connection`); null otherwise.
526
+ */
527
+ waitingFor: text({ enum: ["question", "approval", "connection"] }),
528
+ /** The queued messages it took (protocol 17): their A2A task ids name it too. */
529
+ queuedMessageIds: text().array().notNull().default(sql `'{}'`),
530
+ firstSeq: bigint({ mode: "number" }).notNull(),
531
+ lastSeq: bigint({ mode: "number" }).notNull(),
532
+ txid: xid8().notNull().default(sql `pg_current_xact_id()`),
533
+ }, (t) => [
534
+ primaryKey({ name: "turns_pkey", columns: [t.sessionId, t.turnId] }),
535
+ index("turns_turn").on(t.turnId),
536
+ index("turns_changes").on(t.txid, t.sessionId, t.turnId),
537
+ index("turns_owner_changes").on(t.ownerUserId, t.txid),
538
+ index("turns_owner_agent").on(t.ownerUserId, t.agentId, t.changedAt),
539
+ ]);
498
540
  /**
499
541
  * Each session's queue (protocol 17, D76): messages that arrived while its turn ran or waited, in
500
542
  * order (`seq`), until a turn takes them. A row holds the command as sent, the turn input its
@@ -700,6 +742,58 @@ export const sandboxSignals = nylorun.table("sandbox_signals", {
700
742
  index("sandbox_signals_created_at").on(t.createdAt),
701
743
  check("sandbox_signals_kind_check", sql `${t.kind} IN ('reconcile', 'arm')`),
702
744
  ]);
745
+ /**
746
+ * A2A push notification configs (Host feature `a2a-profile-v2`): where a task's updates go, one
747
+ * row per task and config id (`taskId`: the A2A id its caller knows, a queued message's own until
748
+ * a turn takes it; `turnId`: the turn it follows). The push translator (`api/a2a/push.ts`) reads
749
+ * the session's events from `nextSeq` on and writes each update as an `a2a_pushes` row in one
750
+ * transaction; `doneAt` is set once the task's turn ended, and the row is deleted after the
751
+ * Host tuning's `a2a.pushConfigRetentionMs`. `extensions` are the profile extensions the caller
752
+ * activated when it gave the config; `baseUrl` is the Runtime's URL as that caller reached it,
753
+ * for file URLs; `traceId` is `telemetry/v1`'s.
754
+ */
755
+ export const a2aPushConfigs = nylorun.table("a2a_push_configs", {
756
+ sessionId: textC().notNull(),
757
+ taskId: textC().notNull(),
758
+ configId: textC().notNull(),
759
+ contextId: text().notNull(),
760
+ turnId: textC().notNull(),
761
+ url: text().notNull(),
762
+ token: text(),
763
+ authentication: jsonText(),
764
+ baseUrl: text().notNull(),
765
+ extensions: jsonText().notNull(),
766
+ traceId: text(),
767
+ nextSeq: bigint({ mode: "number" }).notNull(),
768
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
769
+ .notNull()
770
+ .default(sql `clock_timestamp()`),
771
+ doneAt: timestamp({ withTimezone: true, mode: "date" }),
772
+ }, (t) => [
773
+ primaryKey({ name: "a2a_push_configs_pkey", columns: [t.sessionId, t.taskId, t.configId] }),
774
+ index("a2a_push_configs_done_at").on(t.doneAt),
775
+ ]);
776
+ /**
777
+ * The push outbox (outbox kind `push`): one row per A2A update a push config is owed, its body
778
+ * the exact JSON sent (a `StreamResponse`), delivered after commit and by the Tenant sweep as the
779
+ * other outbox rows are (`attempts`, `retry_at`, `parked_at`). The row id is the Standard
780
+ * Webhooks `webhook-id`, the same on every attempt. A row whose config is gone, or that kept
781
+ * failing for the Host tuning's `a2a.pushMaxAgeMs` (24 hours), is deleted: the change feed
782
+ * covers what it would have said.
783
+ */
784
+ export const a2aPushes = nylorun.table("a2a_pushes", {
785
+ id: textC().primaryKey(),
786
+ sessionId: textC().notNull(),
787
+ taskId: textC().notNull(),
788
+ configId: textC().notNull(),
789
+ body: text().notNull(),
790
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
791
+ .notNull()
792
+ .default(sql `clock_timestamp()`),
793
+ attempts: integer().notNull().default(0),
794
+ retryAt: timestamp({ withTimezone: true, mode: "date" }),
795
+ parkedAt: timestamp({ withTimezone: true, mode: "date" }),
796
+ }, (t) => [index("a2a_pushes_created_at").on(t.createdAt)]);
703
797
  // ---------------------------------------------------------------------------
704
798
  // nylorun_streams: the record
705
799
  /**
@@ -59,8 +59,9 @@ import { database, driverError } from "./db.js";
59
59
  import { expectedSchemaVersion, readSchemaVersion } from "./migrate.js";
60
60
  import { hostTuning } from "../../tuning.js";
61
61
  import { createPostgresRecordReader } from "./record.js";
62
+ import { indexTurn, listTurns, turnOfQueuedMessage, turnRow } from "./turns.js";
62
63
  import { postgresAgentRecordWriter, postgresRecordWriter } from "./record-writer.js";
63
- import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, effects, links, modelBudgets, modelUsage, principals, sandboxes, agentEvents, agents, agentManifests, sessionEvents, sessionLogHeads, sessions, sessionQueue, agentTriggers, triggerRuns, memories, memoryVersions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, vaultAudit, vaultCredentials, vaultIdempotency, vaults, sandboxSignals, wakes, } from "./schema.js";
64
+ import { artifacts, artifactVersions, artifactContent, definitionFiles, definitionFileUses, commands, effects, links, modelBudgets, modelUsage, principals, sandboxes, agentEvents, agents, agentManifests, sessionEvents, sessionLogHeads, sessions, sessionQueue, a2aPushConfigs, a2aPushes, agentTriggers, triggerRuns, memories, memoryVersions, toJson, toolCrossings, signingKeys, tenant, tenantSettings, turns, vaultAudit, vaultCredentials, vaultIdempotency, vaults, sandboxSignals, wakes, } from "./schema.js";
64
65
  export function createPostgresSessionStore(options) {
65
66
  return new PostgresSessionStore(options);
66
67
  }
@@ -282,6 +283,7 @@ function environmentOf(row) {
282
283
  ...(row.retiring !== null ? { retiring: row.retiring } : {}),
283
284
  ...(row.volumeId !== null ? { volumeId: row.volumeId } : {}),
284
285
  ...(row.imageDigest !== null ? { imageDigest: row.imageDigest } : {}),
286
+ ...(row.engineImage ? { engineImage: row.engineImage } : {}),
285
287
  },
286
288
  }
287
289
  : {}),
@@ -316,6 +318,7 @@ function podColumns(patch) {
316
318
  retiring: patch.retiring,
317
319
  volumeId: patch.volumeId,
318
320
  imageDigest: patch.imageDigest,
321
+ engineImage: patch.engineImage,
319
322
  };
320
323
  return Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
321
324
  }
@@ -327,7 +330,17 @@ const SIGNING_KEY_STAMP = {
327
330
  };
328
331
  /** The audit columns a row has (`ord` only orders them). */
329
332
  const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
330
- const SESSION_TABLES = [sessions, commands, effects, links, wakes, sessionQueue];
333
+ const SESSION_TABLES = [
334
+ sessions,
335
+ commands,
336
+ effects,
337
+ links,
338
+ wakes,
339
+ sessionQueue,
340
+ turns,
341
+ a2aPushConfigs,
342
+ a2aPushes,
343
+ ];
331
344
  /**
332
345
  * The outbox's tables, by the kind of request each holds. Their delivery columns (`id`,
333
346
  * `created_at`, `attempts`, `retry_at`, `parked_at`) are the same.
@@ -335,7 +348,12 @@ const SESSION_TABLES = [sessions, commands, effects, links, wakes, sessionQueue]
335
348
  const OUTBOX = {
336
349
  wake: wakes,
337
350
  sandbox: sandboxSignals,
351
+ push: a2aPushes,
338
352
  };
353
+ /** A push outbox row as it is delivered. */
354
+ function outboxPush(row) {
355
+ return { id: row.id, sessionId: row.sessionId, taskId: row.taskId, configId: row.configId, body: row.body };
356
+ }
339
357
  /** A wake outbox row as it is delivered: its own dedupe key, or one made from the row id. */
340
358
  function outboxWake(id, reason, dedupeKey) {
341
359
  return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
@@ -439,10 +457,25 @@ class PostgresTx {
439
457
  ...(writer ? { writer } : {}),
440
458
  maxEntryBytes: await this.maxEntryBytes(),
441
459
  });
460
+ // The turn index changes with the log, in this transaction (Host feature `turns`).
461
+ await indexTurn(db, event);
442
462
  this.events.push(event);
443
463
  this.generations.push(generation);
444
464
  return structuredClone(event);
445
465
  }
466
+ // --- turn index ----------------------------------------------------------
467
+ async turn(sessionId, turnId) {
468
+ this.check();
469
+ return turnRow(this.db, sessionId, turnId);
470
+ }
471
+ async turnOfQueuedMessage(sessionId, queuedMessageId) {
472
+ this.check();
473
+ return turnOfQueuedMessage(this.db, sessionId, queuedMessageId);
474
+ }
475
+ async listTurns(query) {
476
+ this.check();
477
+ return listTurns(this.db, query);
478
+ }
446
479
  // --- wake outbox ---------------------------------------------------------
447
480
  async wake(sessionId, wake) {
448
481
  this.check();
@@ -514,12 +547,27 @@ class PostgresTx {
514
547
  due: row.dueSeconds,
515
548
  })),
516
549
  ];
550
+ const push = DUE(OUTBOX.push, graceMs);
551
+ const pushRows = await this.db
552
+ .select({ ...getTableColumns(a2aPushes), dueSeconds: push.dueSeconds })
553
+ .from(a2aPushes)
554
+ .where(and(isNull(a2aPushes.parkedAt), lte(push.due, now)))
555
+ .orderBy(push.due, a2aPushes.id)
556
+ .limit(limit);
557
+ rows.push(...pushRows.map((row) => ({
558
+ kind: "push",
559
+ id: row.id,
560
+ request: { kind: "push", push: outboxPush(row) },
561
+ createdAt: row.createdAt.toISOString(),
562
+ attempts: row.attempts,
563
+ due: row.dueSeconds,
564
+ })));
517
565
  rows.sort((a, b) => a.due - b.due || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
518
566
  return rows.slice(0, limit).map(({ due: _due, ...row }) => row);
519
567
  }
520
568
  async deleteOutbox(rows) {
521
569
  this.check();
522
- for (const kind of ["wake", "sandbox"]) {
570
+ for (const kind of ["wake", "sandbox", "push"]) {
523
571
  const ids = rows.filter((row) => row.kind === kind).map((row) => row.id);
524
572
  if (ids.length > 0)
525
573
  await this.db.delete(OUTBOX[kind]).where(inArray(OUTBOX[kind].id, ids));
@@ -538,6 +586,90 @@ class PostgresTx {
538
586
  })
539
587
  .where(eq(table.id, row.id));
540
588
  }
589
+ // --- A2A push (Host feature `a2a-profile-v2`) ---------------------------------
590
+ async putPushConfig(row) {
591
+ this.check();
592
+ const { sessionId: _s, taskId: _t, configId: _c, ...set } = row;
593
+ await this.db
594
+ .insert(a2aPushConfigs)
595
+ .values({ ...row, doneAt: row.doneAt ?? null })
596
+ .onConflictDoUpdate({
597
+ target: [a2aPushConfigs.sessionId, a2aPushConfigs.taskId, a2aPushConfigs.configId],
598
+ set: { ...set, doneAt: row.doneAt ?? null, createdAt: sql `clock_timestamp()` },
599
+ });
600
+ }
601
+ async pushConfig(sessionId, taskId, configId) {
602
+ this.check();
603
+ const [row] = await this.db
604
+ .select()
605
+ .from(a2aPushConfigs)
606
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), eq(a2aPushConfigs.configId, configId)));
607
+ return row;
608
+ }
609
+ async pushConfigs(sessionId, taskId, options = {}) {
610
+ this.check();
611
+ const query = this.db
612
+ .select()
613
+ .from(a2aPushConfigs)
614
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), options.after === undefined ? undefined : gt(a2aPushConfigs.configId, options.after)))
615
+ .orderBy(a2aPushConfigs.configId)
616
+ .$dynamic();
617
+ return options.limit === undefined ? query : query.limit(options.limit);
618
+ }
619
+ async deletePushConfig(sessionId, taskId, configId) {
620
+ this.check();
621
+ const deleted = await this.db
622
+ .delete(a2aPushConfigs)
623
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), eq(a2aPushConfigs.configId, configId)))
624
+ .returning({ id: a2aPushConfigs.configId });
625
+ return deleted.length > 0;
626
+ }
627
+ async activePushConfigs(sessionId, options = {}) {
628
+ this.check();
629
+ const query = this.db
630
+ .select()
631
+ .from(a2aPushConfigs)
632
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), isNull(a2aPushConfigs.doneAt)))
633
+ .orderBy(a2aPushConfigs.taskId, a2aPushConfigs.configId)
634
+ .$dynamic();
635
+ return options.lock ? query.for("update") : query;
636
+ }
637
+ async pushSessions(limit, after) {
638
+ this.check();
639
+ const rows = await this.db
640
+ .selectDistinct({ sessionId: a2aPushConfigs.sessionId })
641
+ .from(a2aPushConfigs)
642
+ .where(and(isNull(a2aPushConfigs.doneAt), after === undefined ? undefined : gt(a2aPushConfigs.sessionId, after)))
643
+ .orderBy(a2aPushConfigs.sessionId)
644
+ .limit(limit);
645
+ return rows.map((row) => row.sessionId);
646
+ }
647
+ async advancePushConfig(key, change) {
648
+ this.check();
649
+ await this.db
650
+ .update(a2aPushConfigs)
651
+ .set({
652
+ turnId: change.turnId,
653
+ nextSeq: change.nextSeq,
654
+ traceId: change.traceId,
655
+ ...(change.done ? { doneAt: sql `clock_timestamp()` } : {}),
656
+ })
657
+ .where(and(eq(a2aPushConfigs.sessionId, key.sessionId), eq(a2aPushConfigs.taskId, key.taskId), eq(a2aPushConfigs.configId, key.configId)));
658
+ }
659
+ async prunePushConfigs(before) {
660
+ this.check();
661
+ const deleted = await this.db
662
+ .delete(a2aPushConfigs)
663
+ .where(lt(a2aPushConfigs.doneAt, before))
664
+ .returning({ id: a2aPushConfigs.configId });
665
+ return deleted.length;
666
+ }
667
+ async enqueuePush(push) {
668
+ this.check();
669
+ const id = `push_${randomUUID()}`;
670
+ await this.db.insert(a2aPushes).values({ id, ...push });
671
+ this.outbox.push({ kind: "push", id, request: { kind: "push", push: { id, ...push } } });
672
+ }
541
673
  // --- ownership -----------------------------------------------------------
542
674
  async takeOwnership(sessionId, claim) {
543
675
  this.check();
@@ -0,0 +1,62 @@
1
+ import type { EventType, TurnStatus } from "@nylorun/core/contracts";
2
+ import type { Queryable } from "./db.js";
3
+ import { type TurnRow } from "./schema.js";
4
+ /** The events that change a turn's row, and the status each leaves it in. */
5
+ export declare const TURN_EVENT_STATUS: Readonly<Partial<Record<EventType, TurnStatus>>>;
6
+ /** The statuses a turn ends in. */
7
+ export declare const ENDED_TURN_STATUSES: ReadonlySet<TurnStatus>;
8
+ /** What `indexTurn` reads of an event: the envelope fields, as `appendEvent` built them. */
9
+ export interface IndexedEvent {
10
+ readonly sessionId: string;
11
+ readonly turnId: string | null;
12
+ readonly seq: number;
13
+ readonly time: string;
14
+ readonly type: string;
15
+ readonly payload: unknown;
16
+ }
17
+ /** What an interaction asks for, as profile v2 names it (`api/a2a/tasks.ts` `interactionOf`). */
18
+ export type WaitingFor = "question" | "approval" | "connection";
19
+ /** What a `turn.paused` payload's first open interaction asks for, or null. */
20
+ export declare function waitingForOf(payload: unknown): WaitingFor | null;
21
+ /**
22
+ * Writes the change `event` makes to its turn's row, in the event's transaction (the session
23
+ * row is locked). An event without a turn, or of a type that changes none, writes nothing.
24
+ */
25
+ export declare function indexTurn(db: Queryable, event: IndexedEvent): Promise<void>;
26
+ /** One turn's row, or undefined when the session never had that turn. */
27
+ export declare function turnRow(db: Queryable, sessionId: string, turnId: string): Promise<TurnRow | undefined>;
28
+ /** The turn that took queued message `queuedMessageId` of a session, if one did. */
29
+ export declare function turnOfQueuedMessage(db: Queryable, sessionId: string, queuedMessageId: string): Promise<TurnRow | undefined>;
30
+ /** A status a listed turn may have: `paused` narrowed by what it waits for. */
31
+ export interface TurnState {
32
+ readonly status: TurnStatus;
33
+ readonly waitingFor?: readonly WaitingFor[];
34
+ }
35
+ /** Which turns `listTurns` reads: one owner's A2A tasks with one Agent (`ListTasks`). */
36
+ export interface TurnListQuery {
37
+ readonly ownerUserId: string;
38
+ readonly agentId: string;
39
+ /** Only turns of A2A contexts. */
40
+ readonly a2a: true;
41
+ /** Only this session (one A2A context's). */
42
+ readonly sessionId?: string;
43
+ /** Only turns in one of these states; empty matches none. */
44
+ readonly states?: readonly TurnState[];
45
+ /** Only turns whose status changed at or after this ISO time. */
46
+ readonly changedSince?: string;
47
+ /** Only turns after this one in the order: the last row of the previous page. */
48
+ readonly after?: {
49
+ readonly changedAt: string;
50
+ readonly sessionId: string;
51
+ readonly turnId: string;
52
+ };
53
+ readonly limit: number;
54
+ }
55
+ /**
56
+ * The turns `query` names, latest status change first (then by session and turn id, descending),
57
+ * at most `limit`, and how many match without `after` and `limit`.
58
+ */
59
+ export declare function listTurns(db: Queryable, query: TurnListQuery): Promise<{
60
+ turns: TurnRow[];
61
+ total: number;
62
+ }>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The turn index (A2A design §9, Host feature `turns`): the `turns` table's writes and the typed
3
+ * queries on it. `Tx.event` calls `indexTurn` for every event it writes, in the same
4
+ * transaction, so a turn's row changes exactly when its log does:
5
+ *
6
+ * - `command.message` starts the turn (`running`), copying in its session's Agent, owner and A2A
7
+ * context (`info.a2a.contextId`, `api/a2a/routes.ts`) and the queued message it took, if any;
8
+ * a further message of the same turn only adds its queued message;
9
+ * - `command.approve` and `command.respond` set it `running` again, `turn.paused` `paused` with
10
+ * what its first open interaction asks for (`waitingFor`, profile v2's kinds);
11
+ * - `turn.completed`, `turn.failed` and `turn.cancelled` end it, with their end facts' `totals`.
12
+ *
13
+ * Each change stamps the row with its transaction (`txid`), which orders the change feed
14
+ * (`reads.ts`). Rows for the turns recorded before the index are backfilled by migration
15
+ * `0041_turn_index_backfill`.
16
+ */
17
+ import { and, desc, eq, isNotNull, gte, inArray, or, sql } from "drizzle-orm";
18
+ import { sessions, toJson, turns } from "./schema.js";
19
+ /** The events that change a turn's row, and the status each leaves it in. */
20
+ export const TURN_EVENT_STATUS = {
21
+ "command.message": "running",
22
+ "command.approve": "running",
23
+ "command.respond": "running",
24
+ "turn.paused": "paused",
25
+ "turn.completed": "completed",
26
+ "turn.failed": "failed",
27
+ "turn.cancelled": "cancelled",
28
+ };
29
+ /** The statuses a turn ends in. */
30
+ export const ENDED_TURN_STATUSES = new Set([
31
+ "completed",
32
+ "failed",
33
+ "cancelled",
34
+ ]);
35
+ /** An ended turn's `totals`, from its end facts, or null when it has none. */
36
+ function totalsOf(payload) {
37
+ const totals = payload?.totals;
38
+ if (typeof totals?.steps !== "number" ||
39
+ typeof totals.tokens !== "number" ||
40
+ typeof totals.costUsd !== "number")
41
+ return null;
42
+ return { steps: totals.steps, tokens: totals.tokens, costUsd: totals.costUsd };
43
+ }
44
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
45
+ /** What a `turn.paused` payload's first open interaction asks for, or null. */
46
+ export function waitingForOf(payload) {
47
+ const entries = isRecord(payload) && Array.isArray(payload.interactions) ? payload.interactions : [];
48
+ for (const entry of entries) {
49
+ if (!isRecord(entry) || (entry.status !== undefined && entry.status !== "interaction"))
50
+ continue;
51
+ const interaction = entry.interaction;
52
+ if (!isRecord(interaction) || typeof interaction.id !== "string")
53
+ continue;
54
+ const metadata = isRecord(interaction.metadata) ? interaction.metadata : undefined;
55
+ if (interaction.kind === "connection" || metadata?.kind === "connection")
56
+ return "connection";
57
+ return interaction.kind === "approval" ? "approval" : "question";
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Writes the change `event` makes to its turn's row, in the event's transaction (the session
63
+ * row is locked). An event without a turn, or of a type that changes none, writes nothing.
64
+ */
65
+ export async function indexTurn(db, event) {
66
+ const status = TURN_EVENT_STATUS[event.type];
67
+ if (status === undefined || event.turnId === null)
68
+ return;
69
+ const ended = ENDED_TURN_STATUSES.has(status);
70
+ const totals = ended ? totalsOf(event.payload) : null;
71
+ const waitingFor = event.type === "turn.paused" ? waitingForOf(event.payload) : null;
72
+ const queuedMessageId = event.type === "command.message" && isRecord(event.payload) && typeof event.payload.queuedMessageId === "string"
73
+ ? event.payload.queuedMessageId
74
+ : undefined;
75
+ const insert = sql `
76
+ INSERT INTO ${turns} (
77
+ "session_id", "turn_id", "agent_id", "owner_user_id", "a2a_context_id", "status",
78
+ "started_at", "ended_at", "changed_at", "totals", "waiting_for", "queued_message_ids",
79
+ "first_seq", "last_seq"
80
+ )
81
+ SELECT
82
+ ${sessions.id}, ${event.turnId}, coalesce(${sessions.agentId}, ''), ${sessions.ownerUserId},
83
+ nylorun.doc(${sessions.body})->'info'->'a2a'->>'contextId', ${status},
84
+ ${event.time}, ${ended ? event.time : null}, ${event.time},
85
+ ${totals === null ? null : sql `${toJson(totals)}::text::json`}, ${waitingFor},
86
+ ${queuedMessageId === undefined ? sql `'{}'::text[]` : sql `ARRAY[${queuedMessageId}]::text[]`},
87
+ ${event.seq}, ${event.seq}
88
+ FROM ${sessions}
89
+ WHERE ${sessions.id} = ${event.sessionId}`;
90
+ // A turn's further messages (a queued batch) add their queued ids and change nothing else.
91
+ await db.execute(event.type === "command.message"
92
+ ? sql `${insert} ON CONFLICT ("session_id", "turn_id") DO UPDATE SET
93
+ "queued_message_ids" = ${turns}."queued_message_ids" || excluded."queued_message_ids"`
94
+ : sql `${insert} ON CONFLICT ("session_id", "turn_id") DO UPDATE SET
95
+ "status" = excluded."status",
96
+ "ended_at" = excluded."ended_at",
97
+ "changed_at" = excluded."changed_at",
98
+ "totals" = excluded."totals",
99
+ "waiting_for" = excluded."waiting_for",
100
+ "last_seq" = excluded."last_seq",
101
+ "txid" = pg_current_xact_id()`);
102
+ }
103
+ /** One turn's row, or undefined when the session never had that turn. */
104
+ export async function turnRow(db, sessionId, turnId) {
105
+ const [row] = await db
106
+ .select()
107
+ .from(turns)
108
+ .where(and(eq(turns.sessionId, sessionId), eq(turns.turnId, turnId)));
109
+ return row;
110
+ }
111
+ /** The turn that took queued message `queuedMessageId` of a session, if one did. */
112
+ export async function turnOfQueuedMessage(db, sessionId, queuedMessageId) {
113
+ const [row] = await db
114
+ .select()
115
+ .from(turns)
116
+ .where(and(eq(turns.sessionId, sessionId), sql `${queuedMessageId} = ANY(${turns.queuedMessageIds})`))
117
+ .limit(1);
118
+ return row;
119
+ }
120
+ /**
121
+ * The turns `query` names, latest status change first (then by session and turn id, descending),
122
+ * at most `limit`, and how many match without `after` and `limit`.
123
+ */
124
+ export async function listTurns(db, query) {
125
+ if (query.states?.length === 0)
126
+ return { turns: [], total: 0 };
127
+ const where = [
128
+ eq(turns.ownerUserId, query.ownerUserId),
129
+ eq(turns.agentId, query.agentId),
130
+ isNotNull(turns.a2aContextId),
131
+ ];
132
+ if (query.sessionId !== undefined)
133
+ where.push(eq(turns.sessionId, query.sessionId));
134
+ if (query.states !== undefined)
135
+ where.push(or(...query.states.map((state) => and(eq(turns.status, state.status), state.waitingFor === undefined ? undefined : inArray(turns.waitingFor, [...state.waitingFor])))));
136
+ if (query.changedSince !== undefined)
137
+ where.push(gte(turns.changedAt, query.changedSince));
138
+ const after = query.after;
139
+ // One statement at a time: a transaction runs on one connection.
140
+ const rows = await db
141
+ .select()
142
+ .from(turns)
143
+ .where(and(...where, after
144
+ ? sql `(${turns.changedAt}, ${turns.sessionId}, ${turns.turnId}) < (${after.changedAt}, ${after.sessionId}, ${after.turnId})`
145
+ : undefined))
146
+ .orderBy(desc(turns.changedAt), desc(turns.sessionId), desc(turns.turnId))
147
+ .limit(query.limit);
148
+ const [counted] = await db
149
+ .select({ total: sql `count(*)`.mapWith(Number) })
150
+ .from(turns)
151
+ .where(and(...where));
152
+ return { turns: rows, total: counted?.total ?? 0 };
153
+ }
@@ -65,7 +65,9 @@ import type { RecordReader } from "../streams/relay/types.js";
65
65
  import type { SandboxSignal, Wake } from "../execution/types.js";
66
66
  import type { EventPayload, EventType, EventWriter, LiveEvent, AgentEvent, AgentEventPayload, AgentEventType, AgentManifestV8, EnvironmentKind, EnvironmentRecipe, PodDesired, PodObserved, SessionEventOf } from "@nylorun/core/contracts";
67
67
  import type { HostEffect } from "@nylorun/harness/run";
68
- import type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, PrincipalRow, ToolCrossingRow, QueuedMessageRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow } from "./postgres/schema.js";
68
+ import type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, PrincipalRow, ToolCrossingRow, QueuedMessageRow, TurnRow, A2aPushConfigRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow } from "./postgres/schema.js";
69
+ import type { TurnListQuery } from "./postgres/turns.js";
70
+ export type { TurnListQuery };
69
71
  /** Tables holding JSON documents keyed by `id`. */
70
72
  export type DocTable = "sessions" | "commands" | "effects" | "sandboxes" | "links";
71
73
  export declare const DOC_TABLES: readonly DocTable[];
@@ -258,6 +260,15 @@ export interface SandboxPodState {
258
260
  * Environment is ready (Q14): every later pod, after a stop, a relaunch or a reset, runs it.
259
261
  */
260
262
  imageDigest?: string;
263
+ /**
264
+ * The engine the workload image carries (ADR-99, `sandbox/pods/builtin.ts`): its Runtime
265
+ * version, as its engine reported at join, and the digest that pod ran. It counts only while
266
+ * that digest is `imageDigest`.
267
+ */
268
+ engineImage?: {
269
+ version: string;
270
+ digest?: string;
271
+ };
261
272
  }
262
273
  /** A change to a pod's columns: `null` clears an optional one. */
263
274
  export type SandboxPodPatch = {
@@ -295,7 +306,7 @@ export type CommitListener = (commit: Commit) => void;
295
306
  * `bytea` columns, never inside a JSON body), the vault's audit and idempotency records, the
296
307
  * Tenant's signing keys, a model call in the usage ledger and a model budget.
297
308
  */
298
- export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, QueuedMessageRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
309
+ export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, QueuedMessageRow, TurnRow, A2aPushConfigRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
299
310
  /** Which rows of the usage ledger a total covers. */
300
311
  export interface ModelUsageQuery {
301
312
  scope: "tenant" | "manifest" | "turn";
@@ -383,10 +394,22 @@ export interface SessionStore {
383
394
  export type OutboxWake = Wake & {
384
395
  dedupeKey: string;
385
396
  };
397
+ /**
398
+ * One A2A push as the outbox delivers it (`Tx.enqueuePush`): the config it goes to (by session,
399
+ * task and config id) and the exact body. `id` is its Standard Webhooks `webhook-id`.
400
+ */
401
+ export interface OutboxPush {
402
+ id: string;
403
+ sessionId: string;
404
+ taskId: string;
405
+ configId: string;
406
+ body: string;
407
+ }
386
408
  /**
387
409
  * What a committed transaction asked of Durable Session Execution, as the outbox delivers it:
388
410
  * a wake (`Tx.wake`), or a pod sandbox's reconcile or timer (`Tx.signalSandbox`; a reconcile
389
- * carries its idempotency key, `signal:<row id>`).
411
+ * carries its idempotency key, `signal:<row id>`). Or an A2A push to a caller's URL (`push`,
412
+ * `Tx.enqueuePush`), which the Tenant sends itself (`api/a2a/push.ts`).
390
413
  */
391
414
  export type OutboxRequest = {
392
415
  kind: "wake";
@@ -396,6 +419,9 @@ export type OutboxRequest = {
396
419
  kind: "sandbox";
397
420
  sandboxId: string;
398
421
  signal: SandboxSignal;
422
+ } | {
423
+ kind: "push";
424
+ push: OutboxPush;
399
425
  };
400
426
  /**
401
427
  * Hands an outbox request to Durable Session Execution (`SessionStore.deliverTo`). Resolving
@@ -502,6 +528,22 @@ export interface Tx {
502
528
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>,
503
529
  /** Who wrote it (R4.9): absent, core, observed. */
504
530
  writer?: EventWriter): Promise<SessionEventOf<T>>;
531
+ /**
532
+ * One turn's row of the turn index, or undefined when the session never had that turn. `event`
533
+ * keeps the index: each event that changes a turn (`command.message`, `command.approve`,
534
+ * `command.respond`, `turn.*`) writes its row in the same transaction.
535
+ */
536
+ turn(sessionId: string, turnId: string): Promise<TurnRow | undefined>;
537
+ /** The turn that took queued message `queuedMessageId` of the session, if one did (protocol 17). */
538
+ turnOfQueuedMessage(sessionId: string, queuedMessageId: string): Promise<TurnRow | undefined>;
539
+ /**
540
+ * One owner's A2A tasks with one Agent from the turn index (`ListTasks`), latest status change
541
+ * first, with how many match before paging.
542
+ */
543
+ listTurns(query: TurnListQuery): Promise<{
544
+ turns: TurnRow[];
545
+ total: number;
546
+ }>;
505
547
  /**
506
548
  * Asks for an advance of `sessionId` once this transaction commits. The request is a row
507
549
  * of the wake outbox written in this transaction, so it commits, or rolls back, with what
@@ -539,6 +581,43 @@ export interface Tx {
539
581
  retryInMs: number;
540
582
  park: boolean;
541
583
  }): Promise<void>;
584
+ /** Adds or replaces a task's push config (its `createdAt` and `doneAt` start again). */
585
+ putPushConfig(row: Omit<A2aPushConfigRow, "createdAt" | "doneAt"> & {
586
+ doneAt?: Date | null;
587
+ }): Promise<void>;
588
+ /** One push config of a task. */
589
+ pushConfig(sessionId: string, taskId: string, configId: string): Promise<A2aPushConfigRow | undefined>;
590
+ /** A task's push configs, by config id, after `after`, at most `limit`. */
591
+ pushConfigs(sessionId: string, taskId: string, options?: {
592
+ after?: string;
593
+ limit?: number;
594
+ }): Promise<A2aPushConfigRow[]>;
595
+ /** Deletes a task's push config; whether it was there. */
596
+ deletePushConfig(sessionId: string, taskId: string, configId: string): Promise<boolean>;
597
+ /** A session's push configs still following their task (no `doneAt`); with `lock`, locked. */
598
+ activePushConfigs(sessionId: string, options?: {
599
+ lock?: boolean;
600
+ }): Promise<A2aPushConfigRow[]>;
601
+ /** Sessions with a push config still following its task, by id, after `after`, at most `limit`. */
602
+ pushSessions(limit: number, after?: string): Promise<string[]>;
603
+ /** Moves a push config on: the turn it follows, the next event it reads, and whether it is done. */
604
+ advancePushConfig(key: {
605
+ sessionId: string;
606
+ taskId: string;
607
+ configId: string;
608
+ }, change: {
609
+ turnId: string;
610
+ nextSeq: number;
611
+ traceId: string | null;
612
+ done: boolean;
613
+ }): Promise<void>;
614
+ /** Deletes push configs done before `before`; how many. */
615
+ prunePushConfigs(before: Date): Promise<number>;
616
+ /**
617
+ * Writes an A2A push to the outbox (kind `push`), delivered after commit as a wake is, and by
618
+ * the sweep with backoff until it is delivered or too old.
619
+ */
620
+ enqueuePush(push: Omit<OutboxPush, "id">): Promise<void>;
542
621
  /**
543
622
  * Locks the session and takes ownership when it has no owner or the owner's
544
623
  * lease ended at or before `now`; then increments `epoch` and sets the lease