@osolmaz/pi-workflows 0.1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. package/src/workflows/types.ts +318 -0
@@ -0,0 +1,183 @@
1
+ import { ansi, stripAnsi } from "../render/ansi.js";
2
+ import { renderGraphLines } from "../render/graph-render.js";
3
+ import { sanitizeText } from "../workflows/text.js";
4
+ import type {
5
+ WorkflowDefinitionSnapshot,
6
+ WorkflowRunState,
7
+ WorkflowRunStatus,
8
+ } from "../workflows/types.js";
9
+
10
+ const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
11
+ running: "◐",
12
+ waiting: "⏸",
13
+ completed: "✓",
14
+ failed: "✗",
15
+ timed_out: "✗",
16
+ cancelled: "✗",
17
+ };
18
+
19
+ /**
20
+ * pi renders at most this many widget lines (InteractiveMode.MAX_WIDGET_LINES)
21
+ * and appends its own "(widget truncated)" marker beyond it. Stay inside the
22
+ * budget and choose which graph rows to show instead of losing the bottom.
23
+ */
24
+ const PI_MAX_WIDGET_LINES = 10;
25
+
26
+ export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
27
+ if (state.currentNode === nodeId) {
28
+ return "◐";
29
+ }
30
+ const result = state.results[nodeId];
31
+ if (!result) {
32
+ return "·";
33
+ }
34
+ if (state.waitingOn === nodeId) {
35
+ return "⏸";
36
+ }
37
+ return result.outcome === "ok" ? "✓" : "✗";
38
+ }
39
+
40
+ /** Node ids in a stable display order: definition order from the snapshot. */
41
+ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
42
+ return Object.keys(snapshot.nodes);
43
+ }
44
+
45
+ export type WidgetView = {
46
+ lines: string[];
47
+ /** The clamped first visible graph row; feed back in to scroll relatively. */
48
+ scroll: number;
49
+ /** Largest useful scroll value; 0 when the whole graph fits. */
50
+ maxScroll: number;
51
+ };
52
+
53
+ /**
54
+ * Live-progress view for the in-pi widget: a header plus the same boxed
55
+ * graph the standalone viewer draws. When the graph is taller than pi's
56
+ * widget budget, a window is shown with ↑/↓ overflow markers — centered on
57
+ * the active node by default, or at `scroll` when the user scrolled
58
+ * manually. Pure so it can be tested without a TUI.
59
+ */
60
+ export function buildWidgetView(
61
+ state: WorkflowRunState,
62
+ snapshot: WorkflowDefinitionSnapshot,
63
+ now: Date = new Date(),
64
+ scroll: number | null = null,
65
+ held = false,
66
+ ): WidgetView {
67
+ // `held` covers pauses the state cannot see yet: an escape-interrupted
68
+ // step or a pause requested while the current node is still finishing.
69
+ const paused = held || state.paused === true;
70
+ const glyph = paused ? "⏸" : STATUS_GLYPHS[state.status];
71
+ const statusText = paused ? "paused" : state.status;
72
+ // Titles, status details, and errors can carry model- or shell-controlled
73
+ // text; never let escape sequences or newlines reach the terminal.
74
+ const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
75
+ const header = `${glyph} workflow ${sanitizeText(state.workflowName)}${title} [${statusText}]`;
76
+
77
+ const footer: string[] = [];
78
+ if (state.error) {
79
+ footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
80
+ }
81
+ if (state.status === "waiting" && state.waitingOn) {
82
+ footer.push(` waiting on checkpoint: ${state.waitingOn}`);
83
+ }
84
+
85
+ const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
86
+ const graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
87
+ nodeStyle: "box",
88
+ });
89
+ if (graph.length === 0) {
90
+ return {
91
+ lines: [header, ` ${compactNodeStrip(state, snapshot)}`, ...footer],
92
+ scroll: 0,
93
+ maxScroll: 0,
94
+ };
95
+ }
96
+ const windowed = windowLines(graph, budget, scroll ?? focusLine(graph, state), scroll !== null);
97
+ return {
98
+ lines: [header, ...windowed.lines.map((line) => ` ${line}`), ...footer],
99
+ scroll: windowed.scroll,
100
+ maxScroll: windowed.maxScroll,
101
+ };
102
+ }
103
+
104
+ /** Back-compatible line view following the active node. */
105
+ export function buildWidgetLines(
106
+ state: WorkflowRunState,
107
+ snapshot: WorkflowDefinitionSnapshot,
108
+ now: Date = new Date(),
109
+ ): string[] {
110
+ return buildWidgetView(state, snapshot, now).lines;
111
+ }
112
+
113
+ /** The graph row the window should center on: the active or waiting node. */
114
+ function focusLine(graph: string[], state: WorkflowRunState): number {
115
+ const active = graph.findIndex((line) => stripAnsi(line).includes("◐"));
116
+ if (active !== -1) {
117
+ return active;
118
+ }
119
+ if (state.waitingOn) {
120
+ const waiting = graph.findIndex((line) => stripAnsi(line).includes(state.waitingOn as string));
121
+ if (waiting !== -1) {
122
+ return waiting;
123
+ }
124
+ }
125
+ return graph.length - 1;
126
+ }
127
+
128
+ /**
129
+ * Slice `lines` to at most `budget` rows, marking hidden rows at either end.
130
+ * Markers count against the budget. `anchor` is a row to center on (follow
131
+ * mode) or the requested first visible row (manual scroll).
132
+ */
133
+ function windowLines(
134
+ lines: string[],
135
+ budget: number,
136
+ anchor: number,
137
+ anchorIsStart: boolean,
138
+ ): { lines: string[]; scroll: number; maxScroll: number } {
139
+ if (lines.length <= budget) {
140
+ return { lines, scroll: 0, maxScroll: 0 };
141
+ }
142
+ // First pass decides which markers exist; the second re-fits within the
143
+ // remaining space. A marker flag can only turn on (never off) in the
144
+ // second pass, so the result stays within budget.
145
+ let inner = budget;
146
+ for (let pass = 0; pass < 2; pass += 1) {
147
+ const start = clampStart(anchor, inner, lines.length, anchorIsStart);
148
+ inner = budget - (start > 0 ? 1 : 0) - (start + inner < lines.length ? 1 : 0);
149
+ }
150
+ const start = clampStart(anchor, inner, lines.length, anchorIsStart);
151
+ const end = start + inner;
152
+ const out: string[] = [];
153
+ if (start > 0) {
154
+ out.push(ansi.dim(`↑ ${start} more`));
155
+ }
156
+ out.push(...lines.slice(start, end));
157
+ if (end < lines.length) {
158
+ out.push(ansi.dim(`↓ ${lines.length - end} more · shift+↑/↓ scroll`));
159
+ }
160
+ return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
161
+ }
162
+
163
+ function clampStart(anchor: number, inner: number, total: number, anchorIsStart: boolean): number {
164
+ const start = anchorIsStart ? anchor : anchor - Math.floor(inner / 2);
165
+ return Math.max(0, Math.min(start, total - inner));
166
+ }
167
+
168
+ function compactNodeStrip(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): string {
169
+ return displayNodeIds(snapshot)
170
+ .map((nodeId) => {
171
+ const marker = nodeGlyph(state, nodeId);
172
+ const detail =
173
+ state.currentNode === nodeId && state.statusDetail
174
+ ? ` (${sanitizeText(state.statusDetail)})`
175
+ : "";
176
+ return `${marker} ${nodeId}${detail}`;
177
+ })
178
+ .join(" ");
179
+ }
180
+
181
+ function truncate(text: string, maxLength: number): string {
182
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
183
+ }
@@ -0,0 +1,47 @@
1
+ import { sanitizeText, stripAnsi } from "../workflows/text.js";
2
+
3
+ export { sanitizeText, stripAnsi };
4
+
5
+ /**
6
+ * Colors are on for interactive terminals only, so piped output stays clean.
7
+ * `NO_COLOR`/`PI_WORKFLOWS_NO_COLOR` force them off; `FORCE_COLOR` forces
8
+ * them on. Evaluated per call so env changes (tests) take effect.
9
+ */
10
+ function colorEnabled(): boolean {
11
+ if (process.env.NO_COLOR !== undefined || process.env.PI_WORKFLOWS_NO_COLOR !== undefined) {
12
+ return false;
13
+ }
14
+ if (process.env.FORCE_COLOR !== undefined) {
15
+ return true;
16
+ }
17
+ return process.stdout.isTTY === true;
18
+ }
19
+
20
+ function wrap(code: string, text: string): string {
21
+ return colorEnabled() ? `\u001b[${code}m${text}\u001b[0m` : text;
22
+ }
23
+
24
+ export const ansi = {
25
+ bold: (text: string) => wrap("1", text),
26
+ dim: (text: string) => wrap("2", text),
27
+ red: (text: string) => wrap("31", text),
28
+ green: (text: string) => wrap("32", text),
29
+ yellow: (text: string) => wrap("33", text),
30
+ blue: (text: string) => wrap("34", text),
31
+ magenta: (text: string) => wrap("35", text),
32
+ cyan: (text: string) => wrap("36", text),
33
+ };
34
+
35
+ /** Visible length of a string, ignoring ANSI escape sequences. */
36
+ export function visibleLength(text: string): number {
37
+ return stripAnsi(text).length;
38
+ }
39
+
40
+ /** Truncate to a visible width, keeping ANSI sequences intact by stripping them first when needed. */
41
+ export function fitWidth(text: string, width: number): string {
42
+ if (visibleLength(text) <= width) {
43
+ return text;
44
+ }
45
+ const plain = stripAnsi(text);
46
+ return width <= 1 ? plain.slice(0, width) : `${plain.slice(0, width - 1)}…`;
47
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A sparse character grid for drawing the workflow graph. Box-drawing
3
+ * characters merge by connectivity (│ crossing ─ becomes ┼) so overlapping
4
+ * edge polylines join instead of overwriting each other.
5
+ */
6
+
7
+ export type CanvasStyle = "plain" | "dim" | "taken" | "active" | "back" | "ok" | "fail" | "warn";
8
+
9
+ type CanvasChar = { char: string; style: CanvasStyle };
10
+
11
+ export const UP = 1;
12
+ export const DOWN = 2;
13
+ export const LEFT = 4;
14
+ export const RIGHT = 8;
15
+
16
+ /** Which sides each box-drawing character connects to (exported for tests). */
17
+ export const CHAR_TO_MASK: Record<string, number> = {
18
+ "─": LEFT | RIGHT,
19
+ "│": UP | DOWN,
20
+ "┌": DOWN | RIGHT,
21
+ "┐": DOWN | LEFT,
22
+ "└": UP | RIGHT,
23
+ "┘": UP | LEFT,
24
+ "├": UP | DOWN | RIGHT,
25
+ "┤": UP | DOWN | LEFT,
26
+ "┬": DOWN | LEFT | RIGHT,
27
+ "┴": UP | LEFT | RIGHT,
28
+ "┼": UP | DOWN | LEFT | RIGHT,
29
+ };
30
+
31
+ const MASK_TO_CHAR = new Map<number, string>(
32
+ Object.entries(CHAR_TO_MASK).map(([char, mask]) => [mask, char]),
33
+ );
34
+
35
+ /** Styles later in this list win when merged lines overlap. */
36
+ const STYLE_PRIORITY: CanvasStyle[] = [
37
+ "plain",
38
+ "dim",
39
+ "back",
40
+ "warn",
41
+ "ok",
42
+ "fail",
43
+ "taken",
44
+ "active",
45
+ ];
46
+
47
+ function mergeStyles(a: CanvasStyle, b: CanvasStyle): CanvasStyle {
48
+ return STYLE_PRIORITY.indexOf(a) >= STYLE_PRIORITY.indexOf(b) ? a : b;
49
+ }
50
+
51
+ export class CharCanvas {
52
+ private readonly cells = new Map<number, Map<number, CanvasChar>>();
53
+ private maxX = 0;
54
+ private maxY = 0;
55
+
56
+ private row(y: number): Map<number, CanvasChar> {
57
+ let row = this.cells.get(y);
58
+ if (!row) {
59
+ row = new Map();
60
+ this.cells.set(y, row);
61
+ }
62
+ this.maxY = Math.max(this.maxY, y);
63
+ return row;
64
+ }
65
+
66
+ /** Place a single character, merging box-drawing connectivity. */
67
+ put(x: number, y: number, char: string, style: CanvasStyle = "plain"): void {
68
+ if (x < 0 || y < 0) {
69
+ return;
70
+ }
71
+ const row = this.row(y);
72
+ this.maxX = Math.max(this.maxX, x);
73
+ const existing = row.get(x);
74
+ // Spaces never occupy cells; textOverRun handles deliberate padding.
75
+ if (char === " ") {
76
+ return;
77
+ }
78
+ if (existing) {
79
+ const existingMask = CHAR_TO_MASK[existing.char];
80
+ const incomingMask = CHAR_TO_MASK[char];
81
+ if (existingMask !== undefined && incomingMask !== undefined) {
82
+ row.set(x, {
83
+ char: MASK_TO_CHAR.get(existingMask | incomingMask) ?? char,
84
+ style: mergeStyles(existing.style, style),
85
+ });
86
+ return;
87
+ }
88
+ // Non-line characters (labels, glyphs, arrows) win over lines; between
89
+ // two non-line characters the newest wins.
90
+ if (existingMask === undefined && incomingMask !== undefined) {
91
+ return;
92
+ }
93
+ }
94
+ row.set(x, { char, style });
95
+ }
96
+
97
+ /** Write a text run left to right (labels, node lines). */
98
+ text(x: number, y: number, value: string, style: CanvasStyle = "plain"): void {
99
+ for (const [index, char] of [...value].entries()) {
100
+ this.put(x + index, y, char, style);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Write text only when every target cell is empty. Returns whether the
106
+ * text was written. Used for optional decorations (edge labels) that must
107
+ * never corrupt lines or nodes already on the canvas.
108
+ */
109
+ textIfEmpty(x: number, y: number, value: string, style: CanvasStyle = "plain"): boolean {
110
+ if (x < 0 || y < 0) {
111
+ return false;
112
+ }
113
+ const row = this.cells.get(y);
114
+ const width = [...value].length;
115
+ for (let index = 0; index < width; index += 1) {
116
+ if (row?.has(x + index)) {
117
+ return false;
118
+ }
119
+ }
120
+ this.text(x, y, value, style);
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Write text over a plain horizontal run, replacing `─` cells only.
126
+ * Refuses (returns false) unless every target cell and both flanking
127
+ * cells are exactly `─`, so a label can never overwrite or sit against
128
+ * corners, crossings, or other labels. Spaces in `value` become real
129
+ * blanks, visually breaking the run around the label on purpose.
130
+ */
131
+ textOverRun(x: number, y: number, value: string, style: CanvasStyle = "plain"): boolean {
132
+ if (x < 1 || y < 0) {
133
+ return false;
134
+ }
135
+ const chars = [...value];
136
+ const row = this.cells.get(y);
137
+ for (let index = -1; index <= chars.length; index += 1) {
138
+ if (row?.get(x + index)?.char !== "─") {
139
+ return false;
140
+ }
141
+ }
142
+ for (const [index, char] of chars.entries()) {
143
+ this.row(y).set(x + index, { char, style });
144
+ }
145
+ this.maxX = Math.max(this.maxX, x + chars.length - 1);
146
+ return true;
147
+ }
148
+
149
+ hline(y: number, x1: number, x2: number, style: CanvasStyle = "plain"): void {
150
+ const [start, end] = x1 <= x2 ? [x1, x2] : [x2, x1];
151
+ for (let x = start; x <= end; x += 1) {
152
+ this.put(x, y, "─", style);
153
+ }
154
+ }
155
+
156
+ vline(x: number, y1: number, y2: number, style: CanvasStyle = "plain"): void {
157
+ const [start, end] = y1 <= y2 ? [y1, y2] : [y2, y1];
158
+ for (let y = start; y <= end; y += 1) {
159
+ this.put(x, y, "│", style);
160
+ }
161
+ }
162
+
163
+ /** Render to lines; `paint` applies terminal styling per character run. */
164
+ render(paint: (text: string, style: CanvasStyle) => string): string[] {
165
+ const lines: string[] = [];
166
+ for (let y = 0; y <= this.maxY; y += 1) {
167
+ const row = this.cells.get(y);
168
+ if (!row || row.size === 0) {
169
+ lines.push("");
170
+ continue;
171
+ }
172
+ let line = "";
173
+ let runText = "";
174
+ let runStyle: CanvasStyle = "plain";
175
+ const flush = () => {
176
+ if (runText.length > 0) {
177
+ line += paint(runText, runStyle);
178
+ runText = "";
179
+ }
180
+ };
181
+ for (let x = 0; x <= this.maxX; x += 1) {
182
+ const cell = row.get(x);
183
+ const char = cell?.char ?? " ";
184
+ const style = cell?.style ?? "plain";
185
+ if (style !== runStyle) {
186
+ flush();
187
+ runStyle = style;
188
+ }
189
+ runText += char;
190
+ }
191
+ flush();
192
+ lines.push(line.replace(/\s+$/, ""));
193
+ }
194
+ return lines;
195
+ }
196
+ }
@@ -0,0 +1,19 @@
1
+ import type { WorkflowRunState } from "../workflows/types.js";
2
+
3
+ export function formatDuration(durationMs: number): string {
4
+ if (durationMs < 1_000) {
5
+ return `${Math.max(durationMs, 0)}ms`;
6
+ }
7
+ const seconds = durationMs / 1_000;
8
+ if (seconds < 60) {
9
+ return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
10
+ }
11
+ const minutes = Math.floor(seconds / 60);
12
+ const rest = Math.round(seconds % 60);
13
+ return `${minutes}m${rest.toString().padStart(2, "0")}s`;
14
+ }
15
+
16
+ export function runElapsedMs(state: WorkflowRunState, now: Date = new Date()): number {
17
+ const end = state.finishedAt ? Date.parse(state.finishedAt) : now.getTime();
18
+ return Math.max(0, end - Date.parse(state.startedAt));
19
+ }