@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 +35 -1
- package/package.json +1 -1
- package/src/core/index.ts +1 -0
- package/src/core/subprocess-bootstrap.ts +241 -0
- package/src/core/workflow-executor.ts +48 -43
- package/src/core/workflow-socket.ts +1 -0
- package/src/core/workflows.ts +284 -11
- package/src/core.ts +81 -3
- package/src/server.ts +9 -0
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 |
|
|
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
package/src/core/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
// The state machine owns all execution logic and persistence.
|
|
3
|
+
// Bootstraps core services and plugins locally for isolated workflows.
|
|
5
4
|
|
|
6
5
|
import { connect } from "node:net";
|
|
7
6
|
import type { Socket } from "node:net";
|
|
8
|
-
import { Kysely } from "kysely";
|
|
9
|
-
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
10
|
-
import Database from "bun:sqlite";
|
|
11
7
|
import type { WorkflowEvent } from "./workflow-socket";
|
|
12
|
-
import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
|
|
13
8
|
import type { WorkflowDefinition } from "./workflows";
|
|
14
|
-
import { KyselyWorkflowAdapter } from "./workflow-adapter-kysely";
|
|
15
9
|
import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
|
|
10
|
+
import { bootstrapSubprocess } from "./subprocess-bootstrap";
|
|
16
11
|
|
|
17
12
|
// ============================================
|
|
18
13
|
// Types
|
|
@@ -26,6 +21,10 @@ interface ExecutorConfig {
|
|
|
26
21
|
tcpPort?: number;
|
|
27
22
|
modulePath: string;
|
|
28
23
|
dbPath: string;
|
|
24
|
+
pluginNames: string[];
|
|
25
|
+
pluginModulePaths: Record<string, string>;
|
|
26
|
+
pluginConfigs: Record<string, any>;
|
|
27
|
+
coreConfig?: Record<string, any>;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
// ============================================
|
|
@@ -37,19 +36,23 @@ async function main(): Promise<void> {
|
|
|
37
36
|
const stdin = await Bun.stdin.text();
|
|
38
37
|
const config: ExecutorConfig = JSON.parse(stdin);
|
|
39
38
|
|
|
40
|
-
const {
|
|
39
|
+
const {
|
|
40
|
+
instanceId,
|
|
41
|
+
workflowName,
|
|
42
|
+
socketPath,
|
|
43
|
+
tcpPort,
|
|
44
|
+
modulePath,
|
|
45
|
+
dbPath,
|
|
46
|
+
pluginNames,
|
|
47
|
+
pluginModulePaths,
|
|
48
|
+
pluginConfigs,
|
|
49
|
+
coreConfig,
|
|
50
|
+
} = config;
|
|
41
51
|
|
|
42
|
-
// Connect to IPC socket
|
|
43
52
|
const socket = await connectToSocket(socketPath, tcpPort);
|
|
44
|
-
const proxyConnection = new WorkflowProxyConnection(socket);
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
sqlite.run("PRAGMA busy_timeout = 5000");
|
|
49
|
-
const db = new Kysely<any>({
|
|
50
|
-
dialect: new BunSqliteDialect({ database: sqlite }),
|
|
51
|
-
});
|
|
52
|
-
const adapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
|
|
54
|
+
let cleanup: (() => Promise<void>) | undefined;
|
|
55
|
+
let exitCode = 0;
|
|
53
56
|
|
|
54
57
|
// Start heartbeat
|
|
55
58
|
const heartbeatInterval = setInterval(() => {
|
|
@@ -61,39 +64,41 @@ async function main(): Promise<void> {
|
|
|
61
64
|
}, 5000);
|
|
62
65
|
|
|
63
66
|
try {
|
|
64
|
-
// Send started event
|
|
65
|
-
sendEvent(socket, {
|
|
66
|
-
type: "started",
|
|
67
|
-
instanceId,
|
|
68
|
-
timestamp: Date.now(),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
67
|
// Import the workflow module to get the definition
|
|
72
68
|
const module = await import(modulePath);
|
|
73
69
|
const definition = findWorkflowDefinition(module, workflowName, modulePath);
|
|
74
70
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
get(target, prop, receiver) {
|
|
83
|
-
if (prop === "db") return db;
|
|
84
|
-
return Reflect.get(target, prop, receiver);
|
|
71
|
+
const bootstrap = await bootstrapSubprocess({
|
|
72
|
+
dbPath,
|
|
73
|
+
coreConfig,
|
|
74
|
+
pluginMetadata: {
|
|
75
|
+
names: pluginNames,
|
|
76
|
+
modulePaths: pluginModulePaths,
|
|
77
|
+
configs: pluginConfigs,
|
|
85
78
|
},
|
|
86
79
|
});
|
|
80
|
+
cleanup = bootstrap.cleanup;
|
|
81
|
+
|
|
82
|
+
sendEvent(socket, {
|
|
83
|
+
type: "ready",
|
|
84
|
+
instanceId,
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
});
|
|
87
87
|
|
|
88
|
-
// Create state machine with IPC event bridge
|
|
89
88
|
const sm = new WorkflowStateMachine({
|
|
90
|
-
adapter,
|
|
91
|
-
core:
|
|
92
|
-
plugins,
|
|
89
|
+
adapter: bootstrap.workflowAdapter,
|
|
90
|
+
core: bootstrap.core as any,
|
|
91
|
+
plugins: bootstrap.manager.getServices(),
|
|
93
92
|
events: createIpcEventBridge(socket, instanceId),
|
|
94
93
|
pollInterval: 1000,
|
|
95
94
|
});
|
|
96
95
|
|
|
96
|
+
sendEvent(socket, {
|
|
97
|
+
type: "started",
|
|
98
|
+
instanceId,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
|
|
97
102
|
// Run the state machine to completion
|
|
98
103
|
const result = await sm.run(instanceId, definition);
|
|
99
104
|
|
|
@@ -112,16 +117,16 @@ async function main(): Promise<void> {
|
|
|
112
117
|
timestamp: Date.now(),
|
|
113
118
|
error: error instanceof Error ? error.message : String(error),
|
|
114
119
|
});
|
|
115
|
-
|
|
120
|
+
exitCode = 1;
|
|
116
121
|
} finally {
|
|
117
122
|
clearInterval(heartbeatInterval);
|
|
118
|
-
proxyConnection.close();
|
|
119
123
|
socket.end();
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
if (cleanup) {
|
|
125
|
+
await cleanup();
|
|
126
|
+
}
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
process.exit(
|
|
129
|
+
process.exit(exitCode);
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
// ============================================
|
package/src/core/workflows.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|