loop-task 2.1.3 → 2.1.5

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 (148) hide show
  1. package/dist/app/App.js +119 -0
  2. package/dist/{tui → app}/index.js +4 -5
  3. package/dist/app/providers/index.js +1 -0
  4. package/dist/cli/import-writer.js +1 -1
  5. package/dist/cli.js +9 -9
  6. package/dist/client/cli-format.js +7 -0
  7. package/dist/client/commands.js +2 -164
  8. package/dist/client/ipc.js +3 -3
  9. package/dist/client/project-commands.js +159 -0
  10. package/dist/core/{command-runner.js → command/command-runner.js} +6 -13
  11. package/dist/core/{foreground-loop.js → foreground/index.js} +4 -4
  12. package/dist/core/{log-rotator.js → logging/log-rotator.js} +1 -1
  13. package/dist/core/loop/chain-executor.js +67 -0
  14. package/dist/core/loop/delay-utils.js +65 -0
  15. package/dist/core/loop/loop-controller.js +191 -0
  16. package/dist/core/loop/loop-runner.js +133 -0
  17. package/dist/core/loop/run-executor.js +90 -0
  18. package/dist/core/loop/types.js +1 -0
  19. package/dist/daemon/daemon-log.js +1 -1
  20. package/dist/daemon/http/helpers.js +81 -0
  21. package/dist/daemon/http/openapi.js +92 -0
  22. package/dist/daemon/http/route-loops.js +218 -0
  23. package/dist/daemon/http/route-misc.js +36 -0
  24. package/dist/daemon/http/route-projects.js +55 -0
  25. package/dist/daemon/http/route-tasks.js +53 -0
  26. package/dist/daemon/http/routes.js +17 -0
  27. package/dist/daemon/http/server.js +64 -0
  28. package/dist/daemon/http/sse.js +36 -0
  29. package/dist/daemon/index.js +7 -9
  30. package/dist/{ipc/handlers → daemon/ipc}/logs-stream.js +1 -1
  31. package/dist/daemon/managers/loop-entry.js +24 -0
  32. package/dist/daemon/{manager.js → managers/loop-manager.js} +32 -152
  33. package/dist/daemon/managers/loop-options.js +16 -0
  34. package/dist/daemon/managers/loop-serialization.js +43 -0
  35. package/dist/daemon/{projects.js → managers/project-manager.js} +6 -4
  36. package/dist/daemon/{task-manager.js → managers/task-manager.js} +5 -3
  37. package/dist/daemon/server/handlers/index.js +86 -0
  38. package/dist/daemon/server/handlers/log-handlers.js +130 -0
  39. package/dist/daemon/server/handlers/loop-handlers.js +54 -0
  40. package/dist/daemon/server/handlers/project-handlers.js +37 -0
  41. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  42. package/dist/daemon/server/index.js +113 -0
  43. package/dist/daemon/{spawner.js → spawner/index.js} +4 -4
  44. package/dist/daemon/{state.js → state/index.js} +4 -4
  45. package/dist/daemon/{file-watcher.js → watcher/index.js} +8 -6
  46. package/dist/duration.js +1 -1
  47. package/dist/{tui/state.js → entities/loops/filters.js} +0 -76
  48. package/dist/entities/loops/index.js +1 -0
  49. package/dist/entities/projects/filters.js +69 -0
  50. package/dist/entities/projects/index.js +1 -0
  51. package/dist/entities/tasks/filters.js +41 -0
  52. package/dist/entities/tasks/index.js +1 -0
  53. package/dist/{tui/components → features/chain-editor}/ChainEditor.js +7 -5
  54. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  55. package/dist/{tui/components → features/code-editor}/CodeEditorPreview.js +3 -3
  56. package/dist/features/code-editor/useEditorKeyboard.js +147 -0
  57. package/dist/features/code-editor/useModalDimensions.js +9 -0
  58. package/dist/{tui → features/commands}/commands.js +2 -2
  59. package/dist/features/commands/useCommandHandlers.js +147 -0
  60. package/dist/features/commands/useContextualActions.js +75 -0
  61. package/dist/features/commands/useGlobalShortcuts.js +152 -0
  62. package/dist/features/forms/FormRouter.js +44 -0
  63. package/dist/{tui/components → features/overlays}/ContextHelpModal.js +3 -3
  64. package/dist/{tui/components → features/overlays}/ExportModal.js +3 -3
  65. package/dist/{tui/components → features/overlays}/HelpGuideModal.js +3 -3
  66. package/dist/{tui/components → features/overlays}/HelpModal.js +3 -3
  67. package/dist/features/overlays/OverlayStack.js +9 -0
  68. package/dist/{tui/components → features/overlays}/ProjectsModal.js +2 -2
  69. package/dist/{tui/components → features/overlays}/TaskPickerModal.js +2 -2
  70. package/dist/{tui/components → features/overlays}/WelcomeScreen.js +5 -3
  71. package/dist/features/overlays/useOverlayStack.js +51 -0
  72. package/dist/features/state/useAppState.js +150 -0
  73. package/dist/logger.js +0 -1
  74. package/dist/loop-config.js +9 -4
  75. package/dist/{config → shared/config}/constants.js +1 -1
  76. package/dist/shared/container/index.js +22 -0
  77. package/dist/{tui → shared}/hooks/useBreakpoint.js +1 -1
  78. package/dist/shared/hooks/useInject.js +6 -0
  79. package/dist/{tui → shared}/hooks/useLogStream.js +7 -5
  80. package/dist/{hooks → shared/hooks}/useLoopFormValidation.js +2 -2
  81. package/dist/{tui → shared}/hooks/useLoopPolling.js +6 -4
  82. package/dist/shared/{useUndoRedo.js → hooks/useUndoRedo.js} +2 -4
  83. package/dist/shared/providers/InversifyProvider.js +6 -0
  84. package/dist/shared/services/export-service.js +43 -0
  85. package/dist/shared/services/log-service.js +28 -0
  86. package/dist/shared/services/loop-service.js +78 -0
  87. package/dist/shared/services/project-service.js +39 -0
  88. package/dist/shared/services/task-service.js +41 -0
  89. package/dist/shared/services/types.js +7 -0
  90. package/dist/{tui/components → shared/ui}/Button.js +1 -1
  91. package/dist/{tui/components → shared/ui}/DebugPanel.js +1 -1
  92. package/dist/{tui/components → shared/ui}/FocusableButton.js +1 -1
  93. package/dist/{tui/components → shared/ui}/FocusableInput.js +1 -1
  94. package/dist/{tui/components → shared/ui}/FocusableList.js +1 -1
  95. package/dist/{tui/components → shared/ui}/Modal.js +1 -1
  96. package/dist/{tui/components → shared/ui}/SelectModal.js +2 -2
  97. package/dist/{tui/components → shared/ui}/Toast.js +1 -1
  98. package/dist/shared/ui/state.js +9 -0
  99. package/dist/{tui → shared}/utils/paste.js +2 -2
  100. package/dist/{tui → shared}/utils/validation.js +1 -1
  101. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  102. package/dist/widgets/command-input/CommandInput.js +129 -0
  103. package/dist/widgets/command-input/HintBar.js +9 -0
  104. package/dist/widgets/command-input/InputModes.js +68 -0
  105. package/dist/{tui/components → widgets/commands-browser}/CommandsBrowserModal.js +3 -3
  106. package/dist/{tui/components → widgets/header}/Header.js +3 -3
  107. package/dist/{tui/components → widgets/header}/TabBar.js +1 -1
  108. package/dist/{tui/components → widgets/left-panel}/LeftPanel.js +3 -3
  109. package/dist/{tui/components → widgets/left-panel}/Navigator.js +3 -3
  110. package/dist/{tui/components → widgets/left-panel}/ProjectsPage.js +9 -34
  111. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  112. package/dist/{tui/components → widgets/left-panel}/TaskBrowser.js +4 -4
  113. package/dist/{tui/components → widgets/log-modal}/LogModal.js +8 -6
  114. package/dist/widgets/loop-form/CreateForm.js +75 -0
  115. package/dist/widgets/loop-form/TextField.js +9 -0
  116. package/dist/{tui/components → widgets/loop-form}/WizardForm.js +5 -10
  117. package/dist/widgets/loop-form/useCreateSteps.js +107 -0
  118. package/dist/widgets/loop-form/useHandleComplete.js +74 -0
  119. package/dist/{tui/components → widgets/project-form}/ProjectForm.js +9 -7
  120. package/dist/{tui/components → widgets/right-panel}/Inspector.js +4 -4
  121. package/dist/{tui/components → widgets/right-panel}/RightPanel.js +4 -4
  122. package/dist/{tui/components → widgets/right-panel}/RunHistory.js +52 -4
  123. package/dist/{tui/components → widgets/task-form}/TaskForm.js +11 -9
  124. package/package.json +2 -1
  125. package/dist/core/loop-controller.js +0 -511
  126. package/dist/daemon/http-server.js +0 -599
  127. package/dist/daemon/server.js +0 -374
  128. package/dist/tui/App.js +0 -680
  129. package/dist/tui/components/CodeEditorModal.js +0 -244
  130. package/dist/tui/components/CommandInput.js +0 -273
  131. package/dist/tui/components/CreateForm.js +0 -224
  132. package/dist/tui/daemon.js +0 -142
  133. package/dist/tui/format.js +0 -111
  134. /package/dist/{tui/router.js → app/router/index.js} +0 -0
  135. /package/dist/{tui → app}/types.js +0 -0
  136. /package/dist/core/{resolve-cwd.js → command/resolve-cwd.js} +0 -0
  137. /package/dist/core/{context-parser.js → context/context-parser.js} +0 -0
  138. /package/dist/core/{template.js → context/template.js} +0 -0
  139. /package/dist/core/{log-parser.js → logging/log-parser.js} +0 -0
  140. /package/dist/core/{scheduling.js → scheduling/index.js} +0 -0
  141. /package/dist/{ipc → daemon/ipc}/send.js +0 -0
  142. /package/dist/{config → shared/config}/paths.js +0 -0
  143. /package/dist/{i18n → shared/i18n}/en.json +0 -0
  144. /package/dist/{i18n → shared/i18n}/index.js +0 -0
  145. /package/dist/{board → shared/ui}/format.js +0 -0
  146. /package/dist/{tui → shared/ui}/hooks/useHoverState.js +0 -0
  147. /package/dist/{tui → shared/ui}/theme.js +0 -0
  148. /package/dist/{tui → shared}/utils/syntax.js +0 -0
