jbc-dssi 1.1.7 → 1.1.9
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/cordis.patch.yml +19 -0
- package/index.js +45 -0
- package/lib/restart-autocheck.js +428 -0
- package/package.json +1 -1
- package/vendor/groups/bridge/engine/round.js +319 -0
- package/vendor/groups/bridge/engine/session.js +33 -0
- package/vendor/groups/bridge/engine/settle.js +122 -0
- package/vendor/groups/bridge/engine.js +40 -406
- package/vendor/groups/bridge/index.js +26 -0
package/cordis.patch.yml
CHANGED
|
@@ -67,3 +67,22 @@
|
|
|
67
67
|
|
|
68
68
|
# 审批链路自测开关。默认关;验证审批时临时打开。
|
|
69
69
|
allowSelfTest: false
|
|
70
|
+
|
|
71
|
+
# ---- 重启接续检查(2026-11-02,见 docs/架构/重启接续检查.md)----
|
|
72
|
+
# 自动重启 dsh web 后 2 分钟核验四项判据,并发一条群消息接续流程;
|
|
73
|
+
# 无本次重启记录(marker 不存在 / 是旧文件)时**静默不触发**。
|
|
74
|
+
# 默认开;`enabled: false` 一键关闭(插件配置在加载期读,下次重启生效)。
|
|
75
|
+
restart:
|
|
76
|
+
autocheck:
|
|
77
|
+
enabled: true
|
|
78
|
+
delayMs: 120000
|
|
79
|
+
marker: /tmp/dsh-restart-result.txt
|
|
80
|
+
cooldownMs: 21600000
|
|
81
|
+
notifyGroup: true
|
|
82
|
+
resumeSession: true
|
|
83
|
+
# 留空 = 自动选“最近活跃且设了主管的群”
|
|
84
|
+
groupId: ''
|
|
85
|
+
# 留空 = 用插件自身版本与仓库 version.json 比对;读不到 version.json 时退回“自身版本非空”
|
|
86
|
+
expectVersion: ''
|
|
87
|
+
# 工作区目录数下限(结构性判据,不用 mtime):默认 0 = 只要求目录存在;可上调
|
|
88
|
+
minWorkspaces: 0
|
package/index.js
CHANGED
|
@@ -16,7 +16,9 @@ import net from 'node:net';
|
|
|
16
16
|
import tls from 'node:tls';
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { watchUiConfig } from './lib/ui-config-push.js';
|
|
21
|
+
import { createRestartAutocheck } from './lib/restart-autocheck.js';
|
|
20
22
|
|
|
21
23
|
/*
|
|
22
24
|
* ★ 协议层与瘦身层**直接引用 core**,本目录不再保有自己的副本。
|
|
@@ -95,6 +97,15 @@ try {
|
|
|
95
97
|
console.log(`[mob-bridge] groups 模块未加载(群聊能力关闭):${err?.message ?? err}`);
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/** 插件自身版本(读 package.json;读不到返回空串,版本判据按"查不到=失败"处理)。 */
|
|
101
|
+
function pluginVersion() {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version ?? '';
|
|
104
|
+
} catch {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
export const name = 'jbc-dssi';
|
|
99
110
|
|
|
100
111
|
/**
|
|
@@ -1389,6 +1400,40 @@ function activate(ctx, config) {
|
|
|
1389
1400
|
server.close();
|
|
1390
1401
|
};
|
|
1391
1402
|
});
|
|
1403
|
+
|
|
1404
|
+
/*
|
|
1405
|
+
* 重启接续检查(2026-11-02):自动重启后 2 分钟核验四项判据并发一条消息。
|
|
1406
|
+
* 口径见 docs/架构/重启接续检查.md;只读 marker、不改冻结脚本。
|
|
1407
|
+
* 全程 try/catch:本机制只观测,任何异常都不得影响主流程(配对/中继/帧分发)。
|
|
1408
|
+
*/
|
|
1409
|
+
let autocheck = null;
|
|
1410
|
+
if (config.restart?.autocheck?.enabled !== false) {
|
|
1411
|
+
try {
|
|
1412
|
+
autocheck = createRestartAutocheck({
|
|
1413
|
+
ctx,
|
|
1414
|
+
log,
|
|
1415
|
+
groups,
|
|
1416
|
+
packageVersion: pluginVersion(),
|
|
1417
|
+
config: {
|
|
1418
|
+
...(config.restart?.autocheck ?? {}),
|
|
1419
|
+
dshWebPort: config.relay?.dshWebPort ?? 3080,
|
|
1420
|
+
rootDir: typeof config.dssAgent?.rootDir === 'string' ? config.dssAgent.rootDir : undefined,
|
|
1421
|
+
groupsStateFile: typeof config.groups?.stateFile === 'string' ? config.groups.stateFile : undefined,
|
|
1422
|
+
deviceAuthFile,
|
|
1423
|
+
packageRoot: fileURLToPath(new URL('.', import.meta.url)),
|
|
1424
|
+
groupsDisabled: !groups,
|
|
1425
|
+
},
|
|
1426
|
+
});
|
|
1427
|
+
autocheck.start();
|
|
1428
|
+
log('[autocheck] 重启接续检查已启用(marker=/tmp/dsh-restart-result.txt)');
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
log(`[autocheck] 初始化失败(不影响主流程):${err?.message ?? err}`);
|
|
1431
|
+
autocheck = null;
|
|
1432
|
+
}
|
|
1433
|
+
} else {
|
|
1434
|
+
log('[autocheck] 重启接续检查已关闭(restart.autocheck.enabled=false)');
|
|
1435
|
+
}
|
|
1436
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => autocheck?.dispose?.());
|
|
1392
1437
|
}
|
|
1393
1438
|
|
|
1394
1439
|
/**
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jbc-dssi/lib/restart-autocheck.js —— 重启接续检查(2026-11-02)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:自动重启 dsh web 会掐断会话、没有新的触发源。本模块在**重启后 2 分钟**
|
|
5
|
+
* 自动核验重启是否真的成功,并以主管名义发一条简短群消息接续流程;失败则明确写
|
|
6
|
+
* "需人工介入"。设计与口径见 `docs/架构/重启接续检查.md`。
|
|
7
|
+
*
|
|
8
|
+
* 硬约束(遵守才允许上线):
|
|
9
|
+
* - 只读 `/tmp/dsh-restart-result.txt` 的 mtime,**不改冻结脚本** restart-dsh-web.py;
|
|
10
|
+
* - 两道闸:marker 必须存在、且 mtime ≥ 本次进程启动−5s(否则 = 没有本次重启记录,
|
|
11
|
+
* **静默跳过,绝不发"失败"消息**);
|
|
12
|
+
* - 幂等:sha256(markerMtime + ':' + pid) 前 16 hex;同 key 只触发一次;
|
|
13
|
+
* marker mtime 相同的 6h 冷却(防崩溃重启循环刷屏);
|
|
14
|
+
* - 判据"查不到 = 失败"(假阳性最坏);只有 groups 整体关闭时才记 N/A;
|
|
15
|
+
* - 全程 try/catch,任何异常只记日志;定时器 unref(),不阻止进程退出。
|
|
16
|
+
*
|
|
17
|
+
* 本模块不 import `jbc-dssi/index.js`、不写 groups.json、不反向依赖调用方。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { get } from 'node:http';
|
|
23
|
+
import { connect } from 'node:net';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
|
|
27
|
+
/** 默认参数(与文档 §六 一致;配置段可覆盖)。 */
|
|
28
|
+
const DEFAULTS = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
delayMs: 120_000,
|
|
31
|
+
marker: '/tmp/dsh-restart-result.txt',
|
|
32
|
+
cooldownMs: 6 * 60 * 60_000,
|
|
33
|
+
notifyGroup: true,
|
|
34
|
+
resumeSession: true,
|
|
35
|
+
groupId: '',
|
|
36
|
+
dshWebPort: 3080,
|
|
37
|
+
/** 工作区目录数下限(默认 0 = 只要求目录存在;需要更严时由配置上调)。 */
|
|
38
|
+
minWorkspaces: 0,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** marker mtime 允许比进程启动早多少毫秒(两者本就有约 12s 差,用 5s 会让旧文件挡掉;见文档 §一)。 */
|
|
42
|
+
const PROCESS_START_TOLERANCE_MS = 5_000;
|
|
43
|
+
|
|
44
|
+
/** 判据项 → 消息里的标记名。 */
|
|
45
|
+
const MISSING_LABEL = {
|
|
46
|
+
process: 'process',
|
|
47
|
+
port: 'port',
|
|
48
|
+
version: 'version',
|
|
49
|
+
groups: 'baseline(groups)',
|
|
50
|
+
workspaces: 'baseline(workspaces)',
|
|
51
|
+
pairing: 'baseline(pairing)',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 触发判定(纯函数,单测直接钉边界)。
|
|
56
|
+
*
|
|
57
|
+
* @returns {{trigger:boolean, delayMs:number, reason:string}}
|
|
58
|
+
*/
|
|
59
|
+
export function planTrigger({ markerMtimeMs, processStartMs, now, delayMs = DEFAULTS.delayMs }) {
|
|
60
|
+
if (!Number.isFinite(markerMtimeMs) || markerMtimeMs <= 0) {
|
|
61
|
+
return { trigger: false, delayMs: 0, reason: 'marker-missing' };
|
|
62
|
+
}
|
|
63
|
+
if (markerMtimeMs < processStartMs - PROCESS_START_TOLERANCE_MS) {
|
|
64
|
+
return { trigger: false, delayMs: 0, reason: 'marker-older-than-process' };
|
|
65
|
+
}
|
|
66
|
+
const fireAt = markerMtimeMs + delayMs;
|
|
67
|
+
return { trigger: true, delayMs: Math.max(0, fireAt - now), reason: 'scheduled' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 幂等 key:sha256(markerMtimeMs + ':' + pid) 前 16 hex。 */
|
|
71
|
+
export function idempotencyKey(markerMtimeMs, pid, sha = (s) => createHash('sha256').update(s).digest('hex')) {
|
|
72
|
+
return sha(`${markerMtimeMs}:${pid}`).slice(0, 16);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 进程启动时刻(毫秒):now − uptime。 */
|
|
76
|
+
export function processStartMs(now = Date.now(), uptimeSec = process.uptime()) {
|
|
77
|
+
return Math.round(now - uptimeSec * 1000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 成功消息(一行,模板见文档 §四)。 */
|
|
81
|
+
export function formatSuccess({ pid, startedAt, version, checkedAt, versionFallback = false }) {
|
|
82
|
+
const hhmmss = new Date(checkedAt).toLocaleTimeString('zh-CN', { hour12: false });
|
|
83
|
+
return (
|
|
84
|
+
`✅ 重启已接续:PID ${pid}(${new Date(startedAt).toLocaleTimeString('zh-CN', { hour12: false })} 起)` +
|
|
85
|
+
`|插件 ${version}${versionFallback ? '(版本对照 N/A)' : ''}|3080 可连(401 正常)` +
|
|
86
|
+
`|群数据/工作区/配对均未重置(自动核验 ${hhmmss})`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 失败消息(≤2 行,明确"需人工介入" + 缺的判据)。 */
|
|
91
|
+
export function formatFailure({ missing, evidence, checkedAt }) {
|
|
92
|
+
const hhmmss = new Date(checkedAt).toLocaleTimeString('zh-CN', { hour12: false });
|
|
93
|
+
const labels = missing.map((m) => MISSING_LABEL[m] ?? m).join('、');
|
|
94
|
+
return `⚠️ 重启未完成,需人工介入:缺 ${labels} 判据;证据:${evidence}。自动核验 ${hhmmss}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 造自动检查器。
|
|
99
|
+
*
|
|
100
|
+
* @param {object} opts
|
|
101
|
+
* @param {object} opts.ctx DSH 上下文(仅用 sessionController 公开方法)
|
|
102
|
+
* @param {Function} opts.log
|
|
103
|
+
* @param {object} [opts.config] `restart.autocheck` 配置段
|
|
104
|
+
* @param {object|null} [opts.groups] groups 引擎(可选;缺失时只跳过群消息/主管接续)
|
|
105
|
+
* @param {string} [opts.packageVersion] 插件自身版本
|
|
106
|
+
* @param {object} [opts.deps] 测试注入(clock/fs/net/通道)
|
|
107
|
+
*/
|
|
108
|
+
export function createRestartAutocheck({ ctx, log, config = {}, groups = null, packageVersion = '', deps = {} }) {
|
|
109
|
+
const cfg = { ...DEFAULTS, ...(config ?? {}) };
|
|
110
|
+
const now = deps.now ?? (() => Date.now());
|
|
111
|
+
const pid = deps.pid ?? process.pid;
|
|
112
|
+
const startMs = deps.processStartMs ?? (() => processStartMs(now()));
|
|
113
|
+
const statMarker = deps.statMarker ?? ((path) => statSync(path).mtimeMs);
|
|
114
|
+
const statePath = deps.statePath ?? join(homedir(), '.dsh', 'jbc-dssi', 'restart-autocheck.json');
|
|
115
|
+
const readState = deps.readState ?? (() => {
|
|
116
|
+
try { return JSON.parse(readFileSync(statePath, 'utf8')); } catch { return null; }
|
|
117
|
+
});
|
|
118
|
+
const writeState = deps.writeState ?? ((value) => {
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
121
|
+
writeFileSync(statePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log?.(`[autocheck] 写状态文件失败:${err?.message ?? err}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
const probePort = deps.probePort ?? ((port) => probeHttp(port));
|
|
127
|
+
const checkBaseline = deps.checkBaseline ?? ((markerMtimeMs) => baselineCheck({ markerMtimeMs, config: cfg, log }));
|
|
128
|
+
const readExpectedVersion = deps.readExpectedVersion ?? (() => expectedVersionFromRepo(cfg));
|
|
129
|
+
const postGroupLine = deps.postGroupLine ?? (async (groupId, line) => groups?.postSystemLine?.(groupId, line));
|
|
130
|
+
const promptManager = deps.promptManager ?? (async (sessionId, text) => {
|
|
131
|
+
const controller = ctx?.sessionController;
|
|
132
|
+
if (!controller?.prompt || !sessionId) return false;
|
|
133
|
+
await controller.prompt(
|
|
134
|
+
{ requestId: `autocheck-${pid}-${Math.round(now())}`, sessionId, mode: 'queue', content: [{ type: 'text', text }] },
|
|
135
|
+
AbortSignal.timeout(10_000),
|
|
136
|
+
);
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let timer = null;
|
|
141
|
+
let disposed = false;
|
|
142
|
+
let inProcessFired = false;
|
|
143
|
+
|
|
144
|
+
/** 选一个可用的群:配置优先;否则最近活跃且设了主管的群。 */
|
|
145
|
+
async function pickGroupId() {
|
|
146
|
+
if (cfg.groupId) return cfg.groupId;
|
|
147
|
+
try {
|
|
148
|
+
const snap = await groups?.snapshot?.();
|
|
149
|
+
const list = Array.isArray(snap?.groups) ? snap.groups : [];
|
|
150
|
+
return list.find((g) => g?.managerId)?.id ?? null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 执行一次核验(触发后调用;异常一律吞掉,只落日志)。 */
|
|
157
|
+
async function runCheck(markerMtimeMs) {
|
|
158
|
+
const checkedAt = now();
|
|
159
|
+
const missing = [];
|
|
160
|
+
const evidence = [];
|
|
161
|
+
const version = String(packageVersion ?? '');
|
|
162
|
+
const expected = String(cfg.expectVersion || readExpectedVersion() || '');
|
|
163
|
+
/** 判据 1:进程已换新(marker 不早于本次启动)。 */
|
|
164
|
+
const startedAt = startMs();
|
|
165
|
+
if (!(markerMtimeMs >= startedAt - PROCESS_START_TOLERANCE_MS)) missing.push('process');
|
|
166
|
+
evidence.push(`pid=${pid}`, `start=${new Date(startedAt).toISOString()}`);
|
|
167
|
+
|
|
168
|
+
/** 判据 2:3080 可访问(TCP + GET /,status<500 即活;401 是鉴权常态)。 */
|
|
169
|
+
try {
|
|
170
|
+
const alive = await probePort(Number(cfg.dshWebPort ?? 3080));
|
|
171
|
+
evidence.push(`port=${cfg.dshWebPort}:${alive ? 'ok' : 'fail'}`);
|
|
172
|
+
if (!alive) missing.push('port');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
evidence.push(`port=${cfg.dshWebPort}:error(${err?.message ?? err})`);
|
|
175
|
+
missing.push('port');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 判据 3:插件版本符合(读不到 version.json → 退回"自身版本非空"并注明)。 */
|
|
179
|
+
let versionFallback = false;
|
|
180
|
+
if (!version) {
|
|
181
|
+
missing.push('version');
|
|
182
|
+
evidence.push('version=empty');
|
|
183
|
+
} else if (expected) {
|
|
184
|
+
if (version !== expected) {
|
|
185
|
+
missing.push('version');
|
|
186
|
+
evidence.push(`version=${version}!==${expected}`);
|
|
187
|
+
} else {
|
|
188
|
+
evidence.push(`version=${version}`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
versionFallback = true;
|
|
192
|
+
evidence.push(`version=${version}(no version.json)`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 判据 4:数据基线未重置(查不到=失败;groups 整体关闭才 N/A)。 */
|
|
196
|
+
try {
|
|
197
|
+
const baseline = checkBaseline(markerMtimeMs);
|
|
198
|
+
evidence.push(...baseline.evidence);
|
|
199
|
+
if (!baseline.skipped) missing.push(...baseline.missing);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
missing.push('groups');
|
|
202
|
+
evidence.push(`baseline=error(${err?.message ?? err})`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ok = missing.length === 0;
|
|
206
|
+
const text = ok
|
|
207
|
+
? formatSuccess({ pid, startedAt, version, checkedAt, versionFallback })
|
|
208
|
+
: formatFailure({ missing: [...new Set(missing)], evidence: evidence.join(' '), checkedAt });
|
|
209
|
+
|
|
210
|
+
/** 通道 1:群消息(以主管身份落一行;posts 后返回主管会话信息,供通道 2 复用)。 */
|
|
211
|
+
let groupInfo = null;
|
|
212
|
+
if (cfg.notifyGroup) {
|
|
213
|
+
try {
|
|
214
|
+
const groupId = await pickGroupId();
|
|
215
|
+
if (groupId) groupInfo = await postGroupLine(groupId, { text, auto: true });
|
|
216
|
+
else log?.('[autocheck] 找不到可发消息的群,跳过群消息');
|
|
217
|
+
} catch (err) {
|
|
218
|
+
log?.(`[autocheck] 群消息发送失败(不影响主流程):${err?.message ?? err}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 通道 2:内部事件(接续流程,不产生群气泡;取不到主管会话就跳过)。 */
|
|
223
|
+
if (cfg.resumeSession) {
|
|
224
|
+
try {
|
|
225
|
+
if (!groupInfo) {
|
|
226
|
+
const groupId = await pickGroupId();
|
|
227
|
+
groupInfo = groupId ? await postGroupLine(groupId, { silent: true }) : null;
|
|
228
|
+
}
|
|
229
|
+
const sessionId = groupInfo?.managerSessionId ?? null;
|
|
230
|
+
if (sessionId) {
|
|
231
|
+
await promptManager(sessionId, `【自动接续】${text}`);
|
|
232
|
+
log?.(`[autocheck] 已向主管会话 ${sessionId} 投递接续事件`);
|
|
233
|
+
} else {
|
|
234
|
+
log?.('[autocheck] 取不到主管会话,跳过内部接续');
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
log?.(`[autocheck] 内部接续失败(不影响主流程):${err?.message ?? err}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
writeState({ key: idempotencyKey(markerMtimeMs, pid), markerMtime: markerMtimeMs, checkedAt, ok, missing, version });
|
|
242
|
+
log?.(`[autocheck] 核验完成 ok=${ok} missing=[${missing.join(',')}] markerMtime=${markerMtimeMs}`);
|
|
243
|
+
return { ok, missing, text, checkedAt };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 起定时器;不满足两道闸时静默跳过(只打一行日志)。 */
|
|
247
|
+
function start() {
|
|
248
|
+
if (!cfg.enabled) {
|
|
249
|
+
log?.('[autocheck] 已关闭(restart.autocheck.enabled=false),不读 marker、不起定时器');
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let markerMtimeMs = NaN;
|
|
253
|
+
try {
|
|
254
|
+
markerMtimeMs = statMarker(cfg.marker);
|
|
255
|
+
} catch {
|
|
256
|
+
log?.(`[autocheck] 无重启记录(marker 不存在):${cfg.marker},静默跳过`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const decision = planTrigger({ markerMtimeMs, processStartMs: startMs(), now: now(), delayMs: cfg.delayMs });
|
|
260
|
+
if (!decision.trigger) {
|
|
261
|
+
log?.(`[autocheck] 无本次重启记录(${decision.reason}),静默跳过`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const key = idempotencyKey(markerMtimeMs, pid);
|
|
265
|
+
const state = readState();
|
|
266
|
+
if (state?.key === key) {
|
|
267
|
+
log?.(`[autocheck] 同一次重启已核验过(key=${key}),跳过`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (state?.markerMtime === markerMtimeMs && now() - Number(state.checkedAt ?? 0) < cfg.cooldownMs) {
|
|
271
|
+
log?.('[autocheck] 同 marker 在冷却期内已核验,跳过(防重启循环刷屏)');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
log?.(`[autocheck] 排定核验:${Math.round(decision.delayMs / 1000)}s 后(markerMtime=${markerMtimeMs})`);
|
|
275
|
+
timer = setTimeout(() => {
|
|
276
|
+
timer = null;
|
|
277
|
+
if (disposed) return;
|
|
278
|
+
void runCheck(markerMtimeMs).catch((err) => {
|
|
279
|
+
log?.(`[autocheck] 核验异常(不影响主流程):${err?.message ?? err}`);
|
|
280
|
+
});
|
|
281
|
+
}, decision.delayMs);
|
|
282
|
+
timer.unref?.();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function dispose() {
|
|
286
|
+
disposed = true;
|
|
287
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 仅供单测:直接触发一次核验(绕过定时器)。 */
|
|
291
|
+
async function checkNow(markerMtimeMs) {
|
|
292
|
+
if (inProcessFired) return { ok: false, skipped: true };
|
|
293
|
+
inProcessFired = true;
|
|
294
|
+
return runCheck(markerMtimeMs);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { start, dispose, checkNow, config: cfg };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** TCP 连通 + GET /(status<500 即算活着;401 是鉴权常态)。导出供单测直接覆盖本体。 */
|
|
301
|
+
export function probeHttp(port, timeoutMs = 1_200) {
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: timeoutMs });
|
|
304
|
+
let settled = false;
|
|
305
|
+
const done = (alive) => {
|
|
306
|
+
if (settled) return;
|
|
307
|
+
settled = true;
|
|
308
|
+
socket.destroy();
|
|
309
|
+
resolve(alive);
|
|
310
|
+
};
|
|
311
|
+
socket.once('connect', () => {
|
|
312
|
+
try {
|
|
313
|
+
const req = get({ host: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (res) => {
|
|
314
|
+
res.resume();
|
|
315
|
+
done(Number(res.statusCode ?? 0) < 500);
|
|
316
|
+
});
|
|
317
|
+
req.on('timeout', () => { req.destroy(); done(false); });
|
|
318
|
+
req.on('error', () => done(false));
|
|
319
|
+
} catch {
|
|
320
|
+
done(false);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
socket.once('timeout', () => done(false));
|
|
324
|
+
socket.once('error', () => done(false));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 数据基线(**结构性判据,绝不用 mtime**)—— 2026-11-02 测测姐阻断项修复。
|
|
330
|
+
*
|
|
331
|
+
* 旧口径(被拦下):用 `mtime < markerMtime` 判"未重置"。但重启后 2 分钟窗口里,
|
|
332
|
+
* 用户/主管正常发一条群消息就会写 `groups.json` → mtime 变新 → 被误判成"数据重置",
|
|
333
|
+
* 于是第一次实战会发**假的**"需人工介入"。这与"假成功通知"同等有害。
|
|
334
|
+
*
|
|
335
|
+
* 新口径(本函数):
|
|
336
|
+
* ① `groups.json`:存在 + **可 JSON 解析** + 结构完好(`groups` 数组、`rooms`/`groupSessions`
|
|
337
|
+
* 对象、每个群有 id/name);**群数为 0 也算正常**(新装用户合法),mtime 不参与判定;
|
|
338
|
+
* ② `workspaces/`:目录存在且可读 + 顶层目录数 ≥ `minWorkspaces`(默认 1,可配置;
|
|
339
|
+
* 删掉"魔法数 6");
|
|
340
|
+
* ③ `authorized-device.json`:存在 + 非空 + 可解析 + `deviceId`/`deviceName`/`authorizedAt`
|
|
341
|
+
* 三个关键字段非空。
|
|
342
|
+
*
|
|
343
|
+
* 判据纪律不变:**查不到/解析失败 = 失败**(唯一例外:groups 模块整体关闭 → 记 N/A)。
|
|
344
|
+
*/
|
|
345
|
+
export function baselineCheck({ markerMtimeMs, config, log }) {
|
|
346
|
+
const missing = [];
|
|
347
|
+
const evidence = [];
|
|
348
|
+
|
|
349
|
+
// ① groups.json:结构校验(mtime 一律不参与)
|
|
350
|
+
const rootDir = String(config.rootDir || join(homedir(), '.dssAgent'));
|
|
351
|
+
const groupsFile = String(config.groupsStateFile || join(rootDir, 'jbc-groups', 'groups.json'));
|
|
352
|
+
if (config.groupsDisabled === true) {
|
|
353
|
+
evidence.push('groups=N/A(模块关闭)');
|
|
354
|
+
} else {
|
|
355
|
+
try {
|
|
356
|
+
const raw = readFileSync(groupsFile, 'utf8');
|
|
357
|
+
if (!raw.trim()) {
|
|
358
|
+
missing.push('groups');
|
|
359
|
+
evidence.push('groups.json=empty');
|
|
360
|
+
} else {
|
|
361
|
+
const json = JSON.parse(raw);
|
|
362
|
+
const shaped = Boolean(json) && typeof json === 'object'
|
|
363
|
+
&& Array.isArray(json.groups)
|
|
364
|
+
&& json.rooms !== null && typeof json.rooms === 'object'
|
|
365
|
+
&& json.groupSessions !== null && typeof json.groupSessions === 'object';
|
|
366
|
+
const groupsOk = !shaped || json.groups.every((g) => g && typeof g.id === 'string' && typeof g.name === 'string');
|
|
367
|
+
evidence.push(`groups.json=groups:${Array.isArray(json?.groups) ? json.groups.length : '?'},shape:${shaped && groupsOk}`);
|
|
368
|
+
if (!shaped || !groupsOk) missing.push('groups');
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
missing.push('groups');
|
|
372
|
+
evidence.push(`groups.json=unreadable(${err?.message ?? err})`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ② workspaces:存在 + 目录数 ≥ 可配置下限(默认 1,替代旧的魔法数 6)
|
|
377
|
+
const workspacesDir = join(rootDir, 'workspaces');
|
|
378
|
+
const floor = Number.isFinite(Number(config.minWorkspaces)) ? Number(config.minWorkspaces) : 0;
|
|
379
|
+
try {
|
|
380
|
+
const count = readdirSync(workspacesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).length;
|
|
381
|
+
evidence.push(`workspaces=${count}(floor:${floor})`);
|
|
382
|
+
if (count < floor) missing.push('workspaces');
|
|
383
|
+
} catch (err) {
|
|
384
|
+
evidence.push(`workspaces=missing(${err?.code ?? err?.message ?? err})`);
|
|
385
|
+
missing.push('workspaces');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ③ 配对凭据:存在 + 非空 + 可解析 + 关键字段非空
|
|
389
|
+
const deviceFile = String(config.deviceAuthFile || join(homedir(), '.dsh', 'jbc-dssi', 'authorized-device.json'));
|
|
390
|
+
try {
|
|
391
|
+
const raw = readFileSync(deviceFile, 'utf8');
|
|
392
|
+
const json = JSON.parse(raw);
|
|
393
|
+
const fieldsOk = Boolean(json) && typeof json === 'object'
|
|
394
|
+
&& typeof json.deviceId === 'string' && json.deviceId.length > 0
|
|
395
|
+
&& typeof json.deviceName === 'string' && json.deviceName.length > 0
|
|
396
|
+
&& typeof json.authorizedAt === 'string' && json.authorizedAt.length > 0;
|
|
397
|
+
evidence.push(`authorized-device.json=fields:${fieldsOk}`);
|
|
398
|
+
if (!fieldsOk) missing.push('pairing');
|
|
399
|
+
} catch (err) {
|
|
400
|
+
evidence.push(`authorized-device.json=unreadable(${err?.code ?? err?.message ?? err})`);
|
|
401
|
+
missing.push('pairing');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
log?.(`[autocheck] 基线结构校验 missing=[${missing.join(',')}] ${evidence.join(' ')}`);
|
|
405
|
+
return { missing, evidence, skipped: false };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 从插件目录向上找仓库 `version.json`(dev 仓库场景);找不到返回 null。 */
|
|
409
|
+
function expectedVersionFromRepo(config) {
|
|
410
|
+
try {
|
|
411
|
+
let dir = config.packageRoot || null;
|
|
412
|
+
if (!dir) return null;
|
|
413
|
+
for (let i = 0; i < 5; i += 1) {
|
|
414
|
+
const candidate = join(dir, 'version.json');
|
|
415
|
+
if (existsSync(candidate)) {
|
|
416
|
+
const json = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
417
|
+
// 仓库 version.json 的字段名是 versionName(不是 version),两者都认
|
|
418
|
+
return json.versionName ?? json.version ?? null;
|
|
419
|
+
}
|
|
420
|
+
const parent = dirname(dir);
|
|
421
|
+
if (parent === dir) break;
|
|
422
|
+
dir = parent;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|