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.
- package/README.md +279 -195
- package/dist/board/App.js +225 -87
- package/dist/board/components/ActionButtons.js +28 -8
- package/dist/board/components/CreateForm.js +230 -70
- package/dist/board/components/CreateProjectModal.js +104 -0
- package/dist/board/components/DeleteProjectConfirm.js +53 -0
- package/dist/board/components/EditProjectModal.js +106 -0
- package/dist/board/components/FilterBar.js +5 -4
- package/dist/board/components/Footer.js +19 -6
- package/dist/board/components/Header.js +33 -3
- package/dist/board/components/HelpModal.js +11 -10
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +39 -8
- package/dist/board/components/Navigator.js +30 -14
- package/dist/board/components/ProjectsModal.js +70 -0
- package/dist/board/components/ProjectsPage.js +105 -0
- package/dist/board/components/RunHistory.js +48 -9
- package/dist/board/components/TaskBrowser.js +96 -0
- package/dist/board/components/TaskFilterBar.js +6 -0
- package/dist/board/components/TaskForm.js +175 -0
- package/dist/board/daemon.js +56 -0
- package/dist/board/format.js +19 -12
- package/dist/board/hooks/useBoardKeybindings.js +264 -156
- package/dist/board/hooks/useTaskKeybindings.js +130 -0
- package/dist/board/router.js +16 -0
- package/dist/board/state.js +18 -14
- package/dist/cli.js +61 -6
- package/dist/client/commands.js +157 -1
- package/dist/config/constants.js +15 -0
- package/dist/config/paths.js +6 -0
- package/dist/core/command-runner.js +2 -0
- package/dist/core/foreground-loop.js +4 -1
- package/dist/core/loop-controller.js +210 -32
- package/dist/daemon/index.js +5 -2
- package/dist/daemon/manager.js +113 -34
- package/dist/daemon/projects.js +104 -0
- package/dist/daemon/server.js +177 -6
- package/dist/daemon/state.js +32 -1
- package/dist/daemon/task-manager.js +58 -0
- package/dist/i18n/en.json +140 -21
- package/dist/loop-config.js +13 -4
- package/dist/shared/clipboard.js +19 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
if (this._status
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
this.
|
|
135
|
+
if (needsStart) {
|
|
136
|
+
this._stopAfterRun = true;
|
|
137
|
+
this._paused = false;
|
|
138
|
+
this._status = "running";
|
|
139
|
+
this.start();
|
|
85
140
|
}
|
|
86
|
-
|
|
87
|
-
this.
|
|
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
|
|
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
|
|
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.
|
|
171
|
-
this.
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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.
|
|
227
|
-
this.
|
|
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.
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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
|
}
|
package/dist/daemon/manager.js
CHANGED
|
@@ -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.
|
|
13
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
stopLoop(id) {
|
|
112
161
|
const entry = this.loops.get(id);
|
|
113
162
|
if (!entry)
|
|
114
163
|
return false;
|
|
115
|
-
entry.controller.
|
|
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
|
}
|