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.
Files changed (105) hide show
  1. package/README.md +127 -21
  2. package/dist/board/App.js +1 -1
  3. package/dist/board/components/ActionButtons.js +1 -1
  4. package/dist/board/components/BoardButton.js +1 -1
  5. package/dist/board/components/ConfirmModal.js +1 -1
  6. package/dist/board/components/ContextHelpModal.js +1 -1
  7. package/dist/board/components/CreateForm.js +1 -1
  8. package/dist/board/components/CreateProjectModal.js +1 -1
  9. package/dist/board/components/DeleteProjectConfirm.js +1 -1
  10. package/dist/board/components/DetailView.js +1 -1
  11. package/dist/board/components/EditProjectModal.js +1 -1
  12. package/dist/board/components/FilterBar.js +1 -1
  13. package/dist/board/components/Footer.js +1 -1
  14. package/dist/board/components/Header.js +1 -1
  15. package/dist/board/components/HelpModal.js +1 -1
  16. package/dist/board/components/Inspector.js +1 -1
  17. package/dist/board/components/LogModal.js +1 -1
  18. package/dist/board/components/Navigator.js +1 -1
  19. package/dist/board/components/ProjectsModal.js +1 -1
  20. package/dist/board/components/ProjectsPage.js +1 -1
  21. package/dist/board/components/RunHistory.js +1 -1
  22. package/dist/board/components/SearchBox.js +1 -1
  23. package/dist/board/components/SearchSelect.js +1 -1
  24. package/dist/board/components/TaskBrowser.js +1 -1
  25. package/dist/board/components/TaskFilterBar.js +1 -1
  26. package/dist/board/components/TaskForm.js +1 -1
  27. package/dist/board/components/Timeline.js +1 -1
  28. package/dist/board/focus-context.js +1 -1
  29. package/dist/board/format.js +15 -6
  30. package/dist/board/index.js +1 -1
  31. package/dist/board/toast.js +1 -1
  32. package/dist/cli/import-validator.js +151 -0
  33. package/dist/cli/import-writer.js +77 -0
  34. package/dist/cli.js +111 -17
  35. package/dist/config/constants.js +52 -0
  36. package/dist/daemon/file-watcher.js +171 -0
  37. package/dist/daemon/http-server.js +605 -0
  38. package/dist/daemon/index.js +36 -6
  39. package/dist/daemon/manager.js +94 -13
  40. package/dist/daemon/projects.js +10 -0
  41. package/dist/daemon/server.js +21 -0
  42. package/dist/daemon/task-manager.js +7 -0
  43. package/dist/esm-loader.js +4 -1
  44. package/dist/hooks/useLoopFormValidation.js +140 -0
  45. package/dist/i18n/en.json +237 -17
  46. package/dist/shared/fs-utils.js +34 -1
  47. package/dist/tui/app.js +675 -151
  48. package/dist/tui/commands.js +114 -0
  49. package/dist/tui/components/ActionButtons.js +49 -0
  50. package/dist/tui/components/Button.js +18 -0
  51. package/dist/tui/components/ChainEditor.js +112 -0
  52. package/dist/tui/components/CommandBuilderField.js +143 -0
  53. package/dist/tui/components/CommandEditorModal.js +159 -0
  54. package/dist/tui/components/CommandInput.js +303 -0
  55. package/dist/tui/components/CommandsBrowserModal.js +77 -0
  56. package/dist/tui/components/ConfirmModal.js +9 -0
  57. package/dist/tui/components/ContextHelpModal.js +8 -0
  58. package/dist/tui/components/CreateForm.js +223 -0
  59. package/dist/tui/components/DebugPanel.js +8 -0
  60. package/dist/tui/components/ExportModal.js +37 -0
  61. package/dist/tui/components/FilterBar.js +13 -0
  62. package/dist/tui/components/FocusableButton.js +16 -0
  63. package/dist/tui/components/FocusableInput.js +69 -0
  64. package/dist/tui/components/FocusableList.js +46 -0
  65. package/dist/tui/components/FocusableSearchSelect.js +82 -0
  66. package/dist/tui/components/Footer.js +42 -0
  67. package/dist/tui/components/Header.js +40 -0
  68. package/dist/tui/components/HelpGuideModal.js +21 -0
  69. package/dist/tui/components/HelpModal.js +59 -0
  70. package/dist/tui/components/InlineCommandEditor.js +143 -0
  71. package/dist/tui/components/Inspector.js +31 -0
  72. package/dist/tui/components/LeftPanel.js +33 -0
  73. package/dist/tui/components/LogModal.js +121 -0
  74. package/dist/tui/components/Modal.js +11 -0
  75. package/dist/tui/components/Navigator.js +68 -0
  76. package/dist/tui/components/PatchEditForm.js +78 -0
  77. package/dist/tui/components/ProjectForm.js +64 -0
  78. package/dist/tui/components/ProjectsModal.js +40 -0
  79. package/dist/tui/components/ProjectsPage.js +99 -0
  80. package/dist/tui/components/RightPanel.js +34 -0
  81. package/dist/tui/components/RunHistory.js +108 -0
  82. package/dist/tui/components/SearchSelect.js +67 -0
  83. package/dist/tui/components/SelectModal.js +76 -0
  84. package/dist/tui/components/TabBar.js +20 -0
  85. package/dist/tui/components/TaskBrowser.js +96 -0
  86. package/dist/tui/components/TaskFilterBar.js +9 -0
  87. package/dist/tui/components/TaskForm.js +134 -0
  88. package/dist/tui/components/TaskPickerModal.js +60 -0
  89. package/dist/tui/components/Toast.js +37 -0
  90. package/dist/tui/components/WelcomeScreen.js +70 -0
  91. package/dist/tui/components/WizardForm.js +178 -0
  92. package/dist/tui/daemon.js +142 -0
  93. package/dist/tui/format.js +111 -0
  94. package/dist/tui/hooks/useBreakpoint.js +7 -0
  95. package/dist/tui/hooks/useHoverState.js +11 -0
  96. package/dist/tui/hooks/useLogStream.js +32 -0
  97. package/dist/tui/hooks/useLoopPolling.js +28 -0
  98. package/dist/tui/index.js +24 -0
  99. package/dist/tui/router.js +16 -0
  100. package/dist/tui/state.js +175 -0
  101. package/dist/tui/theme.js +96 -0
  102. package/dist/tui/types.js +1 -0
  103. package/dist/tui/utils/paste.js +10 -0
  104. package/dist/tui/utils/validation.js +56 -0
  105. package/package.json +13 -8
@@ -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
- removeDaemonPid();
28
- removeDaemonSignature();
29
- await manager.shutdown();
30
- await server.close();
31
- process.exit(0);
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) => {
@@ -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 ?? null,
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,
@@ -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
  }
@@ -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
  }
@@ -14,7 +14,10 @@ export function resolve(specifier, context, nextResolve) {
14
14
  }
15
15
  }
16
16
 
17
- if (specifier.includes("react-reconciler/constants") || specifier.includes("react-reconciler/reflection")) {
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
+ }