@osolmaz/pi-workflows 0.14.0 → 0.15.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 +78 -110
- package/dist/builtins/autoimplement.workflow.js +3 -1
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/change-verification.workflow.js +7 -2
- package/dist/builtins/change-verification.workflow.js.map +1 -1
- package/dist/builtins/metadata.d.ts +22 -0
- package/dist/builtins/metadata.js +10 -0
- package/dist/builtins/metadata.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +4 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.js +4 -1
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/builtins/workspace-preparation.workflow.js +3 -1
- package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +0 -1
- package/dist/controllers/index.js +0 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +51 -0
- package/dist/controllers/sqlite.js +433 -57
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +1 -0
- package/dist/extension/controller-command.d.ts +22 -0
- package/dist/extension/controller-command.js +32 -0
- package/dist/extension/controller-command.js.map +1 -0
- package/dist/extension/index.d.ts +2 -9
- package/dist/extension/index.js +876 -3447
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/message-card.d.ts +13 -0
- package/dist/extension/message-card.js +46 -0
- package/dist/extension/message-card.js.map +1 -0
- package/dist/extension/step-message.d.ts +5 -6
- package/dist/extension/step-message.js +41 -52
- package/dist/extension/step-message.js.map +1 -1
- package/dist/host/child-worker-supervisor.d.ts +50 -0
- package/dist/host/child-worker-supervisor.js +159 -0
- package/dist/host/child-worker-supervisor.js.map +1 -0
- package/dist/host/client.d.ts +48 -0
- package/dist/host/client.js +216 -0
- package/dist/host/client.js.map +1 -0
- package/dist/host/controller-worker-entry.d.ts +2 -0
- package/dist/host/controller-worker-entry.js +244 -0
- package/dist/host/controller-worker-entry.js.map +1 -0
- package/dist/host/controller-worker-protocol.d.ts +36 -0
- package/dist/host/controller-worker-protocol.js +49 -0
- package/dist/host/controller-worker-protocol.js.map +1 -0
- package/dist/host/controller-worker-supervisor.d.ts +21 -0
- package/dist/host/controller-worker-supervisor.js +54 -0
- package/dist/host/controller-worker-supervisor.js.map +1 -0
- package/dist/host/host-entry.d.ts +2 -0
- package/dist/host/host-entry.js +23 -0
- package/dist/host/host-entry.js.map +1 -0
- package/dist/host/processes.d.ts +17 -12
- package/dist/host/processes.js +154 -50
- package/dist/host/processes.js.map +1 -1
- package/dist/host/protocol.d.ts +38 -0
- package/dist/host/protocol.js +156 -0
- package/dist/host/protocol.js.map +1 -0
- package/dist/host/resolver-entry.d.ts +49 -0
- package/dist/host/resolver-entry.js +149 -0
- package/dist/host/resolver-entry.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +14 -3
- package/dist/host/rpc-executor.js +63 -30
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +95 -29
- package/dist/host/runner.js +2311 -348
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +174 -0
- package/dist/host/state.js +689 -0
- package/dist/host/state.js.map +1 -0
- package/dist/host/worker-entry.d.ts +10 -0
- package/dist/host/worker-entry.js +446 -0
- package/dist/host/worker-entry.js.map +1 -0
- package/dist/host/worker-protocol.d.ts +31 -0
- package/dist/host/worker-protocol.js +122 -0
- package/dist/host/worker-protocol.js.map +1 -0
- package/dist/host/worker-store.d.ts +80 -0
- package/dist/host/worker-store.js +91 -0
- package/dist/host/worker-store.js.map +1 -0
- package/dist/host/worker-supervisor.d.ts +22 -0
- package/dist/host/worker-supervisor.js +54 -0
- package/dist/host/worker-supervisor.js.map +1 -0
- package/dist/render/canvas.d.ts +2 -1
- package/dist/render/canvas.js +40 -15
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +7 -3
- package/dist/render/graph-render.js +125 -74
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.js +3 -1
- package/dist/state/database.js.map +1 -1
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +171 -3
- package/dist/state/schema.js.map +1 -1
- package/dist/state/viewer.d.ts +46 -0
- package/dist/state/viewer.js +249 -0
- package/dist/state/viewer.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -1
- package/dist/viewer/cli.js +47 -18
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/composition.js +25 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +3 -1
- package/dist/workflows/definition.js +25 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +1 -0
- package/dist/workflows/engine.js +157 -42
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +3 -1
- package/dist/workflows/errors.js +4 -7
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +3 -0
- package/dist/workflows/human-decision.js +31 -0
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +17 -6
- package/dist/workflows/schema.js.map +1 -1
- package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
- package/dist/{viewer → workflows}/session-reducer.js +4 -0
- package/dist/workflows/session-reducer.js.map +1 -0
- package/dist/workflows/store.d.ts +87 -1
- package/dist/workflows/store.js +892 -93
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +0 -26
- package/dist/workflows/tool-input.js +2 -50
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +36 -4
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
- package/docs/CONTROLLERS.md +11 -11
- package/docs/DEFERRED_TURNS.md +23 -2
- package/docs/SQLITE_STATE.md +41 -31
- package/docs/WORKFLOW_HOST.md +452 -0
- package/docs/development.md +46 -30
- package/docs/live-replay-protocol.md +129 -100
- package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
- package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
- package/docs/plans/tui-viewer-implementation-plan.md +5 -0
- package/docs/tui-viewer.md +18 -3
- package/docs/workflows.md +162 -210
- package/examples/workflows/command-batch.workflow.ts +2 -0
- package/examples/workflows/shell.workflow.ts +2 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/builtins/autoimplement.workflow.ts +3 -0
- package/src/builtins/change-verification.workflow.ts +7 -2
- package/src/builtins/metadata.ts +9 -0
- package/src/builtins/monitor.workflow.ts +4 -0
- package/src/builtins/sanity-check.workflow.ts +4 -0
- package/src/builtins/workspace-preparation.workflow.ts +3 -1
- package/src/controllers/index.ts +0 -5
- package/src/controllers/sqlite.ts +709 -76
- package/src/controllers/store.ts +1 -0
- package/src/extension/controller-command.ts +45 -0
- package/src/extension/index.ts +944 -4163
- package/src/extension/message-card.ts +61 -0
- package/src/extension/step-message.ts +58 -63
- package/src/host/child-worker-supervisor.ts +183 -0
- package/src/host/client.ts +293 -0
- package/src/host/controller-worker-entry.ts +311 -0
- package/src/host/controller-worker-protocol.ts +104 -0
- package/src/host/controller-worker-supervisor.ts +79 -0
- package/src/host/host-entry.ts +23 -0
- package/src/host/processes.ts +171 -54
- package/src/host/protocol.ts +196 -0
- package/src/host/resolver-entry.ts +241 -0
- package/src/host/rpc-executor.ts +76 -34
- package/src/host/runner.ts +2813 -422
- package/src/host/state.ts +1160 -0
- package/src/host/worker-entry.ts +533 -0
- package/src/host/worker-protocol.ts +165 -0
- package/src/host/worker-store.ts +229 -0
- package/src/host/worker-supervisor.ts +74 -0
- package/src/render/canvas.ts +44 -10
- package/src/render/graph-render.ts +145 -90
- package/src/state/database.ts +2 -1
- package/src/state/index.ts +14 -0
- package/src/state/schema.ts +171 -3
- package/src/state/viewer.ts +356 -0
- package/src/viewer/cli.ts +49 -17
- package/src/workflows/composition.ts +36 -2
- package/src/workflows/definition.ts +32 -0
- package/src/workflows/engine.ts +157 -54
- package/src/workflows/errors.ts +11 -2
- package/src/workflows/human-decision.ts +49 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/schema.ts +19 -6
- package/src/{viewer → workflows}/session-reducer.ts +11 -1
- package/src/workflows/store.ts +1316 -108
- package/src/workflows/tool-input.ts +3 -69
- package/src/workflows/types.ts +32 -4
- package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
- package/dist/controllers/workflow-engine-scheduler.js +0 -175
- package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
- package/dist/extension/controller-host.d.ts +0 -48
- package/dist/extension/controller-host.js +0 -110
- package/dist/extension/controller-host.js.map +0 -1
- package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
- package/dist/extension/deferred-turn-coordinator.js +0 -143
- package/dist/extension/deferred-turn-coordinator.js.map +0 -1
- package/dist/extension/deferred-turn.d.ts +0 -44
- package/dist/extension/deferred-turn.js +0 -110
- package/dist/extension/deferred-turn.js.map +0 -1
- package/dist/extension/executor.d.ts +0 -86
- package/dist/extension/executor.js +0 -311
- package/dist/extension/executor.js.map +0 -1
- package/dist/extension/follow-up-coordinator.d.ts +0 -27
- package/dist/extension/follow-up-coordinator.js +0 -131
- package/dist/extension/follow-up-coordinator.js.map +0 -1
- package/dist/extension/recorder.d.ts +0 -84
- package/dist/extension/recorder.js +0 -528
- package/dist/extension/recorder.js.map +0 -1
- package/dist/extension/restart-policy.d.ts +0 -38
- package/dist/extension/restart-policy.js +0 -116
- package/dist/extension/restart-policy.js.map +0 -1
- package/dist/extension/session-events.d.ts +0 -133
- package/dist/extension/session-events.js +0 -61
- package/dist/extension/session-events.js.map +0 -1
- package/dist/extension/terminal-decision.d.ts +0 -51
- package/dist/extension/terminal-decision.js +0 -110
- package/dist/extension/terminal-decision.js.map +0 -1
- package/dist/viewer/session-reducer.js.map +0 -1
- package/src/controllers/workflow-engine-scheduler.ts +0 -246
- package/src/extension/controller-host.ts +0 -167
- package/src/extension/deferred-turn-coordinator.ts +0 -171
- package/src/extension/deferred-turn.ts +0 -166
- package/src/extension/executor.ts +0 -411
- package/src/extension/follow-up-coordinator.ts +0 -151
- package/src/extension/recorder.ts +0 -655
- package/src/extension/restart-policy.ts +0 -163
- package/src/extension/session-events.ts +0 -121
- package/src/extension/terminal-decision.ts +0 -172
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeText } from "../workflows/text.js";
|
|
4
|
+
|
|
5
|
+
export type MessageCardView = {
|
|
6
|
+
title: string;
|
|
7
|
+
status?: string;
|
|
8
|
+
expandedText?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type MessageCardColor = "accent" | "dim" | "error" | "success" | "warning";
|
|
12
|
+
|
|
13
|
+
export function renderMessageCard(view: MessageCardView, theme: Theme): Box {
|
|
14
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
15
|
+
box.addChild(new TruncatedText(view.title));
|
|
16
|
+
if (view.status !== undefined) {
|
|
17
|
+
box.addChild(new TruncatedText(view.status));
|
|
18
|
+
}
|
|
19
|
+
if (view.expandedText !== undefined) {
|
|
20
|
+
box.addChild(new Text(`\n${view.expandedText}`));
|
|
21
|
+
}
|
|
22
|
+
return box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function customMessageContentText(content: unknown): string {
|
|
26
|
+
if (typeof content === "string") return cleanMultiline(content);
|
|
27
|
+
if (!Array.isArray(content)) return "";
|
|
28
|
+
return content
|
|
29
|
+
.map((part) => {
|
|
30
|
+
if (part === null || typeof part !== "object" || !("text" in part)) return "";
|
|
31
|
+
const text = (part as { text?: unknown }).text;
|
|
32
|
+
return typeof text === "string" ? cleanMultiline(text) : "";
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cleanOptionalSingleLine(value: unknown): string | undefined {
|
|
39
|
+
return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cleanSingleLine(value: string): string {
|
|
43
|
+
return sanitizeText(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function paintMessageCard(
|
|
47
|
+
theme: Pick<Theme, "fg"> | undefined,
|
|
48
|
+
color: MessageCardColor,
|
|
49
|
+
text: string,
|
|
50
|
+
): string {
|
|
51
|
+
return theme?.fg(color, text) ?? text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanMultiline(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.replaceAll("\r\n", "\n")
|
|
57
|
+
.replaceAll("\r", "\n")
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((line) => sanitizeText(line))
|
|
60
|
+
.join("\n");
|
|
61
|
+
}
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
|
|
4
|
-
import { sanitizeText } from "../workflows/text.js";
|
|
5
4
|
import type {
|
|
6
5
|
AgentStepContract,
|
|
7
6
|
AgentStepPresentation,
|
|
8
7
|
AgentStepSubmission,
|
|
9
8
|
} from "../workflows/types.js";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
cleanOptionalSingleLine,
|
|
11
|
+
cleanSingleLine,
|
|
12
|
+
customMessageContentText,
|
|
13
|
+
paintMessageCard,
|
|
14
|
+
renderMessageCard,
|
|
15
|
+
type MessageCardView,
|
|
16
|
+
} from "./message-card.js";
|
|
11
17
|
|
|
12
18
|
export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = "pi-workflows-agent-step";
|
|
13
19
|
export const WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA = "pi-workflows.agent-step-message.v1";
|
|
14
20
|
|
|
21
|
+
type PromptDeliveryKind = "step" | "reminder" | "resume";
|
|
22
|
+
|
|
15
23
|
export type WorkflowAgentStepMessageDetails = {
|
|
16
24
|
schema: typeof WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA;
|
|
17
25
|
kind: PromptDeliveryKind;
|
|
@@ -25,27 +33,13 @@ type WorkflowAgentStepMessage = {
|
|
|
25
33
|
details?: unknown;
|
|
26
34
|
};
|
|
27
35
|
|
|
28
|
-
type WorkflowAgentStepView =
|
|
29
|
-
title: string;
|
|
30
|
-
status?: string;
|
|
31
|
-
expandedText?: string;
|
|
32
|
-
};
|
|
36
|
+
type WorkflowAgentStepView = MessageCardView;
|
|
33
37
|
|
|
34
38
|
export function registerWorkflowAgentStepMessageRenderer(pi: ExtensionAPI): void {
|
|
35
39
|
pi.registerMessageRenderer<WorkflowAgentStepMessageDetails>(
|
|
36
40
|
WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
|
|
37
|
-
(message, { expanded }, theme) =>
|
|
38
|
-
|
|
39
|
-
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
40
|
-
box.addChild(new TruncatedText(view.title));
|
|
41
|
-
if (view.status !== undefined) {
|
|
42
|
-
box.addChild(new TruncatedText(view.status));
|
|
43
|
-
}
|
|
44
|
-
if (view.expandedText !== undefined) {
|
|
45
|
-
box.addChild(new Text(`\n${view.expandedText}`));
|
|
46
|
-
}
|
|
47
|
-
return box;
|
|
48
|
-
},
|
|
41
|
+
(message, { expanded }, theme) =>
|
|
42
|
+
renderMessageCard(buildWorkflowAgentStepView(message, expanded, theme), theme),
|
|
49
43
|
);
|
|
50
44
|
}
|
|
51
45
|
|
|
@@ -65,12 +59,14 @@ export function buildWorkflowAgentStepView(
|
|
|
65
59
|
const label = runTitle ?? workflowName;
|
|
66
60
|
const suffix = kind === "step" ? "" : ` · ${kind}`;
|
|
67
61
|
const glyph = kind === "step" ? "▶" : "↻";
|
|
68
|
-
const title =
|
|
62
|
+
const title = paintMessageCard(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
|
|
69
63
|
|
|
70
64
|
if (!expanded) {
|
|
71
65
|
return {
|
|
72
66
|
title,
|
|
73
|
-
...(statusDetail !== undefined
|
|
67
|
+
...(statusDetail !== undefined
|
|
68
|
+
? { status: paintMessageCard(theme, "dim", statusDetail) }
|
|
69
|
+
: {}),
|
|
74
70
|
};
|
|
75
71
|
}
|
|
76
72
|
|
|
@@ -88,11 +84,11 @@ export function buildWorkflowAgentStepView(
|
|
|
88
84
|
`Completion: ${assistant ? "assistant response" : "workflow submission"}`,
|
|
89
85
|
`Expected output: ${cleanSingleLine(expectedOutput)}`,
|
|
90
86
|
];
|
|
91
|
-
const prompt =
|
|
87
|
+
const prompt = customMessageContentText(message.content);
|
|
92
88
|
return {
|
|
93
89
|
title,
|
|
94
|
-
...(statusDetail !== undefined ? { status:
|
|
95
|
-
expandedText: `${metadata.map((line) =>
|
|
90
|
+
...(statusDetail !== undefined ? { status: paintMessageCard(theme, "dim", statusDetail) } : {}),
|
|
91
|
+
expandedText: `${metadata.map((line) => paintMessageCard(theme, "dim", line)).join("\n")}\n\n${prompt}`,
|
|
96
92
|
};
|
|
97
93
|
}
|
|
98
94
|
|
|
@@ -168,6 +164,43 @@ function sessionEntry(value: unknown): Record<string, unknown> | undefined {
|
|
|
168
164
|
: undefined;
|
|
169
165
|
}
|
|
170
166
|
|
|
167
|
+
function assistantMessageLike(
|
|
168
|
+
message: unknown,
|
|
169
|
+
): Pick<AssistantMessage, "role" | "content" | "stopReason" | "errorMessage"> | undefined {
|
|
170
|
+
if (message === null || typeof message !== "object") return undefined;
|
|
171
|
+
const candidate = message as Partial<AssistantMessage>;
|
|
172
|
+
if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return undefined;
|
|
173
|
+
if (typeof candidate.stopReason !== "string") return undefined;
|
|
174
|
+
return candidate as Pick<AssistantMessage, "role" | "content" | "stopReason" | "errorMessage">;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Extract the exact visible text blocks from one finalized assistant message. */
|
|
178
|
+
export function visibleAssistantText(message: unknown, maxChars?: number): string {
|
|
179
|
+
const assistant = assistantMessageLike(message);
|
|
180
|
+
if (assistant === undefined) {
|
|
181
|
+
throw new Error("Assistant step settled without a final assistant message");
|
|
182
|
+
}
|
|
183
|
+
if (assistant.stopReason !== "stop" && assistant.stopReason !== "length") {
|
|
184
|
+
throw new Error(
|
|
185
|
+
assistant.errorMessage?.trim() ||
|
|
186
|
+
`Assistant step stopped with ${JSON.stringify(assistant.stopReason)} before a final response`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const text = assistant.content
|
|
190
|
+
.filter((part) => part.type === "text")
|
|
191
|
+
.map((part) => part.text)
|
|
192
|
+
.join("\n");
|
|
193
|
+
if (text.trim().length === 0) {
|
|
194
|
+
throw new Error("Assistant step returned no visible text");
|
|
195
|
+
}
|
|
196
|
+
if (maxChars !== undefined && text.length > maxChars) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Assistant response has ${text.length} characters, above the configured limit of ${maxChars}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
|
|
171
204
|
function isAssistantWithVisibleText(message: unknown): boolean {
|
|
172
205
|
if (message === null || typeof message !== "object") return false;
|
|
173
206
|
const candidate = message as { role?: unknown; content?: unknown; stopReason?: unknown };
|
|
@@ -208,41 +241,3 @@ function parseDetails(value: unknown): WorkflowAgentStepMessageDetails | undefin
|
|
|
208
241
|
}
|
|
209
242
|
return candidate as WorkflowAgentStepMessageDetails;
|
|
210
243
|
}
|
|
211
|
-
|
|
212
|
-
function contentText(content: unknown): string {
|
|
213
|
-
if (typeof content === "string") return cleanMultiline(content);
|
|
214
|
-
if (!Array.isArray(content)) return "";
|
|
215
|
-
return content
|
|
216
|
-
.map((part) => {
|
|
217
|
-
if (part === null || typeof part !== "object" || !("text" in part)) return "";
|
|
218
|
-
const text = (part as { text?: unknown }).text;
|
|
219
|
-
return typeof text === "string" ? cleanMultiline(text) : "";
|
|
220
|
-
})
|
|
221
|
-
.filter(Boolean)
|
|
222
|
-
.join("\n");
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function cleanOptionalSingleLine(value: unknown): string | undefined {
|
|
226
|
-
return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function cleanSingleLine(value: string): string {
|
|
230
|
-
return sanitizeText(value);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function cleanMultiline(value: string): string {
|
|
234
|
-
return value
|
|
235
|
-
.replaceAll("\r\n", "\n")
|
|
236
|
-
.replaceAll("\r", "\n")
|
|
237
|
-
.split("\n")
|
|
238
|
-
.map((line) => sanitizeText(line))
|
|
239
|
-
.join("\n");
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function paint(
|
|
243
|
-
theme: Pick<Theme, "fg"> | undefined,
|
|
244
|
-
color: "accent" | "dim",
|
|
245
|
-
text: string,
|
|
246
|
-
): string {
|
|
247
|
-
return theme?.fg(color, text) ?? text;
|
|
248
|
-
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { errorMessage } from "../workflows/errors.js";
|
|
4
|
+
import { HostProcessRegistry, type ProcessIdentity } from "./processes.js";
|
|
5
|
+
import { NdjsonFrameDecoder } from "./protocol.js";
|
|
6
|
+
import type { WorkerOutcome } from "./state.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
9
|
+
const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
10
|
+
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
11
|
+
|
|
12
|
+
export type SupervisedWorkerResult = {
|
|
13
|
+
outcome: WorkerOutcome;
|
|
14
|
+
exitCode: number | null;
|
|
15
|
+
signal: NodeJS.Signals | null;
|
|
16
|
+
diagnostic?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ChildWorkerSupervisorOptions<Message, Response> = {
|
|
20
|
+
label: string;
|
|
21
|
+
registry: HostProcessRegistry;
|
|
22
|
+
onMessage: (message: Message) => Promise<Response>;
|
|
23
|
+
parseMessage: (frame: Buffer) => Message;
|
|
24
|
+
encodeResponse: (response: Response) => Buffer;
|
|
25
|
+
isReady: (message: Message) => boolean;
|
|
26
|
+
isTerminal: (message: Message) => boolean;
|
|
27
|
+
startupTimeoutMs?: number;
|
|
28
|
+
onSpawn?: (identity: ProcessIdentity) => void;
|
|
29
|
+
onDiagnostic?: (message: string) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ChildWorkerStartOptions = {
|
|
33
|
+
args: string[];
|
|
34
|
+
cwd: string;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Shared supervision, framing, diagnostics, and process-group shutdown for host workers. */
|
|
39
|
+
export class ChildWorkerSupervisor<Message, Response> {
|
|
40
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
41
|
+
private identity: ProcessIdentity | null = null;
|
|
42
|
+
private readonly decoder = new NdjsonFrameDecoder();
|
|
43
|
+
private diagnostic = Buffer.alloc(0);
|
|
44
|
+
private terminalMessage = false;
|
|
45
|
+
private stopping: WorkerOutcome | null = null;
|
|
46
|
+
private processing = Promise.resolve();
|
|
47
|
+
private readyResolve: (() => void) | undefined;
|
|
48
|
+
private readyReject: ((error: Error) => void) | undefined;
|
|
49
|
+
private exitResolve: ((result: SupervisedWorkerResult) => void) | undefined;
|
|
50
|
+
private readonly readyPromise = new Promise<void>((resolve, reject) => {
|
|
51
|
+
this.readyResolve = resolve;
|
|
52
|
+
this.readyReject = reject;
|
|
53
|
+
});
|
|
54
|
+
private readonly exitPromise = new Promise<SupervisedWorkerResult>((resolve) => {
|
|
55
|
+
this.exitResolve = resolve;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
constructor(private readonly options: ChildWorkerSupervisorOptions<Message, Response>) {}
|
|
59
|
+
|
|
60
|
+
async start(options: ChildWorkerStartOptions): Promise<void> {
|
|
61
|
+
if (this.child !== null) throw new Error(`${this.options.label} already started`);
|
|
62
|
+
const child = spawn(process.execPath, options.args, {
|
|
63
|
+
cwd: options.cwd,
|
|
64
|
+
env: options.env,
|
|
65
|
+
detached: process.platform !== "win32",
|
|
66
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
this.child = child;
|
|
69
|
+
if (child.pid === undefined) throw new Error(`${this.options.label} did not receive a PID`);
|
|
70
|
+
this.identity = this.options.registry.register(child.pid);
|
|
71
|
+
this.options.onSpawn?.(this.identity);
|
|
72
|
+
child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk));
|
|
73
|
+
child.stderr.on("data", (chunk: Buffer) => this.onStderr(chunk));
|
|
74
|
+
child.on("error", (error) => this.readyReject?.(error));
|
|
75
|
+
child.on("exit", (code, signal) => this.onExit(code, signal));
|
|
76
|
+
|
|
77
|
+
const timeout = setTimeout(() => {
|
|
78
|
+
this.readyReject?.(new Error(`${this.options.label} startup timed out`));
|
|
79
|
+
void this.stop("timedOut");
|
|
80
|
+
}, this.options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS);
|
|
81
|
+
timeout.unref?.();
|
|
82
|
+
try {
|
|
83
|
+
await this.readyPromise;
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async wait(): Promise<SupervisedWorkerResult> {
|
|
90
|
+
return await this.exitPromise;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async stop(outcome: WorkerOutcome = "cancelled"): Promise<void> {
|
|
94
|
+
if (this.child === null) {
|
|
95
|
+
this.exitResolve?.({ outcome, exitCode: null, signal: null });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) return;
|
|
99
|
+
this.stopping = outcome;
|
|
100
|
+
signalProcess(this.child.pid, "SIGTERM");
|
|
101
|
+
const exited = await Promise.race([
|
|
102
|
+
this.exitPromise.then(() => true),
|
|
103
|
+
new Promise<false>((resolve) => {
|
|
104
|
+
const timer = setTimeout(() => resolve(false), DEFAULT_KILL_GRACE_MS);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
if (!exited) signalProcess(this.child.pid, "SIGKILL");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private onStdout(chunk: Buffer): void {
|
|
112
|
+
let frames: Buffer[];
|
|
113
|
+
try {
|
|
114
|
+
frames = this.decoder.push(chunk);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.appendDiagnostic(errorMessage(error));
|
|
117
|
+
void this.stop("crashed");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const frame of frames) {
|
|
121
|
+
this.processing = this.processing
|
|
122
|
+
.then(async () => {
|
|
123
|
+
const message = this.options.parseMessage(frame);
|
|
124
|
+
if (this.options.isTerminal(message)) this.terminalMessage = true;
|
|
125
|
+
const response = await this.options.onMessage(message);
|
|
126
|
+
if (this.child?.stdin.destroyed !== false) return;
|
|
127
|
+
if (!this.child.stdin.write(this.options.encodeResponse(response))) {
|
|
128
|
+
await once(this.child.stdin, "drain");
|
|
129
|
+
}
|
|
130
|
+
if (this.options.isReady(message)) this.readyResolve?.();
|
|
131
|
+
})
|
|
132
|
+
.catch((error: unknown) => {
|
|
133
|
+
this.appendDiagnostic(errorMessage(error));
|
|
134
|
+
void this.stop("crashed");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private onStderr(chunk: Buffer): void {
|
|
140
|
+
this.options.onDiagnostic?.(chunk.toString("utf8").trimEnd());
|
|
141
|
+
if (this.diagnostic.byteLength >= MAX_DIAGNOSTIC_BYTES) return;
|
|
142
|
+
this.diagnostic = Buffer.concat([
|
|
143
|
+
this.diagnostic,
|
|
144
|
+
chunk.subarray(0, MAX_DIAGNOSTIC_BYTES - this.diagnostic.byteLength),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private appendDiagnostic(message: string): void {
|
|
149
|
+
this.onStderr(Buffer.from(`${message}\n`, "utf8"));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private onExit(code: number | null, signal: NodeJS.Signals | null): void {
|
|
153
|
+
if (this.identity !== null) this.options.registry.unregister(this.identity.pid);
|
|
154
|
+
const outcome =
|
|
155
|
+
this.stopping ?? (code === 0 && this.terminalMessage ? "exited" : ("crashed" as const));
|
|
156
|
+
const diagnostic = this.diagnostic.toString("utf8").trim();
|
|
157
|
+
const result: SupervisedWorkerResult = {
|
|
158
|
+
outcome,
|
|
159
|
+
exitCode: code,
|
|
160
|
+
signal,
|
|
161
|
+
...(diagnostic.length === 0 ? {} : { diagnostic }),
|
|
162
|
+
};
|
|
163
|
+
if (outcome !== "exited") {
|
|
164
|
+
this.readyReject?.(
|
|
165
|
+
new Error(diagnostic || `${this.options.label} exited with code ${String(code)}`),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
this.exitResolve?.(result);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function signalProcess(pid: number | undefined, signal: NodeJS.Signals): void {
|
|
173
|
+
if (pid === undefined) return;
|
|
174
|
+
try {
|
|
175
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
176
|
+
} catch {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, signal);
|
|
179
|
+
} catch {
|
|
180
|
+
// The process has already exited.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|