loop-task 1.3.0 → 1.4.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 (43) hide show
  1. package/README.md +279 -195
  2. package/dist/board/App.js +225 -87
  3. package/dist/board/components/ActionButtons.js +28 -8
  4. package/dist/board/components/CreateForm.js +230 -70
  5. package/dist/board/components/CreateProjectModal.js +104 -0
  6. package/dist/board/components/DeleteProjectConfirm.js +53 -0
  7. package/dist/board/components/EditProjectModal.js +106 -0
  8. package/dist/board/components/FilterBar.js +5 -4
  9. package/dist/board/components/Footer.js +19 -6
  10. package/dist/board/components/Header.js +33 -3
  11. package/dist/board/components/HelpModal.js +11 -10
  12. package/dist/board/components/Inspector.js +1 -1
  13. package/dist/board/components/LogModal.js +39 -8
  14. package/dist/board/components/Navigator.js +30 -14
  15. package/dist/board/components/ProjectsModal.js +70 -0
  16. package/dist/board/components/ProjectsPage.js +105 -0
  17. package/dist/board/components/RunHistory.js +48 -9
  18. package/dist/board/components/TaskBrowser.js +96 -0
  19. package/dist/board/components/TaskFilterBar.js +6 -0
  20. package/dist/board/components/TaskForm.js +175 -0
  21. package/dist/board/daemon.js +56 -0
  22. package/dist/board/format.js +19 -12
  23. package/dist/board/hooks/useBoardKeybindings.js +264 -156
  24. package/dist/board/hooks/useTaskKeybindings.js +130 -0
  25. package/dist/board/router.js +16 -0
  26. package/dist/board/state.js +18 -14
  27. package/dist/cli.js +61 -6
  28. package/dist/client/commands.js +157 -1
  29. package/dist/config/constants.js +15 -0
  30. package/dist/config/paths.js +6 -0
  31. package/dist/core/command-runner.js +2 -0
  32. package/dist/core/foreground-loop.js +4 -1
  33. package/dist/core/loop-controller.js +210 -32
  34. package/dist/daemon/index.js +5 -2
  35. package/dist/daemon/manager.js +113 -34
  36. package/dist/daemon/projects.js +104 -0
  37. package/dist/daemon/server.js +177 -6
  38. package/dist/daemon/state.js +32 -1
  39. package/dist/daemon/task-manager.js +58 -0
  40. package/dist/i18n/en.json +140 -21
  41. package/dist/loop-config.js +13 -4
  42. package/dist/shared/clipboard.js +19 -0
  43. package/package.json +13 -3
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import fs from "node:fs";
3
+ import crypto from "node:crypto";
3
4
  import { sleep } from "../shared/sleep.js";
4
5
  import { SLEEP_CHUNK_MS } from "../config/constants.js";
5
6
  import { executeCommand } from "./command-runner.js";
