@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.
Files changed (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { Writable } from "node:stream";
4
+ import { DEFAULT_TASK_MAX_RUNS } from "../../types.js";
5
+ import { executeCommand } from "../command/command-runner.js";
6
+ import { t } from "../../shared/i18n/index.js";
7
+ import { mergeCommandOutput } from "../context/context-parser.js";
8
+ import { interpolate } from "../context/template.js";
9
+ import { formatContextLog } from "../context/log-context.js";
10
+ export function executeChain(options) {
11
+ const { chainTargetId, exitCode, task: _task, chainContext, cwd, signal, runCount, logPath, runHistory, logStream, controller, telemetryManager, loopId, loopName, runId, loopSpan } = options;
12
+ const nullStream = new Writable({ write(_chunk, _enc, cb) { cb(); } });
13
+ if (!chainTargetId) {
14
+ return Promise.resolve({ runHistory, lastExitCode: exitCode, lastDuration: 0 });
15
+ }
16
+ const chainGroupId = crypto.randomUUID().slice(0, 8);
17
+ const mainRecord = runHistory[runHistory.length - 1];
18
+ if (mainRecord)
19
+ mainRecord.chainGroupId = chainGroupId;
20
+ let currentTargetId = chainTargetId;
21
+ let prevBranch = exitCode === 0 ? "onSuccess" : "onFailure";
22
+ let prevExit = exitCode;
23
+ let totalExtraDuration = 0;
24
+ let finalExitCode = exitCode;
25
+ let hadSilentChain = false;
26
+ return (async () => {
27
+ while (currentTargetId) {
28
+ if (signal.aborted)
29
+ break;
30
+ const chainTask = controller.taskResolver(currentTargetId);
31
+ if (!chainTask)
32
+ break;
33
+ // Per-task maxRuns check for chained tasks
34
+ const chainTaskMaxRuns = chainTask.maxRuns ?? DEFAULT_TASK_MAX_RUNS;
35
+ const chainTaskRunCount = controller.getTaskRunCount(chainTask.id);
36
+ if (chainTaskRunCount >= chainTaskMaxRuns) {
37
+ // Chained task exceeded its max runs limit — fail it and continue chain
38
+ const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
39
+ if (chainRecord) {
40
+ chainRecord.exitCode = 1;
41
+ chainRecord.duration = 0;
42
+ chainRecord.logSize = 0;
43
+ chainRecord.status = "completed";
44
+ }
45
+ totalExtraDuration += 0;
46
+ finalExitCode = 1;
47
+ currentTargetId = chainTask.onFailureTaskId ?? null;
48
+ prevBranch = "onFailure";
49
+ prevExit = 1;
50
+ continue;
51
+ }
52
+ const isSilent = chainTask.silentChain === true;
53
+ if (isSilent) {
54
+ controller.incrementSilentChainCount();
55
+ hadSilentChain = true;
56
+ }
57
+ if (logStream && !isSilent) {
58
+ logStream.write(t("loop.chainHeader", { name: chainTask.name, branch: prevBranch, prevExit }));
59
+ }
60
+ const chainStartedAt = new Date().toISOString();
61
+ const chainOffset = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
62
+ if (!isSilent) {
63
+ runHistory.push({
64
+ runNumber: runCount,
65
+ startedAt: chainStartedAt,
66
+ exitCode: -1,
67
+ duration: 0,
68
+ logSize: 0,
69
+ status: "running",
70
+ logOffset: chainOffset,
71
+ chainGroupId,
72
+ chainName: chainTask.name,
73
+ });
74
+ }
75
+ const singleCommandFallback = {
76
+ command: chainTask.command,
77
+ commandArgs: chainTask.commandArgs,
78
+ commandRaw: chainTask.commandRaw,
79
+ };
80
+ const taskSteps = chainTask.steps?.length
81
+ ? chainTask.steps
82
+ : [{ commands: [singleCommandFallback] }];
83
+ const chainTaskHasChains = !!(chainTask.onSuccessTaskId || chainTask.onFailureTaskId);
84
+ const shouldCaptureStdout = chainTaskHasChains || taskSteps.length > 1 || taskSteps[0].commands.length > 1;
85
+ let chainExitCode = 0;
86
+ let chainDuration = 0;
87
+ const effectiveStream = isSilent ? nullStream : (logStream ?? nullStream);
88
+ if (!isSilent) {
89
+ effectiveStream.write(formatContextLog(chainContext));
90
+ }
91
+ // Telemetry: create task span for chained task
92
+ const telemetry = telemetryManager?.getAdapter();
93
+ const chainTaskSpan = (telemetry && chainTask)
94
+ ? telemetry.startTask({
95
+ taskId: chainTask.id,
96
+ taskName: chainTask.name,
97
+ runId: runId ?? `chain-${chainGroupId}`,
98
+ loopId: loopId ?? "chain",
99
+ loopName: loopName ?? "chain",
100
+ }, loopSpan)
101
+ : undefined;
102
+ const chainTelemetryCtx = telemetry
103
+ ? {
104
+ telemetry,
105
+ loopSpan,
106
+ taskSpan: chainTaskSpan,
107
+ runId: runId ?? `chain-${chainGroupId}`,
108
+ loopId: loopId ?? "chain",
109
+ loopName: loopName ?? "chain",
110
+ taskId: chainTask.id,
111
+ taskName: chainTask.name,
112
+ telemetryConfig: chainTask.telemetry,
113
+ }
114
+ : undefined;
115
+ for (const step of taskSteps) {
116
+ if (signal.aborted)
117
+ break;
118
+ 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, false, chainTelemetryCtx)));
119
+ if (signal.aborted)
120
+ break;
121
+ let stepStdout = "";
122
+ let stepStderr = "";
123
+ for (const r of stepResults) {
124
+ if (r.status === "fulfilled") {
125
+ chainDuration += r.value.duration;
126
+ if (r.value.stdout)
127
+ stepStdout += (stepStdout ? "\n" : "") + r.value.stdout;
128
+ if (r.value.stderr)
129
+ stepStderr += (stepStderr ? "\n" : "") + r.value.stderr;
130
+ }
131
+ }
132
+ if (shouldCaptureStdout && (stepStdout || stepStderr)) {
133
+ mergeCommandOutput(chainContext, stepStdout, stepStderr);
134
+ }
135
+ const stepFailure = stepResults.some((r) => r.status === "rejected" || (r.status === "fulfilled" && r.value.exitCode !== 0));
136
+ if (stepFailure) {
137
+ const failed = stepResults.find((r) => r.status === "fulfilled" && r.value.exitCode !== 0);
138
+ chainExitCode = failed ? failed.value.exitCode : 1;
139
+ break;
140
+ }
141
+ }
142
+ const chainLogSize = !isSilent && fs.existsSync(logPath) ? fs.statSync(logPath).size - chainOffset : 0;
143
+ const chainRecord = runHistory.find(r => r.chainGroupId === chainGroupId && r.status === "running" && r.chainName === chainTask.name);
144
+ if (chainRecord) {
145
+ chainRecord.exitCode = chainExitCode;
146
+ chainRecord.duration = chainDuration;
147
+ chainRecord.logSize = Math.max(0, chainLogSize);
148
+ chainRecord.status = "completed";
149
+ }
150
+ totalExtraDuration += chainDuration;
151
+ finalExitCode = chainExitCode;
152
+ // End the chain task span
153
+ if (chainTaskSpan) {
154
+ chainTaskSpan.end(chainExitCode === 0 ? "ok" : "error");
155
+ }
156
+ // Increment per-task run counter for the chained task
157
+ controller.incrementTaskRunCount(chainTask.id);
158
+ currentTargetId = (chainExitCode === 0 ? chainTask.onSuccessTaskId : chainTask.onFailureTaskId) ?? null;
159
+ prevBranch = chainExitCode === 0 ? "onSuccess" : "onFailure";
160
+ prevExit = chainExitCode;
161
+ }
162
+ // If any silent chain task ran, remove all records for this chain group
163
+ // (including the main task record) — the run was uninteresting.
164
+ if (hadSilentChain) {
165
+ const filtered = runHistory.filter(r => r.chainGroupId !== chainGroupId);
166
+ runHistory.length = 0;
167
+ runHistory.push(...filtered);
168
+ }
169
+ return { runHistory, lastExitCode: finalExitCode, lastDuration: totalExtraDuration };
170
+ })();
171
+ }
@@ -0,0 +1,65 @@
1
+ import { sleep } from "../../shared/sleep.js";
2
+ import { SLEEP_CHUNK_MS } from "../../shared/config/constants.js";
3
+ export function waitForResume(ctrl) {
4
+ const savedStatus = ctrl._status;
5
+ ctrl._status = savedStatus === "idle" ? "idle" : "paused";
6
+ return new Promise((resolve) => {
7
+ ctrl.resumeResolve = resolve;
8
+ });
9
+ }
10
+ export async function waitForDelay(ctrl, ms, signal) {
11
+ let remaining = ms;
12
+ ctrl.remainingDelayMs = remaining;
13
+ let announced = false;
14
+ while (remaining > 0) {
15
+ if (ctrl._forceRun) {
16
+ ctrl._savedRemainingMs = remaining;
17
+ ctrl.remainingDelayMs = null;
18
+ ctrl.nextRunAt = null;
19
+ return true;
20
+ }
21
+ if (ctrl._resetSchedule) {
22
+ ctrl._resetSchedule = false;
23
+ remaining = ms;
24
+ ctrl.remainingDelayMs = remaining;
25
+ announced = false;
26
+ }
27
+ if (ctrl._paused) {
28
+ if (ctrl._status !== "idle") {
29
+ ctrl._status = "paused";
30
+ }
31
+ ctrl.emit("paused");
32
+ await waitForResume(ctrl);
33
+ announced = false;
34
+ if (signal.aborted) {
35
+ ctrl.remainingDelayMs = null;
36
+ return false;
37
+ }
38
+ if (ctrl._resetSchedule) {
39
+ ctrl._resetSchedule = false;
40
+ remaining = ms;
41
+ ctrl.remainingDelayMs = remaining;
42
+ }
43
+ }
44
+ if (!announced) {
45
+ ctrl._status = "waiting";
46
+ ctrl.nextRunAt = new Date(Date.now() + remaining).toISOString();
47
+ ctrl.emit("waiting");
48
+ announced = true;
49
+ }
50
+ const chunk = Math.min(remaining, SLEEP_CHUNK_MS);
51
+ const startedAt = Date.now();
52
+ try {
53
+ await sleep(chunk, signal);
54
+ }
55
+ catch {
56
+ ctrl.remainingDelayMs = null;
57
+ return false;
58
+ }
59
+ remaining = Math.max(0, remaining - (Date.now() - startedAt));
60
+ ctrl.remainingDelayMs = remaining;
61
+ }
62
+ ctrl.remainingDelayMs = null;
63
+ ctrl.nextRunAt = null;
64
+ return true;
65
+ }
@@ -0,0 +1,240 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { computePhase, alignToPhase } from "../scheduling/index.js";
3
+ import { RotatingWriteStream } from "../logging/rotating-log-stream.js";
4
+ import { runLoop } from "./loop-runner.js";
5
+ export class LoopController extends EventEmitter {
6
+ getTaskRunCount(taskId) {
7
+ return this.taskRunCounts.get(taskId) ?? 0;
8
+ }
9
+ incrementTaskRunCount(taskId) {
10
+ const current = this.taskRunCounts.get(taskId) ?? 0;
11
+ this.taskRunCounts.set(taskId, current + 1);
12
+ }
13
+ resetTaskRunCounts() {
14
+ this.taskRunCounts.clear();
15
+ }
16
+ constructor(id, options, logPath, taskResolver, state, projectDirectory, telemetryManager) {
17
+ super();
18
+ this.runAbortController = null;
19
+ this._paused = false;
20
+ this._forceRun = false;
21
+ this._savedRemainingMs = null;
22
+ this._resetSchedule = false;
23
+ this._stopAfterRun = false;
24
+ this._maxRunsReached = false;
25
+ this._status = "running";
26
+ this.resumeResolve = null;
27
+ this.runCount = 0;
28
+ this.lastRunAt = null;
29
+ this.lastExitCode = null;
30
+ this.lastDuration = null;
31
+ this.nextRunAt = null;
32
+ this.remainingDelayMs = null;
33
+ this.logStream = null;
34
+ this.loopPromise = null;
35
+ this._loopActive = false;
36
+ this.sessionStartedAt = null;
37
+ this.runHistory = [];
38
+ this.currentRunStartOffset = 0;
39
+ this.skippedCount = 0;
40
+ this._silentChainCount = 0;
41
+ this._totalRunCount = 0;
42
+ this.taskRunCounts = new Map();
43
+ this.id = id;
44
+ this.options = options;
45
+ this.logPath = logPath;
46
+ this.taskResolver = taskResolver;
47
+ this.projectDirectory = projectDirectory;
48
+ this._telemetryManager = telemetryManager ?? null;
49
+ this.abortController = new AbortController();
50
+ this.createdAt = state?.createdAt ?? new Date().toISOString();
51
+ this.runCount = state?.runCount ?? 0;
52
+ this._maxRunsReached = state?.maxRunsReached ?? false;
53
+ this.sessionStartedAt = state?.sessionStartedAt ?? null;
54
+ this.lastRunAt = state?.lastRunAt ?? null;
55
+ this.lastExitCode = state?.lastExitCode ?? null;
56
+ this.lastDuration = state?.lastDuration ?? null;
57
+ this.nextRunAt = state?.nextRunAt ?? null;
58
+ this.remainingDelayMs = state?.remainingDelayMs ?? null;
59
+ this._status = state?.status ?? "running";
60
+ this._paused = state?.status === "paused" || state?.status === "idle";
61
+ this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
62
+ this.skippedCount = state?.skippedCount ?? 0;
63
+ this._silentChainCount = state?.silentChainCount ?? 0;
64
+ this._totalRunCount = state?.totalRunCount ?? this.runCount;
65
+ }
66
+ get status() {
67
+ return this._status;
68
+ }
69
+ get telemetryManager() {
70
+ return this._telemetryManager;
71
+ }
72
+ get silentChainCount() {
73
+ return this._silentChainCount;
74
+ }
75
+ incrementSilentChainCount() {
76
+ this._silentChainCount += 1;
77
+ }
78
+ start() {
79
+ if (this._loopActive)
80
+ return;
81
+ this._loopActive = true;
82
+ this.skippedCount = 0;
83
+ this.resetTaskRunCounts();
84
+ this.logStream?.end();
85
+ this.abortController = new AbortController();
86
+ this.logStream = RotatingWriteStream.create(this.logPath);
87
+ if (this.options.interval === 0 && !this._stopAfterRun) {
88
+ this._status = "idle";
89
+ this.nextRunAt = null;
90
+ this._loopActive = false;
91
+ this.emit("stopped");
92
+ return;
93
+ }
94
+ this.loopPromise = this.run().finally(() => { this._loopActive = false; });
95
+ if (this.sessionStartedAt === null) {
96
+ this.sessionStartedAt = new Date().toISOString();
97
+ }
98
+ }
99
+ pause(interruptCurrentRun = false) {
100
+ if (this._status === "running" || this._status === "waiting") {
101
+ this._paused = true;
102
+ this._status = "paused";
103
+ if (interruptCurrentRun && this._status === "paused") {
104
+ this.runAbortController?.abort();
105
+ }
106
+ this.emit("paused");
107
+ }
108
+ }
109
+ async stopLoop(interruptCurrentRun = false) {
110
+ if (this._status === "running" || this._status === "waiting" || this._status === "paused") {
111
+ this._paused = true;
112
+ this._status = "idle";
113
+ this.sessionStartedAt = null;
114
+ this.remainingDelayMs = null;
115
+ this.nextRunAt = null;
116
+ if (interruptCurrentRun) {
117
+ this.runAbortController?.abort();
118
+ }
119
+ this.abortController.abort();
120
+ if (this.resumeResolve) {
121
+ this.resumeResolve();
122
+ this.resumeResolve = null;
123
+ }
124
+ this.emit("stopped");
125
+ if (this.loopPromise) {
126
+ await this.loopPromise;
127
+ }
128
+ }
129
+ }
130
+ resume() {
131
+ if (this._status === "paused" && this._paused && this.resumeResolve) {
132
+ this._paused = false;
133
+ if (this.remainingDelayMs !== null) {
134
+ this._status = "waiting";
135
+ this.nextRunAt = new Date(Date.now() + this.remainingDelayMs).toISOString();
136
+ }
137
+ this.resumeResolve();
138
+ this.resumeResolve = null;
139
+ this.emit("resumed");
140
+ }
141
+ }
142
+ playLoop() {
143
+ if (this._status !== "idle")
144
+ return false;
145
+ if (this._maxRunsReached)
146
+ return false;
147
+ if (this.options.maxRuns !== null && this.runCount >= this.options.maxRuns) {
148
+ this._maxRunsReached = true;
149
+ return false;
150
+ }
151
+ if (this.options.interval === 0)
152
+ return false;
153
+ this.sessionStartedAt = new Date().toISOString();
154
+ this._resetSchedule = true;
155
+ this._paused = false;
156
+ const phase = this.options.offset !== null
157
+ ? this.options.offset
158
+ : computePhase(this.id, this.options.interval);
159
+ const delay = alignToPhase(Date.now(), this.options.interval, phase);
160
+ this.nextRunAt = new Date(Date.now() + delay).toISOString();
161
+ if (this.resumeResolve) {
162
+ this.resumeResolve();
163
+ this.resumeResolve = null;
164
+ }
165
+ this.start();
166
+ this._status = "waiting";
167
+ this.emit("resumed");
168
+ return true;
169
+ }
170
+ triggerNow() {
171
+ if (this._status === "running")
172
+ return false;
173
+ if (this._maxRunsReached)
174
+ return false;
175
+ const needsStart = this._status === "idle";
176
+ this._savedRemainingMs = this.remainingDelayMs;
177
+ this._forceRun = true;
178
+ if (needsStart) {
179
+ this._stopAfterRun = true;
180
+ this._paused = false;
181
+ this.start();
182
+ this._status = "running";
183
+ }
184
+ else {
185
+ if (this._paused)
186
+ this.resume();
187
+ }
188
+ this.emit("triggered");
189
+ return true;
190
+ }
191
+ async stop() {
192
+ this.abortController.abort();
193
+ if (this._paused)
194
+ this.resume();
195
+ if (this.loopPromise) {
196
+ await this.loopPromise;
197
+ }
198
+ this.logStream?.end();
199
+ this.logStream = null;
200
+ }
201
+ getMeta() {
202
+ return {
203
+ id: this.id,
204
+ taskId: this.options.taskId,
205
+ status: this._status,
206
+ createdAt: this.createdAt,
207
+ maxRunsReached: this._maxRunsReached,
208
+ sessionStartedAt: this.sessionStartedAt,
209
+ runCount: this.runCount,
210
+ lastRunAt: this.lastRunAt,
211
+ lastExitCode: this.lastExitCode,
212
+ lastDuration: this.lastDuration,
213
+ nextRunAt: this.nextRunAt,
214
+ remainingDelayMs: this.remainingDelayMs,
215
+ runHistory: this.runHistory,
216
+ skippedCount: this.skippedCount,
217
+ silentChainCount: this._silentChainCount,
218
+ totalRunCount: this._totalRunCount,
219
+ };
220
+ }
221
+ clearMaxRunsReached() {
222
+ this._maxRunsReached = false;
223
+ this._paused = false;
224
+ this._status = "idle";
225
+ this.remainingDelayMs = null;
226
+ this.nextRunAt = null;
227
+ this.runCount = 0;
228
+ }
229
+ isMaxRunsReached() {
230
+ return this._maxRunsReached;
231
+ }
232
+ checkLogRotation() {
233
+ if (this.logStream instanceof RotatingWriteStream) {
234
+ this.logStream.rotateIfNeeded();
235
+ }
236
+ }
237
+ run() {
238
+ return runLoop(this);
239
+ }
240
+ }
@@ -0,0 +1,194 @@
1
+ import { computePhase, alignToPhase } from "../scheduling/index.js";
2
+ import { resolveEffectiveCwd } from "../command/resolve-cwd.js";
3
+ import { executeChain } from "./chain-executor.js";
4
+ import { executeRunImpl } from "./run-executor.js";
5
+ import { waitForResume, waitForDelay } from "./delay-utils.js";
6
+ import { MAX_INMEMORY_RUN_HISTORY } from "../../shared/config/constants.js";
7
+ import { DEFAULT_TASK_MAX_RUNS } from "../../types.js";
8
+ export async function runLoop(ctrl) {
9
+ const signal = ctrl.abortController.signal;
10
+ let isFirstRun = true;
11
+ try {
12
+ while (!signal.aborted) {
13
+ if (ctrl.options.maxRuns !== null && ctrl.runCount >= ctrl.options.maxRuns) {
14
+ ctrl._maxRunsReached = true;
15
+ ctrl._paused = true;
16
+ ctrl._status = "paused";
17
+ ctrl.emit("paused");
18
+ return;
19
+ }
20
+ if (isFirstRun && ctrl._paused) {
21
+ await waitForResume(ctrl);
22
+ if (signal.aborted)
23
+ break;
24
+ }
25
+ if (isFirstRun && ctrl.nextRunAt) {
26
+ const delay = Math.max(0, new Date(ctrl.nextRunAt).getTime() - Date.now());
27
+ if (delay > 0) {
28
+ const completed = await waitForDelay(ctrl, delay, signal);
29
+ if (!completed)
30
+ break;
31
+ }
32
+ }
33
+ else if (isFirstRun && !ctrl.options.immediate) {
34
+ const phase = ctrl.options.offset !== null
35
+ ? ctrl.options.offset
36
+ : computePhase(ctrl.id, ctrl.options.interval);
37
+ const delay = alignToPhase(Date.now(), ctrl.options.interval, phase);
38
+ if (delay > 0) {
39
+ ctrl.nextRunAt = new Date(Date.now() + delay).toISOString();
40
+ }
41
+ const completed = await waitForDelay(ctrl, delay, signal);
42
+ if (!completed)
43
+ break;
44
+ }
45
+ isFirstRun = false;
46
+ if (ctrl._paused) {
47
+ await waitForResume(ctrl);
48
+ if (signal.aborted)
49
+ break;
50
+ }
51
+ const runStartedAtMs = Date.now();
52
+ ctrl.resetTaskRunCounts();
53
+ let exitCode;
54
+ let totalDuration;
55
+ let chainContext;
56
+ const currentTask = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
57
+ const taskMaxRuns = currentTask?.maxRuns ?? DEFAULT_TASK_MAX_RUNS;
58
+ const currentTaskRunCount = ctrl.options.taskId ? ctrl.getTaskRunCount(ctrl.options.taskId) : 0;
59
+ if (ctrl.options.taskId && currentTaskRunCount >= taskMaxRuns) {
60
+ // Task exceeded its max runs limit — fail it
61
+ ctrl._status = "running";
62
+ ctrl._forceRun = false;
63
+ ctrl.runCount++;
64
+ ctrl.lastRunAt = new Date().toISOString();
65
+ if (ctrl.sessionStartedAt === null) {
66
+ ctrl.sessionStartedAt = ctrl.lastRunAt;
67
+ }
68
+ ctrl.nextRunAt = null;
69
+ ctrl.emit("run:start", ctrl.runCount);
70
+ exitCode = 1;
71
+ totalDuration = 0;
72
+ chainContext = { ...(currentTask?.context ?? {}), ...(ctrl.options.context ?? {}) };
73
+ ctrl.runHistory.push({
74
+ runNumber: ctrl.runCount,
75
+ startedAt: ctrl.lastRunAt,
76
+ exitCode,
77
+ duration: totalDuration,
78
+ logSize: 0,
79
+ status: "completed",
80
+ logOffset: ctrl.currentRunStartOffset,
81
+ });
82
+ ctrl.lastExitCode = exitCode;
83
+ ctrl.lastDuration = totalDuration;
84
+ }
85
+ else {
86
+ const result = await executeRunImpl(ctrl, signal);
87
+ exitCode = result.exitCode;
88
+ totalDuration = result.totalDuration;
89
+ chainContext = result.chainContext;
90
+ // Increment per-task run counter after successful execution
91
+ if (ctrl.options.taskId) {
92
+ ctrl.incrementTaskRunCount(ctrl.options.taskId);
93
+ }
94
+ // Save for chain executor
95
+ ctrl.__lastRunId = result.runId;
96
+ ctrl.__lastLoopSpan = result.loopSpan;
97
+ }
98
+ const chainTargetId = exitCode === 0
99
+ ? (ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId)?.onSuccessTaskId : undefined)
100
+ : (ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId)?.onFailureTaskId : undefined);
101
+ if (chainTargetId && !signal.aborted) {
102
+ const task = ctrl.options.taskId ? ctrl.taskResolver(ctrl.options.taskId) : null;
103
+ const cwd = resolveEffectiveCwd(ctrl.options.cwd, ctrl.projectDirectory);
104
+ const lastRunId = ctrl.__lastRunId;
105
+ const lastLoopSpan = ctrl.__lastLoopSpan;
106
+ const chainResult = await executeChain({
107
+ chainTargetId,
108
+ exitCode,
109
+ task,
110
+ chainContext,
111
+ cwd,
112
+ signal,
113
+ runCount: ctrl.runCount,
114
+ logPath: ctrl.logPath,
115
+ runHistory: ctrl.runHistory,
116
+ logStream: ctrl.logStream,
117
+ controller: ctrl,
118
+ telemetryManager: ctrl.telemetryManager,
119
+ loopId: ctrl.id,
120
+ loopName: ctrl.options.description || ctrl.id,
121
+ runId: lastRunId,
122
+ loopSpan: lastLoopSpan,
123
+ });
124
+ ctrl.runHistory = chainResult.runHistory;
125
+ ctrl.lastExitCode = chainResult.lastExitCode;
126
+ ctrl.lastDuration = (ctrl.lastDuration ?? 0) + chainResult.lastDuration;
127
+ }
128
+ if (ctrl.runHistory.length > MAX_INMEMORY_RUN_HISTORY) {
129
+ ctrl.runHistory = ctrl.runHistory.slice(-MAX_INMEMORY_RUN_HISTORY);
130
+ }
131
+ ctrl.emit("run:end", { exitCode, duration: totalDuration });
132
+ if (signal.aborted)
133
+ break;
134
+ if (ctrl.options.maxRuns !== null && ctrl.runCount >= ctrl.options.maxRuns) {
135
+ ctrl._maxRunsReached = true;
136
+ ctrl._paused = true;
137
+ ctrl._status = "paused";
138
+ ctrl.remainingDelayMs = null;
139
+ ctrl.nextRunAt = null;
140
+ ctrl.emit("paused");
141
+ return;
142
+ }
143
+ if (ctrl._stopAfterRun) {
144
+ ctrl._stopAfterRun = false;
145
+ ctrl._paused = true;
146
+ ctrl._status = "idle";
147
+ ctrl.remainingDelayMs = null;
148
+ ctrl.nextRunAt = null;
149
+ ctrl.emit("stopped");
150
+ return;
151
+ }
152
+ const saved = ctrl._savedRemainingMs;
153
+ ctrl._savedRemainingMs = null;
154
+ if (saved !== null) {
155
+ const remaining = Math.max(0, saved - totalDuration);
156
+ if (remaining > 0) {
157
+ const completed = await waitForDelay(ctrl, remaining, signal);
158
+ if (!completed)
159
+ break;
160
+ }
161
+ }
162
+ else {
163
+ if (ctrl.options.interval === 0) {
164
+ ctrl._paused = true;
165
+ ctrl._status = "idle";
166
+ ctrl.remainingDelayMs = null;
167
+ ctrl.nextRunAt = null;
168
+ ctrl.emit("stopped");
169
+ return;
170
+ }
171
+ const nextSlotMs = runStartedAtMs + ctrl.options.interval;
172
+ const overrunMs = Date.now() - nextSlotMs;
173
+ if (overrunMs >= 0) {
174
+ const missed = Math.floor(overrunMs / ctrl.options.interval) + 1;
175
+ ctrl.skippedCount += missed;
176
+ const adjustedDelay = ctrl.options.interval - (overrunMs % ctrl.options.interval);
177
+ const completed = await waitForDelay(ctrl, adjustedDelay, signal);
178
+ if (!completed)
179
+ break;
180
+ }
181
+ else {
182
+ const completed = await waitForDelay(ctrl, ctrl.options.interval, signal);
183
+ if (!completed)
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ finally {
190
+ if (ctrl._status !== "idle" && ctrl._status !== "paused" && !ctrl._maxRunsReached) {
191
+ ctrl._status = "idle";
192
+ }
193
+ }
194
+ }