@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,300 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import { formatDuration } from "../../duration.js";
|
|
5
|
+
import { t } from "../../shared/i18n/index.js";
|
|
6
|
+
import { MAX_CONTEXT_STDOUT_BYTES, MAX_SPAN_OUTPUT_BYTES } from "../../shared/config/constants.js";
|
|
7
|
+
import { StdoutCaptureTransform } from "./stdout-capture-transform.js";
|
|
8
|
+
import { killProcessTree } from "./process-tree.js";
|
|
9
|
+
import { getAgentIntegrations } from "../../daemon/telemetry/agent-integrations/index.js";
|
|
10
|
+
import { getDataDir } from "../../shared/config/paths.js";
|
|
11
|
+
function quoteArg(arg) {
|
|
12
|
+
if (arg.length === 0)
|
|
13
|
+
return "''";
|
|
14
|
+
if (/^[A-Za-z0-9_\-=:./,@]+$/.test(arg))
|
|
15
|
+
return arg;
|
|
16
|
+
const cleaned = arg.replace(/[\n\r]/g, " ");
|
|
17
|
+
return "'" + cleaned.replace(/'/g, "'\\''") + "'";
|
|
18
|
+
}
|
|
19
|
+
function formatCommandLine(command, commandArgs) {
|
|
20
|
+
return [command, ...commandArgs.map(quoteArg)].join(" ").trim();
|
|
21
|
+
}
|
|
22
|
+
export function extractExitCode(error) {
|
|
23
|
+
return error && typeof error === "object" && "exitCode" in error
|
|
24
|
+
? error.exitCode
|
|
25
|
+
: 1;
|
|
26
|
+
}
|
|
27
|
+
export function childEnv() {
|
|
28
|
+
if (process.env.LOOP_TASK_DEFAULTED_NODE_ENV !== "1") {
|
|
29
|
+
return process.env;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
...process.env,
|
|
33
|
+
NODE_ENV: undefined,
|
|
34
|
+
LOOP_TASK_DEFAULTED_NODE_ENV: undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function loadEnvFile() {
|
|
38
|
+
try {
|
|
39
|
+
const envPath = path.join(getDataDir(), "env");
|
|
40
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const line of content.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
45
|
+
continue;
|
|
46
|
+
const eq = trimmed.indexOf("=");
|
|
47
|
+
if (eq < 0)
|
|
48
|
+
continue;
|
|
49
|
+
const key = trimmed.slice(0, eq).trim();
|
|
50
|
+
const value = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
51
|
+
if (key)
|
|
52
|
+
env[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const activePids = new Set();
|
|
61
|
+
export function getActivePids() {
|
|
62
|
+
return activePids;
|
|
63
|
+
}
|
|
64
|
+
export function killAllActiveProcesses() {
|
|
65
|
+
for (const pid of activePids) {
|
|
66
|
+
killProcessTree(pid, "SIGTERM").catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function executeCommand(command, commandArgs, cwd, logStream, signal, runNumber, captureStdout = false, isChain = false, telemetryCtx) {
|
|
70
|
+
const startedAt = new Date();
|
|
71
|
+
if (!isChain) {
|
|
72
|
+
const header = t("loop.runHeader", { timestamp: startedAt.toLocaleString(), runNumber: runNumber ?? 0 });
|
|
73
|
+
logStream.write(header);
|
|
74
|
+
}
|
|
75
|
+
logStream.write(t("loop.commandLine", { command: formatCommandLine(command, commandArgs) }));
|
|
76
|
+
if (cwd) {
|
|
77
|
+
logStream.write(t("loop.cwdLine", { cwd }));
|
|
78
|
+
}
|
|
79
|
+
if (cwd && !fs.existsSync(cwd)) {
|
|
80
|
+
const endedAt = new Date();
|
|
81
|
+
logStream.write(t("loop.cwdMissingLog", { cwd }));
|
|
82
|
+
logStream.write(t("loop.exitMarker", { code: 1, duration: formatDuration(0) }));
|
|
83
|
+
return { exitCode: 1, duration: 0, startedAt, endedAt };
|
|
84
|
+
}
|
|
85
|
+
const shellCommand = formatCommandLine(command, commandArgs);
|
|
86
|
+
const needShell = /(\$\(|`|&&|\|\||;|>|<|\|)/.test(shellCommand);
|
|
87
|
+
// Per-task telemetry override: skip telemetry for this command entirely
|
|
88
|
+
const taskTelemetryDisabled = telemetryCtx?.telemetryConfig?.enabled === false;
|
|
89
|
+
// Telemetry: create command span and prepare child env
|
|
90
|
+
const commandSpan = (telemetryCtx && !taskTelemetryDisabled)
|
|
91
|
+
? telemetryCtx.telemetry.startCommand({
|
|
92
|
+
command,
|
|
93
|
+
commandLine: formatCommandLine(command, commandArgs),
|
|
94
|
+
argumentCount: commandArgs.length,
|
|
95
|
+
cwd,
|
|
96
|
+
runId: telemetryCtx.runId,
|
|
97
|
+
loopId: telemetryCtx.loopId,
|
|
98
|
+
taskId: telemetryCtx.taskId,
|
|
99
|
+
taskName: telemetryCtx.taskName,
|
|
100
|
+
}, telemetryCtx.taskSpan ?? telemetryCtx.loopSpan)
|
|
101
|
+
: undefined;
|
|
102
|
+
let telemetryEnv = {};
|
|
103
|
+
let detectedIntegrationId;
|
|
104
|
+
if (telemetryCtx && telemetryCtx.telemetry.getStatus().enabled && !taskTelemetryDisabled) {
|
|
105
|
+
const traceCtx = (telemetryCtx.taskSpan ?? telemetryCtx.loopSpan)?.getTraceContext() ?? {};
|
|
106
|
+
const integrationOverride = telemetryCtx.telemetryConfig?.integration;
|
|
107
|
+
const prepared = telemetryCtx.telemetry.prepareChildProcess({ command, args: commandArgs, cwd }, {
|
|
108
|
+
runId: telemetryCtx.runId,
|
|
109
|
+
loopId: telemetryCtx.loopId,
|
|
110
|
+
loopName: telemetryCtx.loopName,
|
|
111
|
+
taskId: telemetryCtx.taskId,
|
|
112
|
+
taskName: telemetryCtx.taskName,
|
|
113
|
+
projectId: telemetryCtx.projectId,
|
|
114
|
+
projectName: telemetryCtx.projectName,
|
|
115
|
+
traceParent: traceCtx.traceParent,
|
|
116
|
+
traceState: traceCtx.traceState,
|
|
117
|
+
}, integrationOverride);
|
|
118
|
+
telemetryEnv = prepared.env;
|
|
119
|
+
detectedIntegrationId = prepared.integrationId;
|
|
120
|
+
if (detectedIntegrationId && commandSpan) {
|
|
121
|
+
commandSpan.setAttribute("loop_task.agent.integration", detectedIntegrationId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const baseEnv = childEnv();
|
|
125
|
+
const fileEnv = loadEnvFile();
|
|
126
|
+
const mergedEnv = {
|
|
127
|
+
...baseEnv,
|
|
128
|
+
...fileEnv,
|
|
129
|
+
...telemetryEnv,
|
|
130
|
+
};
|
|
131
|
+
const detachedOpt = process.platform !== "win32" ? { detached: true } : {};
|
|
132
|
+
const child = needShell
|
|
133
|
+
? execa(shellCommand, {
|
|
134
|
+
stdout: "pipe",
|
|
135
|
+
stderr: "pipe",
|
|
136
|
+
stdin: "ignore",
|
|
137
|
+
buffer: false,
|
|
138
|
+
cwd: cwd || undefined,
|
|
139
|
+
cancelSignal: signal,
|
|
140
|
+
shell: true,
|
|
141
|
+
env: mergedEnv,
|
|
142
|
+
killSignal: "SIGTERM",
|
|
143
|
+
...detachedOpt,
|
|
144
|
+
})
|
|
145
|
+
: execa(command, commandArgs, {
|
|
146
|
+
stdout: "pipe",
|
|
147
|
+
stderr: "pipe",
|
|
148
|
+
stdin: "ignore",
|
|
149
|
+
buffer: false,
|
|
150
|
+
cwd: cwd || undefined,
|
|
151
|
+
cancelSignal: signal,
|
|
152
|
+
env: mergedEnv,
|
|
153
|
+
killSignal: "SIGTERM",
|
|
154
|
+
...detachedOpt,
|
|
155
|
+
});
|
|
156
|
+
if (child.pid) {
|
|
157
|
+
activePids.add(child.pid);
|
|
158
|
+
}
|
|
159
|
+
const stdoutCapture = (captureStdout || !!detectedIntegrationId)
|
|
160
|
+
? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
|
|
161
|
+
: null;
|
|
162
|
+
const stderrCapture = (captureStdout || !!detectedIntegrationId)
|
|
163
|
+
? new StdoutCaptureTransform(MAX_CONTEXT_STDOUT_BYTES)
|
|
164
|
+
: null;
|
|
165
|
+
if (stdoutCapture) {
|
|
166
|
+
child.stdout.pipe(stdoutCapture).pipe(logStream, { end: false });
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
child.stdout.pipe(logStream, { end: false });
|
|
170
|
+
}
|
|
171
|
+
if (stderrCapture) {
|
|
172
|
+
child.stderr.pipe(stderrCapture).pipe(logStream, { end: false });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
child.stderr.pipe(logStream, { end: false });
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const result = await child;
|
|
179
|
+
const endedAt = new Date();
|
|
180
|
+
const duration = endedAt.getTime() - startedAt.getTime();
|
|
181
|
+
if (child.pid)
|
|
182
|
+
activePids.delete(child.pid);
|
|
183
|
+
if (stdoutCapture?.isTruncated()) {
|
|
184
|
+
logStream.write(t("context.truncationWarning"));
|
|
185
|
+
}
|
|
186
|
+
logStream.write(t("loop.exitMarker", { code: String(result.exitCode), duration: formatDuration(duration) }));
|
|
187
|
+
if (commandSpan) {
|
|
188
|
+
commandSpan.setAttribute("process.exit.code", result.exitCode ?? 0);
|
|
189
|
+
if (telemetryCtx?.telemetry.getStatus().captureCommandOutput && stdoutCapture) {
|
|
190
|
+
commandSpan.setAttribute("loop_task.command.stdout", truncateForSpan(stdoutCapture.getCaptured(), MAX_SPAN_OUTPUT_BYTES));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Attempt to parse agent usage from output
|
|
194
|
+
if (detectedIntegrationId && stdoutCapture) {
|
|
195
|
+
tryParseAgentUsage(telemetryCtx.telemetry, detectedIntegrationId, { exitCode: result.exitCode ?? 0, stdout: stdoutCapture.getCaptured(), duration }, commandSpan);
|
|
196
|
+
}
|
|
197
|
+
if (commandSpan) {
|
|
198
|
+
commandSpan.ok();
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
exitCode: result.exitCode ?? 0,
|
|
202
|
+
duration,
|
|
203
|
+
startedAt,
|
|
204
|
+
endedAt,
|
|
205
|
+
...(captureStdout && stdoutCapture ? { stdout: stdoutCapture.getCaptured() } : {}),
|
|
206
|
+
...(captureStdout && stderrCapture ? { stderr: stderrCapture.getCaptured() } : {}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const endedAt = new Date();
|
|
211
|
+
const duration = endedAt.getTime() - startedAt.getTime();
|
|
212
|
+
const exitCode = extractExitCode(error);
|
|
213
|
+
if (child.pid) {
|
|
214
|
+
activePids.delete(child.pid);
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
await killProcessTree(child.pid);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (stdoutCapture?.isTruncated()) {
|
|
220
|
+
logStream.write(t("context.truncationWarning"));
|
|
221
|
+
}
|
|
222
|
+
logStream.write(t("loop.exitMarker", { code: exitCode, duration: formatDuration(duration) }));
|
|
223
|
+
if (commandSpan) {
|
|
224
|
+
commandSpan.setAttribute("process.exit.code", exitCode);
|
|
225
|
+
if (telemetryCtx?.telemetry.getStatus().captureCommandOutput && stdoutCapture) {
|
|
226
|
+
commandSpan.setAttribute("loop_task.command.stdout", truncateForSpan(stdoutCapture.getCaptured(), MAX_SPAN_OUTPUT_BYTES));
|
|
227
|
+
}
|
|
228
|
+
commandSpan.end(signal?.aborted ? "cancelled" : "error");
|
|
229
|
+
}
|
|
230
|
+
// Attempt to parse agent usage from output even on failure
|
|
231
|
+
if (detectedIntegrationId && stdoutCapture) {
|
|
232
|
+
tryParseAgentUsage(telemetryCtx.telemetry, detectedIntegrationId, { exitCode, stdout: stdoutCapture.getCaptured(), duration }, commandSpan);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
exitCode,
|
|
236
|
+
duration,
|
|
237
|
+
startedAt,
|
|
238
|
+
endedAt,
|
|
239
|
+
...(captureStdout && stdoutCapture ? { stdout: stdoutCapture.getCaptured() } : {}),
|
|
240
|
+
...(captureStdout && stderrCapture ? { stderr: stderrCapture.getCaptured() } : {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export async function executeCommandForeground(command, commandArgs, logger, cwd = "") {
|
|
245
|
+
const startedAt = new Date();
|
|
246
|
+
logger.debug(t("loop.executing", { command: `${command} ${commandArgs.join(" ")}` }));
|
|
247
|
+
if (cwd && !fs.existsSync(cwd)) {
|
|
248
|
+
logger.error(t("loop.cwdMissing", { cwd }));
|
|
249
|
+
const endedAt = new Date();
|
|
250
|
+
return { exitCode: 1, duration: 0, startedAt, endedAt };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const result = await execa(command, commandArgs, {
|
|
254
|
+
stdout: "inherit",
|
|
255
|
+
stderr: "inherit",
|
|
256
|
+
stdin: "inherit",
|
|
257
|
+
cwd: cwd || undefined,
|
|
258
|
+
shell: true,
|
|
259
|
+
env: childEnv(),
|
|
260
|
+
});
|
|
261
|
+
const endedAt = new Date();
|
|
262
|
+
return {
|
|
263
|
+
exitCode: result.exitCode ?? 0,
|
|
264
|
+
duration: endedAt.getTime() - startedAt.getTime(),
|
|
265
|
+
startedAt,
|
|
266
|
+
endedAt,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
const endedAt = new Date();
|
|
271
|
+
return {
|
|
272
|
+
exitCode: extractExitCode(error),
|
|
273
|
+
duration: endedAt.getTime() - startedAt.getTime(),
|
|
274
|
+
startedAt,
|
|
275
|
+
endedAt,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function tryParseAgentUsage(telemetry, integrationId, result, span) {
|
|
280
|
+
try {
|
|
281
|
+
const integrations = getAgentIntegrations();
|
|
282
|
+
const match = integrations.find((i) => i.id === integrationId);
|
|
283
|
+
if (!match?.parseUsage)
|
|
284
|
+
return;
|
|
285
|
+
const usage = match.parseUsage(result);
|
|
286
|
+
if (usage) {
|
|
287
|
+
usage.integration = integrationId;
|
|
288
|
+
telemetry.recordAgentUsage(usage, span);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Telemetry must never fail execution
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function truncateForSpan(text, maxBytes) {
|
|
296
|
+
const buf = Buffer.from(text, "utf-8");
|
|
297
|
+
if (buf.length <= maxBytes)
|
|
298
|
+
return text;
|
|
299
|
+
return buf.subarray(0, maxBytes).toString("utf-8") + `\n... [truncated, ${buf.length} bytes total]`;
|
|
300
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function getDetachedSpawnOptions(base) {
|
|
2
|
+
if (process.platform === "win32") {
|
|
3
|
+
return { ...base, windowsHide: true };
|
|
4
|
+
}
|
|
5
|
+
return {
|
|
6
|
+
...base,
|
|
7
|
+
detached: true,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
const SIGKILL_TIMEOUT_MS = 5000;
|
|
11
|
+
export async function killProcessTree(pid, signal = "SIGTERM") {
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
try {
|
|
14
|
+
const { execa: execaSync } = await import("execa");
|
|
15
|
+
await execaSync("taskkill", ["/T", "/F", "/PID", String(pid)]);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, signal);
|
|
20
|
+
}
|
|
21
|
+
catch { /* already dead */ }
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const tryKill = (sig) => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-pid, sig);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, sig);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const isAlive = () => {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const killed = tryKill(signal);
|
|
50
|
+
if (!killed || !isAlive())
|
|
51
|
+
return;
|
|
52
|
+
if (signal !== "SIGKILL") {
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
if (isAlive()) {
|
|
56
|
+
tryKill("SIGKILL");
|
|
57
|
+
}
|
|
58
|
+
resolve();
|
|
59
|
+
}, SIGKILL_TIMEOUT_MS);
|
|
60
|
+
const check = setInterval(() => {
|
|
61
|
+
if (!isAlive()) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
clearInterval(check);
|
|
64
|
+
resolve();
|
|
65
|
+
}
|
|
66
|
+
}, 200);
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
clearInterval(check);
|
|
69
|
+
resolve();
|
|
70
|
+
}, SIGKILL_TIMEOUT_MS + 1000);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function resolveEffectiveCwd(loopCwd, projectDirectory) {
|
|
3
|
+
if (!loopCwd)
|
|
4
|
+
return projectDirectory || process.cwd();
|
|
5
|
+
if (path.isAbsolute(loopCwd))
|
|
6
|
+
return loopCwd;
|
|
7
|
+
return path.join(projectDirectory || process.cwd(), loopCwd);
|
|
8
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
import { MAX_CONTEXT_STDOUT_BYTES } from "../../shared/config/constants.js";
|
|
3
|
+
const KV_LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
4
|
+
export class StdoutCaptureTransform extends Transform {
|
|
5
|
+
constructor(maxBytes = MAX_CONTEXT_STDOUT_BYTES) {
|
|
6
|
+
super({ decodeStrings: true });
|
|
7
|
+
this.captured = [];
|
|
8
|
+
this.totalBytes = 0;
|
|
9
|
+
this.truncated = false;
|
|
10
|
+
this.partialLine = "";
|
|
11
|
+
this.kvPairs = {};
|
|
12
|
+
this.maxBytes = maxBytes;
|
|
13
|
+
}
|
|
14
|
+
_transform(chunk, _encoding, callback) {
|
|
15
|
+
this.push(chunk);
|
|
16
|
+
if (this.truncated) {
|
|
17
|
+
callback();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const chunkBytes = chunk.byteLength;
|
|
21
|
+
const remaining = this.maxBytes - this.totalBytes;
|
|
22
|
+
if (remaining <= 0) {
|
|
23
|
+
this.truncated = true;
|
|
24
|
+
callback();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (chunkBytes <= remaining) {
|
|
28
|
+
this.captured.push(Buffer.from(chunk));
|
|
29
|
+
this.totalBytes += chunkBytes;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const slice = Buffer.from(chunk).subarray(0, remaining);
|
|
33
|
+
this.captured.push(slice);
|
|
34
|
+
this.totalBytes += slice.byteLength;
|
|
35
|
+
this.truncated = true;
|
|
36
|
+
}
|
|
37
|
+
this.extractKvFromChunk(chunk);
|
|
38
|
+
callback();
|
|
39
|
+
}
|
|
40
|
+
extractKvFromChunk(chunk) {
|
|
41
|
+
const text = chunk.toString("utf-8");
|
|
42
|
+
const combined = this.partialLine + text;
|
|
43
|
+
const lines = combined.split("\n");
|
|
44
|
+
this.partialLine = lines.pop() ?? "";
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const match = line.trim().match(KV_LINE_RE);
|
|
47
|
+
if (match) {
|
|
48
|
+
this.kvPairs[match[1]] = match[2];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
_flush(callback) {
|
|
53
|
+
if (this.partialLine) {
|
|
54
|
+
const match = this.partialLine.trim().match(KV_LINE_RE);
|
|
55
|
+
if (match) {
|
|
56
|
+
this.kvPairs[match[1]] = match[2];
|
|
57
|
+
}
|
|
58
|
+
this.partialLine = "";
|
|
59
|
+
}
|
|
60
|
+
callback();
|
|
61
|
+
}
|
|
62
|
+
getCaptured() {
|
|
63
|
+
if (this.captured.length === 0)
|
|
64
|
+
return "";
|
|
65
|
+
return Buffer.concat(this.captured, this.totalBytes).toString("utf-8");
|
|
66
|
+
}
|
|
67
|
+
getKvPairs() {
|
|
68
|
+
return { ...this.kvPairs };
|
|
69
|
+
}
|
|
70
|
+
isTruncated() {
|
|
71
|
+
return this.truncated;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function isObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function parseStdout(raw) {
|
|
5
|
+
const trimmed = raw.trim();
|
|
6
|
+
if (trimmed.length === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
let whole;
|
|
10
|
+
try {
|
|
11
|
+
whole = JSON.parse(trimmed);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
whole = undefined;
|
|
15
|
+
}
|
|
16
|
+
if (whole !== undefined) {
|
|
17
|
+
if (isObject(whole)) {
|
|
18
|
+
return whole;
|
|
19
|
+
}
|
|
20
|
+
if (typeof whole === "string" || typeof whole === "number" || typeof whole === "boolean" || whole === null) {
|
|
21
|
+
return { output: String(whole) };
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(whole)) {
|
|
24
|
+
return { output: trimmed };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const lines = trimmed.split("\n");
|
|
28
|
+
const parsedLines = [];
|
|
29
|
+
let jsonlSuccess = true;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const l = line.trim();
|
|
32
|
+
if (l.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
parsedLines.push(JSON.parse(l));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
jsonlSuccess = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (jsonlSuccess && parsedLines.length > 0) {
|
|
43
|
+
const result = {};
|
|
44
|
+
for (const pl of parsedLines) {
|
|
45
|
+
if (isObject(pl)) {
|
|
46
|
+
Object.assign(result, pl);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.output = String(pl);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
return { output: trimmed };
|
|
55
|
+
}
|
|
56
|
+
export function mergeCommandOutput(context, stdout, stderr) {
|
|
57
|
+
const parsed = parseStdout(stdout);
|
|
58
|
+
if (parsed !== null) {
|
|
59
|
+
Object.assign(context, parsed);
|
|
60
|
+
}
|
|
61
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
62
|
+
if (output.length > 0) {
|
|
63
|
+
context.output = output;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function shellEscape(value) {
|
|
2
|
+
if (value.length === 0)
|
|
3
|
+
return '""';
|
|
4
|
+
// If the value is purely safe characters, pass it through unmodified
|
|
5
|
+
if (/^[A-Za-z0-9_\-=:./,@]+$/.test(value))
|
|
6
|
+
return value;
|
|
7
|
+
// Escape characters that are dangerous inside double quotes, then wrap
|
|
8
|
+
// the whole value in single quotes the strongest shell quoting.
|
|
9
|
+
// Single quotes preserve everything literally (newlines, backticks,
|
|
10
|
+
// $, ", \, parens) except single quotes themselves.
|
|
11
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
12
|
+
}
|
|
13
|
+
export function interpolate(input, context) {
|
|
14
|
+
return input.replace(/{{(\w+)}}/g, (_, key) => {
|
|
15
|
+
const raw = context[key];
|
|
16
|
+
if (raw === undefined || raw === null)
|
|
17
|
+
return "";
|
|
18
|
+
return shellEscape(String(raw));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { formatDuration } from "../../duration.js";
|
|
2
|
+
import { t } from "../../shared/i18n/index.js";
|
|
3
|
+
import { sleep } from "../../shared/sleep.js";
|
|
4
|
+
import { executeCommandForeground } from "../command/command-runner.js";
|
|
5
|
+
export async function runLoop(options, logger, signal) {
|
|
6
|
+
const state = {
|
|
7
|
+
running: false,
|
|
8
|
+
runCount: 0,
|
|
9
|
+
shuttingDown: false,
|
|
10
|
+
};
|
|
11
|
+
const onSignal = () => {
|
|
12
|
+
state.shuttingDown = true;
|
|
13
|
+
logger.info(t("loop.shuttingDown"));
|
|
14
|
+
};
|
|
15
|
+
process.on("SIGINT", onSignal);
|
|
16
|
+
process.on("SIGTERM", onSignal);
|
|
17
|
+
const command = options.command;
|
|
18
|
+
const commandArgs = options.commandArgs;
|
|
19
|
+
const cwd = options.cwd;
|
|
20
|
+
try {
|
|
21
|
+
let isFirstRun = true;
|
|
22
|
+
while (!state.shuttingDown) {
|
|
23
|
+
if (options.maxRuns !== null &&
|
|
24
|
+
state.runCount >= options.maxRuns) {
|
|
25
|
+
logger.info(t("loop.completed", { runs: state.runCount }));
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (isFirstRun && !options.immediate) {
|
|
29
|
+
logger.info(t("loop.waitingFirst", { duration: formatDuration(options.interval) }));
|
|
30
|
+
try {
|
|
31
|
+
await sleep(options.interval, signal);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (state.shuttingDown)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
isFirstRun = false;
|
|
40
|
+
state.running = true;
|
|
41
|
+
state.runCount++;
|
|
42
|
+
logger.info(t("loop.runSeparator", {
|
|
43
|
+
run: state.runCount,
|
|
44
|
+
maxRuns: options.maxRuns !== null ? `/${options.maxRuns}` : "",
|
|
45
|
+
}));
|
|
46
|
+
const result = await executeCommandForeground(command, commandArgs, logger, cwd);
|
|
47
|
+
state.running = false;
|
|
48
|
+
if (result.exitCode !== 0) {
|
|
49
|
+
logger.error(t("loop.commandFailed", { code: result.exitCode }));
|
|
50
|
+
}
|
|
51
|
+
if (options.verbose) {
|
|
52
|
+
logger.debug(t("loop.debugStartedAt", { timestamp: result.startedAt.toISOString() }));
|
|
53
|
+
logger.debug(t("loop.debugEndedAt", { timestamp: result.endedAt.toISOString() }));
|
|
54
|
+
logger.debug(t("loop.debugExitCode", { code: result.exitCode }));
|
|
55
|
+
logger.debug(t("loop.debugDuration", { duration: formatDuration(result.duration) }));
|
|
56
|
+
}
|
|
57
|
+
if (state.shuttingDown)
|
|
58
|
+
break;
|
|
59
|
+
if (options.maxRuns !== null &&
|
|
60
|
+
state.runCount >= options.maxRuns) {
|
|
61
|
+
logger.info(t("loop.completed", { runs: state.runCount }));
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const nextRun = new Date(Date.now() + options.interval);
|
|
65
|
+
logger.info(t("loop.nextRun", {
|
|
66
|
+
duration: formatDuration(options.interval),
|
|
67
|
+
time: nextRun.toLocaleTimeString(),
|
|
68
|
+
}));
|
|
69
|
+
try {
|
|
70
|
+
await sleep(options.interval, signal);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
process.removeListener("SIGINT", onSignal);
|
|
79
|
+
process.removeListener("SIGTERM", onSignal);
|
|
80
|
+
}
|
|
81
|
+
}
|