@plainconceptsplatform/loop-task 2.6.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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,311 @@
1
+ import fs from "node:fs";
2
+ import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
3
+ import { tailFileBounded, readByteRange } from "../../core/logging/bounded-log-reader.js";
4
+ import { followLogFile } from "../../core/logging/log-follower.js";
5
+ import { buildLoopOptions } from "../../loop-config.js";
6
+ import { validateContext } from "../../core/context/validate-context.js";
7
+ import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
8
+ export function registerLoopRoutes(manager, routes, r) {
9
+ r("GET", "/api/loops", (_req, res) => {
10
+ sendOk(res, manager.list());
11
+ });
12
+ r("GET", "/api/loops/:id", (_req, res, params) => {
13
+ const meta = manager.status(params.id);
14
+ if (!meta) {
15
+ sendNotFound(res, params.id);
16
+ return;
17
+ }
18
+ sendOk(res, meta);
19
+ });
20
+ r("POST", "/api/loops", async (req, res) => {
21
+ try {
22
+ const body = await readBody(req);
23
+ let context;
24
+ if (body.context !== undefined) {
25
+ const result = validateContext(body.context);
26
+ if (!result.valid) {
27
+ sendError(res, 400, result.error);
28
+ return;
29
+ }
30
+ context = result.context;
31
+ }
32
+ const intervalHuman = body.intervalHuman ?? "5m";
33
+ const { options } = buildLoopOptions(intervalHuman, {
34
+ command: body.command,
35
+ commandArgs: body.commandArgs,
36
+ taskId: body.taskId,
37
+ cwd: body.cwd,
38
+ now: body.now,
39
+ maxRuns: body.maxRuns,
40
+ verbose: body.verbose,
41
+ description: body.description,
42
+ projectId: body.projectId,
43
+ offset: body.offset,
44
+ context,
45
+ });
46
+ const id = manager.start(options, intervalHuman);
47
+ sendOk(res, { id }, 201);
48
+ }
49
+ catch (err) {
50
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
51
+ }
52
+ });
53
+ r("PATCH", "/api/loops/:id", async (req, res, params) => {
54
+ try {
55
+ const body = await readBody(req);
56
+ let context;
57
+ if (body.context !== undefined) {
58
+ const result = validateContext(body.context);
59
+ if (!result.valid) {
60
+ sendError(res, 400, result.error);
61
+ return;
62
+ }
63
+ context = result.context;
64
+ }
65
+ const intervalHuman = body.intervalHuman ?? "5m";
66
+ const { options } = buildLoopOptions(intervalHuman, {
67
+ command: body.command,
68
+ commandArgs: body.commandArgs,
69
+ taskId: body.taskId,
70
+ cwd: body.cwd,
71
+ now: body.now,
72
+ maxRuns: body.maxRuns,
73
+ verbose: body.verbose,
74
+ description: body.description,
75
+ projectId: body.projectId,
76
+ offset: body.offset,
77
+ context,
78
+ });
79
+ const ok = await manager.update(params.id, options, intervalHuman);
80
+ if (!ok) {
81
+ sendNotFound(res, params.id);
82
+ return;
83
+ }
84
+ sendOk(res, { id: params.id });
85
+ }
86
+ catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ const isRecipeRestriction = message.includes("Recipe loops");
89
+ sendError(res, isRecipeRestriction ? 403 : 400, message);
90
+ }
91
+ });
92
+ r("DELETE", "/api/loops/:id", async (_req, res, params) => {
93
+ if (manager.isRecipeLoop(params.id)) {
94
+ sendError(res, 403, "Recipe loops cannot be deleted; remove the recipe file instead");
95
+ return;
96
+ }
97
+ const ok = await manager.delete(params.id);
98
+ if (!ok) {
99
+ sendNotFound(res, params.id);
100
+ return;
101
+ }
102
+ sendOk(res);
103
+ });
104
+ r("POST", "/api/loops/:id/pause", (_req, res, params) => {
105
+ if (!manager.pause(params.id)) {
106
+ sendNotFound(res, params.id);
107
+ return;
108
+ }
109
+ sendOk(res);
110
+ });
111
+ r("POST", "/api/loops/:id/resume", (_req, res, params) => {
112
+ if (!manager.resume(params.id)) {
113
+ sendNotFound(res, params.id);
114
+ return;
115
+ }
116
+ sendOk(res);
117
+ });
118
+ r("POST", "/api/loops/:id/play", (_req, res, params) => {
119
+ if (manager.isMaxRunsBlocked(params.id)) {
120
+ sendError(res, 409, "Max runs reached");
121
+ return;
122
+ }
123
+ if (manager.isRunning(params.id)) {
124
+ sendError(res, 409, "Loop is already running");
125
+ return;
126
+ }
127
+ if (!manager.playLoop(params.id)) {
128
+ sendNotFound(res, params.id);
129
+ return;
130
+ }
131
+ const meta = manager.status(params.id);
132
+ sendOk(res, meta);
133
+ });
134
+ r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
135
+ if (manager.isMaxRunsBlocked(params.id)) {
136
+ sendError(res, 400, "Max runs reached");
137
+ return;
138
+ }
139
+ if (manager.isRunning(params.id)) {
140
+ sendError(res, 409, "Loop is already running");
141
+ return;
142
+ }
143
+ if (!manager.trigger(params.id)) {
144
+ sendNotFound(res, params.id);
145
+ return;
146
+ }
147
+ sendOk(res);
148
+ });
149
+ r("POST", "/api/loops/:id/stop", (_req, res, params) => {
150
+ if (!manager.stopLoop(params.id)) {
151
+ sendNotFound(res, params.id);
152
+ return;
153
+ }
154
+ sendOk(res);
155
+ });
156
+ r("POST", "/api/loops/stop-all", (_req, res) => {
157
+ const count = manager.stopAllLoops();
158
+ sendOk(res, { count });
159
+ });
160
+ r("GET", "/api/loops/:id/runs", (_req, res, params) => {
161
+ const meta = manager.status(params.id);
162
+ if (!meta) {
163
+ sendNotFound(res, params.id);
164
+ return;
165
+ }
166
+ const query = parseQuery(_req.url);
167
+ const fromStr = query.get("from");
168
+ const toStr = query.get("to");
169
+ let runs = meta.runHistory;
170
+ if (fromStr) {
171
+ const fromMs = new Date(fromStr).getTime();
172
+ if (!Number.isNaN(fromMs)) {
173
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() >= fromMs);
174
+ }
175
+ }
176
+ if (toStr) {
177
+ const toMs = new Date(toStr).getTime();
178
+ if (!Number.isNaN(toMs)) {
179
+ runs = runs.filter((r) => new Date(r.startedAt).getTime() <= toMs);
180
+ }
181
+ }
182
+ sendOk(res, runs);
183
+ });
184
+ r("GET", "/api/loops/:id/logs/date", (_req, res, params) => {
185
+ const meta = manager.status(params.id);
186
+ if (!meta) {
187
+ sendNotFound(res, params.id);
188
+ return;
189
+ }
190
+ const query = parseQuery(_req.url);
191
+ const fromStr = query.get("from");
192
+ const toStr = query.get("to");
193
+ if (!fromStr || !toStr) {
194
+ sendError(res, 400, "Both 'from' and 'to' query parameters are required (ISO 8601)");
195
+ return;
196
+ }
197
+ const fromMs = new Date(fromStr).getTime();
198
+ const toMs = new Date(toStr).getTime();
199
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
200
+ sendError(res, 400, "Invalid date format for 'from' or 'to' (use ISO 8601)");
201
+ return;
202
+ }
203
+ const logPath = manager.getLogPath(params.id);
204
+ if (!logPath || !fs.existsSync(logPath)) {
205
+ sendOk(res, "");
206
+ return;
207
+ }
208
+ const matching = meta.runHistory.filter((r) => {
209
+ const t = new Date(r.startedAt).getTime();
210
+ return t >= fromMs && t <= toMs;
211
+ });
212
+ if (matching.length === 0) {
213
+ sendOk(res, "");
214
+ return;
215
+ }
216
+ matching.sort((a, b) => a.logOffset - b.logOffset);
217
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
218
+ const parts = matching.map((record) => {
219
+ const start = record.logOffset;
220
+ const idx = allSorted.indexOf(record);
221
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : undefined;
222
+ return readByteRange(logPath, start, end);
223
+ });
224
+ sendOk(res, parts.join(""));
225
+ });
226
+ r("GET", "/api/loops/:id/logs", (_req, res, params) => {
227
+ const logPath = manager.getLogPath(params.id);
228
+ if (!logPath) {
229
+ sendNotFound(res, params.id);
230
+ return;
231
+ }
232
+ const query = parseQuery(_req.url);
233
+ const tailCount = parseInt(query.get("tail") ?? String(LOG_TAIL_DEFAULT), 10);
234
+ if (!fs.existsSync(logPath)) {
235
+ sendOk(res, "");
236
+ return;
237
+ }
238
+ const lines = tailFileBounded(logPath, tailCount);
239
+ sendOk(res, lines.join("\n"));
240
+ });
241
+ r("GET", "/api/loops/:id/logs/stream", (_req, res, params) => {
242
+ const logPath = manager.getLogPath(params.id);
243
+ if (!logPath) {
244
+ sendNotFound(res, params.id);
245
+ return;
246
+ }
247
+ res.writeHead(200, {
248
+ "Content-Type": "text/event-stream",
249
+ "Cache-Control": "no-cache",
250
+ "Connection": "keep-alive",
251
+ });
252
+ const query = parseQuery(_req.url);
253
+ const tailCount = parseInt(query.get("tail") ?? "0", 10);
254
+ let initialOffset = 0;
255
+ if (fs.existsSync(logPath)) {
256
+ const lines = tailFileBounded(logPath, tailCount);
257
+ for (const line of lines) {
258
+ if (line) {
259
+ res.write(`data: ${line}\n\n`);
260
+ }
261
+ }
262
+ initialOffset = fs.statSync(logPath).size;
263
+ }
264
+ const watcher = followLogFile({
265
+ logPath,
266
+ initialOffset,
267
+ dest: res,
268
+ formatLine: (line) => `data: ${line}\n\n`,
269
+ onEnd: () => {
270
+ res.write("event: end\ndata: {}\n\n");
271
+ res.end();
272
+ },
273
+ onError: () => {
274
+ res.end();
275
+ },
276
+ });
277
+ _req.on("close", () => {
278
+ watcher.close();
279
+ res.end();
280
+ });
281
+ });
282
+ r("GET", "/api/loops/:id/runs/:num", (_req, res, params) => {
283
+ const { id, num } = params;
284
+ const logPath = manager.getLogPath(id);
285
+ if (!logPath || !fs.existsSync(logPath)) {
286
+ sendOk(res, "");
287
+ return;
288
+ }
289
+ const meta = manager.status(id);
290
+ if (!meta) {
291
+ sendNotFound(res, id);
292
+ return;
293
+ }
294
+ const runNumber = parseInt(num, 10);
295
+ const records = meta.runHistory
296
+ .filter((r) => r.runNumber === runNumber)
297
+ .sort((a, b) => a.logOffset - b.logOffset);
298
+ if (records.length === 0) {
299
+ sendOk(res, "");
300
+ return;
301
+ }
302
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
303
+ const parts = records.map((record) => {
304
+ const start = record.logOffset;
305
+ const idx = allSorted.indexOf(record);
306
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : undefined;
307
+ return readByteRange(logPath, start, end);
308
+ });
309
+ sendOk(res, parts.join(""));
310
+ });
311
+ }
@@ -0,0 +1,60 @@
1
+ import { initSseResponse } from "./sse.js";
2
+ import { buildOpenApiSpec, buildSwaggerHtml } from "./openapi.js";
3
+ import { sendOk, sendError, readBody } from "./helpers.js";
4
+ import { collectDiagnostics, isDiagnosticsEnabled } from "../diagnostics.js";
5
+ export function registerMiscRoutes(sseClients, r) {
6
+ r("GET", "/api/openapi.json", (_req, res) => {
7
+ const spec = buildOpenApiSpec();
8
+ const body = JSON.stringify(spec, null, 2);
9
+ res.writeHead(200, {
10
+ "Content-Type": "application/json",
11
+ "Content-Length": Buffer.byteLength(body),
12
+ });
13
+ res.end(body);
14
+ });
15
+ r("GET", "/api/docs", (_req, res) => {
16
+ const html = buildSwaggerHtml();
17
+ res.writeHead(200, {
18
+ "Content-Type": "text/html",
19
+ "Content-Length": Buffer.byteLength(html),
20
+ });
21
+ res.end(html);
22
+ });
23
+ r("GET", "/", (_req, res) => {
24
+ const html = buildSwaggerHtml();
25
+ res.writeHead(200, {
26
+ "Content-Type": "text/html",
27
+ "Content-Length": Buffer.byteLength(html),
28
+ });
29
+ res.end(html);
30
+ });
31
+ r("GET", "/api/events", (_req, res) => {
32
+ initSseResponse(res);
33
+ sseClients.add(res);
34
+ _req.on("close", () => {
35
+ sseClients.delete(res);
36
+ });
37
+ });
38
+ }
39
+ export function registerSettingsRoutes(settingsManager, r) {
40
+ r("GET", "/api/settings", (_req, res) => {
41
+ sendOk(res, settingsManager.get());
42
+ });
43
+ r("PATCH", "/api/settings", async (req, res) => {
44
+ try {
45
+ const body = await readBody(req);
46
+ const updated = settingsManager.set(body);
47
+ sendOk(res, updated);
48
+ }
49
+ catch (err) {
50
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
51
+ }
52
+ });
53
+ }
54
+ export function registerDiagnosticsRoutes(loopManager, r) {
55
+ r("GET", "/api/diagnostics", (_req, res) => {
56
+ const extended = isDiagnosticsEnabled();
57
+ const diag = collectDiagnostics(loopManager, extended);
58
+ sendOk(res, diag);
59
+ });
60
+ }
@@ -0,0 +1,55 @@
1
+ import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
2
+ export function registerProjectRoutes(projectManager, r) {
3
+ r("GET", "/api/projects", (_req, res) => {
4
+ sendOk(res, projectManager.getAll());
5
+ });
6
+ r("POST", "/api/projects", async (req, res) => {
7
+ try {
8
+ const body = await readBody(req);
9
+ if (!body.name?.trim()) {
10
+ sendError(res, 400, "Project name is required");
11
+ return;
12
+ }
13
+ const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff", body.directory, body.githubSource);
14
+ sendOk(res, project, 201);
15
+ }
16
+ catch (err) {
17
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
18
+ }
19
+ });
20
+ r("PATCH", "/api/projects/:id", async (req, res, params) => {
21
+ try {
22
+ const body = await readBody(req);
23
+ if (!body.name?.trim()) {
24
+ sendError(res, 400, "Project name is required");
25
+ return;
26
+ }
27
+ projectManager.update(params.id, body.name.trim(), body.color, body.directory, body.githubSource);
28
+ sendOk(res);
29
+ }
30
+ catch (err) {
31
+ const msg = err instanceof Error ? err.message : String(err);
32
+ if (msg.includes("not found")) {
33
+ sendNotFound(res, params.id);
34
+ }
35
+ else {
36
+ sendError(res, 400, msg);
37
+ }
38
+ }
39
+ });
40
+ r("DELETE", "/api/projects/:id", (_req, res, params) => {
41
+ try {
42
+ projectManager.delete(params.id);
43
+ sendOk(res);
44
+ }
45
+ catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ if (msg.includes("not found") || msg.includes("system")) {
48
+ sendError(res, 400, msg);
49
+ }
50
+ else {
51
+ sendError(res, 500, msg);
52
+ }
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,141 @@
1
+ import { validateContext } from "../../core/context/validate-context.js";
2
+ import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
3
+ export function registerTaskRoutes(taskManager, r) {
4
+ r("GET", "/api/tasks", (_req, res) => {
5
+ sendOk(res, taskManager.list());
6
+ });
7
+ r("GET", "/api/tasks/:id", (_req, res, params) => {
8
+ const task = taskManager.get(params.id);
9
+ if (!task) {
10
+ sendNotFound(res, params.id);
11
+ return;
12
+ }
13
+ sendOk(res, task);
14
+ });
15
+ r("POST", "/api/tasks", async (req, res) => {
16
+ try {
17
+ const body = await readBody(req);
18
+ if (!body.name?.trim()) {
19
+ sendError(res, 400, "Task name is required");
20
+ return;
21
+ }
22
+ if (!body.command?.trim()) {
23
+ sendError(res, 400, "Task command is required");
24
+ return;
25
+ }
26
+ if (body.context !== undefined) {
27
+ const result = validateContext(body.context);
28
+ if (!result.valid) {
29
+ sendError(res, 400, result.error);
30
+ return;
31
+ }
32
+ body.context = result.context;
33
+ }
34
+ const task = taskManager.create(body);
35
+ sendOk(res, task, 201);
36
+ }
37
+ catch (err) {
38
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
39
+ }
40
+ });
41
+ r("PATCH", "/api/tasks/:id", async (req, res, params) => {
42
+ try {
43
+ const body = await readBody(req);
44
+ if (body.context !== undefined) {
45
+ const result = validateContext(body.context);
46
+ if (!result.valid) {
47
+ sendError(res, 400, result.error);
48
+ return;
49
+ }
50
+ body.context = result.context;
51
+ }
52
+ const updated = taskManager.update(params.id, body);
53
+ if (!updated) {
54
+ sendNotFound(res, params.id);
55
+ return;
56
+ }
57
+ sendOk(res, updated);
58
+ }
59
+ catch (err) {
60
+ if (err instanceof Error && err.message.includes("immutable")) {
61
+ sendError(res, 403, err.message);
62
+ return;
63
+ }
64
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
65
+ }
66
+ });
67
+ r("DELETE", "/api/tasks/:id", (_req, res, params) => {
68
+ try {
69
+ if (!taskManager.delete(params.id)) {
70
+ sendNotFound(res, params.id);
71
+ return;
72
+ }
73
+ sendOk(res);
74
+ }
75
+ catch (err) {
76
+ if (err instanceof Error && err.message.includes("immutable")) {
77
+ sendError(res, 403, err.message);
78
+ return;
79
+ }
80
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
81
+ }
82
+ });
83
+ r("POST", "/api/task-chains", async (req, res) => {
84
+ try {
85
+ const body = await readBody(req);
86
+ if (!body.tasks || !Array.isArray(body.tasks) || body.tasks.length === 0) {
87
+ sendError(res, 400, "tasks array must not be empty");
88
+ return;
89
+ }
90
+ if (body.chain && body.chain !== "sequential-success" && body.chain !== "sequential-failure" && body.chain !== "none") {
91
+ sendError(res, 400, `Invalid chain mode: "${body.chain}". Must be "sequential-success", "sequential-failure", or "none"`);
92
+ return;
93
+ }
94
+ for (const task of body.tasks) {
95
+ if (!task.name?.trim()) {
96
+ sendError(res, 400, "Each task must have a name");
97
+ return;
98
+ }
99
+ if (!task.command?.trim()) {
100
+ sendError(res, 400, "Each task must have a command");
101
+ return;
102
+ }
103
+ }
104
+ const created = [];
105
+ try {
106
+ for (const taskInput of body.tasks) {
107
+ const task = taskManager.create(taskInput);
108
+ created.push(task);
109
+ }
110
+ const chainMode = body.chain ?? "none";
111
+ if (chainMode === "sequential-success") {
112
+ for (let i = 0; i < created.length - 1; i++) {
113
+ taskManager.update(created[i].id, {
114
+ ...created[i],
115
+ onSuccessTaskId: created[i + 1].id,
116
+ });
117
+ }
118
+ }
119
+ else if (chainMode === "sequential-failure") {
120
+ for (let i = 0; i < created.length - 1; i++) {
121
+ taskManager.update(created[i].id, {
122
+ ...created[i],
123
+ onFailureTaskId: created[i + 1].id,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ catch (err) {
129
+ for (const task of created) {
130
+ taskManager.delete(task.id);
131
+ }
132
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
133
+ return;
134
+ }
135
+ sendOk(res, { taskIds: created.map((t) => t.id) }, 201);
136
+ }
137
+ catch (err) {
138
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
139
+ }
140
+ });
141
+ }
@@ -0,0 +1,19 @@
1
+ import { registerLoopRoutes } from "./route-loops.js";
2
+ import { registerTaskRoutes } from "./route-tasks.js";
3
+ import { registerProjectRoutes } from "./route-projects.js";
4
+ import { registerMiscRoutes, registerSettingsRoutes, registerDiagnosticsRoutes } from "./route-misc.js";
5
+ export function registerRoutes(deps) {
6
+ const { manager, taskManager, projectManager, sseClients, settingsManager } = deps;
7
+ const routes = [];
8
+ const r = (method, path, handler) => {
9
+ const segments = path.split("/").filter((s) => s.length > 0);
10
+ routes.push({ method, segments, handler });
11
+ };
12
+ registerLoopRoutes(manager, routes, r);
13
+ registerTaskRoutes(taskManager, r);
14
+ registerProjectRoutes(projectManager, r);
15
+ registerMiscRoutes(sseClients, r);
16
+ registerSettingsRoutes(settingsManager, r);
17
+ registerDiagnosticsRoutes(manager, r);
18
+ return routes;
19
+ }
@@ -0,0 +1,82 @@
1
+ import http from "node:http";
2
+ import { HTTP_API_PORT, HTTP_API_HOST } from "../../shared/config/constants.js";
3
+ import { daemonLog } from "../daemon-log.js";
4
+ import { sendError, matchRoute, parsePath } from "./helpers.js";
5
+ import { SseClientSet } from "./sse.js";
6
+ import { registerRoutes } from "./routes.js";
7
+ export class HttpApiServer {
8
+ constructor(manager, taskManager, projectManager, settingsManager) {
9
+ this.manager = manager;
10
+ this.taskManager = taskManager;
11
+ this.projectManager = projectManager;
12
+ this.settingsManager = settingsManager;
13
+ this.routes = [];
14
+ this.sseClients = new SseClientSet();
15
+ this.isListening = false;
16
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
17
+ this.routes = registerRoutes({
18
+ manager: this.manager,
19
+ taskManager: this.taskManager,
20
+ projectManager: this.projectManager,
21
+ sseClients: this.sseClients,
22
+ settingsManager: this.settingsManager,
23
+ });
24
+ }
25
+ async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
26
+ return new Promise((resolve, reject) => {
27
+ this.server.on("error", (err) => {
28
+ if (err.code === "EADDRINUSE") {
29
+ daemonLog(`HTTP API server: port ${port} already in use, skipping HTTP transport`);
30
+ resolve();
31
+ }
32
+ else {
33
+ reject(err);
34
+ }
35
+ });
36
+ this.server.listen(port, host, () => {
37
+ this.isListening = true;
38
+ daemonLog(`HTTP API server listening on ${host}:${port}`);
39
+ resolve();
40
+ });
41
+ });
42
+ }
43
+ async close() {
44
+ if (!this.isListening)
45
+ return;
46
+ this.isListening = false;
47
+ this.sseClients.destroyAll();
48
+ return new Promise((resolve) => {
49
+ this.server.close(() => {
50
+ resolve();
51
+ });
52
+ // Node's server.close() stops accepting new connections but leaves
53
+ // existing keep-alive sockets open, so a browser tab (e.g. Swagger UI)
54
+ // keeps being served on its persistent connection. Force them closed so
55
+ // the port is actually released when the API is toggled off.
56
+ this.server.closeAllConnections?.();
57
+ });
58
+ }
59
+ async restart(port = HTTP_API_PORT, host = HTTP_API_HOST) {
60
+ await this.close();
61
+ await this.listen(port, host);
62
+ }
63
+ broadcastEvent(event, data) {
64
+ this.sseClients.broadcast(event, data);
65
+ }
66
+ async handleRequest(req, res) {
67
+ try {
68
+ const pathSegments = parsePath(req.url);
69
+ const match = matchRoute(this.routes, req.method ?? "GET", pathSegments);
70
+ if (!match) {
71
+ sendError(res, 404, `Not found: ${req.method} ${req.url}`);
72
+ return;
73
+ }
74
+ await match.handler(req, res, match.params);
75
+ }
76
+ catch (err) {
77
+ if (!res.headersSent) {
78
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
79
+ }
80
+ }
81
+ }
82
+ }