pid1 0.0.0-dev.0 → 0.0.0-dev.3

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 (42) hide show
  1. package/README.md +45 -0
  2. package/dist/cli.mjs +1147 -100
  3. package/dist/cli.mjs.map +1 -1
  4. package/dist/client.d.mts +169 -2
  5. package/dist/client.d.mts.map +1 -0
  6. package/dist/client.mjs +6 -3
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +226 -32
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +9 -4
  11. package/dist/index.mjs.map +1 -0
  12. package/dist/logger-BN9KoHBY.mjs +926 -0
  13. package/dist/logger-BN9KoHBY.mjs.map +1 -0
  14. package/dist/task-list-zO8UZG5r.d.mts +231 -0
  15. package/dist/task-list-zO8UZG5r.d.mts.map +1 -0
  16. package/package.json +18 -19
  17. package/src/api/client.ts +7 -14
  18. package/src/api/contract.ts +117 -0
  19. package/src/api/server.ts +171 -57
  20. package/src/cli.ts +444 -150
  21. package/src/cron-process.ts +255 -0
  22. package/src/env-manager.ts +237 -0
  23. package/src/index.ts +12 -14
  24. package/src/lazy-process.ts +198 -0
  25. package/src/logger.ts +90 -137
  26. package/src/manager.ts +859 -0
  27. package/src/restarting-process.ts +397 -0
  28. package/src/task-list.ts +236 -0
  29. package/dist/client-Bdt88RU-.d.mts +0 -217
  30. package/dist/client-Bdt88RU-.d.mts.map +0 -1
  31. package/dist/config-BgRb4pSG.mjs +0 -186
  32. package/dist/config-BgRb4pSG.mjs.map +0 -1
  33. package/dist/config.d.mts +0 -84
  34. package/dist/config.d.mts.map +0 -1
  35. package/dist/config.mjs +0 -3
  36. package/dist/process-manager-gO56266Q.mjs +0 -643
  37. package/dist/process-manager-gO56266Q.mjs.map +0 -1
  38. package/src/api/router.ts +0 -150
  39. package/src/config.ts +0 -89
  40. package/src/env.ts +0 -74
  41. package/src/exec.ts +0 -85
  42. package/src/process-manager.ts +0 -632
