@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,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import * as yaml from "js-yaml";
5
+ import { LoopController } from "../../core/loop/loop-controller.js";
6
+ import { validateRecipeFile } from "./validator.js";
7
+ import { remapRecipeIds } from "./id-remapper.js";
8
+ import { getLogPath } from "../state/index.js";
9
+ import { daemonLog } from "../daemon-log.js";
10
+ import { parseDuration } from "../../duration.js";
11
+ import { loadRecipeRuntimeState, saveRecipeRuntimeState } from "./runtime-state.js";
12
+ const RECIPE_EXTENSIONS = [".yaml", ".yml"];
13
+ const RECIPE_DIR = ".loops/recipes";
14
+ export function recipeLoopId(projectId, filePath) {
15
+ return `recipe-${crypto.createHash("sha1").update(`${projectId}:${path.resolve(filePath)}`).digest("hex").slice(0, 16)}`;
16
+ }
17
+ export class RecipeScanner {
18
+ constructor(recipeTaskStore) {
19
+ this.loopManager = null;
20
+ this.projectManager = null;
21
+ this.recipes = new Map();
22
+ this.recipeTaskStore = recipeTaskStore;
23
+ }
24
+ setManagers(loopManager, projectManager) {
25
+ this.loopManager = loopManager;
26
+ this.projectManager = projectManager;
27
+ }
28
+ getRecipeTaskStore() {
29
+ return this.recipeTaskStore;
30
+ }
31
+ getRecipe(recipeId) {
32
+ return this.recipes.get(recipeId);
33
+ }
34
+ listRecipes() {
35
+ return [...this.recipes.values()];
36
+ }
37
+ scanAllProjects() {
38
+ if (!this.projectManager)
39
+ return;
40
+ const projects = this.projectManager.getAll();
41
+ for (const project of projects) {
42
+ if (project.directory) {
43
+ this.scanDirectory(project.id, project.directory);
44
+ }
45
+ }
46
+ }
47
+ scanDirectory(projectId, projectDir) {
48
+ const recipesDir = path.join(projectDir, RECIPE_DIR);
49
+ if (!fs.existsSync(recipesDir))
50
+ return;
51
+ try {
52
+ const files = fs.readdirSync(recipesDir).filter((f) => RECIPE_EXTENSIONS.some((ext) => f.endsWith(ext)));
53
+ for (const file of files) {
54
+ const filePath = path.join(recipesDir, file);
55
+ this.loadRecipe(projectId, filePath, file);
56
+ }
57
+ }
58
+ catch (err) {
59
+ daemonLog(`error scanning recipes directory ${recipesDir}: ${String(err)}`);
60
+ }
61
+ }
62
+ loadRecipe(projectId, filePath, fileName) {
63
+ if (!this.loopManager)
64
+ return null;
65
+ const existing = this.findRecipeByPath(filePath);
66
+ if (existing) {
67
+ return existing.id;
68
+ }
69
+ let raw;
70
+ try {
71
+ raw = yaml.load(fs.readFileSync(filePath, "utf-8"));
72
+ }
73
+ catch (err) {
74
+ daemonLog(`error reading recipe file ${filePath}: ${String(err)}`);
75
+ return null;
76
+ }
77
+ const validation = validateRecipeFile(raw);
78
+ if (!validation.valid) {
79
+ daemonLog(`invalid recipe file ${filePath}: ${validation.error}`);
80
+ return null;
81
+ }
82
+ const recipe = validation.data;
83
+ const { loop: recipeLoop, tasks } = remapRecipeIds(recipe, projectId);
84
+ const id = recipeLoopId(projectId, filePath);
85
+ const taskIds = tasks.map((t) => t.id);
86
+ this.recipeTaskStore.setMany(tasks);
87
+ const projectDir = this.projectManager?.get(projectId)?.directory ?? "";
88
+ const intervalHuman = recipeLoop.intervalHuman ?? "0";
89
+ const interval = intervalHuman === "0" || intervalHuman === "manual" ? 0 : parseDuration(intervalHuman);
90
+ const options = {
91
+ interval,
92
+ taskId: recipeLoop.taskId ?? null,
93
+ command: recipeLoop.command ?? "",
94
+ commandArgs: recipeLoop.commandArgs ?? [],
95
+ cwd: projectDir,
96
+ immediate: recipeLoop.immediate ?? false,
97
+ maxRuns: recipeLoop.maxRuns ?? null,
98
+ verbose: recipeLoop.verbose ?? false,
99
+ description: recipeLoop.description ?? "",
100
+ projectId,
101
+ offset: recipeLoop.offset ?? null,
102
+ context: recipeLoop.context,
103
+ };
104
+ const taskResolver = (taskId) => this.recipeTaskStore.get(taskId) ?? null;
105
+ const logPath = getLogPath(id);
106
+ const controller = new LoopController(id, options, logPath, taskResolver, loadRecipeRuntimeState(id), projectDir);
107
+ const entry = { controller, options, intervalHuman };
108
+ this.loopManager.addRecipeLoop(id, entry, fileName, filePath, () => {
109
+ saveRecipeRuntimeState(id, controller.getMeta());
110
+ });
111
+ this.recipes.set(id, {
112
+ id,
113
+ projectId,
114
+ recipeFile: fileName,
115
+ filePath,
116
+ taskIds,
117
+ });
118
+ controller.start();
119
+ daemonLog(`loaded recipe loop ${id} from ${fileName} (project: ${projectId})`);
120
+ return id;
121
+ }
122
+ unloadRecipe(recipeId) {
123
+ if (!this.loopManager)
124
+ return;
125
+ const entry = this.recipes.get(recipeId);
126
+ if (!entry)
127
+ return;
128
+ this.loopManager.removeRecipeLoop(recipeId);
129
+ this.recipeTaskStore.deleteMany(entry.taskIds);
130
+ this.recipes.delete(recipeId);
131
+ daemonLog(`unloaded recipe loop ${recipeId} from ${entry.recipeFile}`);
132
+ }
133
+ reloadRecipe(recipeId) {
134
+ const entry = this.recipes.get(recipeId);
135
+ if (!entry)
136
+ return;
137
+ this.unloadRecipe(recipeId);
138
+ this.loadRecipe(entry.projectId, entry.filePath, entry.recipeFile);
139
+ }
140
+ unloadRecipesForProject(projectId) {
141
+ const toRemove = [];
142
+ for (const [id, entry] of this.recipes) {
143
+ if (entry.projectId === projectId) {
144
+ toRemove.push(id);
145
+ }
146
+ }
147
+ for (const id of toRemove) {
148
+ this.unloadRecipe(id);
149
+ }
150
+ }
151
+ findRecipeByPath(filePath) {
152
+ for (const entry of this.recipes.values()) {
153
+ if (entry.filePath === filePath)
154
+ return entry;
155
+ }
156
+ return undefined;
157
+ }
158
+ findRecipeByFileName(projectId, fileName) {
159
+ for (const entry of this.recipes.values()) {
160
+ if (entry.projectId === projectId && entry.recipeFile === fileName)
161
+ return entry;
162
+ }
163
+ return undefined;
164
+ }
165
+ }
@@ -0,0 +1,33 @@
1
+ export class RecipeTaskStore {
2
+ constructor() {
3
+ this.tasks = new Map();
4
+ }
5
+ get(id) {
6
+ return this.tasks.get(id) ?? null;
7
+ }
8
+ list() {
9
+ return [...this.tasks.values()];
10
+ }
11
+ set(task) {
12
+ this.tasks.set(task.id, task);
13
+ }
14
+ setMany(tasks) {
15
+ for (const task of tasks) {
16
+ this.tasks.set(task.id, task);
17
+ }
18
+ }
19
+ delete(id) {
20
+ return this.tasks.delete(id);
21
+ }
22
+ deleteMany(ids) {
23
+ for (const id of ids) {
24
+ this.tasks.delete(id);
25
+ }
26
+ }
27
+ clear() {
28
+ this.tasks.clear();
29
+ }
30
+ has(id) {
31
+ return this.tasks.has(id);
32
+ }
33
+ }
@@ -0,0 +1,35 @@
1
+ export function validateRecipeFile(raw) {
2
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3
+ return { valid: false, error: "Recipe file must be a mapping (root object)" };
4
+ }
5
+ const data = raw;
6
+ if (!("version" in data)) {
7
+ return { valid: false, error: "Missing export version. Expected: 2" };
8
+ }
9
+ if (data.version !== 2) {
10
+ return { valid: false, error: `Unsupported export version: ${data.version}. Expected: 2` };
11
+ }
12
+ if (!("loops" in data) || !Array.isArray(data.loops)) {
13
+ return { valid: false, error: "Missing or invalid 'loops' array" };
14
+ }
15
+ if (data.loops.length === 0) {
16
+ return { valid: false, error: "Recipe file must have exactly one loop in 'loops' array" };
17
+ }
18
+ if (data.loops.length > 1) {
19
+ return { valid: false, error: "Recipe file must have exactly one loop, found " + data.loops.length };
20
+ }
21
+ if (!("tasks" in data) || !Array.isArray(data.tasks)) {
22
+ return { valid: false, error: "Missing or invalid 'tasks' array" };
23
+ }
24
+ for (let i = 0; i < data.tasks.length; i++) {
25
+ const task = data.tasks[i];
26
+ if (typeof task !== "object" || task === null) {
27
+ return { valid: false, error: `tasks[${i}]: must be an object` };
28
+ }
29
+ const record = task;
30
+ if (!record.id || typeof record.id !== "string") {
31
+ return { valid: false, error: `tasks[${i}]: missing or invalid 'id' field` };
32
+ }
33
+ }
34
+ return { valid: true, data: data };
35
+ }
@@ -0,0 +1,7 @@
1
+ import { send } from "../../ipc/send.js";
2
+ import { collectDiagnostics, isDiagnosticsEnabled } from "../../diagnostics.js";
3
+ export function handleDiagnostics(_request, socket, ctx) {
4
+ const extended = isDiagnosticsEnabled();
5
+ const diag = collectDiagnostics(ctx.manager, extended);
6
+ send(socket, { type: "ok", data: diag });
7
+ }
@@ -0,0 +1,101 @@
1
+ import { handleStart, handleUpdate, handleList, handleStatus, handlePause, handleResume, handleStopLoop, handleStopAll, handlePlayLoop, handleTrigger, handleDelete, } from "./loop-handlers.js";
2
+ import { handleRunLog, handleRunLogStream, handleLogs, } from "./log-handlers.js";
3
+ import { handleTaskCreate, handleTaskUpdate, handleTaskList, handleTaskGet, handleTaskDelete, } from "./task-handlers.js";
4
+ import { handleProjectList, handleProjectCreate, handleProjectUpdate, handleProjectDelete, } from "./project-handlers.js";
5
+ import { handleSettingsGet, handleSettingsSet, } from "./settings-handlers.js";
6
+ import { handleDiagnostics } from "./diagnostics-handlers.js";
7
+ import { handleTelemetryTest } from "./telemetry-handlers.js";
8
+ export { handleStart, handleUpdate, handleList, handleStatus, handlePause, handleResume, handleStopLoop, handleStopAll, handlePlayLoop, handleTrigger, handleDelete, handleRunLog, handleRunLogStream, handleLogs, handleTaskCreate, handleTaskUpdate, handleTaskList, handleTaskGet, handleTaskDelete, handleProjectList, handleProjectCreate, handleProjectUpdate, handleProjectDelete, handleSettingsGet, handleSettingsSet, handleDiagnostics, handleTelemetryTest, };
9
+ /**
10
+ * Dispatch an incoming IPC request to the appropriate handler.
11
+ * Returns true if the request was handled, false otherwise.
12
+ */
13
+ export async function dispatch(request, socket, ctx) {
14
+ switch (request.type) {
15
+ case "start":
16
+ handleStart(request, socket, ctx);
17
+ break;
18
+ case "update":
19
+ await handleUpdate(request, socket, ctx);
20
+ break;
21
+ case "list":
22
+ handleList(request, socket, ctx);
23
+ break;
24
+ case "status":
25
+ handleStatus(request, socket, ctx);
26
+ break;
27
+ case "pause":
28
+ handlePause(request, socket, ctx);
29
+ break;
30
+ case "resume":
31
+ handleResume(request, socket, ctx);
32
+ break;
33
+ case "stop-loop":
34
+ await handleStopLoop(request, socket, ctx);
35
+ break;
36
+ case "stop-all":
37
+ await handleStopAll(request, socket, ctx);
38
+ break;
39
+ case "play-loop":
40
+ handlePlayLoop(request, socket, ctx);
41
+ break;
42
+ case "trigger":
43
+ handleTrigger(request, socket, ctx);
44
+ break;
45
+ case "delete":
46
+ await handleDelete(request, socket, ctx);
47
+ break;
48
+ case "run-log":
49
+ handleRunLog(request, socket, ctx);
50
+ break;
51
+ case "run-log-stream":
52
+ handleRunLogStream(request, socket, ctx);
53
+ break;
54
+ case "attach":
55
+ case "logs":
56
+ handleLogs(request, socket, ctx);
57
+ break;
58
+ case "task-create":
59
+ handleTaskCreate(request, socket, ctx);
60
+ break;
61
+ case "task-update":
62
+ handleTaskUpdate(request, socket, ctx);
63
+ break;
64
+ case "task-list":
65
+ handleTaskList(request, socket, ctx);
66
+ break;
67
+ case "task-get":
68
+ handleTaskGet(request, socket, ctx);
69
+ break;
70
+ case "task-delete":
71
+ handleTaskDelete(request, socket, ctx);
72
+ break;
73
+ case "project-list":
74
+ handleProjectList(request, socket, ctx);
75
+ break;
76
+ case "project-create":
77
+ handleProjectCreate(request, socket, ctx);
78
+ break;
79
+ case "project-update":
80
+ handleProjectUpdate(request, socket, ctx);
81
+ break;
82
+ case "project-delete":
83
+ handleProjectDelete(request, socket, ctx);
84
+ break;
85
+ case "settings-get":
86
+ await handleSettingsGet(request, socket, ctx.settingsManager);
87
+ break;
88
+ case "settings-set":
89
+ await handleSettingsSet(request, socket, ctx.settingsManager);
90
+ break;
91
+ case "diagnostics":
92
+ handleDiagnostics(request, socket, ctx);
93
+ break;
94
+ case "telemetry-test":
95
+ handleTelemetryTest(request, socket, ctx.telemetryManager);
96
+ break;
97
+ default:
98
+ return false;
99
+ }
100
+ return true;
101
+ }
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import { send } from "../../ipc/send.js";
3
+ import { t } from "../../../shared/i18n/index.js";
4
+ import { LOG_TAIL_DEFAULT, MAX_STREAM_INITIAL_BYTES } from "../../../shared/config/constants.js";
5
+ import { tailFileBounded, readByteRange } from "../../../core/logging/bounded-log-reader.js";
6
+ import { followLogFile } from "../../../core/logging/log-follower.js";
7
+ function ipcLine(line) {
8
+ return JSON.stringify({ type: "data", line }) + "\n";
9
+ }
10
+ export function handleRunLog(request, socket, ctx) {
11
+ const { id, runNumber } = request.payload;
12
+ const logPath = ctx.manager.getLogPath(id);
13
+ if (!logPath || !fs.existsSync(logPath)) {
14
+ send(socket, { type: "ok", data: "" });
15
+ return;
16
+ }
17
+ const meta = ctx.manager.status(id);
18
+ if (!meta) {
19
+ send(socket, { type: "ok", data: "" });
20
+ return;
21
+ }
22
+ const records = meta.runHistory
23
+ .filter((r) => r.runNumber === runNumber)
24
+ .sort((a, b) => a.logOffset - b.logOffset);
25
+ if (records.length === 0) {
26
+ send(socket, { type: "ok", data: "" });
27
+ return;
28
+ }
29
+ const allSorted = meta.runHistory.slice().sort((a, b) => a.logOffset - b.logOffset);
30
+ const parts = records.map((record) => {
31
+ const start = record.logOffset;
32
+ const idx = allSorted.indexOf(record);
33
+ const end = idx < allSorted.length - 1 ? allSorted[idx + 1].logOffset : undefined;
34
+ return readByteRange(logPath, start, end);
35
+ });
36
+ send(socket, { type: "ok", data: parts.join("") });
37
+ }
38
+ export function handleRunLogStream(request, socket, ctx) {
39
+ const { id, runNumber } = request.payload;
40
+ const meta = ctx.manager.status(id);
41
+ if (!meta) {
42
+ send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
43
+ return;
44
+ }
45
+ const records = meta.runHistory
46
+ .filter((r) => r.runNumber === runNumber)
47
+ .sort((a, b) => a.logOffset - b.logOffset);
48
+ if (records.length === 0) {
49
+ send(socket, { type: "ok", data: "" });
50
+ return;
51
+ }
52
+ const logPath = ctx.manager.getLogPath(id);
53
+ if (!logPath || !fs.existsSync(logPath)) {
54
+ send(socket, { type: "ok", data: "" });
55
+ return;
56
+ }
57
+ const firstOffset = records[0].logOffset;
58
+ const stat = fs.statSync(logPath);
59
+ if (stat.size > firstOffset) {
60
+ const data = readByteRange(logPath, firstOffset, Math.min(stat.size, firstOffset + MAX_STREAM_INITIAL_BYTES));
61
+ for (const line of data.split("\n")) {
62
+ if (line) {
63
+ send(socket, { type: "data", line });
64
+ }
65
+ }
66
+ }
67
+ const allCompleted = records.every((r) => r.status === "completed");
68
+ if (allCompleted) {
69
+ send(socket, { type: "end" });
70
+ return;
71
+ }
72
+ const watcher = followLogFile({
73
+ logPath,
74
+ initialOffset: stat.size,
75
+ dest: socket,
76
+ formatLine: ipcLine,
77
+ onEnd: () => send(socket, { type: "end" }),
78
+ onError: () => send(socket, { type: "end" }),
79
+ });
80
+ socket.on("close", () => watcher.close());
81
+ socket.on("error", () => watcher.close());
82
+ }
83
+ export function handleLogs(request, socket, ctx) {
84
+ const id = request.payload.id;
85
+ const logPath = ctx.manager.getLogPath(id);
86
+ if (!logPath) {
87
+ send(socket, { type: "error", message: t("errors.loopNotFound", { id }) });
88
+ return;
89
+ }
90
+ if (!fs.existsSync(logPath)) {
91
+ if (request.type === "logs") {
92
+ send(socket, { type: "ok", data: "" });
93
+ }
94
+ else {
95
+ send(socket, { type: "error", message: t("errors.noLogsYet") });
96
+ }
97
+ return;
98
+ }
99
+ const follow = request.type === "attach" || (request.type === "logs" && request.payload.follow);
100
+ const tailCount = request.type === "logs" ? (request.payload.tail ?? LOG_TAIL_DEFAULT) : 0;
101
+ if (follow) {
102
+ streamLogFollowBounded(logPath, socket, tailCount);
103
+ }
104
+ else {
105
+ const lines = tailFileBounded(logPath, tailCount);
106
+ send(socket, { type: "ok", data: lines.join("\n") });
107
+ }
108
+ }
109
+ function streamLogFollowBounded(logPath, socket, tailCount) {
110
+ if (fs.existsSync(logPath)) {
111
+ const lines = tailFileBounded(logPath, tailCount);
112
+ for (const line of lines) {
113
+ if (line) {
114
+ send(socket, { type: "data", line });
115
+ }
116
+ }
117
+ }
118
+ const initialOffset = fs.existsSync(logPath) ? fs.statSync(logPath).size : 0;
119
+ const watcher = followLogFile({
120
+ logPath,
121
+ initialOffset,
122
+ dest: socket,
123
+ formatLine: ipcLine,
124
+ onEnd: () => send(socket, { type: "end" }),
125
+ onError: () => send(socket, { type: "end" }),
126
+ });
127
+ socket.on("close", () => watcher.close());
128
+ socket.on("error", () => watcher.close());
129
+ }
@@ -0,0 +1,64 @@
1
+ import { send } from "../../ipc/send.js";
2
+ import { t } from "../../../shared/i18n/index.js";
3
+ export function handleStart(request, socket, ctx) {
4
+ const { intervalHuman, ...options } = request.payload;
5
+ const id = ctx.manager.start(options, intervalHuman);
6
+ send(socket, { type: "ok", data: { id } });
7
+ }
8
+ export async function handleUpdate(request, socket, ctx) {
9
+ const { id, intervalHuman, ...options } = request.payload;
10
+ try {
11
+ const ok = await ctx.manager.update(id, options, intervalHuman);
12
+ ctx.respondOk(socket, ok, id, ok ? { id } : undefined);
13
+ }
14
+ catch (err) {
15
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
16
+ }
17
+ }
18
+ export function handleList(_request, socket, ctx) {
19
+ send(socket, { type: "ok", data: ctx.manager.list() });
20
+ }
21
+ export function handleStatus(request, socket, ctx) {
22
+ const meta = ctx.manager.status(request.payload.id);
23
+ ctx.respondOk(socket, meta !== null, request.payload.id, meta ?? undefined);
24
+ }
25
+ export function handlePause(request, socket, ctx) {
26
+ ctx.respondOk(socket, ctx.manager.pause(request.payload.id), request.payload.id);
27
+ }
28
+ export function handleResume(request, socket, ctx) {
29
+ ctx.respondOk(socket, ctx.manager.resume(request.payload.id), request.payload.id);
30
+ }
31
+ export async function handleStopLoop(request, socket, ctx) {
32
+ ctx.respondOk(socket, await ctx.manager.stopLoop(request.payload.id), request.payload.id);
33
+ }
34
+ export async function handleStopAll(_request, socket, ctx) {
35
+ const count = await ctx.manager.stopAllLoops();
36
+ send(socket, { type: "ok", data: count });
37
+ }
38
+ export function handlePlayLoop(request, socket, ctx) {
39
+ if (ctx.manager.isMaxRunsBlocked(request.payload.id)) {
40
+ send(socket, { type: "error", message: t("errors.maxRunsReached") });
41
+ return;
42
+ }
43
+ ctx.respondOk(socket, ctx.manager.playLoop(request.payload.id), request.payload.id);
44
+ }
45
+ export function handleTrigger(request, socket, ctx) {
46
+ if (ctx.manager.isMaxRunsBlocked(request.payload.id)) {
47
+ send(socket, { type: "error", message: t("errors.maxRunsReached") });
48
+ return;
49
+ }
50
+ if (ctx.manager.isRunning(request.payload.id)) {
51
+ send(socket, { type: "error", message: t("errors.triggerWhileRunning") });
52
+ return;
53
+ }
54
+ ctx.respondOk(socket, ctx.manager.trigger(request.payload.id), request.payload.id);
55
+ }
56
+ export async function handleDelete(request, socket, ctx) {
57
+ try {
58
+ const ok = await ctx.manager.delete(request.payload.id);
59
+ ctx.respondOk(socket, ok, request.payload.id);
60
+ }
61
+ catch (err) {
62
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ import { send } from "../../ipc/send.js";
2
+ import { t } from "../../../shared/i18n/index.js";
3
+ export function handleProjectList(_request, socket, ctx) {
4
+ send(socket, { type: "ok", data: ctx.manager.listProjects() });
5
+ }
6
+ export function handleProjectCreate(request, socket, ctx) {
7
+ const { name, color, directory, githubSource } = request.payload;
8
+ if (!name || !name.trim()) {
9
+ send(socket, { type: "error", message: t("project.error.nameRequired") });
10
+ return;
11
+ }
12
+ try {
13
+ const project = ctx.manager.createProject(name.trim(), color, directory, githubSource);
14
+ send(socket, { type: "ok", data: project });
15
+ }
16
+ catch (err) {
17
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
18
+ }
19
+ }
20
+ export function handleProjectUpdate(request, socket, ctx) {
21
+ const { id, name, color, directory, githubSource } = request.payload;
22
+ if (!name || !name.trim()) {
23
+ send(socket, { type: "error", message: t("project.error.nameEmpty") });
24
+ return;
25
+ }
26
+ try {
27
+ ctx.manager.updateProject(id, name.trim(), color, directory, githubSource);
28
+ send(socket, { type: "ok" });
29
+ }
30
+ catch (err) {
31
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
32
+ }
33
+ }
34
+ export function handleProjectDelete(request, socket, ctx) {
35
+ try {
36
+ ctx.manager.deleteProject(request.payload.id);
37
+ send(socket, { type: "ok" });
38
+ }
39
+ catch (err) {
40
+ send(socket, { type: "error", message: err instanceof Error ? err.message : String(err) });
41
+ }
42
+ }
@@ -0,0 +1,8 @@
1
+ import { send } from "../../ipc/send.js";
2
+ export async function handleSettingsGet(_request, socket, settingsManager) {
3
+ send(socket, { type: "ok", data: settingsManager.get() });
4
+ }
5
+ export async function handleSettingsSet(request, socket, settingsManager) {
6
+ const updated = settingsManager.set(request.settings);
7
+ send(socket, { type: "ok", data: updated });
8
+ }
@@ -0,0 +1,20 @@
1
+ import { send } from "../../ipc/send.js";
2
+ export function handleTaskCreate(request, socket, ctx) {
3
+ const task = ctx.taskManager.create(request.payload);
4
+ send(socket, { type: "ok", data: task });
5
+ }
6
+ export function handleTaskUpdate(request, socket, ctx) {
7
+ const updated = ctx.taskManager.update(request.payload.id, request.payload);
8
+ ctx.respondOk(socket, updated !== null, request.payload.id, updated ?? undefined);
9
+ }
10
+ export function handleTaskList(_request, socket, ctx) {
11
+ send(socket, { type: "ok", data: ctx.taskManager.list() });
12
+ }
13
+ export function handleTaskGet(request, socket, ctx) {
14
+ const task = ctx.taskManager.get(request.payload.id);
15
+ ctx.respondOk(socket, task !== null, request.payload.id, task ?? undefined);
16
+ }
17
+ export function handleTaskDelete(request, socket, ctx) {
18
+ const ok = ctx.taskManager.delete(request.payload.id);
19
+ ctx.respondOk(socket, ok, request.payload.id);
20
+ }
@@ -0,0 +1,5 @@
1
+ import { send } from "../../ipc/send.js";
2
+ export async function handleTelemetryTest(_request, socket, telemetryManager) {
3
+ const result = await telemetryManager.testConnection();
4
+ send(socket, { type: "ok", data: result });
5
+ }