@osolmaz/pi-workflows 0.5.2 → 0.6.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 (81) hide show
  1. package/README.md +13 -3
  2. package/dist/builtins/catalog.js +1 -1
  3. package/dist/builtins/monitor.workflow.d.ts +25 -69
  4. package/dist/builtins/monitor.workflow.js +194 -123
  5. package/dist/builtins/monitor.workflow.js.map +1 -1
  6. package/dist/extension/executor.d.ts +7 -2
  7. package/dist/extension/executor.js +20 -14
  8. package/dist/extension/executor.js.map +1 -1
  9. package/dist/extension/index.js +56 -15
  10. package/dist/extension/index.js.map +1 -1
  11. package/dist/extension/step-message.d.ts +24 -0
  12. package/dist/extension/step-message.js +106 -0
  13. package/dist/extension/step-message.js.map +1 -0
  14. package/dist/extension/widget.d.ts +4 -2
  15. package/dist/extension/widget.js +111 -17
  16. package/dist/extension/widget.js.map +1 -1
  17. package/dist/extension/workflow-tool.d.ts +9 -0
  18. package/dist/extension/workflow-tool.js +10 -0
  19. package/dist/extension/workflow-tool.js.map +1 -1
  20. package/dist/host/rpc-bridge.js +25 -11
  21. package/dist/host/rpc-bridge.js.map +1 -1
  22. package/dist/host/rpc-executor.d.ts +2 -2
  23. package/dist/host/rpc-executor.js +23 -12
  24. package/dist/host/rpc-executor.js.map +1 -1
  25. package/dist/viewer/cli.js +1 -1
  26. package/dist/viewer/cli.js.map +1 -1
  27. package/dist/viewer/render.js +14 -0
  28. package/dist/viewer/render.js.map +1 -1
  29. package/dist/viewer/tui.js +1 -1
  30. package/dist/viewer/tui.js.map +1 -1
  31. package/dist/workflows/engine.d.ts +6 -1
  32. package/dist/workflows/engine.js +88 -6
  33. package/dist/workflows/engine.js.map +1 -1
  34. package/dist/workflows/index.d.ts +4 -2
  35. package/dist/workflows/index.js +2 -0
  36. package/dist/workflows/index.js.map +1 -1
  37. package/dist/workflows/progress.d.ts +34 -0
  38. package/dist/workflows/progress.js +268 -0
  39. package/dist/workflows/progress.js.map +1 -0
  40. package/dist/workflows/schema.js +21 -1
  41. package/dist/workflows/schema.js.map +1 -1
  42. package/dist/workflows/shell.d.ts +2 -2
  43. package/dist/workflows/shell.js +103 -25
  44. package/dist/workflows/shell.js.map +1 -1
  45. package/dist/workflows/store.d.ts +15 -2
  46. package/dist/workflows/store.js +44 -2
  47. package/dist/workflows/store.js.map +1 -1
  48. package/dist/workflows/types.d.ts +52 -1
  49. package/dist/workflows/updates.d.ts +15 -0
  50. package/dist/workflows/updates.js +188 -0
  51. package/dist/workflows/updates.js.map +1 -0
  52. package/docs/DESIGN_PHILOSOPHY.md +51 -0
  53. package/docs/MONITOR.md +282 -0
  54. package/docs/WORKFLOW_STEP_MESSAGES.md +141 -0
  55. package/docs/WORKFLOW_UPDATES.md +416 -0
  56. package/docs/development.md +7 -3
  57. package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +11 -3
  58. package/docs/plans/2026-08-16-workflow-updates-plan.md +494 -0
  59. package/docs/run-bundles.md +10 -2
  60. package/docs/workflows.md +57 -17
  61. package/package.json +1 -1
  62. package/src/builtins/catalog.ts +1 -1
  63. package/src/builtins/monitor.workflow.ts +217 -148
  64. package/src/extension/executor.ts +36 -14
  65. package/src/extension/index.ts +89 -23
  66. package/src/extension/step-message.ts +145 -0
  67. package/src/extension/widget.ts +158 -14
  68. package/src/extension/workflow-tool.ts +22 -0
  69. package/src/host/rpc-bridge.ts +37 -14
  70. package/src/host/rpc-executor.ts +35 -14
  71. package/src/viewer/cli.ts +1 -1
  72. package/src/viewer/render.ts +27 -0
  73. package/src/viewer/tui.ts +1 -1
  74. package/src/workflows/engine.ts +117 -4
  75. package/src/workflows/index.ts +32 -0
  76. package/src/workflows/progress.ts +326 -0
  77. package/src/workflows/schema.ts +23 -1
  78. package/src/workflows/shell.ts +109 -26
  79. package/src/workflows/store.ts +67 -2
  80. package/src/workflows/types.ts +78 -1
  81. package/src/workflows/updates.ts +208 -0
