@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.
@@ -1,18 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  // Workflow Executor - Subprocess Entry Point
3
- // Thin shell that creates a WorkflowStateMachine with IPC event bridge.
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 { instanceId, workflowName, socketPath, tcpPort, modulePath, dbPath } = config;
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
- // Create database connection + adapter (subprocess owns its own persistence)
47
- const sqlite = new Database(dbPath);
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
- // Create proxy objects for plugin/core access via IPC
76
- const plugins = createPluginsProxy(proxyConnection);
77
- const coreServices = createCoreServicesProxy(proxyConnection);
78
-
79
- // Wrap coreServices proxy so that `db` resolves locally instead of via IPC.
80
- // Spreading a Proxy with no ownKeys trap loses all proxied properties.
81
- const coreWithDb = new Proxy(coreServices, {
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: coreWithDb as any,
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
- process.exit(1);
120
+ exitCode = 1;
116
121
  } finally {
117
122
  clearInterval(heartbeatInterval);
118
- proxyConnection.close();
119
123
  socket.end();
120
- adapter.stop();
121
- await db.destroy();
124
+ if (cleanup) {
125
+ await cleanup();
126
+ }
122
127
  }
123
128
 
124
- process.exit(0);
129
+ process.exit(exitCode);
125
130
  }
126
131
 
127
132
  // ============================================
@@ -12,6 +12,7 @@ import { createServer as createNetServer } from "node:net";
12
12
  // ============================================
13
13
 
14
14
  export type WorkflowEventType =
15
+ | "ready"
15
16
  | "started"
16
17
  | "heartbeat"
17
18
  | "step.started"
@@ -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: this.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> => {
@@ -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
- this.executeIsolatedWorkflow(instance.id, definition, instance.input, modulePath);
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
  // ============================================