@nylorun/runtime 0.36.0-beta → 0.37.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 (59) hide show
  1. package/CHANGELOG.md +60 -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 +532 -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/host/config-for.d.ts +2 -0
  29. package/dist/host/config-for.js +1 -0
  30. package/dist/host/main.js +1 -0
  31. package/dist/host/stack-config.d.ts +16 -0
  32. package/dist/host/stack-config.js +30 -0
  33. package/dist/management-openapi.json +5 -2
  34. package/dist/openapi.json +1485 -738
  35. package/dist/reads/types.d.ts +19 -0
  36. package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
  37. package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
  38. package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
  39. package/dist/store/postgres/drizzle/meta/_journal.json +21 -0
  40. package/dist/store/postgres/reads.js +41 -1
  41. package/dist/store/postgres/schema.d.ts +1296 -534
  42. package/dist/store/postgres/schema.js +92 -0
  43. package/dist/store/postgres/store.js +133 -3
  44. package/dist/store/postgres/turns.d.ts +62 -0
  45. package/dist/store/postgres/turns.js +153 -0
  46. package/dist/store/types.d.ts +73 -3
  47. package/dist/tenant/runtime.js +7 -5
  48. package/dist/tenant/session-model.d.ts +11 -1
  49. package/dist/tenant/session-model.js +34 -5
  50. package/dist/tenant/sessions.d.ts +2 -1
  51. package/dist/tenant/sessions.js +8 -2
  52. package/dist/tenant/sweep.d.ts +3 -0
  53. package/dist/tenant/sweep.js +39 -11
  54. package/dist/tenant/types.d.ts +23 -0
  55. package/dist/tuning.d.ts +9 -0
  56. package/dist/tuning.js +11 -0
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +3 -3
@@ -495,6 +495,46 @@ export const modelBudgets = nylorun.table("model_budgets", {
495
495
  limitTokens: bigint({ mode: "number" }),
496
496
  updatedAt: text().notNull(),
497
497
  }, (t) => [primaryKey({ name: "model_budgets_pkey", columns: [t.scope, t.scopeId] })]);
