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.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1966 -0
- package/OPERATIONS.md +808 -0
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +1245 -26
- package/dist/bin/weflow-cli.js +1349 -72
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +43 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +31 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +23 -0
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +127 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +263 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1484 -20
- package/docs/EXTENDING.md +187 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -96
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +180 -0
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +212 -46
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1352
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +67 -0
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +30 -0
- package/src/services/configService.ts +403 -385
- package/src/services/messageQuery.ts +97 -0
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +363 -0
- package/src/services/syncState.ts +275 -0
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- 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
|
+
}
|