@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.
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
6
+ "include": ["src"]
7
+ }