dsh-bulletin-dispatch 1.3.19
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 +53 -0
- package/index.js +464 -0
- package/package.json +39 -0
- package/src/features/f0-identity.js +342 -0
- package/src/features/f0b-session-gc.js +427 -0
- package/src/features/f1-propose-rename.js +214 -0
- package/src/features/f2-dispatch.js +539 -0
- package/src/features/f2b-dispatch-tools.js +303 -0
- package/src/features/f3-status.js +773 -0
- package/src/identity.js +220 -0
- package/src/log.js +81 -0
- package/src/store.js +760 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 功能 1 · **送达状态表**(给人看的仪表盘)。
|
|
3
|
+
*
|
|
4
|
+
* ## 的四条(2026-09-30,实现前先对过)
|
|
5
|
+
*
|
|
6
|
+
* | 项 | 决定 |
|
|
7
|
+
* |---|---|
|
|
8
|
+
* | **给谁看** | **用户自己** —— 一个窗口,**不需要任何人去核对它** |
|
|
9
|
+
* | **写什么** | 单子清单与状态 · 最近的取走记录 · **插件与存储的健康信息** |
|
|
10
|
+
* | **何时写** | **状态真的变了才写**(发单 / 取走 / 认桌改名 触发) |
|
|
11
|
+
* | **落盘确认** | ⭐ **`flush()` 之后再 `stat()`** —— 真确认,不是假阳性 |
|
|
12
|
+
*
|
|
13
|
+
* ## ⚠️ 我对开工包做了三处更正(实测证据见 `进度与待办.md`)
|
|
14
|
+
*
|
|
15
|
+
* | 开工包 | 实际 |
|
|
16
|
+
* |---|---|
|
|
17
|
+
* | 用 `stat()` 确认"真的落盘了" | **`stat()` 不证明落盘**(契约:backend 延迟物理落盘时 `stat` 也看得到)⇒ 要先 `flush()` |
|
|
18
|
+
* | `stat` 给 `revision` + `size` | 实际是 `{ header, revision, sizeBytes }`,**`eventCount` 是 `undefined`** |
|
|
19
|
+
* | 拿 `revision` 判"变没变" | 实测 **`flush()` 前后 revision 不变**(它是**内容**指纹)⇒ 不能当"落盘信号" |
|
|
20
|
+
*
|
|
21
|
+
* ## ⚠️ 它是**派生视图,不是权威**(开工包的原则,照办)
|
|
22
|
+
*
|
|
23
|
+
* 全部内容都从 `tickets` / `claims` / 会话表**现算**,**不另存一份会漂移的状态**。
|
|
24
|
+
* 另外:**它没有 5 秒防抖**(开工包提过)—— 用"写入/取走的瞬间"触发就够,
|
|
25
|
+
* 防抖只会让仪表盘**滞后**。
|
|
26
|
+
*/
|
|
27
|
+
import { errText, safe } from '../log.js';
|
|
28
|
+
import {
|
|
29
|
+
CLAIMS_KEEP, lastFileError, lastWrite, listRecords, putTracked, readTextFile, sanitizeText, writeTextFile,
|
|
30
|
+
} from '../store.js';
|
|
31
|
+
|
|
32
|
+
/** 本功能的配置键(与 `index.js` 的开关同名)。 */
|
|
33
|
+
export const FEATURE = 'status';
|
|
34
|
+
|
|
35
|
+
/** 取走记录里最多显示几条(文件里存的是有界的 `CLAIMS_KEEP` 条)。 */
|
|
36
|
+
const CLAIMS_SHOWN = 8;
|
|
37
|
+
|
|
38
|
+
/** 最近一次"落盘确认"的结果(给仪表盘用)。 */
|
|
39
|
+
const durability = {
|
|
40
|
+
at: null, ok: null, ms: null, error: null,
|
|
41
|
+
sessionId: null, revision: null, revisionChanged: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 最近一次"状态文件自己"的写入结果。 */
|
|
45
|
+
const lastStatusWrite = { at: null, ok: null, error: null, file: null };
|
|
46
|
+
|
|
47
|
+
let dirty = false;
|
|
48
|
+
let scheduled = false;
|
|
49
|
+
|
|
50
|
+
/** 把时间戳格式化成 `MM-DD HH:mm`(本地时区,给人看)。 */
|
|
51
|
+
function stamp(ms) {
|
|
52
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '—';
|
|
53
|
+
const d = new Date(ms);
|
|
54
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
55
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `桌 04` 这种显示名;认不出的桌原样显示。 */
|
|
59
|
+
function deskName(desk, deskNames) {
|
|
60
|
+
if (typeof desk !== 'string' || desk === '') return '(未知)';
|
|
61
|
+
if (desk === '办公室') return '办公室';
|
|
62
|
+
const label = deskNames?.[desk];
|
|
63
|
+
return label === undefined ? `桌 ${desk}` : `桌 ${desk}(${label})`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 会话 id 缩短显示(只留尾部一段,够认出是谁)。 */
|
|
67
|
+
function shortSession(id) {
|
|
68
|
+
if (typeof id !== 'string') return '—';
|
|
69
|
+
return id.length <= 18 ? id : `…${id.slice(-16)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 表格里的一个单元格:把 `|` 转义掉,免得把表格撑破。 */
|
|
73
|
+
function cell(text) {
|
|
74
|
+
return String(text ?? '').replace(/\|/gu, '\\|').replace(/\r?\n/gu, ' ').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 生成**派生视图**(纯函数:只读存储,不写任何东西)。
|
|
79
|
+
*
|
|
80
|
+
* @param {object} p
|
|
81
|
+
* @param {object} p.store 存储表
|
|
82
|
+
* @param {object} p.config 插件配置
|
|
83
|
+
* @param {string} p.version 插件版本
|
|
84
|
+
* @param {object} [p.health] ⭐ **"写了没人看"的那些东西**(2026-10-01 加,见下面"健康"段)
|
|
85
|
+
* `{ lastFileError, problems, fileTableCount }`
|
|
86
|
+
* @returns {string} markdown
|
|
87
|
+
*/
|
|
88
|
+
export function renderStatus({ store, config, health }) {
|
|
89
|
+
const deskNames = config?.deskNames ?? {};
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ⭐ **会话号 → 人看得懂的称呼**(2026-09-30:"只有会话编号,有的不太好看")。
|
|
94
|
+
*
|
|
95
|
+
* ## 问题
|
|
96
|
+
*
|
|
97
|
+
* 状态表里好几列原来只显示 `…cec-88e78eef6baa` 这种**截断的会话号** ——
|
|
98
|
+
* 对人不友好:**我不认识那个号,我只想知道"是哪张桌、哪个会话"**。
|
|
99
|
+
*
|
|
100
|
+
* ## 修法
|
|
101
|
+
*
|
|
102
|
+
* `sessions` 表里**本来就有** `desk` 和 `title` ⇒ 建一张映射,显示成:
|
|
103
|
+
*
|
|
104
|
+
* ```
|
|
105
|
+
* 桌 02(环境维护)· 02-环境维护
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ 三个边界:
|
|
109
|
+
* | # | 边界 |
|
|
110
|
+
* |---|---|
|
|
111
|
+
* | **1** | **查不到就退回会话号**(不假装认识它 —— 认过的会话才在表里) |
|
|
112
|
+
* | **2** | **会话号仍然保留在括号里/标题里** —— 需要精确对照时还找得到(别把信息删掉) |
|
|
113
|
+
* | **3** | 这是**约定要给人看**的(本文件顶部就写着"派生视图")⇒ 可读性优先 |
|
|
114
|
+
*/
|
|
115
|
+
const sessionLabels = new Map();
|
|
116
|
+
for (const [id, rec] of listRecords(store.tables.sessions)) {
|
|
117
|
+
if (rec === null || typeof rec !== 'object') continue;
|
|
118
|
+
const desk = typeof rec.desk === 'string' && rec.desk !== '' ? rec.desk : null;
|
|
119
|
+
const title = typeof rec.title === 'string' && rec.title !== '' ? rec.title : null;
|
|
120
|
+
if (desk === null && title === null) continue;
|
|
121
|
+
sessionLabels.set(id, {
|
|
122
|
+
desk: desk === null ? null : deskName(desk, deskNames),
|
|
123
|
+
title,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 会话的可读称呼。
|
|
129
|
+
*
|
|
130
|
+
* @param {string} id 会话号
|
|
131
|
+
* @returns {string} 形如 `桌 02(环境维护)· 02-环境维护`;查不到就退回截断的会话号
|
|
132
|
+
*/
|
|
133
|
+
function who(id) {
|
|
134
|
+
const hit = sessionLabels.get(id);
|
|
135
|
+
if (hit === undefined) return `\`${shortSession(id)}\``; // ⚠️ 边界 1:查不到就如实显示号
|
|
136
|
+
const parts = [];
|
|
137
|
+
if (hit.desk !== null) parts.push(hit.desk);
|
|
138
|
+
if (hit.title !== null) parts.push(hit.title);
|
|
139
|
+
return parts.join(' · ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 同上,但**把会话号留在括号里**(给人核对用)。 */
|
|
143
|
+
function whoWithId(id) {
|
|
144
|
+
const hit = sessionLabels.get(id);
|
|
145
|
+
return hit === undefined
|
|
146
|
+
? `\`${shortSession(id)}\``
|
|
147
|
+
: `${who(id)}(\`${shortSession(id)}\`)`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 单子:待取的 / 已取的 ────────────────────────────────────────────────
|
|
151
|
+
const pending = [];
|
|
152
|
+
const taken = [];
|
|
153
|
+
for (const [id, rec] of listRecords(store.tables.tickets)) {
|
|
154
|
+
const row = { id, ...rec };
|
|
155
|
+
if (rec?.takenBy === null || rec?.takenBy === undefined) pending.push(row);
|
|
156
|
+
else taken.push(row);
|
|
157
|
+
}
|
|
158
|
+
pending.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
159
|
+
taken.sort((a, b) => (b.takenAt ?? b.at ?? 0) - (a.takenAt ?? a.at ?? 0));
|
|
160
|
+
/** ⭐ **按单号查单子** —— "最近的取走记录"里老记录没有 `source`,要靠它兜底。 */
|
|
161
|
+
const ticketsById = new Map([...pending, ...taken].map((t) => [String(t.id), t]));
|
|
162
|
+
|
|
163
|
+
// ── 取走历史(自己有界;这里只显示最近几条)──────────────────────────────
|
|
164
|
+
const claims = listRecords(store.tables.claims)
|
|
165
|
+
.map(([key, rec]) => ({ key, ...rec }))
|
|
166
|
+
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
|
|
167
|
+
const recent = claims.slice(0, CLAIMS_SHOWN);
|
|
168
|
+
|
|
169
|
+
// ── 会话(按桌分组)─────────────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* ⚠️⚠️ **要连 key 一起留着**(2026-09-30 修)。
|
|
172
|
+
*
|
|
173
|
+
* 原文是 `.map(([, rec]) => rec)` —— **把 key 丢了**。
|
|
174
|
+
* 而 `sessions` 表的 key **就是会话号** ⇒ 于是**老记录(没有 `sessionId` 字段的那些)
|
|
175
|
+
* 明明有号,却渲染成 `—`**("有数据却说没有")。
|
|
176
|
+
*
|
|
177
|
+
* ⇒ 现在保留 `id`(=存储 key),取号时 `s.sessionId ?? s.id` 两道兜底。
|
|
178
|
+
*/
|
|
179
|
+
const sessions = listRecords(store.tables.sessions)
|
|
180
|
+
.map(([id, rec]) => ({ id, ...rec }))
|
|
181
|
+
.filter((rec) => rec !== null && typeof rec === 'object');
|
|
182
|
+
|
|
183
|
+
const lines = [];
|
|
184
|
+
lines.push('# 投递状态(插件自动生成 —— **不要手改,改了会被覆盖**)');
|
|
185
|
+
lines.push('');
|
|
186
|
+
lines.push(
|
|
187
|
+
`生成时间 ${stamp(now)} | 插件 \`dsh-bulletin-dispatch\` ${config?.__version ?? '?'}`
|
|
188
|
+
+ ` | 存储 ${store.where === 'domain' ? '平台存储域' : '文件'}`
|
|
189
|
+
+ ` | 待取 ${pending.length} · 已取 ${taken.length}`,
|
|
190
|
+
);
|
|
191
|
+
lines.push('');
|
|
192
|
+
|
|
193
|
+
// ── ① 待取的单子(最重要的一段,放最前)────────────────────────────────
|
|
194
|
+
lines.push(`## 待取的单子(${pending.length})`);
|
|
195
|
+
lines.push('');
|
|
196
|
+
if (pending.length === 0) {
|
|
197
|
+
lines.push('(没有待取的单子。)');
|
|
198
|
+
} else {
|
|
199
|
+
/**
|
|
200
|
+
* ## ⭐⭐ **加了「谁投的」这一列**(2026-10-02)
|
|
201
|
+
*
|
|
202
|
+
* ### 为什么(**它是被看到的**)
|
|
203
|
+
*
|
|
204
|
+
* 用户看着面板问:*"这个待取的『投给 04』**没有显示是谁投给 04 的**,
|
|
205
|
+
* 但是下面已取走的就有显示。"*
|
|
206
|
+
*
|
|
207
|
+
* **⇒ 对。而根因在这张表的表头里** —— 它一直只有
|
|
208
|
+
* `单号 | 目标桌 | 一句话 | 详见 | 什么时候发的`,
|
|
209
|
+
* **压根没有"谁投的"这一列** ⇒ **前端读不到,所以画不出来。**
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ **而"谁投的"这张表是最该有的**:**「已被取走」那两张表在 2026-09-30 就加了这一列**,
|
|
212
|
+
* 起因正是同一种误读(*"取走的会话和投单的会话是一样的?"*)——
|
|
213
|
+
* **⇒ 当时只加在了两张已取走的表上,漏了这张最靠前的。**
|
|
214
|
+
*
|
|
215
|
+
* **⭐ 判据**:**三张投递表列的是同一件事,列名就该对齐** ——
|
|
216
|
+
* 不然读者会在"有的表有、有的表没有"之间自己猜。
|
|
217
|
+
*/
|
|
218
|
+
lines.push('| 单号 | 谁投的 | 目标桌 | 一句话 | 详见 | 什么时候发的 |');
|
|
219
|
+
lines.push('|---|---|---|---|---|---|');
|
|
220
|
+
for (const t of pending) {
|
|
221
|
+
lines.push(
|
|
222
|
+
`| \`${cell(t.id)}\` | ${cell(deskName(t.source, deskNames))} | ${cell(deskName(t.desk, deskNames))} | ${cell(t.summary)}`
|
|
223
|
+
+ ` | \`${cell(t.ptr)}\` | ${stamp(t.at)} |`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
lines.push('');
|
|
228
|
+
|
|
229
|
+
// ── ② 已取走的(还没做"做完"跟踪,所以这里只说"被谁取走了")────────────
|
|
230
|
+
lines.push(`## 已被取走(${taken.length})`);
|
|
231
|
+
lines.push('');
|
|
232
|
+
if (taken.length === 0) {
|
|
233
|
+
lines.push('(还没有单子被取走。)');
|
|
234
|
+
} else {
|
|
235
|
+
/**
|
|
236
|
+
* ⭐ **加了「谁投的」这一列**(2026-09-30)。
|
|
237
|
+
*
|
|
238
|
+
* ## 为什么要加(**它是被一个真实的误读逼出来的**)
|
|
239
|
+
*
|
|
240
|
+
* 用户看这张表时问:*"取走的会话和投单的会话是一样的?"*
|
|
241
|
+
* —— **因为他看不出单子是谁发起的**:这张表原来只有"目标桌",
|
|
242
|
+
* **没有发起方** ⇒ 那一列**既是"投给谁"又像是"谁投的"**,读的人只能猜。
|
|
243
|
+
*
|
|
244
|
+
* ## 值的格式由**前端**定,不是随意的
|
|
245
|
+
*
|
|
246
|
+
* 侧边栏面板(`dsh-bulletin-panel`)会把这列渲染成
|
|
247
|
+
* **`[04] 插件事务 → [02] 环境维护`** —— 它靠的是 `桌 NN(桌名)` 这个**固定形状**。
|
|
248
|
+
* ⇒ **改这个格式前要问前端**(那是它解析得出来的前提)。
|
|
249
|
+
*/
|
|
250
|
+
lines.push('| 单号 | 谁投的 | 目标桌 | 一句话 | 谁取走的 | 什么时候 |');
|
|
251
|
+
lines.push('|---|---|---|---|---|---|');
|
|
252
|
+
for (const t of taken.slice(0, 12)) {
|
|
253
|
+
lines.push(
|
|
254
|
+
`| \`${cell(t.id)}\` | ${cell(deskName(t.source, deskNames))}`
|
|
255
|
+
+ ` | ${cell(deskName(t.desk, deskNames))} | ${cell(t.summary)}`
|
|
256
|
+
+ ` | ${cell(who(t.takenBy))} | ${stamp(t.takenAt)} |`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
lines.push('');
|
|
261
|
+
|
|
262
|
+
// ── ③ 最近的取走记录(历史表,**单子被删了也还在**)──────────────────────
|
|
263
|
+
lines.push(`## 最近的取走记录(最近 ${recent.length} 条,最多留 ${CLAIMS_KEEP} 条)`);
|
|
264
|
+
lines.push('');
|
|
265
|
+
if (recent.length === 0) {
|
|
266
|
+
lines.push('(还没有取走记录。)');
|
|
267
|
+
} else {
|
|
268
|
+
/**
|
|
269
|
+
* ⭐ **加了「谁投的」这一列**(2026-09-30)。
|
|
270
|
+
*
|
|
271
|
+
* ⚠️ **老记录里没有 `source` 字段**(那是加这个字段之前写的)⇒
|
|
272
|
+
* **去 `tickets` 表兜底查一次**;查不到就显示 `—`(**诚实地说"不知道"**,不猜)。
|
|
273
|
+
*
|
|
274
|
+
* ⇒ 所以"谁投的"这一列的判据是:**先看记录自带,再查单子表,都没有就 `—`。**
|
|
275
|
+
*/
|
|
276
|
+
const sourceOf = (c) => {
|
|
277
|
+
if (typeof c.source === 'string' && c.source !== '') return c.source;
|
|
278
|
+
const t = ticketsById.get(String(c.ticketId));
|
|
279
|
+
return typeof t?.source === 'string' ? t.source : '';
|
|
280
|
+
};
|
|
281
|
+
lines.push('| 时间 | 单号 | 谁投的 | 目标桌 | 取走的会话 | 那一句话 |');
|
|
282
|
+
lines.push('|---|---|---|---|---|---|');
|
|
283
|
+
for (const c of recent) {
|
|
284
|
+
const src = sourceOf(c);
|
|
285
|
+
lines.push(
|
|
286
|
+
`| ${stamp(c.at)} | \`${cell(c.ticketId)}\` | ${src === '' ? '—' : cell(deskName(src, deskNames))}`
|
|
287
|
+
+ ` | ${cell(deskName(c.desk, deskNames))}`
|
|
288
|
+
+ ` | ${cell(who(c.by))} | ${cell(c.summary)} |`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
|
|
294
|
+
// ── ④ 各桌现在有哪些会话(认过桌的才算)────────────────────────────────
|
|
295
|
+
/**
|
|
296
|
+
* ⚠️ **表头数字要和实际行数一致**(2026-09-30 实测踩到):
|
|
297
|
+
* 原来写 `${sessions.length}`,但下面是**按桌分组**渲染的,
|
|
298
|
+
* 而"**办公室**"(认不出桌的会话)**没有桌号** ⇒ 有的实现会把它分到 `undefined` 组、有的不会
|
|
299
|
+
* ⇒ **表头写 5、实际只有 4 行**("数字对不上"正是我今天在别处反复踩的那类问题)。
|
|
300
|
+
* ⇒ 改成**先分组、再按分组算数**。
|
|
301
|
+
*/
|
|
302
|
+
const byDesk = new Map();
|
|
303
|
+
for (const s of sessions) {
|
|
304
|
+
const list = byDesk.get(s.desk) ?? [];
|
|
305
|
+
list.push(s);
|
|
306
|
+
byDesk.set(s.desk, list);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* ⚠️ **只在名单里显示"真桌号"**(2026-10-03 加,**兜住库里已经有的老记录**)。
|
|
310
|
+
*
|
|
311
|
+
* ## 为什么需要这道兜底
|
|
312
|
+
*
|
|
313
|
+
* 同一天修了 `f0-identity`:**没编号的会话不再进名单**
|
|
314
|
+
* (它原来会以"办公室"记一条,于是**看起来像个成员、而实际取不到任何单子**)。
|
|
315
|
+
*
|
|
316
|
+
* **⚠️ 而修的是"以后不写",不是"以前写的不算"** ——
|
|
317
|
+
* 用过旧版的人的库里**已经躺着"办公室"那些老记录**,
|
|
318
|
+
* **不滤掉的话,他们升级之后界面上还是那个样子**(而且没人知道该去删)。
|
|
319
|
+
*
|
|
320
|
+
* **⇒ 判据:`/^\d{2}$/`(和 `isResolvedDesk` 同一个)——
|
|
321
|
+
* 认过桌的名单里只该有桌号。**
|
|
322
|
+
*/
|
|
323
|
+
const desksShown = [...byDesk.keys()]
|
|
324
|
+
.filter((d) => typeof d === 'string' && /^\d{2}$/u.test(d))
|
|
325
|
+
.sort();
|
|
326
|
+
const rowsShown = desksShown.reduce((n, d) => n + byDesk.get(d).length, 0);
|
|
327
|
+
lines.push(`## 认过桌的会话(${rowsShown})`);
|
|
328
|
+
lines.push('');
|
|
329
|
+
if (rowsShown === 0) {
|
|
330
|
+
lines.push('(还没有会话认过桌。)');
|
|
331
|
+
} else {
|
|
332
|
+
lines.push('| 桌 | 会话标题 | 会话号 | 什么时候认的 |');
|
|
333
|
+
lines.push('|---|---|---|---|');
|
|
334
|
+
for (const desk of desksShown) {
|
|
335
|
+
for (const s of byDesk.get(desk).sort((a, b) => (b.at ?? 0) - (a.at ?? 0))) {
|
|
336
|
+
/**
|
|
337
|
+
* ⚠️ **会话号从第一列挪到第三列**(2026-09-30:"只有会话编号,有的不太好看")。
|
|
338
|
+
*
|
|
339
|
+
* 原因:同一行**已经有"桌"和"会话标题"**了 —— 人靠这两个就认得出。
|
|
340
|
+
* 会话号仍然留着(需要精确对照时有用),但**不再是主角**。
|
|
341
|
+
*
|
|
342
|
+
* ⚠️ 而**缺 `sessionId` 的老记录显示 `—`**:`s.id` 是**存储的 key**(=会话号),
|
|
343
|
+
* 但第一版误以为它也缺 ⇒ 明明有号却显示 `—`。**有 key 就用 key。**
|
|
344
|
+
*/
|
|
345
|
+
const sid = s.sessionId ?? s.id;
|
|
346
|
+
lines.push(
|
|
347
|
+
`| ${cell(deskName(desk, deskNames))} | ${cell(s.title)}`
|
|
348
|
+
+ ` | \`${shortSession(sid)}\` | ${stamp(s.at)} |`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ⚠️ 没分到组的(缺 `desk` 字段的脏记录)要**如实报出来**,不能悄悄吞掉
|
|
353
|
+
const orphans = sessions.length - rowsShown;
|
|
354
|
+
if (orphans > 0) lines.push(`| (没桌号) | 有 ${orphans} 条记录缺 \`desk\` 字段,没有渲染 | | |`);
|
|
355
|
+
}
|
|
356
|
+
lines.push('');
|
|
357
|
+
|
|
358
|
+
// ── ⑤ 健康(排查时最有用的一段)─────────────────────────────────────────
|
|
359
|
+
lines.push('## 健康');
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push(`- 插件版本:**${config?.__version ?? '?'}**`);
|
|
362
|
+
lines.push(`- 存储:**${store.where === 'domain' ? '平台存储域' : '文件'}**(状态文件 \`${safe(store.file) || '(取不到路径)'}\`)`);
|
|
363
|
+
if (store.lastWrite?.at) {
|
|
364
|
+
const w = store.lastWrite;
|
|
365
|
+
// ⚠️ key 可能很长(claims 的 key 是"时间戳|单号")⇒ 截一下别撑爆这一行;
|
|
366
|
+
// 并且**消毒控制字符**(万一别处又漏进来一个 NUL,整份表就又读不出来了)。
|
|
367
|
+
const key = typeof w.key === 'string' && w.key.length > 40 ? `${w.key.slice(0, 40)}…` : w.key;
|
|
368
|
+
lines.push(`- 最近一次写存储:${stamp(w.at)} ${w.ok === true ? '成功 ✅' : `**失败** ❌ ${w.error ?? ''}`}${key ? `(key=${sanitizeText(key)})` : ''}`);
|
|
369
|
+
} else {
|
|
370
|
+
lines.push('- 最近一次写存储:(本进程还没写过)');
|
|
371
|
+
}
|
|
372
|
+
if (durability.at === null) {
|
|
373
|
+
lines.push('- **落盘确认**:(还没跑过)');
|
|
374
|
+
} else {
|
|
375
|
+
lines.push(
|
|
376
|
+
`- **落盘确认**:${stamp(durability.at)} ${durability.ok === true ? '✅ 通过' : `❌ ${durability.error ?? '失败'}`}`
|
|
377
|
+
+ ` —— \`flush()\` ${durability.ms ?? '?'} ms,\`stat()\` revision ${durability.revisionChanged === true ? '**已变化**' : '未变(内容没动时正常)'}`
|
|
378
|
+
+ `(会话 \`${shortSession(durability.sessionId)}\`)`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (lastStatusWrite.at !== null) {
|
|
382
|
+
lines.push(
|
|
383
|
+
`- 本文件上次生成:${stamp(lastStatusWrite.at)} ${lastStatusWrite.ok === true ? '成功 ✅' : `**失败** ❌ ${lastStatusWrite.error ?? ''}`}`
|
|
384
|
+
+ `(**这次生成就是因为它**:状态变了才写)`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* ## ⭐⭐ **把"写了没人看"的东西显示出来**(2026-10-01 加)
|
|
390
|
+
*
|
|
391
|
+
* ### 为什么
|
|
392
|
+
*
|
|
393
|
+
* 这个插件里有**两处"记了但没人读"**的东西 —— Claude 复查时点出来的:
|
|
394
|
+
*
|
|
395
|
+
* | 东西 | 原来 | 现在 |
|
|
396
|
+
* |---|---|---|
|
|
397
|
+
* | **`lastFileError`**(存储写失败 / 读不懂) | **写 3 处、读 0 处** —— 注释还写着*"`dispatch_diag` 会报出来"*,**而那个工具不存在** | 显示在这儿 |
|
|
398
|
+
* | **`problems`**(`warn()` 攒下的问题) | 只在内存里,**从来没露过面** | 显示在这儿 |
|
|
399
|
+
*
|
|
400
|
+
* **⇒ 这正是 `docs\06 习惯二`**("凡是跳过,都要留痕")—— 它们**留了痕,但没人看得见**,
|
|
401
|
+
* 而"没人看得见的痕"和"没留痕"在效果上一样。
|
|
402
|
+
*
|
|
403
|
+
* ⚠️ **放在"健康"段**:那是**用户排查时会去看的地方**(它自己就是这么设计的)。
|
|
404
|
+
*
|
|
405
|
+
* ⚠️ **只在有问题时才出现** —— 健康的东西不该在状态表里占三行(那样会让真正的问题淹没)。
|
|
406
|
+
*/
|
|
407
|
+
const lastFileError = health?.lastFileError;
|
|
408
|
+
if (lastFileError !== null && lastFileError !== undefined && lastFileError.error !== null) {
|
|
409
|
+
lines.push(`- ⚠️ **最近一次存储文件出错**:${stamp(lastFileError.at)} —— ${sanitizeText(String(lastFileError.error))}`);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* ⚠️ **存储文件里到底有几条记录** —— 这一条是对着"单子其实没落盘"那类事故加的:
|
|
413
|
+
* `lastWrite` 说"成功",而文件里是空的 ⇒ **两个数一对比,谎就露了。**
|
|
414
|
+
*/
|
|
415
|
+
if (typeof health?.fileTableCount === 'number') {
|
|
416
|
+
lines.push(`- 存储文件里的单子数:**${health.fileTableCount}**(和上面"待取 + 已取"对不上就说明有东西没落盘)`);
|
|
417
|
+
}
|
|
418
|
+
const problems = health?.problems;
|
|
419
|
+
if (Array.isArray(problems) && problems.length > 0) {
|
|
420
|
+
lines.push(`- ⚠️ **本进程攒下的问题 ${problems.length} 条**:`);
|
|
421
|
+
for (const one of problems.slice(0, 5)) lines.push(` - ${sanitizeText(String(one))}`);
|
|
422
|
+
if (problems.length > 5) lines.push(` - …还有 ${problems.length - 5} 条(看插件日志)`);
|
|
423
|
+
}
|
|
424
|
+
lines.push('');
|
|
425
|
+
lines.push('---');
|
|
426
|
+
lines.push('');
|
|
427
|
+
lines.push('> 这份文件是**派生视图**:全部内容由单子和取走记录现算,**没有另存一份会漂移的状态**。');
|
|
428
|
+
/**
|
|
429
|
+
* ⚠️ **这句原来结尾带着"(2026-09-30)"** —— 2026-10-01 删掉了。
|
|
430
|
+
*
|
|
431
|
+
* ## 为什么删(**这是"生成物该不该带来源"的一个通用判据**)
|
|
432
|
+
*
|
|
433
|
+
* 这份文件是**插件生成的、给各桌读的**。而"谁在什么时候拍板的"是**开发过程**的事,
|
|
434
|
+
* **不属于"这份文件在说什么"** —— 它和它上面那句"派生视图"的说明放在一起,读起来像
|
|
435
|
+
* "这个决定是临时的、是某个人说了算的",**而读者需要知道的是"规矩是什么"。**
|
|
436
|
+
*
|
|
437
|
+
* **⇒ 判据**:**生成物里写"当前事实",不写"这个事实是谁定的"。**
|
|
438
|
+
* **要记来源,记在仓库的 `docs\` 里**(那儿才是"我们为什么这么定"的家)。
|
|
439
|
+
*
|
|
440
|
+
* ⚠️ **而这条是别的桌读出来提给我们的**(桌 03,2026-10-01 的单子)——
|
|
441
|
+
* **他们看到的是生成物,而生成物是他们的界面。**
|
|
442
|
+
*/
|
|
443
|
+
lines.push('> 「已取走」只表示**有会话把它取走了** —— 投递插件**不跟踪"做没做完"**。');
|
|
444
|
+
return lines.join('\n');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 装这个功能。
|
|
449
|
+
*
|
|
450
|
+
* @param {object} api `{ ctx, config, log, warn, store, version }`
|
|
451
|
+
*/
|
|
452
|
+
export function setup(api) {
|
|
453
|
+
const { ctx, config, log, warn, store } = api;
|
|
454
|
+
const file = config.statusFile;
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* ⚠️⚠️ **路径为空 ⇒ 不装配**(2026-10-01 实测踩到,加了这道闸)。
|
|
458
|
+
*
|
|
459
|
+
* ## 踩到什么
|
|
460
|
+
*
|
|
461
|
+
* `writeTextFile` 是**原子写**:先写 `${file}.tmp`、再 `renameSync` 到 `file`。
|
|
462
|
+
*
|
|
463
|
+
* ⇒ **`file` 是空串时**:临时文件成了 **`.tmp`**,而 `renameSync('.tmp', '')` **失败**
|
|
464
|
+
* ⇒ **一个 9 KB 的 `.tmp` 留在进程的 cwd 里**(我们在工作区根和仓库文件夹里**各发现一个**)。
|
|
465
|
+
*
|
|
466
|
+
* ## 为什么 `file` 会是空
|
|
467
|
+
*
|
|
468
|
+
* `apply()` 里是这样推的:
|
|
469
|
+
*
|
|
470
|
+
* ```
|
|
471
|
+
* statusFile = <workspaceRoot>\00-通用\投递状态.md
|
|
472
|
+
* ```
|
|
473
|
+
*
|
|
474
|
+
* **`workspaceRoot` 没配 ⇒ 推导出来就是空串。**
|
|
475
|
+
* 而"什么都没配"的实例**确实会被加载**(实测:插件升级/重装的中间代际跑过一次)——
|
|
476
|
+
* 它没有桌名、没有路径,**却在往磁盘上写东西**。
|
|
477
|
+
*
|
|
478
|
+
* ## 这道闸的意义
|
|
479
|
+
*
|
|
480
|
+
* **没有配好就不许写。** 宁可状态表这一轮不出现,
|
|
481
|
+
* 也不要在别人的工作区里**悄悄留一个叫 `.tmp` 的垃圾文件** ——
|
|
482
|
+
* **那种文件没人知道是谁建的,也没人敢删。**
|
|
483
|
+
*/
|
|
484
|
+
if (typeof file !== 'string' || file.trim() === '') {
|
|
485
|
+
warn('statusFile 是空的 —— 状态表本次不装配(**这不是故障**:多半是 workspaceRoot 没配)', {
|
|
486
|
+
workspaceRoot: config.workspaceRoot === undefined ? '(未配)' : config.workspaceRoot,
|
|
487
|
+
hint: '配好 workspaceRoot,或显式配一个 statusFile',
|
|
488
|
+
});
|
|
489
|
+
return {
|
|
490
|
+
request: () => {}, verifyDurability: async () => ({ ok: null, error: '未装配(statusFile 为空)' }),
|
|
491
|
+
snapshot: () => ({ file: '', durability: {}, lastStatusWrite: {} }),
|
|
492
|
+
generateNow: () => {}, readBack: () => undefined,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* ⚠️ **状态表文件和存储文件不能是同一个路径**(2026-09-30 实测踩到)。
|
|
498
|
+
*
|
|
499
|
+
* 两者格式不同:状态表是**纯 markdown**,存储是**JSON**。
|
|
500
|
+
* 配成同一个路径 ⇒ **互相覆盖**(实测:打开 .md 看到的是存储的 JSON)。
|
|
501
|
+
* ⇒ 这里直接挡住,并且**如实告诉用户配错了**。
|
|
502
|
+
*/
|
|
503
|
+
if (typeof store.file === 'string' && store.file !== ''
|
|
504
|
+
&& store.file.toLowerCase() === String(file).toLowerCase()) {
|
|
505
|
+
warn('状态表文件与存储文件是同一个路径 —— 状态表本次不装配(它们格式不同,会互相覆盖)', {
|
|
506
|
+
statusFile: safe(file),
|
|
507
|
+
});
|
|
508
|
+
return {
|
|
509
|
+
request: () => {}, verifyDurability: async () => ({ ok: null, error: '未装配' }),
|
|
510
|
+
snapshot: () => ({ file, durability: {}, lastStatusWrite: {} }),
|
|
511
|
+
generateNow: () => {}, readBack: () => undefined,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* ⭐⭐ **落盘确认** —— 提成 setup 里的**具名函数**(2026-10-01 修的一个真 bug)。
|
|
517
|
+
*
|
|
518
|
+
* ## 原来错在哪
|
|
519
|
+
*
|
|
520
|
+
* 它原来**只是返回对象的一个方法**(`return { async verifyDurability(exec) {…} }`),
|
|
521
|
+
* 而两个诊断工具里却**裸名字调用**它:
|
|
522
|
+
*
|
|
523
|
+
* ```js
|
|
524
|
+
* const d = await verifyDurability(exec); // ← 闭包里根本没有这个名字
|
|
525
|
+
* ```
|
|
526
|
+
*
|
|
527
|
+
* ⇒ **`refresh_status` 和 `probe_flush` 一调就 `ReferenceError: verifyDurability is not defined`。**
|
|
528
|
+
* (`refresh_status` 更糟:它**先把状态表生成了**,然后才以一个错误结束。)
|
|
529
|
+
*
|
|
530
|
+
* ## 为什么我的测试没抓到
|
|
531
|
+
*
|
|
532
|
+
* 因为它测的是**返回对象的那个方法**(`api.verifyDurability()`),
|
|
533
|
+
* **不是"注册之后、被真正调用"的那个工具**。
|
|
534
|
+
* ⇒ 这正是审阅者说的:**"验证必须实际调用注册后的工具"**。
|
|
535
|
+
*
|
|
536
|
+
* ## 现在的写法
|
|
537
|
+
*
|
|
538
|
+
* **一个函数声明,工具和返回对象共用同一份**(声明会提升 ⇒ 放哪都行,
|
|
539
|
+
* 但为了可读性放在两道早退之后)。
|
|
540
|
+
*
|
|
541
|
+
* @param {object} [exec] 工具执行上下文(拿 sessionId 用)
|
|
542
|
+
*/
|
|
543
|
+
async function verifyDurability(exec) {
|
|
544
|
+
const sessionId = exec?.agent?.session?.id;
|
|
545
|
+
const sp = findPersistence(exec?.agent?.ctx) ?? findPersistence(ctx);
|
|
546
|
+
const t0 = Date.now();
|
|
547
|
+
if (sp === undefined) {
|
|
548
|
+
Object.assign(durability, {
|
|
549
|
+
at: Date.now(), ok: null, ms: null, error: '取不到 sessionPersistence(不影响投递)',
|
|
550
|
+
sessionId: sessionId ?? null, revision: null, revisionChanged: null,
|
|
551
|
+
});
|
|
552
|
+
log('状态表/落盘确认:取不到 sessionPersistence', {});
|
|
553
|
+
return durability;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const before = typeof sessionId === 'string' ? (await sp.stat(sessionId))?.revision : undefined;
|
|
557
|
+
await sp.flush(); // ⭐ 持久屏障(实测 0 ms、全局无参数)
|
|
558
|
+
const after = typeof sessionId === 'string' ? (await sp.stat(sessionId))?.revision : undefined;
|
|
559
|
+
Object.assign(durability, {
|
|
560
|
+
at: Date.now(), ok: true, ms: Date.now() - t0, error: null,
|
|
561
|
+
sessionId: sessionId ?? null,
|
|
562
|
+
revision: after === undefined ? null : String(after),
|
|
563
|
+
revisionChanged: before === undefined || after === undefined ? null : String(before) !== String(after),
|
|
564
|
+
});
|
|
565
|
+
log('状态表/落盘确认', {
|
|
566
|
+
ok: true, ms: durability.ms, revisionChanged: durability.revisionChanged,
|
|
567
|
+
});
|
|
568
|
+
} catch (error) {
|
|
569
|
+
Object.assign(durability, {
|
|
570
|
+
at: Date.now(), ok: false, ms: Date.now() - t0, error: errText(error),
|
|
571
|
+
sessionId: sessionId ?? null, revision: null, revisionChanged: null,
|
|
572
|
+
});
|
|
573
|
+
warn('落盘确认失败(不影响投递)', { error: errText(error) });
|
|
574
|
+
}
|
|
575
|
+
return durability;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* 生成一次并落盘。
|
|
580
|
+
*
|
|
581
|
+
* ⚠️ **`.md` 就写 markdown**(2026-09-30 实测纠正):我原本想"一个文件既给人看、
|
|
582
|
+
* 又给机器读",把 markdown 塞成 JSON 里的一个字符串字段 —— 结果
|
|
583
|
+
* **你打开 `投递状态.md` 看到的是 `{"generatedAt":…}`,根本不能读**。
|
|
584
|
+
* ⇒ 分成两份:**`.md` 是给人看的纯 markdown**;**机器镜像写进存储的 `misc` 表**。
|
|
585
|
+
*/
|
|
586
|
+
function generate(reason) {
|
|
587
|
+
const text = renderStatus({
|
|
588
|
+
store: { ...store, lastWrite },
|
|
589
|
+
config: { ...config, __version: api.version },
|
|
590
|
+
/**
|
|
591
|
+
* ⭐⭐ **"写了没人看"的那些东西**(2026-10-01 加,见"健康"段那段注释)。
|
|
592
|
+
*
|
|
593
|
+
* ⚠️ **每一项都是真读出来的,一个都不许糊弄**:
|
|
594
|
+
* - `lastFileError` 从 store 模块**实时读**(它是可变对象,读时才是最新)
|
|
595
|
+
* - `problems` 从 `api` 拿(`index.js` 建 log 时就攒着)
|
|
596
|
+
* - `fileTableCount` **现场数文件里有多少单子** ——
|
|
597
|
+
* 它是"单子到底有没有落盘"这个问题的直接答案(对着那次"回执成功、磁盘上没有"的事故加的)
|
|
598
|
+
*/
|
|
599
|
+
health: {
|
|
600
|
+
lastFileError,
|
|
601
|
+
problems: api.problems,
|
|
602
|
+
fileTableCount: (() => {
|
|
603
|
+
try { return listRecords(store.tables.tickets).length; } catch { return undefined; }
|
|
604
|
+
})(),
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
try {
|
|
608
|
+
writeTextFile(file, text);
|
|
609
|
+
Object.assign(lastStatusWrite, { at: Date.now(), ok: true, error: null, file });
|
|
610
|
+
log('状态表/已更新', { reason, file: safe(file) });
|
|
611
|
+
} catch (error) {
|
|
612
|
+
Object.assign(lastStatusWrite, { at: Date.now(), ok: false, error: errText(error), file });
|
|
613
|
+
warn('状态表写不出去(不影响投递)', { reason, error: errText(error) });
|
|
614
|
+
}
|
|
615
|
+
// 机器可读的镜像(计数 / 版本 / 存储位置)—— **存进存储**,不污染那个 .md。
|
|
616
|
+
try {
|
|
617
|
+
const tickets = listRecords(store.tables.tickets);
|
|
618
|
+
void putTracked(store.tables.misc, 'status:mirror', {
|
|
619
|
+
value: JSON.stringify({
|
|
620
|
+
at: Date.now(),
|
|
621
|
+
version: api.version,
|
|
622
|
+
store: store.where,
|
|
623
|
+
pending: tickets.filter(([, r]) => r?.takenBy === null || r?.takenBy === undefined).length,
|
|
624
|
+
taken: tickets.filter(([, r]) => r?.takenBy !== null && r?.takenBy !== undefined).length,
|
|
625
|
+
file,
|
|
626
|
+
reason,
|
|
627
|
+
}),
|
|
628
|
+
at: Date.now(),
|
|
629
|
+
}).catch(() => { /* 镜像写不进去无所谓 —— 权威是那个 .md 和 tickets 表 */ });
|
|
630
|
+
} catch { /* 同上 */ }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** 立刻生成一次(合并同一 tick 里的多次请求)。 */
|
|
634
|
+
function flushStatus(reason = '手动') {
|
|
635
|
+
dirty = false;
|
|
636
|
+
generate(reason);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── 两个工具:让人/我能**手动**验这条链路 ─────────────────────────────────
|
|
640
|
+
ctx.effect(() => ctx.inject(['tools'], (tctx) => {
|
|
641
|
+
/**
|
|
642
|
+
* ① 重新生成状态表。
|
|
643
|
+
*
|
|
644
|
+
* ⭐ `verify: true` 时**顺便做落盘确认**(`flush()` 之后 `stat()`)——
|
|
645
|
+
* 这正是"**真的落盘了吗**"那个问题的**唯一正确回答方式**。
|
|
646
|
+
*/
|
|
647
|
+
tctx.tools.register({
|
|
648
|
+
name: 'refresh_status',
|
|
649
|
+
description:
|
|
650
|
+
'重新生成给人看的送达状态表(`00-通用\\投递状态.md`)。'
|
|
651
|
+
+ '传 `verify: true` 会**顺带做落盘确认**:先调 `sessionPersistence.flush()`(持久屏障),'
|
|
652
|
+
+ '再 `stat()` 读回 —— **只 `stat()` 不能证明落盘**(后端可能延迟物理落盘)。',
|
|
653
|
+
parameters: {
|
|
654
|
+
type: 'object',
|
|
655
|
+
properties: {
|
|
656
|
+
verify: { type: 'boolean', description: '是否顺带做落盘确认(flush + stat),默认 true' },
|
|
657
|
+
},
|
|
658
|
+
required: [],
|
|
659
|
+
additionalProperties: false,
|
|
660
|
+
},
|
|
661
|
+
output: {
|
|
662
|
+
schema: { type: 'string' },
|
|
663
|
+
render: (_a, v) => [{ type: 'text', text: String(v) }],
|
|
664
|
+
},
|
|
665
|
+
execute: async (args, exec) => {
|
|
666
|
+
flushStatus('手动');
|
|
667
|
+
const out = [`状态表已重新生成:\`${safe(file)}\``];
|
|
668
|
+
// 计数**现算**(不再读文件里的镜像 —— 那个镜像现在存在存储里,不是文件里)
|
|
669
|
+
const rows = listRecords(store.tables.tickets);
|
|
670
|
+
const pending = rows.filter(([, r]) => r?.takenBy === null || r?.takenBy === undefined).length;
|
|
671
|
+
out.push(`单子:待取 ${pending} · 已取 ${rows.length - pending}`);
|
|
672
|
+
if (args?.verify !== false) {
|
|
673
|
+
const d = await verifyDurability(exec);
|
|
674
|
+
out.push(d.ok === true
|
|
675
|
+
? `落盘确认:✅ 通过(flush ${d.ms} ms,stat revision ${d.revisionChanged === true ? '已变化' : '未变'})`
|
|
676
|
+
: `落盘确认:${d.ok === null ? '⚠️ 跳过' : '❌ 失败'} —— ${d.error ?? ''}`);
|
|
677
|
+
// 确认结果也要写进文件(否则仪表盘上永远看不到它)
|
|
678
|
+
generate('落盘确认');
|
|
679
|
+
}
|
|
680
|
+
return out.join('\n');
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
/** ② 只做落盘确认(不改状态表内容)。 */
|
|
685
|
+
tctx.tools.register({
|
|
686
|
+
name: 'probe_flush',
|
|
687
|
+
description:
|
|
688
|
+
'【只读探针】只做**持久屏障**验证:`sessionPersistence.flush()` + `stat()`。'
|
|
689
|
+
+ '用来回答"这个会话的日志到底落盘了没有"。唯一动作是一次 `flush()`(不产生新事件)。',
|
|
690
|
+
parameters: {
|
|
691
|
+
type: 'object',
|
|
692
|
+
properties: {
|
|
693
|
+
what: { type: 'string', description: 'flush | stat | all(默认 all)' },
|
|
694
|
+
},
|
|
695
|
+
required: [],
|
|
696
|
+
additionalProperties: false,
|
|
697
|
+
},
|
|
698
|
+
output: {
|
|
699
|
+
schema: { type: 'string' },
|
|
700
|
+
render: (_a, v) => [{ type: 'text', text: String(v) }],
|
|
701
|
+
},
|
|
702
|
+
execute: async (args, exec) => {
|
|
703
|
+
const d = await verifyDurability(exec);
|
|
704
|
+
const out = [];
|
|
705
|
+
out.push(`本会话:\`${d.sessionId ?? '(取不到)'}\``);
|
|
706
|
+
out.push(d.ok === true
|
|
707
|
+
? `flush() → ✅ ${d.ms} ms`
|
|
708
|
+
: `flush() → ${d.ok === null ? '⚠️ 跳过(取不到服务)' : `❌ ${d.error}`}`);
|
|
709
|
+
if (d.revision !== null) {
|
|
710
|
+
out.push(`stat() revision:\`${String(d.revision).slice(0, 60)}\``);
|
|
711
|
+
out.push(`flush 前后是否变化:${d.revisionChanged === true ? '变了' : '**没变**(内容没动时正常 —— revision 是内容指纹,不是落盘信号)'}`);
|
|
712
|
+
}
|
|
713
|
+
out.push('(判断依据:**`flush()` 是持久屏障**;单看 `stat()` 会有假阳性。)');
|
|
714
|
+
return out.join('\n');
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
}));
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
/** 状态变了 —— **安排一次生成**(同一 tick 多次请求只写一次)。 */
|
|
721
|
+
request(reason) {
|
|
722
|
+
dirty = true;
|
|
723
|
+
if (scheduled) return;
|
|
724
|
+
scheduled = true;
|
|
725
|
+
// 微任务:把"一轮里发生的多次变化"合并成一次写。
|
|
726
|
+
void Promise.resolve().then(() => {
|
|
727
|
+
scheduled = false;
|
|
728
|
+
if (dirty) flushStatus(reason);
|
|
729
|
+
});
|
|
730
|
+
},
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* ⚠️ **这里原来是方法体,现在只是转发给上面那个具名函数**(2026-10-01 修 bug)。
|
|
736
|
+
*
|
|
737
|
+
* 那次 bug 的成因就是"**有两份**":工具里裸名字调一份(不存在的),
|
|
738
|
+
* 返回对象里定义另一份。**⇒ 现在只有一份,两边都指向它。**
|
|
739
|
+
*
|
|
740
|
+
* 实测:**`flush()` 前后 `revision` 一模一样**(它是**内容**的指纹,不是持久状态)。
|
|
741
|
+
* 契约也说"相等可当没变;**不等什么都不保证**"。
|
|
742
|
+
* ⇒ 这里只把 `revision` 当**参考**记下来,**不拿它下结论**。
|
|
743
|
+
*/
|
|
744
|
+
verifyDurability,
|
|
745
|
+
|
|
746
|
+
/** 供工具/自测读当前状态(不含 markdown)。 */
|
|
747
|
+
snapshot() {
|
|
748
|
+
return {
|
|
749
|
+
file,
|
|
750
|
+
durability: { ...durability },
|
|
751
|
+
lastStatusWrite: { ...lastStatusWrite },
|
|
752
|
+
};
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
/** 自测/诊断用:立刻生成一次。 */
|
|
756
|
+
generateNow: flushStatus,
|
|
757
|
+
|
|
758
|
+
/** 读回刚写下的状态文件(自测用)—— **纯 markdown 文本**。 */
|
|
759
|
+
readBack: () => readTextFile(file),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/** 在给定 ctx 上找 `sessionPersistence`(**两条路都试** —— 根 ctx 与子 ctx 形态不同)。 */
|
|
763
|
+
function findPersistence(c) {
|
|
764
|
+
if (c === undefined || c === null) return undefined;
|
|
765
|
+
try {
|
|
766
|
+
if (c.sessionPersistence !== undefined && c.sessionPersistence !== null) return c.sessionPersistence;
|
|
767
|
+
} catch { /* 试下一条 */ }
|
|
768
|
+
try {
|
|
769
|
+
const viaGet = typeof c.get === 'function' ? c.get('sessionPersistence') : undefined;
|
|
770
|
+
if (viaGet !== undefined && viaGet !== null) return viaGet;
|
|
771
|
+
} catch { /* 放弃 */ }
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|