@aliou/pi-processes 0.6.4 → 0.7.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 +49 -0
- package/package.json +6 -5
- package/src/commands/kill/command.ts +6 -6
- package/src/commands/logs/command.ts +1 -1
- package/src/commands/pin/command.ts +2 -1
- package/src/components/log-dock-component.ts +0 -11
- package/src/components/log-overlay-component.ts +0 -9
- package/src/components/processes-component.ts +32 -16
- package/src/constants/index.ts +4 -0
- package/src/constants/types.ts +36 -1
- package/src/hooks/index.ts +2 -0
- package/src/hooks/message-renderer.ts +35 -3
- package/src/hooks/process-end.ts +2 -0
- package/src/hooks/process-watch.ts +83 -0
- package/src/hooks/widget/setup.ts +0 -4
- package/src/manager.test.ts +331 -0
- package/src/manager.ts +214 -30
- package/src/tools/actions/debug.ts +148 -0
- package/src/tools/actions/index.ts +126 -10
- package/src/tools/actions/kill.ts +14 -1
- package/src/tools/actions/list.ts +153 -2
- package/src/tools/actions/logs.ts +61 -3
- package/src/tools/actions/output.ts +129 -4
- package/src/tools/actions/start.ts +197 -8
- package/src/tools/actions/write.ts +28 -2
- package/src/tools/index.ts +95 -247
- package/src/utils/command-executor.ts +3 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/index.ts +7 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { ManagerEvent } from "./constants";
|
|
3
|
+
import { ProcessManager } from "./manager";
|
|
4
|
+
|
|
5
|
+
function waitForEnd(manager: ProcessManager, id: string): Promise<void> {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const unsub = manager.onEvent((e) => {
|
|
8
|
+
if (e.type === "process_ended" && e.info.id === id) {
|
|
9
|
+
unsub();
|
|
10
|
+
resolve();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collectEvents(manager: ProcessManager): ManagerEvent[] {
|
|
17
|
+
const events: ManagerEvent[] = [];
|
|
18
|
+
// Unsubscribe not stored; manager.cleanup() in afterEach clears all listeners.
|
|
19
|
+
manager.onEvent((e) => events.push(e));
|
|
20
|
+
return events;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("process_output_changed", () => {
|
|
24
|
+
let manager: ProcessManager;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
manager.cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("emits process_output_changed on stdout", async () => {
|
|
31
|
+
manager = new ProcessManager();
|
|
32
|
+
const events = collectEvents(manager);
|
|
33
|
+
const info = manager.start("test", "echo hello", "/tmp");
|
|
34
|
+
await waitForEnd(manager, info.id);
|
|
35
|
+
|
|
36
|
+
const outputEvents = events.filter(
|
|
37
|
+
(e) => e.type === "process_output_changed",
|
|
38
|
+
);
|
|
39
|
+
expect(outputEvents.length).toBeGreaterThanOrEqual(1);
|
|
40
|
+
expect(outputEvents[0]).toEqual({
|
|
41
|
+
type: "process_output_changed",
|
|
42
|
+
id: info.id,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("emits process_output_changed on stderr", async () => {
|
|
47
|
+
manager = new ProcessManager();
|
|
48
|
+
const events = collectEvents(manager);
|
|
49
|
+
const info = manager.start("test", "echo err >&2", "/tmp");
|
|
50
|
+
await waitForEnd(manager, info.id);
|
|
51
|
+
|
|
52
|
+
const outputEvents = events.filter(
|
|
53
|
+
(e) => e.type === "process_output_changed",
|
|
54
|
+
);
|
|
55
|
+
expect(outputEvents.length).toBeGreaterThanOrEqual(1);
|
|
56
|
+
expect(outputEvents[0]).toEqual({
|
|
57
|
+
type: "process_output_changed",
|
|
58
|
+
id: info.id,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throttles rapid output", async () => {
|
|
63
|
+
manager = new ProcessManager();
|
|
64
|
+
const events = collectEvents(manager);
|
|
65
|
+
const info = manager.start("test", "seq 1 200", "/tmp");
|
|
66
|
+
await waitForEnd(manager, info.id);
|
|
67
|
+
|
|
68
|
+
const outputEvents = events.filter(
|
|
69
|
+
(e) => e.type === "process_output_changed",
|
|
70
|
+
);
|
|
71
|
+
// Should be significantly fewer than 200 due to throttling
|
|
72
|
+
expect(outputEvents.length).toBeGreaterThanOrEqual(1);
|
|
73
|
+
expect(outputEvents.length).toBeLessThan(50);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("stdout and stderr share one throttle bucket", async () => {
|
|
77
|
+
manager = new ProcessManager();
|
|
78
|
+
|
|
79
|
+
// Dual-stream burst: writes to both stdout and stderr rapidly
|
|
80
|
+
const events2 = collectEvents(manager);
|
|
81
|
+
const info2 = manager.start(
|
|
82
|
+
"dual",
|
|
83
|
+
"bash -c 'for i in $(seq 1 50); do echo out$i; echo err$i >&2; done'",
|
|
84
|
+
"/tmp",
|
|
85
|
+
);
|
|
86
|
+
await waitForEnd(manager, info2.id);
|
|
87
|
+
const dualCount = events2.filter(
|
|
88
|
+
(e) => e.type === "process_output_changed",
|
|
89
|
+
).length;
|
|
90
|
+
|
|
91
|
+
// Both streams share one throttle bucket, so total events should be low
|
|
92
|
+
expect(dualCount).toBeLessThan(30);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("trailing emit fires after burst ends", async () => {
|
|
96
|
+
manager = new ProcessManager();
|
|
97
|
+
const events = collectEvents(manager);
|
|
98
|
+
const info = manager.start("test", "seq 1 100", "/tmp");
|
|
99
|
+
await waitForEnd(manager, info.id);
|
|
100
|
+
|
|
101
|
+
// There should be at least one output event, and a process_ended event
|
|
102
|
+
const outputEvents = events.filter(
|
|
103
|
+
(e) => e.type === "process_output_changed",
|
|
104
|
+
);
|
|
105
|
+
const endEvents = events.filter((e) => e.type === "process_ended");
|
|
106
|
+
expect(outputEvents.length).toBeGreaterThanOrEqual(1);
|
|
107
|
+
expect(endEvents.length).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("final output event before process_ended", async () => {
|
|
111
|
+
manager = new ProcessManager();
|
|
112
|
+
const events = collectEvents(manager);
|
|
113
|
+
const info = manager.start("test", "echo hello", "/tmp");
|
|
114
|
+
await waitForEnd(manager, info.id);
|
|
115
|
+
|
|
116
|
+
let lastOutputIdx = -1;
|
|
117
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
118
|
+
if (events[i].type === "process_output_changed") {
|
|
119
|
+
lastOutputIdx = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const endIdx = events.findIndex((e) => e.type === "process_ended");
|
|
124
|
+
|
|
125
|
+
expect(lastOutputIdx).toBeGreaterThanOrEqual(0);
|
|
126
|
+
expect(endIdx).toBeGreaterThanOrEqual(0);
|
|
127
|
+
expect(lastOutputIdx).toBeLessThan(endIdx);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("no output events for silent process", async () => {
|
|
131
|
+
manager = new ProcessManager();
|
|
132
|
+
const events = collectEvents(manager);
|
|
133
|
+
const info = manager.start("test", "true", "/tmp");
|
|
134
|
+
await waitForEnd(manager, info.id);
|
|
135
|
+
|
|
136
|
+
// Wait a bit for any stale trailing emits
|
|
137
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
138
|
+
|
|
139
|
+
const outputEvents = events.filter(
|
|
140
|
+
(e) => e.type === "process_output_changed",
|
|
141
|
+
);
|
|
142
|
+
expect(outputEvents.length).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("no stale events after clearFinished", async () => {
|
|
146
|
+
manager = new ProcessManager();
|
|
147
|
+
const info = manager.start("test", "seq 1 50", "/tmp");
|
|
148
|
+
await waitForEnd(manager, info.id);
|
|
149
|
+
|
|
150
|
+
manager.clearFinished();
|
|
151
|
+
|
|
152
|
+
const lateEvents: ManagerEvent[] = [];
|
|
153
|
+
manager.onEvent((e) => lateEvents.push(e));
|
|
154
|
+
|
|
155
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
156
|
+
|
|
157
|
+
const staleOutput = lateEvents.filter(
|
|
158
|
+
(e) => e.type === "process_output_changed",
|
|
159
|
+
);
|
|
160
|
+
expect(staleOutput.length).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("events carry correct process id with multiple processes", async () => {
|
|
164
|
+
manager = new ProcessManager();
|
|
165
|
+
const events = collectEvents(manager);
|
|
166
|
+
|
|
167
|
+
const info1 = manager.start("proc1", "echo one", "/tmp");
|
|
168
|
+
const info2 = manager.start("proc2", "echo two", "/tmp");
|
|
169
|
+
|
|
170
|
+
await Promise.all([
|
|
171
|
+
waitForEnd(manager, info1.id),
|
|
172
|
+
waitForEnd(manager, info2.id),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
const outputEvents = events.filter(
|
|
176
|
+
(e) => e.type === "process_output_changed",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
for (const e of outputEvents) {
|
|
180
|
+
if (e.type === "process_output_changed") {
|
|
181
|
+
expect([info1.id, info2.id]).toContain(e.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Both processes should have at least one output event
|
|
186
|
+
const ids = new Set(
|
|
187
|
+
outputEvents
|
|
188
|
+
.filter(
|
|
189
|
+
(e): e is Extract<ManagerEvent, { type: "process_output_changed" }> =>
|
|
190
|
+
e.type === "process_output_changed",
|
|
191
|
+
)
|
|
192
|
+
.map((e) => e.id),
|
|
193
|
+
);
|
|
194
|
+
expect(ids.has(info1.id)).toBe(true);
|
|
195
|
+
expect(ids.has(info2.id)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("process_watch_matched", () => {
|
|
200
|
+
let manager: ProcessManager;
|
|
201
|
+
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
manager.cleanup();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("fires once by default on first matching line", async () => {
|
|
207
|
+
manager = new ProcessManager();
|
|
208
|
+
const events = collectEvents(manager);
|
|
209
|
+
|
|
210
|
+
const info = manager.start(
|
|
211
|
+
"watch-once",
|
|
212
|
+
"bash -c 'echo ready; echo ready; echo ready'",
|
|
213
|
+
"/tmp",
|
|
214
|
+
{
|
|
215
|
+
logWatches: [{ pattern: "ready" }],
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
await waitForEnd(manager, info.id);
|
|
220
|
+
|
|
221
|
+
const matches = events.filter((e) => e.type === "process_watch_matched");
|
|
222
|
+
expect(matches).toHaveLength(1);
|
|
223
|
+
|
|
224
|
+
const first = matches[0];
|
|
225
|
+
if (first.type === "process_watch_matched") {
|
|
226
|
+
expect(first.match.processId).toBe(info.id);
|
|
227
|
+
expect(first.match.source).toBe("stdout");
|
|
228
|
+
expect(first.match.watch.repeat).toBe(false);
|
|
229
|
+
expect(first.match.line).toBe("ready");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("supports repeat watches", async () => {
|
|
234
|
+
manager = new ProcessManager();
|
|
235
|
+
const events = collectEvents(manager);
|
|
236
|
+
|
|
237
|
+
const info = manager.start(
|
|
238
|
+
"watch-repeat",
|
|
239
|
+
"bash -c 'echo done; echo done; echo done'",
|
|
240
|
+
"/tmp",
|
|
241
|
+
{
|
|
242
|
+
logWatches: [{ pattern: "done", repeat: true }],
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await waitForEnd(manager, info.id);
|
|
247
|
+
|
|
248
|
+
const matches = events.filter((e) => e.type === "process_watch_matched");
|
|
249
|
+
expect(matches).toHaveLength(3);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("respects stream scoping", async () => {
|
|
253
|
+
manager = new ProcessManager();
|
|
254
|
+
const events = collectEvents(manager);
|
|
255
|
+
|
|
256
|
+
const info = manager.start(
|
|
257
|
+
"watch-stream",
|
|
258
|
+
"bash -c 'echo out; echo err >&2'",
|
|
259
|
+
"/tmp",
|
|
260
|
+
{
|
|
261
|
+
logWatches: [{ pattern: "err", stream: "stderr" }],
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await waitForEnd(manager, info.id);
|
|
266
|
+
|
|
267
|
+
const matches = events.filter((e) => e.type === "process_watch_matched");
|
|
268
|
+
expect(matches).toHaveLength(1);
|
|
269
|
+
|
|
270
|
+
const match = matches[0];
|
|
271
|
+
if (match.type === "process_watch_matched") {
|
|
272
|
+
expect(match.match.source).toBe("stderr");
|
|
273
|
+
expect(match.match.line).toBe("err");
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("stream both matches stdout and stderr", async () => {
|
|
278
|
+
manager = new ProcessManager();
|
|
279
|
+
const events = collectEvents(manager);
|
|
280
|
+
|
|
281
|
+
const info = manager.start(
|
|
282
|
+
"watch-both",
|
|
283
|
+
"bash -c 'echo marker; echo marker >&2'",
|
|
284
|
+
"/tmp",
|
|
285
|
+
{
|
|
286
|
+
logWatches: [{ pattern: "marker", stream: "both", repeat: true }],
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await waitForEnd(manager, info.id);
|
|
291
|
+
|
|
292
|
+
const matches = events.filter((e) => e.type === "process_watch_matched");
|
|
293
|
+
expect(matches).toHaveLength(2);
|
|
294
|
+
|
|
295
|
+
const sources = new Set(
|
|
296
|
+
matches
|
|
297
|
+
.filter(
|
|
298
|
+
(e): e is Extract<ManagerEvent, { type: "process_watch_matched" }> =>
|
|
299
|
+
e.type === "process_watch_matched",
|
|
300
|
+
)
|
|
301
|
+
.map((e) => e.match.source),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(sources.has("stdout")).toBe(true);
|
|
305
|
+
expect(sources.has("stderr")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("matches trailing partial line at process end", async () => {
|
|
309
|
+
manager = new ProcessManager();
|
|
310
|
+
const events = collectEvents(manager);
|
|
311
|
+
|
|
312
|
+
const info = manager.start("watch-trailing", "printf ready", "/tmp", {
|
|
313
|
+
logWatches: [{ pattern: "ready" }],
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await waitForEnd(manager, info.id);
|
|
317
|
+
|
|
318
|
+
const matches = events.filter((e) => e.type === "process_watch_matched");
|
|
319
|
+
expect(matches).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("throws for invalid watch regex", () => {
|
|
323
|
+
manager = new ProcessManager();
|
|
324
|
+
|
|
325
|
+
expect(() =>
|
|
326
|
+
manager.start("bad-watch", "echo ok", "/tmp", {
|
|
327
|
+
logWatches: [{ pattern: "(" }],
|
|
328
|
+
}),
|
|
329
|
+
).toThrowError(/Invalid log watch pattern/);
|
|
330
|
+
});
|
|
331
|
+
});
|
package/src/manager.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { Writable } from "node:stream";
|
|
|
14
14
|
import {
|
|
15
15
|
type KillResult,
|
|
16
16
|
LIVE_STATUSES,
|
|
17
|
+
type LogWatch,
|
|
18
|
+
type LogWatchStream,
|
|
17
19
|
type ManagerEvent,
|
|
18
20
|
type ProcessInfo,
|
|
19
21
|
type ProcessStatus,
|
|
@@ -23,12 +25,24 @@ import {
|
|
|
23
25
|
import { isProcessGroupAlive, killProcessGroup } from "./utils";
|
|
24
26
|
import { spawnCommand } from "./utils/command-executor";
|
|
25
27
|
|
|
28
|
+
interface ResolvedWatch {
|
|
29
|
+
index: number;
|
|
30
|
+
pattern: string;
|
|
31
|
+
regex: RegExp;
|
|
32
|
+
stream: LogWatchStream;
|
|
33
|
+
repeat: boolean;
|
|
34
|
+
fired: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
interface ManagedProcess extends ProcessInfo {
|
|
27
38
|
process: ChildProcess;
|
|
28
39
|
stdin: Writable | null;
|
|
29
40
|
stdinClosed: boolean;
|
|
30
41
|
lastSignalSent: NodeJS.Signals | null;
|
|
31
42
|
combinedFile: string;
|
|
43
|
+
stdoutPendingLine: string;
|
|
44
|
+
stderrPendingLine: string;
|
|
45
|
+
watches: ResolvedWatch[];
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
interface ProcessManagerOptions {
|
|
@@ -43,6 +57,9 @@ export class ProcessManager {
|
|
|
43
57
|
private watcher: ReturnType<typeof setInterval> | null = null;
|
|
44
58
|
private getConfiguredShellPath: () => string | undefined;
|
|
45
59
|
|
|
60
|
+
private lastOutputEmitAt: Map<string, number> = new Map();
|
|
61
|
+
private pendingOutputEmit: Map<string, NodeJS.Timeout> = new Map();
|
|
62
|
+
|
|
46
63
|
constructor(options?: ProcessManagerOptions) {
|
|
47
64
|
this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
|
|
48
65
|
mkdirSync(this.logDir, { recursive: true });
|
|
@@ -59,6 +76,48 @@ export class ProcessManager {
|
|
|
59
76
|
this.events.emit("event", event);
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
private notifyOutputChanged(id: string): void {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const lastEmit = this.lastOutputEmitAt.get(id) ?? 0;
|
|
82
|
+
const elapsed = now - lastEmit;
|
|
83
|
+
|
|
84
|
+
if (elapsed >= 100) {
|
|
85
|
+
this.lastOutputEmitAt.set(id, now);
|
|
86
|
+
this.emit({ type: "process_output_changed", id });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!this.pendingOutputEmit.has(id)) {
|
|
91
|
+
const delay = 100 - elapsed;
|
|
92
|
+
const timeout = setTimeout(() => {
|
|
93
|
+
this.pendingOutputEmit.delete(id);
|
|
94
|
+
// Invariant: every path that removes a process from `this.processes`
|
|
95
|
+
// must call `clearOutputChangedState(id)` first, which clears this
|
|
96
|
+
// timeout. This guard is a safety net, not a primary mechanism.
|
|
97
|
+
if (!this.processes.has(id)) return;
|
|
98
|
+
this.lastOutputEmitAt.set(id, Date.now());
|
|
99
|
+
this.emit({ type: "process_output_changed", id });
|
|
100
|
+
}, delay);
|
|
101
|
+
this.pendingOutputEmit.set(id, timeout);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private flushPendingOutputChanged(id: string): void {
|
|
106
|
+
const timeout = this.pendingOutputEmit.get(id);
|
|
107
|
+
if (!timeout) return;
|
|
108
|
+
clearTimeout(timeout);
|
|
109
|
+
this.pendingOutputEmit.delete(id);
|
|
110
|
+
this.lastOutputEmitAt.set(id, Date.now());
|
|
111
|
+
this.emit({ type: "process_output_changed", id });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private clearOutputChangedState(id: string): void {
|
|
115
|
+
const timeout = this.pendingOutputEmit.get(id);
|
|
116
|
+
if (timeout) clearTimeout(timeout);
|
|
117
|
+
this.pendingOutputEmit.delete(id);
|
|
118
|
+
this.lastOutputEmitAt.delete(id);
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
private transition(managed: ManagedProcess, next: ProcessStatus): void {
|
|
63
122
|
if (managed.status === next) return;
|
|
64
123
|
managed.status = next;
|
|
@@ -107,6 +166,9 @@ export class ProcessManager {
|
|
|
107
166
|
managed.endTime = Date.now();
|
|
108
167
|
}
|
|
109
168
|
|
|
169
|
+
this.flushPendingOutputChanged(managed.id);
|
|
170
|
+
this.flushPendingLines(managed);
|
|
171
|
+
|
|
110
172
|
if (managed.lastSignalSent) {
|
|
111
173
|
managed.success = false;
|
|
112
174
|
managed.exitCode = null;
|
|
@@ -125,6 +187,7 @@ export class ProcessManager {
|
|
|
125
187
|
cwd: string,
|
|
126
188
|
options?: StartOptions,
|
|
127
189
|
): ProcessInfo {
|
|
190
|
+
const resolvedWatches = this.resolveLogWatches(options?.logWatches);
|
|
128
191
|
const id = `proc_${++this.counter}`;
|
|
129
192
|
const stdoutFile = join(this.logDir, `${id}-stdout.log`);
|
|
130
193
|
const stderrFile = join(this.logDir, `${id}-stderr.log`);
|
|
@@ -159,6 +222,9 @@ export class ProcessManager {
|
|
|
159
222
|
stdin: child.stdin,
|
|
160
223
|
stdinClosed: false,
|
|
161
224
|
lastSignalSent: null,
|
|
225
|
+
stdoutPendingLine: "",
|
|
226
|
+
stderrPendingLine: "",
|
|
227
|
+
watches: resolvedWatches,
|
|
162
228
|
};
|
|
163
229
|
|
|
164
230
|
this.processes.set(id, managed);
|
|
@@ -179,15 +245,11 @@ export class ProcessManager {
|
|
|
179
245
|
child.stdout?.on("data", (data: Buffer) => {
|
|
180
246
|
try {
|
|
181
247
|
appendFileSync(stdoutFile, data);
|
|
182
|
-
const lines =
|
|
183
|
-
|
|
184
|
-
// or a partial line. We write all parts with the prefix and newline.
|
|
185
|
-
const tagged = lines
|
|
186
|
-
.map((line, i) =>
|
|
187
|
-
i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
|
|
188
|
-
)
|
|
189
|
-
.join("");
|
|
248
|
+
const lines = this.extractCompleteLines(managed, "stdout", data);
|
|
249
|
+
const tagged = lines.map((line) => `1:${line}\n`).join("");
|
|
190
250
|
if (tagged) appendFileSync(combinedFile, tagged);
|
|
251
|
+
this.matchWatches(managed, "stdout", lines);
|
|
252
|
+
this.notifyOutputChanged(id);
|
|
191
253
|
} catch {
|
|
192
254
|
// Ignore
|
|
193
255
|
}
|
|
@@ -196,13 +258,11 @@ export class ProcessManager {
|
|
|
196
258
|
child.stderr?.on("data", (data: Buffer) => {
|
|
197
259
|
try {
|
|
198
260
|
appendFileSync(stderrFile, data);
|
|
199
|
-
const lines =
|
|
200
|
-
const tagged = lines
|
|
201
|
-
.map((line, i) =>
|
|
202
|
-
i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
|
|
203
|
-
)
|
|
204
|
-
.join("");
|
|
261
|
+
const lines = this.extractCompleteLines(managed, "stderr", data);
|
|
262
|
+
const tagged = lines.map((line) => `2:${line}\n`).join("");
|
|
205
263
|
if (tagged) appendFileSync(combinedFile, tagged);
|
|
264
|
+
this.matchWatches(managed, "stderr", lines);
|
|
265
|
+
this.notifyOutputChanged(id);
|
|
206
266
|
} catch {
|
|
207
267
|
// Ignore
|
|
208
268
|
}
|
|
@@ -215,6 +275,9 @@ export class ProcessManager {
|
|
|
215
275
|
managed.endTime = Date.now();
|
|
216
276
|
managed.success = code === 0;
|
|
217
277
|
|
|
278
|
+
this.flushPendingOutputChanged(id);
|
|
279
|
+
this.flushPendingLines(managed);
|
|
280
|
+
|
|
218
281
|
if (signal) {
|
|
219
282
|
this.transition(managed, "killed");
|
|
220
283
|
} else {
|
|
@@ -233,6 +296,8 @@ export class ProcessManager {
|
|
|
233
296
|
managed.exitCode = -1;
|
|
234
297
|
managed.success = false;
|
|
235
298
|
managed.endTime = Date.now();
|
|
299
|
+
this.flushPendingOutputChanged(id);
|
|
300
|
+
this.flushPendingLines(managed);
|
|
236
301
|
this.transition(managed, "exited");
|
|
237
302
|
}
|
|
238
303
|
});
|
|
@@ -254,22 +319,6 @@ export class ProcessManager {
|
|
|
254
319
|
return managed ? this.toProcessInfo(managed) : null;
|
|
255
320
|
}
|
|
256
321
|
|
|
257
|
-
find(query: string): ProcessInfo | null {
|
|
258
|
-
const byId = this.processes.get(query);
|
|
259
|
-
if (byId) return this.toProcessInfo(byId);
|
|
260
|
-
|
|
261
|
-
const queryLower = query.toLowerCase();
|
|
262
|
-
for (const managed of this.processes.values()) {
|
|
263
|
-
if (managed.name.toLowerCase().includes(queryLower)) {
|
|
264
|
-
return this.toProcessInfo(managed);
|
|
265
|
-
}
|
|
266
|
-
if (managed.command.toLowerCase().includes(queryLower)) {
|
|
267
|
-
return this.toProcessInfo(managed);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
322
|
getOutput(
|
|
274
323
|
id: string,
|
|
275
324
|
tailLines = 100,
|
|
@@ -405,6 +454,8 @@ export class ProcessManager {
|
|
|
405
454
|
managed.success = false;
|
|
406
455
|
}
|
|
407
456
|
|
|
457
|
+
this.flushPendingOutputChanged(id);
|
|
458
|
+
this.flushPendingLines(managed);
|
|
408
459
|
this.transition(managed, "killed");
|
|
409
460
|
return { ok: true, info: this.toProcessInfo(managed) };
|
|
410
461
|
}
|
|
@@ -468,6 +519,7 @@ export class ProcessManager {
|
|
|
468
519
|
// Ignore
|
|
469
520
|
}
|
|
470
521
|
|
|
522
|
+
this.clearOutputChangedState(id);
|
|
471
523
|
this.processes.delete(id);
|
|
472
524
|
cleared++;
|
|
473
525
|
}
|
|
@@ -501,6 +553,12 @@ export class ProcessManager {
|
|
|
501
553
|
cleanup(): void {
|
|
502
554
|
this.stopWatcher();
|
|
503
555
|
|
|
556
|
+
for (const timeout of this.pendingOutputEmit.values()) {
|
|
557
|
+
clearTimeout(timeout);
|
|
558
|
+
}
|
|
559
|
+
this.pendingOutputEmit.clear();
|
|
560
|
+
this.lastOutputEmitAt.clear();
|
|
561
|
+
|
|
504
562
|
for (const p of this.processes.values()) {
|
|
505
563
|
if (!LIVE_STATUSES.has(p.status)) continue;
|
|
506
564
|
try {
|
|
@@ -531,6 +589,132 @@ export class ProcessManager {
|
|
|
531
589
|
}
|
|
532
590
|
}
|
|
533
591
|
|
|
592
|
+
private resolveLogWatches(input?: LogWatch[]): ResolvedWatch[] {
|
|
593
|
+
if (!input || input.length === 0) return [];
|
|
594
|
+
|
|
595
|
+
return input.map((watch, index) => {
|
|
596
|
+
const pattern = watch.pattern?.trim();
|
|
597
|
+
if (!pattern) {
|
|
598
|
+
throw new Error(`logWatches[${index}].pattern is required`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let regex: RegExp;
|
|
602
|
+
try {
|
|
603
|
+
regex = new RegExp(pattern);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
const message =
|
|
606
|
+
error instanceof Error ? error.message : "invalid regular expression";
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Invalid log watch pattern at logWatches[${index}]: ${message}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const stream = watch.stream ?? "both";
|
|
613
|
+
if (stream !== "stdout" && stream !== "stderr" && stream !== "both") {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Invalid logWatches[${index}].stream: ${stream}. Expected stdout, stderr, or both`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
index,
|
|
621
|
+
pattern,
|
|
622
|
+
regex,
|
|
623
|
+
stream,
|
|
624
|
+
repeat: watch.repeat ?? false,
|
|
625
|
+
fired: false,
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private extractCompleteLines(
|
|
631
|
+
managed: ManagedProcess,
|
|
632
|
+
source: "stdout" | "stderr",
|
|
633
|
+
data: Buffer,
|
|
634
|
+
): string[] {
|
|
635
|
+
const chunk = data.toString();
|
|
636
|
+
const pending =
|
|
637
|
+
source === "stdout"
|
|
638
|
+
? managed.stdoutPendingLine
|
|
639
|
+
: managed.stderrPendingLine;
|
|
640
|
+
const merged = pending + chunk;
|
|
641
|
+
const parts = merged.split(/\r?\n/);
|
|
642
|
+
const completeLines = parts.slice(0, -1);
|
|
643
|
+
const nextPending = parts[parts.length - 1] ?? "";
|
|
644
|
+
|
|
645
|
+
if (source === "stdout") {
|
|
646
|
+
managed.stdoutPendingLine = nextPending;
|
|
647
|
+
} else {
|
|
648
|
+
managed.stderrPendingLine = nextPending;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return completeLines;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private flushPendingLines(managed: ManagedProcess): void {
|
|
655
|
+
if (managed.stdoutPendingLine) {
|
|
656
|
+
try {
|
|
657
|
+
appendFileSync(
|
|
658
|
+
managed.combinedFile,
|
|
659
|
+
`1:${managed.stdoutPendingLine}\n`,
|
|
660
|
+
);
|
|
661
|
+
} catch {
|
|
662
|
+
// Ignore
|
|
663
|
+
}
|
|
664
|
+
this.matchWatches(managed, "stdout", [managed.stdoutPendingLine]);
|
|
665
|
+
managed.stdoutPendingLine = "";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (managed.stderrPendingLine) {
|
|
669
|
+
try {
|
|
670
|
+
appendFileSync(
|
|
671
|
+
managed.combinedFile,
|
|
672
|
+
`2:${managed.stderrPendingLine}\n`,
|
|
673
|
+
);
|
|
674
|
+
} catch {
|
|
675
|
+
// Ignore
|
|
676
|
+
}
|
|
677
|
+
this.matchWatches(managed, "stderr", [managed.stderrPendingLine]);
|
|
678
|
+
managed.stderrPendingLine = "";
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private matchWatches(
|
|
683
|
+
managed: ManagedProcess,
|
|
684
|
+
source: "stdout" | "stderr",
|
|
685
|
+
lines: string[],
|
|
686
|
+
): void {
|
|
687
|
+
if (managed.watches.length === 0 || lines.length === 0) return;
|
|
688
|
+
|
|
689
|
+
for (const line of lines) {
|
|
690
|
+
for (const watch of managed.watches) {
|
|
691
|
+
if (!watch.repeat && watch.fired) continue;
|
|
692
|
+
if (watch.stream !== "both" && watch.stream !== source) continue;
|
|
693
|
+
|
|
694
|
+
if (!watch.regex.test(line)) continue;
|
|
695
|
+
|
|
696
|
+
watch.fired = true;
|
|
697
|
+
|
|
698
|
+
this.emit({
|
|
699
|
+
type: "process_watch_matched",
|
|
700
|
+
match: {
|
|
701
|
+
processId: managed.id,
|
|
702
|
+
processName: managed.name,
|
|
703
|
+
processCommand: managed.command,
|
|
704
|
+
source,
|
|
705
|
+
line,
|
|
706
|
+
watch: {
|
|
707
|
+
index: watch.index,
|
|
708
|
+
pattern: watch.pattern,
|
|
709
|
+
stream: watch.stream,
|
|
710
|
+
repeat: watch.repeat,
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
534
718
|
private readTailLines(filePath: string, lines: number): string[] {
|
|
535
719
|
try {
|
|
536
720
|
const content = readFileSync(filePath, "utf-8");
|