@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.
Files changed (235) hide show
  1. package/README.md +78 -110
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +0 -1
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +51 -0
  19. package/dist/controllers/sqlite.js +433 -57
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -3447
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/schema.d.ts +1 -1
  94. package/dist/state/schema.js +171 -3
  95. package/dist/state/schema.js.map +1 -1
  96. package/dist/state/viewer.d.ts +46 -0
  97. package/dist/state/viewer.js +249 -0
  98. package/dist/state/viewer.js.map +1 -0
  99. package/dist/viewer/cli.d.ts +1 -1
  100. package/dist/viewer/cli.js +47 -18
  101. package/dist/viewer/cli.js.map +1 -1
  102. package/dist/workflows/composition.js +25 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +3 -1
  105. package/dist/workflows/definition.js +25 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +1 -0
  108. package/dist/workflows/engine.js +157 -42
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -1
  111. package/dist/workflows/errors.js +4 -7
  112. package/dist/workflows/errors.js.map +1 -1
  113. package/dist/workflows/human-decision.d.ts +3 -0
  114. package/dist/workflows/human-decision.js +31 -0
  115. package/dist/workflows/human-decision.js.map +1 -1
  116. package/dist/workflows/index.d.ts +1 -1
  117. package/dist/workflows/index.js +1 -1
  118. package/dist/workflows/index.js.map +1 -1
  119. package/dist/workflows/schema.js +17 -6
  120. package/dist/workflows/schema.js.map +1 -1
  121. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  122. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  123. package/dist/workflows/session-reducer.js.map +1 -0
  124. package/dist/workflows/store.d.ts +87 -1
  125. package/dist/workflows/store.js +892 -93
  126. package/dist/workflows/store.js.map +1 -1
  127. package/dist/workflows/tool-input.d.ts +0 -26
  128. package/dist/workflows/tool-input.js +2 -50
  129. package/dist/workflows/tool-input.js.map +1 -1
  130. package/dist/workflows/types.d.ts +36 -4
  131. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  132. package/docs/CONTROLLERS.md +11 -11
  133. package/docs/DEFERRED_TURNS.md +23 -2
  134. package/docs/SQLITE_STATE.md +41 -31
  135. package/docs/WORKFLOW_HOST.md +452 -0
  136. package/docs/development.md +46 -30
  137. package/docs/live-replay-protocol.md +129 -100
  138. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
  139. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  140. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  141. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  142. package/docs/tui-viewer.md +18 -3
  143. package/docs/workflows.md +162 -210
  144. package/examples/workflows/command-batch.workflow.ts +2 -0
  145. package/examples/workflows/shell.workflow.ts +2 -1
  146. package/herdr-plugin.toml +1 -1
  147. package/package.json +1 -1
  148. package/src/builtins/autoimplement.workflow.ts +3 -0
  149. package/src/builtins/change-verification.workflow.ts +7 -2
  150. package/src/builtins/metadata.ts +9 -0
  151. package/src/builtins/monitor.workflow.ts +4 -0
  152. package/src/builtins/sanity-check.workflow.ts +4 -0
  153. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  154. package/src/controllers/index.ts +0 -5
  155. package/src/controllers/sqlite.ts +709 -76
  156. package/src/controllers/store.ts +1 -0
  157. package/src/extension/controller-command.ts +45 -0
  158. package/src/extension/index.ts +944 -4163
  159. package/src/extension/message-card.ts +61 -0
  160. package/src/extension/step-message.ts +58 -63
  161. package/src/host/child-worker-supervisor.ts +183 -0
  162. package/src/host/client.ts +293 -0
  163. package/src/host/controller-worker-entry.ts +311 -0
  164. package/src/host/controller-worker-protocol.ts +104 -0
  165. package/src/host/controller-worker-supervisor.ts +79 -0
  166. package/src/host/host-entry.ts +23 -0
  167. package/src/host/processes.ts +171 -54
  168. package/src/host/protocol.ts +196 -0
  169. package/src/host/resolver-entry.ts +241 -0
  170. package/src/host/rpc-executor.ts +76 -34
  171. package/src/host/runner.ts +2813 -422
  172. package/src/host/state.ts +1160 -0
  173. package/src/host/worker-entry.ts +533 -0
  174. package/src/host/worker-protocol.ts +165 -0
  175. package/src/host/worker-store.ts +229 -0
  176. package/src/host/worker-supervisor.ts +74 -0
  177. package/src/render/canvas.ts +44 -10
  178. package/src/render/graph-render.ts +145 -90
  179. package/src/state/database.ts +2 -1
  180. package/src/state/index.ts +14 -0
  181. package/src/state/schema.ts +171 -3
  182. package/src/state/viewer.ts +356 -0
  183. package/src/viewer/cli.ts +49 -17
  184. package/src/workflows/composition.ts +36 -2
  185. package/src/workflows/definition.ts +32 -0
  186. package/src/workflows/engine.ts +157 -54
  187. package/src/workflows/errors.ts +11 -2
  188. package/src/workflows/human-decision.ts +49 -0
  189. package/src/workflows/index.ts +2 -0
  190. package/src/workflows/schema.ts +19 -6
  191. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  192. package/src/workflows/store.ts +1316 -108
  193. package/src/workflows/tool-input.ts +3 -69
  194. package/src/workflows/types.ts +32 -4
  195. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  196. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  197. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  198. package/dist/extension/controller-host.d.ts +0 -48
  199. package/dist/extension/controller-host.js +0 -110
  200. package/dist/extension/controller-host.js.map +0 -1
  201. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  202. package/dist/extension/deferred-turn-coordinator.js +0 -143
  203. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  204. package/dist/extension/deferred-turn.d.ts +0 -44
  205. package/dist/extension/deferred-turn.js +0 -110
  206. package/dist/extension/deferred-turn.js.map +0 -1
  207. package/dist/extension/executor.d.ts +0 -86
  208. package/dist/extension/executor.js +0 -311
  209. package/dist/extension/executor.js.map +0 -1
  210. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  211. package/dist/extension/follow-up-coordinator.js +0 -131
  212. package/dist/extension/follow-up-coordinator.js.map +0 -1
  213. package/dist/extension/recorder.d.ts +0 -84
  214. package/dist/extension/recorder.js +0 -528
  215. package/dist/extension/recorder.js.map +0 -1
  216. package/dist/extension/restart-policy.d.ts +0 -38
  217. package/dist/extension/restart-policy.js +0 -116
  218. package/dist/extension/restart-policy.js.map +0 -1
  219. package/dist/extension/session-events.d.ts +0 -133
  220. package/dist/extension/session-events.js +0 -61
  221. package/dist/extension/session-events.js.map +0 -1
  222. package/dist/extension/terminal-decision.d.ts +0 -51
  223. package/dist/extension/terminal-decision.js +0 -110
  224. package/dist/extension/terminal-decision.js.map +0 -1
  225. package/dist/viewer/session-reducer.js.map +0 -1
  226. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  227. package/src/extension/controller-host.ts +0 -167
  228. package/src/extension/deferred-turn-coordinator.ts +0 -171
  229. package/src/extension/deferred-turn.ts +0 -166
  230. package/src/extension/executor.ts +0 -411
  231. package/src/extension/follow-up-coordinator.ts +0 -151
  232. package/src/extension/recorder.ts +0 -655
  233. package/src/extension/restart-policy.ts +0 -163
  234. package/src/extension/session-events.ts +0 -121
  235. 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 { visibleAssistantText, type PromptDeliveryKind } from "./executor.js";
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
- const view = buildWorkflowAgentStepView(message, expanded, theme);
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 = paint(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
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 ? { status: paint(theme, "dim", statusDetail) } : {}),
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 = contentText(message.content);
87
+ const prompt = customMessageContentText(message.content);
92
88
  return {
93
89
  title,
94
- ...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
95
- expandedText: `${metadata.map((line) => paint(theme, "dim", line)).join("\n")}\n\n${prompt}`,
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
+ }