@pylonsync/workflows 0.5.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/examples/onboarding.ts +88 -0
- package/package.json +16 -0
- package/src/index.ts +348 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { workflow, createRunner, type WorkflowContext } from "../src/index";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Workflow: User onboarding
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const onboardingFlow = workflow(
|
|
8
|
+
"user-onboarding",
|
|
9
|
+
async (ctx, { step, sleep, waitForEvent }) => {
|
|
10
|
+
// Step 1: Send welcome email
|
|
11
|
+
const welcomeResult = await step("send-welcome-email", async () => {
|
|
12
|
+
console.log(`Sending welcome email to ${ctx.input.email}`);
|
|
13
|
+
// In real app: await emailService.send(ctx.input.email, 'Welcome!');
|
|
14
|
+
return { sent: true, email: ctx.input.email };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Step 2: Wait 24 hours
|
|
18
|
+
await sleep("24h");
|
|
19
|
+
|
|
20
|
+
// Step 3: Check profile completion
|
|
21
|
+
const profileComplete = await step("check-profile", async () => {
|
|
22
|
+
console.log(`Checking profile for user ${ctx.input.userId}`);
|
|
23
|
+
// In real app: const user = await db.query('User', { id: ctx.input.userId });
|
|
24
|
+
return { completed: Math.random() > 0.5 }; // Simulated
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Step 4: Conditional action
|
|
28
|
+
if (!profileComplete.completed) {
|
|
29
|
+
await step("send-reminder", async () => {
|
|
30
|
+
console.log("Sending reminder email");
|
|
31
|
+
return { reminded: true };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Wait for user to complete profile
|
|
35
|
+
const event = await waitForEvent("profile_completed");
|
|
36
|
+
console.log("Profile completed event received:", event);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Step 5: Final step
|
|
40
|
+
await step("activate-features", async () => {
|
|
41
|
+
console.log("Activating premium features");
|
|
42
|
+
return { activated: true };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return { onboarding: "complete", user: ctx.input.userId };
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Workflow: Data processing pipeline
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const dataProcessingFlow = workflow(
|
|
54
|
+
"data-processing",
|
|
55
|
+
async (ctx, { step, sleep }) => {
|
|
56
|
+
const data = await step("fetch-data", async () => {
|
|
57
|
+
console.log(`Fetching data from ${ctx.input.source}`);
|
|
58
|
+
return { rows: 1000, source: ctx.input.source };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await step("validate", async () => {
|
|
62
|
+
console.log(`Validating ${data.rows} rows`);
|
|
63
|
+
return { valid: data.rows, invalid: 0 };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await step("transform", async () => {
|
|
67
|
+
console.log("Transforming data");
|
|
68
|
+
return { transformed: data.rows };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Pause between steps to avoid rate limits
|
|
72
|
+
await sleep("5s");
|
|
73
|
+
|
|
74
|
+
await step("load", async () => {
|
|
75
|
+
console.log("Loading into database");
|
|
76
|
+
return { loaded: data.rows };
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { processed: data.rows, source: ctx.input.source };
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Start the runner
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const runner = createRunner([onboardingFlow, dataProcessingFlow]);
|
|
88
|
+
runner.serve(4500);
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pylonsync/workflows",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.5.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"bun-types": ">=1.0.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @pylonsync/workflows — DEPRECATED external-runner workflow DSL
|
|
3
|
+
//
|
|
4
|
+
// Use `workflow()` from @pylonsync/functions instead: workflows in the
|
|
5
|
+
// app's workflows/ dir execute IN-PROCESS through the function runtime
|
|
6
|
+
// (steps get a full ActionCtx) with persistence and an automatic driver
|
|
7
|
+
// — no separate runner server. This package remains only for the
|
|
8
|
+
// PYLON_WORKFLOW_RUNNER_URL external-runner escape hatch; it was never
|
|
9
|
+
// published and nothing in the framework spawns its serve() server.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Core types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface WorkflowContext {
|
|
17
|
+
/** The workflow instance ID. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** The workflow name. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** The input data passed when starting the workflow. */
|
|
22
|
+
input: any;
|
|
23
|
+
/** Current step index. */
|
|
24
|
+
currentStep: number;
|
|
25
|
+
/** Results from previously completed steps. */
|
|
26
|
+
completedSteps: StepResult[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StepResult {
|
|
30
|
+
step_id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
status: "pending" | "running" | "completed" | "failed" | "skipped";
|
|
33
|
+
output?: any;
|
|
34
|
+
error?: string;
|
|
35
|
+
started_at?: string;
|
|
36
|
+
completed_at?: string;
|
|
37
|
+
duration_ms?: number;
|
|
38
|
+
retry_count: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Step function types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export type StepFn<T = any> = (ctx: WorkflowContext) => Promise<T>;
|
|
46
|
+
|
|
47
|
+
export interface WorkflowStep {
|
|
48
|
+
name: string;
|
|
49
|
+
fn: StepFn;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SleepStep {
|
|
53
|
+
type: "sleep";
|
|
54
|
+
duration: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WaitEventStep {
|
|
58
|
+
type: "wait_event";
|
|
59
|
+
event: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Workflow helpers — passed to every workflow function
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface WorkflowHelpers {
|
|
67
|
+
/** Execute a named step. The step is durable — if the workflow restarts, completed steps are skipped. */
|
|
68
|
+
step: <T>(name: string, fn: () => Promise<T>) => Promise<T>;
|
|
69
|
+
/** Sleep for a duration. The workflow will be paused and resumed after the duration. */
|
|
70
|
+
sleep: (duration: string) => Promise<void>;
|
|
71
|
+
/** Wait for an external event. The workflow pauses until the event is received. */
|
|
72
|
+
waitForEvent: (eventName: string) => Promise<any>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Workflow definition
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface WorkflowDefinition {
|
|
80
|
+
name: string;
|
|
81
|
+
fn: (ctx: WorkflowContext, helpers: WorkflowHelpers) => Promise<any>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Define a workflow. Returns a workflow definition object.
|
|
86
|
+
*
|
|
87
|
+
* ```typescript
|
|
88
|
+
* const myWorkflow = workflow('my-workflow', async (ctx, { step, sleep, waitForEvent }) => {
|
|
89
|
+
* const result = await step('fetch-data', async () => {
|
|
90
|
+
* return await fetchSomeData(ctx.input.url);
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* await sleep('1h');
|
|
94
|
+
*
|
|
95
|
+
* await step('process', async () => {
|
|
96
|
+
* return await processData(result);
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* const approval = await waitForEvent('approval');
|
|
100
|
+
*
|
|
101
|
+
* if (approval.approved) {
|
|
102
|
+
* await step('finalize', async () => {
|
|
103
|
+
* return await finalize();
|
|
104
|
+
* });
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* return { done: true };
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function workflow(
|
|
112
|
+
name: string,
|
|
113
|
+
fn: (ctx: WorkflowContext, helpers: WorkflowHelpers) => Promise<any>,
|
|
114
|
+
): WorkflowDefinition {
|
|
115
|
+
return { name, fn };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Runner response types
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export type RunnerResponse =
|
|
123
|
+
| { action: "step_complete"; step_name: string; output: any; duration_ms?: number }
|
|
124
|
+
| { action: "sleep"; duration: string }
|
|
125
|
+
| { action: "wait_event"; event: string }
|
|
126
|
+
| { action: "complete"; output: any }
|
|
127
|
+
| { action: "fail"; step_name?: string; error: string };
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Internal error types for control flow
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
class WorkflowPausedError extends Error {
|
|
134
|
+
constructor(reason: string) {
|
|
135
|
+
super(`Workflow paused: ${reason}`);
|
|
136
|
+
this.name = "WorkflowPausedError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class StepNotReachedError extends Error {
|
|
141
|
+
constructor(stepName: string) {
|
|
142
|
+
super(`Step not reached: ${stepName}`);
|
|
143
|
+
this.name = "StepNotReachedError";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Workflow runner
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The workflow runner processes step-by-step execution requests from the
|
|
153
|
+
* Rust engine.
|
|
154
|
+
*
|
|
155
|
+
* The engine sends a request describing the workflow state (completed steps,
|
|
156
|
+
* current step index, input). The runner replays completed steps by returning
|
|
157
|
+
* cached results, then executes the next pending step and returns one of:
|
|
158
|
+
*
|
|
159
|
+
* - `{ action: "step_complete", step_name, output }` — step finished
|
|
160
|
+
* - `{ action: "sleep", duration }` — workflow wants to sleep
|
|
161
|
+
* - `{ action: "wait_event", event }` — workflow awaits an event
|
|
162
|
+
* - `{ action: "complete", output }` — workflow finished
|
|
163
|
+
* - `{ action: "fail", error }` — step or workflow failed
|
|
164
|
+
*/
|
|
165
|
+
export class WorkflowRunner {
|
|
166
|
+
private registry: Map<string, WorkflowDefinition>;
|
|
167
|
+
|
|
168
|
+
constructor(registry: Map<string, WorkflowDefinition>) {
|
|
169
|
+
this.registry = registry;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Handle a step execution request from the engine.
|
|
174
|
+
* Uses a continuation-passing approach: replays completed steps,
|
|
175
|
+
* then executes the next one and returns the result.
|
|
176
|
+
*/
|
|
177
|
+
async handleRequest(request: {
|
|
178
|
+
workflow_id: string;
|
|
179
|
+
workflow_name: string;
|
|
180
|
+
input: any;
|
|
181
|
+
current_step: number;
|
|
182
|
+
completed_steps: StepResult[];
|
|
183
|
+
}): Promise<RunnerResponse> {
|
|
184
|
+
const def = this.registry.get(request.workflow_name);
|
|
185
|
+
if (!def) {
|
|
186
|
+
return { action: "fail", error: `Unknown workflow: ${request.workflow_name}` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const ctx: WorkflowContext = {
|
|
190
|
+
id: request.workflow_id,
|
|
191
|
+
name: request.workflow_name,
|
|
192
|
+
input: request.input,
|
|
193
|
+
currentStep: request.current_step,
|
|
194
|
+
completedSteps: request.completed_steps,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Track which step we're on during replay.
|
|
198
|
+
let stepIndex = 0;
|
|
199
|
+
let pendingResponse: RunnerResponse | null = null;
|
|
200
|
+
|
|
201
|
+
const helpers: WorkflowHelpers = {
|
|
202
|
+
step: async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
203
|
+
const myIndex = stepIndex++;
|
|
204
|
+
|
|
205
|
+
// If this step was already completed, return cached result.
|
|
206
|
+
const existing = request.completed_steps.find(
|
|
207
|
+
(s) => s.name === name && s.status === "completed",
|
|
208
|
+
);
|
|
209
|
+
if (existing && myIndex < request.current_step) {
|
|
210
|
+
return existing.output as T;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// If we're at the current step index, this is the step to execute.
|
|
214
|
+
if (myIndex === request.current_step) {
|
|
215
|
+
const start = Date.now();
|
|
216
|
+
try {
|
|
217
|
+
const result = await fn();
|
|
218
|
+
pendingResponse = {
|
|
219
|
+
action: "step_complete",
|
|
220
|
+
step_name: name,
|
|
221
|
+
output: result,
|
|
222
|
+
duration_ms: Date.now() - start,
|
|
223
|
+
};
|
|
224
|
+
return result;
|
|
225
|
+
} catch (err: any) {
|
|
226
|
+
pendingResponse = {
|
|
227
|
+
action: "fail",
|
|
228
|
+
step_name: name,
|
|
229
|
+
error: err.message || String(err),
|
|
230
|
+
};
|
|
231
|
+
throw err; // Stop workflow execution.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Should not reach here in normal flow.
|
|
236
|
+
throw new StepNotReachedError(name);
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
sleep: async (duration: string): Promise<void> => {
|
|
240
|
+
const myIndex = stepIndex++;
|
|
241
|
+
if (myIndex < request.current_step) {
|
|
242
|
+
return; // Already slept.
|
|
243
|
+
}
|
|
244
|
+
if (myIndex === request.current_step) {
|
|
245
|
+
pendingResponse = { action: "sleep", duration };
|
|
246
|
+
throw new WorkflowPausedError("sleep");
|
|
247
|
+
}
|
|
248
|
+
throw new StepNotReachedError(`sleep:${duration}`);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
waitForEvent: async (eventName: string): Promise<any> => {
|
|
252
|
+
const myIndex = stepIndex++;
|
|
253
|
+
|
|
254
|
+
// If event was already received, return its data.
|
|
255
|
+
const existing = request.completed_steps.find(
|
|
256
|
+
(s) => s.name === `event:${eventName}` && s.status === "completed",
|
|
257
|
+
);
|
|
258
|
+
if (existing && myIndex < request.current_step) {
|
|
259
|
+
return existing.output;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (myIndex === request.current_step) {
|
|
263
|
+
pendingResponse = { action: "wait_event", event: eventName };
|
|
264
|
+
throw new WorkflowPausedError("wait_event");
|
|
265
|
+
}
|
|
266
|
+
throw new StepNotReachedError(`event:${eventName}`);
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const output = await def.fn(ctx, helpers);
|
|
272
|
+
// If we get here without a pending response, the workflow completed.
|
|
273
|
+
if (pendingResponse) {
|
|
274
|
+
return pendingResponse;
|
|
275
|
+
}
|
|
276
|
+
return { action: "complete", output };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof WorkflowPausedError && pendingResponse) {
|
|
279
|
+
return pendingResponse;
|
|
280
|
+
}
|
|
281
|
+
if (err instanceof StepNotReachedError) {
|
|
282
|
+
return { action: "fail", error: `Step sequencing error: ${err.message}` };
|
|
283
|
+
}
|
|
284
|
+
if (pendingResponse) {
|
|
285
|
+
return pendingResponse;
|
|
286
|
+
}
|
|
287
|
+
return { action: "fail", error: (err as any).message || String(err) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Start an HTTP server that handles workflow execution requests.
|
|
293
|
+
* The Rust engine sends POST requests with step execution payloads.
|
|
294
|
+
*/
|
|
295
|
+
serve(port: number = 4500): void {
|
|
296
|
+
const runner = this;
|
|
297
|
+
|
|
298
|
+
const server = Bun.serve({
|
|
299
|
+
port,
|
|
300
|
+
async fetch(req) {
|
|
301
|
+
if (req.method !== "POST") {
|
|
302
|
+
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
303
|
+
status: 405,
|
|
304
|
+
headers: { "Content-Type": "application/json" },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const body = await req.json();
|
|
310
|
+
const response = await runner.handleRequest(body);
|
|
311
|
+
return new Response(JSON.stringify(response), {
|
|
312
|
+
headers: { "Content-Type": "application/json" },
|
|
313
|
+
});
|
|
314
|
+
} catch (err: any) {
|
|
315
|
+
return new Response(
|
|
316
|
+
JSON.stringify({ action: "fail", error: err.message }),
|
|
317
|
+
{
|
|
318
|
+
status: 500,
|
|
319
|
+
headers: { "Content-Type": "application/json" },
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
console.log(`Workflow runner listening on http://localhost:${server.port}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Factory
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Create a workflow runner from an array of workflow definitions.
|
|
336
|
+
*
|
|
337
|
+
* ```typescript
|
|
338
|
+
* const runner = createRunner([onboardingFlow, dataProcessingFlow]);
|
|
339
|
+
* runner.serve(4500);
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
export function createRunner(workflows: WorkflowDefinition[]): WorkflowRunner {
|
|
343
|
+
const registry = new Map<string, WorkflowDefinition>();
|
|
344
|
+
for (const wf of workflows) {
|
|
345
|
+
registry.set(wf.name, wf);
|
|
346
|
+
}
|
|
347
|
+
return new WorkflowRunner(registry);
|
|
348
|
+
}
|