@drewpayment/mink 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +347 -0
- package/package.json +32 -0
- package/src/cli.ts +176 -0
- package/src/commands/bug-search.ts +32 -0
- package/src/commands/config.ts +109 -0
- package/src/commands/cron.ts +295 -0
- package/src/commands/daemon.ts +46 -0
- package/src/commands/dashboard.ts +21 -0
- package/src/commands/designqc.ts +160 -0
- package/src/commands/detect-waste.ts +81 -0
- package/src/commands/framework-advisor.ts +52 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/post-read.ts +123 -0
- package/src/commands/post-write.ts +157 -0
- package/src/commands/pre-read.ts +109 -0
- package/src/commands/pre-write.ts +136 -0
- package/src/commands/reflect.ts +39 -0
- package/src/commands/restore.ts +31 -0
- package/src/commands/scan.ts +101 -0
- package/src/commands/session-start.ts +21 -0
- package/src/commands/session-stop.ts +115 -0
- package/src/commands/status.ts +152 -0
- package/src/commands/update.ts +121 -0
- package/src/core/action-log.ts +341 -0
- package/src/core/backup.ts +122 -0
- package/src/core/bug-memory.ts +223 -0
- package/src/core/cron-parser.ts +94 -0
- package/src/core/daemon.ts +152 -0
- package/src/core/dashboard-api.ts +280 -0
- package/src/core/dashboard-server.ts +580 -0
- package/src/core/description.ts +232 -0
- package/src/core/design-eval/capture.ts +269 -0
- package/src/core/design-eval/route-detect.ts +165 -0
- package/src/core/design-eval/server-detect.ts +91 -0
- package/src/core/framework-advisor/catalog.ts +360 -0
- package/src/core/framework-advisor/decision-tree.ts +287 -0
- package/src/core/framework-advisor/generate.ts +132 -0
- package/src/core/framework-advisor/migration-prompts.ts +502 -0
- package/src/core/framework-advisor/validate.ts +137 -0
- package/src/core/fs-utils.ts +30 -0
- package/src/core/global-config.ts +74 -0
- package/src/core/index-store.ts +72 -0
- package/src/core/learning-memory.ts +120 -0
- package/src/core/paths.ts +86 -0
- package/src/core/pattern-engine.ts +108 -0
- package/src/core/project-id.ts +19 -0
- package/src/core/project-registry.ts +64 -0
- package/src/core/reflection.ts +256 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/scheduler.ts +352 -0
- package/src/core/seed.ts +239 -0
- package/src/core/session.ts +128 -0
- package/src/core/stdin.ts +13 -0
- package/src/core/task-registry.ts +202 -0
- package/src/core/token-estimate.ts +36 -0
- package/src/core/token-ledger.ts +185 -0
- package/src/core/waste-detection.ts +214 -0
- package/src/core/write-exclusions.ts +24 -0
- package/src/types/action-log.ts +20 -0
- package/src/types/backup.ts +6 -0
- package/src/types/bug-memory.ts +24 -0
- package/src/types/config.ts +59 -0
- package/src/types/dashboard.ts +104 -0
- package/src/types/design-eval.ts +64 -0
- package/src/types/file-index.ts +38 -0
- package/src/types/framework-advisor.ts +97 -0
- package/src/types/hook-input.ts +27 -0
- package/src/types/learning-memory.ts +36 -0
- package/src/types/scheduler.ts +82 -0
- package/src/types/session.ts +50 -0
- package/src/types/token-ledger.ts +43 -0
- package/src/types/waste-detection.ts +21 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "fs";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { basename, join, extname } from "path";
|
|
4
|
+
import { projectDir, designCapturesDir } from "./paths";
|
|
5
|
+
import {
|
|
6
|
+
loadOverview,
|
|
7
|
+
loadTokenLedgerPanel,
|
|
8
|
+
loadFileIndexPanel,
|
|
9
|
+
loadSchedulerPanel,
|
|
10
|
+
loadLearningMemoryPanel,
|
|
11
|
+
loadActionLogPanel,
|
|
12
|
+
loadBugLogPanel,
|
|
13
|
+
loadDesignPanel,
|
|
14
|
+
triggerTask,
|
|
15
|
+
triggerDeadLetterRetry,
|
|
16
|
+
triggerRescan,
|
|
17
|
+
} from "./dashboard-api";
|
|
18
|
+
import { listRegisteredProjects, getProjectMeta } from "./project-registry";
|
|
19
|
+
import { generateProjectId } from "./project-id";
|
|
20
|
+
import type { StateFileId, StateChangeEvent } from "../types/dashboard";
|
|
21
|
+
import type { RegisteredProject } from "./project-registry";
|
|
22
|
+
|
|
23
|
+
// ── MIME types for static file serving ────────────────────────────────────
|
|
24
|
+
const MIME_TYPES: Record<string, string> = {
|
|
25
|
+
".html": "text/html; charset=utf-8",
|
|
26
|
+
".js": "application/javascript; charset=utf-8",
|
|
27
|
+
".css": "text/css; charset=utf-8",
|
|
28
|
+
".json": "application/json; charset=utf-8",
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
".woff": "font/woff",
|
|
35
|
+
".woff2": "font/woff2",
|
|
36
|
+
".ttf": "font/ttf",
|
|
37
|
+
".txt": "text/plain; charset=utf-8",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── State File Mapping ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const STATE_FILE_MAP: Record<string, StateFileId> = {
|
|
43
|
+
"token-ledger.json": "token-ledger",
|
|
44
|
+
"file-index.json": "file-index",
|
|
45
|
+
"learning-memory.md": "learning-memory",
|
|
46
|
+
"bug-memory.json": "bug-memory",
|
|
47
|
+
"action-log.md": "action-log",
|
|
48
|
+
"scheduler-manifest.json": "scheduler-manifest",
|
|
49
|
+
"session.json": "session",
|
|
50
|
+
"project-meta.json": "project-meta",
|
|
51
|
+
"design-report.json": "design-report",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ── SSE Manager ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
interface SSEClient {
|
|
57
|
+
id: string;
|
|
58
|
+
controller: ReadableStreamController<Uint8Array>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
|
|
63
|
+
class SSEManager {
|
|
64
|
+
private clients = new Map<string, SSEClient>();
|
|
65
|
+
private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
66
|
+
|
|
67
|
+
start(): void {
|
|
68
|
+
this.keepAliveInterval = setInterval(() => {
|
|
69
|
+
this.sendRaw(": keepalive\n\n");
|
|
70
|
+
}, 15_000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stop(): void {
|
|
74
|
+
if (this.keepAliveInterval) {
|
|
75
|
+
clearInterval(this.keepAliveInterval);
|
|
76
|
+
this.keepAliveInterval = null;
|
|
77
|
+
}
|
|
78
|
+
for (const [id] of this.clients) {
|
|
79
|
+
this.removeClient(id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
addClient(id: string, controller: ReadableStreamController<Uint8Array>): void {
|
|
84
|
+
this.clients.set(id, { id, controller });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
removeClient(id: string): void {
|
|
88
|
+
const client = this.clients.get(id);
|
|
89
|
+
if (client) {
|
|
90
|
+
try {
|
|
91
|
+
client.controller.close();
|
|
92
|
+
} catch {
|
|
93
|
+
// Already closed
|
|
94
|
+
}
|
|
95
|
+
this.clients.delete(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
broadcast(event: StateChangeEvent): void {
|
|
100
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
101
|
+
this.sendRaw(data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private sendRaw(data: string): void {
|
|
105
|
+
const bytes = encoder.encode(data);
|
|
106
|
+
const deadClients: string[] = [];
|
|
107
|
+
|
|
108
|
+
for (const [id, client] of this.clients) {
|
|
109
|
+
try {
|
|
110
|
+
client.controller.enqueue(bytes);
|
|
111
|
+
} catch {
|
|
112
|
+
deadClients.push(id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const id of deadClients) {
|
|
117
|
+
this.clients.delete(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get clientCount(): number {
|
|
122
|
+
return this.clients.size;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Project Resolution ────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function resolveProjectCwd(
|
|
129
|
+
url: URL,
|
|
130
|
+
defaultCwd: string
|
|
131
|
+
): string | null {
|
|
132
|
+
const projectId = url.searchParams.get("project");
|
|
133
|
+
if (!projectId) return defaultCwd;
|
|
134
|
+
|
|
135
|
+
// If the requested project matches the currently active project, use it directly
|
|
136
|
+
// (handles startup projects that may not be in the registry yet)
|
|
137
|
+
if (projectId === generateProjectId(defaultCwd)) return defaultCwd;
|
|
138
|
+
|
|
139
|
+
const projects = listRegisteredProjects();
|
|
140
|
+
const match = projects.find((p) => p.id === projectId);
|
|
141
|
+
if (!match) return null;
|
|
142
|
+
|
|
143
|
+
return match.cwd;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getProjectsList(
|
|
147
|
+
startupCwd: string,
|
|
148
|
+
activeCwd: string
|
|
149
|
+
): {
|
|
150
|
+
projects: RegisteredProject[];
|
|
151
|
+
activeProjectId: string;
|
|
152
|
+
} {
|
|
153
|
+
const activeId = generateProjectId(activeCwd);
|
|
154
|
+
const registered = listRegisteredProjects();
|
|
155
|
+
|
|
156
|
+
// Ensure startup project is always in the list
|
|
157
|
+
const startupId = generateProjectId(startupCwd);
|
|
158
|
+
const hasStartup = registered.some((p) => p.id === startupId);
|
|
159
|
+
if (!hasStartup) {
|
|
160
|
+
const meta = getProjectMeta(projectDir(startupCwd));
|
|
161
|
+
registered.unshift({
|
|
162
|
+
id: startupId,
|
|
163
|
+
cwd: startupCwd,
|
|
164
|
+
name: meta?.name ?? basename(startupCwd),
|
|
165
|
+
version: meta?.version ?? "0.1.0",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ensure active project is in the list (if different from startup)
|
|
170
|
+
if (activeId !== startupId) {
|
|
171
|
+
const hasActive = registered.some((p) => p.id === activeId);
|
|
172
|
+
if (!hasActive) {
|
|
173
|
+
const meta = getProjectMeta(projectDir(activeCwd));
|
|
174
|
+
registered.unshift({
|
|
175
|
+
id: activeId,
|
|
176
|
+
cwd: activeCwd,
|
|
177
|
+
name: meta?.name ?? basename(activeCwd),
|
|
178
|
+
version: meta?.version ?? "0.1.0",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { projects: registered, activeProjectId: activeId };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── File Watcher ───────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function createFileWatcher(
|
|
189
|
+
cwd: string,
|
|
190
|
+
onChange: (fileId: StateFileId) => void
|
|
191
|
+
): { close(): void } {
|
|
192
|
+
const dir = projectDir(cwd);
|
|
193
|
+
const debounceMap = new Map<string, ReturnType<typeof setTimeout>>();
|
|
194
|
+
let watcher: FSWatcher | null = null;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
watcher = watch(dir, (eventType, filename) => {
|
|
198
|
+
if (!filename) return;
|
|
199
|
+
const name = basename(filename);
|
|
200
|
+
|
|
201
|
+
// Ignore .tmp files from atomic writes
|
|
202
|
+
if (name.endsWith(".tmp")) return;
|
|
203
|
+
|
|
204
|
+
const fileId = STATE_FILE_MAP[name];
|
|
205
|
+
if (!fileId) return;
|
|
206
|
+
|
|
207
|
+
// Debounce 300ms per file
|
|
208
|
+
const existing = debounceMap.get(name);
|
|
209
|
+
if (existing) clearTimeout(existing);
|
|
210
|
+
|
|
211
|
+
debounceMap.set(
|
|
212
|
+
name,
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
debounceMap.delete(name);
|
|
215
|
+
onChange(fileId);
|
|
216
|
+
}, 300)
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
} catch {
|
|
220
|
+
// fs.watch not available — fallback could go here
|
|
221
|
+
// For now, the dashboard still works via manual refresh
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
close() {
|
|
226
|
+
if (watcher) {
|
|
227
|
+
watcher.close();
|
|
228
|
+
watcher = null;
|
|
229
|
+
}
|
|
230
|
+
for (const timer of debounceMap.values()) {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
debounceMap.clear();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Route Handling ─────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function jsonResponse(data: unknown, status: number = 200): Response {
|
|
241
|
+
return new Response(JSON.stringify(data), {
|
|
242
|
+
status,
|
|
243
|
+
headers: {
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
"Access-Control-Allow-Origin": "*",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function extractPathParam(pathname: string, prefix: string): string | null {
|
|
251
|
+
if (!pathname.startsWith(prefix)) return null;
|
|
252
|
+
const rest = pathname.slice(prefix.length);
|
|
253
|
+
const slashIdx = rest.indexOf("/");
|
|
254
|
+
if (slashIdx === -1) return rest || null;
|
|
255
|
+
return rest.slice(0, slashIdx) || null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Server ─────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export interface DashboardServer {
|
|
261
|
+
url: string;
|
|
262
|
+
close(): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function startDashboardServer(
|
|
266
|
+
cwd: string,
|
|
267
|
+
options: { port?: number; hostname?: string; open?: boolean } = {}
|
|
268
|
+
): DashboardServer {
|
|
269
|
+
const port = options.port ?? 4040;
|
|
270
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
271
|
+
|
|
272
|
+
const sseManager = new SSEManager();
|
|
273
|
+
sseManager.start();
|
|
274
|
+
|
|
275
|
+
// Mutable active project state (swappable via project switcher)
|
|
276
|
+
let activeCwd = cwd;
|
|
277
|
+
|
|
278
|
+
function swapWatcher(newCwd: string) {
|
|
279
|
+
activeWatcher.close();
|
|
280
|
+
activeCwd = newCwd;
|
|
281
|
+
activeWatcher = createFileWatcher(newCwd, (fileId) => {
|
|
282
|
+
sseManager.broadcast({
|
|
283
|
+
fileId,
|
|
284
|
+
timestamp: new Date().toISOString(),
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Start file watcher
|
|
290
|
+
let activeWatcher = createFileWatcher(cwd, (fileId) => {
|
|
291
|
+
sseManager.broadcast({
|
|
292
|
+
fileId,
|
|
293
|
+
timestamp: new Date().toISOString(),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Resolve the Next.js static build directory
|
|
298
|
+
const dashboardOutDir = join(
|
|
299
|
+
import.meta.dir,
|
|
300
|
+
"..",
|
|
301
|
+
"..",
|
|
302
|
+
"dashboard",
|
|
303
|
+
"out"
|
|
304
|
+
);
|
|
305
|
+
const dashboardBuilt = existsSync(join(dashboardOutDir, "index.html"));
|
|
306
|
+
let clientIdCounter = 0;
|
|
307
|
+
|
|
308
|
+
if (!dashboardBuilt) {
|
|
309
|
+
console.warn(
|
|
310
|
+
"[mink] dashboard not built. Run: cd dashboard && bun run build"
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const server = Bun.serve({
|
|
315
|
+
port,
|
|
316
|
+
hostname,
|
|
317
|
+
idleTimeout: 0, // Disable idle timeout — SSE connections are long-lived
|
|
318
|
+
async fetch(req) {
|
|
319
|
+
const url = new URL(req.url);
|
|
320
|
+
const pathname = url.pathname;
|
|
321
|
+
const method = req.method;
|
|
322
|
+
|
|
323
|
+
// ── Static file serving (Next.js build) ─────────────────────
|
|
324
|
+
if (method === "GET" && !pathname.startsWith("/api/")) {
|
|
325
|
+
if (!dashboardBuilt) {
|
|
326
|
+
if (pathname === "/") {
|
|
327
|
+
return new Response(
|
|
328
|
+
"<html><body><h1>Mink Dashboard</h1><p>Dashboard not built. Run: <code>cd dashboard && bun run build</code></p></body></html>",
|
|
329
|
+
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Serve from dashboard/out/
|
|
334
|
+
let filePath: string;
|
|
335
|
+
if (pathname === "/") {
|
|
336
|
+
filePath = join(dashboardOutDir, "index.html");
|
|
337
|
+
} else {
|
|
338
|
+
filePath = join(dashboardOutDir, pathname);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Security: prevent directory traversal
|
|
342
|
+
if (!filePath.startsWith(dashboardOutDir)) {
|
|
343
|
+
return jsonResponse({ error: "Forbidden" }, 403);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const file = Bun.file(filePath);
|
|
347
|
+
if (await file.exists()) {
|
|
348
|
+
const ext = extname(filePath);
|
|
349
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
350
|
+
return new Response(file, {
|
|
351
|
+
headers: { "Content-Type": contentType },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Client-side routing fallback: try {pathname}.html then index.html
|
|
356
|
+
const htmlFile = Bun.file(filePath + ".html");
|
|
357
|
+
if (await htmlFile.exists()) {
|
|
358
|
+
return new Response(htmlFile, {
|
|
359
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// SPA fallback — serve index.html for unmatched routes
|
|
364
|
+
const indexFile = Bun.file(join(dashboardOutDir, "index.html"));
|
|
365
|
+
if (await indexFile.exists()) {
|
|
366
|
+
return new Response(indexFile, {
|
|
367
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── SSE ──────────────────────────────────────────────────────
|
|
374
|
+
if (method === "GET" && pathname === "/api/events") {
|
|
375
|
+
const clientId = String(++clientIdCounter);
|
|
376
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
377
|
+
start(controller) {
|
|
378
|
+
sseManager.addClient(clientId, controller);
|
|
379
|
+
// Send initial comment immediately to establish the stream
|
|
380
|
+
// and prevent Bun from treating it as idle/complete
|
|
381
|
+
controller.enqueue(encoder.encode(": connected\nretry: 3000\n\n"));
|
|
382
|
+
},
|
|
383
|
+
cancel() {
|
|
384
|
+
sseManager.removeClient(clientId);
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return new Response(stream, {
|
|
389
|
+
headers: {
|
|
390
|
+
"Content-Type": "text/event-stream",
|
|
391
|
+
"Cache-Control": "no-cache",
|
|
392
|
+
Connection: "keep-alive",
|
|
393
|
+
"Access-Control-Allow-Origin": "*",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── REST API (GET) ───────────────────────────────────────────
|
|
399
|
+
if (method === "GET") {
|
|
400
|
+
// GET /api/projects — list all registered projects (no project param needed)
|
|
401
|
+
if (pathname === "/api/projects") {
|
|
402
|
+
return jsonResponse(getProjectsList(cwd, activeCwd));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Resolve project cwd from ?project=<id> query param
|
|
406
|
+
const resolvedCwd = resolveProjectCwd(url, activeCwd);
|
|
407
|
+
if (resolvedCwd === null) {
|
|
408
|
+
return jsonResponse({ error: "Project not found" }, 404);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
switch (pathname) {
|
|
413
|
+
case "/api/overview":
|
|
414
|
+
return jsonResponse(loadOverview(resolvedCwd));
|
|
415
|
+
case "/api/token-ledger":
|
|
416
|
+
return jsonResponse(loadTokenLedgerPanel(resolvedCwd));
|
|
417
|
+
case "/api/file-index":
|
|
418
|
+
return jsonResponse(loadFileIndexPanel(resolvedCwd));
|
|
419
|
+
case "/api/scheduler":
|
|
420
|
+
return jsonResponse(loadSchedulerPanel(resolvedCwd));
|
|
421
|
+
case "/api/learning-memory":
|
|
422
|
+
return jsonResponse(loadLearningMemoryPanel(resolvedCwd));
|
|
423
|
+
case "/api/action-log":
|
|
424
|
+
return jsonResponse(loadActionLogPanel(resolvedCwd));
|
|
425
|
+
case "/api/bugs":
|
|
426
|
+
return jsonResponse(loadBugLogPanel(resolvedCwd));
|
|
427
|
+
case "/api/design":
|
|
428
|
+
return jsonResponse(loadDesignPanel(resolvedCwd));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// GET /api/design-images/:filename — serve captured screenshots
|
|
432
|
+
if (pathname.startsWith("/api/design-images/")) {
|
|
433
|
+
const filename = pathname.slice("/api/design-images/".length);
|
|
434
|
+
if (!filename || filename.includes("..") || filename.includes("/")) {
|
|
435
|
+
return jsonResponse({ error: "Invalid filename" }, 400);
|
|
436
|
+
}
|
|
437
|
+
const imgPath = join(designCapturesDir(resolvedCwd), filename);
|
|
438
|
+
const file = Bun.file(imgPath);
|
|
439
|
+
if (await file.exists()) {
|
|
440
|
+
return new Response(file, {
|
|
441
|
+
headers: {
|
|
442
|
+
"Content-Type": "image/jpeg",
|
|
443
|
+
"Cache-Control": "public, max-age=60",
|
|
444
|
+
"Access-Control-Allow-Origin": "*",
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return jsonResponse({ error: "Image not found" }, 404);
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return jsonResponse(
|
|
452
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
453
|
+
500
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── REST API (POST) ──────────────────────────────────────────
|
|
459
|
+
if (method === "POST") {
|
|
460
|
+
// POST /api/switch-project — swap the active project + file watcher
|
|
461
|
+
if (pathname === "/api/switch-project") {
|
|
462
|
+
try {
|
|
463
|
+
const body = await req.json() as { projectId?: string };
|
|
464
|
+
const projectId = body.projectId;
|
|
465
|
+
if (!projectId) {
|
|
466
|
+
return jsonResponse({ success: false, error: "Missing projectId" }, 400);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const projects = listRegisteredProjects();
|
|
470
|
+
const match = projects.find((p) => p.id === projectId);
|
|
471
|
+
if (!match) {
|
|
472
|
+
return jsonResponse({ success: false, error: "Project not found" }, 404);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
swapWatcher(match.cwd);
|
|
476
|
+
sseManager.broadcast({
|
|
477
|
+
fileId: "project-switched" as StateFileId,
|
|
478
|
+
projectId,
|
|
479
|
+
timestamp: new Date().toISOString(),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return jsonResponse({ success: true });
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return jsonResponse(
|
|
485
|
+
{ success: false, error: err instanceof Error ? err.message : String(err) },
|
|
486
|
+
500
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Resolve project cwd for POST actions
|
|
492
|
+
const resolvedCwd = resolveProjectCwd(url, activeCwd);
|
|
493
|
+
if (resolvedCwd === null) {
|
|
494
|
+
return jsonResponse({ error: "Project not found" }, 404);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// POST /api/tasks/:id/run
|
|
498
|
+
if (pathname.startsWith("/api/tasks/") && pathname.endsWith("/run")) {
|
|
499
|
+
const taskId = extractPathParam(pathname, "/api/tasks/");
|
|
500
|
+
const cleanId = taskId?.replace(/\/run$/, "") ?? "";
|
|
501
|
+
if (!cleanId) {
|
|
502
|
+
return jsonResponse({ success: false, error: "Missing task ID" }, 400);
|
|
503
|
+
}
|
|
504
|
+
return triggerTask(resolvedCwd, cleanId).then((result) =>
|
|
505
|
+
jsonResponse(result, result.success ? 200 : 500)
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// POST /api/dead-letter/:id/retry
|
|
510
|
+
if (
|
|
511
|
+
pathname.startsWith("/api/dead-letter/") &&
|
|
512
|
+
pathname.endsWith("/retry")
|
|
513
|
+
) {
|
|
514
|
+
const taskId = extractPathParam(pathname, "/api/dead-letter/");
|
|
515
|
+
const cleanId = taskId?.replace(/\/retry$/, "") ?? "";
|
|
516
|
+
if (!cleanId) {
|
|
517
|
+
return jsonResponse({ success: false, error: "Missing task ID" }, 400);
|
|
518
|
+
}
|
|
519
|
+
return triggerDeadLetterRetry(resolvedCwd, cleanId).then((result) =>
|
|
520
|
+
jsonResponse(result, result.success ? 200 : 500)
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// POST /api/rescan
|
|
525
|
+
if (pathname === "/api/rescan") {
|
|
526
|
+
return triggerRescan(resolvedCwd).then((result) =>
|
|
527
|
+
jsonResponse(result, result.success ? 200 : 500)
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── CORS Preflight ───────────────────────────────────────────
|
|
533
|
+
if (method === "OPTIONS") {
|
|
534
|
+
return new Response(null, {
|
|
535
|
+
status: 204,
|
|
536
|
+
headers: {
|
|
537
|
+
"Access-Control-Allow-Origin": "*",
|
|
538
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
539
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── 404 ──────────────────────────────────────────────────────
|
|
545
|
+
return jsonResponse({ error: "Not found" }, 404);
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const serverUrl = `http://${hostname}:${server.port}`;
|
|
550
|
+
|
|
551
|
+
// Auto-open browser
|
|
552
|
+
if (options.open !== false) {
|
|
553
|
+
try {
|
|
554
|
+
const platform = process.platform;
|
|
555
|
+
const cmd =
|
|
556
|
+
platform === "darwin"
|
|
557
|
+
? ["open", serverUrl]
|
|
558
|
+
: platform === "win32"
|
|
559
|
+
? ["cmd", "/c", "start", serverUrl]
|
|
560
|
+
: ["xdg-open", serverUrl];
|
|
561
|
+
const proc = Bun.spawn(cmd, {
|
|
562
|
+
stdout: "ignore",
|
|
563
|
+
stderr: "ignore",
|
|
564
|
+
stdin: "ignore",
|
|
565
|
+
});
|
|
566
|
+
proc.unref();
|
|
567
|
+
} catch {
|
|
568
|
+
// Browser open is best-effort
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
url: serverUrl,
|
|
574
|
+
close() {
|
|
575
|
+
sseManager.stop();
|
|
576
|
+
activeWatcher.close();
|
|
577
|
+
server.stop(true);
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|