吸收数字员工诊断审计投影
This commit is contained in:
@@ -5,11 +5,13 @@ import type {
|
||||
CanonicalEvent,
|
||||
CronJob,
|
||||
CronRun,
|
||||
CostSummary,
|
||||
DashboardResponse,
|
||||
DebugPlan,
|
||||
DebugRun,
|
||||
DebugStoreResponse,
|
||||
DebugTask,
|
||||
DiagResult,
|
||||
DigitalEmployeeDecision,
|
||||
DigitalEmployeeDuty,
|
||||
DigitalEmployeeManagedService,
|
||||
@@ -647,6 +649,15 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/dashboard') {
|
||||
return buildDashboard(await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if ((method === 'GET' || method === 'POST') && pathname === '/api/doctor') {
|
||||
return buildDoctor(await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/cost') {
|
||||
return { cost: buildCostSummary(await readQimingclawSnapshot()) } as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/audit') {
|
||||
return { events: buildAuditEvents(await readQimingclawSnapshot(), url.searchParams) } as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/artifact') {
|
||||
return { artifacts: buildArtifacts(await readQimingclawSnapshot()) } as T;
|
||||
}
|
||||
@@ -1889,6 +1900,197 @@ function buildDashboard(snapshot: QimingclawSnapshot | null): DashboardResponse
|
||||
};
|
||||
}
|
||||
|
||||
function buildDoctor(snapshot: QimingclawSnapshot | null): { results: DiagResult[]; summary: Record<string, unknown> } {
|
||||
const now = new Date().toISOString();
|
||||
const managedServices = buildManagedServices(snapshot);
|
||||
const tasks = buildTasks(null, snapshot);
|
||||
const approvals = buildApprovals(snapshot);
|
||||
const runtimeErrors = runtimeEvents(snapshot).filter((event) => /fail|error|reject|cancel/i.test(`${event.kind} ${event.message}`));
|
||||
const results: DiagResult[] = managedServices.map((service) => ({
|
||||
id: `service:${service.service_id}`,
|
||||
severity: service.requires_human_action || service.status === 'stopped' ? 'error' : service.status === 'degraded' ? 'warn' : 'ok',
|
||||
category: 'service',
|
||||
message: `${service.name}:${service.status_label || service.status}`,
|
||||
details: compactRecord({ error: service.error, kind: service.kind, running: service.running, dependent_tasks: service.dependent_tasks }),
|
||||
source: 'qimingclaw-managed-service',
|
||||
created_at: service.last_seen_at || now,
|
||||
}));
|
||||
|
||||
const sync = snapshot?.sync;
|
||||
results.push({
|
||||
id: 'sync:management',
|
||||
severity: doctorSeverityForSync(sync ?? null),
|
||||
category: 'sync',
|
||||
message: syncLine(snapshot),
|
||||
details: sync ? compactRecord({ pending: sync.pending, failed: sync.failed, endpoint: sync.endpoint, missing_credentials: sync.missingCredentials, last_error: sync.lastSyncError }) : null,
|
||||
source: 'qimingclaw-sync-status',
|
||||
created_at: sync?.lastSyncAt ?? now,
|
||||
});
|
||||
|
||||
for (const failure of sync?.recentFailures ?? []) {
|
||||
results.push({
|
||||
id: `sync_failure:${failure.id}`,
|
||||
severity: failure.dueForRetry === false ? 'error' : 'warn',
|
||||
category: 'sync',
|
||||
message: syncFailureLine(failure),
|
||||
details: compactRecord({ entity_type: failure.entityType, entity_id: failure.entityId, operation: failure.operation, attempts: failure.attempts, next_retry_at: failure.nextRetryAt }),
|
||||
source: 'qimingclaw-sync-failure',
|
||||
created_at: failure.lastAttemptAt ?? failure.updatedAt ?? failure.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const failedTasks = tasks.filter((task) => taskStatusTone(task.status) === 'danger');
|
||||
results.push({
|
||||
id: 'runtime:tasks',
|
||||
severity: failedTasks.length > 0 ? 'error' : tasks.length === 0 ? 'warn' : 'ok',
|
||||
category: 'runtime',
|
||||
message: tasks.length === 0 ? '尚未读取到 qimingclaw runtime task。' : `任务 ${tasks.length} 个,异常 ${failedTasks.length} 个。`,
|
||||
details: compactRecord({ task_count: tasks.length, failed_task_count: failedTasks.length }),
|
||||
source: 'qimingclaw-runtime-records',
|
||||
created_at: now,
|
||||
});
|
||||
results.push({
|
||||
id: 'runtime:approvals',
|
||||
severity: approvals.some((approval) => approval.status === 'pending') ? 'warn' : 'ok',
|
||||
category: 'approval',
|
||||
message: `审批 ${approvals.length} 个,待处理 ${approvals.filter((approval) => approval.status === 'pending').length} 个。`,
|
||||
details: compactRecord({ approval_count: approvals.length, pending_count: approvals.filter((approval) => approval.status === 'pending').length }),
|
||||
source: 'qimingclaw-approval-records',
|
||||
created_at: now,
|
||||
});
|
||||
if (runtimeErrors.length > 0) {
|
||||
results.push({
|
||||
id: 'runtime:events:error',
|
||||
severity: 'error',
|
||||
category: 'runtime',
|
||||
message: `最近运行事件中发现 ${runtimeErrors.length} 条异常事实。`,
|
||||
details: { event_ids: runtimeErrors.slice(0, 8).map((event) => event.id) },
|
||||
source: 'qimingclaw-runtime-events',
|
||||
created_at: runtimeErrors[0]?.occurredAt ?? now,
|
||||
});
|
||||
}
|
||||
if (!hasRuntimeRecords(snapshot)) {
|
||||
results.push({
|
||||
id: 'runtime:data:empty',
|
||||
severity: 'warn',
|
||||
category: 'data',
|
||||
message: '本地数字员工 runtime 记录为空,页面将只显示兼容投影。',
|
||||
source: 'qimingclaw-runtime-records',
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
summary: {
|
||||
ok: results.filter((result) => result.severity === 'ok').length,
|
||||
warn: results.filter((result) => result.severity === 'warn').length,
|
||||
error: results.filter((result) => result.severity === 'error').length,
|
||||
generated_at: now,
|
||||
source: 'qimingclaw-runtime-projection',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function doctorSeverityForSync(sync: QimingclawSyncStatus | null): DiagResult['severity'] {
|
||||
if (!sync) return 'warn';
|
||||
if (sync.failed > 0 || sync.lastSyncError || (sync.missingCredentials?.length ?? 0) > 0) return 'error';
|
||||
if (!sync.enabled || !sync.endpoint || sync.pending > 0) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function buildCostSummary(snapshot: QimingclawSnapshot | null): CostSummary {
|
||||
const requestCount = runtimeEvents(snapshot).length + runtimeRuns(snapshot).length + runtimeTasks(snapshot).length;
|
||||
return {
|
||||
session_cost_usd: 0,
|
||||
daily_cost_usd: 0,
|
||||
monthly_cost_usd: 0,
|
||||
total_tokens: 0,
|
||||
request_count: requestCount,
|
||||
by_model: {
|
||||
'qimingclaw-local': {
|
||||
model: 'qimingclaw-local',
|
||||
cost_usd: 0,
|
||||
total_tokens: 0,
|
||||
request_count: requestCount,
|
||||
},
|
||||
},
|
||||
estimated: true,
|
||||
source: 'qimingclaw-runtime-projection',
|
||||
updated_at: snapshot?.generatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuditEvents(snapshot: QimingclawSnapshot | null, searchParams: URLSearchParams): Array<Record<string, unknown>> {
|
||||
const limit = Math.max(1, Math.min(Math.floor(numberValue(searchParams.get('limit')) ?? 100), 300));
|
||||
const category = stringValue(searchParams.get('category')) || stringValue(searchParams.get('kind'));
|
||||
const level = stringValue(searchParams.get('level'));
|
||||
const entityId = stringValue(searchParams.get('id'));
|
||||
const events = [
|
||||
...runtimeEvents(snapshot).map(auditEventFromRuntimeEvent),
|
||||
...(snapshot?.sync?.recentFailures ?? []).map((failure) => ({
|
||||
audit_id: `sync_failure:${failure.id}`,
|
||||
category: 'sync',
|
||||
action: 'sync_failed',
|
||||
level: failure.dueForRetry === false ? 'error' : 'warn',
|
||||
message: syncFailureLine(failure),
|
||||
actor: 'qimingclaw-sync',
|
||||
entity_type: failure.entityType,
|
||||
entity_id: failure.entityId,
|
||||
occurred_at: failure.lastAttemptAt ?? failure.updatedAt ?? failure.createdAt,
|
||||
payload: failure,
|
||||
})),
|
||||
...buildArtifacts(snapshot).flatMap((artifact) => auditEventsFromArtifact(artifact)),
|
||||
];
|
||||
return events
|
||||
.filter((event) => !category || stringValue(event.category) === category || stringValue(event.entity_type) === category)
|
||||
.filter((event) => !level || stringValue(event.level) === level)
|
||||
.filter((event) => !entityId || stringValue(event.entity_id) === entityId || stringValue(event.audit_id).includes(entityId))
|
||||
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function auditEventFromRuntimeEvent(event: QimingclawEventRecord): Record<string, unknown> {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
const category = event.kind.startsWith('governance_')
|
||||
? 'governance'
|
||||
: event.kind === 'route_decision'
|
||||
? 'route'
|
||||
: event.kind === 'approval_decision'
|
||||
? 'approval'
|
||||
: 'runtime';
|
||||
const entityType = event.runId ? 'run' : event.taskId ? 'task' : event.planId ? 'plan' : 'event';
|
||||
return {
|
||||
audit_id: event.id,
|
||||
category,
|
||||
action: event.kind,
|
||||
level: /fail|error|reject|cancel/i.test(`${event.kind} ${event.message}`) ? 'error' : 'info',
|
||||
message: event.message || event.kind,
|
||||
actor: stringValue(payload.actor) || stringValue(payload.source) || 'qimingclaw-runtime',
|
||||
entity_type: entityType,
|
||||
entity_id: event.runId ?? event.taskId ?? event.planId ?? event.id,
|
||||
occurred_at: event.occurredAt,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function auditEventsFromArtifact(artifact: ArtifactEntry): Array<Record<string, unknown>> {
|
||||
const deliveryStatus = stringValue(artifact.delivery_status);
|
||||
if (!deliveryStatus) return [];
|
||||
return [{
|
||||
audit_id: `artifact_delivery:${artifact.artifact_id ?? artifact.subject_id}`,
|
||||
category: 'artifact',
|
||||
action: stringValue(artifact.delivery_stage) || 'artifact_delivery',
|
||||
level: deliveryStatus === 'failed' || deliveryStatus === 'error' ? 'error' : 'info',
|
||||
message: `${artifact.name || artifact.kind || 'Artifact'}:${deliveryStatus}`,
|
||||
actor: stringValue(artifact.source_tool) || 'qimingclaw-file-server',
|
||||
entity_type: 'artifact',
|
||||
entity_id: artifact.artifact_id ?? artifact.subject_id,
|
||||
occurred_at: stringValue(artifact.created_at) || new Date().toISOString(),
|
||||
payload: artifact,
|
||||
}];
|
||||
}
|
||||
|
||||
function buildCanonicalEvents(
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
tasks: DebugTask[],
|
||||
|
||||
Reference in New Issue
Block a user