@donkeylabs/server 2.0.18 → 2.0.20
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/caching-strategies.md +677 -0
- package/docs/dev-experience.md +656 -0
- package/docs/hot-reload-limitations.md +166 -0
- package/docs/load-testing.md +974 -0
- package/docs/plugin-registry-design.md +1064 -0
- package/docs/production.md +1229 -0
- package/docs/workflows.md +90 -3
- package/package.json +18 -2
- package/src/admin/routes.ts +153 -0
- package/src/core/cron.ts +184 -15
- package/src/core/index.ts +25 -0
- package/src/core/job-adapter-kysely.ts +176 -73
- package/src/core/job-adapter-sqlite.ts +10 -0
- package/src/core/jobs.ts +112 -17
- package/src/core/migrations/workflows/002_add_metadata_column.ts +28 -0
- package/src/core/process-adapter-kysely.ts +62 -21
- package/src/core/storage-adapter-local.test.ts +199 -0
- package/src/core/storage.test.ts +197 -0
- package/src/core/workflow-adapter-kysely.ts +66 -19
- package/src/core/workflow-executor.ts +469 -0
- package/src/core/workflow-proxy.ts +238 -0
- package/src/core/workflow-socket.ts +447 -0
- package/src/core/workflows.test.ts +415 -0
- package/src/core/workflows.ts +782 -9
- package/src/core.ts +17 -6
- package/src/index.ts +14 -0
- package/src/server.ts +40 -26
- package/src/testing/database.test.ts +263 -0
- package/src/testing/database.ts +173 -0
- package/src/testing/e2e.test.ts +189 -0
- package/src/testing/e2e.ts +272 -0
- package/src/testing/index.ts +18 -0
|
@@ -31,6 +31,7 @@ interface WorkflowInstancesTable {
|
|
|
31
31
|
completed_at: string | null;
|
|
32
32
|
parent_id: string | null;
|
|
33
33
|
branch_name: string | null;
|
|
34
|
+
metadata: string | null;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
interface Database {
|
|
@@ -41,6 +42,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
41
42
|
private db: Kysely<Database>;
|
|
42
43
|
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
43
44
|
private cleanupDays: number;
|
|
45
|
+
private stopped = false;
|
|
44
46
|
|
|
45
47
|
constructor(db: Kysely<any>, config: KyselyWorkflowAdapterConfig = {}) {
|
|
46
48
|
this.db = db as Kysely<Database>;
|
|
@@ -53,7 +55,16 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/** Check if adapter is stopped (for safe database access) */
|
|
59
|
+
private checkStopped(): boolean {
|
|
60
|
+
return this.stopped;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
async createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance> {
|
|
64
|
+
if (this.checkStopped()) {
|
|
65
|
+
throw new Error("WorkflowAdapter has been stopped");
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
const id = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
58
69
|
|
|
59
70
|
await this.db
|
|
@@ -75,6 +86,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
75
86
|
completed_at: instance.completedAt?.toISOString() ?? null,
|
|
76
87
|
parent_id: instance.parentId ?? null,
|
|
77
88
|
branch_name: instance.branchName ?? null,
|
|
89
|
+
metadata: instance.metadata ? JSON.stringify(instance.metadata) : null,
|
|
78
90
|
})
|
|
79
91
|
.execute();
|
|
80
92
|
|
|
@@ -82,17 +94,27 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
async getInstance(instanceId: string): Promise<WorkflowInstance | null> {
|
|
85
|
-
|
|
86
|
-
.selectFrom("__donkeylabs_workflow_instances__")
|
|
87
|
-
.selectAll()
|
|
88
|
-
.where("id", "=", instanceId)
|
|
89
|
-
.executeTakeFirst();
|
|
97
|
+
if (this.checkStopped()) return null;
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
try {
|
|
100
|
+
const row = await this.db
|
|
101
|
+
.selectFrom("__donkeylabs_workflow_instances__")
|
|
102
|
+
.selectAll()
|
|
103
|
+
.where("id", "=", instanceId)
|
|
104
|
+
.executeTakeFirst();
|
|
105
|
+
|
|
106
|
+
if (!row) return null;
|
|
107
|
+
return this.rowToInstance(row);
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
// Silently ignore errors if adapter was stopped during query
|
|
110
|
+
if (this.stopped && err?.message?.includes("destroyed")) return null;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
async updateInstance(instanceId: string, updates: Partial<WorkflowInstance>): Promise<void> {
|
|
116
|
+
if (this.checkStopped()) return;
|
|
117
|
+
|
|
96
118
|
const updateData: Partial<WorkflowInstancesTable> = {};
|
|
97
119
|
|
|
98
120
|
if (updates.status !== undefined) {
|
|
@@ -121,17 +143,28 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
121
143
|
if (updates.completedAt !== undefined) {
|
|
122
144
|
updateData.completed_at = updates.completedAt?.toISOString() ?? null;
|
|
123
145
|
}
|
|
146
|
+
if (updates.metadata !== undefined) {
|
|
147
|
+
updateData.metadata = updates.metadata ? JSON.stringify(updates.metadata) : null;
|
|
148
|
+
}
|
|
124
149
|
|
|
125
150
|
if (Object.keys(updateData).length === 0) return;
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
try {
|
|
153
|
+
await this.db
|
|
154
|
+
.updateTable("__donkeylabs_workflow_instances__")
|
|
155
|
+
.set(updateData)
|
|
156
|
+
.where("id", "=", instanceId)
|
|
157
|
+
.execute();
|
|
158
|
+
} catch (err: any) {
|
|
159
|
+
// Silently ignore errors if adapter was stopped during query
|
|
160
|
+
if (this.stopped && err?.message?.includes("destroyed")) return;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
async deleteInstance(instanceId: string): Promise<boolean> {
|
|
166
|
+
if (this.checkStopped()) return false;
|
|
167
|
+
|
|
135
168
|
// Check if exists first since BunSqliteDialect doesn't report numDeletedRows properly
|
|
136
169
|
const exists = await this.db
|
|
137
170
|
.selectFrom("__donkeylabs_workflow_instances__")
|
|
@@ -153,6 +186,8 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
153
186
|
workflowName: string,
|
|
154
187
|
status?: WorkflowStatus
|
|
155
188
|
): Promise<WorkflowInstance[]> {
|
|
189
|
+
if (this.checkStopped()) return [];
|
|
190
|
+
|
|
156
191
|
let query = this.db
|
|
157
192
|
.selectFrom("__donkeylabs_workflow_instances__")
|
|
158
193
|
.selectAll()
|
|
@@ -167,16 +202,26 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
async getRunningInstances(): Promise<WorkflowInstance[]> {
|
|
170
|
-
|
|
171
|
-
.selectFrom("__donkeylabs_workflow_instances__")
|
|
172
|
-
.selectAll()
|
|
173
|
-
.where("status", "=", "running")
|
|
174
|
-
.execute();
|
|
205
|
+
if (this.checkStopped()) return [];
|
|
175
206
|
|
|
176
|
-
|
|
207
|
+
try {
|
|
208
|
+
const rows = await this.db
|
|
209
|
+
.selectFrom("__donkeylabs_workflow_instances__")
|
|
210
|
+
.selectAll()
|
|
211
|
+
.where("status", "=", "running")
|
|
212
|
+
.execute();
|
|
213
|
+
|
|
214
|
+
return rows.map((r) => this.rowToInstance(r));
|
|
215
|
+
} catch (err: any) {
|
|
216
|
+
// Silently ignore errors if adapter was stopped during query
|
|
217
|
+
if (this.stopped && err?.message?.includes("destroyed")) return [];
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
177
220
|
}
|
|
178
221
|
|
|
179
222
|
async getAllInstances(options: GetAllWorkflowsOptions = {}): Promise<WorkflowInstance[]> {
|
|
223
|
+
if (this.checkStopped()) return [];
|
|
224
|
+
|
|
180
225
|
const { status, workflowName, limit = 100, offset = 0 } = options;
|
|
181
226
|
|
|
182
227
|
let query = this.db.selectFrom("__donkeylabs_workflow_instances__").selectAll();
|
|
@@ -231,12 +276,13 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
231
276
|
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
|
232
277
|
parentId: row.parent_id ?? undefined,
|
|
233
278
|
branchName: row.branch_name ?? undefined,
|
|
279
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
234
280
|
};
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
/** Clean up old completed/failed/cancelled workflows */
|
|
238
284
|
private async cleanup(): Promise<void> {
|
|
239
|
-
if (this.cleanupDays <= 0) return;
|
|
285
|
+
if (this.cleanupDays <= 0 || this.checkStopped()) return;
|
|
240
286
|
|
|
241
287
|
try {
|
|
242
288
|
const cutoff = new Date();
|
|
@@ -268,6 +314,7 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
268
314
|
|
|
269
315
|
/** Stop the adapter and cleanup timer */
|
|
270
316
|
stop(): void {
|
|
317
|
+
this.stopped = true;
|
|
271
318
|
if (this.cleanupTimer) {
|
|
272
319
|
clearInterval(this.cleanupTimer);
|
|
273
320
|
this.cleanupTimer = undefined;
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Workflow Executor - Subprocess Entry Point
|
|
3
|
+
// Executes isolated workflows in a separate process to prevent blocking the main event loop
|
|
4
|
+
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import type { Socket } from "node:net";
|
|
7
|
+
import { Kysely } from "kysely";
|
|
8
|
+
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
9
|
+
import Database from "bun:sqlite";
|
|
10
|
+
import type { WorkflowEvent, ProxyResponse } from "./workflow-socket";
|
|
11
|
+
import { WorkflowProxyConnection, createPluginsProxy, createCoreServicesProxy } from "./workflow-proxy";
|
|
12
|
+
import type { WorkflowDefinition, WorkflowContext, TaskStepDefinition, StepDefinition } from "./workflows";
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
interface ExecutorConfig {
|
|
19
|
+
/** Workflow instance ID */
|
|
20
|
+
instanceId: string;
|
|
21
|
+
/** Workflow name (for importing the definition) */
|
|
22
|
+
workflowName: string;
|
|
23
|
+
/** Input data for the workflow */
|
|
24
|
+
input: any;
|
|
25
|
+
/** Unix socket path to connect to */
|
|
26
|
+
socketPath?: string;
|
|
27
|
+
/** TCP port for Windows */
|
|
28
|
+
tcpPort?: number;
|
|
29
|
+
/** Module path to import workflow definition from */
|
|
30
|
+
modulePath: string;
|
|
31
|
+
/** Database file path */
|
|
32
|
+
dbPath: string;
|
|
33
|
+
/** Initial step results (for resuming) */
|
|
34
|
+
stepResults?: Record<string, any>;
|
|
35
|
+
/** Current step name (for resuming) */
|
|
36
|
+
currentStep?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// Main Executor
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
async function main(): Promise<void> {
|
|
44
|
+
// Read config from stdin
|
|
45
|
+
const stdin = await Bun.stdin.text();
|
|
46
|
+
const config: ExecutorConfig = JSON.parse(stdin);
|
|
47
|
+
|
|
48
|
+
const { instanceId, workflowName, input, socketPath, tcpPort, modulePath, dbPath, stepResults, currentStep } = config;
|
|
49
|
+
|
|
50
|
+
// Connect to IPC socket
|
|
51
|
+
const socket = await connectToSocket(socketPath, tcpPort);
|
|
52
|
+
const proxyConnection = new WorkflowProxyConnection(socket);
|
|
53
|
+
|
|
54
|
+
// Create database connection for workflow adapter
|
|
55
|
+
const db = new Kysely<any>({
|
|
56
|
+
dialect: new BunSqliteDialect({
|
|
57
|
+
database: new Database(dbPath),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Send started event
|
|
63
|
+
sendEvent(socket, {
|
|
64
|
+
type: "started",
|
|
65
|
+
instanceId,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Import the workflow module to get the definition
|
|
70
|
+
const module = await import(modulePath);
|
|
71
|
+
|
|
72
|
+
// Find the workflow definition - it could be exported various ways
|
|
73
|
+
let definition: WorkflowDefinition | undefined;
|
|
74
|
+
|
|
75
|
+
// Try common export patterns
|
|
76
|
+
for (const key of Object.keys(module)) {
|
|
77
|
+
const exported = module[key];
|
|
78
|
+
if (isWorkflowDefinition(exported) && exported.name === workflowName) {
|
|
79
|
+
definition = exported;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Also check default export
|
|
85
|
+
if (!definition && module.default) {
|
|
86
|
+
if (isWorkflowDefinition(module.default) && module.default.name === workflowName) {
|
|
87
|
+
definition = module.default;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!definition) {
|
|
92
|
+
throw new Error(`Workflow "${workflowName}" not found in module ${modulePath}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create proxy context for plugin/core access
|
|
96
|
+
const plugins = createPluginsProxy(proxyConnection);
|
|
97
|
+
const coreServices = createCoreServicesProxy(proxyConnection);
|
|
98
|
+
|
|
99
|
+
// Execute the workflow
|
|
100
|
+
const result = await executeWorkflow(
|
|
101
|
+
socket,
|
|
102
|
+
proxyConnection,
|
|
103
|
+
definition,
|
|
104
|
+
instanceId,
|
|
105
|
+
input,
|
|
106
|
+
db,
|
|
107
|
+
plugins,
|
|
108
|
+
coreServices,
|
|
109
|
+
stepResults ?? {},
|
|
110
|
+
currentStep ?? definition.startAt
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Send completed event
|
|
114
|
+
sendEvent(socket, {
|
|
115
|
+
type: "completed",
|
|
116
|
+
instanceId,
|
|
117
|
+
timestamp: Date.now(),
|
|
118
|
+
output: result,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Send failed event
|
|
122
|
+
sendEvent(socket, {
|
|
123
|
+
type: "failed",
|
|
124
|
+
instanceId,
|
|
125
|
+
timestamp: Date.now(),
|
|
126
|
+
error: error instanceof Error ? error.message : String(error),
|
|
127
|
+
});
|
|
128
|
+
process.exit(1);
|
|
129
|
+
} finally {
|
|
130
|
+
proxyConnection.close();
|
|
131
|
+
socket.end();
|
|
132
|
+
await db.destroy();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================
|
|
139
|
+
// Socket Connection
|
|
140
|
+
// ============================================
|
|
141
|
+
|
|
142
|
+
function connectToSocket(socketPath?: string, tcpPort?: number): Promise<Socket> {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
let socket: Socket;
|
|
145
|
+
|
|
146
|
+
if (socketPath) {
|
|
147
|
+
socket = connect(socketPath);
|
|
148
|
+
} else if (tcpPort) {
|
|
149
|
+
socket = connect(tcpPort, "127.0.0.1");
|
|
150
|
+
} else {
|
|
151
|
+
reject(new Error("No socket path or TCP port provided"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
socket.once("connect", () => resolve(socket));
|
|
156
|
+
socket.once("error", (err) => reject(err));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================
|
|
161
|
+
// Workflow Execution
|
|
162
|
+
// ============================================
|
|
163
|
+
|
|
164
|
+
async function executeWorkflow(
|
|
165
|
+
socket: Socket,
|
|
166
|
+
proxyConnection: WorkflowProxyConnection,
|
|
167
|
+
definition: WorkflowDefinition,
|
|
168
|
+
instanceId: string,
|
|
169
|
+
input: any,
|
|
170
|
+
db: Kysely<any>,
|
|
171
|
+
plugins: Record<string, any>,
|
|
172
|
+
coreServices: Record<string, any>,
|
|
173
|
+
initialStepResults: Record<string, any>,
|
|
174
|
+
startStep: string
|
|
175
|
+
): Promise<any> {
|
|
176
|
+
const stepResults: Record<string, any> = { ...initialStepResults };
|
|
177
|
+
let currentStepName: string | undefined = startStep;
|
|
178
|
+
let lastOutput: any;
|
|
179
|
+
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
|
180
|
+
|
|
181
|
+
// Start heartbeat
|
|
182
|
+
heartbeatInterval = setInterval(() => {
|
|
183
|
+
sendEvent(socket, {
|
|
184
|
+
type: "heartbeat",
|
|
185
|
+
instanceId,
|
|
186
|
+
timestamp: Date.now(),
|
|
187
|
+
});
|
|
188
|
+
}, 5000); // Every 5 seconds
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
while (currentStepName) {
|
|
192
|
+
const step = definition.steps.get(currentStepName);
|
|
193
|
+
if (!step) {
|
|
194
|
+
throw new Error(`Step "${currentStepName}" not found in workflow`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Send step started event
|
|
198
|
+
sendEvent(socket, {
|
|
199
|
+
type: "step.started",
|
|
200
|
+
instanceId,
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
stepName: currentStepName,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Build context for this step
|
|
206
|
+
const ctx = buildContext(
|
|
207
|
+
input,
|
|
208
|
+
stepResults,
|
|
209
|
+
lastOutput,
|
|
210
|
+
instanceId,
|
|
211
|
+
definition,
|
|
212
|
+
currentStepName,
|
|
213
|
+
db,
|
|
214
|
+
plugins,
|
|
215
|
+
coreServices
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Execute step
|
|
219
|
+
let output: any;
|
|
220
|
+
try {
|
|
221
|
+
output = await executeStep(step, ctx);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// Check for retry config
|
|
224
|
+
const retry = step.retry ?? definition.defaultRetry;
|
|
225
|
+
const attempts = (stepResults[currentStepName]?.attempts ?? 0) + 1;
|
|
226
|
+
|
|
227
|
+
if (retry && attempts < retry.maxAttempts) {
|
|
228
|
+
// Retry logic
|
|
229
|
+
const backoffRate = retry.backoffRate ?? 2;
|
|
230
|
+
const intervalMs = retry.intervalMs ?? 1000;
|
|
231
|
+
const maxIntervalMs = retry.maxIntervalMs ?? 30000;
|
|
232
|
+
const delay = Math.min(
|
|
233
|
+
intervalMs * Math.pow(backoffRate, attempts - 1),
|
|
234
|
+
maxIntervalMs
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
stepResults[currentStepName] = {
|
|
238
|
+
stepName: currentStepName,
|
|
239
|
+
status: "pending",
|
|
240
|
+
attempts,
|
|
241
|
+
error: error instanceof Error ? error.message : String(error),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
245
|
+
continue; // Retry the same step
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// No more retries, send step failed event
|
|
249
|
+
sendEvent(socket, {
|
|
250
|
+
type: "step.failed",
|
|
251
|
+
instanceId,
|
|
252
|
+
timestamp: Date.now(),
|
|
253
|
+
stepName: currentStepName,
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
});
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Store step result
|
|
260
|
+
stepResults[currentStepName] = {
|
|
261
|
+
stepName: currentStepName,
|
|
262
|
+
status: "completed",
|
|
263
|
+
output,
|
|
264
|
+
completedAt: new Date(),
|
|
265
|
+
attempts: (stepResults[currentStepName]?.attempts ?? 0) + 1,
|
|
266
|
+
};
|
|
267
|
+
lastOutput = output;
|
|
268
|
+
|
|
269
|
+
// Send step completed event
|
|
270
|
+
const completedSteps = Object.values(stepResults).filter(
|
|
271
|
+
(r: any) => r.status === "completed"
|
|
272
|
+
).length;
|
|
273
|
+
const totalSteps = definition.steps.size;
|
|
274
|
+
const progress = Math.round((completedSteps / totalSteps) * 100);
|
|
275
|
+
|
|
276
|
+
// Determine next step
|
|
277
|
+
let nextStepName: string | undefined;
|
|
278
|
+
if (step.end) {
|
|
279
|
+
nextStepName = undefined;
|
|
280
|
+
} else if (step.next) {
|
|
281
|
+
nextStepName = step.next;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
sendEvent(socket, {
|
|
285
|
+
type: "step.completed",
|
|
286
|
+
instanceId,
|
|
287
|
+
timestamp: Date.now(),
|
|
288
|
+
stepName: currentStepName,
|
|
289
|
+
output,
|
|
290
|
+
nextStep: nextStepName,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
sendEvent(socket, {
|
|
294
|
+
type: "progress",
|
|
295
|
+
instanceId,
|
|
296
|
+
timestamp: Date.now(),
|
|
297
|
+
progress,
|
|
298
|
+
completedSteps,
|
|
299
|
+
totalSteps,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Move to next step
|
|
303
|
+
if (step.end) {
|
|
304
|
+
currentStepName = undefined;
|
|
305
|
+
} else if (step.next) {
|
|
306
|
+
currentStepName = step.next;
|
|
307
|
+
} else {
|
|
308
|
+
currentStepName = undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return lastOutput;
|
|
313
|
+
} finally {
|
|
314
|
+
if (heartbeatInterval) {
|
|
315
|
+
clearInterval(heartbeatInterval);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function executeStep(step: StepDefinition, ctx: WorkflowContext): Promise<any> {
|
|
321
|
+
switch (step.type) {
|
|
322
|
+
case "task":
|
|
323
|
+
return executeTaskStep(step as TaskStepDefinition, ctx);
|
|
324
|
+
case "pass":
|
|
325
|
+
return executePassStep(step, ctx);
|
|
326
|
+
case "choice":
|
|
327
|
+
throw new Error("Choice steps should be handled by main process flow");
|
|
328
|
+
case "parallel":
|
|
329
|
+
throw new Error("Parallel steps should be handled by main process");
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`Unknown step type: ${(step as any).type}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function executeTaskStep(step: TaskStepDefinition, ctx: WorkflowContext): Promise<any> {
|
|
336
|
+
if (!step.handler) {
|
|
337
|
+
throw new Error("Task step requires handler (job-based steps not supported in isolated mode)");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let input: any;
|
|
341
|
+
|
|
342
|
+
if (step.inputSchema) {
|
|
343
|
+
if (typeof step.inputSchema === "function") {
|
|
344
|
+
// Input mapper function
|
|
345
|
+
input = step.inputSchema(ctx.prev, ctx.input);
|
|
346
|
+
} else {
|
|
347
|
+
// Zod schema - validate workflow input
|
|
348
|
+
const parseResult = step.inputSchema.safeParse(ctx.input);
|
|
349
|
+
if (!parseResult.success) {
|
|
350
|
+
throw new Error(`Input validation failed: ${parseResult.error.message}`);
|
|
351
|
+
}
|
|
352
|
+
input = parseResult.data;
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
input = ctx.input;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Execute handler
|
|
359
|
+
let result = await step.handler(input, ctx);
|
|
360
|
+
|
|
361
|
+
// Validate output if schema provided
|
|
362
|
+
if (step.outputSchema) {
|
|
363
|
+
const parseResult = step.outputSchema.safeParse(result);
|
|
364
|
+
if (!parseResult.success) {
|
|
365
|
+
throw new Error(`Output validation failed: ${parseResult.error.message}`);
|
|
366
|
+
}
|
|
367
|
+
result = parseResult.data;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function executePassStep(step: any, ctx: WorkflowContext): Promise<any> {
|
|
374
|
+
if (step.result !== undefined) {
|
|
375
|
+
return step.result;
|
|
376
|
+
}
|
|
377
|
+
if (step.transform) {
|
|
378
|
+
return step.transform(ctx);
|
|
379
|
+
}
|
|
380
|
+
return ctx.input;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ============================================
|
|
384
|
+
// Context Building
|
|
385
|
+
// ============================================
|
|
386
|
+
|
|
387
|
+
function buildContext(
|
|
388
|
+
input: any,
|
|
389
|
+
stepResults: Record<string, any>,
|
|
390
|
+
prev: any,
|
|
391
|
+
instanceId: string,
|
|
392
|
+
definition: WorkflowDefinition,
|
|
393
|
+
currentStep: string,
|
|
394
|
+
db: Kysely<any>,
|
|
395
|
+
plugins: Record<string, any>,
|
|
396
|
+
coreServices: Record<string, any>
|
|
397
|
+
): WorkflowContext {
|
|
398
|
+
// Build steps object with outputs
|
|
399
|
+
const steps: Record<string, any> = {};
|
|
400
|
+
for (const [name, result] of Object.entries(stepResults)) {
|
|
401
|
+
if ((result as any).status === "completed" && (result as any).output !== undefined) {
|
|
402
|
+
steps[name] = (result as any).output;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Create a fake instance for the context
|
|
407
|
+
const instance = {
|
|
408
|
+
id: instanceId,
|
|
409
|
+
workflowName: definition.name,
|
|
410
|
+
status: "running" as const,
|
|
411
|
+
currentStep,
|
|
412
|
+
input,
|
|
413
|
+
stepResults,
|
|
414
|
+
createdAt: new Date(),
|
|
415
|
+
startedAt: new Date(),
|
|
416
|
+
metadata: {},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Metadata is stored locally - setMetadata sends via proxy
|
|
420
|
+
const metadata: Record<string, any> = {};
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
input,
|
|
424
|
+
steps,
|
|
425
|
+
prev,
|
|
426
|
+
instance,
|
|
427
|
+
getStepResult: <T = any>(stepName: string): T | undefined => {
|
|
428
|
+
return steps[stepName] as T | undefined;
|
|
429
|
+
},
|
|
430
|
+
core: {
|
|
431
|
+
...coreServices,
|
|
432
|
+
db,
|
|
433
|
+
} as any,
|
|
434
|
+
plugins,
|
|
435
|
+
metadata,
|
|
436
|
+
setMetadata: async (key: string, value: any): Promise<void> => {
|
|
437
|
+
metadata[key] = value;
|
|
438
|
+
// Update via proxy to persist
|
|
439
|
+
await coreServices.workflows?.updateMetadata?.(instanceId, key, value);
|
|
440
|
+
},
|
|
441
|
+
getMetadata: <T = any>(key: string): T | undefined => {
|
|
442
|
+
return metadata[key] as T | undefined;
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ============================================
|
|
448
|
+
// Helpers
|
|
449
|
+
// ============================================
|
|
450
|
+
|
|
451
|
+
function sendEvent(socket: Socket, event: WorkflowEvent): void {
|
|
452
|
+
socket.write(JSON.stringify(event) + "\n");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isWorkflowDefinition(obj: any): obj is WorkflowDefinition {
|
|
456
|
+
return (
|
|
457
|
+
obj &&
|
|
458
|
+
typeof obj === "object" &&
|
|
459
|
+
typeof obj.name === "string" &&
|
|
460
|
+
obj.steps instanceof Map &&
|
|
461
|
+
typeof obj.startAt === "string"
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Run main
|
|
466
|
+
main().catch((err) => {
|
|
467
|
+
console.error("[WorkflowExecutor] Fatal error:", err);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|