feat(client): track digital sync retry history

This commit is contained in:
baiyanyun
2026-06-07 01:42:39 +08:00
parent 646901b5fa
commit cef3fe4a43
10 changed files with 375 additions and 133 deletions

View File

@@ -1698,6 +1698,59 @@
font-size: 13px;
line-height: 1.55;
}
.de-sync-attempt-history {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid rgba(126,163,194,0.2);
border-radius: 14px;
background: rgba(247,251,255,0.82);
}
.de-sync-attempt-history > span {
color: #6f8196;
font-size: 12px;
font-weight: 800;
}
.de-sync-attempt-list {
display: grid;
gap: 8px;
}
.de-sync-attempt-row {
display: grid;
gap: 5px;
padding: 9px 10px;
border-radius: 12px;
background: rgba(255,255,255,0.72);
}
.de-sync-attempt-row > div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.de-sync-attempt-row strong {
color: #153754;
font-size: 12px;
}
.de-sync-attempt-row em {
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.de-sync-attempt-row span,
.de-sync-attempt-row small {
color: #667b90;
font-size: 12px;
}
.de-sync-attempt-row small {
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.de-sync-attempt-row--success em { color: #23805d; }
.de-sync-attempt-row--danger em { color: #bb3b3b; }
.de-sync-attempt-row--info em { color: #2e6fa5; }
.de-event-modal-actions {
display: flex;
justify-content: flex-end;

View File

@@ -64,6 +64,15 @@ interface QimingclawSyncStatus {
recentFailures?: QimingclawSyncFailure[];
}
interface QimingclawSyncAttempt {
attemptNo: number;
status: string;
error: string | null;
endpoint?: string | null;
startedAt: string;
finishedAt: string;
}
interface QimingclawSyncFailure {
id: string;
entityType: string;
@@ -74,6 +83,7 @@ interface QimingclawSyncFailure {
nextRetryAt?: string | null;
dueForRetry?: boolean;
managementRecordUrl?: string | null;
attemptHistory?: QimingclawSyncAttempt[];
error: string | null;
createdAt: string;
updatedAt: string;

View File

@@ -64,6 +64,15 @@ interface ManagementSyncStatus {
recentFailures?: ManagementSyncFailure[];
}
interface ManagementSyncAttempt {
attemptNo: number;
status: string;
error: string | null;
endpoint?: string | null;
startedAt: string;
finishedAt: string;
}
interface ManagementSyncFailure {
id: string;
entityType: string;
@@ -74,6 +83,7 @@ interface ManagementSyncFailure {
nextRetryAt?: string | null;
dueForRetry?: boolean;
managementRecordUrl?: string | null;
attemptHistory?: ManagementSyncAttempt[];
error: string | null;
createdAt: string;
updatedAt: string;
@@ -495,6 +505,18 @@ function syncFailureRetryTime(failure: ManagementSyncFailure): string {
return formatBeijingDateTime(failure.nextRetryAt) || '待计算';
}
function syncAttemptStatusLabel(status: string): string {
if (status === 'synced') return '已同步';
if (status === 'failed') return '失败';
return status || '未知';
}
function syncAttemptTone(status: string): 'success' | 'danger' | 'info' {
if (status === 'synced') return 'success';
if (status === 'failed') return 'danger';
return 'info';
}
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSyncStatus) return null;
@@ -1837,6 +1859,31 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span></span>
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
</div>
{(selectedSyncFailure.attemptHistory?.length ?? 0) > 0 && (
<div className="de-sync-attempt-history">
<span></span>
<div className="de-sync-attempt-list">
{selectedSyncFailure.attemptHistory?.map((attempt) => (
<div
key={`${attempt.attemptNo}-${attempt.finishedAt}`}
className={`de-sync-attempt-row de-sync-attempt-row--${syncAttemptTone(
attempt.status,
)}`}
>
<div>
<strong> {attempt.attemptNo} </strong>
<em>{syncAttemptStatusLabel(attempt.status)}</em>
<span>
{formatBeijingDateTime(attempt.finishedAt) ||
attempt.finishedAt}
</span>
</div>
{attempt.error && <small>{attempt.error}</small>}
</div>
))}
</div>
</div>
)}
<div className="de-event-modal-actions">
<button
type="button"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -136,11 +136,26 @@ export function initDatabase(): void {
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS digital_sync_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outbox_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
attempt_no INTEGER NOT NULL,
status TEXT NOT NULL,
error TEXT,
endpoint TEXT,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_digital_plans_sync ON digital_plans(sync_status);
CREATE INDEX IF NOT EXISTS idx_digital_tasks_plan ON digital_tasks(plan_id);
CREATE INDEX IF NOT EXISTS idx_digital_runs_task ON digital_runs(task_id);
CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_digital_sync_outbox_status ON digital_sync_outbox(status, created_at);
CREATE INDEX IF NOT EXISTS idx_digital_sync_attempts_outbox ON digital_sync_attempts(outbox_id, finished_at);
`);
log.info('Database tables created');
} catch (error) {

View File

@@ -11,6 +11,7 @@ const DEFAULT_BATCH_SIZE = 50;
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRY_DELAY_MS = 30 * 60_000;
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
let syncTimer: ReturnType<typeof setInterval> | null = null;
let syncing = false;
@@ -35,6 +36,15 @@ interface OutboxRow {
last_attempt_at?: string | null;
}
export interface DigitalEmployeeSyncAttempt {
attemptNo: number;
status: string;
error: string | null;
endpoint: string | null;
startedAt: string;
finishedAt: string;
}
export interface DigitalEmployeeSyncFailure {
id: string;
entityType: string;
@@ -45,6 +55,7 @@ export interface DigitalEmployeeSyncFailure {
nextRetryAt: string | null;
dueForRetry: boolean;
managementRecordUrl: string | null;
attemptHistory: DigitalEmployeeSyncAttempt[];
error: string | null;
createdAt: string;
updatedAt: string;
@@ -134,17 +145,25 @@ export async function flushDigitalEmployeeSyncOutbox(
}
syncing = true;
const now = new Date().toISOString();
markRowsSyncing(rows, now);
const startedAt = new Date().toISOString();
markRowsSyncing(rows, startedAt);
try {
const response = await postOutbox(config, rows);
const finishedAt = new Date().toISOString();
const acked = response.data?.acked || response.acked || [];
if (response.success === false) {
throw new Error(response.message || "digital employee sync rejected");
}
const syncedRows = acked.length > 0
? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row)))
: rows;
const unackedRows = acked.length > 0
? rows.filter((row) => !acked.some((ack) => ackMatchesRow(ack, row)))
: [];
recordSyncAttempts(syncedRows, config, "synced", null, startedAt, finishedAt);
if (acked.length > 0) {
markRowsSynced(acked, now);
markRowsSynced(acked, finishedAt);
} else {
markRowsSynced(
rows.map((row) => ({
@@ -152,14 +171,30 @@ export async function flushDigitalEmployeeSyncOutbox(
entity_type: row.entity_type,
entity_id: row.entity_id,
})),
now,
finishedAt,
);
}
lastSyncAt = now;
lastSyncError = null;
if (unackedRows.length > 0) {
const unackedError = "management sync response did not acknowledge item";
recordSyncAttempts(
unackedRows,
config,
"failed",
unackedError,
startedAt,
finishedAt,
);
markRowsFailed(unackedRows, finishedAt, unackedError);
lastSyncError = unackedError;
} else {
lastSyncError = null;
}
lastSyncAt = finishedAt;
} catch (error) {
const finishedAt = new Date().toISOString();
lastSyncError = normalizeError(error);
markRowsFailed(rows, now, lastSyncError);
recordSyncAttempts(rows, config, "failed", lastSyncError, startedAt, finishedAt);
markRowsFailed(rows, finishedAt, lastSyncError);
log.warn("[DigitalEmployeeSync] Flush failed:", lastSyncError);
} finally {
syncing = false;
@@ -335,6 +370,50 @@ function markRowsSynced(acks: SyncAck[], now: string): void {
tx();
}
function ackMatchesRow(ack: SyncAck, row: OutboxRow): boolean {
const outboxId = ack.outbox_id || ack.id;
if (outboxId) return outboxId === row.id;
return ack.entity_type === row.entity_type && ack.entity_id === row.entity_id;
}
function recordSyncAttempts(
rows: OutboxRow[],
config: DigitalEmployeeSyncConfig,
status: "synced" | "failed",
error: string | null,
startedAt: string,
finishedAt: string,
): void {
const db = getDb();
if (!db) return;
const tx = db.transaction(() => {
const stmt = db.prepare(`
INSERT INTO digital_sync_attempts (
outbox_id, entity_type, entity_id, operation, attempt_no,
status, error, endpoint, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const row of rows) {
stmt.run(
row.id,
row.entity_type,
row.entity_id,
row.operation,
row.attempts + 1,
status,
error,
config.endpoint,
startedAt,
finishedAt,
);
}
db.prepare("DELETE FROM digital_sync_attempts WHERE finished_at < ?").run(
new Date(Date.parse(finishedAt) - ATTEMPT_HISTORY_RETENTION_MS).toISOString(),
);
});
tx();
}
function updateEntitySyncStatus(ack: SyncAck, now: string): void {
if (!ack.entity_type || !ack.entity_id) return;
const table = entityTable(ack.entity_type);
@@ -429,6 +508,7 @@ function readRecentFailures(
nextRetryAt,
dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt),
managementRecordUrl: buildManagementRecordUrl(config, row),
attemptHistory: readAttemptHistory(row.id),
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -436,6 +516,41 @@ function readRecentFailures(
});
}
function readAttemptHistory(
outboxId: string,
limit = 5,
): DigitalEmployeeSyncAttempt[] {
const db = getDb();
if (!db) return [];
const rows = db
.prepare(
`
SELECT attempt_no, status, error, endpoint, started_at, finished_at
FROM digital_sync_attempts
WHERE outbox_id = ?
ORDER BY finished_at DESC, id DESC
LIMIT ?
`,
)
.all(outboxId, limit) as Array<{
attempt_no: number;
status: string;
error: string | null;
endpoint: string | null;
started_at: string;
finished_at: string;
}>;
return rows.map((row) => ({
attemptNo: row.attempt_no,
status: row.status,
error: row.error,
endpoint: row.endpoint,
startedAt: row.started_at,
finishedAt: row.finished_at,
}));
}
function buildManagementRecordUrl(
config: DigitalEmployeeSyncConfig,
row: {

View File

@@ -446,7 +446,8 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。
- 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
新增 IPC / Bridge
@@ -462,6 +463,7 @@ window.QimingClawBridge.digital.flushSync()
- qiming-backend 已补最小接收端,先以通用同步记录表承接客户端 outbox。
- worker 只消费 outbox不直接侵入 Plan / Task / Run / Event 写入链路。
- 管理端返回格式包含 `acked[]`,每项带 `outbox_id``entity_type``entity_id``remote_id`
- 若管理端只 ack 批次中的部分 item已 ack 的 item 会标记 synced未 ack 的 item 会保留 failed 状态并进入下一轮补偿。
- 当前接口沿用管理端登录态鉴权,客户端必须能读取到登录 ticket/token 才会推送;没有 token 时保持本地 outbox 等待。
后端最小落点:
@@ -487,7 +489,7 @@ GET /api/digital-employee/sync/records
下一步:
- 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图。
- 数字员工页面继续补充更完整的同步重试历史
- 数字员工页面继续补充管理端业务视图入口和失败分组统计
## 管理端联动原则