@axiom-lattice/pg-stores 3.3.13 → 4.0.0

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 (46) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +208 -0
  3. package/dist/index.d.mts +357 -3
  4. package/dist/index.d.ts +357 -3
  5. package/dist/index.js +2337 -77
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2342 -76
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
  11. package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
  12. package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
  13. package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
  14. package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +335 -0
  15. package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
  16. package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
  17. package/src/__tests__/PostgreSQLStore.test.ts +173 -0
  18. package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
  19. package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
  20. package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
  21. package/src/__tests__/createPgStoreConfig.test.ts +38 -0
  22. package/src/__tests__/eval-task-id.test.ts +14 -3
  23. package/src/createPgStoreConfig.ts +35 -2
  24. package/src/index.ts +7 -0
  25. package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
  26. package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
  27. package/src/migrations/agent_run_ledger_migration.ts +51 -0
  28. package/src/migrations/agent_run_project_task_migration.ts +34 -0
  29. package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
  30. package/src/migrations/agent_run_retention_migration.ts +25 -0
  31. package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
  32. package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
  33. package/src/migrations/agent_run_task_queue_migration.ts +65 -0
  34. package/src/migrations/channel_reply_job_migration.ts +55 -0
  35. package/src/migrations/eval_migrations.ts +19 -0
  36. package/src/migrations/store_migrations.ts +50 -0
  37. package/src/migrations/task_migration.ts +17 -0
  38. package/src/migrations/thread_migrations.ts +25 -0
  39. package/src/stores/PostgreSQLAgentRunLedger.ts +1221 -0
  40. package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
  41. package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
  42. package/src/stores/PostgreSQLEvalStore.ts +17 -4
  43. package/src/stores/PostgreSQLStore.ts +167 -0
  44. package/src/stores/PostgreSQLTaskStore.ts +4 -0
  45. package/src/stores/PostgreSQLThreadStore.ts +49 -10
  46. package/src/stores/ThreadMessageQueueStore.ts +10 -5
