吸收数字员工入口路由干预回写

This commit is contained in:
baiyanyun
2026-06-11 18:31:41 +08:00
parent d292c34579
commit 4c862ef4b7
16 changed files with 1151 additions and 191 deletions

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -589,22 +589,35 @@ function checkDigitalRouteDecisionPolicy() {
[stateService, "routeDecisionPolicy", "route decision policy UI state"],
[stateService, "normalizeRouteDecisionPolicy", "route decision policy normalizer"],
[stateService, "evaluateDigitalEmployeeRouteDecisionPolicy", "route decision policy evaluator"],
[stateService, "listDigitalEmployeeRouteInterventions", "route intervention list helper"],
[stateService, "recordDigitalEmployeeRouteInterventionResolution", "route intervention resolution recorder"],
[stateService, "route_decision_blocked", "route decision blocked event kind"],
[stateService, "route_decision_approval_required", "route decision approval-required event kind"],
[stateService, "route_decision_intervention_resolved", "route decision intervention resolved event kind"],
[syncService, "pullDigitalEmployeeRouteDecisionPolicy", "remote route decision policy pull service"],
[syncService, "routeDecisionPolicyEndpoint", "remote route decision policy endpoint config"],
[syncService, "/api/digital-employee/policies/route-decision", "remote route decision policy default path"],
[syncService, "routeDecisionPolicyBusinessView", "route decision policy sync business view"],
[syncService, "routeDecisionInterventionBusinessView", "route decision intervention sync business view"],
[ipcHandlers, "digitalEmployee:pullRouteDecisionPolicy", "IPC route decision policy pull handler"],
[ipcHandlers, "digitalEmployee:evaluateRouteDecisionPolicy", "IPC route decision policy evaluation handler"],
[ipcHandlers, "digitalEmployee:listRouteInterventions", "IPC route intervention list handler"],
[ipcHandlers, "digitalEmployee:recordRouteInterventionResolution", "IPC route intervention resolution handler"],
[preload, "pullRouteDecisionPolicy", "preload route decision policy pull bridge"],
[preload, "evaluateRouteDecisionPolicy", "preload route decision policy evaluation bridge"],
[preload, "listRouteInterventions", "preload route intervention list bridge"],
[preload, "recordRouteInterventionResolution", "preload route intervention resolution bridge"],
[adapter, "pullRouteDecisionPolicy", "adapter route decision policy pull bridge"],
[adapter, "evaluateRouteDecisionPolicy", "adapter route decision policy evaluation bridge"],
[adapter, "/api/route-decision/policy/pull", "adapter route decision policy pull endpoint"],
[adapter, "/api/route-decisions/interventions", "adapter route intervention list endpoint"],
[adapter, "handleRouteInterventionDecision", "adapter route intervention decision handler"],
[adapter, "resolveApprovedRouteInterventions", "adapter approved route intervention auto resolver"],
[adapter, "auto_create_governance_commands", "adapter route command auto-create switch"],
[api, "pullRouteDecisionPolicy", "embedded route decision policy pull API"],
[api, "decideRouteIntervention", "embedded route intervention decision API"],
[types, "RouteDecisionPolicy", "embedded route decision policy type"],
[types, "RouteInterventionRecord", "embedded route intervention type"],
[commandDeck, "pull_route_decision_policy", "home route decision policy pull action"],
[commandDeck, "路由策略", "home route decision policy pull button"],
];

View File

