@versdotsh/reef 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +47 -0
- package/README.md +257 -0
- package/bun.lock +587 -0
- package/examples/services/board/board.test.ts +215 -0
- package/examples/services/board/index.ts +155 -0
- package/examples/services/board/routes.ts +335 -0
- package/examples/services/board/store.ts +329 -0
- package/examples/services/board/tools.ts +214 -0
- package/examples/services/commits/commits.test.ts +74 -0
- package/examples/services/commits/index.ts +14 -0
- package/examples/services/commits/routes.ts +43 -0
- package/examples/services/commits/store.ts +114 -0
- package/examples/services/feed/behaviors.ts +23 -0
- package/examples/services/feed/feed.test.ts +101 -0
- package/examples/services/feed/index.ts +117 -0
- package/examples/services/feed/routes.ts +224 -0
- package/examples/services/feed/store.ts +194 -0
- package/examples/services/feed/tools.ts +83 -0
- package/examples/services/journal/index.ts +15 -0
- package/examples/services/journal/journal.test.ts +57 -0
- package/examples/services/journal/routes.ts +45 -0
- package/examples/services/journal/store.ts +119 -0
- package/examples/services/journal/tools.ts +32 -0
- package/examples/services/log/index.ts +15 -0
- package/examples/services/log/log.test.ts +70 -0
- package/examples/services/log/routes.ts +44 -0
- package/examples/services/log/store.ts +105 -0
- package/examples/services/log/tools.ts +57 -0
- package/examples/services/registry/behaviors.ts +128 -0
- package/examples/services/registry/index.ts +37 -0
- package/examples/services/registry/registry.test.ts +135 -0
- package/examples/services/registry/routes.ts +76 -0
- package/examples/services/registry/store.ts +224 -0
- package/examples/services/registry/tools.ts +116 -0
- package/examples/services/reports/index.ts +14 -0
- package/examples/services/reports/reports.test.ts +75 -0
- package/examples/services/reports/routes.ts +42 -0
- package/examples/services/reports/store.ts +110 -0
- package/examples/services/ui/auth.ts +61 -0
- package/examples/services/ui/index.ts +16 -0
- package/examples/services/ui/routes.ts +160 -0
- package/examples/services/ui/static/app.js +369 -0
- package/examples/services/ui/static/index.html +42 -0
- package/examples/services/ui/static/style.css +157 -0
- package/examples/services/usage/behaviors.ts +166 -0
- package/examples/services/usage/index.ts +19 -0
- package/examples/services/usage/routes.ts +53 -0
- package/examples/services/usage/store.ts +341 -0
- package/examples/services/usage/tools.ts +75 -0
- package/examples/services/usage/usage.test.ts +91 -0
- package/package.json +29 -0
- package/services/agent/index.ts +465 -0
- package/services/board/index.ts +155 -0
- package/services/board/routes.ts +335 -0
- package/services/board/store.ts +329 -0
- package/services/board/tools.ts +214 -0
- package/services/docs/index.ts +391 -0
- package/services/feed/behaviors.ts +23 -0
- package/services/feed/index.ts +117 -0
- package/services/feed/routes.ts +224 -0
- package/services/feed/store.ts +194 -0
- package/services/feed/tools.ts +83 -0
- package/services/installer/index.ts +574 -0
- package/services/services/index.ts +165 -0
- package/services/ui/auth.ts +61 -0
- package/services/ui/index.ts +16 -0
- package/services/ui/routes.ts +160 -0
- package/services/ui/static/app.js +369 -0
- package/services/ui/static/index.html +42 -0
- package/services/ui/static/style.css +157 -0
- package/skills/create-service/SKILL.md +698 -0
- package/src/core/auth.ts +28 -0
- package/src/core/client.ts +99 -0
- package/src/core/discover.ts +152 -0
- package/src/core/events.ts +44 -0
- package/src/core/extension.ts +66 -0
- package/src/core/server.ts +262 -0
- package/src/core/testing.ts +155 -0
- package/src/core/types.ts +194 -0
- package/src/extension.ts +16 -0
- package/src/main.ts +11 -0
- package/tests/server.test.ts +1338 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board tools — registered on the pi extension so the LLM can manage tasks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { FleetClient } from "../src/core/types.js";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
9
|
+
|
|
10
|
+
const STATUS_ENUM = StringEnum(
|
|
11
|
+
["open", "in_progress", "in_review", "blocked", "done"] as const,
|
|
12
|
+
{ description: "Task status" },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const ARTIFACT_TYPE_ENUM = StringEnum(
|
|
16
|
+
["branch", "report", "deploy", "diff", "file", "url"] as const,
|
|
17
|
+
{ description: "Artifact type" },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export function registerTools(pi: ExtensionAPI, client: FleetClient) {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: "board_create_task",
|
|
23
|
+
label: "Board: Create Task",
|
|
24
|
+
description:
|
|
25
|
+
"Create a new task on the shared coordination board. Returns the created task with its ID.",
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
title: Type.String({ description: "Task title" }),
|
|
28
|
+
description: Type.Optional(Type.String({ description: "Detailed task description" })),
|
|
29
|
+
assignee: Type.Optional(Type.String({ description: "Agent or user to assign to" })),
|
|
30
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorization" })),
|
|
31
|
+
}),
|
|
32
|
+
async execute(_id, params) {
|
|
33
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
34
|
+
try {
|
|
35
|
+
const task = await client.api("POST", "/board/tasks", {
|
|
36
|
+
...params,
|
|
37
|
+
createdBy: client.agentName,
|
|
38
|
+
});
|
|
39
|
+
return client.ok(JSON.stringify(task, null, 2), { task });
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
return client.err(e.message);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.registerTool({
|
|
47
|
+
name: "board_list_tasks",
|
|
48
|
+
label: "Board: List Tasks",
|
|
49
|
+
description:
|
|
50
|
+
"List tasks on the shared board. Optionally filter by status, assignee, or tag.",
|
|
51
|
+
parameters: Type.Object({
|
|
52
|
+
status: Type.Optional(STATUS_ENUM),
|
|
53
|
+
assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
|
|
54
|
+
tag: Type.Optional(Type.String({ description: "Filter by tag" })),
|
|
55
|
+
}),
|
|
56
|
+
async execute(_id, params) {
|
|
57
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
58
|
+
try {
|
|
59
|
+
const qs = new URLSearchParams();
|
|
60
|
+
if (params.status) qs.set("status", params.status);
|
|
61
|
+
if (params.assignee) qs.set("assignee", params.assignee);
|
|
62
|
+
if (params.tag) qs.set("tag", params.tag);
|
|
63
|
+
const query = qs.toString();
|
|
64
|
+
const result = await client.api("GET", `/board/tasks${query ? `?${query}` : ""}`);
|
|
65
|
+
return client.ok(JSON.stringify(result, null, 2), { result });
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
return client.err(e.message);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
name: "board_update_task",
|
|
74
|
+
label: "Board: Update Task",
|
|
75
|
+
description:
|
|
76
|
+
"Update a task — change status, reassign, rename, or update tags.",
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
id: Type.String({ description: "Task ID to update" }),
|
|
79
|
+
status: Type.Optional(STATUS_ENUM),
|
|
80
|
+
assignee: Type.Optional(Type.String({ description: "New assignee" })),
|
|
81
|
+
title: Type.Optional(Type.String({ description: "New title" })),
|
|
82
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: "New tags" })),
|
|
83
|
+
}),
|
|
84
|
+
async execute(_toolCallId, params) {
|
|
85
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
86
|
+
try {
|
|
87
|
+
const { id, ...updates } = params;
|
|
88
|
+
const task = await client.api("PATCH", `/board/tasks/${encodeURIComponent(id)}`, updates);
|
|
89
|
+
return client.ok(JSON.stringify(task, null, 2), { task });
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
return client.err(e.message);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.registerTool({
|
|
97
|
+
name: "board_add_note",
|
|
98
|
+
label: "Board: Add Note",
|
|
99
|
+
description:
|
|
100
|
+
"Add a note to a task — findings, blockers, questions, or status updates.",
|
|
101
|
+
parameters: Type.Object({
|
|
102
|
+
taskId: Type.String({ description: "Task ID to add the note to" }),
|
|
103
|
+
content: Type.String({ description: "Note content" }),
|
|
104
|
+
type: StringEnum(["finding", "blocker", "question", "update"] as const, {
|
|
105
|
+
description: "Note type",
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
async execute(_id, params) {
|
|
109
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
110
|
+
try {
|
|
111
|
+
const { taskId, ...body } = params;
|
|
112
|
+
const note = await client.api(
|
|
113
|
+
"POST",
|
|
114
|
+
`/board/tasks/${encodeURIComponent(taskId)}/notes`,
|
|
115
|
+
{ ...body, author: client.agentName },
|
|
116
|
+
);
|
|
117
|
+
return client.ok(JSON.stringify(note, null, 2), { note });
|
|
118
|
+
} catch (e: any) {
|
|
119
|
+
return client.err(e.message);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.registerTool({
|
|
125
|
+
name: "board_submit_for_review",
|
|
126
|
+
label: "Board: Submit for Review",
|
|
127
|
+
description:
|
|
128
|
+
"Submit a task for review — sets status to in_review, adds a summary note, and optionally attaches artifacts.",
|
|
129
|
+
parameters: Type.Object({
|
|
130
|
+
taskId: Type.String({ description: "Task ID to submit for review" }),
|
|
131
|
+
summary: Type.String({ description: "Review summary describing what was done" }),
|
|
132
|
+
artifacts: Type.Optional(
|
|
133
|
+
Type.Array(
|
|
134
|
+
Type.Object({
|
|
135
|
+
type: ARTIFACT_TYPE_ENUM,
|
|
136
|
+
url: Type.String({ description: "URL or path to the artifact" }),
|
|
137
|
+
label: Type.String({ description: "Human-readable label" }),
|
|
138
|
+
}),
|
|
139
|
+
{ description: "Artifacts to attach" },
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
}),
|
|
143
|
+
async execute(_id, params) {
|
|
144
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
145
|
+
try {
|
|
146
|
+
const body: Record<string, unknown> = {
|
|
147
|
+
summary: params.summary,
|
|
148
|
+
reviewedBy: client.agentName,
|
|
149
|
+
};
|
|
150
|
+
if (params.artifacts) body.artifacts = params.artifacts;
|
|
151
|
+
const task = await client.api(
|
|
152
|
+
"POST",
|
|
153
|
+
`/board/tasks/${encodeURIComponent(params.taskId)}/review`,
|
|
154
|
+
body,
|
|
155
|
+
);
|
|
156
|
+
return client.ok(JSON.stringify(task, null, 2), { task });
|
|
157
|
+
} catch (e: any) {
|
|
158
|
+
return client.err(e.message);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerTool({
|
|
164
|
+
name: "board_add_artifact",
|
|
165
|
+
label: "Board: Add Artifact",
|
|
166
|
+
description:
|
|
167
|
+
"Add artifact link(s) to any task — branches, reports, deploys, diffs, files, or URLs.",
|
|
168
|
+
parameters: Type.Object({
|
|
169
|
+
taskId: Type.String({ description: "Task ID" }),
|
|
170
|
+
artifacts: Type.Array(
|
|
171
|
+
Type.Object({
|
|
172
|
+
type: ARTIFACT_TYPE_ENUM,
|
|
173
|
+
url: Type.String({ description: "URL or path" }),
|
|
174
|
+
label: Type.String({ description: "Human-readable label" }),
|
|
175
|
+
}),
|
|
176
|
+
{ description: "Artifacts to attach" },
|
|
177
|
+
),
|
|
178
|
+
}),
|
|
179
|
+
async execute(_id, params) {
|
|
180
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
181
|
+
try {
|
|
182
|
+
const task = await client.api(
|
|
183
|
+
"POST",
|
|
184
|
+
`/board/tasks/${encodeURIComponent(params.taskId)}/artifacts`,
|
|
185
|
+
{ artifacts: params.artifacts.map((a) => ({ ...a, addedBy: client.agentName })) },
|
|
186
|
+
);
|
|
187
|
+
return client.ok(JSON.stringify(task, null, 2), { task });
|
|
188
|
+
} catch (e: any) {
|
|
189
|
+
return client.err(e.message);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
pi.registerTool({
|
|
195
|
+
name: "board_bump",
|
|
196
|
+
label: "Board: Bump Task",
|
|
197
|
+
description: "Bump a task's score by 1. Use to signal priority or upvote.",
|
|
198
|
+
parameters: Type.Object({
|
|
199
|
+
taskId: Type.String({ description: "Task ID to bump" }),
|
|
200
|
+
}),
|
|
201
|
+
async execute(_id, params) {
|
|
202
|
+
if (!client.getBaseUrl()) return client.noUrl();
|
|
203
|
+
try {
|
|
204
|
+
const task = await client.api(
|
|
205
|
+
"POST",
|
|
206
|
+
`/board/tasks/${encodeURIComponent(params.taskId)}/bump`,
|
|
207
|
+
);
|
|
208
|
+
return client.ok(JSON.stringify(task, null, 2), { task });
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
return client.err(e.message);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
|
|
3
|
+
import commits from "./index.js";
|
|
4
|
+
|
|
5
|
+
let t: TestHarness;
|
|
6
|
+
const setup = (async () => {
|
|
7
|
+
t = await createTestHarness({ services: [commits] });
|
|
8
|
+
})();
|
|
9
|
+
afterAll(() => t?.cleanup());
|
|
10
|
+
|
|
11
|
+
describe("commits", () => {
|
|
12
|
+
test("records a commit", async () => {
|
|
13
|
+
await setup;
|
|
14
|
+
const { status, data } = await t.json("/commits", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
auth: true,
|
|
17
|
+
body: {
|
|
18
|
+
commitId: "commit-abc",
|
|
19
|
+
vmId: "vm-001",
|
|
20
|
+
label: "golden-v1",
|
|
21
|
+
agent: "coordinator",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
expect(status).toBe(201);
|
|
25
|
+
expect(data.commitId).toBe("commit-abc");
|
|
26
|
+
expect(data.vmId).toBe("vm-001");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("lists commits", async () => {
|
|
30
|
+
await setup;
|
|
31
|
+
const { status, data } = await t.json<{ commits: any[]; count: number }>("/commits", {
|
|
32
|
+
auth: true,
|
|
33
|
+
});
|
|
34
|
+
expect(status).toBe(200);
|
|
35
|
+
expect(data.commits.length).toBeGreaterThanOrEqual(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("gets a commit by commitId", async () => {
|
|
39
|
+
await setup;
|
|
40
|
+
await t.json("/commits", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
auth: true,
|
|
43
|
+
body: { commitId: "commit-get", vmId: "vm-002", agent: "test" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { status, data } = await t.json("/commits/commit-get", { auth: true });
|
|
47
|
+
expect(status).toBe(200);
|
|
48
|
+
expect(data.commitId).toBe("commit-get");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("deletes a commit by commitId", async () => {
|
|
52
|
+
await setup;
|
|
53
|
+
await t.json("/commits", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
auth: true,
|
|
56
|
+
body: { commitId: "commit-del", vmId: "vm-003", agent: "test" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { status } = await t.json("/commits/commit-del", {
|
|
60
|
+
method: "DELETE",
|
|
61
|
+
auth: true,
|
|
62
|
+
});
|
|
63
|
+
expect(status).toBe(200);
|
|
64
|
+
|
|
65
|
+
const { status: getStatus } = await t.json("/commits/commit-del", { auth: true });
|
|
66
|
+
expect(getStatus).toBe(404);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("requires auth", async () => {
|
|
70
|
+
await setup;
|
|
71
|
+
const { status } = await t.json("/commits");
|
|
72
|
+
expect(status).toBe(401);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceModule } from "../src/core/types.js";
|
|
2
|
+
import { CommitStore } from "./store.js";
|
|
3
|
+
import { createRoutes } from "./routes.js";
|
|
4
|
+
|
|
5
|
+
const store = new CommitStore();
|
|
6
|
+
|
|
7
|
+
const commits: ServiceModule = {
|
|
8
|
+
name: "commits",
|
|
9
|
+
description: "VM snapshot ledger",
|
|
10
|
+
routes: createRoutes(store),
|
|
11
|
+
store: { close: () => { store.close(); return Promise.resolve(); } },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default commits;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { CommitStore } from "./store.js";
|
|
3
|
+
import { ValidationError } from "./store.js";
|
|
4
|
+
|
|
5
|
+
export function createRoutes(store: CommitStore): Hono {
|
|
6
|
+
const routes = new Hono();
|
|
7
|
+
|
|
8
|
+
routes.post("/", async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const body = await c.req.json();
|
|
11
|
+
const record = store.record(body);
|
|
12
|
+
return c.json(record, 201);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
routes.get("/", (c) => {
|
|
20
|
+
const commits = store.list({
|
|
21
|
+
tag: c.req.query("tag") || undefined,
|
|
22
|
+
agent: c.req.query("agent") || undefined,
|
|
23
|
+
label: c.req.query("label") || undefined,
|
|
24
|
+
vmId: c.req.query("vmId") || undefined,
|
|
25
|
+
since: c.req.query("since") || undefined,
|
|
26
|
+
});
|
|
27
|
+
return c.json({ commits, count: commits.length });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
routes.get("/:id", (c) => {
|
|
31
|
+
const commit = store.get(c.req.param("id"));
|
|
32
|
+
if (!commit) return c.json({ error: "commit not found" }, 404);
|
|
33
|
+
return c.json(commit);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
routes.delete("/:id", (c) => {
|
|
37
|
+
const deleted = store.delete(c.req.param("id"));
|
|
38
|
+
if (!deleted) return c.json({ error: "commit not found" }, 404);
|
|
39
|
+
return c.json({ deleted: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return routes;
|
|
43
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commits store — VM snapshot ledger. Tracks golden images, rollback points.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ulid } from "ulid";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface CommitRecord {
|
|
11
|
+
id: string;
|
|
12
|
+
commitId: string;
|
|
13
|
+
vmId: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
agent?: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
createdAt: string;
|
|
18
|
+
recordedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CommitInput {
|
|
22
|
+
commitId: string;
|
|
23
|
+
vmId: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
agent?: string;
|
|
26
|
+
tags?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ValidationError extends Error {
|
|
30
|
+
constructor(message: string) { super(message); this.name = "ValidationError"; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class CommitStore {
|
|
34
|
+
private db: Database;
|
|
35
|
+
|
|
36
|
+
constructor(dbPath = "data/commits.sqlite") {
|
|
37
|
+
const dir = dirname(dbPath);
|
|
38
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
this.db = new Database(dbPath);
|
|
41
|
+
this.db.exec("PRAGMA journal_mode=WAL");
|
|
42
|
+
this.db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
commit_id TEXT NOT NULL UNIQUE,
|
|
46
|
+
vm_id TEXT NOT NULL,
|
|
47
|
+
label TEXT,
|
|
48
|
+
agent TEXT,
|
|
49
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
50
|
+
created_at TEXT NOT NULL,
|
|
51
|
+
recorded_at TEXT NOT NULL
|
|
52
|
+
)
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
record(input: CommitInput): CommitRecord {
|
|
57
|
+
if (!input.commitId?.trim()) throw new ValidationError("commitId is required");
|
|
58
|
+
if (!input.vmId?.trim()) throw new ValidationError("vmId is required");
|
|
59
|
+
|
|
60
|
+
const id = ulid();
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const tags = input.tags || [];
|
|
63
|
+
|
|
64
|
+
this.db.run(
|
|
65
|
+
"INSERT OR REPLACE INTO commits VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
66
|
+
[id, input.commitId.trim(), input.vmId.trim(), input.label?.trim() || null,
|
|
67
|
+
input.agent?.trim() || null, JSON.stringify(tags), now, now],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id, commitId: input.commitId.trim(), vmId: input.vmId.trim(),
|
|
72
|
+
label: input.label?.trim(), agent: input.agent?.trim(),
|
|
73
|
+
tags, createdAt: now, recordedAt: now,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get(commitId: string): CommitRecord | null {
|
|
78
|
+
const row = this.db.query("SELECT * FROM commits WHERE commit_id = ?").get(commitId) as any;
|
|
79
|
+
return row ? rowToCommit(row) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
list(filters?: { tag?: string; agent?: string; label?: string; vmId?: string; since?: string }): CommitRecord[] {
|
|
83
|
+
let sql = "SELECT * FROM commits";
|
|
84
|
+
const conditions: string[] = [];
|
|
85
|
+
const params: any[] = [];
|
|
86
|
+
|
|
87
|
+
if (filters?.agent) { conditions.push("agent = ?"); params.push(filters.agent); }
|
|
88
|
+
if (filters?.label) { conditions.push("label = ?"); params.push(filters.label); }
|
|
89
|
+
if (filters?.vmId) { conditions.push("vm_id = ?"); params.push(filters.vmId); }
|
|
90
|
+
if (filters?.tag) { conditions.push("tags LIKE ?"); params.push(`%"${filters.tag}"%`); }
|
|
91
|
+
if (filters?.since) { conditions.push("created_at >= ?"); params.push(filters.since); }
|
|
92
|
+
|
|
93
|
+
if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
94
|
+
sql += " ORDER BY created_at DESC";
|
|
95
|
+
|
|
96
|
+
return this.db.query(sql).all(...params).map(rowToCommit);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
delete(commitId: string): boolean {
|
|
100
|
+
const result = this.db.run("DELETE FROM commits WHERE commit_id = ?", [commitId]);
|
|
101
|
+
return result.changes > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
close(): void { this.db.close(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rowToCommit(row: any): CommitRecord {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id, commitId: row.commit_id, vmId: row.vm_id,
|
|
110
|
+
label: row.label || undefined, agent: row.agent || undefined,
|
|
111
|
+
tags: JSON.parse(row.tags || "[]"),
|
|
112
|
+
createdAt: row.created_at, recordedAt: row.recorded_at,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed behaviors — auto-publish agent lifecycle events.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { FleetClient } from "../src/core/types.js";
|
|
7
|
+
|
|
8
|
+
export function registerBehaviors(pi: ExtensionAPI, client: FleetClient) {
|
|
9
|
+
pi.on("agent_start", async () => {
|
|
10
|
+
if (!client.getBaseUrl()) return;
|
|
11
|
+
try {
|
|
12
|
+
await client.api("POST", "/feed/events", {
|
|
13
|
+
agent: client.agentName,
|
|
14
|
+
type: "agent_started",
|
|
15
|
+
summary: `Agent ${client.agentName} started processing`,
|
|
16
|
+
});
|
|
17
|
+
} catch {
|
|
18
|
+
// best-effort
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// agent_end publish is handled by the usage service (it has cost data to include)
|
|
23
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
|
|
3
|
+
import feed from "./index.js";
|
|
4
|
+
|
|
5
|
+
let t: TestHarness;
|
|
6
|
+
const setup = (async () => {
|
|
7
|
+
t = await createTestHarness({ services: [feed] });
|
|
8
|
+
})();
|
|
9
|
+
afterAll(() => t?.cleanup());
|
|
10
|
+
|
|
11
|
+
describe("feed", () => {
|
|
12
|
+
test("publishes an event", async () => {
|
|
13
|
+
await setup;
|
|
14
|
+
const { status, data } = await t.json("/feed/events", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
auth: true,
|
|
17
|
+
body: { agent: "test-agent", type: "task_started", summary: "Working on it" },
|
|
18
|
+
});
|
|
19
|
+
expect(status).toBe(201);
|
|
20
|
+
expect(data.agent).toBe("test-agent");
|
|
21
|
+
expect(data.type).toBe("task_started");
|
|
22
|
+
expect(data.id).toBeDefined();
|
|
23
|
+
expect(data.timestamp).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("lists events", async () => {
|
|
27
|
+
await setup;
|
|
28
|
+
const { status, data } = await t.json<any[]>("/feed/events", {
|
|
29
|
+
auth: true,
|
|
30
|
+
});
|
|
31
|
+
expect(status).toBe(200);
|
|
32
|
+
expect(Array.isArray(data)).toBe(true);
|
|
33
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("filters by agent", async () => {
|
|
37
|
+
await setup;
|
|
38
|
+
await t.json("/feed/events", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
auth: true,
|
|
41
|
+
body: { agent: "filter-agent", type: "finding", summary: "Found it" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const { data } = await t.json<any[]>("/feed/events?agent=filter-agent", {
|
|
45
|
+
auth: true,
|
|
46
|
+
});
|
|
47
|
+
for (const e of data) {
|
|
48
|
+
expect(e.agent).toBe("filter-agent");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("filters by type", async () => {
|
|
53
|
+
await setup;
|
|
54
|
+
const { data } = await t.json<any[]>("/feed/events?type=task_started", {
|
|
55
|
+
auth: true,
|
|
56
|
+
});
|
|
57
|
+
for (const e of data) {
|
|
58
|
+
expect(e.type).toBe("task_started");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("gets event by id", async () => {
|
|
63
|
+
await setup;
|
|
64
|
+
const { data: created } = await t.json<any>("/feed/events", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
auth: true,
|
|
67
|
+
body: { agent: "test", type: "custom", summary: "Get by ID" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const { status, data } = await t.json(`/feed/events/${created.id}`, { auth: true });
|
|
71
|
+
expect(status).toBe(200);
|
|
72
|
+
expect(data.summary).toBe("Get by ID");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns stats", async () => {
|
|
76
|
+
await setup;
|
|
77
|
+
const { status, data } = await t.json<any>("/feed/stats", { auth: true });
|
|
78
|
+
expect(status).toBe(200);
|
|
79
|
+
expect(data.total).toBeGreaterThanOrEqual(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("limits results", async () => {
|
|
83
|
+
await setup;
|
|
84
|
+
const { data } = await t.json<any[]>("/feed/events?limit=1", { auth: true });
|
|
85
|
+
expect(data.length).toBeLessThanOrEqual(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("requires auth", async () => {
|
|
89
|
+
await setup;
|
|
90
|
+
const { status } = await t.json("/feed/events");
|
|
91
|
+
expect(status).toBe(401);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("panel endpoint returns HTML", async () => {
|
|
95
|
+
await setup;
|
|
96
|
+
const res = await t.fetch("/feed/_panel", { auth: true });
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
const html = await res.text();
|
|
99
|
+
expect(html).toContain("panel-feed");
|
|
100
|
+
});
|
|
101
|
+
});
|