alepha 0.19.3 → 0.19.5
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/assets/swagger-ui/swagger-ui-bundle.js +1 -1
- package/dist/api/invitations/index.d.ts +790 -0
- package/dist/api/invitations/index.d.ts.map +1 -0
- package/dist/api/invitations/index.js +665 -0
- package/dist/api/invitations/index.js.map +1 -0
- package/dist/api/issues/index.d.ts +810 -0
- package/dist/api/issues/index.d.ts.map +1 -0
- package/dist/api/issues/index.js +447 -0
- package/dist/api/issues/index.js.map +1 -0
- package/dist/api/jobs/index.browser.js +8 -9
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +99 -43
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +257 -40
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/notifications/index.browser.js +0 -1
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.d.ts +3 -3
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +0 -1
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.browser.js +112 -1
- package/dist/api/parameters/index.browser.js.map +1 -1
- package/dist/api/parameters/index.d.ts +90 -3
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +79 -12
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/{billing → api/payments}/index.d.ts +67 -49
- package/dist/api/payments/index.d.ts.map +1 -0
- package/dist/{billing → api/payments}/index.js +108 -74
- package/dist/api/payments/index.js.map +1 -0
- package/dist/api/subscriptions/index.d.ts +1692 -0
- package/dist/api/subscriptions/index.d.ts.map +1 -0
- package/dist/api/subscriptions/index.js +1870 -0
- package/dist/api/subscriptions/index.js.map +1 -0
- package/dist/api/users/index.d.ts +24 -2
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +176 -36
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/api/workflows/index.browser.js +246 -0
- package/dist/api/workflows/index.browser.js.map +1 -0
- package/dist/api/workflows/index.d.ts +1618 -0
- package/dist/api/workflows/index.d.ts.map +1 -0
- package/dist/api/workflows/index.js +1504 -0
- package/dist/api/workflows/index.js.map +1 -0
- package/dist/captcha/index.d.ts +142 -0
- package/dist/captcha/index.d.ts.map +1 -0
- package/dist/captcha/index.js +177 -0
- package/dist/captcha/index.js.map +1 -0
- package/dist/cli/core/index.d.ts +126 -30
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +106 -67
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +84 -10
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +92 -4
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +60 -10
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +177 -45
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +2 -3
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js +21 -2
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +33 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +21 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +21 -2
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +21 -2
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js +24 -8
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js +0 -18
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js +6 -23
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +1 -13
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +6 -23
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js +3 -3
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.d.ts.map +1 -1
- package/dist/orm/postgres/index.js +3 -3
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +1 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +8 -4
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +25 -3
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +16 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +25 -3
- package/dist/react/router/index.js.map +1 -1
- package/dist/security/index.d.ts +28 -0
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +28 -0
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +145 -2
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +364 -63
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js.map +1 -1
- package/package.json +47 -20
- package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
- package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
- package/src/api/invitations/controllers/InvitationController.ts +84 -0
- package/src/api/invitations/entities/invitations.ts +33 -0
- package/src/api/invitations/index.ts +65 -0
- package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
- package/src/api/invitations/providers/InvitationProvider.ts +45 -0
- package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
- package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
- package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
- package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
- package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
- package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
- package/src/api/invitations/services/InvitationService.ts +556 -0
- package/src/api/issues/__tests__/IssueService.spec.ts +263 -0
- package/src/api/issues/controllers/AdminIssueController.ts +149 -0
- package/src/api/issues/controllers/IssueController.ts +44 -0
- package/src/api/issues/entities/issues.ts +49 -0
- package/src/api/issues/index.ts +53 -0
- package/src/api/issues/schemas/createIssueSchema.ts +13 -0
- package/src/api/issues/schemas/issueConfigAtom.ts +13 -0
- package/src/api/issues/schemas/issueQuerySchema.ts +18 -0
- package/src/api/issues/schemas/issueResourceSchema.ts +6 -0
- package/src/api/issues/schemas/myIssueQuerySchema.ts +10 -0
- package/src/api/issues/schemas/updateIssueSchema.ts +13 -0
- package/src/api/issues/services/IssueService.ts +264 -0
- package/src/api/jobs/__tests__/$job.spec.ts +876 -0
- package/src/api/jobs/controllers/AdminJobController.ts +44 -0
- package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
- package/src/api/jobs/index.ts +0 -3
- package/src/api/jobs/primitives/$job.ts +22 -11
- package/src/api/jobs/providers/JobProvider.ts +229 -19
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
- package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
- package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
- package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
- package/src/api/jobs/services/JobService.ts +51 -12
- package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
- package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
- package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
- package/src/api/parameters/index.browser.ts +12 -0
- package/src/api/parameters/primitives/$parameter.ts +20 -3
- package/src/api/parameters/services/ParameterProvider.ts +48 -7
- package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
- package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
- package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
- package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
- package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
- package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
- package/src/{billing → api/payments}/index.ts +31 -25
- package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
- package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
- package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
- package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
- package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
- package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
- package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
- package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
- package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
- package/src/api/subscriptions/entities/subscriptions.ts +68 -0
- package/src/api/subscriptions/index.ts +144 -0
- package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
- package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
- package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
- package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
- package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
- package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
- package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
- package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
- package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
- package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
- package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
- package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
- package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
- package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
- package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
- package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
- package/src/api/subscriptions/services/BillingService.ts +437 -0
- package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
- package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
- package/src/api/subscriptions/services/UsageService.ts +118 -0
- package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
- package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
- package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
- package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
- package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
- package/src/api/users/__tests__/SessionService.spec.ts +142 -1
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
- package/src/api/users/controllers/UserController.ts +3 -8
- package/src/api/users/notifications/UserNotifications.ts +23 -0
- package/src/api/users/primitives/$realm.ts +24 -0
- package/src/api/users/schemas/loginSchema.ts +1 -1
- package/src/api/users/services/CredentialService.ts +57 -7
- package/src/api/users/services/RegistrationService.ts +50 -11
- package/src/api/users/services/SessionService.ts +64 -9
- package/src/api/users/services/UserService.ts +21 -12
- package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
- package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
- package/src/api/workflows/entities/workflowExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
- package/src/api/workflows/index.browser.ts +22 -0
- package/src/api/workflows/index.ts +124 -0
- package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
- package/src/api/workflows/primitives/$workflow.ts +202 -0
- package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
- package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
- package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
- package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
- package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
- package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
- package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
- package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
- package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
- package/src/api/workflows/services/WorkflowService.ts +382 -0
- package/src/captcha/__tests__/MemoryCaptchaProvider.spec.ts +74 -0
- package/src/captcha/index.ts +33 -0
- package/src/captcha/providers/CaptchaProvider.ts +17 -0
- package/src/captcha/providers/MemoryCaptchaProvider.ts +65 -0
- package/src/captcha/providers/TurnstileCaptchaProvider.ts +125 -0
- package/src/cli/core/atoms/buildOptions.ts +57 -0
- package/src/cli/core/commands/build.ts +2 -0
- package/src/cli/core/providers/ViteDevServerProvider.ts +1 -1
- package/src/cli/core/services/ViteUtils.ts +5 -2
- package/src/cli/core/tasks/BuildClientTask.ts +3 -1
- package/src/cli/core/tasks/BuildCloudflareTask.ts +4 -0
- package/src/cli/core/tasks/BuildPwaTask.ts +81 -0
- package/src/cli/core/templates/webAppRouterTs.ts +5 -58
- package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -0
- package/src/cli/platform/atoms/platformOptions.ts +19 -3
- package/src/cli/platform/hooks/PlatformHook.ts +51 -0
- package/src/cli/platform/index.ts +1 -0
- package/src/cli/platform/services/CloudflareApi.ts +22 -1
- package/src/cli/platform/services/PlatformOrchestrator.ts +67 -2
- package/src/cli/vendor/__tests__/VendorService.spec.ts +322 -178
- package/src/cli/vendor/commands/VendorCommand.ts +41 -38
- package/src/cli/vendor/services/VendorService.ts +234 -31
- package/src/command/__tests__/CliProvider.spec.ts +45 -0
- package/src/command/providers/CliProvider.ts +3 -4
- package/src/core/__tests__/TypeProvider.spec.ts +4 -2
- package/src/core/providers/SchemaValidator.ts +1 -1
- package/src/core/providers/TypeProvider.ts +46 -3
- package/src/orm/__tests__/enums.spec.ts +22 -29
- package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
- package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
- package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
- package/src/orm/core/services/Repository.ts +20 -6
- package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +83 -0
- package/src/react/i18n/providers/I18nProvider.ts +12 -10
- package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
- package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
- package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
- package/src/security/primitives/$issuer.ts +3 -1
- package/src/security/primitives/$secure.ts +28 -0
- package/src/server/auth/index.ts +7 -0
- package/src/server/auth/primitives/$auth.ts +37 -3
- package/src/server/auth/primitives/$authApple.ts +114 -4
- package/src/server/auth/primitives/$authFacebook.ts +98 -0
- package/src/server/auth/primitives/$authFranceConnect.ts +105 -0
- package/src/server/auth/primitives/$authGithub.ts +22 -16
- package/src/server/auth/primitives/$authMicrosoft.ts +88 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +197 -72
- package/src/server/cookies/providers/ServerCookiesProvider.ts +3 -0
- package/src/server/core/__tests__/ServerRouterProvider-errorHandler.spec.ts +1 -1
- package/src/websocket/providers/NodeWebSocketServerProvider.ts +3 -1
- package/dist/billing/index.d.ts.map +0 -1
- package/dist/billing/index.js.map +0 -1
- package/src/billing/__tests__/BillingService.spec.ts +0 -136
- /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
- /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
- /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
- /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
|
+
}
|