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.
- package/dist/app/App.js +119 -0
- package/dist/{tui → app}/index.js +4 -5
- package/dist/app/providers/index.js +1 -0
- package/dist/cli/import-writer.js +1 -1
- package/dist/cli.js +9 -9
- package/dist/client/cli-format.js +7 -0
- package/dist/client/commands.js +2 -164
- package/dist/client/ipc.js +3 -3
- package/dist/client/project-commands.js +159 -0
- package/dist/core/{command-runner.js → command/command-runner.js} +6 -13
- package/dist/core/{foreground-loop.js → foreground/index.js} +4 -4
- package/dist/core/{log-rotator.js → logging/log-rotator.js} +1 -1
- package/dist/core/loop/chain-executor.js +67 -0
- package/dist/core/loop/delay-utils.js +65 -0
- package/dist/core/loop/loop-controller.js +191 -0
- package/dist/core/loop/loop-runner.js +133 -0
- package/dist/core/loop/run-executor.js +90 -0
- package/dist/core/loop/types.js +1 -0
- package/dist/daemon/daemon-log.js +1 -1
- package/dist/daemon/http/helpers.js +81 -0
- package/dist/daemon/http/openapi.js +92 -0
- package/dist/daemon/http/route-loops.js +218 -0
- package/dist/daemon/http/route-misc.js +36 -0
- package/dist/daemon/http/route-projects.js +55 -0
- package/dist/daemon/http/route-tasks.js +53 -0
- package/dist/daemon/http/routes.js +17 -0
- package/dist/daemon/http/server.js +64 -0
- package/dist/daemon/http/sse.js +36 -0
- package/dist/daemon/index.js +7 -9
- package/dist/{ipc/handlers → daemon/ipc}/logs-stream.js +1 -1
- package/dist/daemon/managers/loop-entry.js +24 -0
- package/dist/daemon/{manager.js → managers/loop-manager.js} +32 -152
- package/dist/daemon/managers/loop-options.js +16 -0
- package/dist/daemon/managers/loop-serialization.js +43 -0
- package/dist/daemon/{projects.js → managers/project-manager.js} +6 -4
- package/dist/daemon/{task-manager.js → managers/task-manager.js} +5 -3
- package/dist/daemon/server/handlers/index.js +86 -0
- package/dist/daemon/server/handlers/log-handlers.js +130 -0
- package/dist/daemon/server/handlers/loop-handlers.js +54 -0
- package/dist/daemon/server/handlers/project-handlers.js +37 -0
- package/dist/daemon/server/handlers/task-handlers.js +20 -0
- package/dist/daemon/server/index.js +113 -0
- package/dist/daemon/{spawner.js → spawner/index.js} +4 -4
- package/dist/daemon/{state.js → state/index.js} +4 -4
- package/dist/daemon/{file-watcher.js → watcher/index.js} +8 -6
- package/dist/duration.js +1 -1
- package/dist/{tui/state.js → entities/loops/filters.js} +0 -76
- package/dist/entities/loops/index.js +1 -0
- package/dist/entities/projects/filters.js +69 -0
- package/dist/entities/projects/index.js +1 -0
- package/dist/entities/tasks/filters.js +41 -0
- package/dist/entities/tasks/index.js +1 -0
- package/dist/{tui/components → features/chain-editor}/ChainEditor.js +7 -5
- package/dist/features/code-editor/CodeEditorModal.js +84 -0
- package/dist/{tui/components → features/code-editor}/CodeEditorPreview.js +3 -3
- package/dist/features/code-editor/useEditorKeyboard.js +147 -0
- package/dist/features/code-editor/useModalDimensions.js +9 -0
- package/dist/{tui → features/commands}/commands.js +2 -2
- package/dist/features/commands/useCommandHandlers.js +147 -0
- package/dist/features/commands/useContextualActions.js +75 -0
- package/dist/features/commands/useGlobalShortcuts.js +152 -0
- package/dist/features/forms/FormRouter.js +44 -0
- package/dist/{tui/components → features/overlays}/ContextHelpModal.js +3 -3
- package/dist/{tui/components → features/overlays}/ExportModal.js +3 -3
- package/dist/{tui/components → features/overlays}/HelpGuideModal.js +3 -3
- package/dist/{tui/components → features/overlays}/HelpModal.js +3 -3
- package/dist/features/overlays/OverlayStack.js +9 -0
- package/dist/{tui/components → features/overlays}/ProjectsModal.js +2 -2
- package/dist/{tui/components → features/overlays}/TaskPickerModal.js +2 -2
- package/dist/{tui/components → features/overlays}/WelcomeScreen.js +5 -3
- package/dist/features/overlays/useOverlayStack.js +51 -0
- package/dist/features/state/useAppState.js +150 -0
- package/dist/logger.js +0 -1
- package/dist/loop-config.js +9 -4
- package/dist/{config → shared/config}/constants.js +1 -1
- package/dist/shared/container/index.js +22 -0
- package/dist/{tui → shared}/hooks/useBreakpoint.js +1 -1
- package/dist/shared/hooks/useInject.js +6 -0
- package/dist/{tui → shared}/hooks/useLogStream.js +7 -5
- package/dist/{hooks → shared/hooks}/useLoopFormValidation.js +2 -2
- package/dist/{tui → shared}/hooks/useLoopPolling.js +6 -4
- package/dist/shared/{useUndoRedo.js → hooks/useUndoRedo.js} +2 -4
- package/dist/shared/providers/InversifyProvider.js +6 -0
- package/dist/shared/services/export-service.js +43 -0
- package/dist/shared/services/log-service.js +28 -0
- package/dist/shared/services/loop-service.js +78 -0
- package/dist/shared/services/project-service.js +39 -0
- package/dist/shared/services/task-service.js +41 -0
- package/dist/shared/services/types.js +7 -0
- package/dist/{tui/components → shared/ui}/Button.js +1 -1
- package/dist/{tui/components → shared/ui}/DebugPanel.js +1 -1
- package/dist/{tui/components → shared/ui}/FocusableButton.js +1 -1
- package/dist/{tui/components → shared/ui}/FocusableInput.js +1 -1
- package/dist/{tui/components → shared/ui}/FocusableList.js +1 -1
- package/dist/{tui/components → shared/ui}/Modal.js +1 -1
- package/dist/{tui/components → shared/ui}/SelectModal.js +2 -2
- package/dist/{tui/components → shared/ui}/Toast.js +1 -1
- package/dist/shared/ui/state.js +9 -0
- package/dist/{tui → shared}/utils/paste.js +2 -2
- package/dist/{tui → shared}/utils/validation.js +1 -1
- package/dist/widgets/command-input/CommandDropdown.js +69 -0
- package/dist/widgets/command-input/CommandInput.js +129 -0
- package/dist/widgets/command-input/HintBar.js +9 -0
- package/dist/widgets/command-input/InputModes.js +68 -0
- package/dist/{tui/components → widgets/commands-browser}/CommandsBrowserModal.js +3 -3
- package/dist/{tui/components → widgets/header}/Header.js +3 -3
- package/dist/{tui/components → widgets/header}/TabBar.js +1 -1
- package/dist/{tui/components → widgets/left-panel}/LeftPanel.js +3 -3
- package/dist/{tui/components → widgets/left-panel}/Navigator.js +3 -3
- package/dist/{tui/components → widgets/left-panel}/ProjectsPage.js +9 -34
- package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
- package/dist/{tui/components → widgets/left-panel}/TaskBrowser.js +4 -4
- package/dist/{tui/components → widgets/log-modal}/LogModal.js +8 -6
- package/dist/widgets/loop-form/CreateForm.js +75 -0
- package/dist/widgets/loop-form/TextField.js +9 -0
- package/dist/{tui/components → widgets/loop-form}/WizardForm.js +5 -10
- package/dist/widgets/loop-form/useCreateSteps.js +107 -0
- package/dist/widgets/loop-form/useHandleComplete.js +74 -0
- package/dist/{tui/components → widgets/project-form}/ProjectForm.js +9 -7
- package/dist/{tui/components → widgets/right-panel}/Inspector.js +4 -4
- package/dist/{tui/components → widgets/right-panel}/RightPanel.js +4 -4
- package/dist/{tui/components → widgets/right-panel}/RunHistory.js +52 -4
- package/dist/{tui/components → widgets/task-form}/TaskForm.js +11 -9
- package/package.json +2 -1
- package/dist/core/loop-controller.js +0 -511
- package/dist/daemon/http-server.js +0 -599
- package/dist/daemon/server.js +0 -374
- package/dist/tui/App.js +0 -680
- package/dist/tui/components/CodeEditorModal.js +0 -244
- package/dist/tui/components/CommandInput.js +0 -273
- package/dist/tui/components/CreateForm.js +0 -224
- package/dist/tui/daemon.js +0 -142
- package/dist/tui/format.js +0 -111
- /package/dist/{tui/router.js → app/router/index.js} +0 -0
- /package/dist/{tui → app}/types.js +0 -0
- /package/dist/core/{resolve-cwd.js → command/resolve-cwd.js} +0 -0
- /package/dist/core/{context-parser.js → context/context-parser.js} +0 -0
- /package/dist/core/{template.js → context/template.js} +0 -0
- /package/dist/core/{log-parser.js → logging/log-parser.js} +0 -0
- /package/dist/core/{scheduling.js → scheduling/index.js} +0 -0
- /package/dist/{ipc → daemon/ipc}/send.js +0 -0
- /package/dist/{config → shared/config}/paths.js +0 -0
- /package/dist/{i18n → shared/i18n}/en.json +0 -0
- /package/dist/{i18n → shared/i18n}/index.js +0 -0
- /package/dist/{board → shared/ui}/format.js +0 -0
- /package/dist/{tui → shared/ui}/hooks/useHoverState.js +0 -0
- /package/dist/{tui → shared/ui}/theme.js +0 -0
- /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> —
|
|
74
|
+
<a href="/api/openapi.json">OpenAPI JSON</a> —
|
|
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
|
+
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
5
|
-
import { FileWatcher } from "./
|
|
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;
|
|
@@ -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
|
+
}
|