498
+ /**
499
+ * The turn index (A2A design §9, Host feature `turns`): one row per turn, written by `Tx.event`
500
+ * in the transaction of each event that changes the turn (`store/postgres/turns.ts`), so it
501
+ * agrees with the session's log. The session's Agent, owner and A2A context are copied in when
502
+ * the turn starts. `txid` is the transaction that last changed the row: the change feed reads
503
+ * rows in its order, only below every transaction still running (`pg_snapshot_xmin`), as the
504
+ * model call export does. `firstSeq` and `lastSeq` bound the turn's lifecycle events in the log.
505
+ */
506
+ export const turns = nylorun.table("turns", {
507
+ sessionId: textC().notNull(),
508
+ turnId: textC().notNull(),
509
+ agentId: textC().notNull(),
510
+ /** The session owner (`ownerUserId`); null for an application's own session. */
511
+ ownerUserId: textC(),
512
+ /** `info.a2a.contextId` of an A2A context's session. */
513
+ a2aContextId: textC(),
514
+ status: text({ enum: ["running", "paused", "completed", "failed", "cancelled"] }).notNull(),
515
+ /** ISO times: of the turn's first event, its end, and its last status change. */
516
+ startedAt: textC().notNull(),
517
+ endedAt: textC(),
518
+ changedAt: textC().notNull(),
519
+ /** The end facts' `totals` (`{steps, tokens, costUsd}`), once it ended with them. */
520
+ totals: jsonText(),
521
+ /**
522
+ * While `paused`: what its first open interaction asks for, as profile v2 names it
523
+ * (`question`, `approval` or `connection`); null otherwise.
524
+ */
525
+ waitingFor: text({ enum: ["question", "approval", "connection"] }),
526
+ /** The queued messages it took (protocol 17): their A2A task ids name it too. */
527
+ queuedMessageIds: text().array().notNull().default(sql `'{}'`),
528
+ firstSeq: bigint({ mode: "number" }).notNull(),
529
+ lastSeq: bigint({ mode: "number" }).notNull(),
530
+ txid: xid8().notNull().default(sql `pg_current_xact_id()`),
531
+ }, (t) => [
532
+ primaryKey({ name: "turns_pkey", columns: [t.sessionId, t.turnId] }),
533
+ index("turns_turn").on(t.turnId),
534
+ index("turns_changes").on(t.txid, t.sessionId, t.turnId),
535
+ index("turns_owner_changes").on(t.ownerUserId, t.txid),
536
+ index("turns_owner_agent").on(t.ownerUserId, t.agentId, t.changedAt),
537
+ ]);
498
538
  /**
499
539
  * Each session's queue (protocol 17, D76): messages that arrived while its turn ran or waited, in
500
540
  * order (`seq`), until a turn takes them. A row holds the command as sent, the turn input its
@@ -700,6 +740,58 @@ export const sandboxSignals = nylorun.table("sandbox_signals", {
700
740
  index("sandbox_signals_created_at").on(t.createdAt),
701
741
  check("sandbox_signals_kind_check", sql `${t.kind} IN ('reconcile', 'arm')`),
702
742
  ]);
743
+ /**
744
+ * A2A push notification configs (Host feature `a2a-profile-v2`): where a task's updates go, one
745
+ * row per task and config id (`taskId`: the A2A id its caller knows, a queued message's own until
746
+ * a turn takes it; `turnId`: the turn it follows). The push translator (`api/a2a/push.ts`) reads
747
+ * the session's events from `nextSeq` on and writes each update as an `a2a_pushes` row in one
748
+ * transaction; `doneAt` is set once the task's turn ended, and the row is deleted after the
749
+ * Host tuning's `a2a.pushConfigRetentionMs`. `extensions` are the profile extensions the caller
750
+ * activated when it gave the config; `baseUrl` is the Runtime's URL as that caller reached it,
751
+ * for file URLs; `traceId` is `telemetry/v1`'s.
752
+ */
753
+ export const a2aPushConfigs = nylorun.table("a2a_push_configs", {
754
+ sessionId: textC().notNull(),
755
+ taskId: textC().notNull(),
756
+ configId: textC().notNull(),
757
+ contextId: text().notNull(),
758
+ turnId: textC().notNull(),
759
+ url: text().notNull(),
760
+ token: text(),
761
+ authentication: jsonText(),
762
+ baseUrl: text().notNull(),
763
+ extensions: jsonText().notNull(),
764
+ traceId: text(),
765
+ nextSeq: bigint({ mode: "number" }).notNull(),
766
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
767
+ .notNull()
768
+ .default(sql `clock_timestamp()`),
769
+ doneAt: timestamp({ withTimezone: true, mode: "date" }),
770
+ }, (t) => [
771
+ primaryKey({ name: "a2a_push_configs_pkey", columns: [t.sessionId, t.taskId, t.configId] }),
772
+ index("a2a_push_configs_done_at").on(t.doneAt),
773
+ ]);
774
+ /**
775
+ * The push outbox (outbox kind `push`): one row per A2A update a push config is owed, its body
776
+ * the exact JSON sent (a `StreamResponse`), delivered after commit and by the Tenant sweep as the
777
+ * other outbox rows are (`attempts`, `retry_at`, `parked_at`). The row id is the Standard
778
+ * Webhooks `webhook-id`, the same on every attempt. A row whose config is gone, or that kept
779
+ * failing for the Host tuning's `a2a.pushMaxAgeMs` (24 hours), is deleted: the change feed
780
+ * covers what it would have said.
781
+ */
782
+ export const a2aPushes = nylorun.table("a2a_pushes", {
783
+ id: textC().primaryKey(),
784
+ sessionId: textC().notNull(),
785
+ taskId: textC().notNull(),
786
+ configId: textC().notNull(),
787
+ body: text().notNull(),
788
+ createdAt: timestamp({ withTimezone: true, mode: "date" })
789
+ .notNull()
790
+ .default(sql `clock_timestamp()`),
791
+ attempts: integer().notNull().default(0),
792
+ retryAt: timestamp({ withTimezone: true, mode: "date" }),
793
+ parkedAt: timestamp({ withTimezone: true, mode: "date" }),
794
+ }, (t) => [index("a2a_pushes_created_at").on(t.createdAt)]);
703
795
  // ---------------------------------------------------------------------------
704
796
  // nylorun_streams: the record
705
797
  /**
@@ -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
  }
@@ -327,7 +328,17 @@ const SIGNING_KEY_STAMP = {
327
328
  };
328
329
  /** The audit columns a row has (`ord` only orders them). */
