@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.
- 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 +1 -1
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +90 -9
- package/src/core/index.ts +31 -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 +239 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +449 -0
- package/src/core/workflow-state-machine.ts +593 -0
- package/src/core/workflows.test.ts +758 -0
- package/src/core/workflows.ts +705 -595
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- 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,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
|
+
}
|