@axiom-lattice/pg-stores 3.3.12 → 3.4.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +187 -0
- package/dist/index.d.mts +353 -3
- package/dist/index.d.ts +353 -3
- package/dist/index.js +2323 -93
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/PGVectorStoreProvider.ts +19 -13
- package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
- package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
- package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
- package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
- package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
- package/src/__tests__/PostgreSQLStore.test.ts +173 -0
- package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
- package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
- package/src/__tests__/createPgStoreConfig.test.ts +38 -0
- package/src/createPgStoreConfig.ts +36 -1
- package/src/index.ts +7 -0
- package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
- package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
- package/src/migrations/agent_run_ledger_migration.ts +51 -0
- package/src/migrations/agent_run_project_task_migration.ts +34 -0
- package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
- package/src/migrations/agent_run_retention_migration.ts +25 -0
- package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
- package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
- package/src/migrations/agent_run_task_queue_migration.ts +65 -0
- package/src/migrations/channel_reply_job_migration.ts +55 -0
- package/src/migrations/model_provider_migrations.ts +27 -0
- package/src/migrations/store_migrations.ts +50 -0
- package/src/migrations/thread_migrations.ts +25 -0
- package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
- package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
- package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
- package/src/stores/PostgreSQLModelProviderStore.ts +25 -7
- package/src/stores/PostgreSQLStore.ts +167 -0
- package/src/stores/PostgreSQLThreadStore.ts +49 -10
- 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
|
+
});
|
|
@@ -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";
|
|
@@ -98,6 +108,7 @@ import {
|
|
|
98
108
|
addModelProviderModelStale,
|
|
99
109
|
addModelProviderModelExtra,
|
|
100
110
|
addModelProviderModelDefault,
|
|
111
|
+
addModelProviderModelKind,
|
|
101
112
|
} from "./migrations/model_provider_migrations";
|
|
102
113
|
import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
|
|
103
114
|
import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
|
|
@@ -110,6 +121,11 @@ import { PostgreSQLModelProviderStore } from "./stores/PostgreSQLModelProviderSt
|
|
|
110
121
|
import { PostgreSQLLeaseStore } from "./stores/PostgreSQLLeaseStore";
|
|
111
122
|
import { PostgreSQLOutboxStore } from "./stores/PostgreSQLOutboxStore";
|
|
112
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";
|
|
113
129
|
|
|
114
130
|
export async function createPgStoreConfig(connectionString: string) {
|
|
115
131
|
const pool = new Pool({ connectionString });
|
|
@@ -191,6 +207,21 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
191
207
|
mm.register(dropPluginDeploymentAuthToken); // v190
|
|
192
208
|
mm.register(addModelProviderModelExtra); // v191
|
|
193
209
|
mm.register(addModelProviderModelDefault); // v192
|
|
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
|
|
194
225
|
|
|
195
226
|
await mm.migrate();
|
|
196
227
|
|
|
@@ -240,7 +271,11 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
240
271
|
lease: new PostgreSQLLeaseStore(opts),
|
|
241
272
|
outbox: new PostgreSQLOutboxStore(opts),
|
|
242
273
|
pluginDeployment: new PostgreSQLPluginDeploymentStore(opts),
|
|
274
|
+
agentRun: new PostgreSQLAgentRunLedger(opts),
|
|
275
|
+
channelReplyJob: new PostgreSQLChannelReplyJobStore(opts),
|
|
243
276
|
vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
|
|
244
277
|
checkpoint,
|
|
278
|
+
checkpointStorage: new PostgreSQLCheckpointStorage(pool),
|
|
279
|
+
store: new PostgreSQLStore(pool),
|
|
245
280
|
};
|
|
246
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
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Indexes terminal run completion times for retention cleanup.
|
|
5
|
+
*
|
|
6
|
+
* @remarks Only terminal rows are ever eligible for deletion, so the partial
|
|
7
|
+
* index keeps the bounded oldest-first cleanup scan cheap without indexing
|
|
8
|
+
* pending or running queue work.
|
|
9
|
+
*/
|
|
10
|
+
export const createAgentRunRetentionMigration: Migration = {
|
|
11
|
+
version: 201,
|
|
12
|
+
name: "add_agent_run_retention_index",
|
|
13
|
+
up: async (client) => {
|
|
14
|
+
await client.query(`
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_agent_runs_terminal_finished_at
|
|
16
|
+
ON lattice_agent_runs (finished_at)
|
|
17
|
+
WHERE status IN ('success', 'error', 'interrupted', 'cancelled', 'timeout')
|
|
18
|
+
`);
|
|
19
|
+
},
|
|
20
|
+
down: async (client) => {
|
|
21
|
+
await client.query(`
|
|
22
|
+
DROP INDEX IF EXISTS idx_lattice_agent_runs_terminal_finished_at
|
|
23
|
+
`);
|
|
24
|
+
},
|
|
25
|
+
};
|