@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.
@@ -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
- * Required when workflow.isolated !== false and running in isolated mode.
621
- * Use `import.meta.url` to get the current module's path.
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
- // Extract DB path if using Kysely adapter (for isolated workflows)
732
- if (!this.dbPath && (core.db as any)?.getExecutor) {
733
- // Try to get the database path from the Kysely instance
734
- // This is a bit hacky but necessary for isolated workflows
735
- try {
736
- const executor = (core.db as any).getExecutor();
737
- const adapter = executor?.adapter;
738
- if (adapter?.db?.filename) {
739
- this.dbPath = adapter.db.filename;
740
- }
741
- } catch {
742
- // Ignore - dbPath might be set manually
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
- // Store module path for isolated workflows
765
- if (options?.modulePath) {
766
- this.workflowModulePaths.set(definition.name, options.modulePath);
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 isolated workflow has no module path
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 provided. ` +
771
- `Use: workflows.register(myWorkflow, { modulePath: import.meta.url })`
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
- this.executeIsolatedWorkflow(instance.id, definition, instance.input, modulePath);
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
  // ============================================