weflow-cli 1.6.4 → 1.8.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 (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -36,3 +36,100 @@ export async function collectMessagesInRange(
36
36
 
37
37
  return result
38
38
  }
39
+
40
+ /** Why the paging loop stopped. The first two are expected; the last two are not. */
41
+ export type StopReason =
42
+ /** Reached the requested `from` bound. */
43
+ | 'range'
44
+ /** A page came back empty - there is nothing older. */
45
+ | 'exhausted'
46
+ /** Collected the caller's limit. */
47
+ | 'limit'
48
+ /** A page shorter than pageSize ended the loop before `from` was reached. */
49
+ | 'short-page'
50
+
51
+ export interface DetailedMessageCollection {
52
+ messages: Message[]
53
+ pages: number
54
+ stoppedBy: StopReason
55
+ mayHaveMore: boolean
56
+ /**
57
+ * A short page ended the loop while the requested `from` was still ahead
58
+ * of us.
59
+ *
60
+ * `collectMessagesInRange` breaks on a short page, which is right when the
61
+ * page is short because the conversation ended - but a live database can
62
+ * also return a short page while rows are being written, because offset
63
+ * paging shifts underneath. The two are indistinguishable from the result
64
+ * alone, so this records the difference instead of assuming the benign one.
65
+ */
66
+ truncatedEarly: boolean
67
+ }
68
+
69
+ /**
70
+ * `collectMessagesInRange` plus the reason it stopped.
71
+ *
72
+ * Kept separate rather than folded into the original: that one is covered by
73
+ * existing tests and used by every export path, and changing what it returns
74
+ * would change those callers.
75
+ */
76
+ export async function collectMessagesInRangeDetailed(
77
+ fetchPage: (limit: number, offset: number) => Promise<Message[]>,
78
+ limit: number,
79
+ range: MessageTimeRange,
80
+ pageSize = 500,
81
+ ): Promise<DetailedMessageCollection> {
82
+ const messages: Message[] = []
83
+ let offset = 0
84
+ let pages = 0
85
+ let stoppedBy: StopReason = 'exhausted'
86
+ let reachedFrom = range.from === undefined
87
+
88
+ while (true) {
89
+ const page = await fetchPage(pageSize, offset)
90
+ pages += 1
91
+ if (page.length === 0) {
92
+ stoppedBy = 'exhausted'
93
+ reachedFrom = true
94
+ break
95
+ }
96
+
97
+ let oldestTimestamp = Number.POSITIVE_INFINITY
98
+ for (const message of page) {
99
+ const timestamp = Number(message.createTime)
100
+ if (!Number.isFinite(timestamp)) continue
101
+ oldestTimestamp = Math.min(oldestTimestamp, timestamp)
102
+ if (range.from !== undefined && timestamp < range.from) continue
103
+ if (range.to !== undefined && timestamp > range.to) continue
104
+ messages.push(message)
105
+ if (limit > 0 && messages.length >= limit) {
106
+ return { messages, pages, stoppedBy: 'limit', mayHaveMore: true, truncatedEarly: false }
107
+ }
108
+ }
109
+
110
+ offset += page.length
111
+ if (range.from !== undefined && oldestTimestamp < range.from) {
112
+ stoppedBy = 'range'
113
+ reachedFrom = true
114
+ break
115
+ }
116
+ if (page.length < pageSize) {
117
+ // A short *first* page is unambiguous: nothing has shifted yet, so the
118
+ // conversation simply has fewer than pageSize messages. A short later
119
+ // page is the ambiguous one offset paging can produce on a live
120
+ // database, so it keeps its own name.
121
+ stoppedBy = pages === 1 ? 'exhausted' : 'short-page'
122
+ break
123
+ }
124
+ }
125
+
126
+ return {
127
+ messages,
128
+ pages,
129
+ stoppedBy,
130
+ // A limit hit is handled above; anything else that is not 'range' may
131
+ // still have older matching rows we never looked at.
132
+ mayHaveMore: stoppedBy !== 'range' && stoppedBy !== 'exhausted',
133
+ truncatedEarly: stoppedBy === 'short-page' && !reachedFrom,
134
+ }
135
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 调本仓 Python 脚本的**唯一入口**:拿 JSON 回来,或者把失败说清楚。
3
+ *
4
+ * 为什么要有这一层:此前有两份实现——`get_todos` 用 `execFile`(参数走 argv),路由用
5
+ * `spawn`(请求走 stdin)。两份各有一套超时、错误、stdout 解析的写法,正是"同一件事两处
6
+ * 实现"的老毛病;而工具只会越加越多。
7
+ *
8
+ * 三条约定:
9
+ * 1. **只有 JSON 出口**:脚本要么给出可解析的 JSON,要么给出错误——不解析人类可读文本
10
+ * (那种解析在脚本改一行格式时就悄悄失效)。
11
+ * 2. **JSON 可以在最后一行**:有些脚本(如 route_cards)前面会打印给人看的进度行。
12
+ * 所以先试整份 stdout,再退一步取"最后一行能解析成 JSON 的"。
13
+ * 3. **失败要说清是什么失败**:退出码、还是没有 JSON、还是超时——三者分开报,别都变成
14
+ * "执行失败"。
15
+ */
16
+ import { spawn } from 'node:child_process'
17
+ import { join } from 'node:path'
18
+ import { resolvePackageRoot } from '../utils/packageRoot.js'
19
+ import { createPythonProcessEnv } from '../utils/pythonProcessEnv.js'
20
+ import { getPythonCommand } from '../utils/python.js'
21
+
22
+ export interface ScriptRun {
23
+ stdout: string
24
+ stderr: string
25
+ /** 进程退出码;被信号杀死时为 null */
26
+ code: number | null
27
+ }
28
+
29
+ /** 注入点:测试用假 runner 驱动,不 spawn 真进程 */
30
+ export type ScriptRunner = (scriptPath: string, args: string[],
31
+ options: { timeoutMs: number; stdin?: string; env?: Record<string, string> }) => Promise<ScriptRun>
32
+
33
+ export interface JsonResult<T> {
34
+ ok: boolean
35
+ data?: T
36
+ /** 失败原因(已分类:超时 / 退出码 / 没有 JSON) */
37
+ error?: string
38
+ /** 失败时的 stderr 尾巴,便于定位 */
39
+ stderr?: string
40
+ }
41
+
42
+ const DEFAULT_TIMEOUT_MS = 30_000
43
+
44
+ function realRunner(scriptPath: string, args: string[],
45
+ options: { timeoutMs: number; stdin?: string; env?: Record<string, string> }): Promise<ScriptRun> {
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn(getPythonCommand(), [scriptPath, ...args], {
48
+ // 用户输入走**环境变量**而不是 argv:这是仓库写进测试的隐私纪律(进程列表里看不到正文)
49
+ env: createPythonProcessEnv(options.env ?? {}),
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ })
52
+ let stdout = ''
53
+ let stderr = ''
54
+ let settled = false
55
+ const timer = setTimeout(() => {
56
+ if (settled) return
57
+ settled = true
58
+ child.kill()
59
+ reject(new Error(`脚本超时(${options.timeoutMs}ms)`))
60
+ }, options.timeoutMs)
61
+
62
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
63
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
64
+ child.on('error', (error) => {
65
+ if (settled) return
66
+ settled = true
67
+ clearTimeout(timer)
68
+ reject(error)
69
+ })
70
+ child.on('close', (code) => {
71
+ if (settled) return
72
+ settled = true
73
+ clearTimeout(timer)
74
+ resolve({ stdout, stderr, code })
75
+ })
76
+ if (options.stdin !== undefined) child.stdin.end(options.stdin)
77
+ else child.stdin.end()
78
+ })
79
+ }
80
+
81
+ let runner: ScriptRunner = realRunner
82
+
83
+ /** 换掉 runner(测试用)。传 null 恢复真实实现。 */
84
+ export function setScriptRunner(next: ScriptRunner | null): void {
85
+ runner = next ?? realRunner
86
+ }
87
+
88
+ /** 脚本路径:一律相对包根下的 `scripts/`,不给调用方自己拼路径的自由 */
89
+ export function scriptPath(name: string): string {
90
+ return join(resolvePackageRoot(import.meta.url), 'scripts', name)
91
+ }
92
+
93
+ /** 从 stdout 里取 JSON:先试整份,再取最后一行能解析的 */
94
+ export function parseJsonFrom(stdout: string): { ok: boolean; data?: any } {
95
+ const whole = stdout.trim()
96
+ if (whole) {
97
+ try { return { ok: true, data: JSON.parse(whole) } } catch { /* 落下去取最后一行 */ }
98
+ }
99
+ const lines = stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
100
+ for (let i = lines.length - 1; i >= 0; i--) {
101
+ if (!lines[i].startsWith('{') && !lines[i].startsWith('[')) continue
102
+ try { return { ok: true, data: JSON.parse(lines[i]) } } catch { /* 继续往前找 */ }
103
+ }
104
+ return { ok: false }
105
+ }
106
+
107
+ /**
108
+ * 跑一个脚本并解析它给的 JSON。
109
+ *
110
+ * **不抛异常**:调用方(工具分支)要的是"这次调用成不成功",而不是一层 try/catch。
111
+ * 失败的三种情形分开报——超时、退出码非 0、退出码 0 但没有 JSON。
112
+ */
113
+ export async function runPythonJson<T = any>(
114
+ script: string, args: string[],
115
+ options: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
116
+ ): Promise<JsonResult<T>> {
117
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
118
+ let run: ScriptRun
119
+ try {
120
+ run = await runner(scriptPath(script), args, { timeoutMs, stdin: options.stdin, env: options.env })
121
+ } catch (error: any) {
122
+ return { ok: false, error: String(error?.message ?? error).slice(0, 200) }
123
+ }
124
+
125
+ const parsed = parseJsonFrom(run.stdout)
126
+ const stderrTail = run.stderr.trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ').slice(0, 300)
127
+
128
+ if (run.code !== 0) {
129
+ return { ok: false, error: `脚本退出码 ${run.code}`, stderr: stderrTail }
130
+ }
131
+ if (!parsed.ok) {
132
+ return { ok: false, error: '脚本没有给出可解析的 JSON', stderr: stderrTail }
133
+ }
134
+ const data: any = parsed.data
135
+ if (data && typeof data === 'object' && 'success' in data && data.success === false) {
136
+ return { ok: false, error: String(data.error ?? '脚本报错').slice(0, 200), stderr: stderrTail }
137
+ }
138
+ return { ok: true, data: data as T }
139
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * One sync pass: pick a window, read it, dedupe, decide coverage, record state.
3
+ *
4
+ * Deliberately not a general job runner - it is the single caller of the sync
5
+ * state contract for now (docs/SYNC_CONTRACT.md, D-029).
6
+ */
7
+ import type { Message } from '../types.js'
8
+ import type { ShardReport } from '../core/ntCore.js'
9
+ import {
10
+ DEFAULT_OVERLAP_SECONDS,
11
+ SYNC_SOURCE,
12
+ emptySyncState,
13
+ makeJobId,
14
+ syncStateStore,
15
+ type JobRecord,
16
+ type SyncCoverage,
17
+ type SyncState,
18
+ type SyncStateStore,
19
+ } from './syncState.js'
20
+
21
+ /** A window wider than this is worth warning about: the read is not bounded. */
22
+ export const WIDE_WINDOW_SECONDS = 90 * 24 * 3600
23
+
24
+ export class SyncRangeRequiredError extends Error {
25
+ readonly code = 'SYNC_RANGE_REQUIRED'
26
+ constructor() {
27
+ super('首次同步需要 --since <日期> 或 --full')
28
+ this.name = 'SyncRangeRequiredError'
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Stable identity for one message.
34
+ *
35
+ * `serverId` wins when nonzero; otherwise `localId` is paired with
36
+ * `createTime`. A bare `localId` is never an identity - it restarts in every
37
+ * shard, which is the whole reason D-014 exists.
38
+ *
39
+ * `shard` is intentionally absent: messages do not carry it, adding one would
40
+ * touch the fenced export contract, and overlap dedup does not need it (the
41
+ * same message read twice yields the same serverId or the same pair).
42
+ */
43
+ export function messageIdentity(source: string, talker: string, message: Message): string {
44
+ const serverId = String(message.serverId ?? '').trim()
45
+ if (serverId && serverId !== '0') return `${source}|${talker}|s:${serverId}`
46
+ return `${source}|${talker}|l:${message.localId}|t:${message.createTime}`
47
+ }
48
+
49
+ export interface WindowOptions {
50
+ since?: number
51
+ full?: boolean
52
+ overlapSeconds?: number
53
+ now?: number
54
+ }
55
+
56
+ export interface SyncWindow {
57
+ from: number | null
58
+ to: number
59
+ overlapSeconds: number
60
+ full: boolean
61
+ }
62
+
63
+ /**
64
+ * Work out what to read.
65
+ *
66
+ * Without a prior checkpoint there is nothing to resume from, so the caller
67
+ * must say how far back to go - guessing "everything" would silently turn a
68
+ * routine sync into a full-history read.
69
+ */
70
+ export function computeWindow(prior: SyncState | null, options: WindowOptions): SyncWindow {
71
+ const overlapSeconds = options.overlapSeconds ?? prior?.checkpoint.overlapSeconds
72
+ ?? DEFAULT_OVERLAP_SECONDS
73
+ const to = options.now ?? Math.floor(Date.now() / 1000)
74
+
75
+ if (options.full) {
76
+ return { from: null, to, overlapSeconds, full: true }
77
+ }
78
+ if (options.since !== undefined) {
79
+ return { from: options.since, to, overlapSeconds, full: false }
80
+ }
81
+ const newest = prior?.checkpoint.newestCreateTime
82
+ if (prior && typeof newest === 'number') {
83
+ // Back up by the overlap: a second-resolution timestamp is not a cursor,
84
+ // so the boundary messages have to be read again and deduplicated.
85
+ return { from: Math.max(0, newest - overlapSeconds), to, overlapSeconds, full: false }
86
+ }
87
+ throw new SyncRangeRequiredError()
88
+ }
89
+
90
+ /** Drop repeats the overlap window re-read. */
91
+ export function dedupeMessages(
92
+ messages: Message[], source: string, talker: string,
93
+ ): { unique: Message[]; duplicateCount: number } {
94
+ const seen = new Set<string>()
95
+ const unique: Message[] = []
96
+ for (const message of messages) {
97
+ const identity = messageIdentity(source, talker, message)
98
+ if (seen.has(identity)) continue
99
+ seen.add(identity)
100
+ unique.push(message)
101
+ }
102
+ return { unique, duplicateCount: messages.length - unique.length }
103
+ }
104
+
105
+ /**
106
+ * Decide how much of the read we can stand behind.
107
+ *
108
+ * `partial` is the only value that must not be reported as success.
109
+ *
110
+ * A short-page truncation cannot occur here because this pass reads in one
111
+ * call rather than paging; `collectMessagesInRangeDetailed` detects that case
112
+ * for the paged readers and is where a future revision of this should look.
113
+ */
114
+ export function deriveCoverage(input: {
115
+ shards?: ShardReport
116
+ mayHaveMore: boolean
117
+ }): SyncCoverage {
118
+ if (input.mayHaveMore) return 'partial'
119
+ // Any per-shard reason means those rows were not read: a fault after opening
120
+ // (READ_FAILED), a table the reader can name nothing in (SCHEMA_MISMATCH), or
121
+ // a window the shard cannot express (WINDOW_UNAVAILABLE). Coverage is about
122
+ // whether the range was read, so all of them make it partial. A shard read
123
+ // with columns missing is *not* one of these - its rows did come back.
124
+ // A shard that never opened is a different answer and stays 'unverified'
125
+ // below: nothing is known about its rows either way, which is not the same
126
+ // claim as knowing they were missed.
127
+ const anyShardUnread = input.shards?.items.some(item => item.opened && item.reason)
128
+ if (anyShardUnread) return 'partial'
129
+ if (!input.shards) return 'unverified'
130
+ // A shard that never opened is not knowable either way; say so rather than
131
+ // claiming completeness or declaring failure.
132
+ if (input.shards.failed > 0) return 'unverified'
133
+ return 'complete'
134
+ }
135
+
136
+ export function buildNextState(input: {
137
+ prior: SyncState | null
138
+ talker: string
139
+ scope: string
140
+ source: string
141
+ window: SyncWindow
142
+ recordsRead: number
143
+ recordsDeduplicated: number
144
+ recordsReturned: number
145
+ newestCreateTime: number | null
146
+ oldestCreateTime: number | null
147
+ shards?: ShardReport
148
+ coverage: SyncCoverage
149
+ mayHaveMore: boolean
150
+ warnings: string[]
151
+ now?: number
152
+ }): SyncState {
153
+ const state = input.prior ?? emptySyncState(input.talker, input.scope, input.source,
154
+ input.window.overlapSeconds)
155
+ // Same form as coveredFrom/coveredTo: an offset, not a bare Z. A file that
156
+ // mixes the two makes "which local day was this?" unanswerable.
157
+ const stamp = isoFromSeconds(input.now ?? Math.floor(Date.now() / 1000))
158
+
159
+ const priorFrom = state.coveredFrom
160
+ let coveredFrom: string | null = priorFrom
161
+ if (input.window.full) {
162
+ // The database's true minimum is not knowable without another query, so
163
+ // this is the oldest row we actually saw and says so in `warnings`.
164
+ coveredFrom = input.oldestCreateTime ? isoFromSeconds(input.oldestCreateTime) : priorFrom
165
+ } else if (input.window.from !== null) {
166
+ const candidate = isoFromSeconds(input.window.from)
167
+ coveredFrom = priorFrom && priorFrom < candidate ? priorFrom : candidate
168
+ }
169
+
170
+ return {
171
+ ...state,
172
+ scope: input.scope,
173
+ talker: input.talker,
174
+ source: input.source,
175
+ coveredFrom,
176
+ coveredTo: isoFromSeconds(input.window.to),
177
+ checkpoint: {
178
+ newestCreateTime: input.newestCreateTime ?? state.checkpoint.newestCreateTime,
179
+ overlapSeconds: input.window.overlapSeconds,
180
+ },
181
+ recordsRead: input.recordsRead,
182
+ recordsDeduplicated: input.recordsDeduplicated,
183
+ recordsReturned: input.recordsReturned,
184
+ shardsRead: input.shards?.opened ?? 0,
185
+ shardsFailed: input.shards?.failed ?? 0,
186
+ shards: input.shards
187
+ ? { scanned: input.shards.scanned, opened: input.shards.opened,
188
+ failed: input.shards.failed, items: input.shards.items }
189
+ : { scanned: 0, opened: 0, failed: 0, items: [] },
190
+ coverage: input.coverage,
191
+ mayHaveMore: input.mayHaveMore,
192
+ warnings: input.warnings,
193
+ // The attempt always advances; the success does not, so a partial run is
194
+ // never mistaken for a good one later.
195
+ lastAttempt: stamp,
196
+ lastSuccessfulRun: input.coverage === 'partial' ? state.lastSuccessfulRun : stamp,
197
+ updatedAt: stamp,
198
+ }
199
+ }
200
+
201
+ function isoFromSeconds(seconds: number): string {
202
+ const date = new Date(seconds * 1000)
203
+ const offsetMinutes = -date.getTimezoneOffset()
204
+ const sign = offsetMinutes >= 0 ? '+' : '-'
205
+ const pad = (value: number) => String(Math.floor(Math.abs(value))).padStart(2, '0')
206
+ const local = new Date(date.getTime() + offsetMinutes * 60_000)
207
+ const body = local.toISOString().slice(0, 19)
208
+ return `${body}${sign}${pad(offsetMinutes / 60)}:${pad(offsetMinutes % 60)}`
209
+ }
210
+
211
+ export interface SyncRunOptions extends WindowOptions {
212
+ source?: string
213
+ scope?: string
214
+ limit?: number
215
+ /**
216
+ * Injected for tests; the CLI wires this to chatService.
217
+ *
218
+ * `from` is a hint that a backend may push into SQL. It is never a
219
+ * correctness guarantee: runSync still applies the window itself, because a
220
+ * backend that cannot honour the lower bound reads everything instead.
221
+ */
222
+ read: (
223
+ talker: string,
224
+ limit: number,
225
+ from: number | null
226
+ ) => Promise<{ messages: Message[]; shards?: ShardReport }>
227
+ write?: boolean
228
+ /** Defaults to the real store; tests pass one rooted in a temp directory. */
229
+ store?: SyncStateStore
230
+ }
231
+
232
+ export interface SyncRunResult {
233
+ success: boolean
234
+ code?: string
235
+ action: 'sync.messages'
236
+ scope: string
237
+ window: SyncWindow
238
+ recordsRead: number
239
+ recordsDeduplicated: number
240
+ recordsReturned: number
241
+ shards?: ShardReport
242
+ coverage: SyncCoverage
243
+ mayHaveMore: boolean
244
+ partial: boolean
245
+ warnings: string[]
246
+ jobId: string | null
247
+ state: SyncState | null
248
+ }
249
+
250
+ /**
251
+ * Run one pass. Writes state and a job record unless `write: false`.
252
+ *
253
+ * A partial run still writes (the spec forbids discarding usable results) but
254
+ * returns `success: false` with `code: 'SYNC_PARTIAL'` so nothing downstream
255
+ * treats it as complete.
256
+ */
257
+ export async function runSync(talker: string, options: SyncRunOptions): Promise<SyncRunResult> {
258
+ const source = options.source ?? SYNC_SOURCE
259
+ const scope = options.scope ?? talker
260
+ const store = options.store ?? syncStateStore
261
+ const prior = store.read(source, talker)
262
+ const window = computeWindow(prior, options)
263
+
264
+ const warnings: string[] = []
265
+ if (window.full) warnings.push('full-scan')
266
+ if (window.from !== null && window.to - window.from > WIDE_WINDOW_SECONDS) {
267
+ warnings.push('wide-window')
268
+ }
269
+
270
+ const limit = options.limit ?? 0
271
+ const read = await options.read(talker, limit, window.from)
272
+ const { unique, duplicateCount } = dedupeMessages(read.messages, source, talker)
273
+
274
+ // The window is applied here as well as being pushed down, and this filter -
275
+ // not the pushdown - is what makes the result correct: a backend without
276
+ // range support returns the whole conversation, and one that cannot express
277
+ // the lower bound says so per shard rather than quietly dropping the bound.
278
+ const inWindow = window.from === null
279
+ ? unique
280
+ : unique.filter(m => Number(m.createTime) >= (window.from as number))
281
+
282
+ const newestCreateTime = inWindow.length
283
+ ? Math.max(...inWindow.map(m => Number(m.createTime) || 0))
284
+ : null
285
+ const oldestCreateTime = inWindow.length
286
+ ? Math.min(...inWindow.map(m => Number(m.createTime) || 0))
287
+ : null
288
+
289
+ const mayHaveMore = limit > 0 && read.messages.length >= limit
290
+ const coverage = deriveCoverage({ shards: read.shards, mayHaveMore })
291
+ if (!read.shards) warnings.push('shard-report-unavailable')
292
+ if (read.shards) {
293
+ for (const item of read.shards.items) {
294
+ if (!item.opened) warnings.push(`shard-unverified:${item.name}`)
295
+ else if (item.reason === 'READ_FAILED') warnings.push(`shard-read-failed:${item.name}`)
296
+ // A shard the reader could not name anything in, or could not apply the
297
+ // window to. Both mean "this range was not read here", which must not
298
+ // pass as covered - hence a warning rather than a silent skip.
299
+ else if (item.reason) warnings.push(`shard-not-read:${item.name}:${item.reason}`)
300
+ // Read, but with fields missing. Column names are schema, not content,
301
+ // so this stays safe to record in the state file.
302
+ else if (item.missingColumns?.length) {
303
+ warnings.push(`shard-columns-missing:${item.name}:${item.missingColumns.join('+')}`)
304
+ }
305
+ }
306
+ }
307
+
308
+ const next = buildNextState({
309
+ prior, talker, scope, source, window,
310
+ recordsRead: read.messages.length,
311
+ recordsDeduplicated: duplicateCount,
312
+ recordsReturned: inWindow.length,
313
+ newestCreateTime, oldestCreateTime,
314
+ shards: read.shards, coverage, mayHaveMore, warnings,
315
+ // Same clock as the window: without this the recorded attempt would come
316
+ // from Date.now() while window.to came from the caller's `now`.
317
+ now: options.now,
318
+ })
319
+
320
+ const partial = coverage === 'partial'
321
+ let jobId: string | null = null
322
+ if (options.write !== false) {
323
+ jobId = makeJobId()
324
+ const job: JobRecord = {
325
+ schema: 'weflow-job/v1',
326
+ jobId,
327
+ kind: 'sync.messages',
328
+ state: partial ? 'partial' : 'completed',
329
+ createdAt: next.lastAttempt as string,
330
+ updatedAt: next.lastAttempt as string,
331
+ window: { from: window.from, to: window.to },
332
+ progress: { completed: inWindow.length, total: read.messages.length },
333
+ resumeable: partial,
334
+ result: {
335
+ records: inWindow.length,
336
+ recordsDeduplicated: duplicateCount,
337
+ shardsFailed: read.shards?.failed ?? 0,
338
+ coverage,
339
+ },
340
+ error: partial ? 'SYNC_PARTIAL' : null,
341
+ }
342
+ store.write(next)
343
+ store.writeJob(job)
344
+ }
345
+
346
+ return {
347
+ success: !partial,
348
+ code: partial ? 'SYNC_PARTIAL' : undefined,
349
+ action: 'sync.messages',
350
+ scope,
351
+ window,
352
+ recordsRead: read.messages.length,
353
+ recordsDeduplicated: duplicateCount,
354
+ recordsReturned: inWindow.length,
355
+ shards: read.shards,
356
+ coverage,
357
+ mayHaveMore,
358
+ partial,
359
+ warnings,
360
+ jobId,
361
+ state: next,
362
+ }
363
+ }