weflow-cli 1.17.0 → 1.19.1

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 (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. package/src/services/wechatMedia.ts +166 -166
@@ -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
+ }