diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts index deb96818..1feb3820 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/api.ts @@ -17,6 +17,17 @@ import type { DigitalEmployeeBusinessViewKind, DigitalEmployeeBusinessViewQuery, DigitalEmployeeBusinessViewRow, + DigitalEmployeeBatchActionResult, + DigitalEmployeeInterventionWorkbenchResponse, + DigitalEmployeeMemoryGovernanceResponse, + DigitalEmployeeSkillAuditWorkbenchResponse, + DigitalEmployeeWorkbenchBatchResult, + DigitalEmployeeOpsWorkbenchResponse, + DigitalEmployeeNotificationWorkbenchResponse, + DigitalEmployeeDailyReportWorkbenchResponse, + DigitalEmployeePolicyCenterResponse, + DigitalEmployeePolicyPullBatchResult, + DigitalEmployeeSchedulerWorkbenchResponse, PlanStepBusinessRow, PlanStepDispatchPolicy, PlanStepDispatchPolicyPullResult, @@ -295,7 +306,7 @@ export function deleteCronJob(id: string): Promise { } export function patchCronJob( id: string, - patch: { name?: string; schedule?: string; command?: string }, + patch: { name?: string; schedule?: string; command?: string; enabled?: boolean }, ): Promise { return apiFetch( `/api/cron/${encodeURIComponent(id)}`, @@ -336,6 +347,40 @@ export function patchCronSettings( }); } +export async function getDigitalEmployeeSchedulerWorkbench(): Promise { + const [jobs, settings] = await Promise.all([ + getSchedulerJobs(), + getCronSettings().catch(() => ({ enabled: false, catch_up_on_startup: false, max_run_history: 20 })), + ]); + const runEntries = await Promise.all(jobs.slice(0, 30).map(async (job) => { + const runs = await getCronRuns(job.id, 5).catch(() => []); + return [job.id, runs] as const; + })); + const runsByJob = new Map(runEntries); + return { + items: jobs.map((job) => { + const runs = runsByJob.get(job.id) ?? []; + const latest = runs[0]; + const schedule = job.schedule && typeof job.schedule === 'object' ? job.schedule as Record : {}; + return { + schedule_id: job.id, + name: job.name || job.id, + enabled: job.enabled !== false, + status: job.enabled === false ? 'paused' : String(job.last_status || schedule.status || 'enabled'), + cron_expr: job.expression || String(schedule.expression || ''), + next_run: job.next_run || null, + last_run: job.last_run || latest?.finished_at || latest?.started_at || null, + last_status: job.last_status || latest?.status || null, + latest_run_error: job.last_output || latest?.output || null, + run_count: runs.length, + payload: { job, runs }, + }; + }), + settings, + source: 'qimingclaw-adapter', + }; +} + // --------------------------------------------------------------------------- // Integrations // --------------------------------------------------------------------------- @@ -521,6 +566,117 @@ export function decideRouteIntervention( }); } +export function decideRouteInterventionsBatch( + routeDecisionIds: string[], + input: { decision: 'mark_reviewed' | 'dismiss' | 'retry_policy_check'; actor?: string | null; comment?: string | null }, +): Promise { + return apiFetch('/api/route-decisions/interventions/batch', { + method: 'POST', + body: JSON.stringify({ route_decision_ids: routeDecisionIds, ...input }), + }); +} + +export function getDigitalEmployeeInterventionWorkbench( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/interventions/workbench${suffix ? `?${suffix}` : ''}`); +} + +export function getDigitalEmployeeMemoryGovernance( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/memory-governance${suffix ? `?${suffix}` : ''}`); +} + +export function setMemoryGovernanceStatus( + memoryIds: string[], + action: 'archive' | 'restore' | 'mark_reviewed', + options: { actor?: string | null; reason?: string | null } = {}, +): Promise { + return apiFetch('/api/digital-employee/memory-governance/status/batch', { + method: 'POST', + body: JSON.stringify({ memory_ids: memoryIds, action, ...options }), + }); +} + +export function getDigitalEmployeeSkillAuditWorkbench( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/skill-audit-workbench${suffix ? `?${suffix}` : ''}`); +} + +export function getDigitalEmployeeOpsWorkbench( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/ops-workbench${suffix ? `?${suffix}` : ''}`); +} + +export function runDigitalEmployeeOpsBatchAction( + opsIds: string[], + action: 'mark_reviewed' | 'dismiss' | 'open_related_view' | 'retry_policy_pull' | 'safe_restart_service', + options: { actor?: string | null; reason?: string | null } = {}, +): Promise { + return apiFetch('/api/digital-employee/ops-workbench/actions/batch', { + method: 'POST', + body: JSON.stringify({ ops_ids: opsIds, action, ...options }), + timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS, + }); +} + +export function getDigitalEmployeeNotificationWorkbench( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/notifications/workbench${suffix ? `?${suffix}` : ''}`); +} + +export function runDigitalEmployeeNotificationBatchAction( + notificationIds: string[], + action: 'mark_read' | 'dismiss' | 'pull_updates' | 'open_desktop_settings', + options: { actor?: string | null; reason?: string | null } = {}, +): Promise { + return apiFetch('/api/digital-employee/notifications/actions/batch', { + method: 'POST', + body: JSON.stringify({ notification_ids: notificationIds, action, ...options }), + timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS, + }); +} + +export function getDigitalEmployeeDailyReportWorkbench( + params: Record = {}, +): Promise { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`); + }); + const suffix = query.toString(); + return apiFetch(`/api/digital-employee/daily-reports/workbench${suffix ? `?${suffix}` : ''}`); +} + export function routeIngress( body: IngressRouteRequest, ): Promise { @@ -698,6 +854,77 @@ export function pullRouteDecisionPolicy(): Promise { + const [dispatchPolicy, riskPolicy] = await Promise.all([ + getPlanStepDispatchPolicy(), + getRiskApprovalPolicy(), + ]); + return { + plan_step_dispatch_policy: dispatchPolicy, + risk_approval_policy: riskPolicy, + source: 'qimingclaw-adapter', + items: [ + { + policy_id: 'plan_step_dispatch', + title: 'PlanStep 派发策略', + status: dispatchPolicy.enabled ? 'enabled' : 'disabled', + source: dispatchPolicy.source, + updated_at: dispatchPolicy.updated_at, + last_pulled_at: dispatchPolicy.last_pulled_at, + last_pull_error: dispatchPolicy.last_pull_error, + summary: `max_per_sweep=${dispatchPolicy.max_per_sweep}; auto_dispatch_ready_steps=${dispatchPolicy.auto_dispatch_ready_steps}`, + payload: dispatchPolicy, + }, + { + policy_id: 'risk_approval', + title: '风险审批策略', + status: riskPolicy.enabled ? 'enabled' : 'disabled', + source: riskPolicy.source, + updated_at: riskPolicy.updated_at, + last_pulled_at: riskPolicy.last_pulled_at, + last_pull_error: riskPolicy.last_pull_error, + summary: `approval_required=${riskPolicy.approval_required_risk_levels.join(',')}; auto_reject_actions=${riskPolicy.auto_reject_actions.length}`, + payload: riskPolicy, + }, + { + policy_id: 'route_decision', + title: '入口路由策略', + status: 'pull_available', + source: 'remote_pull', + summary: '通过 /api/route-decision/policy/pull 获取管理端路由策略快照。', + }, + { + policy_id: 'skill', + title: '技能策略', + status: 'pull_available', + source: 'remote_pull', + summary: '通过 /api/skill/policies/pull 同步远端技能启停策略。', + }, + ], + }; +} + +export async function pullDigitalEmployeePoliciesBatch(): Promise { + const jobs: Array<{ policy_id: string; run: () => Promise }> = [ + { policy_id: 'plan_step_dispatch', run: pullPlanStepDispatchPolicy }, + { policy_id: 'risk_approval', run: pullRiskApprovalPolicy }, + { policy_id: 'route_decision', run: pullRouteDecisionPolicy }, + { policy_id: 'skill', run: pullSkillPolicies }, + ]; + const items = [] as DigitalEmployeePolicyPullBatchResult['items']; + for (const job of jobs) { + try { + const result = await job.run(); + const record = result as { ok?: boolean; error?: string | null }; + items.push({ policy_id: job.policy_id, ok: record.ok !== false, error: record.error ?? null, result }); + } catch (error) { + items.push({ policy_id: job.policy_id, ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + const pulled = items.filter((item) => item.ok).length; + return { ok: pulled > 0, total: items.length, pulled, failed: items.length - pulled, items }; +} + export function getPlanSteps(params: Record = {}): Promise> { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { @@ -726,6 +953,7 @@ export function getDigitalEmployeeBusinessView( runs: '/api/digital-employee/runs', events: '/api/digital-employee/events', artifacts: '/api/digital-employee/artifacts', + 'artifact-groups': '/api/digital-employee/artifact-groups', approvals: '/api/digital-employee/approvals', }; const query = new URLSearchParams(); @@ -961,6 +1189,17 @@ export function setArtifactRetention( }); } +export function setArtifactRetentionBatch( + artifactIds: string[], + action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention', + options: Record = {}, +): Promise { + return apiFetch('/api/digital-employee/artifacts/retention/batch', { + method: 'POST', + body: JSON.stringify({ artifact_ids: artifactIds, action, ...options }), + }); +} + export function cleanupArtifact( artifactId: string, options: Record = {}, @@ -992,6 +1231,17 @@ export function resolveApprovalConflict( }); } +export function resolveApprovalConflictsBatch( + approvalIds: string[], + resolution: 'keep_local' | 'mark_reviewed', + options: { actor?: string | null; comment?: string | null } = {}, +): Promise { + return apiFetch('/api/approval/conflicts/resolve/batch', { + method: 'POST', + body: JSON.stringify({ approval_ids: approvalIds, resolution, ...options }), + }); +} + export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }> { return apiFetch<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>('/api/approval/updates/pull', { method: 'POST', diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts index 9cf78fdf..58d2b920 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/lib/qimingclawAdapter.ts @@ -121,6 +121,9 @@ interface AdapterState { planStepDispatchPolicy?: PlanStepDispatchPolicy; riskApprovalPolicy?: RiskApprovalPolicy; routeDecisionPolicy?: RouteDecisionPolicy; + memoryGovernanceById?: Record; + opsWorkbenchStateById?: Record; + dailyReportWorkbenchStateById?: Record; consoleRole?: string; rolePreferences?: Record; profile: { @@ -131,6 +134,23 @@ interface AdapterState { }; } +interface MemoryGovernanceOverlay { + governance_status?: string | null; + last_governance_action?: string | null; + last_reviewed_at?: string | null; + actor?: string | null; + reason?: string | null; + updated_at?: string | null; +} + +interface WorkbenchActionOverlay { + status?: string | null; + last_action?: string | null; + actor?: string | null; + reason?: string | null; + updated_at?: string | null; +} + interface NotificationState { status?: UserNotification['status']; read_at?: string | null; @@ -876,7 +896,7 @@ const PLAN_SCHEDULES: Record; export function isQimingclawDigitalApiEnabled(): boolean { @@ -924,6 +944,35 @@ export async function handleQimingclawDigitalApi( const rows = filterSkillCallAuditRows(skillCallAuditRows(await readQimingclawSnapshot()), url.searchParams); return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; } + if (method === 'GET' && pathname === '/api/digital-employee/memory-governance') { + const rows = filterMemoryGovernanceRows(memoryGovernanceRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } + if (method === 'POST' && pathname === '/api/digital-employee/memory-governance/status/batch') { + return await handleMemoryGovernanceStatusBatch(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } + if (method === 'GET' && pathname === '/api/digital-employee/skill-audit-workbench') { + const rows = filterSkillAuditWorkbenchRows(skillAuditWorkbenchRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } + if (method === 'GET' && pathname === '/api/digital-employee/ops-workbench') { + const rows = filterOpsWorkbenchRows(opsWorkbenchRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } + if (method === 'POST' && pathname === '/api/digital-employee/ops-workbench/actions/batch') { + return await handleOpsWorkbenchBatchAction(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } + if (method === 'GET' && pathname === '/api/digital-employee/notifications/workbench') { + const rows = filterNotificationWorkbenchRows(notificationWorkbenchRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } + if (method === 'POST' && pathname === '/api/digital-employee/notifications/actions/batch') { + return await handleNotificationWorkbenchBatchAction(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } + if (method === 'GET' && pathname === '/api/digital-employee/daily-reports/workbench') { + const rows = filterDailyReportWorkbenchRows(dailyReportWorkbenchRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } if (method === 'GET' && pathname === '/api/digital-employee/artifact-access-events') { const rows = filterArtifactAccessRows(artifactAccessRows(await readQimingclawSnapshot()), url.searchParams); return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; @@ -933,6 +982,9 @@ export async function handleQimingclawDigitalApi( const artifactId = decodeURIComponent(artifactAccessMatch[1] || ''); return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T; } + if (method === 'POST' && pathname === '/api/digital-employee/artifacts/retention/batch') { + return await handleArtifactRetentionBatch(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } const artifactRetentionMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/retention$/); if (method === 'POST' && artifactRetentionMatch) { const artifactId = decodeURIComponent(artifactRetentionMatch[1] || ''); @@ -973,6 +1025,9 @@ export async function handleQimingclawDigitalApi( const approvalId = decodeURIComponent(approvalConflictResolveMatch[1] || ''); return await handleApprovalConflictResolution(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; } + if (method === 'POST' && pathname === '/api/approval/conflicts/resolve/batch') { + return await handleApprovalConflictResolutionBatch(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } if (method === 'GET' && pathname === '/api/approval/subscriptions') { return approvalSubscriptionPreferences(notificationAdapterState(await readQimingclawSnapshot())) as T; } @@ -1015,7 +1070,11 @@ export async function handleQimingclawDigitalApi( const rows = filterApprovalHistoryRows(approvalHistoryRows(await readQimingclawSnapshot()), url.searchParams); return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; } - const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|plan-steps|plan-step-graph|tasks|runs|events|artifacts|approvals|approval-history|route-decisions|notifications|audits)$/); + if (method === 'GET' && pathname === '/api/digital-employee/artifact-groups') { + const rows = filterBusinessRows(artifactGroupRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } + const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|plan-steps|plan-step-graph|tasks|runs|events|artifacts|artifact-groups|approvals|approval-history|route-decisions|notifications|audits)$/); if (method === 'GET' && businessViewMatch) { return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T; } @@ -1025,6 +1084,13 @@ export async function handleQimingclawDigitalApi( if (method === 'GET' && pathname === '/api/route-decisions/interventions') { return await readRouteInterventions(Number(url.searchParams.get('limit')) || 80) as T; } + if (method === 'POST' && pathname === '/api/route-decisions/interventions/batch') { + return await handleRouteInterventionsBatch(parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T; + } + if (method === 'GET' && pathname === '/api/digital-employee/interventions/workbench') { + const rows = filterInterventionWorkbenchRows(interventionWorkbenchRows(await readQimingclawSnapshot()), url.searchParams); + return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T; + } const routeInterventionMatch = pathname.match(/^\/api\/route-decisions\/([^/]+)\/intervention$/); if (method === 'POST' && routeInterventionMatch) { return await handleRouteInterventionDecision( @@ -1233,6 +1299,8 @@ function defaultState(): AdapterState { planStepDispatchPolicy: defaultPlanStepDispatchPolicy(), riskApprovalPolicy: defaultRiskApprovalPolicy(), routeDecisionPolicy: defaultRouteDecisionPolicy(), + opsWorkbenchStateById: {}, + dailyReportWorkbenchStateById: {}, consoleRole: 'sales', rolePreferences: {}, profile: { @@ -1262,6 +1330,9 @@ function normalizeAdapterState(value: Partial): AdapterState { planStepDispatchPolicy: planStepDispatchPolicy(value), riskApprovalPolicy: riskApprovalPolicy(value), routeDecisionPolicy: routeDecisionPolicy(value), + memoryGovernanceById: value.memoryGovernanceById ?? {}, + opsWorkbenchStateById: value.opsWorkbenchStateById ?? {}, + dailyReportWorkbenchStateById: value.dailyReportWorkbenchStateById ?? {}, }; } @@ -1722,6 +1793,119 @@ function memoryEntryFromRecord(record: QimingclawMemoryRecord): MemoryEntry { }; } +function memoryGovernanceRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const state = snapshot?.uiState ?? readState(); + const overlays = state.memoryGovernanceById ?? {}; + const memories = snapshot?.runtime?.memories ?? []; + const groups = new Map(); + memories.forEach((memory) => { + const groupKey = memoryGovernanceGroupKey(memory); + groups.set(groupKey, [...(groups.get(groupKey) ?? []), memory]); + }); + return memories.map((memory) => { + const overlay = overlays[memory.id] ?? {}; + const duplicates = (groups.get(memoryGovernanceGroupKey(memory)) ?? []) + .map((item) => item.id) + .filter((id) => id !== memory.id); + const governanceStatus = stringValue(overlay.governance_status) || (memory.deletedAt ? 'archived' : memory.status || 'active'); + return { + memory_id: memory.id, + remote_id: memory.remoteId ?? null, + key: memory.key || memory.id, + category: memory.category || 'fact', + source: memory.source || null, + source_path: memory.sourcePath ?? null, + status: governanceStatus, + governance_status: governanceStatus, + last_governance_action: stringValue(overlay.last_governance_action) || null, + last_reviewed_at: stringValue(overlay.last_reviewed_at) || null, + actor: stringValue(overlay.actor) || null, + reason: stringValue(overlay.reason) || null, + content_preview: memoryContentPreview(memory.content), + score: typeof memory.score === 'number' ? memory.score : null, + session_id: memory.sessionId ?? null, + sync_status: memory.syncStatus ?? null, + sync_error: memory.syncError ?? null, + merge_candidate_count: duplicates.length, + duplicate_memory_ids: duplicates, + latest_memory_id: latestMemoryId(groups.get(memoryGovernanceGroupKey(memory)) ?? []), + entity_type: 'memory', + entity_id: memory.id, + created_at: memory.createdAt, + updated_at: stringValue(overlay.updated_at) || memory.updatedAt || memory.createdAt, + payload: diagnosticAuditPayload('memory_governance', asRecord(memory.payload) ?? { content_length: memory.content.length }), + source_label: 'qimingclaw-memory', + }; + }).sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left)) || stringValue(left.memory_id).localeCompare(stringValue(right.memory_id))); +} + +function filterMemoryGovernanceRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const query = stringValue(searchParams.get('q') ?? searchParams.get('query')); + const category = stringValue(searchParams.get('category')); + const status = stringValue(searchParams.get('status')); + const entityId = stringValue(searchParams.get('entity_id')); + return rows + .filter((row) => !query || [row.key, row.content_preview, row.source].some((value) => stringValue(value).toLowerCase().includes(query.toLowerCase()))) + .filter((row) => !category || stringValue(row.category) === category) + .filter((row) => !status || stringValue(row.governance_status) === status || stringValue(row.status) === status) + .filter((row) => !entityId || businessRowEntityIds(row).includes(entityId) || stringValue(row.memory_id) === entityId); +} + +async function handleMemoryGovernanceStatusBatch( + body: Record, + snapshot: QimingclawSnapshot | null, +): Promise> { + const action = stringValue(body.action) || 'mark_reviewed'; + if (!['archive', 'restore', 'mark_reviewed'].includes(action)) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_memory_governance_action'] }; + } + const memoryIds = uniqueStrings([...arrayValue(body.memory_ids), ...arrayValue(body.memoryIds)]).slice(0, 100); + if (memoryIds.length === 0) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['memory_ids_required'] }; + } + const existingIds = new Set((snapshot?.runtime?.memories ?? []).map((memory) => memory.id)); + const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState(); + state.memoryGovernanceById = state.memoryGovernanceById ?? {}; + const occurredAt = new Date().toISOString(); + const items = memoryIds.map((memoryId) => { + if (!existingIds.has(memoryId)) { + return { memory_id: memoryId, ok: false, status: 'rejected', reason_codes: ['memory_not_found'] }; + } + state.memoryGovernanceById![memoryId] = { + governance_status: action === 'archive' ? 'archived' : action === 'restore' ? 'active' : state.memoryGovernanceById?.[memoryId]?.governance_status ?? 'reviewed', + last_governance_action: action, + last_reviewed_at: action === 'mark_reviewed' ? occurredAt : state.memoryGovernanceById?.[memoryId]?.last_reviewed_at ?? null, + actor: stringValue(body.actor) || 'digital_employee_operator', + reason: stringValue(body.reason) || null, + updated_at: occurredAt, + }; + return { memory_id: memoryId, ok: true, status: 'recorded', action }; + }); + await saveState(state, 'memory_governance_status_batch', todayInputDate(), { action, memory_ids: memoryIds, reason: stringValue(body.reason) || null }); + const recorded = items.filter((item) => item.ok === true).length; + return { + ok: recorded > 0, + action, + total: memoryIds.length, + recorded, + rejected: memoryIds.length - recorded, + items, + reason_codes: uniqueStrings(items.flatMap((item) => arrayValue(item.reason_codes))), + }; +} + +function memoryGovernanceGroupKey(memory: QimingclawMemoryRecord): string { + return `${memory.category || 'fact'}:${memory.key || 'memory'}:${memoryContentPreview(memory.content).toLowerCase()}`; +} + +function memoryContentPreview(content: string): string { + return String(content || '').replace(/\s+/g, ' ').trim().slice(0, 120); +} + +function latestMemoryId(memories: QimingclawMemoryRecord[]): string | null { + return memories.slice().sort((left, right) => stringValue(right.updatedAt).localeCompare(stringValue(left.updatedAt)))[0]?.id ?? null; +} + function defaultCronSettings(): QimingclawCronSettings { return { enabled: true, @@ -2152,6 +2336,53 @@ async function handleArtifactRetention( }; } +async function handleArtifactRetentionBatch( + body: Record, + snapshot: QimingclawSnapshot | null, +): Promise> { + const action = stringValue(body.action) || 'mark_review'; + if (!['mark_keep', 'mark_expire', 'mark_review', 'clear_retention'].includes(action)) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_retention_action'] }; + } + const artifactIds = uniqueStrings([ + ...arrayValue(body.artifact_ids), + ...arrayValue(body.artifactIds), + ]).slice(0, 50); + if (artifactIds.length === 0) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['artifact_ids_required'] }; + } + const retentionBody = { + action, + reason: stringValue(body.reason) || 'digital_employee_artifact_batch_governance', + retention_until: stringValue(body.retention_until ?? body.retentionUntil) || null, + actor: stringValue(body.actor) || 'digital_employee_ui', + source: stringValue(body.source) || 'sgrobot-digital-adapter', + }; + const items: Record[] = []; + for (const artifactId of artifactIds) { + const result = await handleArtifactRetention(artifactId, retentionBody, snapshot); + items.push({ + artifact_id: artifactId, + ok: result.ok === true, + status: stringValue(result.status) || (result.ok === true ? 'recorded' : 'rejected'), + event_id: stringValue(result.event_id) || null, + error: stringValue(result.error) || null, + reason_codes: arrayValue(result.reason_codes).map(stringValue).filter(Boolean), + }); + } + const recorded = items.filter((item) => item.ok === true).length; + const reasonCodes = uniqueStrings(items.flatMap((item) => arrayValue(item.reason_codes))); + return { + ok: recorded > 0, + action, + total: artifactIds.length, + recorded, + rejected: artifactIds.length - recorded, + items, + reason_codes: reasonCodes, + }; +} + async function handleArtifactCleanup( artifactId: string, body: Record, @@ -2290,6 +2521,52 @@ async function handleApprovalConflictResolution( }; } +async function handleApprovalConflictResolutionBatch( + body: Record, + snapshot: QimingclawSnapshot | null, +): Promise> { + const resolution = stringValue(body.resolution) || 'mark_reviewed'; + if (resolution === 'accept_remote') { + return { ok: false, resolution, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['batch_accept_remote_disabled'] }; + } + if (!['keep_local', 'mark_reviewed'].includes(resolution)) { + return { ok: false, resolution, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_resolution'] }; + } + const approvalIds = uniqueStrings([ + ...arrayValue(body.approval_ids), + ...arrayValue(body.approvalIds), + ]).slice(0, 50); + if (approvalIds.length === 0) { + return { ok: false, resolution, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['approval_ids_required'] }; + } + const items: Record[] = []; + for (const approvalId of approvalIds) { + const result = await handleApprovalConflictResolution(approvalId, { + resolution, + actor: stringValue(body.actor) || 'digital_employee_operator', + comment: stringValue(body.comment) || null, + }, snapshot); + items.push({ + approval_id: approvalId, + ok: result.ok === true, + status: stringValue(result.status) || (result.ok === true ? 'recorded' : 'rejected'), + event_id: stringValue(result.event_id) || null, + error: stringValue(result.error) || null, + reason_codes: arrayValue(result.reason_codes).map(stringValue).filter(Boolean), + }); + } + const recorded = items.filter((item) => item.ok === true).length; + return { + ok: recorded > 0, + resolution, + total: approvalIds.length, + recorded, + rejected: approvalIds.length - recorded, + items, + reason_codes: uniqueStrings(items.flatMap((item) => arrayValue(item.reason_codes))), + }; +} + function syncLine(snapshot: QimingclawSnapshot | null): string { const sync = snapshot?.sync; if (!sync) return '管理端同步:未连接'; @@ -3282,6 +3559,7 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS runs: businessRunRows(snapshot), events: businessEventRows(snapshot), artifacts: businessArtifactRows(snapshot), + 'artifact-groups': artifactGroupRows(snapshot), approvals: businessApprovalRows(snapshot), 'approval-history': approvalHistoryRows(snapshot), 'route-decisions': routeDecisionRows(snapshot), @@ -3568,6 +3846,111 @@ function notificationRows(snapshot: QimingclawSnapshot | null, state: AdapterSta .sort((left, right) => stringValue(right.created_at).localeCompare(stringValue(left.created_at)) || stringValue(right.notification_id).localeCompare(stringValue(left.notification_id))); } +function interventionWorkbenchRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const approvalRows = businessApprovalRows(snapshot) + .filter((row) => row.approval_conflict_active === true) + .map((row) => ({ + intervention_id: `approval-conflict:${stringValue(row.approval_id)}`, + kind: 'approval_conflict', + title: stringValue(row.title) || stringValue(row.summary) || '审批跨端冲突', + status: 'open', + priority: 'high', + entity_type: 'approval', + entity_id: stringValue(row.approval_id), + approval_id: stringValue(row.approval_id), + plan_id: stringValue(row.plan_id) || null, + task_id: stringValue(row.task_id) || null, + run_id: stringValue(row.run_id) || null, + reason: stringValue(row.approval_conflict_reason) || 'approval_conflict', + summary: `本地 ${stringValue(row.approval_conflict_local_status) || stringValue(row.status)} / 管理端 ${stringValue(row.approval_conflict_remote_status) || 'unknown'}`, + occurred_at: stringValue(row.approval_conflict_detected_at) || stringValue(row.updated_at) || stringValue(row.created_at), + updated_at: stringValue(row.approval_conflict_detected_at) || stringValue(row.updated_at) || stringValue(row.created_at), + source: BUSINESS_VIEW_SOURCE, + payload: row, + })); + const routeRows = routeDecisionRows(snapshot) + .filter((row) => ['warn', 'error', 'action'].includes(stringValue(row.attention_level))) + .map((row) => ({ + intervention_id: `route-intervention:${stringValue(row.route_decision_id)}`, + kind: 'route_intervention', + title: routeDecisionStatusLabel(row as unknown as RouteDecisionRecord), + status: stringValue(row.policy_result) || stringValue(row.status) || 'attention', + priority: stringValue(row.attention_level) === 'error' ? 'high' : stringValue(row.attention_level) === 'warn' ? 'medium' : 'normal', + entity_type: 'route_decision', + entity_id: stringValue(row.route_decision_id), + route_decision_id: stringValue(row.route_decision_id), + plan_id: stringValue(asRecord(row.context_refs)?.plan_id) || null, + task_id: stringValue(asRecord(row.context_refs)?.task_id) || null, + run_id: stringValue(asRecord(row.context_refs)?.run_id) || null, + reason: uniqueStrings(arrayValue(row.reason_codes)).join(' / ') || stringValue(row.blocked_reason) || stringValue(row.policy_result), + summary: `${stringValue(row.route_kind)} / ${stringValue(row.intent_kind)}`, + occurred_at: stringValue(row.recorded_at) || stringValue(row.created_at), + updated_at: stringValue(row.recorded_at) || stringValue(row.created_at), + source: BUSINESS_VIEW_SOURCE, + payload: row, + })); + const riskRows = runtimeEvents(snapshot) + .filter((event) => ['risk_approval_required', 'risk_policy_rejected'].includes(event.kind)) + .map((event) => { + const payload = asRecord(event.payload) ?? {}; + return { + intervention_id: `risk-approval:${event.id}`, + kind: 'risk_approval', + title: event.kind === 'risk_policy_rejected' ? '风险策略已拒绝' : '风险审批待处理', + status: stringValue(payload.status) || event.kind, + priority: ['high', 'critical'].includes(stringValue(payload.risk_level ?? payload.riskLevel)) ? 'high' : 'medium', + entity_type: 'risk_approval', + entity_id: event.id, + event_id: event.id, + approval_id: stringValue(payload.approval_id ?? payload.approvalId) || null, + plan_id: (event.planId ?? stringValue(payload.plan_id)) || null, + task_id: (event.taskId ?? stringValue(payload.task_id)) || null, + run_id: (event.runId ?? stringValue(payload.run_id)) || null, + reason: stringValue(payload.action_kind ?? payload.actionKind ?? payload.reason) || event.kind, + summary: event.message, + occurred_at: event.occurredAt, + updated_at: event.occurredAt, + source: BUSINESS_VIEW_SOURCE, + payload, + }; + }); + const notificationRowsForWorkbench = notificationRows(snapshot, notificationAdapterState(snapshot)) + .filter((row) => stringValue(row.level) === 'action' && !['resolved', 'dismissed'].includes(stringValue(row.status))) + .map((row) => ({ + intervention_id: `notification:${stringValue(row.notification_id)}`, + kind: 'action_notification', + title: stringValue(row.title) || '待处理通知', + status: stringValue(row.status) || 'unread', + priority: 'normal', + entity_type: 'notification', + entity_id: stringValue(row.notification_id), + notification_id: stringValue(row.notification_id), + plan_id: stringValue(row.plan_id) || null, + task_id: stringValue(row.task_id) || null, + run_id: null, + reason: stringValue(row.kind), + summary: stringValue(row.body), + occurred_at: stringValue(row.created_at), + updated_at: stringValue(row.updated_at) || stringValue(row.created_at), + source: BUSINESS_VIEW_SOURCE, + payload: row, + })); + return [...approvalRows, ...routeRows, ...riskRows, ...notificationRowsForWorkbench] + .sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left)) || stringValue(left.intervention_id).localeCompare(stringValue(right.intervention_id))); +} + +function filterInterventionWorkbenchRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const kind = stringValue(searchParams.get('kind')); + const status = stringValue(searchParams.get('status')); + const priority = stringValue(searchParams.get('priority')); + const entityId = stringValue(searchParams.get('entity_id')); + return rows + .filter((row) => !kind || kind === 'all' || stringValue(row.kind) === kind) + .filter((row) => !status || stringValue(row.status) === status) + .filter((row) => !priority || stringValue(row.priority) === priority) + .filter((row) => !entityId || businessRowEntityIds(row).includes(entityId) || stringValue(row.intervention_id) === entityId || stringValue(row.route_decision_id) === entityId); +} + function filterNotificationRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { const id = stringValue(searchParams.get('id')); const kind = stringValue(searchParams.get('kind')); @@ -3817,6 +4200,75 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView }); } +function artifactGroupRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const groups = new Map(); + businessArtifactRows(snapshot).forEach((artifact) => { + const groupKey = artifactGroupKeyForRow(artifact); + groups.set(groupKey, [...(groups.get(groupKey) ?? []), artifact]); + }); + return [...groups.entries()].map(([groupKey, artifacts]) => { + const sorted = artifacts.slice().sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left))); + const latest = sorted[0] ?? {}; + const artifactIds = uniqueStrings(artifacts.map((artifact) => artifact.artifact_id)); + const deviceIds = uniqueStrings(artifacts.map((artifact) => artifactDeviceId(artifact))); + const versionKeys = uniqueStrings(artifacts.map((artifact) => artifact.version_key || artifact.version || artifact.artifact_id)); + return { + entity_type: 'artifact_group', + entity_id: groupKey, + group_key: groupKey, + version_key: stringValue(latest.version_key) || groupKey, + title: stringValue(latest.name) || stringValue(latest.title) || groupKey, + summary: `产物 ${artifactIds.length} / 设备 ${deviceIds.length} / 版本 ${versionKeys.length}`, + status: dominantCountKey(countByValue(artifacts, 'retention_status')) || 'unmanaged', + sync_status: dominantCountKey(countByValue(artifacts, 'sync_status')) || null, + plan_id: stringValue(latest.plan_id) || null, + task_id: stringValue(latest.task_id) || null, + run_id: stringValue(latest.run_id) || null, + artifact_ids: artifactIds, + artifact_count: artifactIds.length, + device_count: deviceIds.length, + version_count: versionKeys.length, + latest_artifact_id: stringValue(latest.artifact_id) || null, + retention_status_counts: countByValue(artifacts, 'retention_status'), + cleanup_status_counts: countByValue(artifacts, 'cleanup_status'), + updated_at: businessRowUpdatedAt(latest), + created_at: stringValue(latest.created_at) || null, + business_view: compactRecord({ artifact_ids: artifactIds, device_ids: deviceIds, version_keys: versionKeys }), + payload: { artifacts: sorted }, + source: BUSINESS_VIEW_SOURCE, + }; + }).sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left)) || stringValue(left.entity_id).localeCompare(stringValue(right.entity_id))); +} + +function artifactGroupKeyForRow(artifact: BusinessViewRow): string { + const versionKey = stringValue(artifact.version_key).replace(/:v[^:]+$/, ''); + if (versionKey) return versionKey; + const name = stringValue(artifact.name) || stringValue(artifact.title); + return `artifact-group:${stringValue(artifact.kind) || 'artifact'}:${stringValue(artifact.source_label) || stringValue(artifact.source_type)}:${name || stringValue(artifact.uri) || stringValue(artifact.artifact_id)}`; +} + +function artifactDeviceId(artifact: BusinessViewRow): string { + const payload = asRecord(artifact.payload) ?? {}; + return stringValue(artifact.device_id) + || stringValue(artifact.source_device_id) + || stringValue(artifact.remote_device_id) + || stringValue(payload.device_id ?? payload.deviceId) + || stringValue(payload.source_device_id ?? payload.sourceDeviceId) + || 'local'; +} + +function countByValue(rows: BusinessViewRow[], field: string): Record { + return rows.reduce>((counts, row) => { + const key = stringValue(row[field]) || 'none'; + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, {}); +} + +function dominantCountKey(counts: Record): string | null { + return Object.entries(counts).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0] ?? null; +} + function artifactDetailRow(snapshot: QimingclawSnapshot | null, artifactId: string): BusinessViewRow | null { const row = businessArtifactRows(snapshot) .find((artifact) => stringValue(artifact.artifact_id) === artifactId || stringValue(artifact.remote_id) === artifactId) ?? null; @@ -4119,6 +4571,359 @@ function skillCallAuditRows(snapshot: QimingclawSnapshot | null): BusinessViewRo .sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at))); } +function skillAuditWorkbenchRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const skillRows = skillCallAuditRows(snapshot).map((row) => ({ + audit_id: stringValue(row.audit_id), + category: 'skill_call_audit', + decision: stringValue(row.decision), + status: stringValue(row.decision) || 'observed', + skill_id: stringValue(row.skill_id) || null, + tool_name: stringValue(row.tool_name) || null, + server_id: stringValue(row.server_id) || null, + source: stringValue(row.source) || BUSINESS_VIEW_SOURCE, + risk_level: stringValue(row.risk_level) || null, + session_id: stringValue(row.session_id) || null, + task_id: stringValue(row.task_id) || null, + run_id: stringValue(row.run_id) || null, + message: stringValue(row.message) || `${stringValue(row.tool_name) || stringValue(row.skill_id) || 'skill'} ${stringValue(row.decision)}`, + occurred_at: stringValue(row.occurred_at), + updated_at: stringValue(row.occurred_at), + payload_preview: diagnosticAuditPayload('skill_call_audit', asRecord(row.payload) ?? {}), + payload: row.payload, + entity_type: 'skill_audit', + entity_id: stringValue(row.audit_id), + })); + const auditWorkbenchRows = auditRows(snapshot) + .filter((row) => ['tool_call_audit', 'sandbox_audit', 'token_cost'].includes(stringValue(row.category))) + .map((row) => ({ + audit_id: stringValue(row.audit_id), + category: stringValue(row.category), + decision: stringValue(row.decision) || stringValue(row.status) || stringValue(row.level), + status: stringValue(row.status) || stringValue(row.level) || 'info', + skill_id: stringValue(row.skill_id) || null, + tool_name: stringValue(row.tool_name) || null, + server_id: stringValue(row.server_id) || null, + source: stringValue(row.source) || BUSINESS_VIEW_SOURCE, + risk_level: stringValue(row.risk_level) || null, + model: stringValue(row.model) || null, + total_tokens: numberValue(row.total_tokens), + estimated_cost_usd: numberValue(row.estimated_cost_usd), + session_id: stringValue(row.session_id) || null, + task_id: stringValue(row.task_id) || null, + run_id: stringValue(row.run_id) || null, + message: stringValue(row.message) || stringValue(row.action) || stringValue(row.category), + occurred_at: stringValue(row.occurred_at), + updated_at: stringValue(row.updated_at) || stringValue(row.occurred_at), + payload_preview: diagnosticAuditPayload(stringValue(row.category), asRecord(row.payload) ?? {}), + payload: row.payload, + entity_type: 'skill_audit', + entity_id: stringValue(row.audit_id), + })); + return [...skillRows, ...auditWorkbenchRows] + .sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left)) || stringValue(left.audit_id).localeCompare(stringValue(right.audit_id))); +} + +function filterSkillAuditWorkbenchRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const skillId = stringValue(searchParams.get('skill_id')); + const serverId = stringValue(searchParams.get('server_id')); + const toolName = stringValue(searchParams.get('tool_name')); + const decision = stringValue(searchParams.get('decision')); + const category = stringValue(searchParams.get('category')); + const status = stringValue(searchParams.get('status')); + return rows + .filter((row) => !skillId || stringValue(row.skill_id) === skillId) + .filter((row) => !serverId || stringValue(row.server_id) === serverId) + .filter((row) => !toolName || stringValue(row.tool_name) === toolName) + .filter((row) => !decision || stringValue(row.decision) === decision) + .filter((row) => !category || stringValue(row.category) === category) + .filter((row) => !status || stringValue(row.status) === status); +} + +function opsWorkbenchRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const state = snapshot?.uiState ?? readState(); + const overlays = state.opsWorkbenchStateById ?? {}; + const doctorRows = buildDoctor(snapshot).results + .filter((item) => item.severity === 'warn' || item.severity === 'error') + .map((item) => { + const opsId = `diagnostic:${stringValue(item.id) || stringValue(item.category) || item.message}`; + return applyWorkbenchOverlay({ + ops_id: opsId, + kind: 'diagnostic', + title: item.message || '诊断项需要处理', + status: item.severity, + priority: item.severity, + entity_type: 'diagnostic', + entity_id: stringValue(item.id) || stringValue(item.category) || opsId, + category: item.category, + recommended_action: 'mark_reviewed', + reason_codes: [stringValue(item.category) || 'doctor_issue'], + occurred_at: item.created_at ?? snapshot?.generatedAt ?? new Date().toISOString(), + updated_at: item.created_at ?? snapshot?.generatedAt ?? new Date().toISOString(), + payload: item, + }, overlays[opsId]); + }); + const serviceRows = buildManagedServices(snapshot) + .filter((service) => service.requires_human_action || service.status === 'degraded' || service.status === 'stopped') + .map((service) => { + const opsId = `service:${service.service_id}`; + const restartable = isSafeRestartManagedService(service.service_id); + return applyWorkbenchOverlay({ + ops_id: opsId, + kind: 'service', + title: service.name, + status: service.status, + priority: service.status === 'stopped' ? 'error' : 'warn', + entity_type: 'managed_service', + entity_id: service.service_id, + service_id: service.service_id, + recommended_action: restartable ? 'safe_restart_service' : 'mark_reviewed', + reason_codes: [service.status, restartable ? 'safe_restart_service' : 'manual_review_required'], + summary: service.error || service.status_label, + occurred_at: service.last_seen_at, + updated_at: service.last_seen_at, + payload: service, + }, overlays[opsId]); + }); + const auditRiskRows = auditRows(snapshot) + .filter((row) => ['warn', 'error'].includes(stringValue(row.level))) + .map((row) => { + const opsId = `audit:${stringValue(row.audit_id)}`; + return applyWorkbenchOverlay({ + ops_id: opsId, + kind: 'audit', + title: stringValue(row.message) || stringValue(row.action) || '审计风险', + status: stringValue(row.level), + priority: stringValue(row.level), + entity_type: stringValue(row.entity_type) || 'audit', + entity_id: stringValue(row.entity_id) || stringValue(row.audit_id), + audit_id: stringValue(row.audit_id), + recommended_action: 'mark_reviewed', + reason_codes: [stringValue(row.category) || 'audit_risk'], + occurred_at: stringValue(row.occurred_at), + updated_at: stringValue(row.updated_at) || stringValue(row.occurred_at), + payload: row, + }, overlays[opsId]); + }); + const policyRows = policyIssueRows(snapshot, overlays); + return [...doctorRows, ...serviceRows, ...auditRiskRows, ...policyRows] + .filter((row) => stringValue(row.status) !== 'dismissed') + .sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left)) || stringValue(left.ops_id).localeCompare(stringValue(right.ops_id))); +} + +function policyIssueRows(snapshot: QimingclawSnapshot | null, overlays: Record): BusinessViewRow[] { + const state = snapshot?.uiState ?? readState(); + const policies: Array<{ id: string; title: string; error?: string | null; pulledAt?: string | null; recommended: string }> = [ + { id: 'plan_step_dispatch', title: 'PlanStep 派发策略', error: planStepDispatchPolicy(state).last_pull_error, pulledAt: planStepDispatchPolicy(state).last_pulled_at, recommended: 'retry_policy_pull' }, + { id: 'risk_approval', title: '风险审批策略', error: riskApprovalPolicy(state).last_pull_error, pulledAt: riskApprovalPolicy(state).last_pulled_at, recommended: 'retry_policy_pull' }, + { id: 'route_decision', title: '入口路由策略', error: routeDecisionPolicy(state).last_pull_error, pulledAt: routeDecisionPolicy(state).last_pulled_at, recommended: 'retry_policy_pull' }, + ]; + return policies + .filter((policy) => Boolean(policy.error)) + .map((policy) => { + const opsId = `policy:${policy.id}`; + return applyWorkbenchOverlay({ + ops_id: opsId, + kind: 'policy', + title: policy.title, + status: 'error', + priority: 'warn', + entity_type: 'policy', + entity_id: policy.id, + policy_id: policy.id, + recommended_action: policy.recommended, + reason_codes: ['policy_pull_error'], + summary: policy.error, + occurred_at: policy.pulledAt ?? snapshot?.generatedAt ?? new Date().toISOString(), + updated_at: policy.pulledAt ?? snapshot?.generatedAt ?? new Date().toISOString(), + payload: policy, + }, overlays[opsId]); + }); +} + +function notificationWorkbenchRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const state = notificationAdapterState(snapshot); + return buildNotifications(snapshot, state).map((notification) => ({ + notification_id: notification.notification_id, + kind: notification.kind, + title: notification.title, + status: notification.status, + priority: notification.level, + level: notification.level, + entity_type: 'notification', + entity_id: notification.notification_id, + plan_id: notification.plan_id || null, + task_id: notification.task_id || null, + run_id: null, + recommended_action: notification.status === 'unread' ? 'mark_read' : notification.status === 'dismissed' ? null : 'dismiss', + body: notification.body, + reason_codes: [notification.kind, notification.level], + occurred_at: notification.created_at, + updated_at: notification.read_at || notification.dismissed_at || notification.replied_at || notification.created_at, + payload: notification, + })).sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left))); +} + +function dailyReportWorkbenchRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] { + const state = snapshot?.uiState ?? readState(); + const overlays = state.dailyReportWorkbenchStateById ?? {}; + return dailyReportRows(snapshot).map((row) => { + const reportId = stringValue(row.report_id); + const delivery = dailyReportDeliverySummary(snapshot, reportId); + const artifactSources = arrayValue(row.artifact_sources); + const status = stringValue(row.status) || 'ready'; + const needsApproval = delivery.approval_status !== 'requested'; + return applyWorkbenchOverlay({ + report_id: reportId, + date: stringValue(row.date), + title: stringValue(row.title) || '数字员工日报', + status, + priority: status === 'ready' ? 'info' : 'warn', + entity_type: 'daily_report', + entity_id: reportId, + artifact_id: stringValue(row.artifact_id), + artifact_count: artifactSources.length, + delivery_status: delivery.delivery_status, + confirmation_status: delivery.confirmation_status, + approval_status: delivery.approval_status, + recommended_action: delivery.confirmation_status !== 'confirmed' ? 'confirm_report' : needsApproval ? 'request_approval' : 'open_report', + reason_codes: ['daily_report_workbench'], + occurred_at: stringValue(row.generated_at), + updated_at: stringValue(row.updated_at) || stringValue(row.generated_at), + payload: { ...row, delivery }, + }, overlays[reportId]); + }).sort((left, right) => businessRowUpdatedAt(right).localeCompare(businessRowUpdatedAt(left))); +} + +function applyWorkbenchOverlay(row: BusinessViewRow, overlay?: WorkbenchActionOverlay): BusinessViewRow { + if (!overlay) return row; + return { + ...row, + status: overlay.status || row.status, + last_action: overlay.last_action ?? null, + reviewed_at: overlay.last_action === 'mark_reviewed' ? overlay.updated_at ?? null : row.reviewed_at, + dismissed_at: overlay.last_action === 'dismiss' ? overlay.updated_at ?? null : row.dismissed_at, + updated_at: overlay.updated_at || row.updated_at, + actor: overlay.actor ?? row.actor, + reason: overlay.reason ?? row.reason, + }; +} + +function filterOpsWorkbenchRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const kind = stringValue(searchParams.get('kind')); + const priority = stringValue(searchParams.get('priority')); + return filterBusinessRows(rows, searchParams) + .filter((row) => !kind || kind === 'all' || stringValue(row.kind) === kind) + .filter((row) => !priority || stringValue(row.priority) === priority || stringValue(row.status) === priority); +} + +function filterNotificationWorkbenchRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const kind = stringValue(searchParams.get('kind')); + const level = stringValue(searchParams.get('level')); + return filterBusinessRows(rows, searchParams) + .filter((row) => !kind || kind === 'all' || stringValue(row.kind) === kind) + .filter((row) => !level || stringValue(row.level) === level || stringValue(row.priority) === level); +} + +function filterDailyReportWorkbenchRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { + const date = stringValue(searchParams.get('date')); + return filterBusinessRows(rows, searchParams) + .filter((row) => !date || stringValue(row.date) === date); +} + +async function handleOpsWorkbenchBatchAction(body: Record, snapshot: QimingclawSnapshot | null): Promise> { + const action = stringValue(body.action) || 'mark_reviewed'; + if (!['mark_reviewed', 'dismiss', 'open_related_view', 'retry_policy_pull', 'safe_restart_service'].includes(action)) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_ops_workbench_action'] }; + } + const opsIds = uniqueStrings([...arrayValue(body.ops_ids), ...arrayValue(body.opsIds)].map(stringValue)).slice(0, 100); + if (opsIds.length === 0) return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['ops_ids_required'] }; + const rows = opsWorkbenchRows(snapshot); + const byId = new Map(rows.map((row) => [stringValue(row.ops_id), row])); + const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState(); + const overlay = { ...(state.opsWorkbenchStateById ?? {}) }; + const items: Array> = []; + for (const opsId of opsIds) { + const row = byId.get(opsId); + if (!row) { + items.push({ ops_id: opsId, ok: false, status: 'rejected', reason_codes: ['ops_item_not_found'] }); + continue; + } + if (action === 'safe_restart_service') { + const serviceId = stringValue(row.service_id); + if (!isSafeRestartManagedService(serviceId)) { + items.push({ ops_id: opsId, service_id: serviceId, ok: false, status: 'rejected', reason_codes: ['safe_restart_service_not_allowed'] }); + continue; + } + const result = await handleManagedServiceRestart(serviceId, { reason: stringValue(body.reason) || 'ops_workbench_safe_restart', actor: stringValue(body.actor) || 'digital_employee_operator' }); + items.push({ ops_id: opsId, service_id: serviceId, ok: result.ok, status: result.status, reason_codes: result.ok ? ['safe_restart_service'] : [result.error || 'safe_restart_service_failed'], result }); + continue; + } + if (action === 'retry_policy_pull') { + const policyId = stringValue(row.policy_id || row.entity_id); + const result = await retryPolicyPull(policyId, snapshot); + items.push({ ops_id: opsId, policy_id: policyId, ok: result.ok !== false, status: result.ok === false ? 'failed' : 'recorded', reason_codes: [result.error ? 'policy_pull_failed' : 'policy_pull_requested'], result }); + continue; + } + const now = new Date().toISOString(); + overlay[opsId] = { status: action === 'dismiss' ? 'dismissed' : action === 'mark_reviewed' ? 'reviewed' : stringValue(row.status), last_action: action, actor: stringValue(body.actor) || 'digital_employee_operator', reason: stringValue(body.reason) || null, updated_at: now }; + items.push({ ops_id: opsId, ok: true, status: overlay[opsId].status, reason_codes: ['ops_workbench_action_recorded'] }); + } + await saveState({ ...normalizeAdapterState(state), opsWorkbenchStateById: overlay }, 'ops_workbench_action_recorded', undefined, { action, ops_ids: opsIds, reason: stringValue(body.reason) || null }); + const recorded = items.filter((item) => item.ok !== false).length; + return { ok: recorded > 0, action, total: opsIds.length, recorded, rejected: opsIds.length - recorded, items, reason_codes: ['ops_workbench_action_recorded'] }; +} + +async function handleNotificationWorkbenchBatchAction(body: Record, snapshot: QimingclawSnapshot | null): Promise> { + const action = stringValue(body.action) || 'mark_read'; + if (!['mark_read', 'dismiss', 'pull_updates', 'open_desktop_settings'].includes(action)) { + return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_notification_workbench_action'] }; + } + if (action === 'pull_updates') { + const result = await pullBridgeNotificationUpdates(); + return { ok: result.ok !== false, action, total: 1, recorded: result.ok === false ? 0 : 1, rejected: result.ok === false ? 1 : 0, items: [{ ok: result.ok !== false, result }], reason_codes: ['notification_workbench_action_recorded'] }; + } + if (action === 'open_desktop_settings') { + const result = await openBridgeDesktopNotificationSettings(); + return { ok: result.success !== false, action, total: 1, recorded: result.success === false ? 0 : 1, rejected: result.success === false ? 1 : 0, items: [{ ok: result.success !== false, result }], reason_codes: ['notification_workbench_action_recorded'] }; + } + const notificationIds = uniqueStrings([...arrayValue(body.notification_ids), ...arrayValue(body.notificationIds)].map(stringValue)).slice(0, 100); + if (notificationIds.length === 0) return { ok: false, action, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['notification_ids_required'] }; + const items: Array> = []; + for (const notificationId of notificationIds) { + const result = await handleNotificationAction(notificationId, action === 'mark_read' ? 'read' : 'dismiss', {}, snapshot); + items.push({ notification_id: notificationId, ok: result.ok, status: result.status, reason_codes: result.ok ? ['notification_workbench_action_recorded'] : ['notification_action_failed'], result }); + } + const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState(); + await saveState(state, 'notification_workbench_action_recorded', undefined, { action, notification_ids: notificationIds }); + const recorded = items.filter((item) => item.ok !== false).length; + return { ok: recorded > 0, action, total: notificationIds.length, recorded, rejected: notificationIds.length - recorded, items, reason_codes: ['notification_workbench_action_recorded'] }; +} + +function dailyReportDeliverySummary(snapshot: QimingclawSnapshot | null, reportId: string): Record { + const events = runtimeEvents(snapshot) + .filter((event) => ['daily_report_send_recorded', 'daily_report_confirmed', 'daily_report_approval_requested'].includes(event.kind)) + .filter((event) => stringValue(asRecord(event.payload)?.report_id ?? asRecord(event.payload)?.reportId) === reportId) + .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt)); + return { + delivery_status: events.some((event) => event.kind === 'daily_report_send_recorded') ? 'sent' : 'pending', + confirmation_status: events.some((event) => event.kind === 'daily_report_confirmed') ? 'confirmed' : 'pending', + approval_status: events.some((event) => event.kind === 'daily_report_approval_requested') ? 'requested' : 'not_requested', + latest_event_id: events[0]?.id ?? null, + latest_event_at: events[0]?.occurredAt ?? null, + }; +} + +function isSafeRestartManagedService(serviceId: string): boolean { + return serviceId === 'fileServer' || serviceId === 'guiServer'; +} + +async function retryPolicyPull(policyId: string, snapshot: QimingclawSnapshot | null): Promise> { + if (policyId === 'plan_step_dispatch') return await handlePlanStepDispatchPolicyPull(snapshot) as unknown as Record; + if (policyId === 'risk_approval') return await pullBridgeRiskApprovalPolicy() as Record; + if (policyId === 'route_decision') return await pullBridgeRouteDecisionPolicy() as unknown as Record; + return { ok: false, error: 'unknown_policy_id' }; +} + function filterSkillCallAuditRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] { const skillId = stringValue(searchParams.get('skill_id')); const serverId = stringValue(searchParams.get('server_id')); @@ -4430,10 +5235,14 @@ function businessEntityFromRefs(refs: Record): { entity_type: s function businessRowEntityIds(row: BusinessViewRow): string[] { return uniqueStrings([ row.entity_id, + row.intervention_id, + row.route_decision_id, row.plan_id, row.task_id, row.run_id, row.event_id, + row.audit_id, + row.memory_id, row.history_id, row.step_id, row.node_id, @@ -6389,8 +7198,28 @@ async function handleDailyReportDelivery(input: QimingclawDailyReportDeliveryInp remoteId: input.remoteId ?? null, message: input.message ?? null, }; - if (!recordDelivery) return { eventId: '' }; - return await recordDelivery(payload) ?? { eventId: '' }; + const result = recordDelivery ? await recordDelivery(payload) ?? { eventId: '' } : { eventId: '' }; + const state = await readBridgeUiState() ?? readState(); + const now = new Date().toISOString(); + await saveState( + { + ...normalizeAdapterState(state), + dailyReportWorkbenchStateById: { + ...(state.dailyReportWorkbenchStateById ?? {}), + [reportId]: { + status: input.action === 'confirm' ? 'confirmed' : input.action === 'request_approval' ? 'approval_requested' : 'sent', + last_action: input.action, + actor: payload.actor, + reason: input.message ?? null, + updated_at: now, + }, + }, + }, + 'daily_report_workbench_action_recorded', + undefined, + { report_id: reportId, action: input.action, status: input.status, event_id: result.eventId || null }, + ); + return result; } async function respondAcpPermissionForDecision( @@ -6692,6 +7521,72 @@ async function handleRouteInterventionDecision( }; } +async function handleRouteInterventionsBatch( + body: Record, + snapshot: QimingclawSnapshot | null, +): Promise> { + const decision = stringValue(body.decision) || 'mark_reviewed'; + if (!['mark_reviewed', 'dismiss', 'retry_policy_check'].includes(decision)) { + return { ok: false, decision, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['invalid_route_intervention_batch_decision'] }; + } + const routeDecisionIds = uniqueStrings([ + ...arrayValue(body.route_decision_ids), + ...arrayValue(body.routeDecisionIds), + ]).slice(0, 50); + if (routeDecisionIds.length === 0) { + return { ok: false, decision, total: 0, recorded: 0, rejected: 0, items: [], reason_codes: ['route_decision_ids_required'] }; + } + const existing = await readRouteInterventions(200); + const items: Record[] = []; + for (const routeDecisionId of routeDecisionIds) { + const detail = routeDecisionDetail(routeDecisionId, snapshot); + const existingRecord = existing.interventions.find((item) => item.route_decision_id === routeDecisionId) ?? null; + const routeDecision = detail?.decision ?? routeInterventionToRouteDecision(existingRecord, routeDecisionId); + if (!routeDecision) { + items.push({ route_decision_id: routeDecisionId, ok: false, status: 'rejected', error: 'route_intervention_missing_context', reason_codes: ['route_intervention_missing_context'] }); + continue; + } + const intervention = await recordRouteInterventionResolution({ + routeDecisionId, + approvalId: routeDecision.approval_id ?? existingRecord?.approval_id ?? null, + decision, + commandAccepted: false, + commandError: null, + blockedReason: routeDecision.blocked_reason ?? null, + policyResult: routeDecision.policy_result ?? null, + policySource: routeDecision.policy_source ?? null, + matchedIntent: routeDecision.matched_intent ?? null, + routeKind: routeDecision.route_kind, + intentKind: routeDecision.intent_kind, + reasonCodes: routeDecision.reason_codes, + candidateSkills: routeDecision.candidate_skills, + contextRefs: routeDecision.context_refs, + entrypoint: routeDecision.entrypoint ?? null, + actor: stringValue(body.actor) || 'digital_employee_operator', + correlationId: routeDecision.correlation_id, + payload: { comment: stringValue(body.comment) || null, batch_decision: decision }, + occurredAt: new Date().toISOString(), + }); + items.push({ + route_decision_id: routeDecisionId, + ok: intervention.intervention_status !== 'failed', + status: intervention.intervention_status, + intervention, + reason_codes: intervention.reason_codes ?? [], + }); + } + const recorded = items.filter((item) => item.ok === true).length; + return { + ok: recorded > 0, + decision, + total: routeDecisionIds.length, + recorded, + rejected: routeDecisionIds.length - recorded, + items, + reason_codes: uniqueStrings(items.flatMap((item) => arrayValue(item.reason_codes))), + }; +} + async function recordRouteInterventionResolution(input: QimingclawRouteInterventionResolutionInput): Promise { const bridge = window.QimingClawBridge?.digital; if (!bridge?.recordRouteInterventionResolution) { diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/BusinessView.tsx b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/BusinessView.tsx index 4c78c670..b0f36a47 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/BusinessView.tsx +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/BusinessView.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'; -import { Eye, RefreshCw, Search, X } from 'lucide-react'; +import { CheckSquare, Eye, RefreshCw, Search, X } from 'lucide-react'; import GlassCard from '@/components/digital/GlassCard'; import CardTitleBar from '@/components/digital/CardTitleBar'; -import { getDigitalEmployeeBusinessView } from '@/lib/api'; +import { getDigitalEmployeeBusinessView, setArtifactRetentionBatch } from '@/lib/api'; import type { DigitalEmployeeBusinessViewKind, DigitalEmployeeBusinessViewQuery, @@ -15,6 +15,7 @@ const VIEW_OPTIONS: Array<{ id: DigitalEmployeeBusinessViewKind; label: string } { id: 'runs', label: '运行' }, { id: 'events', label: '事件' }, { id: 'artifacts', label: '产物' }, + { id: 'artifact-groups', label: '产物聚合' }, { id: 'approvals', label: '审批' }, ]; @@ -106,10 +107,16 @@ function kindExtra(row: DigitalEmployeeBusinessViewRow, kind: DigitalEmployeeBus if (kind === 'runs') return displayText(row.last_event_message || row.duration_ms, '无最近事件'); if (kind === 'events') return displayText(row.kind, 'event'); if (kind === 'artifacts') return displayText(row.cleanup_status || row.deleted_at || row.uri || row.kind, 'artifact'); + if (kind === 'artifact-groups') return `产物 ${displayText(row.artifact_count, '0')} / 设备 ${displayText(row.device_count, '0')} / 版本 ${displayText(row.version_count, '0')}`; if (kind === 'approvals') return displayText(row.approval_conflict_resolution || row.approval_conflict_resolved_at || row.decision || row.approval_conflict_reason || row.kind, 'approval'); return displayText(row.assigned_skill_id || row.operation || row.kind); } +function rowArtifactIds(row: DigitalEmployeeBusinessViewRow): string[] { + if (Array.isArray(row.artifact_ids)) return row.artifact_ids.map(String).filter(Boolean); + return row.artifact_id ? [String(row.artifact_id)] : []; +} + function queryFromFilters(filters: FilterState, pageNo: number): DigitalEmployeeBusinessViewQuery { return { entity_id: filters.entity.trim() || null, @@ -129,6 +136,13 @@ function statusClass(value: unknown): string { return 'de-badge de-badge-info'; } +function relatedWorkbenchTarget(row: DigitalEmployeeBusinessViewRow): { label: string; search: string } | null { + if (row.notification_id || row.entity_type === 'notification') return { label: '打开通知运营', search: `?tab=notification-workbench&entity=${encodeURIComponent(rowEntityId(row))}` }; + if (row.report_id || row.entity_type === 'daily_report') return { label: '打开日报运营', search: `?tab=daily-report-workbench&entity=${encodeURIComponent(rowEntityId(row))}` }; + if (row.service_id || row.audit_id || row.entity_type === 'managed_service' || row.entity_type === 'audit') return { label: '打开处置台', search: `?tab=ops-workbench&entity=${encodeURIComponent(rowEntityId(row))}` }; + return null; +} + function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | null; onClose: () => void }) { if (!row) { return ; @@ -137,6 +151,7 @@ function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | n business_view: row.business_view ?? null, payload: row.payload ?? null, } : null; + const relatedTarget = relatedWorkbenchTarget(row); return (