吸收数字员工入口路由干预回写
This commit is contained in:
@@ -30,6 +30,9 @@ import type {
|
||||
CreatePlanRequest,
|
||||
RouteDecisionTimelineResponse,
|
||||
RouteDecisionPolicyPullResult,
|
||||
RouteInterventionDecisionRequest,
|
||||
RouteInterventionDecisionResponse,
|
||||
RouteInterventionTimelineResponse,
|
||||
IngressRouteRequest,
|
||||
IngressRouteResponse,
|
||||
GovernanceCommandRequest,
|
||||
@@ -496,6 +499,20 @@ export function getRouteDecisionTimeline(): Promise<RouteDecisionTimelineRespons
|
||||
return apiFetch<RouteDecisionTimelineResponse>('/api/route-decisions');
|
||||
}
|
||||
|
||||
export function getRouteInterventions(): Promise<RouteInterventionTimelineResponse> {
|
||||
return apiFetch<RouteInterventionTimelineResponse>('/api/route-decisions/interventions');
|
||||
}
|
||||
|
||||
export function decideRouteIntervention(
|
||||
routeDecisionId: string,
|
||||
input: RouteInterventionDecisionRequest,
|
||||
): Promise<RouteInterventionDecisionResponse> {
|
||||
return apiFetch<RouteInterventionDecisionResponse>(`/api/route-decisions/${encodeURIComponent(routeDecisionId)}/intervention`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function routeIngress(
|
||||
body: IngressRouteRequest,
|
||||
): Promise<IngressRouteResponse> {
|
||||
|
||||
@@ -42,6 +42,10 @@ import type {
|
||||
RouteDecisionPolicyPullResult,
|
||||
RouteDecisionRecord,
|
||||
RouteDecisionTimelineResponse,
|
||||
RouteInterventionDecisionRequest,
|
||||
RouteInterventionDecisionResponse,
|
||||
RouteInterventionRecord,
|
||||
RouteInterventionTimelineResponse,
|
||||
SkillSpec,
|
||||
UserNotification,
|
||||
} from '@/types/api';
|
||||
@@ -78,7 +82,9 @@ declare global {
|
||||
dispatchPlanReadySteps?: (planId: string) => Promise<ReadyStepDispatchResult>;
|
||||
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
||||
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
|
||||
listRouteInterventions?: (options?: { limit?: number }) => Promise<RouteInterventionRecord[]>;
|
||||
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||
recordRouteInterventionResolution?: (input: QimingclawRouteInterventionResolutionInput) => Promise<RouteInterventionRecord>;
|
||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
|
||||
@@ -377,6 +383,7 @@ interface QimingclawRouteDecisionInput {
|
||||
}
|
||||
|
||||
interface QimingclawRouteDecisionPolicyInput {
|
||||
routeDecisionId?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
@@ -408,6 +415,30 @@ interface QimingclawRouteDecisionPolicyResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawRouteInterventionResolutionInput {
|
||||
routeDecisionId: string;
|
||||
approvalId?: string | null;
|
||||
decision: string;
|
||||
createdCommandId?: string | null;
|
||||
commandAccepted?: boolean | null;
|
||||
commandError?: string | null;
|
||||
blockedReason?: string | null;
|
||||
policyResult?: string | null;
|
||||
policySource?: string | null;
|
||||
matchedIntent?: string | null;
|
||||
routeKind?: string | null;
|
||||
intentKind?: string | null;
|
||||
reasonCodes?: string[] | null;
|
||||
candidateSkills?: string[] | null;
|
||||
contextRefs?: Record<string, unknown> | null;
|
||||
entrypoint?: string | null;
|
||||
actor?: string | null;
|
||||
correlationId?: string | null;
|
||||
message?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
occurredAt?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawRiskPolicyInput {
|
||||
actionKind?: string | null;
|
||||
actionLabel?: string | null;
|
||||
@@ -903,6 +934,17 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/route-decisions') {
|
||||
return await readRouteDecisions(Number(url.searchParams.get('limit')) || 80) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/route-decisions/interventions') {
|
||||
return await readRouteInterventions(Number(url.searchParams.get('limit')) || 80) as T;
|
||||
}
|
||||
const routeInterventionMatch = pathname.match(/^\/api\/route-decisions\/([^/]+)\/intervention$/);
|
||||
if (method === 'POST' && routeInterventionMatch) {
|
||||
return await handleRouteInterventionDecision(
|
||||
decodeURIComponent(routeInterventionMatch[1] || ''),
|
||||
parseJsonBody<RouteInterventionDecisionRequest>(options.body),
|
||||
await readQimingclawSnapshot(),
|
||||
) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/ingress/route') {
|
||||
return await handleIngressRoute(parseJsonBody<IngressRouteRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
@@ -1251,6 +1293,9 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
try {
|
||||
const result = await bridge.pullApprovalUpdates();
|
||||
if (result?.ok !== false) {
|
||||
await resolveApprovedRouteInterventions(await readQimingclawSnapshot());
|
||||
}
|
||||
return {
|
||||
ok: result?.ok !== false,
|
||||
applied: Number(result?.applied ?? 0),
|
||||
@@ -1263,6 +1308,24 @@ async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: num
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
|
||||
const timeline = await readRouteInterventions(120);
|
||||
const approved = timeline.interventions.filter((item) => (
|
||||
item.intervention_status === 'approved'
|
||||
&& item.approval_id
|
||||
&& !item.resolved_at
|
||||
)).slice(0, 5);
|
||||
for (const intervention of approved) {
|
||||
await handleRouteInterventionDecision(intervention.route_decision_id, {
|
||||
approval_id: intervention.approval_id,
|
||||
decision: 'approved',
|
||||
actor: 'management-api',
|
||||
}, snapshot).catch((error) => {
|
||||
console.warn('[qimingclawAdapter] auto route intervention resolution failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise<QimingclawRiskPolicyResult> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.evaluateRiskPolicy) {
|
||||
@@ -3228,12 +3291,19 @@ function buildRouteDecisionSummary(snapshot: QimingclawSnapshot | null): Digital
|
||||
const rows = routeDecisionRows(snapshot);
|
||||
const latest = rows[0] ?? null;
|
||||
const policy = routeDecisionPolicy(snapshot?.uiState ?? readState());
|
||||
const resolutionEvents = runtimeEvents(snapshot).filter((event) => event.kind === 'route_decision_intervention_resolved');
|
||||
const resolvedRouteIds = new Set(resolutionEvents.map((event) => stringValue(asRecord(event.payload)?.route_decision_id)).filter(Boolean));
|
||||
const latestResolution = resolutionEvents.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt))[0] ?? null;
|
||||
const latestResolutionPayload = asRecord(latestResolution?.payload) ?? null;
|
||||
return {
|
||||
total: rows.length,
|
||||
mapped_count: rows.filter((row) => stringValue(row.created_command_id)).length,
|
||||
attention_count: rows.filter((row) => ['warn', 'error'].includes(stringValue(row.attention_level))).length,
|
||||
blocked_count: rows.filter((row) => stringValue(row.policy_result) === 'blocked').length,
|
||||
approval_required_count: rows.filter((row) => stringValue(row.policy_result) === 'approval_required').length,
|
||||
pending_intervention_count: rows.filter((row) => stringValue(row.policy_result) !== 'accepted' && !resolvedRouteIds.has(stringValue(row.route_decision_id))).length,
|
||||
restored_count: resolutionEvents.filter((event) => asRecord(event.payload)?.command_accepted === true).length,
|
||||
latest_intervention_status: stringValue(latestResolutionPayload?.status ?? latestResolutionPayload?.decision) || null,
|
||||
policy_source: policy.source ?? null,
|
||||
policy_last_pulled_at: policy.last_pulled_at ?? null,
|
||||
policy_last_pull_error: policy.last_pull_error ?? null,
|
||||
@@ -5846,6 +5916,224 @@ async function readRouteDecisions(limit = 80): Promise<RouteDecisionTimelineResp
|
||||
}
|
||||
}
|
||||
|
||||
async function readRouteInterventions(limit = 80): Promise<RouteInterventionTimelineResponse> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.listRouteInterventions) {
|
||||
return { has_route_intervention_state: false, interventions: [] };
|
||||
}
|
||||
try {
|
||||
const interventions = await bridge.listRouteInterventions({ limit });
|
||||
return { has_route_intervention_state: true, interventions: interventions ?? [] };
|
||||
} catch (error) {
|
||||
console.warn('[qimingclawAdapter] list route interventions failed:', error);
|
||||
return { has_route_intervention_state: false, interventions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRouteInterventionDecision(
|
||||
routeDecisionId: string,
|
||||
body: RouteInterventionDecisionRequest,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<RouteInterventionDecisionResponse> {
|
||||
const decisionText = stringValue(body.decision) || 'reject';
|
||||
const approvalId = stringValue(body.approval_id) || routeInterventionApprovalId(routeDecisionId, snapshot);
|
||||
const existing = await readRouteInterventions(160);
|
||||
const existingRecord = existing.interventions.find((item) => item.route_decision_id === routeDecisionId) ?? null;
|
||||
const detail = routeDecisionDetail(routeDecisionId, snapshot);
|
||||
const routeDecision = detail?.decision ?? routeInterventionToRouteDecision(existingRecord, routeDecisionId);
|
||||
if (!routeDecision) {
|
||||
const intervention = await recordRouteInterventionResolution({
|
||||
routeDecisionId,
|
||||
approvalId,
|
||||
decision: decisionText,
|
||||
commandAccepted: false,
|
||||
commandError: 'route_intervention_missing_context',
|
||||
actor: body.actor ?? 'digital_employee_operator',
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return { accepted: false, route_decision_id: routeDecisionId, intervention, error: 'invalid_context', message: '入口路由上下文缺失,无法恢复执行。' };
|
||||
}
|
||||
|
||||
await recordLocalRouteApprovalDecision(approvalId, decisionText, body.actor ?? 'digital_employee_operator', snapshot);
|
||||
|
||||
let command: GovernanceCommandResponse | null = null;
|
||||
let commandError: string | null = null;
|
||||
if (isApprovedInterventionDecision(decisionText)) {
|
||||
const routeCommand = buildGovernanceCommandForRouteDecision(routeDecision, detail?.body ?? routeDecisionToIngressBody(routeDecision), snapshot);
|
||||
if (routeCommand.command) {
|
||||
command = await handleGovernanceCommand(routeCommand.command, snapshot);
|
||||
if (!command.accepted) commandError = command.error || 'route_intervention_command_rejected';
|
||||
} else {
|
||||
commandError = routeCommand.reasonCode || 'route_intervention_missing_context';
|
||||
}
|
||||
}
|
||||
|
||||
const intervention = await recordRouteInterventionResolution({
|
||||
routeDecisionId,
|
||||
approvalId,
|
||||
decision: decisionText,
|
||||
createdCommandId: command?.command_id ?? null,
|
||||
commandAccepted: command?.accepted === true,
|
||||
commandError,
|
||||
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: body.actor ?? 'digital_employee_operator',
|
||||
correlationId: routeDecision.correlation_id,
|
||||
payload: { comment: body.comment ?? null },
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
accepted: intervention.intervention_status !== 'failed',
|
||||
route_decision_id: routeDecisionId,
|
||||
intervention,
|
||||
command,
|
||||
error: commandError || undefined,
|
||||
message: intervention.resolution_duplicate
|
||||
? '入口路由干预已处理。'
|
||||
: isApprovedInterventionDecision(decisionText)
|
||||
? command?.command_id ? '入口路由干预已通过并恢复执行。' : '入口路由干预已通过。'
|
||||
: '入口路由干预已拒绝。',
|
||||
};
|
||||
}
|
||||
|
||||
async function recordRouteInterventionResolution(input: QimingclawRouteInterventionResolutionInput): Promise<RouteInterventionRecord> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.recordRouteInterventionResolution) {
|
||||
return {
|
||||
route_decision_id: input.routeDecisionId,
|
||||
approval_id: input.approvalId ?? null,
|
||||
route_kind: input.routeKind ?? 'ChatOnly',
|
||||
intent_kind: input.intentKind ?? 'chat',
|
||||
policy_result: input.policyResult ?? null,
|
||||
intervention_status: input.commandError ? 'failed' : isApprovedInterventionDecision(input.decision) ? 'approved' : 'rejected',
|
||||
decision: input.decision,
|
||||
created_command_id: input.createdCommandId ?? null,
|
||||
blocked_reason: input.blockedReason ?? null,
|
||||
matched_intent: input.matchedIntent ?? null,
|
||||
policy_source: input.policySource ?? null,
|
||||
reason_codes: input.reasonCodes ?? [],
|
||||
candidate_skills: input.candidateSkills ?? [],
|
||||
context_refs: input.contextRefs ?? {},
|
||||
entrypoint: input.entrypoint ?? null,
|
||||
actor: input.actor ?? null,
|
||||
correlation_id: input.correlationId ?? null,
|
||||
approval_status: null,
|
||||
recorded_at: input.occurredAt ?? new Date().toISOString(),
|
||||
resolved_at: input.occurredAt ?? new Date().toISOString(),
|
||||
resolution_duplicate: false,
|
||||
};
|
||||
}
|
||||
return bridge.recordRouteInterventionResolution(input);
|
||||
}
|
||||
|
||||
function routeDecisionDetail(routeDecisionId: string, snapshot: QimingclawSnapshot | null): { decision: RouteDecisionRecord; body: IngressRouteRequest } | null {
|
||||
const event = runtimeEvents(snapshot).find((item) => {
|
||||
if (item.kind !== 'route_decision') return false;
|
||||
const payload = asRecord(item.payload) ?? {};
|
||||
const decision = routeDecisionFromRuntimePayload(payload, item);
|
||||
return decision.id === routeDecisionId;
|
||||
});
|
||||
if (!event) return null;
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
const decision = routeDecisionFromRuntimePayload(payload, event);
|
||||
const envelope = asRecord(payload.envelope) ?? {};
|
||||
return {
|
||||
decision,
|
||||
body: {
|
||||
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
|
||||
actor: decision.actor ?? undefined,
|
||||
text: stringValue(envelope.text) || undefined,
|
||||
payload: asRecord(envelope.payload) ?? {},
|
||||
attachments: arrayValue(envelope.attachments).filter((item): item is string => typeof item === 'string'),
|
||||
timestamp: decision.recorded_at,
|
||||
correlation_id: decision.correlation_id,
|
||||
idempotency_key: `route-intervention:${decision.id}`,
|
||||
context_refs: decision.context_refs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeInterventionToRouteDecision(record: RouteInterventionRecord | null, routeDecisionId: string): RouteDecisionRecord | null {
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: routeDecisionId,
|
||||
route_kind: record.route_kind,
|
||||
intent_kind: record.intent_kind,
|
||||
reason_codes: record.reason_codes ?? [],
|
||||
candidate_skills: record.candidate_skills ?? [],
|
||||
context_refs: record.context_refs ?? {},
|
||||
created_command_id: record.created_command_id,
|
||||
correlation_id: record.correlation_id || routeDecisionId,
|
||||
creates_plan: record.route_kind === 'PlanControl' || record.route_kind === 'PlanExecution',
|
||||
creates_task_run: record.route_kind === 'DirectRun' || record.route_kind === 'PlanExecution',
|
||||
approval_mode: record.approval_id ? 'manual' : null,
|
||||
policy_result: record.policy_result,
|
||||
policy_source: record.policy_source,
|
||||
blocked_reason: record.blocked_reason,
|
||||
approval_id: record.approval_id,
|
||||
matched_intent: record.matched_intent,
|
||||
recorded_at: record.recorded_at,
|
||||
entrypoint: record.entrypoint,
|
||||
actor: record.actor,
|
||||
};
|
||||
}
|
||||
|
||||
function routeDecisionToIngressBody(decision: RouteDecisionRecord): IngressRouteRequest {
|
||||
return {
|
||||
entrypoint: decision.entrypoint as IngressRouteRequest['entrypoint'] ?? 'chat',
|
||||
actor: decision.actor ?? undefined,
|
||||
payload: {},
|
||||
attachments: [],
|
||||
timestamp: decision.recorded_at,
|
||||
correlation_id: decision.correlation_id,
|
||||
idempotency_key: `route-intervention:${decision.id}`,
|
||||
context_refs: decision.context_refs,
|
||||
};
|
||||
}
|
||||
|
||||
function routeInterventionApprovalId(routeDecisionId: string, snapshot: QimingclawSnapshot | null): string | null {
|
||||
const detail = routeDecisionDetail(routeDecisionId, snapshot);
|
||||
if (detail?.decision.approval_id) return detail.decision.approval_id;
|
||||
const row = routeDecisionRows(snapshot).find((item) => stringValue(item.route_decision_id) === routeDecisionId);
|
||||
return stringValue(row?.approval_id) || null;
|
||||
}
|
||||
|
||||
async function recordLocalRouteApprovalDecision(
|
||||
approvalId: string | null,
|
||||
decision: string,
|
||||
actor: string,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<void> {
|
||||
if (!approvalId) return;
|
||||
const date = todayInputDate();
|
||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||
state.decisionResultsByDate = state.decisionResultsByDate ?? {};
|
||||
state.decisionResultsByDate[date] = {
|
||||
...(state.decisionResultsByDate[date] ?? {}),
|
||||
[approvalId]: decision,
|
||||
};
|
||||
await saveState(state, 'decide_approval', date, { decision_id: approvalId, decision, actor });
|
||||
await window.QimingClawBridge?.digital?.submitApprovalDecision?.({
|
||||
approval_id: approvalId,
|
||||
decision,
|
||||
date,
|
||||
actor,
|
||||
source: 'sgrobot-route-intervention',
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
function isApprovedInterventionDecision(decision: string): boolean {
|
||||
return ['approve', 'approved', 'accept', 'accepted'].includes(decision.toLowerCase());
|
||||
}
|
||||
|
||||
async function handleIngressRoute(
|
||||
body: IngressRouteRequest,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
@@ -5881,6 +6169,7 @@ async function handleIngressRoute(
|
||||
actor: body.actor ?? null,
|
||||
};
|
||||
const routePolicy = await evaluateBridgeRouteDecisionPolicy({
|
||||
routeDecisionId: decision.id,
|
||||
routeKind: decision.route_kind,
|
||||
intentKind: decision.intent_kind,
|
||||
reasonCodes: decision.reason_codes,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
dispatchPlanReadySteps,
|
||||
getDigitalEmployeeWorkday,
|
||||
getPlanStepGraph,
|
||||
getRouteInterventions,
|
||||
getSchedulerJobs,
|
||||
governanceCommand,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
pullRiskApprovalPolicy,
|
||||
pullRouteDecisionPolicy,
|
||||
recordApprovalAction,
|
||||
decideRouteIntervention,
|
||||
restartManagedService,
|
||||
} from '@/lib/api';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
@@ -33,6 +35,7 @@ import type {
|
||||
GovernanceCommandKind,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDependencyDetail,
|
||||
RouteInterventionRecord,
|
||||
} from '@/types/api';
|
||||
import type { DigitalNavigationTarget } from './navigation';
|
||||
|
||||
@@ -990,6 +993,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [planStepGraphRows, setPlanStepGraphRows] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
|
||||
const [routeInterventions, setRouteInterventions] = useState<RouteInterventionRecord[]>([]);
|
||||
const [routeInterventionError, setRouteInterventionError] = useState<string | null>(null);
|
||||
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
|
||||
const [blockedInputDraft, setBlockedInputDraft] = useState('');
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
@@ -1076,7 +1081,19 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
|
||||
});
|
||||
|
||||
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise]);
|
||||
const routeInterventionPromise = getRouteInterventions()
|
||||
.then((response) => {
|
||||
if (!mountedRef.current) return;
|
||||
setRouteInterventions(response.interventions.slice(0, 6));
|
||||
setRouteInterventionError(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!mountedRef.current) return;
|
||||
setRouteInterventions([]);
|
||||
setRouteInterventionError(error instanceof Error ? error.message : '路由干预加载失败');
|
||||
});
|
||||
|
||||
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise, routeInterventionPromise]);
|
||||
if (mountedRef.current) {
|
||||
setProjectionLoading(false);
|
||||
}
|
||||
@@ -1458,6 +1475,30 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const resolveRouteIntervention = useCallback(async (intervention: RouteInterventionRecord, decision: 'approved' | 'rejected') => {
|
||||
const actionKey = `route-intervention:${intervention.route_decision_id}:${decision}`;
|
||||
setActionBusy(actionKey);
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await decideRouteIntervention(intervention.route_decision_id, {
|
||||
approval_id: intervention.approval_id,
|
||||
decision,
|
||||
actor: 'digital_employee_operator',
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
await fetchProjection();
|
||||
if (!mountedRef.current) return;
|
||||
setActionNotice(result.message || (decision === 'approved' ? '入口路由干预已通过。' : '入口路由干预已拒绝。'));
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '处理入口路由干预失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
|
||||
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
||||
try {
|
||||
@@ -2177,6 +2218,45 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
|
||||
</div>
|
||||
)}
|
||||
{(routeInterventions.length > 0 || routeInterventionError) && (
|
||||
<div className="de-management-sync-failures" aria-label="入口路由干预" style={{ marginBottom: 10 }}>
|
||||
{routeInterventionError && <p className="de-empty-text">{routeInterventionError}</p>}
|
||||
{routeInterventions.slice(0, 4).map((intervention) => {
|
||||
const status = intervention.intervention_status;
|
||||
const pending = !intervention.resolved_at && ['pending', 'blocked', 'approved'].includes(status);
|
||||
const approveKey = `route-intervention:${intervention.route_decision_id}:approved`;
|
||||
const rejectKey = `route-intervention:${intervention.route_decision_id}:rejected`;
|
||||
return (
|
||||
<div key={intervention.route_decision_id} className="de-management-sync-failure" title={intervention.blocked_reason || intervention.route_decision_id}>
|
||||
<span>{intervention.entrypoint || 'route'}</span>
|
||||
<strong>{intervention.route_kind} / {intervention.intent_kind}</strong>
|
||||
<em>{status}</em>
|
||||
<small>{intervention.blocked_reason || intervention.matched_intent || intervention.approval_id || intervention.route_decision_id}</small>
|
||||
{pending && (
|
||||
<div className="de-task-agent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={actionBusy === approveKey}
|
||||
onClick={() => { void resolveRouteIntervention(intervention, 'approved'); }}
|
||||
>
|
||||
{status === 'approved' ? '恢复' : '通过'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={actionBusy === rejectKey}
|
||||
onClick={() => { void resolveRouteIntervention(intervention, 'rejected'); }}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{managedServiceIssues.length > 0 && (
|
||||
<div className="de-management-sync-failures" aria-label="服务状态处理" style={{ marginBottom: 10 }}>
|
||||
{managedServiceIssues.map((service) => {
|
||||
@@ -2360,9 +2440,12 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em>需关注</em><strong>{routeSummary.attention_count}</strong></span>
|
||||
{(routeSummary.blocked_count ?? 0) > 0 && <span className="is-warning"><em>阻断</em><strong>{routeSummary.blocked_count}</strong></span>}
|
||||
{(routeSummary.approval_required_count ?? 0) > 0 && <span className="is-warning"><em>待审</em><strong>{routeSummary.approval_required_count}</strong></span>}
|
||||
{(routeSummary.pending_intervention_count ?? 0) > 0 && <span className="is-warning"><em>待干预</em><strong>{routeSummary.pending_intervention_count}</strong></span>}
|
||||
{(routeSummary.restored_count ?? 0) > 0 && <span><em>已恢复</em><strong>{routeSummary.restored_count}</strong></span>}
|
||||
<small>
|
||||
{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}
|
||||
{routeSummary.policy_source ? ` · 策略${routeSummary.policy_source === 'remote' ? '远端' : '本地'}` : ''}
|
||||
{routeSummary.latest_intervention_status ? ` · 干预${routeSummary.latest_intervention_status}` : ''}
|
||||
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -409,6 +409,52 @@ export interface RouteDecisionTimelineResponse {
|
||||
decisions: RouteDecisionRecord[];
|
||||
}
|
||||
|
||||
export interface RouteInterventionRecord {
|
||||
route_decision_id: string;
|
||||
approval_id: string | null;
|
||||
route_kind: string;
|
||||
intent_kind: string;
|
||||
policy_result: string | null;
|
||||
intervention_status: 'pending' | 'blocked' | 'approved' | 'rejected' | 'executed' | 'failed' | 'resolved' | string;
|
||||
decision: string | null;
|
||||
created_command_id: string | null;
|
||||
blocked_reason: string | null;
|
||||
matched_intent: string | null;
|
||||
policy_source: string | null;
|
||||
reason_codes: string[];
|
||||
candidate_skills: string[];
|
||||
context_refs: Record<string, unknown>;
|
||||
entrypoint: string | null;
|
||||
actor: string | null;
|
||||
correlation_id: string | null;
|
||||
approval_status: string | null;
|
||||
recorded_at: string;
|
||||
resolved_at: string | null;
|
||||
resolution_event_id?: string | null;
|
||||
resolution_duplicate?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteInterventionTimelineResponse {
|
||||
has_route_intervention_state: boolean;
|
||||
interventions: RouteInterventionRecord[];
|
||||
}
|
||||
|
||||
export interface RouteInterventionDecisionRequest {
|
||||
approval_id?: string | null;
|
||||
decision: 'approve' | 'approved' | 'reject' | 'rejected' | string;
|
||||
actor?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export interface RouteInterventionDecisionResponse {
|
||||
accepted: boolean;
|
||||
route_decision_id: string;
|
||||
intervention: RouteInterventionRecord;
|
||||
command?: unknown;
|
||||
message?: string;
|
||||
error?: GovernanceCommandErrorCode | string;
|
||||
}
|
||||
|
||||
export interface RouteDecisionPolicy {
|
||||
enabled: boolean;
|
||||
auto_create_governance_commands: boolean;
|
||||
@@ -989,6 +1035,9 @@ export interface DigitalEmployeeRouteSummary {
|
||||
attention_count: number;
|
||||
blocked_count?: number;
|
||||
approval_required_count?: number;
|
||||
pending_intervention_count?: number;
|
||||
restored_count?: number;
|
||||
latest_intervention_status?: string | null;
|
||||
policy_source?: string | null;
|
||||
policy_last_pulled_at?: string | null;
|
||||
policy_last_pull_error?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user