@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.
@@ -0,0 +1,449 @@
1
+ // Workflow Socket Server
2
+ // Handles bidirectional communication with isolated workflow processes via Unix sockets (or TCP on Windows)
3
+
4
+ import { mkdir, rm, readdir, unlink } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import type { Server as NetServer, Socket } from "node:net";
8
+ import { createServer as createNetServer } from "node:net";
9
+
10
+ // ============================================
11
+ // Message Protocol Types
12
+ // ============================================
13
+
14
+ export type WorkflowEventType =
15
+ | "started"
16
+ | "heartbeat"
17
+ | "step.started"
18
+ | "step.completed"
19
+ | "step.failed"
20
+ | "progress"
21
+ | "completed"
22
+ | "failed";
23
+
24
+ export interface WorkflowEvent {
25
+ type: WorkflowEventType;
26
+ instanceId: string;
27
+ timestamp: number;
28
+ stepName?: string;
29
+ /** Step type (for step.started events) */
30
+ stepType?: string;
31
+ output?: any;
32
+ error?: string;
33
+ progress?: number;
34
+ completedSteps?: number;
35
+ totalSteps?: number;
36
+ /** Next step to execute (for step.completed events) */
37
+ nextStep?: string;
38
+ }
39
+
40
+ export interface ProxyRequest {
41
+ type: "proxy.call";
42
+ requestId: string;
43
+ target: "plugin" | "core";
44
+ service: string;
45
+ method: string;
46
+ args: any[];
47
+ }
48
+
49
+ export interface ProxyResponse {
50
+ type: "proxy.result" | "proxy.error";
51
+ requestId: string;
52
+ result?: any;
53
+ error?: string;
54
+ }
55
+
56
+ export type WorkflowMessage = WorkflowEvent | ProxyRequest;
57
+
58
+ // ============================================
59
+ // Socket Server Types
60
+ // ============================================
61
+
62
+ export interface WorkflowSocketServerOptions {
63
+ /** Directory for Unix sockets */
64
+ socketDir: string;
65
+ /** TCP port range for Windows fallback */
66
+ tcpPortRange: [number, number];
67
+ /** Callback when a workflow event is received */
68
+ onEvent: (event: WorkflowEvent) => void | Promise<void>;
69
+ /** Callback when a proxy call is received (returns result or throws) */
70
+ onProxyCall: (request: ProxyRequest) => Promise<any>;
71
+ /** Callback when a connection is established */
72
+ onConnect?: (instanceId: string) => void;
73
+ /** Callback when a connection is closed */
74
+ onDisconnect?: (instanceId: string) => void;
75
+ /** Callback for errors */
76
+ onError?: (error: Error, instanceId?: string) => void;
77
+ }
78
+
79
+ export interface WorkflowSocketServer {
80
+ /** Create a new socket for a workflow instance (returns socket path or TCP port) */
81
+ createSocket(instanceId: string): Promise<{ socketPath?: string; tcpPort?: number }>;
82
+ /** Close a specific workflow's socket and release reservations */
83
+ closeSocket(instanceId: string): Promise<void>;
84
+ /** Get all active workflow connections */
85
+ getActiveConnections(): string[];
86
+ /** Send a response to a proxy request */
87
+ sendProxyResponse(instanceId: string, response: ProxyResponse): boolean;
88
+ /** Shutdown all sockets and cleanup */
89
+ shutdown(): Promise<void>;
90
+ /** Clean orphaned socket files from a previous run */
91
+ cleanOrphanedSockets(activeInstanceIds: Set<string>): Promise<void>;
92
+ }
93
+
94
+ // ============================================
95
+ // Implementation
96
+ // ============================================
97
+
98
+ export class WorkflowSocketServerImpl implements WorkflowSocketServer {
99
+ private socketDir: string;
100
+ private tcpPortRange: [number, number];
101
+ private onEvent: (event: WorkflowEvent) => void | Promise<void>;
102
+ private onProxyCall: (request: ProxyRequest) => Promise<any>;
103
+ private onConnect?: (instanceId: string) => void;
104
+ private onDisconnect?: (instanceId: string) => void;
105
+ private onError?: (error: Error, instanceId?: string) => void;
106
+
107
+ // Map of instanceId -> server instance
108
+ private servers = new Map<string, NetServer>();
109
+ // Map of instanceId -> active client socket
110
+ private clientSockets = new Map<string, Socket>();
111
+ // Map of instanceId -> socket path
112
+ private socketPaths = new Map<string, string>();
113
+ // Map of instanceId -> TCP port
114
+ private tcpPorts = new Map<string, number>();
115
+ // Track used TCP ports
116
+ private usedPorts = new Set<number>();
117
+
118
+ private isWindows = process.platform === "win32";
119
+
120
+ constructor(options: WorkflowSocketServerOptions) {
121
+ this.socketDir = options.socketDir;
122
+ this.tcpPortRange = options.tcpPortRange;
123
+ this.onEvent = options.onEvent;
124
+ this.onProxyCall = options.onProxyCall;
125
+ this.onConnect = options.onConnect;
126
+ this.onDisconnect = options.onDisconnect;
127
+ this.onError = options.onError;
128
+ }
129
+
130
+ async createSocket(instanceId: string): Promise<{ socketPath?: string; tcpPort?: number }> {
131
+ // Ensure socket directory exists (only for Unix)
132
+ if (!this.isWindows) {
133
+ await mkdir(this.socketDir, { recursive: true });
134
+ }
135
+
136
+ if (this.isWindows) {
137
+ return this.createTcpServer(instanceId);
138
+ } else {
139
+ return this.createUnixServer(instanceId);
140
+ }
141
+ }
142
+
143
+ private async createUnixServer(instanceId: string): Promise<{ socketPath: string }> {
144
+ const socketPath = join(this.socketDir, `workflow_${instanceId}.sock`);
145
+
146
+ // Remove existing socket file if it exists
147
+ if (existsSync(socketPath)) {
148
+ await unlink(socketPath);
149
+ }
150
+
151
+ return new Promise((resolve, reject) => {
152
+ const server = createNetServer((socket) => {
153
+ this.handleConnection(instanceId, socket);
154
+ });
155
+
156
+ server.on("error", (err) => {
157
+ this.onError?.(err, instanceId);
158
+ reject(err);
159
+ });
160
+
161
+ server.listen(socketPath, () => {
162
+ this.servers.set(instanceId, server);
163
+ this.socketPaths.set(instanceId, socketPath);
164
+ resolve({ socketPath });
165
+ });
166
+ });
167
+ }
168
+
169
+ private async createTcpServer(instanceId: string): Promise<{ tcpPort: number }> {
170
+ const port = await this.findAvailablePort();
171
+
172
+ return new Promise((resolve, reject) => {
173
+ const server = createNetServer((socket) => {
174
+ this.handleConnection(instanceId, socket);
175
+ });
176
+
177
+ server.on("error", (err) => {
178
+ this.usedPorts.delete(port);
179
+ this.onError?.(err, instanceId);
180
+ reject(err);
181
+ });
182
+
183
+ server.listen(port, "127.0.0.1", () => {
184
+ this.servers.set(instanceId, server);
185
+ this.tcpPorts.set(instanceId, port);
186
+ this.usedPorts.add(port);
187
+ resolve({ tcpPort: port });
188
+ });
189
+ });
190
+ }
191
+
192
+ private async findAvailablePort(): Promise<number> {
193
+ const [minPort, maxPort] = this.tcpPortRange;
194
+
195
+ // Try random ports within range
196
+ for (let i = 0; i < 100; i++) {
197
+ const port = minPort + Math.floor(Math.random() * (maxPort - minPort));
198
+ if (this.usedPorts.has(port)) {
199
+ continue;
200
+ }
201
+ const isAvailable = await this.checkPortAvailable(port);
202
+ if (isAvailable) {
203
+ return port;
204
+ }
205
+ }
206
+
207
+ throw new Error(
208
+ `Could not find available port in range ${minPort}-${maxPort}`
209
+ );
210
+ }
211
+
212
+ private checkPortAvailable(port: number): Promise<boolean> {
213
+ return new Promise((resolve) => {
214
+ const server = createNetServer();
215
+ server.once("error", () => resolve(false));
216
+ server.once("listening", () => {
217
+ server.close(() => resolve(true));
218
+ });
219
+ server.listen(port, "127.0.0.1");
220
+ });
221
+ }
222
+
223
+ private handleConnection(instanceId: string, socket: Socket): void {
224
+ // Store the client socket
225
+ this.clientSockets.set(instanceId, socket);
226
+ this.onConnect?.(instanceId);
227
+
228
+ let buffer = "";
229
+
230
+ socket.on("data", async (data) => {
231
+ buffer += data.toString();
232
+
233
+ // Process complete messages (newline-delimited JSON)
234
+ const lines = buffer.split("\n");
235
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
236
+
237
+ for (const line of lines) {
238
+ if (!line.trim()) continue;
239
+
240
+ try {
241
+ const message = JSON.parse(line) as WorkflowMessage;
242
+ await this.handleMessage(instanceId, message);
243
+ } catch (err) {
244
+ this.onError?.(new Error(`Invalid message: ${line}`), instanceId);
245
+ }
246
+ }
247
+ });
248
+
249
+ socket.on("error", (err) => {
250
+ this.onError?.(err, instanceId);
251
+ });
252
+
253
+ socket.on("close", () => {
254
+ this.clientSockets.delete(instanceId);
255
+ this.onDisconnect?.(instanceId);
256
+ });
257
+ }
258
+
259
+ private async handleMessage(instanceId: string, message: WorkflowMessage): Promise<void> {
260
+ if (message.type === "proxy.call") {
261
+ // Handle proxy request
262
+ const request = message as ProxyRequest;
263
+ try {
264
+ const result = await this.onProxyCall(request);
265
+ this.sendProxyResponse(instanceId, {
266
+ type: "proxy.result",
267
+ requestId: request.requestId,
268
+ result,
269
+ });
270
+ } catch (err) {
271
+ this.sendProxyResponse(instanceId, {
272
+ type: "proxy.error",
273
+ requestId: request.requestId,
274
+ error: err instanceof Error ? err.message : String(err),
275
+ });
276
+ }
277
+ } else {
278
+ // Handle workflow event
279
+ await this.onEvent(message as WorkflowEvent);
280
+ }
281
+ }
282
+
283
+ sendProxyResponse(instanceId: string, response: ProxyResponse): boolean {
284
+ const socket = this.clientSockets.get(instanceId);
285
+ if (!socket) {
286
+ return false;
287
+ }
288
+
289
+ try {
290
+ socket.write(JSON.stringify(response) + "\n");
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ async closeSocket(instanceId: string): Promise<void> {
298
+ // Close client socket
299
+ const clientSocket = this.clientSockets.get(instanceId);
300
+ if (clientSocket) {
301
+ clientSocket.destroy();
302
+ this.clientSockets.delete(instanceId);
303
+ }
304
+
305
+ // Close server
306
+ const server = this.servers.get(instanceId);
307
+ if (server) {
308
+ await new Promise<void>((resolve) => {
309
+ server.close(() => resolve());
310
+ });
311
+ this.servers.delete(instanceId);
312
+ }
313
+
314
+ // Clean up socket file (Unix only)
315
+ const socketPath = this.socketPaths.get(instanceId);
316
+ if (socketPath && existsSync(socketPath)) {
317
+ await unlink(socketPath).catch(() => {
318
+ // Ignore errors during cleanup
319
+ });
320
+ }
321
+ this.socketPaths.delete(instanceId);
322
+
323
+ // Clean up port tracking (TCP)
324
+ const port = this.tcpPorts.get(instanceId);
325
+ if (port) {
326
+ this.usedPorts.delete(port);
327
+ this.tcpPorts.delete(instanceId);
328
+ }
329
+ }
330
+
331
+ getActiveConnections(): string[] {
332
+ return Array.from(this.clientSockets.keys());
333
+ }
334
+
335
+ async shutdown(): Promise<void> {
336
+ // Close all client sockets
337
+ for (const socket of this.clientSockets.values()) {
338
+ socket.destroy();
339
+ }
340
+ this.clientSockets.clear();
341
+
342
+ // Close all servers
343
+ const closePromises = Array.from(this.servers.values()).map(
344
+ (server) =>
345
+ new Promise<void>((resolve) => {
346
+ server.close(() => resolve());
347
+ })
348
+ );
349
+ await Promise.all(closePromises);
350
+ this.servers.clear();
351
+
352
+ // Clean up socket files
353
+ for (const socketPath of this.socketPaths.values()) {
354
+ if (existsSync(socketPath)) {
355
+ await unlink(socketPath).catch(() => {});
356
+ }
357
+ }
358
+ this.socketPaths.clear();
359
+ this.tcpPorts.clear();
360
+ this.usedPorts.clear();
361
+ }
362
+
363
+ async cleanOrphanedSockets(activeInstanceIds: Set<string>): Promise<void> {
364
+ if (this.isWindows) {
365
+ // No socket files to clean on Windows
366
+ return;
367
+ }
368
+
369
+ if (!existsSync(this.socketDir)) {
370
+ return;
371
+ }
372
+
373
+ try {
374
+ const files = await readdir(this.socketDir);
375
+
376
+ for (const file of files) {
377
+ // Match socket files: workflow_<instanceId>.sock
378
+ const match = file.match(/^workflow_(.+)\.sock$/);
379
+ if (match) {
380
+ const instanceId = match[1]!;
381
+
382
+ if (!activeInstanceIds.has(instanceId)) {
383
+ // This socket file doesn't correspond to any active workflow
384
+ const socketPath = join(this.socketDir, file);
385
+ await unlink(socketPath).catch(() => {});
386
+ }
387
+ }
388
+ }
389
+ } catch {
390
+ // Ignore errors during cleanup
391
+ }
392
+ }
393
+ }
394
+
395
+ // ============================================
396
+ // Factory Function
397
+ // ============================================
398
+
399
+ export interface WorkflowSocketConfig {
400
+ /** Directory for Unix sockets (default: /tmp/donkeylabs-workflows) */
401
+ socketDir?: string;
402
+ /** TCP port range for Windows fallback (default: [49152, 65535]) */
403
+ tcpPortRange?: [number, number];
404
+ }
405
+
406
+ export function createWorkflowSocketServer(
407
+ config: WorkflowSocketConfig,
408
+ callbacks: {
409
+ onEvent: (event: WorkflowEvent) => void | Promise<void>;
410
+ onProxyCall: (request: ProxyRequest) => Promise<any>;
411
+ onConnect?: (instanceId: string) => void;
412
+ onDisconnect?: (instanceId: string) => void;
413
+ onError?: (error: Error, instanceId?: string) => void;
414
+ }
415
+ ): WorkflowSocketServer {
416
+ return new WorkflowSocketServerImpl({
417
+ socketDir: config.socketDir ?? "/tmp/donkeylabs-workflows",
418
+ tcpPortRange: config.tcpPortRange ?? [49152, 65535],
419
+ onEvent: callbacks.onEvent,
420
+ onProxyCall: callbacks.onProxyCall,
421
+ onConnect: callbacks.onConnect,
422
+ onDisconnect: callbacks.onDisconnect,
423
+ onError: callbacks.onError,
424
+ });
425
+ }
426
+
427
+ // ============================================
428
+ // Message Parsing Helpers
429
+ // ============================================
430
+
431
+ export function isWorkflowEvent(message: WorkflowMessage): message is WorkflowEvent {
432
+ return message.type !== "proxy.call";
433
+ }
434
+
435
+ export function isProxyRequest(message: WorkflowMessage): message is ProxyRequest {
436
+ return message.type === "proxy.call";
437
+ }
438
+
439
+ export function parseWorkflowMessage(data: string): WorkflowMessage | null {
440
+ try {
441
+ const parsed = JSON.parse(data);
442
+ if (!parsed.type) {
443
+ return null;
444
+ }
445
+ return parsed as WorkflowMessage;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }