loop-task 2.1.11 → 2.1.13
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 +3 -1
- package/dist/cli.js +88 -2
- package/dist/client/commands.js +8 -1
- package/dist/client/project-commands.js +7 -2
- package/dist/core/context/template.js +1 -1
- package/dist/core/context/validate-context.js +19 -0
- package/dist/core/loop/chain-executor.js +50 -17
- package/dist/core/loop/loop-controller.js +18 -0
- package/dist/core/loop/loop-runner.js +8 -0
- package/dist/core/loop/run-executor.js +1 -1
- package/dist/core/scheduling/index.js +4 -0
- package/dist/daemon/http/openapi.js +31 -4
- package/dist/daemon/http/route-loops.js +106 -0
- package/dist/daemon/http/route-misc.js +16 -0
- package/dist/daemon/http/route-projects.js +2 -2
- package/dist/daemon/http/route-tasks.js +75 -0
- package/dist/daemon/http/routes.js +3 -2
- package/dist/daemon/http/server.js +20 -2
- package/dist/daemon/index.js +48 -7
- package/dist/daemon/managers/loop-entry.js +2 -0
- package/dist/daemon/managers/loop-manager.js +4 -4
- package/dist/daemon/managers/loop-options.js +1 -0
- package/dist/daemon/managers/loop-serialization.js +1 -0
- package/dist/daemon/managers/project-manager.js +16 -2
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +162 -0
- package/dist/daemon/mcp/tools.js +368 -0
- package/dist/daemon/server/handlers/index.js +8 -1
- package/dist/daemon/server/handlers/project-handlers.js +10 -5
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/index.js +3 -1
- package/dist/daemon/settings-manager.js +46 -0
- package/dist/duration.js +5 -0
- package/dist/entities/loops/filters.js +2 -0
- package/dist/entities/tasks/filters.js +1 -1
- package/dist/features/commands/commands.js +4 -1
- package/dist/features/commands/useCommandHandlers.js +32 -0
- package/dist/features/commands/useGlobalShortcuts.js +0 -1
- package/dist/features/forms/FormRouter.js +2 -0
- package/dist/features/overlays/ExportModal.js +1 -1
- package/dist/loop-config.js +8 -4
- package/dist/shared/clipboard.js +2 -2
- package/dist/shared/config/paths.js +3 -0
- package/dist/shared/container/index.js +2 -0
- package/dist/shared/hooks/useDaemonSettings.js +39 -0
- package/dist/shared/hooks/useUndoRedo.js +1 -1
- package/dist/shared/i18n/en.json +31 -4
- package/dist/shared/services/project-service.js +4 -4
- package/dist/shared/services/settings-service.js +49 -0
- package/dist/shared/services/types.js +1 -0
- package/dist/shared/ui/FocusableInput.js +1 -1
- package/dist/shared/ui/SelectModal.js +1 -1
- package/dist/shared/ui/format.js +2 -0
- package/dist/shared/utils/syntax.js +3 -3
- package/dist/widgets/header/Header.js +15 -2
- package/dist/widgets/left-panel/Navigator.js +3 -2
- package/dist/widgets/left-panel/TaskBrowser.js +3 -2
- package/dist/widgets/log-modal/LogModal.js +1 -1
- package/dist/widgets/loop-form/CreateForm.js +9 -2
- package/dist/widgets/loop-form/WizardForm.js +12 -2
- package/dist/widgets/loop-form/useCreateSteps.js +30 -2
- package/dist/widgets/loop-form/useHandleComplete.js +23 -3
- package/dist/widgets/project-form/ProjectForm.js +11 -2
- package/dist/widgets/right-panel/Inspector.js +1 -1
- package/dist/widgets/right-panel/RightPanel.js +1 -1
- package/dist/widgets/task-form/TaskForm.js +69 -4
- package/package.json +4 -2
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { LOG_TAIL_DEFAULT } from "../../shared/config/constants.js";
|
|
3
3
|
import { tail } from "../../shared/tail.js";
|
|
4
4
|
import { buildLoopOptions } from "../../loop-config.js";
|
|
5
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
5
6
|
import { sendOk, sendError, sendNotFound, parseQuery, readBody } from "./helpers.js";
|
|
6
7
|
export function registerLoopRoutes(manager, routes, r) {
|
|
7
8
|
r("GET", "/api/loops", (_req, res) => {
|
|
@@ -18,6 +19,15 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
18
19
|
r("POST", "/api/loops", async (req, res) => {
|
|
19
20
|
try {
|
|
20
21
|
const body = await readBody(req);
|
|
22
|
+
let context;
|
|
23
|
+
if (body.context !== undefined) {
|
|
24
|
+
const result = validateContext(body.context);
|
|
25
|
+
if (!result.valid) {
|
|
26
|
+
sendError(res, 400, result.error);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
context = result.context;
|
|
30
|
+
}
|
|
21
31
|
const intervalHuman = body.intervalHuman ?? "5m";
|
|
22
32
|
const { options } = buildLoopOptions(intervalHuman, {
|
|
23
33
|
command: body.command,
|
|
@@ -30,6 +40,7 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
30
40
|
description: body.description,
|
|
31
41
|
projectId: body.projectId,
|
|
32
42
|
offset: body.offset,
|
|
43
|
+
context,
|
|
33
44
|
});
|
|
34
45
|
const id = manager.start(options, intervalHuman);
|
|
35
46
|
sendOk(res, { id }, 201);
|
|
@@ -41,6 +52,15 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
41
52
|
r("PATCH", "/api/loops/:id", async (req, res, params) => {
|
|
42
53
|
try {
|
|
43
54
|
const body = await readBody(req);
|
|
55
|
+
let context;
|
|
56
|
+
if (body.context !== undefined) {
|
|
57
|
+
const result = validateContext(body.context);
|
|
58
|
+
if (!result.valid) {
|
|
59
|
+
sendError(res, 400, result.error);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
context = result.context;
|
|
63
|
+
}
|
|
44
64
|
const intervalHuman = body.intervalHuman ?? "5m";
|
|
45
65
|
const { options } = buildLoopOptions(intervalHuman, {
|
|
46
66
|
command: body.command,
|
|
@@ -53,6 +73,7 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
53
73
|
description: body.description,
|
|
54
74
|
projectId: body.projectId,
|
|
55
75
|
offset: body.offset,
|
|
76
|
+
context,
|
|
56
77
|
});
|
|
57
78
|
const ok = await manager.update(params.id, options, intervalHuman);
|
|
58
79
|
if (!ok) {
|
|
@@ -87,6 +108,22 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
87
108
|
}
|
|
88
109
|
sendOk(res);
|
|
89
110
|
});
|
|
111
|
+
r("POST", "/api/loops/:id/play", (_req, res, params) => {
|
|
112
|
+
if (manager.isMaxRunsBlocked(params.id)) {
|
|
113
|
+
sendError(res, 409, "Max runs reached");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (manager.isRunning(params.id)) {
|
|
117
|
+
sendError(res, 409, "Loop is already running");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!manager.playLoop(params.id)) {
|
|
121
|
+
sendNotFound(res, params.id);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const meta = manager.status(params.id);
|
|
125
|
+
sendOk(res, meta);
|
|
126
|
+
});
|
|
90
127
|
r("POST", "/api/loops/:id/trigger", (_req, res, params) => {
|
|
91
128
|
if (manager.isMaxRunsBlocked(params.id)) {
|
|
92
129
|
sendError(res, 400, "Max runs reached");
|
|
@@ -113,6 +150,75 @@ export function registerLoopRoutes(manager, routes, r) {
|
|
|
113
150
|
const count = manager.stopAllLoops();
|
|
114
151
|
sendOk(res, { count });
|
|
115
152
|
});
|
|
153
|
+
// --- Run History ---
|
|
154
|
+
r("GET", "/api/loops/:id/runs", (_req, res, params) => {
|
|
155
|
+
const meta = manager.status(params.id);
|
|
156
|
+
if (!meta) {
|
|
157
|
+
sendNotFound(res, params.id);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const query = parseQuery(_req.url);
|
|
161
|
+
const fromStr = query.get("from");
|
|
162
|
+
const toStr = query.get("to");
|
|
163
|
+
let runs = meta.runHistory;
|
|
164
|
+
if (fromStr) {
|
|
165
|
+
const fromMs = new Date(fromStr).getTime();
|
|
166
|
+
if (!Number.isNaN(fromMs)) {
|
|
167
|
+
runs = runs.filter((r) => new Date(r.startedAt).getTime() >= fromMs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (toStr) {
|
|
171
|
+
const toMs = new Date(toStr).getTime();
|
|
172
|
+
if (!Number.isNaN(toMs)) {
|
|
173
|
+
runs = runs.filter((r) => new Date(r.startedAt).getTime() <= toMs);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
sendOk(res, runs);
|
|
177
|
+
});
|
|
178
|
+
// --- Date-Filtered Logs ---
|
|
179
|
+
r("GET", "/api/loops/:id/logs/date", (_req, res, params) => {
|
|
180
|
+
const meta = manager.status(params.id);
|
|
181
|
+
if (!meta) {
|
|
182
|
+
sendNotFound(res, params.id);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const query = parseQuery(_req.url);
|
|
186
|
+
const fromStr = query.get("from");
|
|
187
|
+
const toStr = query.get("to");
|
|
188
|
+
if (!fromStr || !toStr) {
|
|
189
|
+
sendError(res, 400, "Both 'from' and 'to' query parameters are required (ISO 8601)");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const fromMs = new Date(fromStr).getTime();
|
|
193
|
+
const toMs = new Date(toStr).getTime();
|
|
194
|
+
if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
|
|
195
|
+
sendError(res, 400, "Invalid date format for 'from' or 'to' (use ISO 8601)");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const logPath = manager.getLogPath(params.id);
|
|
199
|
+
if (!logPath || !fs.existsSync(logPath)) {
|
|
200
|
+
sendOk(res, "");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const matching = meta.runHistory.filter((r) => {
|
|
204
|
+
const t = new Date(r.startedAt).getTime();
|
|
205
|
+
return t >= fromMs && t <= toMs;
|
|
206
|
+
});
|
|
207
|
+
if (matching.length === 0) {
|
|
208
|
+
sendOk(res, "");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
matching.sort((a, b) => a.logOffset - b.logOffset);
|
|
212
|
+
const buffer = fs.readFileSync(logPath);
|
|
213
|
+
const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
|
|
214
|
+
const parts = matching.map((record) => {
|
|
215
|
+
const start = record.logOffset;
|
|
216
|
+
const idx = allSorted.indexOf(record);
|
|
217
|
+
const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : buffer.length;
|
|
218
|
+
return buffer.toString("utf-8", start, end);
|
|
219
|
+
});
|
|
220
|
+
sendOk(res, parts.join(""));
|
|
221
|
+
});
|
|
116
222
|
// --- Logs ---
|
|
117
223
|
r("GET", "/api/loops/:id/logs", (_req, res, params) => {
|
|
118
224
|
const logPath = manager.getLogPath(params.id);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initSseResponse } from "./sse.js";
|
|
2
2
|
import { buildOpenApiSpec, buildSwaggerHtml } from "./openapi.js";
|
|
3
|
+
import { sendOk, sendError, readBody } from "./helpers.js";
|
|
3
4
|
export function registerMiscRoutes(sseClients, r) {
|
|
4
5
|
r("GET", "/api/openapi.json", (_req, res) => {
|
|
5
6
|
const spec = buildOpenApiSpec();
|
|
@@ -34,3 +35,18 @@ export function registerMiscRoutes(sseClients, r) {
|
|
|
34
35
|
});
|
|
35
36
|
});
|
|
36
37
|
}
|
|
38
|
+
export function registerSettingsRoutes(settingsManager, r) {
|
|
39
|
+
r("GET", "/api/settings", (_req, res) => {
|
|
40
|
+
sendOk(res, settingsManager.get());
|
|
41
|
+
});
|
|
42
|
+
r("PATCH", "/api/settings", async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const body = await readBody(req);
|
|
45
|
+
const updated = settingsManager.set(body);
|
|
46
|
+
sendOk(res, updated);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
sendError(res, 400, err instanceof Error ? err.message : String(err));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -10,7 +10,7 @@ export function registerProjectRoutes(projectManager, r) {
|
|
|
10
10
|
sendError(res, 400, "Project name is required");
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
|
-
const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff");
|
|
13
|
+
const project = projectManager.create(body.name.trim(), body.color ?? "#ffffff", body.directory, body.githubSource);
|
|
14
14
|
sendOk(res, project, 201);
|
|
15
15
|
}
|
|
16
16
|
catch (err) {
|
|
@@ -24,7 +24,7 @@ export function registerProjectRoutes(projectManager, r) {
|
|
|
24
24
|
sendError(res, 400, "Project name is required");
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
projectManager.update(params.id, body.name.trim(), body.color);
|
|
27
|
+
projectManager.update(params.id, body.name.trim(), body.color, body.directory, body.githubSource);
|
|
28
28
|
sendOk(res);
|
|
29
29
|
}
|
|
30
30
|
catch (err) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateContext } from "../../core/context/validate-context.js";
|
|
1
2
|
import { sendOk, sendError, sendNotFound, readBody } from "./helpers.js";
|
|
2
3
|
export function registerTaskRoutes(taskManager, r) {
|
|
3
4
|
r("GET", "/api/tasks", (_req, res) => {
|
|
@@ -22,6 +23,14 @@ export function registerTaskRoutes(taskManager, r) {
|
|
|
22
23
|
sendError(res, 400, "Task command is required");
|
|
23
24
|
return;
|
|
24
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
|
+
}
|
|
25
34
|
const task = taskManager.create(body);
|
|
26
35
|
sendOk(res, task, 201);
|
|
27
36
|
}
|
|
@@ -32,6 +41,14 @@ export function registerTaskRoutes(taskManager, r) {
|
|
|
32
41
|
r("PATCH", "/api/tasks/:id", async (req, res, params) => {
|
|
33
42
|
try {
|
|
34
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
|
+
}
|
|
35
52
|
const updated = taskManager.update(params.id, body);
|
|
36
53
|
if (!updated) {
|
|
37
54
|
sendNotFound(res, params.id);
|
|
@@ -50,4 +67,62 @@ export function registerTaskRoutes(taskManager, r) {
|
|
|
50
67
|
}
|
|
51
68
|
sendOk(res);
|
|
52
69
|
});
|
|
70
|
+
r("POST", "/api/task-chains", async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const body = await readBody(req);
|
|
73
|
+
if (!body.tasks || !Array.isArray(body.tasks) || body.tasks.length === 0) {
|
|
74
|
+
sendError(res, 400, "tasks array must not be empty");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (body.chain && body.chain !== "sequential-success" && body.chain !== "sequential-failure" && body.chain !== "none") {
|
|
78
|
+
sendError(res, 400, `Invalid chain mode: "${body.chain}". Must be "sequential-success", "sequential-failure", or "none"`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const task of body.tasks) {
|
|
82
|
+
if (!task.name?.trim()) {
|
|
83
|
+
sendError(res, 400, "Each task must have a name");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!task.command?.trim()) {
|
|
87
|
+
sendError(res, 400, "Each task must have a command");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const created = [];
|
|
92
|
+
try {
|
|
93
|
+
for (const taskInput of body.tasks) {
|
|
94
|
+
const task = taskManager.create(taskInput);
|
|
95
|
+
created.push(task);
|
|
96
|
+
}
|
|
97
|
+
const chainMode = body.chain ?? "none";
|
|
98
|
+
if (chainMode === "sequential-success") {
|
|
99
|
+
for (let i = 0; i < created.length - 1; i++) {
|
|
100
|
+
taskManager.update(created[i].id, {
|
|
101
|
+
...created[i],
|
|
102
|
+
onSuccessTaskId: created[i + 1].id,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (chainMode === "sequential-failure") {
|
|
107
|
+
for (let i = 0; i < created.length - 1; i++) {
|
|
108
|
+
taskManager.update(created[i].id, {
|
|
109
|
+
...created[i],
|
|
110
|
+
onFailureTaskId: created[i + 1].id,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
for (const task of created) {
|
|
117
|
+
taskManager.delete(task.id);
|
|
118
|
+
}
|
|
119
|
+
sendError(res, 400, err instanceof Error ? err.message : String(err));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
sendOk(res, { taskIds: created.map((t) => t.id) }, 201);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
sendError(res, 400, err instanceof Error ? err.message : String(err));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
53
128
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { registerLoopRoutes } from "./route-loops.js";
|
|
2
2
|
import { registerTaskRoutes } from "./route-tasks.js";
|
|
3
3
|
import { registerProjectRoutes } from "./route-projects.js";
|
|
4
|
-
import { registerMiscRoutes } from "./route-misc.js";
|
|
4
|
+
import { registerMiscRoutes, registerSettingsRoutes } from "./route-misc.js";
|
|
5
5
|
export function registerRoutes(deps) {
|
|
6
|
-
const { manager, taskManager, projectManager, sseClients } = deps;
|
|
6
|
+
const { manager, taskManager, projectManager, sseClients, settingsManager } = deps;
|
|
7
7
|
const routes = [];
|
|
8
8
|
const r = (method, path, handler) => {
|
|
9
9
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
@@ -13,5 +13,6 @@ export function registerRoutes(deps) {
|
|
|
13
13
|
registerTaskRoutes(taskManager, r);
|
|
14
14
|
registerProjectRoutes(projectManager, r);
|
|
15
15
|
registerMiscRoutes(sseClients, r);
|
|
16
|
+
registerSettingsRoutes(settingsManager, r);
|
|
16
17
|
return routes;
|
|
17
18
|
}
|
|
@@ -5,18 +5,21 @@ import { sendError, matchRoute, parsePath } from "./helpers.js";
|
|
|
5
5
|
import { SseClientSet } from "./sse.js";
|
|
6
6
|
import { registerRoutes } from "./routes.js";
|
|
7
7
|
export class HttpApiServer {
|
|
8
|
-
constructor(manager, taskManager, projectManager) {
|
|
8
|
+
constructor(manager, taskManager, projectManager, settingsManager) {
|
|
9
9
|
this.manager = manager;
|
|
10
10
|
this.taskManager = taskManager;
|
|
11
11
|
this.projectManager = projectManager;
|
|
12
|
+
this.settingsManager = settingsManager;
|
|
12
13
|
this.routes = [];
|
|
13
14
|
this.sseClients = new SseClientSet();
|
|
15
|
+
this.isListening = false;
|
|
14
16
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
15
17
|
this.routes = registerRoutes({
|
|
16
18
|
manager: this.manager,
|
|
17
19
|
taskManager: this.taskManager,
|
|
18
20
|
projectManager: this.projectManager,
|
|
19
21
|
sseClients: this.sseClients,
|
|
22
|
+
settingsManager: this.settingsManager,
|
|
20
23
|
});
|
|
21
24
|
}
|
|
22
25
|
async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
|
|
@@ -31,17 +34,32 @@ export class HttpApiServer {
|
|
|
31
34
|
}
|
|
32
35
|
});
|
|
33
36
|
this.server.listen(port, host, () => {
|
|
37
|
+
this.isListening = true;
|
|
34
38
|
daemonLog(`HTTP API server listening on ${host}:${port}`);
|
|
35
39
|
resolve();
|
|
36
40
|
});
|
|
37
41
|
});
|
|
38
42
|
}
|
|
39
43
|
async close() {
|
|
44
|
+
if (!this.isListening)
|
|
45
|
+
return;
|
|
46
|
+
this.isListening = false;
|
|
40
47
|
this.sseClients.destroyAll();
|
|
41
48
|
return new Promise((resolve) => {
|
|
42
|
-
this.server.close(() =>
|
|
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?.();
|
|
43
57
|
});
|
|
44
58
|
}
|
|
59
|
+
async restart(port = HTTP_API_PORT, host = HTTP_API_HOST) {
|
|
60
|
+
await this.close();
|
|
61
|
+
await this.listen(port, host);
|
|
62
|
+
}
|
|
45
63
|
broadcastEvent(event, data) {
|
|
46
64
|
this.sseClients.broadcast(event, data);
|
|
47
65
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -2,20 +2,27 @@ import { LoopManager } from "./managers/loop-manager.js";
|
|
|
2
2
|
import { TaskManager } from "./managers/task-manager.js";
|
|
3
3
|
import { IpcServer } from "./server/index.js";
|
|
4
4
|
import { HttpApiServer } from "./http/server.js";
|
|
5
|
+
import { McpApiServer } from "./mcp/index.js";
|
|
5
6
|
import { FileWatcher } from "./watcher/index.js";
|
|
7
|
+
import { SettingsManager } from "./settings-manager.js";
|
|
6
8
|
import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, setSelfWriteNotifier, } from "./state/index.js";
|
|
7
9
|
import { setProjectSelfWriteNotifier } from "./managers/project-manager.js";
|
|
8
10
|
import { t } from "../shared/i18n/index.js";
|
|
9
11
|
import { daemonLog } from "./daemon-log.js";
|
|
10
12
|
async function main() {
|
|
13
|
+
const mcpEnabled = (process.env.LOOP_CLI_MCP_ENABLED ?? "true") !== "false";
|
|
14
|
+
const mcpTransport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
|
|
11
15
|
const taskManager = new TaskManager();
|
|
12
16
|
migrateLoopsToJson();
|
|
13
17
|
migrateTasksToJson();
|
|
14
18
|
taskManager.init();
|
|
15
19
|
const manager = new LoopManager(taskManager);
|
|
16
|
-
const
|
|
20
|
+
const settingsManager = new SettingsManager();
|
|
21
|
+
settingsManager.load();
|
|
22
|
+
const server = new IpcServer(manager, taskManager, settingsManager);
|
|
17
23
|
const projectManager = manager.projectManager;
|
|
18
|
-
const httpServer = new HttpApiServer(manager, taskManager, projectManager);
|
|
24
|
+
const httpServer = new HttpApiServer(manager, taskManager, projectManager, settingsManager);
|
|
25
|
+
const mcpServer = new McpApiServer(manager, taskManager, projectManager, mcpTransport);
|
|
19
26
|
try {
|
|
20
27
|
await server.listen();
|
|
21
28
|
}
|
|
@@ -23,13 +30,46 @@ async function main() {
|
|
|
23
30
|
daemonLog(`listen failed (another daemon already holds the socket): ${String(err)}`);
|
|
24
31
|
process.exit(0);
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
|
|
34
|
+
const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
|
|
35
|
+
if (settingsManager.get().httpApiEnabled) {
|
|
36
|
+
try {
|
|
37
|
+
await httpServer.listen(resolvedHttpPort);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
daemonLog(`HTTP API server failed to start: ${String(err)}`);
|
|
41
|
+
}
|
|
29
42
|
}
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
if (mcpEnabled) {
|
|
44
|
+
try {
|
|
45
|
+
await mcpServer.start();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
daemonLog(`MCP server failed to start: ${String(err)}`);
|
|
49
|
+
}
|
|
32
50
|
}
|
|
51
|
+
settingsManager.onChange((settings) => {
|
|
52
|
+
if (settings.httpApiEnabled && !httpServer["isListening"]) {
|
|
53
|
+
httpServer.listen(resolvedHttpPort).catch((err) => {
|
|
54
|
+
daemonLog(`HTTP API server failed to restart: ${String(err)}`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else if (!settings.httpApiEnabled && httpServer["isListening"]) {
|
|
58
|
+
httpServer.close().catch((err) => {
|
|
59
|
+
daemonLog(`HTTP API server failed to stop: ${String(err)}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (settings.mcpApiEnabled && !mcpServer.isListening) {
|
|
63
|
+
mcpServer.start().catch((err) => {
|
|
64
|
+
daemonLog(`MCP server failed to restart: ${String(err)}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (!settings.mcpApiEnabled && mcpServer.isListening) {
|
|
68
|
+
mcpServer.close().catch((err) => {
|
|
69
|
+
daemonLog(`MCP server failed to stop: ${String(err)}`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
33
73
|
manager.init();
|
|
34
74
|
writeDaemonPid(process.pid);
|
|
35
75
|
writeDaemonSignature(computeCodeSignature());
|
|
@@ -53,6 +93,7 @@ async function main() {
|
|
|
53
93
|
await manager.shutdown();
|
|
54
94
|
await server.close();
|
|
55
95
|
await httpServer.close();
|
|
96
|
+
await mcpServer.close();
|
|
56
97
|
}
|
|
57
98
|
catch (err) {
|
|
58
99
|
daemonLog(`error during shutdown: ${String(err)}`);
|
|
@@ -92,11 +92,11 @@ export class LoopManager {
|
|
|
92
92
|
listProjects() {
|
|
93
93
|
return this.projectManager.getAll();
|
|
94
94
|
}
|
|
95
|
-
createProject(name, color, directory) {
|
|
96
|
-
return this.projectManager.create(name, color, directory);
|
|
95
|
+
createProject(name, color, directory, githubSource) {
|
|
96
|
+
return this.projectManager.create(name, color, directory, githubSource);
|
|
97
97
|
}
|
|
98
|
-
updateProject(id, name, color, directory) {
|
|
99
|
-
this.projectManager.update(id, name, color, directory);
|
|
98
|
+
updateProject(id, name, color, directory, githubSource) {
|
|
99
|
+
this.projectManager.update(id, name, color, directory, githubSource);
|
|
100
100
|
}
|
|
101
101
|
deleteProject(id) {
|
|
102
102
|
for (const [loopId, entry] of this.loops) {
|
|
@@ -27,6 +27,7 @@ export function toMeta(controller, options, intervalHuman, taskManager) {
|
|
|
27
27
|
pid: process.pid,
|
|
28
28
|
projectId: options.projectId ?? "default",
|
|
29
29
|
offset: options.offset,
|
|
30
|
+
context: options.context,
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
export function persistLoop(id, controller, options, intervalHuman, taskManager, lastSerialized) {
|
|
@@ -8,6 +8,12 @@ let selfWriteNotifier = null;
|
|
|
8
8
|
export function setProjectSelfWriteNotifier(notifier) {
|
|
9
9
|
selfWriteNotifier = notifier;
|
|
10
10
|
}
|
|
11
|
+
const GITHUB_SOURCE_REGEX = /^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/;
|
|
12
|
+
function validateGithubSource(githubSource) {
|
|
13
|
+
if (!GITHUB_SOURCE_REGEX.test(githubSource)) {
|
|
14
|
+
throw new Error(`Invalid githubSource format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
11
17
|
export class ProjectManager {
|
|
12
18
|
constructor() {
|
|
13
19
|
this.projects = new Map();
|
|
@@ -99,13 +105,16 @@ export class ProjectManager {
|
|
|
99
105
|
get(id) {
|
|
100
106
|
return this.projects.get(id);
|
|
101
107
|
}
|
|
102
|
-
create(name, color, directory) {
|
|
108
|
+
create(name, color, directory, githubSource) {
|
|
109
|
+
if (githubSource !== undefined && githubSource !== "")
|
|
110
|
+
validateGithubSource(githubSource);
|
|
103
111
|
const id = crypto.randomBytes(4).toString("hex");
|
|
104
112
|
const project = {
|
|
105
113
|
id,
|
|
106
114
|
name,
|
|
107
115
|
color,
|
|
108
116
|
directory: directory ?? "",
|
|
117
|
+
githubSource: githubSource || undefined,
|
|
109
118
|
createdAt: new Date().toISOString(),
|
|
110
119
|
isSystem: false,
|
|
111
120
|
isDefault: false,
|
|
@@ -113,7 +122,7 @@ export class ProjectManager {
|
|
|
113
122
|
this.saveProject(project);
|
|
114
123
|
return project;
|
|
115
124
|
}
|
|
116
|
-
update(id, name, color, directory) {
|
|
125
|
+
update(id, name, color, directory, githubSource) {
|
|
117
126
|
const project = this.projects.get(id);
|
|
118
127
|
if (!project)
|
|
119
128
|
throw new Error(`Project ${id} not found`);
|
|
@@ -124,6 +133,11 @@ export class ProjectManager {
|
|
|
124
133
|
project.color = color;
|
|
125
134
|
if (directory !== undefined)
|
|
126
135
|
project.directory = directory;
|
|
136
|
+
if (githubSource !== undefined) {
|
|
137
|
+
if (githubSource !== "")
|
|
138
|
+
validateGithubSource(githubSource);
|
|
139
|
+
project.githubSource = githubSource || undefined;
|
|
140
|
+
}
|
|
127
141
|
this.saveProject(project);
|
|
128
142
|
}
|
|
129
143
|
delete(id) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildOpenApiSpec } from "../http/openapi.js";
|
|
2
|
+
export function buildMcpToolDefsFromOpenApi() {
|
|
3
|
+
const spec = buildOpenApiSpec();
|
|
4
|
+
const defs = [];
|
|
5
|
+
if (!spec.paths)
|
|
6
|
+
return defs;
|
|
7
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
8
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
9
|
+
if (!operation.summary)
|
|
10
|
+
continue;
|
|
11
|
+
const toolName = httpToToolName(method, path);
|
|
12
|
+
if (!toolName)
|
|
13
|
+
continue;
|
|
14
|
+
defs.push({
|
|
15
|
+
name: toolName,
|
|
16
|
+
description: operation.summary,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return defs;
|
|
21
|
+
}
|
|
22
|
+
function httpToToolName(method, path) {
|
|
23
|
+
const mappings = {
|
|
24
|
+
"GET /api/loops": "list_loops",
|
|
25
|
+
"POST /api/loops": "create_loop",
|
|
26
|
+
"GET /api/loops/:id": "get_loop",
|
|
27
|
+
"PATCH /api/loops/:id": "update_loop",
|
|
28
|
+
"DELETE /api/loops/:id": "delete_loop",
|
|
29
|
+
"POST /api/loops/:id/pause": "pause_loop",
|
|
30
|
+
"POST /api/loops/:id/resume": "resume_loop",
|
|
31
|
+
"POST /api/loops/:id/play": "resume_loop",
|
|
32
|
+
"POST /api/loops/:id/trigger": "trigger_loop",
|
|
33
|
+
"POST /api/loops/:id/stop": "stop_loop",
|
|
34
|
+
"POST /api/loops/stop-all": "stop_all_loops",
|
|
35
|
+
"GET /api/loops/:id/logs": "get_loop_logs",
|
|
36
|
+
"GET /api/loops/:id/runs/:num": "get_run_log",
|
|
37
|
+
"GET /api/tasks": "list_tasks",
|
|
38
|
+
"POST /api/tasks": "create_task",
|
|
39
|
+
"GET /api/tasks/:id": "get_task",
|
|
40
|
+
"PATCH /api/tasks/:id": "update_task",
|
|
41
|
+
"DELETE /api/tasks/:id": "delete_task",
|
|
42
|
+
"GET /api/projects": "list_projects",
|
|
43
|
+
"POST /api/projects": "create_project",
|
|
44
|
+
"PATCH /api/projects/:id": "update_project",
|
|
45
|
+
"DELETE /api/projects/:id": "delete_project",
|
|
46
|
+
};
|
|
47
|
+
const normalizedPath = path.replace(/\{[^}]+\}/g, (m) => ":" + m.slice(1, -1));
|
|
48
|
+
const key = `${method.toUpperCase()} ${normalizedPath}`;
|
|
49
|
+
return mappings[key] ?? null;
|
|
50
|
+
}
|