@agentplate/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/agents/architect.md +108 -0
- package/agents/builder.md +97 -0
- package/agents/coordinator.md +113 -0
- package/agents/deployer.md +117 -0
- package/agents/devops.md +114 -0
- package/agents/lead.md +107 -0
- package/agents/merger.md +103 -0
- package/agents/reviewer.md +90 -0
- package/agents/scout.md +95 -0
- package/agents/verifier.md +106 -0
- package/package.json +64 -0
- package/src/agents/guard-rules.ts +55 -0
- package/src/agents/identity.test.ts +161 -0
- package/src/agents/identity.ts +229 -0
- package/src/agents/manifest.test.ts +260 -0
- package/src/agents/manifest.ts +286 -0
- package/src/agents/overlay.test.ts +190 -0
- package/src/agents/overlay.ts +212 -0
- package/src/agents/system-prompt.test.ts +53 -0
- package/src/agents/system-prompt.ts +95 -0
- package/src/agents/turn-runner.ts +79 -0
- package/src/commands/coordinator.test.ts +75 -0
- package/src/commands/coordinator.ts +259 -0
- package/src/commands/deploy.test.ts +504 -0
- package/src/commands/deploy.ts +874 -0
- package/src/commands/doctor.test.ts +106 -0
- package/src/commands/doctor.ts +208 -0
- package/src/commands/init.ts +71 -0
- package/src/commands/log.ts +51 -0
- package/src/commands/mail.ts +197 -0
- package/src/commands/merge.ts +127 -0
- package/src/commands/model.ts +58 -0
- package/src/commands/prime.ts +61 -0
- package/src/commands/reap.ts +87 -0
- package/src/commands/serve.ts +61 -0
- package/src/commands/setup.ts +48 -0
- package/src/commands/ship.test.ts +106 -0
- package/src/commands/ship.ts +202 -0
- package/src/commands/skill.test.ts +458 -0
- package/src/commands/skill.ts +730 -0
- package/src/commands/sling.ts +365 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/stop.ts +56 -0
- package/src/commands/tui.ts +199 -0
- package/src/commands/worktree.ts +77 -0
- package/src/config.test.ts +92 -0
- package/src/config.ts +202 -0
- package/src/db/sqlite.test.ts +77 -0
- package/src/db/sqlite.ts +102 -0
- package/src/deploy/audit.test.ts +233 -0
- package/src/deploy/audit.ts +245 -0
- package/src/deploy/context.test.ts +243 -0
- package/src/deploy/context.ts +72 -0
- package/src/deploy/registry.test.ts +101 -0
- package/src/deploy/registry.ts +86 -0
- package/src/deploy/secrets.test.ts +129 -0
- package/src/deploy/secrets.ts +69 -0
- package/src/deploy/targets/docker-gha.test.ts +323 -0
- package/src/deploy/targets/docker-gha.ts +841 -0
- package/src/deploy/types.ts +153 -0
- package/src/errors.test.ts +42 -0
- package/src/errors.ts +69 -0
- package/src/events/store.test.ts +183 -0
- package/src/events/store.ts +201 -0
- package/src/index.ts +137 -0
- package/src/insights/quality-gates.ts +73 -0
- package/src/json.test.ts +28 -0
- package/src/json.ts +50 -0
- package/src/logging/color.ts +62 -0
- package/src/logging/logger.ts +60 -0
- package/src/logging/sanitizer.test.ts +36 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/client.test.ts +192 -0
- package/src/mail/client.ts +188 -0
- package/src/mail/store.test.ts +279 -0
- package/src/mail/store.ts +311 -0
- package/src/merge/lock.test.ts +88 -0
- package/src/merge/lock.ts +84 -0
- package/src/merge/queue.test.ts +136 -0
- package/src/merge/queue.ts +177 -0
- package/src/merge/resolver.test.ts +219 -0
- package/src/merge/resolver.ts +274 -0
- package/src/paths.ts +36 -0
- package/src/providers/apply.test.ts +90 -0
- package/src/providers/apply.ts +66 -0
- package/src/providers/registry.test.ts +74 -0
- package/src/providers/registry.ts +254 -0
- package/src/runtimes/claude.ts +313 -0
- package/src/runtimes/codex.ts +280 -0
- package/src/runtimes/cursor.ts +247 -0
- package/src/runtimes/gemini.ts +173 -0
- package/src/runtimes/mock.ts +71 -0
- package/src/runtimes/opencode.ts +259 -0
- package/src/runtimes/registry.test.ts +924 -0
- package/src/runtimes/registry.ts +63 -0
- package/src/runtimes/resolve.ts +45 -0
- package/src/runtimes/types.ts +97 -0
- package/src/scaffold.ts +68 -0
- package/src/secrets.test.ts +51 -0
- package/src/secrets.ts +78 -0
- package/src/serve/api.ts +667 -0
- package/src/serve/server.test.ts +433 -0
- package/src/serve/server.ts +271 -0
- package/src/serve/system.ts +90 -0
- package/src/serve/weather.ts +140 -0
- package/src/sessions/reaper.test.ts +162 -0
- package/src/sessions/reaper.ts +149 -0
- package/src/sessions/store.test.ts +351 -0
- package/src/sessions/store.ts +350 -0
- package/src/skills/distiller.test.ts +498 -0
- package/src/skills/distiller.ts +426 -0
- package/src/skills/feedback.test.ts +300 -0
- package/src/skills/feedback.ts +168 -0
- package/src/skills/lifecycle.ts +169 -0
- package/src/skills/retrieval.test.ts +421 -0
- package/src/skills/retrieval.ts +365 -0
- package/src/skills/safety.test.ts +335 -0
- package/src/skills/safety.ts +216 -0
- package/src/skills/store.test.ts +425 -0
- package/src/skills/store.ts +684 -0
- package/src/skills/types.ts +107 -0
- package/src/types.ts +442 -0
- package/src/utils/detect.test.ts +35 -0
- package/src/utils/detect.ts +82 -0
- package/src/version.test.ts +19 -0
- package/src/version.ts +7 -0
- package/src/wizard/setup.ts +254 -0
- package/src/worktree/manager.test.ts +181 -0
- package/src/worktree/manager.ts +229 -0
- package/templates/overlay.md.tmpl +102 -0
- package/ui/dist/assets/index-C7rXIMER.css +1 -0
- package/ui/dist/assets/index-W4kbr4by.js +4526 -0
- package/ui/dist/favicon.svg +21 -0
- package/ui/dist/index.html +16 -0
- package/ui/dist/logo-clay.svg +21 -0
- package/ui/dist/logo.svg +18 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the session/run store.
|
|
3
|
+
*
|
|
4
|
+
* Per project policy we never mock the database: every test runs against a real
|
|
5
|
+
* bun:sqlite handle. We default to an in-memory DB (`:memory:`) for speed and
|
|
6
|
+
* isolation, plus one file-backed case to prove parent-directory creation and
|
|
7
|
+
* cross-reopen persistence.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { AgentSession, SessionState } from "../types.ts";
|
|
15
|
+
import { createSessionStore, type SessionStore } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a fully-populated AgentSession with sensible defaults; pass `overrides`
|
|
19
|
+
* to vary the fields a given test cares about. Centralizing this keeps each test
|
|
20
|
+
* focused on the behavior under exercise rather than on boilerplate.
|
|
21
|
+
*/
|
|
22
|
+
function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
23
|
+
const now = new Date().toISOString();
|
|
24
|
+
return {
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
agentName: `agent-${crypto.randomUUID().slice(0, 4)}`,
|
|
27
|
+
capability: "builder",
|
|
28
|
+
taskId: "task-1",
|
|
29
|
+
runId: "run-test",
|
|
30
|
+
worktreePath: "/tmp/wt",
|
|
31
|
+
branchName: "feature/x",
|
|
32
|
+
state: "booting",
|
|
33
|
+
parentAgent: null,
|
|
34
|
+
depth: 0,
|
|
35
|
+
pid: null,
|
|
36
|
+
runtimeSessionId: null,
|
|
37
|
+
startedAt: now,
|
|
38
|
+
lastActivity: now,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("createSessionStore — runs", () => {
|
|
44
|
+
let store: SessionStore;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
store = createSessionStore(":memory:");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
store.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("createRun returns an active run with a run- prefixed id", () => {
|
|
55
|
+
const run = store.createRun("nightly");
|
|
56
|
+
expect(run.id).toMatch(/^run-[0-9a-f]{8}$/);
|
|
57
|
+
expect(run.label).toBe("nightly");
|
|
58
|
+
expect(run.status).toBe("active");
|
|
59
|
+
// createdAt should be a valid ISO-8601 timestamp.
|
|
60
|
+
expect(Number.isNaN(Date.parse(run.createdAt))).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("createRun without a label omits the label field", () => {
|
|
64
|
+
const run = store.createRun();
|
|
65
|
+
expect(run.label).toBeUndefined();
|
|
66
|
+
const fetched = store.getRun(run.id);
|
|
67
|
+
expect(fetched?.label).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("getRun returns null for an unknown id", () => {
|
|
71
|
+
expect(store.getRun("run-missing")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("getRun round-trips a created run", () => {
|
|
75
|
+
const run = store.createRun("label-a");
|
|
76
|
+
const fetched = store.getRun(run.id);
|
|
77
|
+
expect(fetched).toEqual(run);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("listRuns returns newest first and honors the limit", () => {
|
|
81
|
+
const a = store.createRun("a");
|
|
82
|
+
// Distinct createdAt values guarantee a stable DESC ordering.
|
|
83
|
+
Bun.sleepSync(2);
|
|
84
|
+
const b = store.createRun("b");
|
|
85
|
+
Bun.sleepSync(2);
|
|
86
|
+
const c = store.createRun("c");
|
|
87
|
+
|
|
88
|
+
const all = store.listRuns();
|
|
89
|
+
expect(all.map((r) => r.id)).toEqual([c.id, b.id, a.id]);
|
|
90
|
+
|
|
91
|
+
const limited = store.listRuns(2);
|
|
92
|
+
expect(limited.map((r) => r.id)).toEqual([c.id, b.id]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("completeRun flips status to completed", () => {
|
|
96
|
+
const run = store.createRun();
|
|
97
|
+
expect(store.getRun(run.id)?.status).toBe("active");
|
|
98
|
+
store.completeRun(run.id);
|
|
99
|
+
expect(store.getRun(run.id)?.status).toBe("completed");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("createSessionStore — sessions", () => {
|
|
104
|
+
let store: SessionStore;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
store = createSessionStore(":memory:");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
store.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("upsertSession inserts and getSession round-trips every field", () => {
|
|
115
|
+
const session = makeSession({
|
|
116
|
+
parentAgent: "lead-1",
|
|
117
|
+
depth: 2,
|
|
118
|
+
pid: 4242,
|
|
119
|
+
runtimeSessionId: "rt-123",
|
|
120
|
+
});
|
|
121
|
+
store.upsertSession(session);
|
|
122
|
+
const fetched = store.getSession(session.id);
|
|
123
|
+
expect(fetched).toEqual(session);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("upsertSession preserves null fields", () => {
|
|
127
|
+
const session = makeSession({
|
|
128
|
+
parentAgent: null,
|
|
129
|
+
pid: null,
|
|
130
|
+
runtimeSessionId: null,
|
|
131
|
+
});
|
|
132
|
+
store.upsertSession(session);
|
|
133
|
+
const fetched = store.getSession(session.id);
|
|
134
|
+
expect(fetched?.parentAgent).toBeNull();
|
|
135
|
+
expect(fetched?.pid).toBeNull();
|
|
136
|
+
expect(fetched?.runtimeSessionId).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("upsertSession on the same id updates fields but keeps startedAt", () => {
|
|
140
|
+
const original = makeSession({ state: "booting", capability: "scout" });
|
|
141
|
+
store.upsertSession(original);
|
|
142
|
+
|
|
143
|
+
// Re-upsert with the same id but a later startedAt and changed fields;
|
|
144
|
+
// startedAt must NOT be overwritten (sessions keep their birth time).
|
|
145
|
+
const mutated: AgentSession = {
|
|
146
|
+
...original,
|
|
147
|
+
state: "working",
|
|
148
|
+
capability: "builder",
|
|
149
|
+
pid: 99,
|
|
150
|
+
startedAt: new Date(Date.parse(original.startedAt) + 60_000).toISOString(),
|
|
151
|
+
lastActivity: new Date(Date.parse(original.lastActivity) + 60_000).toISOString(),
|
|
152
|
+
};
|
|
153
|
+
store.upsertSession(mutated);
|
|
154
|
+
|
|
155
|
+
const fetched = store.getSession(original.id);
|
|
156
|
+
expect(fetched?.state).toBe("working");
|
|
157
|
+
expect(fetched?.capability).toBe("builder");
|
|
158
|
+
expect(fetched?.pid).toBe(99);
|
|
159
|
+
expect(fetched?.startedAt).toBe(original.startedAt);
|
|
160
|
+
expect(fetched?.lastActivity).toBe(mutated.lastActivity);
|
|
161
|
+
|
|
162
|
+
// Still exactly one row for that id.
|
|
163
|
+
expect(store.listSessions().length).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("getSession returns null for an unknown id", () => {
|
|
167
|
+
expect(store.getSession("nope")).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("getSessionByAgent returns the most recent session for a name", () => {
|
|
171
|
+
const olderId = crypto.randomUUID();
|
|
172
|
+
const newerId = crypto.randomUUID();
|
|
173
|
+
const t0 = new Date().toISOString();
|
|
174
|
+
store.upsertSession(makeSession({ id: olderId, agentName: "dup", startedAt: t0 }));
|
|
175
|
+
const t1 = new Date(Date.parse(t0) + 1000).toISOString();
|
|
176
|
+
store.upsertSession(makeSession({ id: newerId, agentName: "dup", startedAt: t1 }));
|
|
177
|
+
|
|
178
|
+
const fetched = store.getSessionByAgent("dup");
|
|
179
|
+
expect(fetched?.id).toBe(newerId);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("getSessionByAgent returns null when no session matches", () => {
|
|
183
|
+
expect(store.getSessionByAgent("ghost")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("setRuntimeSessionId persists the id and bumps lastActivity", () => {
|
|
187
|
+
const past = new Date(Date.now() - 60_000).toISOString();
|
|
188
|
+
const session = makeSession({ runtimeSessionId: null, lastActivity: past, startedAt: past });
|
|
189
|
+
store.upsertSession(session);
|
|
190
|
+
|
|
191
|
+
store.setRuntimeSessionId(session.id, "rt-xyz");
|
|
192
|
+
const fetched = store.getSession(session.id);
|
|
193
|
+
expect(fetched?.runtimeSessionId).toBe("rt-xyz");
|
|
194
|
+
expect(Date.parse(fetched?.lastActivity ?? "")).toBeGreaterThan(Date.parse(past));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("touch refreshes lastActivity without changing state", () => {
|
|
198
|
+
const past = new Date(Date.now() - 60_000).toISOString();
|
|
199
|
+
const session = makeSession({ state: "idle", lastActivity: past, startedAt: past });
|
|
200
|
+
store.upsertSession(session);
|
|
201
|
+
|
|
202
|
+
store.touch(session.id);
|
|
203
|
+
const fetched = store.getSession(session.id);
|
|
204
|
+
expect(fetched?.state).toBe("idle");
|
|
205
|
+
expect(Date.parse(fetched?.lastActivity ?? "")).toBeGreaterThan(Date.parse(past));
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("createSessionStore — state transitions", () => {
|
|
210
|
+
let store: SessionStore;
|
|
211
|
+
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
store = createSessionStore(":memory:");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
afterEach(() => {
|
|
217
|
+
store.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("updateSessionState walks a session through its lifecycle", () => {
|
|
221
|
+
const past = new Date(Date.now() - 60_000).toISOString();
|
|
222
|
+
const session = makeSession({ state: "booting", lastActivity: past, startedAt: past });
|
|
223
|
+
store.upsertSession(session);
|
|
224
|
+
|
|
225
|
+
const order: SessionState[] = ["working", "idle", "working", "completed"];
|
|
226
|
+
for (const next of order) {
|
|
227
|
+
store.updateSessionState(session.id, next);
|
|
228
|
+
expect(store.getSession(session.id)?.state).toBe(next);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The transitions must also have advanced lastActivity past its seed.
|
|
232
|
+
const fetched = store.getSession(session.id);
|
|
233
|
+
expect(Date.parse(fetched?.lastActivity ?? "")).toBeGreaterThan(Date.parse(past));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("createSessionStore — listing and filtering", () => {
|
|
238
|
+
let store: SessionStore;
|
|
239
|
+
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
store = createSessionStore(":memory:");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
afterEach(() => {
|
|
245
|
+
store.close();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("listSessions orders by startedAt ascending and filters by run/state", () => {
|
|
249
|
+
const t = (ms: number) => new Date(Date.parse("2026-01-01T00:00:00.000Z") + ms).toISOString();
|
|
250
|
+
// run-a: one working, one completed. run-b: one working.
|
|
251
|
+
store.upsertSession(
|
|
252
|
+
makeSession({ runId: "run-a", state: "working", startedAt: t(0), agentName: "a1" }),
|
|
253
|
+
);
|
|
254
|
+
store.upsertSession(
|
|
255
|
+
makeSession({ runId: "run-a", state: "completed", startedAt: t(10), agentName: "a2" }),
|
|
256
|
+
);
|
|
257
|
+
store.upsertSession(
|
|
258
|
+
makeSession({ runId: "run-b", state: "working", startedAt: t(20), agentName: "b1" }),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// No filter: all three, ascending by startedAt.
|
|
262
|
+
const all = store.listSessions();
|
|
263
|
+
expect(all.map((s) => s.agentName)).toEqual(["a1", "a2", "b1"]);
|
|
264
|
+
|
|
265
|
+
// Filter by runId.
|
|
266
|
+
const runA = store.listSessions({ runId: "run-a" });
|
|
267
|
+
expect(runA.map((s) => s.agentName)).toEqual(["a1", "a2"]);
|
|
268
|
+
|
|
269
|
+
// Filter by state.
|
|
270
|
+
const working = store.listSessions({ state: "working" });
|
|
271
|
+
expect(working.map((s) => s.agentName).sort()).toEqual(["a1", "b1"]);
|
|
272
|
+
|
|
273
|
+
// Combined filter (AND).
|
|
274
|
+
const runAWorking = store.listSessions({ runId: "run-a", state: "working" });
|
|
275
|
+
expect(runAWorking.map((s) => s.agentName)).toEqual(["a1"]);
|
|
276
|
+
|
|
277
|
+
// A filter matching nothing returns an empty array.
|
|
278
|
+
expect(store.listSessions({ runId: "run-zzz" })).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("createSessionStore — countActive", () => {
|
|
283
|
+
let store: SessionStore;
|
|
284
|
+
|
|
285
|
+
beforeEach(() => {
|
|
286
|
+
store = createSessionStore(":memory:");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
store.close();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("countActive counts booting/working/idle and excludes terminal states", () => {
|
|
294
|
+
// run-1: booting, working, idle (3 active), plus completed/failed/stopped.
|
|
295
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "booting" }));
|
|
296
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "working" }));
|
|
297
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "idle" }));
|
|
298
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "completed" }));
|
|
299
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "failed" }));
|
|
300
|
+
store.upsertSession(makeSession({ runId: "run-1", state: "stopped" }));
|
|
301
|
+
// run-2: one working.
|
|
302
|
+
store.upsertSession(makeSession({ runId: "run-2", state: "working" }));
|
|
303
|
+
|
|
304
|
+
// Global active count across both runs: 3 + 1 = 4.
|
|
305
|
+
expect(store.countActive()).toBe(4);
|
|
306
|
+
// Per-run scoping.
|
|
307
|
+
expect(store.countActive("run-1")).toBe(3);
|
|
308
|
+
expect(store.countActive("run-2")).toBe(1);
|
|
309
|
+
// Unknown run -> 0.
|
|
310
|
+
expect(store.countActive("run-none")).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("countActive reflects state transitions", () => {
|
|
314
|
+
const s = makeSession({ runId: "run-x", state: "working" });
|
|
315
|
+
store.upsertSession(s);
|
|
316
|
+
expect(store.countActive("run-x")).toBe(1);
|
|
317
|
+
|
|
318
|
+
store.updateSessionState(s.id, "completed");
|
|
319
|
+
expect(store.countActive("run-x")).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("createSessionStore — file-backed database", () => {
|
|
324
|
+
let dir: string;
|
|
325
|
+
|
|
326
|
+
beforeEach(() => {
|
|
327
|
+
dir = mkdtempSync(join(tmpdir(), "agentplate-sessions-"));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
afterEach(() => {
|
|
331
|
+
rmSync(dir, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("creates the parent directory and persists across reopen", () => {
|
|
335
|
+
// Point at a NON-existent nested path to exercise the mkdir-parent logic.
|
|
336
|
+
const dbPath = join(dir, "nested", "sessions.db");
|
|
337
|
+
|
|
338
|
+
const first = createSessionStore(dbPath);
|
|
339
|
+
const run = first.createRun("persisted");
|
|
340
|
+
const session = makeSession({ runId: run.id, state: "working" });
|
|
341
|
+
first.upsertSession(session);
|
|
342
|
+
first.close();
|
|
343
|
+
|
|
344
|
+
// Reopen the same file: data must still be there.
|
|
345
|
+
const second = createSessionStore(dbPath);
|
|
346
|
+
expect(second.getRun(run.id)?.label).toBe("persisted");
|
|
347
|
+
expect(second.getSession(session.id)?.state).toBe("working");
|
|
348
|
+
expect(second.countActive(run.id)).toBe(1);
|
|
349
|
+
second.close();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed store for agent sessions and runs.
|
|
3
|
+
*
|
|
4
|
+
* Sessions track the lifecycle of every spawned agent; runs group together all
|
|
5
|
+
* the sessions started during one coordinator session. Both tables live in the
|
|
6
|
+
* same database (`.agentplate/sessions.db`) so a single WAL file covers the whole
|
|
7
|
+
* lifecycle and status-style commands read everything from one handle.
|
|
8
|
+
*
|
|
9
|
+
* This module exposes a *factory* (`createSessionStore`) rather than a class.
|
|
10
|
+
* The returned object is the public surface; the `Database` handle and the
|
|
11
|
+
* row<->record mapping stay private in the closure. All column<->field
|
|
12
|
+
* translation lives in one place (`rowToRun` / `rowToSession`) so the snake_case
|
|
13
|
+
* SQL schema never leaks into the rest of the codebase, which only ever sees the
|
|
14
|
+
* camelCase `RunRecord` / `AgentSession` shapes from `types.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
import { openDatabase } from "../db/sqlite.ts";
|
|
20
|
+
import type { AgentSession, Capability, RunRecord, SessionState } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sessions considered "active" for concurrency accounting. Mirrors the
|
|
24
|
+
* pre-terminal states of {@link SessionState}: an agent that is still booting,
|
|
25
|
+
* actively working, or idle (awaiting its next turn / nudge) counts against the
|
|
26
|
+
* fleet/per-run caps. Terminal states (completed/failed/stopped) do not.
|
|
27
|
+
*
|
|
28
|
+
* Kept here (not in types.ts, which we must not edit) as a `const` tuple so the
|
|
29
|
+
* compiler still checks the values against `SessionState` and `countActive`
|
|
30
|
+
* stays single-sourced.
|
|
31
|
+
*/
|
|
32
|
+
const ACTIVE_STATES: readonly SessionState[] = ["booting", "working", "idle"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Filter for {@link SessionStore.listSessions}. Both fields are optional and
|
|
36
|
+
* AND-combined; omit both to list every session. Local to this store (purely a
|
|
37
|
+
* query convenience), so it is defined here rather than in shared types.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionListFilter {
|
|
40
|
+
runId?: string;
|
|
41
|
+
state?: SessionState;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Public surface of the session/run store, returned by `createSessionStore`. */
|
|
45
|
+
export interface SessionStore {
|
|
46
|
+
// --- Runs ---
|
|
47
|
+
createRun(label?: string): RunRecord;
|
|
48
|
+
getRun(id: string): RunRecord | null;
|
|
49
|
+
listRuns(limit?: number): RunRecord[];
|
|
50
|
+
completeRun(id: string): void;
|
|
51
|
+
// --- Sessions ---
|
|
52
|
+
upsertSession(session: AgentSession): void;
|
|
53
|
+
getSession(id: string): AgentSession | null;
|
|
54
|
+
getSessionByAgent(agentName: string): AgentSession | null;
|
|
55
|
+
listSessions(filter?: SessionListFilter): AgentSession[];
|
|
56
|
+
updateSessionState(id: string, state: SessionState): void;
|
|
57
|
+
setRuntimeSessionId(id: string, runtimeSessionId: string): void;
|
|
58
|
+
touch(id: string): void;
|
|
59
|
+
countActive(runId?: string): number;
|
|
60
|
+
// --- Lifecycle ---
|
|
61
|
+
close(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// DDL is idempotent (`IF NOT EXISTS`). Nullable columns (no NOT NULL) mirror the
|
|
65
|
+
// `| null` fields in the record types so `null` round-trips faithfully. The
|
|
66
|
+
// `runs` table stores no `completed_at`: `RunRecord` has no such field, so
|
|
67
|
+
// completion is captured solely by flipping `status` to 'completed'.
|
|
68
|
+
const SCHEMA = `
|
|
69
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
70
|
+
id TEXT PRIMARY KEY,
|
|
71
|
+
created_at TEXT NOT NULL,
|
|
72
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
73
|
+
label TEXT
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
agent_name TEXT NOT NULL,
|
|
79
|
+
capability TEXT NOT NULL,
|
|
80
|
+
task_id TEXT NOT NULL,
|
|
81
|
+
run_id TEXT NOT NULL,
|
|
82
|
+
worktree_path TEXT NOT NULL,
|
|
83
|
+
branch_name TEXT NOT NULL,
|
|
84
|
+
state TEXT NOT NULL,
|
|
85
|
+
parent_agent TEXT,
|
|
86
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
pid INTEGER,
|
|
88
|
+
runtime_session_id TEXT,
|
|
89
|
+
started_at TEXT NOT NULL,
|
|
90
|
+
last_activity TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_run ON sessions(run_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_name);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state);
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
// Raw row shapes as returned by bun:sqlite (snake_case columns, nullable where
|
|
99
|
+
// the schema allows NULL). Mapped to camelCase records before leaving the module.
|
|
100
|
+
interface RunRow {
|
|
101
|
+
id: string;
|
|
102
|
+
created_at: string;
|
|
103
|
+
status: string;
|
|
104
|
+
label: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface SessionRow {
|
|
108
|
+
id: string;
|
|
109
|
+
agent_name: string;
|
|
110
|
+
capability: string;
|
|
111
|
+
task_id: string;
|
|
112
|
+
run_id: string;
|
|
113
|
+
worktree_path: string;
|
|
114
|
+
branch_name: string;
|
|
115
|
+
state: string;
|
|
116
|
+
parent_agent: string | null;
|
|
117
|
+
depth: number;
|
|
118
|
+
pid: number | null;
|
|
119
|
+
runtime_session_id: string | null;
|
|
120
|
+
started_at: string;
|
|
121
|
+
last_activity: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function rowToRun(row: RunRow): RunRecord {
|
|
125
|
+
// `status` is constrained to 'active' | 'completed' by our own writes; the DB
|
|
126
|
+
// column is plain TEXT so we narrow on read. `label` is optional on the record
|
|
127
|
+
// (omit the key entirely when the column is NULL rather than carrying `null`,
|
|
128
|
+
// since RunRecord.label is `string | undefined`, not nullable).
|
|
129
|
+
const record: RunRecord = {
|
|
130
|
+
id: row.id,
|
|
131
|
+
createdAt: row.created_at,
|
|
132
|
+
status: row.status as RunRecord["status"],
|
|
133
|
+
};
|
|
134
|
+
if (row.label !== null) record.label = row.label;
|
|
135
|
+
return record;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function rowToSession(row: SessionRow): AgentSession {
|
|
139
|
+
return {
|
|
140
|
+
id: row.id,
|
|
141
|
+
agentName: row.agent_name,
|
|
142
|
+
// `capability` is constrained to Capability by writers; narrow on read.
|
|
143
|
+
capability: row.capability as Capability,
|
|
144
|
+
taskId: row.task_id,
|
|
145
|
+
runId: row.run_id,
|
|
146
|
+
worktreePath: row.worktree_path,
|
|
147
|
+
branchName: row.branch_name,
|
|
148
|
+
state: row.state as SessionState,
|
|
149
|
+
parentAgent: row.parent_agent,
|
|
150
|
+
depth: row.depth,
|
|
151
|
+
pid: row.pid,
|
|
152
|
+
runtimeSessionId: row.runtime_session_id,
|
|
153
|
+
startedAt: row.started_at,
|
|
154
|
+
lastActivity: row.last_activity,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// SQL IN-list of active states, built from the single ACTIVE_STATES source.
|
|
159
|
+
// Values are hard-coded literals (no user input), so quoting them is safe; we
|
|
160
|
+
// still derive the list programmatically so it never drifts from the constant.
|
|
161
|
+
const ACTIVE_STATES_SQL = `(${ACTIVE_STATES.map((s) => `'${s}'`).join(", ")})`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Open (or create) the session/run store at `dbPath`.
|
|
165
|
+
*
|
|
166
|
+
* Pass `":memory:"` for an ephemeral in-process database (tests). For a file
|
|
167
|
+
* path the parent directory is created if missing, so callers don't have to
|
|
168
|
+
* pre-make `.agentplate/` themselves.
|
|
169
|
+
*/
|
|
170
|
+
export function createSessionStore(dbPath: string): SessionStore {
|
|
171
|
+
// openDatabase (bun:sqlite under it) will not create intermediate
|
|
172
|
+
// directories, so ensure the parent exists for file-backed databases.
|
|
173
|
+
if (dbPath !== ":memory:") {
|
|
174
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
// Guard on `runs.created_at`: the unrelated @ag-eco/agentplate-cli creates a
|
|
177
|
+
// `runs` table with `started_at` instead, which would break our inserts.
|
|
178
|
+
const db = openDatabase(dbPath, { guard: { table: "runs", columns: ["created_at"] } });
|
|
179
|
+
db.exec(SCHEMA);
|
|
180
|
+
|
|
181
|
+
// --- Runs ---
|
|
182
|
+
|
|
183
|
+
function createRun(label?: string): RunRecord {
|
|
184
|
+
// Short, human-scannable id: `run-` + first 8 hex chars of a UUID.
|
|
185
|
+
// Collision risk across a single project's run history is negligible.
|
|
186
|
+
const id = `run-${crypto.randomUUID().slice(0, 8)}`;
|
|
187
|
+
const createdAt = new Date().toISOString();
|
|
188
|
+
db.query("INSERT INTO runs (id, created_at, status, label) VALUES (?, ?, 'active', ?)").run(
|
|
189
|
+
id,
|
|
190
|
+
createdAt,
|
|
191
|
+
label ?? null,
|
|
192
|
+
);
|
|
193
|
+
const record: RunRecord = { id, createdAt, status: "active" };
|
|
194
|
+
if (label !== undefined) record.label = label;
|
|
195
|
+
return record;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getRun(id: string): RunRecord | null {
|
|
199
|
+
const row = db.query("SELECT * FROM runs WHERE id = ?").get(id) as RunRow | null;
|
|
200
|
+
return row ? rowToRun(row) : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function listRuns(limit = 50): RunRecord[] {
|
|
204
|
+
// Newest first: the orchestrator usually cares about the current/last run.
|
|
205
|
+
const rows = db
|
|
206
|
+
.query("SELECT * FROM runs ORDER BY created_at DESC LIMIT ?")
|
|
207
|
+
.all(limit) as RunRow[];
|
|
208
|
+
return rows.map(rowToRun);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function completeRun(id: string): void {
|
|
212
|
+
// RunRecord has no completedAt; completion is captured by status alone.
|
|
213
|
+
db.query("UPDATE runs SET status = 'completed' WHERE id = ?").run(id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Sessions ---
|
|
217
|
+
|
|
218
|
+
function upsertSession(session: AgentSession): void {
|
|
219
|
+
// Idempotent upsert keyed on the session id. `started_at` is intentionally
|
|
220
|
+
// NOT overwritten on conflict (a session keeps its original birth time);
|
|
221
|
+
// every other field, including `last_activity`, reflects the latest write.
|
|
222
|
+
db.query(
|
|
223
|
+
`INSERT INTO sessions (
|
|
224
|
+
id, agent_name, capability, task_id, run_id, worktree_path, branch_name,
|
|
225
|
+
state, parent_agent, depth, pid, runtime_session_id, started_at, last_activity
|
|
226
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
227
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
228
|
+
agent_name = excluded.agent_name,
|
|
229
|
+
capability = excluded.capability,
|
|
230
|
+
task_id = excluded.task_id,
|
|
231
|
+
run_id = excluded.run_id,
|
|
232
|
+
worktree_path = excluded.worktree_path,
|
|
233
|
+
branch_name = excluded.branch_name,
|
|
234
|
+
state = excluded.state,
|
|
235
|
+
parent_agent = excluded.parent_agent,
|
|
236
|
+
depth = excluded.depth,
|
|
237
|
+
pid = excluded.pid,
|
|
238
|
+
runtime_session_id = excluded.runtime_session_id,
|
|
239
|
+
last_activity = excluded.last_activity`,
|
|
240
|
+
).run(
|
|
241
|
+
session.id,
|
|
242
|
+
session.agentName,
|
|
243
|
+
session.capability,
|
|
244
|
+
session.taskId,
|
|
245
|
+
session.runId,
|
|
246
|
+
session.worktreePath,
|
|
247
|
+
session.branchName,
|
|
248
|
+
session.state,
|
|
249
|
+
session.parentAgent,
|
|
250
|
+
session.depth,
|
|
251
|
+
session.pid,
|
|
252
|
+
session.runtimeSessionId,
|
|
253
|
+
session.startedAt,
|
|
254
|
+
session.lastActivity,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getSession(id: string): AgentSession | null {
|
|
259
|
+
const row = db.query("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow | null;
|
|
260
|
+
return row ? rowToSession(row) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getSessionByAgent(agentName: string): AgentSession | null {
|
|
264
|
+
// Agent names can be reused across runs; return the most recent session for
|
|
265
|
+
// that name so callers see the live one.
|
|
266
|
+
const row = db
|
|
267
|
+
.query("SELECT * FROM sessions WHERE agent_name = ? ORDER BY started_at DESC LIMIT 1")
|
|
268
|
+
.get(agentName) as SessionRow | null;
|
|
269
|
+
return row ? rowToSession(row) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function listSessions(filter?: SessionListFilter): AgentSession[] {
|
|
273
|
+
// Build the WHERE clause dynamically; params stay positional so values are
|
|
274
|
+
// always parameterized (no string interpolation of user-supplied data).
|
|
275
|
+
const clauses: string[] = [];
|
|
276
|
+
const params: string[] = [];
|
|
277
|
+
if (filter?.runId !== undefined) {
|
|
278
|
+
clauses.push("run_id = ?");
|
|
279
|
+
params.push(filter.runId);
|
|
280
|
+
}
|
|
281
|
+
if (filter?.state !== undefined) {
|
|
282
|
+
clauses.push("state = ?");
|
|
283
|
+
params.push(filter.state);
|
|
284
|
+
}
|
|
285
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
286
|
+
const rows = db
|
|
287
|
+
.query(`SELECT * FROM sessions ${where} ORDER BY started_at ASC`)
|
|
288
|
+
.all(...params) as SessionRow[];
|
|
289
|
+
return rows.map(rowToSession);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function updateSessionState(id: string, state: SessionState): void {
|
|
293
|
+
// A state change is, by definition, activity — bump last_activity too.
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
db.query("UPDATE sessions SET state = ?, last_activity = ? WHERE id = ?").run(state, now, id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setRuntimeSessionId(id: string, runtimeSessionId: string): void {
|
|
299
|
+
// Learning the runtime session id (e.g. for `--resume`) counts as activity.
|
|
300
|
+
const now = new Date().toISOString();
|
|
301
|
+
db.query("UPDATE sessions SET runtime_session_id = ?, last_activity = ? WHERE id = ?").run(
|
|
302
|
+
runtimeSessionId,
|
|
303
|
+
now,
|
|
304
|
+
id,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function touch(id: string): void {
|
|
309
|
+
// Pure heartbeat: refresh last_activity without changing any other field.
|
|
310
|
+
const now = new Date().toISOString();
|
|
311
|
+
db.query("UPDATE sessions SET last_activity = ? WHERE id = ?").run(now, id);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function countActive(runId?: string): number {
|
|
315
|
+
// Active = booting | working | idle (see ACTIVE_STATES). Used by schedulers
|
|
316
|
+
// to enforce per-run / global concurrency caps.
|
|
317
|
+
if (runId !== undefined) {
|
|
318
|
+
const row = db
|
|
319
|
+
.query(
|
|
320
|
+
`SELECT COUNT(*) AS n FROM sessions WHERE run_id = ? AND state IN ${ACTIVE_STATES_SQL}`,
|
|
321
|
+
)
|
|
322
|
+
.get(runId) as { n: number };
|
|
323
|
+
return row.n;
|
|
324
|
+
}
|
|
325
|
+
const row = db
|
|
326
|
+
.query(`SELECT COUNT(*) AS n FROM sessions WHERE state IN ${ACTIVE_STATES_SQL}`)
|
|
327
|
+
.get() as { n: number };
|
|
328
|
+
return row.n;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function close(): void {
|
|
332
|
+
db.close();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
createRun,
|
|
337
|
+
getRun,
|
|
338
|
+
listRuns,
|
|
339
|
+
completeRun,
|
|
340
|
+
upsertSession,
|
|
341
|
+
getSession,
|
|
342
|
+
getSessionByAgent,
|
|
343
|
+
listSessions,
|
|
344
|
+
updateSessionState,
|
|
345
|
+
setRuntimeSessionId,
|
|
346
|
+
touch,
|
|
347
|
+
countActive,
|
|
348
|
+
close,
|
|
349
|
+
};
|
|
350
|
+
}
|