@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.
- package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.md +125 -36
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +42 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +0 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/check-dev-env.js +218 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/page-manage.ts +78 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +37 -67
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +0 -17
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +60 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-callback.controller.ts +3 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/workflow-callback-url.controller.ts +9 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +55 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.module.ts +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/page-transfer.service.ts +491 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.dto.ts +213 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.module.ts +28 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/resource-transfer.service.ts +137 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/role-transfer.service.ts +248 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-token.service.ts +65 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/user-transfer.service.ts +146 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +55 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.module.ts +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +55 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.module.ts +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.spec.ts +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.ts +3 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +55 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.module.ts +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +96 -14
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +41 -41
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.spec.ts +36 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.ts +90 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +4 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +115 -63
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.spec.ts +166 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.ts +217 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +33 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +57 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +196 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.ts +125 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-node-type.controller.ts +9 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +148 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +12 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +14 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +75 -45
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +25 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferExportModal.tsx +115 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +257 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +40 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +15 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +40 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +15 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agendaJob/list.tsx +89 -65
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +51 -127
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +53 -109
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +45 -41
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +51 -72
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +49 -40
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +16 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +28 -10
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +60 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +94 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +13 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +3 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +8 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +167 -94
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +95 -95
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.test.tsx +81 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.tsx +126 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +48 -52
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -3
- package/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.local.md +0 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/ac.config.ts +0 -24
- 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
|
+
});
|