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