@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
@@ -19,6 +19,55 @@ export type WorkflowNodeContext<TInput = unknown> = {
19
19
  signal: AbortSignal;
20
20
  };
21
21
 
22
+ export type WorkflowUpdateInput = {
23
+ type: string;
24
+ key: string;
25
+ data: Record<string, unknown>;
26
+ };
27
+
28
+ export type WorkflowUpdateRecord = {
29
+ updateId: string;
30
+ seq: number;
31
+ at: string;
32
+ runId: string;
33
+ nodeId: string;
34
+ attemptId: string;
35
+ type: string;
36
+ key: string;
37
+ data: Record<string, unknown>;
38
+ };
39
+
40
+ export type WorkflowUpdateReceipt = Pick<
41
+ WorkflowUpdateRecord,
42
+ "updateId" | "seq" | "at" | "type" | "key"
43
+ >;
44
+
45
+ export type WorkflowActionContext<TInput = unknown> = WorkflowNodeContext<TInput> & {
46
+ publishUpdate(update: WorkflowUpdateInput): Promise<WorkflowUpdateReceipt>;
47
+ };
48
+
49
+ export type WorkflowProgressStatus =
50
+ | "pending"
51
+ | "running"
52
+ | "waiting"
53
+ | "blocked"
54
+ | "completed"
55
+ | "failed"
56
+ | "cancelled"
57
+ | "unknown";
58
+
59
+ export type WorkflowProgressData = {
60
+ schema: "pi-workflows.progress.v1";
61
+ status: WorkflowProgressStatus;
62
+ label?: string;
63
+ phase?: string;
64
+ completed?: number;
65
+ total?: number;
66
+ unit?: string;
67
+ sourceUpdatedAt?: string;
68
+ sourceEstimatedFinishAt?: string;
69
+ };
70
+
22
71
  export type WorkflowNodeCommon = {
23
72
  /**
24
73
  * Per-node timeout or a callback that derives it from the run context.
@@ -78,7 +127,7 @@ export type NotifyNodeDefinition = WorkflowNodeCommon & {
78
127
  /** A deterministic runtime-owned step implemented as a local function. */
79
128
  export type FunctionActionNodeDefinition = WorkflowNodeCommon & {
80
129
  nodeType: "action";
81
- run: (context: WorkflowNodeContext) => MaybePromise<unknown>;
130
+ run: (context: WorkflowActionContext) => MaybePromise<unknown>;
82
131
  };
83
132
 
84
133
  export type ShellActionExecution = {
@@ -106,10 +155,24 @@ export type ShellActionResult = {
106
155
  };
107
156
 
108
157
  /** A deterministic runtime-owned step implemented as a shell command. */
158
+ export type ShellUpdateLine = {
159
+ stream: "stdout" | "stderr";
160
+ text: string;
161
+ };
162
+
163
+ export type ShellActionUpdates = {
164
+ streams?: Array<"stdout" | "stderr">;
165
+ parseLine: (
166
+ line: ShellUpdateLine,
167
+ context: WorkflowActionContext,
168
+ ) => MaybePromise<WorkflowUpdateInput | WorkflowUpdateInput[] | undefined>;
169
+ };
170
+
109
171
  export type ShellActionNodeDefinition = WorkflowNodeCommon & {
110
172
  nodeType: "action";
111
173
  exec: (context: WorkflowNodeContext) => MaybePromise<ShellActionExecution>;
112
174
  parse?: (result: ShellActionResult, context: WorkflowNodeContext) => MaybePromise<unknown>;
175
+ updates?: ShellActionUpdates;
113
176
  };
114
177
 
115
178
  export type ActionNodeDefinition = FunctionActionNodeDefinition | ShellActionNodeDefinition;
@@ -277,6 +340,8 @@ export type WorkflowRunState = {
277
340
  outputs: Record<string, unknown>;
278
341
  results: Record<string, WorkflowNodeResult>;
279
342
  steps: WorkflowStepRecord[];
343
+ /** Latest update for each `(type, key)` pair, sorted by trace sequence. */
344
+ updates?: WorkflowUpdateRecord[];
280
345
  currentNode?: string;
281
346
  currentAttemptId?: string;
282
347
  currentNodeStartedAt?: string;
@@ -424,14 +489,26 @@ export type AgentStepContract = {
424
489
  expectedOutput?: string;
425
490
  };
426
491
 
492
+ /** Optional human-facing labels for an agent step. They never affect execution. */
493
+ export type AgentStepPresentation = {
494
+ runTitle?: string;
495
+ statusDetail?: string;
496
+ };
497
+
427
498
  export type AgentStepRequest = {
428
499
  contract: AgentStepContract;
429
500
  prompt: string;
501
+ presentation?: AgentStepPresentation;
430
502
  /**
431
503
  * Validate a submission from the model. Returns the normalized output or an
432
504
  * error message the executor should surface to the model for retry.
433
505
  */
434
506
  accept: (output: unknown) => Promise<{ ok: true; value: unknown } | { ok: false; error: string }>;
507
+ /** Publish a non-completing update from a headless executor. */
508
+ publishUpdate?: (
509
+ update: WorkflowUpdateInput,
510
+ idempotencyKey?: string,
511
+ ) => Promise<WorkflowUpdateReceipt>;
435
512
  };
436
513
 
437
514
  export type AgentStepSubmission = {
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ WorkflowProgressData,
4
+ WorkflowUpdateInput,
5
+ WorkflowUpdateReceipt,
6
+ WorkflowUpdateRecord,
7
+ } from "./types.js";
8
+
9
+ export const MAX_UPDATE_DATA_BYTES = 64 * 1024;
10
+ export const MAX_CURRENT_UPDATES = 1_024;
11
+ export const UPDATE_RATE_PER_SECOND = 20;
12
+ export const UPDATE_RATE_BURST = 100;
13
+
14
+ const TYPE_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/;
15
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
16
+ const PROGRESS_STATUSES = new Set([
17
+ "pending",
18
+ "running",
19
+ "waiting",
20
+ "blocked",
21
+ "completed",
22
+ "failed",
23
+ "cancelled",
24
+ "unknown",
25
+ ]);
26
+ const PROGRESS_FIELDS = new Set([
27
+ "schema",
28
+ "status",
29
+ "label",
30
+ "phase",
31
+ "completed",
32
+ "total",
33
+ "unit",
34
+ "sourceUpdatedAt",
35
+ "sourceEstimatedFinishAt",
36
+ ]);
37
+
38
+ export function validateWorkflowUpdate(input: unknown): WorkflowUpdateInput {
39
+ if (!isRecord(input)) throw new Error("update must be an object");
40
+ for (const field of Object.keys(input)) {
41
+ if (field !== "type" && field !== "key" && field !== "data") {
42
+ throw new Error(`update.${field} is not supported`);
43
+ }
44
+ }
45
+ const type = input.type;
46
+ const key = input.key;
47
+ const data = input.data;
48
+ if (typeof type !== "string" || !TYPE_PATTERN.test(type)) {
49
+ throw new Error("update.type must match [a-z][a-z0-9.-]{0,63}");
50
+ }
51
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
52
+ throw new Error("update.key must match [A-Za-z0-9][A-Za-z0-9._:/-]{0,127}");
53
+ }
54
+ if (!isRecord(data)) throw new Error("update.data must be a non-null JSON object");
55
+ assertJsonValue(data, "update.data");
56
+ const bytes = Buffer.byteLength(JSON.stringify(data), "utf8");
57
+ if (bytes > MAX_UPDATE_DATA_BYTES) {
58
+ throw new Error(`update.data must be at most ${MAX_UPDATE_DATA_BYTES} bytes`);
59
+ }
60
+ const normalized = { type, key, data };
61
+ if (type === "progress") validateProgressData(data);
62
+ return normalized;
63
+ }
64
+
65
+ export function validateProgressData(data: Record<string, unknown>): WorkflowProgressData {
66
+ for (const field of Object.keys(data)) {
67
+ if (!PROGRESS_FIELDS.has(field)) throw new Error(`progress.${field} is not supported`);
68
+ }
69
+ if (data.schema !== "pi-workflows.progress.v1") {
70
+ throw new Error("progress.schema must equal pi-workflows.progress.v1");
71
+ }
72
+ if (typeof data.status !== "string" || !PROGRESS_STATUSES.has(data.status)) {
73
+ throw new Error("progress.status is invalid");
74
+ }
75
+ optionalString(data.label, "progress.label", 200);
76
+ optionalString(data.phase, "progress.phase", 128);
77
+ optionalFiniteNonNegative(data.completed, "progress.completed");
78
+ if (data.total !== undefined) {
79
+ if (typeof data.total !== "number" || !Number.isFinite(data.total) || data.total <= 0) {
80
+ throw new Error("progress.total must be a finite number greater than zero");
81
+ }
82
+ if (typeof data.completed === "number" && data.total < data.completed) {
83
+ throw new Error("progress.total must be at least progress.completed");
84
+ }
85
+ }
86
+ if (data.completed !== undefined || data.total !== undefined) {
87
+ if (!validUnit(data.unit)) {
88
+ throw new Error(
89
+ "progress.unit is required with counts and must be 1 to 32 printable characters",
90
+ );
91
+ }
92
+ } else if (data.unit !== undefined && !validUnit(data.unit)) {
93
+ throw new Error("progress.unit must be 1 to 32 printable characters");
94
+ }
95
+ optionalDate(data.sourceUpdatedAt, "progress.sourceUpdatedAt");
96
+ optionalDate(data.sourceEstimatedFinishAt, "progress.sourceEstimatedFinishAt");
97
+ return data as WorkflowProgressData;
98
+ }
99
+
100
+ export function updateProjection(
101
+ current: WorkflowUpdateRecord[] | undefined,
102
+ record: WorkflowUpdateRecord,
103
+ ): WorkflowUpdateRecord[] {
104
+ const next = (current ?? []).filter(
105
+ (entry) => !(entry.type === record.type && entry.key === record.key),
106
+ );
107
+ next.push(record);
108
+ next.sort((a, b) => a.seq - b.seq);
109
+ return next;
110
+ }
111
+
112
+ export function createUpdateId(): string {
113
+ return `upd_${randomUUID()}`;
114
+ }
115
+
116
+ export function updateReceipt(record: WorkflowUpdateRecord): WorkflowUpdateReceipt {
117
+ return {
118
+ updateId: record.updateId,
119
+ seq: record.seq,
120
+ at: record.at,
121
+ type: record.type,
122
+ key: record.key,
123
+ };
124
+ }
125
+
126
+ export class UpdateRateLimiter {
127
+ private tokens = UPDATE_RATE_BURST;
128
+ private lastMs = Date.now();
129
+
130
+ take(nowMs = Date.now()): void {
131
+ const elapsed = Math.max(0, nowMs - this.lastMs) / 1_000;
132
+ this.tokens = Math.min(UPDATE_RATE_BURST, this.tokens + elapsed * UPDATE_RATE_PER_SECOND);
133
+ this.lastMs = nowMs;
134
+ if (this.tokens < 1) throw new Error("workflow update rate limit exceeded");
135
+ this.tokens -= 1;
136
+ }
137
+ }
138
+
139
+ function isRecord(value: unknown): value is Record<string, unknown> {
140
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
141
+ const prototype = Object.getPrototypeOf(value) as unknown;
142
+ return prototype === Object.prototype || prototype === null;
143
+ }
144
+
145
+ function assertJsonValue(value: unknown, path: string): void {
146
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
147
+ if (typeof value === "number") {
148
+ if (!Number.isFinite(value)) throw new Error(`${path} contains a non-finite number`);
149
+ return;
150
+ }
151
+ if (Array.isArray(value)) {
152
+ value.forEach((item, index) => assertJsonValue(item, `${path}[${index}]`));
153
+ return;
154
+ }
155
+ if (isRecord(value)) {
156
+ for (const [key, item] of Object.entries(value)) {
157
+ if (item === undefined) throw new Error(`${path}.${key} is undefined`);
158
+ assertJsonValue(item, `${path}.${key}`);
159
+ }
160
+ return;
161
+ }
162
+ throw new Error(`${path} contains a non-JSON value`);
163
+ }
164
+
165
+ function optionalString(value: unknown, field: string, max: number): void {
166
+ if (value === undefined) return;
167
+ if (typeof value !== "string" || value.trim().length < 1 || value.trim().length > max) {
168
+ throw new Error(`${field} must be 1 to ${max} characters`);
169
+ }
170
+ if (
171
+ [...value].some((character) => {
172
+ const code = character.codePointAt(0) ?? 0;
173
+ return code < 32 || (code >= 127 && code <= 159);
174
+ })
175
+ ) {
176
+ throw new Error(`${field} must not contain control characters`);
177
+ }
178
+ }
179
+
180
+ function optionalFiniteNonNegative(value: unknown, field: string): void {
181
+ if (value === undefined) return;
182
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
183
+ throw new Error(`${field} must be a finite non-negative number`);
184
+ }
185
+ }
186
+
187
+ function validUnit(value: unknown): value is string {
188
+ return (
189
+ typeof value === "string" &&
190
+ value.trim().length >= 1 &&
191
+ value.trim().length <= 32 &&
192
+ [...value].every((character) => {
193
+ const code = character.codePointAt(0) ?? 0;
194
+ return code >= 32 && !(code >= 127 && code <= 159);
195
+ })
196
+ );
197
+ }
198
+
199
+ function optionalDate(value: unknown, field: string): void {
200
+ if (value === undefined) return;
201
+ if (
202
+ typeof value !== "string" ||
203
+ !Number.isFinite(Date.parse(value)) ||
204
+ !/[zZ]|[+-]\d\d:\d\d$/.test(value)
205
+ ) {
206
+ throw new Error(`${field} must be an RFC 3339 timestamp with an offset`);
207
+ }
208
+ }