@donkeylabs/server 2.0.18 → 2.0.20

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.
@@ -31,6 +31,7 @@ interface WorkflowInstancesTable {
31
31
  completed_at: string | null;
32
32
  parent_id: string | null;
33
33
  branch_name: string | null;
34
+ metadata: string | null;
34
35
  }
35
36
 
36
37
  interface Database {
@@ -41,6 +42,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
41
42
  private db: Kysely<Database>;
42
43
  private cleanupTimer?: ReturnType<typeof setInterval>;
43
44
  private cleanupDays: number;
45
+ private stopped = false;
44
46
 
45
47
  constructor(db: Kysely<any>, config: KyselyWorkflowAdapterConfig = {}) {
46
48
  this.db = db as Kysely<Database>;
@@ -53,7 +55,16 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
53
55
  }
54
56
  }
55
57
 
58
+ /** Check if adapter is stopped (for safe database access) */
59
+ private checkStopped(): boolean {
60
+ return this.stopped;
61
+ }
62
+
56
63
  async createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance> {
64
+ if (this.checkStopped()) {
65
+ throw new Error("WorkflowAdapter has been stopped");
66
+ }
67
+
57
68
  const id = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
58
69
 
59
70
  await this.db
@@ -75,6 +86,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
75
86
  completed_at: instance.completedAt?.toISOString() ?? null,
76
87
  parent_id: instance.parentId ?? null,
77
88
  branch_name: instance.branchName ?? null,
89
+ metadata: instance.metadata ? JSON.stringify(instance.metadata) : null,
78
90
  })
79
91
  .execute();
80
92
 
@@ -82,17 +94,27 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
82
94
  }
83
95
 
84
96
  async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
85
- const row = await this.db
86
- .selectFrom("__donkeylabs_workflow_instances__")
87
- .selectAll()
88
- .where("id", "=", instanceId)
89
- .executeTakeFirst();
97
+ if (this.checkStopped()) return null;
90
98
 
91
- if (!row) return null;
92
- return this.rowToInstance(row);
99
+ try {
100
+ const row = await this.db
101
+ .selectFrom("__donkeylabs_workflow_instances__")
102
+ .selectAll()
103
+ .where("id", "=", instanceId)
104
+ .executeTakeFirst();
105
+
106
+ if (!row) return null;
107
+ return this.rowToInstance(row);
108
+ } catch (err: any) {
109
+ // Silently ignore errors if adapter was stopped during query
110
+ if (this.stopped && err?.message?.includes("destroyed")) return null;
111
+ throw err;
112
+ }
93
113
  }
94
114
 
