@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,433 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DEFAULT_CONFIG, serializeConfig } from "../config.ts";
|
|
6
|
+
import { createEventStore } from "../events/store.ts";
|
|
7
|
+
import { currentRunPath, eventsDbPath, sessionsDbPath } from "../paths.ts";
|
|
8
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
9
|
+
import type { AgentSession } from "../types.ts";
|
|
10
|
+
import { matchRoute, resolveRoute } from "./api.ts";
|
|
11
|
+
import { type ServeHandle, startServer } from "./server.ts";
|
|
12
|
+
|
|
13
|
+
let root: string;
|
|
14
|
+
let handle: ServeHandle;
|
|
15
|
+
|
|
16
|
+
function initProject(): void {
|
|
17
|
+
mkdirSync(join(root, ".agentplate"), { recursive: true });
|
|
18
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
19
|
+
config.project.name = "serve-test";
|
|
20
|
+
config.project.root = root;
|
|
21
|
+
writeFileSync(join(root, ".agentplate", "config.yaml"), serializeConfig(config), "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-serve-"));
|
|
26
|
+
initProject();
|
|
27
|
+
// Port 0 → an ephemeral free port chosen by the OS.
|
|
28
|
+
handle = startServer({ root, port: 0, host: "127.0.0.1", uiDir: join(root, "no-ui") });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
handle.stop();
|
|
33
|
+
rmSync(root, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("route matching", () => {
|
|
37
|
+
test("matchRoute captures params", () => {
|
|
38
|
+
expect(matchRoute("/api/agents/:name", "/api/agents/builder-1")).toEqual({ name: "builder-1" });
|
|
39
|
+
expect(matchRoute("/api/agents/:name", "/api/agents")).toBeNull();
|
|
40
|
+
expect(matchRoute("/api/overview", "/api/overview")).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("resolveRoute finds known routes", () => {
|
|
44
|
+
expect(resolveRoute("/api/overview")?.route.pattern).toBe("/api/overview");
|
|
45
|
+
expect(resolveRoute("/api/nope")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("http server", () => {
|
|
50
|
+
test("GET /healthz returns ok", async () => {
|
|
51
|
+
const res = await fetch(`${handle.url}/healthz`);
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
const body = (await res.json()) as { ok: boolean; data: { status: string } };
|
|
54
|
+
expect(body.ok).toBe(true);
|
|
55
|
+
expect(body.data.status).toBe("ok");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("GET /api/overview returns the project summary envelope", async () => {
|
|
59
|
+
const res = await fetch(`${handle.url}/api/overview`);
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
const body = (await res.json()) as { ok: boolean; data: { project: string } };
|
|
62
|
+
expect(body.ok).toBe(true);
|
|
63
|
+
expect(body.data.project).toBe("serve-test");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("GET /api/agents returns an array envelope", async () => {
|
|
67
|
+
const res = await fetch(`${handle.url}/api/agents`);
|
|
68
|
+
const body = (await res.json()) as { ok: boolean; data: unknown[] };
|
|
69
|
+
expect(body.ok).toBe(true);
|
|
70
|
+
expect(Array.isArray(body.data)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("GET /api/deploy/targets includes docker-gha", async () => {
|
|
74
|
+
const res = await fetch(`${handle.url}/api/deploy/targets`);
|
|
75
|
+
const body = (await res.json()) as { ok: boolean; data: Array<{ id: string }> };
|
|
76
|
+
expect(body.data.some((t) => t.id === "docker-gha")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("unknown /api route 404s", async () => {
|
|
80
|
+
const res = await fetch(`${handle.url}/api/does-not-exist`);
|
|
81
|
+
expect(res.status).toBe(404);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("static fallback returns 503 when UI is not built", async () => {
|
|
85
|
+
const res = await fetch(`${handle.url}/`);
|
|
86
|
+
expect(res.status).toBe(503);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("GET /api/feed returns an array envelope", async () => {
|
|
90
|
+
const res = await fetch(`${handle.url}/api/feed`);
|
|
91
|
+
const body = (await res.json()) as { ok: boolean; data: unknown[] };
|
|
92
|
+
expect(body.ok).toBe(true);
|
|
93
|
+
expect(Array.isArray(body.data)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("GET /api/system returns real host metrics", async () => {
|
|
97
|
+
const res = await fetch(`${handle.url}/api/system`);
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
const body = (await res.json()) as {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
data: { cpu: { cores: number; percent: number }; memory: { percent: number } };
|
|
102
|
+
};
|
|
103
|
+
expect(body.ok).toBe(true);
|
|
104
|
+
expect(body.data.cpu.cores).toBeGreaterThan(0);
|
|
105
|
+
expect(body.data.memory.percent).toBeGreaterThanOrEqual(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("GET /api/costs returns a (possibly empty) report", async () => {
|
|
109
|
+
const res = await fetch(`${handle.url}/api/costs`);
|
|
110
|
+
const body = (await res.json()) as {
|
|
111
|
+
ok: boolean;
|
|
112
|
+
data: { hasData: boolean; daily: unknown[]; byAgent: unknown[] };
|
|
113
|
+
};
|
|
114
|
+
expect(body.ok).toBe(true);
|
|
115
|
+
expect(typeof body.data.hasData).toBe("boolean");
|
|
116
|
+
expect(Array.isArray(body.data.daily)).toBe(true);
|
|
117
|
+
expect(Array.isArray(body.data.byAgent)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("feed items carry a compact label + level (terminal-feed style)", async () => {
|
|
121
|
+
const mod = await import("../mail/client.ts");
|
|
122
|
+
const client = mod.createMailClient(root);
|
|
123
|
+
client.send({
|
|
124
|
+
from: "builder-9",
|
|
125
|
+
to: "lead",
|
|
126
|
+
subject: "done",
|
|
127
|
+
body: "ok",
|
|
128
|
+
type: "worker_done",
|
|
129
|
+
});
|
|
130
|
+
client.close();
|
|
131
|
+
const res = await fetch(`${handle.url}/api/feed`);
|
|
132
|
+
const body = (await res.json()) as {
|
|
133
|
+
data: Array<{ kind: string; label: string; level: string }>;
|
|
134
|
+
};
|
|
135
|
+
const mailItem = body.data.find((f) => f.kind === "mail");
|
|
136
|
+
expect(mailItem).toBeDefined();
|
|
137
|
+
expect(typeof mailItem?.label).toBe("string");
|
|
138
|
+
expect((mailItem?.label.length ?? 0) > 0).toBe(true);
|
|
139
|
+
expect(["info", "warn", "error"]).toContain(mailItem?.level ?? "");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("GET /api/handoffs returns only protocol handoff mail", async () => {
|
|
143
|
+
// Seed a handoff (worker_done) + a non-handoff (status) message.
|
|
144
|
+
const mod = await import("../mail/client.ts");
|
|
145
|
+
const client = mod.createMailClient(root);
|
|
146
|
+
client.send({
|
|
147
|
+
from: "builder-1",
|
|
148
|
+
to: "lead",
|
|
149
|
+
subject: "done",
|
|
150
|
+
body: "ok",
|
|
151
|
+
type: "worker_done",
|
|
152
|
+
});
|
|
153
|
+
client.send({ from: "operator", to: "coordinator", subject: "hi", body: "x", type: "status" });
|
|
154
|
+
client.close();
|
|
155
|
+
|
|
156
|
+
const res = await fetch(`${handle.url}/api/handoffs`);
|
|
157
|
+
const body = (await res.json()) as { ok: boolean; data: Array<{ type: string }> };
|
|
158
|
+
expect(body.ok).toBe(true);
|
|
159
|
+
expect(body.data.length).toBeGreaterThan(0);
|
|
160
|
+
expect(body.data.every((h) => h.type !== "status")).toBe(true);
|
|
161
|
+
expect(body.data.some((h) => h.type === "worker_done")).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("GET /api/tasks derives tasks from specs + sessions with rolled-up status", async () => {
|
|
165
|
+
// Seed a spec file (a pending task with no session yet).
|
|
166
|
+
const specsDir = join(root, ".agentplate", "specs");
|
|
167
|
+
mkdirSync(specsDir, { recursive: true });
|
|
168
|
+
writeFileSync(join(specsDir, "TASK-1.md"), "# Task TASK-1\n\nBuild the login form\n", "utf8");
|
|
169
|
+
|
|
170
|
+
const res = await fetch(`${handle.url}/api/tasks`);
|
|
171
|
+
const body = (await res.json()) as {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
data: Array<{ taskId: string; status: string; summary: string | null }>;
|
|
174
|
+
};
|
|
175
|
+
expect(body.ok).toBe(true);
|
|
176
|
+
const t = body.data.find((x) => x.taskId === "TASK-1");
|
|
177
|
+
expect(t).toBeDefined();
|
|
178
|
+
expect(t?.status).toBe("pending"); // spec exists, no session yet
|
|
179
|
+
expect(t?.summary).toBe("Build the login form");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("GET /api/agents/:name returns enriched detail (session, events, inbox, sent, children)", async () => {
|
|
183
|
+
const res = await fetch(`${handle.url}/api/agents/coordinator`);
|
|
184
|
+
const body = (await res.json()) as {
|
|
185
|
+
ok: boolean;
|
|
186
|
+
data: { events: unknown[]; inbox: unknown[]; sent: unknown[]; children: unknown[] };
|
|
187
|
+
};
|
|
188
|
+
expect(body.ok).toBe(true);
|
|
189
|
+
expect(Array.isArray(body.data.events)).toBe(true);
|
|
190
|
+
expect(Array.isArray(body.data.inbox)).toBe(true);
|
|
191
|
+
expect(Array.isArray(body.data.sent)).toBe(true);
|
|
192
|
+
expect(Array.isArray(body.data.children)).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("write endpoints", () => {
|
|
197
|
+
test("POST /api/chat messages the coordinator and shows in the feed", async () => {
|
|
198
|
+
const res = await fetch(`${handle.url}/api/chat`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "content-type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ message: "build me a todo app" }),
|
|
202
|
+
});
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
const body = (await res.json()) as { ok: boolean; data: { sent: { to: string } } };
|
|
205
|
+
expect(body.ok).toBe(true);
|
|
206
|
+
expect(body.data.sent.to).toBe("coordinator");
|
|
207
|
+
|
|
208
|
+
// The message appears in the unified feed.
|
|
209
|
+
const feedRes = await fetch(`${handle.url}/api/feed`);
|
|
210
|
+
const feed = (await feedRes.json()) as { data: Array<{ kind: string; summary: string }> };
|
|
211
|
+
expect(feed.data.some((f) => f.kind === "mail" && f.summary.includes("coordinator"))).toBe(
|
|
212
|
+
true,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("POST /api/chat rejects an empty message (400)", async () => {
|
|
217
|
+
const res = await fetch(`${handle.url}/api/chat`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ message: " " }),
|
|
221
|
+
});
|
|
222
|
+
expect(res.status).toBe(400);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("POST /api/tasks writes a spec and accepts the task", async () => {
|
|
226
|
+
const res = await fetch(`${handle.url}/api/tasks`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify({ prompt: "add a health endpoint", capability: "builder" }),
|
|
230
|
+
});
|
|
231
|
+
expect(res.status).toBe(200);
|
|
232
|
+
const body = (await res.json()) as { ok: boolean; data: { accepted: boolean; taskId: string } };
|
|
233
|
+
expect(body.ok).toBe(true);
|
|
234
|
+
expect(body.data.accepted).toBe(true);
|
|
235
|
+
// The spec file was written.
|
|
236
|
+
const { existsSync } = await import("node:fs");
|
|
237
|
+
expect(existsSync(join(root, ".agentplate", "specs", `${body.data.taskId}.md`))).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("GET on a write-only path is a 404 (not found in GET table)", async () => {
|
|
241
|
+
const res = await fetch(`${handle.url}/api/chat`);
|
|
242
|
+
expect(res.status).toBe(404);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("agents run scoping", () => {
|
|
247
|
+
// Seed two runs so we can prove the live view is scoped to the active run.
|
|
248
|
+
function seed(): { current: string; old: string } {
|
|
249
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
250
|
+
try {
|
|
251
|
+
const oldRun = store.createRun("old");
|
|
252
|
+
const curRun = store.createRun("current");
|
|
253
|
+
const mk = (name: string, runId: string): AgentSession => ({
|
|
254
|
+
id: `session-${name}`,
|
|
255
|
+
agentName: name,
|
|
256
|
+
capability: "builder",
|
|
257
|
+
taskId: "t1",
|
|
258
|
+
runId,
|
|
259
|
+
worktreePath: root,
|
|
260
|
+
branchName: "b",
|
|
261
|
+
state: "idle",
|
|
262
|
+
parentAgent: null,
|
|
263
|
+
depth: 0,
|
|
264
|
+
pid: null,
|
|
265
|
+
runtimeSessionId: null,
|
|
266
|
+
startedAt: new Date().toISOString(),
|
|
267
|
+
lastActivity: new Date().toISOString(),
|
|
268
|
+
});
|
|
269
|
+
store.upsertSession(mk("old-a", oldRun.id));
|
|
270
|
+
store.upsertSession(mk("cur-a", curRun.id));
|
|
271
|
+
store.upsertSession(mk("cur-b", curRun.id));
|
|
272
|
+
// current-run.txt is authoritative — point it at the older run on purpose
|
|
273
|
+
// to prove the active run is read from the file, not "newest in the store".
|
|
274
|
+
writeFileSync(currentRunPath(root), `${oldRun.id}\n`, "utf8");
|
|
275
|
+
return { current: oldRun.id, old: curRun.id };
|
|
276
|
+
} finally {
|
|
277
|
+
store.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function agentNames(query: string): Promise<string[]> {
|
|
282
|
+
const res = await fetch(`${handle.url}/api/agents${query}`);
|
|
283
|
+
const body = (await res.json()) as { data: Array<{ agentName: string }> };
|
|
284
|
+
return body.data.map((a) => a.agentName).sort();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
test("/api/agents scopes to the active run from current-run.txt", async () => {
|
|
288
|
+
const { current } = seed();
|
|
289
|
+
// Active run (per the file) is the one holding only old-a.
|
|
290
|
+
expect(await agentNames("")).toEqual(["old-a"]);
|
|
291
|
+
expect(await agentNames(`?run=${current}`)).toEqual(["old-a"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("/api/agents?all=1 returns every run; ?run= targets a specific run", async () => {
|
|
295
|
+
const { old } = seed();
|
|
296
|
+
expect(await agentNames("?all=1")).toEqual(["cur-a", "cur-b", "old-a"]);
|
|
297
|
+
expect(await agentNames(`?run=${old}`)).toEqual(["cur-a", "cur-b"]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("costs aggregation", () => {
|
|
302
|
+
test("/api/costs sums per-turn token/cost events by agent and reports hasData", async () => {
|
|
303
|
+
const events = createEventStore(eventsDbPath(root));
|
|
304
|
+
try {
|
|
305
|
+
const usage = (tokens: number, cost: number) => JSON.stringify({ tokens, cost });
|
|
306
|
+
events.record({
|
|
307
|
+
agentName: "builder-1",
|
|
308
|
+
runId: "r1",
|
|
309
|
+
type: "result",
|
|
310
|
+
detail: usage(100, 0.02),
|
|
311
|
+
});
|
|
312
|
+
events.record({
|
|
313
|
+
agentName: "builder-1",
|
|
314
|
+
runId: "r1",
|
|
315
|
+
type: "result",
|
|
316
|
+
detail: usage(50, 0.01),
|
|
317
|
+
});
|
|
318
|
+
events.record({
|
|
319
|
+
agentName: "scout-1",
|
|
320
|
+
runId: "r1",
|
|
321
|
+
type: "result",
|
|
322
|
+
detail: usage(30, 0.005),
|
|
323
|
+
});
|
|
324
|
+
// Non-usage events must be ignored.
|
|
325
|
+
events.record({ agentName: "scout-1", runId: "r1", type: "assistant", detail: null });
|
|
326
|
+
} finally {
|
|
327
|
+
events.close();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const res = await fetch(`${handle.url}/api/costs`);
|
|
331
|
+
const body = (await res.json()) as {
|
|
332
|
+
data: {
|
|
333
|
+
hasData: boolean;
|
|
334
|
+
totalTokens: number;
|
|
335
|
+
totalCostUsd: number;
|
|
336
|
+
byAgent: Array<{ agent: string; tokens: number; costUsd: number }>;
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
expect(body.data.hasData).toBe(true);
|
|
340
|
+
expect(body.data.totalTokens).toBe(180);
|
|
341
|
+
expect(body.data.totalCostUsd).toBeCloseTo(0.035, 4);
|
|
342
|
+
// Highest spender first; builder-1's two turns are merged.
|
|
343
|
+
expect(body.data.byAgent[0]).toEqual({ agent: "builder-1", tokens: 150, costUsd: 0.03 });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("/api/costs reports hasData:false when no usage has been recorded", async () => {
|
|
347
|
+
const res = await fetch(`${handle.url}/api/costs`);
|
|
348
|
+
const body = (await res.json()) as { data: { hasData: boolean; totalTokens: number } };
|
|
349
|
+
expect(body.data.hasData).toBe(false);
|
|
350
|
+
expect(body.data.totalTokens).toBe(0);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("idle reaper (serve loop)", () => {
|
|
355
|
+
test("a long-idle worker is auto-reaped to stopped; the coordinator is spared", async () => {
|
|
356
|
+
// Seed a stale worker (11m idle) and a coordinator (older, but excluded).
|
|
357
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
358
|
+
const stale = new Date(Date.now() - 11 * 60_000).toISOString();
|
|
359
|
+
const mk = (name: string, capability: AgentSession["capability"]): AgentSession => ({
|
|
360
|
+
id: `session-${name}`,
|
|
361
|
+
agentName: name,
|
|
362
|
+
capability,
|
|
363
|
+
taskId: "t",
|
|
364
|
+
runId: "r1",
|
|
365
|
+
// Outside .agentplate/worktrees so the reaper skips git removal here.
|
|
366
|
+
worktreePath: join(root, "not-managed", name),
|
|
367
|
+
branchName: `agentplate/${name}`,
|
|
368
|
+
state: capability === "coordinator" ? "working" : "idle",
|
|
369
|
+
parentAgent: null,
|
|
370
|
+
depth: capability === "coordinator" ? 0 : 1,
|
|
371
|
+
pid: null,
|
|
372
|
+
runtimeSessionId: null,
|
|
373
|
+
startedAt: stale,
|
|
374
|
+
lastActivity: stale,
|
|
375
|
+
});
|
|
376
|
+
try {
|
|
377
|
+
store.createRun("r1");
|
|
378
|
+
store.upsertSession(mk("builder-old", "builder"));
|
|
379
|
+
store.upsertSession(mk("coordinator", "coordinator"));
|
|
380
|
+
} finally {
|
|
381
|
+
store.close();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// A second server on the same root with a fast reap sweep (default 10m
|
|
385
|
+
// timeout from config; the 11m-idle worker qualifies immediately).
|
|
386
|
+
const reaper = startServer({
|
|
387
|
+
root,
|
|
388
|
+
port: 0,
|
|
389
|
+
host: "127.0.0.1",
|
|
390
|
+
uiDir: join(root, "no-ui"),
|
|
391
|
+
reapIntervalMs: 40,
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
let workerState = "";
|
|
395
|
+
for (let i = 0; i < 60; i++) {
|
|
396
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
397
|
+
const s = createSessionStore(sessionsDbPath(root));
|
|
398
|
+
workerState = s.getSessionByAgent("builder-old")?.state ?? "";
|
|
399
|
+
const coordState = s.getSessionByAgent("coordinator")?.state ?? "";
|
|
400
|
+
s.close();
|
|
401
|
+
if (workerState === "stopped") {
|
|
402
|
+
expect(coordState).toBe("working"); // coordinator never reaped
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
expect(workerState).toBe("stopped");
|
|
407
|
+
} finally {
|
|
408
|
+
reaper.stop();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("websocket", () => {
|
|
414
|
+
test("a connecting client receives a snapshot frame", async () => {
|
|
415
|
+
const wsUrl = handle.url.replace("http", "ws");
|
|
416
|
+
const ws = new WebSocket(`${wsUrl}/ws`);
|
|
417
|
+
const frame = await new Promise<string>((resolve, reject) => {
|
|
418
|
+
const timer = setTimeout(() => reject(new Error("no frame")), 3000);
|
|
419
|
+
ws.onmessage = (ev) => {
|
|
420
|
+
clearTimeout(timer);
|
|
421
|
+
resolve(typeof ev.data === "string" ? ev.data : "");
|
|
422
|
+
};
|
|
423
|
+
ws.onerror = () => {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
reject(new Error("ws error"));
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
ws.close();
|
|
429
|
+
const parsed = JSON.parse(frame) as { type: string; overview: { project: string } };
|
|
430
|
+
expect(parsed.type).toBe("snapshot");
|
|
431
|
+
expect(parsed.overview.project).toBe("serve-test");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP + WebSocket server for the web UI, built on Bun's native `Bun.serve`
|
|
3
|
+
* (no server dependencies).
|
|
4
|
+
*
|
|
5
|
+
* Routes:
|
|
6
|
+
* GET /healthz → liveness JSON
|
|
7
|
+
* GET /api/* → REST handlers (see api.ts), wrapped in the JSON envelope
|
|
8
|
+
* GET /ws → WebSocket; pushes a periodic `overview`+`agents` snapshot
|
|
9
|
+
* GET /* → static SPA from ui/dist with index.html fallback
|
|
10
|
+
*
|
|
11
|
+
* The WS broadcaster polls the stores on an interval and sends snapshots to all
|
|
12
|
+
* connected clients — simple, store-agnostic, and good enough for a local
|
|
13
|
+
* operator dashboard without a change-feed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join, normalize } from "node:path";
|
|
18
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
19
|
+
import { loadConfig } from "../config.ts";
|
|
20
|
+
import { isAgentplateError } from "../errors.ts";
|
|
21
|
+
import { jsonSuccess } from "../json.ts";
|
|
22
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
23
|
+
import { reapIdleSessions } from "../sessions/reaper.ts";
|
|
24
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
25
|
+
import {
|
|
26
|
+
type ApiContext,
|
|
27
|
+
buildFeed,
|
|
28
|
+
buildStatusCounts,
|
|
29
|
+
buildTasks,
|
|
30
|
+
postChat,
|
|
31
|
+
postTask,
|
|
32
|
+
resolveRoute,
|
|
33
|
+
} from "./api.ts";
|
|
34
|
+
import { collectSystemMetrics } from "./system.ts";
|
|
35
|
+
import { fetchWeather } from "./weather.ts";
|
|
36
|
+
|
|
37
|
+
/** How often the serve loop sweeps for idle agents to reap (ms). */
|
|
38
|
+
const REAP_INTERVAL_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
export interface ServeOptions {
|
|
41
|
+
root: string;
|
|
42
|
+
port: number;
|
|
43
|
+
host: string;
|
|
44
|
+
/** Directory holding the built SPA (ui/dist). */
|
|
45
|
+
uiDir: string;
|
|
46
|
+
/** WS snapshot interval in ms (default 5000 — the standard refresh cadence). */
|
|
47
|
+
wsIntervalMs?: number;
|
|
48
|
+
/** Idle-reaper sweep interval in ms (default 60000). */
|
|
49
|
+
reapIntervalMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ServeHandle {
|
|
53
|
+
server: Server<WsData>;
|
|
54
|
+
stop: () => void;
|
|
55
|
+
url: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface WsData {
|
|
59
|
+
room: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
63
|
+
".html": "text/html; charset=utf-8",
|
|
64
|
+
".js": "text/javascript; charset=utf-8",
|
|
65
|
+
".css": "text/css; charset=utf-8",
|
|
66
|
+
".json": "application/json; charset=utf-8",
|
|
67
|
+
".svg": "image/svg+xml",
|
|
68
|
+
".ico": "image/x-icon",
|
|
69
|
+
".png": "image/png",
|
|
70
|
+
".woff2": "font/woff2",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function contentTypeFor(path: string): string {
|
|
74
|
+
const dot = path.lastIndexOf(".");
|
|
75
|
+
const ext = dot >= 0 ? path.slice(dot) : "";
|
|
76
|
+
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
80
|
+
return new Response(`${JSON.stringify(data)}\n`, {
|
|
81
|
+
status,
|
|
82
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Serve a static file from `uiDir`, with SPA fallback to index.html. Guards
|
|
88
|
+
* against path traversal by normalizing and confining to uiDir.
|
|
89
|
+
*/
|
|
90
|
+
async function serveStatic(uiDir: string, pathname: string): Promise<Response> {
|
|
91
|
+
if (!existsSync(uiDir)) {
|
|
92
|
+
return new Response(
|
|
93
|
+
"Agentplate UI is not built. Run `bun run build:ui` (or use the CLI / TUI).",
|
|
94
|
+
{
|
|
95
|
+
status: 503,
|
|
96
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const rel = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
101
|
+
let filePath = join(uiDir, rel);
|
|
102
|
+
if (!filePath.startsWith(uiDir)) filePath = join(uiDir, "index.html");
|
|
103
|
+
if (!existsSync(filePath) || pathname === "/") {
|
|
104
|
+
filePath = join(uiDir, "index.html");
|
|
105
|
+
}
|
|
106
|
+
const file = Bun.file(filePath);
|
|
107
|
+
if (!(await file.exists())) {
|
|
108
|
+
// SPA fallback.
|
|
109
|
+
const index = Bun.file(join(uiDir, "index.html"));
|
|
110
|
+
if (await index.exists()) {
|
|
111
|
+
return new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
112
|
+
}
|
|
113
|
+
return new Response("Not found", { status: 404 });
|
|
114
|
+
}
|
|
115
|
+
return new Response(file, { headers: { "content-type": contentTypeFor(filePath) } });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a live snapshot for WS clients: overview + agents + status counts +
|
|
120
|
+
* the latest feed slice, so the UI updates the status board and activity stream
|
|
121
|
+
* in real time without polling.
|
|
122
|
+
*/
|
|
123
|
+
function snapshot(ctx: ApiContext): unknown {
|
|
124
|
+
const overviewRoute = resolveRoute("/api/overview");
|
|
125
|
+
const agentsRoute = resolveRoute("/api/agents");
|
|
126
|
+
const query = new URLSearchParams();
|
|
127
|
+
return {
|
|
128
|
+
type: "snapshot",
|
|
129
|
+
overview: overviewRoute?.route.handler(ctx, {}, query) ?? null,
|
|
130
|
+
agents: agentsRoute?.route.handler(ctx, {}, query) ?? [],
|
|
131
|
+
statusCounts: buildStatusCounts(ctx),
|
|
132
|
+
tasks: buildTasks(ctx),
|
|
133
|
+
feed: buildFeed(ctx, 40),
|
|
134
|
+
ts: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Start the server. Returns a handle with `stop()`. */
|
|
139
|
+
export function startServer(opts: ServeOptions): ServeHandle {
|
|
140
|
+
const ctx: ApiContext = { root: opts.root };
|
|
141
|
+
const clients = new Set<ServerWebSocket<WsData>>();
|
|
142
|
+
const intervalMs = opts.wsIntervalMs ?? 5000;
|
|
143
|
+
|
|
144
|
+
const server = Bun.serve<WsData>({
|
|
145
|
+
port: opts.port,
|
|
146
|
+
hostname: opts.host,
|
|
147
|
+
async fetch(req, srv) {
|
|
148
|
+
const url = new URL(req.url);
|
|
149
|
+
const { pathname } = url;
|
|
150
|
+
|
|
151
|
+
if (pathname === "/healthz") {
|
|
152
|
+
return jsonResponse(jsonSuccess({ status: "ok", ts: new Date().toISOString() }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (pathname === "/ws") {
|
|
156
|
+
const ok = srv.upgrade(req, { data: { room: "fleet" } });
|
|
157
|
+
return ok ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Write actions (POST): a tight allowlist — message the coordinator or
|
|
161
|
+
// spawn a worker. No deploy/secrets/rollback are writable from the UI.
|
|
162
|
+
if (req.method === "POST" && pathname.startsWith("/api/")) {
|
|
163
|
+
try {
|
|
164
|
+
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
165
|
+
if (pathname === "/api/chat") return jsonResponse(jsonSuccess(postChat(ctx, body)));
|
|
166
|
+
if (pathname === "/api/tasks") return jsonResponse(jsonSuccess(postTask(ctx, body)));
|
|
167
|
+
return jsonResponse({ ok: false, error: "not found" }, 404);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
const status = isAgentplateError(error) ? 400 : 500;
|
|
171
|
+
return jsonResponse({ ok: false, error: { message } }, status);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Async GET endpoints (host metrics + weather) — kept out of the sync
|
|
176
|
+
// route table since they await I/O.
|
|
177
|
+
if (req.method === "GET" && pathname === "/api/system") {
|
|
178
|
+
try {
|
|
179
|
+
return jsonResponse(jsonSuccess(await collectSystemMetrics()));
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return jsonResponse({ ok: false, error: String(error) }, 500);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (req.method === "GET" && pathname === "/api/weather") {
|
|
185
|
+
try {
|
|
186
|
+
return jsonResponse(jsonSuccess(await fetchWeather(url.searchParams.get("loc"))));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return jsonResponse({ ok: false, error: String(error) }, 500);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (pathname.startsWith("/api/")) {
|
|
193
|
+
if (req.method !== "GET") return jsonResponse({ ok: false, error: "method" }, 405);
|
|
194
|
+
const resolved = resolveRoute(pathname);
|
|
195
|
+
if (!resolved) return jsonResponse({ ok: false, error: "not found" }, 404);
|
|
196
|
+
try {
|
|
197
|
+
const data = resolved.route.handler(ctx, resolved.params, url.searchParams);
|
|
198
|
+
return jsonResponse(jsonSuccess(data));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
return jsonResponse({ ok: false, error: { message } }, 500);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return serveStatic(opts.uiDir, pathname);
|
|
206
|
+
},
|
|
207
|
+
websocket: {
|
|
208
|
+
open(ws) {
|
|
209
|
+
clients.add(ws);
|
|
210
|
+
ws.send(JSON.stringify(snapshot(ctx)));
|
|
211
|
+
},
|
|
212
|
+
close(ws) {
|
|
213
|
+
clients.delete(ws);
|
|
214
|
+
},
|
|
215
|
+
message() {
|
|
216
|
+
// Clients are read-only; ignore inbound messages.
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Idle-agent reaper: terminate workers idle past the configured timeout. Runs
|
|
222
|
+
// independently of connected clients (serve running = reaping active). Disabled
|
|
223
|
+
// when idleTimeoutMinutes is 0. Config is read once at startup.
|
|
224
|
+
const idleMinutes = (() => {
|
|
225
|
+
try {
|
|
226
|
+
return loadConfig(opts.root).agents.idleTimeoutMinutes;
|
|
227
|
+
} catch {
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
const reapTimer =
|
|
232
|
+
idleMinutes > 0
|
|
233
|
+
? setInterval(() => {
|
|
234
|
+
const store = createSessionStore(sessionsDbPath(opts.root));
|
|
235
|
+
reapIdleSessions(store, opts.root, { idleMs: idleMinutes * 60_000 })
|
|
236
|
+
.then((reaped) => {
|
|
237
|
+
if (reaped.length > 0) {
|
|
238
|
+
const names = reaped.map((r) => r.agentName).join(", ");
|
|
239
|
+
console.error(
|
|
240
|
+
`[agentplate] reaped ${reaped.length} idle agent(s) (>${idleMinutes}m): ${names}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
.catch(() => {})
|
|
245
|
+
.finally(() => store.close());
|
|
246
|
+
}, opts.reapIntervalMs ?? REAP_INTERVAL_MS)
|
|
247
|
+
: null;
|
|
248
|
+
|
|
249
|
+
// Periodic broadcast loop.
|
|
250
|
+
const timer = setInterval(() => {
|
|
251
|
+
if (clients.size === 0) return;
|
|
252
|
+
let payload: string;
|
|
253
|
+
try {
|
|
254
|
+
payload = JSON.stringify(snapshot(ctx));
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for (const ws of clients) ws.send(payload);
|
|
259
|
+
}, intervalMs);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
server,
|
|
263
|
+
url: `http://${opts.host}:${server.port}`,
|
|
264
|
+
stop: () => {
|
|
265
|
+
clearInterval(timer);
|
|
266
|
+
if (reapTimer) clearInterval(reapTimer);
|
|
267
|
+
for (const ws of clients) ws.close();
|
|
268
|
+
server.stop(true);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|