@youcloud/mantis-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,550 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { RestError } from '../client.js';
3
+ import { ConnectionConfigError } from '../connection-config.js';
4
+ import { Planner } from './planner.js';
5
+ import { Operations } from './operations.js';
6
+ import { schemas, failure, WorkflowError, problem } from './contracts.js';
7
+ import { digest } from './tokens.js';
8
+ import { facts, visibleCollection } from '../services/issues.js';
9
+ import { locateNote, operationMarker, textHash } from '../services/notes.js';
10
+ import { reserveUpload, readUpload } from '../services/files.js';
11
+ /** 固定执行步骤;不自动重试写请求,不提供任意 REST 或 DAG 执行入口。 */
12
+ export class Executor {
13
+ context;
14
+ capabilities;
15
+ uploadDir;
16
+ planner;
17
+ constructor(context, capabilities, uploadDir) {
18
+ this.context = context;
19
+ this.capabilities = capabilities;
20
+ this.uploadDir = uploadDir;
21
+ this.planner = new Planner(context, uploadDir);
22
+ }
23
+ async execute(action, raw) {
24
+ let input = raw, operation, ledger;
25
+ let releaseOperation, releaseTarget, releaseUpload;
26
+ try {
27
+ input = schemas[action].parse(raw);
28
+ if (!input.dry_run && !input.operation_id)
29
+ throw new WorkflowError('INVALID_INPUT', '真实写入必须提供 operation_id');
30
+ if (!input.dry_run && !['report_issue', 'create_version'].includes(action) && !input.expected_revision)
31
+ throw new WorkflowError('INVALID_INPUT', '修改已有对象必须提供 expected_revision');
32
+ if (input.dry_run && input.resume)
33
+ throw new WorkflowError('INVALID_INPUT', '预览不能同时请求恢复');
34
+ input = { ...input, operation_id: input.operation_id ?? randomUUID() };
35
+ const session = await this.context.projects.authenticate();
36
+ if (input.dry_run) {
37
+ if (action === 'upload_file')
38
+ releaseUpload = reserveUpload();
39
+ const { plan } = await this.planner.prepare(action, input, session);
40
+ return { ...failure(action, input, new Error()), outcome: 'preview', error: undefined,
41
+ subject: { project_id: plan.project_id, issue_id: plan.issue_id ?? null }, plan: this.publicPlan(plan),
42
+ warnings: ['PREVIEW_DOES_NOT_CHECK_WRITE_PERMISSION', 'CONCURRENCY_PRECHECK_ONLY'] };
43
+ }
44
+ ledger = new Operations(session.dir);
45
+ const lock = await ledger.lock(`operation-${input.operation_id}`);
46
+ if (!lock)
47
+ return await this.busy(action, input, session, ledger);
48
+ releaseOperation = lock;
49
+ const { dry_run: _dry, resume: _resume, ...business } = input;
50
+ const requestHash = digest({ action, business });
51
+ operation = await ledger.load(input.operation_id) ?? undefined;
52
+ if (operation && (operation.request_hash !== requestHash || operation.action !== action))
53
+ throw new WorkflowError('OPERATION_ID_REUSED', '此操作编号已用于不同动作或业务参数');
54
+ if (!operation && input.resume)
55
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '原操作记录不存在,禁止把恢复作为新操作执行');
56
+ if (operation && ledger.expired(operation))
57
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '操作已超过七天恢复保留期,请人工核对');
58
+ const target = operation ? operation.issue_id ? `issue-${operation.issue_id}` : `project-${operation.project_id}`
59
+ : input.issue_id ? `issue-${input.issue_id}` : `project-${input.project_id}`;
60
+ const targetLock = await ledger.lock(target);
61
+ if (!targetLock)
62
+ throw new WorkflowError('OPERATION_BUSY', '目标对象正由另一个本机操作处理,本次未发送写请求');
63
+ releaseTarget = targetLock;
64
+ let bytes;
65
+ if (operation) {
66
+ const observed = await this.observe(operation, session);
67
+ if (observed.restricted)
68
+ return this.restricted(operation);
69
+ if (operation.result?.outcome === 'succeeded')
70
+ return { ...operation.result, current_observation: observed.public };
71
+ if (operation.steps.some(s => s.state === 'in_flight')) {
72
+ operation.steps.filter(s => s.state === 'in_flight').forEach(s => { s.state = 'unknown'; s.error_code = 'WRITE_OUTCOME_UNKNOWN'; });
73
+ }
74
+ await this.reconcile(operation, observed, session);
75
+ // 恢复核查得到的步骤和写后快照必须先落盘,再允许发送剩余写入。
76
+ await ledger.save(operation);
77
+ if (operation.steps.every(s => s.state === 'succeeded'))
78
+ return await this.finish(operation, session, ledger);
79
+ if (!input.resume || operation.steps.some(s => s.state === 'unknown') || this.needsPrimarySnapshot(operation)) {
80
+ return await this.finish(operation, session, ledger, observed.public);
81
+ }
82
+ const currentBinding = operation.plan.kind === 'version' ? this.context.bindings.revision : this.context.bindingRevision(await this.context.optionalRules(operation.project_id));
83
+ if (currentBinding !== operation.binding || digest(observed.raw) !== (operation.after_hash ?? operation.plan.before_hash)) {
84
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '绑定或对象已改变,不能继续原操作,请人工核对');
85
+ }
86
+ if (operation.steps[0].state !== 'succeeded' && !['report_issue', 'create_version'].includes(action)) {
87
+ session.tokens.check(input.expected_revision, operation.plan.kind === 'version' ? `versions:${operation.project_id}` : `issue:${operation.issue_id}`, currentBinding, observed.raw);
88
+ }
89
+ if (action === 'upload_file' && operation.steps[0].state !== 'succeeded') {
90
+ releaseUpload = reserveUpload();
91
+ const file = await readUpload(input.file_path, input.filename, operation.plan.file.file_sha256, this.uploadDir);
92
+ if (digest(file.info) !== digest(operation.plan.file))
93
+ throw new WorkflowError('FILE_CHANGED', '恢复时附件与已冻结摘要不符');
94
+ bytes = file.bytes;
95
+ }
96
+ }
97
+ else {
98
+ if (action === 'upload_file')
99
+ releaseUpload = reserveUpload();
100
+ const prepared = await this.planner.prepare(action, input, session);
101
+ bytes = prepared.bytes;
102
+ const plan = prepared.plan;
103
+ operation = { operation_id: input.operation_id, action, user_id: session.user.id, project_id: plan.project_id, issue_id: plan.issue_id,
104
+ request_hash: requestHash, resolved_hash: digest(plan), binding: plan.binding, created_at: Date.now(), updated_at: Date.now(), plan,
105
+ steps: [{ name: plan.primary, state: 'not_started' }, { name: 'verify_primary', state: 'not_started' },
106
+ ...(plan.note ? [{ name: 'append_note', state: 'not_started' }] : []),
107
+ ...(plan.next_version ? [{ name: 'create_next_version', state: 'not_started' }] : []),
108
+ { name: 'verify_result', state: 'not_started' }] };
109
+ await ledger.save(operation);
110
+ }
111
+ await this.run(operation, session, ledger, bytes);
112
+ return await this.finish(operation, session, ledger);
113
+ }
114
+ catch (e) {
115
+ if (operation && ledger && releaseTarget) {
116
+ // 账本失败也必须保留已知步骤;任何在途写入都不能被降级为普通失败。
117
+ const result = this.result(operation);
118
+ result.error = problem(e);
119
+ if (e instanceof WorkflowError && !operation.steps.some(s => ['in_flight', 'unknown'].includes(s.state))) {
120
+ result.recovery = { action: 'manual_review', automatic_retry: false };
121
+ if (!operation.steps.some(s => s.state === 'succeeded'))
122
+ result.outcome = e.conflict ? 'conflict' : 'rejected';
123
+ }
124
+ else {
125
+ result.warnings.push('LEDGER_OR_VERIFICATION_UNAVAILABLE');
126
+ if (operation.steps.some(s => ['in_flight', 'unknown'].includes(s.state)))
127
+ result.outcome = 'unknown';
128
+ result.recovery = { action: 'manual_review', automatic_retry: false };
129
+ }
130
+ return result;
131
+ }
132
+ return failure(action, input, e);
133
+ }
134
+ finally {
135
+ releaseUpload?.();
136
+ await releaseTarget?.().catch(() => { });
137
+ await releaseOperation?.().catch(() => { });
138
+ }
139
+ }
140
+ publicPlan(plan) {
141
+ return { fields: plan.body ?? null, note: plan.note ?? null, file: plan.file ?? null, ...plan.revision,
142
+ concurrency_mode: this.capabilities.conditionalRequests && plan.etag ? 'conditional_request' : 'snapshot_precheck',
143
+ next_version: plan.next_version ?? null };
144
+ }
145
+ async busy(action, input, session, ledger) {
146
+ const record = await ledger.load(input.operation_id);
147
+ if (record) {
148
+ const { dry_run: _d, resume: _r, ...business } = input;
149
+ if (record.request_hash !== digest({ action, business }))
150
+ return failure(action, input, new WorkflowError('OPERATION_ID_REUSED', '操作编号已用于不同请求'));
151
+ const observed = await this.observe(record, session);
152
+ if (observed.restricted)
153
+ return this.restricted(record);
154
+ }
155
+ return { ...failure(action, input, new Error()), outcome: 'in_progress', error: undefined, steps: record?.steps ?? [],
156
+ recovery: { action: 'read_again', automatic_retry: false } };
157
+ }
158
+ result(record) {
159
+ const steps = record.steps;
160
+ const uncertain = steps.some(s => ['unknown', 'in_flight'].includes(s.state));
161
+ const done = steps.every(s => s.state === 'succeeded');
162
+ const wrote = steps.some(s => s.state === 'succeeded' && !s.name.startsWith('verify_'));
163
+ const conflict = steps.some(s => s.error_code === 'REVISION_CONFLICT');
164
+ const needsSnapshot = this.needsPrimarySnapshot(record);
165
+ const canResume = !uncertain && !conflict && !needsSnapshot && steps.some(s => s.state !== 'succeeded');
166
+ const mode = this.capabilities.conditionalRequests && record.plan.etag ? 'conditional_request' : 'snapshot_precheck';
167
+ return { schema_version: 1, action: record.action, operation_id: record.operation_id,
168
+ outcome: uncertain ? 'unknown' : done ? 'succeeded' : wrote ? 'partial_success' : conflict ? 'conflict' : 'rejected',
169
+ subject: { project_id: record.project_id, issue_id: record.issue_id ?? null }, result: record.result?.result ?? null,
170
+ steps: steps.map(s => ({ ...s })), verification: { status: done ? 'matched' : 'unverified', scope: Object.keys(record.plan.expected), concurrency_mode: mode },
171
+ recovery: { action: done ? 'none' : uncertain || conflict || needsSnapshot ? 'manual_review' : canResume ? 'retry_same_operation' : 'read_again',
172
+ automatic_retry: false, ...(canResume ? { tool: record.action, resume: true, resume_steps: steps.filter(s => s.state !== 'succeeded').map(s => s.name) } : {}) },
173
+ warnings: mode === 'snapshot_precheck' ? ['CONCURRENCY_PRECHECK_ONLY'] : [] };
174
+ }
175
+ needsPrimarySnapshot(record) {
176
+ return record.steps[0].state === 'succeeded' && !record.after_hash
177
+ && record.steps.slice(1).some(s => !s.name.startsWith('verify_') && s.state !== 'succeeded');
178
+ }
179
+ restricted(record) {
180
+ return { ...this.result(record), result: null, recovery: { action: 'manual_review', automatic_retry: false },
181
+ warnings: ['TARGET_NOT_VISIBLE_HISTORY_REDACTED'], verification: { status: 'unavailable' } };
182
+ }
183
+ async observe(record, session) {
184
+ try {
185
+ if (record.plan.kind === 'version' || !record.issue_id) {
186
+ await this.context.projects.get(record.project_id);
187
+ const versions = record.plan.kind === 'version' ? await this.context.projects.versions(record.project_id) : undefined;
188
+ return { raw: versions, public: versions ? { versions } : { project_id: record.project_id } };
189
+ }
190
+ const state = await this.context.issues.get(record.issue_id);
191
+ if (state.issue.project.id !== record.project_id)
192
+ return { restricted: true, public: {} };
193
+ return { raw: state.issue, public: { issue: facts(state.issue, session.baseUrl) } };
194
+ }
195
+ catch (e) {
196
+ if (e instanceof RestError && e.status === 401)
197
+ throw e;
198
+ if (e instanceof RestError && [403, 404].includes(e.status ?? 0) || e instanceof WorkflowError && e.code === 'NOT_FOUND_OR_NOT_VISIBLE')
199
+ return { restricted: true, public: {} };
200
+ throw e;
201
+ }
202
+ }
203
+ async run(record, session, ledger, bytes) {
204
+ const plan = record.plan;
205
+ for (const step of record.steps) {
206
+ if (step.state === 'succeeded')
207
+ continue;
208
+ if (step.state === 'unknown' || step.state === 'in_flight')
209
+ break;
210
+ if (step.name.startsWith('verify_')) {
211
+ try {
212
+ await this.verify(record, session, step.name === 'verify_result');
213
+ step.state = 'succeeded';
214
+ step.evidence = 'observed_target_state';
215
+ delete step.error_code;
216
+ }
217
+ catch (e) {
218
+ step.state = 'failed';
219
+ step.error_code = problem(e).code;
220
+ await ledger.save(record);
221
+ break;
222
+ }
223
+ await ledger.save(record);
224
+ continue;
225
+ }
226
+ step.state = 'in_flight';
227
+ delete step.error_code;
228
+ await ledger.save(record);
229
+ try {
230
+ let method = plan.method, path = plan.path, body = plan.body;
231
+ if (step.name === 'append_note' && plan.note) {
232
+ method = 'POST';
233
+ path = `issues/${record.issue_id}/notes`;
234
+ body = plan.note;
235
+ }
236
+ if (step.name === 'create_next_version') {
237
+ method = 'POST';
238
+ path = `projects/${record.project_id}/versions`;
239
+ body = { name: plan.next_version };
240
+ }
241
+ if (step.name === 'upload_file') {
242
+ if (!bytes)
243
+ throw new RestError(null, false, true);
244
+ body = { files: [{ name: plan.file.filename, content: bytes.toString('base64'), type: plan.file.content_type }] };
245
+ }
246
+ const response = await this.context.projects.client.request(method, path, body, {
247
+ ifMatch: method === 'PATCH' && this.capabilities.conditionalRequests ? plan.etag ?? undefined : undefined,
248
+ ...(step.name === 'upload_file' ? { timeoutMs: 120000 } : {})
249
+ });
250
+ step.state = 'succeeded';
251
+ step.evidence = 'confirmed_by_response';
252
+ step.http_status = response.status;
253
+ step.request_sent = response.requestSent;
254
+ const responseId = step.name === 'append_note' ? response.body?.note?.id
255
+ : plan.kind === 'version' ? response.body?.version?.id
256
+ : response.body?.issue?.id ?? response.body?.issues?.[0]?.id ?? response.body?.id;
257
+ if (Number.isSafeInteger(responseId))
258
+ step.server_id = responseId;
259
+ if (step.name === 'create_issue')
260
+ record.issue_id = step.server_id;
261
+ if (step === record.steps[0] && (plan.note || plan.next_version)) {
262
+ const targetId = plan.kind === 'version' ? plan.version_id ?? step.server_id : record.issue_id;
263
+ const target = plan.kind === 'version' ? response.body?.version
264
+ : (Array.isArray(response.body?.issues) ? response.body.issues.find((i) => i.id === targetId) : undefined) ?? response.body?.issue;
265
+ // 只保留响应摘要并随成功步骤落盘;不能把稍后读到的对象冒充写入响应。
266
+ if (target?.id === targetId && targetId !== undefined)
267
+ record.primary_response_hash = digest(target);
268
+ }
269
+ }
270
+ catch (e) {
271
+ const definite = e instanceof ConnectionConfigError || e instanceof RestError && e.definiteRejection;
272
+ step.state = definite ? 'failed' : 'unknown';
273
+ step.error_code = problem(e).code;
274
+ if (e instanceof RestError) {
275
+ step.http_status = e.status;
276
+ step.request_sent = e.requestSent;
277
+ }
278
+ if (e instanceof ConnectionConfigError) {
279
+ step.http_status = null;
280
+ step.request_sent = false;
281
+ }
282
+ await ledger.save(record);
283
+ break;
284
+ }
285
+ // 成功响应先单独落盘,再读取快照;读失败不得抹掉已确认的写入。
286
+ await ledger.save(record);
287
+ }
288
+ }
289
+ matches(actual, expected) {
290
+ return Object.entries(expected).every(([key, value]) => {
291
+ if (key === 'custom_fields')
292
+ return value.every((f) => actual.custom_fields?.some((v) => v.field?.id === f.field.id && v.value === f.value));
293
+ if (key === 'timestamp')
294
+ return /^\d{4}-\d{2}-\d{2}$/.test(value)
295
+ ? String(actual[key]).startsWith(value) : Math.floor(Date.parse(actual[key]) / 1000) === Math.floor(Date.parse(value) / 1000);
296
+ if (value && typeof value === 'object')
297
+ return value.id !== undefined ? actual[key]?.id === value.id : actual[key]?.name === value.name || actual[key] === value.name;
298
+ return actual[key] === value;
299
+ });
300
+ }
301
+ async feedbackTransition(record, issue, note) {
302
+ const plan = record.plan;
303
+ const handler = issue.handler?.id ?? 0;
304
+ if (record.action !== 'reopen_issue' || record.steps[0].state !== 'succeeded'
305
+ || record.steps.find(s => s.name === 'verify_primary')?.state !== 'succeeded'
306
+ || record.steps.find(s => s.name === 'append_note')?.state !== 'succeeded'
307
+ || issue.reporter?.id !== record.user_id || plan.before_facts.reporter?.id !== record.user_id
308
+ || handler === record.user_id || handler !== (plan.before_facts.handler?.id ?? 0)
309
+ || !this.matches(issue, { ...plan.expected, status: issue.status }) || !Array.isArray(issue.history))
310
+ return false;
311
+ // 唯一本条备注之后紧邻的同作者、同秒状态历史,才可归入官方反馈流转。
312
+ // 后来再次变更状态/结果/负责人时不放宽核查,即使最终编号恰好相同。
313
+ const history = issue.history;
314
+ const added = history.flatMap((event, index) => event.type?.name === 'note-added' && event.note?.id === note.id ? [index] : []);
315
+ if (added.length !== 1)
316
+ return false;
317
+ const index = added[0], entry = history[index], transition = history[index + 1];
318
+ const time = Date.parse(note.created_at);
319
+ if (!Number.isFinite(time) || entry.user?.id !== record.user_id || Date.parse(entry.created_at) !== time
320
+ || transition?.type?.name !== 'field-updated' || transition.field?.name !== 'status'
321
+ || transition.user?.id !== record.user_id || Date.parse(transition.created_at) !== time
322
+ || transition.old_value?.id !== plan.expected.status?.id || transition.new_value?.id !== issue.status?.id
323
+ || history.slice(index + 2).some(event => ['status', 'resolution', 'handler_id', 'reporter_id', 'handler', 'reporter'].includes(event.field?.name)))
324
+ return false;
325
+ const rules = await this.context.projects.feedbackRules(record.project_id);
326
+ const status = rules[handler === 0 ? 'bug_submit_status' : 'bug_assigned_status'];
327
+ return rules.reassign_on_feedback === 1 && rules.bug_feedback_status === plan.expected.status?.id
328
+ && typeof status === 'number' && Number.isSafeInteger(status) && status > 0 && status === issue.status?.id
329
+ && typeof rules.bug_resolved_status_threshold === 'number' && status < rules.bug_resolved_status_threshold
330
+ && typeof rules.bug_closed_status_threshold === 'number' && status < rules.bug_closed_status_threshold;
331
+ }
332
+ checkPrimarySnapshot(record, current, versions) {
333
+ // 完整回读必须与当时响应一致;空响应、局部响应和缺少主响应证据的账本都不能凭目标字段相符补建基线。
334
+ if (!record.primary_response_hash || digest(current) !== record.primary_response_hash) {
335
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '缺少匹配的主写入响应快照或对象已改变,不能继续原操作,请人工核对');
336
+ }
337
+ if (versions && digest(versions.filter(v => v.id !== current.id)) !== record.plan.unchanged_versions_hash) {
338
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '其他版本已改变或缺少原始快照,不能继续原操作,请人工核对');
339
+ }
340
+ }
341
+ async verify(record, session, final, recoverPrimary = false) {
342
+ const plan = record.plan;
343
+ let result;
344
+ if (record.action === 'delete_issue') {
345
+ try {
346
+ await this.context.issues.get(record.issue_id);
347
+ throw new WorkflowError('VERIFICATION_MISMATCH', '删除响应成功但对象仍可读取');
348
+ }
349
+ catch (e) {
350
+ if (!(e instanceof RestError && e.status === 404))
351
+ throw e;
352
+ }
353
+ result = { deleted: true, issue_id: record.issue_id, expected_revision: null, revision_expires_at: null };
354
+ }
355
+ else if (plan.kind === 'version') {
356
+ const versions = await this.context.projects.versions(record.project_id);
357
+ const current = versions.find(v => v.id === (plan.version_id ?? record.steps[0].server_id));
358
+ if (record.action === 'delete_version' ? !!current : !current || !this.matches(current, plan.expected))
359
+ throw new WorkflowError('VERIFICATION_MISMATCH', '版本回读与预期不符');
360
+ if (recoverPrimary)
361
+ this.checkPrimarySnapshot(record, current, versions);
362
+ if (final && plan.next_version) {
363
+ const next = record.steps.find(s => s.name === 'create_next_version');
364
+ if (!versions.some(v => v.id === next?.server_id && v.name === plan.next_version))
365
+ throw new WorkflowError('VERIFICATION_MISMATCH', '后续版本未核对');
366
+ }
367
+ record.after_hash = digest(versions);
368
+ result = { version: current ?? null, versions_revision: session.tokens.revision(`versions:${record.project_id}`, plan.binding, versions).expected_revision };
369
+ }
370
+ else {
371
+ if (!record.issue_id)
372
+ throw new WorkflowError('VERIFICATION_UNAVAILABLE', '创建响应未返回可核对的缺陷编号');
373
+ const { issue, etag, emptyArraysOmitted } = await this.context.issues.get(record.issue_id);
374
+ if (issue.project.id !== record.project_id)
375
+ throw new WorkflowError('PROJECT_MISMATCH', '写入后项目发生变化');
376
+ let expectedMatched = true, feedbackTransition = false;
377
+ if (plan.expected.removed_section) {
378
+ const list = visibleCollection(issue, plan.expected.removed_section, emptyArraysOmitted)
379
+ ?? (emptyArraysOmitted && ['monitors', 'tags'].includes(plan.expected.removed_section) && !(plan.expected.removed_section in issue) ? [] : undefined);
380
+ if (!Array.isArray(list) || list.some((v) => (plan.expected.removed_section === 'monitors' ? v.name : v.id) === plan.expected.removed_id))
381
+ throw new WorkflowError('VERIFICATION_MISMATCH', '移除对象回读不符');
382
+ if (plan.expected.remaining_monitor_ids && digest(list.map(v => v.id).sort()) !== digest([...plan.expected.remaining_monitor_ids].sort())) {
383
+ throw new WorkflowError('VERIFICATION_MISMATCH', '关注人集合与预期不符');
384
+ }
385
+ }
386
+ else
387
+ expectedMatched = this.matches(issue, plan.expected);
388
+ const note = plan.note ?? (record.action === 'add_note' ? plan.body : undefined);
389
+ const noteIds = [];
390
+ if (note && (final || record.action === 'add_note')) {
391
+ const matching = locateNote(issue, record.operation_id, record.user_id, textHash(note.text));
392
+ const allMarkers = (issue.notes ?? []).filter((n) => n.text?.includes(operationMarker(record.operation_id)));
393
+ if (matching.length !== 1 || allMarkers.length !== 1)
394
+ throw new WorkflowError('VERIFICATION_MISMATCH', '操作备注缺失、重复或正文/作者不符');
395
+ if (matching[0].view_state?.name !== note.view_state.name)
396
+ throw new WorkflowError('VERIFICATION_MISMATCH', '备注回读可见性与明确请求不符,禁止降级或重发');
397
+ const responseNoteId = record.steps.find(s => s.name === 'append_note')?.server_id;
398
+ if (responseNoteId !== undefined && matching[0].id !== responseNoteId)
399
+ throw new WorkflowError('VERIFICATION_MISMATCH', '备注回读编号与响应不符');
400
+ noteIds.push(matching[0].id);
401
+ if (final && !expectedMatched)
402
+ feedbackTransition = await this.feedbackTransition(record, issue, matching[0]);
403
+ }
404
+ if (!expectedMatched && !feedbackTransition)
405
+ throw new WorkflowError('VERIFICATION_MISMATCH', '缺陷回读与预期字段不符');
406
+ let attachment;
407
+ if (plan.file) {
408
+ const candidates = (issue.attachments ?? []).filter((a) => !plan.attachment_ids.includes(a.id) && a.size === plan.file.size);
409
+ const original = plan.file.filename, dot = original.lastIndexOf('.');
410
+ const renamed = (name) => {
411
+ if (name === original)
412
+ return true;
413
+ const prefix = original.slice(0, dot), suffix = original.slice(dot);
414
+ if (!name.startsWith(prefix) || !name.endsWith(suffix))
415
+ return false;
416
+ const counter = name.slice(prefix.length, name.length - suffix.length);
417
+ return /^-[1-9]\d*$/.test(counter) && Number(counter.slice(1)) >= 2;
418
+ };
419
+ const matches = candidates.filter((a) => renamed(a.file_name ?? a.filename ?? ''));
420
+ if (matches.length !== 1)
421
+ throw new WorkflowError('VERIFICATION_UNAVAILABLE', '附件候选不能唯一归因,请只读核查,禁止重新上传');
422
+ const file = matches[0];
423
+ attachment = { id: file.id, filename: file.file_name ?? file.filename, size: file.size, requested_filename: original,
424
+ server_renamed: (file.file_name ?? file.filename) !== original,
425
+ download_url: `${session.baseUrl}/file_download.php?file_id=${file.id}&type=bug` };
426
+ }
427
+ if (recoverPrimary)
428
+ this.checkPrimarySnapshot(record, issue);
429
+ record.after_hash = digest(issue);
430
+ const minimal = facts(issue, session.baseUrl);
431
+ const changes = Object.fromEntries(['status', 'resolution', 'handler'].filter(k => !(k in plan.expected) && digest(minimal[k]) !== digest(plan.before_facts[k])).map(k => [k, minimal[k]]));
432
+ if (feedbackTransition)
433
+ changes.status = minimal.status;
434
+ result = { issue: minimal, issue_id: issue.id, view_url: minimal.view_url, ...this.context.revision(session, issue, plan.binding, etag),
435
+ note_ids: noteIds, note_urls: noteIds.map(id => `${minimal.view_url}#bugnote${id}`), attachment: attachment ?? null, server_side_changes: changes,
436
+ ...(feedbackTransition ? { applied_server_rules: ['reassign_on_feedback'] } : {}) };
437
+ }
438
+ record.result = { ...this.result(record), result };
439
+ }
440
+ async finish(record, session, ledger, observation) {
441
+ const result = this.result(record);
442
+ const failed = record.steps.find(s => s.error_code);
443
+ if (failed)
444
+ result.error = { code: failed.error_code, message: failed.http_status ? `Mantis 请求返回 HTTP ${failed.http_status}` : '步骤未完成,请按恢复指引核查' };
445
+ if (observation)
446
+ result.current_observation = observation;
447
+ if (record.plan.note && record.steps[0].state === 'succeeded' && record.steps.some(s => s.name === 'append_note' && s.state !== 'succeeded'))
448
+ result.warnings.push('STATE_UPDATED_NOTE_MISSING');
449
+ // 不可靠的最终回读不能把主步骤后的旧标识当成新写入前置标识。
450
+ if (result.outcome !== 'succeeded' && result.result) {
451
+ result.result = { ...result.result, expected_revision: null, revision_expires_at: null, versions_revision: null };
452
+ }
453
+ record.result = result;
454
+ if (result.outcome === 'succeeded')
455
+ record.completed_at ??= Date.now();
456
+ await ledger.save(record);
457
+ return result;
458
+ }
459
+ async reconcile(record, observed, session) {
460
+ const issue = observed.raw;
461
+ // PATCH、DELETE、附件的响应未知不能仅凭当前值相同认定本次操作成功。
462
+ for (const step of record.steps) {
463
+ if (step.name !== 'append_note' || step.state !== 'unknown' || !issue)
464
+ continue;
465
+ const note = record.plan.note ?? record.plan.body;
466
+ const matches = locateNote(issue, record.operation_id, record.user_id, textHash(note.text));
467
+ const markers = (issue.notes ?? []).filter((n) => n.text?.includes(operationMarker(record.operation_id)));
468
+ if (matches.length === 1 && markers.length === 1) {
469
+ step.state = 'succeeded';
470
+ step.server_id = matches[0].id;
471
+ step.evidence = 'confirmed_by_operation_marker';
472
+ delete step.error_code;
473
+ }
474
+ }
475
+ const primary = record.steps[0];
476
+ if (record.action === 'report_issue' && ['unknown', 'succeeded'].includes(primary.state) && !record.issue_id) {
477
+ const candidates = [];
478
+ let inspected = 0;
479
+ for (let page = 1; page <= 5; page++) {
480
+ const list = await this.context.issues.page(record.project_id, page);
481
+ for (const candidate of list.filter(i => i.summary === record.plan.body.summary && i.reporter?.id === record.user_id).slice(0, 20 - inspected)) {
482
+ inspected++;
483
+ const current = (await this.context.issues.get(candidate.id)).issue;
484
+ if (current.additional_information === operationMarker(record.operation_id) && this.matches(current, record.plan.expected))
485
+ candidates.push(current);
486
+ }
487
+ if (list.length < 100 || inspected >= 20)
488
+ break;
489
+ }
490
+ if (candidates.length === 1) {
491
+ record.issue_id = candidates[0].id;
492
+ primary.state = 'succeeded';
493
+ primary.server_id = record.issue_id;
494
+ primary.evidence = 'confirmed_by_operation_marker';
495
+ delete primary.error_code;
496
+ }
497
+ }
498
+ const primaryVerification = record.steps.find(s => s.name === 'verify_primary');
499
+ if (this.needsPrimarySnapshot(record) && primaryVerification && primaryVerification.state !== 'succeeded') {
500
+ // 补验必须匹配主写入响应;已有快照不能借此重置为后来变化的对象。
501
+ try {
502
+ await this.verify(record, session, false, true);
503
+ primaryVerification.state = 'succeeded';
504
+ primaryVerification.evidence = 'observed_target_state';
505
+ delete primaryVerification.error_code;
506
+ }
507
+ catch (error) {
508
+ primaryVerification.state = 'failed';
509
+ primaryVerification.error_code = problem(error).code;
510
+ }
511
+ }
512
+ if (record.steps.filter(s => !s.name.startsWith('verify_')).every(s => s.state === 'succeeded')) {
513
+ try {
514
+ await this.verify(record, session, true);
515
+ record.steps.filter(s => s.name.startsWith('verify_')).forEach(s => { s.state = 'succeeded'; s.evidence = 'observed_target_state'; delete s.error_code; });
516
+ }
517
+ catch { /* 核对失败继续保留已有步骤,不触发写入。 */ }
518
+ }
519
+ }
520
+ async getOperation(operationId) {
521
+ const session = await this.context.projects.authenticate();
522
+ const ledger = new Operations(session.dir);
523
+ const release = await ledger.lock(`operation-${operationId}`);
524
+ try {
525
+ const record = await ledger.load(operationId);
526
+ if (!record)
527
+ throw new WorkflowError('RECOVERY_UNAVAILABLE', '当前主体没有该操作记录');
528
+ const observed = await this.observe(record, session);
529
+ if (observed.restricted)
530
+ return this.restricted(record);
531
+ if (record.result?.outcome === 'succeeded')
532
+ return { ...record.result, current_observation: observed.public };
533
+ if (!release)
534
+ return { ...this.result(record), outcome: 'in_progress', recovery: { action: 'read_again', automatic_retry: false } };
535
+ if (ledger.expired(record))
536
+ return { ...this.result(record), current_observation: observed.public,
537
+ recovery: { action: 'manual_review', automatic_retry: false }, warnings: ['OPERATION_RETENTION_EXPIRED'] };
538
+ record.steps.filter(s => s.state === 'in_flight').forEach(s => { s.state = 'unknown'; s.error_code = 'WRITE_OUTCOME_UNKNOWN'; });
539
+ await this.reconcile(record, observed, session);
540
+ if (record.action === 'upload_file' && observed.raw) {
541
+ observed.public.attachment_candidates = (observed.raw.attachments ?? []).filter((a) => !record.plan.attachment_ids.includes(a.id))
542
+ .map((a) => ({ id: a.id, filename: a.file_name ?? a.filename, size: a.size }));
543
+ }
544
+ return await this.finish(record, session, ledger, observed.public);
545
+ }
546
+ finally {
547
+ await release?.().catch(() => { });
548
+ }
549
+ }
550
+ }