@osolmaz/pi-workflows 0.6.1 → 0.8.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.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: pi-workflows
3
+ description: Use when creating, reviewing, debugging, starting, inspecting, or controlling Pi Workflows; authoring .workflow.ts files; using the workflow tool; handling workflow step contracts, checkpoints, updates, or progress; or deciding how a task should compose workflow primitives.
4
+ compatibility: Requires the Pi Workflows extension.
5
+ ---
6
+
7
+ # Pi Workflows
8
+
9
+ Use Pi Workflows for durable multi-step work that needs explicit routing, retries, checkpoints, scheduled waits, or progress. Keep simple one-turn work outside a workflow.
10
+
11
+ The `workflow` tool schema is the authority for call shapes. A workflow step message is the authority for its current step id, attempt id, and expected output. Do not guess these values from an earlier attempt.
12
+
13
+ ## Operate workflows
14
+
15
+ Use the smallest applicable action:
16
+
17
+ - `list` discovers available workflows. Use its offset for later pages.
18
+ - `start` starts a discovered workflow name or workflow file path with structured input.
19
+ - `status` reads the active run, or the named run when `runId` is supplied.
20
+ - `pause`, `resume`, and `cancel` control the current active run.
21
+ - `answer` supplies input to a waiting checkpoint.
22
+ - `update` publishes a non-completing durable update for the active step attempt.
23
+ - `submit` completes the active agent step with its required output.
24
+
25
+ Use `start` only once for one requested run. Do not build a manual polling loop around a workflow that already schedules its own work. Use the `monitor` skill for monitoring requests.
26
+
27
+ ## Complete agent steps
28
+
29
+ When a workflow step message arrives:
30
+
31
+ 1. Do the requested work with the available tools.
32
+ 2. Produce output that matches the exact expected shape.
33
+ 3. Call `workflow` with `action: "submit"` exactly once, using the step and attempt ids from that message.
34
+ 4. If validation rejects the output, correct it and submit again with the same current ids.
35
+ 5. After acceptance, end the turn. The workflow sends the next step or presentation message when needed.
36
+
37
+ A node id can run more than once in a loop. Each run has a new attempt id. Never reuse an attempt id from conversation history.
38
+
39
+ ## Publish updates and progress
40
+
41
+ Use `update` only while the named step attempt is active. An update does not complete the step and does not control routing.
42
+
43
+ For progress, publish `pi-workflows.progress.v1` data with stable track keys. Report observed counts and source-provided estimates. Do not invent totals, rates, confidence, or completion times. Use separate keys for concurrent processes and send explicit terminal states before a track disappears.
44
+
45
+ The workflow definition should publish progress from function and shell actions when the runtime already has exact counts. Do not add an agent step only to format data that code can publish directly.
46
+
47
+ Read [../../docs/WORKFLOW_UPDATES.md](../../docs/WORKFLOW_UPDATES.md) before adding update producers or progress estimation.
48
+
49
+ ## Author workflows
50
+
51
+ A workflow is a `.workflow.ts`, `.workflow.js`, `.workflow.mts`, or `.workflow.mjs` module whose default export comes from `defineWorkflow(...)`.
52
+
53
+ Follow these rules:
54
+
55
+ - Compose the existing node and edge primitives before adding a new primitive.
56
+ - Keep `compute` pure. Put external effects in agent, function-action, or shell-action nodes.
57
+ - Use structured node outputs for routing.
58
+ - Use a checkpoint when progress requires human input.
59
+ - Set explicit step and command timeouts.
60
+ - Bound ordinary loops with `maxSteps` or another clear finish rule.
61
+ - Use a controller instead of a workflow for indefinite resource reconciliation.
62
+ - Keep presentation separate from execution. Use `presentationPrompt` only when a final assistant response is needed.
63
+ - Preserve the single active workflow rule in one Pi session.
64
+
65
+ Read [../../docs/workflows.md](../../docs/workflows.md) before creating or changing a workflow. Read [../../docs/DESIGN_PHILOSOPHY.md](../../docs/DESIGN_PHILOSOPHY.md) before adding public primitives. Use the examples under [../../examples/workflows](../../examples/workflows) as starting points.
66
+
67
+ ## Verify changes
68
+
69
+ For workflow definitions, test success, failure, routing, retries or loops, checkpoints, timeouts, cancellation, and resume behavior that applies.
70
+
71
+ For extension or engine changes, run the repository checks and the real-Pi end-to-end suite. Verify discovery through the installed package path rather than only loading the source extension file.
@@ -0,0 +1,24 @@
1
+ export {
2
+ createJobProgressReporter,
3
+ estimateJobProgress,
4
+ type JobProgressReporter,
5
+ } from "./reporter.js";
6
+ export {
7
+ isTerminalJobProgressState,
8
+ MAX_JOB_PROGRESS_BYTES,
9
+ MAX_JOB_PROGRESS_TRACKS,
10
+ validateJobProgressSnapshot,
11
+ } from "./validation.js";
12
+ export {
13
+ JOB_PROGRESS_SCHEMA,
14
+ type JobProgressCost,
15
+ type JobProgressEstimate,
16
+ type JobProgressIdentity,
17
+ type JobProgressPublish,
18
+ type JobProgressPublishResult,
19
+ type JobProgressReporterOptions,
20
+ type JobProgressSnapshot,
21
+ type JobProgressState,
22
+ type JobProgressTrack,
23
+ type JobProgressUpdate,
24
+ } from "./types.js";
@@ -0,0 +1,304 @@
1
+ import {
2
+ estimateProgress,
3
+ type ProgressSample,
4
+ type ProgressTrackState,
5
+ } from "../workflows/index.js";
6
+ import {
7
+ JOB_PROGRESS_SCHEMA,
8
+ type JobProgressEstimate,
9
+ type JobProgressPublishResult,
10
+ type JobProgressReporterOptions,
11
+ type JobProgressSnapshot,
12
+ type JobProgressTrack,
13
+ type JobProgressUpdate,
14
+ } from "./types.js";
15
+ import { isTerminalJobProgressState, validateJobProgressSnapshot } from "./validation.js";
16
+
17
+ const DEFAULT_MINIMUM_INTERVAL_MS = 30_000;
18
+ const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000;
19
+
20
+ export type JobProgressReporter = {
21
+ report(update: JobProgressUpdate): Promise<JobProgressPublishResult>;
22
+ flush(): Promise<JobProgressPublishResult>;
23
+ snapshot(): JobProgressSnapshot;
24
+ };
25
+
26
+ export function createJobProgressReporter(
27
+ options: JobProgressReporterOptions,
28
+ ): JobProgressReporter {
29
+ const now = options.now ?? (() => new Date());
30
+ const minimumIntervalMs = nonNegativeInteger(
31
+ options.minimumIntervalMs ?? DEFAULT_MINIMUM_INTERVAL_MS,
32
+ "minimumIntervalMs",
33
+ );
34
+ const publishTimeoutMs = positiveInteger(
35
+ options.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS,
36
+ "publishTimeoutMs",
37
+ );
38
+ let current =
39
+ options.previousSnapshot === undefined
40
+ ? validateJobProgressSnapshot({
41
+ schema: JOB_PROGRESS_SCHEMA,
42
+ application: options.application,
43
+ component: options.component,
44
+ jobId: options.jobId,
45
+ sourceRevision: options.sourceRevision,
46
+ contractHash: options.contractHash,
47
+ sequence: 0,
48
+ state: options.initialState ?? "running",
49
+ phase: options.initialPhase ?? "starting",
50
+ startedAt: options.startedAt,
51
+ updatedAt: options.startedAt,
52
+ ...(options.deadlineAt === undefined ? {} : { deadlineAt: options.deadlineAt }),
53
+ tracks: options.initialTracks,
54
+ })
55
+ : validatePreviousSnapshot(options, options.previousSnapshot);
56
+ const resumed = options.previousSnapshot !== undefined;
57
+ let lastQueuedAtMs = resumed ? Date.parse(current.updatedAt) : Number.NEGATIVE_INFINITY;
58
+ let lastQueuedSequence = -1;
59
+ let lastPublishedSequence = resumed ? current.sequence : -1;
60
+ let queuedOperation: Promise<void> | undefined;
61
+ let tail: Promise<void> = Promise.resolve();
62
+
63
+ const enqueue = (snapshot: JobProgressSnapshot): Promise<void> => {
64
+ if (lastQueuedSequence === snapshot.sequence && queuedOperation !== undefined) {
65
+ return queuedOperation;
66
+ }
67
+ lastQueuedAtMs = Date.parse(snapshot.updatedAt);
68
+ lastQueuedSequence = snapshot.sequence;
69
+ const started = tail.then(() =>
70
+ startPublication(options.publish, cloneSnapshot(snapshot), publishTimeoutMs),
71
+ );
72
+ const operation = started.then(async (publication) => {
73
+ await publication.result;
74
+ lastPublishedSequence = Math.max(lastPublishedSequence, snapshot.sequence);
75
+ });
76
+ queuedOperation = operation;
77
+ tail = started.then((publication) => publication.settled).catch(() => undefined);
78
+ void operation
79
+ .finally(() => {
80
+ if (lastQueuedSequence === snapshot.sequence) {
81
+ lastQueuedSequence = -1;
82
+ queuedOperation = undefined;
83
+ }
84
+ })
85
+ .catch(() => undefined);
86
+ return operation;
87
+ };
88
+
89
+ return {
90
+ async report(update) {
91
+ if (isTerminalJobProgressState(current.state)) {
92
+ throw new Error("job progress cannot change after a terminal state");
93
+ }
94
+ if (current.sequence >= Number.MAX_SAFE_INTEGER) {
95
+ throw new Error("job progress sequence is exhausted");
96
+ }
97
+ const timestamp = now().toISOString();
98
+ const state = update.state ?? current.state;
99
+ const phase = update.phase ?? current.phase;
100
+ const finishedAt = isTerminalJobProgressState(state)
101
+ ? (update.finishedAt ?? timestamp)
102
+ : update.finishedAt;
103
+ const tracks = isTerminalJobProgressState(state)
104
+ ? terminalizeTracks(update.tracks ?? current.tracks, state)
105
+ : (update.tracks ?? current.tracks);
106
+ const next = validateJobProgressSnapshot({
107
+ ...current,
108
+ sequence: current.sequence + 1,
109
+ state,
110
+ phase,
111
+ updatedAt: timestamp,
112
+ tracks,
113
+ ...(update.cost === undefined ? {} : { cost: update.cost }),
114
+ ...(finishedAt === undefined ? {} : { finishedAt }),
115
+ });
116
+ assertMonotonicTracks(current, next);
117
+ const publishNow =
118
+ update.force === true ||
119
+ phase !== current.phase ||
120
+ isTerminalJobProgressState(state) ||
121
+ Date.parse(timestamp) - lastQueuedAtMs >= minimumIntervalMs;
122
+ current = next;
123
+ if (!publishNow) return { snapshot: cloneSnapshot(next), published: false };
124
+ await enqueue(next);
125
+ return { snapshot: cloneSnapshot(next), published: true };
126
+ },
127
+
128
+ async flush() {
129
+ const snapshot = current;
130
+ if (lastPublishedSequence >= snapshot.sequence) {
131
+ await tail;
132
+ return { snapshot: cloneSnapshot(snapshot), published: false };
133
+ }
134
+ await enqueue(snapshot);
135
+ return { snapshot: cloneSnapshot(snapshot), published: true };
136
+ },
137
+
138
+ snapshot() {
139
+ return cloneSnapshot(current);
140
+ },
141
+ };
142
+ }
143
+
144
+ export function estimateJobProgress(
145
+ snapshots: JobProgressSnapshot[],
146
+ now = new Date(),
147
+ ): JobProgressEstimate {
148
+ if (snapshots.length === 0)
149
+ throw new Error("job progress estimation requires at least one snapshot");
150
+ const validated = snapshots
151
+ .map(validateJobProgressSnapshot)
152
+ .sort((left, right) => left.sequence - right.sequence);
153
+ const latest = validated.at(-1) as JobProgressSnapshot;
154
+ for (const snapshot of validated) assertSameIdentity(latest, snapshot);
155
+ for (let index = 1; index < validated.length; index += 1) {
156
+ const previous = validated[index - 1] as JobProgressSnapshot;
157
+ const current = validated[index] as JobProgressSnapshot;
158
+ if (previous.sequence === current.sequence) {
159
+ throw new Error(`job progress sequence ${current.sequence} is duplicated`);
160
+ }
161
+ if (Date.parse(previous.updatedAt) > Date.parse(current.updatedAt)) {
162
+ throw new Error("job progress updatedAt must not decrease as sequence increases");
163
+ }
164
+ }
165
+ const keys = latest.tracks.map((track) => track.key);
166
+ const tracks: ProgressTrackState[] = keys.map((key) => {
167
+ const samples: ProgressSample[] = validated.flatMap((snapshot) => {
168
+ const track = snapshot.tracks.find((candidate) => candidate.key === key);
169
+ return track === undefined ? [] : [{ at: snapshot.updatedAt, data: track.data }];
170
+ });
171
+ return { key, samples, estimate: estimateProgress(key, samples, now) };
172
+ });
173
+ return {
174
+ snapshot: cloneSnapshot(latest),
175
+ tracks,
176
+ estimates: tracks.map((track) => track.estimate),
177
+ };
178
+ }
179
+
180
+ function terminalizeTracks(
181
+ tracks: JobProgressTrack[],
182
+ state: "completed" | "failed" | "cancelled",
183
+ ): JobProgressTrack[] {
184
+ return tracks.map((track) => ({
185
+ key: track.key,
186
+ data: {
187
+ ...track.data,
188
+ status:
189
+ track.data.status === "completed" ||
190
+ track.data.status === "failed" ||
191
+ track.data.status === "cancelled"
192
+ ? track.data.status
193
+ : state,
194
+ },
195
+ }));
196
+ }
197
+
198
+ function assertMonotonicTracks(previous: JobProgressSnapshot, next: JobProgressSnapshot): void {
199
+ if (previous.phase !== next.phase) return;
200
+ for (const track of next.tracks) {
201
+ const prior = previous.tracks.find((candidate) => candidate.key === track.key);
202
+ if (prior === undefined || resetsTrack(prior, track)) continue;
203
+ if (
204
+ prior.data.completed !== undefined &&
205
+ track.data.completed !== undefined &&
206
+ track.data.completed < prior.data.completed
207
+ ) {
208
+ throw new Error(`job progress track ${track.key} completed value must not decrease`);
209
+ }
210
+ }
211
+ }
212
+
213
+ function resetsTrack(previous: JobProgressTrack, next: JobProgressTrack): boolean {
214
+ return (
215
+ previous.data.phase !== next.data.phase ||
216
+ previous.data.unit !== next.data.unit ||
217
+ previous.data.total !== next.data.total
218
+ );
219
+ }
220
+
221
+ function assertSameIdentity(expected: JobProgressSnapshot, actual: JobProgressSnapshot): void {
222
+ for (const field of [
223
+ "application",
224
+ "component",
225
+ "jobId",
226
+ "sourceRevision",
227
+ "contractHash",
228
+ "startedAt",
229
+ ] as const) {
230
+ if (expected[field] !== actual[field]) {
231
+ throw new Error(`job progress sample ${field} does not match`);
232
+ }
233
+ }
234
+ }
235
+
236
+ function startPublication(
237
+ publish: JobProgressReporterOptions["publish"],
238
+ snapshot: JobProgressSnapshot,
239
+ timeoutMs: number,
240
+ ): { result: Promise<void>; settled: Promise<void> } {
241
+ const controller = new AbortController();
242
+ let timeout: NodeJS.Timeout | undefined;
243
+ const write = publish(snapshot, controller.signal);
244
+ const deadline = new Promise<never>((_resolve, reject) => {
245
+ timeout = setTimeout(() => {
246
+ controller.abort();
247
+ reject(new Error(`job progress publication timed out after ${timeoutMs} ms`));
248
+ }, timeoutMs);
249
+ });
250
+ const result = Promise.race([write, deadline]).finally(() => {
251
+ if (timeout !== undefined) clearTimeout(timeout);
252
+ });
253
+ return {
254
+ result,
255
+ settled: write.then(
256
+ () => undefined,
257
+ () => undefined,
258
+ ),
259
+ };
260
+ }
261
+
262
+ function validatePreviousSnapshot(
263
+ identity: JobProgressReporterOptions,
264
+ previous: JobProgressSnapshot,
265
+ ): JobProgressSnapshot {
266
+ const validated = validateJobProgressSnapshot(previous);
267
+ for (const field of [
268
+ "application",
269
+ "component",
270
+ "jobId",
271
+ "sourceRevision",
272
+ "contractHash",
273
+ "startedAt",
274
+ ] as const) {
275
+ if (identity[field] !== validated[field]) {
276
+ throw new Error(`job progress previous snapshot ${field} does not match`);
277
+ }
278
+ }
279
+ if (isTerminalJobProgressState(validated.state)) {
280
+ throw new Error("job progress cannot resume from a terminal snapshot");
281
+ }
282
+ if (identity.deadlineAt !== validated.deadlineAt) {
283
+ throw new Error("job progress previous snapshot deadlineAt does not match");
284
+ }
285
+ return validated;
286
+ }
287
+
288
+ function cloneSnapshot(snapshot: JobProgressSnapshot): JobProgressSnapshot {
289
+ return structuredClone(snapshot);
290
+ }
291
+
292
+ function nonNegativeInteger(value: number, field: string): number {
293
+ if (!Number.isSafeInteger(value) || value < 0) {
294
+ throw new Error(`${field} must be a non-negative safe integer`);
295
+ }
296
+ return value;
297
+ }
298
+
299
+ function positiveInteger(value: number, field: string): number {
300
+ if (!Number.isSafeInteger(value) || value <= 0) {
301
+ throw new Error(`${field} must be a positive safe integer`);
302
+ }
303
+ return value;
304
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ ProgressEstimate,
3
+ ProgressTrackState,
4
+ WorkflowProgressData,
5
+ } from "../workflows/index.js";
6
+
7
+ export const JOB_PROGRESS_SCHEMA = "pi-workflows.job-progress.v1" as const;
8
+
9
+ export type JobProgressState =
10
+ | "queued"
11
+ | "running"
12
+ | "waiting"
13
+ | "blocked"
14
+ | "completed"
15
+ | "failed"
16
+ | "cancelled"
17
+ | "unknown";
18
+
19
+ export type JobProgressTrack = {
20
+ key: string;
21
+ data: WorkflowProgressData;
22
+ };
23
+
24
+ export type JobProgressCost = {
25
+ settledUsd: number;
26
+ reservedUsd: number;
27
+ };
28
+
29
+ export type JobProgressSnapshot = {
30
+ schema: typeof JOB_PROGRESS_SCHEMA;
31
+ application: string;
32
+ component: string;
33
+ jobId: string;
34
+ sourceRevision: string;
35
+ contractHash: string;
36
+ sequence: number;
37
+ state: JobProgressState;
38
+ phase: string;
39
+ startedAt: string;
40
+ updatedAt: string;
41
+ deadlineAt?: string;
42
+ finishedAt?: string;
43
+ tracks: JobProgressTrack[];
44
+ cost?: JobProgressCost;
45
+ };
46
+
47
+ export type JobProgressIdentity = Pick<
48
+ JobProgressSnapshot,
49
+ "application" | "component" | "jobId" | "sourceRevision" | "contractHash" | "startedAt"
50
+ > & {
51
+ deadlineAt?: string;
52
+ };
53
+
54
+ export type JobProgressUpdate = {
55
+ state?: JobProgressState;
56
+ phase?: string;
57
+ tracks?: JobProgressTrack[];
58
+ cost?: JobProgressCost;
59
+ finishedAt?: string;
60
+ force?: boolean;
61
+ };
62
+
63
+ export type JobProgressPublish = (
64
+ snapshot: JobProgressSnapshot,
65
+ signal: AbortSignal,
66
+ ) => Promise<void>;
67
+
68
+ type JobProgressReporterBaseOptions = JobProgressIdentity & {
69
+ initialState?: Exclude<JobProgressState, "completed" | "failed" | "cancelled">;
70
+ initialPhase?: string;
71
+ publish: JobProgressPublish;
72
+ minimumIntervalMs?: number;
73
+ publishTimeoutMs?: number;
74
+ now?: () => Date;
75
+ };
76
+
77
+ export type JobProgressReporterOptions = JobProgressReporterBaseOptions &
78
+ (
79
+ | { initialTracks: JobProgressTrack[]; previousSnapshot?: never }
80
+ | { initialTracks?: JobProgressTrack[]; previousSnapshot: JobProgressSnapshot }
81
+ );
82
+
83
+ export type JobProgressPublishResult = {
84
+ snapshot: JobProgressSnapshot;
85
+ published: boolean;
86
+ };
87
+
88
+ export type JobProgressEstimate = {
89
+ snapshot: JobProgressSnapshot;
90
+ tracks: ProgressTrackState[];
91
+ estimates: ProgressEstimate[];
92
+ };