scene-capability-engine 3.0.8 → 3.2.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/CHANGELOG.md +55 -0
- package/docs/331-poc-adaptation-roadmap.md +21 -2
- package/docs/331-poc-dual-track-integration-guide.md +10 -6
- package/docs/331-poc-weekly-delivery-checklist.md +5 -0
- package/docs/README.md +6 -0
- package/docs/command-reference.md +262 -4
- package/docs/handoff-profile-integration-guide.md +88 -0
- package/docs/interactive-customization/331-poc-sce-integration-checklist.md +148 -0
- package/docs/interactive-customization/README.md +362 -0
- package/docs/interactive-customization/adapter-extension-contract.md +55 -0
- package/docs/interactive-customization/adapter-extension-contract.sample.json +59 -0
- package/docs/interactive-customization/adapter-extension-contract.schema.json +192 -0
- package/docs/interactive-customization/approval-role-policy-baseline.json +36 -0
- package/docs/interactive-customization/change-intent.schema.json +72 -0
- package/docs/interactive-customization/change-plan.sample.json +41 -0
- package/docs/interactive-customization/change-plan.schema.json +125 -0
- package/docs/interactive-customization/cross-industry-replication-guide.md +49 -0
- package/docs/interactive-customization/dialogue-governance-policy-baseline.json +49 -0
- package/docs/interactive-customization/domain-pack-extension-flow.md +71 -0
- package/docs/interactive-customization/execution-record.schema.json +62 -0
- package/docs/interactive-customization/governance-alert-playbook.md +51 -0
- package/docs/interactive-customization/governance-report-template.md +46 -0
- package/docs/interactive-customization/governance-threshold-baseline.json +14 -0
- package/docs/interactive-customization/guardrail-policy-baseline.json +27 -0
- package/docs/interactive-customization/high-risk-action-catalog.json +22 -0
- package/docs/interactive-customization/moqui-adapter-interface.md +40 -0
- package/docs/interactive-customization/moqui-context-provider.sample.json +72 -0
- package/docs/interactive-customization/moqui-copilot-context-contract.json +50 -0
- package/docs/interactive-customization/moqui-copilot-integration-guide.md +100 -0
- package/docs/interactive-customization/moqui-interactive-template-playbook.md +94 -0
- package/docs/interactive-customization/non-technical-usability-report.md +57 -0
- package/docs/interactive-customization/page-context.sample.json +73 -0
- package/docs/interactive-customization/page-context.schema.json +150 -0
- package/docs/interactive-customization/phase-acceptance-evidence.md +110 -0
- package/docs/interactive-customization/runtime-mode-policy-baseline.json +99 -0
- package/docs/moqui-template-core-library-playbook.md +28 -0
- package/docs/release-checklist.md +29 -4
- package/docs/security-governance-default-baseline.md +54 -0
- package/docs/starter-kit/README.md +50 -0
- package/docs/starter-kit/handoff-manifest.starter.json +32 -0
- package/docs/starter-kit/handoff-profile-ci.sample.yml +53 -0
- package/docs/starter-kit/release.workflow.sample.yml +41 -0
- package/docs/zh/README.md +12 -0
- package/lib/auto/moqui-recovery-sequence.js +62 -0
- package/lib/commands/auto.js +245 -34
- package/lib/commands/scene.js +867 -0
- package/lib/data/moqui-capability-lexicon.json +14 -1
- package/lib/interactive-customization/change-plan-gate-core.js +201 -0
- package/lib/interactive-customization/index.js +9 -0
- package/lib/interactive-customization/moqui-interactive-adapter.js +732 -0
- package/package.json +27 -2
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const { createMoquiAdapterHandler } = require('../scene-runtime/moqui-adapter');
|
|
7
|
+
const {
|
|
8
|
+
evaluatePlanGate,
|
|
9
|
+
DEFAULT_POLICY,
|
|
10
|
+
DEFAULT_CATALOG
|
|
11
|
+
} = require('./change-plan-gate-core');
|
|
12
|
+
|
|
13
|
+
const ADAPTER_TYPE = 'moqui-interactive-adapter';
|
|
14
|
+
const ADAPTER_VERSION = '1.0.0';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_EXECUTION_RECORD_OUT = '.kiro/reports/interactive-execution-record.latest.json';
|
|
17
|
+
const DEFAULT_EXECUTION_LEDGER_OUT = '.kiro/reports/interactive-execution-ledger.jsonl';
|
|
18
|
+
|
|
19
|
+
const SUPPORTED_ACTION_TYPES = [
|
|
20
|
+
'analysis_only',
|
|
21
|
+
'workflow_approval_chain_change',
|
|
22
|
+
'update_rule_threshold',
|
|
23
|
+
'ui_form_field_adjust',
|
|
24
|
+
'inventory_adjustment_bulk',
|
|
25
|
+
'payment_rule_change',
|
|
26
|
+
'bulk_delete_without_filter',
|
|
27
|
+
'permission_grant_super_admin',
|
|
28
|
+
'credential_export'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function normalizeText(value) {
|
|
32
|
+
return `${value || ''}`.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolvePath(projectRoot, filePath) {
|
|
36
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pickContextValue(context, fallback, field) {
|
|
40
|
+
if (context && context[field] != null) {
|
|
41
|
+
return context[field];
|
|
42
|
+
}
|
|
43
|
+
if (fallback && fallback[field] != null) {
|
|
44
|
+
return fallback[field];
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inferActionTypes(changeIntent, context) {
|
|
50
|
+
const goalText = normalizeText(changeIntent && changeIntent.business_goal).toLowerCase();
|
|
51
|
+
const moduleText = normalizeText(context && context.module).toLowerCase();
|
|
52
|
+
const entityText = normalizeText(context && context.entity).toLowerCase();
|
|
53
|
+
const merged = `${goalText} ${moduleText} ${entityText}`;
|
|
54
|
+
|
|
55
|
+
const actionTypes = [];
|
|
56
|
+
const pushType = (value) => {
|
|
57
|
+
if (!actionTypes.includes(value)) {
|
|
58
|
+
actionTypes.push(value);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (/(approval|workflow|escalation|review)/.test(merged)) {
|
|
63
|
+
pushType('workflow_approval_chain_change');
|
|
64
|
+
}
|
|
65
|
+
if (/(rule|threshold|policy|strategy|decision)/.test(merged)) {
|
|
66
|
+
pushType('update_rule_threshold');
|
|
67
|
+
}
|
|
68
|
+
if (/(field|form|layout|ui|screen|page)/.test(merged)) {
|
|
69
|
+
pushType('ui_form_field_adjust');
|
|
70
|
+
}
|
|
71
|
+
if (/(inventory|stock|warehouse|reserve)/.test(merged)) {
|
|
72
|
+
pushType('inventory_adjustment_bulk');
|
|
73
|
+
}
|
|
74
|
+
if (/(payment|refund|billing)/.test(merged)) {
|
|
75
|
+
pushType('payment_rule_change');
|
|
76
|
+
}
|
|
77
|
+
if (/(delete|drop|remove all|truncate)/.test(merged)) {
|
|
78
|
+
pushType('bulk_delete_without_filter');
|
|
79
|
+
}
|
|
80
|
+
if (/(permission|privilege|admin|role)/.test(merged)) {
|
|
81
|
+
pushType('permission_grant_super_admin');
|
|
82
|
+
}
|
|
83
|
+
if (/(token|secret|credential|apikey|password)/.test(merged)) {
|
|
84
|
+
pushType('credential_export');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (actionTypes.length === 0) {
|
|
88
|
+
pushType('analysis_only');
|
|
89
|
+
}
|
|
90
|
+
return actionTypes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function actionTemplate(type, index) {
|
|
94
|
+
const base = {
|
|
95
|
+
action_id: `act-${String(index + 1).padStart(3, '0')}`,
|
|
96
|
+
type,
|
|
97
|
+
touches_sensitive_data: false,
|
|
98
|
+
requires_privilege_escalation: false,
|
|
99
|
+
irreversible: false
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (['payment_rule_change', 'credential_export'].includes(type)) {
|
|
103
|
+
base.touches_sensitive_data = true;
|
|
104
|
+
}
|
|
105
|
+
if (['permission_grant_super_admin'].includes(type)) {
|
|
106
|
+
base.requires_privilege_escalation = true;
|
|
107
|
+
}
|
|
108
|
+
if (['bulk_delete_without_filter'].includes(type)) {
|
|
109
|
+
base.irreversible = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return base;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function inferRiskLevel(actions, changeIntent, context) {
|
|
116
|
+
const goalText = normalizeText(changeIntent && changeIntent.business_goal).toLowerCase();
|
|
117
|
+
const actionTypes = actions.map(item => item.type);
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
actionTypes.some(type => [
|
|
121
|
+
'credential_export',
|
|
122
|
+
'permission_grant_super_admin',
|
|
123
|
+
'bulk_delete_without_filter'
|
|
124
|
+
].includes(type)) ||
|
|
125
|
+
/(delete|privilege|token|secret|credential)/.test(goalText)
|
|
126
|
+
) {
|
|
127
|
+
return 'high';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
actionTypes.some(type => [
|
|
132
|
+
'workflow_approval_chain_change',
|
|
133
|
+
'payment_rule_change',
|
|
134
|
+
'inventory_adjustment_bulk'
|
|
135
|
+
].includes(type)) ||
|
|
136
|
+
/(approval|workflow|payment|inventory|refund)/.test(goalText) ||
|
|
137
|
+
/(payment|inventory)/.test(normalizeText(context && context.module).toLowerCase())
|
|
138
|
+
) {
|
|
139
|
+
return 'medium';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'low';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildVerificationChecks(actions) {
|
|
146
|
+
const checks = [];
|
|
147
|
+
const pushCheck = (value) => {
|
|
148
|
+
if (!checks.includes(value)) {
|
|
149
|
+
checks.push(value);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
pushCheck('intent-to-plan consistency review');
|
|
154
|
+
|
|
155
|
+
actions.forEach(action => {
|
|
156
|
+
switch (action.type) {
|
|
157
|
+
case 'workflow_approval_chain_change':
|
|
158
|
+
pushCheck('approval workflow regression smoke');
|
|
159
|
+
pushCheck('approval escalation path validation');
|
|
160
|
+
break;
|
|
161
|
+
case 'update_rule_threshold':
|
|
162
|
+
pushCheck('rule threshold snapshot compare');
|
|
163
|
+
break;
|
|
164
|
+
case 'ui_form_field_adjust':
|
|
165
|
+
pushCheck('ui field rendering smoke');
|
|
166
|
+
break;
|
|
167
|
+
case 'inventory_adjustment_bulk':
|
|
168
|
+
pushCheck('inventory non-negative invariant check');
|
|
169
|
+
break;
|
|
170
|
+
case 'payment_rule_change':
|
|
171
|
+
pushCheck('payment authorization regression smoke');
|
|
172
|
+
break;
|
|
173
|
+
case 'bulk_delete_without_filter':
|
|
174
|
+
pushCheck('destructive action simulation in dry-run');
|
|
175
|
+
break;
|
|
176
|
+
default:
|
|
177
|
+
pushCheck('general change smoke validation');
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return checks;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function summarizeImpact(actions, context) {
|
|
186
|
+
const actionTypes = actions.map(item => item.type);
|
|
187
|
+
return {
|
|
188
|
+
business: actionTypes.length === 1 && actionTypes[0] === 'analysis_only'
|
|
189
|
+
? 'analysis only, no direct business mutation proposed'
|
|
190
|
+
: `potential impact on ${normalizeText(context && context.module) || 'business module'} operations`,
|
|
191
|
+
technical: `generated ${actions.length} action(s): ${actionTypes.join(', ')}`,
|
|
192
|
+
data: actions.some(item => item.touches_sensitive_data)
|
|
193
|
+
? 'sensitive data path involved; masking and approval required'
|
|
194
|
+
: 'no explicit sensitive data write path detected'
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildRollbackPlan(actions, nowIso) {
|
|
199
|
+
const irreversible = actions.some(item => item.irreversible);
|
|
200
|
+
return {
|
|
201
|
+
type: irreversible ? 'backup-restore' : 'config-revert',
|
|
202
|
+
reference: irreversible
|
|
203
|
+
? `backup-required-${nowIso.slice(0, 10)}`
|
|
204
|
+
: 'previous-config-snapshot',
|
|
205
|
+
note: irreversible
|
|
206
|
+
? 'irreversible action detected; verified backup is mandatory before apply'
|
|
207
|
+
: 'revert to previous rule/config snapshot'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildApproval(riskLevel, executionMode, actions) {
|
|
212
|
+
const hasPrivilegeEscalation = actions.some(item => item.requires_privilege_escalation);
|
|
213
|
+
const mustApprove = (
|
|
214
|
+
riskLevel === 'high' ||
|
|
215
|
+
(riskLevel === 'medium' && executionMode === 'apply') ||
|
|
216
|
+
hasPrivilegeEscalation
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
status: mustApprove ? 'pending' : 'not-required',
|
|
220
|
+
dual_approved: false,
|
|
221
|
+
approvers: []
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isApproved(changePlan) {
|
|
226
|
+
return (
|
|
227
|
+
changePlan &&
|
|
228
|
+
changePlan.approval &&
|
|
229
|
+
typeof changePlan.approval === 'object' &&
|
|
230
|
+
changePlan.approval.status === 'approved'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildPlanFromIntent(changeIntent, context, options = {}) {
|
|
235
|
+
const nowIso = options.nowIso || new Date().toISOString();
|
|
236
|
+
const idProvider = typeof options.idProvider === 'function'
|
|
237
|
+
? options.idProvider
|
|
238
|
+
: () => crypto.randomUUID();
|
|
239
|
+
const fallbackContext = changeIntent && changeIntent.context_ref && typeof changeIntent.context_ref === 'object'
|
|
240
|
+
? changeIntent.context_ref
|
|
241
|
+
: {};
|
|
242
|
+
const normalizedContext = context && typeof context === 'object' ? context : {};
|
|
243
|
+
const executionMode = ['suggestion', 'apply'].includes(`${options.executionMode || ''}`)
|
|
244
|
+
? `${options.executionMode}`
|
|
245
|
+
: 'suggestion';
|
|
246
|
+
|
|
247
|
+
const actionTypes = inferActionTypes(changeIntent, normalizedContext);
|
|
248
|
+
const actions = actionTypes.map((type, index) => actionTemplate(type, index));
|
|
249
|
+
const riskLevel = inferRiskLevel(actions, changeIntent, normalizedContext);
|
|
250
|
+
const rollbackPlan = buildRollbackPlan(actions, nowIso);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
plan_id: `plan-${idProvider()}`,
|
|
254
|
+
intent_id: normalizeText(changeIntent && changeIntent.intent_id) || `intent-${idProvider()}`,
|
|
255
|
+
risk_level: riskLevel,
|
|
256
|
+
execution_mode: executionMode,
|
|
257
|
+
scope: {
|
|
258
|
+
product: pickContextValue(normalizedContext, fallbackContext, 'product'),
|
|
259
|
+
module: pickContextValue(normalizedContext, fallbackContext, 'module'),
|
|
260
|
+
page: pickContextValue(normalizedContext, fallbackContext, 'page'),
|
|
261
|
+
entity: pickContextValue(normalizedContext, fallbackContext, 'entity'),
|
|
262
|
+
scene_id: pickContextValue(normalizedContext, fallbackContext, 'scene_id')
|
|
263
|
+
},
|
|
264
|
+
actions,
|
|
265
|
+
impact_assessment: summarizeImpact(actions, normalizedContext),
|
|
266
|
+
verification_checks: buildVerificationChecks(actions),
|
|
267
|
+
rollback_plan: rollbackPlan,
|
|
268
|
+
approval: buildApproval(riskLevel, executionMode, actions),
|
|
269
|
+
security: {
|
|
270
|
+
masking_applied: actions.some(item => item.touches_sensitive_data),
|
|
271
|
+
plaintext_secrets_in_payload: false,
|
|
272
|
+
backup_reference: rollbackPlan.type === 'backup-restore'
|
|
273
|
+
? rollbackPlan.reference
|
|
274
|
+
: undefined
|
|
275
|
+
},
|
|
276
|
+
created_at: nowIso
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function inferRecordResult(actionResults) {
|
|
281
|
+
const failedCount = actionResults.filter(item => item.status === 'failed').length;
|
|
282
|
+
return failedCount > 0 ? 'failed' : 'success';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function summarizeActionDiff(actionResults, runtime) {
|
|
286
|
+
const summaryItems = actionResults.map(item => ({
|
|
287
|
+
action_id: item.action_id || null,
|
|
288
|
+
type: item.type || null,
|
|
289
|
+
mode: item.mode || 'simulate',
|
|
290
|
+
status: item.status || 'failed',
|
|
291
|
+
reason: item.reason || null,
|
|
292
|
+
binding_ref: item.binding_ref || null
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
action_total: summaryItems.length,
|
|
297
|
+
success_count: summaryItems.filter(item => item.status === 'success').length,
|
|
298
|
+
failed_count: summaryItems.filter(item => item.status === 'failed').length,
|
|
299
|
+
simulated_count: summaryItems.filter(item => item.mode === 'simulate').length,
|
|
300
|
+
live_count: summaryItems.filter(item => item.mode === 'live').length,
|
|
301
|
+
execution_mode: runtime.liveApply ? 'live' : 'simulate',
|
|
302
|
+
dry_run: runtime.dryRun === true,
|
|
303
|
+
action_results: summaryItems
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseExecutionLedger(text) {
|
|
308
|
+
return `${text || ''}`
|
|
309
|
+
.split(/\r?\n/)
|
|
310
|
+
.map(line => line.trim())
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.map((line) => {
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(line);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.filter(Boolean);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
class MoquiInteractiveAdapter {
|
|
323
|
+
constructor(options = {}) {
|
|
324
|
+
this.projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
325
|
+
this.policyPath = resolvePath(this.projectRoot, options.policyPath || DEFAULT_POLICY);
|
|
326
|
+
this.catalogPath = resolvePath(this.projectRoot, options.catalogPath || DEFAULT_CATALOG);
|
|
327
|
+
this.catalogPathExplicit = Object.prototype.hasOwnProperty.call(options, 'catalogPath');
|
|
328
|
+
this.executionRecordOut = resolvePath(
|
|
329
|
+
this.projectRoot,
|
|
330
|
+
options.executionRecordOut || DEFAULT_EXECUTION_RECORD_OUT
|
|
331
|
+
);
|
|
332
|
+
this.executionLedgerOut = resolvePath(
|
|
333
|
+
this.projectRoot,
|
|
334
|
+
options.executionLedgerOut || DEFAULT_EXECUTION_LEDGER_OUT
|
|
335
|
+
);
|
|
336
|
+
this.defaultLiveApply = options.liveApply === true;
|
|
337
|
+
this.defaultDryRun = options.dryRun !== false;
|
|
338
|
+
this.moquiConfigPath = options.moquiConfigPath || null;
|
|
339
|
+
this.moquiProjectRoot = options.moquiProjectRoot || this.projectRoot;
|
|
340
|
+
this.handlerFactory = typeof options.handlerFactory === 'function'
|
|
341
|
+
? options.handlerFactory
|
|
342
|
+
: createMoquiAdapterHandler;
|
|
343
|
+
this.moquiHandler = null;
|
|
344
|
+
this.client = options.client || null;
|
|
345
|
+
this.nowProvider = typeof options.nowProvider === 'function'
|
|
346
|
+
? options.nowProvider
|
|
347
|
+
: () => new Date();
|
|
348
|
+
this.uuidProvider = typeof options.uuidProvider === 'function'
|
|
349
|
+
? options.uuidProvider
|
|
350
|
+
: () => crypto.randomUUID();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
capabilities() {
|
|
354
|
+
return {
|
|
355
|
+
adapter_type: ADAPTER_TYPE,
|
|
356
|
+
adapter_version: ADAPTER_VERSION,
|
|
357
|
+
runtime: 'moqui',
|
|
358
|
+
supported_change_types: SUPPORTED_ACTION_TYPES.slice(),
|
|
359
|
+
supported_execution_modes: ['suggestion', 'apply'],
|
|
360
|
+
risk_statement: {
|
|
361
|
+
auto_apply_risk_levels: ['low'],
|
|
362
|
+
approval_required_risk_levels: ['medium', 'high'],
|
|
363
|
+
deny_action_types: [
|
|
364
|
+
'credential_export',
|
|
365
|
+
'permission_grant_super_admin',
|
|
366
|
+
'bulk_delete_without_filter'
|
|
367
|
+
],
|
|
368
|
+
default_execution_mode: 'suggestion'
|
|
369
|
+
},
|
|
370
|
+
interfaces: ['capabilities', 'plan', 'validate', 'apply', 'rollback'],
|
|
371
|
+
default_runtime_behavior: {
|
|
372
|
+
live_apply: this.defaultLiveApply,
|
|
373
|
+
dry_run: this.defaultDryRun
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async plan(changeIntent, context = {}, options = {}) {
|
|
379
|
+
if (!changeIntent || typeof changeIntent !== 'object') {
|
|
380
|
+
throw new Error('changeIntent must be a non-null object');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const nowIso = this.nowProvider().toISOString();
|
|
384
|
+
return buildPlanFromIntent(changeIntent, context, {
|
|
385
|
+
executionMode: options.executionMode || changeIntent.execution_mode || 'suggestion',
|
|
386
|
+
nowIso,
|
|
387
|
+
idProvider: this.uuidProvider
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async loadPolicyAndCatalog() {
|
|
392
|
+
if (!(await fs.pathExists(this.policyPath))) {
|
|
393
|
+
throw new Error(`policy not found: ${this.policyPath}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const policy = await fs.readJson(this.policyPath);
|
|
397
|
+
const catalogFromPolicy = policy &&
|
|
398
|
+
policy.catalog_policy &&
|
|
399
|
+
typeof policy.catalog_policy.catalog_file === 'string'
|
|
400
|
+
? resolvePath(this.projectRoot, policy.catalog_policy.catalog_file)
|
|
401
|
+
: null;
|
|
402
|
+
const resolvedCatalogPath = this.catalogPathExplicit
|
|
403
|
+
? this.catalogPath
|
|
404
|
+
: (catalogFromPolicy || this.catalogPath);
|
|
405
|
+
|
|
406
|
+
if (!(await fs.pathExists(resolvedCatalogPath))) {
|
|
407
|
+
throw new Error(`catalog not found: ${resolvedCatalogPath}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const catalog = await fs.readJson(resolvedCatalogPath);
|
|
411
|
+
return {
|
|
412
|
+
policy,
|
|
413
|
+
catalog,
|
|
414
|
+
policyPath: this.policyPath,
|
|
415
|
+
catalogPath: resolvedCatalogPath
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async validate(changePlan) {
|
|
420
|
+
if (!changePlan || typeof changePlan !== 'object') {
|
|
421
|
+
throw new Error('changePlan must be a non-null object');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const bundle = await this.loadPolicyAndCatalog();
|
|
425
|
+
const evaluation = evaluatePlanGate(changePlan, bundle.policy, bundle.catalog);
|
|
426
|
+
return {
|
|
427
|
+
adapter_type: ADAPTER_TYPE,
|
|
428
|
+
validated_at: this.nowProvider().toISOString(),
|
|
429
|
+
policy_path: path.relative(this.projectRoot, bundle.policyPath) || '.',
|
|
430
|
+
catalog_path: path.relative(this.projectRoot, bundle.catalogPath) || '.',
|
|
431
|
+
...evaluation
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
getMoquiHandler() {
|
|
436
|
+
if (!this.moquiHandler) {
|
|
437
|
+
this.moquiHandler = this.handlerFactory({
|
|
438
|
+
configPath: this.moquiConfigPath,
|
|
439
|
+
projectRoot: this.moquiProjectRoot,
|
|
440
|
+
client: this.client
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return this.moquiHandler;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async executeAction(action, runtime) {
|
|
447
|
+
const result = {
|
|
448
|
+
action_id: action && action.action_id ? action.action_id : null,
|
|
449
|
+
type: action && action.type ? action.type : null,
|
|
450
|
+
mode: runtime.liveApply ? 'live' : 'simulate',
|
|
451
|
+
status: 'success',
|
|
452
|
+
reason: null,
|
|
453
|
+
binding_ref: null
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (!runtime.liveApply || runtime.dryRun) {
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const bindingRef = normalizeText(
|
|
461
|
+
action && (action.binding_ref || action.moqui_binding_ref || action.bindingRef)
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
result.binding_ref = bindingRef || null;
|
|
465
|
+
|
|
466
|
+
if (!bindingRef) {
|
|
467
|
+
result.status = 'failed';
|
|
468
|
+
result.reason = 'live apply requires action.binding_ref';
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const handler = this.getMoquiHandler();
|
|
473
|
+
const payload = action && typeof action.payload === 'object' ? action.payload : {};
|
|
474
|
+
const response = await handler.execute({ binding_ref: bindingRef }, payload);
|
|
475
|
+
|
|
476
|
+
if (!response || response.status !== 'success') {
|
|
477
|
+
const errorMessage = response && response.error && response.error.message
|
|
478
|
+
? response.error.message
|
|
479
|
+
: 'moqui execution failed';
|
|
480
|
+
result.status = 'failed';
|
|
481
|
+
result.reason = errorMessage;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
buildExecutionRecord(changePlan, validation, actionResults, overrides = {}) {
|
|
488
|
+
const executionId = normalizeText(overrides.executionId) || `exec-${this.uuidProvider()}`;
|
|
489
|
+
const auditTraceId = normalizeText(overrides.auditTraceId) || `audit-${this.uuidProvider()}`;
|
|
490
|
+
const nowIso = this.nowProvider().toISOString();
|
|
491
|
+
const result = normalizeText(overrides.result) || inferRecordResult(actionResults);
|
|
492
|
+
const rollbackRef = result === 'success'
|
|
493
|
+
? `rollback-${executionId}`
|
|
494
|
+
: (normalizeText(overrides.rollbackRef) || null);
|
|
495
|
+
|
|
496
|
+
const record = {
|
|
497
|
+
execution_id: executionId,
|
|
498
|
+
plan_id: normalizeText(changePlan && changePlan.plan_id) || 'unknown-plan',
|
|
499
|
+
adapter_type: ADAPTER_TYPE,
|
|
500
|
+
policy_decision: validation && validation.decision ? validation.decision : 'deny',
|
|
501
|
+
approval_snapshot: changePlan && changePlan.approval ? changePlan.approval : {},
|
|
502
|
+
diff_summary: summarizeActionDiff(actionResults, overrides.runtime || {}),
|
|
503
|
+
result,
|
|
504
|
+
rollback_ref: rollbackRef,
|
|
505
|
+
audit_trace_id: auditTraceId,
|
|
506
|
+
executed_at: nowIso
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
if (overrides.validationSnapshot) {
|
|
510
|
+
record.validation_snapshot = overrides.validationSnapshot;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return record;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async persistExecutionRecord(record) {
|
|
517
|
+
await fs.ensureDir(path.dirname(this.executionRecordOut));
|
|
518
|
+
await fs.writeJson(this.executionRecordOut, record, { spaces: 2 });
|
|
519
|
+
|
|
520
|
+
await fs.ensureDir(path.dirname(this.executionLedgerOut));
|
|
521
|
+
await fs.appendFile(this.executionLedgerOut, `${JSON.stringify(record)}\n`, 'utf8');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async apply(changePlan, options = {}) {
|
|
525
|
+
if (!changePlan || typeof changePlan !== 'object') {
|
|
526
|
+
throw new Error('changePlan must be a non-null object');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const runtime = {
|
|
530
|
+
liveApply: options.liveApply === true ? true : this.defaultLiveApply,
|
|
531
|
+
dryRun: options.dryRun === false ? false : this.defaultDryRun
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const validation = await this.validate(changePlan);
|
|
535
|
+
|
|
536
|
+
if (validation.decision === 'deny') {
|
|
537
|
+
const blocked = this.buildExecutionRecord(
|
|
538
|
+
changePlan,
|
|
539
|
+
validation,
|
|
540
|
+
[],
|
|
541
|
+
{
|
|
542
|
+
result: 'failed',
|
|
543
|
+
rollbackRef: null,
|
|
544
|
+
validationSnapshot: validation,
|
|
545
|
+
runtime
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
blocked.diff_summary.reason = 'blocked by policy gate (deny)';
|
|
549
|
+
await this.persistExecutionRecord(blocked);
|
|
550
|
+
return { blocked: true, reason: blocked.diff_summary.reason, record: blocked, validation };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (validation.decision === 'review-required' && !isApproved(changePlan)) {
|
|
554
|
+
const blocked = this.buildExecutionRecord(
|
|
555
|
+
changePlan,
|
|
556
|
+
validation,
|
|
557
|
+
[],
|
|
558
|
+
{
|
|
559
|
+
result: 'skipped',
|
|
560
|
+
rollbackRef: null,
|
|
561
|
+
validationSnapshot: validation,
|
|
562
|
+
runtime
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
blocked.diff_summary.reason = 'approval required before apply';
|
|
566
|
+
await this.persistExecutionRecord(blocked);
|
|
567
|
+
return { blocked: true, reason: blocked.diff_summary.reason, record: blocked, validation };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (
|
|
571
|
+
changePlan.execution_mode === 'suggestion' &&
|
|
572
|
+
options.allowSuggestionApply !== true
|
|
573
|
+
) {
|
|
574
|
+
const skipped = this.buildExecutionRecord(
|
|
575
|
+
changePlan,
|
|
576
|
+
validation,
|
|
577
|
+
[],
|
|
578
|
+
{
|
|
579
|
+
result: 'skipped',
|
|
580
|
+
rollbackRef: null,
|
|
581
|
+
validationSnapshot: validation,
|
|
582
|
+
runtime
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
skipped.diff_summary.reason = 'plan is in suggestion mode; set allowSuggestionApply to execute';
|
|
586
|
+
await this.persistExecutionRecord(skipped);
|
|
587
|
+
return { blocked: true, reason: skipped.diff_summary.reason, record: skipped, validation };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const actions = Array.isArray(changePlan.actions)
|
|
591
|
+
? changePlan.actions.filter(item => item && typeof item === 'object')
|
|
592
|
+
: [];
|
|
593
|
+
const actionResults = [];
|
|
594
|
+
|
|
595
|
+
for (const action of actions) {
|
|
596
|
+
const actionResult = await this.executeAction(action, runtime);
|
|
597
|
+
actionResults.push(actionResult);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const record = this.buildExecutionRecord(changePlan, validation, actionResults, {
|
|
601
|
+
runtime,
|
|
602
|
+
validationSnapshot: validation
|
|
603
|
+
});
|
|
604
|
+
await this.persistExecutionRecord(record);
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
blocked: false,
|
|
608
|
+
reason: null,
|
|
609
|
+
record,
|
|
610
|
+
validation,
|
|
611
|
+
action_results: actionResults
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async applyLowRisk(changePlan, options = {}) {
|
|
616
|
+
if (!changePlan || typeof changePlan !== 'object') {
|
|
617
|
+
throw new Error('changePlan must be a non-null object');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const runtime = {
|
|
621
|
+
liveApply: options.liveApply === true ? true : this.defaultLiveApply,
|
|
622
|
+
dryRun: options.dryRun === false ? false : this.defaultDryRun
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const validation = await this.validate(changePlan);
|
|
626
|
+
const riskLevel = normalizeText(changePlan.risk_level).toLowerCase();
|
|
627
|
+
|
|
628
|
+
if (validation.decision !== 'allow' || riskLevel !== 'low') {
|
|
629
|
+
const blocked = this.buildExecutionRecord(
|
|
630
|
+
changePlan,
|
|
631
|
+
validation,
|
|
632
|
+
[],
|
|
633
|
+
{
|
|
634
|
+
result: 'skipped',
|
|
635
|
+
rollbackRef: null,
|
|
636
|
+
validationSnapshot: validation,
|
|
637
|
+
runtime
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
blocked.diff_summary.reason = 'low-risk apply accepts only risk_level=low and gate decision=allow';
|
|
641
|
+
await this.persistExecutionRecord(blocked);
|
|
642
|
+
return {
|
|
643
|
+
blocked: true,
|
|
644
|
+
reason: blocked.diff_summary.reason,
|
|
645
|
+
record: blocked,
|
|
646
|
+
validation
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return this.apply(changePlan, {
|
|
651
|
+
...options,
|
|
652
|
+
liveApply: runtime.liveApply,
|
|
653
|
+
dryRun: runtime.dryRun
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async readLedger() {
|
|
658
|
+
if (!(await fs.pathExists(this.executionLedgerOut))) {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
const content = await fs.readFile(this.executionLedgerOut, 'utf8');
|
|
662
|
+
return parseExecutionLedger(content);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async rollback(executionId) {
|
|
666
|
+
const targetExecutionId = normalizeText(executionId);
|
|
667
|
+
if (!targetExecutionId) {
|
|
668
|
+
throw new Error('executionId is required');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const ledger = await this.readLedger();
|
|
672
|
+
const target = ledger.find(item => item && item.execution_id === targetExecutionId);
|
|
673
|
+
|
|
674
|
+
if (!target) {
|
|
675
|
+
const missingRecord = {
|
|
676
|
+
execution_id: `exec-${this.uuidProvider()}`,
|
|
677
|
+
plan_id: 'unknown-plan',
|
|
678
|
+
adapter_type: ADAPTER_TYPE,
|
|
679
|
+
policy_decision: 'deny',
|
|
680
|
+
approval_snapshot: {},
|
|
681
|
+
diff_summary: {
|
|
682
|
+
reason: `execution_id not found: ${targetExecutionId}`
|
|
683
|
+
},
|
|
684
|
+
result: 'failed',
|
|
685
|
+
rollback_ref: targetExecutionId,
|
|
686
|
+
audit_trace_id: `audit-${this.uuidProvider()}`,
|
|
687
|
+
executed_at: this.nowProvider().toISOString()
|
|
688
|
+
};
|
|
689
|
+
await this.persistExecutionRecord(missingRecord);
|
|
690
|
+
return {
|
|
691
|
+
found: false,
|
|
692
|
+
record: missingRecord
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const rollbackRecord = {
|
|
697
|
+
execution_id: `exec-${this.uuidProvider()}`,
|
|
698
|
+
plan_id: target.plan_id || 'unknown-plan',
|
|
699
|
+
adapter_type: ADAPTER_TYPE,
|
|
700
|
+
policy_decision: target.policy_decision || 'review-required',
|
|
701
|
+
approval_snapshot: target.approval_snapshot || {},
|
|
702
|
+
diff_summary: {
|
|
703
|
+
rollback_of_execution_id: targetExecutionId,
|
|
704
|
+
rollback_scope: target.diff_summary || {},
|
|
705
|
+
note: 'rollback recorded as controlled simulation'
|
|
706
|
+
},
|
|
707
|
+
result: 'rolled-back',
|
|
708
|
+
rollback_ref: targetExecutionId,
|
|
709
|
+
audit_trace_id: `audit-${this.uuidProvider()}`,
|
|
710
|
+
executed_at: this.nowProvider().toISOString()
|
|
711
|
+
};
|
|
712
|
+
rollbackRecord.validation_snapshot = target.validation_snapshot || null;
|
|
713
|
+
|
|
714
|
+
await this.persistExecutionRecord(rollbackRecord);
|
|
715
|
+
return {
|
|
716
|
+
found: true,
|
|
717
|
+
record: rollbackRecord,
|
|
718
|
+
source: target
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
module.exports = {
|
|
724
|
+
ADAPTER_TYPE,
|
|
725
|
+
ADAPTER_VERSION,
|
|
726
|
+
DEFAULT_EXECUTION_RECORD_OUT,
|
|
727
|
+
DEFAULT_EXECUTION_LEDGER_OUT,
|
|
728
|
+
SUPPORTED_ACTION_TYPES,
|
|
729
|
+
buildPlanFromIntent,
|
|
730
|
+
parseExecutionLedger,
|
|
731
|
+
MoquiInteractiveAdapter
|
|
732
|
+
};
|