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.
- package/CHANGELOG.md +59 -1
- package/OPERATIONS.md +16 -1
- package/README.en.md +21 -2
- package/README.md +34 -6
- package/bin/weflow-cli.ts +389 -6
- package/dist/bin/weflow-cli.js +422 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +92 -30
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +6 -3
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/article_notes.py +423 -423
- package/scripts/bonds.py +11 -0
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_task.ps1 +213 -177
- package/scripts/fav_notes.py +321 -321
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -0
- package/scripts/install_tasks.ps1 +105 -91
- package/scripts/leads.py +179 -0
- package/scripts/network.py +206 -206
- package/scripts/nt_decrypt.py +135 -1
- package/scripts/on_this_day.py +178 -178
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +73 -5
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatMedia.ts +166 -166
- package/src/utils/python.ts +33 -4
|
@@ -1,363 +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
|
-
}
|
|
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
|
+
}
|