@donkeylabs/server 2.0.22 → 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 CHANGED
@@ -487,6 +487,37 @@ ctx.core.workflows.register(myWorkflow);
487
487
 
488
488
  > **Advanced:** The module path is captured automatically when you call `.build()`. If you re-export a workflow definition from a different module, pass `{ modulePath: import.meta.url }` explicitly so the subprocess can find the definition.
489
489
 
490
+ #### Isolated Plugin Initialization
491
+
492
+ In isolated mode, the subprocess **boots a full plugin manager** and runs plugin `init` hooks locally. This means your workflow handlers can use `ctx.plugins` without IPC fallbacks, and cron/jobs/workflows/services registered in `init` are available inside the subprocess.
493
+
494
+ Requirements:
495
+ - Plugin modules must be discoverable from their module path (captured during `createPlugin.define()` / `pluginFactory()` calls).
496
+ - Plugin configs and `ctx.core.config` must be JSON-serializable.
497
+
498
+ ```typescript
499
+ // plugins/reports/index.ts
500
+ export const reportsPlugin = createPlugin.define({
501
+ name: "reports",
502
+ service: async (ctx) => ({
503
+ generate: async (id: string) => ctx.db.selectFrom("reports").selectAll().execute(),
504
+ }),
505
+ init: async (ctx) => {
506
+ ctx.core.jobs.register("reports.generate", async () => undefined);
507
+ },
508
+ });
509
+
510
+ // workflows/report.ts
511
+ export const reportWorkflow = workflow("report.generate")
512
+ .task("run", {
513
+ handler: async (input, ctx) => {
514
+ const data = await ctx.plugins.reports.generate(input.reportId);
515
+ return { data };
516
+ },
517
+ })
518
+ .build();
519
+ ```
520
+
490
521
  ### Inline Mode
491
522
 
492
523
  For lightweight workflows that complete quickly, you can opt into inline execution:
@@ -512,7 +543,7 @@ ctx.core.workflows.register(quickWorkflow);
512
543
  |---|---|---|
513
544
  | Step types | All (task, choice, parallel, pass) | All (task, choice, parallel, pass) |
514
545
  | Event loop | Separate process, won't block server | Runs on main thread |
515
- | Plugin access | Via IPC proxy | Direct access |
546
+ | Plugin access | Local plugin services in subprocess | Direct access |
516
547
  | Best for | Long-running, CPU-intensive workflows | Quick validations, lightweight flows |
517
548
  | Setup | `workflows.register(wf)` | `workflows.register(wf)` |
518
549
 
@@ -545,6 +576,9 @@ interface Workflows {
545
576
 
546
577
  /** Stop the workflow service */
547
578
  stop(): Promise<void>;
579
+
580
+ /** Set plugin metadata for isolated workflows (AppServer sets this automatically) */
581
+ setPluginMetadata(metadata: PluginMetadata): void;
548
582
  }
549
583
 
550
584
  interface WorkflowRegisterOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.22",
