alepha 0.19.3 → 0.19.4

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 (215) hide show
  1. package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
  2. package/dist/api/audits/index.d.ts +8 -8
  3. package/dist/api/invitations/index.d.ts +790 -0
  4. package/dist/api/invitations/index.d.ts.map +1 -0
  5. package/dist/api/invitations/index.js +665 -0
  6. package/dist/api/invitations/index.js.map +1 -0
  7. package/dist/api/jobs/index.browser.js +8 -9
  8. package/dist/api/jobs/index.browser.js.map +1 -1
  9. package/dist/api/jobs/index.d.ts +99 -43
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js +257 -40
  12. package/dist/api/jobs/index.js.map +1 -1
  13. package/dist/api/keys/index.d.ts +5 -5
  14. package/dist/api/notifications/index.browser.js +0 -1
  15. package/dist/api/notifications/index.browser.js.map +1 -1
  16. package/dist/api/notifications/index.d.ts +3 -3
  17. package/dist/api/notifications/index.d.ts.map +1 -1
  18. package/dist/api/notifications/index.js +0 -1
  19. package/dist/api/notifications/index.js.map +1 -1
  20. package/dist/api/parameters/index.browser.js +112 -1
  21. package/dist/api/parameters/index.browser.js.map +1 -1
  22. package/dist/api/parameters/index.d.ts +90 -3
  23. package/dist/api/parameters/index.d.ts.map +1 -1
  24. package/dist/api/parameters/index.js +79 -12
  25. package/dist/api/parameters/index.js.map +1 -1
  26. package/dist/{billing → api/payments}/index.d.ts +67 -49
  27. package/dist/api/payments/index.d.ts.map +1 -0
  28. package/dist/{billing → api/payments}/index.js +108 -74
  29. package/dist/api/payments/index.js.map +1 -0
  30. package/dist/api/subscriptions/index.d.ts +1692 -0
  31. package/dist/api/subscriptions/index.d.ts.map +1 -0
  32. package/dist/api/subscriptions/index.js +1870 -0
  33. package/dist/api/subscriptions/index.js.map +1 -0
  34. package/dist/api/users/index.d.ts +18 -2
  35. package/dist/api/users/index.d.ts.map +1 -1
  36. package/dist/api/users/index.js +167 -34
  37. package/dist/api/users/index.js.map +1 -1
  38. package/dist/api/verifications/index.d.ts +13 -13
  39. package/dist/api/workflows/index.browser.js +246 -0
  40. package/dist/api/workflows/index.browser.js.map +1 -0
  41. package/dist/api/workflows/index.d.ts +1618 -0
  42. package/dist/api/workflows/index.d.ts.map +1 -0
  43. package/dist/api/workflows/index.js +1504 -0
  44. package/dist/api/workflows/index.js.map +1 -0
  45. package/dist/cli/core/index.d.ts +44 -28
  46. package/dist/cli/core/index.d.ts.map +1 -1
  47. package/dist/cli/core/index.js +16 -61
  48. package/dist/cli/core/index.js.map +1 -1
  49. package/dist/cli/vendor/index.d.ts +31 -8
  50. package/dist/cli/vendor/index.d.ts.map +1 -1
  51. package/dist/cli/vendor/index.js +79 -24
  52. package/dist/cli/vendor/index.js.map +1 -1
  53. package/dist/core/index.browser.js +21 -2
  54. package/dist/core/index.browser.js.map +1 -1
  55. package/dist/core/index.d.ts +33 -2
  56. package/dist/core/index.d.ts.map +1 -1
  57. package/dist/core/index.js +21 -2
  58. package/dist/core/index.js.map +1 -1
  59. package/dist/core/index.native.js +21 -2
  60. package/dist/core/index.native.js.map +1 -1
  61. package/dist/core/index.workerd.js +21 -2
  62. package/dist/core/index.workerd.js.map +1 -1
  63. package/dist/email/smtp/index.js +24 -8
  64. package/dist/email/smtp/index.js.map +1 -1
  65. package/dist/orm/core/index.browser.js +0 -18
  66. package/dist/orm/core/index.browser.js.map +1 -1
  67. package/dist/orm/core/index.bun.js +0 -17
  68. package/dist/orm/core/index.bun.js.map +1 -1
  69. package/dist/orm/core/index.d.ts +1 -13
  70. package/dist/orm/core/index.d.ts.map +1 -1
  71. package/dist/orm/core/index.js +0 -17
  72. package/dist/orm/core/index.js.map +1 -1
  73. package/dist/orm/postgres/index.bun.js +3 -3
  74. package/dist/orm/postgres/index.bun.js.map +1 -1
  75. package/dist/orm/postgres/index.d.ts.map +1 -1
  76. package/dist/orm/postgres/index.js +3 -3
  77. package/dist/orm/postgres/index.js.map +1 -1
  78. package/dist/react/router/index.browser.js +25 -3
  79. package/dist/react/router/index.browser.js.map +1 -1
  80. package/dist/react/router/index.d.ts +16 -1
  81. package/dist/react/router/index.d.ts.map +1 -1
  82. package/dist/react/router/index.js +25 -3
  83. package/dist/react/router/index.js.map +1 -1
  84. package/dist/security/index.d.ts +28 -0
  85. package/dist/security/index.d.ts.map +1 -1
  86. package/dist/security/index.js +28 -0
  87. package/dist/security/index.js.map +1 -1
  88. package/package.json +37 -20
  89. package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
  90. package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
  91. package/src/api/invitations/controllers/InvitationController.ts +84 -0
  92. package/src/api/invitations/entities/invitations.ts +33 -0
  93. package/src/api/invitations/index.ts +65 -0
  94. package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
  95. package/src/api/invitations/providers/InvitationProvider.ts +45 -0
  96. package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
  97. package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
  98. package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
  99. package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
  100. package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
  101. package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
  102. package/src/api/invitations/services/InvitationService.ts +556 -0
  103. package/src/api/jobs/__tests__/$job.spec.ts +876 -0
  104. package/src/api/jobs/controllers/AdminJobController.ts +44 -0
  105. package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
  106. package/src/api/jobs/index.ts +0 -3
  107. package/src/api/jobs/primitives/$job.ts +22 -11
  108. package/src/api/jobs/providers/JobProvider.ts +229 -19
  109. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
  110. package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
  111. package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
  112. package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
  113. package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
  114. package/src/api/jobs/services/JobService.ts +51 -12
  115. package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
  116. package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
  117. package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
  118. package/src/api/parameters/index.browser.ts +12 -0
  119. package/src/api/parameters/primitives/$parameter.ts +20 -3
  120. package/src/api/parameters/services/ParameterProvider.ts +48 -7
  121. package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
  122. package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
  123. package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
  124. package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
  125. package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
  126. package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
  127. package/src/{billing → api/payments}/index.ts +31 -25
  128. package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
  129. package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
  130. package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
  131. package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
  132. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  133. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  134. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  135. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  136. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  137. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  138. package/src/api/subscriptions/index.ts +144 -0
  139. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  140. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  141. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  142. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  143. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  144. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  145. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  146. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  147. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  148. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  149. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  150. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  151. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  152. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  153. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  154. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  155. package/src/api/subscriptions/services/BillingService.ts +437 -0
  156. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  157. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  158. package/src/api/subscriptions/services/UsageService.ts +118 -0
  159. package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
  160. package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
  161. package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
  162. package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
  163. package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
  164. package/src/api/users/__tests__/SessionService.spec.ts +142 -1
  165. package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
  166. package/src/api/users/controllers/UserController.ts +3 -8
  167. package/src/api/users/notifications/UserNotifications.ts +23 -0
  168. package/src/api/users/schemas/loginSchema.ts +1 -1
  169. package/src/api/users/services/CredentialService.ts +51 -4
  170. package/src/api/users/services/RegistrationService.ts +38 -9
  171. package/src/api/users/services/SessionService.ts +62 -9
  172. package/src/api/users/services/UserService.ts +21 -12
  173. package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
  174. package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
  175. package/src/api/workflows/entities/workflowExecutions.ts +74 -0
  176. package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
  177. package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
  178. package/src/api/workflows/index.browser.ts +22 -0
  179. package/src/api/workflows/index.ts +124 -0
  180. package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
  181. package/src/api/workflows/primitives/$workflow.ts +202 -0
  182. package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
  183. package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
  184. package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
  185. package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
  186. package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
  187. package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
  188. package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
  189. package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
  190. package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
  191. package/src/api/workflows/services/WorkflowService.ts +382 -0
  192. package/src/cli/core/templates/webAppRouterTs.ts +5 -58
  193. package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
  194. package/src/cli/vendor/services/VendorService.ts +126 -27
  195. package/src/core/__tests__/TypeProvider.spec.ts +4 -2
  196. package/src/core/providers/SchemaValidator.ts +1 -1
  197. package/src/core/providers/TypeProvider.ts +46 -3
  198. package/src/orm/__tests__/enums.spec.ts +22 -29
  199. package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
  200. package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
  201. package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
  202. package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
  203. package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
  204. package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
  205. package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
  206. package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
  207. package/src/security/primitives/$secure.ts +28 -0
  208. package/dist/billing/index.d.ts.map +0 -1
  209. package/dist/billing/index.js.map +0 -1
  210. package/src/billing/__tests__/BillingService.spec.ts +0 -136
  211. /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
  212. /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
  213. /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
  214. /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
  215. /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
