@donkeylabs/server 2.0.20 → 2.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.20",
3
+ "version": "2.0.21",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
package/src/core/index.ts CHANGED
@@ -217,6 +217,12 @@ export {
217
217
  createProcessSocketServer,
218
218
  } from "./process-socket";
219
219
 
220
+ export {
221
+ WorkflowStateMachine,
222
+ type StateMachineEvents,
223
+ type StateMachineConfig,
224
+ } from "./workflow-state-machine";
225
+
220
226
  export {
221
227
  KyselyWorkflowAdapter,
222
228
  type KyselyWorkflowAdapterConfig,
@@ -1,39 +1,31 @@
1
1
  #!/usr/bin/env bun
2
2
  // Workflow Executor - Subprocess Entry Point
3
- // Executes isolated workflows in a separate process to prevent blocking the main event loop
3
+ // Thin shell that creates a WorkflowStateMachine with IPC event bridge.
4
+ // The state machine owns all execution logic and persistence.
4
5
 
5
6
  import { connect } from "node:net";
6
7
  import type { Socket } from "node:net";
7
8
  import { Kysely } from "kysely";
8
9
  import { BunSqliteDialect } from "kysely-bun-sqlite";
9
10
  import Database from "bun:sqlite";
10
- import type { WorkflowEvent, ProxyResponse } from "./workflow-socket";
11
+ import type { WorkflowEvent } from "./workflow-socket";
11
12
  import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
12
- import type { WorkflowDefinition, WorkflowContext, TaskStepDefinition, StepDefinition } from "./workflows";
13
+ import type { WorkflowDefinition } from "./workflows";
14
+ import { KyselyWorkflowAdapter } from "./workflow-adapter-kysely";
15
+ import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
13
16
 
14
17
  // ============================================
15
18
  // Types
16
19
  // ============================================
17
20
 
18
21
  interface ExecutorConfig {
19
- /** Workflow instance ID */
20
22
  instanceId: string;
21
- /** Workflow name (for importing the definition) */
22
23
  workflowName: string;
23
- /** Input data for the workflow */
24
24
  input: any;
25
- /** Unix socket path to connect to */
26
25
  socketPath?: string;
27
- /** TCP port for Windows */
28
26
  tcpPort?: number;
29
- /** Module path to import workflow definition from */
30
27
  modulePath: string;
31
- /** Database file path */
32
28
  dbPath: string;
33
- /** Initial step results (for resuming) */
34
- stepResults?: Record<string, any>;
35
- /** Current step name (for resuming) */
36
- currentStep?: string;
37
29
  }
38
30
 
39
31
  // ============================================
@@ -45,18 +37,28 @@ async function main(): Promise<void> {
45
37
  const stdin = await Bun.stdin.text();
46
38
  const config: ExecutorConfig = JSON.parse(stdin);
47
39
 
48
- const { instanceId, workflowName, input, socketPath, tcpPort, modulePath, dbPath, stepResults, currentStep } = config;
40
+ const { instanceId, workflowName, socketPath, tcpPort, modulePath, dbPath } = config;
49
41
 
50
42
  // Connect to IPC socket
51
43
  const socket = await connectToSocket(socketPath, tcpPort);
52
44
  const proxyConnection = new WorkflowProxyConnection(socket);
53
45
 
54
- // Create database connection for workflow adapter
46
+ // Create database connection + adapter (subprocess owns its own persistence)
55
47
  const db = new Kysely<any>({
56
48
  dialect: new BunSqliteDialect({
57
49
  database: new Database(dbPath),
58
50
  }),
59
51
  });
52
+ const adapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
53
+
54
+ // Start heartbeat
55
+ const heartbeatInterval = setInterval(() => {
56
+ sendEvent(socket, {
57
+ type: "heartbeat",
58
+ instanceId,
59
+ timestamp: Date.now(),
60
+ });
61
+ }, 5000);
60
62
 
61
63
  try {
62
64
  // Send started event
@@ -68,47 +70,23 @@ async function main(): Promise<void> {
68
70
 
69
71
  // Import the workflow module to get the definition
70
72
  const module = await import(modulePath);
73
+ const definition = findWorkflowDefinition(module, workflowName, modulePath);
71
74
 
72
- // Find the workflow definition - it could be exported various ways
73
- let definition: WorkflowDefinition | undefined;
74
-
75
- // Try common export patterns
76
- for (const key of Object.keys(module)) {
77
- const exported = module[key];
78
- if (isWorkflowDefinition(exported) && exported.name === workflowName) {
79
- definition = exported;
80
- break;
81
- }
82
- }
83
-
84
- // Also check default export
85
- if (!definition && module.default) {
86
- if (isWorkflowDefinition(module.default) && module.default.name === workflowName) {
87
- definition = module.default;
88
- }
89
- }
90
-
91
- if (!definition) {
92
- throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
93
- }
94
-
95
- // Create proxy context for plugin/core access
75
+ // Create proxy objects for plugin/core access via IPC
96
76
  const plugins = createPluginsProxy(proxyConnection);
97
77
  const coreServices = createCoreServicesProxy(proxyConnection);
98
78
 
99
- // Execute the workflow
100
- const result = await executeWorkflow(
101
- socket,
102
- proxyConnection,
103
- definition,
104
- instanceId,
105
- input,
106
- db,
79
+ // Create state machine with IPC event bridge
80
+ const sm = new WorkflowStateMachine({
81
+ adapter,
82
+ core: { ...coreServices, db } as any,
107
83
  plugins,
108
- coreServices,
109
- stepResults ?? {},
110
- currentStep ?? definition.startAt
111
- );
84
+ events: createIpcEventBridge(socket, instanceId),
85
+ pollInterval: 1000,
86
+ });
87
+
88
+ // Run the state machine to completion
89
+ const result = await sm.run(instanceId, definition);
112
90
 
113
91
  // Send completed event
114
92
  sendEvent(socket, {
@@ -127,8 +105,10 @@ async function main(): Promise<void> {
127
105
  });
128
106
  process.exit(1);
129
107
  } finally {
108
+ clearInterval(heartbeatInterval);
130
109
  proxyConnection.close();
131
110
  socket.end();
111
+ adapter.stop();
132
112
  await db.destroy();
133
113
  }
134
114
 
@@ -136,320 +116,110 @@ async function main(): Promise<void> {
136
116
  }
137
117
 
138
118
  // ============================================
139
- // Socket Connection
140
- // ============================================
141
-
142
- function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
143
- return new Promise((resolve, reject) => {
144
- let socket: Socket;
145
-
146
- if (socketPath) {
147
- socket = connect(socketPath);
148
- } else if (tcpPort) {
149
- socket = connect(tcpPort, "127.0.0.1");
150
- } else {
151
- reject(new Error("No socket path or TCP port provided"));
152
- return;
153
- }
154
-
155
- socket.once("connect", () => resolve(socket));
156
- socket.once("error", (err) => reject(err));
157
- });
158
- }
159
-
160
- // ============================================
161
- // Workflow Execution
119
+ // IPC Event Bridge
162
120
  // ============================================
163
121
 
164
- async function executeWorkflow(
165
- socket: Socket,
166
- proxyConnection: WorkflowProxyConnection,
167
- definition: WorkflowDefinition,
168
- instanceId: string,
169
- input: any,
170
- db: Kysely<any>,
171
- plugins: Record<string, any>,
172
- coreServices: Record<string, any>,
173
- initialStepResults: Record<string, any>,
174
- startStep: string
175
- ): Promise<any> {
176
- const stepResults: Record<string, any> = { ...initialStepResults };
177
- let currentStepName: string | undefined = startStep;
178
- let lastOutput: any;
179
- let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
180
-
181
- // Start heartbeat
182
- heartbeatInterval = setInterval(() => {
183
- sendEvent(socket, {
184
- type: "heartbeat",
185
- instanceId,
186
- timestamp: Date.now(),
187
- });
188
- }, 5000); // Every 5 seconds
189
-
190
- try {
191
- while (currentStepName) {
192
- const step = definition.steps.get(currentStepName);
193
- if (!step) {
194
- throw new Error(`Step "${currentStepName}" not found in workflow`);
195
- }
196
-
197
- // Send step started event
122
+ function createIpcEventBridge(socket: Socket, instanceId: string): StateMachineEvents {
123
+ return {
124
+ onStepStarted: (id, stepName, stepType) => {
198
125
  sendEvent(socket, {
199
126
  type: "step.started",
200
- instanceId,
127
+ instanceId: id,
201
128
  timestamp: Date.now(),
202
- stepName: currentStepName,
129
+ stepName,
130
+ stepType,
203
131
  });
204
-
205
- // Build context for this step
206
- const ctx = buildContext(
207
- input,
208
- stepResults,
209
- lastOutput,
210
- instanceId,
211
- definition,
212
- currentStepName,
213
- db,
214
- plugins,
215
- coreServices
216
- );
217
-
218
- // Execute step
219
- let output: any;
220
- try {
221
- output = await executeStep(step, ctx);
222
- } catch (error) {
223
- // Check for retry config
224
- const retry = step.retry ?? definition.defaultRetry;
225
- const attempts = (stepResults[currentStepName]?.attempts ?? 0) + 1;
226
-
227
- if (retry && attempts < retry.maxAttempts) {
228
- // Retry logic
229
- const backoffRate = retry.backoffRate ?? 2;
230
- const intervalMs = retry.intervalMs ?? 1000;
231
- const maxIntervalMs = retry.maxIntervalMs ?? 30000;
232
- const delay = Math.min(
233
- intervalMs * Math.pow(backoffRate, attempts - 1),
234
- maxIntervalMs
235
- );
236
-
237
- stepResults[currentStepName] = {
238
- stepName: currentStepName,
239
- status: "pending",
240
- attempts,
241
- error: error instanceof Error ? error.message : String(error),
242
- };
243
-
244
- await new Promise((resolve) => setTimeout(resolve, delay));
245
- continue; // Retry the same step
246
- }
247
-
248
- // No more retries, send step failed event
249
- sendEvent(socket, {
250
- type: "step.failed",
251
- instanceId,
252
- timestamp: Date.now(),
253
- stepName: currentStepName,
254
- error: error instanceof Error ? error.message : String(error),
255
- });
256
- throw error;
257
- }
258
-
259
- // Store step result
260
- stepResults[currentStepName] = {
261
- stepName: currentStepName,
262
- status: "completed",
263
- output,
264
- completedAt: new Date(),
265
- attempts: (stepResults[currentStepName]?.attempts ?? 0) + 1,
266
- };
267
- lastOutput = output;
268
-
269
- // Send step completed event
270
- const completedSteps = Object.values(stepResults).filter(
271
- (r: any) => r.status === "completed"
272
- ).length;
273
- const totalSteps = definition.steps.size;
274
- const progress = Math.round((completedSteps / totalSteps) * 100);
275
-
276
- // Determine next step
277
- let nextStepName: string | undefined;
278
- if (step.end) {
279
- nextStepName = undefined;
280
- } else if (step.next) {
281
- nextStepName = step.next;
282
- }
283
-
132
+ },
133
+ onStepCompleted: (id, stepName, output, nextStep) => {
284
134
  sendEvent(socket, {
285
135
  type: "step.completed",
286
- instanceId,
136
+ instanceId: id,
287
137
  timestamp: Date.now(),
288
- stepName: currentStepName,
138
+ stepName,
289
139
  output,
290
- nextStep: nextStepName,
140
+ nextStep,
291
141
  });
292
-
142
+ },
143
+ onStepFailed: (id, stepName, error, attempts) => {
144
+ sendEvent(socket, {
145
+ type: "step.failed",
146
+ instanceId: id,
147
+ timestamp: Date.now(),
148
+ stepName,
149
+ error,
150
+ });
151
+ },
152
+ onStepRetry: () => {
153
+ // Retry is internal to the state machine - no IPC event needed
154
+ },
155
+ onProgress: (id, progress, currentStep, completed, total) => {
293
156
  sendEvent(socket, {
294
157
  type: "progress",
295
- instanceId,
158
+ instanceId: id,
296
159
  timestamp: Date.now(),
297
160
  progress,
298
- completedSteps,
299
- totalSteps,
161
+ completedSteps: completed,
162
+ totalSteps: total,
300
163
  });
301
-
302
- // Move to next step
303
- if (step.end) {
304
- currentStepName = undefined;
305
- } else if (step.next) {
306
- currentStepName = step.next;
307
- } else {
308
- currentStepName = undefined;
309
- }
310
- }
311
-
312
- return lastOutput;
313
- } finally {
314
- if (heartbeatInterval) {
315
- clearInterval(heartbeatInterval);
316
- }
317
- }
318
- }
319
-
320
- async function executeStep(step: StepDefinition, ctx: WorkflowContext): Promise<any> {
321
- switch (step.type) {
322
- case "task":
323
- return executeTaskStep(step as TaskStepDefinition, ctx);
324
- case "pass":
325
- return executePassStep(step, ctx);
326
- case "choice":
327
- throw new Error("Choice steps should be handled by main process flow");
328
- case "parallel":
329
- throw new Error("Parallel steps should be handled by main process");
330
- default:
331
- throw new Error(`Unknown step type: ${(step as any).type}`);
332
- }
164
+ },
165
+ onCompleted: () => {
166
+ // Handled by the main try/catch after sm.run() returns
167
+ },
168
+ onFailed: () => {
169
+ // Handled by the main try/catch after sm.run() throws
170
+ },
171
+ };
333
172
  }
334
173
 
335
- async function executeTaskStep(step: TaskStepDefinition, ctx: WorkflowContext): Promise<any> {
336
- if (!step.handler) {
337
- throw new Error("Task step requires handler (job-based steps not supported in isolated mode)");
338
- }
174
+ // ============================================
175
+ // Socket Connection
176
+ // ============================================
339
177
 
340
- let input: any;
178
+ function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
179
+ return new Promise((resolve, reject) => {
180
+ let socket: Socket;
341
181
 
342
- if (step.inputSchema) {
343
- if (typeof step.inputSchema === "function") {
344
- // Input mapper function
345
- input = step.inputSchema(ctx.prev, ctx.input);
182
+ if (socketPath) {
183
+ socket = connect(socketPath);
184
+ } else if (tcpPort) {
185
+ socket = connect(tcpPort, "127.0.0.1");
346
186
  } else {
347
- // Zod schema - validate workflow input
348
- const parseResult = step.inputSchema.safeParse(ctx.input);
349
- if (!parseResult.success) {
350
- throw new Error(`Input validation failed: ${parseResult.error.message}`);
351
- }
352
- input = parseResult.data;
353
- }
354
- } else {
355
- input = ctx.input;
356
- }
357
-
358
- // Execute handler
359
- let result = await step.handler(input, ctx);
360
-
361
- // Validate output if schema provided
362
- if (step.outputSchema) {
363
- const parseResult = step.outputSchema.safeParse(result);
364
- if (!parseResult.success) {
365
- throw new Error(`Output validation failed: ${parseResult.error.message}`);
187
+ reject(new Error("No socket path or TCP port provided"));
188
+ return;
366
189
  }
367
- result = parseResult.data;
368
- }
369
190
 
370
- return result;
371
- }
372
-
373
- async function executePassStep(step: any, ctx: WorkflowContext): Promise<any> {
374
- if (step.result !== undefined) {
375
- return step.result;
376
- }
377
- if (step.transform) {
378
- return step.transform(ctx);
379
- }
380
- return ctx.input;
191
+ socket.once("connect", () => resolve(socket));
192
+ socket.once("error", (err) => reject(err));
193
+ });
381
194
  }
382
195
 
383
196
  // ============================================
384
- // Context Building
197
+ // Helpers
385
198
  // ============================================
386
199
 
387
- function buildContext(
388
- input: any,
389
- stepResults: Record<string, any>,
390
- prev: any,
391
- instanceId: string,
392
- definition: WorkflowDefinition,
393
- currentStep: string,
394
- db: Kysely<any>,
395
- plugins: Record<string, any>,
396
- coreServices: Record<string, any>
397
- ): WorkflowContext {
398
- // Build steps object with outputs
399
- const steps: Record<string, any> = {};
400
- for (const [name, result] of Object.entries(stepResults)) {
401
- if ((result as any).status === "completed" && (result as any).output !== undefined) {
402
- steps[name] = (result as any).output;
200
+ function sendEvent(socket: Socket, event: WorkflowEvent): void {
201
+ socket.write(JSON.stringify(event) + "\n");
202
+ }
203
+
204
+ function findWorkflowDefinition(
205
+ module: any,
206
+ workflowName: string,
207
+ modulePath: string,
208
+ ): WorkflowDefinition {
209
+ // Try named exports
210
+ for (const key of Object.keys(module)) {
211
+ const exported = module[key];
212
+ if (isWorkflowDefinition(exported) && exported.name === workflowName) {
213
+ return exported;
403
214
  }
404
215
  }
405
216
 
406
- // Create a fake instance for the context
407
- const instance = {
408
- id: instanceId,
409
- workflowName: definition.name,
410
- status: "running" as const,
411
- currentStep,
412
- input,
413
- stepResults,
414
- createdAt: new Date(),
415
- startedAt: new Date(),
416
- metadata: {},
417
- };
418
-
419
- // Metadata is stored locally - setMetadata sends via proxy
420
- const metadata: Record<string, any> = {};
421
-
422
- return {
423
- input,
424
- steps,
425
- prev,
426
- instance,
427
- getStepResult: <T = any>(stepName: string): T | undefined => {
428
- return steps[stepName] as T | undefined;
429
- },
430
- core: {
431
- ...coreServices,
432
- db,
433
- } as any,
434
- plugins,
435
- metadata,
436
- setMetadata: async (key: string, value: any): Promise<void> => {
437
- metadata[key] = value;
438
- // Update via proxy to persist
439
- await coreServices.workflows?.updateMetadata?.(instanceId, key, value);
440
- },
441
- getMetadata: <T = any>(key: string): T | undefined => {
442
- return metadata[key] as T | undefined;
443
- },
444
- };
445
- }
446
-
447
- // ============================================
448
- // Helpers
449
- // ============================================
217
+ // Try default export
218
+ if (module.default && isWorkflowDefinition(module.default) && module.default.name === workflowName) {
219
+ return module.default;
220
+ }
450
221
 
451
- function sendEvent(socket: Socket, event: WorkflowEvent): void {
452
- socket.write(JSON.stringify(event) + "\n");
222
+ throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
453
223
  }
454
224
 
455
225
  function isWorkflowDefinition(obj: any): obj is WorkflowDefinition {
@@ -26,6 +26,8 @@ export interface WorkflowEvent {
26
26
  instanceId: string;
27
27
  timestamp: number;
28
28
  stepName?: string;
29
+ /** Step type (for step.started events) */
30
+ stepType?: string;
29
31
  output?: any;
30
32
  error?: string;
31
33
  progress?: number;