@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
package/src/workflows/types.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|