@way_marks/server 0.8.0 → 1.0.0
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/api/server.js +598 -0
- package/dist/approval/manager.js +249 -0
- package/dist/approval/manager.test.js +315 -0
- package/dist/db/database.js +544 -1
- package/dist/escalation/manager.js +205 -0
- package/dist/escalation/manager.test.js +519 -0
- package/dist/notifications/service.js +457 -0
- package/dist/policy/engine.js +420 -0
- package/dist/remediation/recommender.js +309 -0
- package/dist/risk/analyzer.js +442 -0
- package/dist/risk/analyzer.test.js +482 -0
- package/dist/rollback/blocker.js +222 -0
- package/dist/rollback/manager.js +245 -0
- package/dist/rollback/manager.test.js +552 -0
- package/package.json +1 -1
- package/src/ui/index.html +862 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Auto-Block Rules Engine — Phase 4D
|
|
4
|
+
*
|
|
5
|
+
* Automatically blocks risky rollbacks based on:
|
|
6
|
+
* - Risk assessment score
|
|
7
|
+
* - Policy violations
|
|
8
|
+
* - Configured block rules
|
|
9
|
+
*
|
|
10
|
+
* Prevents rollback of:
|
|
11
|
+
* - High-risk operations (score > threshold)
|
|
12
|
+
* - Policy-violating operations
|
|
13
|
+
* - Manual block rules
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.evaluateAutoBlock = evaluateAutoBlock;
|
|
17
|
+
exports.createBlock = createBlock;
|
|
18
|
+
exports.unblockSession = unblockSession;
|
|
19
|
+
exports.getDefaultBlockRules = getDefaultBlockRules;
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate if rollback should be auto-blocked
|
|
22
|
+
*/
|
|
23
|
+
function evaluateAutoBlock(session, actions, riskAssessment, policyViolations, blockRules, riskThreshold = 7.0, userRole = 'user') {
|
|
24
|
+
const blockingRules = [];
|
|
25
|
+
const blockedAt = new Date().toISOString();
|
|
26
|
+
const blockId = generateBlockId();
|
|
27
|
+
// Check risk score threshold
|
|
28
|
+
const isHighRisk = riskAssessment.score >= riskThreshold;
|
|
29
|
+
// Check matching block rules
|
|
30
|
+
const matchingRules = blockRules.filter(rule => rule.enabled && evaluateBlockRuleCondition(rule.condition, actions, session));
|
|
31
|
+
blockingRules.push(...matchingRules);
|
|
32
|
+
// Check policy violations with block action
|
|
33
|
+
const blockingViolations = policyViolations.filter(v => v.action === 'block');
|
|
34
|
+
// Determine if blocked
|
|
35
|
+
const hasBlockingViolations = blockingViolations.length > 0;
|
|
36
|
+
const blocked = isHighRisk || blockingRules.length > 0 || hasBlockingViolations;
|
|
37
|
+
// Generate reason
|
|
38
|
+
let reason = '';
|
|
39
|
+
const reasons = [];
|
|
40
|
+
if (isHighRisk) {
|
|
41
|
+
reasons.push(`Risk score ${riskAssessment.score.toFixed(1)}/10 exceeds threshold ${riskThreshold}`);
|
|
42
|
+
}
|
|
43
|
+
if (blockingRules.length > 0) {
|
|
44
|
+
reasons.push(`${blockingRules.length} block rule(s) matched`);
|
|
45
|
+
}
|
|
46
|
+
if (hasBlockingViolations) {
|
|
47
|
+
reasons.push(`${blockingViolations.length} policy violation(s) require blocking`);
|
|
48
|
+
}
|
|
49
|
+
reason = reasons.join('; ');
|
|
50
|
+
// Determine override capability
|
|
51
|
+
const canOverride = userRole === 'admin' || userRole === 'super_admin';
|
|
52
|
+
const overrideRole = userRole === 'admin' || userRole === 'super_admin' ? 'admin' : 'admin';
|
|
53
|
+
const result = {
|
|
54
|
+
blocked,
|
|
55
|
+
reason: blocked ? reason : undefined,
|
|
56
|
+
blocking_rules: blockingRules,
|
|
57
|
+
policy_violations: blockingViolations,
|
|
58
|
+
risk_score: riskAssessment.score,
|
|
59
|
+
can_override: canOverride,
|
|
60
|
+
override_required_role: overrideRole,
|
|
61
|
+
};
|
|
62
|
+
if (blocked) {
|
|
63
|
+
result.block_id = blockId;
|
|
64
|
+
result.blocked_at = blockedAt;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Evaluate block rule condition
|
|
70
|
+
*/
|
|
71
|
+
function evaluateBlockRuleCondition(condition, actions, session) {
|
|
72
|
+
// Use same logic as policy conditions
|
|
73
|
+
// Check if any action matches
|
|
74
|
+
for (const action of actions) {
|
|
75
|
+
if (matchCondition(condition, action, session)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Simple condition matcher
|
|
83
|
+
*/
|
|
84
|
+
function matchCondition(condition, action, session) {
|
|
85
|
+
switch (condition.type) {
|
|
86
|
+
case 'operation_type':
|
|
87
|
+
case 'tool_name':
|
|
88
|
+
return matchString(action.tool_name, condition.value, condition.operator);
|
|
89
|
+
case 'action_count':
|
|
90
|
+
return matchNumeric(session.action_count, condition.value, condition.operator);
|
|
91
|
+
case 'file_pattern':
|
|
92
|
+
const target = action.target || '';
|
|
93
|
+
return matchString(target, condition.value, condition.operator);
|
|
94
|
+
case 'error_present':
|
|
95
|
+
const hasError = action.status === 'error';
|
|
96
|
+
return hasError === (condition.value === 'true');
|
|
97
|
+
default:
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* String comparison
|
|
103
|
+
*/
|
|
104
|
+
function matchString(actual, expected, operator) {
|
|
105
|
+
if (operator === 'equals' || operator === 'in') {
|
|
106
|
+
if (Array.isArray(expected)) {
|
|
107
|
+
return expected.includes(actual);
|
|
108
|
+
}
|
|
109
|
+
return actual === String(expected);
|
|
110
|
+
}
|
|
111
|
+
if (operator === 'contains') {
|
|
112
|
+
if (Array.isArray(expected)) {
|
|
113
|
+
return expected.some(e => actual.includes(String(e)));
|
|
114
|
+
}
|
|
115
|
+
return actual.includes(String(expected));
|
|
116
|
+
}
|
|
117
|
+
if (operator === 'matches_regex') {
|
|
118
|
+
try {
|
|
119
|
+
const regex = new RegExp(String(expected));
|
|
120
|
+
return regex.test(actual);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Numeric comparison
|
|
130
|
+
*/
|
|
131
|
+
function matchNumeric(actual, expected, operator) {
|
|
132
|
+
const expectedNum = typeof expected === 'number' ? expected : parseInt(String(expected), 10);
|
|
133
|
+
if (isNaN(expectedNum))
|
|
134
|
+
return false;
|
|
135
|
+
switch (operator) {
|
|
136
|
+
case 'equals':
|
|
137
|
+
return actual === expectedNum;
|
|
138
|
+
case 'greater_than':
|
|
139
|
+
return actual > expectedNum;
|
|
140
|
+
case 'less_than':
|
|
141
|
+
return actual < expectedNum;
|
|
142
|
+
default:
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a block for a session
|
|
148
|
+
*/
|
|
149
|
+
function createBlock(sessionId, riskScore, reason, ruleId, policyViolationCount = 0) {
|
|
150
|
+
return {
|
|
151
|
+
block_id: generateBlockId(),
|
|
152
|
+
session_id: sessionId,
|
|
153
|
+
rule_id: ruleId,
|
|
154
|
+
policy_violation_count: policyViolationCount,
|
|
155
|
+
risk_score: riskScore,
|
|
156
|
+
reason,
|
|
157
|
+
blocked_at: new Date().toISOString(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Unblock a session (admin override)
|
|
162
|
+
*/
|
|
163
|
+
function unblockSession(block, unblockedBy, reason, overrideToken) {
|
|
164
|
+
return {
|
|
165
|
+
...block,
|
|
166
|
+
unblocked_at: new Date().toISOString(),
|
|
167
|
+
unblocked_by: unblockedBy,
|
|
168
|
+
unblock_reason: reason,
|
|
169
|
+
override_token: overrideToken,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generate unique block ID
|
|
174
|
+
*/
|
|
175
|
+
function generateBlockId() {
|
|
176
|
+
return `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get default block rules
|
|
180
|
+
*/
|
|
181
|
+
function getDefaultBlockRules() {
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
rule_id: 'block-production-delete-hours',
|
|
185
|
+
name: 'Production Delete During Off-Hours',
|
|
186
|
+
description: 'Block delete operations in production environment during off-hours',
|
|
187
|
+
condition: {
|
|
188
|
+
type: 'operation_type',
|
|
189
|
+
operator: 'equals',
|
|
190
|
+
value: 'delete_file',
|
|
191
|
+
},
|
|
192
|
+
action: 'block',
|
|
193
|
+
severity: 'error',
|
|
194
|
+
message: 'Cannot delete files during off-hours in production',
|
|
195
|
+
enabled: true,
|
|
196
|
+
created_at: new Date().toISOString(),
|
|
197
|
+
created_by: 'system',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
rule_id: 'block-large-batch-delete',
|
|
201
|
+
name: 'Large Batch Delete Protection',
|
|
202
|
+
description: 'Block deletion of more than 20 files in single session',
|
|
203
|
+
condition: {
|
|
204
|
+
type: 'action_count',
|
|
205
|
+
operator: 'greater_than',
|
|
206
|
+
value: 20,
|
|
207
|
+
},
|
|
208
|
+
action: 'require_approval',
|
|
209
|
+
severity: 'warning',
|
|
210
|
+
message: 'Large batch deletion requires approval',
|
|
211
|
+
enabled: true,
|
|
212
|
+
created_at: new Date().toISOString(),
|
|
213
|
+
created_by: 'system',
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
exports.default = {
|
|
218
|
+
evaluateAutoBlock,
|
|
219
|
+
createBlock,
|
|
220
|
+
unblockSession,
|
|
221
|
+
getDefaultBlockRules,
|
|
222
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Rollback Manager
|
|
4
|
+
*
|
|
5
|
+
* Handles atomic, all-or-nothing rollback of entire agent sessions.
|
|
6
|
+
* Restores files from snapshots, records rollback operations, and maintains audit trail.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.validateRollbackable = validateRollbackable;
|
|
43
|
+
exports.createRollbackTransaction = createRollbackTransaction;
|
|
44
|
+
exports.executeRollbackTransaction = executeRollbackTransaction;
|
|
45
|
+
exports.rollbackSession = rollbackSession;
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const database_1 = require("../db/database");
|
|
49
|
+
/**
|
|
50
|
+
* Parse a snapshot JSON to extract file information
|
|
51
|
+
*/
|
|
52
|
+
function parseSnapshot(snapshotJson) {
|
|
53
|
+
if (!snapshotJson)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(snapshotJson);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate that all actions in session are rollbackable
|
|
64
|
+
*/
|
|
65
|
+
function validateRollbackable(actions) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
for (const action of actions) {
|
|
68
|
+
// Check if action is marked as reversible
|
|
69
|
+
if (action.is_reversible === 0) {
|
|
70
|
+
errors.push({
|
|
71
|
+
action_id: action.action_id,
|
|
72
|
+
reason: 'Action marked as non-reversible',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// For write_file actions, check if we have before_snapshot
|
|
76
|
+
if (action.tool_name === 'write_file' && !action.before_snapshot) {
|
|
77
|
+
errors.push({
|
|
78
|
+
action_id: action.action_id,
|
|
79
|
+
reason: 'Missing before_snapshot for write_file action',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// For bash actions, warn if they had side effects (e.g., database mutations)
|
|
83
|
+
if (action.tool_name === 'bash') {
|
|
84
|
+
const inputPayload = JSON.parse(action.input_payload || '{}');
|
|
85
|
+
const command = inputPayload.command || '';
|
|
86
|
+
// List of dangerous patterns that can't be rolled back
|
|
87
|
+
const irreversiblePatterns = [
|
|
88
|
+
/DROP\s+TABLE/i,
|
|
89
|
+
/DELETE\s+FROM/i,
|
|
90
|
+
/TRUNCATE/i,
|
|
91
|
+
/rm\s+-rf/,
|
|
92
|
+
/git\s+push/,
|
|
93
|
+
/npm\s+publish/,
|
|
94
|
+
];
|
|
95
|
+
for (const pattern of irreversiblePatterns) {
|
|
96
|
+
if (pattern.test(command)) {
|
|
97
|
+
errors.push({
|
|
98
|
+
action_id: action.action_id,
|
|
99
|
+
reason: `Bash command contains irreversible operation: "${command.substring(0, 50)}"`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
isValid: errors.length === 0,
|
|
107
|
+
errors,
|
|
108
|
+
warningCount: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Create a rollback transaction (plan, don't execute)
|
|
113
|
+
*/
|
|
114
|
+
function createRollbackTransaction(session_id, actions) {
|
|
115
|
+
const fileRestores = [];
|
|
116
|
+
for (const action of actions) {
|
|
117
|
+
// Only process write_file actions for file restoration
|
|
118
|
+
if (action.tool_name === 'write_file') {
|
|
119
|
+
const snapshot = parseSnapshot(action.before_snapshot);
|
|
120
|
+
if (snapshot) {
|
|
121
|
+
fileRestores.push({
|
|
122
|
+
action_id: action.action_id,
|
|
123
|
+
file_path: snapshot.file_path,
|
|
124
|
+
snapshot,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
session_id,
|
|
131
|
+
actions,
|
|
132
|
+
fileRestores,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Execute rollback transaction (atomic, all-or-nothing)
|
|
137
|
+
*/
|
|
138
|
+
function executeRollbackTransaction(transaction) {
|
|
139
|
+
try {
|
|
140
|
+
// Phase 1: Validate all files can be restored before making any changes
|
|
141
|
+
const projectRoot = process.env.WAYMARK_PROJECT_ROOT || process.cwd();
|
|
142
|
+
for (const restore of transaction.fileRestores) {
|
|
143
|
+
const fullPath = path.resolve(projectRoot, restore.file_path);
|
|
144
|
+
// Check write permissions
|
|
145
|
+
if (fs.existsSync(fullPath)) {
|
|
146
|
+
try {
|
|
147
|
+
// Try to stat the file to ensure we can access it
|
|
148
|
+
fs.statSync(fullPath);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
throw new Error(`Cannot access file for rollback: ${fullPath}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Phase 2: Restore all files (all-or-nothing)
|
|
156
|
+
let filesRestored = 0;
|
|
157
|
+
for (const restore of transaction.fileRestores) {
|
|
158
|
+
const fullPath = path.resolve(projectRoot, restore.file_path);
|
|
159
|
+
if (restore.snapshot.existed === false) {
|
|
160
|
+
// File didn't exist before, so delete it
|
|
161
|
+
if (fs.existsSync(fullPath)) {
|
|
162
|
+
fs.unlinkSync(fullPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// File existed, restore its content
|
|
167
|
+
const dir = path.dirname(fullPath);
|
|
168
|
+
if (!fs.existsSync(dir)) {
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
fs.writeFileSync(fullPath, restore.snapshot.content || '', 'utf-8');
|
|
172
|
+
}
|
|
173
|
+
filesRestored++;
|
|
174
|
+
}
|
|
175
|
+
// Phase 3: Mark actions as rolled back in database
|
|
176
|
+
for (const action of transaction.actions) {
|
|
177
|
+
(0, database_1.markRolledBack)(action.action_id);
|
|
178
|
+
}
|
|
179
|
+
// Phase 4: Mark session as rolled back
|
|
180
|
+
(0, database_1.markSessionRolledBack)(transaction.session_id);
|
|
181
|
+
return {
|
|
182
|
+
success: true,
|
|
183
|
+
filesRestored,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
filesRestored: 0,
|
|
190
|
+
error: error.message,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Rollback entire session (high-level API)
|
|
196
|
+
*
|
|
197
|
+
* Steps:
|
|
198
|
+
* 1. Get all actions in session
|
|
199
|
+
* 2. Validate all are reversible
|
|
200
|
+
* 3. Create rollback transaction
|
|
201
|
+
* 4. Execute rollback (atomic)
|
|
202
|
+
* 5. Record audit trail
|
|
203
|
+
*/
|
|
204
|
+
function rollbackSession(session_id) {
|
|
205
|
+
try {
|
|
206
|
+
// Get actions
|
|
207
|
+
const actions = (0, database_1.getSessionActions)(session_id);
|
|
208
|
+
if (actions.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
message: `Session ${session_id} has no actions to rollback`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// Validate
|
|
215
|
+
const validation = validateRollbackable(actions);
|
|
216
|
+
if (!validation.isValid) {
|
|
217
|
+
const errorList = validation.errors.map((e) => `${e.action_id}: ${e.reason}`).join('; ');
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
message: `Cannot rollback session: ${errorList}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// Create transaction
|
|
224
|
+
const transaction = createRollbackTransaction(session_id, actions);
|
|
225
|
+
// Execute transaction
|
|
226
|
+
const result = executeRollbackTransaction(transaction);
|
|
227
|
+
if (!result.success) {
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
message: result.error || 'Unknown error during rollback',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
success: true,
|
|
235
|
+
message: `Successfully rolled back ${actions.length} action(s), restored ${result.filesRestored} file(s)`,
|
|
236
|
+
filesRestored: result.filesRestored,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return {
|
|
241
|
+
success: false,
|
|
242
|
+
message: `Rollback failed: ${error.message}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|