@donkeylabs/server 2.0.21 → 2.0.23
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/workflows.md +107 -7
- package/package.json +2 -2
- package/src/admin/dashboard.ts +74 -3
- package/src/admin/routes.ts +62 -0
- package/src/core/cron.ts +17 -10
- package/src/core/index.ts +23 -0
- package/src/core/jobs.ts +8 -2
- package/src/core/logger.ts +14 -0
- package/src/core/logs-adapter-kysely.ts +287 -0
- package/src/core/logs-transport.ts +83 -0
- package/src/core/logs.ts +398 -0
- package/src/core/subprocess-bootstrap.ts +241 -0
- package/src/core/workflow-executor.ts +50 -36
- package/src/core/workflow-socket.ts +1 -0
- package/src/core/workflows.test.ts +56 -0
- package/src/core/workflows.ts +350 -33
- package/src/core.ts +83 -3
- package/src/harness.ts +4 -0
- package/src/index.ts +10 -0
- package/src/server.ts +53 -5
- /package/{CLAUDE.md → agents.md} +0 -0
package/src/core/workflows.ts
CHANGED
|
@@ -12,9 +12,10 @@ import type { Events } from "./events";
|
|
|
12
12
|
import type { Jobs } from "./jobs";
|
|
13
13
|
import type { SSE } from "./sse";
|
|
14
14
|
import type { z } from "zod";
|
|
15
|
+
import { sql } from "kysely";
|
|
15
16
|
import type { CoreServices } from "../core";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
19
|
import {
|
|
19
20
|
createWorkflowSocketServer,
|
|
20
21
|
type WorkflowSocketServer,
|
|
@@ -24,6 +25,31 @@ import {
|
|
|
24
25
|
import { isProcessAlive } from "./external-jobs";
|
|
25
26
|
import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
|
|
26
27
|
|
|
28
|
+
// ============================================
|
|
29
|
+
// Auto-detect caller module for isolated workflows
|
|
30
|
+
// ============================================
|
|
31
|
+
|
|
32
|
+
const WORKFLOWS_FILE = resolve(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk the call stack to find the file that invoked build().
|
|
36
|
+
* Returns a file:// URL string or undefined if detection fails.
|
|
37
|
+
*/
|
|
38
|
+
function captureCallerUrl(): string | undefined {
|
|
39
|
+
const stack = new Error().stack ?? "";
|
|
40
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
41
|
+
const match = line.match(/at\s+(?:.*?\s+\(?)?([^\s():]+):\d+:\d+/);
|
|
42
|
+
if (match) {
|
|
43
|
+
let filePath = match[1];
|
|
44
|
+
if (filePath.startsWith("file://")) filePath = fileURLToPath(filePath);
|
|
45
|
+
if (filePath.startsWith("native")) continue;
|
|
46
|
+
filePath = resolve(filePath);
|
|
47
|
+
if (filePath !== WORKFLOWS_FILE) return pathToFileURL(filePath).href;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
// Type helper for Zod schema inference
|
|
28
54
|
type ZodSchema = z.ZodTypeAny;
|
|
29
55
|
type InferZodOutput<T extends ZodSchema> = z.infer<T>;
|
|
@@ -144,6 +170,8 @@ export interface WorkflowDefinition {
|
|
|
144
170
|
* Set to false for lightweight workflows that benefit from inline execution.
|
|
145
171
|
*/
|
|
146
172
|
isolated?: boolean;
|
|
173
|
+
/** Auto-detected module URL where this workflow was built. Used as fallback for isolated execution. */
|
|
174
|
+
sourceModule?: string;
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
// ============================================
|
|
@@ -576,6 +604,7 @@ export class WorkflowBuilder {
|
|
|
576
604
|
timeout: this._timeout,
|
|
577
605
|
defaultRetry: this._defaultRetry,
|
|
578
606
|
isolated: this._isolated,
|
|
607
|
+
sourceModule: captureCallerUrl(),
|
|
579
608
|
};
|
|
580
609
|
}
|
|
581
610
|
}
|
|
@@ -611,16 +640,23 @@ export interface WorkflowsConfig {
|
|
|
611
640
|
dbPath?: string;
|
|
612
641
|
/** Heartbeat timeout in ms (default: 60000) */
|
|
613
642
|
heartbeatTimeout?: number;
|
|
643
|
+
/** Timeout waiting for isolated subprocess readiness (ms, default: 10000) */
|
|
644
|
+
readyTimeout?: number;
|
|
614
645
|
}
|
|
615
646
|
|
|
616
647
|
/** Options for registering a workflow */
|
|
617
648
|
export interface WorkflowRegisterOptions {
|
|
618
649
|
/**
|
|
619
650
|
* Module path for isolated workflows.
|
|
620
|
-
*
|
|
621
|
-
*
|
|
651
|
+
* Auto-detected from the call site of `build()` in most cases.
|
|
652
|
+
* Only needed if the workflow definition is re-exported from a different
|
|
653
|
+
* module than the one that calls `build()`.
|
|
622
654
|
*
|
|
623
655
|
* @example
|
|
656
|
+
* // Usually not needed — auto-detected:
|
|
657
|
+
* workflows.register(myWorkflow);
|
|
658
|
+
*
|
|
659
|
+
* // Override when re-exporting from another module:
|
|
624
660
|
* workflows.register(myWorkflow, { modulePath: import.meta.url });
|
|
625
661
|
*/
|
|
626
662
|
modulePath?: string;
|
|
@@ -649,10 +685,22 @@ export interface Workflows {
|
|
|
649
685
|
stop(): Promise<void>;
|
|
650
686
|
/** Set core services (called after initialization to resolve circular dependency) */
|
|
651
687
|
setCore(core: CoreServices): void;
|
|
688
|
+
/** Resolve dbPath from the database instance (call after setCore, before resume) */
|
|
689
|
+
resolveDbPath(): Promise<void>;
|
|
652
690
|
/** Set plugin services (called after plugins are initialized) */
|
|
653
691
|
setPlugins(plugins: Record<string, any>): void;
|
|
654
692
|
/** Update metadata for a workflow instance (used by isolated workflows) */
|
|
655
693
|
updateMetadata(instanceId: string, key: string, value: any): Promise<void>;
|
|
694
|
+
/** Set plugin metadata for local instantiation in isolated workflows */
|
|
695
|
+
setPluginMetadata(metadata: PluginMetadata): void;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export interface PluginMetadata {
|
|
699
|
+
names: string[];
|
|
700
|
+
modulePaths: Record<string, string>;
|
|
701
|
+
configs: Record<string, any>;
|
|
702
|
+
dependencies: Record<string, string[]>;
|
|
703
|
+
customErrors: Record<string, Record<string, any>>;
|
|
656
704
|
}
|
|
657
705
|
|
|
658
706
|
// ============================================
|
|
@@ -683,8 +731,25 @@ class WorkflowsImpl implements Workflows {
|
|
|
683
731
|
private tcpPortRange: [number, number];
|
|
684
732
|
private dbPath?: string;
|
|
685
733
|
private heartbeatTimeoutMs: number;
|
|
734
|
+
private readyTimeoutMs: number;
|
|
686
735
|
private workflowModulePaths = new Map<string, string>();
|
|
687
736
|
private isolatedProcesses = new Map<string, IsolatedProcessInfo>();
|
|
737
|
+
private readyWaiters = new Map<
|
|
738
|
+
string,
|
|
739
|
+
{
|
|
740
|
+
promise: Promise<void>;
|
|
741
|
+
resolve: () => void;
|
|
742
|
+
reject: (error: Error) => void;
|
|
743
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
744
|
+
}
|
|
745
|
+
>();
|
|
746
|
+
|
|
747
|
+
// Plugin metadata for local instantiation in isolated workflows
|
|
748
|
+
private pluginNames: string[] = [];
|
|
749
|
+
private pluginModulePaths: Record<string, string> = {};
|
|
750
|
+
private pluginConfigs: Record<string, any> = {};
|
|
751
|
+
private pluginDependencies: Record<string, string[]> = {};
|
|
752
|
+
private pluginCustomErrors: Record<string, Record<string, any>> = {};
|
|
688
753
|
|
|
689
754
|
constructor(config: WorkflowsConfig = {}) {
|
|
690
755
|
this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
|
|
@@ -699,6 +764,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
699
764
|
this.tcpPortRange = config.tcpPortRange ?? [49152, 65535];
|
|
700
765
|
this.dbPath = config.dbPath;
|
|
701
766
|
this.heartbeatTimeoutMs = config.heartbeatTimeout ?? 60000;
|
|
767
|
+
this.readyTimeoutMs = config.readyTimeout ?? 10000;
|
|
702
768
|
}
|
|
703
769
|
|
|
704
770
|
private getSocketServer(): WorkflowSocketServer {
|
|
@@ -728,19 +794,21 @@ class WorkflowsImpl implements Workflows {
|
|
|
728
794
|
|
|
729
795
|
setCore(core: CoreServices): void {
|
|
730
796
|
this.core = core;
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async resolveDbPath(): Promise<void> {
|
|
800
|
+
if (this.dbPath) return;
|
|
801
|
+
if (!this.core?.db) return;
|
|
802
|
+
|
|
803
|
+
// Use PRAGMA database_list to get the file path — works with any SQLite dialect
|
|
804
|
+
try {
|
|
805
|
+
const result = await sql<{ name: string; file: string }>`PRAGMA database_list`.execute(this.core.db);
|
|
806
|
+
const main = result.rows.find((r) => r.name === "main");
|
|
807
|
+
if (main?.file && main.file !== "" && main.file !== ":memory:") {
|
|
808
|
+
this.dbPath = main.file;
|
|
743
809
|
}
|
|
810
|
+
} catch {
|
|
811
|
+
// Not a SQLite database or PRAGMA not supported — dbPath stays unset
|
|
744
812
|
}
|
|
745
813
|
}
|
|
746
814
|
|
|
@@ -748,6 +816,14 @@ class WorkflowsImpl implements Workflows {
|
|
|
748
816
|
this.plugins = plugins;
|
|
749
817
|
}
|
|
750
818
|
|
|
819
|
+
setPluginMetadata(metadata: PluginMetadata): void {
|
|
820
|
+
this.pluginNames = metadata.names;
|
|
821
|
+
this.pluginModulePaths = metadata.modulePaths;
|
|
822
|
+
this.pluginConfigs = metadata.configs;
|
|
823
|
+
this.pluginDependencies = metadata.dependencies;
|
|
824
|
+
this.pluginCustomErrors = metadata.customErrors;
|
|
825
|
+
}
|
|
826
|
+
|
|
751
827
|
async updateMetadata(instanceId: string, key: string, value: any): Promise<void> {
|
|
752
828
|
const instance = await this.adapter.getInstance(instanceId);
|
|
753
829
|
if (!instance) return;
|
|
@@ -761,14 +837,15 @@ class WorkflowsImpl implements Workflows {
|
|
|
761
837
|
throw new Error(`Workflow "${definition.name}" is already registered`);
|
|
762
838
|
}
|
|
763
839
|
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
840
|
+
// Resolve module path: explicit option > auto-detected sourceModule
|
|
841
|
+
const modulePath = options?.modulePath ?? definition.sourceModule;
|
|
842
|
+
if (modulePath) {
|
|
843
|
+
this.workflowModulePaths.set(definition.name, modulePath);
|
|
767
844
|
} else if (definition.isolated !== false) {
|
|
768
|
-
// Warn if
|
|
845
|
+
// Warn only if neither explicit nor auto-detected path is available
|
|
769
846
|
console.warn(
|
|
770
|
-
`[Workflows] Workflow "${definition.name}" is isolated but no modulePath
|
|
771
|
-
`
|
|
847
|
+
`[Workflows] Workflow "${definition.name}" is isolated but no modulePath could be detected. ` +
|
|
848
|
+
`Pass { modulePath: import.meta.url } to register().`
|
|
772
849
|
);
|
|
773
850
|
}
|
|
774
851
|
|
|
@@ -816,13 +893,18 @@ class WorkflowsImpl implements Workflows {
|
|
|
816
893
|
|
|
817
894
|
if (isIsolated && modulePath && this.dbPath) {
|
|
818
895
|
// Execute in isolated subprocess
|
|
819
|
-
this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
|
|
896
|
+
await this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
|
|
820
897
|
} else {
|
|
821
898
|
// Execute inline using state machine
|
|
822
899
|
if (isIsolated && !modulePath) {
|
|
823
900
|
console.warn(
|
|
824
901
|
`[Workflows] Workflow "${workflowName}" falling back to inline execution (no modulePath)`
|
|
825
902
|
);
|
|
903
|
+
} else if (isIsolated && modulePath && !this.dbPath) {
|
|
904
|
+
console.warn(
|
|
905
|
+
`[Workflows] Workflow "${workflowName}" falling back to inline execution (dbPath could not be auto-detected). ` +
|
|
906
|
+
`Set workflows.dbPath in your server config to enable isolated execution.`
|
|
907
|
+
);
|
|
826
908
|
}
|
|
827
909
|
this.startInlineWorkflow(instance.id, definition);
|
|
828
910
|
}
|
|
@@ -908,7 +990,14 @@ class WorkflowsImpl implements Workflows {
|
|
|
908
990
|
const modulePath = this.workflowModulePaths.get(instance.workflowName);
|
|
909
991
|
|
|
910
992
|
if (isIsolated && modulePath && this.dbPath) {
|
|
911
|
-
|
|
993
|
+
try {
|
|
994
|
+
await this.executeIsolatedWorkflow(instance.id, definition, instance.input, modulePath);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
console.error(
|
|
997
|
+
`[Workflows] Failed to resume isolated workflow ${instance.id}:`,
|
|
998
|
+
error instanceof Error ? error.message : String(error)
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
912
1001
|
} else {
|
|
913
1002
|
this.startInlineWorkflow(instance.id, definition);
|
|
914
1003
|
}
|
|
@@ -945,6 +1034,12 @@ class WorkflowsImpl implements Workflows {
|
|
|
945
1034
|
}
|
|
946
1035
|
this.running.clear();
|
|
947
1036
|
|
|
1037
|
+
for (const [instanceId, waiter] of this.readyWaiters) {
|
|
1038
|
+
clearTimeout(waiter.timeout);
|
|
1039
|
+
waiter.reject(new Error(`Workflows stopped before ready: ${instanceId}`));
|
|
1040
|
+
}
|
|
1041
|
+
this.readyWaiters.clear();
|
|
1042
|
+
|
|
948
1043
|
// Stop adapter (cleanup timers and prevent further DB access)
|
|
949
1044
|
if (this.adapter && typeof (this.adapter as any).stop === "function") {
|
|
950
1045
|
(this.adapter as any).stop();
|
|
@@ -1138,20 +1233,36 @@ class WorkflowsImpl implements Workflows {
|
|
|
1138
1233
|
): Promise<void> {
|
|
1139
1234
|
const socketServer = this.getSocketServer();
|
|
1140
1235
|
|
|
1236
|
+
const pluginNames = this.pluginNames.length > 0
|
|
1237
|
+
? this.pluginNames
|
|
1238
|
+
: Object.keys(this.pluginModulePaths);
|
|
1239
|
+
|
|
1240
|
+
if (pluginNames.length === 0 && Object.keys(this.plugins).length > 0) {
|
|
1241
|
+
throw new Error(
|
|
1242
|
+
"[Workflows] Plugin metadata is required for isolated workflows. " +
|
|
1243
|
+
"Call workflows.setPluginMetadata() during server initialization."
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const missingModulePaths = pluginNames.filter((name) => !this.pluginModulePaths[name]);
|
|
1248
|
+
if (missingModulePaths.length > 0) {
|
|
1249
|
+
throw new Error(
|
|
1250
|
+
`[Workflows] Missing module paths for plugins: ${missingModulePaths.join(", ")}. ` +
|
|
1251
|
+
`Ensure plugins are created with createPlugin.define() and registered before workflows start.`
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const pluginConfigs = serializePluginConfigsOrThrow(this.pluginConfigs, pluginNames);
|
|
1256
|
+
const coreConfig = serializeCoreConfigOrThrow(this.core?.config);
|
|
1257
|
+
|
|
1141
1258
|
// Create socket for this workflow instance
|
|
1142
1259
|
const { socketPath, tcpPort } = await socketServer.createSocket(instanceId);
|
|
1143
1260
|
|
|
1144
|
-
// Mark workflow as running
|
|
1145
|
-
await this.adapter.updateInstance(instanceId, {
|
|
1146
|
-
status: "running",
|
|
1147
|
-
startedAt: new Date(),
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
1261
|
// Get the executor path
|
|
1151
1262
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
1152
1263
|
const executorPath = join(currentDir, "workflow-executor.ts");
|
|
1153
1264
|
|
|
1154
|
-
// Prepare config for the executor
|
|
1265
|
+
// Prepare config for the executor, including plugin metadata for local instantiation
|
|
1155
1266
|
const config = {
|
|
1156
1267
|
instanceId,
|
|
1157
1268
|
workflowName: definition.name,
|
|
@@ -1160,6 +1271,10 @@ class WorkflowsImpl implements Workflows {
|
|
|
1160
1271
|
tcpPort,
|
|
1161
1272
|
modulePath,
|
|
1162
1273
|
dbPath: this.dbPath,
|
|
1274
|
+
pluginNames,
|
|
1275
|
+
pluginModulePaths: this.pluginModulePaths,
|
|
1276
|
+
pluginConfigs,
|
|
1277
|
+
coreConfig,
|
|
1163
1278
|
};
|
|
1164
1279
|
|
|
1165
1280
|
// Spawn the subprocess
|
|
@@ -1196,6 +1311,19 @@ class WorkflowsImpl implements Workflows {
|
|
|
1196
1311
|
// Set up heartbeat timeout
|
|
1197
1312
|
this.resetHeartbeatTimeout(instanceId, proc.pid);
|
|
1198
1313
|
|
|
1314
|
+
const exitBeforeReady = proc.exited.then((exitCode) => {
|
|
1315
|
+
throw new Error(`Subprocess exited before ready (code ${exitCode})`);
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
try {
|
|
1319
|
+
await Promise.race([this.waitForIsolatedReady(instanceId), exitBeforeReady]);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
await this.handleIsolatedStartFailure(instanceId, proc.pid, error);
|
|
1322
|
+
exitBeforeReady.catch(() => undefined);
|
|
1323
|
+
throw error;
|
|
1324
|
+
}
|
|
1325
|
+
exitBeforeReady.catch(() => undefined);
|
|
1326
|
+
|
|
1199
1327
|
// Handle process exit
|
|
1200
1328
|
proc.exited.then(async (exitCode) => {
|
|
1201
1329
|
const info = this.isolatedProcesses.get(instanceId);
|
|
@@ -1206,9 +1334,9 @@ class WorkflowsImpl implements Workflows {
|
|
|
1206
1334
|
}
|
|
1207
1335
|
await socketServer.closeSocket(instanceId);
|
|
1208
1336
|
|
|
1209
|
-
// Check if workflow is still running (crashed before completion)
|
|
1337
|
+
// Check if workflow is still pending/running (crashed before completion)
|
|
1210
1338
|
const instance = await this.adapter.getInstance(instanceId);
|
|
1211
|
-
if (instance && instance.status === "running") {
|
|
1339
|
+
if (instance && (instance.status === "running" || instance.status === "pending")) {
|
|
1212
1340
|
console.error(`[Workflows] Isolated workflow ${instanceId} crashed with exit code ${exitCode}`);
|
|
1213
1341
|
await this.adapter.updateInstance(instanceId, {
|
|
1214
1342
|
status: "failed",
|
|
@@ -1249,6 +1377,11 @@ class WorkflowsImpl implements Workflows {
|
|
|
1249
1377
|
}
|
|
1250
1378
|
|
|
1251
1379
|
switch (type) {
|
|
1380
|
+
case "ready": {
|
|
1381
|
+
this.resolveIsolatedReady(instanceId);
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1252
1385
|
case "started":
|
|
1253
1386
|
case "heartbeat":
|
|
1254
1387
|
// No-op: heartbeat handled above, started is handled by executeIsolatedWorkflow
|
|
@@ -1338,6 +1471,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1338
1471
|
case "completed": {
|
|
1339
1472
|
// Clean up isolated process tracking
|
|
1340
1473
|
this.cleanupIsolatedProcess(instanceId);
|
|
1474
|
+
this.resolveIsolatedReady(instanceId);
|
|
1341
1475
|
|
|
1342
1476
|
// Subprocess already persisted state - just emit events
|
|
1343
1477
|
await this.emitEvent("workflow.completed", {
|
|
@@ -1354,6 +1488,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1354
1488
|
case "failed": {
|
|
1355
1489
|
// Clean up isolated process tracking
|
|
1356
1490
|
this.cleanupIsolatedProcess(instanceId);
|
|
1491
|
+
this.rejectIsolatedReady(instanceId, new Error(event.error ?? "Isolated workflow failed"));
|
|
1357
1492
|
|
|
1358
1493
|
// Subprocess already persisted state - just emit events
|
|
1359
1494
|
await this.emitEvent("workflow.failed", {
|
|
@@ -1372,6 +1507,91 @@ class WorkflowsImpl implements Workflows {
|
|
|
1372
1507
|
}
|
|
1373
1508
|
}
|
|
1374
1509
|
|
|
1510
|
+
private waitForIsolatedReady(instanceId: string): Promise<void> {
|
|
1511
|
+
const existing = this.readyWaiters.get(instanceId);
|
|
1512
|
+
if (existing) {
|
|
1513
|
+
return existing.promise;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
let resolveFn!: () => void;
|
|
1517
|
+
let rejectFn!: (error: Error) => void;
|
|
1518
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
1519
|
+
resolveFn = resolve;
|
|
1520
|
+
rejectFn = reject;
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
const timeout = setTimeout(() => {
|
|
1524
|
+
this.readyWaiters.delete(instanceId);
|
|
1525
|
+
rejectFn(new Error(`Timed out waiting for isolated workflow ${instanceId} readiness`));
|
|
1526
|
+
}, this.readyTimeoutMs);
|
|
1527
|
+
|
|
1528
|
+
this.readyWaiters.set(instanceId, {
|
|
1529
|
+
promise,
|
|
1530
|
+
resolve: () => resolveFn(),
|
|
1531
|
+
reject: (error) => rejectFn(error),
|
|
1532
|
+
timeout,
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
return promise;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
private resolveIsolatedReady(instanceId: string): void {
|
|
1539
|
+
const waiter = this.readyWaiters.get(instanceId);
|
|
1540
|
+
if (!waiter) return;
|
|
1541
|
+
clearTimeout(waiter.timeout);
|
|
1542
|
+
this.readyWaiters.delete(instanceId);
|
|
1543
|
+
waiter.resolve();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
private rejectIsolatedReady(instanceId: string, error: Error): void {
|
|
1547
|
+
const waiter = this.readyWaiters.get(instanceId);
|
|
1548
|
+
if (!waiter) return;
|
|
1549
|
+
clearTimeout(waiter.timeout);
|
|
1550
|
+
this.readyWaiters.delete(instanceId);
|
|
1551
|
+
waiter.reject(error);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
private async handleIsolatedStartFailure(
|
|
1555
|
+
instanceId: string,
|
|
1556
|
+
pid: number,
|
|
1557
|
+
error: unknown
|
|
1558
|
+
): Promise<void> {
|
|
1559
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1560
|
+
|
|
1561
|
+
try {
|
|
1562
|
+
process.kill(pid, "SIGTERM");
|
|
1563
|
+
} catch {
|
|
1564
|
+
// Process might already be dead
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
this.cleanupIsolatedProcess(instanceId);
|
|
1568
|
+
await this.getSocketServer().closeSocket(instanceId);
|
|
1569
|
+
|
|
1570
|
+
const instance = await this.adapter.getInstance(instanceId);
|
|
1571
|
+
if (instance && (instance.status === "pending" || instance.status === "running")) {
|
|
1572
|
+
await this.adapter.updateInstance(instanceId, {
|
|
1573
|
+
status: "failed",
|
|
1574
|
+
error: errorMessage,
|
|
1575
|
+
completedAt: new Date(),
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
await this.emitEvent("workflow.failed", {
|
|
1579
|
+
instanceId,
|
|
1580
|
+
workflowName: instance.workflowName,
|
|
1581
|
+
error: errorMessage,
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
if (this.sse) {
|
|
1585
|
+
this.sse.broadcast(`workflow:${instanceId}`, "failed", { error: errorMessage });
|
|
1586
|
+
this.sse.broadcast("workflows:all", "workflow.failed", {
|
|
1587
|
+
instanceId,
|
|
1588
|
+
workflowName: instance.workflowName,
|
|
1589
|
+
error: errorMessage,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1375
1595
|
/**
|
|
1376
1596
|
* Handle proxy calls from isolated subprocess
|
|
1377
1597
|
*/
|
|
@@ -1416,6 +1636,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1416
1636
|
if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
|
|
1417
1637
|
this.isolatedProcesses.delete(instanceId);
|
|
1418
1638
|
}
|
|
1639
|
+
this.rejectIsolatedReady(instanceId, new Error("Isolated workflow cleaned up"));
|
|
1419
1640
|
}
|
|
1420
1641
|
|
|
1421
1642
|
/**
|
|
@@ -1488,6 +1709,102 @@ class WorkflowsImpl implements Workflows {
|
|
|
1488
1709
|
}
|
|
1489
1710
|
}
|
|
1490
1711
|
|
|
1712
|
+
// ============================================
|
|
1713
|
+
// Helpers
|
|
1714
|
+
// ============================================
|
|
1715
|
+
|
|
1716
|
+
function serializePluginConfigsOrThrow(
|
|
1717
|
+
configs: Record<string, any>,
|
|
1718
|
+
pluginNames: string[]
|
|
1719
|
+
): Record<string, any> {
|
|
1720
|
+
const result: Record<string, any> = {};
|
|
1721
|
+
const failures: string[] = [];
|
|
1722
|
+
|
|
1723
|
+
for (const name of pluginNames) {
|
|
1724
|
+
if (!(name in configs)) continue;
|
|
1725
|
+
try {
|
|
1726
|
+
assertJsonSerializable(configs[name], `pluginConfigs.${name}`);
|
|
1727
|
+
const serialized = JSON.stringify(configs[name]);
|
|
1728
|
+
result[name] = JSON.parse(serialized);
|
|
1729
|
+
} catch {
|
|
1730
|
+
failures.push(name);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (failures.length > 0) {
|
|
1735
|
+
throw new Error(
|
|
1736
|
+
`[Workflows] Non-serializable plugin config(s): ${failures.join(", ")}. ` +
|
|
1737
|
+
`Provide JSON-serializable configs for isolated workflows.`
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
return result;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function serializeCoreConfigOrThrow(config?: Record<string, any>): Record<string, any> | undefined {
|
|
1745
|
+
if (!config) return undefined;
|
|
1746
|
+
try {
|
|
1747
|
+
assertJsonSerializable(config, "coreConfig");
|
|
1748
|
+
const serialized = JSON.stringify(config);
|
|
1749
|
+
return JSON.parse(serialized);
|
|
1750
|
+
} catch {
|
|
1751
|
+
throw new Error(
|
|
1752
|
+
"[Workflows] Core config is not JSON-serializable. Provide JSON-serializable values for isolated workflows."
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function assertJsonSerializable(value: any, path: string, seen = new WeakSet<object>()): void {
|
|
1758
|
+
if (
|
|
1759
|
+
value === null ||
|
|
1760
|
+
typeof value === "string" ||
|
|
1761
|
+
typeof value === "number" ||
|
|
1762
|
+
typeof value === "boolean"
|
|
1763
|
+
) {
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
|
|
1768
|
+
throw new Error(`[Workflows] Non-serializable value at ${path}`);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (typeof value === "bigint") {
|
|
1772
|
+
throw new Error(`[Workflows] Non-serializable bigint at ${path}`);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (value instanceof Date) {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (Array.isArray(value)) {
|
|
1780
|
+
for (let i = 0; i < value.length; i++) {
|
|
1781
|
+
assertJsonSerializable(value[i], `${path}[${i}]`, seen);
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
if (typeof value === "object") {
|
|
1787
|
+
if (seen.has(value)) {
|
|
1788
|
+
throw new Error(`[Workflows] Circular reference at ${path}`);
|
|
1789
|
+
}
|
|
1790
|
+
seen.add(value);
|
|
1791
|
+
|
|
1792
|
+
if (!isPlainObject(value)) {
|
|
1793
|
+
throw new Error(`[Workflows] Non-serializable object at ${path}`);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1797
|
+
assertJsonSerializable(nested, `${path}.${key}`, seen);
|
|
1798
|
+
}
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function isPlainObject(value: Record<string, any>): boolean {
|
|
1804
|
+
const proto = Object.getPrototypeOf(value);
|
|
1805
|
+
return proto === Object.prototype || proto === null;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1491
1808
|
// ============================================
|
|
1492
1809
|
// Factory Function
|
|
1493
1810
|
// ============================================
|