@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
@@ -1,4 +1,5 @@
1
1
  import type { ArtifactKind, ArtifactPage, ModelCallExportPage, ModelCallsPage, ToolCallsPage, SessionManifestView, SessionPage, SessionListItem, SessionUsageTotals, LiveEvent } from "@nylorun/core/contracts";
2
+ import type { TurnRow } from "../store/types.js";
2
3
  export type ReadAccess = {
3
4
  owner?: string;
4
5
  agents?: readonly string[];
@@ -14,6 +15,12 @@ export type PageOptions = {
14
15
  limit: number;
15
16
  cursor?: string;
16
17
  };
18
+ /** A page of the turn change feed: rows in the order they changed, and where to read on. */
19
+ export type TurnChangeRows = {
20
+ turns: TurnRow[];
21
+ cursor: string;
22
+ hasMore: boolean;
23
+ };
17
24
  export type ArtifactFilters = {
18
25
  sessionId?: string;
19
26
  kind?: ArtifactKind;
@@ -34,5 +41,17 @@ export interface ReadStore {
34
41
  * for a turn the session never had.
35
42
  */
36
43
  turnEvents(id: string, turnId: string, access: ReadAccess): Promise<LiveEvent[]>;
44
+ /**
45
+ * The turn index's change feed (Host feature `turns`): the turns `access` reaches (of one
46
+ * session with `sessionId`), in the order their transactions changed them, after `changedAfter`
47
+ * (a cursor it returned). Only changes below every transaction still running are read, so a
48
+ * change committed later never lands behind the cursor.
49
+ */
50
+ turnChanges(filters: {
51
+ sessionId?: string;
52
+ }, page: {
53
+ limit: number;
54
+ changedAfter?: string;
55
+ }, access: ReadAccess): Promise<TurnChangeRows>;
37
56
  close(): Promise<void>;
38
57
  }
@@ -20,7 +20,13 @@ export declare class HostAuthError extends Error {
20
20
  }
21
21
  /** The Tenant's side of the exchange, as the Harness API listener calls it. */
22
22
  export interface HostAuthority {
23
- join(request: HostJoinRequest): Promise<HostJoinAnswer>;
23
+ /**
24
+ * `imageEngine`: the Runtime version an engine the workload image carries reported (ADR-99);
25
+ * absent for an engine copied in.
26
+ */
27
+ join(request: HostJoinRequest, from?: {
28
+ imageEngine?: string;
29
+ }): Promise<HostJoinAnswer>;
24
30
  renew(hostToken: string): Promise<HostJoinAnswer>;
25
31
  /** The claims of a current host's token, for a connection; refuses a stale one. */
26
32
  verify(hostToken: string): Promise<HostClaims>;
@@ -85,7 +85,7 @@ export function hostAuthority(ctx) {
85
85
  return verdict.claims;
86
86
  };
87
87
  return {
88
- async join(request) {
88
+ async join(request, from) {
89
89
  const { sandboxId, podUid, joinToken, volumeId } = request;
90
90
  if (!isAgentId(sandboxId) ||
91
91
  typeof podUid !== "string" ||
@@ -146,6 +146,10 @@ export function hostAuthority(ctx) {
146
146
  hostEpoch: epoch,
147
147
  podUid,
148
148
  ...(pod.volumeId === undefined && volumeId !== undefined ? { volumeId } : {}),
149
+ // The engine the image carries, against the digest this pod runs (ADR-99).
150
+ ...(from?.imageEngine === undefined
151
+ ? {}
152
+ : { engineImage: { version: from.imageEngine, ...(pod.imageDigest ? { digest: pod.imageDigest } : {}) } }),
149
153
  ...(pod.desired === "running" ? { observed: "running", reason: null } : {}),
150
154
  }, now);
151
155
  if (pod.podUid !== podUid) {
@@ -0,0 +1,18 @@
1
+ export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
2
+ /** The engine an image carries, as the pod's record keeps it: its version, seen on `digest`. */
3
+ export interface EngineImage {
4
+ readonly version: string;
5
+ readonly digest?: string;
6
+ }
7
+ /**
8
+ * Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
9
+ * the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
10
+ * version or not known yet.
11
+ */
12
+ export declare function runsBuiltinEngine(input: {
13
+ readonly image: string | undefined;
14
+ readonly imageDigest: string | undefined;
15
+ readonly engineImage: EngineImage | undefined;
16
+ readonly listed: readonly string[] | undefined;
17
+ readonly runtimeVersion?: string;
18
+ }): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * An engine built into the workload's image (ADR-99): the Runtime image's tini, node and bundled
3
+ * engine under `/opt/nylorun`, which the sandboxes service runs with `entrypoint: "builtin"` and
4
+ * nothing copied in.
5
+ *
6
+ * The Tenant lists the images that carry one (`engine.images`, by repository). Such an engine is
7
+ * whichever Runtime version the image was built from, so it reports that version when it joins
8
+ * (`IMAGE_ENGINE_HEADER`, sent only by an engine running from `/opt/nylorun`), and the pod's
9
+ * record keeps it beside the digest it ran (`engineImage`). A pod runs the image's engine while
10
+ * that version is the Runtime's own, or not known yet for the digest it runs (a first start, which
11
+ * the Harness API version check at `hello` still guards); otherwise the engine is copied in, so a
12
+ * Runtime upgrade never leaves a pod on an older engine for more than that first turn.
13
+ */
14
+ import { RUNTIME_VERSION } from "../../version.js";
15
+ import { repositoryOf } from "./lifecycle.js";
16
+ export { BUILTIN_ENGINE_ROOT, ENGINE_VERSION_PATTERN, IMAGE_ENGINE_HEADER } from "../../harness/image-engine.js";
17
+ /**
18
+ * Whether a pod of `image` (its pinned digest, or as asked) runs the engine the image carries:
19
+ * the Tenant lists its repository, and the engine recorded for that digest is this Runtime's
20
+ * version or not known yet.
21
+ */
22
+ export function runsBuiltinEngine(input) {
23
+ const image = input.imageDigest ?? input.image;
24
+ if (!image || !input.listed?.length)
25
+ return false;
26
+ const repository = repositoryOf(image);
27
+ if (!input.listed.some((entry) => repositoryOf(entry) === repository))
28
+ return false;
29
+ const known = input.engineImage !== undefined && input.imageDigest !== undefined && input.engineImage.digest === input.imageDigest
30
+ ? input.engineImage.version
31
+ : undefined;
32
+ return known === undefined || known === (input.runtimeVersion ?? RUNTIME_VERSION);
33
+ }
@@ -5,11 +5,12 @@ export interface PodSpec {
5
5
  /** The workload image; the service defaults it to `python:3.13-slim`. */
6
6
  readonly image?: string;
7
7
  /**
8
- * What the workload runs: `engine` (default), our engine copied from `harnessImage`, or
9
- * `image`, the image's own entrypoint, for a custom harness built into it (R4.4).
8
+ * What the workload runs: `engine` (default), our engine copied from `harnessImage`;
9
+ * `builtin`, our engine the image carries under `/opt/nylorun` (ADR-99); or `image`, the image's
10
+ * own entrypoint, for a custom harness built into it (R4.4).
10
11
  */
11
- readonly entrypoint?: "engine" | "image";
12
- /** The Runtime image the engine is copied from; not with `entrypoint: "image"`. */
12
+ readonly entrypoint?: "engine" | "builtin" | "image";
13
+ /** The Runtime image the engine is copied from; not with `entrypoint: "builtin"` or `"image"`. */
13
14
  readonly harnessImage?: string;
14
15
  /** Replaces the engine command (diagnostics). */
15
16
  readonly command?: readonly string[];
@@ -8,6 +8,7 @@ import { DEFAULT_HARNESS } from "@nylorun/core/contracts";
8
8
  import { idleMsOf, memoryMiBOf } from "../policy.js";
9
9
  import { podTuningEnv } from "../../tuning.js";
10
10
  import { opIdOf } from "./lifecycle.js";
11
+ import { runsBuiltinEngine } from "./builtin.js";
11
12
  /** The pod's grace period to stop when the Tenant sets none (D34). */
12
13
  export const DEFAULT_STOP_GRACE_SECONDS = 10;
13
14
  /** The volume's size when the sandbox asks for none. */
@@ -40,12 +41,24 @@ export function podSpecOf(input) {
40
41
  // Pinned by digest once a pod ran it (Q14).
41
42
  const image = pod.imageDigest ?? spec.image;
42
43
  const ours = (input.harness ?? DEFAULT_HARNESS) === DEFAULT_HARNESS;
44
+ // Our engine, built into an image the Tenant lists, while it is this Runtime's (ADR-99).
45
+ const builtin = ours &&
46
+ runsBuiltinEngine({
47
+ image: spec.image,
48
+ imageDigest: pod.imageDigest,
49
+ engineImage: pod.engineImage,
50
+ listed: config.engine?.images,
51
+ });
43
52
  return {
44
53
  opId: opIdOf(pod),
45
54
  mode: input.mode,
46
55
  ...(image ? { image } : {}),
47
56
  // A custom harness is its image: the pod runs the image's entrypoint, nothing copied in.
48
- ...(ours ? { harnessImage: input.harnessImage } : { entrypoint: "image" }),
57
+ ...(!ours
58
+ ? { entrypoint: "image" }
59
+ : builtin
60
+ ? { entrypoint: "builtin" }
61
+ : { harnessImage: input.harnessImage }),
49
62
  cpus: spec.resources?.cpus ?? 1,
50
63
  memoryMiB: memoryMiBOf(spec),
51
64
  storageGiB: storageGiBOf(spec),
@@ -0,0 +1,32 @@
1
+ CREATE TABLE "nylorun"."a2a_push_configs" (
2
+ "session_id" text COLLATE "C" NOT NULL,
3
+ "task_id" text COLLATE "C" NOT NULL,
4
+ "config_id" text COLLATE "C" NOT NULL,
5
+ "context_id" text NOT NULL,
6
+ "turn_id" text COLLATE "C" NOT NULL,
7
+ "url" text NOT NULL,
8
+ "token" text,
9
+ "authentication" json,
10
+ "base_url" text NOT NULL,
11
+ "extensions" json NOT NULL,
12
+ "trace_id" text,
13
+ "next_seq" bigint NOT NULL,
14
+ "created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
15
+ "done_at" timestamp with time zone,
16
+ CONSTRAINT "a2a_push_configs_pkey" PRIMARY KEY("session_id","task_id","config_id")
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE TABLE "nylorun"."a2a_pushes" (
20
+ "id" text COLLATE "C" PRIMARY KEY NOT NULL,
21
+ "session_id" text COLLATE "C" NOT NULL,
22
+ "task_id" text COLLATE "C" NOT NULL,
23
+ "config_id" text COLLATE "C" NOT NULL,
24
+ "body" text NOT NULL,
25
+ "created_at" timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
26
+ "attempts" integer DEFAULT 0 NOT NULL,
27
+ "retry_at" timestamp with time zone,
28
+ "parked_at" timestamp with time zone
29
+ );
30
+ --> statement-breakpoint
31
+ CREATE INDEX "a2a_push_configs_done_at" ON "nylorun"."a2a_push_configs" USING btree ("done_at");--> statement-breakpoint
32
+ CREATE INDEX "a2a_pushes_created_at" ON "nylorun"."a2a_pushes" USING btree ("created_at");
@@ -0,0 +1,23 @@
1
+ CREATE TABLE "nylorun"."turns" (
2
+ "session_id" text COLLATE "C" NOT NULL,
3
+ "turn_id" text COLLATE "C" NOT NULL,
4
+ "agent_id" text COLLATE "C" NOT NULL,
5
+ "owner_user_id" text COLLATE "C",
6
+ "a2a_context_id" text COLLATE "C",
7
+ "status" text NOT NULL,
8
+ "started_at" text COLLATE "C" NOT NULL,
9
+ "ended_at" text COLLATE "C",
10
+ "changed_at" text COLLATE "C" NOT NULL,
11
+ "totals" json,
12
+ "waiting_for" text,
13
+ "queued_message_ids" text[] DEFAULT '{}' NOT NULL,
14
+ "first_seq" bigint NOT NULL,
15
+ "last_seq" bigint NOT NULL,
16
+ "txid" "xid8" DEFAULT pg_current_xact_id() NOT NULL,
17
+ CONSTRAINT "turns_pkey" PRIMARY KEY("session_id","turn_id")
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE INDEX "turns_turn" ON "nylorun"."turns" USING btree ("turn_id");--> statement-breakpoint
21
+ CREATE INDEX "turns_changes" ON "nylorun"."turns" USING btree ("txid","session_id","turn_id");--> statement-breakpoint
22
+ CREATE INDEX "turns_owner_changes" ON "nylorun"."turns" USING btree ("owner_user_id","txid");--> statement-breakpoint
23
+ CREATE INDEX "turns_owner_agent" ON "nylorun"."turns" USING btree ("owner_user_id","agent_id","changed_at");
@@ -0,0 +1,88 @@
1
+ -- Custom SQL (a data change): the turn index (0040) for the turns the record already holds. Each
2
+ -- turn's row is what `Tx.event` would have written for its lifecycle events (`command.message`,
3
+ -- `command.approve`, `command.respond`, `turn.paused`, `turn.completed`, `turn.failed`,
4
+ -- `turn.cancelled`): its status from the last of them (and, paused, what its first open
5
+ -- interaction asks for), its start from the first, and the queued messages it took. Its session's
6
+ -- Agent, owner and A2A context come from the session row as it is now. Rows written from here on
7
+ -- come from `Tx.event`, in the transaction of the event.
8
+ WITH lifecycle AS (
9
+ SELECT "session_id", "seq", "type", nylorun.doc("body") AS doc
10
+ FROM "nylorun_streams"."session_events"
11
+ WHERE "type" IN (
12
+ 'command.message', 'command.approve', 'command.respond',
13
+ 'turn.paused', 'turn.completed', 'turn.failed', 'turn.cancelled'
14
+ )
15
+ ), turned AS (
16
+ SELECT "session_id", doc->>'turnId' AS turn_id, "seq", "type", doc
17
+ FROM lifecycle
18
+ WHERE doc->>'turnId' IS NOT NULL
19
+ ), bounds AS (
20
+ SELECT "session_id", turn_id, min("seq") AS first_seq,
21
+ min(doc->>'time') AS started_at,
22
+ coalesce(
23
+ array_remove(
24
+ array_agg(doc->'payload'->>'queuedMessageId' ORDER BY "seq")
25
+ FILTER (WHERE "type" = 'command.message'),
26
+ NULL
27
+ ),
28
+ '{}'
29
+ ) AS queued_message_ids
30
+ FROM turned
31
+ GROUP BY "session_id", turn_id
32
+ ), latest AS (
33
+ -- A turn's further messages (a queued batch) change nothing but its queued messages.
34
+ SELECT DISTINCT ON (t."session_id", t.turn_id) t."session_id", t.turn_id, t."seq", t."type", t.doc
35
+ FROM turned t
36
+ JOIN bounds b ON b."session_id" = t."session_id" AND b.turn_id = t.turn_id
37
+ WHERE NOT (t."type" = 'command.message' AND t."seq" > b.first_seq)
38
+ ORDER BY t."session_id", t.turn_id, t."seq" DESC
39
+ )
40
+ INSERT INTO "nylorun"."turns" (
41
+ "session_id", "turn_id", "agent_id", "owner_user_id", "a2a_context_id", "status",
42
+ "started_at", "ended_at", "changed_at", "totals", "waiting_for", "queued_message_ids",
43
+ "first_seq", "last_seq"
44
+ )
45
+ SELECT
46
+ b."session_id",
47
+ b.turn_id,
48
+ coalesce(s."agent_id", ''),
49
+ s."owner_user_id",
50
+ nylorun.doc(s."body")->'info'->'a2a'->>'contextId',
51
+ CASE l."type"
52
+ WHEN 'turn.completed' THEN 'completed'
53
+ WHEN 'turn.failed' THEN 'failed'
54
+ WHEN 'turn.cancelled' THEN 'cancelled'
55
+ WHEN 'turn.paused' THEN 'paused'
56
+ ELSE 'running'
57
+ END,
58
+ b.started_at,
59
+ CASE WHEN l."type" IN ('turn.completed', 'turn.failed', 'turn.cancelled') THEN l.doc->>'time' END,
60
+ l.doc->>'time',
61
+ CASE WHEN l."type" IN ('turn.completed', 'turn.failed', 'turn.cancelled')
62
+ AND jsonb_typeof(l.doc->'payload'->'totals'->'steps') = 'number'
63
+ AND jsonb_typeof(l.doc->'payload'->'totals'->'tokens') = 'number'
64
+ AND jsonb_typeof(l.doc->'payload'->'totals'->'costUsd') = 'number'
65
+ THEN json_build_object(
66
+ 'steps', l.doc->'payload'->'totals'->'steps',
67
+ 'tokens', l.doc->'payload'->'totals'->'tokens',
68
+ 'costUsd', l.doc->'payload'->'totals'->'costUsd'
69
+ ) END,
70
+ CASE WHEN l."type" = 'turn.paused' AND jsonb_typeof(l.doc->'payload'->'interactions') = 'array' THEN (
71
+ SELECT CASE
72
+ WHEN x.e->'interaction'->>'kind' = 'connection'
73
+ OR x.e->'interaction'->'metadata'->>'kind' = 'connection' THEN 'connection'
74
+ WHEN x.e->'interaction'->>'kind' = 'approval' THEN 'approval'
75
+ ELSE 'question'
76
+ END
77
+ FROM jsonb_array_elements(l.doc->'payload'->'interactions') WITH ORDINALITY AS x(e, i)
78
+ WHERE (x.e->>'status' IS NULL OR x.e->>'status' = 'interaction')
79
+ AND jsonb_typeof(x.e->'interaction'->'id') = 'string'
80
+ ORDER BY x.i
81
+ LIMIT 1
82
+ ) END,
83
+ b.queued_message_ids,
84
+ b.first_seq,
85
+ l."seq"
86
+ FROM bounds b
87
+ JOIN latest l ON l."session_id" = b."session_id" AND l.turn_id = b.turn_id
88
+ LEFT JOIN "nylorun"."sessions" s ON s."id" = b."session_id";
@@ -0,0 +1 @@
1
+ ALTER TABLE "nylorun"."agents" ADD COLUMN "engine_image" json;
@@ -274,6 +274,34 @@
274
274
  "when": 1792484344984,
275
275
  "tag": "0038_t6_agent_manifest_source",
276
276
  "breakpoints": true
277
+ },
278
+ {
279
+ "idx": 39,
280
+ "version": "7",
281
+ "when": 1793445550433,
282
+ "tag": "0039_a2a_push",
283
+ "breakpoints": true
284
+ },
285
+ {
286
+ "idx": 40,
287
+ "version": "7",
288
+ "when": 1793448351698,
289
+ "tag": "0040_turn_index",
290
+ "breakpoints": true
291
+ },
292
+ {
293
+ "idx": 41,
294
+ "version": "7",
295
+ "when": 1793448353003,
296
+ "tag": "0041_turn_index_backfill",
297
+ "breakpoints": true
298
+ },
299
+ {
300
+ "idx": 42,
301
+ "version": "7",
302
+ "when": 1793509192153,
303
+ "tag": "0042_worker_engine_image",
304
+ "breakpoints": true
277
305
  }
278
306
  ]
279
307
  }
@@ -11,7 +11,7 @@ import { database, driverError } from "./db.js";
11
11
  import { createPostgresReadClient } from "./connect.js";
12
12
  import { hostTuning } from "../../tuning.js";
13
13
  import { toolCallDuration, toolCallStatus } from "../../harness-api/tool-status.js";
14
- import { effects, artifacts, modelUsage, sandboxes, sessions, sessionEvents, tenantSettings, } from "./schema.js";
14
+ import { effects, artifacts, modelUsage, sandboxes, sessions, sessionEvents, tenantSettings, turns, } from "./schema.js";
15
15
  const sessionFields = {
16
16
  id: sessions.id,
17
17
  agentId: sessions.agentId,
@@ -101,6 +101,20 @@ function publicToolCall(body) {
101
101
  durationMs: toolCallDuration(body) ?? null,
102
102
  };
103
103
  }
104
+ /** The change feed's cursor key: the last row's `[txid, sessionId, turnId]`. */
105
+ function turnChangeKey(key) {
106
+ if (key === undefined)
107
+ return undefined;
108
+ const [txid, sessionId, turnId] = key;
109
+ if (key.length !== 3 ||
110
+ typeof txid !== "string" ||
111
+ typeof sessionId !== "string" ||
112
+ typeof turnId !== "string" ||
113
+ !/^\d{1,20}$/.test(txid) ||
114
+ BigInt(txid) > 18446744073709551615n)
115
+ return fail(400, "Invalid cursor key", { code: "cursor_invalid" });
116
+ return [txid, sessionId, turnId];
117
+ }
104
118
  function keyOf(key, length, timestamp = false, nullable = false) {
105
119
  if (key &&
106
120
  (key.length !== length ||
@@ -316,6 +330,32 @@ export function createPostgresReadStore(source, tenantId) {
316
330
  .orderBy(asc(sessionEvents.seq));
317
331
  return rows.map((row) => row.body);
318
332
  }),
333
+ turnChanges: (filters, page, access) => read(async (tx) => {
334
+ if (filters.sessionId !== undefined)
335
+ await requireSession(tx, filters.sessionId, access);
336
+ const cursor = readCursor(tenantId, "turns", { sessionId: filters.sessionId ?? null });
337
+ const key = turnChangeKey(cursor.decode(page.changedAfter));
338
+ const rows = await tx
339
+ .select()
340
+ .from(turns)
341
+ .where(and(
342
+ // Below every transaction still running: one that commits later sorts after.
343
+ sql `${turns.txid} < pg_snapshot_xmin(pg_current_snapshot())`, key
344
+ ? sql `(${turns.txid}, ${turns.sessionId}, ${turns.turnId}) > (${key[0]}::xid8, ${key[1]}, ${key[2]})`
345
+ : undefined, filters.sessionId === undefined ? undefined : eq(turns.sessionId, filters.sessionId), access.owner === undefined ? undefined : eq(turns.ownerUserId, access.owner), access.agents === undefined ? undefined : grantsWhere(turns.agentId, access.agents)))
346
+ .orderBy(asc(turns.txid), asc(turns.sessionId), asc(turns.turnId))
347
+ .limit(page.limit + 1);
348
+ const items = rows.slice(0, page.limit);
349
+ const last = items.at(-1);
350
+ return {
351
+ turns: items,
352
+ // Echoed when nothing changed; a first read starts before every row.
353
+ cursor: last
354
+ ? cursor.encode([last.txid, last.sessionId, last.turnId])
355
+ : (page.changedAfter ?? cursor.encode(["0", "", ""])),
356
+ hasMore: rows.length > page.limit,
357
+ };
358
+ }),
319
359
  exportModel: (after, limit) => read(async (tx) => {
320
360
  const cursor = readCursor(tenantId, "model-export", {});
321
361
  const key = keyOf(cursor.decode(after), 2);