@@ -0,0 +1,326 @@
1
+ import { sanitizeText } from "./text.js";
2
+ import type { WorkflowProgressData, WorkflowTraceEvent, WorkflowUpdateRecord } from "./types.js";
3
+ import { validateProgressData } from "./updates.js";
4
+
5
+ export type ProgressConfidence = "low" | "medium" | "high";
6
+
7
+ export type ProgressEstimate = {
8
+ key: string;
9
+ data: WorkflowProgressData;
10
+ sampleCount: number;
11
+ delta?: number;
12
+ rateLow?: number;
13
+ rateMedian?: number;
14
+ rateHigh?: number;
15
+ remainingLowMs?: number;
16
+ remainingMedianMs?: number;
17
+ remainingHighMs?: number;
18
+ confidence?: ProgressConfidence;
19
+ sourceEstimatedFinishAt?: string;
20
+ unavailableReason?: string;
21
+ };
22
+
23
+ export type ProgressSample = {
24
+ at: string;
25
+ data: WorkflowProgressData;
26
+ };
27
+
28
+ export type ProgressTrackState = {
29
+ key: string;
30
+ samples: ProgressSample[];
31
+ estimate: ProgressEstimate;
32
+ };
33
+
34
+ const TERMINAL = new Set(["completed", "failed", "cancelled"]);
35
+
36
+ export function estimateProgress(
37
+ key: string,
38
+ samples: ProgressSample[],
39
+ now = new Date(),
40
+ ): ProgressEstimate {
41
+ if (samples.length === 0) throw new Error("progress estimation requires at least one sample");
42
+ for (const sample of samples) validateProgressData(sample.data as Record<string, unknown>);
43
+ const latest = samples.at(-1) as ProgressSample;
44
+ const data = latest.data;
45
+ const base: ProgressEstimate = { key, data, sampleCount: 1 };
46
+ if (TERMINAL.has(data.status)) return base;
47
+ const sourceFinish = validSourceFinish(latest, now);
48
+ if (sourceFinish !== undefined) base.sourceEstimatedFinishAt = sourceFinish;
49
+ if (data.status === "waiting" || data.status === "blocked") {
50
+ return sourceFinish === undefined
51
+ ? { ...base, unavailableReason: `progress is ${data.status}` }
52
+ : base;
53
+ }
54
+ if (data.completed === undefined || data.total === undefined) {
55
+ return sourceFinish === undefined ? { ...base, unavailableReason: "total is unknown" } : base;
56
+ }
57
+
58
+ const epoch = currentEpoch(samples);
59
+ const intervals: Array<{ rate: number; delta: number }> = [];
60
+ for (let index = Math.max(1, epoch.length - 8); index < epoch.length; index += 1) {
61
+ const previous = epoch[index - 1] as ProgressSample;
62
+ const current = epoch[index] as ProgressSample;
63
+ const elapsedMs = Date.parse(current.at) - Date.parse(previous.at);
64
+ const previousCompleted = previous.data.completed;
65
+ const currentCompleted = current.data.completed;
66
+ if (elapsedMs <= 0 || previousCompleted === undefined || currentCompleted === undefined)
67
+ continue;
68
+ intervals.push({
69
+ rate: (currentCompleted - previousCompleted) / elapsedMs,
70
+ delta: currentCompleted - previousCompleted,
71
+ });
72
+ }
73
+ base.sampleCount = intervals.length + 1;
74
+ const latestDelta = intervals.at(-1)?.delta;
75
+ if (latestDelta !== undefined) base.delta = latestDelta;
76
+ if (intervals.length === 0) {
77
+ return sourceFinish === undefined
78
+ ? { ...base, unavailableReason: "needs another progress sample" }
79
+ : base;
80
+ }
81
+ const rates = intervals.map((item) => item.rate).sort((a, b) => a - b);
82
+ const median = quantile(rates, 0.5);
83
+ const p25 = quantile(rates, 0.25);
84
+ const p75 = quantile(rates, 0.75);
85
+ const remaining = Math.max(0, data.total - data.completed);
86
+ const positive = rates.filter((rate) => rate > 0);
87
+ if (median <= 0 || positive.length === 0) {
88
+ return sourceFinish === undefined
89
+ ? { ...base, unavailableReason: "no positive progress rate" }
90
+ : base;
91
+ }
92
+ const spread = (p75 - p25) / median;
93
+ const confidence: ProgressConfidence =
94
+ intervals.length >= 5
95
+ ? spread <= 0.25
96
+ ? "high"
97
+ : spread <= 0.5
98
+ ? "medium"
99
+ : "low"
100
+ : intervals.length >= 2 && spread <= 0.5
101
+ ? "medium"
102
+ : "low";
103
+ const slow = p25 > 0 ? p25 : undefined;
104
+ const fast = p75 > 0 ? p75 : median;
105
+ return {
106
+ ...base,
107
+ ...(slow !== undefined ? { rateLow: slow } : {}),
108
+ rateMedian: median,
109
+ rateHigh: fast,
110
+ remainingLowMs: remaining / fast,
111
+ remainingMedianMs: remaining / median,
112
+ ...(slow !== undefined ? { remainingHighMs: remaining / slow } : {}),
113
+ confidence,
114
+ };
115
+ }
116
+
117
+ export function progressRecordsFromTrace(events: WorkflowTraceEvent[]): WorkflowUpdateRecord[] {
118
+ const records: WorkflowUpdateRecord[] = [];
119
+ for (const event of events) {
120
+ if (
121
+ event.type !== "update_published" ||
122
+ event.nodeId === undefined ||
123
+ event.attemptId === undefined
124
+ )
125
+ continue;
126
+ const payload = event.payload;
127
+ if (
128
+ typeof payload.updateId !== "string" ||
129
+ typeof payload.type !== "string" ||
130
+ typeof payload.key !== "string" ||
131
+ payload.data === null ||
132
+ typeof payload.data !== "object" ||
133
+ Array.isArray(payload.data)
134
+ )
135
+ continue;
136
+ records.push({
137
+ updateId: payload.updateId,
138
+ seq: event.seq,
139
+ at: event.at,
140
+ runId: event.runId,
141
+ nodeId: event.nodeId,
142
+ attemptId: event.attemptId,
143
+ type: payload.type,
144
+ key: payload.key,
145
+ data: payload.data as Record<string, unknown>,
146
+ });
147
+ }
148
+ return records;
149
+ }
150
+
151
+ export function appendProgressHistory(
152
+ history: WorkflowUpdateRecord[],
153
+ additions: WorkflowUpdateRecord[],
154
+ maxPerTrack = 9,
155
+ maxRecords = 576,
156
+ ): WorkflowUpdateRecord[] {
157
+ const retained: WorkflowUpdateRecord[] = [];
158
+ const counts = new Map<string, number>();
159
+ const combined = [...history, ...additions];
160
+ for (let index = combined.length - 1; index >= 0 && retained.length < maxRecords; index -= 1) {
161
+ const record = combined[index] as WorkflowUpdateRecord;
162
+ if (record.type !== "progress") continue;
163
+ const count = counts.get(record.key) ?? 0;
164
+ if (count >= maxPerTrack) continue;
165
+ counts.set(record.key, count + 1);
166
+ retained.push(record);
167
+ }
168
+ return retained.reverse();
169
+ }
170
+
171
+ export function progressTracksFromRecords(
172
+ records: WorkflowUpdateRecord[],
173
+ now = new Date(),
174
+ ): ProgressTrackState[] {
175
+ const grouped = new Map<string, ProgressSample[]>();
176
+ for (const record of records) {
177
+ if (record.type !== "progress") continue;
178
+ try {
179
+ const data = validateProgressData(record.data);
180
+ const samples = grouped.get(record.key) ?? [];
181
+ samples.push({ at: record.at, data });
182
+ grouped.set(record.key, samples);
183
+ } catch {
184
+ // Readers skip malformed historical update data instead of failing.
185
+ }
186
+ }
187
+ return [...grouped.entries()].map(([key, samples]) => ({
188
+ key,
189
+ samples,
190
+ estimate: estimateProgress(key, samples, now),
191
+ }));
192
+ }
193
+
194
+ export function formatProgressLine(estimate: ProgressEstimate, now = new Date()): string {
195
+ const { data } = estimate;
196
+ const label = sanitizeText(data.label ?? estimate.key);
197
+ const unit = data.unit === undefined ? "" : sanitizeText(data.unit);
198
+ const count =
199
+ data.completed === undefined
200
+ ? data.status
201
+ : data.total === undefined
202
+ ? `${formatNumber(data.completed)} ${unit}`.trim()
203
+ : `${formatNumber(data.completed)}/${formatNumber(data.total)} ${unit}`.trim();
204
+ let eta = "";
205
+ if (estimate.sourceEstimatedFinishAt !== undefined) {
206
+ eta = `source ETA ${formatRemaining(Date.parse(estimate.sourceEstimatedFinishAt) - now.getTime())}`;
207
+ } else if (estimate.remainingMedianMs !== undefined) {
208
+ const range =
209
+ estimate.remainingLowMs !== undefined &&
210
+ estimate.remainingHighMs !== undefined &&
211
+ Math.abs(estimate.remainingHighMs - estimate.remainingLowMs) >= 1_000
212
+ ? `${formatRemaining(estimate.remainingLowMs)}–${formatRemaining(estimate.remainingHighMs)}`
213
+ : formatRemaining(estimate.remainingMedianMs);
214
+ eta = `ETA ${range}`;
215
+ } else if (!TERMINAL.has(data.status)) {
216
+ eta = `ETA unavailable${estimate.unavailableReason ? ` (${estimate.unavailableReason})` : ""}`;
217
+ }
218
+ return [label, count, eta].filter(Boolean).join(" ");
219
+ }
220
+
221
+ export function formatProgressReport(
222
+ estimates: ProgressEstimate[],
223
+ nextCheckMinutes?: number,
224
+ now = new Date(),
225
+ maxChars = 4_000,
226
+ ): string {
227
+ const footer = nextCheckMinutes === undefined ? undefined : `Next check: ${nextCheckMinutes} min`;
228
+ const lines: string[] = [];
229
+ let omitted = 0;
230
+ for (const estimate of prioritizeProgressEstimates(estimates)) {
231
+ const block = [`Progress: ${formatProgressLine(estimate, now)}`];
232
+ if (estimate.rateMedian !== undefined && estimate.data.unit !== undefined) {
233
+ const median = estimate.rateMedian * 60_000;
234
+ const low = estimate.rateLow === undefined ? undefined : estimate.rateLow * 60_000;
235
+ const high = estimate.rateHigh === undefined ? undefined : estimate.rateHigh * 60_000;
236
+ const rate =
237
+ low !== undefined && high !== undefined && Math.abs(high - low) >= 0.01
238
+ ? `${formatNumber(low)}–${formatNumber(high)}`
239
+ : formatNumber(median);
240
+ block.push(`Rate: ${rate} ${estimate.data.unit}/min`);
241
+ block.push(
242
+ `Estimate: ${estimate.confidence ?? "low"} confidence, ${estimate.sampleCount} samples`,
243
+ );
244
+ }
245
+ const candidate = [...lines, ...block, ...(footer === undefined ? [] : [footer])].join("\n");
246
+ if (candidate.length > maxChars) {
247
+ omitted += 1;
248
+ continue;
249
+ }
250
+ lines.push(...block);
251
+ }
252
+ if (omitted > 0) {
253
+ const marker = `${omitted} progress track${omitted === 1 ? "" : "s"} omitted.`;
254
+ if (
255
+ [...lines, marker, ...(footer === undefined ? [] : [footer])].join("\n").length <= maxChars
256
+ ) {
257
+ lines.push(marker);
258
+ }
259
+ }
260
+ if (footer !== undefined && [...lines, footer].join("\n").length <= maxChars) lines.push(footer);
261
+ return lines.join("\n").slice(0, maxChars);
262
+ }
263
+
264
+ export function formatRemaining(ms: number): string {
265
+ const value = Math.ceil(Math.max(0, ms) / 1_000) * 1_000;
266
+ if (value < 60_000) return `${Math.ceil(value / 1_000)}s`;
267
+ if (value < 3_600_000) return `${Math.ceil(value / 60_000)}m`;
268
+ if (value < 86_400_000) return `${(value / 3_600_000).toFixed(value < 36_000_000 ? 1 : 0)}h`;
269
+ return `${(value / 86_400_000).toFixed(1)}d`;
270
+ }
271
+
272
+ function currentEpoch(samples: ProgressSample[]): ProgressSample[] {
273
+ const epoch: ProgressSample[] = [];
274
+ for (const sample of samples) {
275
+ const prior = epoch.at(-1);
276
+ if (prior !== undefined && resetsEpoch(prior.data, sample.data)) epoch.length = 0;
277
+ epoch.push(sample);
278
+ }
279
+ return epoch;
280
+ }
281
+
282
+ function resetsEpoch(previous: WorkflowProgressData, next: WorkflowProgressData): boolean {
283
+ return (
284
+ previous.phase !== next.phase ||
285
+ previous.unit !== next.unit ||
286
+ previous.total !== next.total ||
287
+ (previous.completed !== undefined &&
288
+ next.completed !== undefined &&
289
+ next.completed < previous.completed) ||
290
+ (TERMINAL.has(previous.status) && !TERMINAL.has(next.status))
291
+ );
292
+ }
293
+
294
+ function validSourceFinish(sample: ProgressSample, now: Date): string | undefined {
295
+ const finish = sample.data.sourceEstimatedFinishAt;
296
+ if (finish === undefined) return undefined;
297
+ const finishMs = Date.parse(finish);
298
+ const sourceMs = Date.parse(sample.data.sourceUpdatedAt ?? sample.at);
299
+ return finishMs > sourceMs && finishMs > now.getTime() ? finish : undefined;
300
+ }
301
+
302
+ function quantile(sorted: number[], p: number): number {
303
+ if (sorted.length === 1) return sorted[0] as number;
304
+ const position = (sorted.length - 1) * p;
305
+ const lower = Math.floor(position);
306
+ const fraction = position - lower;
307
+ const a = sorted[lower] as number;
308
+ const b = sorted[Math.min(lower + 1, sorted.length - 1)] as number;
309
+ return a + (b - a) * fraction;
310
+ }
311
+
312
+ export function prioritizeProgressEstimates(estimates: ProgressEstimate[]): ProgressEstimate[] {
313
+ const weight = (item: ProgressEstimate) =>
314
+ item.key === "overall"
315
+ ? -3
316
+ : item.data.status === "failed" || item.data.status === "blocked"
317
+ ? -2
318
+ : item.data.status === "waiting"
319
+ ? -1
320
+ : 0;
321
+ return [...estimates].sort((a, b) => weight(a) - weight(b));
322
+ }
323
+
324
+ function formatNumber(value: number): string {
325
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(value);
326
+ }
@@ -86,7 +86,9 @@ export function assertValidActionNode(node: ActionNodeDefinition, nodeId = "acti
86
86
  if (typeof node.exec !== "function") {
87
87
  fail(`node ${nodeId} exec must be a function`);
88
88
  }
89
- assertOptionalFunction((node as ShellActionNodeDefinition).parse, `node ${nodeId} parse`);
89
+ const shell = node as ShellActionNodeDefinition;
90
+ assertOptionalFunction(shell.parse, `node ${nodeId} parse`);
91
+ assertShellUpdates(shell, nodeId);
90
92
  } else if (typeof (node as FunctionActionNodeDefinition).run !== "function") {
91
93
  fail(`node ${nodeId} run must be a function`);
92
94
  }