329
330
  const { ord: _ord, ...AUDIT } = getTableColumns(vaultAudit);
330
- const SESSION_TABLES = [sessions, commands, effects, links, wakes, sessionQueue];
331
+ const SESSION_TABLES = [
332
+ sessions,
333
+ commands,
334
+ effects,
335
+ links,
336
+ wakes,
337
+ sessionQueue,
338
+ turns,
339
+ a2aPushConfigs,
340
+ a2aPushes,
341
+ ];
331
342
  /**
332
343
  * The outbox's tables, by the kind of request each holds. Their delivery columns (`id`,
333
344
  * `created_at`, `attempts`, `retry_at`, `parked_at`) are the same.
@@ -335,7 +346,12 @@ const SESSION_TABLES = [sessions, commands, effects, links, wakes, sessionQueue]
335
346
  const OUTBOX = {
336
347
  wake: wakes,
337
348
  sandbox: sandboxSignals,
349
+ push: a2aPushes,
338
350
  };
351
+ /** A push outbox row as it is delivered. */
352
+ function outboxPush(row) {
353
+ return { id: row.id, sessionId: row.sessionId, taskId: row.taskId, configId: row.configId, body: row.body };
354
+ }
339
355
  /** A wake outbox row as it is delivered: its own dedupe key, or one made from the row id. */
340
356
  function outboxWake(id, reason, dedupeKey) {
341
357
  return { reason, dedupeKey: dedupeKey ?? `wake:${id}` };
@@ -439,10 +455,25 @@ class PostgresTx {
439
455
  ...(writer ? { writer } : {}),
440
456
  maxEntryBytes: await this.maxEntryBytes(),
441
457
  });
458
+ // The turn index changes with the log, in this transaction (Host feature `turns`).
459
+ await indexTurn(db, event);
442
460
  this.events.push(event);
443
461
  this.generations.push(generation);
444
462
  return structuredClone(event);
445
463
  }
464
+ // --- turn index ----------------------------------------------------------
465
+ async turn(sessionId, turnId) {
466
+ this.check();
467
+ return turnRow(this.db, sessionId, turnId);
468
+ }
469
+ async turnOfQueuedMessage(sessionId, queuedMessageId) {
470
+ this.check();
471
+ return turnOfQueuedMessage(this.db, sessionId, queuedMessageId);
472
+ }
473
+ async listTurns(query) {
474
+ this.check();
475
+ return listTurns(this.db, query);
476
+ }
446
477
  // --- wake outbox ---------------------------------------------------------
447
478
  async wake(sessionId, wake) {
448
479
  this.check();
@@ -514,12 +545,27 @@ class PostgresTx {
514
545
  due: row.dueSeconds,
515
546
  })),
516
547
  ];
548
+ const push = DUE(OUTBOX.push, graceMs);
549
+ const pushRows = await this.db
550
+ .select({ ...getTableColumns(a2aPushes), dueSeconds: push.dueSeconds })
551
+ .from(a2aPushes)
552
+ .where(and(isNull(a2aPushes.parkedAt), lte(push.due, now)))
553
+ .orderBy(push.due, a2aPushes.id)
554
+ .limit(limit);
555
+ rows.push(...pushRows.map((row) => ({
556
+ kind: "push",
557
+ id: row.id,
558
+ request: { kind: "push", push: outboxPush(row) },
559
+ createdAt: row.createdAt.toISOString(),
560
+ attempts: row.attempts,
561
+ due: row.dueSeconds,
562
+ })));
517
563
  rows.sort((a, b) => a.due - b.due || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
518
564
  return rows.slice(0, limit).map(({ due: _due, ...row }) => row);
519
565
  }
