feat(client): summarize digital sync failures

This commit is contained in:
baiyanyun
2026-06-07 01:51:15 +08:00
parent cef3fe4a43
commit 38ece7e3d3
6 changed files with 95 additions and 8 deletions

View File

@@ -61,9 +61,17 @@ interface QimingclawSyncStatus {
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
failureSummary?: QimingclawSyncFailureSummary;
recentFailures?: QimingclawSyncFailure[];
}
interface QimingclawSyncFailureSummary {
total: number;
dueForRetry: number;
backoff: number;
byEntityType: Array<{ entityType: string; count: number }>;
}
interface QimingclawSyncAttempt {
attemptNo: number;
status: string;

View File

@@ -61,9 +61,17 @@ interface ManagementSyncStatus {
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
failureSummary?: ManagementSyncFailureSummary;
recentFailures?: ManagementSyncFailure[];
}
interface ManagementSyncFailureSummary {
total: number;
dueForRetry: number;
backoff: number;
byEntityType: Array<{ entityType: string; count: number }>;
}
interface ManagementSyncAttempt {
attemptNo: number;
status: string;
@@ -461,9 +469,21 @@ function syncStatusSummary(status: ManagementSyncStatus | null): string {
if (!status.endpoint) return '管理端同步地址未配置。';
const error = summarizeSyncError(status.lastSyncError);
if (status.failed > 0 || error) {
const retrySummary = status.failureSummary
? [
status.failureSummary.dueForRetry > 0
? `${status.failureSummary.dueForRetry} 条可重试`
: '',
status.failureSummary.backoff > 0
? `${status.failureSummary.backoff} 条退避中`
: '',
failureGroupSummary(status.failureSummary),
].filter(Boolean).join('')
: '';
return [
status.failed > 0 ? `${status.failed} 条失败` : '',
status.pending > 0 ? `${status.pending} 条待同步` : '',
retrySummary,
error ? `失败原因:${error}` : '',
].filter(Boolean).join('');
}
@@ -486,6 +506,14 @@ function entityTypeLabel(entityType: string): string {
return labels[entityType] || entityType || 'Entity';
}
function failureGroupSummary(summary: ManagementSyncFailureSummary): string {
const groups = summary.byEntityType
.slice(0, 3)
.map((group) => `${entityTypeLabel(group.entityType)} ${group.count}`)
.join(' / ');
return groups ? `类型:${groups}` : '';
}
function syncFailureSummary(failure: ManagementSyncFailure): string {
const error = summarizeSyncError(failure.error);
return [

View File

@@ -13,7 +13,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-CeOrqzZi.js"></script>
<script type="module" crossorigin src="./assets/index-BKHFgVhX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
</head>
<body>

View File

@@ -45,6 +45,18 @@ export interface DigitalEmployeeSyncAttempt {
finishedAt: string;
}
export interface DigitalEmployeeSyncFailureGroup {
entityType: string;
count: number;
}
export interface DigitalEmployeeSyncFailureSummary {
total: number;
dueForRetry: number;
backoff: number;
byEntityType: DigitalEmployeeSyncFailureGroup[];
}
export interface DigitalEmployeeSyncFailure {
id: string;
entityType: string;
@@ -87,6 +99,7 @@ export interface DigitalEmployeeSyncStatus {
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
failureSummary: DigitalEmployeeSyncFailureSummary;
recentFailures: DigitalEmployeeSyncFailure[];
}
@@ -123,6 +136,7 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
failed: countOutbox("failed"),
lastSyncAt,
lastSyncError,
failureSummary: readFailureSummary(),
recentFailures: readRecentFailures(config),
};
}
@@ -467,6 +481,43 @@ function countOutbox(status: string): number {
return row?.count ?? 0;
}
function readFailureSummary(): DigitalEmployeeSyncFailureSummary {
const db = getDb();
if (!db) return { total: 0, dueForRetry: 0, backoff: 0, byEntityType: [] };
const rows = db
.prepare(
`
SELECT entity_type, attempts, last_attempt_at
FROM digital_sync_outbox
WHERE status = 'failed'
`,
)
.all() as Array<{
entity_type: string;
attempts: number;
last_attempt_at: string | null;
}>;
const groups = new Map<string, number>();
let dueForRetry = 0;
for (const row of rows) {
groups.set(row.entity_type, (groups.get(row.entity_type) || 0) + 1);
const nextRetryAt = nextRetryAtForFailure(row.last_attempt_at, row.attempts);
if (!nextRetryAt || Date.now() >= Date.parse(nextRetryAt)) {
dueForRetry += 1;
}
}
return {
total: rows.length,
dueForRetry,
backoff: rows.length - dueForRetry,
byEntityType: Array.from(groups.entries())
.map(([entityType, count]) => ({ entityType, count }))
.sort((left, right) => right.count - left.count),
};
}
function readRecentFailures(
config: DigitalEmployeeSyncConfig,
limit = 3,

View File

@@ -444,7 +444,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 同步成功后回填 outbox 状态,并根据后端 ack 回填实体 `remote_id` / `last_synced_at`
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
- 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。