@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,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
|
+
}
|