@donkeylabs/server 2.0.22 → 2.0.24
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/cron.md +28 -2
- package/docs/jobs.md +15 -0
- package/docs/workflows.md +60 -1
- package/package.json +1 -1
- package/src/core/cron.ts +50 -7
- package/src/core/index.ts +3 -0
- package/src/core/jobs.ts +42 -3
- package/src/core/subprocess-bootstrap.ts +241 -0
- package/src/core/workflow-executor.ts +48 -43
- package/src/core/workflow-socket.ts +1 -0
- package/src/core/workflow-state-machine.ts +34 -1
- package/src/core/workflows.ts +291 -11
- package/src/core.ts +81 -3
- package/src/server.ts +10 -0
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// Workflow Executor - Subprocess Entry Point
|
|
3
|
-
//
|
|
4
|
-
// The state machine owns all execution logic and persistence.
|
|
3
|
+
// Bootstraps core services and plugins locally for isolated workflows.
|
|
5
4
|
|
|
6
5
|
import { connect } from "node:net";
|
|
7
6
|
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
7
|
import type { WorkflowEvent } from "./workflow-socket";
|
|
12
|
-
import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
|
|
13
8
|
import type { WorkflowDefinition } from "./workflows";
|
|
14
|
-
import { KyselyWorkflowAdapter } from "./workflow-adapter-kysely";
|
|
15
9
|
import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
|
|
10
|
+
import { bootstrapSubprocess } from "./subprocess-bootstrap";
|
|
16
11
|
|
|
17
12
|
// ============================================
|
|
18
13
|
// Types
|
|
@@ -26,6 +21,10 @@ interface ExecutorConfig {
|
|
|
26
21
|
tcpPort?: number;
|
|
27
22
|
modulePath: string;
|
|
28
23
|
dbPath: string;
|
|
24
|
+
pluginNames: string[];
|
|
25
|
+
pluginModulePaths: Record<string, string>;
|
|
26
|
+
pluginConfigs: Record<string, any>;
|
|
27
|
+
coreConfig?: Record<string, any>;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
// ============================================
|
|
@@ -37,19 +36,23 @@ async function main(): Promise<void> {
|
|
|
37
36
|
const stdin = await Bun.stdin.text();
|
|
38
37
|
const config: ExecutorConfig = JSON.parse(stdin);
|
|
39
38
|
|
|
40
|
-
const {
|
|
39
|
+
const {
|
|
40
|
+
instanceId,
|
|
41
|
+
workflowName,
|
|
42
|
+
socketPath,
|
|
43
|
+
tcpPort,
|
|
44
|
+
modulePath,
|
|
45
|
+
dbPath,
|
|
46
|
+
pluginNames,
|
|
47
|
+
pluginModulePaths,
|
|
48
|
+
pluginConfigs,
|
|
49
|
+
coreConfig,
|
|
50
|
+
} = config;
|
|
41
51
|
|
|
42
|
-
// Connect to IPC socket
|
|
43
52
|
const socket = await connectToSocket(socketPath, tcpPort);
|
|
44
|
-
const proxyConnection = new WorkflowProxyConnection(socket);
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
sqlite.run("PRAGMA busy_timeout = 5000");
|
|
49
|
-
const db = new Kysely<any>({
|
|
50
|
-
dialect: new BunSqliteDialect({ database: sqlite }),
|
|
51
|
-
});
|
|
52
|
-
const adapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
|
|
54
|
+
let cleanup: (() => Promise<void>) | undefined;
|
|
55
|
+
let exitCode = 0;
|
|
53
56
|
|
|
54
57
|
// Start heartbeat
|
|
55
58
|
const heartbeatInterval = setInterval(() => {
|
|
@@ -61,39 +64,41 @@ async function main(): Promise<void> {
|
|
|
61
64
|
}, 5000);
|
|
62
65
|
|
|
63
66
|
try {
|
|
64
|
-
// Send started event
|
|
65
|
-
sendEvent(socket, {
|
|
66
|
-
type: "started",
|
|
67
|
-
instanceId,
|
|
68
|
-
timestamp: Date.now(),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
67
|
// Import the workflow module to get the definition
|
|
72
68
|
const module = await import(modulePath);
|
|
73
69
|
const definition = findWorkflowDefinition(module, workflowName, modulePath);
|
|
74
70
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
get(target, prop, receiver) {
|
|
83
|
-
if (prop === "db") return db;
|
|
84
|
-
return Reflect.get(target, prop, receiver);
|
|
71
|
+
const bootstrap = await bootstrapSubprocess({
|
|
72
|
+
dbPath,
|
|
73
|
+
coreConfig,
|
|
74
|
+
pluginMetadata: {
|
|
75
|
+
names: pluginNames,
|
|
76
|
+
modulePaths: pluginModulePaths,
|
|
77
|
+
configs: pluginConfigs,
|
|
85
78
|
},
|
|
86
79
|
});
|
|
80
|
+
cleanup = bootstrap.cleanup;
|
|
81
|
+
|
|
82
|
+
sendEvent(socket, {
|
|
83
|
+
type: "ready",
|
|
84
|
+
instanceId,
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
});
|
|
87
87
|
|
|
88
|
-
// Create state machine with IPC event bridge
|
|
89
88
|
const sm = new WorkflowStateMachine({
|
|
90
|
-
adapter,
|
|
91
|
-
core:
|
|
92
|
-
plugins,
|
|
89
|
+
adapter: bootstrap.workflowAdapter,
|
|
90
|
+
core: bootstrap.core as any,
|
|
91
|
+
plugins: bootstrap.manager.getServices(),
|
|
93
92
|
events: createIpcEventBridge(socket, instanceId),
|
|
94
93
|
pollInterval: 1000,
|
|
95
94
|
});
|
|
96
95
|
|
|
96
|
+
sendEvent(socket, {
|
|
97
|
+
type: "started",
|
|
98
|
+
instanceId,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
|
|
97
102
|
// Run the state machine to completion
|
|
98
103
|
const result = await sm.run(instanceId, definition);
|
|
99
104
|
|
|
@@ -112,16 +117,16 @@ async function main(): Promise<void> {
|
|
|
112
117
|
timestamp: Date.now(),
|
|
113
118
|
error: error instanceof Error ? error.message : String(error),
|
|
114
119
|
});
|
|
115
|
-
|
|
120
|
+
exitCode = 1;
|
|
116
121
|
} finally {
|
|
117
122
|
clearInterval(heartbeatInterval);
|
|
118
|
-
proxyConnection.close();
|
|
119
123
|
socket.end();
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
if (cleanup) {
|
|
125
|
+
await cleanup();
|
|
126
|
+
}
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
process.exit(
|
|
129
|
+
process.exit(exitCode);
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
// ============================================
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Communicates through an event callback interface - no knowledge of IPC, SSE, or process management.
|
|
4
4
|
|
|
5
5
|
import type { CoreServices } from "../core";
|
|
6
|
+
import type { LogLevel } from "./logger";
|
|
6
7
|
import type { Jobs } from "./jobs";
|
|
7
8
|
import type {
|
|
8
9
|
WorkflowAdapter,
|
|
@@ -482,6 +483,35 @@ export class WorkflowStateMachine {
|
|
|
482
483
|
const adapter = this.adapter;
|
|
483
484
|
const instanceId = instance.id;
|
|
484
485
|
|
|
486
|
+
const scopedLogger = this.core?.logger?.scoped("workflow", instance.id);
|
|
487
|
+
const emit = this.core?.events
|
|
488
|
+
? async (event: string, data?: Record<string, any>) => {
|
|
489
|
+
const payload = {
|
|
490
|
+
instanceId: instance.id,
|
|
491
|
+
workflowName: instance.workflowName,
|
|
492
|
+
event,
|
|
493
|
+
data,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
await this.core!.events.emit("workflow.event", payload);
|
|
497
|
+
await this.core!.events.emit(`workflow.${instance.workflowName}.event`, payload);
|
|
498
|
+
await this.core!.events.emit(`workflow.${instance.id}.event`, payload);
|
|
499
|
+
}
|
|
500
|
+
: undefined;
|
|
501
|
+
|
|
502
|
+
const log = scopedLogger
|
|
503
|
+
? (level: LogLevel, message: string, data?: Record<string, any>) => {
|
|
504
|
+
scopedLogger[level](message, data);
|
|
505
|
+
}
|
|
506
|
+
: undefined;
|
|
507
|
+
|
|
508
|
+
const core = this.core
|
|
509
|
+
? {
|
|
510
|
+
...this.core,
|
|
511
|
+
logger: scopedLogger ?? this.core.logger,
|
|
512
|
+
}
|
|
513
|
+
: this.core;
|
|
514
|
+
|
|
485
515
|
return {
|
|
486
516
|
input: instance.input,
|
|
487
517
|
steps,
|
|
@@ -490,7 +520,10 @@ export class WorkflowStateMachine {
|
|
|
490
520
|
getStepResult: <T = any>(stepName: string): T | undefined => {
|
|
491
521
|
return steps[stepName] as T | undefined;
|
|
492
522
|
},
|
|
493
|
-
core:
|
|
523
|
+
core: core!,
|
|
524
|
+
logger: scopedLogger,
|
|
525
|
+
emit,
|
|
526
|
+
log,
|
|
494
527
|
plugins: this.plugins,
|
|
495
528
|
metadata,
|
|
496
529
|
setMetadata: async (key: string, value: any): Promise<void> => {
|
package/src/core/workflows.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { SSE } from "./sse";
|
|
|
14
14
|
import type { z } from "zod";
|
|
15
15
|
import { sql } from "kysely";
|
|
16
16
|
import type { CoreServices } from "../core";
|
|
17
|
+
import type { Logger, LogLevel } from "./logger";
|
|
17
18
|
import { dirname, join, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
20
|
import {
|
|
@@ -243,6 +244,12 @@ export interface WorkflowContext {
|
|
|
243
244
|
getStepResult<T = any>(stepName: string): T | undefined;
|
|
244
245
|
/** Core services (logger, events, cache, etc.) */
|
|
245
246
|
core: CoreServices;
|
|
247
|
+
/** Scoped logger for this workflow instance (source=workflow, sourceId=instanceId) */
|
|
248
|
+
logger?: Logger;
|
|
249
|
+
/** Emit a workflow-scoped custom event */
|
|
250
|
+
emit?: (event: string, data?: Record<string, any>) => Promise<void>;
|
|
251
|
+
/** Write a scoped log entry for this workflow instance */
|
|
252
|
+
log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
|
|
246
253
|
/** Plugin services - available for business logic in workflow handlers */
|
|
247
254
|
plugins: Record<string, any>;
|
|
248
255
|
/**
|
|
@@ -640,6 +647,8 @@ export interface WorkflowsConfig {
|
|
|
640
647
|
dbPath?: string;
|
|
641
648
|
/** Heartbeat timeout in ms (default: 60000) */
|
|
642
649
|
heartbeatTimeout?: number;
|
|
650
|
+
/** Timeout waiting for isolated subprocess readiness (ms, default: 10000) */
|
|
651
|
+
readyTimeout?: number;
|
|
643
652
|
}
|
|
644
653
|
|
|
645
654
|
/** Options for registering a workflow */
|
|
@@ -689,6 +698,16 @@ export interface Workflows {
|
|
|
689
698
|
setPlugins(plugins: Record<string, any>): void;
|
|
690
699
|
/** Update metadata for a workflow instance (used by isolated workflows) */
|
|
691
700
|
updateMetadata(instanceId: string, key: string, value: any): Promise<void>;
|
|
701
|
+
/** Set plugin metadata for local instantiation in isolated workflows */
|
|
702
|
+
setPluginMetadata(metadata: PluginMetadata): void;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export interface PluginMetadata {
|
|
706
|
+
names: string[];
|
|
707
|
+
modulePaths: Record<string, string>;
|
|
708
|
+
configs: Record<string, any>;
|
|
709
|
+
dependencies: Record<string, string[]>;
|
|
710
|
+
customErrors: Record<string, Record<string, any>>;
|
|
692
711
|
}
|
|
693
712
|
|
|
694
713
|
// ============================================
|
|
@@ -719,8 +738,25 @@ class WorkflowsImpl implements Workflows {
|
|
|
719
738
|
private tcpPortRange: [number, number];
|
|
720
739
|
private dbPath?: string;
|
|
721
740
|
private heartbeatTimeoutMs: number;
|
|
741
|
+
private readyTimeoutMs: number;
|
|
722
742
|
private workflowModulePaths = new Map<string, string>();
|
|
723
743
|
private isolatedProcesses = new Map<string, IsolatedProcessInfo>();
|
|
744
|
+
private readyWaiters = new Map<
|
|
745
|
+
string,
|
|
746
|
+
{
|
|
747
|
+
promise: Promise<void>;
|
|
748
|
+
resolve: () => void;
|
|
749
|
+
reject: (error: Error) => void;
|
|
750
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
751
|
+
}
|
|
752
|
+
>();
|
|
753
|
+
|
|
754
|
+
// Plugin metadata for local instantiation in isolated workflows
|
|
755
|
+
private pluginNames: string[] = [];
|
|
756
|
+
private pluginModulePaths: Record<string, string> = {};
|
|
757
|
+
private pluginConfigs: Record<string, any> = {};
|
|
758
|
+
private pluginDependencies: Record<string, string[]> = {};
|
|
759
|
+
private pluginCustomErrors: Record<string, Record<string, any>> = {};
|
|
724
760
|
|
|
725
761
|
constructor(config: WorkflowsConfig = {}) {
|
|
726
762
|
this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
|
|
@@ -735,6 +771,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
735
771
|
this.tcpPortRange = config.tcpPortRange ?? [49152, 65535];
|
|
736
772
|
this.dbPath = config.dbPath;
|
|
737
773
|
this.heartbeatTimeoutMs = config.heartbeatTimeout ?? 60000;
|
|
774
|
+
this.readyTimeoutMs = config.readyTimeout ?? 10000;
|
|
738
775
|
}
|
|
739
776
|
|
|
740
777
|
private getSocketServer(): WorkflowSocketServer {
|
|
@@ -786,6 +823,14 @@ class WorkflowsImpl implements Workflows {
|
|
|
786
823
|
this.plugins = plugins;
|
|
787
824
|
}
|
|
788
825
|
|
|
826
|
+
setPluginMetadata(metadata: PluginMetadata): void {
|
|
827
|
+
this.pluginNames = metadata.names;
|
|
828
|
+
this.pluginModulePaths = metadata.modulePaths;
|
|
829
|
+
this.pluginConfigs = metadata.configs;
|
|
830
|
+
this.pluginDependencies = metadata.dependencies;
|
|
831
|
+
this.pluginCustomErrors = metadata.customErrors;
|
|
832
|
+
}
|
|
833
|
+
|
|
789
834
|
async updateMetadata(instanceId: string, key: string, value: any): Promise<void> {
|
|
790
835
|
const instance = await this.adapter.getInstance(instanceId);
|
|
791
836
|
if (!instance) return;
|
|
@@ -855,7 +900,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
855
900
|
|
|
856
901
|
if (isIsolated && modulePath && this.dbPath) {
|
|
857
902
|
// Execute in isolated subprocess
|
|
858
|
-
this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
|
|
903
|
+
await this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
|
|
859
904
|
} else {
|
|
860
905
|
// Execute inline using state machine
|
|
861
906
|
if (isIsolated && !modulePath) {
|
|
@@ -952,7 +997,14 @@ class WorkflowsImpl implements Workflows {
|
|
|
952
997
|
const modulePath = this.workflowModulePaths.get(instance.workflowName);
|
|
953
998
|
|
|
954
999
|
if (isIsolated && modulePath && this.dbPath) {
|
|
955
|
-
|
|
1000
|
+
try {
|
|
1001
|
+
await this.executeIsolatedWorkflow(instance.id, definition, instance.input, modulePath);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
console.error(
|
|
1004
|
+
`[Workflows] Failed to resume isolated workflow ${instance.id}:`,
|
|
1005
|
+
error instanceof Error ? error.message : String(error)
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
956
1008
|
} else {
|
|
957
1009
|
this.startInlineWorkflow(instance.id, definition);
|
|
958
1010
|
}
|
|
@@ -989,6 +1041,12 @@ class WorkflowsImpl implements Workflows {
|
|
|
989
1041
|
}
|
|
990
1042
|
this.running.clear();
|
|
991
1043
|
|
|
1044
|
+
for (const [instanceId, waiter] of this.readyWaiters) {
|
|
1045
|
+
clearTimeout(waiter.timeout);
|
|
1046
|
+
waiter.reject(new Error(`Workflows stopped before ready: ${instanceId}`));
|
|
1047
|
+
}
|
|
1048
|
+
this.readyWaiters.clear();
|
|
1049
|
+
|
|
992
1050
|
// Stop adapter (cleanup timers and prevent further DB access)
|
|
993
1051
|
if (this.adapter && typeof (this.adapter as any).stop === "function") {
|
|
994
1052
|
(this.adapter as any).stop();
|
|
@@ -1182,20 +1240,36 @@ class WorkflowsImpl implements Workflows {
|
|
|
1182
1240
|
): Promise<void> {
|
|
1183
1241
|
const socketServer = this.getSocketServer();
|
|
1184
1242
|
|
|
1243
|
+
const pluginNames = this.pluginNames.length > 0
|
|
1244
|
+
? this.pluginNames
|
|
1245
|
+
: Object.keys(this.pluginModulePaths);
|
|
1246
|
+
|
|
1247
|
+
if (pluginNames.length === 0 && Object.keys(this.plugins).length > 0) {
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
"[Workflows] Plugin metadata is required for isolated workflows. " +
|
|
1250
|
+
"Call workflows.setPluginMetadata() during server initialization."
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const missingModulePaths = pluginNames.filter((name) => !this.pluginModulePaths[name]);
|
|
1255
|
+
if (missingModulePaths.length > 0) {
|
|
1256
|
+
throw new Error(
|
|
1257
|
+
`[Workflows] Missing module paths for plugins: ${missingModulePaths.join(", ")}. ` +
|
|
1258
|
+
`Ensure plugins are created with createPlugin.define() and registered before workflows start.`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const pluginConfigs = serializePluginConfigsOrThrow(this.pluginConfigs, pluginNames);
|
|
1263
|
+
const coreConfig = serializeCoreConfigOrThrow(this.core?.config);
|
|
1264
|
+
|
|
1185
1265
|
// Create socket for this workflow instance
|
|
1186
1266
|
const { socketPath, tcpPort } = await socketServer.createSocket(instanceId);
|
|
1187
1267
|
|
|
1188
|
-
// Mark workflow as running
|
|
1189
|
-
await this.adapter.updateInstance(instanceId, {
|
|
1190
|
-
status: "running",
|
|
1191
|
-
startedAt: new Date(),
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
1268
|
// Get the executor path
|
|
1195
1269
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
1196
1270
|
const executorPath = join(currentDir, "workflow-executor.ts");
|
|
1197
1271
|
|
|
1198
|
-
// Prepare config for the executor
|
|
1272
|
+
// Prepare config for the executor, including plugin metadata for local instantiation
|
|
1199
1273
|
const config = {
|
|
1200
1274
|
instanceId,
|
|
1201
1275
|
workflowName: definition.name,
|
|
@@ -1204,6 +1278,10 @@ class WorkflowsImpl implements Workflows {
|
|
|
1204
1278
|
tcpPort,
|
|
1205
1279
|
modulePath,
|
|
1206
1280
|
dbPath: this.dbPath,
|
|
1281
|
+
pluginNames,
|
|
1282
|
+
pluginModulePaths: this.pluginModulePaths,
|
|
1283
|
+
pluginConfigs,
|
|
1284
|
+
coreConfig,
|
|
1207
1285
|
};
|
|
1208
1286
|
|
|
1209
1287
|
// Spawn the subprocess
|
|
@@ -1240,6 +1318,19 @@ class WorkflowsImpl implements Workflows {
|
|
|
1240
1318
|
// Set up heartbeat timeout
|
|
1241
1319
|
this.resetHeartbeatTimeout(instanceId, proc.pid);
|
|
1242
1320
|
|
|
1321
|
+
const exitBeforeReady = proc.exited.then((exitCode) => {
|
|
1322
|
+
throw new Error(`Subprocess exited before ready (code ${exitCode})`);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
await Promise.race([this.waitForIsolatedReady(instanceId), exitBeforeReady]);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
await this.handleIsolatedStartFailure(instanceId, proc.pid, error);
|
|
1329
|
+
exitBeforeReady.catch(() => undefined);
|
|
1330
|
+
throw error;
|
|
1331
|
+
}
|
|
1332
|
+
exitBeforeReady.catch(() => undefined);
|
|
1333
|
+
|
|
1243
1334
|
// Handle process exit
|
|
1244
1335
|
proc.exited.then(async (exitCode) => {
|
|
1245
1336
|
const info = this.isolatedProcesses.get(instanceId);
|
|
@@ -1250,9 +1341,9 @@ class WorkflowsImpl implements Workflows {
|
|
|
1250
1341
|
}
|
|
1251
1342
|
await socketServer.closeSocket(instanceId);
|
|
1252
1343
|
|
|
1253
|
-
// Check if workflow is still running (crashed before completion)
|
|
1344
|
+
// Check if workflow is still pending/running (crashed before completion)
|
|
1254
1345
|
const instance = await this.adapter.getInstance(instanceId);
|
|
1255
|
-
if (instance && instance.status === "running") {
|
|
1346
|
+
if (instance && (instance.status === "running" || instance.status === "pending")) {
|
|
1256
1347
|
console.error(`[Workflows] Isolated workflow ${instanceId} crashed with exit code ${exitCode}`);
|
|
1257
1348
|
await this.adapter.updateInstance(instanceId, {
|
|
1258
1349
|
status: "failed",
|
|
@@ -1293,6 +1384,11 @@ class WorkflowsImpl implements Workflows {
|
|
|
1293
1384
|
}
|
|
1294
1385
|
|
|
1295
1386
|
switch (type) {
|
|
1387
|
+
case "ready": {
|
|
1388
|
+
this.resolveIsolatedReady(instanceId);
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1296
1392
|
case "started":
|
|
1297
1393
|
case "heartbeat":
|
|
1298
1394
|
// No-op: heartbeat handled above, started is handled by executeIsolatedWorkflow
|
|
@@ -1382,6 +1478,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1382
1478
|
case "completed": {
|
|
1383
1479
|
// Clean up isolated process tracking
|
|
1384
1480
|
this.cleanupIsolatedProcess(instanceId);
|
|
1481
|
+
this.resolveIsolatedReady(instanceId);
|
|
1385
1482
|
|
|
1386
1483
|
// Subprocess already persisted state - just emit events
|
|
1387
1484
|
await this.emitEvent("workflow.completed", {
|
|
@@ -1398,6 +1495,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1398
1495
|
case "failed": {
|
|
1399
1496
|
// Clean up isolated process tracking
|
|
1400
1497
|
this.cleanupIsolatedProcess(instanceId);
|
|
1498
|
+
this.rejectIsolatedReady(instanceId, new Error(event.error ?? "Isolated workflow failed"));
|
|
1401
1499
|
|
|
1402
1500
|
// Subprocess already persisted state - just emit events
|
|
1403
1501
|
await this.emitEvent("workflow.failed", {
|
|
@@ -1416,6 +1514,91 @@ class WorkflowsImpl implements Workflows {
|
|
|
1416
1514
|
}
|
|
1417
1515
|
}
|
|
1418
1516
|
|
|
1517
|
+
private waitForIsolatedReady(instanceId: string): Promise<void> {
|
|
1518
|
+
const existing = this.readyWaiters.get(instanceId);
|
|
1519
|
+
if (existing) {
|
|
1520
|
+
return existing.promise;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
let resolveFn!: () => void;
|
|
1524
|
+
let rejectFn!: (error: Error) => void;
|
|
1525
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
1526
|
+
resolveFn = resolve;
|
|
1527
|
+
rejectFn = reject;
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
const timeout = setTimeout(() => {
|
|
1531
|
+
this.readyWaiters.delete(instanceId);
|
|
1532
|
+
rejectFn(new Error(`Timed out waiting for isolated workflow ${instanceId} readiness`));
|
|
1533
|
+
}, this.readyTimeoutMs);
|
|
1534
|
+
|
|
1535
|
+
this.readyWaiters.set(instanceId, {
|
|
1536
|
+
promise,
|
|
1537
|
+
resolve: () => resolveFn(),
|
|
1538
|
+
reject: (error) => rejectFn(error),
|
|
1539
|
+
timeout,
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
return promise;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
private resolveIsolatedReady(instanceId: string): void {
|
|
1546
|
+
const waiter = this.readyWaiters.get(instanceId);
|
|
1547
|
+
if (!waiter) return;
|
|
1548
|
+
clearTimeout(waiter.timeout);
|
|
1549
|
+
this.readyWaiters.delete(instanceId);
|
|
1550
|
+
waiter.resolve();
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private rejectIsolatedReady(instanceId: string, error: Error): void {
|
|
1554
|
+
const waiter = this.readyWaiters.get(instanceId);
|
|
1555
|
+
if (!waiter) return;
|
|
1556
|
+
clearTimeout(waiter.timeout);
|
|
1557
|
+
this.readyWaiters.delete(instanceId);
|
|
1558
|
+
waiter.reject(error);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
private async handleIsolatedStartFailure(
|
|
1562
|
+
instanceId: string,
|
|
1563
|
+
pid: number,
|
|
1564
|
+
error: unknown
|
|
1565
|
+
): Promise<void> {
|
|
1566
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
process.kill(pid, "SIGTERM");
|
|
1570
|
+
} catch {
|
|
1571
|
+
// Process might already be dead
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
this.cleanupIsolatedProcess(instanceId);
|
|
1575
|
+
await this.getSocketServer().closeSocket(instanceId);
|
|
1576
|
+
|
|
1577
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
1578
|
+
if (instance && (instance.status === "pending" || instance.status === "running")) {
|
|
1579
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1580
|
+
status: "failed",
|
|
1581
|
+
error: errorMessage,
|
|
1582
|
+
completedAt: new Date(),
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
await this.emitEvent("workflow.failed", {
|
|
1586
|
+
instanceId,
|
|
1587
|
+
workflowName: instance.workflowName,
|
|
1588
|
+
error: errorMessage,
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
if (this.sse) {
|
|
1592
|
+
this.sse.broadcast(`workflow:${instanceId}`, "failed", { error: errorMessage });
|
|
1593
|
+
this.sse.broadcast("workflows:all", "workflow.failed", {
|
|
1594
|
+
instanceId,
|
|
1595
|
+
workflowName: instance.workflowName,
|
|
1596
|
+
error: errorMessage,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1419
1602
|
/**
|
|
1420
1603
|
* Handle proxy calls from isolated subprocess
|
|
1421
1604
|
*/
|
|
@@ -1460,6 +1643,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1460
1643
|
if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
|
|
1461
1644
|
this.isolatedProcesses.delete(instanceId);
|
|
1462
1645
|
}
|
|
1646
|
+
this.rejectIsolatedReady(instanceId, new Error("Isolated workflow cleaned up"));
|
|
1463
1647
|
}
|
|
1464
1648
|
|
|
1465
1649
|
/**
|
|
@@ -1532,6 +1716,102 @@ class WorkflowsImpl implements Workflows {
|
|
|
1532
1716
|
}
|
|
1533
1717
|
}
|
|
1534
1718
|
|
|
1719
|
+
// ============================================
|
|
1720
|
+
// Helpers
|
|
1721
|
+
// ============================================
|
|
1722
|
+
|
|
1723
|
+
function serializePluginConfigsOrThrow(
|
|
1724
|
+
configs: Record<string, any>,
|
|
1725
|
+
pluginNames: string[]
|
|
1726
|
+
): Record<string, any> {
|
|
1727
|
+
const result: Record<string, any> = {};
|
|
1728
|
+
const failures: string[] = [];
|
|
1729
|
+
|
|
1730
|
+
for (const name of pluginNames) {
|
|
1731
|
+
if (!(name in configs)) continue;
|
|
1732
|
+
try {
|
|
1733
|
+
assertJsonSerializable(configs[name], `pluginConfigs.${name}`);
|
|
1734
|
+
const serialized = JSON.stringify(configs[name]);
|
|
1735
|
+
result[name] = JSON.parse(serialized);
|
|
1736
|
+
} catch {
|
|
1737
|
+
failures.push(name);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (failures.length > 0) {
|
|
1742
|
+
throw new Error(
|
|
1743
|
+
`[Workflows] Non-serializable plugin config(s): ${failures.join(", ")}. ` +
|
|
1744
|
+
`Provide JSON-serializable configs for isolated workflows.`
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return result;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function serializeCoreConfigOrThrow(config?: Record<string, any>): Record<string, any> | undefined {
|
|
1752
|
+
if (!config) return undefined;
|
|
1753
|
+
try {
|
|
1754
|
+
assertJsonSerializable(config, "coreConfig");
|
|
1755
|
+
const serialized = JSON.stringify(config);
|
|
1756
|
+
return JSON.parse(serialized);
|
|
1757
|
+
} catch {
|
|
1758
|
+
throw new Error(
|
|
1759
|
+
"[Workflows] Core config is not JSON-serializable. Provide JSON-serializable values for isolated workflows."
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function assertJsonSerializable(value: any, path: string, seen = new WeakSet<object>()): void {
|
|
1765
|
+
if (
|
|
1766
|
+
value === null ||
|
|
1767
|
+
typeof value === "string" ||
|
|
1768
|
+
typeof value === "number" ||
|
|
1769
|
+
typeof value === "boolean"
|
|
1770
|
+
) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
|
|
1775
|
+
throw new Error(`[Workflows] Non-serializable value at ${path}`);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (typeof value === "bigint") {
|
|
1779
|
+
throw new Error(`[Workflows] Non-serializable bigint at ${path}`);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
if (value instanceof Date) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
if (Array.isArray(value)) {
|
|
1787
|
+
for (let i = 0; i < value.length; i++) {
|
|
1788
|
+
assertJsonSerializable(value[i], `${path}[${i}]`, seen);
|
|
1789
|
+
}
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (typeof value === "object") {
|
|
1794
|
+
if (seen.has(value)) {
|
|
1795
|
+
throw new Error(`[Workflows] Circular reference at ${path}`);
|
|
1796
|
+
}
|
|
1797
|
+
seen.add(value);
|
|
1798
|
+
|
|
1799
|
+
if (!isPlainObject(value)) {
|
|
1800
|
+
throw new Error(`[Workflows] Non-serializable object at ${path}`);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1804
|
+
assertJsonSerializable(nested, `${path}.${key}`, seen);
|
|
1805
|
+
}
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function isPlainObject(value: Record<string, any>): boolean {
|
|
1811
|
+
const proto = Object.getPrototypeOf(value);
|
|
1812
|
+
return proto === Object.prototype || proto === null;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1535
1815
|
// ============================================
|
|
1536
1816
|
// Factory Function
|
|
1537
1817
|
// ============================================
|