@axiom-lattice/pg-stores 5.0.0 → 5.0.2

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.
@@ -0,0 +1,447 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ import { PostgreSQLWorkflowTrackingStore } from "../stores/PostgreSQLWorkflowTrackingStore";
5
+
6
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
7
+ const describeIfDatabase = TEST_DATABASE_URL ? describe : describe.skip;
8
+
9
+ describeIfDatabase("PostgreSQLWorkflowTrackingStore integration", () => {
10
+ let store: PostgreSQLWorkflowTrackingStore;
11
+ let pool: Pool;
12
+ const tenants = new Set<string>();
13
+
14
+ beforeAll(async () => {
15
+ store = new PostgreSQLWorkflowTrackingStore({
16
+ poolConfig: TEST_DATABASE_URL!,
17
+ autoMigrate: true,
18
+ });
19
+ await store.initialize();
20
+ pool = new Pool({ connectionString: TEST_DATABASE_URL });
21
+ });
22
+
23
+ afterEach(async () => {
24
+ if (tenants.size === 0) {
25
+ return;
26
+ }
27
+ await pool.query("DELETE FROM lattice_workflow_runs WHERE tenant_id = ANY($1::text[])", [
28
+ [...tenants],
29
+ ]);
30
+ tenants.clear();
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await pool.end();
35
+ await store.dispose();
36
+ });
37
+
38
+ function tenantId(): string {
39
+ const id = `workflow-${randomUUID()}`;
40
+ tenants.add(id);
41
+ return id;
42
+ }
43
+
44
+ function topologyEdges() {
45
+ return [
46
+ { from: "input", to: "planner", purpose: "kickoff" },
47
+ { from: "planner", to: "worker", purpose: "delegate" },
48
+ ];
49
+ }
50
+
51
+ async function createRun(
52
+ tenant: string,
53
+ overrides: {
54
+ threadId?: string;
55
+ assistantId?: string;
56
+ metadata?: Record<string, unknown>;
57
+ } = {},
58
+ ) {
59
+ return store.createWorkflowRun({
60
+ tenantId: tenant,
61
+ assistantId: overrides.assistantId ?? `assistant-${randomUUID()}`,
62
+ threadId: overrides.threadId ?? `thread-${randomUUID()}`,
63
+ topologyEdges: topologyEdges(),
64
+ metadata: overrides.metadata ?? {
65
+ source: "test",
66
+ nested: { workflow: "demo", tags: ["a", "b"] },
67
+ },
68
+ });
69
+ }
70
+
71
+ describe("workflow runs", () => {
72
+ it("creates a run and reads back topology edges and metadata", async () => {
73
+ const tenant = tenantId();
74
+ const threadId = `thread-${randomUUID()}`;
75
+ const assistantId = `assistant-${randomUUID()}`;
76
+
77
+ const run = await store.createWorkflowRun({
78
+ tenantId: tenant,
79
+ assistantId,
80
+ threadId,
81
+ topologyEdges: topologyEdges(),
82
+ metadata: { source: "test", nested: { workflow: "demo", tags: ["a", "b"] } },
83
+ });
84
+
85
+ expect(run).toMatchObject({
86
+ tenantId: tenant,
87
+ assistantId,
88
+ threadId,
89
+ status: "running",
90
+ totalEdges: 2,
91
+ completedEdges: 0,
92
+ });
93
+ expect(run.topologyEdges).toEqual(topologyEdges());
94
+ expect(run.metadata).toEqual({ source: "test", nested: { workflow: "demo", tags: ["a", "b"] } });
95
+ expect(run.startedAt).toBeInstanceOf(Date);
96
+
97
+ const read = await store.getWorkflowRun(run.id);
98
+ expect(read).toMatchObject({ id: run.id, tenantId: tenant, status: "running" });
99
+ expect(read!.topologyEdges).toEqual(topologyEdges());
100
+ expect(read!.metadata).toEqual(run.metadata);
101
+ });
102
+
103
+ it("returns null for an absent run", async () => {
104
+ expect(await store.getWorkflowRun(randomUUID())).toBeNull();
105
+ });
106
+
107
+ it("creates distinct runs for the same thread and timestamp", async () => {
108
+ const tenant = tenantId();
109
+ const threadId = `thread-${randomUUID()}`;
110
+ const assistantId = `assistant-${randomUUID()}`;
111
+ const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1700000000000);
112
+
113
+ try {
114
+ const first = await store.createWorkflowRun({
115
+ tenantId: tenant,
116
+ assistantId,
117
+ threadId,
118
+ topologyEdges: topologyEdges(),
119
+ });
120
+ const second = await store.createWorkflowRun({
121
+ tenantId: tenant,
122
+ assistantId,
123
+ threadId,
124
+ topologyEdges: topologyEdges(),
125
+ });
126
+
127
+ expect(second.id).not.toBe(first.id);
128
+ expect(first.id.startsWith(`${threadId}_`)).toBe(true);
129
+ expect(second.id.startsWith(`${threadId}_`)).toBe(true);
130
+
131
+ const listed = await store.getWorkflowRunsByThreadId(tenant, threadId);
132
+ expect(listed.map((r) => r.id).sort()).toEqual([first.id, second.id].sort());
133
+ } finally {
134
+ nowSpy.mockRestore();
135
+ }
136
+ });
137
+
138
+ it("updates run status, counts, error, completion time, and metadata", async () => {
139
+ const tenant = tenantId();
140
+ const run = await createRun(tenant);
141
+ const completedAt = new Date("2026-02-03T04:05:06.000Z");
142
+
143
+ const updated = await store.updateWorkflowRun(run.id, {
144
+ status: "failed",
145
+ completedEdges: 1,
146
+ errorMessage: "worker exploded",
147
+ completedAt,
148
+ metadata: { failure: { code: "E_WORKER", retryable: false } },
149
+ });
150
+
151
+ expect(updated).toMatchObject({
152
+ id: run.id,
153
+ status: "failed",
154
+ completedEdges: 1,
155
+ totalEdges: 2,
156
+ errorMessage: "worker exploded",
157
+ });
158
+ expect(updated!.completedAt?.toISOString()).toBe(completedAt.toISOString());
159
+ expect(updated!.metadata).toEqual({ failure: { code: "E_WORKER", retryable: false } });
160
+ expect(updated!.topologyEdges).toEqual(topologyEdges());
161
+ });
162
+
163
+ it("returns null when updating an absent run", async () => {
164
+ expect(await store.updateWorkflowRun(randomUUID(), { status: "completed" })).toBeNull();
165
+ });
166
+
167
+ it("deletes a run and treats a repeat delete as a no-op", async () => {
168
+ const tenant = tenantId();
169
+ const run = await createRun(tenant);
170
+
171
+ await store.deleteWorkflowRun(run.id);
172
+ expect(await store.getWorkflowRun(run.id)).toBeNull();
173
+ await expect(store.deleteWorkflowRun(run.id)).resolves.toBeUndefined();
174
+ });
175
+
176
+ it("lists runs by thread, assistant, and tenant with tenant isolation", async () => {
177
+ const tenantA = tenantId();
178
+ const tenantB = tenantId();
179
+ const assistantA = `assistant-${randomUUID()}`;
180
+ const assistantB = `assistant-${randomUUID()}`;
181
+ const threadA1 = `thread-${randomUUID()}`;
182
+ const threadA2 = `thread-${randomUUID()}`;
183
+ const threadA3 = `thread-${randomUUID()}`;
184
+
185
+ const r1 = await createRun(tenantA, { threadId: threadA1, assistantId: assistantA });
186
+ const r2 = await createRun(tenantA, { threadId: threadA2, assistantId: assistantA });
187
+ const r3 = await createRun(tenantA, { threadId: threadA3, assistantId: assistantB });
188
+ const r4 = await createRun(tenantB, { threadId: `thread-${randomUUID()}`, assistantId: assistantA });
189
+
190
+ expect((await store.getWorkflowRunsByThreadId(tenantA, threadA1)).map((r) => r.id)).toEqual([
191
+ r1.id,
192
+ ]);
193
+ expect((await store.getWorkflowRunsByThreadId(tenantA, threadA2)).map((r) => r.id)).toEqual([
194
+ r2.id,
195
+ ]);
196
+ expect((await store.getWorkflowRunsByThreadId(tenantA, threadA3)).map((r) => r.id)).toEqual([
197
+ r3.id,
198
+ ]);
199
+ expect(
200
+ (await store.getWorkflowRunsByAssistantId(tenantA, assistantA)).map((r) => r.id).sort(),
201
+ ).toEqual([r1.id, r2.id].sort());
202
+ expect((await store.getWorkflowRunsByTenantId(tenantA)).map((r) => r.id).sort()).toEqual(
203
+ [r1.id, r2.id, r3.id].sort(),
204
+ );
205
+ expect((await store.getWorkflowRunsByTenantId(tenantB)).map((r) => r.id)).toEqual([r4.id]);
206
+
207
+ expect(await store.getWorkflowRunsByThreadId(tenantB, threadA1)).toEqual([]);
208
+ expect(await store.getWorkflowRunsByAssistantId(tenantB, assistantB)).toEqual([]);
209
+ });
210
+
211
+ it("filters queryWorkflowRuns by status and assistant", async () => {
212
+ const tenant = tenantId();
213
+ const otherTenant = tenantId();
214
+ const assistantA = `assistant-${randomUUID()}`;
215
+ const assistantB = `assistant-${randomUUID()}`;
216
+ const r1 = await createRun(tenant, { assistantId: assistantA });
217
+ const r2 = await createRun(tenant, { assistantId: assistantA });
218
+ const r3 = await createRun(tenant, { assistantId: assistantB });
219
+ await store.updateWorkflowRun(r2.id, { status: "completed" });
220
+
221
+ const running = await store.queryWorkflowRuns(tenant, { status: "running" });
222
+ expect(running.total).toBe(2);
223
+ expect(running.records.map((r) => r.id).sort()).toEqual([r1.id, r3.id].sort());
224
+
225
+ const completed = await store.queryWorkflowRuns(tenant, { status: "completed" });
226
+ expect(completed.total).toBe(1);
227
+ expect(completed.records.map((r) => r.id)).toEqual([r2.id]);
228
+
229
+ const byAssistant = await store.queryWorkflowRuns(tenant, { assistantId: assistantB });
230
+ expect(byAssistant.total).toBe(1);
231
+ expect(byAssistant.records.map((r) => r.id)).toEqual([r3.id]);
232
+
233
+ const combined = await store.queryWorkflowRuns(tenant, {
234
+ status: "running",
235
+ assistantId: assistantA,
236
+ });
237
+ expect(combined.total).toBe(1);
238
+ expect(combined.records.map((r) => r.id)).toEqual([r1.id]);
239
+
240
+ const other = await store.queryWorkflowRuns(otherTenant, { status: "running" });
241
+ expect(other).toEqual({ records: [], total: 0 });
242
+ });
243
+
244
+ it("paginates queryWorkflowRuns while counting all matches", async () => {
245
+ const tenant = tenantId();
246
+ const runs = [await createRun(tenant), await createRun(tenant), await createRun(tenant)];
247
+
248
+ const firstPage = await store.queryWorkflowRuns(tenant, { limit: 2, offset: 0 });
249
+ const secondPage = await store.queryWorkflowRuns(tenant, { limit: 2, offset: 2 });
250
+ const all = await store.queryWorkflowRuns(tenant);
251
+
252
+ expect(firstPage.total).toBe(3);
253
+ expect(secondPage.total).toBe(3);
254
+ expect(firstPage.records).toHaveLength(2);
255
+ expect(secondPage.records).toHaveLength(1);
256
+
257
+ const firstIds = firstPage.records.map((r) => r.id);
258
+ const secondIds = secondPage.records.map((r) => r.id);
259
+ expect(firstIds.filter((id) => secondIds.includes(id))).toEqual([]);
260
+ expect([...firstIds, ...secondIds].sort()).toEqual(runs.map((r) => r.id).sort());
261
+
262
+ expect(all.total).toBe(3);
263
+ expect(all.records).toHaveLength(3);
264
+ });
265
+
266
+ it("falls back to empty metadata when the stored column is null", async () => {
267
+ const tenant = tenantId();
268
+ const run = await createRun(tenant);
269
+
270
+ await pool.query("UPDATE lattice_workflow_runs SET metadata = NULL WHERE id = $1", [run.id]);
271
+
272
+ const read = await store.getWorkflowRun(run.id);
273
+ expect(read!.metadata).toEqual({});
274
+ expect(read!.topologyEdges).toEqual(topologyEdges());
275
+ });
276
+ });
277
+
278
+ describe("run steps", () => {
279
+ it("creates a step and reads back input, edge data, and thread id", async () => {
280
+ const tenant = tenantId();
281
+ const run = await createRun(tenant);
282
+ const threadId = `thread-${randomUUID()}`;
283
+
284
+ const step = await store.createRunStep({
285
+ runId: run.id,
286
+ tenantId: tenant,
287
+ stepType: "tool_call",
288
+ stepName: "search",
289
+ threadId,
290
+ edgeFrom: "planner",
291
+ edgeTo: "worker",
292
+ edgePurpose: "delegate",
293
+ input: { query: "weather", nested: { limit: 3 } },
294
+ });
295
+
296
+ expect(step).toMatchObject({
297
+ runId: run.id,
298
+ tenantId: tenant,
299
+ stepType: "tool_call",
300
+ stepName: "search",
301
+ threadId,
302
+ edgeFrom: "planner",
303
+ edgeTo: "worker",
304
+ edgePurpose: "delegate",
305
+ status: "running",
306
+ });
307
+ expect(step.input).toEqual({ query: "weather", nested: { limit: 3 } });
308
+ expect(step.output).toBeUndefined();
309
+ expect(step.startedAt).toBeInstanceOf(Date);
310
+
311
+ const steps = await store.getRunSteps(run.id);
312
+ expect(steps.map((s) => s.id)).toEqual([step.id]);
313
+ expect(steps[0].input).toEqual({ query: "weather", nested: { limit: 3 } });
314
+ });
315
+
316
+ it("upserts by run, step type, and step name", async () => {
317
+ const tenant = tenantId();
318
+ const run = await createRun(tenant);
319
+ const first = await store.createRunStep({
320
+ runId: run.id,
321
+ tenantId: tenant,
322
+ stepType: "agent",
323
+ stepName: "Writer",
324
+ input: { brief: "original" },
325
+ });
326
+
327
+ const upserted = await store.upsertRunStep({
328
+ runId: run.id,
329
+ tenantId: tenant,
330
+ stepType: "agent",
331
+ stepName: "Writer",
332
+ threadId: "sub-thread-1",
333
+ input: { brief: "replacement" },
334
+ });
335
+
336
+ expect(upserted.id).toBe(first.id);
337
+ expect(upserted.threadId).toBe("sub-thread-1");
338
+ expect(upserted.input).toEqual({ brief: "original" });
339
+
340
+ const again = await store.upsertRunStep({
341
+ runId: run.id,
342
+ tenantId: tenant,
343
+ stepType: "agent",
344
+ stepName: "Writer",
345
+ threadId: "sub-thread-2",
346
+ });
347
+ expect(again.id).toBe(first.id);
348
+ expect(again.threadId).toBe("sub-thread-1");
349
+
350
+ const distinct = await store.upsertRunStep({
351
+ runId: run.id,
352
+ tenantId: tenant,
353
+ stepType: "agent",
354
+ stepName: "Reviewer",
355
+ });
356
+ expect(distinct.id).not.toBe(first.id);
357
+ expect(await store.getRunSteps(run.id)).toHaveLength(2);
358
+ });
359
+
360
+ it("updates a step result and returns null for an absent step", async () => {
361
+ const tenant = tenantId();
362
+ const run = await createRun(tenant);
363
+ const step = await store.createRunStep({
364
+ runId: run.id,
365
+ tenantId: tenant,
366
+ stepType: "agent",
367
+ stepName: "Writer",
368
+ });
369
+ const completedAt = new Date("2026-03-04T05:06:07.000Z");
370
+
371
+ const updated = await store.updateRunStep(run.id, step.id, {
372
+ status: "completed",
373
+ output: { draft: "hello", tokens: { input: 10, output: 20 } },
374
+ completedAt,
375
+ durationMs: 1234,
376
+ });
377
+
378
+ expect(updated).toMatchObject({
379
+ id: step.id,
380
+ status: "completed",
381
+ durationMs: 1234,
382
+ });
383
+ expect(updated!.output).toEqual({ draft: "hello", tokens: { input: 10, output: 20 } });
384
+ expect(updated!.completedAt?.toISOString()).toBe(completedAt.toISOString());
385
+
386
+ expect(
387
+ await store.updateRunStep(run.id, randomUUID(), { status: "completed" }),
388
+ ).toBeNull();
389
+ });
390
+
391
+ it("finds steps by type and interrupted steps", async () => {
392
+ const tenant = tenantId();
393
+ const run = await createRun(tenant);
394
+ const toolStep = await store.createRunStep({
395
+ runId: run.id,
396
+ tenantId: tenant,
397
+ stepType: "tool_call",
398
+ stepName: "search",
399
+ });
400
+ const agentStep = await store.createRunStep({
401
+ runId: run.id,
402
+ tenantId: tenant,
403
+ stepType: "agent",
404
+ stepName: "Writer",
405
+ });
406
+ const humanStep = await store.createRunStep({
407
+ runId: run.id,
408
+ tenantId: tenant,
409
+ stepType: "human_in_loop",
410
+ stepName: "approve",
411
+ });
412
+ await store.updateRunStep(run.id, humanStep.id, { status: "interrupted" });
413
+ await store.updateRunStep(run.id, agentStep.id, { status: "completed" });
414
+
415
+ const toolSteps = await store.getRunStepsByType(run.id, "tool_call");
416
+ expect(toolSteps.map((s) => s.id)).toEqual([toolStep.id]);
417
+
418
+ const interrupted = await store.getInterruptedSteps(run.id);
419
+ expect(interrupted.map((s) => s.id)).toEqual([humanStep.id]);
420
+ expect(interrupted[0].status).toBe("interrupted");
421
+ });
422
+
423
+ it("returns empty step lists for an absent run", async () => {
424
+ const missingRun = randomUUID();
425
+
426
+ expect(await store.getRunSteps(missingRun)).toEqual([]);
427
+ expect(await store.getRunStepsByType(missingRun, "agent")).toEqual([]);
428
+ expect(await store.getInterruptedSteps(missingRun)).toEqual([]);
429
+ });
430
+
431
+ it("falls back to undefined for null step input and output", async () => {
432
+ const tenant = tenantId();
433
+ const run = await createRun(tenant);
434
+ const step = await store.createRunStep({
435
+ runId: run.id,
436
+ tenantId: tenant,
437
+ stepType: "input",
438
+ stepName: "start",
439
+ });
440
+
441
+ const [read] = await store.getRunSteps(run.id);
442
+ expect(read.id).toBe(step.id);
443
+ expect(read.input).toBeUndefined();
444
+ expect(read.output).toBeUndefined();
445
+ });
446
+ });
447
+ });
@@ -300,8 +300,8 @@ export class PostgreSQLEvalStore implements EvalStore {
300
300
  data.name,
301
301
  data.description || null,
302
302
  data.version || null,
303
- JSON.stringify(data.judgeModelConfig),
304
- JSON.stringify(data.targetServerConfig),
303
+ JSON.stringify(data.judgeModelConfig ?? {}),
304
+ JSON.stringify(data.targetServerConfig ?? {}),
305
305
  data.concurrency ?? 3,
306
306
  data.reportConfig === undefined ? null : JSON.stringify(data.reportConfig),
307
307
  ]
