@fffangchenghao/dsh-autoupdater 0.0.0-stage → 0.6.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-autoupdater contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # dsh-autoupdater
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Auto-update plugin for DeepSeek Harness (DSH)** — 定时检查 DSH 本体与已安装插件的更新,只在无人使用的空闲窗口执行。四种调度模式、双向白名单、可视化设置面板。
4
+
5
+ > **社区维护分支**:这是 [niubi6668889595/dsh-autoupdater](https://github.com/niubi6668889595/dsh-autoupdater) 的 fork,主要修复 macOS / Windows 桌面版(Electron)插件更新失败的问题。上游 PR:[#1](https://github.com/niubi6668889595/dsh-autoupdater/pull/1)。
6
+ > npm 包名:`@fffangchenghao/dsh-autoupdater`;GitHub:`fangchenghao/dsh-autoupdater`。
7
+
8
+ English below · [English](#english)
9
+
10
+ ## 功能
11
+
12
+ - **定时检查**:四种调度模式任选——每日 HH:MM / 每 N 小时(整点对齐)/ 每 N 天 / 每 N 分钟
13
+ - **空闲门槛**:会话日志 2 分钟无写入 + 无运行中 Agent 才动手;定时触发再叠加持续静默采样,绝不打断使用
14
+ - **插件批量更新**:遍历已安装插件,逐个对比 npm 最新版并升级(带 `包名@版本` 显式安装 + 磁盘回读验证,杜绝假更新)
15
+ - **双向白名单**:白名单内只提醒 / 白名单内自动升级,两种语义随你切换
16
+ - **DSH 本体更新**:三种策略——不管 / 只暂存+通知 / 通知后宽限自动重启(宽限期内任何会话活动即推迟)
17
+ - **Web 设置面板**:插件管理器内嵌卡片,改配置保存即生效(热配置,无需重启)
18
+ - **最近活动日志**:每次检查/更新/重启都有可读记录,面板内直接查看
19
+ - **防变砖检查**:本体更新后校验 dsh 二进制存在才重启,npm 中断不留半更新状态
20
+
21
+ ## 安装
22
+
23
+ ```sh
24
+ dsh plugin --profile web add @fffangchenghao/dsh-autoupdater
25
+ ```
26
+
27
+ 或从 GitHub:
28
+
29
+ ```sh
30
+ dsh plugin --profile web add github:fangchenghao/dsh-autoupdater
31
+ ```
32
+
33
+ > 用 scoped 包名是为了不和上游 `dsh-autoupdater` 抢注同名包;bundle id 仍是 `dsh-autoupdater`,配置与状态路径不变。
34
+ >
35
+ > Windows 桌面版更推荐直接在 **插件管理器 → 安装插件** 里填包名安装;装完重启 App。
36
+
37
+ 安装后重启 DSH(systemd 环境:`sudo systemctl restart <你的dsh服务名>`),然后在 **插件管理器 → 自动更新** 卡片配置。
38
+
39
+ ## 配置
40
+
41
+ 面板可改全部配置并即时生效;以下为 `cordis.patch.yml` 初始值:
42
+
43
+ | 字段 | 默认值 | 说明 |
44
+ | --- | --- | --- |
45
+ | `scheduleMode` | `daily` | `daily` / `everyHours` / `everyDays` / `everyMinutes` |
46
+ | `dailyTime` | `"04:30"` | 每日 / 每 N 天模式的锚定时刻 |
47
+ | `everyHours` | `6` | 每 N 小时(整点对齐,1–168) |
48
+ | `everyDays` | `1` | 每 N 天(锚定 dailyTime,1–365) |
49
+ | `everyMinutes` | `60` | 每 N 分钟(1–10080) |
50
+ | `idleSeconds` | `300` | 判定无人使用的持续静默秒数 |
51
+ | `idleTimeoutMinutes` | `120` | 每次检查等待空闲的最长时间 |
52
+ | `harnessMode` | `notify` | `off` / `notify`(暂存+通知)/ `auto`(宽限后自动重启) |
53
+ | `graceMinutes` | `30` | auto 模式通知后的宽限分钟数 |
54
+ | `systemdUnit` | `""` | 重启用的 systemd 服务名;空 = 从 cgroup 自动探测 |
55
+ | `pluginMode` | `regexmode-ignore` | 白名单内只提醒其余自动 / 白名单内自动其余提醒 / 全自动 / 全提醒 / 关闭 |
56
+ | `whitelist` | `[]` | 插件包名列表,含义随 pluginMode |
57
+
58
+ ## 空闲判定原理
59
+
60
+ 1. **会话日志 mtime**:`$DSH_HOME/sessions` 下任意文件 2 分钟内无写入 = 无进行中的工作(借鉴 [dsh-self-upgrade](https://github.com/raomaiping-hash/dsh-self-upgrade),比 `agent.status` 可靠——实测 turn 结束后 status 可能停留 `running`)
61
+ 2. **定时触发**额外要求持续静默 `idleSeconds` 秒,双重采样防瞬时空档误判
62
+ 3. **手动触发**(面板"立即检查")只要求此刻无运行中会话——你主动点的,不再等静默
63
+ 4. 探测失败保守当忙,宁可不更新也不冒险
64
+
65
+ ## 本体更新的重启方式
66
+
67
+ - 配置了 `systemdUnit`(或自动探测到)→ `sudo -n systemctl restart <unit>`。**前置条件**:你的部署用户已配置该命令的 sudoers 免密,例如:
68
+
69
+ ```
70
+ youruser ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart your-dsh-service
71
+ ```
72
+
73
+ 未配置免密时 sudo 会失败,日志里如实记录,插件不会反复重试
74
+ - 未探测到 systemd(含 macOS / Windows 桌面 App)→ 不做进程内重启,只把“待重启”写进本轮结果,下次启动生效
75
+ - npm 全局安装后校验 dsh 二进制存在,缺失则**放弃重启**(防变砖)
76
+
77
+ ## 桌面 App(macOS / Windows)
78
+
79
+ 桌面版的 DSH 运行在 Electron 里,与 `dsh web` 的 Linux 进程模型不同,本插件做了三处适配:
80
+
81
+ 1. **插件换版**:桌面 App 的 `pluginManager` 会以 `already-installed` 拒绝替换 HMR 下的已装包,
82
+ 普通 `dsh` CLI 又拒绝 `desktop` 保留 profile。插件改用安装包自带的
83
+ `dsh-desktop-host/lib/cli.js`(Electron `ELECTRON_RUN_AS_NODE=1`)执行
84
+ `dsh plugin --profile desktop add <pkg>@<ver>`——这是桌面版唯一允许写 desktop profile 的入口。
85
+ 2. **安装源**:换版命令默认先走 `registry.npmmirror.com`(与版本查询同一个源),失败再退回
86
+ pnpm 默认源,避免 `registry.npmjs.org` 在国内链路上长时间 `CONNECT_TIMEOUT`。
87
+ 3. **重启**:桌面 App 由 `electron-updater` 自己升级,插件不再检查/暂存 DSH 本体;插件换版后
88
+ 只标记“重启后生效”,不会从进程内部 relaunch(那会连带杀掉正在跑的会话)。
89
+
90
+ 因此桌面版的工作方式是:**静默把插件换版到磁盘 → 面板提示重启 → 你下次打开 App 时生效**。
91
+
92
+ ### Windows 支持(0.6.2+)
93
+
94
+ 0.6.2 起 Windows 桌面版与 macOS 使用同一条换版路径,并修掉了 0.6.1 里的几处 Unix-only 假设:
95
+
96
+ - **空闲判定**不再调用 `find -newermt`(Windows 的 `find.exe` 是文本搜索,不支持这些参数,会让插件永远认为“忙”),改为纯 Node 递归扫描 `%DSH_HOME%\sessions`。目录不存在 = 没有会话 = 空闲;只有根目录不可读才保守当忙。
97
+ - **`dsh.cmd` 调用**:PATH / sibling 找到的 `.cmd` 经 `cmd.exe` 执行并正确加引号;超时用 `taskkill /T /F` 结束整棵进程树,避免留下 pnpm 子进程。
98
+ - **环境变量**:没有 `HOME` 时用 `homedir()`(Windows 的 `USERPROFILE`);PATH 兼容 `Path` 键,并额外查找 `%APPDATA%\npm` / `%LOCALAPPDATA%\pnpm`。
99
+ - Windows 上不执行 DSH 本体更新(交给安装器/App 自带更新器),插件换版后同样只提示重启。
100
+
101
+ 开发机是 macOS;Windows 分支是按 Node/Electron 的跨平台语义改的,并做了逻辑测试,但**尚未在真实 Windows 机器上端到端跑过**。如果 Windows 上仍失败,请把面板日志和 `%USERPROFILE%\.dsh\autoupdater\history.jsonl` 发到 GitHub issue。
102
+
103
+ ## 维护说明
104
+
105
+ - 上游项目:[niubi6668889595/dsh-autoupdater](https://github.com/niubi6668889595/dsh-autoupdater)(MIT)。
106
+ - 本分支新增:桌面版 CLI 换版、npmmirror 优先、跳过 `link:`/git 来源、只有真正升级成功才算“已应用”、Windows 空闲判定/.cmd 调用适配;完整差异见 git log。
107
+ - 发布渠道:npm `@fffangchenghao/dsh-autoupdater`,GitHub `fangchenghao/dsh-autoupdater`,tag `v0.6.2`。
108
+ - 与上游同步:把上游加为 remote,rebase/merge 到本分支后重新发版即可。
109
+
110
+ ## 安全模型
111
+
112
+ - 默认 `harnessMode: notify`——只暂存并通知,重启动作需你显式改为 auto
113
+ - 空闲门槛不可绕过:面板不提供"忽略空闲"开关
114
+ - 更新前后均有磁盘版本校验,结果写入 `~/.dsh/autoupdater/history.jsonl`
115
+ - API 仅写本地 `config.json`,不触碰系统配置
116
+
117
+ ## 已知限制
118
+
119
+ - 插件更新只覆盖声明 `dsh.bundle` 的 npm 形态插件;GitHub 源码安装的插件版本比对不适用
120
+ - 本体更新依赖 npm 全局安装形态(git checkout 形态请用 [dsh-updater](https://github.com/GodCC6/dsh-updater);桌面 App 不参与本体更新,由 App 自带更新器负责)
121
+ - 空闲判定基于本机 `$DSH_HOME/sessions` 的文件 mtime,远程会话(SSH 场景)不计入
122
+ - Windows 原生环境尚未实机验证(0.6.2 已移除 Unix-only 依赖,仍建议先看面板日志确认 desktop CLI 路径)
123
+
124
+ ## 致谢
125
+
126
+ 设计借鉴了社区同类项目:[dsh-self-upgrade](https://github.com/raomaiping-hash/dsh-self-upgrade)(会话日志 mtime 空闲判定、防变砖校验)、[dsh-updater](https://github.com/GodCC6/dsh-updater)(保守失败语义)、[dsh-auto-update](https://github.com/a1113622001/dsh-auto-update)(stage + 退出后应用架构)。
127
+
128
+ ## License
129
+
130
+ MIT
131
+
132
+ ---
133
+
134
+ <a name="english"></a>
135
+ # dsh-autoupdater (English)
136
+
137
+ **Auto-update plugin for DeepSeek Harness (DSH)** — scheduled update checks for DSH itself and installed plugins, applied only in idle windows.
138
+
139
+ - **Four schedule modes**: daily HH:MM / every N hours (aligned to clock) / every N days / every N minutes
140
+ - **Idle gating**: session-log mtime quiet for 2 minutes + no running agents; scheduled runs additionally require sustained quiet with double sampling
141
+ - **Bulk plugin updates**: per-plugin npm version comparison with explicit `name@version` install and post-install disk verification (no phantom updates)
142
+ - **Bidirectional whitelist**: whitelist-notified-others-auto, or the reverse
143
+ - **Harness update strategies**: off / stage+notify (default) / notify then auto-restart after a grace period where any session activity defers it
144
+ - **Web settings panel** embedded in the plugin manager; hot-reload config — changes apply without restart
145
+ - **Activity log**: every check, update, and restart recorded and viewable in the panel
146
+ - **Brick protection**: verifies the dsh binary exists after npm install before restarting
147
+
148
+ Install: `dsh plugin --profile web add @fffangchenghao/dsh-autoupdater` (or `github:fangchenghao/dsh-autoupdater`), then restart DSH and configure under **Plugin Manager → dsh-autoupdater**.
149
+
150
+ **Windows (0.6.2+)**: the desktop path is shared with macOS. 0.6.2 removes the Unix `find` idle check, invokes `dsh.cmd` through `cmd.exe`, falls back to `homedir()` when `HOME` is absent, and leaves DSH core updates to the installer/app. Windows has not been tested end-to-end on a real machine yet; logs are welcome in GitHub issues.
151
+
152
+ See the Chinese sections above for full details — the configuration table and idle-detection notes apply to both languages.
@@ -0,0 +1,235 @@
1
+ // DSH 本体更新执行器(v0.5):快照 → 服务可用性预检 → 安装 → 健康检查 → 失败自动回滚。
2
+ //
3
+ // 由 main.js 通过 `systemd-run --unit=dsh-autoupdater-apply` 启动,
4
+ // 因此不在 dsh-web.service 的 cgroup 里 —— 重启 dsh-web 不会杀掉本进程。
5
+ //
6
+ // 环境变量:
7
+ // AUTOUPDATE_STATE_DIR 状态目录(含 pending-update.json)
8
+ // AUTOUPDATE_SYSTEMD_UNIT 要重启的 systemd 服务名(可空)
9
+ // AUTOUPDATE_ARGV 原启动 argv(空格分隔,systemd 不可用时的裸拉起兜底)
10
+
11
+ import { readFileSync, existsSync, rmSync, appendFileSync, mkdirSync, cpSync, writeFileSync } from 'node:fs';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ const stateDir = process.env.AUTOUPDATE_STATE_DIR || '';
16
+ const pendingPath = join(stateDir, 'pending-update.json');
17
+ const logPath = join(stateDir, 'apply.log');
18
+ const historyPath = join(stateDir, 'history.jsonl');
19
+
20
+ function alog(msg) {
21
+ try {
22
+ mkdirSync(stateDir, { recursive: true });
23
+ appendFileSync(logPath, `${new Date().toISOString()} ${msg}\n`);
24
+ } catch {}
25
+ }
26
+ function ahistory(obj) {
27
+ try { appendFileSync(historyPath, JSON.stringify({ at: new Date().toISOString(), ...obj }) + '\n'); } catch {}
28
+ }
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
31
+ alog(`apply-update started (pid=${process.pid}, stateDir=${stateDir})`);
32
+
33
+ if (!stateDir) { alog('AUTOUPDATE_STATE_DIR missing'); process.exit(1); }
34
+ if (!existsSync(pendingPath)) { alog('no pending-update.json'); process.exit(0); }
35
+
36
+ const instruction = JSON.parse(readFileSync(pendingPath, 'utf8'));
37
+ const unit = instruction.systemdUnit || process.env.AUTOUPDATE_SYSTEMD_UNIT || '';
38
+ const nodeBin = instruction.nodeBin || process.execPath;
39
+ const prefix = dirname(nodeBin);
40
+ const dshBin = join(prefix, 'dsh');
41
+ const npmBin = join(prefix, 'npm');
42
+ const globalRoot = `${prefix}/../lib/node_modules/@deepseek-ai/dsh`;
43
+ const profileDir = instruction.profileDir || process.env.DSH_PROFILE_DIR || '';
44
+ const port = Number(instruction.port) || 3080;
45
+ const healthSeconds = Number(instruction.healthCheckSeconds) || 90;
46
+ const autoRollback = instruction.autoRollback !== false;
47
+
48
+ alog(`target=${instruction.pkg}@${instruction.tag} from=${instruction.from} unit=${unit || 'none'} port=${port} rollback=${autoRollback}`);
49
+
50
+ // ───────── 1. 快照(回滚用)─────────
51
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
52
+ const snapDir = join(stateDir, 'snapshots', stamp);
53
+ const snap = { dshVersion: null, profileFiles: [] };
54
+ try {
55
+ mkdirSync(snapDir, { recursive: true });
56
+ try { snap.dshVersion = JSON.parse(readFileSync(`${globalRoot}/package.json`, 'utf8')).version; } catch {}
57
+ if (profileDir) {
58
+ for (const f of ['package.json', 'cordis.patch.yml']) {
59
+ const src = join(profileDir, f);
60
+ if (existsSync(src)) {
61
+ cpSync(src, join(snapDir, f));
62
+ snap.profileFiles.push(f);
63
+ }
64
+ }
65
+ }
66
+ writeFileSync(join(snapDir, 'snapshot.json'), JSON.stringify(snap, null, 2));
67
+ alog(`snapshot saved: ${snapDir} (dsh=${snap.dshVersion}, files=${snap.profileFiles.join(',') || 'none'})`);
68
+ } catch (e) {
69
+ alog(`snapshot failed: ${e.message} — 继续但不保证可回滚`);
70
+ }
71
+
72
+ // ───────── 2. 安装(服务仍在运行,只替换磁盘)─────────
73
+ function runNpmInstall(targetSpec) {
74
+ try {
75
+ const r = spawnSync(npmBin, ['install', '-g', targetSpec], { encoding: 'utf8', timeout: 900000 });
76
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
77
+ alog(`npm install ${targetSpec} exit=${r.status}\n${out.slice(-2500)}`);
78
+ return r.status === 0;
79
+ } catch (e) {
80
+ alog(`npm install threw: ${e.message}`);
81
+ return false;
82
+ }
83
+ }
84
+
85
+ const installOk = runNpmInstall(`${instruction.pkg}@${instruction.tag}`);
86
+
87
+ function readDiskVersion() {
88
+ try { return JSON.parse(readFileSync(`${globalRoot}/package.json`, 'utf8')).version; } catch { return null; }
89
+ }
90
+ const newVer = readDiskVersion();
91
+ alog(`disk version after install: ${newVer}`);
92
+
93
+ // ───────── 3. 防变砖 + 服务可用性预检(B)─────────
94
+ if (!existsSync(dshBin)) {
95
+ alog(`FATAL: dsh binary missing at ${dshBin} — NOT restarting`);
96
+ ahistory({ kind: 'harness-aborted', reason: 'dsh binary missing', applied: newVer });
97
+ try { rmSync(pendingPath); } catch {}
98
+ process.exit(1);
99
+ }
100
+
101
+ function collectRequiredServices() {
102
+ try {
103
+ const p = join(profileDir, 'cordis.patch.yml');
104
+ if (!existsSync(p)) return [];
105
+ const text = readFileSync(p, 'utf8');
106
+ const set = new Set();
107
+ for (const m of text.matchAll(/^[^\n#]*!!js\s+[^\n]*?ctx\.([A-Za-z_$][\w$]*)/gm)) set.add(m[1]);
108
+ return [...set];
109
+ } catch { return []; }
110
+ }
111
+
112
+ // 在新装版本自己的依赖树里找该服务是否仍被提供
113
+ // (只扫 dsh 包自带的 node_modules,避免全局残留的旧包造成假阳性)
114
+ function serviceExistsInInstall(name) {
115
+ try {
116
+ const nm = `${globalRoot}/node_modules/@deepseek-ai`;
117
+ const r = spawnSync('grep', ['-rl', '--include=*.js', name, nm], { encoding: 'utf8' });
118
+ return (r.stdout || '').trim() !== '';
119
+ } catch { return true; }
120
+ }
121
+
122
+ if (profileDir && newVer && autoRollback) {
123
+ const required = collectRequiredServices();
124
+ if (required.length) {
125
+ const missing = required.filter((s) => !serviceExistsInInstall(s));
126
+ alog(`service precheck: required=${required.join(',')} missing=${missing.join(',') || 'none'}`);
127
+ if (missing.length) {
128
+ alog(`PRECHECK FAILED: 新版本 ${newVer} 里找不到服务 ${missing.join(',')}(profile 配置引用了它们)`);
129
+ ahistory({ kind: 'precheck-failed', to: newVer, missing });
130
+ if (snap.dshVersion) {
131
+ alog(`rolling back install to ${snap.dshVersion} (service untouched)`);
132
+ runNpmInstall(`${instruction.pkg}@${snap.dshVersion}`);
133
+ alog(`disk version after rollback: ${readDiskVersion()}`);
134
+ }
135
+ try { rmSync(pendingPath); } catch {}
136
+ process.exit(1);
137
+ }
138
+ }
139
+ }
140
+
141
+ if (!installOk) {
142
+ // 安装失败:绝不重启(重启只会白白打断会话,版本也没变)
143
+ alog(`install failed (exit != 0) — NOT restarting; disk stays at ${newVer}`);
144
+ ahistory({ kind: 'harness-aborted', reason: 'npm install failed', applied: newVer });
145
+ try { rmSync(pendingPath); } catch {}
146
+ process.exit(1);
147
+ }
148
+
149
+ ahistory({ kind: 'harness-installed', from: instruction.from, to: instruction.tag, applied: newVer });
150
+
151
+ // ───────── 4. 重启 ─────────
152
+ async function restartService() {
153
+ if (unit) {
154
+ try {
155
+ const r = spawn('sudo', ['-n', 'systemctl', 'restart', unit], { stdio: 'ignore' });
156
+ const code = await new Promise((res) => {
157
+ const t = setTimeout(() => res(-1), 120000);
158
+ r.on('close', (c) => { clearTimeout(t); res(c); });
159
+ r.on('error', () => { clearTimeout(t); res(-1); });
160
+ });
161
+ alog(`systemctl restart ${unit} exit=${code}`);
162
+ return code === 0;
163
+ } catch (e) { alog(`systemctl restart threw: ${e.message}`); return false; }
164
+ }
165
+ const argv = (process.env.AUTOUPDATE_ARGV || '').trim().split(/\s+/).filter(Boolean);
166
+ if (argv.length >= 2 && existsSync(argv[1])) {
167
+ try {
168
+ const child = spawn(argv[1], argv.slice(2), { detached: true, stdio: 'ignore' });
169
+ child.unref();
170
+ alog(`fallback relaunch pid=${child.pid}`);
171
+ return true;
172
+ } catch (e) { alog(`fallback relaunch failed: ${e.message}`); }
173
+ }
174
+ return false;
175
+ }
176
+
177
+ // ───────── 5. 健康检查(A)─────────
178
+ async function healthCheck(seconds) {
179
+ const deadline = Date.now() + seconds * 1000;
180
+ let lastErr = '';
181
+ while (Date.now() < deadline) {
182
+ await sleep(3000);
183
+ if (unit) {
184
+ const r = spawnSync('systemctl', ['is-active', unit], { encoding: 'utf8' });
185
+ const st = (r.stdout || '').trim();
186
+ if (st !== 'active') { lastErr = `unit ${st}`; continue; }
187
+ }
188
+ try {
189
+ const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(5000) });
190
+ alog(`health: HTTP ${res.status}`);
191
+ return { ok: true, status: res.status };
192
+ } catch (e) {
193
+ lastErr = e?.cause?.code || e?.message || 'fetch failed';
194
+ }
195
+ }
196
+ alog(`health check FAILED after ${seconds}s (last: ${lastErr})`);
197
+ return { ok: false, reason: lastErr };
198
+ }
199
+
200
+ const restarted = await restartService();
201
+ if (!restarted) {
202
+ alog('restart failed; leaving service as-is');
203
+ ahistory({ kind: 'harness-restart-failed', to: newVer });
204
+ try { rmSync(pendingPath); } catch {}
205
+ process.exit(1);
206
+ }
207
+
208
+ const health = await healthCheck(healthSeconds);
209
+
210
+ // ───────── 6. 失败回滚(A)─────────
211
+ if (!health.ok && autoRollback && snap.dshVersion) {
212
+ alog(`rollback: 回滚到 ${snap.dshVersion} 并恢复 profile 文件`);
213
+ ahistory({ kind: 'harness-rollback-start', from: newVer, to: snap.dshVersion, reason: health.reason });
214
+ runNpmInstall(`${instruction.pkg}@${snap.dshVersion}`);
215
+ for (const f of snap.profileFiles) {
216
+ try { cpSync(join(snapDir, f), join(profileDir, f)); alog(`restored ${f}`); } catch (e) { alog(`restore ${f} failed: ${e.message}`); }
217
+ }
218
+ const verAfter = readDiskVersion();
219
+ alog(`disk version after rollback: ${verAfter}`);
220
+ await restartService();
221
+ const h2 = await healthCheck(Math.min(healthSeconds, 60));
222
+ alog(`post-rollback health: ${h2.ok ? 'OK' : 'STILL BAD (' + h2.reason + ')'}`);
223
+ ahistory({
224
+ kind: 'harness-rolled-back',
225
+ to: verAfter, recovered: h2.ok, reason: health.reason,
226
+ snapshot: snapDir,
227
+ });
228
+ try { rmSync(pendingPath); } catch {}
229
+ process.exit(h2.ok ? 2 : 3); // 2 = 已回滚并恢复
230
+ }
231
+
232
+ ahistory({ kind: 'harness-applied', from: instruction.from, to: instruction.tag, applied: newVer, healthy: health.ok });
233
+ try { rmSync(pendingPath); } catch {}
234
+ alog(`done (installed=${newVer}, health=${health.ok})`);
235
+ process.exit(health.ok ? 0 : 1);