@@ -0,0 +1,173 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import { Pool } from "pg";
3
+ import { PostgreSQLStore } from "../stores/PostgreSQLStore";
4
+
5
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
6
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
7
+
8
+ maybeDescribe("PostgreSQLStore (PostgreSQL)", () => {
9
+ const tenant = "store-test-tenant";
10
+ let store: PostgreSQLStore;
11
+ let pool: Pool;
12
+
13
+ beforeAll(async () => {
14
+ pool = new Pool({ connectionString: TEST_DATABASE_URL! });
15
+ await pool.query(`
16
+ CREATE TABLE IF NOT EXISTS lattice_store_items (
17
+ tenant_id TEXT NOT NULL,
18
+ namespace TEXT[] NOT NULL,
19
+ key TEXT NOT NULL,
20
+ value JSONB NOT NULL,
21
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ expires_at TIMESTAMPTZ,
24
+ PRIMARY KEY (tenant_id, namespace, key)
25
+ )
26
+ `);
27
+ store = new PostgreSQLStore(pool);
28
+ });
29
+
30
+ beforeEach(async () => {
31
+ await pool.query("DELETE FROM lattice_store_items WHERE tenant_id LIKE 'store-test-%'");
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await pool.query("DELETE FROM lattice_store_items WHERE tenant_id LIKE 'store-test-%'");
36
+ await pool.end();
37
+ });
38
+
39
+ it("round-trips items and isolates tenants", async () => {
40
+ await store.put(tenant, { namespace: ["a", "b"], key: "one", value: { n: 1 } });
41
+
42
+ expect(await store.get(tenant, ["a", "b"], "one")).toMatchObject({
43
+ namespace: ["a", "b"], key: "one", value: { n: 1 },
44
+ });
45
+ expect(await store.get("store-test-other", ["a", "b"], "one")).toBeNull();
46
+ expect(await store.delete(tenant, ["a", "b"], "one")).toBe(true);
47
+ expect(await store.get(tenant, ["a", "b"], "one")).toBeNull();
48
+ });
49
+
50
+ it("searches by namespace prefix, JSONB filter, and substring query", async () => {
51
+ await store.put(tenant, {
52
+ namespace: ["app", "a"], key: "one",
53
+ value: { message: "Hello World", nested: { tag: "x" }, list: [1, 2] },
54
+ });
55
+ await store.put(tenant, {
56
+ namespace: ["app", "b"], key: "two",
57
+ value: { message: "other", nested: { tag: "y" }, list: [1, 2] },
58
+ });
59
+ await store.put(tenant, {
60
+ namespace: ["other"], key: "three", value: { message: "Hello other" },
61
+ });
62
+
63
+ expect((await store.search(tenant, { namespacePrefix: ["app"] })).map((item) => item.key).sort())
64
+ .toEqual(["one", "two"]);
65
+ expect((await store.search(tenant, { filter: { nested: { tag: "x" } } })).map((item) => item.key))
66
+ .toEqual(["one"]);
67
+ expect((await store.search(tenant, { query: "HELLO" })).map((item) => item.key).sort())
68
+ .toEqual(["one", "three"]);
69
+ expect((await store.search(tenant, { filter: { list: [2] } })).map((item) => item.key).sort())
70
+ .toEqual(["one", "two"]);
71
+ });
72
+
73
+ it("treats percent and underscore literally in query", async () => {
74
+ await store.put(tenant, { namespace: ["q"], key: "percent", value: { text: "100% done" } });
75
+ await store.put(tenant, { namespace: ["q"], key: "underscore", value: { text: "under_score" } });
76
+
77
+ expect((await store.search(tenant, { query: "%" })).map((item) => item.key))
78
+ .toEqual(["percent"]);
79
+ expect((await store.search(tenant, { query: "_" })).map((item) => item.key))
80
+ .toEqual(["underscore"]);
81
+ });
82
+
83
+ it("upserts per tenant and does not leak across tenants", async () => {
84
+ await store.put(tenant, { namespace: ["shared"], key: "one", value: { owner: "a" } });
85
+ await store.put("store-test-other", { namespace: ["shared"], key: "one", value: { owner: "b" } });
86
+
87
+ expect((await store.get(tenant, ["shared"], "one"))?.value.owner).toBe("a");
88
+ expect((await store.get("store-test-other", ["shared"], "one"))?.value.owner).toBe("b");
89
+ expect((await store.search(tenant, { namespacePrefix: ["shared"] })).map((item) => item.value.owner))
90
+ .toEqual(["a"]);
91
+
92
+ expect(await store.delete(tenant, ["shared"], "one")).toBe(true);
93
+ expect((await store.get("store-test-other", ["shared"], "one"))?.value.owner).toBe("b");
94
+ });
95
+
96
+ it("clears a previous TTL when upserting without one", async () => {
97
+ await store.put(tenant, {
98
+ namespace: ["ttl"], key: "reset", value: { v: 1 }, ttlMinutes: 1,
99
+ });
100
+ await store.put(tenant, { namespace: ["ttl"], key: "reset", value: { v: 2 } });
101
+
102
+ expect((await store.get(tenant, ["ttl"], "reset"))?.value.v).toBe(2);
103
+ expect((await store.get(tenant, ["ttl"], "reset"))?.expiresAt).toBeUndefined();
104
+ });
105
+
106
+ it("rejects identity collisions and invalid TTL/pagination at the store", async () => {
107
+ await expect(store.put(tenant, { namespace: ["a\u001fb"], key: "k", value: {} }))
108
+ .rejects.toThrow("reserved separator");
109
+ await expect(store.put(tenant, { namespace: ["a"], key: "b\u001fc", value: {} }))
110
+ .rejects.toThrow("reserved separator");
111
+ await expect(store.put(tenant, {
112
+ namespace: ["a"], key: "k", value: {}, ttlMinutes: 0,
113
+ })).rejects.toThrow("ttlMinutes");
114
+ await expect(store.search(tenant, { limit: -1 })).rejects.toThrow("limit/offset");
115
+ });
116
+
117
+ it("uses namespace and key as stable tie-breakers", async () => {
118
+ await store.put(tenant, { namespace: ["b"], key: "one", value: {} });
119
+ await store.put(tenant, { namespace: ["a"], key: "two", value: {} });
120
+ await store.put(tenant, { namespace: ["a"], key: "one", value: {} });
121
+ await pool.query(
122
+ "UPDATE lattice_store_items SET updated_at = now() WHERE tenant_id = $1",
123
+ [tenant],
124
+ );
125
+
126
+ expect((await store.search(tenant, {})).map((item) => `${item.namespace[0]}:${item.key}`))
127
+ .toEqual(["a:one", "a:two", "b:one"]);
128
+ });
129
+
130
+ it("paginates in updated-at descending order", async () => {
131
+ for (const [index, key] of ["one", "two", "three"].entries()) {
132
+ await store.put(tenant, { namespace: ["page"], key, value: { index } });
133
+ await pool.query(
134
+ "UPDATE lattice_store_items SET updated_at = $1 WHERE tenant_id = $2 AND key = $3",
135
+ [new Date(Date.UTC(2026, 0, 1, 0, 0, index)), tenant, key],
136
+ );
137
+ }
138
+
139
+ expect((await store.search(tenant, { limit: 2 })).map((item) => item.key))
140
+ .toEqual(["three", "two"]);
141
+ expect((await store.search(tenant, { limit: 2, offset: 1 })).map((item) => item.key))
142
+ .toEqual(["two", "one"]);
143
+ });
144
+
145
+ it("hides expired items and deleteExpired removes them across tenants", async () => {
146
+ await store.put(tenant, {
147
+ namespace: ["ttl"], key: "short", value: { ok: true }, ttlMinutes: 1,
148
+ });
149
+ await store.put("store-test-other", {
150
+ namespace: ["ttl"], key: "short", value: { ok: true }, ttlMinutes: 1,
151
+ });
152
+ await pool.query(
153
+ `UPDATE lattice_store_items SET expires_at = now() - interval '1 minute'
154
+ WHERE tenant_id LIKE 'store-test-%'`,
155
+ );
156
+
157
+ expect(await store.get(tenant, ["ttl"], "short")).toBeNull();
158
+ expect(await store.search(tenant, { namespacePrefix: ["ttl"] })).toEqual([]);
159
+ expect(await store.listNamespaces(tenant)).toEqual([]);
160
+ expect(await store.deleteExpired(new Date(), 10)).toBe(2);
161
+ expect(await store.deleteExpired(new Date(), 10)).toBe(0);
162
+ });
163
+
164
+ it("lists distinct sorted namespaces with limit and prefix", async () => {
165
+ await store.put(tenant, { namespace: ["b"], key: "1", value: {} });
166
+ await store.put(tenant, { namespace: ["a", "x"], key: "2", value: {} });
167
+ await store.put(tenant, { namespace: ["a", "x"], key: "3", value: {} });
168
+
169
+ expect(await store.listNamespaces(tenant)).toEqual([["a", "x"], ["b"]]);
170
+ expect(await store.listNamespaces(tenant, [], 1)).toEqual([["a", "x"]]);
171
+ expect(await store.listNamespaces(tenant, ["a"])).toEqual([["a", "x"]]);
172
+ });
173
+ });
@@ -0,0 +1,80 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Pool } from "pg";
4
+
5
+ import { PostgreSQLThreadStore } from "../stores/PostgreSQLThreadStore";
6
+
7
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
8
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
9
+
10
+ maybeDescribe("PostgreSQLThreadStore TTL (PostgreSQL)", () => {
11
+ let store: PostgreSQLThreadStore;
12
+ let cleanupPool: Pool;
13
+
14
+ beforeAll(async () => {
15
+ store = new PostgreSQLThreadStore({
16
+ poolConfig: TEST_DATABASE_URL!,
17
+ autoMigrate: false,
18
+ });
19
+ await store.initialize();
20
+ cleanupPool = new Pool({ connectionString: TEST_DATABASE_URL! });
21
+ });
22
+
23
+ beforeEach(async () => {
24
+ await cleanupPool.query("DELETE FROM lattice_threads WHERE id LIKE 'ttl-test-%'");
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await cleanupPool.query("DELETE FROM lattice_threads WHERE id LIKE 'ttl-test-%'");
29
+ await cleanupPool.end();
30
+ await store.dispose();
31
+ });
32
+
33
+ function threadId(name: string): string {
34
+ return `ttl-test-${name}-${randomUUID()}`;
35
+ }
36
+
37
+ it("round-trips expiresAt through create, get, and update", async () => {
38
+ const tenantId = randomUUID();
39
+ const id = threadId("roundtrip");
40
+ const expiresAt = new Date("2026-01-01T00:00:00.000Z");
41
+
42
+ await store.createThread(tenantId, "assistant-1", id, { expiresAt });
43
+ expect((await store.getThreadById(tenantId, id))?.expiresAt).toEqual(expiresAt);
44
+
45
+ const next = new Date("2026-02-01T00:00:00.000Z");
46
+ await store.updateThread(tenantId, id, { expiresAt: next });
47
+ expect((await store.getThreadById(tenantId, id))?.expiresAt).toEqual(next);
48
+ });
49
+
50
+ it("lists expired threads across tenants, oldest first, up to the limit", async () => {
51
+ const tenantA = randomUUID();
52
+ const tenantB = randomUUID();
53
+ const now = new Date("2026-01-01T00:00:00.000Z");
54
+ const earlier = threadId("earlier");
55
+ const later = threadId("later");
56
+ const future = threadId("future");
57
+ const never = threadId("never");
58
+
59
+ await store.createThread(tenantA, "assistant-1", later, {
60
+ expiresAt: new Date("2025-12-31T23:00:00.000Z"),
61
+ });
62
+ await store.createThread(tenantB, "assistant-2", earlier, {
63
+ expiresAt: new Date("2025-12-31T22:00:00.000Z"),
64
+ });
65
+ await store.createThread(tenantA, "assistant-1", future, {
66
+ expiresAt: new Date("2026-01-01T01:00:00.000Z"),
67
+ });
68
+ await store.createThread(tenantA, "assistant-1", never, {});
69
+
70
+ const all = await store.listExpiredThreads(now, 10);
71
+ const ids = all.map((thread) => thread.id);
72
+ expect(ids.indexOf(earlier)).toBeGreaterThanOrEqual(0);
73
+ expect(ids.indexOf(later)).toBeGreaterThan(ids.indexOf(earlier));
74
+ expect(ids).not.toContain(future);
75
+ expect(ids).not.toContain(never);
76
+
77
+ const limited = await store.listExpiredThreads(now, 1);
78
+ expect(limited.map((thread) => thread.id)).toEqual([earlier]);
79
+ });
80
+ });
@@ -79,4 +79,25 @@ maybeDescribe("ThreadMessageQueueStore claim/lease (PostgreSQL)", () => {
79
79
  const [pending] = await store.getPendingMessages(threadId);
80
80
  expect(pending.content).toBe("hello");
81
81
  });
