@olierjs/fugue-cli 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +49 -2
- package/THIRD_PARTY_NOTICES +416 -0
- package/bin/fugue-cli.mjs +23 -0
- package/dist/darwin-arm64/fugue-cli +0 -0
- package/dist/darwin-x64/fugue-cli +0 -0
- package/dist/linux-arm64/fugue-cli +0 -0
- package/dist/linux-x64/fugue-cli +0 -0
- package/dist/win32-arm64/fugue-cli.exe +0 -0
- package/dist/win32-x64/fugue-cli.exe +0 -0
- package/package.json +26 -4
- package/scripts/postinstall.mjs +13 -0
- package/scripts/skills.mjs +104 -0
- package/skills/fugue-stack/SKILL.md +10 -0
- package/skills/plan-with-remote/SKILL.md +60 -0
- package/skills/plan-with-remote/references/configuration.md +43 -0
- package/skills/plan-with-remote/scripts/client.mjs +61 -0
- package/skills/plan-with-remote/scripts/config.mjs +53 -0
- package/skills/plan-with-remote/scripts/planning.mjs +279 -0
- package/skills/plan-with-remote/scripts/protocol.mjs +147 -0
- package/skills/plan-with-remote/scripts/service.mjs +146 -0
- package/skills/plan-with-remote/scripts/storage.mjs +74 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
import { serviceAddress } from './config.mjs';
|
|
5
|
+
import { createSession, loadSession, saveSession, lockSession, writeReview, loadReview, digest } from './storage.mjs';
|
|
6
|
+
import { transport, resolveProject, readInput, requestPackage, validateDocuments, assertFresh } from './service.mjs';
|
|
7
|
+
import { runPlanningFromAcceptedOperation, waitForProjectPreparation, continuePlanningAfterHumanInput, buildAcceptSolutionReviewCommand, buildFeedbackSolutionReviewCommand, parseSolutionReviewOperationAccepted, parseDeliveryPackage } from './protocol.mjs';
|
|
8
|
+
|
|
9
|
+
function required(value, label) {
|
|
10
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`请提供 ${label}。`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function start(flags, config, cwd = process.cwd()) {
|
|
15
|
+
if (!['lightweight', 'standard'].includes(flags.mode)) throw new Error('请用 --mode lightweight 或 standard 明确选择规划方式。');
|
|
16
|
+
const input = await readInput(required(flags.input, '--input'));
|
|
17
|
+
if (flags['dry-run']) return { status: 'preview', mode: flags.mode, input, message: '仅校验本地输入,没有发送网络请求或创建任务。' };
|
|
18
|
+
const baseUrl = serviceAddress(config);
|
|
19
|
+
const api = transport(baseUrl, config.requestTimeoutMs);
|
|
20
|
+
const projectId = await resolveProject(api, config.remote, cwd);
|
|
21
|
+
const requirementId = randomUUID();
|
|
22
|
+
const command = { type: 'submit_requirement', input: requestPackage(input, requirementId, projectId, flags.mode, config.agent) };
|
|
23
|
+
const state = { version: 1, requirementId, baseUrl, projectId, mode: flags.mode, phase: 'preparing', pending: { command } };
|
|
24
|
+
const path = await createSession(flags['output-dir'] ?? resolve(cwd, '.plan-with-remote', requirementId), state);
|
|
25
|
+
return withSession(path, config, async context => {
|
|
26
|
+
await submitPending(context);
|
|
27
|
+
return refresh(context);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function useSession(action, flags, config) {
|
|
32
|
+
if (['read', 'retry'].includes(action)) {
|
|
33
|
+
return withSession(required(flags.session, '--session'), config, async context => {
|
|
34
|
+
const { state } = context;
|
|
35
|
+
if (state.pending) {
|
|
36
|
+
if (action === 'read') return { status: 'submission_unconfirmed', session: context.path, message: '上次提交结果尚未确认。使用 retry 重试原请求;不要另建任务。', next: ['retry'] };
|
|
37
|
+
await submitPending(context);
|
|
38
|
+
} else if (action === 'retry' && state.outcome?.code === 'preparation_failed') {
|
|
39
|
+
const resource = `/v1/reconcile/requirements/${encodeURIComponent(state.requirementId)}/context`;
|
|
40
|
+
const current = (await context.api.request(resource)).data;
|
|
41
|
+
if (current?.project?.projectId !== state.projectId || current.preparation?.runId !== state.operationId || current.preparation?.requirementId !== state.requirementId || current.preparation.status !== 'failed' || !Number.isSafeInteger(current.snapshotRevision) || current.snapshotRevision < 1) {
|
|
42
|
+
throw new Error('项目准备状态已变化,请先读取进展。');
|
|
43
|
+
}
|
|
44
|
+
state.pending = { resource: `${resource}/retry`, command: { expectedSnapshotRevision: current.snapshotRevision } };
|
|
45
|
+
await saveSession(context.path, state);
|
|
46
|
+
await submitPending(context);
|
|
47
|
+
} else if (action === 'retry' && state.outcome?.status === 'failed' && state.outcome.code !== 'planning_wait_timeout') {
|
|
48
|
+
const report = (await context.api.post({ type: 'inspect_requirement', requirementId: state.requirementId })).data.report;
|
|
49
|
+
if (report?.requirementId !== state.requirementId || !Number.isSafeInteger(report.snapshotRevision) || report.snapshotRevision < 1) throw new Error('无法确认失败任务的当前版本。');
|
|
50
|
+
state.pending = { command: { type: 'request_replan', requirementId: state.requirementId, commandId: randomUUID(), reason: '用户请求重试失败的规划', expectedSnapshotRevision: report.snapshotRevision } };
|
|
51
|
+
await saveSession(context.path, state);
|
|
52
|
+
await submitPending(context);
|
|
53
|
+
}
|
|
54
|
+
return refresh(context);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const review = await loadReview(required(flags.review, '--review'));
|
|
58
|
+
return withSession(review.session, config, async context => {
|
|
59
|
+
if (context.state.pending) throw new Error('上次提交结果尚未确认,请先用 read / retry 处理原任务。');
|
|
60
|
+
const actor = required(config.actor, '--actor 或 RECONCILE_ACTOR(用户决定的来源)');
|
|
61
|
+
const command = decisionCommand(action, flags, review.outcome, context.state, actor);
|
|
62
|
+
await assertFresh(context.api, context.state, review.outcome);
|
|
63
|
+
context.state.pending = { command, outcome: review.outcome };
|
|
64
|
+
await saveSession(context.path, context.state);
|
|
65
|
+
await submitPending(context);
|
|
66
|
+
return refresh(context);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function decisionCommand(action, flags, outcome, state, actor) {
|
|
71
|
+
const common = { requirementId: state.requirementId, actor, expectedSnapshotRevision: outcome.snapshotRevision, commandId: randomUUID() };
|
|
72
|
+
if (action === 'accept') {
|
|
73
|
+
if (outcome.status !== 'awaiting_solution_review') throw new Error('当前文档不需要方案认可。');
|
|
74
|
+
return buildAcceptSolutionReviewCommand({ ...common, reviewRevision: outcome.solutionReview.reviewRevision, sourceBinding: outcome.solutionReview.sourceBinding });
|
|
75
|
+
}
|
|
76
|
+
if (action === 'approve') {
|
|
77
|
+
if (outcome.status !== 'ready_for_approval' || outcome.gate.decision !== 'pass') throw new Error('当前规划不能批准,请先处理待解决的问题。');
|
|
78
|
+
return { type: 'execution_doc_decision', requirementId: state.requirementId, actor, expectedSnapshotRevision: outcome.snapshotRevision, decision: 'approve' };
|
|
79
|
+
}
|
|
80
|
+
if (action === 'stop') return { type: 'execution_doc_decision', requirementId: state.requirementId, actor, expectedSnapshotRevision: outcome.snapshotRevision, decision: 'stop' };
|
|
81
|
+
if (action === 'answer') {
|
|
82
|
+
if (outcome.status !== 'needs_human_input') throw new Error('当前没有等待回答的问题。');
|
|
83
|
+
const index = Number(flags.question);
|
|
84
|
+
if (!Number.isSafeInteger(index) || index < 1 || index > outcome.questions.length) throw new Error('请使用本次返回的问题编号。');
|
|
85
|
+
const question = outcome.questions[index - 1];
|
|
86
|
+
if (question.kind === 'stage') {
|
|
87
|
+
if (flags.confirm) throw new Error('此问题需要文字回答,不能使用 --confirm。');
|
|
88
|
+
return { ...common, type: 'submit_stage_clarification', questionKey: question.questionKey, questionRef: question.questionRef, blockerRef: question.blockerRef, answer: required(flags.text, '--text') };
|
|
89
|
+
}
|
|
90
|
+
if (question.action === 'confirm_replan') {
|
|
91
|
+
if (!flags.confirm || flags.text) throw new Error('请在用户同意重新规划后使用 --confirm。');
|
|
92
|
+
} else {
|
|
93
|
+
if (flags.confirm) throw new Error('此问题需要文字回答,不能使用 --confirm。');
|
|
94
|
+
required(flags.text, '--text');
|
|
95
|
+
}
|
|
96
|
+
return { ...common, type: 'resolve_owner_resolution', pending_resolution_identity: question.pendingResolutionIdentity, action: question.action, ...(question.action === 'clarify' ? { answer: flags.text } : {}) };
|
|
97
|
+
}
|
|
98
|
+
if (action === 'feedback') {
|
|
99
|
+
const change = required(flags.change, '--change(要修改的内容)');
|
|
100
|
+
const preserve = required(flags.preserve, '--preserve(必须保留的内容)');
|
|
101
|
+
const scope = flags.scope;
|
|
102
|
+
if (outcome.status === 'awaiting_solution_review') {
|
|
103
|
+
const scopes = { requirements: 'requirement', solution: 'technical_design', wording: 'presentation_only' };
|
|
104
|
+
if (!scopes[scope]) throw new Error('方案反馈请选择 --scope requirements、solution 或 wording。');
|
|
105
|
+
return buildFeedbackSolutionReviewCommand({ ...common, reviewRevision: outcome.solutionReview.reviewRevision, sourceBinding: outcome.solutionReview.sourceBinding, scope: scopes[scope], feedback: `需要修改:\n${change}\n\n必须保留:\n${preserve}` });
|
|
106
|
+
}
|
|
107
|
+
if (!['ready_for_approval', 'awaiting_revision'].includes(outcome.status)) throw new Error('请先读取可修改的规划文档。');
|
|
108
|
+
const stages = { requirements: 'prd_understanding', solution: 'technical_design', verification: 'verification_plan' };
|
|
109
|
+
if (!stages[scope]) throw new Error('文档反馈请选择 --scope requirements、solution 或 verification。');
|
|
110
|
+
return { ...common, type: 'execution_doc_decision', decision: 'regenerate', rerunFromStage: stages[scope], revisionConstraint: { mustChange: change, mustPreserve: preserve } };
|
|
111
|
+
}
|
|
112
|
+
throw new Error('未知操作。');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function withSession(path, config, work) {
|
|
116
|
+
path = resolve(path);
|
|
117
|
+
return lockSession(path, async () => {
|
|
118
|
+
const state = await loadSession(path);
|
|
119
|
+
const api = transport(serviceAddress(config, state), config.requestTimeoutMs);
|
|
120
|
+
const context = { path, state, api, config };
|
|
121
|
+
try { return await work(context); }
|
|
122
|
+
catch (error) {
|
|
123
|
+
const diagnostic = resolve(dirname(path), `error-${randomUUID()}.json`);
|
|
124
|
+
await writeFile(diagnostic, JSON.stringify({ message: error.message, status: error.status, detail: error.detail }, null, 2), { mode: 0o600 });
|
|
125
|
+
error.session = path;
|
|
126
|
+
error.diagnostic = diagnostic;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function submitPending(context) {
|
|
133
|
+
const { state, api, path } = context;
|
|
134
|
+
const pending = state.pending;
|
|
135
|
+
if (!pending) return;
|
|
136
|
+
if (!pending.response) {
|
|
137
|
+
try { pending.response = pending.resource ? await api.request(pending.resource, pending.command) : await api.post(pending.command); }
|
|
138
|
+
catch (error) {
|
|
139
|
+
// A definite conflict did not accept this decision. Allow a new read;
|
|
140
|
+
// ambiguous network outcomes keep the exact pending command for retry.
|
|
141
|
+
if (error.status === 409 && pending.command.type !== 'submit_requirement') {
|
|
142
|
+
delete state.pending;
|
|
143
|
+
await saveSession(path, state);
|
|
144
|
+
error.message = '任务已变化,服务拒绝了旧决定。请使用 read 重新读取,再请用户审阅。';
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
// Persist acceptance before polling, so a disconnected client can resume exactly.
|
|
149
|
+
await saveSession(path, state);
|
|
150
|
+
}
|
|
151
|
+
const data = pending.response.data;
|
|
152
|
+
const command = pending.command;
|
|
153
|
+
if (pending.resource) {
|
|
154
|
+
if (data.requirementId !== state.requirementId || data.projectId !== state.projectId || typeof data.runId !== 'string' || !data.runId) throw new Error('服务返回的准备任务身份不匹配。');
|
|
155
|
+
state.operationId = data.runId;
|
|
156
|
+
state.accepted = { data: { requirementId: state.requirementId, operationId: data.runId, polling: { nextPollAfterMs: state.accepted.data.polling.nextPollAfterMs } } };
|
|
157
|
+
state.phase = 'preparing';
|
|
158
|
+
delete state.outcome;
|
|
159
|
+
} else if (data.operationId) {
|
|
160
|
+
if (data.requirementId !== state.requirementId || typeof data.operationId !== 'string') throw new Error('服务返回的任务身份不匹配。');
|
|
161
|
+
if (command.type === 'accept_solution_review' || command.type === 'feedback_solution_review') {
|
|
162
|
+
parseSolutionReviewOperationAccepted(pending.response, command);
|
|
163
|
+
}
|
|
164
|
+
state.operationId = data.operationId;
|
|
165
|
+
state.phase = command.type === 'submit_requirement' ? 'preparing' : 'planning';
|
|
166
|
+
if (command.type === 'submit_requirement') state.accepted = pending.response;
|
|
167
|
+
delete state.outcome;
|
|
168
|
+
} else if (command.type === 'submit_stage_clarification' && data.type === 'submit_stage_clarification') {
|
|
169
|
+
state.outcome = await continuePlanningAfterHumanInput({ requirementId: state.requirementId, command, timeoutMs: context.config.waitMs }, {
|
|
170
|
+
post: async () => pending.response, wait: async () => {}, now: Date.now,
|
|
171
|
+
});
|
|
172
|
+
state.phase = 'awaiting_input';
|
|
173
|
+
} else if (command.type === 'execution_doc_decision' && ['approve', 'stop'].includes(command.decision)) {
|
|
174
|
+
const expected = command.decision === 'approve' ? 'approved' : 'stopped';
|
|
175
|
+
if (data.type !== 'execution_doc_decision' || data.result?.outcome !== expected) {
|
|
176
|
+
delete state.pending;
|
|
177
|
+
await saveSession(path, state);
|
|
178
|
+
throw new Error('服务未接受该决定,请重新读取当前规划后处理。');
|
|
179
|
+
}
|
|
180
|
+
state.phase = expected;
|
|
181
|
+
state.approvedReview = pending.outcome;
|
|
182
|
+
} else {
|
|
183
|
+
throw new Error('服务未返回可确认的结果。请保留任务文件,检查诊断后重试原请求。');
|
|
184
|
+
}
|
|
185
|
+
delete state.pending;
|
|
186
|
+
await saveSession(path, state);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function refresh(context) {
|
|
190
|
+
const { state, config, api, path } = context;
|
|
191
|
+
if (state.phase === 'stopped') return { status: 'stopped', session: path, message: '规划已停止。', next: [] };
|
|
192
|
+
if (state.phase === 'approved') return delivery(context);
|
|
193
|
+
const startedAt = Date.now();
|
|
194
|
+
if (state.phase === 'preparing') {
|
|
195
|
+
try {
|
|
196
|
+
await waitForProjectPreparation({ requirementId: state.requirementId, projectId: state.projectId, timeoutMs: config.waitMs, startedAt }, state.accepted, {
|
|
197
|
+
read: () => api.request(`/v1/reconcile/requirements/${encodeURIComponent(state.requirementId)}/context`),
|
|
198
|
+
wait: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), now: Date.now,
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error.message === 'project preparation timed out') return running(path);
|
|
202
|
+
if (error.message.startsWith('project preparation failed:')) {
|
|
203
|
+
const report = (await api.post({ type: 'inspect_requirement', requirementId: state.requirementId })).data.report;
|
|
204
|
+
if (report?.requirementId !== state.requirementId) throw error;
|
|
205
|
+
state.outcome = { status: 'failed', requirementId: state.requirementId, viewVersion: report.snapshotRevision, code: 'preparation_failed', message: error.message, retryable: true };
|
|
206
|
+
await saveSession(path, state);
|
|
207
|
+
return present(context, state.outcome);
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
state.phase = 'planning';
|
|
212
|
+
await saveSession(path, state);
|
|
213
|
+
}
|
|
214
|
+
const remaining = config.waitMs - (Date.now() - startedAt);
|
|
215
|
+
if (remaining <= 0) return running(path);
|
|
216
|
+
const outcome = await runPlanningFromAcceptedOperation({ requirementId: state.requirementId, operationId: state.operationId, expectedPlanningMode: state.mode, timeoutMs: remaining }, {
|
|
217
|
+
post: async command => {
|
|
218
|
+
const response = await api.post(command);
|
|
219
|
+
if (command.type === 'get_requirement_flow_view' && response.data.requirementId !== state.requirementId) throw new Error('返回的规划进度属于其他任务。');
|
|
220
|
+
return response;
|
|
221
|
+
},
|
|
222
|
+
wait: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), now: Date.now,
|
|
223
|
+
});
|
|
224
|
+
state.outcome = outcome;
|
|
225
|
+
await saveSession(path, state);
|
|
226
|
+
return present(context, outcome);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function running(path) { return { status: 'running', session: path, message: '远端仍在处理。使用 read 继续查看,不要重复提交。', next: ['read'] }; }
|
|
230
|
+
|
|
231
|
+
async function present(context, outcome) {
|
|
232
|
+
const { path, state } = context;
|
|
233
|
+
if (outcome.status === 'failed') {
|
|
234
|
+
if (outcome.code === 'planning_wait_timeout') return running(path);
|
|
235
|
+
return { status: 'failed', session: path, message: '远端规划失败。可使用 retry 请求重试,或检查诊断文件。', diagnostic: path, next: ['retry'] };
|
|
236
|
+
}
|
|
237
|
+
let documents = {};
|
|
238
|
+
let status;
|
|
239
|
+
let message;
|
|
240
|
+
let next;
|
|
241
|
+
if (outcome.status === 'awaiting_solution_review') {
|
|
242
|
+
documents = { 'solution.md': outcome.solutionReview.overview.markdown };
|
|
243
|
+
status = 'review_solution'; message = '请阅读方案。用户认可后可以继续生成最终文档,也可以先反馈修改。'; next = ['accept', 'feedback', 'stop'];
|
|
244
|
+
} else if (['ready_for_approval', 'awaiting_revision'].includes(outcome.status)) {
|
|
245
|
+
documents = validateDocuments(outcome, state.requirementId);
|
|
246
|
+
if (outcome.status === 'ready_for_approval' && (outcome.gate.status !== 'decided' || outcome.gate.decision !== 'pass')) throw new Error('规划文档尚未通过检查,不能提交批准。');
|
|
247
|
+
status = outcome.status === 'ready_for_approval' ? 'review_documents' : 'changes_needed';
|
|
248
|
+
message = status === 'review_documents' ? '请审阅执行文档和验收文档。仅在用户明确批准后提交批准决定。' : '当前规划仍有待解决的问题,暂时不能批准;请阅读文档并提交修改要求。';
|
|
249
|
+
next = status === 'review_documents' ? ['approve', 'feedback', 'stop'] : ['feedback', 'stop'];
|
|
250
|
+
const issues = collectIssues(outcome.flow);
|
|
251
|
+
if (issues.length) documents['issues.md'] = '# 待处理问题\n\n' + issues.map(text => `- ${text}`).join('\n') + '\n';
|
|
252
|
+
} else if (outcome.status === 'needs_human_input') {
|
|
253
|
+
status = 'questions'; message = '请向用户确认以下问题,再使用本次审阅凭据回答。'; next = ['answer', 'stop'];
|
|
254
|
+
} else { throw new Error('服务返回了当前客户端不支持的完成状态。'); }
|
|
255
|
+
const result = await writeReview(path, state, outcome, documents);
|
|
256
|
+
return { status, message, session: path, ...result,
|
|
257
|
+
...(status === 'questions' ? { questions: outcome.questions.map((question, index) => ({ id: String(index + 1), question: question.prompt, response: question.action === 'confirm_replan' ? 'confirm' : 'text' })) } : {}), next };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function collectIssues(value) {
|
|
261
|
+
const issues = new Set();
|
|
262
|
+
function visit(item) {
|
|
263
|
+
if (!item || typeof item !== 'object') return;
|
|
264
|
+
if (typeof item.problem === 'string') issues.add(item.problem);
|
|
265
|
+
for (const child of Object.values(item)) visit(child);
|
|
266
|
+
}
|
|
267
|
+
visit(value);
|
|
268
|
+
return [...issues];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function delivery({ api, state, path }) {
|
|
272
|
+
const pkg = parseDeliveryPackage((await api.post({ type: 'get_delivery_package', requirementId: state.requirementId })).data.deliveryPackage);
|
|
273
|
+
if (pkg.status !== 'ready' || pkg.planningDelivery.requirementId !== state.requirementId) throw new Error('已批准文档暂时不可读取,请使用 read 重试。');
|
|
274
|
+
if (pkg.planningCandidateRevision !== state.approvedReview.candidateRevision || digest(pkg.executionDocumentRef) !== digest(state.approvedReview.executionDocumentRef) || digest(pkg.acceptanceDocumentRef) !== digest(state.approvedReview.acceptanceDocumentRef)) throw new Error('交付文档与已审阅版本不一致。');
|
|
275
|
+
const documents = validateDocuments(state.approvedReview, state.requirementId);
|
|
276
|
+
if (documents['execution.md'] !== pkg.executionDocument || documents['acceptance.md'] !== pkg.acceptanceDocument) throw new Error('交付正文与已审阅正文不一致。');
|
|
277
|
+
const files = await writeReview(path, state, state.approvedReview, documents);
|
|
278
|
+
return { status: 'approved', session: path, documents: files.documents, message: '规划文档已批准并交付;不表示软件已经实现、部署或验收。', next: [] };
|
|
279
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { digest } from './storage.mjs';
|
|
2
|
+
import { validateDocuments } from './service.mjs';
|
|
3
|
+
|
|
4
|
+
const record = (value, name) => {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} is invalid`);
|
|
6
|
+
return value;
|
|
7
|
+
};
|
|
8
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
9
|
+
const same = (left, right) => digest(left) === digest(right);
|
|
10
|
+
|
|
11
|
+
export function buildAcceptSolutionReviewCommand(input) {
|
|
12
|
+
return { type: 'accept_solution_review', ...input };
|
|
13
|
+
}
|
|
14
|
+
export function buildFeedbackSolutionReviewCommand(input) {
|
|
15
|
+
return { type: 'feedback_solution_review', ...input };
|
|
16
|
+
}
|
|
17
|
+
export function parseSolutionReviewOperationAccepted(response, command) {
|
|
18
|
+
const data = record(response?.data, 'solution review acceptance');
|
|
19
|
+
if (data.type !== 'solution_review_operation_accepted' || data.requirementId !== command.requirementId || data.commandId !== command.commandId || typeof data.operationId !== 'string' || data.polling?.operationId !== data.operationId || data.review?.reviewRevision !== command.reviewRevision || !same(data.review?.sourceBinding, command.sourceBinding) || !positive(data.lifecycle?.snapshotRevision)) throw new Error('solution review operation does not match the submitted decision');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function waitForProjectPreparation(input, accepted, deps) {
|
|
23
|
+
const data = record(accepted?.data, 'preparation acceptance');
|
|
24
|
+
if (data.requirementId !== input.requirementId || !data.operationId || !positive(data.polling?.nextPollAfterMs)) throw new Error('preparation acceptance identity is invalid');
|
|
25
|
+
while (deps.now() - input.startedAt < input.timeoutMs) {
|
|
26
|
+
const response = record((await deps.read())?.data, 'project context');
|
|
27
|
+
const preparation = response.preparation;
|
|
28
|
+
if (response.project?.projectId !== input.projectId || preparation?.projectId !== input.projectId || preparation?.requirementId !== input.requirementId || preparation?.runId !== data.operationId) throw new Error('project preparation identity mismatch');
|
|
29
|
+
if (preparation.status === 'failed') throw new Error(`project preparation failed: ${preparation.error?.code ?? 'unknown'}: ${preparation.error?.message ?? 'no details'}`);
|
|
30
|
+
if (preparation.status === 'succeeded' && preparation.baselineId && response.baseline?.baselineId === preparation.baselineId) return;
|
|
31
|
+
await deps.wait(Math.min(data.polling.nextPollAfterMs, input.timeoutMs - (deps.now() - input.startedAt)));
|
|
32
|
+
}
|
|
33
|
+
throw new Error('project preparation timed out');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function continuePlanningAfterHumanInput(input, deps) {
|
|
37
|
+
const response = await deps.post(input.command);
|
|
38
|
+
const data = record(response?.data, 'human input response');
|
|
39
|
+
if (data.operationId) return runPlanningFromAcceptedOperation({ requirementId: input.requirementId, operationId: data.operationId, timeoutMs: input.timeoutMs }, deps);
|
|
40
|
+
if (data.type !== 'submit_stage_clarification' || data.requirementId !== input.requirementId) throw new Error('human input response identity mismatch');
|
|
41
|
+
const pending = record(data.pendingStageResolution, 'pending stage resolution');
|
|
42
|
+
return { status: 'needs_human_input', requirementId: input.requirementId, viewVersion: pending.expectedSnapshotRevision, snapshotRevision: pending.expectedSnapshotRevision, questions: (pending.actions ?? []).map(action => ({ kind: 'stage', questionKey: action.questionKey, questionRef: action.questionRef, blockerRef: action.blockerRef, prompt: action.question })), resolution: { kind: 'stage', expectedSnapshotRevision: pending.expectedSnapshotRevision } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runPlanningFromAcceptedOperation(input, deps) {
|
|
46
|
+
const started = deps.now();
|
|
47
|
+
while (deps.now() - started < input.timeoutMs) {
|
|
48
|
+
const response = await deps.post(flowCommand(input.requirementId, input.operationId, 'summary'));
|
|
49
|
+
const summary = record(response?.data, 'planning flow');
|
|
50
|
+
summary.polling = response.polling;
|
|
51
|
+
summary.limits = response.limits;
|
|
52
|
+
if (summary.requirementId !== input.requirementId || summary.operation?.operationId !== input.operationId) throw new Error('planning flow identity mismatch');
|
|
53
|
+
if (input.expectedPlanningMode && summary.planningMode !== input.expectedPlanningMode) throw new Error('planning mode differs from the submitted request');
|
|
54
|
+
const operation = summary.operation;
|
|
55
|
+
if (summary.pendingStageResolution || summary.pendingOwnerResolution) return humanInput(summary);
|
|
56
|
+
if (summary.displayStatus === 'awaiting_revision' && ['finished', 'failed'].includes(operation.status)) {
|
|
57
|
+
const outcome = await readRevisionDocuments(input, deps, summary);
|
|
58
|
+
if (outcome) return outcome;
|
|
59
|
+
const remaining = input.timeoutMs - (deps.now() - started);
|
|
60
|
+
const interval = summary.polling?.nextPollAfterMs;
|
|
61
|
+
if (!positive(interval)) throw new Error('planning polling interval is invalid');
|
|
62
|
+
if (remaining > 0) await deps.wait(Math.min(interval, remaining));
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const recovering = summary.currentStage === 'failed' && ['queued', 'running'].includes(operation.status);
|
|
66
|
+
if (summary.currentStage === 'failed' && !recovering || operation.status === 'failed' || summary.currentStage === 'stopped') return { status: 'failed', requirementId: input.requirementId, viewVersion: summary.viewVersion, code: operation.error?.code ?? 'runtime_failed', message: operation.error?.message ?? 'planning failed', retryable: operation.error?.retryable === true };
|
|
67
|
+
if (summary.currentStage === 'awaiting_solution_review' && operation.status === 'finished') {
|
|
68
|
+
if (summary.planningMode !== 'standard') throw new Error('lightweight planning cannot require solution acknowledgement');
|
|
69
|
+
const data = record((await deps.post({ type: 'get_solution_review', requirementId: input.requirementId }))?.data, 'solution review');
|
|
70
|
+
if (data.requirementId !== input.requirementId || data.snapshotRevision !== summary.viewVersion || data.review?.reviewRevision !== operation.solutionReviewResult?.reviewRevision || !same(data.review?.sourceBinding, operation.solutionReviewResult?.sourceBinding)) throw new Error('solution review is not bound to the current flow');
|
|
71
|
+
return { status: 'awaiting_solution_review', requirementId: input.requirementId, planningMode: summary.planningMode, viewVersion: summary.viewVersion, snapshotRevision: data.snapshotRevision, solutionReview: data.review, flow: summary };
|
|
72
|
+
}
|
|
73
|
+
if (summary.currentStage === 'awaiting_execution_doc_approval' && operation.status === 'finished') {
|
|
74
|
+
const fullResponse = await deps.post(flowCommand(input.requirementId, input.operationId, 'full'));
|
|
75
|
+
const flow = record(fullResponse?.data, 'full planning flow');
|
|
76
|
+
flow.limits = fullResponse.limits;
|
|
77
|
+
if (flow.requirementId !== input.requirementId || flow.operation?.operationId !== input.operationId || flow.operation?.status !== 'finished' || flow.currentStage !== 'awaiting_execution_doc_approval' || flow.viewVersion !== summary.viewVersion || flow.pageInfo?.hasMore || flow.limits?.omittedCollections?.some(value => ['artifacts', 'gateResults'].includes(value)) || !Array.isArray(flow.gateResults) || !flow.gateResults.length) throw new Error('full planning flow is incomplete or mismatched');
|
|
78
|
+
if (flow.pendingStageResolution || flow.pendingOwnerResolution) return humanInput(flow);
|
|
79
|
+
const documents = (await deps.post({ type: 'get_planning_candidate_documents', requirementId: input.requirementId }))?.data?.documents;
|
|
80
|
+
validateDocuments(documents, input.requirementId);
|
|
81
|
+
if (documents.snapshotRevision !== flow.viewVersion) throw new Error('candidate document version differs from planning flow');
|
|
82
|
+
if (flow.displayStatus === 'awaiting_revision') {
|
|
83
|
+
const revision = flow.planningRevision;
|
|
84
|
+
if (revision?.available !== true || revision.reason !== 'quality_blocked' || revision.candidateRevision !== documents.candidateRevision) throw new Error('quality revision documents do not match the candidate');
|
|
85
|
+
for (const [kind, ref] of [['execution_doc', documents.executionDocumentRef], ['acceptance_doc', documents.acceptanceDocumentRef]]) {
|
|
86
|
+
const entry = flow.planningDocuments?.[kind];
|
|
87
|
+
if (entry?.candidateRevision !== documents.candidateRevision || !same(entry.documentRef, ref)) throw new Error('quality revision document reference differs from flow');
|
|
88
|
+
}
|
|
89
|
+
return { status: 'awaiting_revision', planningMode: flow.planningMode, flow, ...documents };
|
|
90
|
+
}
|
|
91
|
+
if (summary.planningMode === 'standard' && !flow.boundedReview) throw new Error('full planning flow is missing bounded review');
|
|
92
|
+
const gateIds = flow.gateResults.map(gate => gate?.gateId).filter(value => typeof value === 'string');
|
|
93
|
+
const gate = documents.gate;
|
|
94
|
+
if (gate.status !== 'decided' || (summary.currentStageStatus === 'gate_blocked' && gate.decision !== 'blocked') || (gateIds.length && !gateIds.includes(`gate-decision-${gate.gateRevision}`))) throw new Error('candidate Gate differs from planning flow');
|
|
95
|
+
const report = (await deps.post({ type: 'inspect_requirement', requirementId: input.requirementId }))?.data?.report;
|
|
96
|
+
if (report?.requirementId !== input.requirementId || report.snapshotRevision !== documents.snapshotRevision || report?.approvalReport?.requirement_id !== input.requirementId || report.approvalReport.candidate_revision !== documents.candidateRevision || report.approvalReport.gate_revision !== gate.gateRevision || report.approvalReport.decision !== gate.decision) throw new Error('candidate Gate differs from current approval report');
|
|
97
|
+
return { status: gate.decision === 'pass' ? 'ready_for_approval' : 'awaiting_revision', planningMode: flow.planningMode, flow, ...documents };
|
|
98
|
+
}
|
|
99
|
+
const remaining = input.timeoutMs - (deps.now() - started);
|
|
100
|
+
const interval = summary.polling?.nextPollAfterMs;
|
|
101
|
+
if (!positive(interval)) throw new Error('planning polling interval is invalid');
|
|
102
|
+
await deps.wait(Math.min(interval, remaining));
|
|
103
|
+
}
|
|
104
|
+
return { status: 'failed', requirementId: input.requirementId, viewVersion: 0, code: 'planning_wait_timeout', message: 'planning wait timed out', retryable: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function flowCommand(requirementId, operationId, mode) {
|
|
108
|
+
const full = mode === 'full';
|
|
109
|
+
return { type: 'get_requirement_flow_view', requirementId, operationId, responseMode: mode, include: { stages: true, artifacts: full, runtimeRuns: full, gateResults: true, projectionJobs: full, externalProjections: full, evidenceRefs: full }, limit: { maxArtifacts: 200, maxRuntimeRuns: 200, maxEvidenceRefs: 200 }, maxItemsPerSection: 200 };
|
|
110
|
+
}
|
|
111
|
+
function humanInput(flow) {
|
|
112
|
+
const resolution = flow.pendingOwnerResolution ?? flow.pendingStageResolution;
|
|
113
|
+
const owner = !!flow.pendingOwnerResolution;
|
|
114
|
+
const actions = owner ? [resolution] : resolution.actions ?? [];
|
|
115
|
+
const questions = actions.map(action => {
|
|
116
|
+
if (!owner) return { kind: 'stage', questionKey: action.questionKey, questionRef: action.questionRef, blockerRef: action.blockerRef, prompt: action.question };
|
|
117
|
+
if (!['replan_required', 'human_resolution_required'].includes(action.outcome)) throw new Error('owner resolution outcome is invalid');
|
|
118
|
+
return { kind: 'owner', pendingResolutionIdentity: action.pendingResolutionIdentity, action: action.outcome === 'replan_required' ? 'confirm_replan' : 'clarify', prompt: action.reason };
|
|
119
|
+
});
|
|
120
|
+
return { status: 'needs_human_input', requirementId: flow.requirementId, viewVersion: flow.viewVersion, snapshotRevision: resolution.expectedSnapshotRevision, questions, resolution: { kind: owner ? 'owner' : 'stage', expectedSnapshotRevision: resolution.expectedSnapshotRevision } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readRevisionDocuments(input, deps, summary) {
|
|
124
|
+
const fullResponse = await deps.post(flowCommand(input.requirementId, input.operationId, 'full'));
|
|
125
|
+
const flow = record(fullResponse?.data, 'full planning flow');
|
|
126
|
+
flow.limits = fullResponse.limits;
|
|
127
|
+
if (flow.requirementId !== input.requirementId || flow.operation?.operationId !== input.operationId) throw new Error('quality revision flow identity mismatch');
|
|
128
|
+
if (flow.limits?.omittedCollections?.some(value => ['artifacts', 'gateResults'].includes(value)) || !Array.isArray(flow.gateResults)) throw new Error('quality revision flow is missing required collections');
|
|
129
|
+
if (flow.pageInfo?.hasMore) throw new Error('quality revision flow has more pages');
|
|
130
|
+
if (!['finished', 'failed'].includes(flow.operation?.status) || flow.displayStatus !== 'awaiting_revision' || flow.viewVersion !== summary.viewVersion) return undefined;
|
|
131
|
+
if (flow.pendingStageResolution || flow.pendingOwnerResolution) return humanInput(flow);
|
|
132
|
+
const documents = (await deps.post({ type: 'get_planning_candidate_documents', requirementId: input.requirementId }))?.data?.documents;
|
|
133
|
+
validateDocuments(documents, input.requirementId);
|
|
134
|
+
if (documents.snapshotRevision !== flow.viewVersion || flow.planningRevision?.available !== true || flow.planningRevision.reason !== 'quality_blocked' || flow.planningRevision.candidateRevision !== documents.candidateRevision) throw new Error('quality revision documents do not match the candidate');
|
|
135
|
+
for (const [kind, ref] of [['execution_doc', documents.executionDocumentRef], ['acceptance_doc', documents.acceptanceDocumentRef]]) {
|
|
136
|
+
const entry = flow.planningDocuments?.[kind];
|
|
137
|
+
if (entry?.candidateRevision !== documents.candidateRevision || !same(entry.documentRef, ref)) throw new Error('quality revision document reference differs from flow');
|
|
138
|
+
}
|
|
139
|
+
return { status: 'awaiting_revision', planningMode: flow.planningMode, flow, ...documents };
|
|
140
|
+
}
|
|
141
|
+
export function parseDeliveryPackage(pkg) {
|
|
142
|
+
if (!pkg || !['ready', 'not_approved', 'unavailable'].includes(pkg.status)) throw new Error('delivery package is invalid');
|
|
143
|
+
if (pkg.status === 'ready') {
|
|
144
|
+
if (!pkg.planningDelivery || typeof pkg.planningDelivery.requirementId !== 'string' || pkg.planningCandidateRevision !== pkg.planningDelivery.planningCandidateRevision || !same(pkg.executionDocumentRef, pkg.planningDelivery.executionDocumentRef) || !same(pkg.acceptanceDocumentRef, pkg.planningDelivery.acceptanceDocumentRef) || typeof pkg.executionDocument !== 'string' || typeof pkg.acceptanceDocument !== 'string') throw new Error('delivery package binding is invalid');
|
|
145
|
+
}
|
|
146
|
+
return pkg;
|
|
147
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { digest } from './storage.mjs';
|
|
4
|
+
|
|
5
|
+
export function transport(baseUrl, timeoutMs) {
|
|
6
|
+
async function request(path, command) {
|
|
7
|
+
const response = await fetch(baseUrl + path, {
|
|
8
|
+
...(command ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(command) } : {}),
|
|
9
|
+
signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
|
|
10
|
+
});
|
|
11
|
+
const text = await response.text();
|
|
12
|
+
let body;
|
|
13
|
+
try { body = JSON.parse(text); }
|
|
14
|
+
catch {
|
|
15
|
+
const error = new Error('服务未返回有效响应,请检查服务地址或稍后重试原任务。');
|
|
16
|
+
error.detail = text;
|
|
17
|
+
error.status = response.status;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
if (!response.ok || body?.ok !== true || !body.data) {
|
|
21
|
+
const error = new Error('服务未完成请求。请检查服务配置和项目登记;已有任务请勿改用新任务重复提交。');
|
|
22
|
+
error.detail = body;
|
|
23
|
+
error.status = response.status;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
return { request, post: command => request('/v1/reconcile/command', command) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function resolveProject(api, remote, cwd) {
|
|
32
|
+
const repository = execFileSync('git', ['-C', cwd, 'remote', 'get-url', remote], { encoding: 'utf8' }).trim();
|
|
33
|
+
const identity = normalizeRepositoryIdentity(repository);
|
|
34
|
+
if (!identity) throw new Error('目标仓库需要有效的 HTTPS 或 SSH Git remote。');
|
|
35
|
+
const response = await api.request(`/v1/reconcile/projects/resolve?repository=${encodeURIComponent(repository)}`);
|
|
36
|
+
const project = response.data;
|
|
37
|
+
if (!project || typeof project.projectId !== 'string' || typeof project.repositoryIdentity !== 'string') throw new Error('服务返回的项目登记信息无效。');
|
|
38
|
+
if (project.repositoryIdentity !== identity) throw new Error('服务返回的项目与当前仓库不一致。');
|
|
39
|
+
return project.projectId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readInput(path) {
|
|
43
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
44
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('需求文件必须是 JSON 对象。');
|
|
45
|
+
for (const key of Object.keys(value)) {
|
|
46
|
+
if (!['title', 'description', 'sources'].includes(key)) throw new Error(`未知需求字段:${key}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof value.description !== 'string' || !value.description.trim()) throw new Error('description 必须包含需求正文。');
|
|
49
|
+
if (value.title !== undefined && (typeof value.title !== 'string' || !value.title.trim())) throw new Error('title 必须是非空文本。');
|
|
50
|
+
if (value.sources !== undefined && !Array.isArray(value.sources)) throw new Error('sources 必须是数组。');
|
|
51
|
+
for (const source of value.sources ?? []) {
|
|
52
|
+
if (!source || Object.keys(source).some(key => !['label', 'content'].includes(key)) || typeof source.label !== 'string' || !source.label.trim() || typeof source.content !== 'string' || !source.content.trim()) {
|
|
53
|
+
throw new Error('每项来源只需提供 label 和已读取的 content 正文。');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function requestPackage(input, id, projectId, mode, agent) {
|
|
60
|
+
return { requestId: id, planningTargetKey: projectId, planningMode: mode,
|
|
61
|
+
requirement: { description: input.description, ...(input.title ? { title: input.title } : {}) },
|
|
62
|
+
materials: (input.sources ?? []).map(source => ({ kind: 'text', ...source })),
|
|
63
|
+
...(agent ? { agent } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function positive(value) { return Number.isSafeInteger(value) && value > 0; }
|
|
68
|
+
function validRef(ref, fields) {
|
|
69
|
+
return ref && typeof ref === 'object' && fields.every(([key, check]) => check(ref[key]));
|
|
70
|
+
}
|
|
71
|
+
const text = value => typeof value === 'string';
|
|
72
|
+
const nonEmpty = value => text(value) && value.length > 0;
|
|
73
|
+
const integer = value => Number.isSafeInteger(value) && value > 0;
|
|
74
|
+
export function validateDocuments(documents, requirementId) {
|
|
75
|
+
if (!documents || documents.requirementId !== requirementId || !positive(documents.snapshotRevision) || !positive(documents.candidateRevision)) {
|
|
76
|
+
throw new Error('返回的规划文档身份或版本无效。');
|
|
77
|
+
}
|
|
78
|
+
for (const [key, stage] of [['requirementArtifactRef', 'prd_understanding'], ['technicalDesignArtifactRef', 'technical_design']]) {
|
|
79
|
+
const ref = documents[key];
|
|
80
|
+
if (!validRef(ref, [['stage', nonEmpty], ['artifact_revision', integer], ['content_digest', nonEmpty]])) throw new Error('规划来源绑定不匹配。');
|
|
81
|
+
if (ref.stage !== stage) throw new Error('规划来源绑定不匹配。');
|
|
82
|
+
}
|
|
83
|
+
const gate = documents.gate;
|
|
84
|
+
if (!gate || (gate.status !== 'not_decided' && (gate.status !== 'decided' || !positive(gate.gateRevision) || !['pass', 'blocked'].includes(gate.decision)))) {
|
|
85
|
+
throw new Error('规划文档的检查信息缺失或无效。');
|
|
86
|
+
}
|
|
87
|
+
const rendered = {};
|
|
88
|
+
for (const [key, type, filename] of [['executionDocumentMarkdown', 'execution_doc', 'execution.md'], ['acceptanceDocumentMarkdown', 'acceptance_doc', 'acceptance.md']]) {
|
|
89
|
+
const ref = documents[key === 'executionDocumentMarkdown' ? 'executionDocumentRef' : 'acceptanceDocumentRef'];
|
|
90
|
+
const markdown = documents[key];
|
|
91
|
+
if (!validRef(ref, [['document_type', nonEmpty], ['document_revision', integer], ['content_digest', nonEmpty]]) || ref.document_type !== type || !nonEmpty(markdown)) throw new Error('正式文档 Markdown 或其版本引用缺失,请更新规划服务后重试。');
|
|
92
|
+
rendered[filename] = markdown;
|
|
93
|
+
}
|
|
94
|
+
return rendered;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeRepositoryIdentity(source) {
|
|
98
|
+
if (source.startsWith('/') || /^[A-Za-z]:[\\/]/u.test(source)) return undefined;
|
|
99
|
+
let protocol;
|
|
100
|
+
let host;
|
|
101
|
+
let port = '';
|
|
102
|
+
let path;
|
|
103
|
+
const scp = /^git@([A-Za-z0-9.-]+):([A-Za-z0-9._/-]+)$/u.exec(source);
|
|
104
|
+
if (scp) {
|
|
105
|
+
host = scp[1]; path = scp[2]; protocol = 'ssh';
|
|
106
|
+
if (path.includes('..')) return undefined;
|
|
107
|
+
} else {
|
|
108
|
+
if (!/^https:\/\//iu.test(source) && !/^ssh:\/\//iu.test(source)) return undefined;
|
|
109
|
+
const match = /^(https|ssh):\/\/([^/?#]+)(\/[^?#]*)$/iu.exec(source);
|
|
110
|
+
const rawPath = match?.[3];
|
|
111
|
+
if (!match || !rawPath || rawPath.includes('//') || rawPath.includes('\\') || /%2f/iu.test(rawPath) || rawPath.split('/').some(part => part === '.' || part === '..')) return undefined;
|
|
112
|
+
let url;
|
|
113
|
+
try { url = new URL(source); } catch { return undefined; }
|
|
114
|
+
if (!url.hostname || url.password || url.search || url.hash || (url.protocol === 'https:' && url.username) || (url.protocol === 'ssh:' && url.username && url.username !== 'git')) return undefined;
|
|
115
|
+
protocol = url.protocol === 'https:' ? 'https' : 'ssh'; host = url.hostname.toLowerCase(); port = url.port;
|
|
116
|
+
try { path = decodeURIComponent(rawPath.replace(/^\//u, '')); } catch { return undefined; }
|
|
117
|
+
if (!path || path.split('/').some(part => part === '.' || part === '..')) return undefined;
|
|
118
|
+
}
|
|
119
|
+
path = path.replace(/\/$/u, '').replace(/\.git$/u, '').replace(/\/$/u, '');
|
|
120
|
+
if (!path) return undefined;
|
|
121
|
+
const defaultPort = (protocol === 'https' && port === '443') || (protocol === 'ssh' && port === '22');
|
|
122
|
+
return !port || defaultPort ? `${host.toLowerCase()}/${path}` : `${protocol}://${host.toLowerCase()}:${port}/${path}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function assertFresh(api, state, outcome) {
|
|
126
|
+
if (outcome.status === 'awaiting_solution_review') {
|
|
127
|
+
const response = await api.post({ type: 'get_solution_review', requirementId: state.requirementId });
|
|
128
|
+
const current = response.data;
|
|
129
|
+
if (!current || current.type !== 'get_solution_review' || !current.review || !current.review.overview) throw new Error('服务返回的方案审阅信息无效。');
|
|
130
|
+
if (current.requirementId !== state.requirementId || current.snapshotRevision !== outcome.snapshotRevision || digest(current.review.sourceBinding) !== digest(outcome.solutionReview.sourceBinding) || current.review.reviewRevision !== outcome.solutionReview.reviewRevision || current.review.overview.markdown !== outcome.solutionReview.overview.markdown) {
|
|
131
|
+
throw new Error('方案已变化,请重新读取并请用户审阅;旧决定不能用于新方案。');
|
|
132
|
+
}
|
|
133
|
+
} else if (['ready_for_approval', 'awaiting_revision'].includes(outcome.status)) {
|
|
134
|
+
const current = (await api.post({ type: 'get_planning_candidate_documents', requirementId: state.requirementId })).data.documents;
|
|
135
|
+
validateDocuments(current, state.requirementId);
|
|
136
|
+
for (const key of ['snapshotRevision', 'candidateRevision', 'gate', 'requirementArtifactRef', 'technicalDesignArtifactRef', 'executionDocumentRef', 'acceptanceDocumentRef']) {
|
|
137
|
+
if (digest(current[key]) !== digest(outcome[key])) throw new Error('规划文档已变化,请重新读取并请用户审阅;旧决定不能用于新文档。');
|
|
138
|
+
}
|
|
139
|
+
for (const key of ['executionDocumentMarkdown', 'acceptanceDocumentMarkdown']) {
|
|
140
|
+
if (current[key] !== outcome[key]) throw new Error('规划文档正文已变化,请重新读取并请用户审阅;旧决定不能用于新文档。');
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
const report = (await api.post({ type: 'inspect_requirement', requirementId: state.requirementId })).data.report;
|
|
144
|
+
if (report?.requirementId !== state.requirementId || report.snapshotRevision !== outcome.snapshotRevision) throw new Error('待处理问题已变化,请重新读取后再回答。');
|
|
145
|
+
}
|
|
146
|
+
}
|