吸收数字员工入口路由干预回写
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user