@twinsearth/dsh-master 3.3.4 → 3.3.5

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.
Files changed (54) hide show
  1. package/README.md +1 -0
  2. package/client.js +83 -4
  3. package/cordis.patch.yml +2 -0
  4. package/dist-host/agents/pjm-agent.js +18 -2
  5. package/dist-host/agents/pm-agent.js +23 -1
  6. package/dist-host/agents/prompts.js +16 -6
  7. package/dist-host/channel/abort-registry.js +93 -0
  8. package/dist-host/channel/protocol.js +114 -0
  9. package/dist-host/channel/server.js +349 -0
  10. package/dist-host/channel/stream-state.js +343 -0
  11. package/dist-host/channel/ws.js +199 -0
  12. package/dist-host/dual/documents.js +36 -0
  13. package/dist-host/dual/orchestrator.js +214 -0
  14. package/dist-host/dual/schema.js +153 -0
  15. package/dist-host/engine/atomic-switch.js +159 -0
  16. package/dist-host/engine/health-probe.js +86 -0
  17. package/dist-host/engine/index.js +18 -0
  18. package/dist-host/engine/llm-stream.js +215 -0
  19. package/dist-host/engine/plugin-updater.js +392 -0
  20. package/dist-host/engine/safe-update.js +150 -0
  21. package/dist-host/index.js +114 -2
  22. package/dist-host/kernel.js +7 -7
  23. package/dist-host/plugins/cost-optimizer.js +3 -3
  24. package/dist-host/plugins/dual-manager.js +230 -6
  25. package/docs/AGENTS-PM-PJM.md +90 -6
  26. package/docs/CHANGELOG.md +58 -0
  27. package/docs/CHANNEL-WEBSOCKET.md +169 -0
  28. package/docs/DESIGN-command-center.md +147 -0
  29. package/docs/ENGINE-PRIMITIVES.md +215 -0
  30. package/docs/GAP-ANALYSIS-fang-session.md +75 -0
  31. package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
  32. package/docs/examples/command-center/README.md +167 -0
  33. package/package.json +3 -1
  34. package/src/agents/pjm-agent.ts +18 -2
  35. package/src/agents/pm-agent.ts +21 -1
  36. package/src/agents/prompts.ts +16 -6
  37. package/src/channel/abort-registry.ts +85 -0
  38. package/src/channel/protocol.ts +110 -0
  39. package/src/channel/server.ts +289 -0
  40. package/src/channel/stream-state.ts +349 -0
  41. package/src/channel/ws.ts +182 -0
  42. package/src/dual/documents.ts +35 -0
  43. package/src/dual/orchestrator.ts +223 -0
  44. package/src/dual/schema.ts +148 -1
  45. package/src/engine/atomic-switch.ts +146 -0
  46. package/src/engine/health-probe.ts +84 -0
  47. package/src/engine/index.ts +20 -0
  48. package/src/engine/llm-stream.ts +189 -0
  49. package/src/engine/plugin-updater.ts +359 -0
  50. package/src/engine/safe-update.ts +148 -0
  51. package/src/index.ts +105 -2
  52. package/src/kernel.ts +7 -7
  53. package/src/plugins/cost-optimizer.ts +3 -3
  54. 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
+ }
@@ -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,84 @@
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
+
18
+ function positiveInt(value, fallback) {
19
+ const n = Number(value)
20
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
21
+ }
22
+
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)))
25
+ }
26
+
27
+ /**
28
+ * @param {{
29
+ * url: string,
30
+ * fetchImpl?: typeof fetch,
31
+ * timeoutMs?: number, intervalMs?: number, maxAttempts?: number,
32
+ * expectStatus?: (status: number) => boolean,
33
+ * now?: () => number, sleepImpl?: (ms: number) => Promise<void>,
34
+ * }} input
35
+ */
36
+ export async function probeHealth(input = {}) {
37
+ const url = String(input.url ?? '').trim()
38
+ if (!url) return { ok: false, code: 'url-required', error: 'url is required', attempts: 0 }
39
+ const fetchImpl = input.fetchImpl ?? fetch
40
+ const timeoutMs = positiveInt(input.timeoutMs, DEFAULT_TIMEOUT_MS)
41
+ const intervalMs = positiveInt(input.intervalMs, DEFAULT_INTERVAL_MS)
42
+ const maxAttempts = positiveInt(input.maxAttempts, DEFAULT_MAX_ATTEMPTS)
43
+ const now = typeof input.now === 'function' ? input.now : () => Date.now()
44
+ const wait = typeof input.sleepImpl === 'function' ? input.sleepImpl : sleep
45
+ const expectStatus = typeof input.expectStatus === 'function'
46
+ ? input.expectStatus
47
+ : (status) => status >= 200 && status < 300
48
+
49
+ const startedAt = now()
50
+ const deadline = startedAt + timeoutMs
51
+ let attempts = 0
52
+ let lastStatus = null
53
+ let lastError = null
54
+
55
+ while (attempts < maxAttempts) {
56
+ if (attempts > 0 && now() >= deadline) break
57
+ attempts += 1
58
+ try {
59
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
60
+ const timer = controller ? setTimeout(() => controller.abort(), Math.max(1, Math.min(intervalMs, 5_000))) : null
61
+ if (timer && typeof timer.unref === 'function') timer.unref()
62
+ let response = null
63
+ try {
64
+ response = await fetchImpl(url, { method: 'GET', signal: controller ? controller.signal : undefined })
65
+ } finally {
66
+ if (timer) clearTimeout(timer)
67
+ }
68
+ lastStatus = Number(response?.status ?? 0) || null
69
+ lastError = null
70
+ if (lastStatus !== null && expectStatus(lastStatus)) {
71
+ return { ok: true, code: 'healthy', status: lastStatus, attempts, elapsedMs: Math.max(0, now() - startedAt), url }
72
+ }
73
+ } catch (error) {
74
+ lastError = String(error?.message ?? error)
75
+ }
76
+ if (now() >= deadline) break
77
+ if (attempts < maxAttempts) await wait(intervalMs)
78
+ }
79
+
80
+ const elapsedMs = Math.max(0, now() - startedAt)
81
+ if (lastError) return { ok: false, code: 'probe-failed', error: lastError, status: lastStatus, attempts, elapsedMs, url }
82
+ if (lastStatus !== null) return { ok: false, code: 'unhealthy-status', status: lastStatus, attempts, elapsedMs, url }
83
+ return { ok: false, code: 'probe-timeout', attempts, elapsedMs, url }
84
+ }
@@ -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'