@@ -9,7 +10,10 @@ export class LoopController extends EventEmitter {
9
10
  runAbortController = null;
10
11
  _paused = false;
11
12
  _forceRun = false;
12
- interruptedForForceRun = false;
13
+ _savedRemainingMs = null;
14
+ _resetSchedule = false;
15
+ _stopAfterRun = false;
16
+ _maxRunsReached = false;
13
17
  _status = "running";
14
18
  resumeResolve = null;
15
19
  runCount = 0;
@@ -21,34 +25,47 @@ export class LoopController extends EventEmitter {
21
25
  id;
22
26
  options;
23
27
  logPath;
28
+ taskResolver;
24
29
  remainingDelayMs = null;
25
30
  logStream = null;
26
31
  loopPromise = null;
32
+ sessionStartedAt = null;
27
33
  runHistory = [];
28
34
  currentRunStartOffset = 0;
29
- constructor(id, options, logPath, state) {
35
+ skippedCount = 0;
36
+ constructor(id, options, logPath, taskResolver, state) {
30
37
  super();
31
38
  this.id = id;
32
39
  this.options = options;
33
40
  this.logPath = logPath;
41
+ this.taskResolver = taskResolver;
34
42
  this.abortController = new AbortController();
35
43
  this.createdAt = state?.createdAt ?? new Date().toISOString();
36
44
  this.runCount = state?.runCount ?? 0;
45
+ this._maxRunsReached = state?.maxRunsReached ?? false;
46
+ this.sessionStartedAt = state?.sessionStartedAt ?? null;
37
47
  this.lastRunAt = state?.lastRunAt ?? null;
38
48
  this.lastExitCode = state?.lastExitCode ?? null;
39
49
  this.lastDuration = state?.lastDuration ?? null;
40
50
  this.nextRunAt = state?.nextRunAt ?? null;
41
51
  this.remainingDelayMs = state?.remainingDelayMs ?? null;
42
52
  this._status = state?.status ?? "running";
43
- this._paused = state?.status === "paused";
44
- this.runHistory = state?.runHistory ?? [];
53
+ this._paused = state?.status === "paused" || state?.status === "idle";
54
+ this.runHistory = (state?.runHistory ?? []).map((r) => r.status === "running" ? { ...r, status: "completed" } : r).map((r) => ({ ...r, logOffset: r.logOffset ?? 0 }));
55
+ this.skippedCount = state?.skippedCount ?? 0;
45
56
  }
46
57
  get status() {
47
58
  return this._status;
48
59
  }
49
60
  start() {
61
+ this.skippedCount = 0;
62
+ this.logStream?.end();
63
+ this.abortController = new AbortController();
50
64
  this.logStream = fs.createWriteStream(this.logPath, { flags: "a" });
51
65
  this.loopPromise = this.run();
66
+ if (this.sessionStartedAt === null) {
67
+ this.sessionStartedAt = new Date().toISOString();
68
+ }
52
69
  }
53
70
  pause(interruptCurrentRun = false) {
54
71
  if (this._status === "running" || this._status === "waiting") {
@@ -60,8 +77,21 @@ export class LoopController extends EventEmitter {
60
77
  this.emit("paused");
61
78
  }
62
79
  }
80
+ stopLoop(interruptCurrentRun = false) {
81
+ if (this._status === "running" || this._status === "waiting" || this._status === "paused") {
82
+ this._paused = true;
83
+ this._status = "idle";
84
+ this.sessionStartedAt = null;
85
+ this.remainingDelayMs = null;
86
+ this.nextRunAt = null;
87
+ if (interruptCurrentRun) {
88
+ this.runAbortController?.abort();
89
+ }
90
+ this.emit("stopped");
91
+ }
92
+ }
63
93
  resume() {
64
- if (this._paused && this.resumeResolve) {
94
+ if (this._status === "paused" && this._paused && this.resumeResolve) {
65
95
  this._paused = false;
66
96
  if (this.remainingDelayMs !== null) {
67
97
  this._status = "waiting";
@@ -72,19 +102,45 @@ export class LoopController extends EventEmitter {
72
102
  this.emit("resumed");
73
103
  }
74
104
  }
75
- triggerNow(interruptCurrentRun = false) {
76
- if (this._status === "stopped") {
105
+ playLoop() {
106
+ if (this._status !== "idle" && this._status !== "stopped")
107
+ return false;
108
+ if (this._maxRunsReached)
109
+ return false;
110
+ if (this.options.maxRuns !== null && this.runCount >= this.options.maxRuns) {
111
+ this._maxRunsReached = true;
77
112
  return false;
78
113
  }
114
+ this.sessionStartedAt = new Date().toISOString();
115
+ this._resetSchedule = true;
116
+ this._paused = false;
117
+ this._status = "waiting";
118
+ this.nextRunAt = new Date(Date.now() + this.options.interval).toISOString();
119
+ if (this.resumeResolve) {
120
+ this.resumeResolve();
121
+ this.resumeResolve = null;
122
+ }
123
+ this.start();
124
+ this.emit("resumed");
125
+ return true;
126
+ }
127
+ triggerNow() {
128
+ if (this._status === "running")
129
+ return false;
130
+ if (this._maxRunsReached)
131
+ return false;
132
+ const needsStart = this._status === "stopped" || this._status === "idle";
133
+ this._savedRemainingMs = this.remainingDelayMs;
79
134
  this._forceRun = true;
80
- this.remainingDelayMs = null;
81
- this.nextRunAt = null;
82
- if (interruptCurrentRun) {
83
- this.interruptedForForceRun = true;
84
- this.runAbortController?.abort();
135
+ if (needsStart) {
136
+ this._stopAfterRun = true;
137
+ this._paused = false;
138
+ this._status = "running";
139
+ this.start();
85
140
  }
86
- if (this._paused) {
87
- this.resume();
141
+ else {
142
+ if (this._paused)
143
+ this.resume();
88
144
  }
89
145
  this.emit("triggered");
90
146
  return true;
@@ -102,8 +158,11 @@ export class LoopController extends EventEmitter {
102
158
  getMeta() {
103
159
  return {
104
160
  id: this.id,
161
+ taskId: this.options.taskId,
105
162
  status: this._status,
106
163
  createdAt: this.createdAt,
164
+ maxRunsReached: this._maxRunsReached,
165
+ sessionStartedAt: this.sessionStartedAt,
107
166
  runCount: this.runCount,
108
167
  lastRunAt: this.lastRunAt,
109
168
  lastExitCode: this.lastExitCode,
@@ -111,10 +170,22 @@ export class LoopController extends EventEmitter {
111
170
  nextRunAt: this.nextRunAt,
112
171
  remainingDelayMs: this.remainingDelayMs,
113
172
  runHistory: this.runHistory,
173
+ skippedCount: this.skippedCount,
114
174
  };
115
175
  }
176
+ clearMaxRunsReached() {
177
+ this._maxRunsReached = false;
178
+ this._paused = false;
179
+ this._status = "idle";
180
+ this.remainingDelayMs = null;
181
+ this.nextRunAt = null;
182
+ }
183
+ isMaxRunsReached() {
184
+ return this._maxRunsReached;
185
+ }
116
186
  async waitForResume() {
117
- this._status = "paused";
187
+ const savedStatus = this._status;
188
+ this._status = savedStatus === "idle" ? "idle" : "paused";
118
189
  return new Promise((resolve) => {
119
190
  this.resumeResolve = resolve;
120
191
  });
@@ -125,12 +196,21 @@ export class LoopController extends EventEmitter {
125
196
  let announced = false;
126
197
  while (remaining > 0) {
127
198
  if (this._forceRun) {
199
+ this._savedRemainingMs = remaining;
128
200
  this.remainingDelayMs = null;
129
201
  this.nextRunAt = null;
130
202
  return true;
131
203
  }
204
+ if (this._resetSchedule) {
205
+ this._resetSchedule = false;
206
+ remaining = ms;
207
+ this.remainingDelayMs = remaining;
208
+ announced = false;
209
+ }
132
210
  if (this._paused) {
133
- this._status = "paused";
211
+ if (this._status !== "idle") {
212
+ this._status = "paused";
213
+ }
134
214
  this.emit("paused");
135
215
  await this.waitForResume();
136
216
  announced = false;
@@ -138,6 +218,11 @@ export class LoopController extends EventEmitter {
138
218
  this.remainingDelayMs = null;
139
219
  return false;
140
220
  }
221
+ if (this._resetSchedule) {
222
+ this._resetSchedule = false;
223
+ remaining = ms;
224
+ this.remainingDelayMs = remaining;
225
+ }
141
226
  }
142
227
  if (!announced) {
143
228
  this._status = "waiting";
@@ -167,8 +252,10 @@ export class LoopController extends EventEmitter {
167
252
  try {
168
253
  while (!signal.aborted) {
169
254
  if (this.options.maxRuns !== null && this.runCount >= this.options.maxRuns) {
170
- this._status = "stopped";
171
- this.emit("stopped");
255
+ this._maxRunsReached = true;
256
+ this._paused = true;
257
+ this._status = "paused";
258
+ this.emit("paused");
172
259
  return;
173
260
  }
174
261
  if (isFirstRun && this._paused) {
@@ -201,49 +288,140 @@ export class LoopController extends EventEmitter {
201
288
  this._forceRun = false;
202
289
  this.runCount++;
203
290
  this.lastRunAt = new Date().toISOString();
291
+ const runStartedAtMs = Date.now();
292
+ if (this.sessionStartedAt === null) {
293
+ this.sessionStartedAt = this.lastRunAt;
294
+ }
204
295
  this.nextRunAt = null;
205
296
  this.emit("run:start", this.runCount);
206
297
  this.logStream = rotateLogIfNeeded(this.logPath, this.logStream);
207
298
  this.currentRunStartOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
299
+ this.runHistory.push({
300
+ runNumber: this.runCount,
301
+ startedAt: this.lastRunAt,
302
+ exitCode: -1,
303
+ duration: 0,
304
+ logSize: 0,
305
+ status: "running",
306
+ logOffset: this.currentRunStartOffset,
307
+ });
208
308
  this.runAbortController = new AbortController();
209
- const result = await executeCommand(this.options.command, this.options.commandArgs, this.options.cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount);
309
+ const task = this.options.taskId ? this.taskResolver(this.options.taskId) : null;
310
+ const command = task?.command ?? this.options.command;
311
+ const commandArgs = task?.commandArgs ?? this.options.commandArgs;
312
+ const cwd = this.options.cwd;
313
+ const result = await executeCommand(command, commandArgs, cwd, this.logStream, AbortSignal.any([signal, this.runAbortController.signal]), this.runCount);
210
314
  this.runAbortController = null;
211
315
  this.lastExitCode = result.exitCode;
212
316
  this.lastDuration = result.duration;
213
- if (!this.interruptedForForceRun) {
214
- const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
317
+ const logSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - this.currentRunStartOffset : 0;
318
+ const runningRecord = this.runHistory.find((r) => r.runNumber === this.runCount);
319
+ if (runningRecord) {
320
+ runningRecord.exitCode = result.exitCode;
321
+ runningRecord.duration = result.duration;
322
+ runningRecord.logSize = Math.max(0, logSize);
323
+ runningRecord.status = "completed";
324
+ }
325
+ else {
215
326
  this.runHistory.push({
216
327
  runNumber: this.runCount,
217
328
  startedAt: this.lastRunAt,
218
329
  exitCode: result.exitCode,
219
330
  duration: result.duration,
220
331
  logSize: Math.max(0, logSize),
332
+ status: "completed",
333
+ logOffset: this.currentRunStartOffset,
221
334
  });
222
- if (this.runHistory.length > 50) {
223
- this.runHistory = this.runHistory.slice(-50);
335
+ }
336
+ const chainTargetId = result.exitCode === 0 ? task?.onSuccessTaskId : task?.onFailureTaskId;
337
+ if (chainTargetId) {
338
+ const chainTask = this.taskResolver(chainTargetId);
339
+ if (chainTask) {
340
+ const chainGroupId = crypto.randomUUID().slice(0, 8);
341
+ const mainRecord = this.runHistory[this.runHistory.length - 1];
342
+ if (mainRecord)
343
+ mainRecord.chainGroupId = chainGroupId;
344
+ const chainStartedAt = new Date().toISOString();
345
+ const chainOffset = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size : 0;
346
+ this.runHistory.push({
347
+ runNumber: this.runCount,
348
+ startedAt: chainStartedAt,
349
+ exitCode: -1,
350
+ duration: 0,
351
+ logSize: 0,
352
+ status: "running",
353
+ logOffset: chainOffset,
354
+ chainGroupId,
355
+ chainName: chainTask.name,
356
+ });
357
+ const chainResult = await executeCommand(chainTask.command, chainTask.commandArgs, this.options.cwd, this.logStream, signal, this.runCount);
358
+ const chainLogSize = fs.existsSync(this.logPath) ? fs.statSync(this.logPath).size - chainOffset : 0;
359
+ const chainRecord = this.runHistory.find((r) => r.chainGroupId === chainGroupId && r.status === "running");
360
+ if (chainRecord) {
361
+ chainRecord.exitCode = chainResult.exitCode;
362
+ chainRecord.duration = chainResult.duration;
363
+ chainRecord.logSize = Math.max(0, chainLogSize);
364
+ chainRecord.status = "completed";
365
+ }
366
+ this.lastExitCode = chainResult.exitCode;
367
+ this.lastDuration = (this.lastDuration ?? 0) + chainResult.duration;
224
368
  }
225
369
  }
226
- if (this.interruptedForForceRun) {
227
- this.runCount = Math.max(0, this.runCount - 1);
228
- this.interruptedForForceRun = false;
229
- this._forceRun = true;
370
+ if (this.runHistory.length > 50) {
371
+ this.runHistory = this.runHistory.slice(-50);
230
372
  }
231
373
  this.emit("run:end", result);
232
374
  if (signal.aborted)
233
375
  break;
234
376
  if (this.options.maxRuns !== null && this.runCount >= this.options.maxRuns) {
235
- this._status = "stopped";
377
+ this._maxRunsReached = true;
378
+ this._paused = true;
379
+ this._status = "paused";
380
+ this.remainingDelayMs = null;
381
+ this.nextRunAt = null;
382
+ this.emit("paused");
383
+ return;
384
+ }
385
+ if (this._stopAfterRun) {
386
+ this._stopAfterRun = false;
387
+ this._paused = true;
388
+ this._status = "idle";
389
+ this.remainingDelayMs = null;
390
+ this.nextRunAt = null;
236
391
  this.emit("stopped");
237
392
  return;
238
393
  }
239
- const completed = await this.waitForDelay(this.options.interval, signal);
240
- if (!completed) {
241
- break;
394
+ const saved = this._savedRemainingMs;
395
+ this._savedRemainingMs = null;
396
+ if (saved !== null) {
397
+ const remaining = Math.max(0, saved - result.duration);
398
+ if (remaining > 0) {
399
+ const completed = await this.waitForDelay(remaining, signal);
400
+ if (!completed)
401
+ break;
402
+ }
403
+ }
404
+ else {
405
+ const nextSlotMs = runStartedAtMs + this.options.interval;
406
+ const overrunMs = Date.now() - nextSlotMs;
407
+ if (overrunMs >= 0) {
408
+ const missed = Math.floor(overrunMs / this.options.interval) + 1;
409
+ this.skippedCount += missed;
410
+ const adjustedDelay = this.options.interval - (overrunMs % this.options.interval);
411
+ const completed = await this.waitForDelay(adjustedDelay, signal);
412
+ if (!completed)
413
+ break;
414
+ }
415
+ else {
416
+ const completed = await this.waitForDelay(this.options.interval, signal);
417
+ if (!completed)
418
+ break;
419
+ }
242
420
  }
243
421
  }
244
422
  }
245
423
  finally {
246
- if (this._status !== "stopped") {
424
+ if (this._status !== "stopped" && this._status !== "idle" && this._status !== "paused" && !this._maxRunsReached) {
247
425
  this._status = "stopped";
248
426
  }
249
427
  }
@@ -1,11 +1,14 @@
1
1
  import { LoopManager } from "./manager.js";
2
+ import { TaskManager } from "./task-manager.js";
2
3
  import { IpcServer } from "./server.js";
3
4
  import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, } from "./state.js";
4
5
  import { t } from "../i18n/index.js";
5
6
  import { daemonLog } from "./daemon-log.js";
6
7
  async function main() {
7
- const manager = new LoopManager();
8
- const server = new IpcServer(manager);
8
+ const taskManager = new TaskManager();
9
+ taskManager.init();
10
+ const manager = new LoopManager(taskManager);
11
+ const server = new IpcServer(manager, taskManager);
9
12
  try {
10
13
  await server.listen();
11
14
  }
@@ -1,31 +1,56 @@
1
1
  import crypto from "node:crypto";
2
2
  import { LoopController } from "../core/loop-controller.js";
3
+ import { ProjectManager } from "./projects.js";
3
4
  import { saveLoop, loadAllLoops, deleteLoop as deleteLoopState, getLogPath, } from "./state.js";
4
5
  import { daemonLog } from "./daemon-log.js";
5
6
  export class LoopManager {
6
7
  loops = new Map();
7
8
  lastSerialized = new Map();
9
+ taskManager;
10
+ projectManager;
11
+ constructor(taskManager, projectManager) {
12
+ this.taskManager = taskManager;
13
+ this.projectManager = projectManager || new ProjectManager();
14
+ }
15
+ taskResolver = (taskId) => this.taskManager.get(taskId);
8
16
  init() {
17
+ // Initialize projects first
18
+ this.projectManager.init();
9
19
  const saved = loadAllLoops();
10
20
  let restarted = 0;
21
+ let migrated = 0;
22
+ const shouldAutoStart = (s) => s !== "stopped" && s !== "idle";
11
23
  for (const meta of saved) {
12
- if (meta.status === "stopped")
13
- continue;
24
+ if (!meta.projectId) {
25
+ meta.projectId = "default";
26
+ saveLoop(meta);
27
+ }
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
+ }
14
36
  const options = {
15
37
  interval: meta.interval,
38
+ taskId: taskId ?? null,
16
39
  command: meta.command,
17
40
  commandArgs: meta.commandArgs,
41
+ cwd: meta.cwd ?? "",
18
42
  immediate: false,
19
43
  maxRuns: meta.maxRuns,
20
44
  verbose: meta.verbose,
21
- cwd: meta.cwd ?? "",
22
45
  description: meta.description ?? "",
46
+ projectId: meta.projectId ?? "default",
23
47
  };
24
48
  const logPath = getLogPath(meta.id);
25
- const controller = new LoopController(meta.id, options, logPath, {
49
+ const controller = new LoopController(meta.id, options, logPath, this.taskResolver, {
26
50
  status: meta.status,
27
51
  createdAt: meta.createdAt,
28
52
  runCount: meta.runCount,
53
+ sessionStartedAt: meta.sessionStartedAt,
29
54
  lastRunAt: meta.lastRunAt,
30
55
  lastExitCode: meta.lastExitCode,
31
56
  lastDuration: meta.lastDuration,
@@ -39,8 +64,13 @@ export class LoopManager {
39
64
  intervalHuman: meta.intervalHuman,
40
65
  });
41
66
  this.wireEvents(meta.id, controller, options, meta.intervalHuman);
42
- controller.start();
43
- restarted += 1;
67
+ if (shouldAutoStart(meta.status)) {
68
+ controller.start();
69
+ restarted += 1;
70
+ }
71
+ }
72
+ if (migrated > 0) {
73
+ daemonLog(`migrated ${migrated} loop(s) to task model`);
44
74
  }
45
75
  if (restarted > 0) {
46
76
  daemonLog(`restarted ${restarted} loop(s) from persisted state`);
@@ -49,7 +79,7 @@ export class LoopManager {
49
79
  start(options, intervalHuman) {
50
80
  const id = crypto.randomUUID().slice(0, 8);
51
81
  const logPath = getLogPath(id);
52
- const controller = new LoopController(id, options, logPath);
82
+ const controller = new LoopController(id, options, logPath, this.taskResolver);
53
83
  this.loops.set(id, { controller, options, intervalHuman });
54
84
  controller.start();
55
85
  this.wireEvents(id, controller, options, intervalHuman);
@@ -61,19 +91,20 @@ export class LoopManager {
61
91
  if (!entry)
62
92
  return false;
63
93
  const executionChanged = entry.options.interval !== options.interval ||
64
- entry.options.command !== options.command ||
65
- entry.options.commandArgs.length !== options.commandArgs.length ||
66
- entry.options.commandArgs.some((arg, index) => arg !== options.commandArgs[index]) ||
94
+ entry.options.taskId !== options.taskId ||
67
95
  entry.options.immediate !== options.immediate ||
68
96
  entry.options.maxRuns !== options.maxRuns ||
69
- entry.options.verbose !== options.verbose ||
70
- entry.options.cwd !== options.cwd;
97
+ entry.options.verbose !== options.verbose;
98
+ const maxRunsChanged = entry.options.maxRuns !== options.maxRuns;
71
99
  Object.assign(entry.options, options);
72
100
  entry.intervalHuman = intervalHuman;
101
+ if (maxRunsChanged) {
102
+ entry.controller.clearMaxRunsReached();
103
+ }
73
104
  if (entry.controller.status === "running") {
74
105
  entry.controller.pause(true);
75
106
  }
76
- else if (executionChanged && entry.controller.status !== "paused") {
107
+ else if (executionChanged && entry.controller.status !== "paused" && entry.controller.status !== "idle") {
77
108
  entry.controller.pause();
78
109
  }
79
110
  this.persist(id, entry.controller, entry.options, entry.intervalHuman);
@@ -86,6 +117,24 @@ export class LoopManager {
86
117
  }
87
118
  return result;
88
119
  }
120
+ listProjects() {
121
+ return this.projectManager.getAll();
122
+ }
123
+ createProject(name, color) {
124
+ return this.projectManager.create(name, color);
125
+ }
126
+ updateProject(id, name, color) {
127
+ this.projectManager.update(id, name, color);
128
+ }
129
+ deleteProject(id) {
130
+ for (const [loopId, entry] of this.loops) {
131
+ if (entry.options.projectId === id) {
132
+ entry.options.projectId = "default";
133
+ this.persist(loopId, entry.controller, entry.options, entry.intervalHuman);
134
+ }
135
+ }
136
+ this.projectManager.delete(id);
137
+ }
89
138
  status(id) {
90
139
  const entry = this.loops.get(id);
91
140
  if (!entry)
@@ -108,14 +157,42 @@ export class LoopManager {
108
157
  this.persist(id, entry.controller, entry.options, entry.intervalHuman);
109
158
  return true;
110
159
  }
111
- trigger(id) {
160
+ stopLoop(id) {
112
161
  const entry = this.loops.get(id);
113
162
  if (!entry)
114
163
  return false;
115
- entry.controller.triggerNow(true);
164
+ entry.controller.stopLoop();
116
165
  this.persist(id, entry.controller, entry.options, entry.intervalHuman);
117
166
  return true;
118
167
  }
168
+ playLoop(id) {
169
+ const entry = this.loops.get(id);
170
+ if (!entry)
171
+ return false;
172
+ const ok = entry.controller.playLoop();
173
+ if (ok) {
174
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
175
+ }
176
+ return ok;
177
+ }
178
+ trigger(id) {
179
+ const entry = this.loops.get(id);
180
+ if (!entry)
181
+ return false;
182
+ const ok = entry.controller.triggerNow();
183
+ if (ok) {
184
+ this.persist(id, entry.controller, entry.options, entry.intervalHuman);
185
+ }
186
+ return ok;
187
+ }
188
+ isMaxRunsBlocked(id) {
189
+ const entry = this.loops.get(id);
190
+ return !!entry?.controller.isMaxRunsReached();
191
+ }
192
+ isRunning(id) {
193
+ const entry = this.loops.get(id);
194
+ return entry?.controller.status === "running";
195
+ }
119
196
  async delete(id) {
120
197
  const entry = this.loops.get(id);
121
198
  if (!entry)
@@ -150,7 +227,7 @@ export class LoopManager {
150
227
  controller.on("stopped", persist);
151
228
  }
152
229
  persist(id, controller, options, intervalHuman) {
153
- const meta = toMeta(controller, options, intervalHuman);
230
+ const meta = this.toMeta(controller, options, intervalHuman);
154
231
  const serialized = JSON.stringify(meta);
155
232
  if (this.lastSerialized.get(id) === serialized) {
156
233
  return;
@@ -159,23 +236,25 @@ export class LoopManager {
159
236
  saveLoop(meta);
160
237
  }
161
238
  buildMeta(id, entry) {
162
- return toMeta(entry.controller, entry.options, entry.intervalHuman);
239
+ return this.toMeta(entry.controller, entry.options, entry.intervalHuman);
240
+ }
241
+ toMeta(controller, options, intervalHuman) {
242
+ const runtime = controller.getMeta();
243
+ const task = options.taskId ? this.taskManager.get(options.taskId) : null;
244
+ return {
245
+ ...runtime,
246
+ command: task?.command ?? options.command,
247
+ commandArgs: task?.commandArgs ?? options.commandArgs,
248
+ cwd: options.cwd || "",
249
+ interval: options.interval,
250
+ intervalHuman,
251
+ immediate: options.immediate,
252
+ maxRuns: options.maxRuns,
253
+ verbose: options.verbose,
254
+ description: options.description,
255
+ remainingDelayMs: runtime.remainingDelayMs,
256
+ pid: process.pid,
257
+ projectId: options.projectId ?? "default",
258
+ };
163
259
  }
164
- }
165
- function toMeta(controller, options, intervalHuman) {
166
- const runtime = controller.getMeta();
167
- return {
168
- ...runtime,
169
- command: options.command,
170
- commandArgs: options.commandArgs,
171
- interval: options.interval,
172
- intervalHuman,
173
- immediate: options.immediate,
174
- maxRuns: options.maxRuns,
175
- verbose: options.verbose,
176
- cwd: options.cwd,
177
- description: options.description,
178
- remainingDelayMs: runtime.remainingDelayMs,
179
- pid: process.pid,
180
- };
181
260
  }