@aliou/pi-processes 0.6.3 → 0.7.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/CONTRIBUTING.md +123 -0
- package/README.md +133 -102
- package/package.json +8 -2
- package/skills/pi-processes/SKILL.md +39 -0
- 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/commands/settings/build-sections.ts +1 -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/config.ts +3 -3
- 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/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 +105 -10
- package/src/tools/actions/kill.ts +15 -2
- 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 +101 -246
- package/src/utils/command-executor.ts +3 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/index.ts +7 -1
|
@@ -48,6 +48,19 @@ function truncate(str: string, maxLen: number): string {
|
|
|
48
48
|
return `${str.slice(0, maxLen - 3)}...`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function fitCell(
|
|
52
|
+
value: string,
|
|
53
|
+
width: number,
|
|
54
|
+
align: "left" | "right" = "left",
|
|
55
|
+
): string {
|
|
56
|
+
const truncated = truncateToWidth(value, Math.max(0, width));
|
|
57
|
+
const pad = Math.max(0, width - visibleWidth(truncated));
|
|
58
|
+
if (align === "right") {
|
|
59
|
+
return " ".repeat(pad) + truncated;
|
|
60
|
+
}
|
|
61
|
+
return truncated + " ".repeat(pad);
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
export class ProcessesComponent implements Component {
|
|
52
65
|
private tui: { requestRender: () => void };
|
|
53
66
|
private theme: Theme;
|
|
@@ -243,8 +256,7 @@ export class ProcessesComponent implements Component {
|
|
|
243
256
|
const scaleFactor =
|
|
244
257
|
innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1;
|
|
245
258
|
|
|
246
|
-
const
|
|
247
|
-
const nameWidth = Math.max(8, Math.floor(15 * scaleFactor));
|
|
259
|
+
const processWidth = Math.max(14, Math.floor(24 * scaleFactor));
|
|
248
260
|
const statusWidth = Math.max(10, Math.floor(18 * scaleFactor));
|
|
249
261
|
const timeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
250
262
|
const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
@@ -258,8 +270,7 @@ export class ProcessesComponent implements Component {
|
|
|
258
270
|
// Calculate command column width based on remaining space
|
|
259
271
|
const fixedWidth =
|
|
260
272
|
prefixWidth +
|
|
261
|
-
|
|
262
|
-
nameWidth +
|
|
273
|
+
processWidth +
|
|
263
274
|
statusWidth +
|
|
264
275
|
timeWidth +
|
|
265
276
|
sizeWidth +
|
|
@@ -269,8 +280,7 @@ export class ProcessesComponent implements Component {
|
|
|
269
280
|
lines.push(padLine(""));
|
|
270
281
|
const header =
|
|
271
282
|
" " +
|
|
272
|
-
dim("
|
|
273
|
-
dim("Name".padEnd(nameWidth)) +
|
|
283
|
+
dim("Process".padEnd(processWidth)) +
|
|
274
284
|
dim("Command".padEnd(cmdWidth)) +
|
|
275
285
|
dim("Status".padEnd(statusWidth)) +
|
|
276
286
|
dim("Time".padEnd(timeWidth)) +
|
|
@@ -294,18 +304,24 @@ export class ProcessesComponent implements Component {
|
|
|
294
304
|
const totalSize = sizes ? sizes.stdout + sizes.stderr : 0;
|
|
295
305
|
|
|
296
306
|
const statusText = this.formatStatus(proc);
|
|
297
|
-
|
|
298
|
-
|
|
307
|
+
|
|
308
|
+
// Keep process cell bounded even with large IDs.
|
|
309
|
+
const idPlain = `(${proc.id})`;
|
|
310
|
+
const maxNameLen = Math.max(
|
|
311
|
+
1,
|
|
312
|
+
processWidth - visibleWidth(idPlain) - 1,
|
|
313
|
+
);
|
|
314
|
+
const tName = truncate(proc.name, maxNameLen);
|
|
315
|
+
const processCell = isSelected
|
|
316
|
+
? `${accent(tName)} ${dim(` ${idPlain}`)}`
|
|
317
|
+
: `${tName}${dim(` ${idPlain}`)}`;
|
|
299
318
|
|
|
300
319
|
const row =
|
|
301
|
-
(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
statusText.padEnd(statusPadding) +
|
|
307
|
-
formatRuntime(proc.startTime, proc.endTime).padEnd(timeWidth) +
|
|
308
|
-
formatBytes(totalSize).padStart(sizeWidth);
|
|
320
|
+
fitCell(processCell, processWidth) +
|
|
321
|
+
fitCell(truncate(proc.command, cmdWidth - 1), cmdWidth) +
|
|
322
|
+
fitCell(statusText, statusWidth) +
|
|
323
|
+
fitCell(formatRuntime(proc.startTime, proc.endTime), timeWidth) +
|
|
324
|
+
fitCell(formatBytes(totalSize), sizeWidth, "right");
|
|
309
325
|
|
|
310
326
|
if (isSelected) {
|
|
311
327
|
lines.push(padLine(`${accent(">")} ${row}`));
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration for the processes extension.
|
|
3
3
|
*
|
|
4
|
-
* Global: ~/.pi/agent/extensions/
|
|
5
|
-
* Memory: ephemeral overrides via /
|
|
4
|
+
* Global: ~/.pi/agent/extensions/process.json
|
|
5
|
+
* Memory: ephemeral overrides via /ps:settings
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ConfigLoader } from "@aliou/pi-utils-settings";
|
|
@@ -11,7 +11,7 @@ import { DEFAULT_KEYBINDINGS } from "./utils/keybindings";
|
|
|
11
11
|
|
|
12
12
|
export interface ProcessesConfig {
|
|
13
13
|
processList?: {
|
|
14
|
-
/** Max visible processes in the /
|
|
14
|
+
/** Max visible processes in the /ps TUI list. */
|
|
15
15
|
maxVisibleProcesses?: number;
|
|
16
16
|
/** Max log preview lines shown below the selected process. */
|
|
17
17
|
maxPreviewLines?: number;
|
package/src/constants/index.ts
CHANGED
package/src/constants/types.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Custom message type for process update notifications
|
|
2
2
|
export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
|
|
3
3
|
|
|
4
|
+
export type ProcessAction =
|
|
5
|
+
| "start"
|
|
6
|
+
| "list"
|
|
7
|
+
| "output"
|
|
8
|
+
| "logs"
|
|
9
|
+
| "kill"
|
|
10
|
+
| "clear"
|
|
11
|
+
| "write"
|
|
12
|
+
| "debug_preview";
|
|
13
|
+
|
|
4
14
|
export type ProcessStatus =
|
|
5
15
|
| "running"
|
|
6
16
|
| "terminating"
|
|
@@ -14,6 +24,14 @@ export const LIVE_STATUSES: ReadonlySet<ProcessStatus> = new Set([
|
|
|
14
24
|
"terminate_timeout",
|
|
15
25
|
]);
|
|
16
26
|
|
|
27
|
+
export type LogWatchStream = "stdout" | "stderr" | "both";
|
|
28
|
+
|
|
29
|
+
export interface LogWatch {
|
|
30
|
+
pattern: string;
|
|
31
|
+
stream?: LogWatchStream;
|
|
32
|
+
repeat?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export interface ProcessInfo {
|
|
18
36
|
id: string;
|
|
19
37
|
name: string;
|
|
@@ -32,9 +50,25 @@ export interface ProcessInfo {
|
|
|
32
50
|
alertOnKill: boolean;
|
|
33
51
|
}
|
|
34
52
|
|
|
53
|
+
export interface LogWatchMatchEvent {
|
|
54
|
+
processId: string;
|
|
55
|
+
processName: string;
|
|
56
|
+
processCommand: string;
|
|
57
|
+
source: "stdout" | "stderr";
|
|
58
|
+
line: string;
|
|
59
|
+
watch: {
|
|
60
|
+
index: number;
|
|
61
|
+
pattern: string;
|
|
62
|
+
stream: LogWatchStream;
|
|
63
|
+
repeat: boolean;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
export type ManagerEvent =
|
|
36
68
|
| { type: "process_started"; info: ProcessInfo }
|
|
37
69
|
| { type: "process_ended"; info: ProcessInfo }
|
|
70
|
+
| { type: "process_output_changed"; id: string }
|
|
71
|
+
| { type: "process_watch_matched"; match: LogWatchMatchEvent }
|
|
38
72
|
| { type: "processes_changed" };
|
|
39
73
|
|
|
40
74
|
export type KillResult =
|
|
@@ -52,10 +86,11 @@ export interface StartOptions {
|
|
|
52
86
|
alertOnSuccess?: boolean;
|
|
53
87
|
alertOnFailure?: boolean;
|
|
54
88
|
alertOnKill?: boolean;
|
|
89
|
+
logWatches?: LogWatch[];
|
|
55
90
|
}
|
|
56
91
|
|
|
57
92
|
export interface ProcessesDetails {
|
|
58
|
-
action:
|
|
93
|
+
action: ProcessAction;
|
|
59
94
|
success: boolean;
|
|
60
95
|
message: string;
|
|
61
96
|
process?: ProcessInfo;
|
package/src/hooks/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { setupBackgroundBlocker } from "./background-blocker";
|
|
|
5
5
|
import { setupCleanupHook } from "./cleanup";
|
|
6
6
|
import { setupMessageRenderer } from "./message-renderer";
|
|
7
7
|
import { setupProcessEndHook } from "./process-end";
|
|
8
|
+
import { setupProcessWatchHook } from "./process-watch";
|
|
8
9
|
import { type DockActions, setupProcessWidget } from "./widget";
|
|
9
10
|
|
|
10
11
|
export type { DockActions };
|
|
@@ -16,6 +17,7 @@ export function setupProcessesHooks(
|
|
|
16
17
|
): { update: () => void; dockActions: DockActions } {
|
|
17
18
|
setupCleanupHook(pi, manager);
|
|
18
19
|
setupProcessEndHook(pi, manager);
|
|
20
|
+
setupProcessWatchHook(pi, manager);
|
|
19
21
|
|
|
20
22
|
if (config.interception.blockBackgroundCommands) {
|
|
21
23
|
setupBackgroundBlocker(pi);
|
|
@@ -6,7 +6,8 @@ import type {
|
|
|
6
6
|
import { Text } from "@mariozechner/pi-tui";
|
|
7
7
|
import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
|
|
8
8
|
|
|
9
|
-
interface
|
|
9
|
+
interface ProcessLifecycleDetails {
|
|
10
|
+
kind?: "lifecycle";
|
|
10
11
|
processId: string;
|
|
11
12
|
processName: string;
|
|
12
13
|
command: string;
|
|
@@ -16,10 +17,25 @@ interface ProcessUpdateDetails {
|
|
|
16
17
|
runtime: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
interface ProcessWatchMatchDetails {
|
|
21
|
+
kind: "watch_matched";
|
|
22
|
+
processId: string;
|
|
23
|
+
processName: string;
|
|
24
|
+
command: string;
|
|
25
|
+
source: "stdout" | "stderr";
|
|
26
|
+
line: string;
|
|
27
|
+
watch: {
|
|
28
|
+
index: number;
|
|
29
|
+
pattern: string;
|
|
30
|
+
stream: "stdout" | "stderr" | "both";
|
|
31
|
+
repeat: boolean;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
interface ProcessUpdateMessage {
|
|
20
36
|
customType: string;
|
|
21
37
|
content: string | Array<{ type: string; text?: string }>;
|
|
22
|
-
details?:
|
|
38
|
+
details?: ProcessLifecycleDetails | ProcessWatchMatchDetails;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
function getContentText(
|
|
@@ -35,7 +51,9 @@ function getContentText(
|
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
export function setupMessageRenderer(pi: ExtensionAPI) {
|
|
38
|
-
pi.registerMessageRenderer<
|
|
54
|
+
pi.registerMessageRenderer<
|
|
55
|
+
ProcessLifecycleDetails | ProcessWatchMatchDetails
|
|
56
|
+
>(
|
|
39
57
|
MESSAGE_TYPE_PROCESS_UPDATE,
|
|
40
58
|
(
|
|
41
59
|
message: ProcessUpdateMessage,
|
|
@@ -48,6 +66,20 @@ export function setupMessageRenderer(pi: ExtensionAPI) {
|
|
|
48
66
|
return new Text(getContentText(message.content), 0, 0);
|
|
49
67
|
}
|
|
50
68
|
|
|
69
|
+
if (details.kind === "watch_matched") {
|
|
70
|
+
const streamColor = details.source === "stderr" ? "warning" : "accent";
|
|
71
|
+
const text =
|
|
72
|
+
theme.fg("success", "* ") +
|
|
73
|
+
theme.fg("accent", `"${details.processName}"`) +
|
|
74
|
+
theme.fg("muted", ` (${details.processId}) `) +
|
|
75
|
+
theme.fg("success", "watch matched ") +
|
|
76
|
+
theme.fg("muted", `/${details.watch.pattern}/ `) +
|
|
77
|
+
theme.fg(streamColor, `[${details.source}]`) +
|
|
78
|
+
theme.fg("muted", ` ${details.line}`);
|
|
79
|
+
|
|
80
|
+
return new Text(text, 0, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
let icon: string;
|
|
52
84
|
let color: "success" | "error" | "warning";
|
|
53
85
|
|
package/src/hooks/process-end.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ProcessManager } from "../manager";
|
|
|
4
4
|
import { formatRuntime } from "../utils";
|
|
5
5
|
|
|
6
6
|
interface ProcessUpdateDetails {
|
|
7
|
+
kind: "lifecycle";
|
|
7
8
|
processId: string;
|
|
8
9
|
processName: string;
|
|
9
10
|
command: string;
|
|
@@ -43,6 +44,7 @@ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
|
|
|
43
44
|
// Send the message to the conversation - displayed via custom renderer in UI
|
|
44
45
|
// Only trigger an agent turn when the notification preferences say so.
|
|
45
46
|
const details: ProcessUpdateDetails = {
|
|
47
|
+
kind: "lifecycle",
|
|
46
48
|
processId: info.id,
|
|
47
49
|
processName: info.name,
|
|
48
50
|
command: info.command,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
|
|
3
|
+
import type { ProcessManager } from "../manager";
|
|
4
|
+
|
|
5
|
+
interface ProcessWatchUpdateDetails {
|
|
6
|
+
kind: "watch_matched";
|
|
7
|
+
processId: string;
|
|
8
|
+
processName: string;
|
|
9
|
+
command: string;
|
|
10
|
+
source: "stdout" | "stderr";
|
|
11
|
+
line: string;
|
|
12
|
+
watch: {
|
|
13
|
+
index: number;
|
|
14
|
+
pattern: string;
|
|
15
|
+
stream: "stdout" | "stderr" | "both";
|
|
16
|
+
repeat: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const REPEAT_WATCH_TURN_COOLDOWN_MS = 5000;
|
|
21
|
+
|
|
22
|
+
export function setupProcessWatchHook(
|
|
23
|
+
pi: ExtensionAPI,
|
|
24
|
+
manager: ProcessManager,
|
|
25
|
+
) {
|
|
26
|
+
const lastRepeatTurnAt = new Map<string, number>();
|
|
27
|
+
|
|
28
|
+
manager.onEvent((event) => {
|
|
29
|
+
if (event.type === "process_ended") {
|
|
30
|
+
// Cleanup cooldown state for this process.
|
|
31
|
+
const prefix = `${event.info.id}:`;
|
|
32
|
+
for (const key of lastRepeatTurnAt.keys()) {
|
|
33
|
+
if (key.startsWith(prefix)) {
|
|
34
|
+
lastRepeatTurnAt.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (event.type !== "process_watch_matched") return;
|
|
41
|
+
|
|
42
|
+
const match = event.match;
|
|
43
|
+
const message =
|
|
44
|
+
`Watch matched for '${match.processName}' (${match.processId}) ` +
|
|
45
|
+
`[${match.source}] /${match.watch.pattern}/`;
|
|
46
|
+
|
|
47
|
+
const details: ProcessWatchUpdateDetails = {
|
|
48
|
+
kind: "watch_matched",
|
|
49
|
+
processId: match.processId,
|
|
50
|
+
processName: match.processName,
|
|
51
|
+
command: match.processCommand,
|
|
52
|
+
source: match.source,
|
|
53
|
+
line: match.line,
|
|
54
|
+
watch: {
|
|
55
|
+
index: match.watch.index,
|
|
56
|
+
pattern: match.watch.pattern,
|
|
57
|
+
stream: match.watch.stream,
|
|
58
|
+
repeat: match.watch.repeat,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let triggerTurn = true;
|
|
63
|
+
if (match.watch.repeat) {
|
|
64
|
+
const watchKey = `${match.processId}:${match.watch.index}`;
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const last = lastRepeatTurnAt.get(watchKey) ?? 0;
|
|
67
|
+
triggerTurn = now - last >= REPEAT_WATCH_TURN_COOLDOWN_MS;
|
|
68
|
+
if (triggerTurn) {
|
|
69
|
+
lastRepeatTurnAt.set(watchKey, now);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pi.sendMessage(
|
|
74
|
+
{
|
|
75
|
+
customType: MESSAGE_TYPE_PROCESS_UPDATE,
|
|
76
|
+
content: message,
|
|
77
|
+
display: true,
|
|
78
|
+
details,
|
|
79
|
+
},
|
|
80
|
+
{ triggerTurn },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -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
|
+
});
|