@anteros/core 0.0.41 → 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.
Files changed (102) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +110 -32
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
@@ -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 { getWorkflow } from "../lib/workflow";
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 type { WorkflowDefinition, WorkflowRun, WorkflowRunStep, WorkflowRunStatus } from "../types/workflow";
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
- * Run a workflow by its ID with the given data.
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
- const runId = crypto.randomUUID();
33
- const now = new Date();
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
- const run: WorkflowRun = {
36
- _id: runId,
37
- workflowId,
38
- workflowVersion: wf.version,
39
- tenant_id: this.#tenant_id,
40
- status: 'running',
41
- progress: 0,
42
- data,
43
- context,
44
- currentStep: 0,
45
- totalExecuted: 0,
46
- totalSkipped: 0,
47
- steps: wf.steps.map(s => ({
48
- stepId: s.id,
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
- await this.saveRun(run);
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
- // Execute global exec if present
88
+ private async releaseLock(runId: string): Promise<void> {
61
89
  try {
62
- if (wf.exec) {
63
- await wf.exec({ data, prevOutput: null, rest: this, error: fn.error, jwt: func.jwt, input: undefined });
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
- // Execute steps sequentially
74
- for (let i = 0; i < wf.steps.length; i++) {
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
- // Check the step condition
93
- if (stepDef.condition) {
94
- const shouldRun = await stepDef.condition({ data, prevOutput });
95
- if (!shouldRun) {
96
- stepRun.status = 'skipped';
97
- stepRun.completedAt = new Date();
98
- run.steps[i] = stepRun;
99
- run.totalSkipped = run.steps.filter(s => s.status === 'skipped').length;
100
- run.progress = Math.round(((i + 1) / wf.steps.length) * 100);
101
- run.updatedAt = new Date();
102
- await this.saveRun(run);
103
- continue; // skip this 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 stepDef.exec({ data, prevOutput, input: stepDef.input, rest: this, error: fn.error, jwt: func.jwt });
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
- run.updatedAt = new Date();
123
- await this.saveRun(run);
124
-
125
- // Run the compensations (successful steps in reverse order)
126
- if (wf.compensations?.length) {
127
- const failedIndex = run.steps.findIndex(s => s.status === 'failed');
128
- const stepsToCompensate = run.steps.slice(0, failedIndex).filter(s => s.status === 'completed').reverse();
129
- run.compensations = [];
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
- * Resume a paused or failed workflow run from the last failed/pending step.
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 resume(runId: string, data?: any): Promise<WorkflowRun> {
181
- const run = await this.getRun(runId);
182
- if (!run) throw new AppError(`Workflow run '${runId}' not found`, { code: 'RUN_NOT_FOUND', status: 404 });
183
- if (run.status === 'completed') throw new AppError('Workflow already completed', { code: 'RUN_COMPLETED', status: 400 });
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
- const wf = getWorkflow(run.workflowId, this.#tenant_id);
186
- if (!wf) throw new AppError(`Workflow '${run.workflowId}' not found`, { code: 'WORKFLOW_NOT_FOUND', status: 404 });
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 = 'running';
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
- // Find the first non-completed step
193
- const startIndex = run.steps.findIndex(s => s.status !== 'completed');
194
- if (startIndex === -1) {
195
- run.status = 'completed';
196
- run.progress = 100;
197
- run.completedAt = new Date();
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
- for (let i = startIndex; i < wf.steps.length; i++) {
203
- const stepDef = wf.steps[i]!;
204
- run.currentStep = i;
205
- const stepRun: WorkflowRunStep = {
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
- try {
217
- const prevOutput = i > 0 ? run.steps[i - 1]?.output ?? null : null;
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
- // Check the step condition
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; // skip this step
231
- }
232
- }
432
+ await this.saveRun(run);
233
433
 
234
- const result = await stepDef.exec({ data: run.data, prevOutput, input: stepDef.input, rest: this, error: fn.error, jwt: func.jwt });
235
- stepRun.status = 'completed';
236
- stepRun.output = result;
237
- stepRun.completedAt = new Date();
238
- run.steps[i] = stepRun;
239
- run.totalExecuted = run.steps.filter(s => s.status === 'completed').length;
240
- run.progress = Math.round(((i + 1) / wf.steps.length) * 100);
241
- } catch (err: any) {
242
- stepRun.status = 'failed';
243
- stepRun.error = { message: err.message || 'Step failed', code: err.code };
244
- stepRun.completedAt = new Date();
245
- run.steps[i] = stepRun;
246
- run.totalExecuted = run.steps.filter(s => s.status === 'completed').length;
247
- run.status = 'failed';
248
- run.error = { message: err.message || 'Workflow failed', code: err.code, stepId: stepDef.id };
249
- run.updatedAt = new Date();
250
- await this.saveRun(run);
251
-
252
- // Run the compensations (successful steps in reverse order)
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
- run.status = 'completed';
297
- run.progress = 100;
298
- run.completedAt = new Date();
299
- run.updatedAt = new Date();
300
- await this.saveRun(run);
301
- return run;
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
- /** Pause a running workflow */
333
- async pause(runId: string): Promise<void> {
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
- /** Cancel a workflow run permanently */
342
- async cancel(runId: string): Promise<void> {
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
- /** Resume all paused or failed workflows */
351
- async resumeAll(data?: any): Promise<{ resumed: number; failed: number }> {
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 runs = await col.find({
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
- for (const run of runs) {
361
- try {
362
- await this.resume(run._id, data);
363
- resumed++;
364
- } catch {
365
- failed++;
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 }