@desplega.ai/agent-swarm 1.10.0 → 1.10.3
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/.claude/settings.local.json +3 -1
- package/Dockerfile.worker +1 -1
- package/package.json +20 -1
- package/src/be/db.ts +101 -0
- package/src/commands/runner.ts +111 -0
- package/src/http.ts +72 -0
- package/src/tests/session-logs.test.ts +388 -0
- package/src/types.ts +14 -0
- package/thoughts/shared/plans/2025-12-23-runner-session-logs.md +1000 -0
- package/ui/src/components/SessionLogPanel.tsx +433 -0
- package/ui/src/components/TaskDetailPanel.tsx +98 -78
- package/ui/src/hooks/queries.ts +9 -0
- package/ui/src/hooks/useAutoScroll.ts +55 -0
- package/ui/src/lib/api.ts +10 -0
- package/ui/src/types/api.ts +15 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createSessionLogs,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
getSessionLogsBySession,
|
|
9
|
+
getSessionLogsByTaskId,
|
|
10
|
+
getTaskById,
|
|
11
|
+
initDb,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
|
|
14
|
+
const TEST_DB_PATH = "./test-session-logs.sqlite";
|
|
15
|
+
const TEST_PORT = 13014;
|
|
16
|
+
|
|
17
|
+
// Helper to parse path segments
|
|
18
|
+
function getPathSegments(url: string): string[] {
|
|
19
|
+
const pathEnd = url.indexOf("?");
|
|
20
|
+
const path = pathEnd === -1 ? url : url.slice(0, pathEnd);
|
|
21
|
+
return path.split("/").filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Minimal HTTP handler for session logs endpoints
|
|
25
|
+
async function handleRequest(
|
|
26
|
+
req: { method: string; url: string },
|
|
27
|
+
body: string,
|
|
28
|
+
): Promise<{ status: number; body: unknown }> {
|
|
29
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
30
|
+
|
|
31
|
+
// POST /api/session-logs - Store session logs (batch)
|
|
32
|
+
if (req.method === "POST" && pathSegments[0] === "api" && pathSegments[1] === "session-logs") {
|
|
33
|
+
const parsedBody = JSON.parse(body);
|
|
34
|
+
|
|
35
|
+
// Validate required fields
|
|
36
|
+
if (!parsedBody.sessionId || typeof parsedBody.sessionId !== "string") {
|
|
37
|
+
return { status: 400, body: { error: "Missing or invalid 'sessionId' field" } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof parsedBody.iteration !== "number" || parsedBody.iteration < 1) {
|
|
41
|
+
return { status: 400, body: { error: "Missing or invalid 'iteration' field" } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!Array.isArray(parsedBody.lines) || parsedBody.lines.length === 0) {
|
|
45
|
+
return { status: 400, body: { error: "Missing or invalid 'lines' array" } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
createSessionLogs({
|
|
50
|
+
taskId: parsedBody.taskId || undefined,
|
|
51
|
+
sessionId: parsedBody.sessionId,
|
|
52
|
+
iteration: parsedBody.iteration,
|
|
53
|
+
cli: parsedBody.cli || "claude",
|
|
54
|
+
lines: parsedBody.lines,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return { status: 201, body: { success: true, count: parsedBody.lines.length } };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error("[TEST] Failed to create session logs:", error);
|
|
60
|
+
return { status: 500, body: { error: "Failed to store session logs" } };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// GET /api/tasks/:id/session-logs - Get session logs for a task
|
|
65
|
+
if (
|
|
66
|
+
req.method === "GET" &&
|
|
67
|
+
pathSegments[0] === "api" &&
|
|
68
|
+
pathSegments[1] === "tasks" &&
|
|
69
|
+
pathSegments[2] &&
|
|
70
|
+
pathSegments[3] === "session-logs"
|
|
71
|
+
) {
|
|
72
|
+
const taskId = pathSegments[2];
|
|
73
|
+
const task = getTaskById(taskId);
|
|
74
|
+
|
|
75
|
+
if (!task) {
|
|
76
|
+
return { status: 404, body: { error: "Task not found" } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const logs = getSessionLogsByTaskId(taskId);
|
|
80
|
+
return { status: 200, body: { logs } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { status: 404, body: { error: "Not found" } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create test HTTP server
|
|
87
|
+
function createTestServer(): Server {
|
|
88
|
+
return createHttpServer(async (req, res) => {
|
|
89
|
+
res.setHeader("Content-Type", "application/json");
|
|
90
|
+
|
|
91
|
+
const chunks: Buffer[] = [];
|
|
92
|
+
for await (const chunk of req) {
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
}
|
|
95
|
+
const body = Buffer.concat(chunks).toString();
|
|
96
|
+
|
|
97
|
+
const result = await handleRequest({ method: req.method || "GET", url: req.url || "/" }, body);
|
|
98
|
+
|
|
99
|
+
res.writeHead(result.status);
|
|
100
|
+
res.end(JSON.stringify(result.body));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("Session Logs API", () => {
|
|
105
|
+
let server: Server;
|
|
106
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
// Clean up any existing test database
|
|
110
|
+
try {
|
|
111
|
+
await unlink(TEST_DB_PATH);
|
|
112
|
+
} catch {
|
|
113
|
+
// File doesn't exist, that's fine
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Initialize test database
|
|
117
|
+
initDb(TEST_DB_PATH);
|
|
118
|
+
|
|
119
|
+
// Start test server
|
|
120
|
+
server = createTestServer();
|
|
121
|
+
await new Promise<void>((resolve) => {
|
|
122
|
+
server.listen(TEST_PORT, () => {
|
|
123
|
+
console.log(`Test server listening on port ${TEST_PORT}`);
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
// Close server
|
|
131
|
+
await new Promise<void>((resolve) => {
|
|
132
|
+
server.close(() => resolve());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Close database
|
|
136
|
+
closeDb();
|
|
137
|
+
|
|
138
|
+
// Clean up test database file
|
|
139
|
+
try {
|
|
140
|
+
await unlink(TEST_DB_PATH);
|
|
141
|
+
await unlink(`${TEST_DB_PATH}-wal`);
|
|
142
|
+
await unlink(`${TEST_DB_PATH}-shm`);
|
|
143
|
+
} catch {
|
|
144
|
+
// Files may not exist
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("Database Functions", () => {
|
|
149
|
+
test("should create and retrieve session logs by taskId", () => {
|
|
150
|
+
// Create a task first
|
|
151
|
+
const task = createTaskExtended("Test task for session logs");
|
|
152
|
+
|
|
153
|
+
// Create session logs for the task
|
|
154
|
+
createSessionLogs({
|
|
155
|
+
taskId: task.id,
|
|
156
|
+
sessionId: "test-session-1",
|
|
157
|
+
iteration: 1,
|
|
158
|
+
cli: "claude",
|
|
159
|
+
lines: ['{"type":"system"}', '{"type":"assistant","message":"Hello"}'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Retrieve logs
|
|
163
|
+
const logs = getSessionLogsByTaskId(task.id);
|
|
164
|
+
|
|
165
|
+
expect(logs.length).toBe(2);
|
|
166
|
+
expect(logs[0]?.content).toBe('{"type":"system"}');
|
|
167
|
+
expect(logs[1]?.content).toBe('{"type":"assistant","message":"Hello"}');
|
|
168
|
+
expect(logs[0]?.taskId).toBe(task.id);
|
|
169
|
+
expect(logs[0]?.sessionId).toBe("test-session-1");
|
|
170
|
+
expect(logs[0]?.iteration).toBe(1);
|
|
171
|
+
expect(logs[0]?.cli).toBe("claude");
|
|
172
|
+
expect(logs[0]?.lineNumber).toBe(0);
|
|
173
|
+
expect(logs[1]?.lineNumber).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("should create session logs without taskId", () => {
|
|
177
|
+
createSessionLogs({
|
|
178
|
+
sessionId: "ai-loop-session",
|
|
179
|
+
iteration: 1,
|
|
180
|
+
cli: "claude",
|
|
181
|
+
lines: ['{"type":"system","subtype":"init"}'],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Retrieve by session
|
|
185
|
+
const logs = getSessionLogsBySession("ai-loop-session", 1);
|
|
186
|
+
|
|
187
|
+
expect(logs.length).toBe(1);
|
|
188
|
+
expect(logs[0]?.taskId).toBeUndefined();
|
|
189
|
+
expect(logs[0]?.sessionId).toBe("ai-loop-session");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("should order logs by iteration and lineNumber", () => {
|
|
193
|
+
const task = createTaskExtended("Task for ordering test");
|
|
194
|
+
|
|
195
|
+
// Create logs for multiple iterations
|
|
196
|
+
createSessionLogs({
|
|
197
|
+
taskId: task.id,
|
|
198
|
+
sessionId: "order-session",
|
|
199
|
+
iteration: 1,
|
|
200
|
+
cli: "claude",
|
|
201
|
+
lines: ["line1-iter1", "line2-iter1"],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
createSessionLogs({
|
|
205
|
+
taskId: task.id,
|
|
206
|
+
sessionId: "order-session",
|
|
207
|
+
iteration: 2,
|
|
208
|
+
cli: "claude",
|
|
209
|
+
lines: ["line1-iter2", "line2-iter2"],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const logs = getSessionLogsByTaskId(task.id);
|
|
213
|
+
|
|
214
|
+
expect(logs.length).toBe(4);
|
|
215
|
+
// First iteration, first line
|
|
216
|
+
expect(logs[0]?.content).toBe("line1-iter1");
|
|
217
|
+
expect(logs[0]?.iteration).toBe(1);
|
|
218
|
+
expect(logs[0]?.lineNumber).toBe(0);
|
|
219
|
+
// First iteration, second line
|
|
220
|
+
expect(logs[1]?.content).toBe("line2-iter1");
|
|
221
|
+
expect(logs[1]?.iteration).toBe(1);
|
|
222
|
+
expect(logs[1]?.lineNumber).toBe(1);
|
|
223
|
+
// Second iteration, first line
|
|
224
|
+
expect(logs[2]?.content).toBe("line1-iter2");
|
|
225
|
+
expect(logs[2]?.iteration).toBe(2);
|
|
226
|
+
expect(logs[2]?.lineNumber).toBe(0);
|
|
227
|
+
// Second iteration, second line
|
|
228
|
+
expect(logs[3]?.content).toBe("line2-iter2");
|
|
229
|
+
expect(logs[3]?.iteration).toBe(2);
|
|
230
|
+
expect(logs[3]?.lineNumber).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("POST /api/session-logs", () => {
|
|
235
|
+
test("should return 400 if sessionId is missing", async () => {
|
|
236
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { "Content-Type": "application/json" },
|
|
239
|
+
body: JSON.stringify({ iteration: 1, lines: ["test"] }),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(response.status).toBe(400);
|
|
243
|
+
const data = (await response.json()) as { error: string };
|
|
244
|
+
expect(data.error).toContain("sessionId");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("should return 400 if iteration is missing", async () => {
|
|
248
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "Content-Type": "application/json" },
|
|
251
|
+
body: JSON.stringify({ sessionId: "test", lines: ["test"] }),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(response.status).toBe(400);
|
|
255
|
+
const data = (await response.json()) as { error: string };
|
|
256
|
+
expect(data.error).toContain("iteration");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("should return 400 if iteration is less than 1", async () => {
|
|
260
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({ sessionId: "test", iteration: 0, lines: ["test"] }),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(response.status).toBe(400);
|
|
267
|
+
const data = (await response.json()) as { error: string };
|
|
268
|
+
expect(data.error).toContain("iteration");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("should return 400 if lines is missing", async () => {
|
|
272
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "Content-Type": "application/json" },
|
|
275
|
+
body: JSON.stringify({ sessionId: "test", iteration: 1 }),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(response.status).toBe(400);
|
|
279
|
+
const data = (await response.json()) as { error: string };
|
|
280
|
+
expect(data.error).toContain("lines");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("should return 400 if lines is empty array", async () => {
|
|
284
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({ sessionId: "test", iteration: 1, lines: [] }),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(response.status).toBe(400);
|
|
291
|
+
const data = (await response.json()) as { error: string };
|
|
292
|
+
expect(data.error).toContain("lines");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("should return 201 on successful POST", async () => {
|
|
296
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: { "Content-Type": "application/json" },
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
sessionId: "api-test-session",
|
|
301
|
+
iteration: 1,
|
|
302
|
+
cli: "claude",
|
|
303
|
+
lines: ['{"type":"system"}', '{"type":"result"}'],
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(response.status).toBe(201);
|
|
308
|
+
const data = (await response.json()) as { success: boolean; count: number };
|
|
309
|
+
expect(data.success).toBe(true);
|
|
310
|
+
expect(data.count).toBe(2);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("should store logs with taskId", async () => {
|
|
314
|
+
const task = createTaskExtended("API test task");
|
|
315
|
+
|
|
316
|
+
const response = await fetch(`${baseUrl}/api/session-logs`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { "Content-Type": "application/json" },
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
sessionId: "api-task-session",
|
|
321
|
+
iteration: 1,
|
|
322
|
+
taskId: task.id,
|
|
323
|
+
cli: "claude",
|
|
324
|
+
lines: ['{"type":"test"}'],
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(response.status).toBe(201);
|
|
329
|
+
|
|
330
|
+
// Verify it was stored correctly
|
|
331
|
+
const logs = getSessionLogsByTaskId(task.id);
|
|
332
|
+
expect(logs.length).toBe(1);
|
|
333
|
+
expect(logs[0]?.taskId).toBe(task.id);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("GET /api/tasks/:id/session-logs", () => {
|
|
338
|
+
test("should return 404 if task does not exist", async () => {
|
|
339
|
+
const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/session-logs`);
|
|
340
|
+
|
|
341
|
+
expect(response.status).toBe(404);
|
|
342
|
+
const data = (await response.json()) as { error: string };
|
|
343
|
+
expect(data.error).toContain("Task not found");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("should return empty logs array for task with no logs", async () => {
|
|
347
|
+
const task = createTaskExtended("Task without logs");
|
|
348
|
+
|
|
349
|
+
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session-logs`);
|
|
350
|
+
|
|
351
|
+
expect(response.status).toBe(200);
|
|
352
|
+
const data = (await response.json()) as { logs: unknown[] };
|
|
353
|
+
expect(data.logs).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("should return session logs for a task", async () => {
|
|
357
|
+
const task = createTaskExtended("Task with logs for GET test");
|
|
358
|
+
|
|
359
|
+
// Create some logs
|
|
360
|
+
createSessionLogs({
|
|
361
|
+
taskId: task.id,
|
|
362
|
+
sessionId: "get-test-session",
|
|
363
|
+
iteration: 1,
|
|
364
|
+
cli: "claude",
|
|
365
|
+
lines: ['{"type":"system"}', '{"type":"assistant"}'],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session-logs`);
|
|
369
|
+
|
|
370
|
+
expect(response.status).toBe(200);
|
|
371
|
+
const data = (await response.json()) as {
|
|
372
|
+
logs: Array<{
|
|
373
|
+
id: string;
|
|
374
|
+
taskId: string;
|
|
375
|
+
sessionId: string;
|
|
376
|
+
iteration: number;
|
|
377
|
+
cli: string;
|
|
378
|
+
content: string;
|
|
379
|
+
lineNumber: number;
|
|
380
|
+
createdAt: string;
|
|
381
|
+
}>;
|
|
382
|
+
};
|
|
383
|
+
expect(data.logs.length).toBe(2);
|
|
384
|
+
expect(data.logs[0]?.content).toBe('{"type":"system"}');
|
|
385
|
+
expect(data.logs[1]?.content).toBe('{"type":"assistant"}');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -171,3 +171,17 @@ export const AgentLogSchema = z.object({
|
|
|
171
171
|
|
|
172
172
|
export type AgentLogEventType = z.infer<typeof AgentLogEventTypeSchema>;
|
|
173
173
|
export type AgentLog = z.infer<typeof AgentLogSchema>;
|
|
174
|
+
|
|
175
|
+
// Session Log Types (raw CLI output)
|
|
176
|
+
export const SessionLogSchema = z.object({
|
|
177
|
+
id: z.uuid(),
|
|
178
|
+
taskId: z.uuid().optional(),
|
|
179
|
+
sessionId: z.string(),
|
|
180
|
+
iteration: z.number().int().min(1),
|
|
181
|
+
cli: z.string().default("claude"),
|
|
182
|
+
content: z.string(), // Raw JSON line
|
|
183
|
+
lineNumber: z.number().int().min(0),
|
|
184
|
+
createdAt: z.iso.datetime(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export type SessionLog = z.infer<typeof SessionLogSchema>;
|