@youcloud/metersphere-mcp 0.0.0-stage → 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.
@@ -0,0 +1,599 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ApiError, ensure, problem, WorkflowError } from '../errors.js';
3
+ import { executionDefinition, executionSnapshot, fieldsMatch, pick, renderRecord, resultBody, segment, serializeSteps, escapeHtml, reportSnapshotVerification } from '../adapters/v1-20.js';
4
+ import { digest } from './tokens.js';
5
+ const creates = new Set(['create_test_case', 'create_case_module', 'create_test_plan', 'create_case_review']);
6
+ const editableFields = ['id', 'nodeId', 'testId', 'nodePath', 'projectId', 'name', 'type', 'maintainer', 'priority', 'method', 'otherTestName', 'reviewStatus', 'tags', 'demandId', 'demandName', 'status', 'stepModel', 'customNum', 'casePublic', 'versionId', 'refId', 'prerequisite', 'remark', 'steps', 'stepDescription', 'expectedResult', 'customFields'];
7
+ const business = (input) => { const { dry_run: _d, resume: _r, ...rest } = input; return rest; };
8
+ export class Executor {
9
+ context;
10
+ constructor(context) {
11
+ this.context = context;
12
+ }
13
+ get ms() { return this.context.ms; }
14
+ async execute(action, input) {
15
+ return this.withLocks(releases => this.executeWithLocks(action, input, releases));
16
+ }
17
+ async executeWithLocks(action, input, releases) {
18
+ let op = null, ledger, mayPersist = false;
19
+ try {
20
+ this.context.assertToolEnabled(action);
21
+ ensure(!input.dry_run || !input.resume, 'INVALID_INPUT', '预览不能同时恢复');
22
+ ensure(input.dry_run || input.operation_id, 'INVALID_INPUT', '真实写入必须提供 operation_id');
23
+ ensure(input.dry_run || creates.has(action) || input.expected_revision, 'INVALID_INPUT', '修改已有对象必须提供 expected_revision');
24
+ const session = await this.ms.session();
25
+ ledger = this.context.ledger(session);
26
+ await this.context.visible(input);
27
+ if (input.dry_run) {
28
+ const plan = await this.prepare(action, input, session);
29
+ return this.output(action, null, 'preview', { subject: plan.subject, result: { proposed: plan.public_plan }, verification: { write_permission_verified: false } });
30
+ }
31
+ const lock = await ledger.lock(`operation:${input.operation_id}`);
32
+ if (!lock)
33
+ return this.output(action, input.operation_id, 'in_progress');
34
+ releases.push(lock);
35
+ op = await ledger.load(input.operation_id);
36
+ const args = business(input);
37
+ if (op) {
38
+ ensure(op.actor_id === session.user.id && op.action === action && op.request_hash === digest(args), 'OPERATION_CONFLICT', '同一操作 ID 的工具、主体或业务参数不能改变');
39
+ ensure(op.config_hash === digest(this.ms.config.workflow), 'CONFIG_CONFLICT', '操作创建后的配置已改变,请先核对原配置再恢复');
40
+ await this.context.visible({ ...op.args, ...op.plan.subject });
41
+ }
42
+ else
43
+ ensure(!input.resume, 'RECOVERY_UNAVAILABLE', '原操作记录不存在,禁止将恢复当成新操作执行');
44
+ mayPersist = true;
45
+ for (const key of await this.targetLocks(action, input)) {
46
+ const targetLock = await ledger.lock(key);
47
+ if (!targetLock)
48
+ return this.output(action, input.operation_id, 'in_progress');
49
+ releases.push(targetLock);
50
+ }
51
+ if (!op) {
52
+ const plan = await this.prepare(action, input, session);
53
+ op = { schema_version: 1, id: input.operation_id, action, actor_id: session.user.id, config_hash: digest(this.ms.config.workflow), args, request_hash: digest(args),
54
+ plan, plan_hash: digest(plan), steps: [], created_at: Date.now(), updated_at: Date.now() };
55
+ await ledger.save(op);
56
+ }
57
+ else {
58
+ for (const step of op.steps)
59
+ if (step.state === 'in_flight')
60
+ step.state = 'unknown';
61
+ if (op.outcome === 'succeeded')
62
+ return this.format(op, { historical_result: true });
63
+ }
64
+ // 已存在的操作,只有明确恢复才允许发出尚未开始/明确被拒绝的写入;核查始终可以读。
65
+ const mayWrite = op.steps.length === 0 || input.resume === true;
66
+ await this.run(op, session, ledger, mayWrite);
67
+ await ledger.save(op);
68
+ return this.format(op);
69
+ }
70
+ catch (error) {
71
+ if (op && ledger && mayPersist) {
72
+ this.recordFailure(op, error);
73
+ try {
74
+ await ledger.save(op);
75
+ }
76
+ catch (persistenceError) {
77
+ return this.persistenceFailure(op, persistenceError);
78
+ }
79
+ return this.format(op);
80
+ }
81
+ return this.output(action, input.operation_id ?? null, 'rejected', { error: problem(error) });
82
+ }
83
+ }
84
+ async operation(id) {
85
+ this.context.assertToolEnabled('get_workflow_operation');
86
+ const session = await this.ms.session(), ledger = this.context.ledger(session);
87
+ let op = await ledger.load(id);
88
+ ensure(op && op.actor_id === session.user.id, 'RECOVERY_UNAVAILABLE', '当前主体没有该操作记录');
89
+ await this.context.visible({ ...op.args, ...op.plan.subject });
90
+ return this.withLocks(async (releases) => {
91
+ ensure(op, 'RECOVERY_UNAVAILABLE', '操作记录不可恢复');
92
+ for (const key of [`operation:${id}`, ...await this.targetLocks(op.action, op.args)]) {
93
+ const lock = await ledger.lock(key);
94
+ if (!lock)
95
+ return this.output(op.action, id, 'in_progress');
96
+ releases.push(lock);
97
+ }
98
+ op = await ledger.load(id);
99
+ ensure(op && op.actor_id === session.user.id, 'RECOVERY_UNAVAILABLE', '操作记录不可恢复');
100
+ if (op.outcome === 'succeeded')
101
+ return this.format(op, { historical_result: true });
102
+ ensure(op.config_hash === digest(this.ms.config.workflow), 'CONFIG_CONFLICT', '操作创建后的配置已改变,请先核对原配置再恢复');
103
+ for (const step of op.steps)
104
+ if (step.state === 'in_flight')
105
+ step.state = 'unknown';
106
+ try {
107
+ await this.run(op, session, ledger, false);
108
+ }
109
+ catch (error) {
110
+ this.recordFailure(op, error);
111
+ }
112
+ try {
113
+ await ledger.save(op);
114
+ }
115
+ catch (error) {
116
+ this.recordFailure(op, error);
117
+ return this.persistenceFailure(op, error);
118
+ }
119
+ return this.format(op);
120
+ });
121
+ }
122
+ async withLocks(work) {
123
+ const releases = [], errors = [];
124
+ let result;
125
+ try {
126
+ result = await work(releases);
127
+ return result;
128
+ }
129
+ finally {
130
+ // 清理失败不能覆盖已确认的业务结果,也不能阻止其余锁释放。
131
+ for (const release of releases.reverse()) {
132
+ try {
133
+ await release();
134
+ }
135
+ catch (error) {
136
+ errors.push(problem(error));
137
+ }
138
+ }
139
+ if (result && errors.length) {
140
+ result.verification = { ...result.verification, locks_released: false, lock_release_errors: errors };
141
+ result.recovery = { action: 'manual_review', automatic_retry: false };
142
+ result.warnings.push('本地锁未能全部释放;业务结果保留,修复存储并清理遗留锁后用原 operation_id 核查');
143
+ }
144
+ }
145
+ }
146
+ recordFailure(op, error) {
147
+ op.error = problem(error);
148
+ op.outcome = op.steps.some(s => ['unknown', 'in_flight'].includes(s.state)) ? 'unknown'
149
+ : error instanceof WorkflowError && ['REVISION_CONFLICT', 'RESULT_MISMATCH', 'TARGET_MISMATCH'].includes(error.code) ? 'conflict'
150
+ : op.steps.some(s => s.state === 'succeeded') ? 'partial_success' : 'rejected';
151
+ }
152
+ persistenceFailure(op, error) {
153
+ // 账本再次保存失败不能退化成无步骤的 rejected,更不能暗示未知请求可直接重发。
154
+ const result = this.format(op, { ledger_persisted: false, persistence_error: problem(error) });
155
+ return { ...result, recovery: { action: 'manual_review', automatic_retry: false },
156
+ warnings: [...result.warnings, '操作账本未能可靠保存;保留原 operation_id,恢复存储后先核查'] };
157
+ }
158
+ async targetLocks(action, args) {
159
+ const keys = [`project:${args.project_id}:${creates.has(action) ? 'creation' : args.plan_id || args.case_id}`];
160
+ if (args.case_id)
161
+ keys.push(`case:${args.case_id}`);
162
+ for (const id of args.case_ids || [])
163
+ keys.push(`case:${id}`);
164
+ if (args.plan_case_id)
165
+ keys.push(`case:${(await this.ms.planCase(args.project_id, args.plan_id, args.plan_case_id)).caseId}`);
166
+ return [...new Set(keys)].sort();
167
+ }
168
+ output(action, id, outcome, extra = {}) {
169
+ return { schema_version: 1, action, operation_id: id, outcome, subject: {}, result: null, steps: [],
170
+ verification: { concurrency_mode: 'snapshot_precheck' }, recovery: { action: 'none', automatic_retry: false }, warnings: [], ...extra };
171
+ }
172
+ format(op, verification = {}) {
173
+ const uncertain = op.outcome === 'unknown' || op.steps.some(s => s.state === 'unknown' || s.state === 'in_flight');
174
+ const success = op.outcome === 'succeeded';
175
+ const enabled = this.context.enabledTools.has(op.action);
176
+ const resumable = !success && !uncertain && op.outcome !== 'conflict' && enabled;
177
+ return this.output(op.action, op.id, op.outcome || (uncertain ? 'unknown' : 'partial_success'), {
178
+ subject: op.plan.subject, result: op.result || null,
179
+ steps: op.steps.map(s => ({ name: s.name, state: s.state, ...(s.error ? { error: s.error } : {}) })),
180
+ verification: { concurrency_mode: 'snapshot_precheck', readback_verified: op.verified === true, ...verification },
181
+ recovery: { action: success ? 'none' : resumable ? 'resume_same_operation' : 'manual_review', automatic_retry: false,
182
+ ...(resumable ? { tool: op.action, resume: true } : {}) },
183
+ warnings: ['本地前置检查不能消除网页用户或其他机器的并发写入', ...(!success && !enabled ? ['当前配置禁用原写工具,仅可核查,恢复前须调整访问配置并重启服务'] : [])], ...(op.error ? { error: op.error } : {})
184
+ });
185
+ }
186
+ async prepare(action, args, session) {
187
+ const project = await this.ms.project(args.project_id), plan = { subject: pick(args, ['project_id', 'case_id', 'plan_id', 'plan_case_id', 'run_id', 'attempt_id']), public_plan: { action } };
188
+ const marker = `MSMCP:${args.operation_id || 'preview'}`;
189
+ if (action === 'create_test_plan' || action === 'create_case_review') {
190
+ plan.marker = marker;
191
+ const path = action === 'create_test_plan' ? '/test/plan/list' : '/test/case/review/list';
192
+ const existing = (await this.ms.all(path, { projectId: args.project_id, name: args.name })).filter(x => x.name === args.name && x.projectId === args.project_id);
193
+ ensure(!existing.length, 'AMBIGUOUS_TARGET', '已存在同名对象,请先核对并使用其真实 ID;不自动复用或重复创建');
194
+ }
195
+ if (action === 'create_case_module') {
196
+ plan.location = await this.ms.moduleLocation(args.project_id, args.name, args.parent_id);
197
+ plan.body = { id: randomUUID(), projectId: args.project_id, name: args.name, parentId: plan.location.parent_id, level: plan.location.level };
198
+ }
199
+ else if (action === 'create_test_case') {
200
+ const node = await this.ms.node(args.project_id, args.node_id);
201
+ plan.node_location = node;
202
+ const tags = [...new Set([...args.tags, 'AI'])];
203
+ ensure(tags.length <= 50, 'INVALID_INPUT', '包含自动添加的 AI 标签后,标签总数不能超过 50 个');
204
+ // v1.20.23 接受客户端 UUID;随计划先持久化,未知写入按 ID 回读,不污染业务备注。
205
+ plan.body = { id: randomUUID(), projectId: args.project_id, nodeId: node.id, nodePath: node.path, name: args.name, type: 'functional', priority: args.priority,
206
+ prerequisite: escapeHtml(args.prerequisite), stepModel: 'STEP', steps: serializeSteps(args.steps), tags: JSON.stringify(tags), remark: escapeHtml(args.remark), status: 'Prepare', follows: [], updatedFileList: [], selected: [], handleAttachment: false };
207
+ }
208
+ else if (action === 'update_test_case') {
209
+ const state = await this.ms.caseEditState(args.project_id, args.case_id);
210
+ this.revision(args, session, 'case_definition', args.case_id, state);
211
+ plan.before = state;
212
+ plan.body = { ...pick(state.item, editableFields), follows: state.follows, updatedFileList: state.files, selected: [], handleAttachment: false };
213
+ for (const [key, value] of Object.entries(args.changes)) {
214
+ if (key === 'steps') {
215
+ ensure(state.item.stepModel === 'STEP', 'UNSUPPORTED_STEP_MODEL', '不允许将 TEXT 隐式转换成 STEP');
216
+ plan.body.steps = serializeSteps(value);
217
+ }
218
+ else if (key === 'step_description' || key === 'expected_result') {
219
+ ensure(state.item.stepModel === 'TEXT', 'UNSUPPORTED_STEP_MODEL', '不允许将 STEP 隐式转换成 TEXT');
220
+ plan.body[key === 'step_description' ? 'stepDescription' : 'expectedResult'] = escapeHtml(value);
221
+ }
222
+ else if (key === 'node_id') {
223
+ const node = await this.ms.node(args.project_id, value);
224
+ plan.body.nodeId = node.id;
225
+ plan.body.nodePath = node.path;
226
+ }
227
+ else
228
+ plan.body[key] = key === 'tags' ? JSON.stringify(value) : ['remark', 'prerequisite'].includes(key) ? escapeHtml(value) : value;
229
+ }
230
+ }
231
+ else if (action === 'create_test_plan') {
232
+ await this.ms.checkMembers(args.project_id, args.principal_ids);
233
+ const stages = await this.ms.client.read(`/test/plan/get/stage/option/${segment(args.project_id)}`);
234
+ ensure(Array.isArray(stages) && stages.some(x => x === args.stage || x?.value === args.stage || x?.id === args.stage), 'INVALID_STAGE', '阶段不在当前项目的可选值中');
235
+ plan.body = { projectId: args.project_id, workspaceId: project.workspaceId, name: args.name, stage: args.stage, status: 'Prepare', principals: [...new Set(args.principal_ids)], follows: [],
236
+ plannedStartTime: Date.parse(args.starts_at), plannedEndTime: Date.parse(args.ends_at), description: `${escapeHtml(args.description)}\n${marker}` };
237
+ }
238
+ else if (action === 'create_case_review') {
239
+ await this.ms.checkMembers(args.project_id, args.reviewer_ids);
240
+ await this.checkCases(args);
241
+ plan.sources = [];
242
+ for (const id of args.case_ids)
243
+ plan.sources.push({ id, snapshot: executionDefinition(await this.ms.case(args.project_id, id)) });
244
+ plan.body = { projectId: args.project_id, name: args.name, userIds: [...new Set(args.reviewer_ids)], followIds: [],
245
+ endTime: args.ends_at ? Date.parse(args.ends_at) : Date.now() + 3 * 86400000, description: `${escapeHtml(args.description)}\n${marker}` };
246
+ }
247
+ else if (action === 'add_cases_to_plan') {
248
+ await this.checkCases(args);
249
+ const inventory = await this.ms.inventory(args.project_id, args.plan_id);
250
+ ensure(inventory.plan.status !== 'Archived', 'ARCHIVED_PLAN', '已归档计划不可修改');
251
+ this.revision(args, session, 'plan_membership', args.plan_id, this.ms.membership(inventory));
252
+ plan.before = this.ms.membership(inventory);
253
+ plan.existing = inventory.cases;
254
+ plan.sources = [];
255
+ for (const id of args.case_ids)
256
+ plan.sources.push({ id, snapshot: executionDefinition(await this.ms.case(args.project_id, id)) });
257
+ }
258
+ else if (action === 'record_case_result' || action === 'link_mantis_issue') {
259
+ const targetPlan = await this.ms.plan(args.project_id, args.plan_id);
260
+ ensure(targetPlan.status !== 'Archived', 'ARCHIVED_PLAN', '已归档计划不可修改');
261
+ const item = await this.ms.planCase(args.project_id, args.plan_id, args.plan_case_id);
262
+ this.revision(args, session, 'case_execution', args.plan_case_id, executionSnapshot(item));
263
+ plan.before = executionSnapshot(item);
264
+ plan.case_id = item.caseId;
265
+ const records = await this.ms.records(item.caseId);
266
+ const matching = records.filter(x => x.project_id === args.project_id && x.plan_id === args.plan_id && x.plan_case_id === args.plan_case_id && x.run_id === args.run_id && x.attempt_id === args.attempt_id && x.kind === 'execution');
267
+ const record = { schema_version: 1, kind: action === 'record_case_result' ? 'execution' : 'mantis_reference', actor_id: session.user.id,
268
+ ...pick(args, ['project_id', 'plan_id', 'plan_case_id', 'run_id', 'attempt_id']), case_id: item.caseId, operation_id: args.operation_id || 'preview', definition_hash: digest(executionDefinition(item)) };
269
+ if (action === 'record_case_result') {
270
+ ensure(!matching.length, 'DUPLICATE_ATTEMPT', `该 attempt 已记录,请核查原操作 ${matching[0]?.operation_id || ''}`);
271
+ plan.body = resultBody(item, args.result);
272
+ record.result = args.result;
273
+ }
274
+ else {
275
+ ensure(matching.length === 1 && matching[0].actor_id === session.user.id && matching[0].definition_hash === record.definition_hash, 'MISSING_CURRENT_ATTEMPT', '找不到当前主体的唯一对应执行记录');
276
+ const binding = this.ms.config.workflow.project_bindings.find(x => x.metersphere_project_id === args.project_id);
277
+ ensure(binding?.mantis_project_id === args.mantis_project_id, 'MISSING_PROJECT_BINDING', 'Mantis 项目与显式绑定不一致');
278
+ Object.assign(record, pick(args, ['mantis_project_id', 'issue_id', 'relation', 'reason']), { url: `${this.ms.config.workflow.mantis_base_url}/view.php?id=${args.issue_id}` });
279
+ }
280
+ ensure(JSON.stringify(record).length <= this.ms.config.workflow.limits.max_execution_record_chars, 'RECORD_TOO_LARGE', '执行记录超过配置上限,未截断证据');
281
+ plan.record = record;
282
+ plan.comment = { caseId: item.caseId, description: renderRecord(record) };
283
+ }
284
+ else if (action === 'complete_test_plan' || action === 'generate_test_report') {
285
+ const inventory = await this.ms.inventory(args.project_id, args.plan_id);
286
+ plan.execution_summary = await this.ms.executionSummary(inventory, args.run_id);
287
+ plan.before = this.ms.completionSnapshot(inventory, args.run_id, plan.execution_summary);
288
+ this.revision(args, session, 'plan_completion', args.plan_id, plan.before);
289
+ ensure(!(await this.context.ledger(session).pending(args.plan_id, args.operation_id)).length, 'PENDING_OPERATION', '该计划有尚待核查的写操作');
290
+ if (action === 'complete_test_plan') {
291
+ ensure(inventory.plan.status !== 'Archived', 'ARCHIVED_PLAN', '已归档计划不可完成');
292
+ ensure(plan.execution_summary.completion_ready, 'INCOMPLETE_SCOPE', '本轮存在未执行、未处置失败或不支持的计划成员,请读取 execution_summary');
293
+ plan.already_terminal = ['Completed', 'Finished'].includes(inventory.plan.status);
294
+ }
295
+ else {
296
+ this.reportReady(inventory, plan.execution_summary);
297
+ plan.before_report_ids = (await this.ms.all('/test/plan/report/list', { projectId: args.project_id })).map(x => x.id);
298
+ plan.report_snapshot = inventory.cases.map((x) => pick(x, ['id', 'caseId', 'status', 'results', 'actualResult']));
299
+ }
300
+ }
301
+ plan.public_plan = { action, subject: plan.subject, ...(plan.body ? { fields: plan.body } : {}),
302
+ ...(plan.location ? { path: plan.location.path } : {}),
303
+ ...(plan.comment ? { execution_record: plan.record, may_notify_followers: true } : {}), ...(args.case_ids ? { case_ids: args.case_ids } : {}),
304
+ ...(plan.execution_summary ? { execution_summary: plan.execution_summary } : {}) };
305
+ return plan;
306
+ }
307
+ revision(args, session, scope, subject, snapshot) {
308
+ if (args.expected_revision)
309
+ session.tokens.check(args.expected_revision, scope, subject, snapshot);
310
+ else
311
+ ensure(args.dry_run, 'INVALID_INPUT', '缺少对应的 expected_revision');
312
+ }
313
+ async checkCases(args) {
314
+ ensure(args.case_ids.length <= this.ms.config.workflow.limits.max_association_items && new Set(args.case_ids).size === args.case_ids.length, 'INVALID_INPUT', '关联用例不能重复或超过配置上限');
315
+ for (const id of args.case_ids)
316
+ await this.ms.case(args.project_id, id);
317
+ }
318
+ reportReady(inventory, summary) {
319
+ ensure(inventory.plan.status !== 'Archived', 'ARCHIVED_PLAN', '已归档计划不可生成报告,以免解除归档');
320
+ ensure(['Completed', 'Finished'].includes(inventory.plan.status), 'PLAN_NOT_COMPLETED', '生成报告会重算计划状态;请先完成本轮计划,不支持未完成的阶段报告');
321
+ ensure(!Object.values(inventory.other_counts).some(x => Number(x) > 0), 'MIXED_PLAN', '首版不生成混合计划的完整测试报告');
322
+ ensure(summary.completion_ready, 'INCOMPLETE_SCOPE', '生成报告前须核对本轮完整执行与失败处置,不能使用旧轮次结果');
323
+ }
324
+ membershipMatches(current, before, allowAdditions = false) {
325
+ // testPlanRelevance 在新增成员后将 Prepare/Completed 改为 Underway,发送前不放宽检查。
326
+ const expectedPlan = allowAdditions && ['Prepare', 'Completed'].includes(before.plan.status) ? { ...before.plan, status: 'Underway' } : before.plan;
327
+ return digest(current.plan) === digest(expectedPlan) && digest(current.other_counts) === digest(before.other_counts)
328
+ && (allowAdditions || current.members.length === before.members.length)
329
+ // 旧账本的列表快照可能没有 refId;保留其中已记录字段的检查,不能因补读详情而误报变化。
330
+ && before.members.every((x) => current.members.some((v) => fieldsMatch(v, x)));
331
+ }
332
+ async guard(op) {
333
+ const a = op.args;
334
+ await this.context.visible(a);
335
+ if (op.action === 'create_case_module') {
336
+ const location = await this.ms.moduleLocation(a.project_id, a.name, a.parent_id);
337
+ ensure(digest(location) === digest(op.plan.location), 'REVISION_CONFLICT', '创建前父模块的位置或名称已改变,请重新核对模块树');
338
+ }
339
+ if (op.action === 'create_test_case') {
340
+ const node = await this.ms.node(a.project_id, a.node_id);
341
+ const unchanged = op.plan.node_location ? digest(node) === digest(op.plan.node_location) : node.path === op.plan.body.nodePath;
342
+ ensure(unchanged, 'REVISION_CONFLICT', '创建前目标模块的位置或名称已改变,请重新核对模块树');
343
+ }
344
+ if (op.action === 'update_test_case')
345
+ ensure(digest(await this.ms.caseEditState(a.project_id, a.case_id)) === digest(op.plan.before), 'REVISION_CONFLICT', '编辑前用例、附件或关注人已改变');
346
+ if (op.action === 'add_cases_to_plan') {
347
+ const inventory = await this.ms.inventory(a.project_id, a.plan_id);
348
+ ensure(this.membershipMatches(this.ms.membership(inventory), op.plan.current_membership || op.plan.before), 'REVISION_CONFLICT', '关联前计划成员已改变');
349
+ for (const source of op.plan.sources)
350
+ ensure(digest(executionDefinition(await this.ms.case(a.project_id, source.id))) === digest(source.snapshot), 'REVISION_CONFLICT', '待关联用例内容已改变');
351
+ }
352
+ if (op.action === 'create_case_review') {
353
+ for (const source of op.plan.sources)
354
+ ensure(digest(executionDefinition(await this.ms.case(a.project_id, source.id))) === digest(source.snapshot), 'REVISION_CONFLICT', '待评审用例内容已改变');
355
+ if (op.plan.created_id)
356
+ await this.ms.review(a.project_id, op.plan.created_id);
357
+ }
358
+ if (['record_case_result', 'link_mantis_issue'].includes(op.action)) {
359
+ const current = await this.ms.planCase(a.project_id, a.plan_id, a.plan_case_id);
360
+ ensure(digest(executionSnapshot(current)) === digest(op.plan.before), 'REVISION_CONFLICT', '执行结果或用例定义已改变');
361
+ ensure((await this.ms.plan(a.project_id, a.plan_id)).status !== 'Archived', 'ARCHIVED_PLAN', '计划已归档');
362
+ }
363
+ if (['complete_test_plan', 'generate_test_report'].includes(op.action)) {
364
+ const now = await this.ms.inventory(a.project_id, a.plan_id);
365
+ const summary = await this.ms.executionSummary(now, a.run_id);
366
+ if (op.action === 'generate_test_report')
367
+ this.reportReady(now, summary);
368
+ ensure(digest(this.ms.completionSnapshot(now, a.run_id, summary)) === digest(op.plan.before), 'REVISION_CONFLICT', '计划范围、结果或执行证据已改变');
369
+ }
370
+ }
371
+ async write(op, ledger, name, spec, mayWrite, verify) {
372
+ let step = op.steps.find(x => x.name === name);
373
+ if (!step) {
374
+ step = { name, state: 'not_started' };
375
+ op.steps.push(step);
376
+ await ledger.save(op);
377
+ }
378
+ if (['succeeded', 'unknown'].includes(step.state)) {
379
+ if (await verify()) {
380
+ step.state = 'succeeded';
381
+ step.error = undefined;
382
+ await ledger.save(op);
383
+ return true;
384
+ }
385
+ if (op.action === 'generate_test_report') {
386
+ op.outcome = 'unknown';
387
+ return false;
388
+ }
389
+ if (step.state === 'succeeded')
390
+ throw new WorkflowError('RESULT_MISMATCH', '已确认写入的当前内容与预期不一致;禁止重复写入');
391
+ op.outcome = 'unknown';
392
+ return false;
393
+ }
394
+ if (!mayWrite) {
395
+ op.outcome = op.steps.some(x => x.state === 'succeeded') ? 'partial_success' : 'rejected';
396
+ return false;
397
+ }
398
+ await this.guard(op);
399
+ const hash = digest(spec);
400
+ ensure(!step.request_hash || step.request_hash === hash, 'OPERATION_CONFLICT', '恢复步骤的请求内容发生变化');
401
+ step.request_hash = hash;
402
+ step.state = 'in_flight';
403
+ step.error = undefined;
404
+ step.started_at = Date.now();
405
+ await ledger.save(op);
406
+ try {
407
+ step.response = await this.ms.client.request(spec);
408
+ step.state = 'succeeded';
409
+ step.acknowledged_at = Date.now();
410
+ await ledger.save(op);
411
+ }
412
+ catch (error) {
413
+ step.state = error instanceof ApiError && error.definite ? 'rejected' : 'unknown';
414
+ step.error = problem(error);
415
+ await ledger.save(op);
416
+ throw error;
417
+ }
418
+ if (!await verify()) {
419
+ if (op.action === 'generate_test_report') {
420
+ op.outcome = 'unknown';
421
+ return false;
422
+ }
423
+ throw new WorkflowError('RESULT_MISMATCH', '写入响应成功,但回读未确认预期效果;未重新发送写入');
424
+ }
425
+ await ledger.save(op);
426
+ return true;
427
+ }
428
+ async comment(op, session, ledger, mayWrite) {
429
+ return this.write(op, ledger, 'append_execution_record', { method: 'POST', path: '/test/case/comment/save', effect: 'write', body: op.plan.comment }, mayWrite, async () => {
430
+ const found = (await this.ms.comments(op.plan.case_id)).filter(x => x.caseId === op.plan.case_id && x.author === session.user.id && x.description === op.plan.comment.description);
431
+ if (found.length !== 1)
432
+ return false;
433
+ op.plan.comment_id = found[0].id;
434
+ return true;
435
+ });
436
+ }
437
+ async run(op, session, ledger, mayWrite) {
438
+ const a = op.args, p = op.plan;
439
+ op.error = undefined;
440
+ if (op.action === 'create_case_module') {
441
+ if (!await this.write(op, ledger, 'create_module', { method: 'POST', path: '/case/node/add', effect: 'write', body: p.body }, mayWrite, async () => {
442
+ const response = op.steps.find(s => s.name === 'create_module')?.response;
443
+ if (response !== undefined && response !== p.body.id)
444
+ return false;
445
+ const node = await this.ms.node(a.project_id, p.body.id);
446
+ if (!fieldsMatch(node, p.body) || node.path !== p.location.path)
447
+ return false;
448
+ p.subject.node_id = node.id;
449
+ return true;
450
+ }))
451
+ return;
452
+ op.result = { ...p.subject, parent_id: p.body.parentId, level: p.body.level, path: p.location.path, created: true };
453
+ }
454
+ else if (['create_test_case', 'create_test_plan', 'create_case_review'].includes(op.action)) {
455
+ const kind = op.action === 'create_test_case' ? 'case' : op.action === 'create_test_plan' ? 'plan' : 'review';
456
+ const path = kind === 'case' ? '/test/case/add' : kind === 'plan' ? '/test/plan/add' : '/test/case/review/save';
457
+ const verified = await this.write(op, ledger, 'create', { method: 'POST', path, effect: 'write', body: p.body, multipart: kind === 'case' }, mayWrite, async () => {
458
+ const response = op.steps.find(s => s.name === 'create')?.response;
459
+ const responseId = typeof response === 'string' && response !== 'success' ? response : typeof response === 'object' ? response?.id : undefined;
460
+ if (kind === 'case' && p.body.id && responseId && responseId !== p.body.id)
461
+ return false;
462
+ let id = p.created_id || (kind === 'case' ? p.body.id : undefined) || responseId;
463
+ if (!id) {
464
+ // 兼容升级前已持久化的来源标记;新用例不再生成标记,也不按名称推测目标。
465
+ if (typeof p.marker !== 'string' || !p.marker)
466
+ return false;
467
+ const listPath = kind === 'case' ? '/test/case/list' : kind === 'plan' ? '/test/plan/list' : '/test/case/review/list';
468
+ const found = (await this.ms.all(listPath, { projectId: a.project_id, name: a.name })).filter(x => x.projectId === a.project_id && x.name === a.name && String(x[kind === 'case' ? 'remark' : 'description']).includes(p.marker));
469
+ if (found.length !== 1)
470
+ return false;
471
+ id = found[0].id;
472
+ }
473
+ let actual;
474
+ if (kind === 'case')
475
+ actual = await this.ms.case(a.project_id, id);
476
+ else if (kind === 'plan')
477
+ actual = await this.ms.plan(a.project_id, id);
478
+ else
479
+ actual = await this.ms.review(a.project_id, id);
480
+ const expected = kind === 'case' ? pick(p.body, ['id', 'projectId', 'name', 'type', 'nodeId', 'nodePath', 'stepModel', 'steps', 'priority', 'prerequisite', 'tags', 'remark'])
481
+ : kind === 'plan' ? pick(p.body, ['projectId', 'name', 'stage', 'description', 'plannedStartTime', 'plannedEndTime']) : pick(p.body, ['projectId', 'name', 'description', 'endTime']);
482
+ if (!fieldsMatch(actual, expected))
483
+ return false;
484
+ if (kind === 'plan') {
485
+ const principals = await this.ms.principals(id);
486
+ if (digest([...new Set(principals.map(x => x.id))].sort()) !== digest([...p.body.principals].sort()))
487
+ return false;
488
+ }
489
+ if (kind === 'review' && digest((await this.ms.reviewers(id)).map(x => x.id).sort()) !== digest([...new Set(a.reviewer_ids)].sort()))
490
+ return false;
491
+ p.created_id = id;
492
+ p.subject[`${kind}_id`] = id;
493
+ return true;
494
+ });
495
+ if (!verified)
496
+ return;
497
+ if (kind === 'review') {
498
+ const linked = await this.write(op, ledger, 'associate_review_cases', { method: 'POST', path: '/test/case/review/relevance', effect: 'write', body: { reviewId: p.created_id, testCaseIds: a.case_ids, request: { selectAll: false }, checked: false } }, mayWrite, async () => {
499
+ const cases = await this.ms.all('/test/review/case/list', { projectId: a.project_id, reviewId: p.created_id });
500
+ for (const item of cases)
501
+ await this.ms.reviewCase(a.project_id, p.created_id, item);
502
+ return cases.length === a.case_ids.length && a.case_ids.every((id) => cases.filter(x => x.caseId === id).length === 1);
503
+ });
504
+ if (!linked)
505
+ return;
506
+ }
507
+ op.result = { ...p.subject, created: true, ...(kind === 'review' ? { case_ids: a.case_ids, review_decision_written: false } : {}) };
508
+ }
509
+ else if (op.action === 'update_test_case') {
510
+ if (!await this.write(op, ledger, 'update_definition', { method: 'POST', path: '/test/case/edit', effect: 'write', body: p.body, multipart: true }, mayWrite, async () => {
511
+ const state = await this.ms.caseEditState(a.project_id, a.case_id);
512
+ return fieldsMatch(state.item, pick(p.body, editableFields)) && digest(state.follows) === digest(p.before.follows) && digest(state.files) === digest(p.before.files);
513
+ }))
514
+ return;
515
+ const state = await this.ms.caseEditState(a.project_id, a.case_id);
516
+ op.result = { case_id: a.case_id, expected_revision: session.tokens.revision('case_definition', a.case_id, state) };
517
+ }
518
+ else if (op.action === 'add_cases_to_plan') {
519
+ for (const id of a.case_ids) {
520
+ if (p.existing.some(x => x.caseId === id))
521
+ continue;
522
+ if (!await this.write(op, ledger, `associate:${id}`, { method: 'POST', path: '/test/plan/relevance', effect: 'write', body: { planId: a.plan_id, ids: [id], request: { selectAll: false }, checked: false } }, mayWrite, async () => {
523
+ const inventory = await this.ms.inventory(a.project_id, a.plan_id);
524
+ const current = this.ms.membership(inventory), before = p.current_membership || p.before;
525
+ const additions = current.members.filter((x) => !before.members.some((v) => v.id === x.id));
526
+ if (inventory.cases.filter((x) => x.caseId === id && x.planId === a.plan_id && x.projectId === a.project_id).length !== 1)
527
+ return false;
528
+ if (!this.membershipMatches(current, before, true))
529
+ return false;
530
+ // 核查前面的成功步骤时,后续已发送步骤也可能已经生效;只接纳本操作确实发送过的成员。
531
+ const sent = new Set(op.steps.filter(s => ['succeeded', 'unknown', 'in_flight'].includes(s.state)).map(s => s.name));
532
+ if (additions.some((x) => {
533
+ const source = p.sources.find((s) => s.id === x.caseId);
534
+ return !sent.has(`associate:${x.caseId}`) || !source || x.refId !== source.snapshot.refId || x.versionId !== source.snapshot.versionId
535
+ || current.members.filter((v) => v.caseId === x.caseId).length !== 1;
536
+ }))
537
+ return false;
538
+ p.current_membership = current;
539
+ return true;
540
+ }))
541
+ return;
542
+ }
543
+ const inventory = await this.ms.inventory(a.project_id, a.plan_id);
544
+ ensure(a.case_ids.every((id) => inventory.cases.filter((x) => x.caseId === id).length === 1), 'RESULT_MISMATCH', '部分关联缺失或重复,未宣称全部成功');
545
+ op.result = { associations: inventory.cases.filter((x) => a.case_ids.includes(x.caseId)).map((x) => ({ case_id: x.caseId, plan_case_id: x.id, already_existed: p.existing.some((v) => v.id === x.id) })), expected_revision: session.tokens.revision('plan_membership', a.plan_id, this.ms.membership(inventory)) };
546
+ }
547
+ else if (op.action === 'record_case_result' || op.action === 'link_mantis_issue') {
548
+ if (!await this.comment(op, session, ledger, mayWrite))
549
+ return;
550
+ if (op.action === 'record_case_result') {
551
+ if (!await this.write(op, ledger, 'update_execution_result', { method: 'POST', path: '/test/plan/case/edit', effect: 'write', body: p.body }, mayWrite, async () => fieldsMatch(await this.ms.planCase(a.project_id, a.plan_id, a.plan_case_id), p.body)))
552
+ return;
553
+ }
554
+ const current = await this.ms.planCase(a.project_id, a.plan_id, a.plan_case_id);
555
+ op.result = { ...p.subject, comment_id: p.comment_id, observed_executor: current.executor ?? null, status: current.status,
556
+ expected_revision: session.tokens.revision('case_execution', a.plan_case_id, executionSnapshot(current)),
557
+ ...(op.action === 'link_mantis_issue' ? { issue_id: a.issue_id, url: p.record.url, link_kind: 'external_reference', native_issue_id: null, external_validation: 'caller_supplied' } : {}) };
558
+ }
559
+ else if (op.action === 'complete_test_plan') {
560
+ if (!p.already_terminal && !await this.write(op, ledger, 'complete_plan', { method: 'POST', path: '/test/plan/edit', effect: 'write', body: { id: a.plan_id, status: 'Completed' } }, mayWrite, async () => ['Completed', 'Finished'].includes((await this.ms.plan(a.project_id, a.plan_id)).status)))
561
+ return;
562
+ const inventory = await this.ms.inventory(a.project_id, a.plan_id);
563
+ const summary = await this.ms.executionSummary(inventory, a.run_id);
564
+ ensure(summary.completion_ready && ['Completed', 'Finished'].includes(inventory.plan.status), 'RESULT_MISMATCH', '完成后的实时范围、结果或生命周期不能确认完整');
565
+ ensure(digest(this.ms.evidenceSnapshot(summary)) === digest(this.ms.evidenceSnapshot(p.execution_summary)), 'RESULT_MISMATCH', '计划结束时的执行证据已发生变化');
566
+ op.result = { plan_id: a.plan_id, lifecycle_status: inventory.plan.status, summary: a.summary, summary_storage: 'local_operation', execution_summary: summary,
567
+ completion_revision: session.tokens.revision('plan_completion', a.plan_id, this.ms.completionSnapshot(inventory, a.run_id, summary)) };
568
+ }
569
+ else if (op.action === 'generate_test_report') {
570
+ if (!await this.write(op, ledger, 'generate_report', { method: 'GET', path: `/test/plan/report/saveTestPlanReport/${segment(a.plan_id)}/MANUAL`, effect: 'write' }, mayWrite, async () => {
571
+ const currentPlan = await this.ms.plan(a.project_id, a.plan_id);
572
+ const lifecycle = { plan_id: a.plan_id, lifecycle_status_before: p.before.membership.plan.status, lifecycle_status: currentPlan.status,
573
+ lifecycle_verified: ['Completed', 'Finished'].includes(currentPlan.status) };
574
+ op.result = lifecycle;
575
+ // 请求没有可靠确认时,单个相近报告也不能证明是该操作生成的。
576
+ const step = op.steps.find(x => x.name === 'generate_report');
577
+ if (step?.response !== 'success' || !step.started_at || !step.acknowledged_at)
578
+ return false;
579
+ // creator 在列表中是显示名,服务端筛选参数才使用真实用户 ID。
580
+ const found = (await this.ms.all('/test/plan/report/list', { projectId: a.project_id, creator: session.user.id })).filter(x => x.testPlanId === a.plan_id && !p.before_report_ids.includes(x.id) && x.triggerMode === 'MANUAL' && x.createTime >= step.started_at - 60000 && x.createTime <= step.acknowledged_at + 60000);
581
+ if (found.length !== 1) {
582
+ op.outcome = 'unknown';
583
+ return false;
584
+ }
585
+ const report = await this.ms.report(a.project_id, found[0].id);
586
+ op.result = { ...this.ms.reportView(report), ...lifecycle, execution_summary: p.execution_summary, stage_report: !p.execution_summary.completion_ready, correlation: 'best_effort', created_by_this_operation_verified: false, content_verification: 'partial' };
587
+ const verification = reportSnapshotVerification(report.content.functionAllCases, p.report_snapshot);
588
+ if (!verification)
589
+ return false;
590
+ Object.assign(op.result, verification);
591
+ return lifecycle.lifecycle_verified;
592
+ }))
593
+ return;
594
+ }
595
+ op.verified = true;
596
+ op.outcome = 'succeeded';
597
+ op.error = undefined;
598
+ }
599
+ }