3
+ "version": "2.0.23",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
package/src/core/index.ts CHANGED
@@ -149,6 +149,7 @@ export {
149
149
  type PassStepDefinition,
150
150
  type RetryConfig,
151
151
  type GetAllWorkflowsOptions,
152
+ type PluginMetadata,
152
153
  WorkflowBuilder,
153
154
  MemoryWorkflowAdapter,
154
155
  workflow,
@@ -0,0 +1,241 @@
1
+ import { Kysely } from "kysely";
2
+ import { BunSqliteDialect } from "kysely-bun-sqlite";
3
+ import Database from "bun:sqlite";
4
+ import {
5
+ createLogger,
6
+ createCache,
7
+ createEvents,
8
+ createCron,
9
+ createJobs,
10
+ createSSE,
11
+ createRateLimiter,
12
+ createErrors,
13
+ createWorkflows,
14
+ createProcesses,
15
+ createAudit,
16
+ createWebSocket,
17
+ createStorage,
18
+ createLogs,
19
+ KyselyJobAdapter,
20
+ KyselyWorkflowAdapter,
21
+ MemoryAuditAdapter,
22
+ MemoryLogsAdapter,
23
+ } from "./index";
24
+ import { PluginManager, type CoreServices, type ConfiguredPlugin } from "../core";
25
+
26
+ export interface SubprocessPluginMetadata {
27
+ names: string[];
28
+ modulePaths: Record<string, string>;
29
+ configs: Record<string, any>;
30
+ }
31
+
32
+ export interface SubprocessBootstrapOptions {
33
+ dbPath: string;
34
+ coreConfig?: Record<string, any>;
35
+ pluginMetadata: SubprocessPluginMetadata;
36
+ startServices?: {
37
+ cron?: boolean;
38
+ jobs?: boolean;
39
+ workflows?: boolean;
40
+ processes?: boolean;
41
+ };
42
+ }
43
+
44
+ export interface SubprocessBootstrapResult {
45
+ core: CoreServices;
46
+ manager: PluginManager;
47
+ db: Kysely<any>;
48
+ workflowAdapter: KyselyWorkflowAdapter;
49
+ cleanup: () => Promise<void>;
50
+ }
51
+
52
+ export async function bootstrapSubprocess(
53
+ options: SubprocessBootstrapOptions
54
+ ): Promise<SubprocessBootstrapResult> {
55
+ const sqlite = new Database(options.dbPath);
56
+ sqlite.run("PRAGMA busy_timeout = 5000");
57
+
58
+ const db = new Kysely<any>({
59
+ dialect: new BunSqliteDialect({ database: sqlite }),
60
+ });
61
+
62
+ const cache = createCache();
63
+ const events = createEvents();
64
+ const sse = createSSE();
65
+ const rateLimiter = createRateLimiter();
66
+ const errors = createErrors();
67
+
68
+ const logs = createLogs({ adapter: new MemoryLogsAdapter(), events });
69
+ const logger = createLogger();
70
+
71
+ const cron = createCron({ logger });
72
+
73
+ const jobAdapter = new KyselyJobAdapter(db, { cleanupDays: 0 });
74
+ const workflowAdapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
75
+ const auditAdapter = new MemoryAuditAdapter();
76
+
77
+ const jobs = createJobs({
78
+ events,
79
+ logger,
80
+ adapter: jobAdapter,
81
+ persist: false,
82
+ });
83
+
84
+ const workflows = createWorkflows({
85
+ events,
86
+ jobs,
87
+ sse,
88
+ adapter: workflowAdapter,
89
+ });
90
+
91
+ const processes = createProcesses({ events, autoRecoverOrphans: false });
92
+ const audit = createAudit({ adapter: auditAdapter });
93
+ const websocket = createWebSocket();
94
+ const storage = createStorage();
95
+
96
+ const core: CoreServices = {
97
+ db,
98
+ config: options.coreConfig ?? {},
99
+ logger,
100
+ cache,
101
+ events,
102
+ cron,
103
+ jobs,
104
+ sse,
105
+ rateLimiter,
106
+ errors,
107
+ workflows,
108
+ processes,
109
+ audit,
110
+ websocket,
111
+ storage,
112
+ logs,
113
+ };
114
+
115
+ workflows.setCore(core);
116
+
117
+ const manager = new PluginManager(core);
118
+ const plugins = await loadConfiguredPlugins(options.pluginMetadata);
119
+
120
+ for (const plugin of plugins) {
121
+ manager.register(plugin);
122
+ }
123
+
124
+ await manager.init();
125
+ workflows.setPlugins(manager.getServices());
126
+
127
+ if (options.startServices?.cron) {
128
+ core.cron.start();
129
+ }
130
+ if (options.startServices?.jobs) {
131
+ core.jobs.start();
132
+ }
133
+ if (options.startServices?.workflows) {
134
+ await core.workflows.resolveDbPath();
135
+ await core.workflows.resume();
136
+ }
137
+ if (options.startServices?.processes) {
138
+ core.processes.start();
139
+ }
140
+
141
+ const cleanup = async () => {
142
+ await core.cron.stop();
143
+ await core.jobs.stop();
144
+ await core.workflows.stop();
145
+ await core.processes.shutdown();
146
+
147
+ if (typeof (logs as any).stop === "function") {
148
+ (logs as any).stop();
149
+ }
150
+
151
+ if (typeof (audit as any).stop === "function") {
152
+ (audit as any).stop();
153
+ }
154
+
155
+ await db.destroy();
156
+ sqlite.close();
157
+ };
158
+
159
+ return { core, manager, db, workflowAdapter, cleanup };
160
+ }
161
+
162
+ async function loadConfiguredPlugins(
163
+ metadata: SubprocessPluginMetadata
164
+ ): Promise<ConfiguredPlugin[]> {
165
+ const plugins: ConfiguredPlugin[] = [];
166
+
167
+ for (const name of metadata.names) {
168
+ const modulePath = metadata.modulePaths[name];
169
+ if (!modulePath) {
170
+ throw new Error(`Missing module path for plugin "${name}"`);
171
+ }
172
+
173
+ const module = await import(modulePath);
174
+ const config = metadata.configs?.[name];
175
+ const plugin = findPluginDefinition(module, name, config);
176
+
177
+ if (!plugin) {
178
+ throw new Error(
179
+ `Plugin "${name}" not found in module ${modulePath}. ` +
180
+ `Ensure the plugin is exported and its config is serializable.`
181
+ );
182
+ }
183
+
184
+ plugins.push(plugin);
185
+ }
186
+
187
+ return plugins;
188
+ }
189
+
190
+ function findPluginDefinition(
191
+ mod: any,
192
+ pluginName: string,
193
+ boundConfig?: any
194
+ ): ConfiguredPlugin | null {
195
+ for (const key of Object.keys(mod)) {
196
+ const exported = mod[key];
197
+ const direct = resolvePluginDefinition(exported, pluginName, boundConfig);
198
+ if (direct) return direct;
199
+ }
200
+
201
+ if (mod.default) {
202
+ const direct = resolvePluginDefinition(mod.default, pluginName, boundConfig);
203
+ if (direct) return direct;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ function resolvePluginDefinition(
210
+ exported: any,
211
+ pluginName: string,
212
+ boundConfig?: any
213
+ ): ConfiguredPlugin | null {
214
+ if (!exported) return null;
215
+
216
+ if (
217
+ typeof exported === "object" &&
218
+ exported.name === pluginName &&
219
+ typeof exported.service === "function"
220
+ ) {
221
+ return exported as ConfiguredPlugin;
222
+ }
223
+
224
+ if (typeof exported === "function" && boundConfig !== undefined) {
225
+ try {
226
+ const result = exported(boundConfig);
227
+ if (
228
+ result &&
229
+ typeof result === "object" &&
230
+ result.name === pluginName &&
231
+ typeof result.service === "function"
232
+ ) {
233
+ return result as ConfiguredPlugin;
234
+ }
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ return null;
241
+ }
@@ -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"
@@ -640,6 +640,8 @@ export interface WorkflowsConfig {
640
640
  dbPath?: string;
641
641
  /** Heartbeat timeout in ms (default: 60000) */
642
642
  heartbeatTimeout?: number;
643
+ /** Timeout waiting for isolated subprocess readiness (ms, default: 10000) */
644
+ readyTimeout?: number;
643
645
  }
644
646
 
645
647
  /** Options for registering a workflow */
@@ -689,6 +691,16 @@ export interface Workflows {
689
691
  setPlugins(plugins: Record<string, any>): void;
690
692
  /** Update metadata for a workflow instance (used by isolated workflows) */
691
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>>;
692
704
  }
693
705
 
694
706
  // ============================================
@@ -719,8 +731,25 @@ class WorkflowsImpl implements Workflows {
719
731
  private tcpPortRange: [number, number];
720
732
  private dbPath?: string;
721
733
  private heartbeatTimeoutMs: number;
734
+ private readyTimeoutMs: number;
722
735
  private workflowModulePaths = new Map<string, string>();
723
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>> = {};
724
753
 
725
754
  constructor(config: WorkflowsConfig = {}) {
726
755
  this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
@@ -735,6 +764,7 @@ class WorkflowsImpl implements Workflows {
735
764
  this.tcpPortRange = config.tcpPortRange ?? [49152, 65535];
736
765
  this.dbPath = config.dbPath;
737
766
  this.heartbeatTimeoutMs = config.heartbeatTimeout ?? 60000;
767
+ this.readyTimeoutMs = config.readyTimeout ?? 10000;
738
768
  }
739
769
 
740
770
  private getSocketServer(): WorkflowSocketServer {
@@ -786,6 +816,14 @@ class WorkflowsImpl implements Workflows {
786
816
  this.plugins = plugins;
787
817
  }
788
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
+
789
827
  async updateMetadata(instanceId: string, key: string, value: any): Promise<void> {
790
828
  const instance = await this.adapter.getInstance(instanceId);
791
829
  if (!instance) return;
@@ -855,7 +893,7 @@ class WorkflowsImpl implements Workflows {
855
893
 
856
894
  if (isIsolated && modulePath && this.dbPath) {
857
895
  // Execute in isolated subprocess
858
- this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
896
+ await this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
859
897
  } else {
860
898
  // Execute inline using state machine
861
899
  if (isIsolated && !modulePath) {
@@ -952,7 +990,14 @@ class WorkflowsImpl implements Workflows {
952
990
  const modulePath = this.workflowModulePaths.get(instance.workflowName);
953
991
 
954
992
  if (isIsolated && modulePath && this.dbPath) {
955
- 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
+ }
956
1001
  } else {
957
1002
  this.startInlineWorkflow(instance.id, definition);
958
1003
  }
@@ -989,6 +1034,12 @@ class WorkflowsImpl implements Workflows {
989
1034
  }
990
1035
  this.running.clear();
991
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
+
992
1043
  // Stop adapter (cleanup timers and prevent further DB access)
993
1044
  if (this.adapter && typeof (this.adapter as any).stop === "function") {
994
1045
  (this.adapter as any).stop();
@@ -1182,20 +1233,36 @@ class WorkflowsImpl implements Workflows {
1182
1233
  ): Promise<void> {
1183
1234
  const socketServer = this.getSocketServer();
1184
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
+
1185
1258
  // Create socket for this workflow instance
1186
1259
  const { socketPath, tcpPort } = await socketServer.createSocket(instanceId);
1187
1260
 
1188
- // Mark workflow as running
1189
- await this.adapter.updateInstance(instanceId, {
1190
- status: "running",
1191
- startedAt: new Date(),
1192
- });
1193
-
1194
1261
  // Get the executor path
1195
1262
  const currentDir = dirname(fileURLToPath(import.meta.url));
1196
1263
  const executorPath = join(currentDir, "workflow-executor.ts");
1197
1264
 
1198
- // Prepare config for the executor
1265
+ // Prepare config for the executor, including plugin metadata for local instantiation
1199
1266
  const config = {
1200
1267
  instanceId,
1201
1268
  workflowName: definition.name,
@@ -1204,6 +1271,10 @@ class WorkflowsImpl implements Workflows {
1204
1271
  tcpPort,
1205
1272
  modulePath,
1206
1273
  dbPath: this.dbPath,
1274
+ pluginNames,
1275
+ pluginModulePaths: this.pluginModulePaths,
1276
+ pluginConfigs,
1277
+ coreConfig,
1207
1278
  };
1208
1279
 
1209
1280
  // Spawn the subprocess
@@ -1240,6 +1311,19 @@ class WorkflowsImpl implements Workflows {
1240
1311
  // Set up heartbeat timeout
1241
1312
  this.resetHeartbeatTimeout(instanceId, proc.pid);
1242
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
+
1243
1327
  // Handle process exit
1244
1328
  proc.exited.then(async (exitCode) => {
1245
1329
  const info = this.isolatedProcesses.get(instanceId);
@@ -1250,9 +1334,9 @@ class WorkflowsImpl implements Workflows {
1250
1334
  }
1251
1335
  await socketServer.closeSocket(instanceId);
1252
1336
 
1253
- // Check if workflow is still running (crashed before completion)
1337
+ // Check if workflow is still pending/running (crashed before completion)
1254
1338
  const instance = await this.adapter.getInstance(instanceId);
1255
- if (instance && instance.status === "running") {
1339
+ if (instance && (instance.status === "running" || instance.status === "pending")) {
1256
1340
  console.error(`[Workflows] Isolated workflow ${instanceId} crashed with exit code ${exitCode}`);
1257
1341
  await this.adapter.updateInstance(instanceId, {
1258
1342
  status: "failed",
@@ -1293,6 +1377,11 @@ class WorkflowsImpl implements Workflows {
1293
1377
  }
1294
1378
 
1295
1379
  switch (type) {
1380
+ case "ready": {
1381
+ this.resolveIsolatedReady(instanceId);
1382
+ break;
1383
+ }
1384
+
1296
1385
  case "started":
1297
1386
  case "heartbeat":
1298
1387
  // No-op: heartbeat handled above, started is handled by executeIsolatedWorkflow
@@ -1382,6 +1471,7 @@ class WorkflowsImpl implements Workflows {
1382
1471
  case "completed": {
1383
1472
  // Clean up isolated process tracking
1384
1473
  this.cleanupIsolatedProcess(instanceId);
1474
+ this.resolveIsolatedReady(instanceId);
1385
1475
 
1386
1476
  // Subprocess already persisted state - just emit events
1387
1477
  await this.emitEvent("workflow.completed", {
@@ -1398,6 +1488,7 @@ class WorkflowsImpl implements Workflows {
1398
1488
  case "failed": {
1399
1489
  // Clean up isolated process tracking
1400
1490
  this.cleanupIsolatedProcess(instanceId);
1491
+ this.rejectIsolatedReady(instanceId, new Error(event.error ?? "Isolated workflow failed"));
1401
1492
 
1402
1493
  // Subprocess already persisted state - just emit events
1403
1494
  await this.emitEvent("workflow.failed", {
@@ -1416,6 +1507,91 @@ class WorkflowsImpl implements Workflows {
1416
1507
  }
1417
1508
  }
1418
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
+
1419
1595
  /**
1420
1596
  * Handle proxy calls from isolated subprocess
1421
1597
  */
@@ -1460,6 +1636,7 @@ class WorkflowsImpl implements Workflows {
1460
1636
  if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
1461
1637
  this.isolatedProcesses.delete(instanceId);
1462
1638
  }
1639
+ this.rejectIsolatedReady(instanceId, new Error("Isolated workflow cleaned up"));
1463
1640
  }
1464
1641
 
1465
1642
  /**
@@ -1532,6 +1709,102 @@ class WorkflowsImpl implements Workflows {
1532
1709
  }
1533
1710
  }
1534
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
+
1535
1808
  // ============================================
1536
1809
  // Factory Function
1537
1810
  // ============================================
package/src/core.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { sql, type Kysely } from "kysely";
2
2
  import { readdir } from "node:fs/promises";
3
- import { join, dirname } from "node:path";
4
- import { fileURLToPath } from "node:url";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import type { z } from "zod";
6
6
  import type { Logger } from "./core/logger";
7
7
  import type { Cache } from "./core/cache";
@@ -18,6 +18,32 @@ import type { WebSocketService } from "./core/websocket";
18
18
  import type { Storage } from "./core/storage";
19
19
  import type { Logs } from "./core/logs";
20
20
 
21
+ // ============================================
22
+ // Auto-detect caller module for plugin define()
23
+ // ============================================
24
+
25
+ const CORE_FILE = resolve(fileURLToPath(import.meta.url));
26
+
27
+ /**
28
+ * Walk the call stack to find the file that invoked define().
29
+ * Returns a file:// URL string or undefined if detection fails.
30
+ * Skips frames originating from this file (core.ts).
31
+ */
32
+ function captureCallerUrl(): string | undefined {
33
+ const stack = new Error().stack ?? "";
34
+ for (const line of stack.split("\n").slice(1)) {
35
+ const match = line.match(/at\s+(?:.*?\s+\(?)?([^\s():]+):\d+:\d+/);
36
+ if (match) {
37
+ let filePath = match[1];
38
+ if (filePath.startsWith("file://")) filePath = fileURLToPath(filePath);
39
+ if (filePath.startsWith("native")) continue;
40
+ filePath = resolve(filePath);
41
+ if (filePath !== CORE_FILE) return pathToFileURL(filePath).href;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
21
47
  export interface PluginRegistry {}
22
48
 
23
49
  export interface ClientConfig {
@@ -330,7 +356,7 @@ export class PluginBuilder<LocalSchema = {}> {
330
356
  client?: ClientConfig;
331
357
  customErrors?: CustomErrors;
332
358
  } {
333
- return config as any;
359
+ return { ...config, _modulePath: captureCallerUrl() } as any;
334
360
  }
335
361
  }
336
362
 
@@ -386,9 +412,11 @@ export class ConfiguredPluginBuilder<LocalSchema, Config> {
386
412
  client?: ClientConfig;
387
413
  customErrors?: CustomErrors;
388
414
  }> {
415
+ const modulePath = captureCallerUrl();
389
416
  const factory = (config: Config) => ({
390
417
  ...pluginDef,
391
418
  _boundConfig: config,
419
+ _modulePath: modulePath,
392
420
  });
393
421
  return factory as any;
394
422
  }
@@ -425,6 +453,8 @@ export type Plugin = {
425
453
  service: (ctx: any) => any;
426
454
  /** Called after service is created - use for registering crons, events, etc. */
427
455
  init?: (ctx: any, service: any) => void | Promise<void>;
456
+ /** Auto-detected module path where the plugin was defined */
457
+ _modulePath?: string;
428
458
  };
429
459
 
430
460
  export type PluginWithConfig<Config = void> = Plugin & {
@@ -454,6 +484,54 @@ export class PluginManager {
454
484
  return Array.from(this.plugins.values());
455
485
  }
456
486
 
487
+ getPluginNames(): string[] {
488
+ return Array.from(this.plugins.keys());
489
+ }
490
+
491
+ /** Returns { name: modulePath } for plugins that have a captured module path */
492
+ getPluginModulePaths(): Record<string, string> {
493
+ const result: Record<string, string> = {};
494
+ for (const [name, plugin] of this.plugins) {
495
+ if (plugin._modulePath) {
496
+ result[name] = plugin._modulePath;
497
+ }
498
+ }
499
+ return result;
500
+ }
501
+
502
+ /** Returns { name: boundConfig } for configured plugins */
503
+ getPluginConfigs(): Record<string, any> {
504
+ const result: Record<string, any> = {};
505
+ for (const [name, plugin] of this.plugins) {
506
+ if ((plugin as ConfiguredPlugin)._boundConfig !== undefined) {
507
+ result[name] = (plugin as ConfiguredPlugin)._boundConfig;
508
+ }
509
+ }
510
+ return result;
511
+ }
512
+
513
+ /** Returns { name: [...deps] } for plugins with dependencies */
514
+ getPluginDependencies(): Record<string, string[]> {
515
+ const result: Record<string, string[]> = {};
516
+ for (const [name, plugin] of this.plugins) {
517
+ if (plugin.dependencies && plugin.dependencies.length > 0) {
518
+ result[name] = [...plugin.dependencies];
519
+ }
520
+ }
521
+ return result;
522
+ }
523
+
524
+ /** Returns custom error definitions per plugin */
525
+ getPluginCustomErrors(): Record<string, Record<string, any>> {
526
+ const result: Record<string, Record<string, any>> = {};
527
+ for (const [name, plugin] of this.plugins) {
528
+ if (plugin.customErrors && Object.keys(plugin.customErrors).length > 0) {
529
+ result[name] = plugin.customErrors;
530
+ }
531
+ }
532
+ return result;
533
+ }
534
+
457
535
  register(plugin: ConfiguredPlugin): void {
458
536
  if (this.plugins.has(plugin.name)) {
459
537
  throw new Error(`Plugin ${plugin.name} is already registered.`);
package/src/server.ts CHANGED
@@ -1023,6 +1023,15 @@ ${factoryFunction}
1023
1023
  // Pass plugins to workflows so handlers can access ctx.plugins
1024
1024
  this.coreServices.workflows.setPlugins(this.manager.getServices());
1025
1025
 
1026
+ // Forward plugin metadata so isolated workflows can instantiate plugins locally
1027
+ this.coreServices.workflows.setPluginMetadata({
1028
+ names: this.manager.getPluginNames(),
1029
+ modulePaths: this.manager.getPluginModulePaths(),
1030
+ configs: this.manager.getPluginConfigs(),
1031
+ dependencies: this.manager.getPluginDependencies(),
1032
+ customErrors: this.manager.getPluginCustomErrors(),
1033
+ });
1034
+
1026
1035
  this.isInitialized = true;
1027
1036
 
1028
1037
  this.coreServices.cron.start();