@longshot/cli 0.0.1
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/LICENSE +8 -0
- package/dist/agent.js +214 -0
- package/dist/cli.js +172 -0
- package/dist/git.js +291 -0
- package/dist/index.js +1250 -0
- package/dist/profile.js +79 -0
- package/dist/projects.js +337 -0
- package/dist/queue.js +868 -0
- package/dist/services.js +194 -0
- package/dist/store.js +612 -0
- package/dist/views/agent-progress.js +242 -0
- package/dist/views/branches.js +191 -0
- package/dist/views/chat.js +386 -0
- package/dist/views/diff.js +321 -0
- package/dist/views/history.js +124 -0
- package/dist/views/layout.js +121 -0
- package/dist/views/run.js +92 -0
- package/dist/views/services.js +230 -0
- package/dist/views/spec.js +18 -0
- package/dist/views/tasks.js +898 -0
- package/dist/views/verify.js +209 -0
- package/package.json +36 -0
- package/public/style.css +2088 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
4
|
+
import { readFile as fsReadFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { marked } from "marked";
|
|
8
|
+
import * as store from "./store.js";
|
|
9
|
+
import * as git from "./git.js";
|
|
10
|
+
import { spawnAgent, chatWithClaude } from "./agent.js";
|
|
11
|
+
import { enqueue, getQueueState, pendingDiffs, persistPendingDiffs, reloadPendingDiffs, startQueueProcessor, findConflictItem, resolveConflictRestart, resolveConflictAgent, resolveConflictManual, resolveConflictDone } from "./queue.js";
|
|
12
|
+
import { chatPage } from "./views/chat.js";
|
|
13
|
+
import { specPage } from "./views/spec.js";
|
|
14
|
+
import { diffPage } from "./views/diff.js";
|
|
15
|
+
import { historyPage } from "./views/history.js";
|
|
16
|
+
import { tasksListPage, taskDetailPage } from "./views/tasks.js";
|
|
17
|
+
import { runPage } from "./views/run.js";
|
|
18
|
+
import { agentProgressPage } from "./views/agent-progress.js";
|
|
19
|
+
import { verifyPage, verifyLogPage } from "./views/verify.js";
|
|
20
|
+
import { servicesPage, serviceLogsPage } from "./views/services.js";
|
|
21
|
+
import { branchesPage } from "./views/branches.js";
|
|
22
|
+
import * as services from "./services.js";
|
|
23
|
+
import * as projects from "./projects.js";
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
const PORT = parseInt(process.env.PORT || "3333", 10);
|
|
26
|
+
const runningVerifications = [];
|
|
27
|
+
// --- Static files ---
|
|
28
|
+
app.use("/style.css", serveStatic({ root: "./public" }));
|
|
29
|
+
// --- Project API routes (multi-project mode) ---
|
|
30
|
+
app.get("/api/projects", (c) => {
|
|
31
|
+
if (!projects.isMultiProject())
|
|
32
|
+
return c.json({ multiProject: false, projects: [] });
|
|
33
|
+
return c.json({
|
|
34
|
+
multiProject: true,
|
|
35
|
+
projects: projects.readProjects(),
|
|
36
|
+
current: projects.getCurrentProjectName(),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
app.get("/api/projects/current", (c) => {
|
|
40
|
+
return c.json({
|
|
41
|
+
multiProject: projects.isMultiProject(),
|
|
42
|
+
current: projects.getCurrentProjectName(),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
app.post("/api/projects/current", async (c) => {
|
|
46
|
+
if (!projects.isMultiProject())
|
|
47
|
+
return c.json({ error: "Not in multi-project mode" }, 400);
|
|
48
|
+
const body = await c.req.json();
|
|
49
|
+
if (!body.name)
|
|
50
|
+
return c.json({ error: "No project name" }, 400);
|
|
51
|
+
const ok = projects.switchProject(body.name);
|
|
52
|
+
if (!ok)
|
|
53
|
+
return c.json({ error: "Project not found" }, 404);
|
|
54
|
+
// Reload pending diffs for the new project
|
|
55
|
+
reloadPendingDiffs();
|
|
56
|
+
return c.json({ ok: true, current: body.name });
|
|
57
|
+
});
|
|
58
|
+
app.post("/api/projects", async (c) => {
|
|
59
|
+
if (!projects.isMultiProject())
|
|
60
|
+
return c.json({ error: "Not in multi-project mode" }, 400);
|
|
61
|
+
const body = await c.req.json();
|
|
62
|
+
if (!body.name?.trim())
|
|
63
|
+
return c.json({ error: "No project name" }, 400);
|
|
64
|
+
const result = projects.createProject(body.name.trim());
|
|
65
|
+
if (!result.ok)
|
|
66
|
+
return c.json({ error: result.error }, 400);
|
|
67
|
+
// Switch to the new project
|
|
68
|
+
projects.switchProject(body.name.trim());
|
|
69
|
+
reloadPendingDiffs();
|
|
70
|
+
return c.json({ ok: true }, 201);
|
|
71
|
+
});
|
|
72
|
+
app.post("/api/projects/adopt", async (c) => {
|
|
73
|
+
if (!projects.isMultiProject())
|
|
74
|
+
return c.json({ error: "Not in multi-project mode" }, 400);
|
|
75
|
+
const body = await c.req.json();
|
|
76
|
+
if (!body.name?.trim())
|
|
77
|
+
return c.json({ error: "No project name" }, 400);
|
|
78
|
+
const result = projects.adoptProject(body.name.trim());
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
const status = result.error?.includes("not found") ? 404 : 400;
|
|
81
|
+
return c.json({ error: result.error }, status);
|
|
82
|
+
}
|
|
83
|
+
reloadPendingDiffs();
|
|
84
|
+
return c.json({ ok: true });
|
|
85
|
+
});
|
|
86
|
+
app.get("/api/projects/available", (c) => {
|
|
87
|
+
if (!projects.isMultiProject())
|
|
88
|
+
return c.json({ available: [] });
|
|
89
|
+
return c.json({ available: projects.listAvailableSubdirs() });
|
|
90
|
+
});
|
|
91
|
+
// --- Routes ---
|
|
92
|
+
app.get("/", (c) => c.redirect("/chat"));
|
|
93
|
+
// Agent status (for polling)
|
|
94
|
+
app.get("/api/agent-status", (c) => {
|
|
95
|
+
const qs = getQueueState();
|
|
96
|
+
return c.json({ running: qs.agentRunning, runId: qs.currentRunId });
|
|
97
|
+
});
|
|
98
|
+
// Chat — Q&A with Claude + notepad
|
|
99
|
+
app.get("/chat", async (c) => {
|
|
100
|
+
const allMessages = await store.readConversation();
|
|
101
|
+
const messages = allMessages.slice(-50); // Show last 50 in UI
|
|
102
|
+
const pending = [];
|
|
103
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
104
|
+
pending.push({ diffId, summary: diff.task });
|
|
105
|
+
}
|
|
106
|
+
const qs = getQueueState();
|
|
107
|
+
const queue = await store.readQueue();
|
|
108
|
+
const queueCounts = store.getQueueCounts(queue);
|
|
109
|
+
const svcList = await services.getServicesWithStatus();
|
|
110
|
+
const chatServices = svcList.map((s) => ({ id: s.id, name: s.name, status: s.status }));
|
|
111
|
+
const historyEntries = await store.readHistory();
|
|
112
|
+
const recentHistory = historyEntries.slice(-10).reverse();
|
|
113
|
+
return c.html(chatPage(messages, pending, qs.agentRunning, qs.currentRunId, chatPending, queueCounts.pending, chatServices, recentHistory));
|
|
114
|
+
});
|
|
115
|
+
app.post("/chat", async (c) => {
|
|
116
|
+
const body = await c.req.json();
|
|
117
|
+
const message = body.message?.trim();
|
|
118
|
+
if (!message) {
|
|
119
|
+
return c.json({ error: "No message" }, 400);
|
|
120
|
+
}
|
|
121
|
+
let messages = await store.readConversation();
|
|
122
|
+
messages.push({
|
|
123
|
+
role: "user",
|
|
124
|
+
content: message,
|
|
125
|
+
timestamp: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
messages = await store.archiveConversationIfNeeded(messages);
|
|
128
|
+
await store.saveConversation(messages);
|
|
129
|
+
return c.json({ ok: true });
|
|
130
|
+
});
|
|
131
|
+
// Chat with Claude (read-only, no tools)
|
|
132
|
+
let chatPending = false;
|
|
133
|
+
const taskChatPending = new Map();
|
|
134
|
+
app.post("/api/chat/send", async (c) => {
|
|
135
|
+
const body = await c.req.json();
|
|
136
|
+
const message = body.message?.trim();
|
|
137
|
+
if (!message)
|
|
138
|
+
return c.json({ error: "No message" }, 400);
|
|
139
|
+
if (chatPending)
|
|
140
|
+
return c.json({ error: "Chat already pending" }, 409);
|
|
141
|
+
// Save user message
|
|
142
|
+
let messages = await store.readConversation();
|
|
143
|
+
messages.push({
|
|
144
|
+
role: "user",
|
|
145
|
+
content: message,
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
});
|
|
148
|
+
messages = await store.archiveConversationIfNeeded(messages);
|
|
149
|
+
await store.saveConversation(messages);
|
|
150
|
+
// Spawn read-only Claude in background
|
|
151
|
+
chatPending = true;
|
|
152
|
+
(async () => {
|
|
153
|
+
try {
|
|
154
|
+
const response = await chatWithClaude(messages, projects.getProjectRoot());
|
|
155
|
+
let updated = await store.readConversation();
|
|
156
|
+
updated.push({
|
|
157
|
+
role: "assistant",
|
|
158
|
+
content: response,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
updated = await store.archiveConversationIfNeeded(updated);
|
|
162
|
+
await store.saveConversation(updated);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.error("Chat error:", err);
|
|
166
|
+
let updated = await store.readConversation();
|
|
167
|
+
updated.push({
|
|
168
|
+
role: "assistant",
|
|
169
|
+
content: "Sorry, something went wrong getting a response.",
|
|
170
|
+
timestamp: new Date().toISOString(),
|
|
171
|
+
});
|
|
172
|
+
await store.saveConversation(updated);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
chatPending = false;
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
return c.json({ ok: true });
|
|
179
|
+
});
|
|
180
|
+
// Add service via Claude chat
|
|
181
|
+
let addServicePending = false;
|
|
182
|
+
app.post("/api/chat/add-service", async (c) => {
|
|
183
|
+
const body = await c.req.json();
|
|
184
|
+
const message = body.message?.trim();
|
|
185
|
+
if (!message)
|
|
186
|
+
return c.json({ error: "No message" }, 400);
|
|
187
|
+
if (addServicePending || chatPending)
|
|
188
|
+
return c.json({ error: "Chat already pending" }, 409);
|
|
189
|
+
// Save user message
|
|
190
|
+
let messages = await store.readConversation();
|
|
191
|
+
messages.push({
|
|
192
|
+
role: "user",
|
|
193
|
+
content: message,
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
});
|
|
196
|
+
messages = await store.archiveConversationIfNeeded(messages);
|
|
197
|
+
await store.saveConversation(messages);
|
|
198
|
+
// Spawn read-only Claude with service-extraction system prompt
|
|
199
|
+
addServicePending = true;
|
|
200
|
+
chatPending = true;
|
|
201
|
+
(async () => {
|
|
202
|
+
try {
|
|
203
|
+
const systemPrompt = `You are a helpful assistant. The user wants to add a background service (a long-running shell command like a dev server, watcher, etc.).
|
|
204
|
+
|
|
205
|
+
Extract a service definition from their message and respond with:
|
|
206
|
+
1. A brief confirmation message
|
|
207
|
+
2. A JSON block with the service definition
|
|
208
|
+
|
|
209
|
+
The JSON block MUST be wrapped in a code fence like this:
|
|
210
|
+
\`\`\`json
|
|
211
|
+
{ "name": "Service Name", "command": "the shell command", "cwd": "/optional/working/directory" }
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
Rules:
|
|
215
|
+
- "name" should be a short, descriptive name (e.g. "Vite Dev Server", "TypeScript Watcher")
|
|
216
|
+
- "command" is the shell command to run
|
|
217
|
+
- "cwd" is optional — only include it if the user specifies a working directory
|
|
218
|
+
- If you cannot determine a reasonable service definition from the message, respond normally without any JSON block`;
|
|
219
|
+
const response = await chatWithClaude(messages, projects.getProjectRoot(), { systemPrompt });
|
|
220
|
+
// Try to extract JSON service definition from the response
|
|
221
|
+
const jsonMatch = response.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
|
|
222
|
+
let service = null;
|
|
223
|
+
if (jsonMatch) {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(jsonMatch[1]);
|
|
226
|
+
if (parsed.name && parsed.command) {
|
|
227
|
+
const created = await store.createService(parsed.name.trim(), parsed.command.trim(), parsed.cwd?.trim() || undefined);
|
|
228
|
+
service = created;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// JSON parse failed — treat as normal chat response
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Save Claude's response to conversation
|
|
236
|
+
let updated = await store.readConversation();
|
|
237
|
+
updated.push({
|
|
238
|
+
role: "assistant",
|
|
239
|
+
content: response,
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
});
|
|
242
|
+
updated = await store.archiveConversationIfNeeded(updated);
|
|
243
|
+
await store.saveConversation(updated);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
console.error("Add service chat error:", err);
|
|
247
|
+
let updated = await store.readConversation();
|
|
248
|
+
updated.push({
|
|
249
|
+
role: "assistant",
|
|
250
|
+
content: "Sorry, something went wrong getting a response.",
|
|
251
|
+
timestamp: new Date().toISOString(),
|
|
252
|
+
});
|
|
253
|
+
await store.saveConversation(updated);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
addServicePending = false;
|
|
257
|
+
chatPending = false;
|
|
258
|
+
}
|
|
259
|
+
})();
|
|
260
|
+
return c.json({ ok: true });
|
|
261
|
+
});
|
|
262
|
+
app.get("/api/chat/status", (c) => {
|
|
263
|
+
return c.json({ pending: chatPending });
|
|
264
|
+
});
|
|
265
|
+
app.get("/api/chat/messages", async (c) => {
|
|
266
|
+
const after = parseInt(c.req.query("after") || "0", 10);
|
|
267
|
+
const allMessages = await store.readConversation();
|
|
268
|
+
// Filter out agent messages (those with runId) — display-only
|
|
269
|
+
const filtered = allMessages.filter((m) => !m.runId);
|
|
270
|
+
return c.json({
|
|
271
|
+
messages: filtered.slice(after).map((m) => ({
|
|
272
|
+
...m,
|
|
273
|
+
html: marked.parse(m.content, { async: false }),
|
|
274
|
+
})),
|
|
275
|
+
total: filtered.length,
|
|
276
|
+
pending: chatPending,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
app.post("/chat/reset", async (c) => {
|
|
280
|
+
await store.clearConversation();
|
|
281
|
+
return c.redirect("/chat");
|
|
282
|
+
});
|
|
283
|
+
// --- Task chat helpers ---
|
|
284
|
+
async function buildTaskChatPrompt(taskId, task, diffContent) {
|
|
285
|
+
const spec = await store.readSpec();
|
|
286
|
+
const diffSection = diffContent
|
|
287
|
+
? `\nCurrent diff (changes made so far):\n\`\`\`diff\n${diffContent.slice(0, 10000)}\n\`\`\`\n`
|
|
288
|
+
: "";
|
|
289
|
+
return `You are a helpful assistant for a software project. You can read files and search the codebase to answer questions accurately, but do not modify anything. You have access to Read, Glob, Grep, and LS tools for discovery.
|
|
290
|
+
|
|
291
|
+
Project spec:
|
|
292
|
+
${spec}
|
|
293
|
+
|
|
294
|
+
Task #${taskId}: ${task.meta.title}
|
|
295
|
+
Task spec:
|
|
296
|
+
${task.spec}
|
|
297
|
+
${diffSection}`;
|
|
298
|
+
}
|
|
299
|
+
// --- Task chat routes ---
|
|
300
|
+
app.get("/api/tasks/:id/chat", async (c) => {
|
|
301
|
+
const id = c.req.param("id");
|
|
302
|
+
const taskIdStr = String(id);
|
|
303
|
+
const after = parseInt(c.req.query("after") || "0", 10);
|
|
304
|
+
const messages = await store.readTaskConversation(taskIdStr);
|
|
305
|
+
const pending = taskChatPending.get(id) || false;
|
|
306
|
+
return c.json({
|
|
307
|
+
messages: messages.slice(after).map((m) => ({
|
|
308
|
+
...m,
|
|
309
|
+
html: marked.parse(m.content, { async: false }),
|
|
310
|
+
})),
|
|
311
|
+
total: messages.length,
|
|
312
|
+
pending,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
app.post("/api/tasks/:id/chat", async (c) => {
|
|
316
|
+
const id = c.req.param("id");
|
|
317
|
+
const body = await c.req.json();
|
|
318
|
+
const message = body.message?.trim();
|
|
319
|
+
if (!message)
|
|
320
|
+
return c.json({ error: "No message" }, 400);
|
|
321
|
+
if (taskChatPending.get(id))
|
|
322
|
+
return c.json({ error: "Chat already pending" }, 409);
|
|
323
|
+
const task = await store.getTask(id);
|
|
324
|
+
if (!task)
|
|
325
|
+
return c.json({ error: "Task not found" }, 404);
|
|
326
|
+
// Save user message to task conversation
|
|
327
|
+
let messages = await store.readTaskConversation(id);
|
|
328
|
+
messages.push({
|
|
329
|
+
role: "user",
|
|
330
|
+
content: message,
|
|
331
|
+
timestamp: new Date().toISOString(),
|
|
332
|
+
});
|
|
333
|
+
messages = await store.archiveTaskConversationIfNeeded(id, messages);
|
|
334
|
+
await store.saveTaskConversation(id, messages);
|
|
335
|
+
// Spawn read-only Claude in background with task context
|
|
336
|
+
taskChatPending.set(id, true);
|
|
337
|
+
(async () => {
|
|
338
|
+
try {
|
|
339
|
+
const systemPrompt = await buildTaskChatPrompt(id, task);
|
|
340
|
+
const taskConv = await store.readTaskConversation(id);
|
|
341
|
+
const response = await chatWithClaude(taskConv, projects.getProjectRoot(), { systemPrompt });
|
|
342
|
+
let updated = await store.readTaskConversation(id);
|
|
343
|
+
updated.push({
|
|
344
|
+
role: "assistant",
|
|
345
|
+
content: response,
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
});
|
|
348
|
+
updated = await store.archiveTaskConversationIfNeeded(id, updated);
|
|
349
|
+
await store.saveTaskConversation(id, updated);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
console.error("Task chat error:", err);
|
|
353
|
+
let updated = await store.readTaskConversation(id);
|
|
354
|
+
updated.push({
|
|
355
|
+
role: "assistant",
|
|
356
|
+
content: "Sorry, something went wrong getting a response.",
|
|
357
|
+
timestamp: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
await store.saveTaskConversation(id, updated);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
taskChatPending.set(id, false);
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
return c.json({ ok: true });
|
|
366
|
+
});
|
|
367
|
+
// --- Agent lifecycle routes ---
|
|
368
|
+
app.post("/agent/draft-task", async (c) => {
|
|
369
|
+
const body = await c.req.json().catch(() => ({}));
|
|
370
|
+
const userMessage = body.message?.trim();
|
|
371
|
+
if (!userMessage) {
|
|
372
|
+
return c.json({ error: "No message" }, 400);
|
|
373
|
+
}
|
|
374
|
+
const item = await enqueue("draft-task", { message: userMessage });
|
|
375
|
+
return c.json({ ok: true, id: item.id, taskId: item.params.taskId });
|
|
376
|
+
});
|
|
377
|
+
app.post("/agent/refine-task/:id", async (c) => {
|
|
378
|
+
const id = c.req.param("id");
|
|
379
|
+
const task = await store.getTask(id);
|
|
380
|
+
if (!task)
|
|
381
|
+
return c.json({ error: "Task not found" }, 404);
|
|
382
|
+
const body = await c.req.json().catch(() => ({}));
|
|
383
|
+
const userMessage = body.message?.trim() || "Refine this task spec based on recent chat feedback.";
|
|
384
|
+
const item = await enqueue("refine-task", { taskId: id, message: userMessage });
|
|
385
|
+
return c.json({ ok: true, id: item.id });
|
|
386
|
+
});
|
|
387
|
+
// Agent progress page
|
|
388
|
+
app.get("/agent/:runId", async (c) => {
|
|
389
|
+
const runId = c.req.param("runId");
|
|
390
|
+
const events = await store.readRunLog(runId);
|
|
391
|
+
const qs = getQueueState();
|
|
392
|
+
const isRunning = qs.agentRunning && qs.currentRunId === runId;
|
|
393
|
+
return c.html(agentProgressPage(runId, events, isRunning));
|
|
394
|
+
});
|
|
395
|
+
// Agent events polling endpoint
|
|
396
|
+
app.get("/api/agent/:runId/events", async (c) => {
|
|
397
|
+
const runId = c.req.param("runId");
|
|
398
|
+
const after = parseInt(c.req.query("after") || "0", 10);
|
|
399
|
+
const events = await store.readRunLog(runId);
|
|
400
|
+
const newEvents = events.slice(after);
|
|
401
|
+
const qs = getQueueState();
|
|
402
|
+
const isRunning = qs.agentRunning && qs.currentRunId === runId;
|
|
403
|
+
// Check if there's a diff_ready or auto_committed event
|
|
404
|
+
let diffId = null;
|
|
405
|
+
let autoCommitted = false;
|
|
406
|
+
for (const ev of events) {
|
|
407
|
+
if (ev.type === "diff_ready") {
|
|
408
|
+
diffId = ev.diffId;
|
|
409
|
+
}
|
|
410
|
+
if (ev.type === "auto_committed") {
|
|
411
|
+
autoCommitted = true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Check if agent is done (has a done event or diff_ready or agent_done_no_changes or auto_committed or agent_done_no_commit)
|
|
415
|
+
const isDone = events.some((e) => e.type === "done" || e.type === "diff_ready" || e.type === "agent_done_no_changes" || e.type === "auto_committed" || e.type === "agent_done_no_commit");
|
|
416
|
+
return c.json({
|
|
417
|
+
events: newEvents,
|
|
418
|
+
total: events.length,
|
|
419
|
+
done: isDone && !isRunning,
|
|
420
|
+
running: isRunning,
|
|
421
|
+
diffId,
|
|
422
|
+
autoCommitted,
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
// Tasks API (for polling)
|
|
426
|
+
app.get("/api/tasks", async (c) => {
|
|
427
|
+
const tasks = await store.listTasks();
|
|
428
|
+
reloadPendingDiffs();
|
|
429
|
+
const taskDiffs = {};
|
|
430
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
431
|
+
if (diff.taskId) {
|
|
432
|
+
taskDiffs[diff.taskId] = diffId;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const qs = getQueueState();
|
|
436
|
+
const queue = await store.readQueue();
|
|
437
|
+
let agentTaskId = null;
|
|
438
|
+
if (qs.agentRunning && qs.currentRunId) {
|
|
439
|
+
const running = queue.find((item) => item.status === "running");
|
|
440
|
+
if (running?.params.taskId)
|
|
441
|
+
agentTaskId = running.params.taskId;
|
|
442
|
+
}
|
|
443
|
+
// Collect task IDs with pending/running queue items
|
|
444
|
+
const pendingQueueTaskIds = queue
|
|
445
|
+
.filter((item) => (item.status === "pending" || item.status === "running") && item.params.taskId)
|
|
446
|
+
.map((item) => item.params.taskId);
|
|
447
|
+
return c.json({
|
|
448
|
+
tasks,
|
|
449
|
+
taskDiffs,
|
|
450
|
+
agentRunning: qs.agentRunning,
|
|
451
|
+
currentRunId: qs.currentRunId,
|
|
452
|
+
agentTaskId,
|
|
453
|
+
pendingQueueTaskIds,
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
app.get("/api/tasks/:id/status", async (c) => {
|
|
457
|
+
const id = c.req.param("id");
|
|
458
|
+
const task = await store.getTask(id);
|
|
459
|
+
if (!task)
|
|
460
|
+
return c.json({ error: "Task not found" }, 404);
|
|
461
|
+
const qs = getQueueState();
|
|
462
|
+
const queue = await store.readQueue();
|
|
463
|
+
let agentTaskId = null;
|
|
464
|
+
if (qs.agentRunning && qs.currentRunId) {
|
|
465
|
+
const running = queue.find((item) => item.status === "running");
|
|
466
|
+
if (running?.params.taskId)
|
|
467
|
+
agentTaskId = running.params.taskId;
|
|
468
|
+
}
|
|
469
|
+
reloadPendingDiffs();
|
|
470
|
+
let pendingDiffId = null;
|
|
471
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
472
|
+
if (diff.taskId === id) {
|
|
473
|
+
pendingDiffId = diffId;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const hasPendingQueueItem = queue.some((item) => (item.status === "pending" || item.status === "running") && item.params.taskId === id);
|
|
478
|
+
// Find conflict error if in conflict status
|
|
479
|
+
let conflictError = null;
|
|
480
|
+
if (task.meta.status === "conflict") {
|
|
481
|
+
const conflictItem = findConflictItem(queue, id);
|
|
482
|
+
if (conflictItem) {
|
|
483
|
+
conflictError = conflictItem.error || "Merge conflict occurred";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const specHtml = task.spec
|
|
487
|
+
? marked.parse(task.spec, { async: false })
|
|
488
|
+
: '<p class="empty-state">No task spec written yet.</p>';
|
|
489
|
+
return c.json({
|
|
490
|
+
status: task.meta.status,
|
|
491
|
+
title: task.meta.title,
|
|
492
|
+
agentRunning: qs.agentRunning,
|
|
493
|
+
currentRunId: qs.currentRunId,
|
|
494
|
+
agentTaskId,
|
|
495
|
+
pendingDiffId,
|
|
496
|
+
chatPending: taskChatPending.get(id) || false,
|
|
497
|
+
hasSpec: store.hasRealSpec(task.spec),
|
|
498
|
+
hasPendingQueueItem,
|
|
499
|
+
conflictError,
|
|
500
|
+
specHtml,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
// Tasks
|
|
504
|
+
app.get("/tasks", async (c) => {
|
|
505
|
+
const tasks = await store.listTasks();
|
|
506
|
+
// Build pending diffs map: taskId -> diffId
|
|
507
|
+
reloadPendingDiffs();
|
|
508
|
+
const taskDiffs = new Map();
|
|
509
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
510
|
+
if (diff.taskId) {
|
|
511
|
+
taskDiffs.set(diff.taskId, diffId);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Determine which task has a running agent and which have pending queue items
|
|
515
|
+
const qs = getQueueState();
|
|
516
|
+
const queue = await store.readQueue();
|
|
517
|
+
let agentTaskId = null;
|
|
518
|
+
if (qs.agentRunning && qs.currentRunId) {
|
|
519
|
+
const running = queue.find((item) => item.status === "running");
|
|
520
|
+
if (running?.params.taskId)
|
|
521
|
+
agentTaskId = running.params.taskId;
|
|
522
|
+
}
|
|
523
|
+
const pendingQueueTaskIds = new Set(queue
|
|
524
|
+
.filter((item) => (item.status === "pending" || item.status === "running") && item.params.taskId)
|
|
525
|
+
.map((item) => item.params.taskId));
|
|
526
|
+
return c.html(tasksListPage(tasks, taskDiffs, qs.agentRunning, qs.currentRunId, agentTaskId, pendingQueueTaskIds));
|
|
527
|
+
});
|
|
528
|
+
app.get("/tasks/:id", async (c) => {
|
|
529
|
+
const id = c.req.param("id");
|
|
530
|
+
const task = await store.getTask(id);
|
|
531
|
+
if (!task)
|
|
532
|
+
return c.html("<p>Task not found.</p>", 404);
|
|
533
|
+
const taskMessages = (task.meta.status === "drafting" || task.meta.status === "refining")
|
|
534
|
+
? await store.readTaskConversation(id)
|
|
535
|
+
: [];
|
|
536
|
+
const taskChatIsPending = taskChatPending.get(id) || false;
|
|
537
|
+
const report = task.meta.status === "complete"
|
|
538
|
+
? await store.getTaskReport(id)
|
|
539
|
+
: null;
|
|
540
|
+
const qs = getQueueState();
|
|
541
|
+
const queue = await store.readQueue();
|
|
542
|
+
let detailAgentTaskId = null;
|
|
543
|
+
if (qs.agentRunning && qs.currentRunId) {
|
|
544
|
+
const running = queue.find((item) => item.status === "running");
|
|
545
|
+
if (running?.params.taskId)
|
|
546
|
+
detailAgentTaskId = running.params.taskId;
|
|
547
|
+
}
|
|
548
|
+
// Look up pending diff for this task
|
|
549
|
+
reloadPendingDiffs();
|
|
550
|
+
let pendingDiffId;
|
|
551
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
552
|
+
if (diff.taskId === id) {
|
|
553
|
+
pendingDiffId = diffId;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const hasPendingQueueItem = queue.some((item) => (item.status === "pending" || item.status === "running") && item.params.taskId === id);
|
|
558
|
+
// Find conflict error if in conflict status
|
|
559
|
+
let conflictError;
|
|
560
|
+
if (task.meta.status === "conflict") {
|
|
561
|
+
const conflictItem = findConflictItem(queue, task.meta.id);
|
|
562
|
+
if (conflictItem) {
|
|
563
|
+
conflictError = conflictItem.error || "Merge conflict occurred";
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return c.html(taskDetailPage(task.meta, task.spec, qs.agentRunning, qs.currentRunId, taskMessages, taskChatIsPending, report, detailAgentTaskId, pendingDiffId, hasPendingQueueItem, conflictError));
|
|
567
|
+
});
|
|
568
|
+
app.post("/tasks/:id/status", async (c) => {
|
|
569
|
+
const id = c.req.param("id");
|
|
570
|
+
const body = await c.req.json();
|
|
571
|
+
if (body.status === "rejected") {
|
|
572
|
+
// Cancel task: clean up pending queue items and pending diffs
|
|
573
|
+
const queue = await store.readQueue();
|
|
574
|
+
for (const item of queue) {
|
|
575
|
+
if (item.params.taskId === id && item.status === "pending") {
|
|
576
|
+
await store.removeQueueItem(item.id);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Clean up any pending diff for this task
|
|
580
|
+
reloadPendingDiffs();
|
|
581
|
+
for (const [diffId, diff] of pendingDiffs) {
|
|
582
|
+
if (diff.taskId === id) {
|
|
583
|
+
if (diff.stashRef) {
|
|
584
|
+
await git.stashDrop(projects.getProjectRoot(), diff.stashRef);
|
|
585
|
+
}
|
|
586
|
+
pendingDiffs.delete(diffId);
|
|
587
|
+
persistPendingDiffs(pendingDiffs);
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
await store.updateTaskStatus(id, "rejected");
|
|
592
|
+
await store.appendHistory({
|
|
593
|
+
timestamp: new Date().toISOString(),
|
|
594
|
+
type: "rejection",
|
|
595
|
+
summary: `Cancelled task #${id}`,
|
|
596
|
+
});
|
|
597
|
+
return c.json({ ok: true });
|
|
598
|
+
}
|
|
599
|
+
if (body.status === "in_progress") {
|
|
600
|
+
const item = await enqueue("start-work", { taskId: id });
|
|
601
|
+
return c.json({ ok: true, id: item.id });
|
|
602
|
+
}
|
|
603
|
+
// When marking ready, commit spec + conversation
|
|
604
|
+
if (body.status === "ready") {
|
|
605
|
+
const task = await store.getTask(id);
|
|
606
|
+
if (task) {
|
|
607
|
+
if (!store.hasRealSpec(task.spec)) {
|
|
608
|
+
return c.json({ error: "Cannot mark ready — task spec is still a placeholder. Wait for the drafting agent to finish or refine the spec first." }, 400);
|
|
609
|
+
}
|
|
610
|
+
await store.updateTaskStatus(id, "ready");
|
|
611
|
+
try {
|
|
612
|
+
const specPath = store.getTaskSpecPath(id, task.meta.slug);
|
|
613
|
+
const convPath = store.getTaskConversationPath(id, task.meta.slug);
|
|
614
|
+
await git.ensureRepo(projects.getProjectRoot());
|
|
615
|
+
// Stage specific files and commit
|
|
616
|
+
const { execFile } = await import("node:child_process");
|
|
617
|
+
const { promisify } = await import("node:util");
|
|
618
|
+
const execAsync = promisify(execFile);
|
|
619
|
+
await execAsync("git", ["add", specPath, convPath], { cwd: projects.getProjectRoot() });
|
|
620
|
+
try {
|
|
621
|
+
await execAsync("git", ["commit", "-m", `task #${id}: Mark ready — ${task.meta.title}`], { cwd: projects.getProjectRoot() });
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// Nothing to commit (no changes to spec/conversation)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
console.error("Mark ready commit error:", err);
|
|
629
|
+
}
|
|
630
|
+
return c.json({ ok: true });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
await store.updateTaskStatus(id, body.status);
|
|
634
|
+
return c.json({ ok: true });
|
|
635
|
+
});
|
|
636
|
+
// Spec
|
|
637
|
+
app.get("/spec", async (c) => {
|
|
638
|
+
const spec = await store.readSpec();
|
|
639
|
+
return c.html(specPage(spec));
|
|
640
|
+
});
|
|
641
|
+
app.get("/api/spec", async (c) => {
|
|
642
|
+
const spec = await store.readSpec();
|
|
643
|
+
return c.text(spec);
|
|
644
|
+
});
|
|
645
|
+
// Diff review
|
|
646
|
+
app.get("/diff/:id", async (c) => {
|
|
647
|
+
const id = c.req.param("id");
|
|
648
|
+
reloadPendingDiffs();
|
|
649
|
+
const pending = pendingDiffs.get(id);
|
|
650
|
+
if (!pending) {
|
|
651
|
+
return c.html("<p>Diff not found or already reviewed.</p>", 404);
|
|
652
|
+
}
|
|
653
|
+
const chatMessages = pending.taskId
|
|
654
|
+
? await store.readTaskConversation(pending.taskId)
|
|
655
|
+
: [];
|
|
656
|
+
const chatPending = pending.taskId
|
|
657
|
+
? taskChatPending.get(pending.taskId) || false
|
|
658
|
+
: false;
|
|
659
|
+
// Extract acceptance criteria from task spec if available
|
|
660
|
+
let acceptanceCriteria;
|
|
661
|
+
if (pending.taskId) {
|
|
662
|
+
const taskSpec = await store.readTaskSpec(pending.taskId);
|
|
663
|
+
if (taskSpec) {
|
|
664
|
+
const match = taskSpec.match(/\n## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n---|\s*$)/i);
|
|
665
|
+
if (match) {
|
|
666
|
+
acceptanceCriteria = match[1].trim();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return c.html(diffPage(id, pending.diffContent, pending.task, pending.taskId, chatMessages, chatPending, acceptanceCriteria, pending.agentSummary, pending.checks));
|
|
671
|
+
});
|
|
672
|
+
app.post("/diff/:id/approve", async (c) => {
|
|
673
|
+
const id = c.req.param("id");
|
|
674
|
+
reloadPendingDiffs();
|
|
675
|
+
const pending = pendingDiffs.get(id);
|
|
676
|
+
if (!pending) {
|
|
677
|
+
return c.json({ ok: false, error: "Diff not found" }, 404);
|
|
678
|
+
}
|
|
679
|
+
// Apply stash + commit immediately (no queue)
|
|
680
|
+
try {
|
|
681
|
+
if (pending.stashRef) {
|
|
682
|
+
await git.stashApply(pending.cwd, pending.stashRef);
|
|
683
|
+
}
|
|
684
|
+
const commitHash = await git.commit(pending.cwd, `checkpoint: ${pending.task}`);
|
|
685
|
+
if (pending.stashRef) {
|
|
686
|
+
await git.stashDrop(pending.cwd, pending.stashRef);
|
|
687
|
+
}
|
|
688
|
+
await store.appendHistory({
|
|
689
|
+
timestamp: new Date().toISOString(),
|
|
690
|
+
type: "checkpoint",
|
|
691
|
+
summary: pending.task,
|
|
692
|
+
details: `commit: ${commitHash}, from: ${pending.snapshotHash}`,
|
|
693
|
+
});
|
|
694
|
+
pendingDiffs.delete(id);
|
|
695
|
+
persistPendingDiffs(pendingDiffs);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
return c.json({ ok: false, error: err.message }, 500);
|
|
699
|
+
}
|
|
700
|
+
// If this was a task diff, enqueue spec-update agent
|
|
701
|
+
if (pending.taskId && !pending.specUpdate) {
|
|
702
|
+
const item = await enqueue("spec-update", { taskId: pending.taskId });
|
|
703
|
+
return c.json({ ok: true, id: item.id, specUpdate: true });
|
|
704
|
+
}
|
|
705
|
+
return c.json({ ok: true, specUpdate: false });
|
|
706
|
+
});
|
|
707
|
+
app.post("/diff/:id/reject", async (c) => {
|
|
708
|
+
const id = c.req.param("id");
|
|
709
|
+
reloadPendingDiffs();
|
|
710
|
+
const pending = pendingDiffs.get(id);
|
|
711
|
+
if (!pending) {
|
|
712
|
+
return c.json({ ok: false, error: "Diff not found" }, 404);
|
|
713
|
+
}
|
|
714
|
+
if (pending.stashRef) {
|
|
715
|
+
await git.stashDrop(pending.cwd, pending.stashRef);
|
|
716
|
+
}
|
|
717
|
+
await store.appendHistory({
|
|
718
|
+
timestamp: new Date().toISOString(),
|
|
719
|
+
type: "rejection",
|
|
720
|
+
summary: `Rejected: ${pending.task}`,
|
|
721
|
+
details: `Snapshot: ${pending.snapshotHash}`,
|
|
722
|
+
});
|
|
723
|
+
pendingDiffs.delete(id);
|
|
724
|
+
persistPendingDiffs(pendingDiffs);
|
|
725
|
+
return c.json({ ok: true });
|
|
726
|
+
});
|
|
727
|
+
// Fix task (targeted fix during diff review)
|
|
728
|
+
app.post("/api/tasks/:id/fix", async (c) => {
|
|
729
|
+
const taskId = c.req.param("id");
|
|
730
|
+
const body = await c.req.json();
|
|
731
|
+
const message = body.message?.trim();
|
|
732
|
+
if (!message)
|
|
733
|
+
return c.json({ error: "No message" }, 400);
|
|
734
|
+
const task = await store.getTask(taskId);
|
|
735
|
+
if (!task)
|
|
736
|
+
return c.json({ error: "Task not found" }, 404);
|
|
737
|
+
const item = await enqueue("fix-task", { taskId, message, diffId: body.diffId });
|
|
738
|
+
return c.json({ ok: true, id: item.id });
|
|
739
|
+
});
|
|
740
|
+
// --- Conflict resolution ---
|
|
741
|
+
app.post("/api/tasks/:id/conflict/restart", async (c) => {
|
|
742
|
+
const taskId = c.req.param("id");
|
|
743
|
+
const ok = await resolveConflictRestart(taskId);
|
|
744
|
+
if (!ok)
|
|
745
|
+
return c.json({ error: "No conflict found for this task" }, 404);
|
|
746
|
+
return c.json({ ok: true });
|
|
747
|
+
});
|
|
748
|
+
app.post("/api/tasks/:id/conflict/agent-resolve", async (c) => {
|
|
749
|
+
const taskId = c.req.param("id");
|
|
750
|
+
const item = await resolveConflictAgent(taskId);
|
|
751
|
+
if (!item)
|
|
752
|
+
return c.json({ error: "No conflict found for this task" }, 404);
|
|
753
|
+
return c.json({ ok: true, id: item.id });
|
|
754
|
+
});
|
|
755
|
+
app.post("/api/tasks/:id/conflict/manual", async (c) => {
|
|
756
|
+
const taskId = c.req.param("id");
|
|
757
|
+
const result = await resolveConflictManual(taskId);
|
|
758
|
+
if (!result)
|
|
759
|
+
return c.json({ error: "No conflict found for this task" }, 404);
|
|
760
|
+
return c.json({ ok: true, conflicts: result.conflicts });
|
|
761
|
+
});
|
|
762
|
+
app.post("/api/tasks/:id/conflict/done", async (c) => {
|
|
763
|
+
const taskId = c.req.param("id");
|
|
764
|
+
const diffId = await resolveConflictDone(taskId);
|
|
765
|
+
if (!diffId)
|
|
766
|
+
return c.json({ error: "No changes to review" }, 400);
|
|
767
|
+
return c.json({ ok: true, diffId });
|
|
768
|
+
});
|
|
769
|
+
// --- Services ---
|
|
770
|
+
app.get("/services", async (c) => {
|
|
771
|
+
const svcList = await services.getServicesWithStatus();
|
|
772
|
+
return c.html(servicesPage(svcList));
|
|
773
|
+
});
|
|
774
|
+
app.get("/services/:id/logs", async (c) => {
|
|
775
|
+
const id = c.req.param("id");
|
|
776
|
+
const service = await store.getService(id);
|
|
777
|
+
if (!service)
|
|
778
|
+
return c.html("<p>Service not found.</p>", 404);
|
|
779
|
+
const status = services.getServiceStatus(id);
|
|
780
|
+
const logs = await services.getServiceLogs(id);
|
|
781
|
+
return c.html(serviceLogsPage({ ...service, ...status }, logs));
|
|
782
|
+
});
|
|
783
|
+
app.get("/api/services", async (c) => {
|
|
784
|
+
const svcList = await services.getServicesWithStatus();
|
|
785
|
+
return c.json(svcList);
|
|
786
|
+
});
|
|
787
|
+
app.post("/api/services", async (c) => {
|
|
788
|
+
const body = await c.req.json();
|
|
789
|
+
if (!body.name?.trim() || !body.command?.trim()) {
|
|
790
|
+
return c.json({ error: "Name and command required" }, 400);
|
|
791
|
+
}
|
|
792
|
+
const service = await store.createService(body.name.trim(), body.command.trim(), body.cwd?.trim() || undefined);
|
|
793
|
+
return c.json(service, 201);
|
|
794
|
+
});
|
|
795
|
+
app.put("/api/services/:id", async (c) => {
|
|
796
|
+
const id = c.req.param("id");
|
|
797
|
+
const status = services.getServiceStatus(id);
|
|
798
|
+
if (status.status === "running") {
|
|
799
|
+
return c.json({ error: "Cannot edit a running service" }, 400);
|
|
800
|
+
}
|
|
801
|
+
const body = await c.req.json();
|
|
802
|
+
const updated = await store.updateService(id, {
|
|
803
|
+
name: body.name?.trim(),
|
|
804
|
+
command: body.command?.trim(),
|
|
805
|
+
cwd: body.cwd?.trim(),
|
|
806
|
+
});
|
|
807
|
+
if (!updated)
|
|
808
|
+
return c.json({ error: "Service not found" }, 404);
|
|
809
|
+
return c.json({ ok: true });
|
|
810
|
+
});
|
|
811
|
+
app.delete("/api/services/:id", async (c) => {
|
|
812
|
+
const id = c.req.param("id");
|
|
813
|
+
const status = services.getServiceStatus(id);
|
|
814
|
+
if (status.status === "running") {
|
|
815
|
+
return c.json({ error: "Cannot delete a running service" }, 400);
|
|
816
|
+
}
|
|
817
|
+
const deleted = await store.deleteService(id);
|
|
818
|
+
if (!deleted)
|
|
819
|
+
return c.json({ error: "Service not found" }, 404);
|
|
820
|
+
return c.json({ ok: true });
|
|
821
|
+
});
|
|
822
|
+
app.post("/api/services/:id/start", async (c) => {
|
|
823
|
+
const id = c.req.param("id");
|
|
824
|
+
const result = await services.startService(id);
|
|
825
|
+
if (!result.ok)
|
|
826
|
+
return c.json({ error: result.error }, 400);
|
|
827
|
+
return c.json({ ok: true });
|
|
828
|
+
});
|
|
829
|
+
app.post("/api/services/:id/stop", async (c) => {
|
|
830
|
+
const id = c.req.param("id");
|
|
831
|
+
const result = await services.stopService(id);
|
|
832
|
+
if (!result.ok)
|
|
833
|
+
return c.json({ error: result.error }, 400);
|
|
834
|
+
return c.json({ ok: true });
|
|
835
|
+
});
|
|
836
|
+
app.post("/api/services/:id/restart", async (c) => {
|
|
837
|
+
const id = c.req.param("id");
|
|
838
|
+
const result = await services.restartService(id);
|
|
839
|
+
if (!result.ok)
|
|
840
|
+
return c.json({ error: result.error }, 400);
|
|
841
|
+
return c.json({ ok: true });
|
|
842
|
+
});
|
|
843
|
+
app.get("/api/services/:id/logs", async (c) => {
|
|
844
|
+
const id = c.req.param("id");
|
|
845
|
+
const tailLines = parseInt(c.req.query("lines") || "200", 10);
|
|
846
|
+
const logs = await services.getServiceLogs(id, tailLines);
|
|
847
|
+
return c.json({ logs });
|
|
848
|
+
});
|
|
849
|
+
app.post("/api/services/:id/logs/clear", async (c) => {
|
|
850
|
+
const id = c.req.param("id");
|
|
851
|
+
await services.clearServiceLogs(id);
|
|
852
|
+
return c.json({ ok: true });
|
|
853
|
+
});
|
|
854
|
+
// --- Git sync routes ---
|
|
855
|
+
app.get("/api/git/status", async (c) => {
|
|
856
|
+
const cwd = projects.getProjectRoot();
|
|
857
|
+
const status = await git.getRemoteStatus(cwd);
|
|
858
|
+
const outgoing = status.ahead > 0 ? await git.getOutgoingCommits(cwd) : [];
|
|
859
|
+
return c.json({ ...status, outgoing });
|
|
860
|
+
});
|
|
861
|
+
app.post("/api/git/push", async (c) => {
|
|
862
|
+
const result = await git.push(projects.getProjectRoot());
|
|
863
|
+
return c.json(result);
|
|
864
|
+
});
|
|
865
|
+
app.post("/api/git/pull", async (c) => {
|
|
866
|
+
const body = await c.req.json().catch(() => ({}));
|
|
867
|
+
const result = body.rebase
|
|
868
|
+
? await git.pullRebase(projects.getProjectRoot())
|
|
869
|
+
: await git.pull(projects.getProjectRoot());
|
|
870
|
+
return c.json(result);
|
|
871
|
+
});
|
|
872
|
+
// --- Branch management routes ---
|
|
873
|
+
app.get("/branches", async (c) => {
|
|
874
|
+
const cwd = projects.getProjectRoot();
|
|
875
|
+
const { local, current } = await git.listBranches(cwd);
|
|
876
|
+
const branchState = await store.readBranches();
|
|
877
|
+
// Check for pending diffs
|
|
878
|
+
reloadPendingDiffs();
|
|
879
|
+
const hasPendingDiffs = pendingDiffs.size > 0;
|
|
880
|
+
const branches = local.map((name) => ({
|
|
881
|
+
name,
|
|
882
|
+
isCurrent: name === current,
|
|
883
|
+
info: branchState.branches.find((b) => b.name === name),
|
|
884
|
+
}));
|
|
885
|
+
return c.html(branchesPage(branches, current, hasPendingDiffs));
|
|
886
|
+
});
|
|
887
|
+
app.get("/api/branches", async (c) => {
|
|
888
|
+
const cwd = projects.getProjectRoot();
|
|
889
|
+
const { local, current } = await git.listBranches(cwd);
|
|
890
|
+
const branchState = await store.readBranches();
|
|
891
|
+
return c.json({ branches: local, current, state: branchState });
|
|
892
|
+
});
|
|
893
|
+
app.post("/api/branches", async (c) => {
|
|
894
|
+
const body = await c.req.json();
|
|
895
|
+
const name = body.name?.trim();
|
|
896
|
+
if (!name)
|
|
897
|
+
return c.json({ error: "No branch name" }, 400);
|
|
898
|
+
// Validate branch name (basic)
|
|
899
|
+
if (/[\s~^:?*\[\\]/.test(name)) {
|
|
900
|
+
return c.json({ error: "Invalid branch name" }, 400);
|
|
901
|
+
}
|
|
902
|
+
const cwd = projects.getProjectRoot();
|
|
903
|
+
try {
|
|
904
|
+
const currentBranch = await git.getCurrentBranch(cwd);
|
|
905
|
+
await git.createBranch(cwd, name);
|
|
906
|
+
// Track in branch state
|
|
907
|
+
const state = await store.readBranches();
|
|
908
|
+
state.active = name;
|
|
909
|
+
state.branches.push({
|
|
910
|
+
name,
|
|
911
|
+
createdFrom: currentBranch,
|
|
912
|
+
createdAt: new Date().toISOString(),
|
|
913
|
+
taskIds: [],
|
|
914
|
+
});
|
|
915
|
+
await store.saveBranches(state);
|
|
916
|
+
return c.json({ ok: true, branch: name });
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
return c.json({ error: err.stderr || err.message }, 400);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
app.post("/api/branches/switch", async (c) => {
|
|
923
|
+
const body = await c.req.json();
|
|
924
|
+
const name = body.name?.trim();
|
|
925
|
+
if (!name)
|
|
926
|
+
return c.json({ error: "No branch name" }, 400);
|
|
927
|
+
// Block if pending diffs exist
|
|
928
|
+
reloadPendingDiffs();
|
|
929
|
+
if (pendingDiffs.size > 0) {
|
|
930
|
+
return c.json({ error: "Cannot switch branches while diffs are pending review. Approve or reject them first." }, 400);
|
|
931
|
+
}
|
|
932
|
+
const cwd = projects.getProjectRoot();
|
|
933
|
+
try {
|
|
934
|
+
await git.switchBranch(cwd, name);
|
|
935
|
+
// Update branch state
|
|
936
|
+
const state = await store.readBranches();
|
|
937
|
+
const isDefault = name === "master" || name === "main";
|
|
938
|
+
state.active = isDefault ? null : name;
|
|
939
|
+
await store.saveBranches(state);
|
|
940
|
+
return c.json({ ok: true, branch: name });
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
return c.json({ error: err.stderr || err.message }, 400);
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
app.post("/api/branches/:name/push", async (c) => {
|
|
947
|
+
const name = c.req.param("name");
|
|
948
|
+
const cwd = projects.getProjectRoot();
|
|
949
|
+
const result = await git.pushBranch(cwd, name);
|
|
950
|
+
return c.json(result);
|
|
951
|
+
});
|
|
952
|
+
app.post("/api/branches/:name/merge", async (c) => {
|
|
953
|
+
const name = c.req.param("name");
|
|
954
|
+
const cwd = projects.getProjectRoot();
|
|
955
|
+
const branchState = await store.readBranches();
|
|
956
|
+
const branchInfo = branchState.branches.find((b) => b.name === name);
|
|
957
|
+
const target = branchInfo?.createdFrom || "master";
|
|
958
|
+
const result = await git.mergeBranch(cwd, name, target);
|
|
959
|
+
if (result.success) {
|
|
960
|
+
// Update branch state — switch active to target
|
|
961
|
+
const isDefault = target === "master" || target === "main";
|
|
962
|
+
branchState.active = isDefault ? null : target;
|
|
963
|
+
await store.saveBranches(branchState);
|
|
964
|
+
}
|
|
965
|
+
return c.json(result);
|
|
966
|
+
});
|
|
967
|
+
app.delete("/api/branches/:name", async (c) => {
|
|
968
|
+
const name = c.req.param("name");
|
|
969
|
+
const cwd = projects.getProjectRoot();
|
|
970
|
+
// Don't delete current branch
|
|
971
|
+
const current = await git.getCurrentBranch(cwd);
|
|
972
|
+
if (current === name) {
|
|
973
|
+
return c.json({ error: "Cannot delete the current branch" }, 400);
|
|
974
|
+
}
|
|
975
|
+
const result = await git.deleteBranch(cwd, name);
|
|
976
|
+
if (result.success) {
|
|
977
|
+
// Remove from branch state
|
|
978
|
+
const state = await store.readBranches();
|
|
979
|
+
state.branches = state.branches.filter((b) => b.name !== name);
|
|
980
|
+
if (state.active === name)
|
|
981
|
+
state.active = null;
|
|
982
|
+
await store.saveBranches(state);
|
|
983
|
+
}
|
|
984
|
+
return c.json(result);
|
|
985
|
+
});
|
|
986
|
+
// Queue management
|
|
987
|
+
app.get("/api/queue", async (c) => {
|
|
988
|
+
const queue = await store.readQueue();
|
|
989
|
+
return c.json(queue);
|
|
990
|
+
});
|
|
991
|
+
app.post("/api/queue/:id/remove", async (c) => {
|
|
992
|
+
const id = c.req.param("id");
|
|
993
|
+
const removed = await store.removeQueueItem(id);
|
|
994
|
+
return c.json({ ok: removed });
|
|
995
|
+
});
|
|
996
|
+
// History
|
|
997
|
+
app.get("/history", async (c) => {
|
|
998
|
+
const entries = await store.readHistory();
|
|
999
|
+
return c.html(historyPage(entries));
|
|
1000
|
+
});
|
|
1001
|
+
// History API (for polling)
|
|
1002
|
+
app.get("/api/history", async (c) => {
|
|
1003
|
+
const entries = await store.readHistory();
|
|
1004
|
+
return c.json({ entries, total: entries.length });
|
|
1005
|
+
});
|
|
1006
|
+
// Run logs
|
|
1007
|
+
app.get("/run/:id", async (c) => {
|
|
1008
|
+
const id = c.req.param("id");
|
|
1009
|
+
const events = await store.readRunLog(id);
|
|
1010
|
+
if (events.length === 0) {
|
|
1011
|
+
return c.html("<p>Run not found.</p>", 404);
|
|
1012
|
+
}
|
|
1013
|
+
return c.html(runPage(id, events));
|
|
1014
|
+
});
|
|
1015
|
+
// --- Verification routes ---
|
|
1016
|
+
// Verify page
|
|
1017
|
+
app.get("/task/:id/verify", async (c) => {
|
|
1018
|
+
const taskId = c.req.param("id");
|
|
1019
|
+
const diffId = c.req.query("diffId") || "";
|
|
1020
|
+
const pending = diffId ? pendingDiffs.get(diffId) : null;
|
|
1021
|
+
const taskSummary = pending?.task || `Task #${taskId}`;
|
|
1022
|
+
const results = await store.listVerifyResults(taskId);
|
|
1023
|
+
return c.html(verifyPage(taskId, diffId, taskSummary, results, runningVerifications));
|
|
1024
|
+
});
|
|
1025
|
+
// Spawn verification agent
|
|
1026
|
+
app.post("/task/:id/verify", async (c) => {
|
|
1027
|
+
const taskId = c.req.param("id");
|
|
1028
|
+
const body = await c.req.json();
|
|
1029
|
+
const prompt = body.prompt?.trim();
|
|
1030
|
+
const diffId = body.diffId;
|
|
1031
|
+
if (!prompt)
|
|
1032
|
+
return c.json({ error: "No prompt" }, 400);
|
|
1033
|
+
const task = await store.getTask(taskId);
|
|
1034
|
+
if (!task)
|
|
1035
|
+
return c.json({ error: "Task not found" }, 404);
|
|
1036
|
+
const pending = diffId ? pendingDiffs.get(diffId) : null;
|
|
1037
|
+
const diffContent = pending?.diffContent || "";
|
|
1038
|
+
const runNumber = await store.nextVerifyRunNumber(taskId);
|
|
1039
|
+
const verifyDirPath = await store.ensureVerifyDir(taskId);
|
|
1040
|
+
const padded = String(runNumber).padStart(3, "0");
|
|
1041
|
+
// Load MCP config for verification agents
|
|
1042
|
+
const mcpConfig = await store.readMcpConfig();
|
|
1043
|
+
// Build available tools section based on MCP config
|
|
1044
|
+
const mcpToolsSection = mcpConfig?.mcpServers
|
|
1045
|
+
? `\n## Available Tools
|
|
1046
|
+
You have access to MCP servers for enhanced verification:
|
|
1047
|
+
${Object.keys(mcpConfig.mcpServers).includes("playwright") ? `- **Playwright** — browser automation: navigate to URLs, take screenshots, click elements, fill forms
|
|
1048
|
+
- The app runs on http://localhost:${PORT}
|
|
1049
|
+
- Use these tools to visually verify UI changes` : ""}
|
|
1050
|
+
${Object.keys(mcpConfig.mcpServers)
|
|
1051
|
+
.filter((k) => k !== "playwright")
|
|
1052
|
+
.map((k) => `- **${k}** MCP server`)
|
|
1053
|
+
.join("\n")}
|
|
1054
|
+
`
|
|
1055
|
+
: "";
|
|
1056
|
+
// Build verification agent prompt
|
|
1057
|
+
const verifyPrompt = `You are a verification agent. Your job is to verify that changes work correctly.
|
|
1058
|
+
|
|
1059
|
+
## Task
|
|
1060
|
+
${task.spec}
|
|
1061
|
+
|
|
1062
|
+
## Git Diff Being Reviewed
|
|
1063
|
+
\`\`\`diff
|
|
1064
|
+
${diffContent.slice(0, 10000)}
|
|
1065
|
+
\`\`\`
|
|
1066
|
+
|
|
1067
|
+
## Your Verification Task
|
|
1068
|
+
${prompt}
|
|
1069
|
+
${mcpToolsSection}
|
|
1070
|
+
## Instructions
|
|
1071
|
+
1. Perform the verification described above
|
|
1072
|
+
2. Save any output artifacts (screenshots, test output, etc.) to: ${verifyDirPath}/
|
|
1073
|
+
- Name files with prefix: run-${padded}-
|
|
1074
|
+
- Example: run-${padded}-screenshot.png
|
|
1075
|
+
3. When done, write a JSON result file to: ${verifyDirPath}/run-${padded}-result.json
|
|
1076
|
+
With this format:
|
|
1077
|
+
\`\`\`json
|
|
1078
|
+
{
|
|
1079
|
+
"runNumber": ${runNumber},
|
|
1080
|
+
"status": "pass" or "fail" or "error",
|
|
1081
|
+
"summary": "Brief description of what you found",
|
|
1082
|
+
"prompt": ${JSON.stringify(prompt)},
|
|
1083
|
+
"artifacts": ["list of artifact filenames you saved"],
|
|
1084
|
+
"startedAt": "${new Date().toISOString()}",
|
|
1085
|
+
"completedAt": "<ISO timestamp when done>"
|
|
1086
|
+
}
|
|
1087
|
+
\`\`\`
|
|
1088
|
+
4. Do NOT modify any source code — you are only verifying
|
|
1089
|
+
5. You may run processes, tests, use puppeteer, curl, etc.`;
|
|
1090
|
+
// Save initial result as running
|
|
1091
|
+
const initialResult = {
|
|
1092
|
+
runNumber,
|
|
1093
|
+
status: "running",
|
|
1094
|
+
summary: "",
|
|
1095
|
+
prompt,
|
|
1096
|
+
artifacts: [],
|
|
1097
|
+
startedAt: new Date().toISOString(),
|
|
1098
|
+
};
|
|
1099
|
+
await store.saveVerifyResult(taskId, initialResult);
|
|
1100
|
+
await store.appendVerifyEvent(taskId, runNumber, {
|
|
1101
|
+
ts: new Date().toISOString(),
|
|
1102
|
+
type: "user_message",
|
|
1103
|
+
content: prompt,
|
|
1104
|
+
});
|
|
1105
|
+
const tracking = {
|
|
1106
|
+
runNumber,
|
|
1107
|
+
taskId,
|
|
1108
|
+
done: false,
|
|
1109
|
+
lastEvent: "Starting...",
|
|
1110
|
+
};
|
|
1111
|
+
runningVerifications.push(tracking);
|
|
1112
|
+
// Spawn agent (fire and forget)
|
|
1113
|
+
(async () => {
|
|
1114
|
+
try {
|
|
1115
|
+
const agent = spawnAgent(verifyPrompt, projects.getProjectRoot(), 10 * 60 * 1000, mcpConfig ?? undefined);
|
|
1116
|
+
for await (const event of agent.events) {
|
|
1117
|
+
await store.appendVerifyEvent(taskId, runNumber, {
|
|
1118
|
+
ts: new Date().toISOString(),
|
|
1119
|
+
...event,
|
|
1120
|
+
});
|
|
1121
|
+
if (event.type === "tool_use") {
|
|
1122
|
+
tracking.lastEvent = `Running: ${event.name}`;
|
|
1123
|
+
}
|
|
1124
|
+
else if (event.type === "text") {
|
|
1125
|
+
tracking.lastEvent = event.content.slice(0, 80);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
// Check if agent wrote a result file
|
|
1129
|
+
const agentResult = await store.readVerifyResult(taskId, runNumber);
|
|
1130
|
+
if (!agentResult || agentResult.status === "running") {
|
|
1131
|
+
await store.saveVerifyResult(taskId, {
|
|
1132
|
+
...initialResult,
|
|
1133
|
+
status: "error",
|
|
1134
|
+
summary: "Agent finished without writing a result",
|
|
1135
|
+
completedAt: new Date().toISOString(),
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
catch (err) {
|
|
1140
|
+
console.error(`Verification agent error:`, err);
|
|
1141
|
+
await store.appendVerifyEvent(taskId, runNumber, {
|
|
1142
|
+
ts: new Date().toISOString(),
|
|
1143
|
+
type: "error",
|
|
1144
|
+
message: err.message,
|
|
1145
|
+
});
|
|
1146
|
+
await store.saveVerifyResult(taskId, {
|
|
1147
|
+
...initialResult,
|
|
1148
|
+
status: "error",
|
|
1149
|
+
summary: err.message,
|
|
1150
|
+
completedAt: new Date().toISOString(),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
tracking.done = true;
|
|
1155
|
+
const idx = runningVerifications.indexOf(tracking);
|
|
1156
|
+
if (idx >= 0)
|
|
1157
|
+
runningVerifications.splice(idx, 1);
|
|
1158
|
+
}
|
|
1159
|
+
})();
|
|
1160
|
+
return c.json({ ok: true, runNumber });
|
|
1161
|
+
});
|
|
1162
|
+
// Verification run status (for polling)
|
|
1163
|
+
app.get("/task/:id/verify/:runNum/status", async (c) => {
|
|
1164
|
+
const taskId = c.req.param("id");
|
|
1165
|
+
const runNum = parseInt(c.req.param("runNum"), 10);
|
|
1166
|
+
const tracking = runningVerifications.find((v) => v.taskId === taskId && v.runNumber === runNum);
|
|
1167
|
+
if (tracking) {
|
|
1168
|
+
return c.json({ done: false, lastEvent: tracking.lastEvent });
|
|
1169
|
+
}
|
|
1170
|
+
const result = await store.readVerifyResult(taskId, runNum);
|
|
1171
|
+
return c.json({
|
|
1172
|
+
done: true,
|
|
1173
|
+
status: result?.status || "unknown",
|
|
1174
|
+
lastEvent: result?.summary || "Complete",
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
// Verification results list
|
|
1178
|
+
app.get("/task/:id/verify/results", async (c) => {
|
|
1179
|
+
const taskId = c.req.param("id");
|
|
1180
|
+
const results = await store.listVerifyResults(taskId);
|
|
1181
|
+
return c.json(results);
|
|
1182
|
+
});
|
|
1183
|
+
// Verification run log page
|
|
1184
|
+
app.get("/task/:id/verify/:runNum/log", async (c) => {
|
|
1185
|
+
const taskId = c.req.param("id");
|
|
1186
|
+
const runNum = parseInt(c.req.param("runNum"), 10);
|
|
1187
|
+
const diffId = c.req.query("diffId") || "";
|
|
1188
|
+
const events = await store.readVerifyLog(taskId, runNum);
|
|
1189
|
+
return c.html(verifyLogPage(taskId, runNum, events, diffId));
|
|
1190
|
+
});
|
|
1191
|
+
// Serve verification artifacts
|
|
1192
|
+
app.get("/task/:id/verify/artifact/:runNum/:filename", async (c) => {
|
|
1193
|
+
const taskId = c.req.param("id");
|
|
1194
|
+
const filename = c.req.param("filename");
|
|
1195
|
+
const verifyDirPath = await store.ensureVerifyDir(taskId);
|
|
1196
|
+
const filePath = join(verifyDirPath, filename);
|
|
1197
|
+
if (!existsSync(filePath)) {
|
|
1198
|
+
return c.text("Not found", 404);
|
|
1199
|
+
}
|
|
1200
|
+
const content = await fsReadFile(filePath);
|
|
1201
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
1202
|
+
const mimeTypes = {
|
|
1203
|
+
png: "image/png",
|
|
1204
|
+
jpg: "image/jpeg",
|
|
1205
|
+
jpeg: "image/jpeg",
|
|
1206
|
+
gif: "image/gif",
|
|
1207
|
+
webp: "image/webp",
|
|
1208
|
+
json: "application/json",
|
|
1209
|
+
txt: "text/plain",
|
|
1210
|
+
log: "text/plain",
|
|
1211
|
+
};
|
|
1212
|
+
const contentType = mimeTypes[ext] || "application/octet-stream";
|
|
1213
|
+
return new Response(content, {
|
|
1214
|
+
headers: { "Content-Type": contentType },
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
// Export app for testing
|
|
1218
|
+
export { app };
|
|
1219
|
+
export async function startServer() {
|
|
1220
|
+
// Detect multi-project mode
|
|
1221
|
+
projects.detectMode();
|
|
1222
|
+
// Startup cleanup and queue processor
|
|
1223
|
+
try {
|
|
1224
|
+
const deleted = await store.cleanOldRuns();
|
|
1225
|
+
if (deleted > 0)
|
|
1226
|
+
console.log(`Cleaned ${deleted} old run files`);
|
|
1227
|
+
await store.truncateHistoryIfNeeded();
|
|
1228
|
+
await startQueueProcessor();
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
console.error("Startup cleanup error:", err);
|
|
1232
|
+
}
|
|
1233
|
+
// Start
|
|
1234
|
+
serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
|
|
1235
|
+
console.log(`longshot running on http://0.0.0.0:${info.port}`);
|
|
1236
|
+
if (projects.isMultiProject()) {
|
|
1237
|
+
console.log(`Multi-project mode — root: ${projects.getRootDir()}`);
|
|
1238
|
+
const current = projects.getCurrentProjectName();
|
|
1239
|
+
if (current)
|
|
1240
|
+
console.log(`Active project: ${current}`);
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
console.log(`Project root: ${projects.getProjectRoot()}`);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
// Only start server when not imported as a module for testing
|
|
1248
|
+
if (!process.env.TESTING) {
|
|
1249
|
+
startServer();
|
|
1250
|
+
}
|