@anteros/core 0.0.40 → 0.0.42
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/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
package/database/workflow.ts
CHANGED
|
@@ -3,15 +3,37 @@ import { getTenant } from "./tenant";
|
|
|
3
3
|
import { AppError, fn } from "../lib/error";
|
|
4
4
|
import * as func from "../utils/func";
|
|
5
5
|
import { useRest } from "./rest";
|
|
6
|
-
import {
|
|
6
|
+
import { createAgents } from "../lib/agents";
|
|
7
|
+
import { createApi } from "../lib/api";
|
|
8
|
+
import { getWorkflow, validateWorkflowContext } from "../lib/workflow";
|
|
9
|
+
import { logger } from "../utils/logger";
|
|
7
10
|
import crypto from "crypto";
|
|
8
|
-
import
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import type { WorkflowDefinition, WorkflowRun, WorkflowRunStep, WorkflowRunStatus, WorkflowStep } from "../types/workflow";
|
|
13
|
+
|
|
14
|
+
/** Distributed lock TTL — refreshed before every step, released on the way out */
|
|
15
|
+
const LOCK_TTL = 15 * 60_000;
|
|
16
|
+
/** Default delay before a retry, doubled on each attempt */
|
|
17
|
+
const DEFAULT_BACKOFF = 250;
|
|
18
|
+
const MAX_BACKOFF = 5_000;
|
|
19
|
+
|
|
20
|
+
/** Statuses a run can no longer be moved out of */
|
|
21
|
+
const TERMINAL: WorkflowRunStatus[] = ['completed', 'compensated', 'cancelled'];
|
|
22
|
+
|
|
23
|
+
function sleep(ms: number): Promise<void> {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
}
|
|
9
26
|
|
|
10
27
|
class Workflow {
|
|
11
28
|
#tenant_id: string;
|
|
29
|
+
// The client handed to step handlers: a real `rest`, so a step can read and
|
|
30
|
+
// write with the whole public API (`rest.find`, `rest.insertOne`,
|
|
31
|
+
// `rest.workflow.run` for a sub-workflow, `rest.vars`, …)
|
|
32
|
+
#rest: InstanceType<typeof useRest>;
|
|
12
33
|
|
|
13
34
|
constructor(tenant_id: string) {
|
|
14
35
|
this.#tenant_id = tenant_id;
|
|
36
|
+
this.#rest = new useRest({ tenant_id, internal: true });
|
|
15
37
|
}
|
|
16
38
|
|
|
17
39
|
private async getCollection() {
|
|
@@ -21,147 +43,269 @@ class Workflow {
|
|
|
21
43
|
return db.collection('_workflows_');
|
|
22
44
|
}
|
|
23
45
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
* Executes each step sequentially and tracks progress.
|
|
27
|
-
*/
|
|
28
|
-
async run(workflowId: string, data: any, context?: any): Promise<WorkflowRun> {
|
|
46
|
+
/** Resolve a definition, refusing to replay a run started by another version */
|
|
47
|
+
private definition(workflowId: string, runVersion?: number, force = false): WorkflowDefinition {
|
|
29
48
|
const wf = getWorkflow(workflowId, this.#tenant_id);
|
|
30
49
|
if (!wf) throw new AppError(`Workflow '${workflowId}' not found`, { code: 'WORKFLOW_NOT_FOUND', status: 404 });
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
if (!force && runVersion !== undefined && wf.version !== undefined && wf.version !== runVersion) {
|
|
52
|
+
throw new AppError(
|
|
53
|
+
`Workflow '${workflowId}' is now in version ${wf.version}, this run started in version ${runVersion} — resume with { force: true } to continue anyway`,
|
|
54
|
+
{ code: 'WORKFLOW_VERSION_MISMATCH', status: 409 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return wf;
|
|
58
|
+
}
|
|
34
59
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
name: s.name,
|
|
50
|
-
status: 'pending' as WorkflowRunStatus,
|
|
51
|
-
startedAt: now,
|
|
52
|
-
})),
|
|
53
|
-
compensations: [],
|
|
54
|
-
createdAt: now,
|
|
55
|
-
updatedAt: now,
|
|
56
|
-
};
|
|
60
|
+
/**
|
|
61
|
+
* Distributed lock: only one node may advance a given run. A node that dies
|
|
62
|
+
* releases it automatically when the TTL expires.
|
|
63
|
+
*/
|
|
64
|
+
private async acquireLock(runId: string): Promise<void> {
|
|
65
|
+
try {
|
|
66
|
+
await this.#rest.lock(`workflow:${runId}`, LOCK_TTL);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new AppError(`Workflow run '${runId}' is already being processed by another node`, {
|
|
69
|
+
code: 'RUN_LOCKED',
|
|
70
|
+
status: 409,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
/** Keep the lock alive while the run progresses — fails if we lost ownership */
|
|
76
|
+
private async touchLock(runId: string): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
const res = await this.#rest.db.collection('_locks_').updateOne(
|
|
79
|
+
{ _id: `${this.#tenant_id}:workflow:${runId}` as any, pid: process.pid },
|
|
80
|
+
{ $set: { expiresAt: Date.now() + LOCK_TTL } },
|
|
81
|
+
);
|
|
82
|
+
return (res.matchedCount ?? 0) > 0;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
59
87
|
|
|
60
|
-
|
|
88
|
+
private async releaseLock(runId: string): Promise<void> {
|
|
61
89
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
await this.#rest.unlock(`workflow:${runId}`);
|
|
91
|
+
} catch { /* best effort */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Persist the run.
|
|
96
|
+
*
|
|
97
|
+
* `guard: true` (used for every progress write) refuses to overwrite a run
|
|
98
|
+
* that was **paused or cancelled** while a step was running — a compare-and-set
|
|
99
|
+
* that makes `pause()` / `cancel()` effective on an in-flight run. Returns
|
|
100
|
+
* `false` when the write was refused, so the caller stops instead of
|
|
101
|
+
* resurrecting the status.
|
|
102
|
+
*/
|
|
103
|
+
private async saveRun(run: WorkflowRun, opts: { guard?: boolean } = {}): Promise<boolean> {
|
|
104
|
+
const col = await this.getCollection();
|
|
105
|
+
if (!opts.guard) {
|
|
106
|
+
await col.replaceOne({ _id: run._id as any }, run as any, { upsert: true });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
const res: any = await col.replaceOne(
|
|
110
|
+
{ _id: run._id as any, status: { $nin: ['paused', 'cancelled'] } },
|
|
111
|
+
run as any,
|
|
112
|
+
);
|
|
113
|
+
return (res?.matchedCount ?? 0) > 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The current DB status, when the run was paused/cancelled from outside */
|
|
117
|
+
private async interruption(run: WorkflowRun): Promise<WorkflowRun | null> {
|
|
118
|
+
const current: any = await this.getCollection().then((col) => col.findOne({ _id: run._id as any }));
|
|
119
|
+
if (!current) return run;
|
|
120
|
+
if (current.status === 'paused' || current.status === 'cancelled') {
|
|
121
|
+
run.status = current.status;
|
|
122
|
+
run.progress = current.progress ?? run.progress;
|
|
123
|
+
return run;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persist a **step outcome** without touching the run status: a pause or a
|
|
130
|
+
* cancel requested while the step was running must survive it. The step's
|
|
131
|
+
* side effects happened — recording it is what prevents a later resume from
|
|
132
|
+
* replaying them.
|
|
133
|
+
*/
|
|
134
|
+
private async recordStep(run: WorkflowRun, index: number, opts: { status?: WorkflowRunStatus } = {}): Promise<void> {
|
|
135
|
+
const col = await this.getCollection();
|
|
136
|
+
await col.updateOne(
|
|
137
|
+
{ _id: run._id as any },
|
|
138
|
+
{
|
|
139
|
+
$set: {
|
|
140
|
+
[`steps.${index}`]: run.steps[index],
|
|
141
|
+
totalExecuted: run.totalExecuted,
|
|
142
|
+
totalSkipped: run.totalSkipped,
|
|
143
|
+
progress: run.progress,
|
|
144
|
+
currentStep: run.currentStep,
|
|
145
|
+
updatedAt: new Date(),
|
|
146
|
+
...(opts.status ? { status: opts.status } : {}),
|
|
147
|
+
...(opts.status === 'failed' ? { error: run.error } : {}),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Take ownership of the run: mark it `running` in the database **before** the
|
|
155
|
+
* step loop starts, so the pause/cancel checks don't see the previous `paused`
|
|
156
|
+
* status as an interruption. A compare-and-set — a cancel that arrives first wins.
|
|
157
|
+
*/
|
|
158
|
+
private async claimRun(runId: string): Promise<boolean> {
|
|
159
|
+
const res = await this.getCollection().then((col) => col.updateOne(
|
|
160
|
+
{ _id: runId as any, status: { $nin: TERMINAL } },
|
|
161
|
+
{ $set: { status: 'running', pid: process.pid, hostname: os.hostname(), updatedAt: new Date() } },
|
|
162
|
+
));
|
|
163
|
+
return (res.matchedCount ?? 0) > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Abort a promise after `ms`, so a hanging step can never block a run forever */
|
|
167
|
+
private async withTimeout<T>(promise: Promise<T>, ms: number, stepId: string, abort?: AbortController): Promise<T> {
|
|
168
|
+
let timer: any;
|
|
169
|
+
try {
|
|
170
|
+
return await Promise.race([
|
|
171
|
+
promise,
|
|
172
|
+
new Promise<T>((_, reject) => {
|
|
173
|
+
timer = setTimeout(() => {
|
|
174
|
+
abort?.abort();
|
|
175
|
+
reject(new AppError(`Step '${stepId}' timed out after ${ms}ms`, { code: 'WORKFLOW_STEP_TIMEOUT', status: 408 }));
|
|
176
|
+
}, ms);
|
|
177
|
+
}),
|
|
178
|
+
]);
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run one step (or the global `exec`) with its `retries` / `backoffMs` /
|
|
186
|
+
* `timeout`. Only the last error is thrown, so a transient failure never rolls
|
|
187
|
+
* the saga back when a retry succeeds.
|
|
188
|
+
*/
|
|
189
|
+
private async invoke<T>(handler: (ctx: any) => Promise<T>, ctx: Record<string, any>, stepId: string): Promise<T> {
|
|
190
|
+
const attempts = 1 + Math.max(0, ctx.__retries ?? 0);
|
|
191
|
+
const backoff = ctx.__backoffMs ?? DEFAULT_BACKOFF;
|
|
192
|
+
const timeout = ctx.__timeout ?? null;
|
|
193
|
+
let lastError: any;
|
|
194
|
+
|
|
195
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
196
|
+
const abort = timeout ? new AbortController() : undefined;
|
|
197
|
+
try {
|
|
198
|
+
const call = () => handler({ ...ctx, signal: abort?.signal });
|
|
199
|
+
if (!timeout) return await call();
|
|
200
|
+
return await this.withTimeout(call(), timeout, stepId, abort);
|
|
201
|
+
} catch (err: any) {
|
|
202
|
+
lastError = err;
|
|
203
|
+
if (attempt < attempts) await sleep(Math.min(backoff * 2 ** (attempt - 1), MAX_BACKOFF));
|
|
64
204
|
}
|
|
65
|
-
} catch (err: any) {
|
|
66
|
-
run.status = 'failed';
|
|
67
|
-
run.error = { message: err.message || 'Workflow execution failed', stepId: '__global__' };
|
|
68
|
-
run.updatedAt = new Date();
|
|
69
|
-
await this.saveRun(run);
|
|
70
|
-
throw err;
|
|
71
205
|
}
|
|
206
|
+
throw lastError;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Ctx of a step handler — `rest` is the tenant client, not the engine */
|
|
210
|
+
private stepCtx(data: any, prevOutput: any, step: WorkflowStep): Record<string, any> {
|
|
211
|
+
return {
|
|
212
|
+
data,
|
|
213
|
+
prevOutput,
|
|
214
|
+
input: step.input,
|
|
215
|
+
rest: this.#rest,
|
|
216
|
+
// Tenant-scoped LLM registry, bound to the same `rest` as the step
|
|
217
|
+
agents: createAgents(this.#tenant_id, this.#rest),
|
|
218
|
+
api: createApi(this.#rest),
|
|
219
|
+
error: fn.error,
|
|
220
|
+
jwt: func.jwt,
|
|
221
|
+
__retries: step.retries,
|
|
222
|
+
__backoffMs: step.backoffMs,
|
|
223
|
+
__timeout: step.timeout === undefined ? null : func.parseDuration(step.timeout),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
72
226
|
|
|
73
|
-
|
|
74
|
-
|
|
227
|
+
/** Advance the run from `startIndex`, one step at a time */
|
|
228
|
+
private async executeSteps(run: WorkflowRun, wf: WorkflowDefinition, startIndex: number): Promise<WorkflowRun> {
|
|
229
|
+
for (let i = startIndex; i < wf.steps.length; i++) {
|
|
75
230
|
const stepDef = wf.steps[i]!;
|
|
231
|
+
|
|
232
|
+
// Paused or cancelled while the previous step was running?
|
|
233
|
+
const interrupted = await this.interruption(run);
|
|
234
|
+
if (interrupted) return interrupted;
|
|
235
|
+
|
|
236
|
+
// Another node took the run over (our process was considered gone): stop
|
|
237
|
+
// without writing anything, it owns the run now
|
|
238
|
+
if (!(await this.touchLock(run._id))) {
|
|
239
|
+
logger.file('warn', 'workflow: lost the run lock', { tenant: this.#tenant_id, run: run._id, pid: process.pid });
|
|
240
|
+
return (await this.getRun(run._id)) ?? run;
|
|
241
|
+
}
|
|
76
242
|
run.currentStep = i;
|
|
243
|
+
|
|
77
244
|
const stepRun: WorkflowRunStep = {
|
|
78
245
|
stepId: stepDef.id,
|
|
79
246
|
name: stepDef.name,
|
|
80
247
|
status: 'running',
|
|
81
|
-
input: data,
|
|
248
|
+
input: run.data,
|
|
82
249
|
startedAt: new Date(),
|
|
83
250
|
};
|
|
84
|
-
|
|
85
251
|
run.steps[i] = stepRun;
|
|
86
252
|
run.updatedAt = new Date();
|
|
87
|
-
await this.saveRun(run);
|
|
253
|
+
if (!(await this.saveRun(run, { guard: true }))) return (await this.interruption(run)) ?? run;
|
|
88
254
|
|
|
89
255
|
try {
|
|
90
256
|
const prevOutput = i > 0 ? run.steps[i - 1]?.output ?? null : null;
|
|
91
257
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await this.saveRun(run);
|
|
103
|
-
continue; // saute ce step
|
|
104
|
-
}
|
|
258
|
+
if (stepDef.condition && !(await stepDef.condition({ data: run.data, prevOutput }))) {
|
|
259
|
+
stepRun.status = 'skipped';
|
|
260
|
+
stepRun.completedAt = new Date();
|
|
261
|
+
run.steps[i] = stepRun;
|
|
262
|
+
run.totalSkipped = run.steps.filter(s => s.status === 'skipped').length;
|
|
263
|
+
run.progress = Math.round(((i + 1) / wf.steps.length) * 100);
|
|
264
|
+
await this.recordStep(run, i);
|
|
265
|
+
const stopped = await this.interruption(run);
|
|
266
|
+
if (stopped) return stopped;
|
|
267
|
+
continue;
|
|
105
268
|
}
|
|
106
269
|
|
|
107
|
-
const result = await
|
|
270
|
+
const result = await this.invoke(
|
|
271
|
+
(ctx) => stepDef.exec(ctx as any),
|
|
272
|
+
this.stepCtx(run.data, prevOutput, stepDef),
|
|
273
|
+
stepDef.id,
|
|
274
|
+
);
|
|
108
275
|
stepRun.status = 'completed';
|
|
109
276
|
stepRun.output = result;
|
|
110
277
|
stepRun.completedAt = new Date();
|
|
111
278
|
run.steps[i] = stepRun;
|
|
112
279
|
run.totalExecuted = run.steps.filter(s => s.status === 'completed').length;
|
|
113
280
|
run.progress = Math.round(((i + 1) / wf.steps.length) * 100);
|
|
281
|
+
// Recorded immediately (without the run status): a pause/cancel/crash after
|
|
282
|
+
// this step must find it `completed`, never replay its side effects
|
|
283
|
+
await this.recordStep(run, i);
|
|
284
|
+
const stopped = await this.interruption(run);
|
|
285
|
+
if (stopped) return stopped;
|
|
114
286
|
} catch (err: any) {
|
|
115
287
|
stepRun.status = 'failed';
|
|
116
288
|
stepRun.error = { message: err.message || 'Step failed', code: err.code };
|
|
117
289
|
stepRun.completedAt = new Date();
|
|
118
290
|
run.steps[i] = stepRun;
|
|
119
291
|
run.totalExecuted = run.steps.filter(s => s.status === 'completed').length;
|
|
120
|
-
run.status = 'failed';
|
|
121
292
|
run.error = { message: err.message || 'Workflow failed', code: err.code, stepId: stepDef.id };
|
|
122
|
-
|
|
123
|
-
await this.
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
for (const completedStep of stepsToCompensate) {
|
|
131
|
-
const compDef = wf.compensations?.find(c => c.depend?.includes(completedStep.stepId) || c.id === completedStep.stepId);
|
|
132
|
-
if (!compDef?.exec) continue;
|
|
133
|
-
try {
|
|
134
|
-
await compDef.exec({
|
|
135
|
-
data: run.data,
|
|
136
|
-
prevOutput: completedStep.output,
|
|
137
|
-
input: undefined,
|
|
138
|
-
rest: this,
|
|
139
|
-
error: fn.error,
|
|
140
|
-
jwt: func.jwt,
|
|
141
|
-
});
|
|
142
|
-
run.compensations.push({
|
|
143
|
-
stepId: compDef.id,
|
|
144
|
-
name: compDef.name,
|
|
145
|
-
status: 'completed',
|
|
146
|
-
output: null,
|
|
147
|
-
startedAt: new Date(),
|
|
148
|
-
completedAt: new Date(),
|
|
149
|
-
});
|
|
150
|
-
} catch (compErr: any) {
|
|
151
|
-
run.compensations.push({
|
|
152
|
-
stepId: compDef.id,
|
|
153
|
-
name: compDef.name,
|
|
154
|
-
status: 'failed',
|
|
155
|
-
error: { message: compErr.message || 'Compensation failed' },
|
|
156
|
-
startedAt: new Date(),
|
|
157
|
-
completedAt: new Date(),
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
run.updatedAt = new Date();
|
|
162
|
-
await this.saveRun(run);
|
|
293
|
+
|
|
294
|
+
const stopped = await this.interruption(run);
|
|
295
|
+
|
|
296
|
+
// A **cancel** wins over the failure: the operator asked to stop and to
|
|
297
|
+
// leave the data alone, so nothing is compensated.
|
|
298
|
+
if (stopped?.status === 'cancelled') {
|
|
299
|
+
await this.recordStep(run, i);
|
|
300
|
+
return stopped;
|
|
163
301
|
}
|
|
164
302
|
|
|
303
|
+
// A failure is authoritative (a pause requested during the failing step is
|
|
304
|
+
// superseded): the saga is rolled back.
|
|
305
|
+
run.status = 'failed';
|
|
306
|
+
run.updatedAt = new Date();
|
|
307
|
+
await this.recordStep(run, i, { status: 'failed' });
|
|
308
|
+
await this.compensate(run, wf);
|
|
165
309
|
throw err;
|
|
166
310
|
}
|
|
167
311
|
}
|
|
@@ -170,135 +314,203 @@ class Workflow {
|
|
|
170
314
|
run.progress = 100;
|
|
171
315
|
run.completedAt = new Date();
|
|
172
316
|
run.updatedAt = new Date();
|
|
173
|
-
await this.saveRun(run);
|
|
317
|
+
if (!(await this.saveRun(run, { guard: true }))) return (await this.interruption(run)) ?? run;
|
|
174
318
|
return run;
|
|
175
319
|
}
|
|
176
320
|
|
|
177
321
|
/**
|
|
178
|
-
*
|
|
322
|
+
* Roll the saga back: compensate the completed steps **before the failure**,
|
|
323
|
+
* in reverse order.
|
|
324
|
+
*
|
|
325
|
+
* Two rules keep it safe to re-run:
|
|
326
|
+
* - a compensation that already succeeded is **never replayed**;
|
|
327
|
+
* - a compensation requested when the process dies is simply resumed later
|
|
328
|
+
* (`resume()` finishes the rollback instead of continuing forward), and a
|
|
329
|
+
* partially rolled back run stays `failed`.
|
|
179
330
|
*/
|
|
180
|
-
async
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
331
|
+
private async compensate(run: WorkflowRun, wf: WorkflowDefinition): Promise<void> {
|
|
332
|
+
const failedIndex = run.steps.findIndex(s => s.status === 'failed');
|
|
333
|
+
const completed = (failedIndex < 0 ? run.steps : run.steps.slice(0, failedIndex))
|
|
334
|
+
.filter(s => s.status === 'completed')
|
|
335
|
+
.reverse();
|
|
336
|
+
|
|
337
|
+
const done = new Set((run.compensations ?? []).filter(c => c.status === 'completed').map(c => c.stepId));
|
|
338
|
+
run.compensations = run.compensations ?? [];
|
|
339
|
+
|
|
340
|
+
for (const step of completed) {
|
|
341
|
+
const def = wf.compensations?.find(c => c.depend?.includes(step.stepId) || c.id === step.stepId);
|
|
342
|
+
if (!def?.exec || done.has(def.id)) continue;
|
|
343
|
+
|
|
344
|
+
const record: WorkflowRunStep = {
|
|
345
|
+
stepId: def.id,
|
|
346
|
+
name: def.name,
|
|
347
|
+
status: 'running',
|
|
348
|
+
input: undefined,
|
|
349
|
+
startedAt: new Date(),
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
await this.invoke(
|
|
353
|
+
(ctx) => def.exec!(ctx as any) as Promise<any>,
|
|
354
|
+
{
|
|
355
|
+
data: run.data,
|
|
356
|
+
stepOutput: step.output,
|
|
357
|
+
stepError: run.error ?? { message: 'Workflow failed' },
|
|
358
|
+
rest: this.#rest,
|
|
359
|
+
agents: createAgents(this.#tenant_id, this.#rest),
|
|
360
|
+
api: createApi(this.#rest),
|
|
361
|
+
error: fn.error,
|
|
362
|
+
jwt: func.jwt,
|
|
363
|
+
__retries: def.retries,
|
|
364
|
+
__backoffMs: def.backoffMs,
|
|
365
|
+
__timeout: def.timeout === undefined ? null : func.parseDuration(def.timeout),
|
|
366
|
+
},
|
|
367
|
+
def.id,
|
|
368
|
+
);
|
|
369
|
+
record.status = 'completed';
|
|
370
|
+
record.completedAt = new Date();
|
|
371
|
+
done.add(def.id);
|
|
372
|
+
} catch (compErr: any) {
|
|
373
|
+
record.status = 'failed';
|
|
374
|
+
record.error = { message: compErr.message || 'Compensation failed', code: compErr.code };
|
|
375
|
+
record.completedAt = new Date();
|
|
376
|
+
}
|
|
377
|
+
run.compensations.push(record);
|
|
378
|
+
run.updatedAt = new Date();
|
|
379
|
+
await this.saveRun(run, { guard: true });
|
|
380
|
+
}
|
|
184
381
|
|
|
185
|
-
|
|
186
|
-
|
|
382
|
+
// Fully rolled back? Every step that needed a compensation succeeded.
|
|
383
|
+
const needed = completed
|
|
384
|
+
.map(s => wf.compensations?.find(c => c.depend?.includes(s.stepId) || c.id === s.stepId))
|
|
385
|
+
.filter(def => !!def?.exec);
|
|
386
|
+
const allDone = needed.length > 0 && needed.every(def => done.has(def!.id));
|
|
187
387
|
|
|
188
|
-
run.status = '
|
|
189
|
-
run.data = data || run.data;
|
|
388
|
+
if (allDone) run.status = 'compensated';
|
|
190
389
|
run.updatedAt = new Date();
|
|
390
|
+
await this.saveRun(run, { guard: true });
|
|
391
|
+
}
|
|
191
392
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
await this.saveRun(run);
|
|
199
|
-
return run;
|
|
200
|
-
}
|
|
393
|
+
/**
|
|
394
|
+
* Execute a workflow: creates the run, takes the lock, runs the steps.
|
|
395
|
+
* Throws when a step fails (after the rollback attempt).
|
|
396
|
+
*/
|
|
397
|
+
async run(workflowId: string, data: any, context?: any): Promise<WorkflowRun> {
|
|
398
|
+
const wf = this.definition(workflowId);
|
|
201
399
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
stepId: stepDef.id,
|
|
207
|
-
name: stepDef.name,
|
|
208
|
-
status: 'running',
|
|
209
|
-
input: run.data,
|
|
210
|
-
startedAt: new Date(),
|
|
211
|
-
};
|
|
212
|
-
run.steps[i] = stepRun;
|
|
213
|
-
run.updatedAt = new Date();
|
|
214
|
-
await this.saveRun(run);
|
|
400
|
+
const runId = crypto.randomUUID();
|
|
401
|
+
const now = new Date();
|
|
402
|
+
// Validated (and coerced) against the workflow's `context` declaration
|
|
403
|
+
const safeContext = validateWorkflowContext(wf, context);
|
|
215
404
|
|
|
216
|
-
|
|
217
|
-
|
|
405
|
+
const run: WorkflowRun = {
|
|
406
|
+
_id: runId,
|
|
407
|
+
workflowId,
|
|
408
|
+
workflowVersion: wf.version,
|
|
409
|
+
tenant_id: this.#tenant_id,
|
|
410
|
+
status: 'running',
|
|
411
|
+
progress: 0,
|
|
412
|
+
data,
|
|
413
|
+
context: safeContext,
|
|
414
|
+
currentStep: 0,
|
|
415
|
+
totalExecuted: 0,
|
|
416
|
+
totalSkipped: 0,
|
|
417
|
+
steps: wf.steps.map(s => ({
|
|
418
|
+
stepId: s.id,
|
|
419
|
+
name: s.name,
|
|
420
|
+
status: 'pending' as WorkflowRunStatus,
|
|
421
|
+
startedAt: now,
|
|
422
|
+
})),
|
|
423
|
+
compensations: [],
|
|
424
|
+
// Which process executes this run — with Bun `reusePort` / forked workers,
|
|
425
|
+
// the run document says who owns it (the lock holds the same information)
|
|
426
|
+
pid: process.pid,
|
|
427
|
+
hostname: os.hostname(),
|
|
428
|
+
createdAt: now,
|
|
429
|
+
updatedAt: now,
|
|
430
|
+
};
|
|
218
431
|
|
|
219
|
-
|
|
220
|
-
if (stepDef.condition) {
|
|
221
|
-
const shouldRun = await stepDef.condition({ data: run.data, prevOutput });
|
|
222
|
-
if (!shouldRun) {
|
|
223
|
-
stepRun.status = 'skipped';
|
|
224
|
-
stepRun.completedAt = new Date();
|
|
225
|
-
run.steps[i] = stepRun;
|
|
226
|
-
run.totalSkipped = run.steps.filter(s => s.status === 'skipped').length;
|
|
227
|
-
run.progress = Math.round(((i + 1) / wf.steps.length) * 100);
|
|
228
|
-
run.updatedAt = new Date();
|
|
229
|
-
await this.saveRun(run);
|
|
230
|
-
continue; // saute ce step
|
|
231
|
-
}
|
|
232
|
-
}
|
|
432
|
+
await this.saveRun(run);
|
|
233
433
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
run.updatedAt = new Date();
|
|
250
|
-
await this.saveRun(run);
|
|
251
|
-
|
|
252
|
-
// Exécuter les compensations (steps réussis en ordre inverse)
|
|
253
|
-
if (wf.compensations?.length) {
|
|
254
|
-
const failedIndex = run.steps.findIndex(s => s.status === 'failed');
|
|
255
|
-
const stepsToCompensate = run.steps.slice(0, failedIndex).filter(s => s.status === 'completed').reverse();
|
|
256
|
-
run.compensations = [];
|
|
257
|
-
for (const completedStep of stepsToCompensate) {
|
|
258
|
-
const compDef = wf.compensations?.find(c => c.depend?.includes(completedStep.stepId) || c.id === completedStep.stepId);
|
|
259
|
-
if (!compDef?.exec) continue;
|
|
260
|
-
try {
|
|
261
|
-
await compDef.exec({
|
|
262
|
-
data: run.data,
|
|
263
|
-
prevOutput: completedStep.output,
|
|
264
|
-
input: undefined,
|
|
265
|
-
rest: this,
|
|
266
|
-
error: fn.error,
|
|
267
|
-
jwt: func.jwt,
|
|
268
|
-
});
|
|
269
|
-
run.compensations.push({
|
|
270
|
-
stepId: compDef.id,
|
|
271
|
-
name: compDef.name,
|
|
272
|
-
status: 'completed',
|
|
273
|
-
output: null,
|
|
274
|
-
startedAt: new Date(),
|
|
275
|
-
completedAt: new Date(),
|
|
276
|
-
});
|
|
277
|
-
} catch (compErr: any) {
|
|
278
|
-
run.compensations.push({
|
|
279
|
-
stepId: compDef.id,
|
|
280
|
-
name: compDef.name,
|
|
281
|
-
status: 'failed',
|
|
282
|
-
error: { message: compErr.message || 'Compensation failed' },
|
|
283
|
-
startedAt: new Date(),
|
|
284
|
-
completedAt: new Date(),
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
434
|
+
await this.acquireLock(runId);
|
|
435
|
+
try {
|
|
436
|
+
await this.touchLock(runId);
|
|
437
|
+
|
|
438
|
+
// Execute global exec if present (never compensated: nothing has run yet)
|
|
439
|
+
if (wf.exec) {
|
|
440
|
+
try {
|
|
441
|
+
await this.invoke(
|
|
442
|
+
(ctx) => wf.exec!(ctx as any),
|
|
443
|
+
{ data, prevOutput: null, input: undefined, rest: this.#rest, agents: createAgents(this.#tenant_id, this.#rest), api: createApi(this.#rest), error: fn.error, jwt: func.jwt },
|
|
444
|
+
'__global__',
|
|
445
|
+
);
|
|
446
|
+
} catch (err: any) {
|
|
447
|
+
run.status = 'failed';
|
|
448
|
+
run.error = { message: err.message || 'Workflow execution failed', code: err.code, stepId: '__global__' };
|
|
288
449
|
run.updatedAt = new Date();
|
|
289
|
-
await this.saveRun(run);
|
|
450
|
+
await this.saveRun(run, { guard: true });
|
|
451
|
+
throw err;
|
|
290
452
|
}
|
|
291
|
-
|
|
292
|
-
throw err;
|
|
293
453
|
}
|
|
454
|
+
|
|
455
|
+
return await this.executeSteps(run, wf, 0);
|
|
456
|
+
} finally {
|
|
457
|
+
await this.releaseLock(runId);
|
|
294
458
|
}
|
|
459
|
+
}
|
|
295
460
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Resume a paused or failed run.
|
|
463
|
+
*
|
|
464
|
+
* - `completed` / `cancelled` / `compensated` → refused: those are terminal.
|
|
465
|
+
* - a run that was (partially) **rolled back** → the rollback is finished, the
|
|
466
|
+
* steps are never replayed (their effects were compensated).
|
|
467
|
+
* - otherwise → continues from the first non-completed step.
|
|
468
|
+
*
|
|
469
|
+
* A run started by another `version` of the workflow is refused unless
|
|
470
|
+
* `{ force: true }` — the step list may have changed underneath.
|
|
471
|
+
*/
|
|
472
|
+
async resume(runId: string, data?: any, opts?: { force?: boolean }): Promise<WorkflowRun> {
|
|
473
|
+
const run = await this.getRun(runId);
|
|
474
|
+
if (!run) throw new AppError(`Workflow run '${runId}' not found`, { code: 'RUN_NOT_FOUND', status: 404 });
|
|
475
|
+
|
|
476
|
+
if (run.status === 'compensated') {
|
|
477
|
+
throw new AppError(`Workflow run '${runId}' was compensated (rolled back) — nothing to resume`, { code: 'RUN_COMPENSATED', status: 409 });
|
|
478
|
+
}
|
|
479
|
+
if (TERMINAL.includes(run.status)) {
|
|
480
|
+
throw new AppError(`Workflow run '${runId}' is ${run.status} — resume is not applicable`, { code: 'RUN_TERMINAL', status: 400 });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const wf = this.definition(run.workflowId, run.workflowVersion, opts?.force);
|
|
484
|
+
|
|
485
|
+
await this.acquireLock(runId);
|
|
486
|
+
try {
|
|
487
|
+
run.status = 'running';
|
|
488
|
+
run.data = data || run.data;
|
|
489
|
+
run.updatedAt = new Date();
|
|
490
|
+
|
|
491
|
+
if (!(await this.claimRun(runId))) {
|
|
492
|
+
throw new AppError(`Workflow run '${runId}' is ${run.status} — resume is not applicable`, { code: 'RUN_TERMINAL', status: 400 });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A rollback was started: finish it, never continue forward
|
|
496
|
+
if (run.compensations?.length) {
|
|
497
|
+
await this.compensate(run, wf);
|
|
498
|
+
return run;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const startIndex = run.steps.findIndex(s => s.status !== 'completed');
|
|
502
|
+
if (startIndex === -1) {
|
|
503
|
+
run.status = 'completed';
|
|
504
|
+
run.progress = 100;
|
|
505
|
+
run.completedAt = new Date();
|
|
506
|
+
await this.saveRun(run, { guard: true });
|
|
507
|
+
return run;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return await this.executeSteps(run, wf, startIndex);
|
|
511
|
+
} finally {
|
|
512
|
+
await this.releaseLock(runId);
|
|
513
|
+
}
|
|
302
514
|
}
|
|
303
515
|
|
|
304
516
|
/** Get a workflow run by ID */
|
|
@@ -329,56 +541,70 @@ class Workflow {
|
|
|
329
541
|
};
|
|
330
542
|
}
|
|
331
543
|
|
|
332
|
-
/**
|
|
333
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Ask a running run to pause at the next step boundary.
|
|
546
|
+
* Returns `false` when the run is not running (nothing to pause).
|
|
547
|
+
*/
|
|
548
|
+
async pause(runId: string): Promise<boolean> {
|
|
334
549
|
const col = await this.getCollection();
|
|
335
|
-
await col.updateOne(
|
|
550
|
+
const res = await col.updateOne(
|
|
336
551
|
{ _id: runId as any, status: 'running' },
|
|
337
552
|
{ $set: { status: 'paused', updatedAt: new Date() } }
|
|
338
553
|
);
|
|
554
|
+
return (res.matchedCount ?? 0) > 0;
|
|
339
555
|
}
|
|
340
556
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Cancel a run permanently — it stops at the next step boundary and can no
|
|
559
|
+
* longer be resumed. Terminal runs are left untouched (`false`).
|
|
560
|
+
*/
|
|
561
|
+
async cancel(runId: string): Promise<boolean> {
|
|
343
562
|
const col = await this.getCollection();
|
|
344
|
-
await col.updateOne(
|
|
345
|
-
{ _id: runId as any },
|
|
563
|
+
const res = await col.updateOne(
|
|
564
|
+
{ _id: runId as any, status: { $nin: TERMINAL } },
|
|
346
565
|
{ $set: { status: 'cancelled', updatedAt: new Date() } }
|
|
347
566
|
);
|
|
567
|
+
return (res.matchedCount ?? 0) > 0;
|
|
348
568
|
}
|
|
349
569
|
|
|
350
|
-
/**
|
|
351
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Resume every paused or failed run, in `_id` order and by batches — a large
|
|
572
|
+
* backlog is never loaded in memory at once.
|
|
573
|
+
*/
|
|
574
|
+
async resumeAll(data?: any, opts?: { batchSize?: number }): Promise<{ resumed: number; failed: number }> {
|
|
352
575
|
const col = await this.getCollection();
|
|
353
|
-
const
|
|
354
|
-
status: { $in: ['paused', 'failed'] }
|
|
355
|
-
}).toArray() as unknown as WorkflowRun[];
|
|
576
|
+
const batchSize = Math.max(1, opts?.batchSize ?? 100);
|
|
356
577
|
|
|
578
|
+
let cursor: any = null;
|
|
357
579
|
let resumed = 0;
|
|
358
580
|
let failed = 0;
|
|
359
581
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
582
|
+
while (true) {
|
|
583
|
+
const filter: any = { status: { $in: ['paused', 'failed'] } };
|
|
584
|
+
if (cursor) filter._id = { $gt: cursor };
|
|
585
|
+
|
|
586
|
+
const runs = await col.find(filter).sort({ _id: 1 }).limit(batchSize).toArray() as unknown as WorkflowRun[];
|
|
587
|
+
if (!runs.length) break;
|
|
588
|
+
|
|
589
|
+
for (const run of runs) {
|
|
590
|
+
try {
|
|
591
|
+
await this.resume(run._id, data);
|
|
592
|
+
resumed++;
|
|
593
|
+
} catch {
|
|
594
|
+
failed++;
|
|
595
|
+
}
|
|
366
596
|
}
|
|
597
|
+
|
|
598
|
+
cursor = runs[runs.length - 1]?._id;
|
|
599
|
+
if (runs.length < batchSize) break;
|
|
367
600
|
}
|
|
368
601
|
|
|
369
602
|
return { resumed, failed };
|
|
370
603
|
}
|
|
371
|
-
|
|
372
|
-
private async saveRun(run: WorkflowRun): Promise<void> {
|
|
373
|
-
const col = await this.getCollection();
|
|
374
|
-
await col.replaceOne(
|
|
375
|
-
{ _id: run._id as any },
|
|
376
|
-
run as any,
|
|
377
|
-
{ upsert: true }
|
|
378
|
-
);
|
|
379
|
-
}
|
|
380
604
|
}
|
|
381
605
|
|
|
382
606
|
export function createWorkflow(tenant_id: string): Workflow {
|
|
383
607
|
return new Workflow(tenant_id);
|
|
384
608
|
}
|
|
609
|
+
|
|
610
|
+
export { Workflow }
|