@gadmin2n/schematics 0.0.150 → 0.0.152

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 (82) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.md +125 -36
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +42 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +0 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -0
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/check-dev-env.js +218 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/page-manage.ts +78 -12
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +37 -67
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +0 -17
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +46 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +60 -1
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-callback.controller.ts +3 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/workflow-callback-url.controller.ts +9 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +55 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.module.ts +2 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/page-transfer.service.ts +491 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.dto.ts +213 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.module.ts +28 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/resource-transfer.service.ts +137 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/role-transfer.service.ts +248 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-token.service.ts +65 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/user-transfer.service.ts +146 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +55 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.module.ts +2 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +55 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.module.ts +2 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.spec.ts +46 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.ts +3 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +55 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.module.ts +2 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +1 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +96 -14
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +41 -41
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.spec.ts +36 -0
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.ts +90 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +4 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +1 -0
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +115 -63
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.spec.ts +166 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.ts +217 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +33 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +57 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +196 -0
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.ts +125 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-node-type.controller.ts +9 -3
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +148 -4
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +12 -1
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +14 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +75 -45
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +25 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferExportModal.tsx +115 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +257 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -2
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +40 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +15 -13
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +40 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +15 -13
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +2 -1
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agendaJob/list.tsx +89 -65
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +51 -127
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +53 -109
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +45 -41
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +51 -72
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +49 -40
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +16 -5
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +28 -10
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +60 -1
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +94 -12
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +13 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +3 -3
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +8 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +167 -94
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +95 -95
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.test.tsx +81 -0
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.tsx +126 -0
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +48 -52
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +2 -2
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -3
  79. package/package.json +1 -1
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.local.md +0 -8
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/ac.config.ts +0 -24
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/SqlModal.tsx +0 -495
@@ -0,0 +1,166 @@
1
+ import axios from 'axios';
2
+ import { WorkflowLifecycleOutboxService } from './workflow-lifecycle-outbox.service';
3
+
4
+ jest.mock('axios');
5
+
6
+ describe('WorkflowLifecycleOutboxService', () => {
7
+ const row = {
8
+ id: BigInt(5),
9
+ eventKey: 'workflow-instance:45:FAILED',
10
+ workflowInstanceId: BigInt(45),
11
+ workflowCode: 'asset-disposal-approval',
12
+ businessReference: {
13
+ schema: 'feature_myoa3',
14
+ table: 't_request',
15
+ operation: 'INSERT',
16
+ primaryKey: { id: 7 },
17
+ },
18
+ connectionRef: 'hardware-service',
19
+ callbackPath: '/api/hardware-disposal/workflow-failed',
20
+ failedAt: new Date('2026-09-03T08:00:00.000Z'),
21
+ retryCount: 0,
22
+ lockedBy: 'worker-a:claim-1',
23
+ };
24
+
25
+ function setup() {
26
+ const outbox = {
27
+ findMany: jest
28
+ .fn()
29
+ .mockResolvedValueOnce([row])
30
+ .mockResolvedValueOnce([row]),
31
+ updateMany: jest.fn().mockResolvedValue({ count: 1 }),
32
+ };
33
+ const prisma = {
34
+ workflowLifecycleOutbox: outbox,
35
+ $transaction: jest.fn(async (callback: any) =>
36
+ callback({ workflowLifecycleOutbox: outbox }),
37
+ ),
38
+ };
39
+ const connections = {
40
+ resolveRuntime: jest.fn().mockResolvedValue({
41
+ baseUrl: 'https://business.example.com',
42
+ authType: 'NONE',
43
+ publicConfig: {},
44
+ secrets: {},
45
+ }),
46
+ };
47
+ return {
48
+ connections,
49
+ outbox,
50
+ service: new WorkflowLifecycleOutboxService(
51
+ prisma as any,
52
+ connections as any,
53
+ ),
54
+ };
55
+ }
56
+
57
+ beforeEach(() => {
58
+ jest.clearAllMocks();
59
+ (axios.isAxiosError as unknown as jest.Mock).mockImplementation((error) =>
60
+ Boolean(error?.isAxiosError),
61
+ );
62
+ });
63
+
64
+ it('marks a 2xx callback completed and sends only the public contract', async () => {
65
+ const { outbox, service } = setup();
66
+ (axios.request as jest.Mock).mockResolvedValue({ status: 204 });
67
+
68
+ await service.poll();
69
+
70
+ expect(axios.request).toHaveBeenCalledWith(
71
+ expect.objectContaining({
72
+ url: 'https://business.example.com/api/hardware-disposal/workflow-failed',
73
+ method: 'POST',
74
+ headers: expect.objectContaining({
75
+ 'Idempotency-Key': 'workflow-instance:45:FAILED',
76
+ }),
77
+ data: {
78
+ eventId: 'workflow-instance:45:FAILED',
79
+ workflowCode: 'asset-disposal-approval',
80
+ instanceId: '45',
81
+ businessReference: row.businessReference,
82
+ status: 'FAILED',
83
+ failureCode: 'WORKFLOW_EXECUTION_FAILED',
84
+ failedAt: '2026-09-03T08:00:00.000Z',
85
+ },
86
+ }),
87
+ );
88
+ expect(outbox.updateMany).toHaveBeenLastCalledWith(
89
+ expect.objectContaining({
90
+ where: {
91
+ id: BigInt(5),
92
+ status: 'PROCESSING',
93
+ lockedBy: 'worker-a:claim-1',
94
+ },
95
+ data: expect.objectContaining({ status: 'COMPLETED' }),
96
+ }),
97
+ );
98
+ const payload = (axios.request as jest.Mock).mock.calls[0][0].data;
99
+ expect(payload).not.toHaveProperty('sourceOutboxId');
100
+ expect(payload).not.toHaveProperty('temporalRunId');
101
+ expect(payload).not.toHaveProperty('nodeId');
102
+ expect(payload).not.toHaveProperty('context');
103
+ });
104
+
105
+ it.each([500, 503])('retries HTTP %s responses', async (status) => {
106
+ const { outbox, service } = setup();
107
+ (axios.request as jest.Mock).mockResolvedValue({ status });
108
+
109
+ await service.poll();
110
+
111
+ expect(outbox.updateMany).toHaveBeenLastCalledWith(
112
+ expect.objectContaining({
113
+ data: expect.objectContaining({
114
+ status: 'PENDING',
115
+ retryCount: 1,
116
+ lastError: `Business failure callback returned HTTP ${status}`,
117
+ }),
118
+ }),
119
+ );
120
+ });
121
+
122
+ it('retries a network error with the same idempotency key', async () => {
123
+ const { outbox, service } = setup();
124
+ const networkError = Object.assign(new Error('socket closed'), {
125
+ isAxiosError: true,
126
+ });
127
+ (axios.request as jest.Mock)
128
+ .mockRejectedValueOnce(networkError)
129
+ .mockResolvedValueOnce({ status: 200 });
130
+
131
+ await service.poll();
132
+ outbox.findMany
133
+ .mockResolvedValueOnce([{ ...row, retryCount: 1 }])
134
+ .mockResolvedValueOnce([{ ...row, retryCount: 1 }]);
135
+ await service.poll();
136
+
137
+ expect(axios.request).toHaveBeenCalledTimes(2);
138
+ expect(
139
+ (axios.request as jest.Mock).mock.calls.map(
140
+ ([request]) => request.headers['Idempotency-Key'],
141
+ ),
142
+ ).toEqual(['workflow-instance:45:FAILED', 'workflow-instance:45:FAILED']);
143
+ expect(outbox.updateMany).toHaveBeenLastCalledWith(
144
+ expect.objectContaining({
145
+ data: expect.objectContaining({ status: 'COMPLETED' }),
146
+ }),
147
+ );
148
+ });
149
+
150
+ it('marks a deterministic 4xx response permanently failed', async () => {
151
+ const { outbox, service } = setup();
152
+ (axios.request as jest.Mock).mockResolvedValue({ status: 409 });
153
+
154
+ await service.poll();
155
+
156
+ expect(outbox.updateMany).toHaveBeenLastCalledWith(
157
+ expect.objectContaining({
158
+ data: expect.objectContaining({
159
+ status: 'FAILED',
160
+ retryCount: 1,
161
+ lastError: 'Business failure callback returned HTTP 409',
162
+ }),
163
+ }),
164
+ );
165
+ });
166
+ });
@@ -0,0 +1,217 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { Interval } from '@nestjs/schedule';
3
+ import axios from 'axios';
4
+ import { PrismaService } from 'nestjs-prisma';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { Agent as HttpsAgent } from 'node:https';
7
+ import { materializeHttpRequest } from '../httpRequest/http-request-runtime.util';
8
+ import {
9
+ OUTBOX_MAX_RETRY_COUNT,
10
+ outboxError,
11
+ outboxRetryAt,
12
+ } from '../humanApproval/outbox-retry.util';
13
+ import { IntegrationConnectionService } from '../integrationConnection/integration-connection.service';
14
+
15
+ const BATCH_SIZE = 25;
16
+ const LOCK_TIMEOUT_MS = 60_000;
17
+
18
+ interface LifecycleOutboxRow {
19
+ id: bigint;
20
+ eventKey: string;
21
+ workflowInstanceId: bigint;
22
+ workflowCode: string;
23
+ businessReference: unknown;
24
+ connectionRef: string;
25
+ callbackPath: string;
26
+ failedAt: Date;
27
+ retryCount: number;
28
+ lockedBy: string | null;
29
+ }
30
+
31
+ @Injectable()
32
+ export class WorkflowLifecycleOutboxService {
33
+ private readonly logger = new Logger(WorkflowLifecycleOutboxService.name);
34
+ private readonly workerId = `workflow-lifecycle-${
35
+ process.pid
36
+ }-${randomUUID()}`;
37
+
38
+ constructor(
39
+ private readonly prisma: PrismaService,
40
+ private readonly connections: IntegrationConnectionService,
41
+ ) {}
42
+
43
+ @Interval(5000)
44
+ async poll(): Promise<void> {
45
+ const rows = await this.claim();
46
+ for (const row of rows) await this.dispatch(row);
47
+ }
48
+
49
+ private claim(): Promise<LifecycleOutboxRow[]> {
50
+ const now = new Date();
51
+ const staleAt = new Date(now.getTime() - LOCK_TIMEOUT_MS);
52
+ const claimId = `${this.workerId}:${randomUUID()}`.slice(0, 128);
53
+ return this.prisma.$transaction(async (tx) => {
54
+ const candidates = await tx.workflowLifecycleOutbox.findMany({
55
+ where: {
56
+ nextAttemptAt: { lte: now },
57
+ OR: [
58
+ { status: 'PENDING', lockedAt: null },
59
+ { status: 'PROCESSING', lockedAt: { lt: staleAt } },
60
+ ],
61
+ },
62
+ orderBy: { id: 'asc' },
63
+ take: BATCH_SIZE,
64
+ });
65
+ if (!candidates.length) return [];
66
+ const ids = candidates.map(({ id }) => id);
67
+ await tx.workflowLifecycleOutbox.updateMany({
68
+ where: {
69
+ id: { in: ids },
70
+ OR: [
71
+ { status: 'PENDING', lockedAt: null },
72
+ { status: 'PROCESSING', lockedAt: { lt: staleAt } },
73
+ ],
74
+ },
75
+ data: { status: 'PROCESSING', lockedAt: now, lockedBy: claimId },
76
+ });
77
+ return tx.workflowLifecycleOutbox.findMany({
78
+ where: { id: { in: ids }, status: 'PROCESSING', lockedBy: claimId },
79
+ orderBy: { id: 'asc' },
80
+ });
81
+ });
82
+ }
83
+
84
+ private async dispatch(row: LifecycleOutboxRow): Promise<void> {
85
+ try {
86
+ const connection = await this.connections.resolveRuntime(
87
+ row.connectionRef,
88
+ );
89
+ const request = materializeHttpRequest(
90
+ {
91
+ connectionRef: row.connectionRef,
92
+ method: 'POST',
93
+ path: row.callbackPath,
94
+ bodyMode: 'JSON',
95
+ body: lifecycleFailurePayload(row),
96
+ timeoutSeconds: 30,
97
+ },
98
+ connection,
99
+ { input: {}, workflow: {} },
100
+ );
101
+ for (const key of Object.keys(request.headers)) {
102
+ if (key.toLowerCase() === 'idempotency-key')
103
+ delete request.headers[key];
104
+ }
105
+ request.headers['Idempotency-Key'] = row.eventKey;
106
+
107
+ const response = await axios.request({
108
+ url: request.url,
109
+ method: request.method,
110
+ params: request.query,
111
+ headers: request.headers,
112
+ data: request.body,
113
+ timeout: request.timeoutMs,
114
+ ...(request.ignoreSslErrors && {
115
+ httpsAgent: new HttpsAgent({ rejectUnauthorized: false }),
116
+ }),
117
+ maxRedirects: 5,
118
+ validateStatus: () => true,
119
+ });
120
+ if (response.status >= 200 && response.status < 300) {
121
+ await this.complete(row);
122
+ return;
123
+ }
124
+ const message = `Business failure callback returned HTTP ${response.status}`;
125
+ if (response.status >= 500) {
126
+ await this.retry(row, message);
127
+ return;
128
+ }
129
+ await this.failPermanently(row, row.retryCount + 1, message);
130
+ } catch (error) {
131
+ if (axios.isAxiosError(error)) {
132
+ await this.retry(row, outboxError(error));
133
+ return;
134
+ }
135
+ await this.failPermanently(row, row.retryCount + 1, outboxError(error));
136
+ }
137
+ }
138
+
139
+ private async complete(row: LifecycleOutboxRow): Promise<void> {
140
+ await this.prisma.workflowLifecycleOutbox.updateMany({
141
+ where: {
142
+ id: row.id,
143
+ status: 'PROCESSING',
144
+ lockedBy: row.lockedBy,
145
+ },
146
+ data: {
147
+ status: 'COMPLETED',
148
+ processedAt: new Date(),
149
+ lockedAt: null,
150
+ lockedBy: null,
151
+ lastError: null,
152
+ },
153
+ });
154
+ }
155
+
156
+ private async retry(row: LifecycleOutboxRow, message: string): Promise<void> {
157
+ const retryCount = row.retryCount + 1;
158
+ if (retryCount >= OUTBOX_MAX_RETRY_COUNT) {
159
+ await this.failPermanently(row, retryCount, message);
160
+ return;
161
+ }
162
+ await this.prisma.workflowLifecycleOutbox.updateMany({
163
+ where: {
164
+ id: row.id,
165
+ status: 'PROCESSING',
166
+ lockedBy: row.lockedBy,
167
+ },
168
+ data: {
169
+ status: 'PENDING',
170
+ retryCount,
171
+ nextAttemptAt: outboxRetryAt(retryCount),
172
+ lockedAt: null,
173
+ lockedBy: null,
174
+ lastError: message,
175
+ },
176
+ });
177
+ this.logger.warn(
178
+ `Workflow lifecycle event ${row.eventKey} delivery failed and will retry: ${message}`,
179
+ );
180
+ }
181
+
182
+ private async failPermanently(
183
+ row: LifecycleOutboxRow,
184
+ retryCount: number,
185
+ message: string,
186
+ ): Promise<void> {
187
+ await this.prisma.workflowLifecycleOutbox.updateMany({
188
+ where: {
189
+ id: row.id,
190
+ status: 'PROCESSING',
191
+ lockedBy: row.lockedBy,
192
+ },
193
+ data: {
194
+ status: 'FAILED',
195
+ retryCount,
196
+ lockedAt: null,
197
+ lockedBy: null,
198
+ lastError: message,
199
+ },
200
+ });
201
+ this.logger.warn(
202
+ `Workflow lifecycle outbox ${row.id} failed permanently: ${message}`,
203
+ );
204
+ }
205
+ }
206
+
207
+ function lifecycleFailurePayload(row: LifecycleOutboxRow) {
208
+ return {
209
+ eventId: row.eventKey,
210
+ workflowCode: row.workflowCode,
211
+ instanceId: row.workflowInstanceId.toString(),
212
+ businessReference: row.businessReference,
213
+ status: 'FAILED',
214
+ failureCode: 'WORKFLOW_EXECUTION_FAILED',
215
+ failedAt: row.failedAt.toISOString(),
216
+ };
217
+ }
@@ -0,0 +1,33 @@
1
+ import { WorkflowLifecycleRuntimeController } from './workflow-lifecycle-runtime.controller';
2
+
3
+ describe('WorkflowLifecycleRuntimeController', () => {
4
+ it('routes Worker failure reports through the transactional lifecycle service', async () => {
5
+ const prisma = {
6
+ workflowInstance: {
7
+ findUnique: jest.fn().mockResolvedValue({ status: 'FAILED' }),
8
+ },
9
+ };
10
+ const runtimeConfig = { assertTenant: jest.fn() };
11
+ const lifecycle = { markFailed: jest.fn().mockResolvedValue(true) };
12
+ const controller = new WorkflowLifecycleRuntimeController(
13
+ prisma as any,
14
+ runtimeConfig as any,
15
+ lifecycle as any,
16
+ );
17
+
18
+ await expect(
19
+ controller.reportFailed({
20
+ tenantNamespace: 'gadmin-test',
21
+ workflowInstanceId: '45',
22
+ result: { error: 'dynamic_users is required' },
23
+ }),
24
+ ).resolves.toEqual({ status: 'FAILED' });
25
+
26
+ expect(runtimeConfig.assertTenant).toHaveBeenCalledWith('gadmin-test');
27
+ expect(lifecycle.markFailed).toHaveBeenCalledWith({
28
+ instanceId: BigInt(45),
29
+ expectedStatuses: ['PENDING', 'RUNNING'],
30
+ result: { error: 'dynamic_users is required' },
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,57 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ NotFoundException,
5
+ Post,
6
+ UseGuards,
7
+ } from '@nestjs/common';
8
+ import { Prisma } from '@prisma/client';
9
+ import { IsObject, IsOptional, IsString, Matches } from 'class-validator';
10
+ import { PrismaService } from 'nestjs-prisma';
11
+ import { AllowUnauthorizedRequest } from '../../lib/auth.guard';
12
+ import { RuntimeTokenGuard } from '../humanApproval/runtime/runtime-token.guard';
13
+ import { WorkflowRuntimeConfigService } from '../workflowRuntimeConfig/workflow-runtime-config.service';
14
+ import { WorkflowLifecycleService } from './workflow-lifecycle.service';
15
+
16
+ export class ReportWorkflowFailureRuntimeDto {
17
+ @IsString()
18
+ tenantNamespace!: string;
19
+
20
+ @IsString()
21
+ @Matches(/^[1-9][0-9]*$/)
22
+ workflowInstanceId!: string;
23
+
24
+ @IsOptional()
25
+ @IsObject()
26
+ result?: Record<string, unknown>;
27
+ }
28
+
29
+ @Controller('workflow-runtime/lifecycle')
30
+ @UseGuards(RuntimeTokenGuard)
31
+ export class WorkflowLifecycleRuntimeController {
32
+ constructor(
33
+ private readonly prisma: PrismaService,
34
+ private readonly runtimeConfig: WorkflowRuntimeConfigService,
35
+ private readonly lifecycle: WorkflowLifecycleService,
36
+ ) {}
37
+
38
+ @Post('failed')
39
+ @AllowUnauthorizedRequest()
40
+ async reportFailed(@Body() input: ReportWorkflowFailureRuntimeDto) {
41
+ await this.runtimeConfig.assertTenant(input.tenantNamespace);
42
+ const instanceId = BigInt(input.workflowInstanceId);
43
+ await this.lifecycle.markFailed({
44
+ instanceId,
45
+ expectedStatuses: ['PENDING', 'RUNNING'],
46
+ result: (input.result ?? {
47
+ code: 'WORKFLOW_EXECUTION_FAILED',
48
+ }) as Prisma.InputJsonObject,
49
+ });
50
+ const instance = await this.prisma.workflowInstance.findUnique({
51
+ where: { id: instanceId },
52
+ select: { status: true },
53
+ });
54
+ if (!instance) throw new NotFoundException('Workflow instance not found');
55
+ return { status: instance.status };
56
+ }
57
+ }
@@ -0,0 +1,196 @@
1
+ import { WorkflowLifecycleService } from './workflow-lifecycle.service';
2
+
3
+ describe('WorkflowLifecycleService', () => {
4
+ const failedAt = new Date('2026-09-03T08:00:00.000Z');
5
+
6
+ function setup(overrides: { sourceOutboxId?: bigint | null } = {}) {
7
+ const tx = {
8
+ workflowInstance: {
9
+ updateMany: jest.fn().mockResolvedValue({ count: 1 }),
10
+ findUniqueOrThrow: jest.fn().mockResolvedValue({
11
+ versionId: BigInt(9),
12
+ sourceOutboxId:
13
+ overrides.sourceOutboxId === undefined
14
+ ? BigInt(17)
15
+ : overrides.sourceOutboxId,
16
+ workflow: { code: 'asset-disposal-approval' },
17
+ }),
18
+ },
19
+ workflowVersion: {
20
+ findUniqueOrThrow: jest.fn().mockResolvedValue({
21
+ dsl: {
22
+ nodes: [
23
+ {
24
+ type: 'event_trigger',
25
+ config: {
26
+ failureCallback: {
27
+ connectionRef: 'hardware-service',
28
+ path: '/api/hardware-disposal/workflow-failed',
29
+ },
30
+ },
31
+ },
32
+ ],
33
+ },
34
+ }),
35
+ },
36
+ workflowEventOutbox: {
37
+ findUnique: jest.fn().mockResolvedValue({
38
+ schemaName: 'feature_myoa3',
39
+ tableName: 't_request',
40
+ operation: 'INSERT',
41
+ primaryKey: { id: 7 },
42
+ }),
43
+ },
44
+ workflowLifecycleOutbox: { upsert: jest.fn().mockResolvedValue({}) },
45
+ };
46
+ const prisma = {
47
+ $transaction: jest.fn(async (callback: (client: typeof tx) => unknown) =>
48
+ callback(tx),
49
+ ),
50
+ };
51
+ return {
52
+ prisma,
53
+ service: new WorkflowLifecycleService(prisma as any),
54
+ tx,
55
+ };
56
+ }
57
+
58
+ it('commits FAILED and its public lifecycle event in one transaction', async () => {
59
+ const { prisma, service, tx } = setup();
60
+
61
+ await expect(
62
+ service.markFailed({
63
+ instanceId: BigInt(45),
64
+ expectedStatuses: ['PENDING', 'RUNNING'],
65
+ temporalRunId: 'wf-45',
66
+ result: { code: 'TEMPORAL_TERMINAL_STATUS_RECONCILED' },
67
+ finishedAt: failedAt,
68
+ }),
69
+ ).resolves.toBe(true);
70
+
71
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
72
+ expect(tx.workflowInstance.updateMany).toHaveBeenCalledWith({
73
+ where: {
74
+ id: BigInt(45),
75
+ status: { in: ['PENDING', 'RUNNING'] },
76
+ temporalRunId: 'wf-45',
77
+ },
78
+ data: {
79
+ status: 'FAILED',
80
+ result: { code: 'TEMPORAL_TERMINAL_STATUS_RECONCILED' },
81
+ finishedAt: failedAt,
82
+ },
83
+ });
84
+ expect(tx.workflowLifecycleOutbox.upsert).toHaveBeenCalledWith({
85
+ where: { eventKey: 'workflow-instance:45:FAILED' },
86
+ create: expect.objectContaining({
87
+ eventKey: 'workflow-instance:45:FAILED',
88
+ workflowInstanceId: BigInt(45),
89
+ eventType: 'FAILED',
90
+ workflowCode: 'asset-disposal-approval',
91
+ businessReference: {
92
+ schema: 'feature_myoa3',
93
+ table: 't_request',
94
+ operation: 'INSERT',
95
+ primaryKey: { id: 7 },
96
+ },
97
+ connectionRef: 'hardware-service',
98
+ callbackPath: '/api/hardware-disposal/workflow-failed',
99
+ failedAt,
100
+ }),
101
+ update: {},
102
+ });
103
+ const create = tx.workflowLifecycleOutbox.upsert.mock.calls[0][0].create;
104
+ expect(create).not.toHaveProperty('sourceOutboxId');
105
+ expect(create).not.toHaveProperty('temporalRunId');
106
+ expect(create).not.toHaveProperty('context');
107
+ });
108
+
109
+ it('rolls back the FAILED state when lifecycle outbox creation fails', async () => {
110
+ let persistedStatus = 'PENDING';
111
+ const { service, tx } = setup();
112
+ tx.workflowLifecycleOutbox.upsert.mockRejectedValueOnce(
113
+ new Error('outbox insert failed'),
114
+ );
115
+ (tx.workflowInstance.updateMany as jest.Mock).mockImplementationOnce(
116
+ async () => {
117
+ persistedStatus = 'FAILED';
118
+ return { count: 1 };
119
+ },
120
+ );
121
+ const originalTransaction = (service as any).prisma.$transaction;
122
+ originalTransaction.mockImplementationOnce(async (callback: any) => {
123
+ const before = persistedStatus;
124
+ try {
125
+ return await callback(tx);
126
+ } catch (error) {
127
+ persistedStatus = before;
128
+ throw error;
129
+ }
130
+ });
131
+
132
+ await expect(
133
+ service.markFailed({
134
+ instanceId: BigInt(45),
135
+ expectedStatuses: ['RUNNING'],
136
+ result: { code: 'FAILED' },
137
+ }),
138
+ ).rejects.toThrow('outbox insert failed');
139
+ expect(persistedStatus).toBe('PENDING');
140
+ });
141
+
142
+ it('uses the status CAS and unique event key to avoid duplicate events', async () => {
143
+ const { service, tx } = setup();
144
+ tx.workflowInstance.updateMany
145
+ .mockResolvedValueOnce({ count: 1 })
146
+ .mockResolvedValueOnce({ count: 0 });
147
+
148
+ await expect(
149
+ service.markFailed({
150
+ instanceId: BigInt(45),
151
+ expectedStatuses: ['RUNNING'],
152
+ result: { code: 'FAILED' },
153
+ }),
154
+ ).resolves.toBe(true);
155
+ await expect(
156
+ service.markFailed({
157
+ instanceId: BigInt(45),
158
+ expectedStatuses: ['RUNNING'],
159
+ result: { code: 'FAILED' },
160
+ }),
161
+ ).resolves.toBe(false);
162
+
163
+ expect(tx.workflowLifecycleOutbox.upsert).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('does not create a callback event for a non-event-triggered instance', async () => {
167
+ const { service, tx } = setup({ sourceOutboxId: null });
168
+
169
+ await expect(
170
+ service.markFailed({
171
+ instanceId: BigInt(45),
172
+ expectedStatuses: ['PENDING'],
173
+ result: { code: 'FAILED' },
174
+ }),
175
+ ).resolves.toBe(true);
176
+
177
+ expect(tx.workflowVersion.findUniqueOrThrow).not.toHaveBeenCalled();
178
+ expect(tx.workflowLifecycleOutbox.upsert).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('marks an event-triggered instance failed without an outbox when no callback is configured', async () => {
182
+ const { service, tx } = setup();
183
+ tx.workflowVersion.findUniqueOrThrow.mockResolvedValueOnce({
184
+ dsl: { nodes: [{ type: 'event_trigger', config: {} }] },
185
+ });
186
+
187
+ await expect(
188
+ service.markFailed({
189
+ instanceId: BigInt(45),
190
+ expectedStatuses: ['RUNNING'],
191
+ result: { code: 'FAILED' },
192
+ }),
193
+ ).resolves.toBe(true);
194
+ expect(tx.workflowLifecycleOutbox.upsert).not.toHaveBeenCalled();
195
+ });
196
+ });