@osolmaz/pi-workflows 0.5.3 → 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.
- package/README.md +10 -2
- package/dist/builtins/catalog.js +1 -1
- package/dist/builtins/monitor.workflow.d.ts +25 -69
- package/dist/builtins/monitor.workflow.js +194 -123
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/extension/executor.d.ts +7 -2
- package/dist/extension/executor.js +20 -14
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +51 -13
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/step-message.d.ts +24 -0
- package/dist/extension/step-message.js +106 -0
- package/dist/extension/step-message.js.map +1 -0
- package/dist/extension/widget.d.ts +2 -2
- package/dist/extension/widget.js +64 -5
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +9 -0
- package/dist/extension/workflow-tool.js +10 -0
- package/dist/extension/workflow-tool.js.map +1 -1
- package/dist/host/rpc-bridge.js +25 -11
- package/dist/host/rpc-bridge.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +2 -2
- package/dist/host/rpc-executor.js +23 -12
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/viewer/cli.js +1 -1
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +14 -0
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/tui.js +1 -1
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -1
- package/dist/workflows/engine.js +88 -6
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +4 -2
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +34 -0
- package/dist/workflows/progress.js +268 -0
- package/dist/workflows/progress.js.map +1 -0
- package/dist/workflows/schema.js +21 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/shell.d.ts +2 -2
- package/dist/workflows/shell.js +103 -25
- package/dist/workflows/shell.js.map +1 -1
- package/dist/workflows/store.d.ts +15 -2
- package/dist/workflows/store.js +44 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +52 -1
- package/dist/workflows/updates.d.ts +15 -0
- package/dist/workflows/updates.js +188 -0
- package/dist/workflows/updates.js.map +1 -0
- package/docs/DESIGN_PHILOSOPHY.md +51 -0
- package/docs/MONITOR.md +282 -0
- package/docs/WORKFLOW_STEP_MESSAGES.md +141 -0
- package/docs/WORKFLOW_UPDATES.md +416 -0
- package/docs/development.md +7 -3
- package/docs/plans/2026-08-16-workflow-updates-plan.md +494 -0
- package/docs/run-bundles.md +10 -2
- package/docs/workflows.md +57 -17
- package/package.json +1 -1
- package/src/builtins/catalog.ts +1 -1
- package/src/builtins/monitor.workflow.ts +217 -148
- package/src/extension/executor.ts +36 -14
- package/src/extension/index.ts +81 -19
- package/src/extension/step-message.ts +145 -0
- package/src/extension/widget.ts +93 -4
- package/src/extension/workflow-tool.ts +22 -0
- package/src/host/rpc-bridge.ts +37 -14
- package/src/host/rpc-executor.ts +35 -14
- package/src/viewer/cli.ts +1 -1
- package/src/viewer/render.ts +27 -0
- package/src/viewer/tui.ts +1 -1
- package/src/workflows/engine.ts +117 -4
- package/src/workflows/index.ts +32 -0
- package/src/workflows/progress.ts +326 -0
- package/src/workflows/schema.ts +23 -1
- package/src/workflows/shell.ts +109 -26
- package/src/workflows/store.ts +67 -2
- package/src/workflows/types.ts +78 -1
- 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
|
+
}
|
package/src/workflows/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/src/workflows/shell.ts
CHANGED
|
@@ -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.
|
|
119
|
-
child.stderr.
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
package/src/workflows/store.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|