loop-task 1.5.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +141 -21
  2. package/dist/board/App.js +1 -1
  3. package/dist/board/components/ActionButtons.js +1 -1
  4. package/dist/board/components/BoardButton.js +1 -1
  5. package/dist/board/components/ConfirmModal.js +1 -1
  6. package/dist/board/components/ContextHelpModal.js +1 -1
  7. package/dist/board/components/CreateForm.js +1 -1
  8. package/dist/board/components/CreateProjectModal.js +1 -1
  9. package/dist/board/components/DeleteProjectConfirm.js +1 -1
  10. package/dist/board/components/DetailView.js +1 -1
  11. package/dist/board/components/EditProjectModal.js +1 -1
  12. package/dist/board/components/FilterBar.js +1 -1
  13. package/dist/board/components/Footer.js +1 -1
  14. package/dist/board/components/Header.js +1 -1
  15. package/dist/board/components/HelpModal.js +1 -1
  16. package/dist/board/components/Inspector.js +1 -1
  17. package/dist/board/components/LogModal.js +1 -1
  18. package/dist/board/components/Navigator.js +1 -1
  19. package/dist/board/components/ProjectsModal.js +1 -1
  20. package/dist/board/components/ProjectsPage.js +1 -1
  21. package/dist/board/components/RunHistory.js +1 -1
  22. package/dist/board/components/SearchBox.js +1 -1
  23. package/dist/board/components/SearchSelect.js +1 -1
  24. package/dist/board/components/TaskBrowser.js +1 -1
  25. package/dist/board/components/TaskFilterBar.js +1 -1
  26. package/dist/board/components/TaskForm.js +1 -1
  27. package/dist/board/components/Timeline.js +1 -1
  28. package/dist/board/focus-context.js +1 -1
  29. package/dist/board/format.js +15 -6
  30. package/dist/board/index.js +1 -1
  31. package/dist/board/toast.js +1 -1
  32. package/dist/cli/import-validator.js +151 -0
  33. package/dist/cli/import-writer.js +77 -0
  34. package/dist/cli.js +111 -17
  35. package/dist/config/constants.js +52 -0
  36. package/dist/daemon/file-watcher.js +171 -0
  37. package/dist/daemon/http-server.js +605 -0
  38. package/dist/daemon/index.js +36 -6
  39. package/dist/daemon/manager.js +94 -13
  40. package/dist/daemon/projects.js +10 -0
  41. package/dist/daemon/server.js +21 -0
  42. package/dist/daemon/task-manager.js +7 -0
  43. package/dist/esm-loader.js +4 -1
  44. package/dist/hooks/useLoopFormValidation.js +140 -0
  45. package/dist/i18n/en.json +237 -17
  46. package/dist/shared/fs-utils.js +34 -1
  47. package/dist/tui/app.js +675 -151
  48. package/dist/tui/commands.js +114 -0
  49. package/dist/tui/components/ActionButtons.js +49 -0
  50. package/dist/tui/components/Button.js +18 -0
  51. package/dist/tui/components/ChainEditor.js +112 -0
  52. package/dist/tui/components/CommandBuilderField.js +143 -0
  53. package/dist/tui/components/CommandEditorModal.js +159 -0
  54. package/dist/tui/components/CommandInput.js +303 -0
  55. package/dist/tui/components/CommandsBrowserModal.js +77 -0
  56. package/dist/tui/components/ConfirmModal.js +9 -0
  57. package/dist/tui/components/ContextHelpModal.js +8 -0
  58. package/dist/tui/components/CreateForm.js +223 -0
  59. package/dist/tui/components/DebugPanel.js +8 -0
  60. package/dist/tui/components/ExportModal.js +37 -0
  61. package/dist/tui/components/FilterBar.js +13 -0
  62. package/dist/tui/components/FocusableButton.js +16 -0
  63. package/dist/tui/components/FocusableInput.js +69 -0
  64. package/dist/tui/components/FocusableList.js +46 -0
  65. package/dist/tui/components/FocusableSearchSelect.js +82 -0
  66. package/dist/tui/components/Footer.js +42 -0
  67. package/dist/tui/components/Header.js +40 -0
  68. package/dist/tui/components/HelpGuideModal.js +21 -0
  69. package/dist/tui/components/HelpModal.js +59 -0
  70. package/dist/tui/components/InlineCommandEditor.js +143 -0
  71. package/dist/tui/components/Inspector.js +31 -0
  72. package/dist/tui/components/LeftPanel.js +33 -0
  73. package/dist/tui/components/LogModal.js +121 -0
  74. package/dist/tui/components/Modal.js +11 -0
  75. package/dist/tui/components/Navigator.js +68 -0
  76. package/dist/tui/components/PatchEditForm.js +78 -0
  77. package/dist/tui/components/ProjectForm.js +64 -0
  78. package/dist/tui/components/ProjectsModal.js +40 -0
  79. package/dist/tui/components/ProjectsPage.js +99 -0
  80. package/dist/tui/components/RightPanel.js +34 -0
  81. package/dist/tui/components/RunHistory.js +108 -0
  82. package/dist/tui/components/SearchSelect.js +67 -0
  83. package/dist/tui/components/SelectModal.js +76 -0
  84. package/dist/tui/components/TabBar.js +20 -0
  85. package/dist/tui/components/TaskBrowser.js +96 -0
  86. package/dist/tui/components/TaskFilterBar.js +9 -0
  87. package/dist/tui/components/TaskForm.js +134 -0
  88. package/dist/tui/components/TaskPickerModal.js +60 -0
  89. package/dist/tui/components/Toast.js +37 -0
  90. package/dist/tui/components/WelcomeScreen.js +70 -0
  91. package/dist/tui/components/WizardForm.js +178 -0
  92. package/dist/tui/daemon.js +142 -0
  93. package/dist/tui/format.js +111 -0
  94. package/dist/tui/hooks/useBreakpoint.js +7 -0
  95. package/dist/tui/hooks/useHoverState.js +11 -0
  96. package/dist/tui/hooks/useLogStream.js +32 -0
  97. package/dist/tui/hooks/useLoopPolling.js +28 -0
  98. package/dist/tui/index.js +24 -0
  99. package/dist/tui/router.js +16 -0
  100. package/dist/tui/state.js +175 -0
  101. package/dist/tui/theme.js +96 -0
  102. package/dist/tui/types.js +1 -0
  103. package/dist/tui/utils/paste.js +10 -0
  104. package/dist/tui/utils/validation.js +56 -0
  105. package/package.json +13 -8