@@ -0,0 +1,92 @@
1
+ import { HTTP_API_HOST, HTTP_API_PORT } from "../../shared/config/constants.js";
2
+ export function buildOpenApiSpec() {
3
+ return {
4
+ openapi: "3.0.3",
5
+ info: {
6
+ title: "loop-task HTTP API",
7
+ version: "1.0.0",
8
+ description: "REST + SSE API for managing loops, tasks, projects, and logs. All endpoints are localhost-only (127.0.0.1).",
9
+ },
10
+ servers: [
11
+ { url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
12
+ ],
13
+ paths: {
14
+ "/api/loops": {
15
+ get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
16
+ 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" } } },
17
+ },
18
+ "/api/loops/{id}": {
19
+ 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" } } },
20
+ 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" } } },
21
+ 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" } } },
22
+ },
23
+ "/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" } } } },
24
+ "/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" } } } },
25
+ "/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" } } } },
26
+ "/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" } } } },
27
+ "/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" } } } } } } } } },
28
+ "/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" } } } },
29
+ "/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" } } } },
30
+ "/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" } } } },
31
+ "/api/tasks": {
32
+ get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
33
+ 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" } } },
34
+ },
35
+ "/api/tasks/{id}": {
36
+ 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" } } },
37
+ 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" } } },
38
+ 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" } } },
39
+ },
40
+ "/api/projects": {
41
+ get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
42
+ 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" } } },
43
+ },
44
+ "/api/projects/{id}": {
45
+ 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" } } },
46
+ 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" } } },
47
+ },
48
+ "/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
49
+ "/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
50
+ "/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
51
+ },
52
+ };
53
+ }
54
+ export function buildSwaggerHtml() {
55
+ const specUrl = "/api/openapi.json";
56
+ return `<!DOCTYPE html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
61
+ <title>loop-task API</title>
62
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
63
+ <style>
64
+ body { margin: 0; }
65
+ .swagger-ui { max-width: 1200px; margin: 0 auto; }
66
+ .header { background: #0d1117; color: #58a6ff; padding: 12px 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
67
+ .header a { color: #58a6ff; text-decoration: none; }
68
+ .header a:hover { text-decoration: underline; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <div class="header">
73
+ <strong>loop-task HTTP API</strong> &mdash;
74
+ <a href="/api/openapi.json">OpenAPI JSON</a> &mdash;
75
+ <a href="/api/docs">Swagger UI</a>
76
+ </div>
77
+ <div id="swagger-ui"></div>
78
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
79
+ <script>
80
+ window.onload = function() {
81
+ window.ui = SwaggerUIBundle({
82
+ url: "${specUrl}",
83
+ dom_id: "#swagger-ui",
84
+ deepLinking: true,
85
+ presets: [SwaggerUIBundle.presets.apis],
86
+ layout: "BaseLayout",
87
+ });
88
+ };
89
+ </script>
90
+ </body>
91
+ </html>`;
92
+ }
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
3
+ import { tail } from "../../shared/tail.js";
4
+ import { buildLoopOptions } from "../../loop-config.js";
5
+ import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
6
+ export function registerLoopRoutes(manager, routes, r) {
7
+ r("GET", "/api/loops", (_req, res) => {
8
+ sendOk(res, manager.list());
9
+ });
10
+ r("GET", "/api/loops/:id", (_req, res, params) => {
11
+ const meta = manager.status(params.id);
12
+ if (!meta) {
13
+ sendNotFound(res, params.id);
14
+ return;
15
+ }
16
+ sendOk(res, meta);
17
+ });
18
+ r("POST", "/api/loops", async (req, res) => {
19
+ try {
20
+ const body = await readBody(req);
21
+ const intervalHuman = body.intervalHuman ?? "5m";
22
+ const { options } = buildLoopOptions(intervalHuman, {
23
+ command: body.command,
24
+ commandArgs: body.commandArgs,
25
+ taskId: body.taskId,
26
+ cwd: body.cwd,
27
+ now: body.now,
28
+ maxRuns: body.maxRuns,
29
+ verbose: body.verbose,
30
+ description: body.description,
31
+ projectId: body.projectId,
32
+ offset: body.offset,
33
+ });
34
+ const id = manager.start(options, intervalHuman);
35
+ sendOk(res, { id }, 201);
36
+ }
37
+ catch (err) {
38
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
39
+ }
40
+ });
41
+ r("PATCH", "/api/loops/:id", async (req, res, params) => {
42
+ try {
43
+ const body = await readBody(req);
44
+ const intervalHuman = body.intervalHuman ?? "5m";
45
+ const { options } = buildLoopOptions(intervalHuman, {
46
+ command: body.command,
47
+ commandArgs: body.commandArgs,
48
+ taskId: body.taskId,
49
+ cwd: body.cwd,
50
+ now: body.now,
51
+ maxRuns: body.maxRuns,
52
+ verbose: body.verbose,
53
+ description: body.description,
54
+ projectId: body.projectId,
55
+ offset: body.offset,
56
+ });
57
+ const ok = await manager.update(params.id, options, intervalHuman);
58
+ if (!ok) {
59
+ sendNotFound(res, params.id);
60
+ return;
61
+ }
62
+ sendOk(res, { id: params.id });
63
+ }
64
+ catch (err) {
65
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
66
+ }
67
+ });
68
+ r("DELETE", "/api/loops/:id", async (_req, res, params) => {
69
+ const ok = await manager.delete(params.id);
70
+ if (!ok) {
71
+ sendNotFound(res, params.id);
72
+ return;
73
+ }
74
+ sendOk(res);
75
+ });
76
+ r("POST", "/api/loops/:id/pause", (_req, res, params) => {
77
+ if (!manager.pause(params.id)) {
78
+ sendNotFound(res, params.id);
79
+ return;
80
+ }
81
+ sendOk(res);
82
+ });
83
+ r("POST", "/api/loops/:id/resume", (_req, res, params) => {
84
+ if (!manager.resume(params.id)) {
85
+ sendNotFound(res, params.id);
86
+ return;
87
+ }
88
+ sendOk(res);
89
+ });
90
+ r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
91
+ if (manager.isMaxRunsBlocked(params.id)) {
92
+ sendError(res, 400, "Max runs reached");
93
+ return;
94
+ }
95
+ if (manager.isRunning(params.id)) {
96
+ sendError(res, 409, "Loop is already running");
97
+ return;
98
+ }
99
+ if (!manager.trigger(params.id)) {
100
+ sendNotFound(res, params.id);
101
+ return;
102
+ }
103
+ sendOk(res);
104
+ });
105
+ r("POST", "/api/loops/:id/stop", (_req, res, params) => {
106
+ if (!manager.stopLoop(params.id)) {
107
+ sendNotFound(res, params.id);
108
+ return;
109
+ }
110
+ sendOk(res);
111
+ });
112
+ r("POST", "/api/loops/stop-all", (_req, res) => {
113
+ const count = manager.stopAllLoops();
114
+ sendOk(res, { count });
115
+ });
116
+ // --- Logs ---
117
+ r("GET", "/api/loops/:id/logs", (_req, res, params) => {
118
+ const logPath = manager.getLogPath(params.id);
119
+ if (!logPath) {
120
+ sendNotFound(res, params.id);
121
+ return;
122
+ }
123
+ const query = parseQuery(_req.url);
124
+ const tailCount = parseInt(query.get("tail") ?? String(LOG_TAIL_DEFAULT), 10);
125
+ if (!fs.existsSync(logPath)) {
126
+ sendOk(res, "");
127
+ return;
128
+ }
129
+ const content = fs.readFileSync(logPath, "utf-8");
130
+ sendOk(res, tail(content, tailCount).join("\n"));
131
+ });
132
+ r("GET", "/api/loops/:id/logs/stream", (_req, res, params) => {
133
+ const logPath = manager.getLogPath(params.id);
134
+ if (!logPath) {
135
+ sendNotFound(res, params.id);
136
+ return;
137
+ }
138
+ res.writeHead(200, {
139
+ "Content-Type": "text/event-stream",
140
+ "Cache-Control": "no-cache",
141
+ "Connection": "keep-alive",
142
+ });
143
+ const query = parseQuery(_req.url);
144
+ const tailCount = parseInt(query.get("tail") ?? "0", 10);
145
+ if (fs.existsSync(logPath)) {
146
+ const content = fs.readFileSync(logPath, "utf-8");
147
+ for (const line of tail(content, tailCount)) {
148
+ if (line) {
149
+ res.write(`data: ${line}\n\n`);
150
+ }
151
+ }
152
+ }
153
+ let fileSize = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
154
+ const watcher = fs.watch(logPath, (eventType) => {
155
+ if (eventType === "rename" && !fs.existsSync(logPath)) {
156
+ watcher.close();
157
+ res.write("event: end\ndata: {}\n\n");
158
+ res.end();
159
+ return;
160
+ }
161
+ if (eventType === "change") {
162
+ try {
163
+ const stat = fs.statSync(logPath);
164
+ if (stat.size > fileSize) {
165
+ const fd = fs.openSync(logPath, "r");
166
+ const buf = Buffer.alloc(stat.size - fileSize);
167
+ fs.readSync(fd, buf, 0, buf.length, fileSize);
168
+ fs.closeSync(fd);
169
+ fileSize = stat.size;
170
+ for (const line of buf.toString().split("\n")) {
171
+ if (line) {
172
+ res.write(`data: ${line}\n\n`);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ watcher.close();
179
+ res.end();
180
+ }
181
+ }
182
+ });
183
+ _req.on("close", () => {
184
+ watcher.close();
185
+ res.end();
186
+ });
187
+ });
188
+ r("GET", "/api/loops/:id/runs/:num", (_req, res, params) => {
189
+ const { id, num } = params;
190
+ const logPath = manager.getLogPath(id);
191
+ if (!logPath || !fs.existsSync(logPath)) {
192
+ sendOk(res, "");
193
+ return;
194
+ }
195
+ const meta = manager.status(id);
196
+ if (!meta) {
197
+ sendNotFound(res, id);
198
+ return;
199
+ }
200
+ const runNumber = parseInt(num, 10);
201
+ const records = meta.runHistory
202
+ .filter((r) => r.runNumber === runNumber)
203
+ .sort((a, b) => a.logOffset - b.logOffset);
204
+ if (records.length === 0) {
205
+ sendOk(res, "");
206
+ return;
207
+ }
208
+ const buffer = fs.readFileSync(logPath);
209
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
210
+ const parts = records.map((record) => {
211
+ const start = record.logOffset;
212
+ const idx = allSorted.indexOf(record);
213
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
214
+ return buffer.toString("utf-8", start, end);
215
+ });
216
+ sendOk(res, parts.join(""));
217
+ });
218
+ }
@@ -0,0 +1,36 @@
1
+ import { initSseResponse } from "./sse.js";
2
+ import { buildOpenApiSpec, buildSwaggerHtml } from "./openapi.js";
3
+ export function registerMiscRoutes(sseClients, r) {
4
+ r("GET", "/api/openapi.json", (_req, res) => {
5
+ const spec = buildOpenApiSpec();
6
+ const body = JSON.stringify(spec, null, 2);
7
+ res.writeHead(200, {
8
+ "Content-Type": "application/json",
9
+ "Content-Length": Buffer.byteLength(body),
10
+ });
11
+ res.end(body);
12
+ });
13
+ r("GET", "/api/docs", (_req, res) => {
14
+ const html = buildSwaggerHtml();
15
+ res.writeHead(200, {
16
+ "Content-Type": "text/html",
17
+ "Content-Length": Buffer.byteLength(html),
18
+ });
19
+ res.end(html);
20
+ });
21
+ r("GET", "/", (_req, res) => {
22
+ const html = buildSwaggerHtml();
23
+ res.writeHead(200, {
24
+ "Content-Type": "text/html",
25
+ "Content-Length": Buffer.byteLength(html),
26
+ });
27
+ res.end(html);
28
+ });
29
+ r("GET", "/api/events", (_req, res) => {
30
+ initSseResponse(res);
31
+ sseClients.add(res);
32
+ _req.on("close", () => {
33
+ sseClients.delete(res);
34
+ });
35
+ });
36
+ }
@@ -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");
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);
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,53 @@
1
+ import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
2
+ export function registerTaskRoutes(taskManager, r) {
3
+ r("GET", "/api/tasks", (_req, res) => {
4
+ sendOk(res, taskManager.list());
5
+ });
6
+ r("GET", "/api/tasks/:id", (_req, res, params) => {
7
+ const task = taskManager.get(params.id);
8
+ if (!task) {
9
+ sendNotFound(res, params.id);
10
+ return;
11
+ }
12
+ sendOk(res, task);
13
+ });
14
+ r("POST", "/api/tasks", async (req, res) => {
15
+ try {
16
+ const body = await readBody(req);
17
+ if (!body.name?.trim()) {
18
+ sendError(res, 400, "Task name is required");
19
+ return;
20
+ }
21
+ if (!body.command?.trim()) {
22
+ sendError(res, 400, "Task command is required");
23
+ return;
24
+ }
25
+ const task = taskManager.create(body);
26
+ sendOk(res, task, 201);
27
+ }
28
+ catch (err) {
29
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
30
+ }
31
+ });
32
+ r("PATCH", "/api/tasks/:id", async (req, res, params) => {
33
+ try {
34
+ const body = await readBody(req);
35
+ const updated = taskManager.update(params.id, body);
36
+ if (!updated) {
37
+ sendNotFound(res, params.id);
38
+ return;
39
+ }
40
+ sendOk(res, updated);
41
+ }
42
+ catch (err) {
43
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
44
+ }
45
+ });
46
+ r("DELETE", "/api/tasks/:id", (_req, res, params) => {
47
+ if (!taskManager.delete(params.id)) {
48
+ sendNotFound(res, params.id);
49
+ return;
50
+ }
51
+ sendOk(res);
52
+ });
53
+ }
@@ -0,0 +1,17 @@
1
+ import { registerLoopRoutes } from "./route-loops.js";
2
+ import { registerTaskRoutes } from "./route-tasks.js";
3
+ import { registerProjectRoutes } from "./route-projects.js";
4
+ import { registerMiscRoutes } from "./route-misc.js";
5
+ export function registerRoutes(deps) {
6
+ const { manager, taskManager, projectManager, sseClients } = 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
+ return routes;
17
+ }
@@ -0,0 +1,64 @@
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) {
9
+ this.manager = manager;
10
+ this.taskManager = taskManager;
11
+ this.projectManager = projectManager;
12
+ this.routes = [];
13
+ this.sseClients = new SseClientSet();
14
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
15
+ this.routes = registerRoutes({
16
+ manager: this.manager,
17
+ taskManager: this.taskManager,
18
+ projectManager: this.projectManager,
19
+ sseClients: this.sseClients,
20
+ });
21
+ }
22
+ async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
23
+ return new Promise((resolve, reject) => {
24
+ this.server.on("error", (err) => {
25
+ if (err.code === "EADDRINUSE") {
26
+ daemonLog(`HTTP API server: port ${port} already in use, skipping HTTP transport`);
27
+ resolve();
28
+ }
29
+ else {
30
+ reject(err);
31
+ }
32
+ });
33
+ this.server.listen(port, host, () => {
34
+ daemonLog(`HTTP API server listening on ${host}:${port}`);
35
+ resolve();
36
+ });
37
+ });
38
+ }
39
+ async close() {
40
+ this.sseClients.destroyAll();
41
+ return new Promise((resolve) => {
42
+ this.server.close(() => resolve());
43
+ });
44
+ }
45
+ broadcastEvent(event, data) {
46
+ this.sseClients.broadcast(event, data);
47
+ }
48
+ async handleRequest(req, res) {
49
+ try {
50
+ const pathSegments = parsePath(req.url);
51
+ const match = matchRoute(this.routes, req.method ?? "GET", pathSegments);
52
+ if (!match) {
53
+ sendError(res, 404, `Not found: ${req.method} ${req.url}`);
54
+ return;
55
+ }
56
+ await match.handler(req, res, match.params);
57
+ }
58
+ catch (err) {
59
+ if (!res.headersSent) {
60
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
61
+ }
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ export class SseClientSet {
2
+ constructor() {
3
+ this.clients = new Set();
4
+ }
5
+ add(res) {
6
+ this.clients.add(res);
7
+ }
8
+ delete(res) {
9
+ this.clients.delete(res);
10
+ }
11
+ destroyAll() {
12
+ for (const client of this.clients) {
13
+ client.destroy();
14
+ }
15
+ this.clients.clear();
16
+ }
17
+ broadcast(event, data) {
18
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data ?? {})}\n\n`;
19
+ for (const client of this.clients) {
20
+ try {
21
+ client.write(payload);
22
+ }
23
+ catch {
24
+ this.clients.delete(client);
25
+ }
26
+ }
27
+ }
28
+ }
29
+ export function initSseResponse(res) {
30
+ res.writeHead(200, {
31
+ "Content-Type": "text/event-stream",
32
+ "Cache-Control": "no-cache",
33
+ "Connection": "keep-alive",
34
+ });
35
+ res.write(": connected\n\n");
36
+ }
@@ -1,10 +1,10 @@
1
- import { LoopManager } from "./manager.js";
2
- import { TaskManager } from "./task-manager.js";
3
- import { IpcServer } from "./server.js";
4
- import { HttpApiServer } from "./http-server.js";
5
- import { FileWatcher } from "./file-watcher.js";
6
- import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, } from "./state.js";
7
- import { t } from "../i18n/index.js";
1
+ import { LoopManager } from "./managers/loop-manager.js";
2
+ import { TaskManager } from "./managers/task-manager.js";
3
+ import { IpcServer } from "./server/index.js";
4
+ import { HttpApiServer } from "./http/server.js";
5
+ import { FileWatcher } from "./watcher/index.js";
6
+ import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, } from "./state/index.js";
7
+ import { t } from "../shared/i18n/index.js";
8
8
  import { daemonLog } from "./daemon-log.js";
9
9
  async function main() {
10
10
  const taskManager = new TaskManager();
@@ -39,8 +39,6 @@ async function main() {
39
39
  daemonLog(`file watcher started for hot-reloading JSON configs`);
40
40
  let shuttingDown = false;
41
41
  const cleanup = async () => {
42
- // Re-entry guard: a persist failure during shutdown must not re-trigger
43
- // cleanup via uncaughtException, or the daemon crash-loops forever.
44
42
  if (shuttingDown)
45
43
  return;
46
44
  shuttingDown = true;
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { send } from "../send.js";
2
+ import { send } from "./send.js";
3
3
  import { tail } from "../../shared/tail.js";
4
4
  const TAIL_WINDOW_BYTES = 64 * 1024;
5
5
  function readInitialTail(logPath, tailCount) {
@@ -0,0 +1,24 @@
1
+ import { LoopController } from "../../core/loop/loop-controller.js";
2
+ import { getLogPath } from "../state/index.js";
3
+ import { buildLoopOptions } from "./loop-options.js";
4
+ export function createLoopEntry(meta, taskResolver, getProjectDirectory, state) {
5
+ const options = buildLoopOptions(meta);
6
+ const logPath = getLogPath(meta.id);
7
+ const projectDir = getProjectDirectory(options.projectId ?? "default");
8
+ const controller = new LoopController(meta.id, options, logPath, taskResolver, state, projectDir);
9
+ return { controller, options, intervalHuman: meta.intervalHuman };
10
+ }
11
+ export function metaToState(meta) {
12
+ return {
13
+ status: meta.status,
14
+ createdAt: meta.createdAt,
15
+ runCount: meta.runCount,
16
+ sessionStartedAt: meta.sessionStartedAt,
17
+ lastRunAt: meta.lastRunAt,
18
+ lastExitCode: meta.lastExitCode,
19
+ lastDuration: meta.lastDuration,
20
+ nextRunAt: meta.nextRunAt,
21
+ remainingDelayMs: meta.remainingDelayMs,
22
+ runHistory: meta.runHistory,
23
+ };
24
+ }