@osolmaz/pi-workflows 0.13.4 → 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 (237) hide show
  1. package/README.md +146 -160
  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 +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  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 -2928
  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/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  import type {
2
3
  WorkflowDefinitionSnapshot,
3
4
  WorkflowRunState,
@@ -26,6 +27,9 @@ import { nodeTypeBadge } from "./node-type.js";
26
27
  export type GraphView = {
27
28
  state: WorkflowRunState;
28
29
  snapshot: WorkflowDefinitionSnapshot | null;
30
+ graphScene?: GraphLayout;
31
+ graphSteps?: WorkflowStepRecord[];
32
+ takenTransitions?: string[];
29
33
  };
30
34
 
31
35
  type NodeStatus =
@@ -74,8 +78,12 @@ const STATUS_STYLES: Record<NodeStatus, CanvasStyle> = {
74
78
  const CELL_GAP = 6;
75
79
  const GUTTER_GAP = 2;
76
80
  const GRAPH_SIDE_MARGIN = 2;
77
- const CARD_MIN_CONTENT_WIDTH = 28;
78
- const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
81
+ const CARD_MIN_CONTENT_WIDTH = 20;
82
+ const CARD_MAX_CONTENT_WIDTH = 28;
83
+ const CARD_CORE_HEIGHT = 7;
84
+ const CARD_MAX_BRANCH_ROWS = 3;
85
+ const CARD_WIDEST_STATUS_BADGE = "◆ replay focus";
86
+ const CARD_PAIRED_ROW_GAP = 1;
79
87
 
80
88
  function nodeTypeStyle(nodeType: string): CanvasStyle {
81
89
  switch (nodeType) {
@@ -92,14 +100,24 @@ function nodeTypeStyle(nodeType: string): CanvasStyle {
92
100
  }
93
101
 
94
102
  function fitText(text: string, width: number): string {
95
- const chars = [...text];
96
- if (chars.length <= width) return text;
97
- return width <= 1 ? chars.slice(0, width).join("") : `${chars.slice(0, width - 1).join("")}…`;
103
+ if (visibleWidth(text) <= width) return text;
104
+ if (width <= 0) return "";
105
+ const ellipsis = "";
106
+ const available = Math.max(0, width - visibleWidth(ellipsis));
107
+ let fitted = "";
108
+ let used = 0;
109
+ for (const char of text) {
110
+ const charWidth = visibleWidth(char);
111
+ if (used + charWidth > available) break;
112
+ fitted += char;
113
+ used += charWidth;
114
+ }
115
+ return `${fitted}${ellipsis}`;
98
116
  }
99
117
 
100
118
  function centeredText(text: string, width: number): string {
101
119
  const fitted = fitText(text, width);
102
- const left = Math.max(0, Math.floor((width - [...fitted].length) / 2));
120
+ const left = Math.max(0, Math.floor((width - visibleWidth(fitted)) / 2));
103
121
  return `${" ".repeat(left)}${fitted}`;
104
122
  }
105
123
 
@@ -110,15 +128,13 @@ export type GraphRenderOptions = {
110
128
  nodeStyle?: GraphNodeStyle;
111
129
  };
112
130
 
113
- type CardMetrics = {
131
+ type CardShape = {
114
132
  width: number;
115
133
  height: number;
116
- contentWidth: number;
117
- branchRows: number;
118
134
  };
119
135
 
120
- function cellHeight(nodeStyle: GraphNodeStyle, boxHeight: number): number {
121
- return nodeStyle === "box" ? boxHeight : 1;
136
+ function cellHeight(nodeStyle: GraphNodeStyle, cell: RenderedCell): number {
137
+ return nodeStyle === "box" ? cell.height : 1;
122
138
  }
123
139
 
124
140
  function paint(text: string, style: CanvasStyle): string {
@@ -201,39 +217,66 @@ function nodeBranchLabels(view: GraphView, nodeId: string): string[] {
201
217
  ).map(sanitizeText);
202
218
  }
203
219
 
204
- function cardMetrics(view: GraphView): CardMetrics {
205
- const snapshot = view.snapshot;
206
- if (!snapshot) {
207
- return { width: CARD_MIN_CONTENT_WIDTH + 4, height: 7, contentWidth: 24, branchRows: 0 };
208
- }
209
- let contentWidth = CARD_MIN_CONTENT_WIDTH;
210
- let branchRows = 0;
211
- const measure = (text: string) => {
212
- contentWidth = Math.max(contentWidth, text.length);
213
- };
214
- measure(CARD_DYNAMIC_RESERVE);
215
- for (const status of Object.keys(STATUS_LABELS) as NodeStatus[]) {
216
- measure(`${nodeTypeBadge("checkpoint")} ${STATUS_GLYPHS[status]} ${STATUS_LABELS[status]}`);
217
- }
218
- for (const [nodeId, node] of Object.entries(snapshot.nodes)) {
219
- measure(sanitizeText(hierarchicalNodeLabel(nodeId, node)));
220
- measure(nodeTypeBadge(node.nodeType, node.actionExecution));
221
- const labels = nodeBranchLabels(view, nodeId);
222
- branchRows = Math.max(branchRows, labels.length);
223
- for (const label of labels) measure(`◇ ${label}`);
224
- }
220
+ function cardShape(view: GraphView, nodeId: string): CardShape {
221
+ const node = view.snapshot?.nodes[nodeId];
222
+ const labels = nodeBranchLabels(view, nodeId);
223
+ const branchRows = Math.min(labels.length, CARD_MAX_BRANCH_ROWS);
224
+ const actionExecution = "actionExecution" in (node ?? {}) ? node?.actionExecution : undefined;
225
+ const humanAudience = node && "humanDecision" in node ? node.humanDecision.audience : undefined;
226
+ const configuredDetail = node?.statusDetail ?? node?.summary;
227
+ const assistantDetail =
228
+ node?.nodeType === "agent" &&
229
+ typeof node.expectedOutput === "object" &&
230
+ node.expectedOutput.kind === "assistant-message"
231
+ ? "assistant response"
232
+ : undefined;
233
+ const cardDetail = [assistantDetail, configuredDetail].filter(Boolean).join(" · ");
234
+ const typeBadge = node ? nodeTypeBadge(node.nodeType, actionExecution) : "? unknown";
235
+ const candidates = [
236
+ hierarchicalNodeLabel(nodeId, node),
237
+ typeBadge,
238
+ `${typeBadge} ${CARD_WIDEST_STATUS_BADGE}`,
239
+ ...(humanAudience === undefined ? [] : [`… human decision · ${humanAudience}`]),
240
+ ...(cardDetail === "" ? [] : [`… ${cardDetail}`]),
241
+ ...boundedBranchLines(labels),
242
+ ];
243
+ const desiredWidth = Math.max(...candidates.map(visibleWidth));
244
+ const contentWidth = Math.max(
245
+ CARD_MIN_CONTENT_WIDTH,
246
+ Math.min(CARD_MAX_CONTENT_WIDTH, desiredWidth),
247
+ );
225
248
  return {
226
249
  width: contentWidth + 4,
227
- height: 7 + branchRows,
228
- contentWidth,
229
- branchRows,
250
+ height: CARD_CORE_HEIGHT + branchRows,
230
251
  };
231
252
  }
232
253
 
233
- /** Canonical outer dimensions shared by every full card in this graph. */
234
- export function graphCardSize(view: GraphView): { width: number; height: number } {
235
- const { width, height } = cardMetrics(view);
236
- return { width, height };
254
+ /** Canonical bounded outer dimensions for one boxed node card. */
255
+ export function graphCardSize(view: GraphView, nodeId: string): { width: number; height: number } {
256
+ return cardShape(view, nodeId);
257
+ }
258
+
259
+ function boundedNodeLabel(
260
+ nodeId: string,
261
+ node: WorkflowDefinitionSnapshot["nodes"][string] | undefined,
262
+ contentWidth: number,
263
+ ): string {
264
+ const full = hierarchicalNodeLabel(nodeId, node);
265
+ if (visibleWidth(full) <= contentWidth) return full;
266
+ const local = sanitizeText(node?.localNodeId ?? nodeId);
267
+ const suffix =
268
+ node?.includeTransition === "entry"
269
+ ? "enter"
270
+ : node?.includeTransition === "exit"
271
+ ? `${local} exit`
272
+ : local;
273
+ const candidate = `… › ${suffix}`;
274
+ return visibleWidth(candidate) <= contentWidth ? candidate : fitText(suffix, contentWidth);
275
+ }
276
+
277
+ function boundedBranchLines(labels: string[]): string[] {
278
+ if (labels.length <= CARD_MAX_BRANCH_ROWS) return labels.map((label) => `◇ ${label}`);
279
+ return [`◇ ${labels[0]}`, `◇ ${labels[1]}`, `+${labels.length - 2} branches`];
237
280
  }
238
281
 
239
282
  type RenderedCell = {
@@ -250,6 +293,7 @@ type RenderedCell = {
250
293
  isStart: boolean;
251
294
  isEnd: boolean;
252
295
  width: number;
296
+ height: number;
253
297
  };
254
298
 
255
299
  function renderCellText(
@@ -259,7 +303,7 @@ function renderCellText(
259
303
  atLatestStep: boolean,
260
304
  now: Date,
261
305
  nodeStyle: GraphNodeStyle,
262
- metrics: CardMetrics,
306
+ shape: CardShape,
263
307
  ): RenderedCell {
264
308
  if (cell.kind === "virtual") {
265
309
  return {
@@ -276,6 +320,7 @@ function renderCellText(
276
320
  isStart: false,
277
321
  isEnd: false,
278
322
  width: 1,
323
+ height: 1,
279
324
  };
280
325
  }
281
326
  const state = view.state;
@@ -330,13 +375,7 @@ function renderCellText(
330
375
  human === undefined
331
376
  ? undefined
332
377
  : state.waitingOn === nodeId
333
- ? `human decision · ${sanitizeText(typeof waitingRequest?.audience === "string" ? waitingRequest.audience : human.audience)}${typeof waitingRequest?.presentation?.summary === "string" ? ` · ${sanitizeText(waitingRequest.presentation.summary)}` : ""} · ${Object.values(
334
- human.choices,
335
- )
336
- .map((choice) => sanitizeText(choice.label))
337
- .join(
338
- " / ",
339
- )}${typeof waitingRequest?.presentationDigest === "string" ? ` · ${waitingRequest.presentationDigest.slice(7, 19)}` : ""}`
378
+ ? `human decision · ${sanitizeText(typeof waitingRequest?.audience === "string" ? waitingRequest.audience : human.audience)}`
340
379
  : selected !== undefined
341
380
  ? `human: ${sanitizeText(selected.label)}`
342
381
  : undefined;
@@ -356,13 +395,11 @@ function renderCellText(
356
395
  : "";
357
396
  const nodeDetail = [assistantDetail, configuredDetail].filter(Boolean).join(" · ");
358
397
  const detail = humanDetail ?? nodeDetail;
359
- const branchLines = Array.from({ length: metrics.branchRows }, (_, index) =>
360
- labels[index] ? `◇ ${labels[index]}` : "",
361
- );
398
+ const branchLines = boundedBranchLines(labels);
362
399
  const count = atLatestStep && state.currentNode === nodeId ? Math.max(attempts, 1) : attempts;
363
400
  const timing =
364
401
  attempt || count > 0 ? `${count} attempt${count === 1 ? "" : "s"} · ${elapsed}` : "not visited";
365
- const displayNodeId = hierarchicalNodeLabel(nodeId, node);
402
+ const displayNodeId = boundedNodeLabel(nodeId, node, shape.width - 4);
366
403
  const text = `${displayNodeId} [${nodeType}] ${timing}`;
367
404
  return {
368
405
  cell,
@@ -377,7 +414,8 @@ function renderCellText(
377
414
  branchLines,
378
415
  isStart,
379
416
  isEnd,
380
- width: nodeStyle === "box" ? metrics.width : text.length + 2,
417
+ width: nodeStyle === "box" ? shape.width : visibleWidth(text) + 2,
418
+ height: nodeStyle === "box" ? shape.height : 1,
381
419
  };
382
420
  }
383
421
 
@@ -397,12 +435,13 @@ type RankGeometry = {
397
435
  centers: number[];
398
436
  };
399
437
 
400
- type PlacedRank = RankGeometry & { y: number };
438
+ type PlacedRank = RankGeometry & { y: number; height: number };
401
439
 
402
440
  /** A strip segment with final pixel geometry and its assigned track row. */
403
441
  type GeomSegment = {
404
442
  edgeId: string;
405
443
  label?: string | undefined;
444
+ fromCell: number;
406
445
  fromX: number;
407
446
  toX: number;
408
447
  track: number;
@@ -441,18 +480,26 @@ export function renderGraphLines(
441
480
  return [];
442
481
  }
443
482
  const nodeStyle = options.nodeStyle ?? "line";
444
- const metrics = cardMetrics(view);
445
- const layout = layoutGraph(snapshot);
446
- const steps = view.state.steps;
483
+ const layout = view.graphScene ?? layoutGraph(snapshot);
484
+ const stateSteps = view.state.steps;
485
+ const steps = view.graphSteps ?? stateSteps;
447
486
  const boundedIndex = Math.min(Math.max(selectedStepIndex, -1), steps.length - 1);
448
487
  const atLatestStep = boundedIndex >= steps.length - 1;
449
488
  const visibleSteps = steps.slice(0, boundedIndex + 1);
450
- const transitions = takenTransitions(visibleSteps);
489
+ const transitions = new Set(view.takenTransitions ?? takenTransitions(visibleSteps));
451
490
  const activePair = derivePairInFlight(view, visibleSteps, atLatestStep);
452
491
 
453
492
  const rendered = layout.ranks.map((rank) =>
454
493
  rank.map((cell) =>
455
- renderCellText(view, cell, visibleSteps, atLatestStep, now, nodeStyle, metrics),
494
+ renderCellText(
495
+ view,
496
+ cell,
497
+ visibleSteps,
498
+ atLatestStep,
499
+ now,
500
+ nodeStyle,
501
+ cell.kind === "node" ? cardShape(view, cell.nodeId) : { width: 1, height: 1 },
502
+ ),
456
503
  ),
457
504
  );
458
505
 
@@ -489,16 +536,17 @@ export function renderGraphLines(
489
536
  const topLanes = lanes.above(0).length;
490
537
  let y = topLanes > 0 ? topLanes + 1 : 0;
491
538
  for (const [rankIndex, rank] of geometry.entries()) {
492
- placed.push({ ...rank, y });
539
+ const height = Math.max(1, ...rank.cells.map((cell) => cellHeight(nodeStyle, cell)));
540
+ placed.push({ ...rank, y, height });
493
541
  y +=
494
- cellHeight(nodeStyle, metrics.height) +
542
+ height +
495
543
  lanes.below(rankIndex).length +
496
544
  gapRows(strips[rankIndex] as StripGeometry, rankIndex, layout.ranks.length) +
497
545
  lanes.above(rankIndex + 1).length;
498
546
  }
499
547
 
500
548
  const canvas = new CharCanvas();
501
- drawNodes(canvas, placed, layout, transitions, nodeStyle, metrics.height);
549
+ drawNodes(canvas, placed, layout, transitions, nodeStyle);
502
550
  const labels = drawSegments(
503
551
  canvas,
504
552
  placed,
@@ -508,10 +556,9 @@ export function renderGraphLines(
508
556
  activePair,
509
557
  graphWidth,
510
558
  nodeStyle,
511
- metrics.height,
512
559
  lanes,
513
560
  );
514
- drawBackEdges(canvas, placed, layout, transitions, graphWidth, nodeStyle, metrics.height, lanes);
561
+ drawBackEdges(canvas, placed, layout, transitions, graphWidth, lanes);
515
562
  // Labels go on last, once every line is on the canvas: placement can then
516
563
  // guarantee no later stroke crosses through a label.
517
564
  for (const label of labels) {
@@ -610,7 +657,14 @@ function computeStripGeometry(
610
657
  if (targetIsNode && Math.abs(toX - fromX) <= 1) {
611
658
  toX = fromX;
612
659
  }
613
- return { edgeId: segment.edgeId, label: segment.label, fromX, toX, targetIsNode };
660
+ return {
661
+ edgeId: segment.edgeId,
662
+ label: segment.label,
663
+ fromCell: segment.fromCell,
664
+ fromX,
665
+ toX,
666
+ targetIsNode,
667
+ };
614
668
  });
615
669
 
616
670
  // First-fit track assignment over pixel spans; straight unlabeled
@@ -697,7 +751,6 @@ function drawNodes(
697
751
  layout: GraphLayout,
698
752
  transitions: Set<string>,
699
753
  nodeStyle: GraphNodeStyle,
700
- boxHeight: number,
701
754
  ): void {
702
755
  for (const rank of placed) {
703
756
  for (const [index, rendered] of rank.cells.entries()) {
@@ -708,12 +761,7 @@ function drawNodes(
708
761
  const taken = edge ? transitions.has(`${edge.from}->${edge.to}`) : false;
709
762
  // Pass-through cells span the full cell height so the edge stays
710
763
  // visually continuous across the rank row(s).
711
- canvas.vline(
712
- center,
713
- rank.y,
714
- rank.y + cellHeight(nodeStyle, boxHeight) - 1,
715
- taken ? "taken" : "dim",
716
- );
764
+ canvas.vline(center, rank.y, rank.y + rank.height - 1, taken ? "taken" : "dim");
717
765
  continue;
718
766
  }
719
767
  const status = rendered.status ?? "queued";
@@ -765,9 +813,13 @@ function drawNodeBox(
765
813
  rightStyle: CanvasStyle,
766
814
  ) => {
767
815
  rowBorder(row);
768
- canvas.text(startX + 2, row, fitText(left, innerWidth - 2), leftStyle);
769
- const rightText = fitText(right, innerWidth - 2);
770
- canvas.text(rightX - 1 - [...rightText].length, row, rightText, rightStyle);
816
+ const contentWidth = Math.max(0, innerWidth - 2);
817
+ const rightText = fitText(right, contentWidth);
818
+ const rightWidth = visibleWidth(rightText);
819
+ const gap = left === "" || rightText === "" ? 0 : CARD_PAIRED_ROW_GAP;
820
+ const leftText = fitText(left, Math.max(0, contentWidth - rightWidth - gap));
821
+ canvas.text(startX + 2, row, leftText, leftStyle);
822
+ canvas.text(startX + 2 + contentWidth - rightWidth, row, rightText, rightStyle);
771
823
  };
772
824
 
773
825
  canvas.text(startX, y, `${chars.tl}${horizontal}${chars.tr}`, borderStyle);
@@ -825,7 +877,6 @@ function drawSegments(
825
877
  activePair: string | null,
826
878
  graphWidth: number,
827
879
  nodeStyle: GraphNodeStyle,
828
- boxHeight: number,
829
880
  lanes: BackEdgeLanes,
830
881
  ): PendingLabel[] {
831
882
  const labels: PendingLabel[] = [];
@@ -839,11 +890,13 @@ function drawSegments(
839
890
  // Forward lines start right below the source cell, cross any back-edge
840
891
  // lane rows (as ┼ crossings), run their strip tracks, then cross the
841
892
  // entry lanes to the arrow row directly above the target cell.
842
- const stubTop = top.y + cellHeight(nodeStyle, boxHeight);
843
- const stripTop = stubTop + lanes.below(rank).length;
893
+ const stripTop = top.y + top.height + lanes.below(rank).length;
844
894
  const arrowY = bottom.y - 1;
845
895
  const stripBottom = arrowY - 1 - lanes.above(rank + 1).length;
846
896
  for (const segment of strip.segments) {
897
+ const source = top.cells[segment.fromCell];
898
+ const stubTop =
899
+ source?.cell.kind === "node" ? top.y + cellHeight(nodeStyle, source) : top.y + top.height;
847
900
  const edge = layout.edges.find((candidate) => candidate.edgeId === segment.edgeId);
848
901
  if (!edge) {
849
902
  continue;
@@ -906,23 +959,25 @@ function drawSegmentLabel(canvas: CharCanvas, label: PendingLabel): void {
906
959
  if (fromX !== toX) {
907
960
  const runStart = Math.min(fromX, toX) + 1;
908
961
  const runEnd = Math.max(fromX, toX) - 1;
909
- const center = Math.floor((runStart + runEnd) / 2) - Math.floor(padded.length / 2);
962
+ const paddedWidth = visibleWidth(padded);
963
+ const center = Math.floor((runStart + runEnd) / 2) - Math.floor(paddedWidth / 2);
910
964
  if (
911
- runEnd - runStart + 1 >= padded.length + 2 &&
965
+ runEnd - runStart + 1 >= paddedWidth + 2 &&
912
966
  canvas.textOverRun(center, trackY, padded, style)
913
967
  ) {
914
968
  return;
915
969
  }
916
970
  }
971
+ const textWidth = visibleWidth(text);
917
972
  const candidates: [number, number][] =
918
973
  toX >= Math.floor(graphWidth / 2)
919
974
  ? [
920
- [toX - text.length - 1, labelRow],
975
+ [toX - textWidth - 1, labelRow],
921
976
  [toX + 2, labelRow],
922
977
  ]
923
978
  : [
924
979
  [toX + 2, labelRow],
925
- [toX - text.length - 1, labelRow],
980
+ [toX - textWidth - 1, labelRow],
926
981
  ];
927
982
  for (const [x, y] of candidates) {
928
983
  if (canvas.textIfEmpty(x, y, text, style)) {
@@ -946,8 +1001,6 @@ function drawBackEdges(
946
1001
  layout: GraphLayout,
947
1002
  transitions: Set<string>,
948
1003
  graphWidth: number,
949
- nodeStyle: GraphNodeStyle,
950
- boxHeight: number,
951
1004
  lanes: BackEdgeLanes,
952
1005
  ): void {
953
1006
  let gutterX = graphWidth + GUTTER_GAP;
@@ -965,14 +1018,16 @@ function drawBackEdges(
965
1018
  continue;
966
1019
  }
967
1020
  const style: CanvasStyle = transitions.has(`${edge.from}->${edge.to}`) ? "taken" : "back";
968
- const exitLaneY = from.y + cellHeight(nodeStyle, boxHeight) + exit.lane;
1021
+ const exitLaneY = from.y + from.height + exit.lane;
969
1022
  const aboveCount = lanes.above(toRank).length;
970
1023
  const arrowY = to.y - 1;
971
1024
  const entryLaneY = arrowY - aboveCount + entry.lane;
972
1025
 
973
- // Downward stub out of the source cell, then right along the exit lane.
974
- if (exitLaneY > from.y + cellHeight(nodeStyle, boxHeight)) {
975
- canvas.vline(exit.x, from.y + cellHeight(nodeStyle, boxHeight), exitLaneY - 1, style);
1026
+ // Downward stub out of the source cell, through any rank padding, then
1027
+ // right below the tallest card. A short card must not route an edge
1028
+ // through a taller card in the same rank.
1029
+ if (exitLaneY > from.y + exit.height) {
1030
+ canvas.vline(exit.x, from.y + exit.height, exitLaneY - 1, style);
976
1031
  }
977
1032
  canvas.put(exit.x, exitLaneY, "└", style);
978
1033
  canvas.hline(exitLaneY, exit.x + 1, gutterX - 1, style);
@@ -992,7 +1047,7 @@ function drawBackEdges(
992
1047
  canvas.text(gutterX + 2, entryLaneY, edge.label, style);
993
1048
  }
994
1049
  // Reserve horizontal room for this gutter and its label before the next.
995
- gutterX += 2 + (edge.label === undefined ? 0 : edge.label.length + 1);
1050
+ gutterX += 2 + (edge.label === undefined ? 0 : visibleWidth(edge.label) + 1);
996
1051
  }
997
1052
  }
998
1053
 
@@ -1006,7 +1061,7 @@ function cellAnchor(
1006
1061
  nodeId: string,
1007
1062
  laneEdges: GraphEdge[],
1008
1063
  edge: GraphEdge,
1009
- ): { x: number; lane: number } | null {
1064
+ ): { x: number; lane: number; height: number } | null {
1010
1065
  const index = rank.cells.findIndex(
1011
1066
  (cell) => cell.cell.kind === "node" && cell.cell.nodeId === nodeId,
1012
1067
  );
@@ -1017,5 +1072,5 @@ function cellAnchor(
1017
1072
  const cell = rank.cells[index] as RenderedCell;
1018
1073
  const center = rank.centers[index] as number;
1019
1074
  const rightmost = center + Math.floor(cell.width / 2) - 1;
1020
- return { x: Math.min(center + 2 + lane * 2, rightmost), lane };
1075
+ return { x: Math.min(center + 2 + lane * 2, rightmost), lane, height: cell.height };
1021
1076
  }
@@ -17,7 +17,7 @@ const DATABASE_FILE = "state.sqlite";
17
17
  const BUSY_TIMEOUT_MS = 5_000;
18
18
  const JOURNAL_SIZE_LIMIT = 64 * 1024 * 1024;
19
19
  const RESET_INSTRUCTION =
20
- "Pi Workflows durable state is incompatible. Move or remove the old workflow state, then create a new state.sqlite database.";
20
+ "Pi Workflows durable state is incompatible. Back up and move state.sqlite with its -wal and -shm files, then start Pi Workflows to create a new state.sqlite database. The incompatible state was not changed.";
21
21
 
22
22
  export type StateDatabaseMode = "read-write" | "read-only";
23
23
 
@@ -114,6 +114,7 @@ export class StateDatabase {
114
114
  if (this.mode === "read-only") {
115
115
  throw new Error("Cannot mutate a read-only Pi Workflows database");
116
116
  }
117
+ if (this.connection.inTransaction) return operation();
117
118
  this.connection.exec("BEGIN IMMEDIATE");
118
119
  try {
119
120
  const result = operation();
@@ -19,6 +19,20 @@ export {
19
19
  type ResourceType,
20
20
  type WritePermit,
21
21
  } from "./mutation.js";
22
+ export {
23
+ VIEWER_DELTA_RETENTION,
24
+ VIEWER_PAGE_SIZE,
25
+ initializeViewerRun,
26
+ readViewerDeltas,
27
+ recordViewerDeltas,
28
+ viewerTailPatch,
29
+ type ViewerCursorResult,
30
+ type ViewerDeltaDraft,
31
+ type ViewerDeltaRecord,
32
+ type ViewerPatchOperation,
33
+ type ViewerTailItem,
34
+ type ViewerTargetType,
35
+ } from "./viewer.js";
22
36
  export {
23
37
  STATE_APPLICATION_ID,
24
38
  STATE_APP_VERSION,
@@ -10,6 +10,7 @@ const UNSETTLED_EFFECT_STATUSES = ["pending", "applying", "ambiguous"];
10
10
  type RunAgeRow = {
11
11
  runId: string;
12
12
  parentRunId: string | null;
13
+ launchOptionsHash: Buffer;
13
14
  status: string;
14
15
  finishedAt: number | null;
15
16
  };
@@ -52,7 +53,7 @@ export async function pruneState(
52
53
  filePath: databasePath,
53
54
  mode: options.apply ? "read-write" : "read-only",
54
55
  });
55
- const selection = selectRunTrees(state.connection, cutoff);
56
+ const selection = selectRunTrees(state, cutoff);
56
57
  const sizeBefore = databaseBytes(databasePath);
57
58
  const base = {
58
59
  cutoff: new Date(cutoff).toISOString(),
@@ -83,7 +84,7 @@ export async function pruneState(
83
84
  let deletedBlobBytes = 0;
84
85
  state.connection.exec("BEGIN EXCLUSIVE");
85
86
  try {
86
- const checked = selectRunTrees(state.connection, cutoff);
87
+ const checked = selectRunTrees(state, cutoff);
87
88
  if (
88
89
  checked.runIds.join("\0") !== selection.runIds.join("\0") ||
89
90
  checked.signature !== selection.signature
@@ -141,12 +142,14 @@ export async function pruneState(
141
142
  }
142
143
 
143
144
  function selectRunTrees(
144
- database: Database.Database,
145
+ state: StateDatabase,
145
146
  cutoff: number,
146
147
  ): { candidateTrees: number; blockedTrees: number; runIds: string[]; signature: string } {
148
+ const { connection: database } = state;
147
149
  const rows = database
148
150
  .prepare(
149
- `SELECT run_id AS runId, parent_run_id AS parentRunId, status, finished_at AS finishedAt
151
+ `SELECT run_id AS runId, parent_run_id AS parentRunId,
152
+ launch_options_hash AS launchOptionsHash, status, finished_at AS finishedAt
150
153
  FROM runs ORDER BY created_at, run_id`,
151
154
  )
152
155
  .all()
@@ -162,15 +165,25 @@ function selectRunTrees(
162
165
  .map((row) => row.runId),
163
166
  );
164
167
  const children = new Map<string, string[]>();
168
+ const parents = new Map<string, string[]>();
165
169
  for (const row of rows) {
166
- if (row.parentRunId === null) continue;
167
- const values = children.get(row.parentRunId) ?? [];
168
- values.push(row.runId);
169
- children.set(row.parentRunId, values);
170
+ const runParents = new Set<string>();
171
+ if (row.parentRunId !== null) runParents.add(row.parentRunId);
172
+ const restartParentRunId = restartParentFromLaunchOptions(
173
+ state.readJson(row.launchOptionsHash),
174
+ );
175
+ if (restartParentRunId !== null) runParents.add(restartParentRunId);
176
+ parents.set(row.runId, [...runParents]);
177
+ for (const parentRunId of runParents) {
178
+ const values = children.get(parentRunId) ?? [];
179
+ values.push(row.runId);
180
+ children.set(parentRunId, values);
181
+ }
170
182
  }
171
183
  const roots = rows.filter(
172
184
  (row) =>
173
- eligible.has(row.runId) && (row.parentRunId === null || !eligible.has(row.parentRunId)),
185
+ eligible.has(row.runId) &&
186
+ (parents.get(row.runId) ?? []).every((parentRunId) => !eligible.has(parentRunId)),
174
187
  );
175
188
  const selected = new Set<string>();
176
189
  let blockedTrees = 0;
@@ -407,15 +420,28 @@ function blobReferencePredicate(database: Database.Database): string {
407
420
 
408
421
  function descendants(root: string, children: Map<string, string[]>): string[] {
409
422
  const result: string[] = [];
423
+ const seen = new Set<string>();
410
424
  const pending = [root];
411
425
  while (pending.length !== 0) {
412
426
  const runId = pending.pop() as string;
427
+ if (seen.has(runId)) continue;
428
+ seen.add(runId);
413
429
  result.push(runId);
414
430
  pending.push(...(children.get(runId) ?? []));
415
431
  }
416
432
  return result;
417
433
  }
418
434
 
435
+ function restartParentFromLaunchOptions(value: unknown): string | null {
436
+ if (!isRecord(value)) throw new Error("Stored workflow launch options are invalid");
437
+ const lineage = value.restartLineage;
438
+ if (lineage === undefined) return null;
439
+ if (!isRecord(lineage) || typeof lineage.parentRunId !== "string") {
440
+ throw new Error("Stored workflow restart lineage is invalid");
441
+ }
442
+ return lineage.parentRunId;
443
+ }
444
+
419
445
  function parseCutoff(value: string): number {
420
446
  const cutoff = Date.parse(value);
421
447
  if (!Number.isFinite(cutoff)) throw new Error("state prune --before requires a valid timestamp");