@donkeylabs/server 2.0.20 → 2.0.22

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.
@@ -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)
47
+ const sqlite = new Database(dbPath);
48
+ sqlite.run("PRAGMA busy_timeout = 5000");
55
49
  const db = new Kysely<any>({
56
- dialect: new BunSqliteDialect({
57
- database: new Database(dbPath),
58
- }),
50
+ dialect: new BunSqliteDialect({ database: sqlite }),
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,32 @@ 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
+ // Wrap coreServices proxy so that `db` resolves locally instead of via IPC.
80
+ // Spreading a Proxy with no ownKeys trap loses all proxied properties.
81
+ const coreWithDb = new Proxy(coreServices, {
82
+ get(target, prop, receiver) {
83
+ if (prop === "db") return db;
84
+ return Reflect.get(target, prop, receiver);
85
+ },
86
+ });
87
+
88
+ // Create state machine with IPC event bridge
89
+ const sm = new WorkflowStateMachine({
90
+ adapter,
91
+ core: coreWithDb as any,
107
92
  plugins,
108
- coreServices,
109
- stepResults ?? {},
110
- currentStep ?? definition.startAt
111
- );
93
+ events: createIpcEventBridge(socket, instanceId),
94
+ pollInterval: 1000,
95
+ });
96
+
97
+ // Run the state machine to completion
98
+ const result = await sm.run(instanceId, definition);
112
99
 
113
100
  // Send completed event
114
101
  sendEvent(socket, {
@@ -127,8 +114,10 @@ async function main(): Promise<void> {
127
114
  });
128
115
  process.exit(1);
129
116
  } finally {
117
+ clearInterval(heartbeatInterval);
130
118
  proxyConnection.close();
131
119
  socket.end();
120
+ adapter.stop();
132
121
  await db.destroy();
133
122
  }
134
123
 
@@ -136,320 +125,110 @@ async function main(): Promise<void> {
136
125
  }
137
126
 
138
127
  // ============================================
139
- // Socket Connection
128
+ // IPC Event Bridge
140
129
  // ============================================
141
130
 
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
162
- // ============================================
163
-
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
131
+ function createIpcEventBridge(socket: Socket, instanceId: string): StateMachineEvents {
132
+ return {
133
+ onStepStarted: (id, stepName, stepType) => {
198
134
  sendEvent(socket, {
199
135
  type: "step.started",
200
- instanceId,
136
+ instanceId: id,
201
137
  timestamp: Date.now(),
202
- stepName: currentStepName,
138
+ stepName,
139
+ stepType,
203
140
  });
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
-
141
+ },
142
+ onStepCompleted: (id, stepName, output, nextStep) => {
284
143
  sendEvent(socket, {
285
144
  type: "step.completed",
286
- instanceId,
145
+ instanceId: id,
287
146
  timestamp: Date.now(),
288
- stepName: currentStepName,
147
+ stepName,
289
148
  output,
290
- nextStep: nextStepName,
149
+ nextStep,
291
150
  });
292
-
151
+ },
152
+ onStepFailed: (id, stepName, error, attempts) => {
153
+ sendEvent(socket, {
154
+ type: "step.failed",
155
+ instanceId: id,
156
+ timestamp: Date.now(),
157
+ stepName,
158
+ error,
159
+ });
160
+ },
161
+ onStepRetry: () => {
162
+ // Retry is internal to the state machine - no IPC event needed
163
+ },
164
+ onProgress: (id, progress, currentStep, completed, total) => {
293
165
  sendEvent(socket, {
294
166
  type: "progress",
295
- instanceId,
167
+ instanceId: id,
296
168
  timestamp: Date.now(),
297
169
  progress,
298
- completedSteps,
299
- totalSteps,
170
+ completedSteps: completed,
171
+ totalSteps: total,
300
172
  });
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
- }
173
+ },
174
+ onCompleted: () => {
175
+ // Handled by the main try/catch after sm.run() returns
176
+ },
177
+ onFailed: () => {
178
+ // Handled by the main try/catch after sm.run() throws
179
+ },
180
+ };
333
181
  }
334
182
 
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
- }
183
+ // ============================================
184
+ // Socket Connection
185
+ // ============================================
339
186
 
340
- let input: any;
187
+ function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
188
+ return new Promise((resolve, reject) => {
189
+ let socket: Socket;
341
190
 
342
- if (step.inputSchema) {
343
- if (typeof step.inputSchema === "function") {
344
- // Input mapper function
345
- input = step.inputSchema(ctx.prev, ctx.input);
191
+ if (socketPath) {
192
+ socket = connect(socketPath);
193
+ } else if (tcpPort) {
194
+ socket = connect(tcpPort, "127.0.0.1");
346
195
  } 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}`);
196
+ reject(new Error("No socket path or TCP port provided"));
197
+ return;
366
198
  }
367
- result = parseResult.data;
368
- }
369
199
 
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;
200
+ socket.once("connect", () => resolve(socket));
201
+ socket.once("error", (err) => reject(err));
202
+ });
381
203
  }
382
204
 
383
205
  // ============================================
384
- // Context Building
206
+ // Helpers
385
207
  // ============================================
386
208
 
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;
209
+ function sendEvent(socket: Socket, event: WorkflowEvent): void {
210
+ socket.write(JSON.stringify(event) + "\n");
211
+ }
212
+
213
+ function findWorkflowDefinition(
214
+ module: any,
215
+ workflowName: string,
216
+ modulePath: string,
217
+ ): WorkflowDefinition {
218
+ // Try named exports
219
+ for (const key of Object.keys(module)) {
220
+ const exported = module[key];
221
+ if (isWorkflowDefinition(exported) && exported.name === workflowName) {
222
+ return exported;
403
223
  }
404
224
  }
405
225
 
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
- // ============================================
226
+ // Try default export
227
+ if (module.default && isWorkflowDefinition(module.default) && module.default.name === workflowName) {
228
+ return module.default;
229
+ }
450
230
 
451
- function sendEvent(socket: Socket, event: WorkflowEvent): void {
452
- socket.write(JSON.stringify(event) + "\n");
231
+ throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
453
232
  }
454
233
 
455
234
  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;