@donkeylabs/server 2.0.19 → 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.
@@ -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,239 @@
1
+ #!/usr/bin/env bun
2
+ // Workflow Executor - Subprocess Entry Point
3
+ // Thin shell that creates a WorkflowStateMachine with IPC event bridge.
4
+ // The state machine owns all execution logic and persistence.
5
+
6
+ import { connect } from "node:net";
7
+ import type { Socket } from "node:net";
8
+ import { Kysely } from "kysely";
9
+ import { BunSqliteDialect } from "kysely-bun-sqlite";
10
+ import Database from "bun:sqlite";
11
+ import type { WorkflowEvent } from "./workflow-socket";
12
+ import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
13
+ import type { WorkflowDefinition } from "./workflows";
14
+ import { KyselyWorkflowAdapter } from "./workflow-adapter-kysely";
15
+ import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
16
+
17
+ // ============================================
18
+ // Types
19
+ // ============================================
20
+
21
+ interface ExecutorConfig {
22
+ instanceId: string;
23
+ workflowName: string;
24
+ input: any;
25
+ socketPath?: string;
26
+ tcpPort?: number;
27
+ modulePath: string;
28
+ dbPath: string;
29
+ }
30
+
31
+ // ============================================
32
+ // Main Executor
33
+ // ============================================
34
+
35
+ async function main(): Promise<void> {
36
+ // Read config from stdin
37
+ const stdin = await Bun.stdin.text();
38
+ const config: ExecutorConfig = JSON.parse(stdin);
39
+
40
+ const { instanceId, workflowName, socketPath, tcpPort, modulePath, dbPath } = config;
41
+
42
+ // Connect to IPC socket
43
+ const socket = await connectToSocket(socketPath, tcpPort);
44
+ const proxyConnection = new WorkflowProxyConnection(socket);
45
+
46
+ // Create database connection + adapter (subprocess owns its own persistence)
47
+ const db = new Kysely<any>({
48
+ dialect: new BunSqliteDialect({
49
+ database: new Database(dbPath),
50
+ }),
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);
62
+
63
+ try {
64
+ // Send started event
65
+ sendEvent(socket, {
66
+ type: "started",
67
+ instanceId,
68
+ timestamp: Date.now(),
69
+ });
70
+
71
+ // Import the workflow module to get the definition
72
+ const module = await import(modulePath);
73
+ const definition = findWorkflowDefinition(module, workflowName, modulePath);
74
+
75
+ // Create proxy objects for plugin/core access via IPC
76
+ const plugins = createPluginsProxy(proxyConnection);
77
+ const coreServices = createCoreServicesProxy(proxyConnection);
78
+
79
+ // Create state machine with IPC event bridge
80
+ const sm = new WorkflowStateMachine({
81
+ adapter,
82
+ core: { ...coreServices, db } as any,
83
+ plugins,
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);
90
+
91
+ // Send completed event
92
+ sendEvent(socket, {
93
+ type: "completed",
94
+ instanceId,
95
+ timestamp: Date.now(),
96
+ output: result,
97
+ });
98
+ } catch (error) {
99
+ // Send failed event
100
+ sendEvent(socket, {
101
+ type: "failed",
102
+ instanceId,
103
+ timestamp: Date.now(),
104
+ error: error instanceof Error ? error.message : String(error),
105
+ });
106
+ process.exit(1);
107
+ } finally {
108
+ clearInterval(heartbeatInterval);
109
+ proxyConnection.close();
110
+ socket.end();
111
+ adapter.stop();
112
+ await db.destroy();
113
+ }
114
+
115
+ process.exit(0);
116
+ }
117
+
118
+ // ============================================
119
+ // IPC Event Bridge
120
+ // ============================================
121
+
122
+ function createIpcEventBridge(socket: Socket, instanceId: string): StateMachineEvents {
123
+ return {
124
+ onStepStarted: (id, stepName, stepType) => {
125
+ sendEvent(socket, {
126
+ type: "step.started",
127
+ instanceId: id,
128
+ timestamp: Date.now(),
129
+ stepName,
130
+ stepType,
131
+ });
132
+ },
133
+ onStepCompleted: (id, stepName, output, nextStep) => {
134
+ sendEvent(socket, {
135
+ type: "step.completed",
136
+ instanceId: id,
137
+ timestamp: Date.now(),
138
+ stepName,
139
+ output,
140
+ nextStep,
141
+ });
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) => {
156
+ sendEvent(socket, {
157
+ type: "progress",
158
+ instanceId: id,
159
+ timestamp: Date.now(),
160
+ progress,
161
+ completedSteps: completed,
162
+ totalSteps: total,
163
+ });
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
+ };
172
+ }
173
+
174
+ // ============================================
175
+ // Socket Connection
176
+ // ============================================
177
+
178
+ function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
179
+ return new Promise((resolve, reject) => {
180
+ let socket: Socket;
181
+
182
+ if (socketPath) {
183
+ socket = connect(socketPath);
184
+ } else if (tcpPort) {
185
+ socket = connect(tcpPort, "127.0.0.1");
186
+ } else {
187
+ reject(new Error("No socket path or TCP port provided"));
188
+ return;
189
+ }
190
+
191
+ socket.once("connect", () => resolve(socket));
192
+ socket.once("error", (err) => reject(err));
193
+ });
194
+ }
195
+
196
+ // ============================================
197
+ // Helpers
198
+ // ============================================
199
+
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;
214
+ }
215
+ }
216
+
217
+ // Try default export
218
+ if (module.default && isWorkflowDefinition(module.default) && module.default.name === workflowName) {
219
+ return module.default;
220
+ }
221
+
222
+ throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
223
+ }
224
+
225
+ function isWorkflowDefinition(obj: any): obj is WorkflowDefinition {
226
+ return (
227
+ obj &&
228
+ typeof obj === "object" &&
229
+ typeof obj.name === "string" &&
230
+ obj.steps instanceof Map &&
231
+ typeof obj.startAt === "string"
232
+ );
233
+ }
234
+
235
+ // Run main
236
+ main().catch((err) => {
237
+ console.error("[WorkflowExecutor] Fatal error:", err);
238
+ process.exit(1);
239
+ });
@@ -0,0 +1,238 @@
1
+ // Workflow Proxy Utilities
2
+ // Provides transparent access to plugins and core services from isolated workflow subprocess via IPC
3
+
4
+ import type { Socket } from "node:net";
5
+ import type { ProxyRequest, ProxyResponse } from "./workflow-socket";
6
+
7
+ // ============================================
8
+ // Types
9
+ // ============================================
10
+
11
+ export interface ProxyConnection {
12
+ /** Send a proxy request and wait for response */
13
+ call(target: "plugin" | "core", service: string, method: string, args: any[]): Promise<any>;
14
+ /** Close the connection */
15
+ close(): void;
16
+ }
17
+
18
+ interface PendingRequest {
19
+ resolve: (value: any) => void;
20
+ reject: (error: Error) => void;
21
+ timeout: ReturnType<typeof setTimeout>;
22
+ }
23
+
24
+ // ============================================
25
+ // Proxy Connection Implementation
26
+ // ============================================
27
+
28
+ /**
29
+ * Creates a proxy connection that sends requests over a socket and handles responses.
30
+ * Used by the workflow executor subprocess to communicate with the main process.
31
+ */
32
+ export class WorkflowProxyConnection implements ProxyConnection {
33
+ private socket: Socket;
34
+ private pendingRequests = new Map<string, PendingRequest>();
35
+ private requestCounter = 0;
36
+ private timeoutMs: number;
37
+ private buffer = "";
38
+
39
+ constructor(socket: Socket, timeoutMs = 30000) {
40
+ this.socket = socket;
41
+ this.timeoutMs = timeoutMs;
42
+
43
+ // Handle incoming data (proxy responses)
44
+ socket.on("data", (data) => {
45
+ this.buffer += data.toString();
46
+ this.processBuffer();
47
+ });
48
+
49
+ socket.on("error", (err) => {
50
+ // Reject all pending requests on socket error
51
+ for (const [requestId, pending] of this.pendingRequests) {
52
+ clearTimeout(pending.timeout);
53
+ pending.reject(new Error(`Socket error: ${err.message}`));
54
+ }
55
+ this.pendingRequests.clear();
56
+ });
57
+
58
+ socket.on("close", () => {
59
+ // Reject all pending requests on socket close
60
+ for (const [requestId, pending] of this.pendingRequests) {
61
+ clearTimeout(pending.timeout);
62
+ pending.reject(new Error("Socket closed"));
63
+ }
64
+ this.pendingRequests.clear();
65
+ });
66
+ }
67
+
68
+ private processBuffer(): void {
69
+ const lines = this.buffer.split("\n");
70
+ this.buffer = lines.pop() || "";
71
+
72
+ for (const line of lines) {
73
+ if (!line.trim()) continue;
74
+
75
+ try {
76
+ const response = JSON.parse(line) as ProxyResponse;
77
+ this.handleResponse(response);
78
+ } catch {
79
+ // Ignore invalid JSON - might be other message types
80
+ }
81
+ }
82
+ }
83
+
84
+ private handleResponse(response: ProxyResponse): void {
85
+ const pending = this.pendingRequests.get(response.requestId);
86
+ if (!pending) return;
87
+
88
+ clearTimeout(pending.timeout);
89
+ this.pendingRequests.delete(response.requestId);
90
+
91
+ if (response.type === "proxy.result") {
92
+ pending.resolve(response.result);
93
+ } else {
94
+ pending.reject(new Error(response.error ?? "Proxy call failed"));
95
+ }
96
+ }
97
+
98
+ async call(target: "plugin" | "core", service: string, method: string, args: any[]): Promise<any> {
99
+ const requestId = `req_${++this.requestCounter}_${Date.now()}`;
100
+
101
+ const request: ProxyRequest = {
102
+ type: "proxy.call",
103
+ requestId,
104
+ target,
105
+ service,
106
+ method,
107
+ args,
108
+ };
109
+
110
+ return new Promise((resolve, reject) => {
111
+ const timeout = setTimeout(() => {
112
+ this.pendingRequests.delete(requestId);
113
+ reject(new Error(`Proxy call timed out: ${target}.${service}.${method}`));
114
+ }, this.timeoutMs);
115
+
116
+ this.pendingRequests.set(requestId, { resolve, reject, timeout });
117
+
118
+ // Send request
119
+ this.socket.write(JSON.stringify(request) + "\n", (err) => {
120
+ if (err) {
121
+ clearTimeout(timeout);
122
+ this.pendingRequests.delete(requestId);
123
+ reject(new Error(`Failed to send proxy request: ${err.message}`));
124
+ }
125
+ });
126
+ });
127
+ }
128
+
129
+ close(): void {
130
+ // Cancel all pending requests
131
+ for (const [requestId, pending] of this.pendingRequests) {
132
+ clearTimeout(pending.timeout);
133
+ pending.reject(new Error("Connection closed"));
134
+ }
135
+ this.pendingRequests.clear();
136
+ }
137
+ }
138
+
139
+ // ============================================
140
+ // Proxy Factories
141
+ // ============================================
142
+
143
+ /**
144
+ * Creates a proxy object for accessing a plugin service.
145
+ * Method calls are intercepted and forwarded via IPC to the main process.
146
+ *
147
+ * @example
148
+ * const usersProxy = createPluginProxy(connection, "users");
149
+ * const user = await usersProxy.getById("user_123"); // Calls main process
150
+ */
151
+ export function createPluginProxy<T = Record<string, any>>(
152
+ connection: ProxyConnection,
153
+ pluginName: string
154
+ ): T {
155
+ return new Proxy({}, {
156
+ get(_target, prop) {
157
+ if (typeof prop === "symbol") return undefined;
158
+
159
+ // Return a function that calls the method via IPC
160
+ return async (...args: any[]) => {
161
+ return connection.call("plugin", pluginName, prop as string, args);
162
+ };
163
+ },
164
+ }) as T;
165
+ }
166
+
167
+ /**
168
+ * Creates a proxy object for accessing a core service.
169
+ * Method calls are intercepted and forwarded via IPC to the main process.
170
+ *
171
+ * @example
172
+ * const cacheProxy = createCoreProxy(connection, "cache");
173
+ * await cacheProxy.set("key", "value"); // Calls main process
174
+ */
175
+ export function createCoreProxy<T = Record<string, any>>(
176
+ connection: ProxyConnection,
177
+ serviceName: string
178
+ ): T {
179
+ return new Proxy({}, {
180
+ get(_target, prop) {
181
+ if (typeof prop === "symbol") return undefined;
182
+
183
+ // Return a function that calls the method via IPC
184
+ return async (...args: any[]) => {
185
+ return connection.call("core", serviceName, prop as string, args);
186
+ };
187
+ },
188
+ }) as T;
189
+ }
190
+
191
+ /**
192
+ * Creates a full plugins proxy that lazily creates plugin proxies on access.
193
+ *
194
+ * @example
195
+ * const plugins = createPluginsProxy(connection);
196
+ * const user = await plugins.users.getById("user_123");
197
+ * const order = await plugins.orders.create({ ... });
198
+ */
199
+ export function createPluginsProxy(connection: ProxyConnection): Record<string, any> {
200
+ const cache = new Map<string, any>();
201
+
202
+ return new Proxy({}, {
203
+ get(_target, prop) {
204
+ if (typeof prop === "symbol") return undefined;
205
+
206
+ const pluginName = prop as string;
207
+ if (!cache.has(pluginName)) {
208
+ cache.set(pluginName, createPluginProxy(connection, pluginName));
209
+ }
210
+ return cache.get(pluginName);
211
+ },
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Creates a full core services proxy that lazily creates service proxies on access.
217
+ * Note: Some services like db require special handling as they can't be fully proxied.
218
+ *
219
+ * @example
220
+ * const core = createCoreServicesProxy(connection);
221
+ * await core.cache.set("key", "value");
222
+ * await core.events.emit("user.created", { userId: "123" });
223
+ */
224
+ export function createCoreServicesProxy(connection: ProxyConnection): Record<string, any> {
225
+ const cache = new Map<string, any>();
226
+
227
+ return new Proxy({}, {
228
+ get(_target, prop) {
229
+ if (typeof prop === "symbol") return undefined;
230
+
231
+ const serviceName = prop as string;
232
+ if (!cache.has(serviceName)) {
233
+ cache.set(serviceName, createCoreProxy(connection, serviceName));
234
+ }
235
+ return cache.get(serviceName);
236
+ },
237
+ });
238
+ }