dsh-crossterm 1.0.3 → 1.0.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.
- package/CHANGELOG.md +42 -0
- package/MAINTAINERS.md +27 -0
- package/README.md +11 -1
- package/components/device/manifest.json +1 -1
- package/components/restart/manifest.json +1 -1
- package/components/restart/scripts/countdown.html +75 -49
- package/components/restart/scripts/trigger-restart.mjs +71 -17
- package/components/session/manifest.json +1 -1
- package/components/terminal/manifest.json +1 -1
- package/components/terminal/selftest.mjs +103 -11
- package/components/ui/lib/web.js +12 -4
- package/functions/cloud-ops/README.md +12 -1
- package/functions/cloud-ops/cloud/fanout.js +658 -0
- package/functions/cloud-ops/cloud/relay.js +120 -31
- package/lib/bus.js +9 -6
- package/lib/index.js +320 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
## 1.0.5 — 2026-10-21
|
|
2
|
+
### 修复:插件卡片「简介 / 更新日志 / 运行日志」消失
|
|
3
|
+
|
|
4
|
+
- 现象(机主 2026-10-21 报):插件面板卡片上的插件简介没了,「更新日志」「运行日志」两个按钮也没了。
|
|
5
|
+
- 根因:1.0.3 目录重组把 `lib/web.js` 搬到 `components/ui/lib/web.js`,但 `ROOT` 仍按「上一级」算 ⇒ 只到 `components/ui`;`aboutOf()` 于是读不到 `locale/zh.json` 与 `components/*/manifest.json`,接口 `/crossterm/api/about` 返回 `ok:true` 但 `description`/`changelog` 为空、组件版本全空。卡片渲染条件是 `about && about.changelog`,changelog 空 ⇒ 按钮整块不渲染。
|
|
6
|
+
- 修复:`ROOT` 改成往上三级(真正回到包根);`/crossterm/api/logs` 本身正常(实测 `ok:true`、120 行),不需要改。
|
|
7
|
+
- 附带修复:`components/terminal/manifest.json` 曾带 UTF-8 BOM(PowerShell 5.1 写回所致),`JSON.parse` 直接抛 ⇒ 组件版本读不到;已去 BOM,并在 `aboutOf()` 里增加 `readJsonFile()` 容忍 BOM。
|
|
8
|
+
- 生效条件:`components/**` 由宿主启动时加载,**必须重启 DSH 才生效**(机主定:随 1.0.5 一起重启)。
|
|
9
|
+
- 回归网:新增 `test/about.test.mjs`,直接问 `aboutOf()` 要「简介 / 更新日志 / 五组件版本」,ROOT 再算错或 manifest 再带 BOM 就会红(此前没有任何测试覆盖这条,1.0.3 才漏出去)。
|
|
10
|
+
|
|
11
|
+
### 变更:磁盘邮件头按机主口径调整(跨工作区 0.2.4)
|
|
12
|
+
|
|
13
|
+
- 机主 2026-10-21 直接下令:去掉「发件终端」行;「发件会话」改名「发件人」;其下新增「收件人」行。
|
|
14
|
+
- 落点:`lib/bus.js` `writeMail()` 头部顺序改为「主题 → 特征码(仅链)→ 发件人 → 收件人 → 发件工作区 → 邮件 id → 发出时间 → 附件」;`lib/index.js` `send()→enqueueTo()` 的信封 `to{}` 新增 `sessionLabel`(优先「名字(短号)」,撞车用 idTag,解析不出退回 `labelOf`)。
|
|
15
|
+
- 「发件工作区」保留(机主只要求去掉终端)。
|
|
16
|
+
- 已知限制:接收侧若发件方是旧版本(信封无 `sessionLabel`),发件人一行退化为裸 `sessionId`;跨机老版本兼容不做额外处理。
|
|
17
|
+
- 越界备案:`lib/bus.js` 属跨会话/主负责人范围,本次为机主直接令就地改,已记 `ownership.log` 追认,不回滚。
|
|
18
|
+
|
|
19
|
+
### 变更:M2 接线落地(mapFanoutResults 进仓库)
|
|
20
|
+
|
|
21
|
+
- `functions/cloud-ops/cloud/fanout.js` 现含 `mapFanoutResults`(M2 结果映射),落盘基线 31,692 B / `302e5c75`;相对云端候选另补两项:`recipients`(取 `plan.counts.input`)、合并组目标设备版本 <1.0.2 时 `warnings` 记 `v1-degrade`。
|
|
22
|
+
- 形状定稿 v3:`docs/sendmany-result-shape.md`(4,341 B)——行按目标数展开、`summary` 只数成行目标、`expectedReceipts` 单独记信封数、`fallback` 为 `{device,sessionId?,sessionLabel?,reason,dropped[]}`;`plan.failed/skipped` 不并入 `summary`,仍需消费方并列展示。
|
|
23
|
+
- 回归网:`test/map-fanout.test.mjs` 20 组(含 recipients / v1-degrade 新增用例)。
|
|
24
|
+
### 变更:跨设备信封 silent 附加字段(跨设备 0.4.4 内)
|
|
25
|
+
|
|
26
|
+
- `functions/cloud-ops/cloud/relay.js` `sendEnvelope` 支持 `silent`:**仅 true 时**写 `envelope.silent = true`;未签名附加字段,v1/v2 canonical 串与验签结果逐字节不变;`parseEnvelope` 透传给 `deliver`,由接收端决定是否自动注入。
|
|
27
|
+
- 多目标 + `silent` **本轮不放开**:`sendManyCloud` fail-closed 拒绝(detail:`silent 只支持本机/单目标`),语义等 1.0.6 再议;`docs/sendmany-result-shape.md` 已记「本轮实现约束」。
|
|
28
|
+
|
|
29
|
+
### 变更:M2 一发多投接线(`sendManyCloud` / `term_send`)
|
|
30
|
+
|
|
31
|
+
- `lib/index.js` `send()` 接受 `targets` / `toStrict`;解析出多于 1 个目标即走 `sendManyCloud()`:`planFanout`(本终端目标直接拒、带 version)→ 逐组 `groupSendArgs` + `home.send` → `mapFanoutResults` → `{...out, via:'cloud'}`。
|
|
32
|
+
- `term_send` 增加 `targets`(数组)与 `toStrict`,透传 `send()`;**`to` 与 `targets` 同给时拒绝**(`to 与 targets 只能给一个`)。
|
|
33
|
+
- 单目标老路径零改动(含 `to="self"` + `sessionId`)。
|
|
34
|
+
|
|
35
|
+
### 修复:默认会话失效时不再乱投(跨会话 0.5.7)
|
|
36
|
+
|
|
37
|
+
- `pickDeliverSession` 第 2 级 `defaultSessionId` 指向**未打开**的会话时,明确返回失败原因(`默认会话『X』当前没打开…`)并按留件重试,不再静默落到第 3/4 级猜测。
|
|
38
|
+
|
|
39
|
+
### 修复:倒计时窗口在检查点卡住、进度跳 0(自动重启 0.3.46)
|
|
40
|
+
|
|
41
|
+
- `components/restart/scripts/countdown.html` 的检查点改为异步探测 `busy-check`,删除 2 秒硬截断,零点等结论再关窗;新增 `test/countdown.test.mjs`(假时钟两幕)。
|
|
42
|
+
|
|
1
43
|
## 1.0.3 — 2026-10-21
|
|
2
44
|
### 重构:目录按组件/功能分文件夹(机主指令)
|
|
3
45
|
|
package/MAINTAINERS.md
CHANGED
|
@@ -147,6 +147,33 @@
|
|
|
147
147
|
4. 发版顺序固定:组件负责人改 → 主负责人预检(`node --check` + 三套测试 + `pnpm pack` 检查残留)→ 主负责人发布 → 次负责人验收 → 结论回写 `STATE`。
|
|
148
148
|
5. 只有主负责人能改 `package.json` / `lib/bus.js` 的 `VERSION` / `CHANGELOG.md` / `README.md` 的版本段 / `MAINTAINERS.md`。
|
|
149
149
|
|
|
150
|
+
## 任务下发(2026-10-21 机主指令:「任务不一定要全部自己完成,可以按匹配度下发」)
|
|
151
|
+
|
|
152
|
+
主负责人是**派活的人**,不是唯一的干活的人。凡是能落到某个组件/功能负责人职责范围内的事,一律下发,不自己代做。
|
|
153
|
+
|
|
154
|
+
| 任务类型 | 匹配给 | 说明 |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `components/restart/**` 的改动与自测 | `357e0561` | 含 helper / busy-check / countdown / rearm / trigger / core |
|
|
157
|
+
| `components/device/**`、信封协议、版本门控 | `61b1ec6c` | 协议代码在 `functions/cloud-ops/cloud/{relay,presence}.js`,他提 diff |
|
|
158
|
+
| `components/session/**`、投递路由与收件箱语义 | `d4984f54` | `lib/index.js` 的投递调用点也归他(定点改、落地即报) |
|
|
159
|
+
| `components/terminal/**`、presence 与 `term_list` 展示 | `b71bbc49` | 显示层与解析层都在他范围 |
|
|
160
|
+
| `functions/cloud-ops/**`、网盘与备份链路 | `eb661573` | 该目录唯一落盘人 |
|
|
161
|
+
| 发版后的第二设备验收 | 副手 `yoce-c-4fb38e` | 阻断/建议两栏,不自行发版 |
|
|
162
|
+
| 本机装包后的实装自检、跨机对比 | 兜底 `71734ef7` | 只读为主,不参与写代码与发版 |
|
|
163
|
+
| 打包、版本号、CHANGELOG 定稿、`MAINTAINERS.md`、跨组件取舍、对外代表 | **主负责人保留** | 这些不下发 |
|
|
164
|
+
|
|
165
|
+
**下发格式**(每封必含五段,缺一段就会被退回来问):
|
|
166
|
+
|
|
167
|
+
1. **目标**:要解决什么现象 / 达成什么。
|
|
168
|
+
2. **范围**:允许改哪些文件(含「不许碰」清单)。
|
|
169
|
+
3. **验收**:跑哪几个测试、看哪个字段、期望值是什么。
|
|
170
|
+
4. **约束**:版本号归属、冻结窗口、锚点写法(函数名 + 唯一片段,不用行号)、落地即报。
|
|
171
|
+
5. **回报**:交什么(diff / 自测输出 / 新 sha256 + mtime / 待我拍的点),以及**不需要我回信时直接做**。
|
|
172
|
+
|
|
173
|
+
**并行纪律**:同一文件同一时间只有一个写者;`lib/index.js` 按「跨工作区 → 跨设备 → 跨会话」排队;跨组件依赖先报主负责人裁决,不许自行扩大范围。
|
|
174
|
+
|
|
175
|
+
**留痕**:每封任务下发与完成各记一行进 `STATE-0.9.13.md`(下发人、负责人、目标、验收、状态)。
|
|
176
|
+
|
|
150
177
|
## 兜底会话(2026-10-20 机主指令:「任命兜底会话为互通插件组的兜底工作」)
|
|
151
178
|
|
|
152
179
|
`兜底会话` = `session-71734ef7-e18b-496e-a621-054b79b3afa1`(短号 `71734ef7`,TS-01)。它的定位是**尽量不依赖插件**:宿主原生的 `pwsh` / `read` / `write` / `glob` / `grep` 能做的就不走插件,插件整体失效时仍要能值守。
|
package/README.md
CHANGED
|
@@ -32,7 +32,17 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
32
32
|
>
|
|
33
33
|
> 重启脚本的调用位置:只从部署副本 `~/.dsh/crossterm/restart-module/` 调(`trigger-restart.mjs` / `busy-check.mjs` / `rearm-watch.mjs` / `dsh-restart-helper.mjs`)。直接跑 `node_modules` 里的副本时,那个目录下没有 `logs/busy-agents.json`,倒计时检查点会判 `unknown` 并取消本次重启 —— 这是预期的 fail-closed,不是缺陷。
|
|
34
34
|
|
|
35
|
-
**本版更新(1.0.
|
|
35
|
+
**本版更新(1.0.5)**
|
|
36
|
+
|
|
37
|
+
- **修复:插件卡片「简介 / 更新日志 / 运行日志」消失** —— 1.0.3 目录重组把 `lib/web.js` 搬到 `components/ui/lib/web.js` 后,`ROOT` 只算到 `components/ui`,`aboutOf()` 读不到 `locale/zh.json` 与组件 manifest,接口 `ok:true` 但简介与更新日志为空,卡片整块不渲染。`ROOT` 已回到包根;另加 `readJsonFile()` 容忍 BOM(`components/terminal/manifest.json` 曾带 BOM)。新增 `test/about.test.mjs` 覆盖。
|
|
38
|
+
- **变更:磁盘邮件头按机主口径** —— 去掉「发件终端」,`发件会话` 改「发件人」,其下新增「收件人」行,「发件工作区」保留。
|
|
39
|
+
- **跨设备:信封 `silent` 附加字段** —— `sendEnvelope` 仅在 `true` 时写 `envelope.silent`,不参与签名;收端透传给 `deliver`。多目标 + `silent` 本轮仍 fail-closed 拒绝。
|
|
40
|
+
- **M2 一发多投接线** —— `send()` 接受 `targets` / `toStrict`,多于 1 个目标走 `sendManyCloud()`(`planFanout` → 逐组发送 → `mapFanoutResults`);`term_send` 增 `targets` / `toStrict`;形状按 `docs/sendmany-result-shape.md` v3(`recipients` 数目标、`expectedReceipts` 数信封)。
|
|
41
|
+
- **跨会话 0.5.7:默认会话失效 fail-closed** —— `pickDeliverSession` 第 2 级遇到 `defaultSessionId` 指向未打开的会话时明确报错并留件重试,不猜会话。
|
|
42
|
+
- **自动重启 0.3.46:倒计时不再「卡住跳 0」** —— 检查点异步探测 `busy-check`、删 2 秒硬截断、零点等结论;新增 `test/countdown.test.mjs`。
|
|
43
|
+
- 组件版本:跨会话 **0.5.7**、跨工作区 **0.2.4**、跨设备 **0.4.4**、自动重启 **0.3.46**、入口 **0.9.3**;`test/` 下 **15 个** `*.test.mjs`,本版 **15/15 全绿**。
|
|
44
|
+
|
|
45
|
+
**上一版(1.0.3)**
|
|
36
46
|
|
|
37
47
|
- **修复:换台机器就重启不了(第二设备验收 B1)** —— 桌面端的 web 端口是动态的,helper / 倒计时窗 / `rearm-watch` 由 mshta 或 WMI 拉起时拿不到 `DSH_WEB_URL`,端口退回 19387 → `waitUp` 永远判失败 → 把刚拉起来的健康实例杀掉再拉一次,一次重启变两次还报错。现在 `trigger` 把 `resolveWebPort()` 写进 `request.webPort`,helper 用 `applyWebPort(req)` 优先取请求里的端口(日志写 `port=19387 (request)`),`busy-check` / `rearm-watch` 认 `--port` 一路透传。顺带修 taskkill 输出在中文系统上的 GBK 乱码。
|
|
38
48
|
- **重构:目录按组件/功能分文件夹(机主指令)** —— `lib/cloud/**` → `functions/cloud-ops/cloud/**`,`lib/web.js` → `components/ui/lib/web.js`;`lib/` 只剩宿主入口 `index.js` 与共享核心 `bus.js` / `logs.js` / `names.js`。`MAINTAINERS.md` 新增「目录归属」表与硬规矩(新功能一律 `functions/<名字>/`,越界即误改)。
|
|
@@ -300,9 +300,15 @@ function fitWindow() {
|
|
|
300
300
|
// 忙 → 删 request.json 自动取消本次重启、窗口转后台,并拉起 rearm-watch.mjs;
|
|
301
301
|
// 它等连续 5 s 无其他会话活动后,再用 trigger 重新排一次(delay 由 rearm 派生 max(15, quiet+10),
|
|
302
302
|
// 这里不再写死 15,两个检查点必须放得进新窗口)。
|
|
303
|
+
//
|
|
304
|
+
// 0.3.46:探测必须**异步**拉起。旧实现是同步等待(`sh.Run` 第三个参数为 true),而 busy-check
|
|
305
|
+
// 要先攒够 3 s 连续静默(quiet 默认 3 s)才出结论 —— 等于把最后 5 s 整段冻住;回到窗口时已过
|
|
306
|
+
// 零点,屏幕从 5 直接跳到 00:00(机主报的「5 s 卡顿一下直接跳 0」)。现在改成异步拉起
|
|
307
|
+
// + 每拍轮询结果文件,窗口继续走秒;到零点还没结论就停在 00:00 等结论(最多再等 ~10 s)。
|
|
303
308
|
var CP10 = BASE + "\\logs\\busy-10s.json", CP5 = BASE + "\\logs\\busy-5s.json";
|
|
304
309
|
var cp10 = false, cp5 = false;
|
|
305
310
|
var selfId = req.selfSessionId || req.sessionId || "";
|
|
311
|
+
var pend = null; // 正在跑的探测:{ tag, out, at, maxWaitMs }
|
|
306
312
|
|
|
307
313
|
// B1:mshta 不继承宿主环境,端口只能从 request.json 里取;没写就不加参数(内部再回退 env/19387)。
|
|
308
314
|
function portArg() {
|
|
@@ -310,12 +316,45 @@ function fitWindow() {
|
|
|
310
316
|
return (p > 0 && p < 65536) ? ' --port ' + p : '';
|
|
311
317
|
}
|
|
312
318
|
|
|
313
|
-
function
|
|
319
|
+
function probeCmd(maxWaitSec, out) {
|
|
320
|
+
return '"' + (req.nodeExe || "node") + '" "' + BASE + '\\busy-check.mjs" --self "' + selfId +
|
|
321
|
+
'" --max-wait ' + maxWaitSec + ' --out "' + out + '"' + portArg();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function startProbe(tag, maxWaitSec, out) {
|
|
314
325
|
try { if (fso.FileExists(out)) fso.DeleteFile(out, true); } catch (e) {}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
326
|
+
try { sh.Run(probeCmd(maxWaitSec, out), 0, false); }
|
|
327
|
+
catch (e) { dbg("probe " + tag + " spawn err " + e.message); return false; }
|
|
328
|
+
pend = { tag: tag, out: out, at: Date.now(), maxWaitMs: Math.max(0, Number(maxWaitSec) || 0) * 1000 };
|
|
329
|
+
dbg("probe " + tag + " spawned");
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 每拍调一次:出结论返回 { tag, res, waitedMs },还在跑返回 null。
|
|
334
|
+
// 超时(比 busy-check 自己的 max-wait 再多 10 s)当「没结论」,沿用旧口径:不误取消。
|
|
335
|
+
function pollProbe() {
|
|
336
|
+
if (!pend) return null;
|
|
337
|
+
var res = readJson(pend.out);
|
|
338
|
+
if (res) { var p = pend; pend = null; return { tag: p.tag, res: res, waitedMs: Date.now() - p.at }; }
|
|
339
|
+
if (Date.now() - pend.at > pend.maxWaitMs + 10000) {
|
|
340
|
+
var q = pend; pend = null;
|
|
341
|
+
dbg("probe " + q.tag + " timeout");
|
|
342
|
+
return { tag: q.tag, res: null, timeout: true };
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleProbe(got) {
|
|
348
|
+
if (!got) return;
|
|
349
|
+
if (got.tag === "10s") {
|
|
350
|
+
if (got.res && got.res.ok === false && got.res.busy && got.res.busy.length) { toBackground(got.res.busy, "10s"); return; }
|
|
351
|
+
if (got.res) { dbg("cp10 idle -> keep counting"); el("hint").textContent = "10 秒检查:其他会话都空闲,继续重启"; }
|
|
352
|
+
else dbg("cp10 no result");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (got.res && got.res.ok === true) { dbg("cp5 idle -> keep counting (" + got.waitedMs + "ms)"); el("hint").textContent = "其他会话已空闲,继续重启"; return; }
|
|
356
|
+
if (got.res) { toBackground(got.res.busy, "5s"); return; }
|
|
357
|
+
dbg("cp5 no result -> keep counting");
|
|
319
358
|
}
|
|
320
359
|
|
|
321
360
|
function toBackground(busy, tag) {
|
|
@@ -337,22 +376,6 @@ function fitWindow() {
|
|
|
337
376
|
setTimeout(closeWin, 2800);
|
|
338
377
|
}
|
|
339
378
|
|
|
340
|
-
function checkpoint10() {
|
|
341
|
-
var res = probeBusy(0, CP10);
|
|
342
|
-
if (!res) { dbg("cp10 no result"); return; }
|
|
343
|
-
if (res.ok === false && res.busy && res.busy.length) { toBackground(res.busy, "10s"); return; }
|
|
344
|
-
dbg("cp10 idle -> keep counting");
|
|
345
|
-
el("hint").textContent = "10 秒检查:其他会话都空闲,继续重启";
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function checkpoint5() {
|
|
349
|
-
el("hint").textContent = "5 秒检查:等其他会话空闲(最多 15 秒)…";
|
|
350
|
-
var res = probeBusy(15, CP5);
|
|
351
|
-
if (!res) { dbg("cp5 no result"); return; }
|
|
352
|
-
if (res.ok === true) { dbg("cp5 idle -> keep counting"); el("hint").textContent = "其他会话已空闲,继续重启"; return; }
|
|
353
|
-
toBackground(res.busy, "5s");
|
|
354
|
-
}
|
|
355
|
-
|
|
356
379
|
el("now").onclick = function () {
|
|
357
380
|
if (done || spawned) return;
|
|
358
381
|
if (req.dryRun === true) { bail("试运行没有真实重启", "warn", 1600); return; }
|
|
@@ -377,43 +400,46 @@ function fitWindow() {
|
|
|
377
400
|
|
|
378
401
|
if (!fso.FileExists(REQ) && !spawned) { bail("已取消:DSH 不会重启", "warn", 1600); return; }
|
|
379
402
|
|
|
380
|
-
// 检查点只在「还没拉 helper、还在倒计时、且没有 force
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (!cp10 && MAX > 12000 && left <= 10000 && left > 5000) { cp10 = true;
|
|
385
|
-
else if (!cp5 && left <= 5000) { cp5 = true;
|
|
386
|
-
if (done) return;
|
|
403
|
+
// 检查点只在「还没拉 helper、还在倒计时、且没有 force」时开火。
|
|
404
|
+
// 短窗口(delay < 12 s)跳过 10 s 检查点:否则它在倒计时刚开始就开火,和 5 s 检查点挤在一起,
|
|
405
|
+
// 等于白做两次探测、还多两个 busy-check 进程。
|
|
406
|
+
if (!spawned && left > 0 && req.force !== true) {
|
|
407
|
+
if (!cp10 && MAX > 12000 && left <= 10000 && left > 5000) { cp10 = true; startProbe("10s", 0, CP10); }
|
|
408
|
+
else if (!cp5 && left <= 5000) { cp5 = true; startProbe("5s", 15, CP5); }
|
|
387
409
|
}
|
|
410
|
+
if (pend) handleProbe(pollProbe()); // 探测结果每拍收一次,窗口不再冻结
|
|
411
|
+
if (done) return;
|
|
388
412
|
|
|
389
|
-
|
|
413
|
+
// 走秒:最后 2 s 也照常显示(旧版在 left ≤ 2 s 就直接钉 00:00,看起来同样像「跳 0」),到 0 才停住。
|
|
414
|
+
if (left > 0) {
|
|
390
415
|
el("clock").textContent = mmss(left / 1000);
|
|
391
416
|
el("fill").style.width = Math.max(0, Math.min(100, (left / MAX) * 100)) + "%";
|
|
392
|
-
|
|
417
|
+
} else {
|
|
418
|
+
el("clock").textContent = "00:00";
|
|
419
|
+
el("fill").style.width = "0%";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 剩 2 s 拉起 helper(保留原来的提前量);但探测还没出结论就先停在 00:00 等,别抢跑。
|
|
423
|
+
if (left <= 2000 && !spawned) {
|
|
424
|
+
if (pend) { el("hint").textContent = "正在确认其他会话是否空闲…"; return; }
|
|
425
|
+
if (!zeroAt) zeroAt = Date.now();
|
|
426
|
+
spawnHelper();
|
|
393
427
|
}
|
|
394
|
-
if (!spawned) { el("clock").textContent = "00:00"; el("fill").style.width = "0%"; zeroAt = Date.now(); spawnHelper(); }
|
|
395
428
|
|
|
396
429
|
// 归零后先停在 00:00 等 helper 真动手(守卫可能还在等空闲),
|
|
397
430
|
// 只有 helper 进入 killing/starting 才切正计时 + 转圈,避免“倒计时没读完就开始 +”。
|
|
398
|
-
var u = readJson(UI);
|
|
431
|
+
var u = spawned ? readJson(UI) : null;
|
|
399
432
|
var phase = u && u.phase ? String(u.phase) : "";
|
|
400
433
|
var killing = phase === "killing" || phase === "starting";
|
|
401
|
-
if (zeroAt && !done) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
dbg("spinner on");
|
|
411
|
-
}
|
|
412
|
-
} else {
|
|
413
|
-
el("clock").textContent = "00:00";
|
|
414
|
-
if (el("fill").style.display !== "none") {
|
|
415
|
-
el("fill").style.width = "0%";
|
|
416
|
-
}
|
|
434
|
+
if (zeroAt && !done && killing) {
|
|
435
|
+
el("clock").textContent = "+" + mmss((Date.now() - zeroAt) / 1000);
|
|
436
|
+
if (el("fill").style.display !== "none") {
|
|
437
|
+
el("fill").style.display = "none";
|
|
438
|
+
var bar = el("bar");
|
|
439
|
+
if (bar) bar.className = "bar off";
|
|
440
|
+
var sp = el("spin");
|
|
441
|
+
if (sp) sp.className = "spin on";
|
|
442
|
+
dbg("spinner on");
|
|
417
443
|
}
|
|
418
444
|
}
|
|
419
445
|
|
|
@@ -422,7 +448,7 @@ function fitWindow() {
|
|
|
422
448
|
else if (phase === "waiting") el("hint").textContent = "等待其他会话空闲后重启…";
|
|
423
449
|
else if (zeroAt && !killing) el("hint").textContent = "倒计时结束,正在准备重启…";
|
|
424
450
|
|
|
425
|
-
var r = readJson(RES);
|
|
451
|
+
var r = spawned ? readJson(RES) : null;
|
|
426
452
|
var when = r ? Date.parse(r.finishedAt || r.at || "") : NaN;
|
|
427
453
|
if (r && !isNaN(when) && when >= spawnedAt - 2000) {
|
|
428
454
|
if (r.ok === true && r.dryRun === true) { bail("试运行完成:进程未被结束", "warn", 2200); return; }
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 用法:
|
|
6
6
|
// node trigger-restart.mjs --delay 45 [--note "说明"] [--dry-run]
|
|
7
7
|
// node trigger-restart.mjs --cancel
|
|
8
|
+
// node trigger-restart.mjs --print-log-dir (只打印解析到的日志目录,不建目录、不排程)
|
|
8
9
|
//
|
|
9
10
|
// 退出码:0 已排程 / 3 已有 helper 在跑 / 4 request.json 写失败 / 5 WMI 拉起失败 / 6 参数错误
|
|
10
11
|
import fs from 'node:fs';
|
|
@@ -15,9 +16,54 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
16
|
import { waitQuiet, resolveWebPort } from './busy-check.mjs';
|
|
16
17
|
|
|
17
18
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
// 0.3.45:日志目录不再默认落在脚本旁边。从仓库直跑 trigger 时旧写法会把 logs/ 写进待打包树
|
|
21
|
+
// (1.0.0 就因此混进过 45 个文件)。候选顺序 = DSH_RESTART_MODULE_DIR → 当前工作目录(生产环境
|
|
22
|
+
// core.runTrigger 以部署副本为 cwd)→ 脚本所在目录;每个候选都必须不在插件仓库内,全不合格就
|
|
23
|
+
// 整体不写日志、只在 stderr 提示——绝不回落到仓库内路径。
|
|
24
|
+
function pluginRepoRoot(startDir) {
|
|
25
|
+
let cur = path.resolve(startDir);
|
|
26
|
+
for (;;) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cur, 'package.json'), 'utf8'));
|
|
29
|
+
if (pkg && pkg.name === 'dsh-crossterm') return cur;
|
|
30
|
+
} catch { /* 这一层没有 package.json,继续往上 */ }
|
|
31
|
+
const up = path.dirname(cur);
|
|
32
|
+
if (up === cur) return '';
|
|
33
|
+
cur = up;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function insideOrEqual(child, parent) {
|
|
38
|
+
const c = path.resolve(child).toLowerCase();
|
|
39
|
+
const p = path.resolve(parent).toLowerCase();
|
|
40
|
+
return c === p || c.startsWith(p + path.sep);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveLogsDir(rawEnv, cwdDir) {
|
|
44
|
+
const repoRoot = pluginRepoRoot(MODULE_DIR);
|
|
45
|
+
const rejected = [];
|
|
46
|
+
for (const raw of [rawEnv, cwdDir, MODULE_DIR]) {
|
|
47
|
+
const v = String(raw || '').trim();
|
|
48
|
+
if (!v) continue;
|
|
49
|
+
const abs = path.resolve(cwdDir, v);
|
|
50
|
+
if (repoRoot && insideOrEqual(abs, repoRoot)) { rejected.push(`${abs} (在插件仓库内,拒绝)`); continue; }
|
|
51
|
+
return path.join(abs, 'logs');
|
|
52
|
+
}
|
|
53
|
+
console.error(rejected.length
|
|
54
|
+
? `[trigger] 没有合规的日志目录:${rejected.join('; ')};本次不写日志`
|
|
55
|
+
: '[trigger] 没有可用的日志目录(未设 DSH_RESTART_MODULE_DIR);本次不写日志');
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LOGS_DIR = resolveLogsDir(process.env.DSH_RESTART_MODULE_DIR, process.cwd());
|
|
60
|
+
// 排障/单测用:只打印解析结果,不建目录、不排程。
|
|
61
|
+
if (process.argv.includes('--print-log-dir')) {
|
|
62
|
+
console.log(LOGS_DIR || '');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
19
65
|
const REQUEST_FILE = path.join(MODULE_DIR, 'request.json');
|
|
20
|
-
const PID_FILE = path.join(LOGS_DIR, 'helper.pid');
|
|
66
|
+
const PID_FILE = LOGS_DIR ? path.join(LOGS_DIR, 'helper.pid') : '';
|
|
21
67
|
const RUN_CMD = path.join(MODULE_DIR, 'run-helper.cmd');
|
|
22
68
|
const UI_FILE = path.join(MODULE_DIR, 'countdown.html');
|
|
23
69
|
// helper 用真 node 拉起(不是 Electron 宿主),所以这里必须解析出一个真实存在的 node.exe。
|
|
@@ -75,6 +121,7 @@ function alive(pid) {
|
|
|
75
121
|
}
|
|
76
122
|
|
|
77
123
|
function helperStillRunning() {
|
|
124
|
+
if (!PID_FILE) return 0;
|
|
78
125
|
try {
|
|
79
126
|
const pid = Number(fs.readFileSync(PID_FILE, 'utf8').trim());
|
|
80
127
|
if (!pid || !alive(pid)) return 0;
|
|
@@ -84,27 +131,33 @@ function helperStillRunning() {
|
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
const opts = parseArgs(process.argv.slice(2));
|
|
87
|
-
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
134
|
+
if (LOGS_DIR) fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
88
135
|
|
|
89
136
|
if (opts.cancel) {
|
|
90
137
|
// 撤请求 + 杀掉还在等待的 helper(helper 自己也会在 500ms 内发现文件消失并作废,双保险)
|
|
91
138
|
let killed = null;
|
|
92
|
-
|
|
93
|
-
const pid = Number(String(fs.readFileSync(PID_FILE, 'utf8')).trim());
|
|
94
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
95
|
-
try { execFileSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true }); killed = pid; } catch {}
|
|
96
|
-
}
|
|
97
|
-
} catch {}
|
|
98
|
-
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
99
|
-
try { fs.unlinkSync(REQUEST_FILE); } catch {}
|
|
100
|
-
if (killed) {
|
|
139
|
+
if (PID_FILE) {
|
|
101
140
|
try {
|
|
102
|
-
fs.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
141
|
+
const pid = Number(String(fs.readFileSync(PID_FILE, 'utf8')).trim());
|
|
142
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
143
|
+
try { execFileSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true }); killed = pid; } catch {}
|
|
144
|
+
}
|
|
106
145
|
} catch {}
|
|
107
146
|
}
|
|
147
|
+
if (PID_FILE) { try { fs.unlinkSync(PID_FILE); } catch {} }
|
|
148
|
+
try { fs.unlinkSync(REQUEST_FILE); } catch {}
|
|
149
|
+
if (killed) {
|
|
150
|
+
if (!LOGS_DIR) {
|
|
151
|
+
console.error(`[trigger] 已取消 helper ${killed},但日志目录不可用,result.json 未落盘`);
|
|
152
|
+
} else {
|
|
153
|
+
try {
|
|
154
|
+
fs.writeFileSync(path.join(LOGS_DIR, 'result.json'), JSON.stringify({
|
|
155
|
+
ok: false, dryRun: false, reason: 'cancelled', by: 'trigger-cancel',
|
|
156
|
+
at: new Date().toISOString(), helperPid: killed,
|
|
157
|
+
}, null, 2), 'utf8');
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
108
161
|
console.log(killed ? `cancelled: helper ${killed} killed + request.json removed` : 'cancelled: request.json removed');
|
|
109
162
|
process.exit(0);
|
|
110
163
|
}
|
|
@@ -189,6 +242,7 @@ function wmiCreate(commandLine) {
|
|
|
189
242
|
// 倒计时窗口要贴在 DSH 窗口正中间(而不是屏幕正中):先取一次 DSH 主窗口矩形写到 logs\ui-geom.json,
|
|
190
243
|
// 窗口那边读它定位。拿不到就写 {},窗口自动回退成屏幕居中。
|
|
191
244
|
function writeWindowGeom() {
|
|
245
|
+
if (!LOGS_DIR) return {};
|
|
192
246
|
const out = path.join(LOGS_DIR, 'ui-geom.json');
|
|
193
247
|
let geom = {};
|
|
194
248
|
try {
|
|
@@ -277,5 +331,5 @@ if (!spawnPid) { console.error('WMI create 未返回 ProcessId'); process.exit(5
|
|
|
277
331
|
console.log(`WMI create ok; ${via} pid=${spawnPid}`);
|
|
278
332
|
if (via === 'node') console.log('无界面兜底:窗口没起来,已直接拉起 node helper(到点照样杀进程重拉;取消用 dsh_restart action=cancel)');
|
|
279
333
|
console.log(`已排程:${new Date(now + opts.delay * 1000).toISOString()} 杀 DSH 并重拉(dryRun=${opts.dryRun})`);
|
|
280
|
-
console.log(`日志目录:${LOGS_DIR}`);
|
|
334
|
+
console.log(`日志目录:${LOGS_DIR || '(不写日志:没有合规目录)'}`);
|
|
281
335
|
process.exit(0);
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 跨工作区组件自测(terminal
|
|
2
|
+
* 跨工作区组件自测(terminal 版本取自 components/terminal/manifest.json,不写死)
|
|
3
3
|
* 1) term_list / term_whoami 的渲染里必须带 8 位短号(以前有名字就把短号吞掉,
|
|
4
4
|
* 事故里"找会话 id 得去翻 presence 文件"就是这个坑)
|
|
5
5
|
* 2) 同终端投递必须把目标会话钉死(复用 pickDeliverSession):短名、显示名、完整 id
|
|
6
6
|
* 都能投;打错、有歧义、宿主也不认识的一律当场报错并列候选
|
|
7
7
|
* 3) 宿主认识但当前没打开的会话仍放行(留给投递侧 resume)
|
|
8
|
-
* 4) 目标终端心跳超过 3
|
|
8
|
+
* 4) 目标终端心跳超过 3 分钟(3–9 分钟窗口):仍可发、仍回 ok:true,但必须带 offline 示警,
|
|
9
|
+
* 且这档不能被回收——回收期 9 分钟(PRESENCE_CLEANUP_MS)必须显著长于展示阈值,否则离线档看不见
|
|
9
10
|
* 5) term_list 显示 defaultSessionId=<值|未设>、离线节点标「离线:presence 已 N 分钟未更新」
|
|
10
11
|
*
|
|
11
12
|
* 运行:node --import ./test/register.mjs components/terminal/selftest.mjs
|
|
12
13
|
*/
|
|
13
|
-
import realAssert from 'node:assert/strict'
|
|
14
|
+
import realAssert from 'node:assert/strict'
|
|
15
|
+
import fs from 'node:fs'
|
|
14
16
|
import fsp from 'node:fs/promises'
|
|
15
17
|
import os from 'node:os'
|
|
16
18
|
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
const MANIFEST_VERSION = JSON.parse(fs.readFileSync(new URL('./manifest.json', import.meta.url), 'utf8').replace(/^/, '')).version
|
|
17
21
|
|
|
18
22
|
// 运行时统计真正执行过的断言数(以前手写「22 项 / 28 项」跟脚本对不上,被主负责人抓过三次;
|
|
19
23
|
// 现在数字是量出来的:静态 assert.* 调用 + 循环里的多次执行都算)
|
|
@@ -124,6 +128,7 @@ const listText = text(listed)
|
|
|
124
128
|
assert.match(listText, /组件—跨工作区负责人001(acme1111)/, '本终端会话行必须带 8 位短号')
|
|
125
129
|
assert.match(listText, /组件—跨会话负责人001(acme2222)/, '其它会话行也必须带短号')
|
|
126
130
|
assert.match(listText, /★当前/, '调用方自己那条要标 ★当前')
|
|
131
|
+
assert.doesNotMatch(listText, /◆默认投递/, '未设置默认投递时不出现 ◆默认投递')
|
|
127
132
|
|
|
128
133
|
const who = await tool('term_whoami').execute({})
|
|
129
134
|
assert.match(render('term_whoami', who), /(acme2222)/, 'whoami 也带短号')
|
|
@@ -133,11 +138,26 @@ const send = (sessionId, extra = {}) => tool('term_send').execute({ to: 'self',
|
|
|
133
138
|
|
|
134
139
|
const byShort = await send('acme2222')
|
|
135
140
|
assert.equal(byShort.ok, true, byShort.detail)
|
|
141
|
+
// 1.0.4 / P3:回执要带「真正钉住的会话」,名字 + 短号
|
|
142
|
+
assert.match(byShort.detail, /的会话「组件—跨会话负责人001(acme2222)」/, '回执要带钉住的会话名和短号')
|
|
136
143
|
let queue = await pendingOf()
|
|
137
144
|
assert.equal(queue.length, 1)
|
|
138
145
|
assert.equal(queue[0].to.nodeId, me.nodeId)
|
|
139
146
|
assert.equal(queue[0].to.sessionId, ID_B, '短名必须展开成完整会话 id')
|
|
140
147
|
|
|
148
|
+
// ── 2b. 磁盘邮件头(机主 2026-10-21 要求):去「发件终端」,「发件会话」改「发件人」,其下加「收件人」──
|
|
149
|
+
// .md 由 mailView → bus.writeMail 在**投递时**写;这里没有自动投递,就直接拿入队信封喂 writeMail,
|
|
150
|
+
// 既验证我把收件人显示名塞进信封,也验证 mail.js 的渲染顺序。
|
|
151
|
+
const pinnedMail = queue[0]
|
|
152
|
+
assert.equal(pinnedMail.to.sessionLabel, '组件—跨会话负责人001(acme2222)', '收件人显示名要随信封走')
|
|
153
|
+
const shortMail = await fsp.readFile(bus.writeMail(pinnedMail, '自测:短名寻址'), 'utf8')
|
|
154
|
+
assert.ok(!/发件终端/.test(shortMail), '磁盘邮件头不应再有「发件终端」')
|
|
155
|
+
assert.ok(!/- 发件会话:/.test(shortMail), '「发件会话」应改名为「发件人」')
|
|
156
|
+
assert.match(shortMail, /^- 发件人:\S/m, '发件人要有显示名')
|
|
157
|
+
assert.match(shortMail, /^- 收件人:组件—跨会话负责人001(acme2222)$/m, '发件人下面要有收件人,解析成会话名+短号')
|
|
158
|
+
assert.ok(shortMail.indexOf('- 发件人:') < shortMail.indexOf('- 收件人:'), '收件人必须在发件人下面')
|
|
159
|
+
assert.ok(shortMail.indexOf('- 收件人:') < shortMail.indexOf('- 邮件 id:'), '收件人要在邮件 id 之前')
|
|
160
|
+
|
|
141
161
|
const byName = await send('组件—跨会话负责人001')
|
|
142
162
|
assert.equal(byName.ok, true, byName.detail)
|
|
143
163
|
queue = await pendingOf()
|
|
@@ -183,11 +203,20 @@ queue = await pendingOf()
|
|
|
183
203
|
assert.equal(queue.length, 6)
|
|
184
204
|
assert.equal(queue[5].to.sessionId, ID_GHOST, '短名寻址必须换成完整 id 再入队')
|
|
185
205
|
|
|
186
|
-
// ── 4.
|
|
187
|
-
|
|
206
|
+
// ── 4. 回归:发给别的终端不受影响;远端默认投递标记同规则(P2) ──────────
|
|
207
|
+
const ID_REMOTE = 'session-remb7777-0000-4000-8000-000000000007'
|
|
208
|
+
await bus.beat({
|
|
209
|
+
nodeId: 'node-b',
|
|
210
|
+
name: 'selftest-B',
|
|
211
|
+
hostname: os.hostname(),
|
|
212
|
+
sessions: [{ id: ID_REMOTE, name: '远端会话', short: 'remb7777', active: false }],
|
|
213
|
+
defaultSessionId: ID_REMOTE,
|
|
214
|
+
})
|
|
188
215
|
const remote = await tool('term_send').execute({ to: 'selftest-B', text: '跨终端' })
|
|
189
216
|
assert.equal(remote.ok, true, remote.detail)
|
|
190
217
|
assert.equal((await bus.listPending('node-b')).length, 1)
|
|
218
|
+
const remoteMail = await fsp.readFile(bus.writeMail((await bus.listPending('node-b'))[0], '自测:跨终端'), 'utf8')
|
|
219
|
+
assert.match(remoteMail, /^- 收件人:未指定(等兜底路由)$/m, '没指定会话的跨终端邮件也要有收件人行')
|
|
191
220
|
|
|
192
221
|
// 本终端不带 sessionId 仍要明确拒绝(老规矩)
|
|
193
222
|
const noSession = await tool('term_send').execute({ to: 'self', text: '发给当前会话' })
|
|
@@ -212,11 +241,16 @@ assert.equal(reloaded.ok, true, reloaded.detail)
|
|
|
212
241
|
|
|
213
242
|
const whoDefault = await tool('term_whoami').execute({})
|
|
214
243
|
assert.equal(whoDefault.defaultSessionId, ID_A, '配置的是短名,presence 里必须写完整 id')
|
|
244
|
+
const withDefault = render('term_list', await tool('term_list').execute({}))
|
|
215
245
|
assert.match(
|
|
216
|
-
|
|
217
|
-
new RegExp(`defaultSessionId=${ID_A}(组件—跨工作区负责人001
|
|
246
|
+
withDefault,
|
|
247
|
+
new RegExp(`defaultSessionId=${ID_A}(组件—跨工作区负责人001(acme1111))`),
|
|
218
248
|
'term_list 要能看见默认会话是谁',
|
|
219
249
|
)
|
|
250
|
+
// P2:本终端与远端都按同一规则出 ◆默认投递,且和 ★当前 并存
|
|
251
|
+
assert.match(withDefault, /组件—跨工作区负责人001(acme1111) ★当前 ◆默认投递/, '默认投递会话要标 ◆默认投递')
|
|
252
|
+
assert.match(withDefault, /远端会话(remb7777) ◆默认投递/, '远端节点同样规则标 ◆默认投递')
|
|
253
|
+
assert.match(withDefault, new RegExp(`defaultSessionId=${ID_REMOTE}(远端会话(remb7777))`), '远端也要显示默认会话')
|
|
220
254
|
|
|
221
255
|
// ── 6. 离线目标(0.2.2 / 主负责人 15:11 口径):照样发,但必须示警 ──────
|
|
222
256
|
const ID_STALE = 'session-sle33333-0000-4000-8000-000000000003'
|
|
@@ -229,7 +263,7 @@ await fsp.writeFile(
|
|
|
229
263
|
workspace: 'F:\\ws-stale',
|
|
230
264
|
pid: 1,
|
|
231
265
|
version: '0.9.13',
|
|
232
|
-
updatedAt: Date.now() -
|
|
266
|
+
updatedAt: Date.now() - 5 * 60 * 1000, // 5 分钟没心跳 → 落在 3–9 分钟「离线但未回收」窗口
|
|
233
267
|
sessions: [{ id: ID_STALE, name: '离线会话', short: 'sle33333', active: false }],
|
|
234
268
|
}),
|
|
235
269
|
'utf8',
|
|
@@ -237,13 +271,71 @@ await fsp.writeFile(
|
|
|
237
271
|
const offlineSend = await tool('term_send').execute({ to: 'selftest-C', text: '发给离线终端' })
|
|
238
272
|
assert.equal(offlineSend.ok, true, '离线也必须 ok:true(否则云端 relay 会反复退避)')
|
|
239
273
|
assert.equal(offlineSend.offline, true, '必须带 offline 示警')
|
|
240
|
-
assert.ok(
|
|
274
|
+
assert.ok(
|
|
275
|
+
offlineSend.presenceAgeMs >= 3 * 60 * 1000 && offlineSend.presenceAgeMs < 9 * 60 * 1000,
|
|
276
|
+
'要带 presenceAgeMs,且落在 3–9 分钟的「离线但未回收」窗口',
|
|
277
|
+
)
|
|
241
278
|
assert.match(offlineSend.detail, /离线/, '文案要说明目标离线')
|
|
242
279
|
assert.match(offlineSend.detail, /分钟未更新/, '文案要给出多久没心跳')
|
|
243
280
|
assert.match(offlineSend.detail, /收件箱/, '要说明消息留在对方收件箱')
|
|
244
281
|
assert.equal((await bus.listPending('node-stale')).length, 1, '离线消息仍要入队,不许丢')
|
|
282
|
+
// 回收期 9 分钟(PRESENCE_CLEANUP_MS):5 分钟的 stale 节点必须还在。
|
|
283
|
+
// 0.9.x 里回收是 60 秒,节点会从「在线」直接跳到「消失」,3 分钟离线档永远看不见。
|
|
284
|
+
await bus.cleanupStale()
|
|
285
|
+
assert.ok(
|
|
286
|
+
await fsp.stat(path.join(sandbox, 'crossterm', 'presence', 'node-stale.json')).then(() => true, () => false),
|
|
287
|
+
'5 分钟 stale 的 presence 必须还在(cleanupStale 阈值 9 分钟,跨工作区 001 报的老缝隙)',
|
|
288
|
+
)
|
|
245
289
|
|
|
246
290
|
const staleList = render('term_list', await tool('term_list').execute({}))
|
|
247
|
-
|
|
291
|
+
// P2:离线行补「约 N 分钟后从列表回收」(回收 9 分钟、展示 3 分钟,别让发送方以为它一直挂着)
|
|
292
|
+
assert.match(
|
|
293
|
+
staleList,
|
|
294
|
+
/「selftest-C」.*(离线:presence 已 \d+ 分钟未更新,仍可发,消息留它收件箱;约 \d+ 分钟后从列表回收)/,
|
|
295
|
+
'term_list 要显式标离线,并预告回收时间',
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
// ── 7. 短号撞车(1.0.4 / P1·P4)─────────────────────────────────────────
|
|
299
|
+
// dsh-automation-session-<uuid> 短号全塌成 dsh:旧实现 find(short===lower) 静默钉第一个。
|
|
300
|
+
const AUTO = [
|
|
301
|
+
'dsh-automation-session-230a1668-1111-4000-8000-000000000011',
|
|
302
|
+
'dsh-automation-session-31bb2779-2222-4000-8000-000000000012',
|
|
303
|
+
'dsh-automation-session-42cc388a-3333-4000-8000-000000000013',
|
|
304
|
+
'dsh-automation-session-53dd499b-4444-4000-8000-000000000014',
|
|
305
|
+
'dsh-automation-session-64ee5aac-5555-4000-8000-000000000015',
|
|
306
|
+
]
|
|
307
|
+
for (const id of AUTO) agents.set(id, makeAgent(id))
|
|
308
|
+
const tags = ['230a1668', '31bb2779', '42cc388a', '53dd499b', '64ee5aac']
|
|
309
|
+
|
|
310
|
+
const collided = render('term_list', await tool('term_list').execute({}))
|
|
311
|
+
for (const tag of tags) assert.match(collided, new RegExp(`dsh(${tag})`), `撞车的自动化会话必须用 8 位段区分:${tag}`)
|
|
312
|
+
|
|
313
|
+
const ambiguousShort = await send('dsh')
|
|
314
|
+
assert.equal(ambiguousShort.ok, false, '5 个短号都是 dsh 时必须报歧义,不许静默钉第一个')
|
|
315
|
+
assert.match(ambiguousShort.detail, /多个候选/)
|
|
316
|
+
for (const tag of tags) assert.match(ambiguousShort.detail, new RegExp(tag), `候选必须列全,包含 ${tag}`)
|
|
317
|
+
|
|
318
|
+
// 渲染出来的 8 位段必须可直接寻址(否则「看得见、发不进去」)
|
|
319
|
+
const byTag = await send(tags[2])
|
|
320
|
+
assert.equal(byTag.ok, true, byTag.detail)
|
|
321
|
+
assert.equal((await pendingOf()).slice(-1)[0].to.sessionId, AUTO[2], 'idTag 寻址要落到对应会话')
|
|
322
|
+
|
|
323
|
+
// defaultSessionId 撞车时也不许归一(返回原样,让消费方 fail-closed)
|
|
324
|
+
await fsp.writeFile(
|
|
325
|
+
path.join(sandbox, 'crossterm', 'config.json'),
|
|
326
|
+
JSON.stringify({ defaultSessionId: 'dsh' }),
|
|
327
|
+
'utf8',
|
|
328
|
+
)
|
|
329
|
+
await tool('term_config').execute({ inboxDir: '' })
|
|
330
|
+
assert.equal((await tool('term_whoami').execute({})).defaultSessionId, 'dsh', '撞车的默认会话不许归一成第一个')
|
|
331
|
+
|
|
332
|
+
// ── 8. P4:候选超 20 条才截断 ────────────────────────────────────────────
|
|
333
|
+
for (let i = 0; i < 16; i += 1) {
|
|
334
|
+
const tag = (0x70000000 + i).toString(16).padStart(8, '0')
|
|
335
|
+
agents.set(`dsh-automation-session-${tag}-6666-4000-8000-0000000001${String(i).padStart(2, '0')}`, makeAgent(`dsh-automation-session-${tag}-6666-4000-8000-0000000001${String(i).padStart(2, '0')}`))
|
|
336
|
+
}
|
|
337
|
+
const tooMany = await send('zzzz9999')
|
|
338
|
+
assert.equal(tooMany.ok, false, '查无此会话必须失败')
|
|
339
|
+
assert.match(tooMany.detail, /等 \d+ 个(用 term_list 看全量)/, '超过 20 条要截断并提示 term_list')
|
|
248
340
|
|
|
249
|
-
console.log(`terminal
|
|
341
|
+
console.log(`terminal ${MANIFEST_VERSION} 自测通过:${assertionCount} 项断言(短号/idTag 渲染与寻址、撞车报歧义列全候选、默认投递标记、回执带钉住会话、离线示警与回收预告、候选截断、跨终端回归)`)
|