weflow-cli 1.6.3 → 1.7.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 +38 -0
- package/OPERATIONS.md +36 -1
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/bin/weflow-cli.ts +312 -0
- package/dist/bin/weflow-cli.js +352 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +26 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +21 -0
- package/dist/src/core/ntCore.js.map +1 -1
- 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 +20 -0
- package/dist/src/services/chatService.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/syncService.d.ts +121 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +244 -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/docs/DECISIONS.md +60 -20
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/HEALTH-CHECK.md +6 -5
- package/docs/MCP.md +1 -1
- package/docs/NPM-PUBLISH.md +12 -4
- package/docs/PROJECT_STATE.md +8 -6
- package/docs/SETUP.md +1 -1
- package/docs/SYNC_CONTRACT.md +152 -0
- package/package.json +3 -3
- package/scripts/export_chat_html.py +242 -26
- package/scripts/nt_decrypt.py +123 -15
- package/src/core/ntCore.ts +42 -0
- package/src/services/chatService.ts +26 -0
- package/src/services/messageQuery.ts +97 -0
- package/src/services/syncService.ts +335 -0
- package/src/services/syncState.ts +275 -0
package/dist/bin/weflow-cli.js
CHANGED
|
@@ -5,6 +5,8 @@ import inquirer from 'inquirer';
|
|
|
5
5
|
import { basename, join } from 'path';
|
|
6
6
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
|
+
import { SYNC_SCHEMA, SYNC_SOURCE, syncStateStore } from '../src/services/syncState.js';
|
|
9
|
+
import { SyncRangeRequiredError, runSync } from '../src/services/syncService.js';
|
|
8
10
|
import { dbPathService } from '../src/core/dbPathService.js';
|
|
9
11
|
import { keyService } from '../src/core/keyService.js';
|
|
10
12
|
import { NtCore } from '../src/core/ntCore.js';
|
|
@@ -309,6 +311,26 @@ program
|
|
|
309
311
|
previewExposesPaths: false,
|
|
310
312
|
},
|
|
311
313
|
evidencePackage: { cli: 'evidence <talker> --json --non-interactive', localOnly: true },
|
|
314
|
+
sync: {
|
|
315
|
+
preview: 'sync run <talker> --dry-run --json',
|
|
316
|
+
execute: 'sync run <talker> --yes --json',
|
|
317
|
+
status: 'sync status [<talker>] --json',
|
|
318
|
+
verify: 'sync verify <talker> --json',
|
|
319
|
+
confirmationRequired: true,
|
|
320
|
+
readsLocalData: true,
|
|
321
|
+
invokesAI: false,
|
|
322
|
+
sideEffects: ['local-checkpoint-file'],
|
|
323
|
+
stateSchema: 'weflow-sync/v1',
|
|
324
|
+
jobSchema: 'weflow-job/v1',
|
|
325
|
+
stateLocation: 'local-user-config-dir',
|
|
326
|
+
previewResolvesTalker: false,
|
|
327
|
+
previewExposesLocalPaths: false,
|
|
328
|
+
// D-027: a stable cursor must be backed by every database backend
|
|
329
|
+
// before it is advertised. This exposes overlapping windows only.
|
|
330
|
+
stableCursor: false,
|
|
331
|
+
coverageValues: ['complete', 'unverified', 'partial'],
|
|
332
|
+
partialCompletesSuccessfully: false,
|
|
333
|
+
},
|
|
312
334
|
aiAnalysis: {
|
|
313
335
|
preview: 'evidence-review <talker> --dry-run --json',
|
|
314
336
|
execute: 'evidence-review <talker> --yes --json',
|
|
@@ -1217,6 +1239,336 @@ program
|
|
|
1217
1239
|
console.log(chalk.gray(`[${time}]`) + ` ${senderName}: ${content}`);
|
|
1218
1240
|
}
|
|
1219
1241
|
});
|
|
1242
|
+
// ==================== sync ====================
|
|
1243
|
+
// 增量同步检查点。契约见 docs/SYNC_CONTRACT.md, 边界见 D-029。
|
|
1244
|
+
// 不宣称「稳定游标」: D-027 要求它在所有后端支持前不得对外宣称,
|
|
1245
|
+
// 这里只做重叠时间窗 + 本地去重。
|
|
1246
|
+
const syncCmd = program.command('sync').description('本地消息同步检查点(重叠窗口读取与覆盖报告)');
|
|
1247
|
+
syncCmd
|
|
1248
|
+
.command('run <talker>')
|
|
1249
|
+
.description('执行一次同步并写入检查点')
|
|
1250
|
+
.option('--since <date>', '起始日期或 ISO 时间(首次运行需要)')
|
|
1251
|
+
.option('--full', '读取全部历史(首次运行需要;耗时可能较长)')
|
|
1252
|
+
.option('--overlap <seconds>', '重叠窗口秒数', '300')
|
|
1253
|
+
.option('-n, --limit <number>', '本次最多读取条数(0=不限)', '0')
|
|
1254
|
+
.option('--dry-run', '仅预览,不读取消息、不写状态')
|
|
1255
|
+
.option('--yes', '确认执行')
|
|
1256
|
+
.option('--json', '输出机器可读结果')
|
|
1257
|
+
.option('--non-interactive', '禁止交互确认')
|
|
1258
|
+
.action(async (talker, opts) => {
|
|
1259
|
+
const overlap = parseCliInteger(opts.overlap, 'overlap', 0, 86400, !!opts.json);
|
|
1260
|
+
const limit = parseCliInteger(opts.limit, 'limit', 0, 1_000_000, !!opts.json);
|
|
1261
|
+
let since;
|
|
1262
|
+
if (opts.since) {
|
|
1263
|
+
try {
|
|
1264
|
+
since = parseLocalDateOrIso(opts.since);
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
const message = error instanceof DateRangeError ? error.message : '日期格式无效';
|
|
1268
|
+
if (opts.json) {
|
|
1269
|
+
console.log(JSON.stringify({ success: false, code: 'INVALID_DATE', error: message }));
|
|
1270
|
+
process.exit(1);
|
|
1271
|
+
}
|
|
1272
|
+
console.error(chalk.red(`✗ ${message}`));
|
|
1273
|
+
process.exit(1);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (opts.full && since !== undefined) {
|
|
1277
|
+
if (opts.json) {
|
|
1278
|
+
console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--since 与 --full 不能同时使用' }));
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
console.error(chalk.red('✗ --since 与 --full 不能同时使用'));
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
// Preview deliberately does not resolve the talker: that reads the
|
|
1285
|
+
// database, and a preview must not touch local data (D-025).
|
|
1286
|
+
const prior = (() => {
|
|
1287
|
+
try {
|
|
1288
|
+
return syncStateStore.read(SYNC_SOURCE, talker);
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
})();
|
|
1294
|
+
const preview = {
|
|
1295
|
+
success: true,
|
|
1296
|
+
dryRun: true,
|
|
1297
|
+
action: 'sync.run',
|
|
1298
|
+
schema: SYNC_SCHEMA,
|
|
1299
|
+
source: SYNC_SOURCE,
|
|
1300
|
+
talkerResolved: false,
|
|
1301
|
+
hasCheckpoint: !!prior,
|
|
1302
|
+
window: {
|
|
1303
|
+
from: since ?? prior?.checkpoint.newestCreateTime ?? null,
|
|
1304
|
+
overlapSeconds: overlap,
|
|
1305
|
+
full: !!opts.full,
|
|
1306
|
+
},
|
|
1307
|
+
estimatedRecords: prior?.recordsRead ?? null,
|
|
1308
|
+
readsLocalChat: true,
|
|
1309
|
+
writesLocalState: true,
|
|
1310
|
+
stableCursor: false,
|
|
1311
|
+
};
|
|
1312
|
+
if (opts.dryRun) {
|
|
1313
|
+
if (opts.json)
|
|
1314
|
+
console.log(JSON.stringify(preview));
|
|
1315
|
+
else {
|
|
1316
|
+
console.log(chalk.cyan('\n同步预览'));
|
|
1317
|
+
console.log(chalk.gray(` 范围: ${opts.full ? '全部历史' : since ? new Date(since * 1000).toLocaleString() : '上次检查点'}`));
|
|
1318
|
+
console.log(chalk.gray(` 重叠窗口: ${overlap} 秒`));
|
|
1319
|
+
console.log(chalk.gray(' 将读取本地聊天记录并写入同步检查点,不调用 AI。\n'));
|
|
1320
|
+
}
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (!opts.yes) {
|
|
1324
|
+
if (opts.json) {
|
|
1325
|
+
console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }));
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
if (opts.nonInteractive) {
|
|
1329
|
+
console.error(chalk.red('✗ 非交互模式需要 --yes'));
|
|
1330
|
+
process.exit(1);
|
|
1331
|
+
}
|
|
1332
|
+
const { confirmed } = await inquirer.prompt([{
|
|
1333
|
+
type: 'confirm',
|
|
1334
|
+
name: 'confirmed',
|
|
1335
|
+
message: opts.full
|
|
1336
|
+
? '将读取该会话的全部历史消息,可能耗时数分钟。继续吗?'
|
|
1337
|
+
: '将读取指定范围内的消息并写入本地同步检查点。继续吗?',
|
|
1338
|
+
default: false,
|
|
1339
|
+
}]);
|
|
1340
|
+
if (!confirmed) {
|
|
1341
|
+
console.log(chalk.gray('已取消'));
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (!configService.isConfigured()) {
|
|
1346
|
+
if (opts.json) {
|
|
1347
|
+
console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' }));
|
|
1348
|
+
process.exit(1);
|
|
1349
|
+
}
|
|
1350
|
+
console.error(chalk.red('✗ 未完成初始化,请先运行 weflow-cli init'));
|
|
1351
|
+
process.exit(1);
|
|
1352
|
+
}
|
|
1353
|
+
let resolvedTalker = '';
|
|
1354
|
+
try {
|
|
1355
|
+
resolvedTalker = await resolveTalkerCore(talker, { interactive: !opts.nonInteractive });
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
resolvedTalker = '';
|
|
1359
|
+
}
|
|
1360
|
+
if (!resolvedTalker) {
|
|
1361
|
+
if (opts.json) {
|
|
1362
|
+
console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '未找到该会话' }));
|
|
1363
|
+
process.exit(1);
|
|
1364
|
+
}
|
|
1365
|
+
console.error(chalk.red('✗ 未找到该会话'));
|
|
1366
|
+
process.exit(1);
|
|
1367
|
+
}
|
|
1368
|
+
// A display name is nicety only; the state is keyed by the resolved id.
|
|
1369
|
+
let displayName = resolvedTalker;
|
|
1370
|
+
try {
|
|
1371
|
+
const match = (await chatService.listSessions()).find(s => s.username === resolvedTalker);
|
|
1372
|
+
if (match?.displayName && match.displayName !== resolvedTalker)
|
|
1373
|
+
displayName = match.displayName;
|
|
1374
|
+
}
|
|
1375
|
+
catch { /* keep the id */ }
|
|
1376
|
+
try {
|
|
1377
|
+
const result = await runSync(resolvedTalker, {
|
|
1378
|
+
since, full: !!opts.full, overlapSeconds: overlap, limit,
|
|
1379
|
+
scope: displayName,
|
|
1380
|
+
read: (who, howMany) => chatService.getMessagesWithShards(who, howMany, 0),
|
|
1381
|
+
});
|
|
1382
|
+
if (opts.json) {
|
|
1383
|
+
console.log(JSON.stringify({
|
|
1384
|
+
success: result.success,
|
|
1385
|
+
...(result.code ? { code: result.code } : {}),
|
|
1386
|
+
action: result.action,
|
|
1387
|
+
schema: SYNC_SCHEMA,
|
|
1388
|
+
source: SYNC_SOURCE,
|
|
1389
|
+
scope: result.scope,
|
|
1390
|
+
talker: resolvedTalker,
|
|
1391
|
+
window: result.window,
|
|
1392
|
+
recordsRead: result.recordsRead,
|
|
1393
|
+
recordsDeduplicated: result.recordsDeduplicated,
|
|
1394
|
+
recordsReturned: result.recordsReturned,
|
|
1395
|
+
shards: result.shards,
|
|
1396
|
+
coverage: result.coverage,
|
|
1397
|
+
mayHaveMore: result.mayHaveMore,
|
|
1398
|
+
partial: result.partial,
|
|
1399
|
+
warnings: result.warnings,
|
|
1400
|
+
jobId: result.jobId,
|
|
1401
|
+
}));
|
|
1402
|
+
if (!result.success)
|
|
1403
|
+
process.exit(1);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const mark = result.coverage === 'complete' ? '✓' : result.coverage === 'unverified' ? '!' : '✗';
|
|
1407
|
+
console.log(chalk.green(`\n${mark} 同步完成: ${result.recordsReturned}/${result.recordsRead} 条(去重 ${result.recordsDeduplicated})`));
|
|
1408
|
+
console.log(chalk.gray(` 覆盖: ${result.coverage} · 分片: ${result.shards?.opened ?? '?'} 读 / ${result.shards?.failed ?? 0} 失败`));
|
|
1409
|
+
for (const warning of result.warnings)
|
|
1410
|
+
console.log(chalk.yellow(` ⚠ ${warning}`));
|
|
1411
|
+
if (result.partial) {
|
|
1412
|
+
console.log(chalk.yellow(' 本次为部分完成,未推进成功时间;用 sync status 查看详情。'));
|
|
1413
|
+
}
|
|
1414
|
+
console.log('');
|
|
1415
|
+
if (!result.success)
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
}
|
|
1418
|
+
catch (error) {
|
|
1419
|
+
if (error instanceof SyncRangeRequiredError) {
|
|
1420
|
+
if (opts.json) {
|
|
1421
|
+
console.log(JSON.stringify({ success: false, code: 'SYNC_RANGE_REQUIRED', error: error.message }));
|
|
1422
|
+
process.exit(1);
|
|
1423
|
+
}
|
|
1424
|
+
console.error(chalk.red(`✗ ${error.message}`));
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
if (opts.json) {
|
|
1428
|
+
console.log(JSON.stringify({ success: false, code: 'SYNC_FAILED', error: '同步失败' }));
|
|
1429
|
+
process.exit(1);
|
|
1430
|
+
}
|
|
1431
|
+
console.error(chalk.red(`✗ 同步失败: ${error?.message || error}`));
|
|
1432
|
+
process.exit(1);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
syncCmd
|
|
1436
|
+
.command('status [talker]')
|
|
1437
|
+
.description('查看同步检查点与最近任务')
|
|
1438
|
+
.option('--json', '输出 JSON 格式')
|
|
1439
|
+
.action(async (talker, opts) => {
|
|
1440
|
+
// No database access: this has to work while the database is locked.
|
|
1441
|
+
let states = [];
|
|
1442
|
+
try {
|
|
1443
|
+
states = syncStateStore.list();
|
|
1444
|
+
}
|
|
1445
|
+
catch {
|
|
1446
|
+
states = [];
|
|
1447
|
+
}
|
|
1448
|
+
const lastJob = syncStateStore.recentJobs(1)[0] ?? null;
|
|
1449
|
+
if (talker) {
|
|
1450
|
+
const match = states.find(s => s.talker === talker || s.scope === talker);
|
|
1451
|
+
if (!match) {
|
|
1452
|
+
if (opts.json) {
|
|
1453
|
+
console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' }));
|
|
1454
|
+
process.exit(1);
|
|
1455
|
+
}
|
|
1456
|
+
console.error(chalk.red('✗ 没有该会话的同步记录'));
|
|
1457
|
+
process.exit(1);
|
|
1458
|
+
}
|
|
1459
|
+
if (opts.json) {
|
|
1460
|
+
console.log(JSON.stringify({ success: true, schema: SYNC_SCHEMA, state: match, lastJob }));
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
console.log(chalk.cyan(`\n同步记录 — ${match.scope}`));
|
|
1464
|
+
console.log(chalk.gray(` 覆盖: ${match.coveredFrom || '—'} ~ ${match.coveredTo || '—'}`));
|
|
1465
|
+
console.log(chalk.gray(` 可信度: ${match.coverage} · 分片 ${match.shardsRead} 读 / ${match.shardsFailed} 失败`));
|
|
1466
|
+
console.log(chalk.gray(` 上次成功: ${match.lastSuccessfulRun || '—'} · 上次尝试: ${match.lastAttempt || '—'}\n`));
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (opts.json) {
|
|
1470
|
+
console.log(JSON.stringify({
|
|
1471
|
+
success: true,
|
|
1472
|
+
schema: SYNC_SCHEMA,
|
|
1473
|
+
stableCursor: false,
|
|
1474
|
+
scopes: states.map(s => ({
|
|
1475
|
+
source: s.source, scope: s.scope, talker: s.talker,
|
|
1476
|
+
lastSuccessfulRun: s.lastSuccessfulRun, lastAttempt: s.lastAttempt,
|
|
1477
|
+
coveredFrom: s.coveredFrom, coveredTo: s.coveredTo,
|
|
1478
|
+
recordsRead: s.recordsRead, recordsDeduplicated: s.recordsDeduplicated,
|
|
1479
|
+
coverage: s.coverage, shardsRead: s.shardsRead, shardsFailed: s.shardsFailed,
|
|
1480
|
+
mayHaveMore: s.mayHaveMore, warnings: s.warnings,
|
|
1481
|
+
})),
|
|
1482
|
+
lastJob,
|
|
1483
|
+
}));
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
if (!states.length) {
|
|
1487
|
+
console.log(chalk.gray('\n还没有任何同步记录。首次运行: weflow-cli sync run <会话> --since <日期>\n'));
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
console.log(chalk.cyan(`\n同步记录 (${states.length} 个会话):\n`));
|
|
1491
|
+
for (const s of states) {
|
|
1492
|
+
const mark = s.coverage === 'complete' ? '✓' : s.coverage === 'unverified' ? '!' : '✗';
|
|
1493
|
+
console.log(` ${mark} ${s.scope} ${chalk.gray(`${s.coveredFrom || '—'} ~ ${s.coveredTo || '—'} 分片 ${s.shardsRead}/${s.shardsFailed} 失败`)}`);
|
|
1494
|
+
}
|
|
1495
|
+
console.log('');
|
|
1496
|
+
});
|
|
1497
|
+
syncCmd
|
|
1498
|
+
.command('verify <talker>')
|
|
1499
|
+
.description('核对检查点与当前数据的边界')
|
|
1500
|
+
.option('--json', '输出 JSON 格式')
|
|
1501
|
+
.action(async (talker, opts) => {
|
|
1502
|
+
// Read-only: it writes nothing, so it needs no confirmation - the same
|
|
1503
|
+
// posture as `messages`.
|
|
1504
|
+
if (!configService.isConfigured()) {
|
|
1505
|
+
if (opts.json) {
|
|
1506
|
+
console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' }));
|
|
1507
|
+
process.exit(1);
|
|
1508
|
+
}
|
|
1509
|
+
console.error(chalk.red('✗ 未完成初始化'));
|
|
1510
|
+
process.exit(1);
|
|
1511
|
+
}
|
|
1512
|
+
let state = null;
|
|
1513
|
+
try {
|
|
1514
|
+
state = syncStateStore.read(SYNC_SOURCE, talker);
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
state = null;
|
|
1518
|
+
}
|
|
1519
|
+
if (!state)
|
|
1520
|
+
state = syncStateStore.list().find(s => s.scope === talker) ?? null;
|
|
1521
|
+
if (!state) {
|
|
1522
|
+
if (opts.json) {
|
|
1523
|
+
console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' }));
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
console.error(chalk.red('✗ 没有该会话的同步记录'));
|
|
1527
|
+
process.exit(1);
|
|
1528
|
+
}
|
|
1529
|
+
const checks = [{ name: 'checkpoint-present', ok: true }];
|
|
1530
|
+
// Re-read and compare. This is the count summary of §4.4 in the absence
|
|
1531
|
+
// of a local index.
|
|
1532
|
+
const read = await chatService.getMessagesWithShards(state.talker, 0, 0);
|
|
1533
|
+
const fromSeconds = state.coveredFrom ? Math.floor(Date.parse(state.coveredFrom) / 1000) : null;
|
|
1534
|
+
const inWindow = fromSeconds === null
|
|
1535
|
+
? read.messages
|
|
1536
|
+
: read.messages.filter(m => Number(m.createTime) >= fromSeconds);
|
|
1537
|
+
checks.push({ name: 'window-re-readable', ok: inWindow.length > 0, returned: inWindow.length });
|
|
1538
|
+
const newest = read.messages.length
|
|
1539
|
+
? Math.max(...read.messages.map(m => Number(m.createTime) || 0))
|
|
1540
|
+
: null;
|
|
1541
|
+
checks.push({
|
|
1542
|
+
name: 'newest-message-present',
|
|
1543
|
+
ok: newest !== null && newest >= (state.checkpoint?.newestCreateTime ?? 0),
|
|
1544
|
+
recorded: state.checkpoint?.newestCreateTime ?? null,
|
|
1545
|
+
observed: newest,
|
|
1546
|
+
});
|
|
1547
|
+
if (read.shards) {
|
|
1548
|
+
checks.push({ name: 'shards-readable', ok: read.shards.failed === 0,
|
|
1549
|
+
scanned: read.shards.scanned, failed: read.shards.failed });
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
// Neither a pass nor a failure: this backend simply cannot say.
|
|
1553
|
+
checks.push({ name: 'shards-readable', ok: true, skipped: true,
|
|
1554
|
+
reason: 'backend-does-not-report-shards' });
|
|
1555
|
+
}
|
|
1556
|
+
const success = checks.every(c => c.ok || c.skipped);
|
|
1557
|
+
if (opts.json) {
|
|
1558
|
+
console.log(JSON.stringify({ success, schema: SYNC_SCHEMA, scope: state.scope, coverage: state.coverage, checks }));
|
|
1559
|
+
if (!success)
|
|
1560
|
+
process.exit(1);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
console.log(chalk.cyan(`\n核对 — ${state.scope}\n`));
|
|
1564
|
+
for (const check of checks) {
|
|
1565
|
+
const mark = check.skipped ? '–' : check.ok ? '✓' : '✗';
|
|
1566
|
+
console.log(` ${mark} ${check.name}${check.skipped ? chalk.gray(` (跳过: ${check.reason})`) : ''}`);
|
|
1567
|
+
}
|
|
1568
|
+
console.log('');
|
|
1569
|
+
if (!success)
|
|
1570
|
+
process.exit(1);
|
|
1571
|
+
});
|
|
1220
1572
|
// ==================== contacts ====================
|
|
1221
1573
|
program
|
|
1222
1574
|
.command('contacts')
|