@@ -86,6 +86,8 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
syncQimingclawMemoryEntries: vi.fn(),
recordDigitalEmployeeRouteDecision: vi.fn(),
listDigitalEmployeeRouteDecisions: vi.fn(),
listDigitalEmployeeRouteInterventions: vi.fn(() => [{ route_decision_id: "route-1", intervention_status: "pending" }]),
recordDigitalEmployeeRouteInterventionResolution: vi.fn(() => ({ route_decision_id: "route-1", intervention_status: "executed" })),
}));
vi.mock("../services/digitalEmployee/schedulerService", () => ({
@@ -318,6 +320,27 @@ describe("digital employee managed service actions", () => {
expect(stateService.evaluateDigitalEmployeeRouteDecisionPolicy).toHaveBeenCalledWith({ intentKind: "schedule" });
});
it("registers route intervention list and resolution through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const stateService = await import("../services/digitalEmployee/stateService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const listCall = calls.find(([channel]) => channel === "digitalEmployee:listRouteInterventions");
const resolutionCall = calls.find(([channel]) => channel === "digitalEmployee:recordRouteInterventionResolution");
expect(listCall).toBeTruthy();
expect(resolutionCall).toBeTruthy();
const listResult = await (listCall?.[1] as (_event: unknown, options: unknown) => Promise<unknown>)({}, { limit: 5 });
const resolutionResult = await (resolutionCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { routeDecisionId: "route-1", decision: "approved" });
expect(listResult).toEqual([expect.objectContaining({ route_decision_id: "route-1" })]);
expect(resolutionResult).toMatchObject({ route_decision_id: "route-1", intervention_status: "executed" });
expect(stateService.listDigitalEmployeeRouteInterventions).toHaveBeenCalledWith({ limit: 5 });
expect(stateService.recordDigitalEmployeeRouteInterventionResolution).toHaveBeenCalledWith({ routeDecisionId: "route-1", decision: "approved" });
});
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -39,6 +39,8 @@ import {
syncQimingclawMemoryEntries,
recordDigitalEmployeeRouteDecision,
listDigitalEmployeeRouteDecisions,
listDigitalEmployeeRouteInterventions,
recordDigitalEmployeeRouteInterventionResolution,
type DigitalEmployeeManagedService,
type DigitalEmployeeManagedServiceActionInput,
type DigitalEmployeeGovernanceCommandRecord,
@@ -47,6 +49,7 @@ import {
type DigitalEmployeeApprovalActionInput,
type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeRouteInterventionResolutionInput,
type DigitalEmployeeServiceStatus,
type DigitalEmployeeSnapshot,
type DigitalEmployeeUiStateUpdate,
@@ -256,10 +259,18 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return listDigitalEmployeeRouteDecisions(options ?? {});
});
ipcMain.handle("digitalEmployee:listRouteInterventions", async (_, options?: { limit?: number }) => {
return listDigitalEmployeeRouteInterventions(options ?? {});
});
ipcMain.handle("digitalEmployee:recordRouteDecision", async (_, input: DigitalEmployeeRouteDecisionInput) => {
return recordDigitalEmployeeRouteDecision(input);
});
ipcMain.handle("digitalEmployee:recordRouteInterventionResolution", async (_, input: DigitalEmployeeRouteInterventionResolutionInput) => {
return recordDigitalEmployeeRouteInterventionResolution(input);
});
ipcMain.handle(
"digitalEmployee:recordGovernanceCommand",
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {

View File

@@ -734,6 +734,7 @@ describe("digital employee state service", () => {
occurredAt: "2026-06-07T08:12:00.000Z",
});
const approval = evaluateDigitalEmployeeRouteDecisionPolicy({
routeDecisionId: "route-decision-execute",
routeKind: "DirectRun",
intentKind: "execute",
correlationId: "corr-execute",
@@ -765,6 +766,90 @@ describe("digital employee state service", () => {
expect(mockState.db?.events.get(blocked.event_id!)).toMatchObject({ kind: "route_decision_blocked" });
expect(mockState.db?.events.get(approval.event_id!)).toMatchObject({ kind: "route_decision_approval_required" });
expect(mockState.db?.approvals.get(approval.approval_id!)).toMatchObject({ status: "pending" });
expect(JSON.parse(mockState.db?.approvals.get(approval.approval_id!)?.payload ?? "{}")).toMatchObject({
approval_kind: "route_decision_policy",
route_decision_id: "route-decision-execute",
route_kind: "DirectRun",
intent_kind: "execute",
});
});
it("records route intervention resolution once and derives intervention rows", async () => {
const {
listDigitalEmployeeRouteInterventions,
recordDigitalEmployeeRouteDecision,
recordDigitalEmployeeRouteInterventionResolution,
} = await import("./stateService");
recordDigitalEmployeeRouteDecision({
id: "route-decision-restore",
correlationId: "route-corr-restore",
routeKind: "DirectRun",
intentKind: "execute",
reasonCodes: ["route_intent_requires_approval"],
candidateSkills: ["crm-search"],
policyResult: "approval_required",
policySource: "remote",
approvalId: "route-approval-restore",
matchedIntent: "execute",
contextRefs: { task_id: "task-1" },
recordedAt: "2026-06-07T08:14:00.000Z",
});
mockState.db?.approvals.set("route-approval-restore", {
id: "route-approval-restore",
plan_id: "",
task_id: "",
run_id: "",
title: "入口路由审批",
status: "approved",
payload: JSON.stringify({ approval_kind: "route_decision_policy", route_decision_id: "route-decision-restore" }),
sync_status: "pending",
created_at: "2026-06-07T08:14:00.000Z",
updated_at: "2026-06-07T08:15:00.000Z",
});
expect(listDigitalEmployeeRouteInterventions()).toEqual([
expect.objectContaining({
route_decision_id: "route-decision-restore",
approval_id: "route-approval-restore",
intervention_status: "approved",
approval_status: "approved",
}),
]);
const first = recordDigitalEmployeeRouteInterventionResolution({
routeDecisionId: "route-decision-restore",
approvalId: "route-approval-restore",
decision: "approved",
createdCommandId: "governance-command-restore",
commandAccepted: true,
routeKind: "DirectRun",
intentKind: "execute",
policySource: "remote",
occurredAt: "2026-06-07T08:16:00.000Z",
});
const second = recordDigitalEmployeeRouteInterventionResolution({
routeDecisionId: "route-decision-restore",
approvalId: "route-approval-restore",
decision: "approved",
createdCommandId: "governance-command-duplicate",
commandAccepted: true,
occurredAt: "2026-06-07T08:17:00.000Z",
});
expect(first).toMatchObject({
route_decision_id: "route-decision-restore",
approval_id: "route-approval-restore",
intervention_status: "executed",
created_command_id: "governance-command-restore",
});
expect(second).toMatchObject({ resolution_duplicate: true, created_command_id: "governance-command-restore" });
expect(Array.from(mockState.db?.events.values() ?? []).filter((event) => event.kind === "route_decision_intervention_resolved")).toHaveLength(1);
expect(listDigitalEmployeeRouteInterventions()[0]).toMatchObject({
intervention_status: "executed",
resolved_at: "2026-06-07T08:16:00.000Z",
created_command_id: "governance-command-restore",
});
});
it("updates formal approval records when UI approval decisions are saved", async () => {

View File

@@ -456,6 +456,7 @@ export interface DigitalEmployeeRouteDecisionPolicy {
export type DigitalEmployeeRouteDecisionPolicyDecision = "allowed" | "approval_required" | "blocked";
export interface DigitalEmployeeRouteDecisionPolicyInput {
routeDecisionId?: string | null;
routeKind?: string | null;
intentKind?: string | null;
reasonCodes?: string[] | null;
@@ -477,6 +478,7 @@ export interface DigitalEmployeeRouteDecisionPolicyResult {
policy_result: "accepted" | "approval_required" | "blocked";
route_kind: string;
intent_kind: string;
route_decision_id?: string | null;
reason_codes: string[];
matched_intent?: string | null;
blocked_reason?: string | null;
@@ -562,6 +564,55 @@ export interface DigitalEmployeeRouteDecisionInput {
recordedAt?: string | null;
}
export interface DigitalEmployeeRouteInterventionRecord {
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";
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 DigitalEmployeeRouteInterventionResolutionInput {
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;
}
export interface DigitalEmployeeArtifactRecord {
id: string;
remoteId?: string | null;
@@ -1743,12 +1794,134 @@ export function listDigitalEmployeeRouteDecisions(options: { limit?: number } =
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
}
export function listDigitalEmployeeRouteInterventions(options: { limit?: number } = {}): DigitalEmployeeRouteInterventionRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const safeLimit = Math.max(1, Math.min(Math.floor(options.limit ?? 80), 300));
const eventRows = db.prepare(`
SELECT id, kind, payload, occurred_at, created_at
FROM digital_events
WHERE kind IN ('route_decision', 'route_decision_blocked', 'route_decision_approval_required', 'route_decision_intervention_resolved')
ORDER BY occurred_at DESC, created_at DESC
LIMIT ?
`).all(Math.max(safeLimit * 4, 80)) as Array<Record<string, unknown>>;
const approvalRows = db.prepare(`
SELECT id, status, payload, updated_at, created_at
FROM digital_approvals
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(Math.max(safeLimit * 4, 80)) as Array<Record<string, unknown>>;
const approvalsById = new Map<string, { status: string | null; payload: Record<string, unknown> | null }>();
for (const row of approvalRows) {
const id = stringValue(row.id);
if (!id) continue;
approvalsById.set(id, {
status: stringValue(row.status) || null,
payload: objectRecord(parseStoredPayload(row.payload)) ?? null,
});
}
const records = new Map<string, DigitalEmployeeRouteInterventionRecord>();
const resolutions = new Map<string, Record<string, unknown>>();
for (const row of eventRows) {
const kind = stringValue(row.kind);
const payload = objectRecord(parseStoredPayload(row.payload)) ?? {};
if (kind === "route_decision_intervention_resolved") {
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
if (routeDecisionId) resolutions.set(routeDecisionId, { ...payload, event_id: row.id, occurred_at: row.occurred_at });
continue;
}
const decision = kind === "route_decision" ? routeDecisionFromEventPayload(payload) : null;
const source = decision ? routeInterventionSourceFromDecision(decision) : routeInterventionSourceFromPayload(payload, row);
if (!source) continue;
records.set(source.route_decision_id, source);
}
for (const approval of approvalsById.values()) {
const payload = approval.payload;
if (!payload || stringValue(payload.approval_kind) !== "route_decision_policy") continue;
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
if (!routeDecisionId || records.has(routeDecisionId)) continue;
records.set(routeDecisionId, routeInterventionSourceFromPayload({
...payload,
approval_id: stringValue(payload.approval_id ?? payload.approvalId) || null,
policy_result: "approval_required",
}, { occurred_at: stringValue(payload.created_at ?? payload.createdAt) || new Date().toISOString() })!);
}
for (const record of records.values()) {
const approval = record.approval_id ? approvalsById.get(record.approval_id) : null;
if (approval?.status) record.approval_status = approval.status;
const resolution = resolutions.get(record.route_decision_id);
if (resolution) applyRouteInterventionResolution(record, resolution);
else if (record.approval_status && isApprovedRouteInterventionDecision(record.approval_status)) record.intervention_status = "approved";
else if (record.approval_status && isRejectedRouteInterventionDecision(record.approval_status)) record.intervention_status = "rejected";
}
return Array.from(records.values())
.sort((left, right) => (right.resolved_at || right.recorded_at).localeCompare(left.resolved_at || left.recorded_at))
.slice(0, safeLimit);
}
export function recordDigitalEmployeeRouteInterventionResolution(
input: DigitalEmployeeRouteInterventionResolutionInput,
): DigitalEmployeeRouteInterventionRecord {
const db = getDigitalEmployeeDb();
const now = input.occurredAt || new Date().toISOString();
const routeDecisionId = stringValue(input.routeDecisionId);
if (!routeDecisionId) throw new Error("route decision id is required");
const approvalId = stringValue(input.approvalId) || null;
if (db) {
const existing = findRouteInterventionResolution(routeDecisionId, approvalId);
if (existing) {
return routeInterventionRecordFromResolution(input, existing, true);
}
}
const decision = normalizeRouteInterventionDecision(input.decision);
const commandAccepted = input.commandAccepted === true;
const commandError = stringValue(input.commandError) || null;
const eventId = `route-intervention:${safeId(routeDecisionId)}:${safeId(approvalId || "no-approval")}`;
const payload: Record<string, unknown> = {
source: "qimingclaw-route-intervention",
route_decision_id: routeDecisionId,
approval_id: approvalId,
decision,
status: routeInterventionStatusFromResolution(decision, commandAccepted, commandError),
created_command_id: stringValue(input.createdCommandId) || null,
command_accepted: commandAccepted,
command_error: commandError,
blocked_reason: stringValue(input.blockedReason) || null,
policy_result: stringValue(input.policyResult) || null,
policy_source: stringValue(input.policySource) || null,
matched_intent: stringValue(input.matchedIntent) || null,
route_kind: normalizeRouteKind(input.routeKind),
intent_kind: normalizeRouteIntentKind(input.intentKind),
reason_codes: normalizeSkillIds(input.reasonCodes ?? []),
candidate_skills: normalizeSkillIds(input.candidateSkills ?? []),
context_refs: objectRecord(input.contextRefs) ?? {},
entrypoint: stringValue(input.entrypoint) || null,
actor: stringValue(input.actor) || "digital_employee_operator",
correlation_id: stringValue(input.correlationId) || null,
resolution_payload: sanitizeRouteDecisionPolicyPayload(input.payload),
};
insertEvent(
{
event_id: eventId,
kind: "route_decision_intervention_resolved",
occurred_at: now,
message: input.message || routeInterventionResolutionMessage(decision, routeDecisionId),
plan_id: null,
task_id: null,
},
null,
payload,
);
return routeInterventionRecordFromResolution(input, { ...payload, event_id: eventId, occurred_at: now }, false);
}
export function evaluateDigitalEmployeeRouteDecisionPolicy(
input: DigitalEmployeeRouteDecisionPolicyInput,
): DigitalEmployeeRouteDecisionPolicyResult {
const policy = readDigitalEmployeeRouteDecisionPolicy();
const routeKind = normalizeRouteKind(input.routeKind);
const intentKind = normalizeRouteIntentKind(input.intentKind);
const routeDecisionId = stringValue(input.routeDecisionId) || null;
const baseReasonCodes = normalizeSkillIds(input.reasonCodes ?? []);
const source = policy.source || "local";
if (!policy.enabled) {
@@ -1759,6 +1932,7 @@ export function evaluateDigitalEmployeeRouteDecisionPolicy(
policy_result: "accepted",
route_kind: routeKind,
intent_kind: intentKind,
route_decision_id: routeDecisionId,
reason_codes: [...baseReasonCodes, "route_policy_disabled"],
policy_source: source,
policy,
@@ -1787,6 +1961,7 @@ export function evaluateDigitalEmployeeRouteDecisionPolicy(
policy_result: "accepted",
route_kind: routeKind,
intent_kind: intentKind,
route_decision_id: routeDecisionId,
reason_codes: baseReasonCodes.length > 0 ? baseReasonCodes : ["route_policy_allowed"],
policy_source: source,
policy,
@@ -1803,6 +1978,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
const now = input.occurredAt || new Date().toISOString();
const routeKind = normalizeRouteKind(input.routeKind);
const intentKind = normalizeRouteIntentKind(input.intentKind);
const routeDecisionId = stringValue(input.routeDecisionId) || null;
const source = stringValue(input.source) || "qimingclaw-route-policy";
const correlationId = stringValue(input.correlationId) || stringValue(input.idempotencyKey) || `${routeKind}:${intentKind}:${now}`;
const eventId = `route-policy:${safeId(decision)}:${safeId(intentKind)}:${safeId(correlationId)}:${safeId(now)}`;
@@ -1818,6 +1994,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
policy_result: "blocked",
route_kind: routeKind,
intent_kind: intentKind,
route_decision_id: routeDecisionId,
reason_codes: [...reasonCodes, "database_unavailable"],
matched_intent: intentKind,
blocked_reason: "database_unavailable",
@@ -1838,6 +2015,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
payload: {
source,
approval_kind: "route_decision_policy",
route_decision_id: routeDecisionId,
route_kind: routeKind,
intent_kind: intentKind,
reason_codes: reasonCodes,
@@ -1854,6 +2032,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
source,
route_kind: routeKind,
intent_kind: intentKind,
route_decision_id: routeDecisionId,
decision,
status: decision,
policy_result: decision,
@@ -1889,6 +2068,7 @@ function recordDigitalEmployeeRouteDecisionPolicyIntervention(
policy_result: decision,
route_kind: routeKind,
intent_kind: intentKind,
route_decision_id: routeDecisionId,
reason_codes: reasonCodes,
matched_intent: intentKind,
blocked_reason: blockedReason,
@@ -2112,6 +2292,164 @@ function routeDecisionPolicyMessage(
: `入口路由已被策略阻断:${routeKind} / ${intentKind}`;
}
function routeInterventionSourceFromDecision(decision: DigitalEmployeeRouteDecisionRecord): DigitalEmployeeRouteInterventionRecord | null {
if (!decision.approval_id && !decision.blocked_reason && decision.policy_result !== "blocked" && decision.policy_result !== "approval_required") return null;
return {
route_decision_id: decision.id,
approval_id: decision.approval_id ?? null,
route_kind: decision.route_kind,
intent_kind: decision.intent_kind,
policy_result: decision.policy_result,
intervention_status: decision.policy_result === "approval_required" ? "pending" : "blocked",
decision: null,
created_command_id: decision.created_command_id,
blocked_reason: decision.blocked_reason ?? null,
matched_intent: decision.matched_intent ?? null,
policy_source: decision.policy_source ?? null,
reason_codes: decision.reason_codes,
candidate_skills: decision.candidate_skills,
context_refs: decision.context_refs,
entrypoint: decision.entrypoint ?? null,
actor: decision.actor ?? null,
correlation_id: decision.correlation_id,
approval_status: null,
recorded_at: decision.recorded_at,
resolved_at: null,
};
}
function routeInterventionSourceFromPayload(
payload: Record<string, unknown>,
row: Record<string, unknown>,
): DigitalEmployeeRouteInterventionRecord | null {
const routeDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
if (!routeDecisionId) return null;
const policyResult = stringValue(payload.policy_result ?? payload.policyResult ?? payload.decision) || null;
return {
route_decision_id: routeDecisionId,
approval_id: stringValue(payload.approval_id ?? payload.approvalId) || null,
route_kind: normalizeRouteKind(payload.route_kind ?? payload.routeKind),
intent_kind: normalizeRouteIntentKind(payload.intent_kind ?? payload.intentKind),
policy_result: policyResult,
intervention_status: policyResult === "approval_required" ? "pending" : "blocked",
decision: null,
created_command_id: null,
blocked_reason: stringValue(payload.blocked_reason ?? payload.blockedReason) || null,
matched_intent: stringValue(payload.matched_intent ?? payload.matchedIntent) || null,
policy_source: stringValue(payload.policy_source ?? payload.policySource) || null,
reason_codes: parseRouteDecisionStringArray(payload.reason_codes ?? payload.reasonCodes),
candidate_skills: parseRouteDecisionStringArray(payload.candidate_skills ?? payload.candidateSkills),
context_refs: objectRecord(payload.context_refs ?? payload.contextRefs) ?? {},
entrypoint: stringValue(payload.entrypoint) || null,
actor: stringValue(payload.actor) || null,
correlation_id: stringValue(payload.correlation_id ?? payload.correlationId) || null,
approval_status: null,
recorded_at: stringValue(row.occurred_at ?? row.occurredAt) || new Date().toISOString(),
resolved_at: null,
};
}
function applyRouteInterventionResolution(record: DigitalEmployeeRouteInterventionRecord, resolution: Record<string, unknown>): void {
record.decision = stringValue(resolution.decision) || null;
record.created_command_id = stringValue(resolution.created_command_id ?? resolution.createdCommandId) || record.created_command_id;
record.intervention_status = routeInterventionStatusFromResolution(
record.decision || "resolved",
resolution.command_accepted === true || resolution.commandAccepted === true,
stringValue(resolution.command_error ?? resolution.commandError) || null,
);
record.resolved_at = stringValue(resolution.occurred_at ?? resolution.occurredAt) || null;
record.resolution_event_id = stringValue(resolution.event_id ?? resolution.eventId) || null;
}
function routeInterventionRecordFromResolution(
input: DigitalEmployeeRouteInterventionResolutionInput,
resolution: Record<string, unknown>,
duplicate: boolean,
): DigitalEmployeeRouteInterventionRecord {
const decision = stringValue(resolution.decision) || normalizeRouteInterventionDecision(input.decision);
const record: DigitalEmployeeRouteInterventionRecord = {
route_decision_id: stringValue(resolution.route_decision_id ?? resolution.routeDecisionId) || input.routeDecisionId,
approval_id: stringValue(resolution.approval_id ?? resolution.approvalId ?? input.approvalId) || null,
route_kind: normalizeRouteKind(resolution.route_kind ?? resolution.routeKind ?? input.routeKind),
intent_kind: normalizeRouteIntentKind(resolution.intent_kind ?? resolution.intentKind ?? input.intentKind),
policy_result: stringValue(resolution.policy_result ?? resolution.policyResult ?? input.policyResult) || null,
intervention_status: routeInterventionStatusFromResolution(
decision,
resolution.command_accepted === true || input.commandAccepted === true,
stringValue(resolution.command_error ?? resolution.commandError ?? input.commandError) || null,
),
decision,
created_command_id: stringValue(resolution.created_command_id ?? resolution.createdCommandId ?? input.createdCommandId) || null,
blocked_reason: stringValue(resolution.blocked_reason ?? resolution.blockedReason ?? input.blockedReason) || null,
matched_intent: stringValue(resolution.matched_intent ?? resolution.matchedIntent ?? input.matchedIntent) || null,
policy_source: stringValue(resolution.policy_source ?? resolution.policySource ?? input.policySource) || null,
reason_codes: parseRouteDecisionStringArray(resolution.reason_codes ?? resolution.reasonCodes ?? input.reasonCodes),
candidate_skills: parseRouteDecisionStringArray(resolution.candidate_skills ?? resolution.candidateSkills ?? input.candidateSkills),
context_refs: objectRecord(resolution.context_refs ?? resolution.contextRefs ?? input.contextRefs) ?? {},
entrypoint: stringValue(resolution.entrypoint ?? input.entrypoint) || null,
actor: stringValue(resolution.actor ?? input.actor) || null,
correlation_id: stringValue(resolution.correlation_id ?? resolution.correlationId ?? input.correlationId) || null,
approval_status: null,
recorded_at: stringValue(resolution.occurred_at ?? resolution.occurredAt ?? input.occurredAt) || new Date().toISOString(),
resolved_at: stringValue(resolution.occurred_at ?? resolution.occurredAt ?? input.occurredAt) || new Date().toISOString(),
resolution_event_id: stringValue(resolution.event_id ?? resolution.eventId) || null,
resolution_duplicate: duplicate,
};
return record;
}
function findRouteInterventionResolution(routeDecisionId: string, approvalId: string | null): Record<string, unknown> | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const rows = db.prepare(`
SELECT id, payload, occurred_at, created_at
FROM digital_events
WHERE kind = 'route_decision_intervention_resolved'
ORDER BY occurred_at DESC, created_at DESC
LIMIT 300
`).all() as Array<Record<string, unknown>>;
for (const row of rows) {
const payload = objectRecord(parseStoredPayload(row.payload));
if (!payload) continue;
const payloadRouteDecisionId = stringValue(payload.route_decision_id ?? payload.routeDecisionId);
const payloadApprovalId = stringValue(payload.approval_id ?? payload.approvalId) || null;
if (payloadRouteDecisionId === routeDecisionId && payloadApprovalId === approvalId) {
return { ...payload, event_id: row.id, occurred_at: row.occurred_at };
}
}
return null;
}
function normalizeRouteInterventionDecision(value: unknown): string {
return stringValue(value).trim().toLowerCase() || "resolved";
}
function isApprovedRouteInterventionDecision(value: string): boolean {
return ["approve", "approved", "accept", "accepted"].includes(value.toLowerCase());
}
function isRejectedRouteInterventionDecision(value: string): boolean {
return ["reject", "rejected", "deny", "denied", "dismiss", "dismissed"].includes(value.toLowerCase());
}
function routeInterventionStatusFromResolution(
decision: string,
commandAccepted: boolean,
commandError: string | null,
): DigitalEmployeeRouteInterventionRecord["intervention_status"] {
if (commandError) return "failed";
if (commandAccepted) return "executed";
if (isRejectedRouteInterventionDecision(decision)) return "rejected";
if (isApprovedRouteInterventionDecision(decision)) return "approved";
return "resolved";
}
function routeInterventionResolutionMessage(decision: string, routeDecisionId: string): string {
if (isRejectedRouteInterventionDecision(decision)) return `入口路由干预已拒绝:${routeDecisionId}`;
if (isApprovedRouteInterventionDecision(decision)) return `入口路由干预已通过:${routeDecisionId}`;
return `入口路由干预已处理:${routeDecisionId}`;
}
export function recordDigitalEmployeeSkillCallAudit(
input: DigitalEmployeeSkillCallAuditInput,
): DigitalEmployeeSkillCallAuditRecord | null {

View File

@@ -1024,6 +1024,19 @@ describe("digital employee sync service", () => {
matched_intent: "schedule",
},
});
insertEventEntity("event-route-intervention", "route_decision_intervention_resolved", {
route_decision_id: "route-1",
approval_id: "route-approval-1",
route_kind: "PlanStepDispatch",
intent_kind: "schedule",
decision: "approved",
status: "executed",
policy_result: "approval_required",
policy_source: "remote",
created_command_id: "governance-command-restore",
command_accepted: true,
reason_codes: ["route_intent_requires_approval"],
});
insertEventEntity("event-approval", "approval_action_recorded", {
approval_id: "approval-1",
action: "mark_risk",
@@ -1272,6 +1285,7 @@ describe("digital employee sync service", () => {
success: true,
acked: [
{ entity_type: "event", entity_id: "event-route" },
{ entity_type: "event", entity_id: "event-route-intervention" },
{ entity_type: "event", entity_id: "event-approval" },
{ entity_type: "event", entity_id: "event-approval-decision" },
{ entity_type: "event", entity_id: "event-artifact" },
@@ -1315,6 +1329,17 @@ describe("digital employee sync service", () => {
matched_intent: "schedule",
attention_level: "action",
});
expect(businessView("event-route-intervention")).toMatchObject({
category: "route",
route_decision_id: "route-1",
approval_id: "route-approval-1",
decision: "approved",
status: "executed",
created_command_id: "governance-command-restore",
policy_source: "remote",
command_accepted: true,
attention_level: "action",
});
expect(businessView("event-approval")).toMatchObject({
category: "approval",
approval_id: "approval-1",

View File

@@ -1491,6 +1491,7 @@ function eventSpecificBusinessView(
): Record<string, unknown> {
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
if (kind === "route_decision_blocked" || kind === "route_decision_approval_required") return routeDecisionPolicyBusinessView(payload);
if (kind === "route_decision_intervention_resolved") return routeDecisionInterventionBusinessView(payload);
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
@@ -1547,6 +1548,26 @@ function routeDecisionPolicyBusinessView(payload: Record<string, unknown>): Reco
};
}
function routeDecisionInterventionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
route_decision_id: stringField(payload.route_decision_id ?? payload.routeDecisionId) || null,
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
route_kind: stringField(payload.route_kind ?? payload.routeKind) || "ChatOnly",
intent_kind: stringField(payload.intent_kind ?? payload.intentKind) || "chat",
decision: stringField(payload.decision) || null,
status: stringField(payload.status) || null,
policy_result: stringField(payload.policy_result ?? payload.policyResult) || null,
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
blocked_reason: stringField(payload.blocked_reason ?? payload.blockedReason) || null,
matched_intent: stringField(payload.matched_intent ?? payload.matchedIntent) || null,
created_command_id: stringField(payload.created_command_id ?? payload.createdCommandId) || null,
command_accepted: payload.command_accepted === true || payload.commandAccepted === true,
command_error: stringField(payload.command_error ?? payload.commandError) || null,
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
attention_level: stringField(payload.command_error ?? payload.commandError) ? "error" : "action",
};
}
function approvalActionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const comment = objectRecord(payload.comment_summary);
const delegate = objectRecord(payload.delegate_summary);
@@ -1774,7 +1795,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
}
function eventBusinessCategory(kind: string): string {
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required") return "route";
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required" || kind === "route_decision_intervention_resolved") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSyncEvent(kind)) return "approval";

View File

@@ -173,9 +173,15 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async listRouteDecisions(options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:listRouteDecisions", options);
},
async listRouteInterventions(options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:listRouteInterventions", options);
},
async recordRouteDecision(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordRouteDecision", input);
},
async recordRouteInterventionResolution(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordRouteInterventionResolution", input);
},
async recordGovernanceCommand(command: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
},

View File

@@ -453,7 +453,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `approval_decision` / `digital_workday_decide_approval` runtime event。approval payload 已支持 `workflow``steps``current_step_id``participants``decision_history`,旧单步审批会自动规范化为单步 workflow多步审批未完成时保持 `pending`,全部通过才进入 `approved`,任一步拒绝进入 `rejected`。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
- 审批跨端更新与回写已开始闭环:客户端可通过 `POST /api/approval/updates/pull` 拉取管理端 `GET /api/digital-employee/approvals/updates` 审批更新,也会在本地处理审批后通过 `POST /api/digital-employee/approvals/decisions` 尝试回写管理端;远端异常会写入 `digital_workday_approval_*` 事件,不阻断本地审批状态推进。
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event命中拒绝会写入 `risk_policy_rejected` event管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
- 入口路由治理策略已沉淀到 UI state 的 `routeDecisionPolicy`,可通过 `POST /api/route-decision/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/route-decision` 下发策略;`/api/ingress/route` 在生成 route decision 后、映射 governance command 前会先评估策略,命中阻断会写入 `route_decision_blocked` event 并停止执行,命中审批会创建 `route_decision_approval_required` approval/event 并等待人工处理,放行时才继续按 `auto_create_governance_commands` 映射命令route decision 与管理端 business view 会携带 `policy_result``policy_source``blocked_reason``approval_id``matched_intent`
- 入口路由治理策略已沉淀到 UI state 的 `routeDecisionPolicy`,可通过 `POST /api/route-decision/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/route-decision` 下发策略;`/api/ingress/route` 在生成 route decision 后、映射 governance command 前会先评估策略,命中阻断会写入 `route_decision_blocked` event 并停止执行,命中审批会创建 `route_decision_approval_required` approval/event 并等待人工处理,放行时才继续按 `auto_create_governance_commands` 映射命令route decision 与管理端 business view 会携带 `policy_result``policy_source``blocked_reason``approval_id``matched_intent`embedded 已新增 `/api/route-decisions/interventions``/api/route-decisions/:routeDecisionId/intervention`,审批通过后会按 `approval_id + route_decision_id` 幂等恢复对应 governance command处理结果写入 `route_decision_intervention_resolved`
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan``submit_plan``approve_plan``activate_plan``create_schedule``run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
- 任务中心和委托详情不再使用 sgRobot demo mission fallback没有 qimingclaw Plan / Task / Run / Event 时展示真实空态或错误态,避免示例任务冒充本地任务记录。
@@ -598,9 +598,9 @@ GET /api/digital-employee/approvals
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision入口路由策略可从管理端远端下发支持按 intent 阻断、按 intent 要求审批、限制自动创建 governance command并在策略干预时先记录 route decision 再停止本地命令执行scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要、策略来源/拉取错误、阻断/待审计数和通知联动已开始闭环。
- 后续缺口:管理端正式路由 UI、路由策略下发和跨端路由干预入口仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和审批处理回写,把入口路由从跨端治理策略推进到可运营的管理端干预台。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision入口路由策略可从管理端远端下发支持按 intent 阻断、按 intent 要求审批、限制自动创建 governance command并在策略干预时先记录 route decision 再停止本地命令执行;首页已展示路由干预队列,审批通过/拒绝会写入本地 approval decision 并回写管理端,管理端审批更新拉取后会自动扫描已批准 route approval 并按幂等规则恢复执行;scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要、策略来源/拉取错误、阻断/待审/待干预/已恢复计数和通知联动已开始闭环。
- 后续缺口:管理端正式路由 UI、跨设备路由干预冲突可视化和批量干预入口仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把入口路由从客户端可运营干预推进到管理端运营台。
11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成只读诊断、估算成本和审计投影;数字员工首页已展示诊断/审计/成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。