@donkeylabs/server 2.0.27 → 2.0.29
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/jobs.md +20 -0
- package/docs/processes.md +27 -0
- package/docs/workflows.md +69 -0
- package/package.json +1 -1
- package/src/admin/routes.ts +24 -0
- package/src/core/external-job-socket.ts +20 -1
- package/src/core/external-jobs.ts +4 -0
- package/src/core/index.ts +4 -0
- package/src/core/jobs.ts +96 -50
- package/src/core/process-socket.ts +20 -1
- package/src/core/processes.ts +95 -4
- package/src/core/subprocess-bootstrap.ts +14 -1
- package/src/core/workflow-adapter-kysely.ts +5 -0
- package/src/core/workflow-executor.ts +28 -0
- package/src/core/workflow-socket.ts +28 -2
- package/src/core/workflow-state-machine.ts +147 -2
- package/src/core/workflows.ts +260 -13
package/src/core/workflows.ts
CHANGED
|
@@ -59,7 +59,7 @@ type InferZodOutput<T extends ZodSchema> = z.infer<T>;
|
|
|
59
59
|
// Step Types
|
|
60
60
|
// ============================================
|
|
61
61
|
|
|
62
|
-
export type StepType = "task" | "parallel" | "choice" | "pass";
|
|
62
|
+
export type StepType = "task" | "parallel" | "choice" | "pass" | "poll" | "loop";
|
|
63
63
|
|
|
64
64
|
export interface BaseStepDefinition {
|
|
65
65
|
name: string;
|
|
@@ -146,11 +146,54 @@ export interface PassStepDefinition extends BaseStepDefinition {
|
|
|
146
146
|
result?: any;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
export interface PollStepResult<T = any> {
|
|
150
|
+
done: boolean;
|
|
151
|
+
result?: T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface PollStepDefinition<
|
|
155
|
+
TInput extends ZodSchema = ZodSchema,
|
|
156
|
+
TOutput extends ZodSchema = ZodSchema,
|
|
157
|
+
> extends BaseStepDefinition {
|
|
158
|
+
type: "poll";
|
|
159
|
+
/** Wait duration between checks in ms */
|
|
160
|
+
interval: number;
|
|
161
|
+
/** Max total time before failing this step (ms) */
|
|
162
|
+
timeout?: number;
|
|
163
|
+
/** Max number of check cycles before failing */
|
|
164
|
+
maxAttempts?: number;
|
|
165
|
+
/** Input schema or mapper */
|
|
166
|
+
inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
|
|
167
|
+
/** Output schema for the final result */
|
|
168
|
+
outputSchema?: TOutput;
|
|
169
|
+
/** Check handler: return done:true to proceed */
|
|
170
|
+
check: (
|
|
171
|
+
input: InferZodOutput<TInput>,
|
|
172
|
+
ctx: WorkflowContext
|
|
173
|
+
) => Promise<PollStepResult<InferZodOutput<TOutput>>> | PollStepResult<InferZodOutput<TOutput>>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface LoopStepDefinition extends BaseStepDefinition {
|
|
177
|
+
type: "loop";
|
|
178
|
+
/** Condition to continue looping */
|
|
179
|
+
condition: (ctx: WorkflowContext) => boolean;
|
|
180
|
+
/** Step name to jump back to when condition is true */
|
|
181
|
+
target: string;
|
|
182
|
+
/** Optional delay before looping (ms) */
|
|
183
|
+
interval?: number;
|
|
184
|
+
/** Max total time before failing this loop (ms) */
|
|
185
|
+
timeout?: number;
|
|
186
|
+
/** Max number of loop iterations before failing */
|
|
187
|
+
maxIterations?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
149
190
|
export type StepDefinition =
|
|
150
191
|
| TaskStepDefinition
|
|
151
192
|
| ParallelStepDefinition
|
|
152
193
|
| ChoiceStepDefinition
|
|
153
|
-
| PassStepDefinition
|
|
194
|
+
| PassStepDefinition
|
|
195
|
+
| PollStepDefinition
|
|
196
|
+
| LoopStepDefinition;
|
|
154
197
|
|
|
155
198
|
// ============================================
|
|
156
199
|
// Workflow Definition
|
|
@@ -203,6 +246,11 @@ export interface StepResult {
|
|
|
203
246
|
startedAt?: Date;
|
|
204
247
|
completedAt?: Date;
|
|
205
248
|
attempts: number;
|
|
249
|
+
pollCount?: number;
|
|
250
|
+
lastPolledAt?: Date;
|
|
251
|
+
loopCount?: number;
|
|
252
|
+
lastLoopedAt?: Date;
|
|
253
|
+
loopStartedAt?: Date;
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
export interface WorkflowInstance {
|
|
@@ -569,6 +617,69 @@ export class WorkflowBuilder {
|
|
|
569
617
|
return this;
|
|
570
618
|
}
|
|
571
619
|
|
|
620
|
+
loop(
|
|
621
|
+
name: string,
|
|
622
|
+
config: {
|
|
623
|
+
condition: (ctx: WorkflowContext) => boolean;
|
|
624
|
+
target: string;
|
|
625
|
+
interval?: number;
|
|
626
|
+
timeout?: number;
|
|
627
|
+
maxIterations?: number;
|
|
628
|
+
next?: string;
|
|
629
|
+
end?: boolean;
|
|
630
|
+
}
|
|
631
|
+
): this {
|
|
632
|
+
const step: LoopStepDefinition = {
|
|
633
|
+
name,
|
|
634
|
+
type: "loop",
|
|
635
|
+
condition: config.condition,
|
|
636
|
+
target: config.target,
|
|
637
|
+
interval: config.interval,
|
|
638
|
+
timeout: config.timeout,
|
|
639
|
+
maxIterations: config.maxIterations,
|
|
640
|
+
next: config.next,
|
|
641
|
+
end: config.end,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
this.addStep(step);
|
|
645
|
+
return this;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
poll<TInput extends ZodSchema = ZodSchema, TOutput extends ZodSchema = ZodSchema>(
|
|
649
|
+
name: string,
|
|
650
|
+
config: {
|
|
651
|
+
check: (
|
|
652
|
+
input: InferZodOutput<TInput>,
|
|
653
|
+
ctx: WorkflowContext
|
|
654
|
+
) => Promise<PollStepResult<InferZodOutput<TOutput>>> | PollStepResult<InferZodOutput<TOutput>>;
|
|
655
|
+
interval: number;
|
|
656
|
+
timeout?: number;
|
|
657
|
+
maxAttempts?: number;
|
|
658
|
+
inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
|
|
659
|
+
outputSchema?: TOutput;
|
|
660
|
+
retry?: RetryConfig;
|
|
661
|
+
next?: string;
|
|
662
|
+
end?: boolean;
|
|
663
|
+
}
|
|
664
|
+
): this {
|
|
665
|
+
const step: PollStepDefinition<TInput, TOutput> = {
|
|
666
|
+
name,
|
|
667
|
+
type: "poll",
|
|
668
|
+
check: config.check,
|
|
669
|
+
interval: config.interval,
|
|
670
|
+
timeout: config.timeout,
|
|
671
|
+
maxAttempts: config.maxAttempts,
|
|
672
|
+
inputSchema: config.inputSchema,
|
|
673
|
+
outputSchema: config.outputSchema,
|
|
674
|
+
retry: config.retry,
|
|
675
|
+
next: config.next,
|
|
676
|
+
end: config.end,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
this.addStep(step);
|
|
680
|
+
return this;
|
|
681
|
+
}
|
|
682
|
+
|
|
572
683
|
/** Add an end step (shorthand for pass with end: true) */
|
|
573
684
|
end(name: string = "end"): this {
|
|
574
685
|
return this.pass(name, { end: true });
|
|
@@ -649,12 +760,22 @@ export interface WorkflowsConfig {
|
|
|
649
760
|
heartbeatTimeout?: number;
|
|
650
761
|
/** Timeout waiting for isolated subprocess readiness (ms, default: 10000) */
|
|
651
762
|
readyTimeout?: number;
|
|
763
|
+
/** Grace period before SIGKILL when terminating isolated subprocesses (ms, default: 5000) */
|
|
764
|
+
killGraceMs?: number;
|
|
765
|
+
/** SQLite pragmas for isolated subprocess connections */
|
|
766
|
+
sqlitePragmas?: SqlitePragmaConfig;
|
|
652
767
|
/** Resume strategy for orphaned workflows (default: "blocking") */
|
|
653
768
|
resumeStrategy?: WorkflowResumeStrategy;
|
|
654
769
|
}
|
|
655
770
|
|
|
656
771
|
export type WorkflowResumeStrategy = "blocking" | "background" | "skip";
|
|
657
772
|
|
|
773
|
+
export interface SqlitePragmaConfig {
|
|
774
|
+
busyTimeout?: number;
|
|
775
|
+
synchronous?: "OFF" | "NORMAL" | "FULL" | "EXTRA";
|
|
776
|
+
journalMode?: "DELETE" | "TRUNCATE" | "PERSIST" | "MEMORY" | "WAL" | "OFF";
|
|
777
|
+
}
|
|
778
|
+
|
|
658
779
|
/** Options for registering a workflow */
|
|
659
780
|
export interface WorkflowRegisterOptions {
|
|
660
781
|
/**
|
|
@@ -743,6 +864,8 @@ class WorkflowsImpl implements Workflows {
|
|
|
743
864
|
private dbPath?: string;
|
|
744
865
|
private heartbeatTimeoutMs: number;
|
|
745
866
|
private readyTimeoutMs: number;
|
|
867
|
+
private killGraceMs: number;
|
|
868
|
+
private sqlitePragmas?: SqlitePragmaConfig;
|
|
746
869
|
private resumeStrategy!: WorkflowResumeStrategy;
|
|
747
870
|
private workflowModulePaths = new Map<string, string>();
|
|
748
871
|
private isolatedProcesses = new Map<string, IsolatedProcessInfo>();
|
|
@@ -777,6 +900,8 @@ class WorkflowsImpl implements Workflows {
|
|
|
777
900
|
this.dbPath = config.dbPath;
|
|
778
901
|
this.heartbeatTimeoutMs = config.heartbeatTimeout ?? 60000;
|
|
779
902
|
this.readyTimeoutMs = config.readyTimeout ?? 10000;
|
|
903
|
+
this.killGraceMs = config.killGraceMs ?? 5000;
|
|
904
|
+
this.sqlitePragmas = config.sqlitePragmas;
|
|
780
905
|
this.resumeStrategy = config.resumeStrategy ?? "blocking";
|
|
781
906
|
}
|
|
782
907
|
|
|
@@ -938,11 +1063,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
938
1063
|
// Kill isolated process if running
|
|
939
1064
|
const isolatedInfo = this.isolatedProcesses.get(instanceId);
|
|
940
1065
|
if (isolatedInfo) {
|
|
941
|
-
|
|
942
|
-
process.kill(isolatedInfo.pid, "SIGTERM");
|
|
943
|
-
} catch {
|
|
944
|
-
// Process might already be dead
|
|
945
|
-
}
|
|
1066
|
+
await killProcessWithGrace(isolatedInfo.pid, this.killGraceMs);
|
|
946
1067
|
if (isolatedInfo.timeout) clearTimeout(isolatedInfo.timeout);
|
|
947
1068
|
if (isolatedInfo.heartbeatTimeout) clearTimeout(isolatedInfo.heartbeatTimeout);
|
|
948
1069
|
this.isolatedProcesses.delete(instanceId);
|
|
@@ -1198,6 +1319,51 @@ class WorkflowsImpl implements Workflows {
|
|
|
1198
1319
|
});
|
|
1199
1320
|
}
|
|
1200
1321
|
},
|
|
1322
|
+
onStepPoll: (id, stepName, pollCount, done, result) => {
|
|
1323
|
+
this.emitEvent("workflow.step.poll", {
|
|
1324
|
+
instanceId: id,
|
|
1325
|
+
stepName,
|
|
1326
|
+
pollCount,
|
|
1327
|
+
done,
|
|
1328
|
+
result,
|
|
1329
|
+
});
|
|
1330
|
+
if (this.sse) {
|
|
1331
|
+
this.sse.broadcast(`workflow:${id}`, "step.poll", {
|
|
1332
|
+
stepName,
|
|
1333
|
+
pollCount,
|
|
1334
|
+
done,
|
|
1335
|
+
result,
|
|
1336
|
+
});
|
|
1337
|
+
this.sse.broadcast("workflows:all", "workflow.step.poll", {
|
|
1338
|
+
instanceId: id,
|
|
1339
|
+
stepName,
|
|
1340
|
+
pollCount,
|
|
1341
|
+
done,
|
|
1342
|
+
result,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
onStepLoop: (id, stepName, loopCount, target) => {
|
|
1347
|
+
this.emitEvent("workflow.step.loop", {
|
|
1348
|
+
instanceId: id,
|
|
1349
|
+
stepName,
|
|
1350
|
+
loopCount,
|
|
1351
|
+
target,
|
|
1352
|
+
});
|
|
1353
|
+
if (this.sse) {
|
|
1354
|
+
this.sse.broadcast(`workflow:${id}`, "step.loop", {
|
|
1355
|
+
stepName,
|
|
1356
|
+
loopCount,
|
|
1357
|
+
target,
|
|
1358
|
+
});
|
|
1359
|
+
this.sse.broadcast("workflows:all", "workflow.step.loop", {
|
|
1360
|
+
instanceId: id,
|
|
1361
|
+
stepName,
|
|
1362
|
+
loopCount,
|
|
1363
|
+
target,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1201
1367
|
onStepRetry: (id, stepName, attempt, max, delayMs) => {
|
|
1202
1368
|
this.emitEvent("workflow.step.retry", {
|
|
1203
1369
|
instanceId: id,
|
|
@@ -1314,6 +1480,7 @@ class WorkflowsImpl implements Workflows {
|
|
|
1314
1480
|
pluginModulePaths: this.pluginModulePaths,
|
|
1315
1481
|
pluginConfigs,
|
|
1316
1482
|
coreConfig,
|
|
1483
|
+
sqlitePragmas: this.sqlitePragmas,
|
|
1317
1484
|
};
|
|
1318
1485
|
|
|
1319
1486
|
// Spawn the subprocess
|
|
@@ -1484,6 +1651,55 @@ class WorkflowsImpl implements Workflows {
|
|
|
1484
1651
|
break;
|
|
1485
1652
|
}
|
|
1486
1653
|
|
|
1654
|
+
case "step.poll": {
|
|
1655
|
+
await this.emitEvent("workflow.step.poll", {
|
|
1656
|
+
instanceId,
|
|
1657
|
+
stepName: event.stepName,
|
|
1658
|
+
pollCount: event.pollCount,
|
|
1659
|
+
done: event.done,
|
|
1660
|
+
result: event.result,
|
|
1661
|
+
});
|
|
1662
|
+
if (this.sse) {
|
|
1663
|
+
this.sse.broadcast(`workflow:${instanceId}`, "step.poll", {
|
|
1664
|
+
stepName: event.stepName,
|
|
1665
|
+
pollCount: event.pollCount,
|
|
1666
|
+
done: event.done,
|
|
1667
|
+
result: event.result,
|
|
1668
|
+
});
|
|
1669
|
+
this.sse.broadcast("workflows:all", "workflow.step.poll", {
|
|
1670
|
+
instanceId,
|
|
1671
|
+
stepName: event.stepName,
|
|
1672
|
+
pollCount: event.pollCount,
|
|
1673
|
+
done: event.done,
|
|
1674
|
+
result: event.result,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
case "step.loop": {
|
|
1681
|
+
await this.emitEvent("workflow.step.loop", {
|
|
1682
|
+
instanceId,
|
|
1683
|
+
stepName: event.stepName,
|
|
1684
|
+
loopCount: event.loopCount,
|
|
1685
|
+
target: event.target,
|
|
1686
|
+
});
|
|
1687
|
+
if (this.sse) {
|
|
1688
|
+
this.sse.broadcast(`workflow:${instanceId}`, "step.loop", {
|
|
1689
|
+
stepName: event.stepName,
|
|
1690
|
+
loopCount: event.loopCount,
|
|
1691
|
+
target: event.target,
|
|
1692
|
+
});
|
|
1693
|
+
this.sse.broadcast("workflows:all", "workflow.step.loop", {
|
|
1694
|
+
instanceId,
|
|
1695
|
+
stepName: event.stepName,
|
|
1696
|
+
loopCount: event.loopCount,
|
|
1697
|
+
target: event.target,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1487
1703
|
case "progress": {
|
|
1488
1704
|
await this.emitEvent("workflow.progress", {
|
|
1489
1705
|
instanceId,
|
|
@@ -1790,6 +2006,11 @@ class WorkflowsImpl implements Workflows {
|
|
|
1790
2006
|
}
|
|
1791
2007
|
|
|
1792
2008
|
console.error(`[Workflows] No heartbeat from isolated workflow ${instanceId} for ${this.heartbeatTimeoutMs}ms`);
|
|
2009
|
+
await this.emitEvent("workflow.watchdog.stale", {
|
|
2010
|
+
instanceId,
|
|
2011
|
+
reason: "heartbeat",
|
|
2012
|
+
timeoutMs: this.heartbeatTimeoutMs,
|
|
2013
|
+
});
|
|
1793
2014
|
await this.handleIsolatedTimeout(instanceId, pid);
|
|
1794
2015
|
}, this.heartbeatTimeoutMs);
|
|
1795
2016
|
}
|
|
@@ -1801,12 +2022,12 @@ class WorkflowsImpl implements Workflows {
|
|
|
1801
2022
|
const info = this.isolatedProcesses.get(instanceId);
|
|
1802
2023
|
if (!info) return;
|
|
1803
2024
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
}
|
|
2025
|
+
await killProcessWithGrace(pid, this.killGraceMs);
|
|
2026
|
+
await this.emitEvent("workflow.watchdog.killed", {
|
|
2027
|
+
instanceId,
|
|
2028
|
+
reason: "timeout",
|
|
2029
|
+
timeoutMs: this.heartbeatTimeoutMs,
|
|
2030
|
+
});
|
|
1810
2031
|
|
|
1811
2032
|
// Clean up
|
|
1812
2033
|
if (info.timeout) clearTimeout(info.timeout);
|
|
@@ -1943,3 +2164,29 @@ function isPlainObject(value: Record<string, any>): boolean {
|
|
|
1943
2164
|
export function createWorkflows(config?: WorkflowsConfig): Workflows {
|
|
1944
2165
|
return new WorkflowsImpl(config);
|
|
1945
2166
|
}
|
|
2167
|
+
|
|
2168
|
+
async function killProcessWithGrace(pid: number, graceMs: number): Promise<void> {
|
|
2169
|
+
try {
|
|
2170
|
+
process.kill(pid, "SIGTERM");
|
|
2171
|
+
} catch {
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (graceMs <= 0) {
|
|
2176
|
+
try {
|
|
2177
|
+
process.kill(pid, "SIGKILL");
|
|
2178
|
+
} catch {
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
2185
|
+
|
|
2186
|
+
try {
|
|
2187
|
+
process.kill(pid, 0);
|
|
2188
|
+
process.kill(pid, "SIGKILL");
|
|
2189
|
+
} catch {
|
|
2190
|
+
// Process already exited
|
|
2191
|
+
}
|
|
2192
|
+
}
|