@@ -101,9 +103,29 @@ export function assertValidShellActionNode(
101
103
  fail(`node ${nodeId} requires an exec function`);
102
104
  }
103
105
  assertOptionalFunction(node.parse, `node ${nodeId} parse`);
106
+ assertShellUpdates(node, nodeId);
104
107
  assertCommonNodeFields(node, nodeId);
105
108
  }
106
109
 
110
+ function assertShellUpdates(node: ShellActionNodeDefinition, nodeId: string): void {
111
+ if (node.updates === undefined) return;
112
+ if (node.updates === null || typeof node.updates !== "object") {
113
+ fail(`node ${nodeId} updates must be an object`);
114
+ }
115
+ if (typeof node.updates.parseLine !== "function") {
116
+ fail(`node ${nodeId} updates.parseLine must be a function`);
117
+ }
118
+ if (node.updates.streams !== undefined) {
119
+ if (
120
+ !Array.isArray(node.updates.streams) ||
121
+ node.updates.streams.length === 0 ||
122
+ node.updates.streams.some((stream) => stream !== "stdout" && stream !== "stderr")
123
+ ) {
124
+ fail(`node ${nodeId} updates.streams must contain stdout or stderr`);
125
+ }
126
+ }
127
+ }
128
+
107
129
  export function assertValidCheckpointNode(
108
130
  node: CheckpointNodeDefinition,
109
131
  nodeId = "checkpoint",
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { CancelledError, TimeoutError } from "./errors.js";
3
- import type { ShellActionExecution, ShellActionResult } from "./types.js";
3
+ import type { ShellActionExecution, ShellActionResult, ShellUpdateLine } from "./types.js";
4
4
 
5
5
  /** Default cap on captured stdout/stderr, each. */
6
6
  const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000;
@@ -51,6 +51,7 @@ function shellFailure(
51
51
  export async function runShellAction(
52
52
  spec: ShellActionExecution,
53
53
  signal?: AbortSignal,
54
+ onLine?: (line: ShellUpdateLine) => Promise<void>,
54
55
  ): Promise<ShellActionResult> {
55
56
  // The node may have been cancelled while an async `exec` callback resolved;
56
57
  // never start side effects for an already-abandoned attempt.
@@ -75,6 +76,7 @@ export async function runShellAction(
75
76
  let stdout = "";
76
77
  let stderr = "";
77
78
  let killedBy: "timeout" | "abort" | null = null;
79
+ let lineError: Error | undefined;
78
80
  let timeout: NodeJS.Timeout | undefined;
79
81
 
80
82
  // Cap retained output so a verbose or unending command cannot exhaust
@@ -114,15 +116,71 @@ export async function runShellAction(
114
116
  };
115
117
  const onAbort = () => kill("abort");
116
118
 
119
+ const lineBuffers: Record<"stdout" | "stderr", string> = { stdout: "", stderr: "" };
120
+ const decoders = {
121
+ stdout: new TextDecoder("utf-8", { fatal: onLine !== undefined }),
122
+ stderr: new TextDecoder("utf-8", { fatal: onLine !== undefined }),
123
+ };
124
+ let lineWork = Promise.resolve();
125
+ const processChunk = (
126
+ stream: "stdout" | "stderr",
127
+ chunk: string,
128
+ source: NodeJS.ReadableStream & { pause(): unknown; resume(): unknown },
129
+ ) => {
130
+ if (stream === "stdout") stdout = appendCapped(stdout, chunk);
131
+ else stderr = appendCapped(stderr, chunk);
132
+ if (onLine === undefined || lineError !== undefined) return;
133
+ source.pause();
134
+ lineWork = lineWork
135
+ .then(async () => {
136
+ const parts = `${lineBuffers[stream]}${chunk}`.split("\n");
137
+ lineBuffers[stream] = parts.pop() ?? "";
138
+ if (Buffer.byteLength(lineBuffers[stream], "utf8") > 64 * 1024) {
139
+ throw new Error(`shell ${stream} update line exceeded 65536 bytes`);
140
+ }
141
+ for (const raw of parts) {
142
+ const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
143
+ if (Buffer.byteLength(text, "utf8") > 64 * 1024) {
144
+ throw new Error(`shell ${stream} update line exceeded 65536 bytes`);
145
+ }
146
+ await onLine({ stream, text });
147
+ }
148
+ })
149
+ .catch((error: unknown) => {
150
+ lineError = error instanceof Error ? error : new Error(String(error));
151
+ kill("abort");
152
+ })
153
+ .finally(() => source.resume());
154
+ };
155
+ const flushLines = async () => {
156
+ await lineWork;
157
+ if (onLine === undefined || lineError !== undefined) return;
158
+ for (const stream of ["stdout", "stderr"] as const) {
159
+ const text = lineBuffers[stream];
160
+ if (Buffer.byteLength(text, "utf8") > 64 * 1024) {
161
+ throw new Error(`shell ${stream} update line exceeded 65536 bytes`);
162
+ }
163
+ if (text.length > 0)
164
+ await onLine({ stream, text: text.endsWith("\r") ? text.slice(0, -1) : text });
165
+ }
166
+ };
167
+
168
+ const decodeChunk = (
169
+ stream: "stdout" | "stderr",
170
+ chunk: Buffer,
171
+ source: NodeJS.ReadableStream & { pause(): unknown; resume(): unknown },
172
+ ) => {
173
+ try {
174
+ processChunk(stream, decoders[stream].decode(chunk, { stream: true }), source);
175
+ } catch (error) {
176
+ lineError = error instanceof Error ? error : new Error(String(error));
177
+ kill("abort");
178
+ }
179
+ };
180
+
117
181
  const finish = new Promise<ShellActionResult>((resolve, reject) => {
118
- child.stdout.setEncoding("utf8");
119
- child.stderr.setEncoding("utf8");
120
- child.stdout.on("data", (chunk: string) => {
121
- stdout = appendCapped(stdout, chunk);
122
- });
123
- child.stderr.on("data", (chunk: string) => {
124
- stderr = appendCapped(stderr, chunk);
125
- });
182
+ child.stdout.on("data", (chunk: Buffer) => decodeChunk("stdout", chunk, child.stdout));
183
+ child.stderr.on("data", (chunk: Buffer) => decodeChunk("stderr", chunk, child.stderr));
126
184
 
127
185
  child.once("error", (error) => {
128
186
  // Spawn failures (missing executable, EACCES) still get a receipt so
@@ -143,24 +201,49 @@ export async function runShellAction(
143
201
  // `close` (unlike `exit`) fires only after stdio has fully closed, so
144
202
  // captured output is never truncated.
145
203
  child.once("close", (exitCode, signalName) => {
146
- const result: ShellActionResult = {
147
- command: spec.command,
148
- args,
149
- cwd,
150
- stdout,
151
- stderr,
152
- exitCode,
153
- signal: signalName,
154
- durationMs: Date.now() - startMs,
155
- };
156
- const error = shellFailure(spec, args, result, killedBy);
157
- if (error) {
158
- // Attach the result so callers can persist the action receipt.
159
- (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result;
160
- reject(error);
161
- return;
204
+ try {
205
+ processChunk("stdout", decoders.stdout.decode(), child.stdout);
206
+ processChunk("stderr", decoders.stderr.decode(), child.stderr);
207
+ } catch (error) {
208
+ lineError = error instanceof Error ? error : new Error(String(error));
162
209
  }
163
- resolve(result);
210
+ void flushLines()
211
+ .then(() => {
212
+ const result: ShellActionResult = {
213
+ command: spec.command,
214
+ args,
215
+ cwd,
216
+ stdout,
217
+ stderr,
218
+ exitCode,
219
+ signal: signalName,
220
+ durationMs: Date.now() - startMs,
221
+ };
222
+ const error = lineError ?? shellFailure(spec, args, result, killedBy);
223
+ if (error) {
224
+ // Attach the result so callers can persist the action receipt.
225
+ (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result;
226
+ reject(error);
227
+ return;
228
+ }
229
+ resolve(result);
230
+ })
231
+ .catch((error: unknown) => {
232
+ const failure = error instanceof Error ? error : new Error(String(error));
233
+ lineError = failure;
234
+ const result: ShellActionResult = {
235
+ command: spec.command,
236
+ args,
237
+ cwd,
238
+ stdout,
239
+ stderr,
240
+ exitCode,
241
+ signal: signalName,
242
+ durationMs: Date.now() - startMs,
243
+ };
244
+ (failure as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result;
245
+ reject(failure);
246
+ });
164
247
  });
165
248
  });
166
249
 
@@ -16,7 +16,10 @@ import type {
16
16
  WorkflowSessionEventRecord,
17
17
  WorkflowTraceEvent,
18
18
  WorkflowTraceEventDraft,
19
+ WorkflowUpdateInput,
20
+ WorkflowUpdateRecord,
19
21
  } from "./types.js";
22
+ import { MAX_CURRENT_UPDATES, createUpdateId, updateProjection } from "./updates.js";
20
23
 
21
24
  export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1" as const;
22
25
  export const RUN_STATE_SCHEMA = "pi-workflows.run-state.v1" as const;
@@ -429,6 +432,53 @@ export class WorkflowRunStore {
429
432
  return await readRunBundle(runDir);
430
433
  }
431
434
 
435
+ /** Publish one durable update under the run's serialized claim-fenced writer. */
436
+ async publishUpdate(
437
+ runDir: string,
438
+ state: WorkflowRunState,
439
+ nodeId: string,
440
+ attemptId: string,
441
+ update: WorkflowUpdateInput,
442
+ options: { signal?: AbortSignal } = {},
443
+ ): Promise<{ event: WorkflowTraceEvent; record: WorkflowUpdateRecord }> {
444
+ return await this.withRunLock(runDir, async () => {
445
+ if (options.signal?.aborted === true) {
446
+ throw options.signal.reason ?? new Error("workflow update attempt is no longer active");
447
+ }
448
+ const exists = (state.updates ?? []).some(
449
+ (record) => record.type === update.type && record.key === update.key,
450
+ );
451
+ if (!exists && (state.updates?.length ?? 0) >= MAX_CURRENT_UPDATES) {
452
+ throw new Error(`workflow run supports at most ${MAX_CURRENT_UPDATES} current updates`);
453
+ }
454
+ const updateId = createUpdateId();
455
+ const data = JSON.parse(JSON.stringify(update.data)) as Record<string, unknown>;
456
+ const event = await this.appendTraceEvent(runDir, state.runId, {
457
+ scope: "node",
458
+ type: "update_published",
459
+ nodeId,
460
+ attemptId,
461
+ payload: { updateId, type: update.type, key: update.key, data },
462
+ });
463
+ const record: WorkflowUpdateRecord = {
464
+ updateId,
465
+ seq: event.seq,
466
+ at: event.at,
467
+ runId: state.runId,
468
+ nodeId,
469
+ attemptId,
470
+ type: update.type,
471
+ key: update.key,
472
+ data,
473
+ };
474
+ state.updates = updateProjection(state.updates, record);
475
+ state.traceSeq = event.seq;
476
+ state.updatedAt = event.at;
477
+ await this.writeProjections(runDir, state);
478
+ return { event, record };
479
+ });
480
+ }
481
+
432
482
  /**
433
483
  * Persist one transition: append the trace event, then rewrite the
434
484
  * projections reflecting it.
@@ -996,6 +1046,8 @@ export type LoadedRunBundle = {
996
1046
  manifest: WorkflowRunManifest;
997
1047
  state: WorkflowRunState;
998
1048
  snapshot: WorkflowDefinitionSnapshot | null;
1049
+ /** Full durable trace when loaded by the current reader. */
1050
+ traceEvents?: WorkflowTraceEvent[];
999
1051
  sessionBinding: WorkflowSessionBinding | null;
1000
1052
  sessionEntries: WorkflowSessionEntryRecord[];
1001
1053
  sessionEvents: WorkflowSessionEventRecord[];
@@ -1016,8 +1068,16 @@ export async function readLastTraceEvent(
1016
1068
  return events.records.at(-1) ?? null;
1017
1069
  }
1018
1070
 
1071
+ export type ReadRunBundleOptions = {
1072
+ /** Load the full append-only trace. Detail views need it; run lists do not. */
1073
+ includeTrace?: boolean;
1074
+ };
1075
+
1019
1076
  /** Read a run bundle from disk. Returns null when the bundle is unreadable. */
1020
- export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
1077
+ export async function readRunBundle(
1078
+ runDir: string,
1079
+ options: ReadRunBundleOptions = {},
1080
+ ): Promise<LoadedRunBundle | null> {
1021
1081
  const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
1022
1082
  if (!manifest || manifest.schema !== RUN_BUNDLE_SCHEMA) {
1023
1083
  return null;
@@ -1035,6 +1095,10 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
1035
1095
  const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
1036
1096
  resolveBundlePath(runDir, paths.workflow, WORKFLOW_SNAPSHOT_PATH),
1037
1097
  );
1098
+ const trace =
1099
+ options.includeTrace === true
1100
+ ? await readNdjsonFile<WorkflowTraceEvent>(resolveBundlePath(runDir, paths.trace, TRACE_PATH))
1101
+ : undefined;
1038
1102
  const sessionDir = resolveBundlePath(runDir, paths.session, SESSION_DIR);
1039
1103
  const sessionBinding = await readJsonFile<WorkflowSessionBinding>(
1040
1104
  path.join(sessionDir, "binding.json"),
@@ -1109,6 +1173,7 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
1109
1173
  manifest,
1110
1174
  state,
1111
1175
  snapshot,
1176
+ ...(trace !== undefined ? { traceEvents: trace.records } : {}),
1112
1177
  sessionBinding,
1113
1178
  sessionEntries: entries.records,
1114
1179
  sessionEvents: events.records,
@@ -1147,7 +1212,7 @@ export async function listRunBundles(outputRoot: string): Promise<LoadedRunBundl
1147
1212
  }
1148
1213
  const bundles: LoadedRunBundle[] = [];
1149
1214
  for (const entry of entries) {
1150
- const bundle = await readRunBundle(path.join(outputRoot, entry));
1215
+ const bundle = await readRunBundle(path.join(outputRoot, entry), { includeTrace: false });
1151
1216
  if (bundle) {
1152
1217
  bundles.push(bundle);
1153
1218
  }