@@ -0,0 +1,605 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import { HTTP_API_PORT, HTTP_API_HOST, LOG_TAIL_DEFAULT } from "../config/constants.js";
4
+ import { daemonLog } from "./daemon-log.js";
5
+ import { tail } from "../shared/tail.js";
6
+ import { buildLoopOptions } from "../loop-config.js";
7
+ function sendJson(res, status, data) {
8
+ const body = JSON.stringify(data);
9
+ res.writeHead(status, {
10
+ "Content-Type": "application/json",
11
+ "Content-Length": Buffer.byteLength(body),
12
+ });
13
+ res.end(body);
14
+ }
15
+ function sendOk(res, data, status = 200) {
16
+ sendJson(res, status, { ok: true, data: data ?? null });
17
+ }
18
+ function sendError(res, status, message) {
19
+ sendJson(res, status, { ok: false, error: { message } });
20
+ }
21
+ function sendNotFound(res, id) {
22
+ sendError(res, 404, `Not found: ${id}`);
23
+ }
24
+ function matchRoute(routes, method, pathSegments) {
25
+ for (const route of routes) {
26
+ if (route.method !== method)
27
+ continue;
28
+ if (route.segments.length !== pathSegments.length)
29
+ continue;
30
+ const params = {};
31
+ let matched = true;
32
+ for (let i = 0; i < route.segments.length; i++) {
33
+ const routeSeg = route.segments[i];
34
+ const pathSeg = pathSegments[i];
35
+ if (routeSeg.startsWith(":")) {
36
+ params[routeSeg.slice(1)] = decodeURIComponent(pathSeg);
37
+ }
38
+ else if (routeSeg !== pathSeg) {
39
+ matched = false;
40
+ break;
41
+ }
42
+ }
43
+ if (matched) {
44
+ return { params, handler: route.handler };
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ function parsePath(url) {
50
+ if (!url)
51
+ return [];
52
+ const pathname = url.split("?")[0] ?? "";
53
+ return pathname.split("/").filter((s) => s.length > 0);
54
+ }
55
+ function parseQuery(url) {
56
+ if (!url)
57
+ return new URLSearchParams();
58
+ const qs = url.split("?")[1] ?? "";
59
+ return new URLSearchParams(qs);
60
+ }
61
+ async function readBody(req) {
62
+ return new Promise((resolve, reject) => {
63
+ let body = "";
64
+ req.on("data", (chunk) => {
65
+ body += chunk.toString();
66
+ if (body.length > 1024 * 1024) {
67
+ reject(new Error("Body too large"));
68
+ req.destroy();
69
+ }
70
+ });
71
+ req.on("end", () => {
72
+ if (!body.trim()) {
73
+ resolve({});
74
+ return;
75
+ }
76
+ try {
77
+ resolve(JSON.parse(body));
78
+ }
79
+ catch {
80
+ reject(new Error("Invalid JSON body"));
81
+ }
82
+ });
83
+ req.on("error", reject);
84
+ });
85
+ }
86
+ export class HttpApiServer {
87
+ server;
88
+ manager;
89
+ taskManager;
90
+ projectManager;
91
+ routes = [];
92
+ sseClients = new Set();
93
+ constructor(manager, taskManager, projectManager) {
94
+ this.manager = manager;
95
+ this.taskManager = taskManager;
96
+ this.projectManager = projectManager;
97
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
98
+ this.registerRoutes();
99
+ }
100
+ async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
101
+ return new Promise((resolve, reject) => {
102
+ this.server.on("error", (err) => {
103
+ if (err.code === "EADDRINUSE") {
104
+ daemonLog(`HTTP API server: port ${port} already in use, skipping HTTP transport`);
105
+ resolve();
106
+ }
107
+ else {
108
+ reject(err);
109
+ }
110
+ });
111
+ this.server.listen(port, host, () => {
112
+ daemonLog(`HTTP API server listening on ${host}:${port}`);
113
+ resolve();
114
+ });
115
+ });
116
+ }
117
+ async close() {
118
+ for (const client of this.sseClients) {
119
+ client.destroy();
120
+ }
121
+ this.sseClients.clear();
122
+ return new Promise((resolve) => {
123
+ this.server.close(() => resolve());
124
+ });
125
+ }
126
+ broadcastEvent(event, data) {
127
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data ?? {})}\n\n`;
128
+ for (const client of this.sseClients) {
129
+ try {
130
+ client.write(payload);
131
+ }
132
+ catch {
133
+ this.sseClients.delete(client);
134
+ }
135
+ }
136
+ }
137
+ registerRoutes() {
138
+ const r = (method, path, handler) => {
139
+ const segments = path.split("/").filter((s) => s.length > 0);
140
+ this.routes.push({ method, segments, handler });
141
+ };
142
+ // ── Loops ──────────────────────────────────────────────────────
143
+ r("GET", "/api/loops", (_req, res) => {
144
+ sendOk(res, this.manager.list());
145
+ });
146
+ r("GET", "/api/loops/:id", (_req, res, params) => {
147
+ const meta = this.manager.status(params.id);
148
+ if (!meta) {
149
+ sendNotFound(res, params.id);
150
+ return;
151
+ }
152
+ sendOk(res, meta);
153
+ });
154
+ r("POST", "/api/loops", async (req, res) => {
155
+ try {
156
+ const body = await readBody(req);
157
+ const intervalHuman = body.intervalHuman ?? "5m";
158
+ const { options } = buildLoopOptions(intervalHuman, {
159
+ command: body.command,
160
+ commandArgs: body.commandArgs,
161
+ taskId: body.taskId,
162
+ cwd: body.cwd,
163
+ now: body.now,
164
+ maxRuns: body.maxRuns,
165
+ verbose: body.verbose,
166
+ description: body.description,
167
+ projectId: body.projectId,
168
+ offset: body.offset,
169
+ });
170
+ const id = this.manager.start(options, intervalHuman);
171
+ sendOk(res, { id }, 201);
172
+ }
173
+ catch (err) {
174
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
175
+ }
176
+ });
177
+ r("PATCH", "/api/loops/:id", async (req, res, params) => {
178
+ try {
179
+ const body = await readBody(req);
180
+ const intervalHuman = body.intervalHuman ?? "5m";
181
+ const { options } = buildLoopOptions(intervalHuman, {
182
+ command: body.command,
183
+ commandArgs: body.commandArgs,
184
+ taskId: body.taskId,
185
+ cwd: body.cwd,
186
+ now: body.now,
187
+ maxRuns: body.maxRuns,
188
+ verbose: body.verbose,
189
+ description: body.description,
190
+ projectId: body.projectId,
191
+ offset: body.offset,
192
+ });
193
+ const ok = await this.manager.update(params.id, options, intervalHuman);
194
+ if (!ok) {
195
+ sendNotFound(res, params.id);
196
+ return;
197
+ }
198
+ sendOk(res, { id: params.id });
199
+ }
200
+ catch (err) {
201
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
202
+ }
203
+ });
204
+ r("DELETE", "/api/loops/:id", async (_req, res, params) => {
205
+ const ok = await this.manager.delete(params.id);
206
+ if (!ok) {
207
+ sendNotFound(res, params.id);
208
+ return;
209
+ }
210
+ sendOk(res);
211
+ });
212
+ r("POST", "/api/loops/:id/pause", (_req, res, params) => {
213
+ if (!this.manager.pause(params.id)) {
214
+ sendNotFound(res, params.id);
215
+ return;
216
+ }
217
+ sendOk(res);
218
+ });
219
+ r("POST", "/api/loops/:id/resume", (_req, res, params) => {
220
+ if (!this.manager.resume(params.id)) {
221
+ sendNotFound(res, params.id);
222
+ return;
223
+ }
224
+ sendOk(res);
225
+ });
226
+ r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
227
+ if (this.manager.isMaxRunsBlocked(params.id)) {
228
+ sendError(res, 400, "Max runs reached");
229
+ return;
230
+ }
231
+ if (this.manager.isRunning(params.id)) {
232
+ sendError(res, 409, "Loop is already running");
233
+ return;
234
+ }
235
+ if (!this.manager.trigger(params.id)) {
236
+ sendNotFound(res, params.id);
237
+ return;
238
+ }
239
+ sendOk(res);
240
+ });
241
+ r("POST", "/api/loops/:id/stop", (_req, res, params) => {
242
+ if (!this.manager.stopLoop(params.id)) {
243
+ sendNotFound(res, params.id);
244
+ return;
245
+ }
246
+ sendOk(res);
247
+ });
248
+ r("POST", "/api/loops/stop-all", (_req, res) => {
249
+ const count = this.manager.stopAllLoops();
250
+ sendOk(res, { count });
251
+ });
252
+ // ── Loop logs ─────────────────────────────────────────────────
253
+ r("GET", "/api/loops/:id/logs", (_req, res, params) => {
254
+ const logPath = this.manager.getLogPath(params.id);
255
+ if (!logPath) {
256
+ sendNotFound(res, params.id);
257
+ return;
258
+ }
259
+ const query = parseQuery(_req.url);
260
+ const tailCount = parseInt(query.get("tail") ?? String(LOG_TAIL_DEFAULT), 10);
261
+ if (!fs.existsSync(logPath)) {
262
+ sendOk(res, "");
263
+ return;
264
+ }
265
+ const content = fs.readFileSync(logPath, "utf-8");
266
+ sendOk(res, tail(content, tailCount).join("\n"));
267
+ });
268
+ r("GET", "/api/loops/:id/logs/stream", (_req, res, params) => {
269
+ const logPath = this.manager.getLogPath(params.id);
270
+ if (!logPath) {
271
+ sendNotFound(res, params.id);
272
+ return;
273
+ }
274
+ res.writeHead(200, {
275
+ "Content-Type": "text/event-stream",
276
+ "Cache-Control": "no-cache",
277
+ "Connection": "keep-alive",
278
+ });
279
+ const query = parseQuery(_req.url);
280
+ const tailCount = parseInt(query.get("tail") ?? "0", 10);
281
+ if (fs.existsSync(logPath)) {
282
+ const content = fs.readFileSync(logPath, "utf-8");
283
+ for (const line of tail(content, tailCount)) {
284
+ if (line) {
285
+ res.write(`data: ${line}\n\n`);
286
+ }
287
+ }
288
+ }
289
+ let fileSize = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
290
+ const watcher = fs.watch(logPath, (eventType) => {
291
+ if (eventType === "rename" && !fs.existsSync(logPath)) {
292
+ watcher.close();
293
+ res.write("event: end\ndata: {}\n\n");
294
+ res.end();
295
+ return;
296
+ }
297
+ if (eventType === "change") {
298
+ try {
299
+ const stat = fs.statSync(logPath);
300
+ if (stat.size > fileSize) {
301
+ const fd = fs.openSync(logPath, "r");
302
+ const buf = Buffer.alloc(stat.size - fileSize);
303
+ fs.readSync(fd, buf, 0, buf.length, fileSize);
304
+ fs.closeSync(fd);
305
+ fileSize = stat.size;
306
+ for (const line of buf.toString().split("\n")) {
307
+ if (line) {
308
+ res.write(`data: ${line}\n\n`);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ catch {
314
+ watcher.close();
315
+ res.end();
316
+ }
317
+ }
318
+ });
319
+ _req.on("close", () => {
320
+ watcher.close();
321
+ res.end();
322
+ });
323
+ });
324
+ r("GET", "/api/loops/:id/runs/:num", (_req, res, params) => {
325
+ const { id, num } = params;
326
+ const logPath = this.manager.getLogPath(id);
327
+ if (!logPath || !fs.existsSync(logPath)) {
328
+ sendOk(res, "");
329
+ return;
330
+ }
331
+ const meta = this.manager.status(id);
332
+ if (!meta) {
333
+ sendNotFound(res, id);
334
+ return;
335
+ }
336
+ const runNumber = parseInt(num, 10);
337
+ const records = meta.runHistory
338
+ .filter((r) => r.runNumber === runNumber)
339
+ .sort((a, b) => a.logOffset - b.logOffset);
340
+ if (records.length === 0) {
341
+ sendOk(res, "");
342
+ return;
343
+ }
344
+ const buffer = fs.readFileSync(logPath);
345
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
346
+ const parts = records.map((record) => {
347
+ const start = record.logOffset;
348
+ const idx = allSorted.indexOf(record);
349
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
350
+ return buffer.toString("utf-8", start, end);
351
+ });
352
+ sendOk(res, parts.join(""));
353
+ });
354
+ // ── Tasks ──────────────────────────────────────────────────────
355
+ r("GET", "/api/tasks", (_req, res) => {
356
+ sendOk(res, this.taskManager.list());
357
+ });
358
+ r("GET", "/api/tasks/:id", (_req, res, params) => {
359
+ const task = this.taskManager.get(params.id);
360
+ if (!task) {
361
+ sendNotFound(res, params.id);
362
+ return;
363
+ }
364
+ sendOk(res, task);
365
+ });
366
+ r("POST", "/api/tasks", async (req, res) => {
367
+ try {
368
+ const body = await readBody(req);
369
+ if (!body.name?.trim()) {
370
+ sendError(res, 400, "Task name is required");
371
+ return;
372
+ }
373
+ if (!body.command?.trim()) {
374
+ sendError(res, 400, "Task command is required");
375
+ return;
376
+ }
377
+ const task = this.taskManager.create(body);
378
+ sendOk(res, task, 201);
379
+ }
380
+ catch (err) {
381
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
382
+ }
383
+ });
384
+ r("PATCH", "/api/tasks/:id", async (req, res, params) => {
385
+ try {
386
+ const body = await readBody(req);
387
+ const updated = this.taskManager.update(params.id, body);
388
+ if (!updated) {
389
+ sendNotFound(res, params.id);
390
+ return;
391
+ }
392
+ sendOk(res, updated);
393
+ }
394
+ catch (err) {
395
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
396
+ }
397
+ });
398
+ r("DELETE", "/api/tasks/:id", (_req, res, params) => {
399
+ if (!this.taskManager.delete(params.id)) {
400
+ sendNotFound(res, params.id);
401
+ return;
402
+ }
403
+ sendOk(res);
404
+ });
405
+ // ── Projects ───────────────────────────────────────────────────
406
+ r("GET", "/api/projects", (_req, res) => {
407
+ sendOk(res, this.projectManager.getAll());
408
+ });
409
+ r("POST", "/api/projects", async (req, res) => {
410
+ try {
411
+ const body = await readBody(req);
412
+ if (!body.name?.trim()) {
413
+ sendError(res, 400, "Project name is required");
414
+ return;
415
+ }
416
+ const project = this.projectManager.create(body.name.trim(), body.color ?? "#ffffff");
417
+ sendOk(res, project, 201);
418
+ }
419
+ catch (err) {
420
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
421
+ }
422
+ });
423
+ r("PATCH", "/api/projects/:id", async (req, res, params) => {
424
+ try {
425
+ const body = await readBody(req);
426
+ if (!body.name?.trim()) {
427
+ sendError(res, 400, "Project name is required");
428
+ return;
429
+ }
430
+ this.projectManager.update(params.id, body.name.trim(), body.color);
431
+ sendOk(res);
432
+ }
433
+ catch (err) {
434
+ const msg = err instanceof Error ? err.message : String(err);
435
+ if (msg.includes("not found")) {
436
+ sendNotFound(res, params.id);
437
+ }
438
+ else {
439
+ sendError(res, 400, msg);
440
+ }
441
+ }
442
+ });
443
+ r("DELETE", "/api/projects/:id", (_req, res, params) => {
444
+ try {
445
+ this.projectManager.delete(params.id);
446
+ sendOk(res);
447
+ }
448
+ catch (err) {
449
+ const msg = err instanceof Error ? err.message : String(err);
450
+ if (msg.includes("not found") || msg.includes("system")) {
451
+ sendError(res, 400, msg);
452
+ }
453
+ else {
454
+ sendError(res, 500, msg);
455
+ }
456
+ }
457
+ });
458
+ // ── Swagger / OpenAPI ────────────────────────────────────────
459
+ r("GET", "/api/openapi.json", (_req, res) => {
460
+ const spec = this.buildOpenApiSpec();
461
+ const body = JSON.stringify(spec, null, 2);
462
+ res.writeHead(200, {
463
+ "Content-Type": "application/json",
464
+ "Content-Length": Buffer.byteLength(body),
465
+ });
466
+ res.end(body);
467
+ });
468
+ r("GET", "/api/docs", (_req, res) => {
469
+ const html = this.buildSwaggerHtml();
470
+ res.writeHead(200, {
471
+ "Content-Type": "text/html",
472
+ "Content-Length": Buffer.byteLength(html),
473
+ });
474
+ res.end(html);
475
+ });
476
+ r("GET", "/", (_req, res) => {
477
+ const html = this.buildSwaggerHtml();
478
+ res.writeHead(200, {
479
+ "Content-Type": "text/html",
480
+ "Content-Length": Buffer.byteLength(html),
481
+ });
482
+ res.end(html);
483
+ });
484
+ // ── SSE Events ────────────────────────────────────────────────
485
+ r("GET", "/api/events", (_req, res) => {
486
+ res.writeHead(200, {
487
+ "Content-Type": "text/event-stream",
488
+ "Cache-Control": "no-cache",
489
+ "Connection": "keep-alive",
490
+ });
491
+ res.write(": connected\n\n");
492
+ this.sseClients.add(res);
493
+ _req.on("close", () => {
494
+ this.sseClients.delete(res);
495
+ });
496
+ });
497
+ }
498
+ buildOpenApiSpec() {
499
+ return {
500
+ openapi: "3.0.3",
501
+ info: {
502
+ title: "loop-task HTTP API",
503
+ version: "1.0.0",
504
+ description: "REST + SSE API for managing loops, tasks, projects, and logs. All endpoints are localhost-only (127.0.0.1).",
505
+ },
506
+ servers: [
507
+ { url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
508
+ ],
509
+ paths: {
510
+ "/api/loops": {
511
+ get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
512
+ post: { summary: "Create a new loop", tags: ["Loops"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, intervalHuman: { type: "string", example: "5m" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
513
+ },
514
+ "/api/loops/{id}": {
515
+ get: { summary: "Get loop status", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Loop details" }, "404": { description: "Not found" } } },
516
+ patch: { summary: "Update a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object" } } } }, responses: { "200": { description: "Updated" }, "400": { description: "Validation error" }, "404": { description: "Not found" } } },
517
+ delete: { summary: "Delete a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "404": { description: "Not found" } } },
518
+ },
519
+ "/api/loops/{id}/pause": { post: { summary: "Pause a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Paused" }, "404": { description: "Not found" } } } },
520
+ "/api/loops/{id}/resume": { post: { summary: "Resume a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Resumed" }, "404": { description: "Not found" } } } },
521
+ "/api/loops/{id}/trigger": { post: { summary: "Trigger a loop now", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Triggered" }, "404": { description: "Not found" }, "400": { description: "Max runs reached" }, "409": { description: "Already running" } } } },
522
+ "/api/loops/{id}/stop": { post: { summary: "Stop a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Stopped" }, "404": { description: "Not found" } } } },
523
+ "/api/loops/stop-all": { post: { summary: "Stop all loops", tags: ["Loops"], responses: { "200": { description: "All stopped", content: { "application/json": { schema: { type: "object", properties: { count: { type: "integer" } } } } } } } } },
524
+ "/api/loops/{id}/logs": { get: { summary: "Fetch loop logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer", default: 50 } }], responses: { "200": { description: "Log content" }, "404": { description: "Not found" } } } },
525
+ "/api/loops/{id}/logs/stream": { get: { summary: "Stream logs via SSE", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", schema: { type: "integer" } }], responses: { "200": { description: "SSE stream" }, "404": { description: "Not found" } } } },
526
+ "/api/loops/{id}/runs/{num}": { get: { summary: "Get run-specific log", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "num", in: "path", required: true, schema: { type: "integer" } }], responses: { "200": { description: "Run log content" } } } },
527
+ "/api/tasks": {
528
+ get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
529
+ post: { summary: "Create a task", tags: ["Tasks"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
530
+ },
531
+ "/api/tasks/{id}": {
532
+ get: { summary: "Get a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Task details" }, "404": { description: "Not found" } } },
533
+ patch: { summary: "Update a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object" } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" } } },
534
+ delete: { summary: "Delete a task", tags: ["Tasks"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "404": { description: "Not found" } } },
535
+ },
536
+ "/api/projects": {
537
+ get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
538
+ post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
539
+ },
540
+ "/api/projects/{id}": {
541
+ patch: { summary: "Update a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
542
+ delete: { summary: "Delete a project", tags: ["Projects"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Deleted" }, "400": { description: "Cannot delete system project" } } },
543
+ },
544
+ "/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
545
+ "/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
546
+ "/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
547
+ },
548
+ };
549
+ }
550
+ buildSwaggerHtml() {
551
+ const specUrl = "/api/openapi.json";
552
+ return `<!DOCTYPE html>
553
+ <html lang="en">
554
+ <head>
555
+ <meta charset="UTF-8">
556
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
557
+ <title>loop-task API</title>
558
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
559
+ <style>
560
+ body { margin: 0; }
561
+ .swagger-ui { max-width: 1200px; margin: 0 auto; }
562
+ .header { background: #0d1117; color: #58a6ff; padding: 12px 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
563
+ .header a { color: #58a6ff; text-decoration: none; }
564
+ .header a:hover { text-decoration: underline; }
565
+ </style>
566
+ </head>
567
+ <body>
568
+ <div class="header">
569
+ <strong>loop-task HTTP API</strong> &mdash;
570
+ <a href="/api/openapi.json">OpenAPI JSON</a> &mdash;
571
+ <a href="/api/docs">Swagger UI</a>
572
+ </div>
573
+ <div id="swagger-ui"></div>
574
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
575
+ <script>
576
+ window.onload = function() {
577
+ window.ui = SwaggerUIBundle({
578
+ url: "${specUrl}",
579
+ dom_id: "#swagger-ui",
580
+ deepLinking: true,
581
+ presets: [SwaggerUIBundle.presets.apis],
582
+ layout: "BaseLayout",
583
+ });
584
+ };
585
+ </script>
586
+ </body>
587
+ </html>`;
588
+ }
589
+ async handleRequest(req, res) {
590
+ try {
591
+ const pathSegments = parsePath(req.url);
592
+ const match = matchRoute(this.routes, req.method ?? "GET", pathSegments);
593
+ if (!match) {
594
+ sendError(res, 404, `Not found: ${req.method} ${req.url}`);
595
+ return;
596
+ }
597
+ await match.handler(req, res, match.params);
598
+ }
599
+ catch (err) {
600
+ if (!res.headersSent) {
601
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
602
+ }
603
+ }
604
+ }
605
+ }