@polpo-ai/drizzle 0.2.13 → 0.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/dist/__tests__/stores-pg.test.d.ts +2 -0
- package/dist/__tests__/stores-pg.test.d.ts.map +1 -0
- package/dist/__tests__/stores-pg.test.js +981 -0
- package/dist/__tests__/stores-pg.test.js.map +1 -0
- package/dist/__tests__/stores.test.js +1 -160
- package/dist/__tests__/stores.test.js.map +1 -1
- package/dist/index.d.ts +635 -1901
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -24
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +0 -51
- package/dist/migrate.js.map +1 -1
- package/dist/schema/index.d.ts +0 -4
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +0 -4
- package/dist/schema/index.js.map +1 -1
- package/dist/stores/agent-store.d.ts.map +1 -1
- package/dist/stores/agent-store.js +2 -2
- package/dist/stores/agent-store.js.map +1 -1
- package/dist/stores/approval-store.d.ts.map +1 -1
- package/dist/stores/approval-store.js +2 -2
- package/dist/stores/approval-store.js.map +1 -1
- package/dist/stores/attachment-store.d.ts.map +1 -1
- package/dist/stores/attachment-store.js +3 -3
- package/dist/stores/attachment-store.js.map +1 -1
- package/dist/stores/index.d.ts +0 -2
- package/dist/stores/index.d.ts.map +1 -1
- package/dist/stores/index.js +0 -2
- package/dist/stores/index.js.map +1 -1
- package/dist/stores/playbook-store.d.ts.map +1 -1
- package/dist/stores/playbook-store.js +2 -3
- package/dist/stores/playbook-store.js.map +1 -1
- package/dist/stores/session-store.d.ts.map +1 -1
- package/dist/stores/session-store.js +4 -4
- package/dist/stores/session-store.js.map +1 -1
- package/dist/stores/task-store.d.ts.map +1 -1
- package/dist/stores/task-store.js +3 -3
- package/dist/stores/task-store.js.map +1 -1
- package/dist/stores/team-store.d.ts.map +1 -1
- package/dist/stores/team-store.js +2 -2
- package/dist/stores/team-store.js.map +1 -1
- package/dist/stores/vault-store.d.ts.map +1 -1
- package/dist/stores/vault-store.js +2 -3
- package/dist/stores/vault-store.js.map +1 -1
- package/dist/utils.d.ts +20 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +38 -0
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
- package/dist/schema/notifications.d.ts +0 -521
- package/dist/schema/notifications.d.ts.map +0 -1
- package/dist/schema/notifications.js +0 -47
- package/dist/schema/notifications.js.map +0 -1
- package/dist/schema/peers.d.ts +0 -743
- package/dist/schema/peers.d.ts.map +0 -1
- package/dist/schema/peers.js +0 -71
- package/dist/schema/peers.js.map +0 -1
- package/dist/stores/notification-store.d.ts +0 -20
- package/dist/stores/notification-store.d.ts.map +0 -1
- package/dist/stores/notification-store.js +0 -111
- package/dist/stores/notification-store.js.map +0 -1
- package/dist/stores/peer-store.d.ts +0 -40
- package/dist/stores/peer-store.d.ts.map +0 -1
- package/dist/stores/peer-store.js +0 -203
- package/dist/stores/peer-store.js.map +0 -1
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @polpo-ai/drizzle — PostgreSQL tests for all Drizzle stores.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors stores.test.ts but runs against a real PostgreSQL database.
|
|
5
|
+
* Requires TEST_DATABASE_URL env var (default: postgresql://postgres:postgres@localhost:5432/polpo_test).
|
|
6
|
+
* Skipped when no PG connection is available.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
import postgres from "postgres";
|
|
12
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
13
|
+
import { createPgStores } from "../index.js";
|
|
14
|
+
import { ensurePgSchema } from "../migrate.js";
|
|
15
|
+
// Provide a deterministic vault key for tests (32 bytes hex-encoded)
|
|
16
|
+
process.env.POLPO_VAULT_KEY = randomBytes(32).toString("hex");
|
|
17
|
+
const DATABASE_URL = process.env.TEST_DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/polpo_test";
|
|
18
|
+
// All tables managed by ensurePgSchema, in safe truncation order (children before parents)
|
|
19
|
+
const ALL_TABLES = [
|
|
20
|
+
"log_entries",
|
|
21
|
+
"messages",
|
|
22
|
+
"attachments",
|
|
23
|
+
"approvals",
|
|
24
|
+
"runs",
|
|
25
|
+
"tasks",
|
|
26
|
+
"missions",
|
|
27
|
+
"processes",
|
|
28
|
+
"metadata",
|
|
29
|
+
"sessions",
|
|
30
|
+
"log_sessions",
|
|
31
|
+
"memory",
|
|
32
|
+
"agents",
|
|
33
|
+
"teams",
|
|
34
|
+
"vault",
|
|
35
|
+
"playbooks",
|
|
36
|
+
];
|
|
37
|
+
// ── Connection check ──────────────────────────────────────────────────
|
|
38
|
+
let canConnect = false;
|
|
39
|
+
try {
|
|
40
|
+
const probe = postgres(DATABASE_URL, { max: 1, connect_timeout: 3 });
|
|
41
|
+
await probe `SELECT 1`;
|
|
42
|
+
await probe.end();
|
|
43
|
+
canConnect = true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
canConnect = false;
|
|
47
|
+
}
|
|
48
|
+
// ── Test suite ────────────────────────────────────────────────────────
|
|
49
|
+
describe.skipIf(!canConnect)("PostgreSQL Drizzle stores", () => {
|
|
50
|
+
let pgClient;
|
|
51
|
+
let db;
|
|
52
|
+
let stores;
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
pgClient = postgres(DATABASE_URL, { max: 10 });
|
|
55
|
+
db = drizzle(pgClient);
|
|
56
|
+
await ensurePgSchema(db);
|
|
57
|
+
stores = createPgStores(db);
|
|
58
|
+
});
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await pgClient.end();
|
|
61
|
+
});
|
|
62
|
+
/** Truncate all tables and recreate stores between tests for full isolation. */
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
// TRUNCATE CASCADE handles foreign key dependencies
|
|
65
|
+
for (const table of ALL_TABLES) {
|
|
66
|
+
await db.execute(sql.raw(`TRUNCATE TABLE "${table}" CASCADE`));
|
|
67
|
+
}
|
|
68
|
+
// Recreate stores to reset in-memory state (e.g. LogStore.currentSessionId)
|
|
69
|
+
stores = createPgStores(db);
|
|
70
|
+
});
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
72
|
+
// TaskStore
|
|
73
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
74
|
+
describe("DrizzleTaskStore", () => {
|
|
75
|
+
it("addTask + getTask round-trip", async () => {
|
|
76
|
+
const task = await stores.taskStore.addTask({
|
|
77
|
+
title: "Fix bug",
|
|
78
|
+
description: "Fix the login bug",
|
|
79
|
+
assignTo: "claude",
|
|
80
|
+
dependsOn: [],
|
|
81
|
+
maxRetries: 3,
|
|
82
|
+
expectations: [{ type: "llm_review", criteria: "Login works" }],
|
|
83
|
+
metrics: [],
|
|
84
|
+
});
|
|
85
|
+
expect(task.id).toBeDefined();
|
|
86
|
+
expect(task.status).toBe("pending");
|
|
87
|
+
expect(task.retries).toBe(0);
|
|
88
|
+
expect(task.title).toBe("Fix bug");
|
|
89
|
+
const fetched = await stores.taskStore.getTask(task.id);
|
|
90
|
+
expect(fetched).toBeDefined();
|
|
91
|
+
expect(fetched.title).toBe("Fix bug");
|
|
92
|
+
expect(fetched.expectations).toEqual([{ type: "llm_review", criteria: "Login works" }]);
|
|
93
|
+
});
|
|
94
|
+
it("getAllTasks returns ordered by createdAt", async () => {
|
|
95
|
+
await stores.taskStore.addTask({
|
|
96
|
+
title: "A", description: "first", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
97
|
+
});
|
|
98
|
+
await stores.taskStore.addTask({
|
|
99
|
+
title: "B", description: "second", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
100
|
+
});
|
|
101
|
+
const all = await stores.taskStore.getAllTasks();
|
|
102
|
+
expect(all).toHaveLength(2);
|
|
103
|
+
expect(all[0].title).toBe("A");
|
|
104
|
+
expect(all[1].title).toBe("B");
|
|
105
|
+
});
|
|
106
|
+
it("updateTask merges fields", async () => {
|
|
107
|
+
const task = await stores.taskStore.addTask({
|
|
108
|
+
title: "Original", description: "desc", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
109
|
+
});
|
|
110
|
+
const updated = await stores.taskStore.updateTask(task.id, { title: "Updated" });
|
|
111
|
+
expect(updated.title).toBe("Updated");
|
|
112
|
+
expect(updated.description).toBe("desc"); // unchanged
|
|
113
|
+
const fetched = await stores.taskStore.getTask(task.id);
|
|
114
|
+
expect(fetched.title).toBe("Updated");
|
|
115
|
+
});
|
|
116
|
+
it("removeTask deletes by ID", async () => {
|
|
117
|
+
const task = await stores.taskStore.addTask({
|
|
118
|
+
title: "Delete me", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
119
|
+
});
|
|
120
|
+
const removed = await stores.taskStore.removeTask(task.id);
|
|
121
|
+
expect(removed).toBe(true);
|
|
122
|
+
const fetched = await stores.taskStore.getTask(task.id);
|
|
123
|
+
expect(fetched).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
it("removeTasks with filter", async () => {
|
|
126
|
+
await stores.taskStore.addTask({
|
|
127
|
+
title: "Keep", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
128
|
+
});
|
|
129
|
+
await stores.taskStore.addTask({
|
|
130
|
+
title: "Remove", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [], group: "old",
|
|
131
|
+
});
|
|
132
|
+
const count = await stores.taskStore.removeTasks((t) => t.group === "old");
|
|
133
|
+
expect(count).toBe(1);
|
|
134
|
+
const all = await stores.taskStore.getAllTasks();
|
|
135
|
+
expect(all).toHaveLength(1);
|
|
136
|
+
expect(all[0].title).toBe("Keep");
|
|
137
|
+
});
|
|
138
|
+
it("transition validates state machine", async () => {
|
|
139
|
+
const task = await stores.taskStore.addTask({
|
|
140
|
+
title: "T", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
141
|
+
});
|
|
142
|
+
// pending -> assigned is valid
|
|
143
|
+
const assigned = await stores.taskStore.transition(task.id, "assigned");
|
|
144
|
+
expect(assigned.status).toBe("assigned");
|
|
145
|
+
// assigned -> pending is invalid
|
|
146
|
+
await expect(stores.taskStore.transition(task.id, "pending")).rejects.toThrow();
|
|
147
|
+
});
|
|
148
|
+
it("transition increments retries on failed->pending", async () => {
|
|
149
|
+
const task = await stores.taskStore.addTask({
|
|
150
|
+
title: "T", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 3, expectations: [], metrics: [],
|
|
151
|
+
});
|
|
152
|
+
await stores.taskStore.transition(task.id, "assigned");
|
|
153
|
+
await stores.taskStore.transition(task.id, "in_progress");
|
|
154
|
+
await stores.taskStore.transition(task.id, "failed");
|
|
155
|
+
const retried = await stores.taskStore.transition(task.id, "pending");
|
|
156
|
+
expect(retried.retries).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
it("unsafeSetStatus bypasses state machine", async () => {
|
|
159
|
+
const task = await stores.taskStore.addTask({
|
|
160
|
+
title: "T", description: "d", assignTo: "claude", dependsOn: [], maxRetries: 2, expectations: [], metrics: [],
|
|
161
|
+
});
|
|
162
|
+
// pending -> done is not a valid transition, but unsafeSetStatus allows it
|
|
163
|
+
const result = await stores.taskStore.unsafeSetStatus(task.id, "done", "admin override");
|
|
164
|
+
expect(result.status).toBe("done");
|
|
165
|
+
});
|
|
166
|
+
// ── Missions ────────────────────────────────────────────────────────
|
|
167
|
+
it("saveMission + getMission round-trip", async () => {
|
|
168
|
+
const mission = await stores.taskStore.saveMission({
|
|
169
|
+
name: "mission-1",
|
|
170
|
+
data: '{"tasks":[]}',
|
|
171
|
+
status: "draft",
|
|
172
|
+
});
|
|
173
|
+
expect(mission.id).toBeDefined();
|
|
174
|
+
expect(mission.name).toBe("mission-1");
|
|
175
|
+
const fetched = await stores.taskStore.getMission(mission.id);
|
|
176
|
+
expect(fetched).toBeDefined();
|
|
177
|
+
expect(fetched.name).toBe("mission-1");
|
|
178
|
+
});
|
|
179
|
+
it("getMissionByName finds by name", async () => {
|
|
180
|
+
await stores.taskStore.saveMission({ name: "deploy-v2", data: "{}", status: "draft" });
|
|
181
|
+
const found = await stores.taskStore.getMissionByName("deploy-v2");
|
|
182
|
+
expect(found).toBeDefined();
|
|
183
|
+
expect(found.name).toBe("deploy-v2");
|
|
184
|
+
});
|
|
185
|
+
it("updateMission merges fields", async () => {
|
|
186
|
+
const m = await stores.taskStore.saveMission({ name: "m-1", data: "{}", status: "draft" });
|
|
187
|
+
const updated = await stores.taskStore.updateMission(m.id, { status: "active" });
|
|
188
|
+
expect(updated.status).toBe("active");
|
|
189
|
+
expect(updated.name).toBe("m-1");
|
|
190
|
+
});
|
|
191
|
+
it("deleteMission removes", async () => {
|
|
192
|
+
const m = await stores.taskStore.saveMission({ name: "m-del", data: "{}", status: "draft" });
|
|
193
|
+
const ok = await stores.taskStore.deleteMission(m.id);
|
|
194
|
+
expect(ok).toBe(true);
|
|
195
|
+
const fetched = await stores.taskStore.getMission(m.id);
|
|
196
|
+
expect(fetched).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
it("nextMissionName increments", async () => {
|
|
199
|
+
expect(await stores.taskStore.nextMissionName()).toBe("mission-1");
|
|
200
|
+
await stores.taskStore.saveMission({ name: "mission-1", data: "{}", status: "draft" });
|
|
201
|
+
expect(await stores.taskStore.nextMissionName()).toBe("mission-2");
|
|
202
|
+
await stores.taskStore.saveMission({ name: "mission-5", data: "{}", status: "draft" });
|
|
203
|
+
expect(await stores.taskStore.nextMissionName()).toBe("mission-6");
|
|
204
|
+
});
|
|
205
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
206
|
+
it("setState + getState round-trip", async () => {
|
|
207
|
+
await stores.taskStore.setState({
|
|
208
|
+
project: "test-project",
|
|
209
|
+
teams: [{ name: "alpha", agents: [{ name: "claude" }] }],
|
|
210
|
+
startedAt: "2025-01-01T00:00:00Z",
|
|
211
|
+
});
|
|
212
|
+
const state = await stores.taskStore.getState();
|
|
213
|
+
expect(state.project).toBe("test-project");
|
|
214
|
+
expect(state.teams).toHaveLength(1);
|
|
215
|
+
expect(state.teams[0].name).toBe("alpha");
|
|
216
|
+
expect(state.startedAt).toBe("2025-01-01T00:00:00Z");
|
|
217
|
+
});
|
|
218
|
+
it("setState with processes", async () => {
|
|
219
|
+
await stores.taskStore.setState({
|
|
220
|
+
project: "p",
|
|
221
|
+
processes: [{
|
|
222
|
+
agentName: "claude",
|
|
223
|
+
pid: 1234,
|
|
224
|
+
taskId: "t1",
|
|
225
|
+
startedAt: "2025-01-01T00:00:00Z",
|
|
226
|
+
alive: true,
|
|
227
|
+
activity: { filesCreated: [], filesEdited: [], toolCalls: 5, totalTokens: 100, lastUpdate: "now" },
|
|
228
|
+
}],
|
|
229
|
+
});
|
|
230
|
+
const state = await stores.taskStore.getState();
|
|
231
|
+
expect(state.processes).toHaveLength(1);
|
|
232
|
+
expect(state.processes[0].pid).toBe(1234);
|
|
233
|
+
expect(state.processes[0].alive).toBe(true);
|
|
234
|
+
expect(state.processes[0].activity.toolCalls).toBe(5);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
238
|
+
// RunStore
|
|
239
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
240
|
+
describe("DrizzleRunStore", () => {
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
const makeRun = (id, taskId, status = "running") => ({
|
|
243
|
+
id,
|
|
244
|
+
taskId,
|
|
245
|
+
pid: 0,
|
|
246
|
+
agentName: "claude",
|
|
247
|
+
sessionId: undefined,
|
|
248
|
+
status,
|
|
249
|
+
startedAt: now,
|
|
250
|
+
updatedAt: now,
|
|
251
|
+
activity: { filesCreated: [], filesEdited: [], toolCalls: 0, totalTokens: 0, lastUpdate: "" },
|
|
252
|
+
result: undefined,
|
|
253
|
+
outcomes: undefined,
|
|
254
|
+
configPath: "/tmp/config.json",
|
|
255
|
+
});
|
|
256
|
+
it("upsertRun + getRun round-trip", async () => {
|
|
257
|
+
const run = makeRun("r1", "t1");
|
|
258
|
+
await stores.runStore.upsertRun(run);
|
|
259
|
+
const fetched = await stores.runStore.getRun("r1");
|
|
260
|
+
expect(fetched).toBeDefined();
|
|
261
|
+
expect(fetched.taskId).toBe("t1");
|
|
262
|
+
expect(fetched.status).toBe("running");
|
|
263
|
+
});
|
|
264
|
+
it("upsertRun updates on conflict", async () => {
|
|
265
|
+
await stores.runStore.upsertRun(makeRun("r1", "t1"));
|
|
266
|
+
await stores.runStore.upsertRun({ ...makeRun("r1", "t1"), status: "completed" });
|
|
267
|
+
const fetched = await stores.runStore.getRun("r1");
|
|
268
|
+
expect(fetched.status).toBe("completed");
|
|
269
|
+
});
|
|
270
|
+
it("getRunByTaskId returns latest", async () => {
|
|
271
|
+
await stores.runStore.upsertRun({ ...makeRun("r1", "t1"), startedAt: "2025-01-01T00:00:00Z" });
|
|
272
|
+
await stores.runStore.upsertRun({ ...makeRun("r2", "t1"), startedAt: "2025-01-02T00:00:00Z" });
|
|
273
|
+
const latest = await stores.runStore.getRunByTaskId("t1");
|
|
274
|
+
expect(latest).toBeDefined();
|
|
275
|
+
expect(latest.id).toBe("r2");
|
|
276
|
+
});
|
|
277
|
+
it("getActiveRuns returns only running", async () => {
|
|
278
|
+
await stores.runStore.upsertRun(makeRun("r1", "t1"));
|
|
279
|
+
await stores.runStore.upsertRun({ ...makeRun("r2", "t2"), status: "completed" });
|
|
280
|
+
const active = await stores.runStore.getActiveRuns();
|
|
281
|
+
expect(active).toHaveLength(1);
|
|
282
|
+
expect(active[0].id).toBe("r1");
|
|
283
|
+
});
|
|
284
|
+
it("getTerminalRuns returns completed/failed/killed", async () => {
|
|
285
|
+
await stores.runStore.upsertRun(makeRun("r1", "t1"));
|
|
286
|
+
await stores.runStore.upsertRun({ ...makeRun("r2", "t2"), status: "completed" });
|
|
287
|
+
await stores.runStore.upsertRun({ ...makeRun("r3", "t3"), status: "failed" });
|
|
288
|
+
const terminal = await stores.runStore.getTerminalRuns();
|
|
289
|
+
expect(terminal).toHaveLength(2);
|
|
290
|
+
});
|
|
291
|
+
it("completeRun guards against overwriting terminal status", async () => {
|
|
292
|
+
await stores.runStore.upsertRun({ ...makeRun("r1", "t1"), status: "completed" });
|
|
293
|
+
// Try to overwrite with failed — should be silently ignored
|
|
294
|
+
await stores.runStore.completeRun("r1", "failed", { exitCode: 1, stdout: "", stderr: "nope", duration: 100 });
|
|
295
|
+
const fetched = await stores.runStore.getRun("r1");
|
|
296
|
+
expect(fetched.status).toBe("completed"); // unchanged
|
|
297
|
+
});
|
|
298
|
+
it("updateActivity updates activity and sessionId", async () => {
|
|
299
|
+
await stores.runStore.upsertRun(makeRun("r1", "t1"));
|
|
300
|
+
await stores.runStore.updateActivity("r1", {
|
|
301
|
+
filesCreated: ["a.ts"], filesEdited: [], toolCalls: 10, totalTokens: 500, lastUpdate: "now", sessionId: "s1",
|
|
302
|
+
});
|
|
303
|
+
const fetched = await stores.runStore.getRun("r1");
|
|
304
|
+
expect(fetched.activity.toolCalls).toBe(10);
|
|
305
|
+
expect(fetched.sessionId).toBe("s1");
|
|
306
|
+
});
|
|
307
|
+
it("deleteRun removes the record", async () => {
|
|
308
|
+
await stores.runStore.upsertRun(makeRun("r1", "t1"));
|
|
309
|
+
await stores.runStore.deleteRun("r1");
|
|
310
|
+
expect(await stores.runStore.getRun("r1")).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
314
|
+
// SessionStore
|
|
315
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
316
|
+
describe("DrizzleSessionStore", () => {
|
|
317
|
+
it("create + getSession", async () => {
|
|
318
|
+
const id = await stores.sessionStore.create("My Session");
|
|
319
|
+
const session = await stores.sessionStore.getSession(id);
|
|
320
|
+
expect(session).toBeDefined();
|
|
321
|
+
expect(session.title).toBe("My Session");
|
|
322
|
+
expect(session.messageCount).toBe(0);
|
|
323
|
+
});
|
|
324
|
+
it("addMessage + getMessages", async () => {
|
|
325
|
+
const sid = await stores.sessionStore.create();
|
|
326
|
+
await stores.sessionStore.addMessage(sid, "user", "Hello");
|
|
327
|
+
await stores.sessionStore.addMessage(sid, "assistant", "Hi there");
|
|
328
|
+
const msgs = await stores.sessionStore.getMessages(sid);
|
|
329
|
+
expect(msgs).toHaveLength(2);
|
|
330
|
+
expect(msgs[0].role).toBe("user");
|
|
331
|
+
expect(msgs[1].content).toBe("Hi there");
|
|
332
|
+
});
|
|
333
|
+
it("getRecentMessages returns last N", async () => {
|
|
334
|
+
const sid = await stores.sessionStore.create();
|
|
335
|
+
await stores.sessionStore.addMessage(sid, "user", "1");
|
|
336
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
337
|
+
await stores.sessionStore.addMessage(sid, "assistant", "2");
|
|
338
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
339
|
+
await stores.sessionStore.addMessage(sid, "user", "3");
|
|
340
|
+
const recent = await stores.sessionStore.getRecentMessages(sid, 2);
|
|
341
|
+
expect(recent).toHaveLength(2);
|
|
342
|
+
expect(recent[0].content).toBe("2");
|
|
343
|
+
expect(recent[1].content).toBe("3");
|
|
344
|
+
});
|
|
345
|
+
it("listSessions includes messageCount", async () => {
|
|
346
|
+
const s1 = await stores.sessionStore.create("S1");
|
|
347
|
+
await stores.sessionStore.addMessage(s1, "user", "msg1");
|
|
348
|
+
await stores.sessionStore.addMessage(s1, "assistant", "msg2");
|
|
349
|
+
await stores.sessionStore.create("S2");
|
|
350
|
+
const list = await stores.sessionStore.listSessions();
|
|
351
|
+
expect(list).toHaveLength(2);
|
|
352
|
+
const withMessages = list.find((s) => s.title === "S1");
|
|
353
|
+
expect(withMessages.messageCount).toBe(2);
|
|
354
|
+
});
|
|
355
|
+
it("renameSession updates title", async () => {
|
|
356
|
+
const id = await stores.sessionStore.create("Old");
|
|
357
|
+
const ok = await stores.sessionStore.renameSession(id, "New");
|
|
358
|
+
expect(ok).toBe(true);
|
|
359
|
+
const session = await stores.sessionStore.getSession(id);
|
|
360
|
+
expect(session.title).toBe("New");
|
|
361
|
+
});
|
|
362
|
+
it("deleteSession cascade-deletes messages", async () => {
|
|
363
|
+
const id = await stores.sessionStore.create("Del");
|
|
364
|
+
await stores.sessionStore.addMessage(id, "user", "msg");
|
|
365
|
+
const ok = await stores.sessionStore.deleteSession(id);
|
|
366
|
+
expect(ok).toBe(true);
|
|
367
|
+
expect(await stores.sessionStore.getSession(id)).toBeUndefined();
|
|
368
|
+
expect(await stores.sessionStore.getMessages(id)).toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
it("prune keeps the N most recent sessions", async () => {
|
|
371
|
+
await stores.sessionStore.create("Old");
|
|
372
|
+
await stores.sessionStore.create("New");
|
|
373
|
+
const pruned = await stores.sessionStore.prune(1);
|
|
374
|
+
expect(pruned).toBe(1);
|
|
375
|
+
const list = await stores.sessionStore.listSessions();
|
|
376
|
+
expect(list).toHaveLength(1);
|
|
377
|
+
});
|
|
378
|
+
it("getLatestSession returns most recently updated", async () => {
|
|
379
|
+
await stores.sessionStore.create("First");
|
|
380
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
381
|
+
const id2 = await stores.sessionStore.create("Second");
|
|
382
|
+
const latest = await stores.sessionStore.getLatestSession();
|
|
383
|
+
expect(latest).toBeDefined();
|
|
384
|
+
expect(latest.id).toBe(id2);
|
|
385
|
+
});
|
|
386
|
+
it("updateMessage changes content", async () => {
|
|
387
|
+
const sid = await stores.sessionStore.create();
|
|
388
|
+
const msg = await stores.sessionStore.addMessage(sid, "assistant", "draft");
|
|
389
|
+
const ok = await stores.sessionStore.updateMessage(sid, msg.id, "final");
|
|
390
|
+
expect(ok).toBe(true);
|
|
391
|
+
const msgs = await stores.sessionStore.getMessages(sid);
|
|
392
|
+
expect(msgs[0].content).toBe("final");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
396
|
+
// LogStore
|
|
397
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
398
|
+
describe("DrizzleLogStore", () => {
|
|
399
|
+
it("startSession + append + getSessionEntries", async () => {
|
|
400
|
+
const sid = await stores.logStore.startSession();
|
|
401
|
+
expect(sid).toBeDefined();
|
|
402
|
+
await stores.logStore.append({ ts: "2025-01-01T00:00:00Z", event: "task:started", data: { taskId: "t1" } });
|
|
403
|
+
await stores.logStore.append({ ts: "2025-01-01T00:01:00Z", event: "task:done", data: { taskId: "t1" } });
|
|
404
|
+
const entries = await stores.logStore.getSessionEntries(sid);
|
|
405
|
+
expect(entries).toHaveLength(2);
|
|
406
|
+
expect(entries[0].event).toBe("task:started");
|
|
407
|
+
expect(entries[1].event).toBe("task:done");
|
|
408
|
+
});
|
|
409
|
+
it("getSessionId returns current", async () => {
|
|
410
|
+
expect(await stores.logStore.getSessionId()).toBeUndefined();
|
|
411
|
+
const sid = await stores.logStore.startSession();
|
|
412
|
+
expect(await stores.logStore.getSessionId()).toBe(sid);
|
|
413
|
+
});
|
|
414
|
+
it("listSessions returns sessions with entry count", async () => {
|
|
415
|
+
await stores.logStore.startSession();
|
|
416
|
+
await stores.logStore.append({ ts: "2025-01-01T00:00:00Z", event: "e1", data: null });
|
|
417
|
+
await stores.logStore.append({ ts: "2025-01-01T00:01:00Z", event: "e2", data: null });
|
|
418
|
+
const sessions = await stores.logStore.listSessions();
|
|
419
|
+
expect(sessions).toHaveLength(1);
|
|
420
|
+
expect(sessions[0].entries).toBe(2);
|
|
421
|
+
});
|
|
422
|
+
it("auto-creates session on append if none started", async () => {
|
|
423
|
+
await stores.logStore.append({ ts: "2025-01-01T00:00:00Z", event: "auto", data: null });
|
|
424
|
+
const sid = await stores.logStore.getSessionId();
|
|
425
|
+
expect(sid).toBeDefined();
|
|
426
|
+
const entries = await stores.logStore.getSessionEntries(sid);
|
|
427
|
+
expect(entries).toHaveLength(1);
|
|
428
|
+
});
|
|
429
|
+
it("prune removes old sessions", async () => {
|
|
430
|
+
await stores.logStore.startSession();
|
|
431
|
+
await stores.logStore.append({ ts: "2025-01-01T00:00:00Z", event: "old", data: null });
|
|
432
|
+
await stores.logStore.startSession();
|
|
433
|
+
await stores.logStore.append({ ts: "2025-01-02T00:00:00Z", event: "new", data: null });
|
|
434
|
+
const pruned = await stores.logStore.prune(1);
|
|
435
|
+
expect(pruned).toBe(1);
|
|
436
|
+
expect(await stores.logStore.listSessions()).toHaveLength(1);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
440
|
+
// ApprovalStore
|
|
441
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
442
|
+
describe("DrizzleApprovalStore", () => {
|
|
443
|
+
const makeApproval = (id, overrides = {}) => ({
|
|
444
|
+
id,
|
|
445
|
+
gateId: "gate-1",
|
|
446
|
+
gateName: "Deploy Gate",
|
|
447
|
+
taskId: "t1",
|
|
448
|
+
status: "pending",
|
|
449
|
+
payload: null,
|
|
450
|
+
requestedAt: new Date().toISOString(),
|
|
451
|
+
...overrides,
|
|
452
|
+
});
|
|
453
|
+
it("upsert + get round-trip", async () => {
|
|
454
|
+
const req = makeApproval("a1");
|
|
455
|
+
await stores.approvalStore.upsert(req);
|
|
456
|
+
const fetched = await stores.approvalStore.get("a1");
|
|
457
|
+
expect(fetched).toBeDefined();
|
|
458
|
+
expect(fetched.gateName).toBe("Deploy Gate");
|
|
459
|
+
expect(fetched.status).toBe("pending");
|
|
460
|
+
});
|
|
461
|
+
it("upsert updates on conflict", async () => {
|
|
462
|
+
await stores.approvalStore.upsert(makeApproval("a1"));
|
|
463
|
+
await stores.approvalStore.upsert(makeApproval("a1", {
|
|
464
|
+
status: "approved",
|
|
465
|
+
resolvedBy: "admin",
|
|
466
|
+
resolvedAt: new Date().toISOString(),
|
|
467
|
+
}));
|
|
468
|
+
const fetched = await stores.approvalStore.get("a1");
|
|
469
|
+
expect(fetched.status).toBe("approved");
|
|
470
|
+
expect(fetched.resolvedBy).toBe("admin");
|
|
471
|
+
});
|
|
472
|
+
it("list filters by status", async () => {
|
|
473
|
+
await stores.approvalStore.upsert(makeApproval("a1", { status: "pending" }));
|
|
474
|
+
await stores.approvalStore.upsert(makeApproval("a2", { status: "approved" }));
|
|
475
|
+
const pending = await stores.approvalStore.list("pending");
|
|
476
|
+
expect(pending).toHaveLength(1);
|
|
477
|
+
expect(pending[0].id).toBe("a1");
|
|
478
|
+
});
|
|
479
|
+
it("listByTask filters by taskId", async () => {
|
|
480
|
+
await stores.approvalStore.upsert(makeApproval("a1", { taskId: "t1" }));
|
|
481
|
+
await stores.approvalStore.upsert(makeApproval("a2", { taskId: "t2" }));
|
|
482
|
+
const t1 = await stores.approvalStore.listByTask("t1");
|
|
483
|
+
expect(t1).toHaveLength(1);
|
|
484
|
+
expect(t1[0].id).toBe("a1");
|
|
485
|
+
});
|
|
486
|
+
it("delete removes", async () => {
|
|
487
|
+
await stores.approvalStore.upsert(makeApproval("a1"));
|
|
488
|
+
const ok = await stores.approvalStore.delete("a1");
|
|
489
|
+
expect(ok).toBe(true);
|
|
490
|
+
expect(await stores.approvalStore.get("a1")).toBeUndefined();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
494
|
+
// MemoryStore
|
|
495
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
496
|
+
describe("DrizzleMemoryStore", () => {
|
|
497
|
+
it("starts empty", async () => {
|
|
498
|
+
expect(await stores.memoryStore.exists()).toBe(false);
|
|
499
|
+
expect(await stores.memoryStore.get()).toBe("");
|
|
500
|
+
});
|
|
501
|
+
it("save + get round-trip", async () => {
|
|
502
|
+
await stores.memoryStore.save("Hello world");
|
|
503
|
+
expect(await stores.memoryStore.exists()).toBe(true);
|
|
504
|
+
expect(await stores.memoryStore.get()).toBe("Hello world");
|
|
505
|
+
});
|
|
506
|
+
it("save overwrites", async () => {
|
|
507
|
+
await stores.memoryStore.save("first");
|
|
508
|
+
await stores.memoryStore.save("second");
|
|
509
|
+
expect(await stores.memoryStore.get()).toBe("second");
|
|
510
|
+
});
|
|
511
|
+
it("append adds lines", async () => {
|
|
512
|
+
await stores.memoryStore.append("line 1");
|
|
513
|
+
await stores.memoryStore.append("line 2");
|
|
514
|
+
expect(await stores.memoryStore.get()).toBe("line 1\nline 2");
|
|
515
|
+
});
|
|
516
|
+
it("update replaces text", async () => {
|
|
517
|
+
await stores.memoryStore.save("foo bar baz");
|
|
518
|
+
const result = await stores.memoryStore.update("bar", "qux");
|
|
519
|
+
expect(result).toBe(true);
|
|
520
|
+
expect(await stores.memoryStore.get()).toBe("foo qux baz");
|
|
521
|
+
});
|
|
522
|
+
it("update returns error string when text not found", async () => {
|
|
523
|
+
await stores.memoryStore.save("hello");
|
|
524
|
+
const result = await stores.memoryStore.update("missing", "new");
|
|
525
|
+
expect(typeof result).toBe("string");
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
529
|
+
// CheckpointStore
|
|
530
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
531
|
+
describe("DrizzleCheckpointStore", () => {
|
|
532
|
+
it("load returns empty state when nothing saved", async () => {
|
|
533
|
+
const state = await stores.checkpointStore.load();
|
|
534
|
+
expect(state).toEqual({ definitions: {}, active: {}, resumed: [] });
|
|
535
|
+
});
|
|
536
|
+
it("save + load round-trip", async () => {
|
|
537
|
+
const state = {
|
|
538
|
+
definitions: { "mission-1": [{ name: "review", afterTasks: ["Task A"], blocksTasks: ["Task B"] }] },
|
|
539
|
+
active: { "mission-1:review": { checkpoint: { name: "review", afterTasks: ["Task A"], blocksTasks: ["Task B"] }, reachedAt: "2025-01-01T00:00:00Z" } },
|
|
540
|
+
resumed: [],
|
|
541
|
+
};
|
|
542
|
+
await stores.checkpointStore.save(state);
|
|
543
|
+
const loaded = await stores.checkpointStore.load();
|
|
544
|
+
expect(loaded.definitions["mission-1"]).toHaveLength(1);
|
|
545
|
+
expect(loaded.active["mission-1:review"]).toBeDefined();
|
|
546
|
+
});
|
|
547
|
+
it("removeGroup clears group-specific data", async () => {
|
|
548
|
+
const cp1 = { name: "cp1", afterTasks: ["A"], blocksTasks: ["B"] };
|
|
549
|
+
const cp2 = { name: "cp2", afterTasks: ["C"], blocksTasks: ["D"] };
|
|
550
|
+
const state = {
|
|
551
|
+
definitions: { "g1": [cp1], "g2": [cp2] },
|
|
552
|
+
active: {
|
|
553
|
+
"g1:cp1": { checkpoint: cp1, reachedAt: "now" },
|
|
554
|
+
"g2:cp2": { checkpoint: cp2, reachedAt: "now" },
|
|
555
|
+
},
|
|
556
|
+
resumed: ["g1:cp1", "g2:cp2"],
|
|
557
|
+
};
|
|
558
|
+
await stores.checkpointStore.save(state);
|
|
559
|
+
const next = await stores.checkpointStore.removeGroup(state, "g1");
|
|
560
|
+
expect(next.definitions["g1"]).toBeUndefined();
|
|
561
|
+
expect(next.definitions["g2"]).toBeDefined();
|
|
562
|
+
expect(next.active["g1:cp1"]).toBeUndefined();
|
|
563
|
+
expect(next.active["g2:cp2"]).toBeDefined();
|
|
564
|
+
expect(next.resumed).toEqual(["g2:cp2"]);
|
|
565
|
+
// Verify persisted
|
|
566
|
+
const reloaded = await stores.checkpointStore.load();
|
|
567
|
+
expect(reloaded.definitions["g1"]).toBeUndefined();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
571
|
+
// DelayStore
|
|
572
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
573
|
+
describe("DrizzleDelayStore", () => {
|
|
574
|
+
it("load returns empty state when nothing saved", async () => {
|
|
575
|
+
const state = await stores.delayStore.load();
|
|
576
|
+
expect(state).toEqual({ definitions: {}, active: {}, expired: [] });
|
|
577
|
+
});
|
|
578
|
+
it("save + load round-trip", async () => {
|
|
579
|
+
const delay = { name: "cooldown", duration: "PT5M", afterTasks: ["Task A"], blocksTasks: ["Task B"] };
|
|
580
|
+
const state = {
|
|
581
|
+
definitions: { "mission-1": [delay] },
|
|
582
|
+
active: { "mission-1:cooldown": { delay, startedAt: "2025-01-01T00:00:00Z", expiresAt: "2025-01-01T00:05:00Z" } },
|
|
583
|
+
expired: [],
|
|
584
|
+
};
|
|
585
|
+
await stores.delayStore.save(state);
|
|
586
|
+
const loaded = await stores.delayStore.load();
|
|
587
|
+
expect(loaded.definitions["mission-1"]).toHaveLength(1);
|
|
588
|
+
expect(loaded.active["mission-1:cooldown"]).toBeDefined();
|
|
589
|
+
});
|
|
590
|
+
it("removeGroup clears group-specific data", async () => {
|
|
591
|
+
const d1 = { name: "d1", duration: "PT5M", afterTasks: ["A"], blocksTasks: ["B"] };
|
|
592
|
+
const d2 = { name: "d2", duration: "PT10M", afterTasks: ["C"], blocksTasks: ["D"] };
|
|
593
|
+
const state = {
|
|
594
|
+
definitions: { "g1": [d1], "g2": [d2] },
|
|
595
|
+
active: {
|
|
596
|
+
"g1:d1": { delay: d1, startedAt: "now", expiresAt: "later" },
|
|
597
|
+
"g2:d2": { delay: d2, startedAt: "now", expiresAt: "later" },
|
|
598
|
+
},
|
|
599
|
+
expired: ["g1:d1", "g2:d2"],
|
|
600
|
+
};
|
|
601
|
+
await stores.delayStore.save(state);
|
|
602
|
+
const next = await stores.delayStore.removeGroup(state, "g1");
|
|
603
|
+
expect(next.definitions["g1"]).toBeUndefined();
|
|
604
|
+
expect(next.active["g1:d1"]).toBeUndefined();
|
|
605
|
+
expect(next.expired).toEqual(["g2:d2"]);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
609
|
+
// ConfigStore
|
|
610
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
611
|
+
describe("DrizzleConfigStore", () => {
|
|
612
|
+
it("exists returns false initially", async () => {
|
|
613
|
+
expect(await stores.configStore.exists()).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
it("save + get round-trip", async () => {
|
|
616
|
+
const config = {
|
|
617
|
+
settings: { storage: "postgres", model: "claude-sonnet-4-20250514" },
|
|
618
|
+
};
|
|
619
|
+
await stores.configStore.save(config);
|
|
620
|
+
expect(await stores.configStore.exists()).toBe(true);
|
|
621
|
+
const loaded = await stores.configStore.get();
|
|
622
|
+
expect(loaded).toBeDefined();
|
|
623
|
+
expect(loaded.settings.storage).toBe("postgres");
|
|
624
|
+
});
|
|
625
|
+
it("save overwrites previous config", async () => {
|
|
626
|
+
await stores.configStore.save({ settings: { workDir: "/old" } });
|
|
627
|
+
await stores.configStore.save({ settings: { workDir: "/new" } });
|
|
628
|
+
const loaded = await stores.configStore.get();
|
|
629
|
+
expect(loaded.settings.workDir).toBe("/new");
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
633
|
+
// TeamStore
|
|
634
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
635
|
+
describe("DrizzleTeamStore", () => {
|
|
636
|
+
it("createTeam + getTeam round-trip", async () => {
|
|
637
|
+
const team = await stores.teamStore.createTeam({ name: "alpha", agents: [] });
|
|
638
|
+
expect(team.name).toBe("alpha");
|
|
639
|
+
expect(team.agents).toEqual([]);
|
|
640
|
+
const fetched = await stores.teamStore.getTeam("alpha");
|
|
641
|
+
expect(fetched).toBeDefined();
|
|
642
|
+
expect(fetched.name).toBe("alpha");
|
|
643
|
+
});
|
|
644
|
+
it("getTeams returns all teams", async () => {
|
|
645
|
+
await stores.teamStore.createTeam({ name: "alpha", agents: [] });
|
|
646
|
+
await stores.teamStore.createTeam({ name: "beta", agents: [] });
|
|
647
|
+
const teams = await stores.teamStore.getTeams();
|
|
648
|
+
expect(teams).toHaveLength(2);
|
|
649
|
+
const names = teams.map(t => t.name).sort();
|
|
650
|
+
expect(names).toEqual(["alpha", "beta"]);
|
|
651
|
+
});
|
|
652
|
+
it("createTeam rejects duplicates", async () => {
|
|
653
|
+
await stores.teamStore.createTeam({ name: "alpha", agents: [] });
|
|
654
|
+
await expect(stores.teamStore.createTeam({ name: "alpha", agents: [] })).rejects.toThrow(/already exists/);
|
|
655
|
+
});
|
|
656
|
+
it("updateTeam merges description", async () => {
|
|
657
|
+
await stores.teamStore.createTeam({ name: "alpha", agents: [], description: "old" });
|
|
658
|
+
const updated = await stores.teamStore.updateTeam("alpha", { description: "new" });
|
|
659
|
+
expect(updated.description).toBe("new");
|
|
660
|
+
});
|
|
661
|
+
it("renameTeam updates team and agent foreign keys", async () => {
|
|
662
|
+
await stores.teamStore.createTeam({ name: "old-name", agents: [] });
|
|
663
|
+
await stores.agentStore.createAgent({ name: "claude" }, "old-name");
|
|
664
|
+
const renamed = await stores.teamStore.renameTeam("old-name", "new-name");
|
|
665
|
+
expect(renamed.name).toBe("new-name");
|
|
666
|
+
// Old name should not exist
|
|
667
|
+
expect(await stores.teamStore.getTeam("old-name")).toBeUndefined();
|
|
668
|
+
// Agent should be under the new team
|
|
669
|
+
const agentTeam = await stores.agentStore.getAgentTeam("claude");
|
|
670
|
+
expect(agentTeam).toBe("new-name");
|
|
671
|
+
});
|
|
672
|
+
it("deleteTeam cascade-deletes agents", async () => {
|
|
673
|
+
await stores.teamStore.createTeam({ name: "alpha", agents: [] });
|
|
674
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
675
|
+
const ok = await stores.teamStore.deleteTeam("alpha");
|
|
676
|
+
expect(ok).toBe(true);
|
|
677
|
+
expect(await stores.teamStore.getTeam("alpha")).toBeUndefined();
|
|
678
|
+
expect(await stores.agentStore.getAgent("claude")).toBeUndefined();
|
|
679
|
+
});
|
|
680
|
+
it("deleteTeam returns false for non-existent", async () => {
|
|
681
|
+
expect(await stores.teamStore.deleteTeam("ghost")).toBe(false);
|
|
682
|
+
});
|
|
683
|
+
it("seed skips existing teams", async () => {
|
|
684
|
+
await stores.teamStore.createTeam({ name: "alpha", description: "original", agents: [] });
|
|
685
|
+
await stores.teamStore.seed([
|
|
686
|
+
{ name: "alpha", description: "overwrite?", agents: [] },
|
|
687
|
+
{ name: "beta", agents: [] },
|
|
688
|
+
]);
|
|
689
|
+
const alpha = await stores.teamStore.getTeam("alpha");
|
|
690
|
+
expect(alpha.description).toBe("original"); // not overwritten
|
|
691
|
+
const beta = await stores.teamStore.getTeam("beta");
|
|
692
|
+
expect(beta).toBeDefined();
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
696
|
+
// AgentStore
|
|
697
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
698
|
+
describe("DrizzleAgentStore", () => {
|
|
699
|
+
beforeEach(async () => {
|
|
700
|
+
// Need a team to attach agents to
|
|
701
|
+
await stores.teamStore.createTeam({ name: "alpha", agents: [] });
|
|
702
|
+
});
|
|
703
|
+
it("createAgent + getAgent round-trip", async () => {
|
|
704
|
+
const agent = await stores.agentStore.createAgent({ name: "claude", role: "coder" }, "alpha");
|
|
705
|
+
expect(agent.name).toBe("claude");
|
|
706
|
+
expect(agent.role).toBe("coder");
|
|
707
|
+
const fetched = await stores.agentStore.getAgent("claude");
|
|
708
|
+
expect(fetched).toBeDefined();
|
|
709
|
+
expect(fetched.role).toBe("coder");
|
|
710
|
+
});
|
|
711
|
+
it("getAgents with and without team filter", async () => {
|
|
712
|
+
await stores.teamStore.createTeam({ name: "beta", agents: [] });
|
|
713
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
714
|
+
await stores.agentStore.createAgent({ name: "gpt" }, "beta");
|
|
715
|
+
const all = await stores.agentStore.getAgents();
|
|
716
|
+
expect(all).toHaveLength(2);
|
|
717
|
+
const alphaOnly = await stores.agentStore.getAgents("alpha");
|
|
718
|
+
expect(alphaOnly).toHaveLength(1);
|
|
719
|
+
expect(alphaOnly[0].name).toBe("claude");
|
|
720
|
+
});
|
|
721
|
+
it("getAgentTeam returns team name", async () => {
|
|
722
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
723
|
+
expect(await stores.agentStore.getAgentTeam("claude")).toBe("alpha");
|
|
724
|
+
expect(await stores.agentStore.getAgentTeam("ghost")).toBeUndefined();
|
|
725
|
+
});
|
|
726
|
+
it("createAgent rejects duplicates", async () => {
|
|
727
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
728
|
+
await expect(stores.agentStore.createAgent({ name: "claude" }, "alpha")).rejects.toThrow(/already exists/);
|
|
729
|
+
});
|
|
730
|
+
it("updateAgent merges fields", async () => {
|
|
731
|
+
await stores.agentStore.createAgent({ name: "claude", role: "coder" }, "alpha");
|
|
732
|
+
const updated = await stores.agentStore.updateAgent("claude", { role: "reviewer" });
|
|
733
|
+
expect(updated.role).toBe("reviewer");
|
|
734
|
+
expect(updated.name).toBe("claude");
|
|
735
|
+
});
|
|
736
|
+
it("moveAgent changes team", async () => {
|
|
737
|
+
await stores.teamStore.createTeam({ name: "beta", agents: [] });
|
|
738
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
739
|
+
await stores.agentStore.moveAgent("claude", "beta");
|
|
740
|
+
expect(await stores.agentStore.getAgentTeam("claude")).toBe("beta");
|
|
741
|
+
});
|
|
742
|
+
it("deleteAgent removes the agent", async () => {
|
|
743
|
+
await stores.agentStore.createAgent({ name: "claude" }, "alpha");
|
|
744
|
+
expect(await stores.agentStore.deleteAgent("claude")).toBe(true);
|
|
745
|
+
expect(await stores.agentStore.getAgent("claude")).toBeUndefined();
|
|
746
|
+
expect(await stores.agentStore.deleteAgent("ghost")).toBe(false);
|
|
747
|
+
});
|
|
748
|
+
it("seed skips existing agents", async () => {
|
|
749
|
+
await stores.agentStore.createAgent({ name: "claude", role: "coder" }, "alpha");
|
|
750
|
+
await stores.agentStore.seed([
|
|
751
|
+
{ name: "claude", role: "overwrite?", teamName: "alpha" },
|
|
752
|
+
{ name: "gpt", role: "planner", teamName: "alpha" },
|
|
753
|
+
]);
|
|
754
|
+
const claude = await stores.agentStore.getAgent("claude");
|
|
755
|
+
expect(claude.role).toBe("coder"); // not overwritten
|
|
756
|
+
const gpt = await stores.agentStore.getAgent("gpt");
|
|
757
|
+
expect(gpt).toBeDefined();
|
|
758
|
+
expect(gpt.role).toBe("planner");
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
762
|
+
// VaultStore
|
|
763
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
764
|
+
describe("DrizzleVaultStore", () => {
|
|
765
|
+
it("set + get round-trip with encryption", async () => {
|
|
766
|
+
const entry = { type: "api_key", credentials: { key: "sk-secret-123" } };
|
|
767
|
+
await stores.vaultStore.set("claude", "openai", entry);
|
|
768
|
+
const fetched = await stores.vaultStore.get("claude", "openai");
|
|
769
|
+
expect(fetched).toBeDefined();
|
|
770
|
+
expect(fetched.type).toBe("api_key");
|
|
771
|
+
expect(fetched.credentials.key).toBe("sk-secret-123");
|
|
772
|
+
});
|
|
773
|
+
it("getAllForAgent returns map by service", async () => {
|
|
774
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "k1" } });
|
|
775
|
+
await stores.vaultStore.set("claude", "smtp", { type: "smtp", credentials: { host: "mail.test" } });
|
|
776
|
+
const all = await stores.vaultStore.getAllForAgent("claude");
|
|
777
|
+
expect(Object.keys(all).sort()).toEqual(["openai", "smtp"]);
|
|
778
|
+
expect(all.openai.credentials.key).toBe("k1");
|
|
779
|
+
});
|
|
780
|
+
it("set upserts on conflict", async () => {
|
|
781
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "old" } });
|
|
782
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "new" } });
|
|
783
|
+
const fetched = await stores.vaultStore.get("claude", "openai");
|
|
784
|
+
expect(fetched.credentials.key).toBe("new");
|
|
785
|
+
});
|
|
786
|
+
it("patch merges credentials", async () => {
|
|
787
|
+
await stores.vaultStore.set("claude", "smtp", { type: "smtp", credentials: { host: "mail.test", port: "587" } });
|
|
788
|
+
const keys = await stores.vaultStore.patch("claude", "smtp", { credentials: { user: "alice" } });
|
|
789
|
+
expect(keys.sort()).toEqual(["host", "port", "user"]);
|
|
790
|
+
const fetched = await stores.vaultStore.get("claude", "smtp");
|
|
791
|
+
expect(fetched.credentials.user).toBe("alice");
|
|
792
|
+
expect(fetched.credentials.host).toBe("mail.test"); // preserved
|
|
793
|
+
});
|
|
794
|
+
it("remove deletes entry", async () => {
|
|
795
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "k" } });
|
|
796
|
+
const ok = await stores.vaultStore.remove("claude", "openai");
|
|
797
|
+
expect(ok).toBe(true);
|
|
798
|
+
expect(await stores.vaultStore.get("claude", "openai")).toBeUndefined();
|
|
799
|
+
});
|
|
800
|
+
it("list returns metadata without full credentials", async () => {
|
|
801
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", label: "Main", credentials: { key: "sk", org: "o" } });
|
|
802
|
+
const list = await stores.vaultStore.list("claude");
|
|
803
|
+
expect(list).toHaveLength(1);
|
|
804
|
+
expect(list[0].service).toBe("openai");
|
|
805
|
+
expect(list[0].type).toBe("api_key");
|
|
806
|
+
expect(list[0].label).toBe("Main");
|
|
807
|
+
expect(list[0].keys.sort()).toEqual(["key", "org"]);
|
|
808
|
+
});
|
|
809
|
+
it("hasEntries returns correct boolean", async () => {
|
|
810
|
+
expect(await stores.vaultStore.hasEntries("claude")).toBe(false);
|
|
811
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "k" } });
|
|
812
|
+
expect(await stores.vaultStore.hasEntries("claude")).toBe(true);
|
|
813
|
+
});
|
|
814
|
+
it("renameAgent moves entries to new name", async () => {
|
|
815
|
+
await stores.vaultStore.set("old-agent", "openai", { type: "api_key", credentials: { key: "k" } });
|
|
816
|
+
await stores.vaultStore.renameAgent("old-agent", "new-agent");
|
|
817
|
+
expect(await stores.vaultStore.get("old-agent", "openai")).toBeUndefined();
|
|
818
|
+
const fetched = await stores.vaultStore.get("new-agent", "openai");
|
|
819
|
+
expect(fetched).toBeDefined();
|
|
820
|
+
expect(fetched.credentials.key).toBe("k");
|
|
821
|
+
});
|
|
822
|
+
it("removeAgent deletes all entries for agent", async () => {
|
|
823
|
+
await stores.vaultStore.set("claude", "openai", { type: "api_key", credentials: { key: "k1" } });
|
|
824
|
+
await stores.vaultStore.set("claude", "smtp", { type: "smtp", credentials: { host: "h" } });
|
|
825
|
+
await stores.vaultStore.removeAgent("claude");
|
|
826
|
+
expect(await stores.vaultStore.hasEntries("claude")).toBe(false);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
830
|
+
// PlaybookStore
|
|
831
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
832
|
+
describe("DrizzlePlaybookStore", () => {
|
|
833
|
+
const makePlaybook = (name, overrides = {}) => ({
|
|
834
|
+
name,
|
|
835
|
+
description: `Playbook ${name}`,
|
|
836
|
+
mission: { prompt: "Do the thing", tasks: [] },
|
|
837
|
+
...overrides,
|
|
838
|
+
});
|
|
839
|
+
it("save + get round-trip", async () => {
|
|
840
|
+
const path = await stores.playbookStore.save(makePlaybook("deploy-v1"));
|
|
841
|
+
expect(path).toContain("deploy-v1");
|
|
842
|
+
const fetched = await stores.playbookStore.get("deploy-v1");
|
|
843
|
+
expect(fetched).not.toBeNull();
|
|
844
|
+
expect(fetched.name).toBe("deploy-v1");
|
|
845
|
+
expect(fetched.description).toBe("Playbook deploy-v1");
|
|
846
|
+
expect(fetched.mission).toEqual({ prompt: "Do the thing", tasks: [] });
|
|
847
|
+
});
|
|
848
|
+
it("save upserts on conflict", async () => {
|
|
849
|
+
await stores.playbookStore.save(makePlaybook("pb", { description: "old" }));
|
|
850
|
+
await stores.playbookStore.save(makePlaybook("pb", { description: "new" }));
|
|
851
|
+
const fetched = await stores.playbookStore.get("pb");
|
|
852
|
+
expect(fetched.description).toBe("new");
|
|
853
|
+
});
|
|
854
|
+
it("list returns metadata for all playbooks", async () => {
|
|
855
|
+
await stores.playbookStore.save(makePlaybook("alpha", {
|
|
856
|
+
parameters: [{ name: "env", description: "Target environment", required: true }],
|
|
857
|
+
}));
|
|
858
|
+
await stores.playbookStore.save(makePlaybook("beta"));
|
|
859
|
+
const list = await stores.playbookStore.list();
|
|
860
|
+
expect(list).toHaveLength(2);
|
|
861
|
+
const alpha = list.find(p => p.name === "alpha");
|
|
862
|
+
expect(alpha).toBeDefined();
|
|
863
|
+
expect(alpha.parameters).toHaveLength(1);
|
|
864
|
+
expect(alpha.parameters[0].name).toBe("env");
|
|
865
|
+
expect(alpha.path).toContain("alpha");
|
|
866
|
+
});
|
|
867
|
+
it("get returns null for non-existent", async () => {
|
|
868
|
+
expect(await stores.playbookStore.get("ghost")).toBeNull();
|
|
869
|
+
});
|
|
870
|
+
it("delete removes playbook", async () => {
|
|
871
|
+
await stores.playbookStore.save(makePlaybook("del-me"));
|
|
872
|
+
const ok = await stores.playbookStore.delete("del-me");
|
|
873
|
+
expect(ok).toBe(true);
|
|
874
|
+
expect(await stores.playbookStore.get("del-me")).toBeNull();
|
|
875
|
+
});
|
|
876
|
+
it("delete returns false for non-existent", async () => {
|
|
877
|
+
expect(await stores.playbookStore.delete("ghost")).toBe(false);
|
|
878
|
+
});
|
|
879
|
+
it("preserves optional fields: version, author, tags", async () => {
|
|
880
|
+
await stores.playbookStore.save(makePlaybook("rich", {
|
|
881
|
+
version: "1.2.0",
|
|
882
|
+
author: "alice",
|
|
883
|
+
tags: ["infra", "deploy"],
|
|
884
|
+
}));
|
|
885
|
+
const fetched = await stores.playbookStore.get("rich");
|
|
886
|
+
expect(fetched.version).toBe("1.2.0");
|
|
887
|
+
expect(fetched.author).toBe("alice");
|
|
888
|
+
expect(fetched.tags).toEqual(["infra", "deploy"]);
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
892
|
+
// AttachmentStore
|
|
893
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
894
|
+
describe("DrizzleAttachmentStore", () => {
|
|
895
|
+
it("save + get round-trip", async () => {
|
|
896
|
+
const attachment = {
|
|
897
|
+
id: "att-1",
|
|
898
|
+
sessionId: "s1",
|
|
899
|
+
filename: "screenshot.png",
|
|
900
|
+
mimeType: "image/png",
|
|
901
|
+
size: 1024,
|
|
902
|
+
path: "/tmp/screenshot.png",
|
|
903
|
+
createdAt: new Date().toISOString(),
|
|
904
|
+
};
|
|
905
|
+
await stores.attachmentStore.save(attachment);
|
|
906
|
+
const fetched = await stores.attachmentStore.get("att-1");
|
|
907
|
+
expect(fetched).toBeDefined();
|
|
908
|
+
expect(fetched.filename).toBe("screenshot.png");
|
|
909
|
+
expect(fetched.mimeType).toBe("image/png");
|
|
910
|
+
expect(fetched.size).toBe(1024);
|
|
911
|
+
});
|
|
912
|
+
it("save with messageId", async () => {
|
|
913
|
+
const attachment = {
|
|
914
|
+
id: "att-2",
|
|
915
|
+
sessionId: "s1",
|
|
916
|
+
messageId: "msg-1",
|
|
917
|
+
filename: "doc.pdf",
|
|
918
|
+
mimeType: "application/pdf",
|
|
919
|
+
size: 2048,
|
|
920
|
+
path: "/tmp/doc.pdf",
|
|
921
|
+
createdAt: new Date().toISOString(),
|
|
922
|
+
};
|
|
923
|
+
await stores.attachmentStore.save(attachment);
|
|
924
|
+
const fetched = await stores.attachmentStore.get("att-2");
|
|
925
|
+
expect(fetched).toBeDefined();
|
|
926
|
+
expect(fetched.messageId).toBe("msg-1");
|
|
927
|
+
});
|
|
928
|
+
it("getBySession returns all attachments for session", async () => {
|
|
929
|
+
const base = { sessionId: "s1", mimeType: "text/plain", size: 100, createdAt: new Date().toISOString() };
|
|
930
|
+
await stores.attachmentStore.save({ ...base, id: "att-1", filename: "a.txt", path: "/tmp/a.txt" });
|
|
931
|
+
await stores.attachmentStore.save({ ...base, id: "att-2", filename: "b.txt", path: "/tmp/b.txt" });
|
|
932
|
+
await stores.attachmentStore.save({ ...base, id: "att-3", filename: "c.txt", path: "/tmp/c.txt", sessionId: "s2" });
|
|
933
|
+
const s1Attachments = await stores.attachmentStore.getBySession("s1");
|
|
934
|
+
expect(s1Attachments).toHaveLength(2);
|
|
935
|
+
});
|
|
936
|
+
it("delete removes attachment", async () => {
|
|
937
|
+
await stores.attachmentStore.save({
|
|
938
|
+
id: "att-del",
|
|
939
|
+
sessionId: "s1",
|
|
940
|
+
filename: "temp.txt",
|
|
941
|
+
mimeType: "text/plain",
|
|
942
|
+
size: 10,
|
|
943
|
+
path: "/tmp/temp.txt",
|
|
944
|
+
createdAt: new Date().toISOString(),
|
|
945
|
+
});
|
|
946
|
+
const ok = await stores.attachmentStore.delete("att-del");
|
|
947
|
+
expect(ok).toBe(true);
|
|
948
|
+
expect(await stores.attachmentStore.get("att-del")).toBeUndefined();
|
|
949
|
+
});
|
|
950
|
+
it("deleteBySession removes all for session", async () => {
|
|
951
|
+
const base = { sessionId: "s-del", mimeType: "text/plain", size: 10, createdAt: new Date().toISOString() };
|
|
952
|
+
await stores.attachmentStore.save({ ...base, id: "att-1", filename: "a.txt", path: "/tmp/a.txt" });
|
|
953
|
+
await stores.attachmentStore.save({ ...base, id: "att-2", filename: "b.txt", path: "/tmp/b.txt" });
|
|
954
|
+
const count = await stores.attachmentStore.deleteBySession("s-del");
|
|
955
|
+
expect(count).toBe(2);
|
|
956
|
+
expect(await stores.attachmentStore.getBySession("s-del")).toEqual([]);
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
960
|
+
// Factory function
|
|
961
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
962
|
+
describe("createPgStores", () => {
|
|
963
|
+
it("returns all stores", () => {
|
|
964
|
+
expect(stores.taskStore).toBeDefined();
|
|
965
|
+
expect(stores.runStore).toBeDefined();
|
|
966
|
+
expect(stores.sessionStore).toBeDefined();
|
|
967
|
+
expect(stores.logStore).toBeDefined();
|
|
968
|
+
expect(stores.approvalStore).toBeDefined();
|
|
969
|
+
expect(stores.memoryStore).toBeDefined();
|
|
970
|
+
expect(stores.checkpointStore).toBeDefined();
|
|
971
|
+
expect(stores.delayStore).toBeDefined();
|
|
972
|
+
expect(stores.configStore).toBeDefined();
|
|
973
|
+
expect(stores.teamStore).toBeDefined();
|
|
974
|
+
expect(stores.agentStore).toBeDefined();
|
|
975
|
+
expect(stores.vaultStore).toBeDefined();
|
|
976
|
+
expect(stores.playbookStore).toBeDefined();
|
|
977
|
+
expect(stores.attachmentStore).toBeDefined();
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
//# sourceMappingURL=stores-pg.test.js.map
|