82
+
83
+ it("reports hasPending=true for pending rows and false for processing-only rows", async () => {
84
+ const pendingThread = newThreadId();
85
+ const processingThread = newThreadId();
86
+ for (const threadId of [pendingThread, processingThread]) {
87
+ await store.addMessage({
88
+ threadId,
89
+ tenantId: "tenant",
90
+ assistantId: "assistant",
91
+ content: { id: randomUUID(), message: "message", queueMessage: { input: {} } },
92
+ id: randomUUID(),
93
+ });
94
+ }
95
+ await store.claimPendingMessages(processingThread, undefined, { owner: "r1", ttlMs: 60_000 });
96
+
97
+ const threads = await store.getThreadsWithPendingMessages();
98
+ const byThread = new Map(threads.map((thread) => [thread.threadId, thread]));
99
+
100
+ expect(byThread.get(pendingThread)?.hasPending).toBe(true);
101
+ expect(byThread.get(processingThread)?.hasPending).toBe(false);
102
+ });
82
103
  });
@@ -457,9 +457,9 @@ describe('ThreadMessageQueueStore', () => {
457
457
  describe('getThreadsWithPendingMessages', () => {
458
458
  it('should return unique threads with pending messages', async () => {
459
459
  const mockThreads = [
460
- { tenant_id: 'tenant-1', assistant_id: 'assistant-1', thread_id: 'thread-1' },
461
- { tenant_id: 'tenant-1', assistant_id: 'assistant-1', thread_id: 'thread-2' },
462
- { tenant_id: 'tenant-2', assistant_id: 'assistant-2', thread_id: 'thread-3' },
460
+ { tenant_id: 'tenant-1', assistant_id: 'assistant-1', thread_id: 'thread-1', has_pending: true },
461
+ { tenant_id: 'tenant-1', assistant_id: 'assistant-1', thread_id: 'thread-2', has_pending: true },
462
+ { tenant_id: 'tenant-2', assistant_id: 'assistant-2', thread_id: 'thread-3', has_pending: false },
463
463
  ];
464
464
 
465
465
  mockQuery.mockResolvedValueOnce({ rows: mockThreads });
@@ -470,6 +470,9 @@ describe('ThreadMessageQueueStore', () => {
470
470
  expect(result[0].threadId).toBe('thread-1');
471
471
  expect(result[1].threadId).toBe('thread-2');
472
472
  expect(result[2].threadId).toBe('thread-3');
473
+ expect(result.map((row) => row.hasPending)).toEqual([true, true, false]);
474
+ expect(mockQuery.mock.calls[0][0]).toMatch(/bool_or\(status = 'pending'\) AS has_pending/);
475
+ expect(mockQuery.mock.calls[0][0]).toMatch(/GROUP BY tenant_id, assistant_id, thread_id, workspace_id, project_id/);
473
476
  });
474
477
 
475
478
  it('preserves NULL workspace and project identity for legacy rows', async () => {
@@ -480,6 +483,7 @@ describe('ThreadMessageQueueStore', () => {
480
483
  thread_id: mockThreadId,
481
484
  workspace_id: null,
482
485
  project_id: null,
486
+ has_pending: true,
483
487
  }],
484
488
  });
485
489
 
@@ -489,6 +493,7 @@ describe('ThreadMessageQueueStore', () => {
489
493
  threadId: mockThreadId,
490
494
  workspaceId: null,
491
495
  projectId: null,
496
+ hasPending: true,
492
497
  }]);
493
498
  });
494
499
  });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+
3
+ jest.mock("@axiom-lattice/core", () => ({}));
4
+
5
+ jest.mock("pg", () => ({
6
+ Pool: class Pool {
7
+ query = async (): Promise<{ rows: unknown[] }> => ({ rows: [] });
8
+ connect = async (): Promise<unknown> => ({
9
+ query: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
10
+ release: (): void => undefined,
11
+ });
12
+ end = async (): Promise<void> => undefined;
13
+ },
14
+ }));
15
+
16
+ jest.mock("../migrations/migration", () => ({
17
+ MigrationManager: class MigrationManager {
18
+ register(): void {}
19
+ async migrate(): Promise<void> {}
20
+ },
21
+ }));
22
+
23
+ jest.mock("@langchain/langgraph-checkpoint-postgres", () => ({
24
+ PostgresSaver: {
25
+ fromConnString: () => ({ setup: async (): Promise<void> => undefined }),
26
+ },
27
+ }));
28
+
29
+ import { createPgStoreConfig } from "../createPgStoreConfig";
30
+ import { PostgreSQLAgentRunLedger } from "../stores/PostgreSQLAgentRunLedger";
31
+
32
+ describe("createPgStoreConfig", () => {
33
+ it("registers the agentRun ledger store", async () => {
34
+ const stores = await createPgStoreConfig("postgres://unused");
35
+
36
+ expect(stores.agentRun).toBeInstanceOf(PostgreSQLAgentRunLedger);
37
+ });
38
+ });
@@ -6,16 +6,27 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it, jest } from "@jest/globals";
9
- import { addTaskIdToEvalRuns, evalMigrations } from "../migrations/eval_migrations";
9
+ import { addCancelRequestedToEvalRuns, addTaskIdToEvalRuns, evalMigrations } from "../migrations/eval_migrations";
10
10
 
