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
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
- insert:
|
|
2
|
+
- id: bulletin-dispatch
|
|
3
|
+
name: dsh-bulletin-dispatch
|
|
4
|
+
# ⚠️ **这里故意几乎不配任何东西**(2026-10-01)。
|
|
5
|
+
#
|
|
6
|
+
# 原来这份文件里写着**作者本机的路径和桌名** —— 那是"能不能进仓库"的硬伤:
|
|
7
|
+
# · 对别人**一定是错的**,而且**错得不出声**(插件会安静地去读一个不存在的目录)
|
|
8
|
+
# · 一旦提交,它就**永久留在 git 历史里**
|
|
9
|
+
#
|
|
10
|
+
# ⇒ 现在这份只做一件事:**把插件登记进来**。
|
|
11
|
+
#
|
|
12
|
+
# ## ⭐ 你的环境配置该写在哪
|
|
13
|
+
#
|
|
14
|
+
# 写在**你自己的 profile patch** 里,**不要写在这里**:
|
|
15
|
+
#
|
|
16
|
+
# ```
|
|
17
|
+
# <harness>\profiles\<你的 profile>\cordis.patch.yml
|
|
18
|
+
#
|
|
19
|
+
# - id: bulletin-dispatch
|
|
20
|
+
# name: dsh-bulletin-dispatch
|
|
21
|
+
# config:
|
|
22
|
+
# workspaceRoot: D:\my-office
|
|
23
|
+
# deskNames:
|
|
24
|
+
# "01": 甲组
|
|
25
|
+
# "02": 乙组
|
|
26
|
+
# statusFile: D:\my-office\00-通用\投递状态.md
|
|
27
|
+
# ```
|
|
28
|
+
#
|
|
29
|
+
# ⚠️ **Cordis 的覆盖会整条替换该条目的配置** ——
|
|
30
|
+
# 所以在 profile patch 里**要写全你需要的字段**,不能只写想改的那几个。
|
|
31
|
+
#
|
|
32
|
+
# ## 必填的一项
|
|
33
|
+
#
|
|
34
|
+
# - `workspaceRoot` —— 工作区根(用于路径归一,和判断"哪些路径算办公室范围")
|
|
35
|
+
#
|
|
36
|
+
# ⚠️ **没配的后果**(2026-10-03 照代码核过,原来这里写的是"插件装不上"—— 不准):
|
|
37
|
+
# 插件**照常加载**,但**状态表不会装配**,日志里出一条警告。
|
|
38
|
+
# ⇒ 症状是"面板里没有投递页 / 没有 `00-通用\投递状态.md`",而不是装不上。
|
|
39
|
+
#
|
|
40
|
+
# ## 可选(不配就用通用默认值)
|
|
41
|
+
#
|
|
42
|
+
# - `features` —— 功能开关。**这是一整个对象,不配就用全默认**(**每一项默认都是开的**);
|
|
43
|
+
# 它的子项都带 `.default(true)` ⇒ **想少要功能是"逐个关",不是"逐个开"**
|
|
44
|
+
# (⚠️ 原来这里写成"想只用一部分就逐个开" —— 说反了)。
|
|
45
|
+
# 子项:`identity` · `sessionGc` · `proposeRename` · `dispatch` · `dispatchTools` · `status`。
|
|
46
|
+
# **清单里没有的功能就是没有的**(比如曾经的 `guard` 已删,不是"关着而已")。
|
|
47
|
+
# - `deskNames` —— 桌号 → 桌名。**不配也能跑**(显示成"桌 01")
|
|
48
|
+
# - `identityNotice` —— 认桌成功后注入的那一句(`{desk}` / `{title}` 会被替换)
|
|
49
|
+
# - `recheckMs` —— 多久检查一次会话标题(认桌)
|
|
50
|
+
# - `sweepIntervalMs` —— 会话回收的节流(毫秒)。0 = 关掉回收
|
|
51
|
+
# - `statusFile` —— 状态表输出到哪。**留空 ⇒ `<workspaceRoot>\00-通用\投递状态.md`**
|
|
52
|
+
# - `stateFile` · `mirrorToDomain` —— 存储落哪(一般不用动)
|
|
53
|
+
# - `debugLog` —— 诊断日志。**默认关闭** —— 排查时才填一个路径
|
package/index.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨桌投递插件 `dsh-bulletin-dispatch`
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **架构要求(2026-09-30 明确)**:
|
|
5
|
+
* *"不用一个功能写一个插件,而是一个插件包含多个功能,我们后面开发的功能可以直接装进插件里。"*
|
|
6
|
+
* *"既然要做插件,就要做好来,不是极简也不是解决单一问题。"*
|
|
7
|
+
*
|
|
8
|
+
* ⇒ **一个包、多功能、配置里逐个开关**(**不要**运行时扫目录/动态 import ——
|
|
9
|
+
* 平台没有热插拔,加功能一定要重装+重启,动态那点优势根本用不上;
|
|
10
|
+
* 而且开关式的行为完全由"代码版本 + 配置"决定,好排查)。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **每个功能都必须 fail-open**(一个包装多个功能 ⇒ 任何一个写错就是每个会话都受影响):
|
|
13
|
+
* **读文件失败、解析失败、存储失败、平台 API 抛错 —— 一律只记日志,绝不冒泡。**
|
|
14
|
+
*
|
|
15
|
+
* ⚠️⚠️ **这个包现在只有 6 个功能,而且"就这些了"**(2026-09-30):
|
|
16
|
+
* `identity`(认桌)· `sessionGc`(会话回收 —— 清掉已删除会话的记录)·
|
|
17
|
+
* `proposeRename`(提议改名)· `dispatch`(按桌投递)·
|
|
18
|
+
* `dispatchTools`(发单/看单)· `status`(给人看的状态表)。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **2026-10-01 更正**:这段原来写"**5 个**"、并列了 5 个名字 ——
|
|
21
|
+
* **而下面 `FEATURES` 里是 6 个**(`sessionGc` 被漏掉了)。**数字和名单都对不上。**
|
|
22
|
+
* ⭐ **教训**:**"总共 N 个"这种句子,要么不写,要么让它和那份名单挨着** ——
|
|
23
|
+
* 分开写就一定会漂,而**它漂了多久没人知道**。
|
|
24
|
+
*
|
|
25
|
+
* **开工包里的功能 3/4/5/6 全部取消或暂停**(写入护栏 / 事实表+扫副本 / 体检分发 / Codex 桥)——
|
|
26
|
+
* ⚠️ **别把这几个编号和上面那 6 个搞混**:那是**开工包里被砍掉的编号**,不是同一套。
|
|
27
|
+
* 它们共用同一个模式:*"自动检测到异常 → 自动投给某张桌"*。
|
|
28
|
+
* 而**投递单的价值恰在于:指针型 · 由有判断力的一方发出 · 不需要回执** ⇒
|
|
29
|
+
* **把它自动化,得到的是"更多单子",不是"更少跨桌改动"。**
|
|
30
|
+
* 决定与证据:见 `docs\05-取舍与放弃.md`
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ **模块顶层不做会抛错的事** —— 2026-09-29 公告插件就是因为域名带连字符
|
|
33
|
+
* 在模块加载时抛错,导致 **DSH 起不来**(那条正则在 `defineDomain` 里,`store.js` 已注明)。
|
|
34
|
+
*/
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { dirname } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import z from 'schemastery';
|
|
39
|
+
import { createLog, errText } from './src/log.js';
|
|
40
|
+
import { DOMAIN_NAME, domainSpec, openStore, resetStoreForTest, TABLES } from './src/store.js';
|
|
41
|
+
import { setup as setupIdentity } from './src/features/f0-identity.js';
|
|
42
|
+
import { setup as setupSessionGc } from './src/features/f0b-session-gc.js';
|
|
43
|
+
import { setup as setupProposeRename } from './src/features/f1-propose-rename.js';
|
|
44
|
+
import { setup as setupDispatch } from './src/features/f2-dispatch.js';
|
|
45
|
+
import { setup as setupDispatchTools } from './src/features/f2b-dispatch-tools.js';
|
|
46
|
+
import { setup as setupStatus } from './src/features/f3-status.js';
|
|
47
|
+
|
|
48
|
+
export const name = 'bulletin-dispatch';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ⭐ **把"留空的配置项"推导出一个默认路径**(2026-10-01 加)。
|
|
52
|
+
*
|
|
53
|
+
* ## 为什么需要它
|
|
54
|
+
*
|
|
55
|
+
* 这个插件的默认值原来是**写死的本机绝对路径**(`<作者的工作区>\…`)。
|
|
56
|
+
* 那种默认值对别人**一定是错的** —— 而更糟的是:**它会安静地在别处建文件**,
|
|
57
|
+
* 用户根本不知道为什么桌面上多了个目录。
|
|
58
|
+
*
|
|
59
|
+
* ⇒ 现在那些键**默认留空**,由这里**从 `workspaceRoot` 推导**:
|
|
60
|
+
*
|
|
61
|
+
* ```
|
|
62
|
+
* workspaceRoot = <你的工作区>
|
|
63
|
+
* statusFile = <你的工作区>\00-通用\投递状态.md
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ **用反斜杠拼**(不用 `path.join`):`path.join` 在 Windows 上给正斜杠,
|
|
67
|
+
* 而这个项目的路径**全程是反斜杠** —— 混用会让日志和配置看起来像两种东西。
|
|
68
|
+
*/
|
|
69
|
+
function underRoot(workspaceRoot, ...segments) {
|
|
70
|
+
const root = String(workspaceRoot ?? '').replace(/[\\/]+$/u, '');
|
|
71
|
+
if (root === '') return '';
|
|
72
|
+
return [root, ...segments].join('\\');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 所有依赖都走可选查找(`ctx.get`),缺了任何一个都不该让插件装不上。 */
|
|
76
|
+
export const inject = [];
|
|
77
|
+
|
|
78
|
+
/** 自己故意抛的错都带这个前缀 —— 便于"识别是不是我拦的"(见文件头)。 */
|
|
79
|
+
export const GUARD_PREFIX = '[bulletin-dispatch]';
|
|
80
|
+
|
|
81
|
+
export const Config = z.object({
|
|
82
|
+
/**
|
|
83
|
+
* ⭐ **办公室的根目录** —— 公告、投递状态、信箱这些都在它下面。
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ **这个没有默认值**(2026-10-01 改的):原来默认写死了作者本机的路径。
|
|
86
|
+
* 一个不写默认值的必填项,**比一个猜错的默认值好** ——
|
|
87
|
+
* 猜错的话,插件会安静地在别处建文件,而你不知道为什么。
|
|
88
|
+
*
|
|
89
|
+
* ## ⭐⭐ **怎么填(2026-10-01 说清了,这是最容易被误读的一条)**
|
|
90
|
+
*
|
|
91
|
+
* **填"这间办公室在哪",不是"你的工作区在哪"。**
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ **它们可以是两个完全不同的地方** ——
|
|
94
|
+
* 这个插件服务的是**同一台电脑上的所有 DSH 会话**,
|
|
95
|
+
* **它们的工作区可以各不相同**(一张桌在 `D:\MyGame`,另一张在 `D:\DSH_workspace`),
|
|
96
|
+
* **而它们照样在同一间办公室里。**
|
|
97
|
+
*
|
|
98
|
+
* | 情况 | 填什么 |
|
|
99
|
+
* |---|---|
|
|
100
|
+
* | 几"桌"共用一个工作区 | 那个共同的上层目录 |
|
|
101
|
+
* | ⭐ **各桌工作区不同** | **另找一个中立目录当办公室**(`D:\办公室` 之类)—— 它不属于任何一张桌 |
|
|
102
|
+
*/
|
|
103
|
+
workspaceRoot: z.string()
|
|
104
|
+
.description('办公室工作区根。用于路径归一与"哪些路径算办公室范围"'),
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ⭐ **功能开关** —— 加新功能就在这里加一个键,代码里带全部功能。
|
|
108
|
+
* 关掉的功能**完全不装配**(连监听器都不挂),而不是"装配了但不做事"。
|
|
109
|
+
*/
|
|
110
|
+
features: z.object({
|
|
111
|
+
identity: z.boolean().default(true).description('认桌:读会话标题的 NN- 前缀,开桌时自报身份'),
|
|
112
|
+
sessionGc: z.boolean().default(true)
|
|
113
|
+
.description('会话回收:把平台侧**已删除**的会话从记录里清掉(判据是 `sessionPersistence.stat()` 返回 undefined;'
|
|
114
|
+
+ '⚠️ 实测过:它认得"没在跑的旧会话",不会误删历史)'),
|
|
115
|
+
proposeRename: z.boolean().default(true)
|
|
116
|
+
.description('提议改名:认不出桌时提议一次(**改不改由用户点头**,AI 用 set_session_title 工具改)'),
|
|
117
|
+
dispatch: z.boolean().default(true)
|
|
118
|
+
.description('按桌投递:单子挂在"桌"上,**谁先来谁取走**,写进系统提示(不占消息流)'),
|
|
119
|
+
dispatchTools: z.boolean().default(true)
|
|
120
|
+
.description('投递工具:`dispatch_ticket`(发单)与 `list_tickets`(看本桌待取)'),
|
|
121
|
+
status: z.boolean().default(true)
|
|
122
|
+
.description('送达状态表:生成 `00-通用\\投递状态.md`(**用户自己看的仪表盘**,派生视图)'),
|
|
123
|
+
/**
|
|
124
|
+
* ⚠️ **这里曾经有一个 `guard` 开关,2026-09-30 删掉了**(功能 3「写入护栏」,2026-09-30 取消)。
|
|
125
|
+
*
|
|
126
|
+
* **为什么删掉开关、而不是留着**:挂着一个"永远不实现的开关"本身就是误导 ——
|
|
127
|
+
* 看配置的人会以为"关着而已,打开就有护栏"。**清单里没有的,就是没有的。**
|
|
128
|
+
*
|
|
129
|
+
* 取消的两条理由(③ 是技术事实,跟流量无关):
|
|
130
|
+
* ① 公告(用户裁决)+ 投递单(AI 自主)两个通道已覆盖"跨桌协作"的真实需求
|
|
131
|
+
* ② 路径判据**分不清"许可的交付/部署"和"越权改动"**(路径长得一模一样)
|
|
132
|
+
* ⇒ 要么误伤交付、要么加一堆例外 ⇒ 最后没人信它
|
|
133
|
+
*
|
|
134
|
+
* ⚠️⚠️ **功能 4 / 5 / 6 也一并取消了**(2026-09-30,独立评审背书)——
|
|
135
|
+
* 所以 `facts` / `checkup` / `codex` 这三个开关**也删了**,
|
|
136
|
+
* 连它们的配置骨架(`factsFile` / `scanRoots` / `existenceRoots` / `textExtensions`)一起删。
|
|
137
|
+
*
|
|
138
|
+
* **三个功能是同一个东西的三个实例**:*"自动检测到异常 → 自动投给某张桌"*。
|
|
139
|
+
* 而投递单的价值恰在于:**指针型、由有判断力的一方发出、不需要回执** ——
|
|
140
|
+
* **把它自动化,得到的是"更多单子",不是"更少跨桌改动"。**
|
|
141
|
+
*
|
|
142
|
+
* 决定与证据:见 `docs\05-取舍与放弃.md`
|
|
143
|
+
*/
|
|
144
|
+
}).description('逐个功能的开关。关掉的功能不装配'),
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 桌号 → 桌名。用于"办公室有哪几张桌"的提示与名字建议。
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ **默认空**(2026-10-01):原来这里写着作者办公室的四张真实桌名 ——
|
|
150
|
+
* 那种默认值对别人**没有意义**,而且会让人以为"必须配成那样"。
|
|
151
|
+
*
|
|
152
|
+
* **不配也能跑**:状态表会显示成「桌 01」「桌 02」,功能一个不少。
|
|
153
|
+
* **配了更好看**:认桌提示和状态表里会用你的名字。
|
|
154
|
+
*/
|
|
155
|
+
deskNames: z.dict(z.string()).default({})
|
|
156
|
+
.description('桌号 → 桌名,如 { "02": "环境维护" }。**不配也能跑**(显示成"桌 02")'),
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 会话回收的**节流**(毫秒)。`0` = 关掉回收(配置可关)。
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ 为什么必须节流:判据 `sessionPersistence.stat()` 是**异步**的,
|
|
162
|
+
* 每轮把全部会话查一遍不合适。默认 **10 分钟**一次 ——
|
|
163
|
+
* 用户删会话那种事**不需要秒级反应**。
|
|
164
|
+
*/
|
|
165
|
+
sweepIntervalMs: z.number().min(0).default(600_000)
|
|
166
|
+
.description('会话回收的全查间隔(毫秒)。0 = 关掉回收'),
|
|
167
|
+
|
|
168
|
+
/** 开桌时那条"报身份"的消息。`{desk}` / `{title}` 会被替换。 */
|
|
169
|
+
identityNotice: z.string().default(
|
|
170
|
+
'(跨桌投递:本会话认作 **{desk}** —— 从会话标题「{title}」读出来的。'
|
|
171
|
+
+ '本桌的投递单会送到这里。)',
|
|
172
|
+
).description('认桌成功后注入的那一句。`{desk}`=桌号、`{title}`=会话标题;留空 = 不注入'),
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 重读标题的冷却(毫秒)。
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ **为什么必须有这个**(2026-09-30 说明的一个硬事实):
|
|
178
|
+
* **开新会话不能直接改名** —— 得先发一条消息、产生真实会话,**然后**才能手动改。
|
|
179
|
+
* ⇒ "平台先给自动标题、用户后改名"是**必然顺序**,不是偶发。
|
|
180
|
+
* ⇒ 所以"没认出桌"的会话必须允许**重读**,否则永远认在自动标题上(如「打招呼问候」)。
|
|
181
|
+
*
|
|
182
|
+
* 冷却只是防止每步都读(`readTitle` 有成本)。`0` = 每步都读(最灵敏、最费)。
|
|
183
|
+
*/
|
|
184
|
+
recheckMs: z.number().default(10_000)
|
|
185
|
+
.description('没认出桌时,隔多久重读一次标题。0 = 每步都读'),
|
|
186
|
+
|
|
187
|
+
/** 状态持久化的兜底文件(域打不开时用;域正常时也可能被读来做并集)。 */
|
|
188
|
+
stateFile: z.string().default('')
|
|
189
|
+
.description('状态文件(**这是权威存储**)。留空 = 用 DSH 家目录的 storages\\bulletin_dispatch_state.json'),
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 是否**额外**把状态镜像一份到平台存储域。
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ **默认关**(2026-09-30 后实测决定的):
|
|
195
|
+
* 平台存储域在这个环境里**打开成功、写却不落盘、也不报错**(证据见 `进度与待办.md` 二之八之七),
|
|
196
|
+
* 而文件存储**真的能写**。⇒ 文件是唯一权威;域镜像只是可选的额外备份,
|
|
197
|
+
* **默认关掉它,让主路径彻底不受域影响。**
|
|
198
|
+
*/
|
|
199
|
+
mirrorToDomain: z.boolean().default(false)
|
|
200
|
+
.description('额外把状态镜像到平台存储域(默认关;域在这个环境里写不落盘)'),
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* ⚠️⚠️ **这里曾经有 4 个"功能 4 用"的配置骨架,2026-09-30 一起删了**:
|
|
204
|
+
* `factsFile` / `scanRoots` / `existenceRoots` / `textExtensions`。
|
|
205
|
+
*
|
|
206
|
+
* **为什么删**:功能 4 已取消(2026-09-30)⇒ **留着这些键就是"挂着一个永远不实现的开关"**,
|
|
207
|
+
* 而那个先例是我们自己在功能 3 上立的(见 `features` 那段长注释)。
|
|
208
|
+
* **看配置的人会以为"骨架都在,只差实现"** —— 那是误导。
|
|
209
|
+
*
|
|
210
|
+
* 要用的那天再写回来(成本很低:事实表就 8 行、扫描判据也已经实测过,
|
|
211
|
+
* 证据都在 `docs\05-取舍与放弃.md`)。
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* ⚠️ **这里曾经有一个 `ticketsFile`(默认 `00-通用\投递.jsonl`),2026-09-30 删掉了。**
|
|
216
|
+
*
|
|
217
|
+
* 它是**功能 2 早期设计的残留**:那时打算把单子存成一个 JSONL 文件。
|
|
218
|
+
* 实际实现走的是**存储层**(文件 `harness\storages\bulletin_dispatch_state.json`,
|
|
219
|
+
* 表 `tickets` / `claims` / `sessions` / `misc`)——
|
|
220
|
+
* ⇒ 那个键**从未被读过一次**,而它指的 `投递.jsonl` **在磁盘上根本不存在**。
|
|
221
|
+
*
|
|
222
|
+
* **⇒ 删掉**,理由与"取消的功能不留空开关"同一条(见 `features` 那段长注释):
|
|
223
|
+
* **挂着一个永远不生效的键,会让读配置的人以为"单子存在那个文件里"。**
|
|
224
|
+
* (这个键是 2026-09-30 办公室体检发现的 —— 见 `00-通用\办公室体检\体检报告-20260930.md`。)
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 状态表输出到哪(功能 1)。
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ **默认值由 `workspaceRoot` 推导**(2026-10-01):
|
|
231
|
+
* 原来是写死的作者本机路径 —— 那种默认值对别人**一定是错的**,
|
|
232
|
+
* 而错了之后它会**安静地生成在别处**。
|
|
233
|
+
* ⇒ 现在**留空就推导**:`<workspaceRoot>\00-通用\投递状态.md`。
|
|
234
|
+
*/
|
|
235
|
+
statusFile: z.string().default('')
|
|
236
|
+
.description('人看的送达状态表(插件自动生成,不要手改)。留空 = <工作区>\\00-通用\\投递状态.md'),
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 诊断日志(JSONL)。**留空 = 关闭**。
|
|
240
|
+
*
|
|
241
|
+
* ⚠️ 默认值也改成空了(2026-10-01):原来写死作者本机的 `tmp\…`。
|
|
242
|
+
* **一个"默认就会写日志"的插件,会在别人机器上到处留文件。**
|
|
243
|
+
*/
|
|
244
|
+
debugLog: z.string().default('')
|
|
245
|
+
.description('诊断日志。留空 = 关闭(排查时才有用)'),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/** 功能装配表:**加新功能 = 加一个 features/fN-*.js + 这里加一行 + 配置加一个开关**。 */
|
|
249
|
+
const FEATURES = [
|
|
250
|
+
{ key: 'identity', title: '认桌(骨架)', setup: setupIdentity },
|
|
251
|
+
{ key: 'sessionGc', title: '会话回收(清掉已删除的会话记录)', setup: setupSessionGc },
|
|
252
|
+
{ key: 'proposeRename', title: '提议改名(用户点头才改)', setup: setupProposeRename },
|
|
253
|
+
{ key: 'dispatch', title: '按桌投递(谁先来谁取走)', setup: setupDispatch },
|
|
254
|
+
{ key: 'dispatchTools', title: '投递工具(发单 / 看本桌待取)', setup: setupDispatchTools },
|
|
255
|
+
{ key: 'status', title: '送达状态表(给人看的仪表盘)', setup: setupStatus, needsStatus: true },
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 版本号:**读真的 package.json**。
|
|
260
|
+
*
|
|
261
|
+
* 2026-09-29 两次因为"装的是新版、跑的是旧版"白查半天 ⇒
|
|
262
|
+
* **日志里没有版本号就无法自证**,所以挂载时自报版本。
|
|
263
|
+
* 用 `import.meta.url` 定位:装好之后 `index.js` 与 `package.json` 同目录。
|
|
264
|
+
*/
|
|
265
|
+
const PLUGIN_VERSION = (() => {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(readFileSync(`${dirname(fileURLToPath(import.meta.url))}/package.json`, 'utf8')).version ?? 'unknown';
|
|
268
|
+
} catch { return 'unknown'; }
|
|
269
|
+
})();
|
|
270
|
+
|
|
271
|
+
export function apply(ctx, config) {
|
|
272
|
+
/**
|
|
273
|
+
* 把留空的键推导成实际路径 —— **必须在建 log 之前做**(log 也要读 `debugLog`)。
|
|
274
|
+
* ⚠️ 不直接改 `config`(那是框架的对象)⇒ 复制一份再填。
|
|
275
|
+
*/
|
|
276
|
+
config = {
|
|
277
|
+
...config,
|
|
278
|
+
statusFile: config.statusFile !== '' && config.statusFile !== undefined
|
|
279
|
+
? config.statusFile
|
|
280
|
+
: underRoot(config.workspaceRoot, '00-通用', '投递状态.md'),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const { log, warn, problems } = createLog({ logPath: config.debugLog, tag: name });
|
|
284
|
+
|
|
285
|
+
const enabled = Object.entries(config.features ?? {})
|
|
286
|
+
.filter(([, on]) => on === true)
|
|
287
|
+
.map(([k]) => k);
|
|
288
|
+
|
|
289
|
+
log(`已挂载 v${PLUGIN_VERSION}`, {
|
|
290
|
+
version: PLUGIN_VERSION,
|
|
291
|
+
featuresEnabled: enabled,
|
|
292
|
+
featuresAll: Object.keys(config.features ?? {}),
|
|
293
|
+
workspaceRoot: config.workspaceRoot,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// 共享运行环境:每个功能拿到的是同一份 log / store / config。
|
|
297
|
+
const api = { ctx, config, log, warn, problems, store: undefined, version: PLUGIN_VERSION };
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ⭐⭐ **用 `inject` 拿存储,而不是 `ctx.get`**(2026-09-30 实测纠正)。
|
|
301
|
+
*
|
|
302
|
+
* ## 失败现场
|
|
303
|
+
*
|
|
304
|
+
* 原来在 `apply()` 里调 `ctx.get('storageDomain')`,**热重载后连续 7 次挂载每次都取不到**,
|
|
305
|
+
* 全退回了兜底文件存储。加了探针一看:
|
|
306
|
+
*
|
|
307
|
+
* ```
|
|
308
|
+
* probe: storageDomain=无 storage=无 sessionQuery=无
|
|
309
|
+
* ```
|
|
310
|
+
*
|
|
311
|
+
* **不只 `storageDomain`,连我确认能用的 `sessionQuery` 也是"无"** ⇒
|
|
312
|
+
* **`ctx.get` 在"插件刚挂载那一刻"根本不可靠** —— 那些服务是**之后**才挂到 ctx 上的。
|
|
313
|
+
*
|
|
314
|
+
* ## 为什么 `inject` 能解决
|
|
315
|
+
*
|
|
316
|
+
* 官方契约(`cordis_inspect` 的 Service 表里写着):
|
|
317
|
+
* `hardDependency: { inject: ['storageDomain'], expression: 'ctx.storageDomain' }`
|
|
318
|
+
* ⇒ **`inject` 就是"让框架保证依赖可用之后再叫我"**。
|
|
319
|
+
* 用 `ctx.inject(['storageDomain'], cb)` 包起来 ⇒ **回调触发时服务一定在**。
|
|
320
|
+
*
|
|
321
|
+
* ⚠️ 这同时解释了 2026-09-29 那次"`storageDomain` 缺失"的误判 ——
|
|
322
|
+
* **根本原因就是查得太早**,只是当时靠"懒打开"绕了过去,没找到这一层。
|
|
323
|
+
*/
|
|
324
|
+
const storeReady = new Promise((resolve) => {
|
|
325
|
+
let settled = false;
|
|
326
|
+
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
327
|
+
try {
|
|
328
|
+
ctx.inject(['storageDomain'], (sctx) => {
|
|
329
|
+
void openStore(sctx, {
|
|
330
|
+
file: config.stateFile !== '' ? config.stateFile : undefined,
|
|
331
|
+
log,
|
|
332
|
+
warn,
|
|
333
|
+
mirrorToDomain: config.mirrorToDomain === true,
|
|
334
|
+
}).then(done, (error) => {
|
|
335
|
+
warn('存储打开失败(功能会用不了,但会话不受影响)', { error: errText(error) });
|
|
336
|
+
done(undefined);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
/**
|
|
340
|
+
* 兜底:万一 `inject` 永远不触发(服务确实不在),
|
|
341
|
+
* **别让功能永远等不到存储**(那会导致"装上了但什么都不做"这种最难查的状态)。
|
|
342
|
+
*
|
|
343
|
+
* ⚠️⚠️ **`ctx.setTimeout` 不能"读一下看看在不在"**(2026-09-30 实测):
|
|
344
|
+
* 平台 ctx 用 getter 实现它,**读那个属性本身就会抛**
|
|
345
|
+
* `Error: cannot get property "timer" without inject`
|
|
346
|
+
* ⇒ 我原来写的 `typeof ctx.setTimeout === 'function'` **拦不住**(读的时候已经炸了),
|
|
347
|
+
* 而那次异常被外层 `catch` 吞掉 ⇒ **兜底计时器根本没设上**。
|
|
348
|
+
* ⇒ 现在:**用 try/catch 包住那次读取**,读不到就用全局 `setTimeout`。
|
|
349
|
+
*/
|
|
350
|
+
const later = (() => {
|
|
351
|
+
try {
|
|
352
|
+
if (typeof ctx.setTimeout === 'function') return (fn, ms) => ctx.setTimeout(fn, ms);
|
|
353
|
+
} catch { /* 读它就抛 ⇒ 用全局的 */ }
|
|
354
|
+
return (fn, ms) => setTimeout(fn, ms);
|
|
355
|
+
})();
|
|
356
|
+
later(() => {
|
|
357
|
+
if (settled) return;
|
|
358
|
+
log('等存储域超时(10 秒)—— 改用兜底文件存储装配功能', {});
|
|
359
|
+
void openStore(ctx, { file: config.stateFile !== '' ? config.stateFile : undefined, log, warn })
|
|
360
|
+
.then(done, () => done(undefined));
|
|
361
|
+
}, 10_000);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
warn('inject(storageDomain) 失败 —— 改用兜底文件存储', { error: errText(error) });
|
|
364
|
+
void openStore(ctx, { file: config.stateFile !== '' ? config.stateFile : undefined, log, warn })
|
|
365
|
+
.then(done, () => done(undefined));
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const ensureStore = () => storeReady;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* ⭐ **"状态变了"的通知口**(功能 1 用)。
|
|
373
|
+
*
|
|
374
|
+
* 谁变谁喊一声(发单 / 取走 / 认桌改名),由状态表决定**怎么写、写几次**
|
|
375
|
+
* —— 这样投递那边**不需要知道仪表盘的存在**,耦合是单向的。
|
|
376
|
+
* 功能 1 没开时这里就是空操作(fail-open)。
|
|
377
|
+
*/
|
|
378
|
+
let statusApi;
|
|
379
|
+
const onStateChanged = (reason) => {
|
|
380
|
+
try { statusApi?.request?.(reason); } catch { /* 仪表盘的问题绝不影响投递 */ }
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
for (const feature of FEATURES) {
|
|
384
|
+
if (config.features?.[feature.key] !== true) {
|
|
385
|
+
log('功能未开启,跳过装配', { feature: feature.key, title: feature.title });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
// 需要"状态变了"通知的功能,把口子接过去(**单向往外喊**)。
|
|
390
|
+
const extra = feature.needsStatus === true ? {} : { onStateChanged };
|
|
391
|
+
// 功能等存储就位再装配。
|
|
392
|
+
void ensureStore().then((store) => {
|
|
393
|
+
if (store === undefined) {
|
|
394
|
+
warn('存储不可用,该功能本次不装配', { feature: feature.key });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const built = feature.setup({ ...api, store, ...extra });
|
|
399
|
+
if (feature.needsStatus === true) {
|
|
400
|
+
statusApi = built;
|
|
401
|
+
// 装配完先出一次 —— 否则新装的仪表盘要等到"下一次状态变化"才有内容。
|
|
402
|
+
built?.generateNow?.('装配');
|
|
403
|
+
}
|
|
404
|
+
log('功能已装配', { feature: feature.key, title: feature.title, store: store.where });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
warn(`功能装配失败(已忽略):${feature.title}`, { feature: feature.key, error: errText(error) });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// 一个功能装不上,绝不能影响别的功能,更不能影响插件挂载。
|
|
411
|
+
warn(`功能装配抛错(已忽略):${feature.title}`, { feature: feature.key, error: errText(error) });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* ⭐ **卸载留痕**(0.1.3 加)。
|
|
417
|
+
*
|
|
418
|
+
* 为什么需要:2026-09-30 排查"认桌没生效"时,**分不清到底是**
|
|
419
|
+
* (a)处理器根本没被调用,还是(b)被调用了但判定不用重读。
|
|
420
|
+
* 有了这行,日志里就能看出"某个版本是不是被静默卸载了"。
|
|
421
|
+
*/
|
|
422
|
+
ctx.effect(() => () => {
|
|
423
|
+
log('已卸载', { version: PLUGIN_VERSION });
|
|
424
|
+
/**
|
|
425
|
+
* ⚠️⚠️ **刻意不 `close()` 存储域**(2026-09-30 实测,代价是丢了两张真单子)。
|
|
426
|
+
*
|
|
427
|
+
* ## 失败现场
|
|
428
|
+
*
|
|
429
|
+
* 12:50:56 与 12:53:54 两条 `投递/已发单` 都正常返回,**但介质里 `tickets: 0 条`**。
|
|
430
|
+
* 而 12:56:12 又有一次热挂载 ⇒ **旧实例卸载时把域 `close()` 了**,
|
|
431
|
+
* 那两条 `put` 还在**写链**里没落盘,**一起没了**。
|
|
432
|
+
*
|
|
433
|
+
* ## 为什么不该由我关
|
|
434
|
+
*
|
|
435
|
+
* `storageDomain` 是**全局单例**(同名域只能开一次,再开报 `already-open`),
|
|
436
|
+
* 而这个实例只是"当前用它的插件实例之一"。**为了我自己的热重载,
|
|
437
|
+
* 去关掉一个别人(包括我自己的下一个实例)还要用的共享资源 —— 这是我的错。**
|
|
438
|
+
*
|
|
439
|
+
* ⇒ 由**平台**在真正卸载时关闭(`dsh-storage-domain` 官方契约:
|
|
440
|
+
* *"Domains still open when the facility unmounts are closed by the plugin disposer"*)。
|
|
441
|
+
* ⇒ 我这边只留痕。**代价是热重载期间会短暂出现"域已被旧实例打开"**,
|
|
442
|
+
* 那时新实例退回兜底存储 —— **可以接受**(重启后一切归位),
|
|
443
|
+
* **总比丢数据好。**
|
|
444
|
+
*/
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 供**装机前验证**用的导出(不导出就无法断言"域版本 / 兼容声明"这类契约)。
|
|
450
|
+
*
|
|
451
|
+
* ⚠️ 两个注意:
|
|
452
|
+
* ① 平台不会读它 —— 只是把内部事实露给**仓库外那套自测**里的装机前验证用
|
|
453
|
+
* (⚠️ **那套自测没有随本仓库发布**,见 `docs\05` 末尾那段);
|
|
454
|
+
* ② **必须放在文件末尾** —— 放前面会因为读 `PLUGIN_VERSION` 撞上"临时死区"
|
|
455
|
+
* (`Cannot access 'PLUGIN_VERSION' before initialization`,实测踩到过)。
|
|
456
|
+
*/
|
|
457
|
+
export const __test = {
|
|
458
|
+
domainName: DOMAIN_NAME,
|
|
459
|
+
domainSpec,
|
|
460
|
+
tables: TABLES,
|
|
461
|
+
version: PLUGIN_VERSION,
|
|
462
|
+
/** 仅供自测:清掉域句柄缓存(模块级状态会跨场景)。 */
|
|
463
|
+
resetStoreForTest,
|
|
464
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-bulletin-dispatch",
|
|
3
|
+
"version": "1.3.19",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "跨桌投递:认桌(读会话标题的 NN- 前缀)+ 按桌投递(单子挂在「桌」上,谁先来谁取走)+ 会话回收 + 给人看的状态表。一个包、多功能、可逐个开关。",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/southsnowL/dsh-bulletin.git",
|
|
10
|
+
"directory": "plugins/dsh-bulletin-dispatch"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/southsnowL/dsh-bulletin#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/southsnowL/dsh-bulletin/issues"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"src/"
|
|
20
|
+
],
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
33
|
+
"@deepseek-ai/dsh-storage-domain": "*"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"schemastery": "^3.18.0",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
}
|
|
39
|
+
}
|