520
566
  async deleteOutbox(rows) {
521
567
  this.check();
522
- for (const kind of ["wake", "sandbox"]) {
568
+ for (const kind of ["wake", "sandbox", "push"]) {
523
569
  const ids = rows.filter((row) => row.kind === kind).map((row) => row.id);
524
570
  if (ids.length > 0)
525
571
  await this.db.delete(OUTBOX[kind]).where(inArray(OUTBOX[kind].id, ids));
@@ -538,6 +584,90 @@ class PostgresTx {
538
584
  })
539
585
  .where(eq(table.id, row.id));
540
586
  }
587
+ // --- A2A push (Host feature `a2a-profile-v2`) ---------------------------------
588
+ async putPushConfig(row) {
589
+ this.check();
590
+ const { sessionId: _s, taskId: _t, configId: _c, ...set } = row;
591
+ await this.db
592
+ .insert(a2aPushConfigs)
593
+ .values({ ...row, doneAt: row.doneAt ?? null })
594
+ .onConflictDoUpdate({
595
+ target: [a2aPushConfigs.sessionId, a2aPushConfigs.taskId, a2aPushConfigs.configId],
596
+ set: { ...set, doneAt: row.doneAt ?? null, createdAt: sql `clock_timestamp()` },
597
+ });
598
+ }
599
+ async pushConfig(sessionId, taskId, configId) {
600
+ this.check();
601
+ const [row] = await this.db
602
+ .select()
603
+ .from(a2aPushConfigs)
604
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), eq(a2aPushConfigs.configId, configId)));
605
+ return row;
606
+ }
607
+ async pushConfigs(sessionId, taskId, options = {}) {
608
+ this.check();
609
+ const query = this.db
610
+ .select()
611
+ .from(a2aPushConfigs)
612
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), options.after === undefined ? undefined : gt(a2aPushConfigs.configId, options.after)))
613
+ .orderBy(a2aPushConfigs.configId)
614
+ .$dynamic();
615
+ return options.limit === undefined ? query : query.limit(options.limit);
616
+ }
617
+ async deletePushConfig(sessionId, taskId, configId) {
618
+ this.check();
619
+ const deleted = await this.db
620
+ .delete(a2aPushConfigs)
621
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), eq(a2aPushConfigs.taskId, taskId), eq(a2aPushConfigs.configId, configId)))
622
+ .returning({ id: a2aPushConfigs.configId });
623
+ return deleted.length > 0;
624
+ }
625
+ async activePushConfigs(sessionId, options = {}) {
626
+ this.check();
627
+ const query = this.db
628
+ .select()
629
+ .from(a2aPushConfigs)
630
+ .where(and(eq(a2aPushConfigs.sessionId, sessionId), isNull(a2aPushConfigs.doneAt)))
631
+ .orderBy(a2aPushConfigs.taskId, a2aPushConfigs.configId)
632
+ .$dynamic();
633
+ return options.lock ? query.for("update") : query;
634
+ }
635
+ async pushSessions(limit, after) {
636
+ this.check();
637
+ const rows = await this.db
638
+ .selectDistinct({ sessionId: a2aPushConfigs.sessionId })
639
+ .from(a2aPushConfigs)
640
+ .where(and(isNull(a2aPushConfigs.doneAt), after === undefined ? undefined : gt(a2aPushConfigs.sessionId, after)))
641
+ .orderBy(a2aPushConfigs.sessionId)
642
+ .limit(limit);
643
+ return rows.map((row) => row.sessionId);
644
+ }
645
+ async advancePushConfig(key, change) {
646
+ this.check();
647
+ await this.db
648
+ .update(a2aPushConfigs)
649
+ .set({
650
+ turnId: change.turnId,
651
+ nextSeq: change.nextSeq,
652
+ traceId: change.traceId,
653
+ ...(change.done ? { doneAt: sql `clock_timestamp()` } : {}),
654
+ })
655
+ .where(and(eq(a2aPushConfigs.sessionId, key.sessionId), eq(a2aPushConfigs.taskId, key.taskId), eq(a2aPushConfigs.configId, key.configId)));
656
+ }
657
+ async prunePushConfigs(before) {
658
+ this.check();
659
+ const deleted = await this.db
660
+ .delete(a2aPushConfigs)
661
+ .where(lt(a2aPushConfigs.doneAt, before))
662
+ .returning({ id: a2aPushConfigs.configId });
663
+ return deleted.length;
664
+ }
665
+ async enqueuePush(push) {
666
+ this.check();
667
+ const id = `push_${randomUUID()}`;
668
+ await this.db.insert(a2aPushes).values({ id, ...push });
669
+ this.outbox.push({ kind: "push", id, request: { kind: "push", push: { id, ...push } } });
670
+ }
541
671
  // --- ownership -----------------------------------------------------------
