pidnap 0.0.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +1166 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/client.d.mts +169 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +230 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +10 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-crc5neL8.mjs +966 -0
- package/dist/logger-crc5neL8.mjs.map +1 -0
- package/dist/task-list-CIdbB3wM.d.mts +230 -0
- package/dist/task-list-CIdbB3wM.d.mts.map +1 -0
- package/package.json +50 -0
- package/src/api/client.ts +13 -0
- package/src/api/contract.ts +117 -0
- package/src/api/server.ts +180 -0
- package/src/cli.ts +462 -0
- package/src/cron-process.ts +255 -0
- package/src/env-manager.ts +237 -0
- package/src/index.ts +12 -0
- package/src/lazy-process.ts +228 -0
- package/src/logger.ts +108 -0
- package/src/manager.ts +859 -0
- package/src/restarting-process.ts +397 -0
- package/src/task-list.ts +236 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { LazyProcess, type ProcessDefinition, type ProcessState } from "./lazy-process.ts";
|
|
3
|
+
import type { Logger } from "./logger.ts";
|
|
4
|
+
|
|
5
|
+
// Restart policies
|
|
6
|
+
export const RestartPolicySchema = v.picklist([
|
|
7
|
+
"always",
|
|
8
|
+
"on-failure",
|
|
9
|
+
"never",
|
|
10
|
+
"unless-stopped",
|
|
11
|
+
"on-success",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export type RestartPolicy = v.InferOutput<typeof RestartPolicySchema>;
|
|
15
|
+
|
|
16
|
+
// Backoff strategy schema
|
|
17
|
+
export const BackoffStrategySchema = v.union([
|
|
18
|
+
v.object({
|
|
19
|
+
type: v.literal("fixed"),
|
|
20
|
+
delayMs: v.number(),
|
|
21
|
+
}),
|
|
22
|
+
v.object({
|
|
23
|
+
type: v.literal("exponential"),
|
|
24
|
+
initialDelayMs: v.number(),
|
|
25
|
+
maxDelayMs: v.number(),
|
|
26
|
+
multiplier: v.optional(v.number()),
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export type BackoffStrategy = v.InferOutput<typeof BackoffStrategySchema>;
|
|
31
|
+
|
|
32
|
+
// Crash loop detection config schema
|
|
33
|
+
export const CrashLoopConfigSchema = v.object({
|
|
34
|
+
maxRestarts: v.number(),
|
|
35
|
+
windowMs: v.number(),
|
|
36
|
+
backoffMs: v.number(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type CrashLoopConfig = v.InferOutput<typeof CrashLoopConfigSchema>;
|
|
40
|
+
|
|
41
|
+
// Restarting process options schema
|
|
42
|
+
export const RestartingProcessOptionsSchema = v.object({
|
|
43
|
+
restartPolicy: RestartPolicySchema,
|
|
44
|
+
backoff: v.optional(BackoffStrategySchema),
|
|
45
|
+
crashLoop: v.optional(CrashLoopConfigSchema),
|
|
46
|
+
minUptimeMs: v.optional(v.number()),
|
|
47
|
+
maxTotalRestarts: v.optional(v.number()),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type RestartingProcessOptions = v.InferOutput<typeof RestartingProcessOptionsSchema>;
|
|
51
|
+
|
|
52
|
+
// State
|
|
53
|
+
export const RestartingProcessStateSchema = v.picklist([
|
|
54
|
+
"idle",
|
|
55
|
+
"running",
|
|
56
|
+
"restarting",
|
|
57
|
+
"stopping",
|
|
58
|
+
"stopped",
|
|
59
|
+
"crash-loop-backoff",
|
|
60
|
+
"max-restarts-reached",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export type RestartingProcessState = v.InferOutput<typeof RestartingProcessStateSchema>;
|
|
64
|
+
|
|
65
|
+
const DEFAULT_BACKOFF: BackoffStrategy = { type: "fixed", delayMs: 1000 };
|
|
66
|
+
const DEFAULT_CRASH_LOOP: CrashLoopConfig = { maxRestarts: 5, windowMs: 60000, backoffMs: 60000 };
|
|
67
|
+
|
|
68
|
+
export class RestartingProcess {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
private lazyProcess: LazyProcess;
|
|
71
|
+
private definition: ProcessDefinition;
|
|
72
|
+
private options: Required<Omit<RestartingProcessOptions, "maxTotalRestarts">> & {
|
|
73
|
+
maxTotalRestarts?: number;
|
|
74
|
+
};
|
|
75
|
+
private logger: Logger;
|
|
76
|
+
|
|
77
|
+
// State tracking
|
|
78
|
+
private _state: RestartingProcessState = "idle";
|
|
79
|
+
private _restartCount: number = 0;
|
|
80
|
+
private restartTimestamps: number[] = []; // For crash loop detection
|
|
81
|
+
private consecutiveFailures: number = 0; // For exponential backoff
|
|
82
|
+
private lastStartTime: number | null = null;
|
|
83
|
+
private stopRequested: boolean = false;
|
|
84
|
+
private pendingDelayTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
name: string,
|
|
88
|
+
definition: ProcessDefinition,
|
|
89
|
+
options: RestartingProcessOptions,
|
|
90
|
+
logger: Logger,
|
|
91
|
+
) {
|
|
92
|
+
this.name = name;
|
|
93
|
+
this.definition = definition;
|
|
94
|
+
this.logger = logger;
|
|
95
|
+
this.options = {
|
|
96
|
+
restartPolicy: options.restartPolicy,
|
|
97
|
+
backoff: options.backoff ?? DEFAULT_BACKOFF,
|
|
98
|
+
crashLoop: options.crashLoop ?? DEFAULT_CRASH_LOOP,
|
|
99
|
+
minUptimeMs: options.minUptimeMs ?? 0,
|
|
100
|
+
maxTotalRestarts: options.maxTotalRestarts,
|
|
101
|
+
};
|
|
102
|
+
this.lazyProcess = new LazyProcess(name, definition, logger);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get state(): RestartingProcessState {
|
|
106
|
+
return this._state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get restarts(): number {
|
|
110
|
+
return this._restartCount;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
start(): void {
|
|
114
|
+
if (this._state === "running" || this._state === "restarting") {
|
|
115
|
+
throw new Error(`Process "${this.name}" is already ${this._state}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this._state === "stopping") {
|
|
119
|
+
throw new Error(`Process "${this.name}" is currently stopping`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fresh start from terminal states - reset counters
|
|
123
|
+
if (
|
|
124
|
+
this._state === "stopped" ||
|
|
125
|
+
this._state === "idle" ||
|
|
126
|
+
this._state === "max-restarts-reached"
|
|
127
|
+
) {
|
|
128
|
+
this.resetCounters();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.stopRequested = false;
|
|
132
|
+
this.startProcess();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async stop(timeout?: number): Promise<void> {
|
|
136
|
+
this.stopRequested = true;
|
|
137
|
+
|
|
138
|
+
// Clear any pending delays
|
|
139
|
+
if (this.pendingDelayTimeout) {
|
|
140
|
+
clearTimeout(this.pendingDelayTimeout);
|
|
141
|
+
this.pendingDelayTimeout = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
this._state === "idle" ||
|
|
146
|
+
this._state === "stopped" ||
|
|
147
|
+
this._state === "max-restarts-reached"
|
|
148
|
+
) {
|
|
149
|
+
this._state = "stopped";
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._state = "stopping";
|
|
154
|
+
await this.lazyProcess.stop(timeout);
|
|
155
|
+
this._state = "stopped";
|
|
156
|
+
this.logger.info(`RestartingProcess stopped`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async restart(force: boolean = false): Promise<void> {
|
|
160
|
+
// Fresh start from terminal states - reset counters and no delay
|
|
161
|
+
if (
|
|
162
|
+
this._state === "stopped" ||
|
|
163
|
+
this._state === "idle" ||
|
|
164
|
+
this._state === "max-restarts-reached"
|
|
165
|
+
) {
|
|
166
|
+
this.resetCounters();
|
|
167
|
+
this.stopRequested = false;
|
|
168
|
+
this.startProcess();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Stop the current process first
|
|
173
|
+
await this.stop();
|
|
174
|
+
|
|
175
|
+
this.stopRequested = false;
|
|
176
|
+
|
|
177
|
+
if (force) {
|
|
178
|
+
// Force restart - no delay
|
|
179
|
+
this.startProcess();
|
|
180
|
+
} else {
|
|
181
|
+
// Follow normal delay strategy
|
|
182
|
+
const delay = this.calculateDelay();
|
|
183
|
+
if (delay > 0) {
|
|
184
|
+
this._state = "restarting";
|
|
185
|
+
this.logger.info(`Restarting in ${delay}ms`);
|
|
186
|
+
await this.delay(delay);
|
|
187
|
+
if (this.stopRequested) return;
|
|
188
|
+
}
|
|
189
|
+
this.startProcess();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update process definition and optionally restart with new config
|
|
195
|
+
*/
|
|
196
|
+
async reload(
|
|
197
|
+
newDefinition: ProcessDefinition,
|
|
198
|
+
restartImmediately: boolean = true,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
this.logger.info(`Reloading process with new definition`);
|
|
201
|
+
this.definition = newDefinition;
|
|
202
|
+
this.lazyProcess.updateDefinition(newDefinition);
|
|
203
|
+
|
|
204
|
+
if (restartImmediately) {
|
|
205
|
+
// Restart with force=true to apply changes immediately
|
|
206
|
+
await this.restart(true);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Update restart options
|
|
212
|
+
*/
|
|
213
|
+
updateOptions(newOptions: Partial<RestartingProcessOptions>): void {
|
|
214
|
+
this.logger.info(`Updating restart options`);
|
|
215
|
+
this.options = {
|
|
216
|
+
...this.options,
|
|
217
|
+
restartPolicy: newOptions.restartPolicy ?? this.options.restartPolicy,
|
|
218
|
+
backoff: newOptions.backoff ?? this.options.backoff,
|
|
219
|
+
crashLoop: newOptions.crashLoop ?? this.options.crashLoop,
|
|
220
|
+
minUptimeMs: newOptions.minUptimeMs ?? this.options.minUptimeMs,
|
|
221
|
+
maxTotalRestarts: newOptions.maxTotalRestarts ?? this.options.maxTotalRestarts,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private resetCounters(): void {
|
|
226
|
+
this._restartCount = 0;
|
|
227
|
+
this.consecutiveFailures = 0;
|
|
228
|
+
this.restartTimestamps = [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private startProcess(): void {
|
|
232
|
+
this.lastStartTime = Date.now();
|
|
233
|
+
this._state = "running";
|
|
234
|
+
|
|
235
|
+
this.lazyProcess
|
|
236
|
+
.reset()
|
|
237
|
+
.then(() => {
|
|
238
|
+
if (this.stopRequested) return;
|
|
239
|
+
this.lazyProcess.start();
|
|
240
|
+
return this.lazyProcess.waitForExit();
|
|
241
|
+
})
|
|
242
|
+
.then((exitState) => {
|
|
243
|
+
if (!exitState) return;
|
|
244
|
+
if (this.stopRequested && exitState === "error") {
|
|
245
|
+
this._state = "stopped";
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (exitState === "stopped" || exitState === "error") {
|
|
249
|
+
this.handleProcessExit(exitState);
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
.catch((err) => {
|
|
253
|
+
if (this.stopRequested) return;
|
|
254
|
+
this._state = "stopped";
|
|
255
|
+
this.logger.error(`Failed to start process:`, err);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private handleProcessExit(exitState: ProcessState): void {
|
|
260
|
+
if (this.stopRequested) {
|
|
261
|
+
this._state = "stopped";
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const uptime = this.lastStartTime ? Date.now() - this.lastStartTime : 0;
|
|
266
|
+
const wasHealthy = uptime >= this.options.minUptimeMs;
|
|
267
|
+
const exitedWithError = exitState === "error";
|
|
268
|
+
|
|
269
|
+
// Reset consecutive failures if the process ran long enough
|
|
270
|
+
if (wasHealthy) {
|
|
271
|
+
this.consecutiveFailures = 0;
|
|
272
|
+
} else {
|
|
273
|
+
this.consecutiveFailures++;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check if policy allows restart
|
|
277
|
+
if (!this.shouldRestart(exitedWithError)) {
|
|
278
|
+
this._state = "stopped";
|
|
279
|
+
this.logger.info(
|
|
280
|
+
`Process exited, policy "${this.options.restartPolicy}" does not allow restart`,
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check max total restarts
|
|
286
|
+
if (
|
|
287
|
+
this.options.maxTotalRestarts !== undefined &&
|
|
288
|
+
this._restartCount >= this.options.maxTotalRestarts
|
|
289
|
+
) {
|
|
290
|
+
this._state = "max-restarts-reached";
|
|
291
|
+
this.logger.warn(`Max total restarts (${this.options.maxTotalRestarts}) reached`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Record restart timestamp for crash loop detection
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
this.restartTimestamps.push(now);
|
|
298
|
+
|
|
299
|
+
// Check for crash loop
|
|
300
|
+
if (this.isInCrashLoop()) {
|
|
301
|
+
this._state = "crash-loop-backoff";
|
|
302
|
+
this.logger.warn(
|
|
303
|
+
`Crash loop detected (${this.options.crashLoop.maxRestarts} restarts in ${this.options.crashLoop.windowMs}ms), backing off for ${this.options.crashLoop.backoffMs}ms`,
|
|
304
|
+
);
|
|
305
|
+
this.scheduleCrashLoopRecovery();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Schedule restart with delay
|
|
310
|
+
this._restartCount++;
|
|
311
|
+
this.scheduleRestart();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private shouldRestart(exitedWithError: boolean): boolean {
|
|
315
|
+
switch (this.options.restartPolicy) {
|
|
316
|
+
case "always":
|
|
317
|
+
return true;
|
|
318
|
+
case "never":
|
|
319
|
+
return false;
|
|
320
|
+
case "on-failure":
|
|
321
|
+
return exitedWithError;
|
|
322
|
+
case "on-success":
|
|
323
|
+
return !exitedWithError;
|
|
324
|
+
case "unless-stopped":
|
|
325
|
+
return !this.stopRequested;
|
|
326
|
+
default:
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private isInCrashLoop(): boolean {
|
|
332
|
+
const { maxRestarts, windowMs } = this.options.crashLoop;
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
const cutoff = now - windowMs;
|
|
335
|
+
|
|
336
|
+
// Clean up old timestamps
|
|
337
|
+
this.restartTimestamps = this.restartTimestamps.filter((ts) => ts > cutoff);
|
|
338
|
+
|
|
339
|
+
return this.restartTimestamps.length >= maxRestarts;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private calculateDelay(): number {
|
|
343
|
+
const { backoff } = this.options;
|
|
344
|
+
|
|
345
|
+
if (backoff.type === "fixed") {
|
|
346
|
+
return backoff.delayMs;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Exponential backoff
|
|
350
|
+
const multiplier = backoff.multiplier ?? 2;
|
|
351
|
+
const delay = backoff.initialDelayMs * Math.pow(multiplier, this.consecutiveFailures);
|
|
352
|
+
return Math.min(delay, backoff.maxDelayMs);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private scheduleRestart(): void {
|
|
356
|
+
this._state = "restarting";
|
|
357
|
+
const delay = this.calculateDelay();
|
|
358
|
+
|
|
359
|
+
this.logger.info(`Restarting in ${delay}ms (restart #${this._restartCount})`);
|
|
360
|
+
|
|
361
|
+
this.pendingDelayTimeout = setTimeout(() => {
|
|
362
|
+
this.pendingDelayTimeout = null;
|
|
363
|
+
if (this.stopRequested) {
|
|
364
|
+
this._state = "stopped";
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.startProcess();
|
|
368
|
+
}, delay);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private scheduleCrashLoopRecovery(): void {
|
|
372
|
+
const { backoffMs } = this.options.crashLoop;
|
|
373
|
+
|
|
374
|
+
this.pendingDelayTimeout = setTimeout(() => {
|
|
375
|
+
this.pendingDelayTimeout = null;
|
|
376
|
+
if (this.stopRequested) {
|
|
377
|
+
this._state = "stopped";
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Reset crash loop timestamps after backoff
|
|
382
|
+
this.restartTimestamps = [];
|
|
383
|
+
this._restartCount++;
|
|
384
|
+
this.logger.info(`Crash loop backoff complete, restarting (restart #${this._restartCount})`);
|
|
385
|
+
this.startProcess();
|
|
386
|
+
}, backoffMs);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private delay(ms: number): Promise<void> {
|
|
390
|
+
return new Promise((resolve) => {
|
|
391
|
+
this.pendingDelayTimeout = setTimeout(() => {
|
|
392
|
+
this.pendingDelayTimeout = null;
|
|
393
|
+
resolve();
|
|
394
|
+
}, ms);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
package/src/task-list.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { LazyProcess, ProcessDefinitionSchema } from "./lazy-process.ts";
|
|
3
|
+
import type { Logger } from "./logger.ts";
|
|
4
|
+
|
|
5
|
+
// Per-task state
|
|
6
|
+
export const TaskStateSchema = v.picklist(["pending", "running", "completed", "failed", "skipped"]);
|
|
7
|
+
|
|
8
|
+
export type TaskState = v.InferOutput<typeof TaskStateSchema>;
|
|
9
|
+
|
|
10
|
+
// Schema for named process definition
|
|
11
|
+
export const NamedProcessDefinitionSchema = v.object({
|
|
12
|
+
name: v.string(),
|
|
13
|
+
process: ProcessDefinitionSchema,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type NamedProcessDefinition = v.InferOutput<typeof NamedProcessDefinitionSchema>;
|
|
17
|
+
|
|
18
|
+
// A task entry (single or parallel processes) with its state
|
|
19
|
+
export interface TaskEntry {
|
|
20
|
+
id: string; // Unique task ID
|
|
21
|
+
processes: NamedProcessDefinition[]; // Array (length 1 = sequential, >1 = parallel)
|
|
22
|
+
state: TaskState;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Simple TaskList state (just running or not)
|
|
26
|
+
export type TaskListState = "idle" | "running" | "stopped";
|
|
27
|
+
|
|
28
|
+
export class TaskList {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
private _tasks: TaskEntry[] = [];
|
|
31
|
+
private _state: TaskListState = "idle";
|
|
32
|
+
private logger: Logger;
|
|
33
|
+
private logFileResolver?: (processName: string) => string | undefined;
|
|
34
|
+
private taskIdCounter: number = 0;
|
|
35
|
+
private runningProcesses: LazyProcess[] = [];
|
|
36
|
+
private stopRequested: boolean = false;
|
|
37
|
+
private runLoopPromise: Promise<void> | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
name: string,
|
|
41
|
+
logger: Logger,
|
|
42
|
+
initialTasks?: (NamedProcessDefinition | NamedProcessDefinition[])[],
|
|
43
|
+
logFileResolver?: (processName: string) => string | undefined,
|
|
44
|
+
) {
|
|
45
|
+
this.name = name;
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
this.logFileResolver = logFileResolver;
|
|
48
|
+
|
|
49
|
+
// Add initial tasks if provided
|
|
50
|
+
if (initialTasks) {
|
|
51
|
+
for (const task of initialTasks) {
|
|
52
|
+
this.addTask(task);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get state(): TaskListState {
|
|
58
|
+
return this._state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get tasks(): ReadonlyArray<TaskEntry> {
|
|
62
|
+
return this._tasks;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
removeTaskByTarget(target: string | number): TaskEntry {
|
|
66
|
+
const index =
|
|
67
|
+
typeof target === "number" ? target : this._tasks.findIndex((t) => t.id === target);
|
|
68
|
+
if (index < 0 || index >= this._tasks.length) {
|
|
69
|
+
throw new Error(`Task not found: ${target}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const task = this._tasks[index];
|
|
73
|
+
if (task.state === "running") {
|
|
74
|
+
throw new Error(`Cannot remove running task: ${task.id}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this._tasks.splice(index, 1);
|
|
78
|
+
this.logger.info(`Task "${task.id}" removed`);
|
|
79
|
+
return task;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add a single process or parallel processes as a new task
|
|
84
|
+
* @returns The unique task ID
|
|
85
|
+
*/
|
|
86
|
+
addTask(task: NamedProcessDefinition | NamedProcessDefinition[]): string {
|
|
87
|
+
const id = `task-${++this.taskIdCounter}`;
|
|
88
|
+
const processes = Array.isArray(task) ? task : [task];
|
|
89
|
+
|
|
90
|
+
const entry: TaskEntry = {
|
|
91
|
+
id,
|
|
92
|
+
processes,
|
|
93
|
+
state: "pending",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
this._tasks.push(entry);
|
|
97
|
+
this.logger.info(`Task "${id}" added with ${processes.length} process(es)`);
|
|
98
|
+
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Begin executing pending tasks
|
|
104
|
+
*/
|
|
105
|
+
start(): void {
|
|
106
|
+
if (this._state === "running") {
|
|
107
|
+
throw new Error(`TaskList "${this.name}" is already running`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.stopRequested = false;
|
|
111
|
+
this._state = "running";
|
|
112
|
+
this.logger.info(`TaskList started`);
|
|
113
|
+
|
|
114
|
+
// Start the run loop (non-blocking)
|
|
115
|
+
this.runLoopPromise = this.runLoop();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wait until the TaskList becomes idle (all pending tasks completed)
|
|
120
|
+
*/
|
|
121
|
+
async waitUntilIdle(): Promise<void> {
|
|
122
|
+
if (this._state === "idle" || this._state === "stopped") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Wait for the run loop to complete
|
|
127
|
+
if (this.runLoopPromise) {
|
|
128
|
+
await this.runLoopPromise;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Stop execution and mark remaining tasks as skipped
|
|
134
|
+
*/
|
|
135
|
+
async stop(timeout?: number): Promise<void> {
|
|
136
|
+
if (this._state === "idle" || this._state === "stopped") {
|
|
137
|
+
this._state = "stopped";
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.stopRequested = true;
|
|
142
|
+
this.logger.info(`Stopping TaskList...`);
|
|
143
|
+
|
|
144
|
+
// Stop all currently running processes
|
|
145
|
+
const stopPromises = this.runningProcesses.map((p) => p.stop(timeout));
|
|
146
|
+
await Promise.all(stopPromises);
|
|
147
|
+
this.runningProcesses = [];
|
|
148
|
+
|
|
149
|
+
// Mark all pending tasks as skipped
|
|
150
|
+
for (const task of this._tasks) {
|
|
151
|
+
if (task.state === "pending") {
|
|
152
|
+
task.state = "skipped";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Wait for run loop to finish
|
|
157
|
+
if (this.runLoopPromise) {
|
|
158
|
+
await this.runLoopPromise;
|
|
159
|
+
this.runLoopPromise = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this._state = "stopped";
|
|
163
|
+
this.logger.info(`TaskList stopped`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async runLoop(): Promise<void> {
|
|
167
|
+
while (this._state === "running" && !this.stopRequested) {
|
|
168
|
+
// Find the next pending task
|
|
169
|
+
const nextTask = this._tasks.find((t) => t.state === "pending");
|
|
170
|
+
|
|
171
|
+
if (!nextTask) {
|
|
172
|
+
// No more pending tasks, go back to idle
|
|
173
|
+
this._state = "idle";
|
|
174
|
+
this.logger.info(`All tasks completed, TaskList is idle`);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await this.executeTask(nextTask);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async executeTask(task: TaskEntry): Promise<void> {
|
|
183
|
+
if (this.stopRequested) {
|
|
184
|
+
task.state = "skipped";
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
task.state = "running";
|
|
189
|
+
const taskNames = task.processes.map((p) => p.name).join(", ");
|
|
190
|
+
this.logger.info(`Executing task "${task.id}": [${taskNames}]`);
|
|
191
|
+
|
|
192
|
+
// Create LazyProcess instances for each process in the task
|
|
193
|
+
const lazyProcesses: LazyProcess[] = task.processes.map((p) => {
|
|
194
|
+
const logFile = this.logFileResolver?.(p.name);
|
|
195
|
+
const childLogger = logFile
|
|
196
|
+
? this.logger.child(p.name, { logFile })
|
|
197
|
+
: this.logger.child(p.name);
|
|
198
|
+
return new LazyProcess(p.name, p.process, childLogger);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.runningProcesses = lazyProcesses;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// Start all processes (parallel if multiple)
|
|
205
|
+
for (const lp of lazyProcesses) {
|
|
206
|
+
lp.start();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Wait for all processes to complete
|
|
210
|
+
const results = await Promise.all(lazyProcesses.map((lp) => this.waitForProcess(lp)));
|
|
211
|
+
|
|
212
|
+
// Check if any failed
|
|
213
|
+
const anyFailed = results.some((r) => r === "error");
|
|
214
|
+
|
|
215
|
+
if (this.stopRequested) {
|
|
216
|
+
task.state = "skipped";
|
|
217
|
+
} else if (anyFailed) {
|
|
218
|
+
task.state = "failed";
|
|
219
|
+
this.logger.warn(`Task "${task.id}" failed`);
|
|
220
|
+
} else {
|
|
221
|
+
task.state = "completed";
|
|
222
|
+
this.logger.info(`Task "${task.id}" completed`);
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
task.state = "failed";
|
|
226
|
+
this.logger.error(`Task "${task.id}" error:`, err);
|
|
227
|
+
} finally {
|
|
228
|
+
this.runningProcesses = [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async waitForProcess(lp: LazyProcess): Promise<"stopped" | "error"> {
|
|
233
|
+
const state = await lp.waitForExit();
|
|
234
|
+
return state === "error" ? "error" : "stopped";
|
|
235
|
+
}
|
|
236
|
+
}
|