11
11
  describe("add_task_id_to_eval_runs migration", () => {
12
12
  it("is registered in evalMigrations", () => {
13
13
  expect(evalMigrations.map((m) => m.name)).toContain("add_task_id_to_eval_runs");
14
14
  });
15
15
 
16
- it("has a version greater than all existing eval migrations", () => {
16
+ it("keeps task_id before the cancellation migration", () => {
17
17
  const versions = evalMigrations.map((m) => m.version);
18
- expect(addTaskIdToEvalRuns.version).toBe(Math.max(...versions));
18
+ expect(addCancelRequestedToEvalRuns.version).toBe(Math.max(...versions));
19
+ expect(addTaskIdToEvalRuns.version).toBeLessThan(addCancelRequestedToEvalRuns.version);
20
+ });
21
+
22
+ it("emits the cooperative cancel column in up() and down()", async () => {
23
+ const upQueries: string[] = [];
24
+ await addCancelRequestedToEvalRuns.up({ query: jest.fn(async (sql: string) => { upQueries.push(sql); }) } as never);
25
+ expect(upQueries.some((q) => q.includes("cancel_requested BOOLEAN NOT NULL DEFAULT FALSE"))).toBe(true);
26
+
27
+ const downQueries: string[] = [];
28
+ await addCancelRequestedToEvalRuns.down({ query: jest.fn(async (sql: string) => { downQueries.push(sql); }) } as never);
29
+ expect(downQueries.some((q) => q.includes("DROP COLUMN IF EXISTS cancel_requested"))).toBe(true);
19
30
  });
20
31
 
21
32
  it("emits ADD COLUMN task_id in up()", async () => {
@@ -30,7 +30,7 @@ import { PGVectorStoreProvider } from "./PGVectorStoreProvider";
30
30
 
31
31
  // Migration imports — all migrations registered centrally to guarantee
32
32
  // ordering and prevent name collisions across features.
33
- import { createThreadsTable } from "./migrations/thread_migrations";
33
+ import { addThreadExpiresAt, createThreadsTable } from "./migrations/thread_migrations";
34
34
  import { addThreadTenantId } from "./migrations/thread_tenant_migration";
35
35
  import { changeThreadPrimaryKey } from "./migrations/thread_pk_migration";
36
36
  import { createScheduledTasksTable } from "./migrations/schedule_migrations";
@@ -80,6 +80,16 @@ import {
80
80
  addProjectLifecycleEventIndex,
81
81
  } from "./migrations/task_work_items_migration";
82
82
  import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
83
+ import { createAgentRunLedgerTable } from "./migrations/agent_run_ledger_migration";
84
+ import { createChannelReplyJobsTable } from "./migrations/channel_reply_job_migration";
85
+ import { createAgentRunTaskQueueMigration } from "./migrations/agent_run_task_queue_migration";
86
+ import { createAgentRunCapabilityRevisionMigration } from "./migrations/agent_run_capability_revision_migration";
87
+ import { createAgentRunBaseCheckpointCapturedMigration } from "./migrations/agent_run_base_checkpoint_captured_migration";
88
+ import { createAgentRunRollbackFailureMigration } from "./migrations/agent_run_rollback_failure_migration";
89
+ import { agentRunRunningIndexSourceMigration } from "./migrations/agent_run_running_index_source_migration";
90
+ import { createAgentRunRetentionMigration } from "./migrations/agent_run_retention_migration";
91
+ import { createAgentRunProjectTaskMigration } from "./migrations/agent_run_project_task_migration";
92
+ import { agentRunProjectTaskRunningIndexMigration } from "./migrations/agent_run_project_task_running_index_migration";
83
93
  import { addAgentWebAppsIdentity } from "./migrations/add_agent_web_apps_identity_migration";
84
94
  import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
85
95
  import { createProjectRoomReadStates, createProjectRoomTables } from "./migrations/project_room_migration";
@@ -111,6 +121,11 @@ import { PostgreSQLModelProviderStore } from "./stores/PostgreSQLModelProviderSt
111
121
  import { PostgreSQLLeaseStore } from "./stores/PostgreSQLLeaseStore";
112
122
  import { PostgreSQLOutboxStore } from "./stores/PostgreSQLOutboxStore";
113
123
  import { PostgreSQLPluginDeploymentStore } from "./stores/PostgreSQLPluginDeploymentStore";
124
+ import { PostgreSQLAgentRunLedger } from "./stores/PostgreSQLAgentRunLedger";
125
+ import { PostgreSQLChannelReplyJobStore } from "./stores/PostgreSQLChannelReplyJobStore";
126
+ import { PostgreSQLCheckpointStorage } from "./stores/PostgreSQLCheckpointStorage";
127
+ import { PostgreSQLStore } from "./stores/PostgreSQLStore";
128
+ import { createStoreItemsTable } from "./migrations/store_migrations";
114
129
 
115
130
  export async function createPgStoreConfig(connectionString: string) {
116
131
  const pool = new Pool({ connectionString });
@@ -192,7 +207,21 @@ export async function createPgStoreConfig(connectionString: string) {
192
207
  mm.register(dropPluginDeploymentAuthToken); // v190
193
208
  mm.register(addModelProviderModelExtra); // v191
194
209
  mm.register(addModelProviderModelDefault); // v192
195
- mm.register(addModelProviderModelKind); // v193
210
+ mm.register(addModelProviderModelKind); // v193 (model kind)
211
+ // v193 is intentionally shared with the agent-run ledger (versions only order
212
+ // registrations; migration identity is the name). Keep both.
213
+ mm.register(createAgentRunLedgerTable); // v193
214
+ mm.register(createAgentRunTaskQueueMigration); // v196
215
+ mm.register(createAgentRunCapabilityRevisionMigration); // v197
216
+ mm.register(createAgentRunBaseCheckpointCapturedMigration); // v198
217
+ mm.register(createAgentRunRollbackFailureMigration);
218
+ mm.register(agentRunRunningIndexSourceMigration); // v202
219
+ mm.register(addThreadExpiresAt); // v194
220
+ mm.register(createStoreItemsTable); // v195
221
+ mm.register(createChannelReplyJobsTable); // v200
222
+ mm.register(createAgentRunRetentionMigration); // v201
223
+ mm.register(createAgentRunProjectTaskMigration); // v203
224
+ mm.register(agentRunProjectTaskRunningIndexMigration); // v204
196
225
 
197
226
  await mm.migrate();
198
227
 
@@ -242,7 +271,11 @@ export async function createPgStoreConfig(connectionString: string) {
242
271
  lease: new PostgreSQLLeaseStore(opts),
243
272
  outbox: new PostgreSQLOutboxStore(opts),
244
273
  pluginDeployment: new PostgreSQLPluginDeploymentStore(opts),
274
+ agentRun: new PostgreSQLAgentRunLedger(opts),
275
+ channelReplyJob: new PostgreSQLChannelReplyJobStore(opts),
245
276
  vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
246
277
  checkpoint,
278
+ checkpointStorage: new PostgreSQLCheckpointStorage(pool),
279
+ store: new PostgreSQLStore(pool),
247
280
  };
248
281
  }
package/src/index.ts CHANGED
@@ -82,6 +82,13 @@ export * from "./stores/PostgreSQLProjectRoomReadStateStore";
82
82
  export * from "./migrations/project_room_migration";
83
83
  export * from "./stores/PostgreSQLPluginDeploymentStore";
84
84
  export * from "./migrations/plugin_deployment_migration";
85
+ export * from "./migrations/agent_run_ledger_migration";
86
+ export * from "./migrations/channel_reply_job_migration";
87
+ export * from "./migrations/store_migrations";
88
+ export * from "./stores/PostgreSQLAgentRunLedger";
89
+ export * from "./stores/PostgreSQLChannelReplyJobStore";
90
+ export * from "./stores/PostgreSQLCheckpointStorage";
91
+ export * from "./stores/PostgreSQLStore";
85
92
 
86
93
  export * from "./PGVectorStoreProvider";
87
94
 
@@ -0,0 +1,34 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Records whether a run captured its rollback base checkpoint.
5
+ *
6
+ * @remarks A crash after claim but before `setBaseCheckpoint` leaves
7
+ * `base_checkpoint_id` null without meaning "no checkpoint". Recovery must only
8
+ * delete after `null` when this marker is true; otherwise it terminates the
9
+ * rollback with a repair signal instead of deleting unrelated history.
10
+ */
11
+ export const createAgentRunBaseCheckpointCapturedMigration: Migration = {
12
+ version: 198,
13
+ name: "add_agent_run_base_checkpoint_captured",
14
+ up: async (client) => {
15
+ await client.query(`
16
+ ALTER TABLE lattice_agent_runs
17
+ ADD COLUMN IF NOT EXISTS base_checkpoint_captured BOOLEAN NOT NULL DEFAULT FALSE
18
+ `);
19
+ // Rows that already persisted a base were captured by an older worker.
20
+ // A captured null base cannot be distinguished from an early crash, so it
21
+ // stays false (the safe choice: no checkpoint deletion).
22
+ await client.query(`
23
+ UPDATE lattice_agent_runs
24
+ SET base_checkpoint_captured = TRUE
25
+ WHERE base_checkpoint_id IS NOT NULL
26
+ `);
27
+ },
28
+ down: async (client) => {
29
+ await client.query(`
30
+ ALTER TABLE lattice_agent_runs
31
+ DROP COLUMN IF EXISTS base_checkpoint_captured
32
+ `);
33
+ },
34
+ };
@@ -0,0 +1,24 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Adds the resolved capability runtime revision to run records.
5
+ *
6
+ * @remarks The run worker persists the revision before settling/interruption so
7
+ * HITL command runs and retries can pin the same runtime policy.
8
+ */
9
+ export const createAgentRunCapabilityRevisionMigration: Migration = {
10
+ version: 197,
11
+ name: "add_agent_run_capability_revision",
12
+ up: async (client) => {
13
+ await client.query(`
14
+ ALTER TABLE lattice_agent_runs
15
+ ADD COLUMN IF NOT EXISTS capability_runtime_revision TEXT
16
+ `);
17
+ },
18
+ down: async (client) => {
19
+ await client.query(`
20
+ ALTER TABLE lattice_agent_runs
21
+ DROP COLUMN IF EXISTS capability_runtime_revision
22
+ `);
23
+ },
24
+ };
@@ -0,0 +1,51 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Creates the durable agent run ledger.
5
+ *
6
+ * @remarks
7
+ * The partial unique index enforces the ledger invariant: at most one
8
+ * `running` row per `(tenant_id, thread_id)`.
9
+ */
10
+ export const createAgentRunLedgerTable: Migration = {
11
+ version: 193,
12
+ name: "create_lattice_agent_runs_table",
13
+ up: async (client) => {
14
+ await client.query(`
15
+ CREATE TABLE IF NOT EXISTS lattice_agent_runs (
16
+ run_id UUID PRIMARY KEY,
17
+ tenant_id VARCHAR(255) NOT NULL,
18
+ workspace_id VARCHAR(255),
19
+ project_id VARCHAR(255),
20
+ assistant_id VARCHAR(255) NOT NULL,
21
+ thread_id VARCHAR(255) NOT NULL,
22
+ status VARCHAR(32) NOT NULL,
23
+ owner VARCHAR(255) NOT NULL,
24
+ lease_expires_at TIMESTAMPTZ NOT NULL,
25
+ input_message_ids TEXT[] NOT NULL DEFAULT '{}',
26
+ attempts INTEGER NOT NULL DEFAULT 1,
27
+ error TEXT,
28
+ cancel_requested BOOLEAN NOT NULL DEFAULT FALSE,
29
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
30
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
32
+ finished_at TIMESTAMPTZ
33
+ )
34
+ `);
35
+ await client.query(`
36
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_agent_runs_one_running
37
+ ON lattice_agent_runs (tenant_id, thread_id) WHERE status = 'running'
38
+ `);
39
+ await client.query(`
40
+ CREATE INDEX IF NOT EXISTS idx_lattice_agent_runs_history
41
+ ON lattice_agent_runs (tenant_id, thread_id, created_at DESC)
42
+ `);
43
+ await client.query(`
44
+ CREATE INDEX IF NOT EXISTS idx_lattice_agent_runs_reap
45
+ ON lattice_agent_runs (status, lease_expires_at)
46
+ `);
47
+ },
48
+ down: async (client) => {
49
+ await client.query("DROP TABLE IF EXISTS lattice_agent_runs");
50
+ },
51
+ };
@@ -0,0 +1,34 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Adds ProjectTask identity to durable agent runs.
5
+ *
6
+ * @remarks
7
+ * `task_id` links a `source='project_task'` run to the canonical TaskItem it
8
+ * executes. The partial index resolves the single active run for a task without
9
+ * indexing ordinary run-API or legacy rows.
10
+ */
11
+ export const createAgentRunProjectTaskMigration: Migration = {
12
+ version: 203,
13
+ name: "add_agent_run_project_task",
14
+ up: async (client) => {
15
+ await client.query(`
16
+ ALTER TABLE lattice_agent_runs
17
+ ADD COLUMN IF NOT EXISTS task_id TEXT NULL
18
+ `);
19
+ await client.query(`
20
+ CREATE INDEX IF NOT EXISTS idx_lattice_agent_runs_project_task
21
+ ON lattice_agent_runs (tenant_id, task_id, status, created_at DESC)
22
+ WHERE task_id IS NOT NULL
23
+ `);
24
+ },
25
+ down: async (client) => {
26
+ await client.query(`
27
+ DROP INDEX IF EXISTS idx_lattice_agent_runs_project_task
28
+ `);
29
+ await client.query(`
30
+ ALTER TABLE lattice_agent_runs
31
+ DROP COLUMN IF EXISTS task_id
32
+ `);
33
+ },
34
+ };
@@ -0,0 +1,33 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /**
4
+ * Enforces one running row per thread across both durable queue sources.
5
+ *
6
+ * @remarks
7
+ * The previous index only covered `run_api`, so concurrent `project_task` or
8
+ * cross-source claims could both mark a thread running. The queue sources share
9
+ * the same `thread-run` execution authority, so the unique predicate now spans
10
+ * `run_api` and `project_task`. Legacy rows keep their separate supervisor and
11
+ * are intentionally excluded.
12
+ */
13
+ export const agentRunProjectTaskRunningIndexMigration: Migration = {
14
+ version: 204,
15
+ name: "scope_agent_run_running_index_to_queue_sources",
16
+ up: async (client) => {
17
+ await client.query("DROP INDEX IF EXISTS uq_lattice_agent_runs_one_running");
18
+ await client.query(`
19
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_agent_runs_one_running
20
+ ON lattice_agent_runs (tenant_id, thread_id)
21
+ WHERE status = 'running'
22
+ AND source IN ('run_api', 'project_task')
23
+ `);
24
+ },
25
+ down: async (client) => {
26
+ await client.query("DROP INDEX IF EXISTS uq_lattice_agent_runs_one_running");
27
+ await client.query(`
28
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_agent_runs_one_running
29
+ ON lattice_agent_runs (tenant_id, thread_id)
30
+ WHERE status = 'running' AND source = 'run_api'
31
+ `);
32
+ },
33
+ };