weflow-cli 1.15.1 → 1.17.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. package/src/utils/python.ts +33 -4
package/docs/REPLAY.md ADDED
@@ -0,0 +1,169 @@
1
+ # 时间重放(Replay)施工文档
2
+
3
+ > 目标:把「关系网络怎么长出来的」做成能按月回放的东西。数据全部来自本地库,不联网、不调模型。
4
+ >
5
+ > 本文是施工前的规格,含**探测到的真实数据形状**。所有数字来自 2026-10-25 对当前库的一次扫描。
6
+
7
+ ---
8
+
9
+ ## 1. 要做什么
10
+
11
+ 一条命令,按月回放:
12
+
13
+ ```
14
+ weflow-cli replay # 文本:每月一段,看数字与断边
15
+ weflow-cli replay --out out.html # 动画:按播放键,看边长出来
16
+ ```
17
+
18
+ **核心看点不是"图变大了"**(那只是数据变多),而是**边断掉、边新长出来**。
19
+
20
+ ---
21
+
22
+ ## 2. 数据现实(先看这个,它决定了设计)
23
+
24
+ 扫描结果:**32 个月(2024-03 ~ 2026-10)**,但有三个阶段:
25
+
26
+ | 阶段 | 月份 | 每月会话数 | 每月消息 | 说明 |
27
+ |---|---|---|---|---|
28
+ | 稀薄期 | 2024-03 ~ 2025-08(17 个月) | 1 ~ 2 | 7 ~ 695 | 几乎只有一两个会话 |
29
+ | 完整期 | 2025-09 ~ 2026-10(14 个月) | 42 ~ 74 | 1,860 ~ 12,805 | 数据齐全 |
30
+ | 低谷 | 2026-02 | 31 | 1,860 | 春节,正常 |
31
+
32
+ 单聊关系 80 个、群 65 个。其中:
33
+
34
+ - **最长连续活跃 22 个月**(一段关系从 2024 一直到现在)
35
+ - 连续 ≥3 个月的只有 **41%**
36
+ - **36 个单聊只出现过 1 个月**(45%)—— 一闪而过
37
+
38
+ ### 由此得到三条硬约束
39
+
40
+ 1. **不能从 2024-03 直接播**:前 17 个月几乎是一条空线,看的人会以为坏了。
41
+ → 每一帧必须**同时输出"当月数据完整度"**(会话数),让它自己说明这一段是数据缺失、不是没聊天。
42
+ 2. **不能把所有关系都画上**:36 个一闪而过的单聊会让画面在后半段剧烈抖动。
43
+ → 设最低门槛(当月 ≥10 条才进图),并在输出里说明被过滤了多少。
44
+ 3. **群的"成员变化"拿不到时间戳**:`contact.db` 的 `chatroom_member` 只有**当前**成员,没有"谁何时进群"。
45
+ → 拓扑变化只能**从发言推断**:本月在这个群说过话 = 本月活跃成员。文档、代码、输出都要写清楚这是推断,不是成员名单。
46
+
47
+ ---
48
+
49
+ ## 3. 数据模型
50
+
51
+ ### 帧
52
+
53
+ 一帧 = 一个自然月。字段:
54
+
55
+ | 字段 | 含义 | 来源 |
56
+ |---|---|---|
57
+ | `month` | `YYYY-MM` | `create_time` |
58
+ | `messages` | 当月消息总数 | 计数 |
59
+ | `talkers` | 当月有消息的会话数 | `distinct talker` |
60
+ | `people` | 当月发过言的人数 | `real_sender_id` → `Name2Id` |
61
+ | `completeness` | **数据完整度标记** | 会话数 ≥ 20 记 `full`,否则 `sparse` |
62
+ | `edges` | 当月的边(见下) | 聚合 |
63
+
64
+ ### 节点
65
+
66
+ | 类型 | 定义 | 大小由什么决定 |
67
+ |---|---|---|
68
+ | 人 | 发过言的 `real_sender_id` | 当月发言条数 |
69
+ | 群 | `@chatroom` 会话 | 当月消息条数 |
70
+
71
+ ### 边
72
+
73
+ | 边 | 含义 | 权重 |
74
+ |---|---|---|
75
+ | 人—人 | 两人在**同一个群**里都发过言(弱关系) | 共现次数 |
76
+ | 我—人 | 单聊有消息(强关系) | 当月消息条数 |
77
+ | 群—人 | 这个人在这个群说过话(**推断的活跃成员**) | 当月发言条数 |
78
+
79
+ **边权只叫「互动量」,不叫「亲密度」。** 和同事一天 50 条是工作,和家人一周 3 条是亲近。
80
+ 除非把「主动发起比例 / 回复延迟 / 深夜时段」也算进去,但那需要单独校准,不在这一期。
81
+
82
+ ---
83
+
84
+ ## 4. 每月的边状态
85
+
86
+ 每帧给每条边打一个状态,这是动画的"颜色"来源:
87
+
88
+ | 状态 | 判据 |
89
+ |---|---|
90
+ | `new` | 上月没有这条边,本月出现 |
91
+ | `stable` | 连续 ≥3 个月存在 |
92
+ | `fading` | 存在,但权重比上上个月降 ≥50% |
93
+ | `gone` | 上月有,本月没有 |
94
+
95
+ **`gone` 是最重要的产出**——"和某人最后一次说话是 2025-12,之后再没出现过"。
96
+
97
+ ---
98
+
99
+ ## 5. 分两期
100
+
101
+ ### 一期:文本版(CLI)
102
+
103
+ ```
104
+ weflow-cli replay [--months 6] [--min-weight 10] [--json]
105
+ ```
106
+
107
+ 输出形态:
108
+
109
+ ```
110
+ 2025-09 full 消息 4,748 · 会话 47 · 发言者 190
111
+ 新出现 12 条关系(例:联系人A、联系人B…)
112
+ 断掉 3 条关系(例:联系人C 最后一句「改天聊」)
113
+ 2025-10 full 消息 6,751 · 会话 45 · 发言者 189
114
+ …
115
+ ```
116
+
117
+ **先做它的理由**:数字对了、断边捞得出来了,动画才有意义。如果数据显示"其实没什么断的",二期直接砍掉。
118
+
119
+ ### 二期:HTML 动画
120
+
121
+ 单文件、无外部依赖、离线可开。三条轨道:
122
+
123
+ 1. 主画布:关系网络逐月生长(边按状态着色,`gone` 留一道残影后消失)
124
+ 2. 右侧:消息量 / 会话数 曲线,播放头随时间走
125
+ 3. 底部:当月高频词(取自已有的 `.articles.json` 与消息文本,不调模型)
126
+
127
+ 按「播放」逐帧推进,可拖时间轴、可单月步进。
128
+
129
+ ---
130
+
131
+ ## 6. 验收标准(可验,不是"看着差不多")
132
+
133
+ | 编号 | 检查 | 判据 |
134
+ |---|---|---|
135
+ | A1 | 帧数守恒 | `--json` 的帧数 == 库里出现的月数 |
136
+ | A2 | 消息数守恒 | 各帧 `messages` 之和 == 全库消息总数(误差 0) |
137
+ | A3 | 完整度标记正确 | 2025-08 必须 `sparse`,2025-09 必须 `full` |
138
+ | A4 | 断边可复现 | 任取一条 `gone`,用它两个月的原始消息能手算出来 |
139
+ | A5 | 过滤有交代 | 被 `--min-weight` 过滤掉的边数要打印出来,不许静默丢 |
140
+ | A6 | 不联网 | 全程无出网请求(纯本地读取) |
141
+
142
+ A2 是关键——它对不上就说明有分片没读到,那个"关系消失了"可能是假象。
143
+
144
+ ---
145
+
146
+ ## 7. 不做的事(明确划掉)
147
+
148
+ - **不做"亲密度"**:只输出互动量,命名就叫 `interactions`。
149
+ - **不做"谁什么时候进群"**:没有时间戳,不猜。
150
+ - **不做增量/实时**:一次全量扫描,30 秒内跑完即可,不做缓存。
151
+ - **不接 AI**:这一期的卖点是"数据自己长出来的形状",加模型只会让人怀疑画的东西是编的。
152
+
153
+ ---
154
+
155
+ ## 8. 工作量
156
+
157
+ | 期 | 内容 | 预计 |
158
+ |---|---|---|
159
+ | 一期 | 扫描 + 按月聚合 + 断边检测 + CLI + 测试(A1–A6) | 2–3 小时 |
160
+ | 二期 | 单文件 HTML + 帧数据内嵌 + 播放控件 | 3–4 小时 |
161
+
162
+ 一期风险最高的是 **A2 守恒**:现有代码里消息读取有"某分片读不了"的分支,如果扫的时候有分片失败,总数会少于真实值,而断边看起来就像真断了。所以一期**必须先做守恒检查,再输出任何结论**。
163
+
164
+ ---
165
+
166
+ ## 9. 参考
167
+
168
+ - 触发这份文档的文章:<https://mp.weixin.qq.com/s/T3ScldZVOuXD13tZpn-rSg>(讲 Obsidian 插件 Maps of the Ideaverse 的时间重放)
169
+ - 该插件的图是**二值**的(笔记有链接/没链接),只能表现"连线生长";我们的边加权且多层(共同群 / 单聊 / 共同话题),能表现"边变粗、变细、断掉"。
@@ -1,180 +1,197 @@
1
- # 同步与任务状态契约
2
-
3
- 本文冻结两个内部状态 schema:`weflow-sync/v1`(跨运行的同步检查点)与 `weflow-job/v1`
4
- (单次运行的记录)。二者都在本机 `~/.weflow-cli/` 下,**不对外暴露**。
5
-
6
- 它们是 [FUNCTIONAL-IMPROVEMENT-SPEC.md](FUNCTIONAL-IMPROVEMENT-SPEC.md) §4 与 §5 的落地,
7
- 边界受 [DECISIONS.md](DECISIONS.md) 约束(尤其 D-014、D-019、D-025、D-027)。
8
-
9
- ## 为什么是两个文件
10
-
11
- 生命周期不同,不是猜测性抽象:
12
-
13
- | 文件 | 生命周期 | 记录的是 |
14
- | --- | --- | --- |
15
- | `sync/<source>-<name>-<hash>.json` | **跨运行**,每次同步覆盖 | 已覆盖到哪、分片读没读全 |
16
- | `jobs/<jobId>.json` | **单次运行** | 这次运行的起止、状态、结果 |
17
-
18
- 一次运行写一个 job 记录;同步检查点记录"到目前为止覆盖范围"这一长期事实。
19
-
20
- ## `weflow-sync/v1`
21
-
22
- 路径:`~/.weflow-cli/sync/<source>-<sanitize(talker)>-<sha256(talker)[0:8]>.json`
23
-
24
- **按会话分文件**,不是一个全局文件。理由:全局文件要求跨会话读改写并合并,而分文件
25
- 让每次写入都是整文件原子替换;同时文件名里的 hash 后缀让 `wxid_a@chatroom` 与
26
- `wxid_a_chatroom` 落到不同文件,而显示名不会出现在目录列表里。
27
-
28
- ```json
29
- {
30
- "schema": "weflow-sync/v1",
31
- "schemaVersion": 1,
32
- "source": "wechat-nt",
33
- "scope": "<显示名,未知时等于 talker>",
34
- "talker": "<会话标识,不是路径>",
35
- "lastSuccessfulRun": "2026-09-19T09:00:00+08:00",
36
- "lastAttempt": "2026-09-19T09:02:10+08:00",
37
- "coveredFrom": "2026-09-01T00:00:00+08:00",
38
- "coveredTo": "2026-09-19T08:59:59+08:00",
39
- "checkpoint": { "newestCreateTime": 1789000000, "overlapSeconds": 300 },
40
- "recordsRead": 1200,
41
- "recordsDeduplicated": 14,
42
- "recordsReturned": 1186,
43
- "shardsRead": 4,
44
- "shardsFailed": 0,
45
- "shards": {
46
- "scanned": 4, "opened": 4, "failed": 0,
47
- "items": [
48
- { "name": "message_0.db", "opened": true, "hasTalkerTable": true,
49
- "rowsForTalker": 8123, "reason": null, "missingColumns": [] }
50
- ]
51
- },
52
- "coverage": "complete",
53
- "mayHaveMore": false,
54
- "warnings": [],
55
- "updatedAt": "2026-09-19T09:02:10+08:00"
56
- }
57
- ```
58
-
59
- ### 两个时间戳必须分开
60
-
61
- | 字段 | 何时推进 |
62
- | --- | --- |
63
- | `lastAttempt` | **每次尝试都推进**,无论结果 |
64
- | `lastSuccessfulRun` | **仅在 `coverage !== 'partial'` 时推进** |
65
-
66
- 只有一个的话,"跑了但部分完成"会和"跑成功了"混为一谈——而这正是方案 §5.2 明令禁止的
67
- (部分完成不能返回成功状态)。
68
-
69
- ### `coverage` 三档
70
-
71
- | 值 | 含义 |
72
- | --- | --- |
73
- | `complete` | 扫到的分片全部打开、打开的全读成功(无 `reason`,允许有 `missingColumns`),且 `mayHaveMore` 为 false |
74
- | `unverified` | 有分片**打不开**,但打开的都读成功。已读部分是可信的,未打开的分片是否含本会话消息**无法判断** |
75
- | `partial` | 某个**已打开**的分片带 `reason`,或 `mayHaveMore` 为 true,或分页在到达窗口起点前就短页结束 |
76
-
77
- `unverified` 不是失败:`connect_message_shards` 一直把打不开的分片当作可跳过的(这是有意设计),
78
- CLI 无法知道那个分片里有没有本会话的消息。如实标成 `unverified` + `warnings` 比直接失败更有用,
79
- 也让自动化不至于卡死。
80
-
81
- `partial` 的判据是**已打开的分片带 reason**,而不是某个特定的 reason 值。除 `READ_FAILED`(读到一半
82
- 出错)外还有两种同样意味着"这个分片的这段范围没读到"的情况:
83
-
84
- | `reason` | 含义 |
85
- | --- | --- |
86
- | `READ_FAILED` | 已打开,读的过程中出错 |
87
- | `SCHEMA_MISMATCH` | 已打开,但表里**没有一个**读者认得的列,这些行无法变成消息 |
88
- | `WINDOW_UNAVAILABLE` | 已打开,但表里没有 `create_time`,请求的时间窗无法表达。**宁可拒绝也不近似**——不筛就返回会让调用方把没读的范围记成已覆盖 |
89
-
90
- 把三者都算 `partial` 是必须的:它们描述的都是"这些行没读",而这正是 `coverage` 要回答的问题。
91
-
92
- **`missingColumns` 是另一回事,不算 partial。** 它的行**读回来了**,只是少了字段(如缺 `server_id`),
93
- 所以 `coverage` 仍是 `complete`,另发一条 `shard-columns-missing:<分片>:<列+列>` 警告。
94
- 把两者混为一谈,会让这类数据库上的每次同步永远报 `partial`,`lastSuccessfulRun` 永远不推进。
95
- 列名是 schema 不是内容,写进状态文件不违反脱敏约束。
96
-
97
- `partial` 则**必须失败**(`code: 'SYNC_PARTIAL'`,退出码 1):状态文件照写(方案 §5.2 要求
98
- 部分完成不得删除已生成的可用结果),但 `lastSuccessfulRun` 不推进。
99
-
100
- ### 时间窗下推:是优化,不是正确性来源
101
-
102
- `sync` 会把窗口下界作为**提示**传给后端(NT 走 `nt_decrypt.py messages --from`),SQL 侧因此不必
103
- 把整段会话取回来再丢弃。但**窗口的真正执行者是 `syncService` 自己**:它在读回之后再按
104
- `createTime >= from` 过滤一遍。这样划分的原因是**非 NT 后端没有范围下推**,若把正确性寄托在下推上,
105
- 这些后端上的同步会静默地整段读、却报告成"按窗口读了"。
106
-
107
- **实测(2026-09-20,本机真实数据,925 条 4 分片)**:下推带来的收益**观测不到**——
108
- 无窗口 519 ms / `--from=最新` 514 ms,两者在噪声内。这个耗时的绝大部分是进程启动 + 每个分片的
109
- PBKDF2(256000 轮),跟搬多少行无关。所以下推的意义是**渐近的**(会话越大越有用),
110
- 不是当前就能测到的时间节省;这也意味着**不要**拿它当性能优化写进发布说明。
111
-
112
- ### 身份
113
-
114
- 按方案 §4.2,`serverId` 非零时优先;**`localId` 绝不单独作为全局身份**(D-014 同此约束)。
115
-
116
- ```
117
- talker|s:<serverId> serverId 非零
118
- talker|l:<localId>|t:<createTime> 否则
119
- ```
120
-
121
- **与方案 §4.2 的一处有意偏离**:键里**不含 `shard`**。消息载荷里没有 shard 字段
122
- (`nt_decrypt._message_dict` 不产出它),加进去要动 `export json` 的消息契约,而 D-016/D-027
123
- 把那条线封住了。它对本契约要解决的**重叠去重**也不必要——同一条消息重读两次,得到的
124
- `serverId` 或 `(localId, createTime)` 本来就一样。只有跨分片的 `(localId, createTime)` 撞车
125
- 才需要它,而 `talker` + `createTime` 已让这种撞车极其遥远。**真观测到再加,不预先加。**
126
-
127
- ### 明确不做的事
128
-
129
- **`mayHaveMore` 不是稳定游标。** 按 D-027,稳定增量游标必须在所有相关后端都支持后才能
130
- 对外宣称;在此之前只提供重叠时间窗 + 本地去重,由下游决定重复容忍度。
131
-
132
- **不得写入**:数据库密钥、`x'<key><salt>'`、聊天正文、绝对路径(D-001 / D-002 / D-025)。
133
- 状态服务在写盘前统一过一遍脱敏,不指望每个调用方都记得。
134
-
135
- ## `weflow-job/v1`
136
-
137
- 路径:`~/.weflow-cli/jobs/<jobId>.json`。`jobId` 形如 `job_20260919_0001`。
138
-
139
- ```json
140
- {
141
- "schema": "weflow-job/v1",
142
- "jobId": "job_20260919_0001",
143
- "kind": "sync.messages",
144
- "state": "running",
145
- "createdAt": "2026-09-19T09:00:00+08:00",
146
- "updatedAt": "2026-09-19T09:02:10+08:00",
147
- "window": { "from": 1788000000, "to": 1789000000 },
148
- "progress": { "completed": 800, "total": 1200 },
149
- "resumeable": true,
150
- "result": { "records": 800, "recordsDeduplicated": 14, "shardsFailed": 0 },
151
- "error": null
152
- }
153
- ```
154
-
155
- 状态机:`planned → waiting_confirmation → running → paused`,终态 `completed | partial | failed | cancelled`。
156
- 本轮只实现 `running | completed | partial | failed`,其余是预留(不实现的不写进状态机文档当既有能力)。
157
-
158
- `partial` 是本项目新增的一档,**不算成功**。`error` 只放既有的大写错误码
159
- (`NOT_INITIALIZED`、`CONFIRMATION_REQUIRED`、`INVALID_ARGUMENT`、`SYNC_PARTIAL`、
160
- `DATABASE_CONNECTION_FAILED` 等),不写中文长句供机器解析。
161
-
162
- **幂等**:同一范围重复提交应返回已有任务或明确新建,不得静默重复写入。本轮用
163
- `kind + talker + 窗口` 生成幂等提示,命中未完成的同键任务时返回该任务而非新建。
164
-
165
- > 本轮 `sync` 是 job 记录的唯一写入者。若日后认为不值得单独一个文件,
166
- > **回退路径**:删掉 job 存储、把那个 `lastJob` 对象并入各会话的 `weflow-sync/v1` 文件。
167
- > 因为此刻还没有第二个消费者,这是一次单文件改动,无兼容性影响。
168
-
169
- ## 版本升级策略
170
-
171
- - `schema` 变化 = 不兼容变更,需要迁移说明。
172
- - 只增字段 = 兼容变更;读方必须容忍未知字段(与 `DATA_CONTRACT.md` 对下游的要求一致)。
173
- - 读到未知 `schema` 时**不得覆盖**:报明确错误并保留原文件。
174
-
175
- ## 相关文档
176
-
177
- - [功能增强技术方案](FUNCTIONAL-IMPROVEMENT-SPEC.md)
178
- - [消息数据契约](DATA_CONTRACT.md)
179
- - [技术决策记录](DECISIONS.md)
180
- - [项目当前状态](PROJECT_STATE.md)
1
+ # 同步与任务状态契约
2
+
3
+ 本文冻结两个内部状态 schema:`weflow-sync/v1`(跨运行的同步检查点)与 `weflow-job/v1`
4
+ (单次运行的记录)。二者都在本机 `~/.weflow-cli/` 下,**不对外暴露**。
5
+
6
+ 它们是 [FUNCTIONAL-IMPROVEMENT-SPEC.md](FUNCTIONAL-IMPROVEMENT-SPEC.md) §4 与 §5 的落地,
7
+ 边界受 [DECISIONS.md](DECISIONS.md) 约束(尤其 D-014、D-019、D-025、D-027)。
8
+
9
+ ## 为什么是两个文件
10
+
11
+ 生命周期不同,不是猜测性抽象:
12
+
13
+ | 文件 | 生命周期 | 记录的是 |
14
+ | --- | --- | --- |
15
+ | `sync/<source>-<name>-<hash>.json` | **跨运行**,每次同步覆盖 | 已覆盖到哪、分片读没读全 |
16
+ | `jobs/<jobId>.json` | **单次运行** | 这次运行的起止、状态、结果 |
17
+
18
+ 一次运行写一个 job 记录;同步检查点记录"到目前为止覆盖范围"这一长期事实。
19
+
20
+ ## `weflow-sync/v1`
21
+
22
+ 路径:`~/.weflow-cli/sync/<source>-<sanitize(talker)>-<sha256(talker)[0:8]>.json`
23
+
24
+ **按会话分文件**,不是一个全局文件。理由:全局文件要求跨会话读改写并合并,而分文件
25
+ 让每次写入都是整文件原子替换;同时文件名里的 hash 后缀让 `wxid_a@chatroom` 与
26
+ `wxid_a_chatroom` 落到不同文件,而显示名不会出现在目录列表里。
27
+
28
+ ```json
29
+ {
30
+ "schema": "weflow-sync/v1",
31
+ "schemaVersion": 1,
32
+ "source": "wechat-nt",
33
+ "scope": "<显示名,未知时等于 talker>",
34
+ "talker": "<会话标识,不是路径>",
35
+ "lastSuccessfulRun": "2026-09-19T09:00:00+08:00",
36
+ "lastAttempt": "2026-09-19T09:02:10+08:00",
37
+ "coveredFrom": "2026-09-01T00:00:00+08:00",
38
+ "coveredTo": "2026-09-19T08:59:59+08:00",
39
+ "checkpoint": { "newestCreateTime": 1789000000, "overlapSeconds": 300 },
40
+ "recordsRead": 1200,
41
+ "recordsDeduplicated": 14,
42
+ "recordsReturned": 1186,
43
+ "shardsRead": 4,
44
+ "shardsFailed": 0,
45
+ "shards": {
46
+ "scanned": 4, "opened": 4, "failed": 0,
47
+ "items": [
48
+ { "name": "message_0.db", "opened": true, "hasTalkerTable": true,
49
+ "rowsForTalker": 8123, "reason": null, "missingColumns": [] }
50
+ ]
51
+ },
52
+ "coverage": "complete",
53
+ "mayHaveMore": false,
54
+ "warnings": [],
55
+ "updatedAt": "2026-09-19T09:02:10+08:00"
56
+ }
57
+ ```
58
+
59
+ ### 两个时间戳必须分开
60
+
61
+ | 字段 | 何时推进 |
62
+ | --- | --- |
63
+ | `lastAttempt` | **每次尝试都推进**,无论结果 |
64
+ | `lastSuccessfulRun` | **仅在 `coverage !== 'partial'` 时推进** |
65
+
66
+ 只有一个的话,"跑了但部分完成"会和"跑成功了"混为一谈——而这正是方案 §5.2 明令禁止的
67
+ (部分完成不能返回成功状态)。
68
+
69
+ ### `coverage` 三档
70
+
71
+ | 值 | 含义 |
72
+ | --- | --- |
73
+ | `complete` | 扫到的分片全部打开、打开的全读成功(无 `reason`,允许有 `missingColumns`),且 `mayHaveMore` 为 false |
74
+ | `unverified` | 有分片**打不开**,但打开的都读成功。已读部分是可信的,未打开的分片是否含本会话消息**无法判断** |
75
+ | `partial` | 某个**已打开**的分片带 `reason`,或 `mayHaveMore` 为 true,或分页在到达窗口起点前就短页结束 |
76
+
77
+ `unverified` 不是失败:`connect_message_shards` 一直把打不开的分片当作可跳过的(这是有意设计),
78
+ CLI 无法知道那个分片里有没有本会话的消息。如实标成 `unverified` + `warnings` 比直接失败更有用,
79
+ 也让自动化不至于卡死。
80
+
81
+ `partial` 的判据是**已打开的分片带 reason**,而不是某个特定的 reason 值。除 `READ_FAILED`(读到一半
82
+ 出错)外还有两种同样意味着"这个分片的这段范围没读到"的情况:
83
+
84
+ | `reason` | 含义 |
85
+ | --- | --- |
86
+ | `READ_FAILED` | 已打开,读的过程中出错 |
87
+ | `SCHEMA_MISMATCH` | 已打开,但表里**没有一个**读者认得的列,这些行无法变成消息 |
88
+ | `WINDOW_UNAVAILABLE` | 已打开,但表里没有 `create_time`,请求的时间窗无法表达。**宁可拒绝也不近似**——不筛就返回会让调用方把没读的范围记成已覆盖 |
89
+
90
+ 把三者都算 `partial` 是必须的:它们描述的都是"这些行没读",而这正是 `coverage` 要回答的问题。
91
+
92
+ **`missingColumns` 是另一回事,不算 partial。** 它的行**读回来了**,只是少了字段(如缺 `server_id`),
93
+ 所以 `coverage` 仍是 `complete`,另发一条 `shard-columns-missing:<分片>:<列+列>` 警告。
94
+ 把两者混为一谈,会让这类数据库上的每次同步永远报 `partial`,`lastSuccessfulRun` 永远不推进。
95
+ 列名是 schema 不是内容,写进状态文件不违反脱敏约束。
96
+
97
+ `partial` 则**必须失败**(`code: 'SYNC_PARTIAL'`,退出码 1):状态文件照写(方案 §5.2 要求
98
+ 部分完成不得删除已生成的可用结果),但 `lastSuccessfulRun` 不推进。
99
+
100
+ ### 时间窗下推:是优化,不是正确性来源
101
+
102
+ `sync` 会把窗口下界作为**提示**传给后端(NT 走 `nt_decrypt.py messages --from`),SQL 侧因此不必
103
+ 把整段会话取回来再丢弃。但**窗口的真正执行者是 `syncService` 自己**:它在读回之后再按
104
+ `createTime >= from` 过滤一遍。这样划分的原因是**非 NT 后端没有范围下推**,若把正确性寄托在下推上,
105
+ 这些后端上的同步会静默地整段读、却报告成"按窗口读了"。
106
+
107
+ **实测(2026-09-20,本机真实数据,925 条 4 分片)**:下推带来的收益**观测不到**——
108
+ 无窗口 519 ms / `--from=最新` 514 ms,两者在噪声内。这个耗时的绝大部分是进程启动 + 每个分片的
109
+ PBKDF2(256000 轮),跟搬多少行无关。所以下推的意义是**渐近的**(会话越大越有用),
110
+ 不是当前就能测到的时间节省;这也意味着**不要**拿它当性能优化写进发布说明。
111
+
112
+ ### 身份
113
+
114
+ 按方案 §4.2,`serverId` 非零时优先;**`localId` 绝不单独作为全局身份**(D-014 同此约束)。
115
+
116
+ ```
117
+ talker|s:<serverId> serverId 非零
118
+ talker|l:<localId>|t:<createTime> 否则
119
+ ```
120
+
121
+ **与方案 §4.2 的一处有意偏离**:键里**不含 `shard`**。消息载荷里没有 shard 字段
122
+ (`nt_decrypt._message_dict` 不产出它),加进去要动 `export json` 的消息契约,而 D-016/D-027
123
+ 把那条线封住了。它对本契约要解决的**重叠去重**也不必要——同一条消息重读两次,得到的
124
+ `serverId` 或 `(localId, createTime)` 本来就一样。只有跨分片的 `(localId, createTime)` 撞车
125
+ 才需要它,而 `talker` + `createTime` 已让这种撞车极其遥远。**真观测到再加,不预先加。**
126
+
127
+ ### 明确不做的事
128
+
129
+ **`mayHaveMore` 不是稳定游标。** 按 D-027,稳定增量游标必须在所有相关后端都支持后才能
130
+ 对外宣称;在此之前只提供重叠时间窗 + 本地去重,由下游决定重复容忍度。
131
+
132
+ **不得写入**:数据库密钥、`x'<key><salt>'`、聊天正文、绝对路径(D-001 / D-002 / D-025)。
133
+ 状态服务在写盘前统一过一遍脱敏,不指望每个调用方都记得。
134
+
135
+ ## `weflow-job/v1`
136
+
137
+ 路径:`~/.weflow-cli/jobs/<jobId>.json`。`jobId` 形如 `job_20260919_0001`。
138
+
139
+ ```json
140
+ {
141
+ "schema": "weflow-job/v1",
142
+ "jobId": "job_20260919_0001",
143
+ "kind": "sync.messages",
144
+ "state": "running",
145
+ "createdAt": "2026-09-19T09:00:00+08:00",
146
+ "updatedAt": "2026-09-19T09:02:10+08:00",
147
+ "window": { "from": 1788000000, "to": 1789000000 },
148
+ "progress": { "completed": 800, "total": 1200 },
149
+ "resumeable": true,
150
+ "result": { "records": 800, "recordsDeduplicated": 14, "shardsFailed": 0 },
151
+ "error": null
152
+ }
153
+ ```
154
+
155
+ 状态机:`planned → waiting_confirmation → running → paused`,终态 `completed | partial | failed | cancelled`。
156
+ 本轮只实现 `running | completed | partial | failed`,其余是预留(不实现的不写进状态机文档当既有能力)。
157
+
158
+ `partial` 是本项目新增的一档,**不算成功**。`error` 只放既有的大写错误码
159
+ (`NOT_INITIALIZED`、`CONFIRMATION_REQUIRED`、`INVALID_ARGUMENT`、`SYNC_PARTIAL`、
160
+ `DATABASE_CONNECTION_FAILED` 等),不写中文长句供机器解析。
161
+
162
+ **幂等**:同一范围重复提交应返回已有任务或明确新建,不得静默重复写入。本轮用
163
+ `kind + talker + 窗口` 生成幂等提示,命中未完成的同键任务时返回该任务而非新建。
164
+
165
+ > 本轮 `sync` 是 job 记录的唯一写入者。若日后认为不值得单独一个文件,
166
+ > **回退路径**:删掉 job 存储、把那个 `lastJob` 对象并入各会话的 `weflow-sync/v1` 文件。
167
+ > 因为此刻还没有第二个消费者,这是一次单文件改动,无兼容性影响。
168
+
169
+ ## 版本升级策略
170
+
171
+ - `schema` 变化 = 不兼容变更,需要迁移说明。
172
+ - 只增字段 = 兼容变更;读方必须容忍未知字段(与 `DATA_CONTRACT.md` 对下游的要求一致)。
173
+ - 读到未知 `schema` 时**不得覆盖**:报明确错误并保留原文件。
174
+
175
+ ## 相关文档
176
+
177
+ - [功能增强技术方案](FUNCTIONAL-IMPROVEMENT-SPEC.md)
178
+ - [消息数据契约](DATA_CONTRACT.md)
179
+ - [技术决策记录](DECISIONS.md)
180
+ - [项目当前状态](PROJECT_STATE.md)
181
+
182
+ ## 稳定游标的路径可见(`sync cursor-probe`)
183
+
184
+ D-027 的结论仍然成立:**没有稳定游标**,增量读取就是"重叠时间窗 + 本地去重"。2026-10-23
185
+ 起,`false` 这个结论背后的路径是可查的,而不是一个黑盒布尔:
186
+
187
+ - `capabilities --json` 的 `read.exports.incrementalRead.cursorReadiness` 列出五条判据
188
+ (稳定身份 / 跨分片可比的顺序键 / 时间窗下推 / 覆盖报告 / 所有后端同时满足)与四个消息后端
189
+ (`nt` / `raw-sqlite-4x` / `wcdb-4x` / `legacy-3x`)各自的代码事实;其中 `probe:` 指向的命令串
190
+ 由 `check_readme_commands.py` 与 `cli:`/`preview:` 一起校验。
191
+ - `sync cursor-probe --json`(只读、本地、无确认门)实测 NT 后端的**数据侧**:schema 检查全量
192
+ (每张消息表的锚点列),身份检查有界采样(每分片 5 张表、每表两端各 500 行,可调),结果自带
193
+ `exhaustive: false`。server_id 以**字符串**上报(int64 过 2^53 在 JSON.parse 里会丢精度)。
194
+ 探针不翻结论:`stableCursor` 恒为 `false`,直到 D-027 的条件满足。
195
+
196
+ 判据与后端事实的单一来源是 `src/services/cursorProbe.ts`(代码事实)与 `nt_decrypt.py
197
+ cursor-probe`(数据事实);改读取器(下推 / 报告 / 顺序键)时同步改那张表。