@twinsearth/dsh-master 3.3.4 → 3.3.6
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/README.md +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +90 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +63 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +88 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PM/PJM 编排策略(3.3.5)。
|
|
7
|
+
*
|
|
8
|
+
* 解决的问题:模型知道工具清单,但不知道“现在这一步该用哪个”。让它在 7 个工具里猜,
|
|
9
|
+
* 就会出现「PRD 还没写就交接」「还有未决问题就派发」这类错误。
|
|
10
|
+
*
|
|
11
|
+
* 这里把闭环状态机写成确定性函数:给定结构化状态,返回**唯一推荐动作**与理由。
|
|
12
|
+
* 模型仍然负责内容(写 PRD、拆 WBS、判定验收),编排不替它做判断,也不产生副作用。
|
|
13
|
+
*
|
|
14
|
+
* 约束:
|
|
15
|
+
* * 纯函数,不读全局、不写数据;
|
|
16
|
+
* * 每个 action 都必须对应真实存在的工具名(测试会校验,防止漂移);
|
|
17
|
+
* * 无法推进时返回 `blocked` + 具名原因,而不是编一个动作。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const PM_ACTIONS = Object.freeze([
|
|
21
|
+
'create_prd', 'create_assessment', 'clarify', 'handoff', 'review', 'done', 'blocked',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export const PJM_ACTIONS = Object.freeze([
|
|
25
|
+
'take_handoff', 'return_to_pm', 'create_plan', 'dispatch', 'report', 'complete_feedback', 'close', 'done', 'blocked',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** action → 真实工具名(agent preset 里注册过;测试比对)。 */
|
|
29
|
+
export const ACTION_TOOL = Object.freeze({
|
|
30
|
+
create_prd: 'pm_create_prd',
|
|
31
|
+
create_assessment: 'pm_create_assessment',
|
|
32
|
+
clarify: 'pm_create_prd',
|
|
33
|
+
handoff: 'pm_handoff_to_pjm',
|
|
34
|
+
review: 'pm_review_feedback',
|
|
35
|
+
take_handoff: 'pjm_take_handoff',
|
|
36
|
+
return_to_pm: 'pjm_take_handoff',
|
|
37
|
+
create_plan: 'pjm_create_plan',
|
|
38
|
+
dispatch: 'pjm_dispatch_step',
|
|
39
|
+
report: 'pjm_report',
|
|
40
|
+
complete_feedback: 'pjm_submit_feedback',
|
|
41
|
+
close: 'pjm_close_project',
|
|
42
|
+
done: null,
|
|
43
|
+
blocked: null,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PM 的下一步:PRD → 盘点 → 澄清 → 交接 → 复核。
|
|
48
|
+
* @param {{prd?: object|null, assessment?: object|null, handoff?: object|null, feedback?: object|null, review?: object|null}} state
|
|
49
|
+
*/
|
|
50
|
+
export function nextPmAction(state = {}) {
|
|
51
|
+
const prd = state.prd ?? null
|
|
52
|
+
const assessment = state.assessment ?? null
|
|
53
|
+
const handoff = state.handoff ?? null
|
|
54
|
+
const feedback = state.feedback ?? null
|
|
55
|
+
const review = state.review ?? null
|
|
56
|
+
|
|
57
|
+
if (!prd) {
|
|
58
|
+
return { action: 'create_prd', tool: ACTION_TOOL.create_prd, reason: 'no-prd-yet', detail: '先写结构化 PRD(title + goal)' }
|
|
59
|
+
}
|
|
60
|
+
if (!assessment) {
|
|
61
|
+
return { action: 'create_assessment', tool: ACTION_TOOL.create_assessment, reason: 'no-assessment-yet', detail: '六类盘点必须先落库,completed 项要有证据' }
|
|
62
|
+
}
|
|
63
|
+
const openQuestions = Array.isArray(prd.openQuestions) ? prd.openQuestions.length : 0
|
|
64
|
+
const conflicts = Array.isArray(assessment.conflicts) ? assessment.conflicts.length : 0
|
|
65
|
+
if (openQuestions > 0 || conflicts > 0) {
|
|
66
|
+
return {
|
|
67
|
+
action: 'clarify',
|
|
68
|
+
tool: null,
|
|
69
|
+
reason: openQuestions > 0 ? 'open-questions-unresolved' : 'conflicts-unresolved',
|
|
70
|
+
detail: openQuestions > 0
|
|
71
|
+
? `PRD 还有 ${openQuestions} 个未决问题,必须用 ask_user_question 一次问一个决策点`
|
|
72
|
+
: `盘点里有 ${conflicts} 个冲突,必须让用户重新判断(回复:新需求新方案)`,
|
|
73
|
+
blocking: true,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!handoff) {
|
|
77
|
+
return {
|
|
78
|
+
action: 'handoff',
|
|
79
|
+
tool: ACTION_TOOL.handoff,
|
|
80
|
+
reason: 'ready-to-handoff',
|
|
81
|
+
detail: '验收标准齐全且无未决项,打交接包给 PJM',
|
|
82
|
+
argsHint: { prdId: prd.id, assessmentId: assessment.id },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (handoff.status === 'needs-clarification') {
|
|
86
|
+
return {
|
|
87
|
+
action: 'blocked',
|
|
88
|
+
tool: null,
|
|
89
|
+
reason: handoff.reason || 'handoff-not-ready',
|
|
90
|
+
detail: '交接包未就绪,先解决未决问题再重新交接',
|
|
91
|
+
blocking: true,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (feedback && !review) {
|
|
95
|
+
return {
|
|
96
|
+
action: 'review',
|
|
97
|
+
tool: ACTION_TOOL.review,
|
|
98
|
+
reason: 'feedback-awaiting-review',
|
|
99
|
+
detail: 'PJM 已交反馈:逐条核对验收(met + 证据),写复盘与总结;结论必须与验收一致',
|
|
100
|
+
argsHint: { feedbackId: feedback.id },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (review) {
|
|
104
|
+
return {
|
|
105
|
+
action: 'done',
|
|
106
|
+
tool: null,
|
|
107
|
+
reason: review.verdict === 'accepted' ? 'loop-accepted' : 'loop-changes-requested',
|
|
108
|
+
detail: review.verdict === 'accepted' ? '本轮闭环已通过' : 'PJM 需按复核意见重做',
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
action: 'blocked',
|
|
113
|
+
tool: null,
|
|
114
|
+
reason: 'waiting-for-pjm',
|
|
115
|
+
detail: '等 PJM 接单并提交反馈;PM 不替它派发',
|
|
116
|
+
blocking: false,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* PJM 的下一步:接单 → 计划 → 派发 → 反馈 → 结案。
|
|
122
|
+
* @param {{handoff?: object|null, plan?: object|null, feedback?: object|null}} state
|
|
123
|
+
*/
|
|
124
|
+
export function nextPjmAction(state = {}) {
|
|
125
|
+
const handoff = state.handoff ?? null
|
|
126
|
+
const plan = state.plan ?? null
|
|
127
|
+
const feedback = state.feedback ?? null
|
|
128
|
+
|
|
129
|
+
if (handoff && handoff.status === 'needs-clarification') {
|
|
130
|
+
return {
|
|
131
|
+
action: 'return_to_pm',
|
|
132
|
+
tool: null,
|
|
133
|
+
reason: 'handoff-needs-clarification',
|
|
134
|
+
detail: '交接包未就绪:退回 PM 澄清,不得硬开工',
|
|
135
|
+
blocking: true,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (handoff && handoff.status === 'ready' && !plan) {
|
|
139
|
+
return {
|
|
140
|
+
action: 'take_handoff',
|
|
141
|
+
tool: ACTION_TOOL.take_handoff,
|
|
142
|
+
reason: 'handoff-ready',
|
|
143
|
+
detail: '接单后再拆 WBS',
|
|
144
|
+
argsHint: { handoffId: handoff.id },
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!plan) {
|
|
148
|
+
return {
|
|
149
|
+
action: 'create_plan',
|
|
150
|
+
tool: ACTION_TOOL.create_plan,
|
|
151
|
+
reason: 'no-plan-yet',
|
|
152
|
+
detail: '用 pjm_create_plan 写七类项目内容,步骤必须自己拆(交接包不含 WBS)',
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const steps = Array.isArray(plan.steps) ? plan.steps : []
|
|
156
|
+
const failed = steps.filter((step) => step.status === 'failed')
|
|
157
|
+
const running = steps.filter((step) => step.status === 'running' || step.status === 'dispatching')
|
|
158
|
+
const pending = steps.filter((step) => step.status === 'pending')
|
|
159
|
+
const blockedReport = Array.isArray(plan.reports) ? [...plan.reports].reverse().find((report) => report.status === 'blocked') : null
|
|
160
|
+
|
|
161
|
+
if (blockedReport && (blockedReport.blocker || blockedReport.summary)) {
|
|
162
|
+
return {
|
|
163
|
+
action: 'report',
|
|
164
|
+
tool: ACTION_TOOL.report,
|
|
165
|
+
reason: 'blocked-reported',
|
|
166
|
+
detail: `最近一次节点报告为阻塞:${blockedReport.blocker || blockedReport.summary};需要 PM/用户决策后才能继续`,
|
|
167
|
+
blocking: true,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (failed.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
action: 'report',
|
|
173
|
+
tool: ACTION_TOOL.report,
|
|
174
|
+
reason: 'step-failed',
|
|
175
|
+
detail: `${failed.length} 个步骤失败:如实汇报失败原因,不得写成完成`,
|
|
176
|
+
argsHint: { planId: plan.id, status: 'blocked' },
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (running.length > 0) {
|
|
180
|
+
return {
|
|
181
|
+
action: 'report',
|
|
182
|
+
tool: ACTION_TOOL.report,
|
|
183
|
+
reason: 'step-running',
|
|
184
|
+
detail: '有步骤在隔离执行体中运行:等结果并汇报节点,不重复派发',
|
|
185
|
+
argsHint: { planId: plan.id, status: 'progress' },
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (pending.length > 0) {
|
|
189
|
+
const next = pending[0]
|
|
190
|
+
return {
|
|
191
|
+
action: 'dispatch',
|
|
192
|
+
tool: ACTION_TOOL.dispatch,
|
|
193
|
+
reason: 'step-pending',
|
|
194
|
+
detail: '派发下一个待办步骤(走 fangForker.spawn;不可用即 fail-closed)',
|
|
195
|
+
argsHint: { planId: plan.id, stepId: next.id },
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (feedback) {
|
|
199
|
+
if (plan.status === 'closed') {
|
|
200
|
+
return { action: 'done', tool: null, reason: 'project-closed', detail: '项目已结案,等 PM 复核结论' }
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
action: 'close',
|
|
204
|
+
tool: ACTION_TOOL.close,
|
|
205
|
+
reason: 'all-steps-done',
|
|
206
|
+
detail: '全部步骤完成且已提交反馈:结案需要总结、评审报告、验收与资料清单',
|
|
207
|
+
argsHint: { planId: plan.id },
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
action: 'complete_feedback',
|
|
212
|
+
tool: ACTION_TOOL.complete_feedback,
|
|
213
|
+
reason: 'all-steps-done-no-feedback',
|
|
214
|
+
detail: '全部步骤完成:提交交付、偏差、遗留问题与测试证据,交给 PM 复核',
|
|
215
|
+
argsHint: { planId: plan.id },
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 给 UI/测试用的紧凑摘要。 */
|
|
220
|
+
export function describeAction(result) {
|
|
221
|
+
if (!result || !result.action) return 'blocked: unknown state'
|
|
222
|
+
return [result.action, result.reason, result.detail].filter(Boolean).join(' · ')
|
|
223
|
+
}
|
package/src/dual/schema.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
export const ASSESSMENT_CATEGORIES = Object.freeze(['completed', 'unfinished', 'blocked', 'planned', 'conflicts', 'ambiguities'])
|
|
8
8
|
export const PROJECT_SECTIONS = Object.freeze(['projectReview', 'steps', 'requirements', 'schedule', 'team', 'quality', 'closure'])
|
|
9
|
-
|
|
10
9
|
const text = (value, fallback = '') => String(value ?? fallback).trim()
|
|
11
10
|
const list = (value) => Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : (text(value) ? [text(value)] : [])
|
|
12
11
|
const records = (value) => Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : []
|
|
@@ -206,3 +205,151 @@ export function closePlan(plan, input = {}, now = Date.now()) {
|
|
|
206
205
|
}
|
|
207
206
|
return { ok: true, plan, closure: plan.closure, progress }
|
|
208
207
|
}
|
|
208
|
+
|
|
209
|
+
// ── 闭环:PM 交接 → PJM 执行/反馈 → PM 复核/复盘/总结 ──────────────────────────
|
|
210
|
+
|
|
211
|
+
export const HANDOFF_STATUS = Object.freeze(['needs-clarification', 'ready', 'accepted', 'withdrawn'])
|
|
212
|
+
export const REVIEW_VERDICTS = Object.freeze(['accepted', 'changes-requested'])
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* PM → PJM 交接包。`ready` 是硬门禁:
|
|
216
|
+
* * 没有验收标准 → 不 ready(PJM 无法判定“做没做成”);
|
|
217
|
+
* * 还有未决问题/冲突 → 不 ready(PJM 不得替用户决定需求)。
|
|
218
|
+
*/
|
|
219
|
+
export function normalizeHandoff(prd, assessment, input = {}, { id, now = Date.now() } = {}) {
|
|
220
|
+
if (!prd) return error('prd-not-found', 'PRD not found', { id: String(input.prdId ?? '') })
|
|
221
|
+
const acceptance = [...(list(input.acceptance).length ? list(input.acceptance) : prd.acceptance)]
|
|
222
|
+
const openQuestions = [...prd.openQuestions, ...list(input.openQuestions)]
|
|
223
|
+
const conflicts = Array.isArray(assessment?.conflicts) ? assessment.conflicts.map((item) => text(item.question)).filter(Boolean) : []
|
|
224
|
+
const ready = acceptance.length > 0 && openQuestions.length === 0 && conflicts.length === 0
|
|
225
|
+
const reason = ready
|
|
226
|
+
? null
|
|
227
|
+
: acceptance.length === 0
|
|
228
|
+
? 'acceptance-required'
|
|
229
|
+
: openQuestions.length > 0
|
|
230
|
+
? `open-questions-unresolved:${openQuestions.length}`
|
|
231
|
+
: `conflicts-unresolved:${conflicts.length}`
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
handoff: ({
|
|
235
|
+
id: String(id),
|
|
236
|
+
prdId: prd.id,
|
|
237
|
+
assessmentId: String(input.assessmentId ?? '') || null,
|
|
238
|
+
title: text(input.title, prd.title),
|
|
239
|
+
priority: prd.priority,
|
|
240
|
+
acceptance: Object.freeze(acceptance),
|
|
241
|
+
constraints: prd.constraints,
|
|
242
|
+
openQuestions: Object.freeze(openQuestions),
|
|
243
|
+
conflicts: Object.freeze(conflicts),
|
|
244
|
+
status: ready ? 'ready' : 'needs-clarification',
|
|
245
|
+
reason,
|
|
246
|
+
planId: null,
|
|
247
|
+
preparedAt: now,
|
|
248
|
+
acceptedAt: null,
|
|
249
|
+
}),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** PJM 接单:只有 ready 的交接包可以被接走,否则退回 PM 澄清。 */
|
|
254
|
+
export function acceptHandoff(handoff, input = {}, now = Date.now()) {
|
|
255
|
+
if (!handoff) return error('handoff-not-found', 'Handoff not found')
|
|
256
|
+
if (handoff.status === 'accepted') return error('handoff-already-accepted', 'Handoff already accepted', { planId: handoff.planId })
|
|
257
|
+
if (handoff.status !== 'ready') {
|
|
258
|
+
return error('handoff-not-ready', 'Handoff is not ready; PM must resolve open questions or add acceptance criteria', {
|
|
259
|
+
status: handoff.status, reason: handoff.reason,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
handoff.status = 'accepted'
|
|
263
|
+
handoff.acceptedAt = now
|
|
264
|
+
handoff.planId = String(input.planId ?? '') || null
|
|
265
|
+
return { ok: true, handoff }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* PJM → PM 自反馈:必须在计划全部步骤完成后提交;只写实测结果,不写结论性评价。
|
|
270
|
+
*/
|
|
271
|
+
export function normalizeFeedback(plan, prd, input = {}, { id, now = Date.now() } = {}) {
|
|
272
|
+
if (!plan) return error('plan-not-found', 'Plan not found', { id: String(input.planId ?? '') })
|
|
273
|
+
const progress = planProgress(plan)
|
|
274
|
+
if (!progress.canClose) return error('plan-not-ready', 'feedback requires every step to be done', { progress })
|
|
275
|
+
const summary = text(input.summary)
|
|
276
|
+
if (!summary) return error('summary-required', 'feedback summary is required')
|
|
277
|
+
const delivered = plan.steps
|
|
278
|
+
.filter((step) => step.status === 'done')
|
|
279
|
+
.map((step) => Object.freeze({
|
|
280
|
+
stepId: step.id,
|
|
281
|
+
title: step.title,
|
|
282
|
+
owner: step.owner,
|
|
283
|
+
summary: step.summary,
|
|
284
|
+
evidence: Object.freeze([...(step.evidence ?? [])]),
|
|
285
|
+
actualMinutes: step.actualMinutes ?? null,
|
|
286
|
+
}))
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
feedback: ({
|
|
290
|
+
id: String(id),
|
|
291
|
+
planId: plan.id,
|
|
292
|
+
prdId: plan.prdId ?? prd?.id ?? null,
|
|
293
|
+
handoffId: String(input.handoffId ?? '') || null,
|
|
294
|
+
status: plan.closure?.status === 'closed' ? 'closed' : 'reported',
|
|
295
|
+
summary,
|
|
296
|
+
delivered: Object.freeze(delivered),
|
|
297
|
+
deviations: Object.freeze(list(input.deviations)),
|
|
298
|
+
openIssues: Object.freeze(list(input.openIssues)),
|
|
299
|
+
tests: Object.freeze(list(input.tests ?? plan.quality?.tests)),
|
|
300
|
+
metrics: Object.freeze({
|
|
301
|
+
steps: progress.total,
|
|
302
|
+
done: progress.done,
|
|
303
|
+
estimatedMinutes: progress.estimatedMinutes,
|
|
304
|
+
actualMinutes: progress.actualMinutes,
|
|
305
|
+
percent: progress.percent,
|
|
306
|
+
}),
|
|
307
|
+
createdAt: now,
|
|
308
|
+
}),
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* PM 复核:逐条验收标准必须给出 met 布尔与证据;结论必须与验收结果一致,
|
|
314
|
+
* 不允许「有未达标却判 accepted」或「全达标却判 changes-requested」。
|
|
315
|
+
*/
|
|
316
|
+
export function normalizeReview(feedback, input = {}, { id, now = Date.now() } = {}) {
|
|
317
|
+
if (!feedback) return error('feedback-not-found', 'Feedback not found', { id: String(input.feedbackId ?? '') })
|
|
318
|
+
const checks = []
|
|
319
|
+
for (const [index, raw] of records(input.acceptanceChecks).entries()) {
|
|
320
|
+
const criterion = text(raw.criterion)
|
|
321
|
+
if (!criterion) return error(`acceptanceChecks[${index}].criterion-required`, `acceptanceChecks[${index}].criterion is required`)
|
|
322
|
+
if (typeof raw.met !== 'boolean') return error(`acceptanceChecks[${index}].met-required`, `acceptanceChecks[${index}].met must be boolean`)
|
|
323
|
+
checks.push(Object.freeze({ criterion, met: raw.met, evidence: Object.freeze(list(raw.evidence)), note: text(raw.note) }))
|
|
324
|
+
}
|
|
325
|
+
if (checks.length === 0) return error('acceptance-checks-required', 'at least one acceptance check is required')
|
|
326
|
+
const allMet = checks.every((check) => check.met === true)
|
|
327
|
+
const verdict = REVIEW_VERDICTS.includes(text(input.verdict)) ? text(input.verdict) : (allMet ? 'accepted' : 'changes-requested')
|
|
328
|
+
if (verdict === 'accepted' && !allMet) {
|
|
329
|
+
return error('verdict-inconsistent', 'cannot accept while an acceptance check is not met', { failed: checks.filter((check) => !check.met).map((check) => check.criterion) })
|
|
330
|
+
}
|
|
331
|
+
if (verdict === 'changes-requested' && allMet) {
|
|
332
|
+
return error('verdict-inconsistent', 'all checks are met; use accepted or explain with a new check')
|
|
333
|
+
}
|
|
334
|
+
const summary = text(input.summary)
|
|
335
|
+
if (!summary) return error('summary-required', 'review summary is required')
|
|
336
|
+
const retrospective = input.retrospective && typeof input.retrospective === 'object' ? input.retrospective : {}
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
review: ({
|
|
340
|
+
id: String(id),
|
|
341
|
+
feedbackId: feedback.id,
|
|
342
|
+
planId: feedback.planId,
|
|
343
|
+
prdId: feedback.prdId ?? null,
|
|
344
|
+
verdict,
|
|
345
|
+
acceptanceChecks: Object.freeze(checks),
|
|
346
|
+
retrospective: Object.freeze({
|
|
347
|
+
kept: Object.freeze(list(retrospective.kept ?? input.kept)),
|
|
348
|
+
improve: Object.freeze(list(retrospective.improve ?? input.improve)),
|
|
349
|
+
lessons: Object.freeze(list(retrospective.lessons ?? input.lessons)),
|
|
350
|
+
}),
|
|
351
|
+
summary,
|
|
352
|
+
createdAt: now,
|
|
353
|
+
}),
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 蓝绿指针切换(AtomicSwitcher 的可编译实现)。
|
|
7
|
+
*
|
|
8
|
+
* 只做一件事:把 `linkPath` 这个目录指针**原子地**指向 `targetDir`。
|
|
9
|
+
* 做法是「新链接写临时名 → rename 覆盖」,因为同文件系统内 rename 是原子的:
|
|
10
|
+
* 读者永远看到旧指针或新指针,不会看到中间态。
|
|
11
|
+
*
|
|
12
|
+
* 平台差异(不隐藏):
|
|
13
|
+
* * POSIX:`symlink(target, link, 'dir')`,rename 覆盖。
|
|
14
|
+
* * Windows:`symlink(..., 'junction')`。**junction 只能指向目录、不能跨卷**,
|
|
15
|
+
* 且不需要管理员权限(symlink 需要)。因此这里对 Windows 明确用 junction。
|
|
16
|
+
*
|
|
17
|
+
* 不做什么:不删旧目录、不动进程、不看护端口 —— 那些属于调用方(或瓮城的 VM 层)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fsDefault from 'node:fs/promises'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
const noop = () => {}
|
|
24
|
+
|
|
25
|
+
function asString(value) { return String(value ?? '').trim() }
|
|
26
|
+
|
|
27
|
+
async function exists(fs, target) {
|
|
28
|
+
try { await fs.lstat(target); return true } catch { return false }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{
|
|
33
|
+
* linkPath: string, targetDir: string,
|
|
34
|
+
* fs?: object, platform?: string,
|
|
35
|
+
* now?: () => number,
|
|
36
|
+
* }} input
|
|
37
|
+
*/
|
|
38
|
+
export async function switchPointer(input = {}) {
|
|
39
|
+
const fs = input.fs ?? fsDefault
|
|
40
|
+
const platform = String(input.platform ?? process.platform)
|
|
41
|
+
const now = typeof input.now === 'function' ? input.now : () => Date.now()
|
|
42
|
+
const linkPath = path.resolve(asString(input.linkPath))
|
|
43
|
+
const targetDir = path.resolve(asString(input.targetDir))
|
|
44
|
+
if (!asString(input.linkPath)) return { ok: false, code: 'link-path-required', error: 'linkPath is required' }
|
|
45
|
+
if (!asString(input.targetDir)) return { ok: false, code: 'target-dir-required', error: 'targetDir is required' }
|
|
46
|
+
if (linkPath === targetDir) return { ok: false, code: 'self-reference', error: 'linkPath and targetDir must differ' }
|
|
47
|
+
|
|
48
|
+
// 目标必须是真实存在的目录:切换到一个不存在的绿槽比不切换更糟。
|
|
49
|
+
let targetStat = null
|
|
50
|
+
try { targetStat = await fs.stat(targetDir) } catch { /* handled below */ }
|
|
51
|
+
if (!targetStat || !targetStat.isDirectory()) {
|
|
52
|
+
return { ok: false, code: 'target-missing', error: `target directory does not exist: ${targetDir}` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parent = path.dirname(linkPath)
|
|
56
|
+
if (!(await exists(fs, parent))) return { ok: false, code: 'parent-missing', error: `parent directory does not exist: ${parent}` }
|
|
57
|
+
|
|
58
|
+
// 现有指针必须是链接(或不存在);覆盖普通目录/文件会静默毁数据,直接拒绝。
|
|
59
|
+
if (await exists(fs, linkPath)) {
|
|
60
|
+
const current = await fs.lstat(linkPath)
|
|
61
|
+
if (!current.isSymbolicLink() && platform !== 'win32') {
|
|
62
|
+
return { ok: false, code: 'link-is-not-a-symlink', error: `${linkPath} exists and is not a symlink` }
|
|
63
|
+
}
|
|
64
|
+
if (!current.isSymbolicLink() && platform === 'win32') {
|
|
65
|
+
// Windows 上 junction 在 lstat 里呈现为 symlink;普通目录一律拒绝。
|
|
66
|
+
return { ok: false, code: 'link-is-not-a-junction', error: `${linkPath} exists and is not a junction` }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tempLink = `${linkPath}.${process.pid}.${now()}.tmp`
|
|
71
|
+
const type = platform === 'win32' ? 'junction' : 'dir'
|
|
72
|
+
const startedAt = now()
|
|
73
|
+
try {
|
|
74
|
+
await fs.rm(tempLink, { force: true, recursive: true }).catch(noop)
|
|
75
|
+
await fs.symlink(targetDir, tempLink, type)
|
|
76
|
+
// rename 覆盖仅在同一文件系统内原子;跨卷时调用方必须自己保证同卷。
|
|
77
|
+
// Windows 上 rename 不能覆盖已存在的 junction(EPERM),退回“先移开再放”,
|
|
78
|
+
// 并如实标记 atomic=false —— 这个平台限制是真实存在的,不假装完美。
|
|
79
|
+
let replaced = 'rename'
|
|
80
|
+
let atomic = true
|
|
81
|
+
try {
|
|
82
|
+
await fs.rename(tempLink, linkPath)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (platform !== 'win32' || error?.code !== 'EPERM') throw error
|
|
85
|
+
const backup = `${linkPath}.${process.pid}.${now()}.bak`
|
|
86
|
+
await fs.rename(linkPath, backup)
|
|
87
|
+
try {
|
|
88
|
+
await fs.rename(tempLink, linkPath)
|
|
89
|
+
} catch (inner) {
|
|
90
|
+
await fs.rename(backup, linkPath).catch(noop)
|
|
91
|
+
throw inner
|
|
92
|
+
}
|
|
93
|
+
await fs.rm(backup, { force: true, recursive: true }).catch(noop)
|
|
94
|
+
replaced = 'move-aside'
|
|
95
|
+
atomic = false
|
|
96
|
+
}
|
|
97
|
+
let resolved = null
|
|
98
|
+
try { resolved = await fs.realpath(linkPath) } catch { /* 读取失败不影响切换结论 */ }
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
code: 'switched',
|
|
102
|
+
linkPath,
|
|
103
|
+
targetDir,
|
|
104
|
+
linkType: type,
|
|
105
|
+
replaced,
|
|
106
|
+
atomic,
|
|
107
|
+
resolved,
|
|
108
|
+
resolvedMatchesTarget: resolved ? path.resolve(resolved) === targetDir : null,
|
|
109
|
+
elapsedMs: Math.max(0, now() - startedAt),
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
await fs.rm(tempLink, { force: true, recursive: true }).catch(noop)
|
|
113
|
+
return { ok: false, code: 'switch-failed', error: String(error?.message ?? error), linkPath, targetDir }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 读指针当前指向(只读)。不存在 / 不是链接时给出具名原因,不抛。
|
|
119
|
+
*/
|
|
120
|
+
export async function readPointer(input = {}) {
|
|
121
|
+
const fs = input.fs ?? fsDefault
|
|
122
|
+
const linkPath = path.resolve(asString(input.linkPath))
|
|
123
|
+
if (!asString(input.linkPath)) return { ok: false, code: 'link-path-required', error: 'linkPath is required' }
|
|
124
|
+
if (!(await exists(fs, linkPath))) return { ok: false, code: 'link-missing', error: `${linkPath} does not exist` }
|
|
125
|
+
try {
|
|
126
|
+
const stat = await fs.lstat(linkPath)
|
|
127
|
+
const target = await fs.readlink(linkPath).catch(() => null)
|
|
128
|
+
const resolved = await fs.realpath(linkPath).catch(() => null)
|
|
129
|
+
return { ok: true, linkPath, isSymlink: stat.isSymbolicLink(), target, resolved }
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { ok: false, code: 'read-failed', error: String(error?.message ?? error) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 用「先切到新槽、失败切回旧槽」的方式包装一次切换,失败时保证指针回到原目标。
|
|
137
|
+
*/
|
|
138
|
+
export async function switchWithRollback(input = {}) {
|
|
139
|
+
const fs = input.fs ?? fsDefault
|
|
140
|
+
const previous = await readPointer({ fs, linkPath: input.linkPath })
|
|
141
|
+
const switched = await switchPointer(input)
|
|
142
|
+
if (switched.ok) return { ...switched, rollback: null }
|
|
143
|
+
if (!previous.ok || !previous.resolved) return { ...switched, rollback: null }
|
|
144
|
+
const restore = await switchPointer({ ...input, targetDir: previous.resolved })
|
|
145
|
+
return { ...switched, rollback: restore }
|
|
146
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 健康检查探针(HealthProbe 的可编译实现)。
|
|
7
|
+
*
|
|
8
|
+
* 只做「有界轮询 + 明确结论」:
|
|
9
|
+
* * 有总超时和最大尝试次数,不会无限等;
|
|
10
|
+
* * 任何一次拿到期望状态码即成功(默认 200–299);
|
|
11
|
+
* * 超时/连接失败返回具名原因,**不**把“没连上”当成“健康”。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_INTERVAL_MS = 1_000
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
16
|
+
const DEFAULT_MAX_ATTEMPTS = 30
|
|
17
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000
|
|
18
|
+
|
|
19
|
+
function positiveInt(value, fallback) {
|
|
20
|
+
const n = Number(value)
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{
|
|
30
|
+
* url: string,
|
|
31
|
+
* fetchImpl?: typeof fetch,
|
|
32
|
+
* timeoutMs?: number, intervalMs?: number, maxAttempts?: number,
|
|
33
|
+
* expectStatus?: (status: number) => boolean,
|
|
34
|
+
* now?: () => number, sleepImpl?: (ms: number) => Promise<void>,
|
|
35
|
+
* }} input
|
|
36
|
+
*/
|
|
37
|
+
export async function probeHealth(input = {}) {
|
|
38
|
+
const url = String(input.url ?? '').trim()
|
|
39
|
+
if (!url) return { ok: false, code: 'url-required', error: 'url is required', attempts: 0 }
|
|
40
|
+
const fetchImpl = input.fetchImpl ?? fetch
|
|
41
|
+
const timeoutMs = positiveInt(input.timeoutMs, DEFAULT_TIMEOUT_MS)
|
|
42
|
+
const intervalMs = positiveInt(input.intervalMs, DEFAULT_INTERVAL_MS)
|
|
43
|
+
const maxAttempts = positiveInt(input.maxAttempts, DEFAULT_MAX_ATTEMPTS)
|
|
44
|
+
const now = typeof input.now === 'function' ? input.now : () => Date.now()
|
|
45
|
+
const wait = typeof input.sleepImpl === 'function' ? input.sleepImpl : sleep
|
|
46
|
+
const expectStatus = typeof input.expectStatus === 'function'
|
|
47
|
+
? input.expectStatus
|
|
48
|
+
: (status) => status >= 200 && status < 300
|
|
49
|
+
|
|
50
|
+
const startedAt = now()
|
|
51
|
+
const deadline = startedAt + timeoutMs
|
|
52
|
+
// 单次请求超时必须与轮询间隔分开:拿 intervalMs 当请求超时会把「慢但有响应」误判成 probe-failed
|
|
53
|
+
const requestTimeoutMs = positiveInt(input.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS)
|
|
54
|
+
let attempts = 0
|
|
55
|
+
let lastStatus = null
|
|
56
|
+
let lastError = null
|
|
57
|
+
|
|
58
|
+
while (attempts < maxAttempts) {
|
|
59
|
+
if (attempts > 0 && now() >= deadline) break
|
|
60
|
+
attempts += 1
|
|
61
|
+
try {
|
|
62
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
63
|
+
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1, Math.min(requestTimeoutMs, 30_000))) : null
|
|
64
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
65
|
+
let response = null
|
|
66
|
+
try {
|
|
67
|
+
response = await fetchImpl(url, { method: 'GET', signal: controller ? controller.signal : undefined })
|
|
68
|
+
} finally {
|
|
69
|
+
if (timer) clearTimeout(timer)
|
|
70
|
+
}
|
|
71
|
+
lastStatus = Number(response?.status ?? 0) || null
|
|
72
|
+
lastError = null
|
|
73
|
+
if (lastStatus !== null && expectStatus(lastStatus)) {
|
|
74
|
+
return { ok: true, code: 'healthy', status: lastStatus, attempts, elapsedMs: Math.max(0, now() - startedAt), url }
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
lastError = String(error?.message ?? error)
|
|
78
|
+
}
|
|
79
|
+
if (now() >= deadline) break
|
|
80
|
+
if (attempts < maxAttempts) await wait(intervalMs)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const elapsedMs = Math.max(0, now() - startedAt)
|
|
84
|
+
// 只要拿到过响应,就以状态码为准——不能因为某次连接失败把「服务返回 503」改写成 probe-failed
|
|
85
|
+
if (lastStatus !== null) return { ok: false, code: 'unhealthy-status', status: lastStatus, attempts, elapsedMs, url }
|
|
86
|
+
if (lastError) return { ok: false, code: 'probe-failed', error: lastError, status: lastStatus, attempts, elapsedMs, url }
|
|
87
|
+
return { ok: false, code: 'probe-timeout', attempts, elapsedMs, url }
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 3.3.5 · 宿主侧引擎原语(不依赖任何 VM / 瓮城代码)。
|
|
7
|
+
*
|
|
8
|
+
* 这三个原语回答的是「切换/验证/回滚」的正确性,而不是「怎么管 VM」:
|
|
9
|
+
* * `switchPointer` / `switchWithRollback` —— 蓝绿指针原子切换,失败回到旧目标;
|
|
10
|
+
* * `probeHealth` —— 有界健康检查,不会把“连不上”当健康;
|
|
11
|
+
* * `runSafeUpdate` —— 快照 → 更新 → 冒烟 → 失败回滚,执行能力由调用方注入。
|
|
12
|
+
*
|
|
13
|
+
* 没有 `vm_create`/`vm_exec` 这类假设:房主不持有 VM;需要时由宿主或瓮城提供 executor。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { switchPointer, readPointer, switchWithRollback } from './atomic-switch.ts'
|
|
17
|
+
export { probeHealth } from './health-probe.ts'
|
|
18
|
+
export { runSafeUpdate, switchAndVerify } from './safe-update.ts'
|
|
19
|
+
export { createPluginUpdater, buildSmokeScript, shellArgv, UPDATE_PHASES } from './plugin-updater.ts'
|
|
20
|
+
export { createLlmStreamer, llmStreamFn } from './llm-stream.ts'
|