loop-task 1.5.5 → 2.0.1
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/README.md +127 -21
- package/dist/board/App.js +1 -1
- package/dist/board/components/ActionButtons.js +1 -1
- package/dist/board/components/BoardButton.js +1 -1
- package/dist/board/components/ConfirmModal.js +1 -1
- package/dist/board/components/ContextHelpModal.js +1 -1
- package/dist/board/components/CreateForm.js +1 -1
- package/dist/board/components/CreateProjectModal.js +1 -1
- package/dist/board/components/DeleteProjectConfirm.js +1 -1
- package/dist/board/components/DetailView.js +1 -1
- package/dist/board/components/EditProjectModal.js +1 -1
- package/dist/board/components/FilterBar.js +1 -1
- package/dist/board/components/Footer.js +1 -1
- package/dist/board/components/Header.js +1 -1
- package/dist/board/components/HelpModal.js +1 -1
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +1 -1
- package/dist/board/components/Navigator.js +1 -1
- package/dist/board/components/ProjectsModal.js +1 -1
- package/dist/board/components/ProjectsPage.js +1 -1
- package/dist/board/components/RunHistory.js +1 -1
- package/dist/board/components/SearchBox.js +1 -1
- package/dist/board/components/SearchSelect.js +1 -1
- package/dist/board/components/TaskBrowser.js +1 -1
- package/dist/board/components/TaskFilterBar.js +1 -1
- package/dist/board/components/TaskForm.js +1 -1
- package/dist/board/components/Timeline.js +1 -1
- package/dist/board/focus-context.js +1 -1
- package/dist/board/format.js +15 -6
- package/dist/board/index.js +1 -1
- package/dist/board/toast.js +1 -1
- package/dist/cli/import-validator.js +151 -0
- package/dist/cli/import-writer.js +77 -0
- package/dist/cli.js +111 -17
- package/dist/config/constants.js +52 -0
- package/dist/daemon/file-watcher.js +171 -0
- package/dist/daemon/http-server.js +605 -0
- package/dist/daemon/index.js +36 -6
- package/dist/daemon/manager.js +94 -13
- package/dist/daemon/projects.js +10 -0
- package/dist/daemon/server.js +21 -0
- package/dist/daemon/task-manager.js +7 -0
- package/dist/esm-loader.js +4 -1
- package/dist/hooks/useLoopFormValidation.js +140 -0
- package/dist/i18n/en.json +237 -17
- package/dist/shared/fs-utils.js +34 -1
- package/dist/tui/app.js +675 -151
- package/dist/tui/commands.js +114 -0
- package/dist/tui/components/ActionButtons.js +49 -0
- package/dist/tui/components/Button.js +18 -0
- package/dist/tui/components/ChainEditor.js +112 -0
- package/dist/tui/components/CommandBuilderField.js +143 -0
- package/dist/tui/components/CommandEditorModal.js +159 -0
- package/dist/tui/components/CommandInput.js +303 -0
- package/dist/tui/components/CommandsBrowserModal.js +77 -0
- package/dist/tui/components/ConfirmModal.js +9 -0
- package/dist/tui/components/ContextHelpModal.js +8 -0
- package/dist/tui/components/CreateForm.js +223 -0
- package/dist/tui/components/DebugPanel.js +8 -0
- package/dist/tui/components/ExportModal.js +37 -0
- package/dist/tui/components/FilterBar.js +13 -0
- package/dist/tui/components/FocusableButton.js +16 -0
- package/dist/tui/components/FocusableInput.js +69 -0
- package/dist/tui/components/FocusableList.js +46 -0
- package/dist/tui/components/FocusableSearchSelect.js +82 -0
- package/dist/tui/components/Footer.js +42 -0
- package/dist/tui/components/Header.js +40 -0
- package/dist/tui/components/HelpGuideModal.js +21 -0
- package/dist/tui/components/HelpModal.js +59 -0
- package/dist/tui/components/InlineCommandEditor.js +143 -0
- package/dist/tui/components/Inspector.js +31 -0
- package/dist/tui/components/LeftPanel.js +33 -0
- package/dist/tui/components/LogModal.js +121 -0
- package/dist/tui/components/Modal.js +11 -0
- package/dist/tui/components/Navigator.js +68 -0
- package/dist/tui/components/PatchEditForm.js +78 -0
- package/dist/tui/components/ProjectForm.js +64 -0
- package/dist/tui/components/ProjectsModal.js +40 -0
- package/dist/tui/components/ProjectsPage.js +99 -0
- package/dist/tui/components/RightPanel.js +34 -0
- package/dist/tui/components/RunHistory.js +108 -0
- package/dist/tui/components/SearchSelect.js +67 -0
- package/dist/tui/components/SelectModal.js +76 -0
- package/dist/tui/components/TabBar.js +20 -0
- package/dist/tui/components/TaskBrowser.js +96 -0
- package/dist/tui/components/TaskFilterBar.js +9 -0
- package/dist/tui/components/TaskForm.js +134 -0
- package/dist/tui/components/TaskPickerModal.js +60 -0
- package/dist/tui/components/Toast.js +37 -0
- package/dist/tui/components/WelcomeScreen.js +70 -0
- package/dist/tui/components/WizardForm.js +178 -0
- package/dist/tui/daemon.js +142 -0
- package/dist/tui/format.js +111 -0
- package/dist/tui/hooks/useBreakpoint.js +7 -0
- package/dist/tui/hooks/useHoverState.js +11 -0
- package/dist/tui/hooks/useLogStream.js +32 -0
- package/dist/tui/hooks/useLoopPolling.js +28 -0
- package/dist/tui/index.js +24 -0
- package/dist/tui/router.js +16 -0
- package/dist/tui/state.js +175 -0
- package/dist/tui/theme.js +96 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/utils/paste.js +10 -0
- package/dist/tui/utils/validation.js +56 -0
- package/package.json +13 -8
package/dist/daemon/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LoopManager } from "./manager.js";
|
|
2
2
|
import { TaskManager } from "./task-manager.js";
|
|
3
3
|
import { IpcServer } from "./server.js";
|
|
4
|
+
import { HttpApiServer } from "./http-server.js";
|
|
5
|
+
import { FileWatcher } from "./file-watcher.js";
|
|
4
6
|
import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, } from "./state.js";
|
|
5
7
|
import { t } from "../i18n/index.js";
|
|
6
8
|
import { daemonLog } from "./daemon-log.js";
|
|
@@ -11,6 +13,8 @@ async function main() {
|
|
|
11
13
|
taskManager.init();
|
|
12
14
|
const manager = new LoopManager(taskManager);
|
|
13
15
|
const server = new IpcServer(manager, taskManager);
|
|
16
|
+
const projectManager = manager.projectManager;
|
|
17
|
+
const httpServer = new HttpApiServer(manager, taskManager, projectManager);
|
|
14
18
|
try {
|
|
15
19
|
await server.listen();
|
|
16
20
|
}
|
|
@@ -18,24 +22,50 @@ async function main() {
|
|
|
18
22
|
daemonLog(`listen failed (another daemon already holds the socket): ${String(err)}`);
|
|
19
23
|
process.exit(0);
|
|
20
24
|
}
|
|
25
|
+
try {
|
|
26
|
+
const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
|
|
27
|
+
await httpServer.listen(Number.isNaN(httpPort) ? undefined : httpPort);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
daemonLog(`HTTP API server failed to start: ${String(err)}`);
|
|
31
|
+
}
|
|
21
32
|
manager.init();
|
|
22
33
|
writeDaemonPid(process.pid);
|
|
23
34
|
writeDaemonSignature(computeCodeSignature());
|
|
24
35
|
daemonLog(`started pid=${process.pid}`);
|
|
36
|
+
const fileWatcher = new FileWatcher();
|
|
37
|
+
fileWatcher.setManagers(manager, taskManager, manager["projectManager"]);
|
|
38
|
+
fileWatcher.start();
|
|
39
|
+
daemonLog(`file watcher started for hot-reloading JSON configs`);
|
|
40
|
+
let shuttingDown = false;
|
|
25
41
|
const cleanup = async () => {
|
|
42
|
+
// Re-entry guard: a persist failure during shutdown must not re-trigger
|
|
43
|
+
// cleanup via uncaughtException, or the daemon crash-loops forever.
|
|
44
|
+
if (shuttingDown)
|
|
45
|
+
return;
|
|
46
|
+
shuttingDown = true;
|
|
26
47
|
daemonLog(`shutting down pid=${process.pid}`);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
try {
|
|
49
|
+
fileWatcher.stop();
|
|
50
|
+
removeDaemonPid();
|
|
51
|
+
removeDaemonSignature();
|
|
52
|
+
await manager.shutdown();
|
|
53
|
+
await server.close();
|
|
54
|
+
await httpServer.close();
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
daemonLog(`error during shutdown: ${String(err)}`);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
32
62
|
};
|
|
33
63
|
process.on("SIGINT", cleanup);
|
|
34
64
|
process.on("SIGTERM", cleanup);
|
|
35
65
|
process.on("uncaughtException", (err) => {
|
|
36
66
|
daemonLog(`uncaught exception: ${String(err)}`);
|
|
37
67
|
console.error(t("errors.daemonUncaught"), err);
|
|
38
|
-
cleanup();
|
|
68
|
+
void cleanup();
|
|
39
69
|
});
|
|
40
70
|
}
|
|
41
71
|
main().catch((err) => {
|
package/dist/daemon/manager.js
CHANGED
|
@@ -18,26 +18,18 @@ export class LoopManager {
|
|
|
18
18
|
this.projectManager.init();
|
|
19
19
|
const saved = loadAllLoops();
|
|
20
20
|
let restarted = 0;
|
|
21
|
-
let migrated = 0;
|
|
22
21
|
const shouldAutoStart = (s) => s !== "stopped" && s !== "idle";
|
|
23
22
|
for (const meta of saved) {
|
|
24
23
|
if (!meta.projectId) {
|
|
25
24
|
meta.projectId = "default";
|
|
26
25
|
saveLoop(meta);
|
|
27
26
|
}
|
|
28
|
-
let taskId = meta.taskId;
|
|
29
|
-
if (!taskId && meta.command) {
|
|
30
|
-
const task = this.taskManager.createInline(meta.command, meta.commandArgs);
|
|
31
|
-
taskId = task.id;
|
|
32
|
-
meta.taskId = taskId;
|
|
33
|
-
saveLoop(meta);
|
|
34
|
-
migrated += 1;
|
|
35
|
-
}
|
|
36
27
|
const options = {
|
|
37
28
|
interval: meta.interval,
|
|
38
|
-
taskId: taskId
|
|
29
|
+
taskId: meta.taskId,
|
|
39
30
|
command: meta.command,
|
|
40
31
|
commandArgs: meta.commandArgs,
|
|
32
|
+
commandRaw: meta.commandRaw,
|
|
41
33
|
cwd: meta.cwd ?? "",
|
|
42
34
|
immediate: false,
|
|
43
35
|
maxRuns: meta.maxRuns,
|
|
@@ -70,9 +62,6 @@ export class LoopManager {
|
|
|
70
62
|
restarted += 1;
|
|
71
63
|
}
|
|
72
64
|
}
|
|
73
|
-
if (migrated > 0) {
|
|
74
|
-
daemonLog(`migrated ${migrated} loop(s) to task model`);
|
|
75
|
-
}
|
|
76
65
|
if (restarted > 0) {
|
|
77
66
|
daemonLog(`restarted ${restarted} loop(s) from persisted state`);
|
|
78
67
|
}
|
|
@@ -93,6 +82,8 @@ export class LoopManager {
|
|
|
93
82
|
return false;
|
|
94
83
|
const executionChanged = entry.options.interval !== options.interval ||
|
|
95
84
|
entry.options.taskId !== options.taskId ||
|
|
85
|
+
entry.options.command !== options.command ||
|
|
86
|
+
entry.options.commandArgs.join(" ") !== options.commandArgs.join(" ") ||
|
|
96
87
|
entry.options.immediate !== options.immediate ||
|
|
97
88
|
entry.options.maxRuns !== options.maxRuns ||
|
|
98
89
|
entry.options.verbose !== options.verbose;
|
|
@@ -226,6 +217,95 @@ export class LoopManager {
|
|
|
226
217
|
await Promise.all(stops);
|
|
227
218
|
this.loops.clear();
|
|
228
219
|
}
|
|
220
|
+
reconcile(newLoops) {
|
|
221
|
+
const newIds = new Set(newLoops.map((l) => l.id));
|
|
222
|
+
const shouldAutoStart = (s) => s !== "stopped" && s !== "idle";
|
|
223
|
+
for (const [existingId, entry] of this.loops) {
|
|
224
|
+
if (!newIds.has(existingId)) {
|
|
225
|
+
void entry.controller.stop().then(() => {
|
|
226
|
+
this.loops.delete(existingId);
|
|
227
|
+
this.lastSerialized.delete(existingId);
|
|
228
|
+
deleteLoopState(existingId);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const meta of newLoops) {
|
|
233
|
+
const existing = this.loops.get(meta.id);
|
|
234
|
+
if (existing) {
|
|
235
|
+
const configChanged = existing.options.interval !== meta.interval ||
|
|
236
|
+
existing.options.command !== meta.command ||
|
|
237
|
+
existing.options.cwd !== (meta.cwd ?? "");
|
|
238
|
+
if (configChanged) {
|
|
239
|
+
void existing.controller.stop().then(() => {
|
|
240
|
+
const options = {
|
|
241
|
+
interval: meta.interval,
|
|
242
|
+
taskId: meta.taskId ?? null,
|
|
243
|
+
command: meta.command,
|
|
244
|
+
commandArgs: meta.commandArgs,
|
|
245
|
+
cwd: meta.cwd ?? "",
|
|
246
|
+
immediate: false,
|
|
247
|
+
maxRuns: meta.maxRuns,
|
|
248
|
+
verbose: meta.verbose,
|
|
249
|
+
description: meta.description ?? "",
|
|
250
|
+
projectId: meta.projectId ?? "default",
|
|
251
|
+
offset: meta.offset ?? null,
|
|
252
|
+
};
|
|
253
|
+
const logPath = getLogPath(meta.id);
|
|
254
|
+
const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
|
|
255
|
+
status: meta.status,
|
|
256
|
+
createdAt: meta.createdAt,
|
|
257
|
+
runCount: meta.runCount,
|
|
258
|
+
sessionStartedAt: meta.sessionStartedAt,
|
|
259
|
+
lastRunAt: meta.lastRunAt,
|
|
260
|
+
lastExitCode: meta.lastExitCode,
|
|
261
|
+
lastDuration: meta.lastDuration,
|
|
262
|
+
nextRunAt: meta.nextRunAt,
|
|
263
|
+
remainingDelayMs: meta.remainingDelayMs,
|
|
264
|
+
runHistory: meta.runHistory,
|
|
265
|
+
});
|
|
266
|
+
this.loops.set(meta.id, { controller, options, intervalHuman: meta.intervalHuman });
|
|
267
|
+
this.wireEvents(meta.id, controller, options, meta.intervalHuman);
|
|
268
|
+
if (shouldAutoStart(meta.status)) {
|
|
269
|
+
controller.start();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
const options = {
|
|
276
|
+
interval: meta.interval,
|
|
277
|
+
taskId: meta.taskId ?? null,
|
|
278
|
+
command: meta.command,
|
|
279
|
+
commandArgs: meta.commandArgs,
|
|
280
|
+
cwd: meta.cwd ?? "",
|
|
281
|
+
immediate: false,
|
|
282
|
+
maxRuns: meta.maxRuns,
|
|
283
|
+
verbose: meta.verbose,
|
|
284
|
+
description: meta.description ?? "",
|
|
285
|
+
projectId: meta.projectId ?? "default",
|
|
286
|
+
offset: meta.offset ?? null,
|
|
287
|
+
};
|
|
288
|
+
const logPath = getLogPath(meta.id);
|
|
289
|
+
const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
|
|
290
|
+
status: meta.status,
|
|
291
|
+
createdAt: meta.createdAt,
|
|
292
|
+
runCount: meta.runCount,
|
|
293
|
+
sessionStartedAt: meta.sessionStartedAt,
|
|
294
|
+
lastRunAt: meta.lastRunAt,
|
|
295
|
+
lastExitCode: meta.lastExitCode,
|
|
296
|
+
lastDuration: meta.lastDuration,
|
|
297
|
+
nextRunAt: meta.nextRunAt,
|
|
298
|
+
remainingDelayMs: meta.remainingDelayMs,
|
|
299
|
+
runHistory: meta.runHistory,
|
|
300
|
+
});
|
|
301
|
+
this.loops.set(meta.id, { controller, options, intervalHuman: meta.intervalHuman });
|
|
302
|
+
this.wireEvents(meta.id, controller, options, meta.intervalHuman);
|
|
303
|
+
if (shouldAutoStart(meta.status)) {
|
|
304
|
+
controller.start();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
229
309
|
wireEvents(id, controller, options, intervalHuman) {
|
|
230
310
|
const persist = () => this.persist(id, controller, options, intervalHuman);
|
|
231
311
|
controller.on("run:start", persist);
|
|
@@ -255,6 +335,7 @@ export class LoopManager {
|
|
|
255
335
|
...runtime,
|
|
256
336
|
command: task?.command ?? options.command,
|
|
257
337
|
commandArgs: task?.commandArgs ?? options.commandArgs,
|
|
338
|
+
commandRaw: options.commandRaw,
|
|
258
339
|
cwd: options.cwd || "",
|
|
259
340
|
interval: options.interval,
|
|
260
341
|
intervalHuman,
|
package/dist/daemon/projects.js
CHANGED
|
@@ -122,4 +122,14 @@ export class ProjectManager {
|
|
|
122
122
|
this.projects.delete(id);
|
|
123
123
|
this.saveAllProjects();
|
|
124
124
|
}
|
|
125
|
+
reload(newProjects) {
|
|
126
|
+
this.projects.clear();
|
|
127
|
+
for (const project of newProjects) {
|
|
128
|
+
this.projects.set(project.id, project);
|
|
129
|
+
}
|
|
130
|
+
if (!this.projects.has("default")) {
|
|
131
|
+
this.createDefaultProject();
|
|
132
|
+
}
|
|
133
|
+
daemonLog(`reloaded ${newProjects.length} project(s) from external change`);
|
|
134
|
+
}
|
|
125
135
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -12,6 +12,7 @@ export class IpcServer {
|
|
|
12
12
|
taskManager;
|
|
13
13
|
socketPath;
|
|
14
14
|
clients = new Set();
|
|
15
|
+
subscribers = new Set();
|
|
15
16
|
constructor(manager, taskManager) {
|
|
16
17
|
this.manager = manager;
|
|
17
18
|
this.taskManager = taskManager;
|
|
@@ -33,6 +34,7 @@ export class IpcServer {
|
|
|
33
34
|
client.destroy();
|
|
34
35
|
}
|
|
35
36
|
this.clients.clear();
|
|
37
|
+
this.subscribers.clear();
|
|
36
38
|
return new Promise((resolve) => {
|
|
37
39
|
this.server.close(() => {
|
|
38
40
|
removeSocketFile();
|
|
@@ -40,8 +42,22 @@ export class IpcServer {
|
|
|
40
42
|
});
|
|
41
43
|
});
|
|
42
44
|
}
|
|
45
|
+
pushEvent(event, data) {
|
|
46
|
+
for (const socket of this.subscribers) {
|
|
47
|
+
try {
|
|
48
|
+
send(socket, { type: "event", event, data });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
this.subscribers.delete(socket);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
43
55
|
handleConnection(socket) {
|
|
44
56
|
this.clients.add(socket);
|
|
57
|
+
socket.on("close", () => {
|
|
58
|
+
this.clients.delete(socket);
|
|
59
|
+
this.subscribers.delete(socket);
|
|
60
|
+
});
|
|
45
61
|
let buffer = "";
|
|
46
62
|
socket.on("data", (chunk) => {
|
|
47
63
|
buffer += chunk.toString();
|
|
@@ -218,6 +234,11 @@ export class IpcServer {
|
|
|
218
234
|
}
|
|
219
235
|
break;
|
|
220
236
|
}
|
|
237
|
+
case "subscribe": {
|
|
238
|
+
this.subscribers.add(socket);
|
|
239
|
+
send(socket, { type: "ok" });
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
221
242
|
case "shutdown": {
|
|
222
243
|
send(socket, { type: "ok" });
|
|
223
244
|
await this.manager.shutdown();
|
|
@@ -55,4 +55,11 @@ export class TaskManager {
|
|
|
55
55
|
onFailureTaskId: null,
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
reload(newTasks) {
|
|
59
|
+
this.tasks.clear();
|
|
60
|
+
for (const task of newTasks) {
|
|
61
|
+
this.tasks.set(task.id, task);
|
|
62
|
+
}
|
|
63
|
+
daemonLog(`reloaded ${newTasks.length} task(s) from external change`);
|
|
64
|
+
}
|
|
58
65
|
}
|
package/dist/esm-loader.js
CHANGED
|
@@ -14,7 +14,10 @@ export function resolve(specifier, context, nextResolve) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
if (
|
|
17
|
+
if (
|
|
18
|
+
(specifier.includes("react-reconciler/constants") || specifier.includes("react-reconciler/reflection")) &&
|
|
19
|
+
!specifier.endsWith(".js")
|
|
20
|
+
) {
|
|
18
21
|
return nextResolve(specifier + ".js", context);
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parseDuration } from "../duration.js";
|
|
3
|
+
import { parseMaxRuns } from "../loop-config.js";
|
|
4
|
+
import { t } from "../i18n/index.js";
|
|
5
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
6
|
+
export const createFields = [
|
|
7
|
+
"interval",
|
|
8
|
+
"taskMode",
|
|
9
|
+
"command",
|
|
10
|
+
"cwd",
|
|
11
|
+
"taskId",
|
|
12
|
+
"description",
|
|
13
|
+
"runNow",
|
|
14
|
+
"maxRuns",
|
|
15
|
+
"project",
|
|
16
|
+
];
|
|
17
|
+
// ── Validation ──────────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Validates a single loop form field and returns an error string, or
|
|
20
|
+
* `null` if the value is valid.
|
|
21
|
+
*
|
|
22
|
+
* Mirrors the validation logic used in the board's CreateForm submit()
|
|
23
|
+
* and the TUI's validation utility, but in a single shared hook that
|
|
24
|
+
* both surfaces can consume.
|
|
25
|
+
*
|
|
26
|
+
* @param key - The field to validate.
|
|
27
|
+
* @param values - All form values (needed for cross-field rules like
|
|
28
|
+
* "command is required only when taskMode is inline").
|
|
29
|
+
*/
|
|
30
|
+
function validateField(key, values) {
|
|
31
|
+
const value = values[key] ?? "";
|
|
32
|
+
switch (key) {
|
|
33
|
+
// ── interval ──────────────────────────────────────────────────
|
|
34
|
+
// Always required; delegates to parseDuration() which throws on
|
|
35
|
+
// empty, invalid syntax, or non-positive values.
|
|
36
|
+
case "interval": {
|
|
37
|
+
if (!value.trim()) {
|
|
38
|
+
return t("errors.durationEmpty");
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
parseDuration(value);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return err instanceof Error ? err.message : String(err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ── command ───────────────────────────────────────────────────
|
|
49
|
+
// Required only when the user chose inline mode.
|
|
50
|
+
case "command": {
|
|
51
|
+
if (values.taskMode === "existing")
|
|
52
|
+
return null;
|
|
53
|
+
if (!value.trim()) {
|
|
54
|
+
return t("errors.commandEmpty");
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// ── cwd ───────────────────────────────────────────────────────
|
|
59
|
+
// Optional; if provided the directory must exist on disk.
|
|
60
|
+
case "cwd": {
|
|
61
|
+
if (!value.trim())
|
|
62
|
+
return null;
|
|
63
|
+
if (!fs.existsSync(value)) {
|
|
64
|
+
return t("board.cwdMissing", { cwd: value });
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// ── description ───────────────────────────────────────────────
|
|
69
|
+
// Always required.
|
|
70
|
+
case "description": {
|
|
71
|
+
if (!value.trim()) {
|
|
72
|
+
return t("errors.descriptionEmpty");
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
// ── maxRuns ───────────────────────────────────────────────────
|
|
77
|
+
// Optional; if provided it must be a positive integer.
|
|
78
|
+
case "maxRuns": {
|
|
79
|
+
if (!value.trim())
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
parseMaxRuns(value);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return err instanceof Error ? err.message : String(err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── no validation needed ──────────────────────────────────────
|
|
90
|
+
case "taskMode":
|
|
91
|
+
case "runNow":
|
|
92
|
+
case "project":
|
|
93
|
+
case "taskId":
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Validates every relevant field and returns a map of field-key → error
|
|
99
|
+
* message. Only fields that fail validation appear in the map (omitted
|
|
100
|
+
* fields are valid).
|
|
101
|
+
*/
|
|
102
|
+
function validateAll(values) {
|
|
103
|
+
const errors = {};
|
|
104
|
+
const fieldsToValidate = [
|
|
105
|
+
"interval",
|
|
106
|
+
"command",
|
|
107
|
+
"cwd",
|
|
108
|
+
"description",
|
|
109
|
+
"maxRuns",
|
|
110
|
+
];
|
|
111
|
+
for (const field of fieldsToValidate) {
|
|
112
|
+
const error = validateField(field, values);
|
|
113
|
+
if (error !== null) {
|
|
114
|
+
errors[field] = error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return errors;
|
|
118
|
+
}
|
|
119
|
+
// ── Hook ────────────────────────────────────────────────────────────
|
|
120
|
+
/**
|
|
121
|
+
* Shared form-validation hook for loop creation / edit forms.
|
|
122
|
+
*
|
|
123
|
+
* Returns stable references to `validateField` and `validateAll` so it
|
|
124
|
+
* can be called from any React component without triggering re-renders.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const { validateField, validateAll } = useLoopFormValidation();
|
|
129
|
+
*
|
|
130
|
+
* // On-blur per-field check:
|
|
131
|
+
* const err = validateField("interval", values);
|
|
132
|
+
*
|
|
133
|
+
* // On-submit full check:
|
|
134
|
+
* const errors = validateAll(values);
|
|
135
|
+
* if (Object.keys(errors).length === 0) { submit(); }
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function useLoopFormValidation() {
|
|
139
|
+
return { validateField, validateAll };
|
|
140
|
+
}
|