95
115
  async updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void> {
116
+ if (this.checkStopped()) return;
117
+
96
118
  const updateData: Partial<WorkflowInstancesTable> = {};
97
119
 
98
120
  if (updates.status !== undefined) {
@@ -121,17 +143,28 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
121
143
  if (updates.completedAt !== undefined) {
122
144
  updateData.completed_at = updates.completedAt?.toISOString() ?? null;
123
145
  }
146
+ if (updates.metadata !== undefined) {
147
+ updateData.metadata = updates.metadata ? JSON.stringify(updates.metadata) : null;
148
+ }
124
149
 
125
150
  if (Object.keys(updateData).length === 0) return;
126
151
 
127
- await this.db
128
- .updateTable("__donkeylabs_workflow_instances__")
129
- .set(updateData)
130
- .where("id", "=", instanceId)
131
- .execute();
152
+ try {
153
+ await this.db
154
+ .updateTable("__donkeylabs_workflow_instances__")
155
+ .set(updateData)
156
+ .where("id", "=", instanceId)
157
+ .execute();
158
+ } catch (err: any) {
159
+ // Silently ignore errors if adapter was stopped during query
160
+ if (this.stopped && err?.message?.includes("destroyed")) return;
161
+ throw err;
162
+ }
132
163
  }
133
164
 
134
165
  async deleteInstance(instanceId: string): Promise<boolean> {
166
+ if (this.checkStopped()) return false;
167
+
135
168
  // Check if exists first since BunSqliteDialect doesn't report numDeletedRows properly
136
169
  const exists = await this.db
137
170
  .selectFrom("__donkeylabs_workflow_instances__")
@@ -153,6 +186,8 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
153
186
  workflowName: string,
154
187
  status?: WorkflowStatus
155
188
  ): Promise<WorkflowInstance[]> {
189
+ if (this.checkStopped()) return [];
190
+
156
191
  let query = this.db
157
192
  .selectFrom("__donkeylabs_workflow_instances__")
158
193
  .selectAll()
@@ -167,16 +202,26 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
167
202
  }
168
203
 
169
204
  async getRunningInstances(): Promise<WorkflowInstance[]> {
170
- const rows = await this.db
171
- .selectFrom("__donkeylabs_workflow_instances__")
172
- .selectAll()
173
- .where("status", "=", "running")
174
- .execute();
205
+ if (this.checkStopped()) return [];
175
206
 
176
- return rows.map((r) => this.rowToInstance(r));
207
+ try {
208
+ const rows = await this.db
209
+ .selectFrom("__donkeylabs_workflow_instances__")
210
+ .selectAll()
211
+ .where("status", "=", "running")
212
+ .execute();
213
+
214
+ return rows.map((r) => this.rowToInstance(r));
215
+ } catch (err: any) {
216
+ // Silently ignore errors if adapter was stopped during query
217
+ if (this.stopped && err?.message?.includes("destroyed")) return [];
218
+ throw err;
219
+ }
177
220
  }
178
221
 
179
222
  async getAllInstances(options: GetAllWorkflowsOptions = {}): Promise<WorkflowInstance[]> {
223
+ if (this.checkStopped()) return [];
224
+
180
225
  const { status, workflowName, limit = 100, offset = 0 } = options;
181
226
 
182
227
  let query = this.db.selectFrom("__donkeylabs_workflow_instances__").selectAll();
@@ -231,12 +276,13 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
231
276
  completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
232
277
  parentId: row.parent_id ?? undefined,
233
278
  branchName: row.branch_name ?? undefined,
279
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
234
280
  };
235
281
  }
236
282
 
237
283
  /** Clean up old completed/failed/cancelled workflows */