@@ -0,0 +1,1284 @@
1
+ import {
2
+ $hook,
3
+ $inject,
4
+ $state,
5
+ Alepha,
6
+ AlephaError,
7
+ type Static,
8
+ type TSchema,
9
+ } from "alepha";
10
+ import { DateTimeProvider } from "alepha/datetime";
11
+ import { LockProvider } from "alepha/lock";
12
+ import type { LogEntry } from "alepha/logger";
13
+ import { $logger } from "alepha/logger";
14
+ import { $repository } from "alepha/orm";
15
+ import {
16
+ type WorkflowExecutionEntity,
17
+ type WorkflowStatus,
18
+ workflowExecutions,
19
+ } from "../entities/workflowExecutions.ts";
20
+ import {
21
+ type WorkflowStepExecutionEntity,
22
+ workflowStepExecutions,
23
+ } from "../entities/workflowStepExecutions.ts";
24
+ import { workflowStepLogs } from "../entities/workflowStepLogs.ts";
25
+ import type {
26
+ HandlerStep,
27
+ WorkflowPrimitive,
28
+ WorkflowPrimitiveOptions,
29
+ WorkflowRetryBackoff,
30
+ WorkflowRetryOptions,
31
+ WorkflowStartOptions,
32
+ } from "../primitives/$workflow.ts";
33
+ import { workflowConfig } from "../schemas/workflowConfigAtom.ts";
34
+
35
+ // -----------------------------------------------------------------------------------------------------------------
36
+
37
+ const PRIORITY_MAP: Record<string, number> = {
38
+ critical: 0,
39
+ high: 1,
40
+ normal: 2,
41
+ low: 3,
42
+ };
43
+
44
+ interface WorkflowRegistration {
45
+ name: string;
46
+ options: WorkflowPrimitiveOptions;
47
+ }
48
+
49
+ export interface CancelOptions {
50
+ compensate?: boolean;
51
+ cancelledBy?: string;
52
+ cancelledByName?: string;
53
+ }
54
+
55
+ // -----------------------------------------------------------------------------------------------------------------
56
+
57
+ export class WorkflowProvider {
58
+ protected readonly alepha = $inject(Alepha);
59
+ protected readonly dt = $inject(DateTimeProvider);
60
+ protected readonly lockProvider = $inject(LockProvider);
61
+ protected readonly config = $state(workflowConfig);
62
+ protected readonly log = $logger();
63
+ protected readonly executions = $repository(workflowExecutions);
64
+ protected readonly stepExecutions = $repository(workflowStepExecutions);
65
+ protected readonly stepLogs = $repository(workflowStepLogs);
66
+
67
+ protected readonly workflows = new Map<string, WorkflowRegistration>();
68
+ protected readonly pausedWorkflows = new Set<string>();
69
+ protected readonly inFlight = new Set<Promise<void>>();
70
+ protected readonly abortControllers = new Map<string, AbortController>();
71
+ protected readonly logs = new Map<string, LogEntry[]>();
72
+ protected stopping = false;
73
+
74
+ /**
75
+ * When set, step dispatches go through a queue.
76
+ * Set by WorkflowJobs on start.
77
+ */
78
+ public stepDispatch:
79
+ | ((
80
+ workflowId: string,
81
+ stepName: string,
82
+ priority: number,
83
+ ) => Promise<void>)
84
+ | null = null;
85
+
86
+ // --- Registration ---
87
+
88
+ public register(primitive: WorkflowPrimitive<any>): void {
89
+ if (this.workflows.has(primitive.name)) {
90
+ throw new AlephaError(`Workflow already registered: ${primitive.name}`);
91
+ }
92
+ this.workflows.set(primitive.name, {
93
+ name: primitive.name,
94
+ options: primitive.options,
95
+ });
96
+ this.log.debug(`Registered workflow '${primitive.name}'`, {
97
+ steps: primitive.options.steps.length,
98
+ });
99
+ }
100
+
101
+ public getRegisteredWorkflows(): Map<string, WorkflowRegistration> {
102
+ return this.workflows;
103
+ }
104
+
105
+ // --- Start ---
106
+
107
+ public async start(
108
+ workflowName: string,
109
+ payload: unknown,
110
+ options?: WorkflowStartOptions,
111
+ ): Promise<string> {
112
+ const registration = this.getRegistration(workflowName);
113
+ const opts = registration.options;
114
+
115
+ // Validate payload
116
+ const validated = this.alepha.codec.validate(opts.schema, payload);
117
+
118
+ const priority =
119
+ PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"];
120
+ const status: WorkflowStatus = options?.delay ? "pending" : "running";
121
+
122
+ // Compute deadline
123
+ let deadlineAt: string | undefined;
124
+ if (opts.timeout) {
125
+ deadlineAt = this.dt
126
+ .now()
127
+ .add(this.dt.duration(opts.timeout))
128
+ .toISOString();
129
+ }
130
+
131
+ // Keyed deduplication
132
+ if (options?.key) {
133
+ const existing = await this.executions.findMany({
134
+ where: {
135
+ workflowName: { eq: workflowName },
136
+ key: { eq: options.key },
137
+ status: {
138
+ inArray: [
139
+ "pending",
140
+ "running",
141
+ "waiting_for_signal",
142
+ "compensating",
143
+ ],
144
+ },
145
+ },
146
+ limit: 1,
147
+ });
148
+ if (existing.length > 0) {
149
+ return existing[0].id;
150
+ }
151
+ }
152
+
153
+ // Create workflow execution
154
+ const execution = await this.executions.create({
155
+ workflowName,
156
+ payload: validated as Record<string, unknown>,
157
+ status,
158
+ priority,
159
+ deadlineAt,
160
+ key: options?.key,
161
+ triggeredBy: options?.triggeredBy,
162
+ triggeredByName: options?.triggeredByName,
163
+ tags: options?.tags ?? opts.tags,
164
+ startedAt: status === "running" ? this.dt.nowISOString() : undefined,
165
+ });
166
+
167
+ // Create step execution records
168
+ for (let i = 0; i < opts.steps.length; i++) {
169
+ const step = opts.steps[i];
170
+ const retryOpts = step.retry;
171
+ await this.stepExecutions.create({
172
+ workflowExecutionId: execution.id,
173
+ stepName: step.name,
174
+ stepIndex: i,
175
+ stepType: step.type ?? "handler",
176
+ status: "pending",
177
+ maxAttempts: (retryOpts?.retries ?? 0) + 1,
178
+ });
179
+ }
180
+
181
+ this.log.info(`Started workflow '${workflowName}'`, {
182
+ workflowId: execution.id,
183
+ steps: opts.steps.length,
184
+ });
185
+
186
+ await this.alepha.events.emit(
187
+ "workflow:started",
188
+ {
189
+ workflowName,
190
+ workflowId: execution.id,
191
+ },
192
+ { catch: true },
193
+ );
194
+
195
+ // Dispatch first step
196
+ if (status === "running" && !this.stopping) {
197
+ const firstStep = opts.steps[0];
198
+ if (firstStep) {
199
+ await this.dispatchStep(execution.id, firstStep.name, priority);
200
+ } else {
201
+ // No steps — complete immediately
202
+ await this.executions.updateById(execution.id, {
203
+ status: "completed",
204
+ completedAt: this.dt.nowISOString(),
205
+ });
206
+ }
207
+ }
208
+
209
+ return execution.id;
210
+ }
211
+
212
+ // --- Process Step ---
213
+
214
+ public async processStep(
215
+ workflowId: string,
216
+ stepName: string,
217
+ ): Promise<void> {
218
+ const promise = this.processStepInner(workflowId, stepName);
219
+ this.inFlight.add(promise);
220
+ try {
221
+ await promise;
222
+ } finally {
223
+ this.inFlight.delete(promise);
224
+ }
225
+ }
226
+
227
+ protected async processStepInner(
228
+ workflowId: string,
229
+ stepName: string,
230
+ ): Promise<void> {
231
+ // Acquire workflow-level lock
232
+ const lockKey = `workflow:${workflowId}`;
233
+ const lockValue = `${crypto.randomUUID()},${this.dt.nowISOString()}`;
234
+ const lockResult = await this.lockProvider.set(
235
+ lockKey,
236
+ lockValue,
237
+ true,
238
+ 600_000,
239
+ );
240
+ const [lockId] = lockResult.split(",");
241
+ if (lockId !== lockValue.split(",")[0]) {
242
+ this.log.debug(
243
+ `Workflow ${workflowId} locked by another worker, skipping`,
244
+ );
245
+ return;
246
+ }
247
+
248
+ try {
249
+ const workflow = await this.executions.findById(workflowId);
250
+ if (!workflow) return;
251
+
252
+ if (workflow.status !== "running" && workflow.status !== "pending") {
253
+ return;
254
+ }
255
+
256
+ // Transition pending → running if needed
257
+ if (workflow.status === "pending") {
258
+ await this.executions.updateById(workflowId, {
259
+ status: "running",
260
+ startedAt: this.dt.nowISOString(),
261
+ });
262
+ }
263
+
264
+ const registration = this.getRegistration(workflow.workflowName);
265
+ const stepDef = registration.options.steps.find(
266
+ (s) => s.name === stepName,
267
+ );
268
+ if (!stepDef) return;
269
+
270
+ const stepExec = await this.findStepExecution(workflowId, stepName);
271
+ if (!stepExec) return;
272
+
273
+ if (stepExec.status !== "pending") return;
274
+
275
+ // Check when() condition
276
+ if (stepDef.when) {
277
+ const results = await this.assembleResults(workflowId);
278
+ const shouldRun = await stepDef.when({
279
+ payload: workflow.payload as Static<TSchema>,
280
+ results,
281
+ });
282
+ if (!shouldRun) {
283
+ await this.stepExecutions.updateById(stepExec.id, {
284
+ status: "skipped",
285
+ completedAt: this.dt.nowISOString(),
286
+ });
287
+ await this.alepha.events.emit(
288
+ "workflow:step:skipped",
289
+ {
290
+ workflowName: workflow.workflowName,
291
+ workflowId,
292
+ stepName,
293
+ },
294
+ { catch: true },
295
+ );
296
+ await this.advance(workflowId);
297
+ return;
298
+ }
299
+ }
300
+
301
+ // Handler step execution
302
+ await this.executeHandlerStep(workflow, stepExec, stepDef as HandlerStep);
303
+ } finally {
304
+ await this.lockProvider.del(lockKey);
305
+ }
306
+ }
307
+
308
+ protected async executeHandlerStep(
309
+ workflow: WorkflowExecutionEntity,
310
+ stepExec: WorkflowStepExecutionEntity,
311
+ stepDef: HandlerStep,
312
+ ): Promise<void> {
313
+ const workflowId = workflow.id;
314
+ const stepName = stepExec.stepName;
315
+
316
+ // Claim step
317
+ await this.stepExecutions.updateById(stepExec.id, {
318
+ status: "running",
319
+ attempt: stepExec.attempt + 1,
320
+ startedAt: this.dt.nowISOString(),
321
+ });
322
+
323
+ await this.executions.updateById(workflowId, {
324
+ currentStep: stepName,
325
+ });
326
+
327
+ await this.alepha.events.emit(
328
+ "workflow:step:begin",
329
+ {
330
+ workflowName: workflow.workflowName,
331
+ workflowId,
332
+ stepName,
333
+ },
334
+ { catch: true },
335
+ );
336
+
337
+ // Set up abort controller
338
+ const abortController = new AbortController();
339
+ const abortKey = `${workflowId}:${stepName}`;
340
+ this.abortControllers.set(abortKey, abortController);
341
+
342
+ // Set up timeout
343
+ const timeoutMs = stepDef.timeout
344
+ ? this.dt.duration(stepDef.timeout).as("milliseconds")
345
+ : this.config.defaultStepTimeout;
346
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
347
+
348
+ // Capture logs
349
+ const context = this.alepha.context.createContextId();
350
+ this.logs.set(context, []);
351
+
352
+ try {
353
+ await this.alepha.context.run(
354
+ async () => {
355
+ const results = await this.assembleResults(workflowId);
356
+
357
+ const handlerResult = await stepDef.handler({
358
+ payload: workflow.payload as Static<TSchema>,
359
+ results,
360
+ context: {
361
+ workflowId,
362
+ executionId: stepExec.id,
363
+ stepName,
364
+ attempt: stepExec.attempt + 1,
365
+ },
366
+ signal: abortController.signal,
367
+ });
368
+
369
+ // Success
370
+ await this.stepExecutions.updateById(stepExec.id, {
371
+ status: "completed",
372
+ result:
373
+ handlerResult != null
374
+ ? (handlerResult as Record<string, unknown>)
375
+ : undefined,
376
+ completedAt: this.dt.nowISOString(),
377
+ });
378
+
379
+ await this.writeLogs(stepExec.id, context);
380
+
381
+ this.log.info(`Workflow step '${stepName}' completed`, {
382
+ workflowId,
383
+ });
384
+
385
+ await this.alepha.events.emit(
386
+ "workflow:step:completed",
387
+ {
388
+ workflowName: workflow.workflowName,
389
+ workflowId,
390
+ stepName,
391
+ result: handlerResult,
392
+ },
393
+ { catch: true },
394
+ );
395
+
396
+ // Advance to next step
397
+ await this.advance(workflowId);
398
+ },
399
+ { context },
400
+ );
401
+ } catch (error) {
402
+ const err = error instanceof Error ? error : new Error(String(error));
403
+
404
+ await this.writeLogs(stepExec.id, context);
405
+
406
+ if (abortController.signal.aborted) {
407
+ // Timeout — treat as failure
408
+ await this.handleStepFailure(
409
+ workflow,
410
+ stepExec,
411
+ stepDef,
412
+ new Error("Step timed out"),
413
+ context,
414
+ );
415
+ } else {
416
+ await this.handleStepFailure(workflow, stepExec, stepDef, err, context);
417
+ }
418
+ } finally {
419
+ clearTimeout(timeoutId);
420
+ this.abortControllers.delete(abortKey);
421
+ this.logs.delete(context);
422
+ }
423
+ }
424
+
425
+ protected async handleStepFailure(
426
+ workflow: WorkflowExecutionEntity,
427
+ stepExec: WorkflowStepExecutionEntity,
428
+ stepDef: HandlerStep,
429
+ error: Error,
430
+ _context: string,
431
+ ): Promise<void> {
432
+ const retryOpts = stepDef.retry;
433
+ const canRetry =
434
+ retryOpts &&
435
+ stepExec.attempt + 1 < stepExec.maxAttempts &&
436
+ (retryOpts.when ? retryOpts.when(error) : true);
437
+
438
+ if (canRetry) {
439
+ const nextScheduledAt = this.computeBackoff(
440
+ retryOpts,
441
+ stepExec.attempt + 1,
442
+ );
443
+
444
+ this.log.info(
445
+ `Workflow step '${stepExec.stepName}' failed, scheduling retry`,
446
+ { workflowId: workflow.id, error: error.message },
447
+ );
448
+
449
+ await this.stepExecutions.updateById(stepExec.id, {
450
+ status: "pending",
451
+ error: error.message,
452
+ deadlineAt: nextScheduledAt,
453
+ });
454
+
455
+ // Schedule retry after backoff
456
+ const delayMs = Math.max(
457
+ 0,
458
+ new Date(nextScheduledAt).getTime() - this.dt.nowMillis(),
459
+ );
460
+ this.dt.createTimeout(
461
+ () =>
462
+ void this.dispatchStep(
463
+ workflow.id,
464
+ stepExec.stepName,
465
+ workflow.priority,
466
+ ),
467
+ delayMs,
468
+ );
469
+ } else {
470
+ // Step exhausted — mark failed
471
+ this.log.info(`Workflow step '${stepExec.stepName}' failed permanently`, {
472
+ workflowId: workflow.id,
473
+ error: error.message,
474
+ });
475
+
476
+ await this.stepExecutions.updateById(stepExec.id, {
477
+ status: "failed",
478
+ error: error.message,
479
+ completedAt: this.dt.nowISOString(),
480
+ });
481
+
482
+ await this.alepha.events.emit(
483
+ "workflow:step:failed",
484
+ {
485
+ workflowName: workflow.workflowName,
486
+ workflowId: workflow.id,
487
+ stepName: stepExec.stepName,
488
+ error,
489
+ },
490
+ { catch: true },
491
+ );
492
+
493
+ // Determine error strategy
494
+ const registration = this.getRegistration(workflow.workflowName);
495
+ const onError = registration.options.onError ?? "compensate";
496
+
497
+ if (onError === "compensate") {
498
+ await this.compensate(workflow.id, {
499
+ failedStep: stepExec.stepName,
500
+ error,
501
+ });
502
+ } else {
503
+ await this.executions.updateById(workflow.id, {
504
+ status: "failed",
505
+ error: error.message,
506
+ errorStep: stepExec.stepName,
507
+ completedAt: this.dt.nowISOString(),
508
+ });
509
+
510
+ await this.alepha.events.emit(
511
+ "workflow:failed",
512
+ {
513
+ workflowName: workflow.workflowName,
514
+ workflowId: workflow.id,
515
+ error,
516
+ stepName: stepExec.stepName,
517
+ },
518
+ { catch: true },
519
+ );
520
+ }
521
+ }
522
+ }
523
+
524
+ // --- Advance ---
525
+
526
+ protected async advance(workflowId: string): Promise<void> {
527
+ const workflow = await this.executions.findById(workflowId);
528
+ if (!workflow || workflow.status !== "running") return;
529
+
530
+ const registration = this.getRegistration(workflow.workflowName);
531
+
532
+ // Find next pending step by index
533
+ const steps = await this.stepExecutions.findMany({
534
+ where: { workflowExecutionId: { eq: workflowId } },
535
+ orderBy: { column: "stepIndex", direction: "asc" },
536
+ });
537
+
538
+ const nextStep = steps.find((s) => s.status === "pending");
539
+
540
+ if (nextStep) {
541
+ await this.executions.updateById(workflowId, {
542
+ currentStep: nextStep.stepName,
543
+ });
544
+ await this.dispatchStep(workflowId, nextStep.stepName, workflow.priority);
545
+ } else {
546
+ // All steps done
547
+ await this.executions.updateById(workflowId, {
548
+ status: "completed",
549
+ currentStep: undefined,
550
+ completedAt: this.dt.nowISOString(),
551
+ key: null,
552
+ });
553
+
554
+ this.log.info(`Workflow '${workflow.workflowName}' completed`, {
555
+ workflowId,
556
+ });
557
+
558
+ await this.alepha.events.emit(
559
+ "workflow:completed",
560
+ {
561
+ workflowName: workflow.workflowName,
562
+ workflowId,
563
+ },
564
+ { catch: true },
565
+ );
566
+ }
567
+ }
568
+
569
+ // --- Compensate ---
570
+
571
+ public async compensate(
572
+ workflowId: string,
573
+ context?: { failedStep?: string; error?: Error },
574
+ ): Promise<void> {
575
+ const workflow = await this.executions.findById(workflowId);
576
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
577
+
578
+ const registration = this.getRegistration(workflow.workflowName);
579
+
580
+ await this.executions.updateById(workflowId, {
581
+ status: "compensating",
582
+ error: context?.error?.message,
583
+ errorStep: context?.failedStep,
584
+ });
585
+
586
+ await this.alepha.events.emit(
587
+ "workflow:compensating",
588
+ {
589
+ workflowName: workflow.workflowName,
590
+ workflowId,
591
+ stepName: context?.failedStep ?? "",
592
+ },
593
+ { catch: true },
594
+ );
595
+
596
+ // Get completed steps in reverse order
597
+ const completedSteps = await this.stepExecutions.findMany({
598
+ where: {
599
+ workflowExecutionId: { eq: workflowId },
600
+ status: { eq: "completed" },
601
+ },
602
+ orderBy: { column: "stepIndex", direction: "desc" },
603
+ });
604
+
605
+ const results = await this.assembleResults(workflowId);
606
+
607
+ for (const stepExec of completedSteps) {
608
+ const stepDef = registration.options.steps.find(
609
+ (s) => s.name === stepExec.stepName,
610
+ );
611
+ if (!stepDef?.compensate) continue;
612
+
613
+ await this.stepExecutions.updateById(stepExec.id, {
614
+ status: "compensating",
615
+ });
616
+
617
+ try {
618
+ await stepDef.compensate({
619
+ payload: workflow.payload as Static<TSchema>,
620
+ result: stepExec.result,
621
+ results,
622
+ context: {
623
+ workflowId,
624
+ executionId: stepExec.id,
625
+ stepName: stepExec.stepName,
626
+ error: context?.error ?? new Error("Compensation triggered"),
627
+ },
628
+ });
629
+
630
+ await this.stepExecutions.updateById(stepExec.id, {
631
+ status: "compensated",
632
+ completedAt: this.dt.nowISOString(),
633
+ });
634
+ } catch (compError) {
635
+ const err =
636
+ compError instanceof Error ? compError : new Error(String(compError));
637
+
638
+ this.log.error(`Compensation failed for step '${stepExec.stepName}'`, {
639
+ workflowId,
640
+ error: err.message,
641
+ });
642
+
643
+ await this.stepExecutions.updateById(stepExec.id, {
644
+ status: "compensation_failed",
645
+ error: err.message,
646
+ });
647
+
648
+ await this.executions.updateById(workflowId, {
649
+ status: "compensation_failed",
650
+ completedAt: this.dt.nowISOString(),
651
+ key: null,
652
+ });
653
+
654
+ await this.alepha.events.emit(
655
+ "workflow:compensation:failed",
656
+ {
657
+ workflowName: workflow.workflowName,
658
+ workflowId,
659
+ stepName: stepExec.stepName,
660
+ error: err,
661
+ },
662
+ { catch: true },
663
+ );
664
+
665
+ return;
666
+ }
667
+ }
668
+
669
+ // All compensations succeeded
670
+ await this.executions.updateById(workflowId, {
671
+ status: "compensated",
672
+ completedAt: this.dt.nowISOString(),
673
+ key: null,
674
+ });
675
+
676
+ this.log.info(`Workflow '${workflow.workflowName}' compensated`, {
677
+ workflowId,
678
+ });
679
+
680
+ await this.alepha.events.emit(
681
+ "workflow:compensated",
682
+ {
683
+ workflowName: workflow.workflowName,
684
+ workflowId,
685
+ },
686
+ { catch: true },
687
+ );
688
+ }
689
+
690
+ // --- Cancel ---
691
+
692
+ public async cancel(
693
+ workflowId: string,
694
+ options?: CancelOptions,
695
+ ): Promise<void> {
696
+ const workflow = await this.executions.findById(workflowId);
697
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
698
+
699
+ if (
700
+ workflow.status !== "pending" &&
701
+ workflow.status !== "running" &&
702
+ workflow.status !== "waiting_for_signal"
703
+ ) {
704
+ throw new AlephaError(
705
+ `Cannot cancel workflow in '${workflow.status}' status`,
706
+ );
707
+ }
708
+
709
+ // Abort any running step
710
+ for (const [key, controller] of this.abortControllers) {
711
+ if (key.startsWith(`${workflowId}:`)) {
712
+ controller.abort();
713
+ }
714
+ }
715
+
716
+ // Cancel all pending/waiting steps
717
+ const pendingSteps = await this.stepExecutions.findMany({
718
+ where: {
719
+ workflowExecutionId: { eq: workflowId },
720
+ status: { inArray: ["pending", "waiting"] },
721
+ },
722
+ });
723
+ for (const step of pendingSteps) {
724
+ await this.stepExecutions.updateById(step.id, { status: "cancelled" });
725
+ }
726
+
727
+ if (options?.compensate) {
728
+ await this.compensate(workflowId, {
729
+ error: new Error("Cancelled with compensation"),
730
+ });
731
+ // After compensation, mark as cancelled (override compensated status)
732
+ await this.executions.updateById(workflowId, {
733
+ status: "cancelled",
734
+ cancelledBy: options?.cancelledBy,
735
+ cancelledByName: options?.cancelledByName,
736
+ });
737
+ } else {
738
+ await this.executions.updateById(workflowId, {
739
+ status: "cancelled",
740
+ cancelledBy: options?.cancelledBy,
741
+ cancelledByName: options?.cancelledByName,
742
+ completedAt: this.dt.nowISOString(),
743
+ key: null,
744
+ });
745
+ }
746
+
747
+ this.log.info(`Workflow cancelled`, { workflowId });
748
+
749
+ await this.alepha.events.emit(
750
+ "workflow:cancelled",
751
+ {
752
+ workflowName: workflow.workflowName,
753
+ workflowId,
754
+ },
755
+ { catch: true },
756
+ );
757
+ }
758
+
759
+ // --- Signal ---
760
+
761
+ /**
762
+ * Send a signal to a waiting workflow step.
763
+ */
764
+ public async signal(
765
+ workflowId: string,
766
+ stepName: string,
767
+ payload?: unknown,
768
+ signalledBy?: string,
769
+ ): Promise<void> {
770
+ const workflow = await this.executions.findById(workflowId);
771
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
772
+
773
+ if (workflow.status !== "waiting_for_signal") {
774
+ throw new AlephaError(
775
+ `Cannot signal workflow in '${workflow.status}' status`,
776
+ );
777
+ }
778
+
779
+ const stepExec = await this.findStepExecution(workflowId, stepName);
780
+ if (!stepExec) {
781
+ throw new AlephaError(
782
+ `Step '${stepName}' not found on workflow ${workflowId}`,
783
+ );
784
+ }
785
+
786
+ if (stepExec.status !== "waiting") {
787
+ throw new AlephaError(
788
+ `Step '${stepName}' is in '${stepExec.status}' status, expected 'waiting'`,
789
+ );
790
+ }
791
+
792
+ await this.stepExecutions.updateById(stepExec.id, {
793
+ status: "completed",
794
+ signalPayload:
795
+ payload != null ? (payload as Record<string, unknown>) : undefined,
796
+ signalledBy,
797
+ signalledAt: this.dt.nowISOString(),
798
+ completedAt: this.dt.nowISOString(),
799
+ });
800
+
801
+ // Resume workflow
802
+ await this.executions.updateById(workflowId, {
803
+ status: "running",
804
+ });
805
+
806
+ this.log.info(`Workflow signalled step '${stepName}'`, { workflowId });
807
+
808
+ // Advance to next step
809
+ await this.advance(workflowId);
810
+ }
811
+
812
+ // --- Retry ---
813
+
814
+ public async retry(workflowId: string): Promise<void> {
815
+ const workflow = await this.executions.findById(workflowId);
816
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
817
+
818
+ if (workflow.status !== "failed" && workflow.status !== "timed_out") {
819
+ throw new AlephaError(
820
+ `Cannot retry workflow in '${workflow.status}' status. Use restart() for compensated workflows.`,
821
+ );
822
+ }
823
+
824
+ // Find the failed step
825
+ const failedStep = await this.stepExecutions.findMany({
826
+ where: {
827
+ workflowExecutionId: { eq: workflowId },
828
+ status: { eq: "failed" },
829
+ },
830
+ limit: 1,
831
+ });
832
+
833
+ if (failedStep.length === 0) {
834
+ throw new AlephaError("No failed step found to retry");
835
+ }
836
+
837
+ // Reset the failed step
838
+ await this.stepExecutions.updateById(failedStep[0].id, {
839
+ status: "pending",
840
+ error: undefined,
841
+ startedAt: undefined,
842
+ completedAt: undefined,
843
+ });
844
+
845
+ // Resume workflow
846
+ await this.executions.updateById(workflowId, {
847
+ status: "running",
848
+ error: undefined,
849
+ errorStep: undefined,
850
+ completedAt: undefined,
851
+ });
852
+
853
+ await this.dispatchStep(
854
+ workflowId,
855
+ failedStep[0].stepName,
856
+ workflow.priority,
857
+ );
858
+ }
859
+
860
+ // --- Restart ---
861
+
862
+ public async restart(workflowId: string): Promise<string> {
863
+ const workflow = await this.executions.findById(workflowId);
864
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
865
+
866
+ if (
867
+ workflow.status !== "compensated" &&
868
+ workflow.status !== "compensation_failed" &&
869
+ workflow.status !== "failed"
870
+ ) {
871
+ throw new AlephaError(
872
+ `Cannot restart workflow in '${workflow.status}' status`,
873
+ );
874
+ }
875
+
876
+ return this.start(workflow.workflowName, workflow.payload);
877
+ }
878
+
879
+ // --- Query ---
880
+
881
+ public async getExecution(workflowId: string) {
882
+ const workflow = await this.executions.findById(workflowId);
883
+ if (!workflow) throw new AlephaError(`Workflow not found: ${workflowId}`);
884
+
885
+ const steps = await this.stepExecutions.findMany({
886
+ where: { workflowExecutionId: { eq: workflowId } },
887
+ orderBy: { column: "stepIndex", direction: "asc" },
888
+ });
889
+
890
+ return { ...workflow, steps };
891
+ }
892
+
893
+ // --- Pause / Resume ---
894
+
895
+ public pauseWorkflow(name: string): void {
896
+ this.getRegistration(name);
897
+ this.pausedWorkflows.add(name);
898
+ this.log.info(`Paused workflow '${name}'`);
899
+ }
900
+
901
+ public async resumeWorkflow(name: string): Promise<void> {
902
+ this.getRegistration(name);
903
+ this.pausedWorkflows.delete(name);
904
+ this.log.info(`Resumed workflow '${name}'`);
905
+ }
906
+
907
+ public isWorkflowPaused(name: string): boolean {
908
+ return this.pausedWorkflows.has(name);
909
+ }
910
+
911
+ public getPausedWorkflows(): string[] {
912
+ return [...this.pausedWorkflows];
913
+ }
914
+
915
+ // --- Internal dispatch ---
916
+
917
+ protected async dispatchStep(
918
+ workflowId: string,
919
+ stepName: string,
920
+ priority: number,
921
+ ): Promise<void> {
922
+ if (this.stopping) return;
923
+
924
+ if (this.stepDispatch) {
925
+ await this.stepDispatch(workflowId, stepName, priority);
926
+ } else {
927
+ await this.processStep(workflowId, stepName);
928
+ }
929
+ }
930
+
931
+ // --- Helpers ---
932
+
933
+ protected async assembleResults(
934
+ workflowId: string,
935
+ ): Promise<Record<string, unknown>> {
936
+ const completed = await this.stepExecutions.findMany({
937
+ where: {
938
+ workflowExecutionId: { eq: workflowId },
939
+ status: { eq: "completed" },
940
+ },
941
+ orderBy: { column: "stepIndex", direction: "asc" },
942
+ });
943
+ const results: Record<string, unknown> = {};
944
+ for (const step of completed) {
945
+ if (step.result) results[step.stepName] = step.result;
946
+ }
947
+ return results;
948
+ }
949
+
950
+ protected async findStepExecution(
951
+ workflowId: string,
952
+ stepName: string,
953
+ ): Promise<WorkflowStepExecutionEntity | undefined> {
954
+ const rows = await this.stepExecutions.findMany({
955
+ where: {
956
+ workflowExecutionId: { eq: workflowId },
957
+ stepName: { eq: stepName },
958
+ },
959
+ limit: 1,
960
+ });
961
+ return rows[0];
962
+ }
963
+
964
+ protected computeBackoff(
965
+ retryOpts: WorkflowRetryOptions,
966
+ attempt: number,
967
+ ): string {
968
+ const now = this.dt.now();
969
+
970
+ if (!retryOpts.backoff) {
971
+ return now.add(1, "second").toISOString();
972
+ }
973
+
974
+ if (Array.isArray(retryOpts.backoff)) {
975
+ const delay = this.dt.duration(retryOpts.backoff);
976
+ return now.add(delay).toISOString();
977
+ }
978
+
979
+ const backoff = retryOpts.backoff as WorkflowRetryBackoff;
980
+ const initial = this.dt.duration(backoff.initial).as("milliseconds");
981
+ const factor = backoff.factor ?? 2;
982
+ let delayMs = initial * factor ** (attempt - 1);
983
+
984
+ if (backoff.max) {
985
+ const maxMs = this.dt.duration(backoff.max).as("milliseconds");
986
+ delayMs = Math.min(delayMs, maxMs);
987
+ }
988
+
989
+ if (backoff.jitter) {
990
+ delayMs = delayMs * (0.75 + Math.random() * 0.5);
991
+ }
992
+
993
+ return now.add(delayMs, "millisecond").toISOString();
994
+ }
995
+
996
+ protected async writeLogs(
997
+ stepExecutionId: string,
998
+ context: string,
999
+ ): Promise<void> {
1000
+ const entries = this.logs.get(context);
1001
+ if (!entries || entries.length === 0) return;
1002
+
1003
+ const maxEntries = this.config.logMaxEntries;
1004
+ if (maxEntries === 0) return;
1005
+
1006
+ let logs = entries;
1007
+ if (logs.length > maxEntries) {
1008
+ logs = logs.slice(0, maxEntries);
1009
+ logs.push({
1010
+ level: "WARN",
1011
+ message: `Log entries truncated at ${maxEntries}`,
1012
+ timestamp: this.dt.nowMillis(),
1013
+ service: "alepha.workflows",
1014
+ module: "WorkflowProvider",
1015
+ } as LogEntry);
1016
+ }
1017
+
1018
+ try {
1019
+ await this.stepLogs.create({ id: stepExecutionId, logs });
1020
+ } catch {
1021
+ this.log.warn(`Failed to write logs for step ${stepExecutionId}`);
1022
+ }
1023
+ }
1024
+
1025
+ protected getRegistration(name: string): WorkflowRegistration {
1026
+ const reg = this.workflows.get(name);
1027
+ if (!reg) throw new AlephaError(`Workflow not registered: ${name}`);
1028
+ return reg;
1029
+ }
1030
+
1031
+ // --- Sweeps ---
1032
+
1033
+ public async recoverySweep(): Promise<void> {
1034
+ if (this.stopping) return;
1035
+
1036
+ const lockValue = `${crypto.randomUUID()},${this.dt.nowISOString()}`;
1037
+ const result = await this.lockProvider.set(
1038
+ "_alepha:workflows:recovery-lock",
1039
+ lockValue,
1040
+ true,
1041
+ 300_000,
1042
+ );
1043
+ if (result.split(",")[0] !== lockValue.split(",")[0]) return;
1044
+
1045
+ try {
1046
+ const staleThreshold = this.dt
1047
+ .now()
1048
+ .subtract(this.config.recovery.staleThreshold, "millisecond")
1049
+ .toISOString();
1050
+
1051
+ // Find stale running steps
1052
+ const staleSteps = await this.stepExecutions.findMany({
1053
+ where: {
1054
+ status: { eq: "running" },
1055
+ startedAt: { lte: staleThreshold },
1056
+ },
1057
+ });
1058
+
1059
+ for (const step of staleSteps) {
1060
+ if (
1061
+ this.abortControllers.has(
1062
+ `${step.workflowExecutionId}:${step.stepName}`,
1063
+ )
1064
+ ) {
1065
+ continue;
1066
+ }
1067
+
1068
+ this.log.warn(
1069
+ `Recovery sweep: marking stale step '${step.stepName}' as failed`,
1070
+ { workflowId: step.workflowExecutionId },
1071
+ );
1072
+
1073
+ await this.stepExecutions.updateById(step.id, {
1074
+ status: "failed",
1075
+ error: "Step assumed crashed (recovered by sweep)",
1076
+ completedAt: this.dt.nowISOString(),
1077
+ });
1078
+
1079
+ const workflow = await this.executions.findById(
1080
+ step.workflowExecutionId,
1081
+ );
1082
+ if (!workflow) continue;
1083
+
1084
+ const registration = this.workflows.get(workflow.workflowName);
1085
+ if (!registration) continue;
1086
+
1087
+ const onError = registration.options.onError ?? "compensate";
1088
+ if (onError === "compensate") {
1089
+ await this.compensate(workflow.id, {
1090
+ failedStep: step.stepName,
1091
+ error: new Error("Step assumed crashed"),
1092
+ });
1093
+ } else {
1094
+ await this.executions.updateById(workflow.id, {
1095
+ status: "failed",
1096
+ error: "Step assumed crashed",
1097
+ errorStep: step.stepName,
1098
+ completedAt: this.dt.nowISOString(),
1099
+ });
1100
+ }
1101
+ }
1102
+
1103
+ // Find inconsistent workflows (running but no active steps)
1104
+ const runningWorkflows = await this.executions.findMany({
1105
+ where: { status: { eq: "running" } },
1106
+ });
1107
+
1108
+ for (const wf of runningWorkflows) {
1109
+ const activeSteps = await this.stepExecutions.findMany({
1110
+ where: {
1111
+ workflowExecutionId: { eq: wf.id },
1112
+ status: { inArray: ["running", "pending"] },
1113
+ },
1114
+ limit: 1,
1115
+ });
1116
+
1117
+ if (activeSteps.length === 0) {
1118
+ this.log.warn("Recovery sweep: re-advancing inconsistent workflow", {
1119
+ workflowId: wf.id,
1120
+ });
1121
+ await this.advance(wf.id);
1122
+ }
1123
+ }
1124
+ } catch (e) {
1125
+ this.log.error("Recovery sweep failed", { error: e });
1126
+ } finally {
1127
+ await this.lockProvider.del("_alepha:workflows:recovery-lock");
1128
+ }
1129
+ }
1130
+
1131
+ public async timeoutSweep(): Promise<void> {
1132
+ if (this.stopping) return;
1133
+
1134
+ const lockValue = `${crypto.randomUUID()},${this.dt.nowISOString()}`;
1135
+ const result = await this.lockProvider.set(
1136
+ "_alepha:workflows:timeout-lock",
1137
+ lockValue,
1138
+ true,
1139
+ 60_000,
1140
+ );
1141
+ if (result.split(",")[0] !== lockValue.split(",")[0]) return;
1142
+
1143
+ try {
1144
+ const now = this.dt.nowISOString();
1145
+
1146
+ // Workflow-level timeouts
1147
+ const timedOutWorkflows = await this.executions.findMany({
1148
+ where: {
1149
+ status: { inArray: ["running", "waiting_for_signal"] },
1150
+ deadlineAt: { lte: now },
1151
+ },
1152
+ });
1153
+
1154
+ for (const wf of timedOutWorkflows) {
1155
+ this.log.warn(`Timeout sweep: workflow timed out`, {
1156
+ workflowId: wf.id,
1157
+ });
1158
+
1159
+ // Abort any running step
1160
+ for (const [key, controller] of this.abortControllers) {
1161
+ if (key.startsWith(`${wf.id}:`)) controller.abort();
1162
+ }
1163
+
1164
+ // Mark running steps as failed
1165
+ await this.stepExecutions.updateMany(
1166
+ {
1167
+ workflowExecutionId: { eq: wf.id },
1168
+ status: { inArray: ["running", "waiting"] },
1169
+ },
1170
+ {
1171
+ status: "failed",
1172
+ error: "Workflow timed out",
1173
+ completedAt: now,
1174
+ },
1175
+ );
1176
+
1177
+ await this.executions.updateById(wf.id, {
1178
+ status: "timed_out",
1179
+ completedAt: now,
1180
+ });
1181
+
1182
+ await this.alepha.events.emit(
1183
+ "workflow:timed_out",
1184
+ {
1185
+ workflowName: wf.workflowName,
1186
+ workflowId: wf.id,
1187
+ },
1188
+ { catch: true },
1189
+ );
1190
+
1191
+ const reg = this.workflows.get(wf.workflowName);
1192
+ if (reg?.options.onError === "compensate") {
1193
+ await this.compensate(wf.id, {
1194
+ error: new Error("Workflow timed out"),
1195
+ });
1196
+ }
1197
+ }
1198
+ } catch (e) {
1199
+ this.log.error("Timeout sweep failed", { error: e });
1200
+ } finally {
1201
+ await this.lockProvider.del("_alepha:workflows:timeout-lock");
1202
+ }
1203
+ }
1204
+
1205
+ public async purge(): Promise<void> {
1206
+ if (this.stopping) return;
1207
+ try {
1208
+ const cutoff = this.dt
1209
+ .now()
1210
+ .subtract(this.config.retentionDays, "day")
1211
+ .toISOString();
1212
+
1213
+ const terminalStatuses: WorkflowStatus[] = [
1214
+ "completed",
1215
+ "failed",
1216
+ "compensated",
1217
+ "compensation_failed",
1218
+ "cancelled",
1219
+ "timed_out",
1220
+ ];
1221
+
1222
+ const old = await this.executions.findMany({
1223
+ where: {
1224
+ status: { inArray: terminalStatuses },
1225
+ completedAt: { lte: cutoff },
1226
+ },
1227
+ });
1228
+
1229
+ if (old.length > 0) {
1230
+ const ids = old.map((e) => e.id);
1231
+ // Step logs and step executions cascade-delete with the workflow
1232
+ await this.executions.deleteMany({ id: { inArray: ids } });
1233
+ this.log.info(`Purge: deleted ${ids.length} old workflow executions`);
1234
+ }
1235
+ } catch (e) {
1236
+ this.log.error("Purge failed", { error: e });
1237
+ }
1238
+ }
1239
+
1240
+ // --- Lifecycle ---
1241
+
1242
+ protected readonly onStart = $hook({
1243
+ on: "start",
1244
+ handler: async () => {
1245
+ this.log.info("Workflow engine OK", {
1246
+ dispatch: this.stepDispatch ? "queue" : "inline",
1247
+ workflows: this.workflows.size,
1248
+ });
1249
+
1250
+ // Log capture listener
1251
+ this.alepha.events.on("log", ({ entry }) => {
1252
+ const ctx = entry.context;
1253
+ if (!ctx) return;
1254
+ const entries = this.logs.get(ctx);
1255
+ if (!entries) return;
1256
+ entries.push(entry);
1257
+ });
1258
+ },
1259
+ });
1260
+
1261
+ protected readonly onStop = $hook({
1262
+ on: "stop",
1263
+ handler: async () => {
1264
+ this.stopping = true;
1265
+
1266
+ if (this.inFlight.size > 0) {
1267
+ this.log.info(`Draining ${this.inFlight.size} in-flight step(s)...`);
1268
+ await Promise.race([
1269
+ Promise.allSettled([...this.inFlight]),
1270
+ this.dt.wait([this.config.drainTimeout, "millisecond"]),
1271
+ ]);
1272
+ }
1273
+
1274
+ if (this.abortControllers.size > 0) {
1275
+ this.log.warn(
1276
+ `Aborting ${this.abortControllers.size} remaining step(s)`,
1277
+ );
1278
+ for (const controller of this.abortControllers.values()) {
1279
+ controller.abort();
1280
+ }
1281
+ }
1282
+ },
1283
+ });
1284
+ }