@@ -0,0 +1,255 @@
1
+ import { Cron } from "croner";
2
+ import * as v from "valibot";
3
+ import { LazyProcess, type ProcessDefinition } from "./lazy-process.ts";
4
+ import type { Logger } from "./logger.ts";
5
+
6
+ // Retry configuration schema
7
+ export const RetryConfigSchema = v.object({
8
+ maxRetries: v.number(),
9
+ delayMs: v.optional(v.number()),
10
+ });
11
+
12
+ export type RetryConfig = v.InferOutput<typeof RetryConfigSchema>;
13
+
14
+ // Cron process options schema
15
+ export const CronProcessOptionsSchema = v.object({
16
+ schedule: v.string(),
17
+ retry: v.optional(RetryConfigSchema),
18
+ runOnStart: v.optional(v.boolean()),
19
+ });
20
+
21
+ export type CronProcessOptions = v.InferOutput<typeof CronProcessOptionsSchema>;
22
+
23
+ // State
24
+ export const CronProcessStateSchema = v.picklist([
25
+ "idle",
26
+ "scheduled",
27
+ "running",
28
+ "retrying",
29
+ "queued",
30
+ "stopping",
31
+ "stopped",
32
+ ]);
33
+
34
+ export type CronProcessState = v.InferOutput<typeof CronProcessStateSchema>;
35
+
36
+ const DEFAULT_RETRY_DELAY = 1000;
37
+
38
+ export class CronProcess {
39
+ readonly name: string;
40
+ private lazyProcess: LazyProcess;
41
+ private options: CronProcessOptions;
42
+ private logger: Logger;
43
+ private cronJob: Cron | null = null;
44
+
45
+ // State tracking
46
+ private _state: CronProcessState = "idle";
47
+ private _runCount: number = 0;
48
+ private _failCount: number = 0;
49
+ private currentRetryAttempt: number = 0;
50
+ private queuedRun: boolean = false;
51
+ private stopRequested: boolean = false;
52
+ private retryTimeout: ReturnType<typeof setTimeout> | null = null;
53
+
54
+ constructor(
55
+ name: string,
56
+ definition: ProcessDefinition,
57
+ options: CronProcessOptions,
58
+ logger: Logger,
59
+ ) {
60
+ this.name = name;
61
+ this.options = options;
62
+ this.logger = logger;
63
+ this.lazyProcess = new LazyProcess(name, definition, logger);
64
+ }
65
+
66
+ get state(): CronProcessState {
67
+ return this._state;
68
+ }
69
+
70
+ get runCount(): number {
71
+ return this._runCount;
72
+ }
73
+
74
+ get failCount(): number {
75
+ return this._failCount;
76
+ }
77
+
78
+ get nextRun(): Date | null {
79
+ if (!this.cronJob) return null;
80
+ const next = this.cronJob.nextRun();
81
+ return next ?? null;
82
+ }
83
+
84
+ start(): void {
85
+ if (this._state === "scheduled" || this._state === "running" || this._state === "queued") {
86
+ throw new Error(`CronProcess "${this.name}" is already ${this._state}`);
87
+ }
88
+
89
+ if (this._state === "stopping") {
90
+ throw new Error(`CronProcess "${this.name}" is currently stopping`);
91
+ }
92
+
93
+ this.stopRequested = false;
94
+ this.logger.info(`Starting cron schedule: ${this.options.schedule}`);
95
+
96
+ // Create cron job with UTC timezone
97
+ this.cronJob = new Cron(this.options.schedule, { timezone: "UTC" }, () => {
98
+ this.onCronTick();
99
+ });
100
+
101
+ this._state = "scheduled";
102
+
103
+ // Run immediately if configured
104
+ if (this.options.runOnStart) {
105
+ this.executeJob();
106
+ }
107
+ }
108
+
109
+ async stop(timeout?: number): Promise<void> {
110
+ this.stopRequested = true;
111
+
112
+ // Stop the cron job
113
+ if (this.cronJob) {
114
+ this.cronJob.stop();
115
+ this.cronJob = null;
116
+ }
117
+
118
+ // Clear any pending retry timeout
119
+ if (this.retryTimeout) {
120
+ clearTimeout(this.retryTimeout);
121
+ this.retryTimeout = null;
122
+ }
123
+
124
+ if (this._state === "idle" || this._state === "stopped") {
125
+ this._state = "stopped";
126
+ return;
127
+ }
128
+
129
+ // If running, stop the current job
130
+ if (this._state === "running" || this._state === "retrying" || this._state === "queued") {
131
+ this._state = "stopping";
132
+ await this.lazyProcess.stop(timeout);
133
+ }
134
+
135
+ this._state = "stopped";
136
+ this.queuedRun = false;
137
+ this.logger.info(`CronProcess stopped`);
138
+ }
139
+
140
+ async trigger(): Promise<void> {
141
+ if (this.stopRequested) {
142
+ throw new Error(`CronProcess "${this.name}" is stopped`);
143
+ }
144
+
145
+ // If already queued, just return (already have a run pending)
146
+ if (this._state === "queued") {
147
+ return;
148
+ }
149
+
150
+ // If already running, queue this trigger
151
+ if (this._state === "running" || this._state === "retrying") {
152
+ this.queuedRun = true;
153
+ this._state = "queued";
154
+ this.logger.info(`Run queued (current job still running)`);
155
+ return;
156
+ }
157
+
158
+ await this.executeJob();
159
+ }
160
+
161
+ private onCronTick(): void {
162
+ if (this.stopRequested) return;
163
+
164
+ // If already running, queue the next run
165
+ if (this._state === "running" || this._state === "retrying" || this._state === "queued") {
166
+ this.queuedRun = true;
167
+ if (this._state !== "queued") {
168
+ this._state = "queued";
169
+ }
170
+ this.logger.info(`Cron tick: run queued (current job still running)`);
171
+ return;
172
+ }
173
+
174
+ this.executeJob();
175
+ }
176
+
177
+ private async executeJob(): Promise<void> {
178
+ if (this.stopRequested) return;
179
+
180
+ this._state = "running";
181
+ this.currentRetryAttempt = 0;
182
+ this.logger.info(`Executing job`);
183
+
184
+ await this.runJobWithRetry();
185
+ }
186
+
187
+ private async runJobWithRetry(): Promise<void> {
188
+ if (this.stopRequested) return;
189
+
190
+ // Reset and start the process
191
+ await this.lazyProcess.reset();
192
+ this.lazyProcess.start();
193
+
194
+ const exitState = await this.lazyProcess.waitForExit();
195
+ if (this.stopRequested && exitState === "error") {
196
+ this._state = "stopped";
197
+ return;
198
+ }
199
+ this.handleJobComplete(exitState === "error");
200
+ }
201
+
202
+ private handleJobComplete(failed: boolean): void {
203
+ if (this.stopRequested) {
204
+ this._state = "stopped";
205
+ return;
206
+ }
207
+
208
+ if (failed) {
209
+ const maxRetries = this.options.retry?.maxRetries ?? 0;
210
+
211
+ if (this.currentRetryAttempt < maxRetries) {
212
+ // Retry
213
+ this.currentRetryAttempt++;
214
+ this._state = "retrying";
215
+ const delayMs = this.options.retry?.delayMs ?? DEFAULT_RETRY_DELAY;
216
+
217
+ this.logger.warn(
218
+ `Job failed, retrying in ${delayMs}ms (attempt ${this.currentRetryAttempt}/${maxRetries})`,
219
+ );
220
+
221
+ this.retryTimeout = setTimeout(() => {
222
+ this.retryTimeout = null;
223
+ if (this.stopRequested) {
224
+ this._state = "stopped";
225
+ return;
226
+ }
227
+ this.runJobWithRetry();
228
+ }, delayMs);
229
+ return;
230
+ }
231
+
232
+ // All retries exhausted
233
+ this._failCount++;
234
+ this.logger.error(`Job failed after ${this.currentRetryAttempt} retries`);
235
+ } else {
236
+ this._runCount++;
237
+ this.logger.info(`Job completed successfully`);
238
+ }
239
+
240
+ // Check for queued run
241
+ if (this.queuedRun) {
242
+ this.queuedRun = false;
243
+ this.logger.info(`Starting queued run`);
244
+ this.executeJob();
245
+ return;
246
+ }
247
+
248
+ // Back to scheduled state
249
+ if (this.cronJob) {
250
+ this._state = "scheduled";
251
+ } else {
252
+ this._state = "stopped";
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,237 @@
1
+ import { parse } from "dotenv";
2
+ import { existsSync, globSync, watch, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve, join, basename } from "node:path";
5
+
6
+ export type EnvChangeCallback = (changedKeys: string[]) => void;
7
+
8
+ export interface EnvManagerConfig {
9
+ /**
10
+ * Directory to search for .env files
11
+ * Defaults to process.cwd()
12
+ */
13
+ cwd?: string;
14
+
15
+ /**
16
+ * Explicit env file paths to load
17
+ * Key is the identifier (e.g., "global", "app1")
18
+ * Value is the file path relative to cwd or absolute
19
+ */
20
+ files?: Record<string, string>;
21
+
22
+ /**
23
+ * Enable file watching for env files
24
+ * Defaults to false
25
+ */
26
+ watch?: boolean;
27
+ }
28
+
29
+ export class EnvManager {
30
+ private env: Map<string, Record<string, string>> = new Map();
31
+ private cwd: string;
32
+ private watchEnabled: boolean;
33
+ private watchers: Map<string, ReturnType<typeof watch>> = new Map();
34
+ private fileToKeys: Map<string, Set<string>> = new Map();
35
+ private changeCallbacks: Set<EnvChangeCallback> = new Set();
36
+ private reloadDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
37
+
38
+ constructor(config: EnvManagerConfig = {}) {
39
+ this.cwd = config.cwd ?? process.cwd();
40
+ this.watchEnabled = config.watch ?? false;
41
+
42
+ // Load .env and .env.* files from cwd
43
+ this.loadEnvFilesFromCwd();
44
+
45
+ // Load explicitly specified files
46
+ if (config.files) {
47
+ for (const [key, filePath] of Object.entries(config.files)) {
48
+ this.loadEnvFile(key, filePath);
49
+ }
50
+ }
51
+ }
52
+
53
+ registerFile(key: string, filePath: string): void {
54
+ this.loadEnvFile(key, filePath);
55
+ }
56
+
57
+ getEnvForKey(key: string): Record<string, string> {
58
+ return this.env.get(key) ?? {};
59
+ }
60
+
61
+ /**
62
+ * Load .env and .env.* files from the cwd
63
+ */
64
+ private loadEnvFilesFromCwd(): void {
65
+ // Load .env file as global
66
+ const dotEnvPath = resolve(this.cwd, ".env");
67
+ if (existsSync(dotEnvPath)) {
68
+ this.loadEnvFile("global", dotEnvPath);
69
+ }
70
+
71
+ // Load .env.* files
72
+ try {
73
+ const pattern = join(this.cwd, ".env.*");
74
+ const envFiles = globSync(pattern);
75
+
76
+ for (const filePath of envFiles) {
77
+ // Extract the suffix after .env.
78
+ const fileName = basename(filePath);
79
+ const match = fileName.match(/^\.env\.(.+)$/);
80
+ if (match) {
81
+ const suffix = match[1];
82
+ this.loadEnvFile(suffix, filePath);
83
+ }
84
+ }
85
+ } catch (err) {
86
+ console.warn("Failed to scan env files:", err);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load a single env file and store it in the map
92
+ */
93
+ private loadEnvFile(key: string, filePath: string): void {
94
+ const absolutePath = resolve(this.cwd, filePath);
95
+
96
+ if (!existsSync(absolutePath)) {
97
+ return; // Silently skip non-existent files
98
+ }
99
+
100
+ try {
101
+ const content = readFileSync(absolutePath, "utf-8");
102
+ const parsed = parse(content);
103
+ this.env.set(key, parsed);
104
+
105
+ // Track which file maps to which key
106
+ if (!this.fileToKeys.has(absolutePath)) {
107
+ this.fileToKeys.set(absolutePath, new Set());
108
+ }
109
+ this.fileToKeys.get(absolutePath)!.add(key);
110
+
111
+ // Start watching if enabled and not already watching
112
+ if (this.watchEnabled && !this.watchers.has(absolutePath)) {
113
+ this.watchFile(absolutePath);
114
+ }
115
+ } catch (err) {
116
+ console.warn(`Failed to load env file: ${absolutePath}`, err);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Watch a file for changes
122
+ */
123
+ private watchFile(absolutePath: string): void {
124
+ try {
125
+ const watcher = watch(absolutePath, (eventType) => {
126
+ if (eventType === "change") {
127
+ this.handleFileChange(absolutePath);
128
+ }
129
+ });
130
+
131
+ this.watchers.set(absolutePath, watcher);
132
+ } catch (err) {
133
+ console.warn(`Failed to watch env file: ${absolutePath}`, err);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Handle file change with debouncing
139
+ */
140
+ private handleFileChange(absolutePath: string): void {
141
+ // Clear existing timer if any
142
+ const existingTimer = this.reloadDebounceTimers.get(absolutePath);
143
+ if (existingTimer) {
144
+ clearTimeout(existingTimer);
145
+ }
146
+
147
+ // Debounce reload by 100ms to avoid multiple rapid reloads
148
+ const timer = setTimeout(() => {
149
+ this.reloadFile(absolutePath);
150
+ this.reloadDebounceTimers.delete(absolutePath);
151
+ }, 100);
152
+
153
+ this.reloadDebounceTimers.set(absolutePath, timer);
154
+ }
155
+
156
+ /**
157
+ * Reload a file and notify callbacks
158
+ */
159
+ private reloadFile(absolutePath: string): void {
160
+ const keys = this.fileToKeys.get(absolutePath);
161
+ if (!keys) return;
162
+
163
+ readFile(absolutePath, "utf-8")
164
+ .then((content) => parse(content))
165
+ .then((parsed) => {
166
+ const changedKeys: string[] = [];
167
+ for (const key of keys) {
168
+ this.env.set(key, parsed);
169
+ changedKeys.push(key);
170
+ }
171
+
172
+ // Notify all callbacks
173
+ if (changedKeys.length > 0) {
174
+ for (const callback of this.changeCallbacks) {
175
+ callback(changedKeys);
176
+ }
177
+ }
178
+ })
179
+ .catch((err) => {
180
+ console.warn(`Failed to reload env file: ${absolutePath}`, err);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Register a callback to be called when env files change
186
+ * Returns a function to unregister the callback
187
+ */
188
+ onChange(callback: EnvChangeCallback): () => void {
189
+ this.changeCallbacks.add(callback);
190
+ return () => {
191
+ this.changeCallbacks.delete(callback);
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Stop watching all files and cleanup
197
+ */
198
+ dispose(): void {
199
+ // Clear all timers
200
+ for (const timer of this.reloadDebounceTimers.values()) {
201
+ clearTimeout(timer);
202
+ }
203
+ this.reloadDebounceTimers.clear();
204
+
205
+ // Close all watchers
206
+ for (const watcher of this.watchers.values()) {
207
+ watcher.close();
208
+ }
209
+ this.watchers.clear();
210
+
211
+ // Clear callbacks
212
+ this.changeCallbacks.clear();
213
+ }
214
+
215
+ /**
216
+ * Get environment variables for a specific process
217
+ * Merges global env with process-specific env
218
+ * Process-specific env variables override global ones
219
+ */
220
+ getEnvVars(processKey?: string): Record<string, string> {
221
+ const globalEnv = this.env.get("global") ?? {};
222
+
223
+ if (!processKey) {
224
+ return { ...globalEnv };
225
+ }
226
+
227
+ const processEnv = this.env.get(processKey) ?? {};
228
+ return { ...globalEnv, ...processEnv };
229
+ }
230
+
231
+ /**
232
+ * Get all loaded env maps (for debugging/inspection)
233
+ */
234
+ getAllEnv(): ReadonlyMap<string, Record<string, string>> {
235
+ return this.env;
236
+ }
237
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,12 @@
1
- export {
2
- ProcessManager,
3
- type ProcessManagerOptions,
4
- type ProcessStatus,
5
- type ProcessConfig,
6
- type ManagedProcess,
7
- type ProcessInfo,
8
- } from "./process-manager.ts";
9
- export { defineConfig, loadConfigFile } from "./config.ts";
10
- export { globalLogger } from "./logger.ts";
11
- export { spawnWithLogging, type SpawnedProcess } from "./exec.ts";
12
- export { createServer, type ServerOptions, type Router } from "./api/server.ts";
13
- export { createClient, type Client, type ClientOptions } from "./api/client.ts";
14
- export { router } from "./api/router.ts";
1
+ import type { ManagerConfig } from "./manager.ts";
2
+
3
+ export function defineConfig(config: ManagerConfig) {
4
+ return config;
5
+ }
6
+
7
+ export * from "./restarting-process.ts";
8
+ export * from "./cron-process.ts";
9
+ export * from "./task-list.ts";
10
+ export * from "./lazy-process.ts";
11
+ export * from "./env-manager.ts";
12
+ export * from "./logger.ts";
@@ -0,0 +1,198 @@
1
+ import { x, type Result } from "tinyexec";
2
+ import * as v from "valibot";
3
+ import type { Logger } from "./logger.ts";
4
+
5
+ export const ProcessDefinitionSchema = v.object({
6
+ command: v.string(),
7
+ args: v.optional(v.array(v.string())),
8
+ cwd: v.optional(v.string()),
9
+ env: v.optional(v.record(v.string(), v.string())),
10
+ });
11
+
12
+ export type ProcessDefinition = v.InferOutput<typeof ProcessDefinitionSchema>;
13
+
14
+ export const ProcessStateSchema = v.picklist([
15
+ "idle",
16
+ "starting",
17
+ "running",
18
+ "stopping",
19
+ "stopped",
20
+ "error",
21
+ ]);
22
+
23
+ export type ProcessState = v.InferOutput<typeof ProcessStateSchema>;
24
+
25
+ export class LazyProcess {
26
+ readonly name: string;
27
+ private definition: ProcessDefinition;
28
+ private logger: Logger;
29
+ private process: Result | null = null;
30
+ private _state: ProcessState = "idle";
31
+ private outputLoopPromise: Promise<void> | null = null;
32
+ private donePromise: Promise<ProcessState> | null = null;
33
+
34
+ constructor(name: string, definition: ProcessDefinition, logger: Logger) {
35
+ this.name = name;
36
+ this.definition = definition;
37
+ this.logger = logger;
38
+ }
39
+
40
+ get state(): ProcessState {
41
+ return this._state;
42
+ }
43
+
44
+ start(): void {
45
+ if (this._state === "running" || this._state === "starting") {
46
+ throw new Error(`Process "${this.name}" is already ${this._state}`);
47
+ }
48
+
49
+ if (this._state === "stopping") {
50
+ throw new Error(`Process "${this.name}" is currently stopping`);
51
+ }
52
+
53
+ this._state = "starting";
54
+ this.logger.info(`Starting process: ${this.definition.command}`);
55
+
56
+ try {
57
+ this.process = x(this.definition.command, this.definition.args ?? [], {
58
+ nodeOptions: {
59
+ cwd: this.definition.cwd,
60
+ env: this.definition.env ? { ...process.env, ...this.definition.env } : undefined,
61
+ },
62
+ });
63
+
64
+ this._state = "running";
65
+
66
+ // Start async output logging loop
67
+ this.outputLoopPromise = this.logOutput();
68
+
69
+ // Handle process completion
70
+ this.donePromise = Promise.resolve(this.process)
71
+ .then((result) => {
72
+ if (this._state === "running") {
73
+ if (result.exitCode === 0) {
74
+ this._state = "stopped";
75
+ this.logger.info(`Process exited with code ${result.exitCode}`);
76
+ } else {
77
+ this._state = "error";
78
+ this.logger.error(`Process exited with code ${result.exitCode}`);
79
+ }
80
+ }
81
+ return this._state;
82
+ })
83
+ .catch((err) => {
84
+ if (this._state !== "stopping" && this._state !== "stopped") {
85
+ this._state = "error";
86
+ this.logger.error(`Process error:`, err);
87
+ }
88
+ return this._state;
89
+ });
90
+ } catch (err) {
91
+ this._state = "error";
92
+ this.logger.error(`Failed to start process:`, err);
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ async stop(timeout?: number): Promise<void> {
98
+ if (this._state === "idle" || this._state === "stopped" || this._state === "error") {
99
+ return;
100
+ }
101
+
102
+ if (this._state === "stopping") {
103
+ // Already stopping, wait for completion
104
+ await this.waitForStop();
105
+ return;
106
+ }
107
+
108
+ if (!this.process) {
109
+ this._state = "stopped";
110
+ return;
111
+ }
112
+
113
+ this._state = "stopping";
114
+ this.logger.info(`Stopping process with SIGTERM`);
115
+
116
+ // Send SIGTERM for graceful shutdown
117
+ this.process.kill("SIGTERM");
118
+
119
+ const timeoutMs = timeout ?? 5000;
120
+
121
+ // Wait for process to exit or timeout
122
+ // Wrap PromiseLike in Promise.resolve to get proper Promise with .catch()
123
+ const exitPromise = Promise.resolve(this.process).catch(() => {});
124
+
125
+ const timeoutPromise = new Promise<"timeout">((resolve) =>
126
+ setTimeout(() => resolve("timeout"), timeoutMs),
127
+ );
128
+
129
+ const result = await Promise.race([exitPromise.then(() => "exited" as const), timeoutPromise]);
130
+
131
+ if (result === "timeout") {
132
+ this.logger.warn(`Process did not exit within ${timeoutMs}ms, sending SIGKILL`);
133
+ this.process.kill("SIGKILL");
134
+ await Promise.resolve(this.process).catch(() => {});
135
+ }
136
+
137
+ // Wait for output loop to finish
138
+ if (this.outputLoopPromise) {
139
+ await this.outputLoopPromise.catch(() => {});
140
+ }
141
+
142
+ this._state = "stopped";
143
+ this.process = null;
144
+ this.outputLoopPromise = null;
145
+ this.logger.info(`Process stopped`);
146
+ }
147
+
148
+ async reset(): Promise<void> {
149
+ if (this._state !== "idle" && this._state !== "stopped") {
150
+ await this.stop();
151
+ }
152
+
153
+ this._state = "idle";
154
+ this.process = null;
155
+ this.outputLoopPromise = null;
156
+ this.donePromise = null;
157
+ this.logger.info(`Process reset to idle`);
158
+ }
159
+
160
+ updateDefinition(definition: ProcessDefinition): void {
161
+ this.definition = definition;
162
+ }
163
+
164
+ async waitForExit(): Promise<ProcessState> {
165
+ if (!this.process || !this.donePromise) {
166
+ return this._state;
167
+ }
168
+
169
+ await this.donePromise.catch(() => {});
170
+
171
+ if (this.outputLoopPromise) {
172
+ await this.outputLoopPromise.catch(() => {});
173
+ }
174
+
175
+ return this._state;
176
+ }
177
+
178
+ private async logOutput(): Promise<void> {
179
+ if (!this.process) return;
180
+
181
+ try {
182
+ for await (const line of this.process) {
183
+ this.logger.info(line);
184
+ }
185
+ } catch {
186
+ // Process may have been killed, ignore iteration errors
187
+ }
188
+ }
189
+
190
+ private async waitForStop(): Promise<void> {
191
+ if (this.process) {
192
+ await Promise.resolve(this.process).catch(() => {});
193
+ }
194
+ if (this.outputLoopPromise) {
195
+ await this.outputLoopPromise.catch(() => {});
196
+ }
197
+ }
198
+ }