@plainconceptsplatform/loop-task 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/dist/app/App.js +132 -0
- package/dist/app/BoardLayout.js +5 -0
- package/dist/app/index.js +48 -0
- package/dist/app/providers/index.js +1 -0
- package/dist/app/router/index.js +16 -0
- package/dist/app/types.js +1 -0
- package/dist/cli/import-validator.js +152 -0
- package/dist/cli/import-writer.js +81 -0
- package/dist/cli.js +573 -0
- package/dist/client/cli-format.js +7 -0
- package/dist/client/commands.js +162 -0
- package/dist/client/ipc.js +96 -0
- package/dist/client/project-commands.js +164 -0
- package/dist/core/command/command-runner.js +300 -0
- package/dist/core/command/process-tree.js +73 -0
- package/dist/core/command/resolve-cwd.js +8 -0
- package/dist/core/command/stdout-capture-transform.js +73 -0
- package/dist/core/context/context-parser.js +65 -0
- package/dist/core/context/log-context.js +3 -0
- package/dist/core/context/template.js +20 -0
- package/dist/core/context/validate-context.js +19 -0
- package/dist/core/foreground/index.js +81 -0
- package/dist/core/logging/bounded-log-reader.js +368 -0
- package/dist/core/logging/log-follower.js +26 -0
- package/dist/core/logging/log-parser.js +29 -0
- package/dist/core/logging/log-rotator.js +34 -0
- package/dist/core/logging/rotating-log-stream.js +137 -0
- package/dist/core/loop/chain-executor.js +171 -0
- package/dist/core/loop/delay-utils.js +65 -0
- package/dist/core/loop/loop-controller.js +240 -0
- package/dist/core/loop/loop-runner.js +194 -0
- package/dist/core/loop/run-executor.js +144 -0
- package/dist/core/loop/types.js +1 -0
- package/dist/core/scheduling/index.js +16 -0
- package/dist/daemon/daemon-log.js +11 -0
- package/dist/daemon/diagnostics.js +85 -0
- package/dist/daemon/http/helpers.js +81 -0
- package/dist/daemon/http/openapi.js +119 -0
- package/dist/daemon/http/route-loops.js +311 -0
- package/dist/daemon/http/route-misc.js +60 -0
- package/dist/daemon/http/route-projects.js +55 -0
- package/dist/daemon/http/route-tasks.js +141 -0
- package/dist/daemon/http/routes.js +19 -0
- package/dist/daemon/http/server.js +82 -0
- package/dist/daemon/http/sse.js +36 -0
- package/dist/daemon/index.js +189 -0
- package/dist/daemon/ipc/send.js +5 -0
- package/dist/daemon/managers/loop-entry.js +27 -0
- package/dist/daemon/managers/loop-manager.js +346 -0
- package/dist/daemon/managers/loop-options.js +17 -0
- package/dist/daemon/managers/loop-serialization.js +53 -0
- package/dist/daemon/managers/project-manager.js +170 -0
- package/dist/daemon/managers/task-manager.js +94 -0
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +167 -0
- package/dist/daemon/mcp/tools.js +382 -0
- package/dist/daemon/recipe/deferred-reload.js +32 -0
- package/dist/daemon/recipe/diagram-reader.js +26 -0
- package/dist/daemon/recipe/file-writer.js +52 -0
- package/dist/daemon/recipe/id-remapper.js +46 -0
- package/dist/daemon/recipe/runtime-state.js +23 -0
- package/dist/daemon/recipe/scanner.js +165 -0
- package/dist/daemon/recipe/task-store.js +33 -0
- package/dist/daemon/recipe/validator.js +35 -0
- package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
- package/dist/daemon/server/handlers/index.js +101 -0
- package/dist/daemon/server/handlers/log-handlers.js +129 -0
- package/dist/daemon/server/handlers/loop-handlers.js +64 -0
- package/dist/daemon/server/handlers/project-handlers.js +42 -0
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/handlers/task-handlers.js +20 -0
- package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
- package/dist/daemon/server/index.js +117 -0
- package/dist/daemon/settings-manager.js +73 -0
- package/dist/daemon/spawner/index.js +204 -0
- package/dist/daemon/state/index.js +209 -0
- package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
- package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
- package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
- package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
- package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
- package/dist/daemon/telemetry/index.js +6 -0
- package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
- package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
- package/dist/daemon/telemetry/telemetry-manager.js +147 -0
- package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
- package/dist/daemon/telemetry/telemetry-types.js +45 -0
- package/dist/daemon/telemetry/telemetry.js +1 -0
- package/dist/daemon/watcher/index.js +250 -0
- package/dist/duration.js +24 -0
- package/dist/entities/loops/filters.js +109 -0
- 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/entry.js +16 -0
- package/dist/esm-loader.js +39 -0
- package/dist/features/chain-editor/ChainEditor.js +116 -0
- package/dist/features/chain-editor/mermaidToAscii.js +375 -0
- package/dist/features/chain-editor/renderChainDiagram.js +106 -0
- package/dist/features/code-editor/CodeEditorModal.js +84 -0
- package/dist/features/code-editor/CodeEditorPreview.js +14 -0
- package/dist/features/code-editor/useEditorKeyboard.js +150 -0
- package/dist/features/code-editor/useModalDimensions.js +9 -0
- package/dist/features/commands/commands.js +132 -0
- package/dist/features/commands/useCommandHandlers.js +280 -0
- package/dist/features/commands/useContextualActions.js +78 -0
- package/dist/features/commands/useGlobalShortcuts.js +154 -0
- package/dist/features/forms/FormRouter.js +46 -0
- package/dist/features/overlays/ContextHelpModal.js +8 -0
- package/dist/features/overlays/DiagramModal.js +45 -0
- package/dist/features/overlays/ExportModal.js +51 -0
- package/dist/features/overlays/HelpGuideModal.js +23 -0
- package/dist/features/overlays/HelpModal.js +59 -0
- package/dist/features/overlays/OverlayStack.js +10 -0
- package/dist/features/overlays/ProjectsModal.js +42 -0
- package/dist/features/overlays/TaskPickerModal.js +62 -0
- package/dist/features/overlays/WelcomeScreen.js +74 -0
- package/dist/features/overlays/useOverlayStack.js +55 -0
- package/dist/features/state/useAppState.js +151 -0
- package/dist/logger.js +25 -0
- package/dist/loop-config.js +152 -0
- package/dist/shared/clipboard.js +111 -0
- package/dist/shared/config/constants.js +104 -0
- package/dist/shared/config/paths.js +67 -0
- package/dist/shared/container/index.js +24 -0
- package/dist/shared/fs-utils.js +44 -0
- package/dist/shared/hooks/useBreakpoint.js +11 -0
- package/dist/shared/hooks/useDaemonSettings.js +41 -0
- package/dist/shared/hooks/useInject.js +6 -0
- package/dist/shared/hooks/useLogStream.js +37 -0
- package/dist/shared/hooks/useLoopFormValidation.js +131 -0
- package/dist/shared/hooks/useLoopPolling.js +30 -0
- package/dist/shared/hooks/useMouseScroll.js +56 -0
- package/dist/shared/hooks/useUndoRedo.js +77 -0
- package/dist/shared/i18n/en.json +709 -0
- package/dist/shared/i18n/index.js +11 -0
- 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/settings-service.js +102 -0
- package/dist/shared/services/task-service.js +41 -0
- package/dist/shared/services/types.js +8 -0
- package/dist/shared/sleep.js +17 -0
- package/dist/shared/tail.js +4 -0
- package/dist/shared/ui/Button.js +18 -0
- package/dist/shared/ui/DebugPanel.js +9 -0
- package/dist/shared/ui/FocusableButton.js +18 -0
- package/dist/shared/ui/FocusableInput.js +72 -0
- package/dist/shared/ui/FocusableList.js +48 -0
- package/dist/shared/ui/Modal.js +13 -0
- package/dist/shared/ui/SelectModal.js +78 -0
- package/dist/shared/ui/Toast.js +37 -0
- package/dist/shared/ui/format.js +117 -0
- package/dist/shared/ui/hooks/useHoverState.js +11 -0
- package/dist/shared/ui/state.js +9 -0
- package/dist/shared/ui/theme.js +96 -0
- package/dist/shared/utils/log-lines.js +13 -0
- package/dist/shared/utils/paste.js +37 -0
- package/dist/shared/utils/syntax.js +128 -0
- package/dist/shared/utils/validation.js +56 -0
- package/dist/types.js +1 -0
- package/dist/visual-evidence/capture.js +51 -0
- package/dist/visual-evidence/cli.js +85 -0
- package/dist/visual-evidence/evidence-required.js +71 -0
- package/dist/visual-evidence/index.js +10 -0
- package/dist/visual-evidence/launch.js +61 -0
- package/dist/visual-evidence/manifest.js +32 -0
- package/dist/visual-evidence/openspec-resolver.js +45 -0
- package/dist/visual-evidence/publish.js +126 -0
- package/dist/visual-evidence/run.js +121 -0
- package/dist/visual-evidence/scenario-registry.js +132 -0
- package/dist/widgets/command-input/CommandDropdown.js +69 -0
- package/dist/widgets/command-input/CommandInput.js +133 -0
- package/dist/widgets/command-input/HintBar.js +9 -0
- package/dist/widgets/command-input/InputModes.js +72 -0
- package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
- package/dist/widgets/header/Header.js +54 -0
- package/dist/widgets/header/TabBar.js +20 -0
- package/dist/widgets/left-panel/LeftPanel.js +34 -0
- package/dist/widgets/left-panel/Navigator.js +107 -0
- package/dist/widgets/left-panel/ProjectsPage.js +76 -0
- package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
- package/dist/widgets/left-panel/TaskBrowser.js +132 -0
- package/dist/widgets/log-modal/LogModal.js +211 -0
- package/dist/widgets/loop-form/CreateForm.js +82 -0
- package/dist/widgets/loop-form/TextField.js +9 -0
- package/dist/widgets/loop-form/WizardForm.js +199 -0
- package/dist/widgets/loop-form/useCreateSteps.js +135 -0
- package/dist/widgets/loop-form/useHandleComplete.js +94 -0
- package/dist/widgets/project-form/ProjectForm.js +83 -0
- package/dist/widgets/right-panel/Inspector.js +41 -0
- package/dist/widgets/right-panel/RightPanel.js +38 -0
- package/dist/widgets/right-panel/RunHistory.js +187 -0
- package/dist/widgets/task-form/TaskForm.js +272 -0
- package/package.json +119 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { executeCommand } from "../command/command-runner.js";
|
|
3
|
+
import { rotateLogIfNeeded } from "../logging/log-rotator.js";
|
|
4
|
+
import { mergeCommandOutput } from "../context/context-parser.js";
|
|
5
|
+
import { formatContextLog } from "../context/log-context.js";
|
|
6
|
+
import { interpolate } from "../context/template.js";
|
|
7
|
+
import { resolveEffectiveCwd } from "../command/resolve-cwd.js";
|
|
8
|
+
export async function executeRunImpl(ctrl, signal) {
|
|
9
|
+
ctrl._status = "running";
|
|
10
|
+
ctrl._forceRun = false;
|
|
11
|
+
ctrl.runCount++;
|
|
12
|
+
ctrl.lastRunAt = new Date().toISOString();
|
|
13
|
+
if (ctrl.sessionStartedAt === null) {
|
|
14
|
+
ctrl.sessionStartedAt = ctrl.lastRunAt;
|
|
15
|
+
}
|
|
16
|
+
ctrl.nextRunAt = null;
|
|
17
|
+
ctrl.emit("run:start", ctrl.runCount);
|
|
18
|
+
ctrl.logStream = rotateLogIfNeeded(ctrl.logPath, ctrl.logStream);
|
|
19
|
+
ctrl.currentRunStartOffset = fs.existsSync(ctrl.logPath) ? fs.statSync(ctrl.logPath).size : 0;
|
|
20
|
+
ctrl.runHistory.push({
|
|
21
|
+
runNumber: ctrl.runCount,
|
|
22
|
+
startedAt: ctrl.lastRunAt,
|
|
23
|
+
exitCode: -1,
|
|
24
|
+
duration: 0,
|
|
25
|
+
logSize: 0,
|
|
26
|
+
status: "running",
|
|
27
|
+
logOffset: ctrl.currentRunStartOffset,
|
|
28
|
+
});
|
|
29
|
+
ctrl.runAbortController = new AbortController();
|
|
30
|
+
const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
|
|
31
|
+
const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
|
|
32
|
+
const chainContext = { ...(task?.context ?? {}), ...(ctrl.options.context ?? {}) };
|
|
33
|
+
const hasChainTasks = !!(task?.onSuccessTaskId || task?.onFailureTaskId);
|
|
34
|
+
const singleCommandFallback = {
|
|
35
|
+
command: task?.command ?? ctrl.options.command,
|
|
36
|
+
commandArgs: task?.commandArgs ?? ctrl.options.commandArgs,
|
|
37
|
+
commandRaw: task?.commandRaw ?? ctrl.options.commandRaw,
|
|
38
|
+
};
|
|
39
|
+
const taskSteps = task?.steps?.length
|
|
40
|
+
? task.steps
|
|
41
|
+
: [{ commands: [singleCommandFallback] }];
|
|
42
|
+
const shouldCaptureStdout = hasChainTasks || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
|
|
43
|
+
// Telemetry: create loop run span
|
|
44
|
+
const telemetry = ctrl.telemetryManager?.getAdapter();
|
|
45
|
+
const runId = `${ctrl.id}-${ctrl.runCount}`;
|
|
46
|
+
const loopSpan = telemetry?.startLoop({
|
|
47
|
+
loopId: ctrl.id,
|
|
48
|
+
loopName: ctrl.options.description || ctrl.id,
|
|
49
|
+
runId,
|
|
50
|
+
projectId: ctrl.options.projectId,
|
|
51
|
+
});
|
|
52
|
+
// Telemetry: create task span
|
|
53
|
+
const taskSpan = (task && telemetry)
|
|
54
|
+
? telemetry.startTask({
|
|
55
|
+
taskId: task.id,
|
|
56
|
+
taskName: task.name,
|
|
57
|
+
runId,
|
|
58
|
+
loopId: ctrl.id,
|
|
59
|
+
loopName: ctrl.options.description || ctrl.id,
|
|
60
|
+
projectId: ctrl.options.projectId,
|
|
61
|
+
}, loopSpan)
|
|
62
|
+
: undefined;
|
|
63
|
+
// Build telemetry context for commands
|
|
64
|
+
const telemetryCtx = telemetry
|
|
65
|
+
? {
|
|
66
|
+
telemetry,
|
|
67
|
+
loopSpan,
|
|
68
|
+
taskSpan,
|
|
69
|
+
runId,
|
|
70
|
+
loopId: ctrl.id,
|
|
71
|
+
loopName: ctrl.options.description || ctrl.id,
|
|
72
|
+
taskId: task?.id,
|
|
73
|
+
taskName: task?.name,
|
|
74
|
+
projectId: ctrl.options.projectId,
|
|
75
|
+
telemetryConfig: task?.telemetry,
|
|
76
|
+
}
|
|
77
|
+
: undefined;
|
|
78
|
+
let exitCode = 0;
|
|
79
|
+
let totalDuration = 0;
|
|
80
|
+
try {
|
|
81
|
+
ctrl.logStream?.write(formatContextLog(chainContext));
|
|
82
|
+
for (const step of taskSteps) {
|
|
83
|
+
if (signal.aborted || ctrl.runAbortController?.signal.aborted)
|
|
84
|
+
break;
|
|
85
|
+
const stepResults = await Promise.allSettled(step.commands.map((cmd) => executeCommand(interpolate(cmd.command, chainContext), cmd.commandArgs.map(a => interpolate(a, chainContext)), cwd, ctrl.logStream, AbortSignal.any([signal, ctrl.runAbortController.signal]), ctrl.runCount, shouldCaptureStdout, false, telemetryCtx)));
|
|
86
|
+
if (signal.aborted || ctrl.runAbortController?.signal.aborted)
|
|
87
|
+
break;
|
|
88
|
+
ctrl.checkLogRotation();
|
|
89
|
+
let stepStdout = "";
|
|
90
|
+
let stepStderr = "";
|
|
91
|
+
for (const r of stepResults) {
|
|
92
|
+
if (r.status === "fulfilled") {
|
|
93
|
+
totalDuration += r.value.duration;
|
|
94
|
+
if (r.value.stdout)
|
|
95
|
+
stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
|
|
96
|
+
if (r.value.stderr)
|
|
97
|
+
stepStderr += (stepStderr ? "\n" : "") + r.value.stderr;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (shouldCaptureStdout && (stepStdout || stepStderr)) {
|
|
101
|
+
mergeCommandOutput(chainContext, stepStdout, stepStderr);
|
|
102
|
+
}
|
|
103
|
+
const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
|
|
104
|
+
if (stepFailure) {
|
|
105
|
+
const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
|
|
106
|
+
exitCode = failed ? failed.value.exitCode : 1;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
// End telemetry spans
|
|
113
|
+
if (taskSpan) {
|
|
114
|
+
taskSpan.end(exitCode === 0 ? "ok" : "error");
|
|
115
|
+
}
|
|
116
|
+
if (loopSpan) {
|
|
117
|
+
loopSpan.end(exitCode === 0 ? "ok" : "error");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
ctrl.runAbortController = null;
|
|
121
|
+
ctrl.lastExitCode = exitCode;
|
|
122
|
+
ctrl.lastDuration = totalDuration;
|
|
123
|
+
ctrl.checkLogRotation();
|
|
124
|
+
const logSize = fs.existsSync(ctrl.logPath) ? fs.statSync(ctrl.logPath).size - ctrl.currentRunStartOffset : 0;
|
|
125
|
+
const runningRecord = ctrl.runHistory.find((r) => r.runNumber === ctrl.runCount);
|
|
126
|
+
if (runningRecord) {
|
|
127
|
+
runningRecord.exitCode = exitCode;
|
|
128
|
+
runningRecord.duration = totalDuration;
|
|
129
|
+
runningRecord.logSize = Math.max(0, logSize);
|
|
130
|
+
runningRecord.status = "completed";
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
ctrl.runHistory.push({
|
|
134
|
+
runNumber: ctrl.runCount,
|
|
135
|
+
startedAt: ctrl.lastRunAt,
|
|
136
|
+
exitCode,
|
|
137
|
+
duration: totalDuration,
|
|
138
|
+
logSize: Math.max(0, logSize),
|
|
139
|
+
status: "completed",
|
|
140
|
+
logOffset: ctrl.currentRunStartOffset,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { exitCode, totalDuration, chainContext, runId, loopSpan };
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function computePhase(loopId, intervalMs) {
|
|
2
|
+
if (intervalMs <= 0)
|
|
3
|
+
return 0;
|
|
4
|
+
let hash = 0;
|
|
5
|
+
for (let i = 0; i < loopId.length; i++) {
|
|
6
|
+
hash = ((hash << 5) - hash + loopId.charCodeAt(i)) | 0;
|
|
7
|
+
}
|
|
8
|
+
return Math.abs(hash) % intervalMs;
|
|
9
|
+
}
|
|
10
|
+
export function alignToPhase(now, intervalMs, phaseMs) {
|
|
11
|
+
if (intervalMs <= 0)
|
|
12
|
+
return 0;
|
|
13
|
+
const elapsed = now % intervalMs;
|
|
14
|
+
const delay = (phaseMs - elapsed + intervalMs) % intervalMs;
|
|
15
|
+
return delay;
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { getDataDir, getDaemonLogFile } from "../shared/config/paths.js";
|
|
3
|
+
export function daemonLog(message) {
|
|
4
|
+
try {
|
|
5
|
+
fs.mkdirSync(getDataDir(), { recursive: true });
|
|
6
|
+
fs.appendFileSync(getDaemonLogFile(), `${new Date().toISOString()} ${message}\n`);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// operational logging is best effort
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
let activePidsGetter = null;
|
|
3
|
+
export function setActivePidsGetter(getter) {
|
|
4
|
+
activePidsGetter = getter;
|
|
5
|
+
}
|
|
6
|
+
export function collectDiagnostics(loopManager, extended = false) {
|
|
7
|
+
const usage = process.memoryUsage();
|
|
8
|
+
const loops = loopManager.list();
|
|
9
|
+
let running = 0;
|
|
10
|
+
let paused = 0;
|
|
11
|
+
let idle = 0;
|
|
12
|
+
let totalRunHistory = 0;
|
|
13
|
+
for (const loop of loops) {
|
|
14
|
+
switch (loop.status) {
|
|
15
|
+
case "running":
|
|
16
|
+
case "waiting":
|
|
17
|
+
running++;
|
|
18
|
+
break;
|
|
19
|
+
case "paused":
|
|
20
|
+
paused++;
|
|
21
|
+
break;
|
|
22
|
+
default:
|
|
23
|
+
idle++;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
totalRunHistory += loop.runHistory.length;
|
|
27
|
+
}
|
|
28
|
+
const result = {
|
|
29
|
+
process: {
|
|
30
|
+
rss: usage.rss,
|
|
31
|
+
heapUsed: usage.heapUsed,
|
|
32
|
+
heapTotal: usage.heapTotal,
|
|
33
|
+
external: usage.external,
|
|
34
|
+
arrayBuffers: usage.arrayBuffers,
|
|
35
|
+
},
|
|
36
|
+
loops: {
|
|
37
|
+
total: loops.length,
|
|
38
|
+
running,
|
|
39
|
+
paused,
|
|
40
|
+
idle,
|
|
41
|
+
totalRunHistory,
|
|
42
|
+
},
|
|
43
|
+
activeExecutions: running,
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
if (extended) {
|
|
47
|
+
const eventListenerCounts = {};
|
|
48
|
+
try {
|
|
49
|
+
const counts = process._getActiveRequests;
|
|
50
|
+
if (typeof counts === "function") {
|
|
51
|
+
eventListenerCounts.activeRequests = counts().length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* not available */ }
|
|
55
|
+
try {
|
|
56
|
+
const handles = process._getActiveHandles;
|
|
57
|
+
if (typeof handles === "function") {
|
|
58
|
+
eventListenerCounts.activeHandles = handles().length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch { /* not available */ }
|
|
62
|
+
let activeFileDescriptors = null;
|
|
63
|
+
try {
|
|
64
|
+
activeFileDescriptors = fs.readdirSync("/proc/self/fd").length;
|
|
65
|
+
}
|
|
66
|
+
catch { /* not available on non-Linux */ }
|
|
67
|
+
let activeChildPids = [];
|
|
68
|
+
try {
|
|
69
|
+
if (activePidsGetter) {
|
|
70
|
+
activeChildPids = [...activePidsGetter()];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { /* not available */ }
|
|
74
|
+
result.extended = {
|
|
75
|
+
eventListenerCounts,
|
|
76
|
+
activeFileDescriptors,
|
|
77
|
+
activeChildPids,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
const DIAGNOSTICS_ENV_VAR = "LOOP_TASK_DIAGNOSTICS";
|
|
83
|
+
export function isDiagnosticsEnabled() {
|
|
84
|
+
return process.env[DIAGNOSTICS_ENV_VAR] === "1" || process.env[DIAGNOSTICS_ENV_VAR] === "true";
|
|
85
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// --- Response helpers ---
|
|
2
|
+
export function sendJson(res, status, data) {
|
|
3
|
+
const body = JSON.stringify(data);
|
|
4
|
+
res.writeHead(status, {
|
|
5
|
+
"Content-Type": "application/json",
|
|
6
|
+
"Content-Length": Buffer.byteLength(body),
|
|
7
|
+
});
|
|
8
|
+
res.end(body);
|
|
9
|
+
}
|
|
10
|
+
export function sendOk(res, data, status = 200) {
|
|
11
|
+
sendJson(res, status, { ok: true, data: data ?? null });
|
|
12
|
+
}
|
|
13
|
+
export function sendError(res, status, message) {
|
|
14
|
+
sendJson(res, status, { ok: false, error: { message } });
|
|
15
|
+
}
|
|
16
|
+
export function sendNotFound(res, id) {
|
|
17
|
+
sendError(res, 404, `Not found: ${id}`);
|
|
18
|
+
}
|
|
19
|
+
export function matchRoute(routes, method, pathSegments) {
|
|
20
|
+
for (const route of routes) {
|
|
21
|
+
if (route.method !== method)
|
|
22
|
+
continue;
|
|
23
|
+
if (route.segments.length !== pathSegments.length)
|
|
24
|
+
continue;
|
|
25
|
+
const params = {};
|
|
26
|
+
let matched = true;
|
|
27
|
+
for (let i = 0; i < route.segments.length; i++) {
|
|
28
|
+
const routeSeg = route.segments[i];
|
|
29
|
+
const pathSeg = pathSegments[i];
|
|
30
|
+
if (routeSeg.startsWith(":")) {
|
|
31
|
+
params[routeSeg.slice(1)] = decodeURIComponent(pathSeg);
|
|
32
|
+
}
|
|
33
|
+
else if (routeSeg !== pathSeg) {
|
|
34
|
+
matched = false;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (matched) {
|
|
39
|
+
return { params, handler: route.handler };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// --- Request parsing ---
|
|
45
|
+
export function parsePath(url) {
|
|
46
|
+
if (!url)
|
|
47
|
+
return [];
|
|
48
|
+
const pathname = url.split("?")[0] ?? "";
|
|
49
|
+
return pathname.split("/").filter((s) => s.length > 0);
|
|
50
|
+
}
|
|
51
|
+
export function parseQuery(url) {
|
|
52
|
+
if (!url)
|
|
53
|
+
return new URLSearchParams();
|
|
54
|
+
const qs = url.split("?")[1] ?? "";
|
|
55
|
+
return new URLSearchParams(qs);
|
|
56
|
+
}
|
|
57
|
+
export async function readBody(req) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let body = "";
|
|
60
|
+
req.on("data", (chunk) => {
|
|
61
|
+
body += chunk.toString();
|
|
62
|
+
if (body.length > 1024 * 1024) {
|
|
63
|
+
reject(new Error("Body too large"));
|
|
64
|
+
req.destroy();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
req.on("end", () => {
|
|
68
|
+
if (!body.trim()) {
|
|
69
|
+
resolve({});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
resolve(JSON.parse(body));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
reject(new Error("Invalid JSON body"));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
req.on("error", reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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. Binds to all interfaces (0.0.0.0) by default so it is reachable remotely; the bind host is configurable via `loop-task http-host`. The API is unauthenticated, secure access at the network layer (SSH/Tailscale/firewall), or restrict the bind to 127.0.0.1.",
|
|
9
|
+
},
|
|
10
|
+
servers: [
|
|
11
|
+
{ url: `http://${HTTP_API_HOST}:${HTTP_API_PORT}`, description: "Local daemon" },
|
|
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
|
+
],
|
|
24
|
+
paths: {
|
|
25
|
+
"/api/loops": {
|
|
26
|
+
get: { summary: "List all loops", tags: ["Loops"], responses: { "200": { description: "Array of loops", content: { "application/json": { schema: { type: "array" } } } } } },
|
|
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" } } },
|
|
28
|
+
},
|
|
29
|
+
"/api/loops/{id}": {
|
|
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" } } },
|
|
31
|
+
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" } } },
|
|
32
|
+
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" } } },
|
|
33
|
+
},
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
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" } } } },
|
|
45
|
+
"/api/tasks": {
|
|
46
|
+
get: { summary: "List all tasks", tags: ["Tasks"], responses: { "200": { description: "Array of tasks" } } },
|
|
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" }, maxRuns: { type: "integer" }, context: { type: "object", additionalProperties: true, description: "Initial context for chain interpolation" } } } } } }, responses: { "201": { description: "Task created" }, "400": { description: "Validation error" } } },
|
|
48
|
+
},
|
|
49
|
+
"/api/tasks/{id}": {
|
|
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" } } },
|
|
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" } } },
|
|
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" } } },
|
|
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" }, maxRuns: { type: "integer" } } } }, 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" } } } },
|
|
55
|
+
"/api/projects": {
|
|
56
|
+
get: { summary: "List all projects", tags: ["Projects"], responses: { "200": { description: "Array of projects" } } },
|
|
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" } } },
|
|
58
|
+
},
|
|
59
|
+
"/api/projects/{id}": {
|
|
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" } } },
|
|
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" } } },
|
|
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" } } } },
|
|
64
|
+
"/api/events": { get: { summary: "Subscribe to daemon events via SSE", tags: ["Events"], responses: { "200": { description: "SSE event stream" } } } },
|
|
65
|
+
"/api/openapi.json": { get: { summary: "OpenAPI 3.0 spec", tags: ["Docs"], responses: { "200": { description: "OpenAPI JSON spec" } } } },
|
|
66
|
+
"/api/docs": { get: { summary: "Swagger UI", tags: ["Docs"], responses: { "200": { description: "HTML page" } } } },
|
|
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
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function buildSwaggerHtml() {
|
|
82
|
+
const specUrl = "/api/openapi.json";
|
|
83
|
+
return `<!DOCTYPE html>
|
|
84
|
+
<html lang="en">
|
|
85
|
+
<head>
|
|
86
|
+
<meta charset="UTF-8">
|
|
87
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
88
|
+
<title>loop-task API</title>
|
|
89
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
|
90
|
+
<style>
|
|
91
|
+
body { margin: 0; }
|
|
92
|
+
.swagger-ui { max-width: 1200px; margin: 0 auto; }
|
|
93
|
+
.header { background: #0d1117; color: #58a6ff; padding: 12px 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; }
|
|
94
|
+
.header a { color: #58a6ff; text-decoration: none; }
|
|
95
|
+
.header a:hover { text-decoration: underline; }
|
|
96
|
+
</style>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<div class="header">
|
|
100
|
+
<strong>loop-task HTTP API</strong> —
|
|
101
|
+
<a href="/api/openapi.json">OpenAPI JSON</a> —
|
|
102
|
+
<a href="/api/docs">Swagger UI</a>
|
|
103
|
+
</div>
|
|
104
|
+
<div id="swagger-ui"></div>
|
|
105
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
106
|
+
<script>
|
|
107
|
+
window.onload = function() {
|
|
108
|
+
window.ui = SwaggerUIBundle({
|
|
109
|
+
url: "${specUrl}",
|
|
110
|
+
dom_id: "#swagger-ui",
|
|
111
|
+
deepLinking: true,
|
|
112
|
+
presets: [SwaggerUIBundle.presets.apis],
|
|
113
|
+
layout: "BaseLayout",
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
</script>
|
|
117
|
+
</body>
|
|
118
|
+
</html>`;
|
|
119
|
+
}
|