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
package/dist/app/App.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo, useCallback } from "react";
|
|
3
3
|
import { Box, useApp } from "ink";
|
|
4
4
|
import { useLoopPolling } from "../shared/hooks/useLoopPolling.js";
|
|
5
|
+
import { useDaemonSettings } from "../shared/hooks/useDaemonSettings.js";
|
|
5
6
|
import { useLogStream } from "../shared/hooks/useLogStream.js";
|
|
6
7
|
import { useBreakpoint } from "../shared/hooks/useBreakpoint.js";
|
|
7
8
|
import { useToasts } from "../shared/ui/Toast.js";
|
|
@@ -42,6 +43,7 @@ export function App(props) {
|
|
|
42
43
|
const logService = useInject(TYPES.LogService);
|
|
43
44
|
const exportService = useInject(TYPES.ExportService);
|
|
44
45
|
const { loops, daemonStatus, refresh } = useLoopPolling();
|
|
46
|
+
const daemonSettings = useDaemonSettings();
|
|
45
47
|
const { view, push, pop } = useRouter("board");
|
|
46
48
|
const { toasts, push: pushToast } = useToasts();
|
|
47
49
|
const breakpoint = useBreakpoint();
|
|
@@ -113,7 +115,7 @@ export function App(props) {
|
|
|
113
115
|
pushToast("success", updated ? t("project.toastUpdated", { name }) : t("project.toastCreated", { name }));
|
|
114
116
|
};
|
|
115
117
|
const commandContext = useMemo(() => ({ activeTab: s.activeTab, selectedLoop: s.selected, selectedTask: s.selectedTask, selectedProject: s.selectedProjectEntity }), [s.activeTab, s.selected, s.selectedTask]);
|
|
116
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
|
|
118
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: process.stdout.rows || 24, backgroundColor: theme.bg.base, children: [_jsx(Header, { daemonStatus: daemonStatus, httpApiEnabled: daemonSettings.reachable ? daemonSettings.httpApiEnabled : undefined, mcpApiEnabled: daemonSettings.reachable ? daemonSettings.mcpApiEnabled : undefined, counts: s.counts, activeTab: s.activeTab, onTabChange: s.setActiveTab, tabCounts: s.tabCounts }), _jsx(Box, { flexGrow: 1, children: isBoardView(view) ? (_jsxs(Box, { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, children: [_jsx(LeftPanel, { isFocused: s.focusedPanel === "left" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, query: s.leftPanelQuery, loops: s.visible, selectedIndex: s.clampedIndex, filters: s.filters, sort: s.sort, breakpoint: breakpoint, projects: s.projects, onSelect: (i) => s.setSelectedIndex(i), onActivate: (i) => { s.setSelectedIndex(i); }, tasks: s.filteredTasks, taskSelectedIndex: s.taskClampedIndex, onTaskSelect: (i) => s.setTaskSelectedIndex(i), onTaskActivate: (i) => { s.setTaskSelectedIndex(i); s.setEditTask(s.filteredTasks[i] ?? null); push("task-edit"); }, onStatusCycle: () => s.setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => s.setSort(cycleSortMode(s.sort)), onSelectProject: () => s.setActiveTab("projects"), currentProjectName: s.currentProjectId === "all" ? t("project.showAll") : (s.projects.find(p => p.id === s.currentProjectId)?.name ?? "Default"), projectFilters: s.projectFilters, projectSelectedIndex: s.projectClampedIndex, onProjectSelect: (i) => s.setProjectSelectedIndex(i), onProjectActivate: (i) => { s.setProjectSelectedIndex(i); }, projectLoops: loops }), _jsx(RightPanel, { isFocused: s.focusedPanel === "right" && !anyModalOpen, navActive: inputOwner === "panel", activeTab: s.activeTab, loop: s.selected, selectedRunIndex: s.selectedRunIndex, onSelectRun: (i) => s.setSelectedRunIndex(i), onOpenRun: s.handleOpenRunLog, selectedTask: s.selectedTask, allTasks: s.tasks, selectedProject: s.selectedProjectEntity, projectLoopCount: s.projectLoopCount, projects: s.projects, onProjectEdit: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
|
|
117
119
|
handleCommand("edit"); }, onProjectDelete: () => { if (s.selectedProjectEntity && !s.selectedProjectEntity.isSystem)
|
|
118
120
|
handleCommand("delete"); } }), s.debugMode ? _jsx(DebugPanel, { entries: s.debugEntries }) : null] })) : (_jsx(FormRouter, { view: view, editTarget: s.editTarget, cloneMode: s.cloneMode, editTask: s.editTask, editProject: s.editProject, pendingTaskSelection: s.pendingTaskSelection, tasks: s.tasks, projects: s.projects, currentProjectId: s.currentProjectId, cancelCreate: cancelCreate, onCreateDone: onCreateDone, handleChooseTask: handleChooseTask, cancelTask: cancelTask, onTaskDone: onTaskDone, cancelProject: cancelProject, onProjectDone: onProjectDone })) }, viewKey(view, s.editTarget, s.editTask)), isBoardView(view) ? (_jsx(CommandInput, { context: commandContext, onCommand: handleCommand, confirmState: s.confirmState, searchState: s.searchState, searchValue: s.searchValue, onSearchChange: s.handleSearchChange, onSearchSubmit: s.handleSearchSubmit, onSearchCancel: s.handleSearchCancel, onConfirmYes: handleConfirmYes, onConfirmCancel: handleConfirmCancel, onCopy: handleContextualCopy, onPanelAction: triggerContextualAction, disabled: commandInputDisabled, navOwner: inputOwner, onInputStateChange: (hasText, dropdownOpen) => { s.setCommandBarHasText(hasText); s.setCommandBarDropdownOpen(dropdownOpen); } })) : null, _jsx(OverlayStack, { commandsBrowserOpen: s.commandsBrowserOpen, commandContext: commandContext, onCommandsBrowserClose: () => s.setCommandsBrowserOpen(false), onCommandsBrowserExecute: (v) => { s.setCommandsBrowserOpen(false); handleCommand(v); }, contextHelpOpen: s.contextHelpOpen, onContextHelpClose: () => s.setContextHelpOpen(false), exportModal: s.exportModal, onExportModalClose: () => s.setExportModal(null), onExportCopy: () => pushToast("success", t("board.toastCopied")), logModalRun: s.logModalRun, logModalLoopId: s.logModalLoopId, logModalLines: s.logModalLines, logModalLoading: s.logModalLoading, onLogModalClose: () => { s.setLogModalRun(null); s.setLogModalLoopId(null); }, onLogCopy: () => pushToast("success", t("board.toastCopied")), toasts: toasts })] }));
|
|
119
121
|
}
|
package/dist/cli.js
CHANGED
|
@@ -79,12 +79,31 @@ program
|
|
|
79
79
|
.option("--project <name>", t("cli.optProject"))
|
|
80
80
|
.option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
|
|
81
81
|
.option("--description <desc>", "Description of the loop")
|
|
82
|
+
.option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
|
|
82
83
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
83
84
|
try {
|
|
84
85
|
const projectId = opts.project
|
|
85
86
|
? await resolveProjectId(opts.project)
|
|
86
87
|
: undefined;
|
|
87
88
|
const offsetMs = opts.offset ? parseDuration(opts.offset) : null;
|
|
89
|
+
let context;
|
|
90
|
+
if (opts.context) {
|
|
91
|
+
const { validateContext } = await import("./core/context/validate-context.js");
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(opts.context);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
console.error("Invalid JSON in --context flag");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const result = validateContext(parsed);
|
|
101
|
+
if (!result.valid) {
|
|
102
|
+
console.error(`Invalid context: ${result.error}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
context = result.context;
|
|
106
|
+
}
|
|
88
107
|
const built = buildLoopOptions(intervalStr, {
|
|
89
108
|
...opts,
|
|
90
109
|
command: cmdArgs[0],
|
|
@@ -93,6 +112,7 @@ program
|
|
|
93
112
|
projectId,
|
|
94
113
|
offset: offsetMs,
|
|
95
114
|
description: opts.description ?? cmdArgs.join(" "),
|
|
115
|
+
context,
|
|
96
116
|
});
|
|
97
117
|
await startLoop(built.options, built.intervalHuman);
|
|
98
118
|
}
|
|
@@ -112,19 +132,43 @@ program
|
|
|
112
132
|
.option("--verbose", t("cli.optVerbose"))
|
|
113
133
|
.option("--cwd <dir>", t("cli.optCwd"))
|
|
114
134
|
.option("--description <desc>", "Description of the loop")
|
|
135
|
+
.option("-C, --context <json>", "Initial context JSON (e.g. {\"env\":\"staging\"})")
|
|
115
136
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
116
137
|
if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
|
|
117
138
|
program.help();
|
|
118
139
|
return;
|
|
119
140
|
}
|
|
120
141
|
const logger = new Logger(opts.verbose ?? false);
|
|
142
|
+
let context;
|
|
143
|
+
if (opts.context) {
|
|
144
|
+
const { validateContext } = await import("./core/context/validate-context.js");
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(opts.context);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
console.error("Invalid JSON in --context flag");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const result = validateContext(parsed);
|
|
154
|
+
if (!result.valid) {
|
|
155
|
+
console.error(`Invalid context: ${result.error}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
context = result.context;
|
|
159
|
+
}
|
|
121
160
|
const built = buildLoopOptions(intervalStr, {
|
|
122
161
|
...opts,
|
|
123
162
|
command: cmdArgs[0],
|
|
124
163
|
commandArgs: cmdArgs.slice(1),
|
|
125
164
|
cwd: opts.cwd ?? process.cwd(),
|
|
126
165
|
description: opts.description ?? cmdArgs.join(" "),
|
|
166
|
+
context,
|
|
127
167
|
});
|
|
168
|
+
if (built.options.interval === 0) {
|
|
169
|
+
console.error("Manual loops are not supported in foreground mode. Use 'loop-task new manual -- <command>' instead.");
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
128
172
|
const controller = new AbortController();
|
|
129
173
|
process.on("SIGINT", () => controller.abort());
|
|
130
174
|
process.on("SIGTERM", () => controller.abort());
|
|
@@ -143,8 +187,10 @@ projectCmd
|
|
|
143
187
|
.description(t("cli.projectNewDescription"))
|
|
144
188
|
.argument("<name>", t("cli.projectArgName"))
|
|
145
189
|
.option("--color <color>", t("cli.optProjectColor"))
|
|
190
|
+
.option("--directory <path>", t("cli.optProjectDirectory"))
|
|
191
|
+
.option("--github-source <owner/repo>", t("cli.optProjectGithubSource"))
|
|
146
192
|
.action(async (name, opts) => {
|
|
147
|
-
await createProjectCli(name, opts.color);
|
|
193
|
+
await createProjectCli(name, opts.color, opts.directory, opts.githubSource);
|
|
148
194
|
});
|
|
149
195
|
projectCmd
|
|
150
196
|
.command("rename")
|
|
@@ -177,6 +223,7 @@ program
|
|
|
177
223
|
.command("status")
|
|
178
224
|
.description("Show status of all loops")
|
|
179
225
|
.option("--json", "Output as JSON")
|
|
226
|
+
.option("--verbose", "Show extra details (skipped, silent-chain counts)")
|
|
180
227
|
.action(async (opts) => {
|
|
181
228
|
const { sendRequest } = await import("./client/ipc.js");
|
|
182
229
|
const response = await sendRequest({ type: "list" });
|
|
@@ -191,7 +238,17 @@ program
|
|
|
191
238
|
else {
|
|
192
239
|
for (const loop of loops) {
|
|
193
240
|
const { describeLoop, statusLabel } = await import("./shared/ui/format.js");
|
|
194
|
-
|
|
241
|
+
let line = `${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`;
|
|
242
|
+
if (opts.verbose) {
|
|
243
|
+
const extra = [];
|
|
244
|
+
if (loop.skippedCount > 0)
|
|
245
|
+
extra.push(`skipped=${loop.skippedCount}`);
|
|
246
|
+
if ((loop.silentChainCount ?? 0) > 0)
|
|
247
|
+
extra.push(`silent=${loop.silentChainCount}`);
|
|
248
|
+
if (extra.length)
|
|
249
|
+
line += ` [${extra.join(", ")}]`;
|
|
250
|
+
}
|
|
251
|
+
console.log(line);
|
|
195
252
|
}
|
|
196
253
|
}
|
|
197
254
|
});
|
|
@@ -279,4 +336,33 @@ program
|
|
|
279
336
|
console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
|
|
280
337
|
console.log(` Events: ${baseUrl}/api/events (SSE)`);
|
|
281
338
|
});
|
|
339
|
+
program
|
|
340
|
+
.command("mcp")
|
|
341
|
+
.description("Show MCP server info and how to connect an MCP client")
|
|
342
|
+
.action(() => {
|
|
343
|
+
const transport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
|
|
344
|
+
const port = process.env.LOOP_CLI_MCP_PORT ?? "8846";
|
|
345
|
+
const sseUrl = `http://${HTTP_API_HOST}:${port}/sse`;
|
|
346
|
+
console.log(`MCP Server`);
|
|
347
|
+
console.log(` Transport: ${transport}`);
|
|
348
|
+
console.log(` SSE URL: ${sseUrl}`);
|
|
349
|
+
console.log("");
|
|
350
|
+
console.log(` Connect an MCP client to the SSE URL above.`);
|
|
351
|
+
console.log("");
|
|
352
|
+
if (transport === "stdio") {
|
|
353
|
+
console.log(` Currently using stdio transport. To switch to SSE (default),`);
|
|
354
|
+
console.log(` restart without LOOP_CLI_MCP_TRANSPORT=stdio.`);
|
|
355
|
+
console.log("");
|
|
356
|
+
}
|
|
357
|
+
console.log(` Example MCP client configs:`);
|
|
358
|
+
console.log("");
|
|
359
|
+
console.log(` OpenCode (opencode.json):`);
|
|
360
|
+
console.log(` { "mcp": { "loop-task": { "type": "remote", "url": "${sseUrl}" } } }`);
|
|
361
|
+
console.log("");
|
|
362
|
+
console.log(` Claude Code (.claude/mcp.json):`);
|
|
363
|
+
console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
|
|
364
|
+
console.log("");
|
|
365
|
+
console.log(` Cursor (.cursor/mcp.json):`);
|
|
366
|
+
console.log(` { "mcpServers": { "loop-task": { "type": "sse", "url": "${sseUrl}" } } }`);
|
|
367
|
+
});
|
|
282
368
|
await program.parseAsync(process.argv);
|
package/dist/client/commands.js
CHANGED
|
@@ -23,6 +23,9 @@ export async function startLoop(options, intervalHuman) {
|
|
|
23
23
|
console.log(t("cli.startedCommand", { command: options.command }));
|
|
24
24
|
console.log(t("cli.startedInterval", { interval: intervalHuman }));
|
|
25
25
|
console.log(t("cli.startedStatus"));
|
|
26
|
+
if (options.interval === 0) {
|
|
27
|
+
console.log(" Note: manual loop use 'trigger' to run on demand");
|
|
28
|
+
}
|
|
26
29
|
console.log();
|
|
27
30
|
console.log(t("cli.startedHint"));
|
|
28
31
|
process.exit(0);
|
|
@@ -73,9 +76,13 @@ export async function showStatus(id) {
|
|
|
73
76
|
const maxRuns = loop.maxRuns !== null ? String(loop.maxRuns) : t("cli.unlimited");
|
|
74
77
|
console.log(t("cli.statusTitle", { id: loop.id }));
|
|
75
78
|
console.log(t("cli.statusCommand", { command: cmd }));
|
|
76
|
-
|
|
79
|
+
const intervalDisplay = loop.interval === 0 ? "manual" : formatDuration(loop.interval);
|
|
80
|
+
console.log(t("cli.statusInterval", { interval: loop.intervalHuman, duration: intervalDisplay }));
|
|
77
81
|
console.log(t("cli.statusStatus", { status: loop.status }));
|
|
78
82
|
console.log(t("cli.statusRuns", { runs: loop.runCount, maxRuns }));
|
|
83
|
+
if ((loop.silentChainCount ?? 0) > 0) {
|
|
84
|
+
console.log(t("cli.statusSilentChains", { count: loop.silentChainCount.toLocaleString() }));
|
|
85
|
+
}
|
|
79
86
|
console.log(t("cli.statusCreated", { created: loop.createdAt }));
|
|
80
87
|
if (loop.lastRunAt) {
|
|
81
88
|
const exitInfo = loop.lastExitCode !== null ? t("cli.exitInfo", { code: loop.lastExitCode }) : "";
|
|
@@ -70,12 +70,17 @@ export async function listProjectsCli() {
|
|
|
70
70
|
process.exit(1);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
export async function createProjectCli(name, colorInput) {
|
|
73
|
+
export async function createProjectCli(name, colorInput, directory, githubSource) {
|
|
74
74
|
try {
|
|
75
|
+
if (githubSource !== undefined && githubSource !== "") {
|
|
76
|
+
if (!/^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/.test(githubSource)) {
|
|
77
|
+
throw new Error(`Invalid github-source format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
75
80
|
const color = colorInput ? resolveColor(colorInput) : PROJECT_COLORS.cyan;
|
|
76
81
|
const response = await sendRequest({
|
|
77
82
|
type: "project-create",
|
|
78
|
-
payload: { name, color },
|
|
83
|
+
payload: { name, color, directory, githubSource },
|
|
79
84
|
});
|
|
80
85
|
if (response.type !== "ok") {
|
|
81
86
|
throw new Error(response.message);
|
|
@@ -5,7 +5,7 @@ function shellEscape(value) {
|
|
|
5
5
|
if (/^[A-Za-z0-9_\-=:./,@]+$/.test(value))
|
|
6
6
|
return value;
|
|
7
7
|
// Escape characters that are dangerous inside double quotes, then wrap
|
|
8
|
-
// the whole value in single quotes
|
|
8
|
+
// the whole value in single quotes the strongest shell quoting.
|
|
9
9
|
// Single quotes preserve everything literally (newlines, backticks,
|
|
10
10
|
// $, ", \, parens) except single quotes themselves.
|
|
11
11
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function validateContext(value) {
|
|
2
|
+
if (value === undefined || value === null) {
|
|
3
|
+
return { valid: true, context: {} };
|
|
4
|
+
}
|
|
5
|
+
if (typeof value !== "object" || Array.isArray(value) || value === null) {
|
|
6
|
+
return { valid: false, error: "Context must be a JSON object" };
|
|
7
|
+
}
|
|
8
|
+
const obj = value;
|
|
9
|
+
for (const key of Object.keys(obj)) {
|
|
10
|
+
const v = obj[key];
|
|
11
|
+
if (Array.isArray(v)) {
|
|
12
|
+
return { valid: false, error: `Context key "${key}" has an array only string values are allowed` };
|
|
13
|
+
}
|
|
14
|
+
if (typeof v === "object" && v !== null) {
|
|
15
|
+
return { valid: false, error: `Context key "${key}" has a nested object only string values are allowed` };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { valid: true, context: obj };
|
|
19
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
3
4
|
import { executeCommand } from "../command/command-runner.js";
|
|
4
5
|
import { t } from "../../shared/i18n/index.js";
|
|
5
6
|
import { parseStdout } from "../context/context-parser.js";
|
|
6
7
|
import { interpolate } from "../context/template.js";
|
|
7
8
|
export function executeChain(options) {
|
|
8
9
|
const { chainTargetId, exitCode, task, chainContext, cwd, signal, runCount, logPath, runHistory, logStream, controller } = options;
|
|
10
|
+
const nullStream = new Writable({ write(_chunk, _enc, cb) { cb(); } });
|
|
9
11
|
if (!chainTargetId) {
|
|
10
12
|
return Promise.resolve({ runHistory, lastExitCode: exitCode, lastDuration: 0 });
|
|
11
13
|
}
|
|
@@ -23,11 +25,15 @@ export function executeChain(options) {
|
|
|
23
25
|
const chainTask = controller.taskResolver(currentTargetId);
|
|
24
26
|
if (!chainTask)
|
|
25
27
|
break;
|
|
26
|
-
|
|
28
|
+
const isSilent = chainTask.silentChain === true;
|
|
29
|
+
if (isSilent) {
|
|
30
|
+
controller.incrementSilentChainCount();
|
|
31
|
+
}
|
|
32
|
+
if (logStream && !isSilent) {
|
|
27
33
|
logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
|
|
28
34
|
}
|
|
29
35
|
const chainStartedAt = new Date().toISOString();
|
|
30
|
-
const chainOffset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
|
|
36
|
+
const chainOffset = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
|
|
31
37
|
runHistory.push({
|
|
32
38
|
runNumber: runCount,
|
|
33
39
|
startedAt: chainStartedAt,
|
|
@@ -39,28 +45,55 @@ export function executeChain(options) {
|
|
|
39
45
|
chainGroupId,
|
|
40
46
|
chainName: chainTask.name,
|
|
41
47
|
});
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
const singleCommandFallback = {
|
|
49
|
+
command: chainTask.command,
|
|
50
|
+
commandArgs: chainTask.commandArgs,
|
|
51
|
+
commandRaw: chainTask.commandRaw,
|
|
52
|
+
};
|
|
53
|
+
const taskSteps = chainTask.steps?.length
|
|
54
|
+
? chainTask.steps
|
|
55
|
+
: [{ commands: [singleCommandFallback] }];
|
|
56
|
+
const chainTaskHasChains = !!(chainTask.onSuccessTaskId || chainTask.onFailureTaskId);
|
|
57
|
+
const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
|
|
58
|
+
let chainExitCode = 0;
|
|
59
|
+
let chainDuration = 0;
|
|
60
|
+
const effectiveStream = isSilent ? nullStream : (logStream ?? nullStream);
|
|
61
|
+
for (const step of taskSteps) {
|
|
62
|
+
const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, effectiveStream, signal, runCount, shouldCaptureStdout)));
|
|
63
|
+
let stepStdout = "";
|
|
64
|
+
for (const r of stepResults) {
|
|
65
|
+
if (r.status === "fulfilled") {
|
|
66
|
+
chainDuration += r.value.duration;
|
|
67
|
+
if (r.value.stdout)
|
|
68
|
+
stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (shouldCaptureStdout && stepStdout) {
|
|
72
|
+
const parsed = parseStdout(stepStdout);
|
|
73
|
+
if (parsed !== null) {
|
|
74
|
+
Object.assign(chainContext, parsed);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
|
|
78
|
+
if (stepFailure) {
|
|
79
|
+
const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
|
|
80
|
+
chainExitCode = failed ? failed.value.exitCode : 1;
|
|
81
|
+
break;
|
|
49
82
|
}
|
|
50
83
|
}
|
|
51
|
-
const chainLogSize = fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
|
|
84
|
+
const chainLogSize = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
|
|
52
85
|
const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
|
|
53
86
|
if (chainRecord) {
|
|
54
|
-
chainRecord.exitCode =
|
|
55
|
-
chainRecord.duration =
|
|
87
|
+
chainRecord.exitCode = chainExitCode;
|
|
88
|
+
chainRecord.duration = chainDuration;
|
|
56
89
|
chainRecord.logSize = Math.max(0, chainLogSize);
|
|
57
90
|
chainRecord.status = "completed";
|
|
58
91
|
}
|
|
59
|
-
totalExtraDuration +=
|
|
60
|
-
finalExitCode =
|
|
61
|
-
currentTargetId = (
|
|
62
|
-
prevBranch =
|
|
63
|
-
prevExit =
|
|
92
|
+
totalExtraDuration += chainDuration;
|
|
93
|
+
finalExitCode = chainExitCode;
|
|
94
|
+
currentTargetId = (chainExitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
|
|
95
|
+
prevBranch = chainExitCode === 0 ? "onSuccess" : "onFailure";
|
|
96
|
+
prevExit = chainExitCode;
|
|
64
97
|
}
|
|
65
98
|
return { runHistory, lastExitCode: finalExitCode, lastDuration: totalExtraDuration };
|
|
66
99
|
})();
|
|
@@ -27,6 +27,7 @@ export class LoopController extends EventEmitter {
|
|
|
27
27
|
this.runHistory = [];
|
|
28
28
|
this.currentRunStartOffset = 0;
|
|
29
29
|
this.skippedCount = 0;
|
|
30
|
+
this._silentChainCount = 0;
|
|
30
31
|
this.id = id;
|
|
31
32
|
this.options = options;
|
|
32
33
|
this.logPath = logPath;
|
|
@@ -46,10 +47,17 @@ export class LoopController extends EventEmitter {
|
|
|
46
47
|
this._paused = state?.status === "paused" || state?.status === "idle";
|
|
47
48
|
this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
|
|
48
49
|
this.skippedCount = state?.skippedCount ?? 0;
|
|
50
|
+
this._silentChainCount = state?.silentChainCount ?? 0;
|
|
49
51
|
}
|
|
50
52
|
get status() {
|
|
51
53
|
return this._status;
|
|
52
54
|
}
|
|
55
|
+
get silentChainCount() {
|
|
56
|
+
return this._silentChainCount;
|
|
57
|
+
}
|
|
58
|
+
incrementSilentChainCount() {
|
|
59
|
+
this._silentChainCount += 1;
|
|
60
|
+
}
|
|
53
61
|
start() {
|
|
54
62
|
if (this._loopActive)
|
|
55
63
|
return;
|
|
@@ -58,6 +66,13 @@ export class LoopController extends EventEmitter {
|
|
|
58
66
|
this.logStream?.end();
|
|
59
67
|
this.abortController = new AbortController();
|
|
60
68
|
this.logStream = fs.createWriteStream(this.logPath, { flags: "a" });
|
|
69
|
+
if (this.options.interval === 0 && !this._stopAfterRun) {
|
|
70
|
+
this._status = "idle";
|
|
71
|
+
this.nextRunAt = null;
|
|
72
|
+
this._loopActive = false;
|
|
73
|
+
this.emit("stopped");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
61
76
|
this.loopPromise = this.run().finally(() => { this._loopActive = false; });
|
|
62
77
|
if (this.sessionStartedAt === null) {
|
|
63
78
|
this.sessionStartedAt = new Date().toISOString();
|
|
@@ -112,6 +127,8 @@ export class LoopController extends EventEmitter {
|
|
|
112
127
|
this._maxRunsReached = true;
|
|
113
128
|
return false;
|
|
114
129
|
}
|
|
130
|
+
if (this.options.interval === 0)
|
|
131
|
+
return false;
|
|
115
132
|
this.sessionStartedAt = new Date().toISOString();
|
|
116
133
|
this._resetSchedule = true;
|
|
117
134
|
this._paused = false;
|
|
@@ -176,6 +193,7 @@ export class LoopController extends EventEmitter {
|
|
|
176
193
|
remainingDelayMs: this.remainingDelayMs,
|
|
177
194
|
runHistory: this.runHistory,
|
|
178
195
|
skippedCount: this.skippedCount,
|
|
196
|
+
silentChainCount: this._silentChainCount,
|
|
179
197
|
};
|
|
180
198
|
}
|
|
181
199
|
clearMaxRunsReached() {
|
|
@@ -106,6 +106,14 @@ export async function runLoop(ctrl) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
|
+
if (ctrl.options.interval === 0) {
|
|
110
|
+
ctrl._paused = true;
|
|
111
|
+
ctrl._status = "idle";
|
|
112
|
+
ctrl.remainingDelayMs = null;
|
|
113
|
+
ctrl.nextRunAt = null;
|
|
114
|
+
ctrl.emit("stopped");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
109
117
|
const nextSlotMs = runStartedAtMs + ctrl.options.interval;
|
|
110
118
|
const overrunMs = Date.now() - nextSlotMs;
|
|
111
119
|
if (overrunMs >= 0) {
|
|
@@ -28,7 +28,7 @@ export async function executeRunImpl(ctrl, signal) {
|
|
|
28
28
|
ctrl.runAbortController = new AbortController();
|
|
29
29
|
const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
|
|
30
30
|
const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
|
|
31
|
-
const chainContext = {};
|
|
31
|
+
const chainContext = { ...(task?.context ?? {}), ...(ctrl.options.context ?? {}) };
|
|
32
32
|
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
33
33
|
const singleCommandFallback = {
|
|
34
34
|
command: task?.command ?? ctrl.options.command,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export function computePhase(loopId, intervalMs) {
|
|
2
|
+
if (intervalMs <= 0)
|
|
3
|
+
return 0;
|
|
2
4
|
let hash = 0;
|
|
3
5
|
for (let i = 0; i < loopId.length; i++) {
|
|
4
6
|
hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
|
|
@@ -6,6 +8,8 @@ export function computePhase(loopId, intervalMs) {
|
|
|
6
8
|
return Math.abs(hash) % intervalMs;
|
|
7
9
|
}
|
|
8
10
|
export function alignToPhase(now, intervalMs, phaseMs) {
|
|
11
|
+
if (intervalMs <= 0)
|
|
12
|
+
return 0;
|
|
9
13
|
const elapsed = now % intervalMs;
|
|
10
14
|
const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
|
|
11
15
|
return delay;
|
|
@@ -10,10 +10,21 @@ export function buildOpenApiSpec() {
|
|
|
10
10
|
servers: [
|
|
11
11
|
{ url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
|
|
12
12
|
],
|
|
13
|
+
tags: [
|
|
14
|
+
{ name: "Loops", description: "Loop management" },
|
|
15
|
+
{ name: "Tasks", description: "Task management" },
|
|
16
|
+
{ name: "Task Chains", description: "Create chained tasks" },
|
|
17
|
+
{ name: "Projects", description: "Project management" },
|
|
18
|
+
{ name: "Logs", description: "Log retrieval" },
|
|
19
|
+
{ name: "Events", description: "Server-sent events" },
|
|
20
|
+
{ name: "Settings", description: "Daemon settings" },
|
|
21
|
+
{ name: "MCP", description: "Model Context Protocol server" },
|
|
22
|
+
{ name: "Docs", description: "API documentation" },
|
|
23
|
+
],
|
|
13
24
|
paths: {
|
|
14
25
|
"/api/loops": {
|
|
15
26
|
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" } } },
|
|
27
|
+
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", description: "Interval like 30s, 5m, 1h, or 'manual' for trigger-only loops" }, cwd: { type: "string" }, description: { type: "string" }, taskId: { type: "string" }, now: { type: "boolean" }, maxRuns: { type: "integer" }, verbose: { type: "boolean" }, projectId: { type: "string" }, offset: { type: "integer" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Loop created", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } }, "400": { description: "Validation error" } } },
|
|
17
28
|
},
|
|
18
29
|
"/api/loops/{id}": {
|
|
19
30
|
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" } } },
|
|
@@ -22,33 +33,49 @@ export function buildOpenApiSpec() {
|
|
|
22
33
|
},
|
|
23
34
|
"/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
35
|
"/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" } } } },
|
|
36
|
+
"/api/loops/{id}/play": { post: { summary: "Play (start) a loop", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "Playing", content: { "application/json": { schema: { type: "object" } } } }, "404": { description: "Not found" }, "409": { description: "Already running or max runs blocked" } } } },
|
|
25
37
|
"/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
38
|
"/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
39
|
"/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" } } } } } } } } },
|
|
40
|
+
"/api/loops/{id}/runs": { get: { summary: "List run history", tags: ["Loops"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", schema: { type: "string", description: "ISO 8601 date" } }, { name: "to", in: "query", schema: { type: "string", description: "ISO 8601 date" } }], responses: { "200": { description: "Run records", content: { "application/json": { schema: { type: "array" } } } }, "404": { description: "Not found" } } } },
|
|
41
|
+
"/api/loops/{id}/logs/date": { get: { summary: "Date-filtered logs", tags: ["Logs"], parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "from", in: "query", required: true, schema: { type: "string", description: "ISO 8601 start date" } }, { name: "to", in: "query", required: true, schema: { type: "string", description: "ISO 8601 end date" } }], responses: { "200": { description: "Filtered log content" }, "404": { description: "Not found" }, "400": { description: "Missing or invalid date params" } } } },
|
|
28
42
|
"/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
43
|
"/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
44
|
"/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
45
|
"/api/tasks": {
|
|
32
46
|
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" } } },
|
|
47
|
+
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" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
|
|
34
48
|
},
|
|
35
49
|
"/api/tasks/{id}": {
|
|
36
50
|
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
51
|
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
52
|
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
53
|
},
|
|
54
|
+
"/api/task-chains": { post: { summary: "Create a chain of tasks", tags: ["Task Chains"], requestBody: { content: { "application/json": { schema: { type: "object", required: ["tasks"], properties: { tasks: { type: "array", items: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, command: { type: "string" }, commandArgs: { type: "array", items: { type: "string" } }, onSuccessTaskId: { type: "string" }, onFailureTaskId: { type: "string" } } } }, chain: { type: "string", enum: ["sequential-success", "sequential-failure", "none"], description: "How to wire tasks together" } } } } } }, responses: { "201": { description: "Chain created", content: { "application/json": { schema: { type: "object", properties: { taskIds: { type: "array", items: { type: "string" } } } } } } }, "400": { description: "Validation error or rollback" } } } },
|
|
40
55
|
"/api/projects": {
|
|
41
56
|
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" } } },
|
|
57
|
+
post: { summary: "Create a project", tags: ["Projects"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, color: { type: "string" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "201": { description: "Project created" }, "400": { description: "Validation error" } } },
|
|
43
58
|
},
|
|
44
59
|
"/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" } } },
|
|
60
|
+
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" }, directory: { type: "string", description: "Optional local working directory for loops" }, githubSource: { type: "string", description: "Optional GitHub repository in owner/repo format (e.g. CKGrafico/loop-task)" } } } } } }, responses: { "200": { description: "Updated" }, "404": { description: "Not found" }, "400": { description: "Validation error" } } },
|
|
46
61
|
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
62
|
},
|
|
63
|
+
"/api/settings": { get: { summary: "Get daemon settings", tags: ["Settings"], responses: { "200": { description: "Current settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } } } }, patch: { summary: "Update daemon settings", tags: ["Settings"], requestBody: { content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, responses: { "200": { description: "Updated settings", content: { "application/json": { schema: { type: "object", properties: { httpApiEnabled: { type: "boolean" }, mcpApiEnabled: { type: "boolean" } } } } } }, "400": { description: "Validation error" } } } },
|
|
48
64
|
"/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
|
|
49
65
|
"/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
|
|
50
66
|
"/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
|
|
51
67
|
},
|
|
68
|
+
components: {
|
|
69
|
+
schemas: {
|
|
70
|
+
DaemonSettings: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
httpApiEnabled: { type: "boolean", description: "Whether the HTTP API server is enabled" },
|
|
74
|
+
mcpApiEnabled: { type: "boolean", description: "Whether the MCP server is enabled" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
52
79
|
};
|
|
53
80
|
}
|
|
54
81
|
export function buildSwaggerHtml() {
|