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