@@ -857,7 +857,7 @@ export class PostgreSQLEvalStore implements EvalStore {
857
857
  AND run_id IN (SELECT id FROM lattice_eval_runs WHERE tenant_id = $${i + 1})
858
858
  RETURNING id, run_id, suite_name, case_id,
859
859
  pass, score, summary, dimension_results,
860
- duration_ms, messages, logs, error, created_at`,
860
+ duration_ms, messages, logs, error, interrupted, created_at`,
861
861
  vals
862
862
  );
863
863
  return rows.length ? this.mapRowToRunResult(rows[0] as unknown as Record<string, unknown>) : null;
@@ -502,7 +502,7 @@ export class PostgreSQLSkillStore implements SkillStore {
502
502
  WHERE tenant_id = $1 AND sub_skills @> to_jsonb($2::text)
503
503
  ORDER BY created_at DESC
504
504
  `,
505
- [tenantId, JSON.stringify([parentSkillName])]
505
+ [tenantId, parentSkillName]
506
506
  );
507
507
 
508
508
  return result.rows.map(this.mapRowToSkill);
@@ -2,6 +2,7 @@
2
2
  * PostgreSQL implementation of WorkflowTrackingStore
3
3
  */
4
4
 
5
+ import { randomUUID } from "node:crypto";
5
6
  import { Pool } from "pg";
6
7
  import type { PoolConfig } from "pg";
7
8
  import {
@@ -92,7 +93,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
92
93
  async createWorkflowRun(request: CreateWorkflowRunRequest): Promise<WorkflowRun> {
93
94
  await this.ensureInitialized();
94
95
  const now = new Date();
95
- const id = `${request.threadId}_${Date.now()}`;
96
+ const id = `${request.threadId}_${Date.now()}_${randomUUID()}`;
96
97
 
97
98
  await this.pool.query(
98
99
  `INSERT INTO lattice_workflow_runs (id, tenant_id, assistant_id, thread_id, status, topology_edges, total_edges, completed_edges, metadata, started_at, created_at, updated_at)