238
284
  private async cleanup(): Promise<void> {
239
- if (this.cleanupDays <= 0) return;
285
+ if (this.cleanupDays <= 0 || this.checkStopped()) return;
240
286
 
241
287
  try {
242
288
  const cutoff = new Date();
@@ -268,6 +314,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
268
314
 
269
315
  /** Stop the adapter and cleanup timer */
270
316
  stop(): void {
317
+ this.stopped = true;
271
318
  if (this.cleanupTimer) {
272
319
  clearInterval(this.cleanupTimer);
273
320
  this.cleanupTimer = undefined;
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env bun
2
+ // Workflow Executor - Subprocess Entry Point
3
+ // Executes isolated workflows in a separate process to prevent blocking the main event loop
4
+
5
+ import { connect } from "node:net";
6
+ import type { Socket } from "node:net";
7
+ import { Kysely } from "kysely";
8
+ import { BunSqliteDialect } from "kysely-bun-sqlite";
9
+ import Database from "bun:sqlite";
10
+ import type { WorkflowEvent, ProxyResponse } from "./workflow-socket";
11
+ import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
12
+ import type { WorkflowDefinition, WorkflowContext, TaskStepDefinition, StepDefinition } from "./workflows";
13
+
14
+ // ============================================
15
+ // Types
16
+ // ============================================
17
+
18
+ interface ExecutorConfig {
19
+ /** Workflow instance ID */
20
+ instanceId: string;
21
+ /** Workflow name (for importing the definition) */
22
+ workflowName: string;
23
+ /** Input data for the workflow */
24
+ input: any;
25
+ /** Unix socket path to connect to */
26
+ socketPath?: string;
27
+ /** TCP port for Windows */
28
+ tcpPort?: number;
29
+ /** Module path to import workflow definition from */
30
+ modulePath: string;
31
+ /** Database file path */
32
+ dbPath: string;
33
+ /** Initial step results (for resuming) */
34
+ stepResults?: Record<string, any>;
35
+ /** Current step name (for resuming) */
36
+ currentStep?: string;
37
+ }
38
+
39
+ // ============================================
40
+ // Main Executor
41
+ // ============================================
42
+
43
+ async function main(): Promise<void> {
44
+ // Read config from stdin
45
+ const stdin = await Bun.stdin.text();
46
+ const config: ExecutorConfig = JSON.parse(stdin);
47
+
48
+ const { instanceId, workflowName, input, socketPath, tcpPort, modulePath, dbPath, stepResults, currentStep } = config;
49
+
50
+ // Connect to IPC socket
51
+ const socket = await connectToSocket(socketPath, tcpPort);
52
+ const proxyConnection = new WorkflowProxyConnection(socket);
53
+
54
+ // Create database connection for workflow adapter
55
+ const db = new Kysely<any>({
56
+ dialect: new BunSqliteDialect({
57
+ database: new Database(dbPath),
58
+ }),
59
+ });
60
+
61
+ try {
62
+ // Send started event
63
+ sendEvent(socket, {
64
+ type: "started",
65
+ instanceId,
66
+ timestamp: Date.now(),
67
+ });
68
+
69
+ // Import the workflow module to get the definition
70
+ const module = await import(modulePath);
71
+
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
96
+ const plugins = createPluginsProxy(proxyConnection);
97
+ const coreServices = createCoreServicesProxy(proxyConnection);
98
+
99
+ // Execute the workflow
100
+ const result = await executeWorkflow(
101
+ socket,
102
+ proxyConnection,
103
+ definition,
104
+ instanceId,
105
+ input,
106
+ db,
107
+ plugins,
108
+ coreServices,
109
+ stepResults ?? {},
110
+ currentStep ?? definition.startAt
111
+ );
112
+
113
+ // Send completed event
114
+ sendEvent(socket, {
115
+ type: "completed",
116
+ instanceId,
117
+ timestamp: Date.now(),
118
+ output: result,
119
+ });
120
+ } catch (error) {
121
+ // Send failed event
122
+ sendEvent(socket, {
123
+ type: "failed",
124
+ instanceId,
125
+ timestamp: Date.now(),
126
+ error: error instanceof Error ? error.message : String(error),
127
+ });
128
+ process.exit(1);
129
+ } finally {
130
+ proxyConnection.close();
131
+ socket.end();
132
+ await db.destroy();
133
+ }
134
+
135
+ process.exit(0);
136
+ }
137
+
138
+ // ============================================
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
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
198
+ sendEvent(socket, {
199
+ type: "step.started",
200
+ instanceId,
201
+ timestamp: Date.now(),
202
+ stepName: currentStepName,
203
+ });
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
+
284
+ sendEvent(socket, {
285
+ type: "step.completed",
286
+ instanceId,
287
+ timestamp: Date.now(),
288
+ stepName: currentStepName,
289
+ output,
290
+ nextStep: nextStepName,
291
+ });
292
+
293
+ sendEvent(socket, {
294
+ type: "progress",
295
+ instanceId,
296
+ timestamp: Date.now(),
297
+ progress,
298
+ completedSteps,
299
+ totalSteps,
300
+ });
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
+ }
333
+ }
334
+
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
+ }
339
+
340
+ let input: any;
341
+
342
+ if (step.inputSchema) {
343
+ if (typeof step.inputSchema === "function") {
344
+ // Input mapper function
345
+ input = step.inputSchema(ctx.prev, ctx.input);
346
+ } 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}`);
366
+ }
367
+ result = parseResult.data;
368
+ }
369
+
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;
381
+ }
382
+
383
+ // ============================================
384
+ // Context Building
385
+ // ============================================
386
+
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;
403
+ }
404
+ }
405
+
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
+ // ============================================
450
+
451
+ function sendEvent(socket: Socket, event: WorkflowEvent): void {
452
+ socket.write(JSON.stringify(event) + "\n");
453
+ }
454
+
455
+ function isWorkflowDefinition(obj: any): obj is WorkflowDefinition {
456
+ return (
457
+ obj &&
458
+ typeof obj === "object" &&
459
+ typeof obj.name === "string" &&
460
+ obj.steps instanceof Map &&
461
+ typeof obj.startAt === "string"
462
+ );
463
+ }
464
+
465
+ // Run main
466
+ main().catch((err) => {
467
+ console.error("[WorkflowExecutor] Fatal error:", err);
468
+ process.exit(1);
469
+ });