542
672
  async takeOwnership(sessionId, claim) {
543
673
  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[];
@@ -295,7 +297,7 @@ export type CommitListener = (commit: Commit) => void;
295
297
  * `bytea` columns, never inside a JSON body), the vault's audit and idempotency records, the
296
298
  * Tenant's signing keys, a model call in the usage ledger and a model budget.
297
299
  */
298
- export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, QueuedMessageRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
300
+ export type { ArtifactRow, ArtifactVersionRow, DefinitionFileRow, ModelBudgetRow, ModelUsageRow, ModelUsageWrite, ToolCrossingRow, QueuedMessageRow, TurnRow, A2aPushConfigRow, AgentTriggerRow, TriggerRunRow, MemoryRow, MemoryVersionRow, PrincipalRow, SigningKeyRow, VaultAuditRow, VaultCredentialRow, VaultIdempotencyRow, VaultRow, };
299
301
  /** Which rows of the usage ledger a total covers. */
300
302
  export interface ModelUsageQuery {
301
303
  scope: "tenant" | "manifest" | "turn";
@@ -383,10 +385,22 @@ export interface SessionStore {
383
385
  export type OutboxWake = Wake & {
384
386
  dedupeKey: string;
385
387
  };
388
+ /**
389
+ * One A2A push as the outbox delivers it (`Tx.enqueuePush`): the config it goes to (by session,
390
+ * task and config id) and the exact body. `id` is its Standard Webhooks `webhook-id`.
391
+ */
392
+ export interface OutboxPush {
393
+ id: string;
394
+ sessionId: string;
395
+ taskId: string;
396
+ configId: string;
397
+ body: string;
398
+ }
386
399
  /**
387
400
  * What a committed transaction asked of Durable Session Execution, as the outbox delivers it:
388
401
  * a wake (`Tx.wake`), or a pod sandbox's reconcile or timer (`Tx.signalSandbox`; a reconcile
389
- * carries its idempotency key, `signal:<row id>`).
402
+ * carries its idempotency key, `signal:<row id>`). Or an A2A push to a caller's URL (`push`,
403
+ * `Tx.enqueuePush`), which the Tenant sends itself (`api/a2a/push.ts`).
390
404
  */
391
405
  export type OutboxRequest = {
392
406
  kind: "wake";
@@ -396,6 +410,9 @@ export type OutboxRequest = {
396
410
  kind: "sandbox";
397
411
  sandboxId: string;
398
412
  signal: SandboxSignal;
413
+ } | {
414
+ kind: "push";
415
+ push: OutboxPush;
399
416
  };
400
417
  /**
401
418
  * Hands an outbox request to Durable Session Execution (`SessionStore.deliverTo`). Resolving
@@ -502,6 +519,22 @@ export interface Tx {
502
519
  event<T extends EventType>(sessionId: string, turnId: string | null, type: T, payload: EventPayload<T>,
503
520
  /** Who wrote it (R4.9): absent, core, observed. */
504
521
  writer?: EventWriter): Promise<SessionEventOf<T>>;
522
+ /**
523
+ * One turn's row of the turn index, or undefined when the session never had that turn. `event`
524
+ * keeps the index: each event that changes a turn (`command.message`, `command.approve`,
525
+ * `command.respond`, `turn.*`) writes its row in the same transaction.
526
+ */
527
+ turn(sessionId: string, turnId: string): Promise<TurnRow | undefined>;
528
+ /** The turn that took queued message `queuedMessageId` of the session, if one did (protocol 17). */
529
+ turnOfQueuedMessage(sessionId: string, queuedMessageId: string): Promise<TurnRow | undefined>;
530
+ /**
531
+ * One owner's A2A tasks with one Agent from the turn index (`ListTasks`), latest status change
532
+ * first, with how many match before paging.
533
+ */
534
+ listTurns(query: TurnListQuery): Promise<{
535
+ turns: TurnRow[];
536
+ total: number;
537
+ }>;
505
538
  /**
506
539
  * Asks for an advance of `sessionId` once this transaction commits. The request is a row
507
540
  * of the wake outbox written in this transaction, so it commits, or rolls back, with what
@@ -539,6 +572,43 @@ export interface Tx {
539
572
  retryInMs: number;
540
573
  park: boolean;
541
574
  }): Promise<void>;
575
+ /** Adds or replaces a task's push config (its `createdAt` and `doneAt` start again). */
576
+ putPushConfig(row: Omit<A2aPushConfigRow, "createdAt" | "doneAt"> & {
577
+ doneAt?: Date | null;
578
+ }): Promise<void>;
579
+ /** One push config of a task. */
580
+ pushConfig(sessionId: string, taskId: string, configId: string): Promise<A2aPushConfigRow | undefined>;
581
+ /** A task's push configs, by config id, after `after`, at most `limit`. */
582
+ pushConfigs(sessionId: string, taskId: string, options?: {
583
+ after?: string;
584
+ limit?: number;
585
+ }): Promise<A2aPushConfigRow[]>;
586
+ /** Deletes a task's push config; whether it was there. */
587
+ deletePushConfig(sessionId: string, taskId: string, configId: string): Promise<boolean>;
588
+ /** A session's push configs still following their task (no `doneAt`); with `lock`, locked. */
589
+ activePushConfigs(sessionId: string, options?: {
590
+ lock?: boolean;
591
+ }): Promise<A2aPushConfigRow[]>;
592
+ /** Sessions with a push config still following its task, by id, after `after`, at most `limit`. */
593
+ pushSessions(limit: number, after?: string): Promise<string[]>;
594
+ /** Moves a push config on: the turn it follows, the next event it reads, and whether it is done. */
595
+ advancePushConfig(key: {
596
+ sessionId: string;
597
+ taskId: string;
598
+ configId: string;
599
+ }, change: {
600
+ turnId: string;
601
+ nextSeq: number;
602
+ traceId: string | null;
603
+ done: boolean;
604
+ }): Promise<void>;
605
+ /** Deletes push configs done before `before`; how many. */
606
+ prunePushConfigs(before: Date): Promise<number>;
607
+ /**
608
+ * Writes an A2A push to the outbox (kind `push`), delivered after commit as a wake is, and by
609
+ * the sweep with backoff until it is delivered or too old.
610
+ */
611
+ enqueuePush(push: Omit<OutboxPush, "id">): Promise<void>;
542
612
  /**
543
613
  * Locks the session and takes ownership when it has no owner or the owner's
544
614
  * lease ended at or before `now`; then increments `epoch` and sets the lease
@@ -17,7 +17,8 @@ import { createSessionStreams, endAllStreams, } from "./session-streams.js";
17
17
  import { closeStreams, streamsStatus, wireStreams, } from "./streams.js";
18
18
  import { abortAll, abortLocal, createWorkState, drain, waitForIdle, } from "./scheduler.js";
19
19
  import { advance } from "./advance.js";
20
- import { sweep } from "./sweep.js";
20
+ import { outboxFields, sweep } from "./sweep.js";
21
+ import { deliverPush } from "../api/a2a/push.js";
21
22
  import { TenantWorkers, WORKER_ID, } from "./worker.js";
22
23
  import { authorize } from "./effects.js";
23
24
  import { inProcessToolGate, openMcpThrough } from "../gates/tool-gate.js";
@@ -276,7 +277,10 @@ export class TenantRuntime {
276
277
  },
277
278
  send: (request) => request.kind === "wake"
278
279
  ? ctx.wake(request.sessionId, request.wake)
279
- : ctx.sandboxSignal(request.sandboxId, request.signal),
280
+ : request.kind === "push"
281
+ ? // An A2A push goes to the caller's URL, not to the execution (`api/a2a/push.ts`).
282
+ deliverPush(ctx, request.push)
283
+ : ctx.sandboxSignal(request.sandboxId, request.signal),
280
284
  ...(hooks.execution?.stuckInvocations
281
285
  ? {
282
286
  stuckInvocations: () => hooks.execution.stuckInvocations(config.tenantId),
@@ -296,9 +300,7 @@ export class TenantRuntime {
296
300
  }
297
301
  catch (error) {
298
302
  config.logger.warn("outbox delivery after commit failed; the sweep sends it again", {
299
- ...(request.kind === "wake"
300
- ? { sessionId: request.sessionId, reason: request.wake.reason }
301
- : { sandboxId: request.sandboxId, signal: request.signal.kind }),
303
+ ...outboxFields(request),
302
304
  message: error instanceof Error ? error.message : String(error),
303
305
  });
304
306
  return false;