吸收数字员工入口路由决策
This commit is contained in:
@@ -22,9 +22,13 @@ import type {
|
|||||||
FlowResponse,
|
FlowResponse,
|
||||||
GovernanceCommandRequest,
|
GovernanceCommandRequest,
|
||||||
GovernanceCommandResponse,
|
GovernanceCommandResponse,
|
||||||
|
IngressRouteRequest,
|
||||||
|
IngressRouteResponse,
|
||||||
MemoryEntry,
|
MemoryEntry,
|
||||||
PlanDispatchResponse,
|
PlanDispatchResponse,
|
||||||
PlanStepSummary,
|
PlanStepSummary,
|
||||||
|
RouteDecisionRecord,
|
||||||
|
RouteDecisionTimelineResponse,
|
||||||
SkillSpec,
|
SkillSpec,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ declare global {
|
|||||||
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
||||||
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
|
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
|
||||||
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
||||||
|
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
|
||||||
|
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||||
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
||||||
};
|
};
|
||||||
@@ -189,6 +195,27 @@ interface QimingclawGovernanceCommandRecord {
|
|||||||
result?: Record<string, unknown> | null;
|
result?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QimingclawRouteDecisionInput {
|
||||||
|
id?: string | null;
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
correlationId?: string | null;
|
||||||
|
routeKind: string;
|
||||||
|
intentKind: string;
|
||||||
|
reasonCodes?: string[];
|
||||||
|
candidateSkills?: string[];
|
||||||
|
contextRefs?: Record<string, unknown>;
|
||||||
|
createdCommandId?: string | null;
|
||||||
|
createsPlan?: boolean;
|
||||||
|
createsTaskRun?: boolean;
|
||||||
|
approvalMode?: string | null;
|
||||||
|
policyResult?: string | null;
|
||||||
|
entrypoint?: string | null;
|
||||||
|
actor?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
envelope?: Record<string, unknown> | null;
|
||||||
|
recordedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface QimingclawRuntimeRecords {
|
interface QimingclawRuntimeRecords {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
plans: QimingclawPlanRecord[];
|
plans: QimingclawPlanRecord[];
|
||||||
@@ -552,6 +579,12 @@ export async function handleQimingclawDigitalApi<T>(
|
|||||||
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
|
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
|
||||||
return await handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
|
return await handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||||
}
|
}
|
||||||
|
if (method === 'GET' && pathname === '/api/route-decisions') {
|
||||||
|
return await readRouteDecisions(Number(url.searchParams.get('limit')) || 80) as T;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && pathname === '/api/ingress/route') {
|
||||||
|
return await handleIngressRoute(parseJsonBody<IngressRouteRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||||
|
}
|
||||||
if (method === 'POST' && pathname === '/api/governance/command') {
|
if (method === 'POST' && pathname === '/api/governance/command') {
|
||||||
return await handleGovernanceCommand(parseJsonBody<GovernanceCommandRequest>(options.body), await readQimingclawSnapshot()) as T;
|
return await handleGovernanceCommand(parseJsonBody<GovernanceCommandRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||||
}
|
}
|
||||||
@@ -2582,6 +2615,189 @@ function acpResponseForDecision(decision: string): 'once' | 'always' | 'reject'
|
|||||||
return 'once';
|
return 'once';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readRouteDecisions(limit = 80): Promise<RouteDecisionTimelineResponse> {
|
||||||
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
|
if (!bridge?.listRouteDecisions) {
|
||||||
|
return { has_route_decision_state: false, decisions: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decisions = await bridge.listRouteDecisions({ limit });
|
||||||
|
return { has_route_decision_state: true, decisions: decisions ?? [] };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[qimingclawAdapter] list route decisions failed:', error);
|
||||||
|
return { has_route_decision_state: false, decisions: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIngressRoute(
|
||||||
|
body: IngressRouteRequest,
|
||||||
|
snapshot: QimingclawSnapshot | null,
|
||||||
|
): Promise<IngressRouteResponse> {
|
||||||
|
const recordedAt = body.timestamp || new Date().toISOString();
|
||||||
|
const correlationId = body.correlation_id || `route-${Date.now()}`;
|
||||||
|
const idSource = body.idempotency_key || correlationId;
|
||||||
|
const contextRefs = buildIngressContextRefs(body);
|
||||||
|
const classification = classifyIngressRoute(body, contextRefs, snapshot);
|
||||||
|
const decision: RouteDecisionRecord = {
|
||||||
|
id: `route-decision-${slugifyIdPart(idSource)}`,
|
||||||
|
route_kind: classification.routeKind,
|
||||||
|
intent_kind: classification.intentKind,
|
||||||
|
reason_codes: classification.reasonCodes,
|
||||||
|
candidate_skills: classification.candidateSkills,
|
||||||
|
context_refs: contextRefs,
|
||||||
|
created_command_id: null,
|
||||||
|
correlation_id: correlationId,
|
||||||
|
creates_plan: classification.createsPlan,
|
||||||
|
creates_task_run: classification.createsTaskRun,
|
||||||
|
approval_mode: null,
|
||||||
|
policy_result: 'accepted',
|
||||||
|
recorded_at: recordedAt,
|
||||||
|
entrypoint: body.entrypoint ?? null,
|
||||||
|
actor: body.actor ?? null,
|
||||||
|
};
|
||||||
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
|
const recorded = bridge?.recordRouteDecision
|
||||||
|
? await bridge.recordRouteDecision({
|
||||||
|
id: decision.id,
|
||||||
|
idempotencyKey: body.idempotency_key,
|
||||||
|
correlationId,
|
||||||
|
routeKind: decision.route_kind,
|
||||||
|
intentKind: decision.intent_kind,
|
||||||
|
reasonCodes: decision.reason_codes,
|
||||||
|
candidateSkills: decision.candidate_skills,
|
||||||
|
contextRefs: decision.context_refs,
|
||||||
|
createdCommandId: decision.created_command_id,
|
||||||
|
createsPlan: decision.creates_plan,
|
||||||
|
createsTaskRun: decision.creates_task_run,
|
||||||
|
approvalMode: decision.approval_mode,
|
||||||
|
policyResult: decision.policy_result,
|
||||||
|
entrypoint: decision.entrypoint,
|
||||||
|
actor: decision.actor,
|
||||||
|
message: `入口路由决策:${decision.route_kind} / ${decision.intent_kind}`,
|
||||||
|
envelope: {
|
||||||
|
text: body.text ?? null,
|
||||||
|
payload: body.payload ?? null,
|
||||||
|
attachments: body.attachments ?? [],
|
||||||
|
},
|
||||||
|
recordedAt,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const routeDecision = recorded ?? decision;
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
envelope: {
|
||||||
|
entrypoint: body.entrypoint ?? 'chat',
|
||||||
|
text: body.text ?? null,
|
||||||
|
payload: body.payload ?? null,
|
||||||
|
attachments: body.attachments ?? [],
|
||||||
|
context_refs: contextRefs,
|
||||||
|
},
|
||||||
|
response_kind: 'route_decision',
|
||||||
|
route_decision: routeDecision,
|
||||||
|
command: null,
|
||||||
|
created_plan_id: null,
|
||||||
|
created_schedule_id: null,
|
||||||
|
created_task_ids: [],
|
||||||
|
created_run_ids: [],
|
||||||
|
projection_name: null,
|
||||||
|
projection_payload: null,
|
||||||
|
message: `已记录入口路由:${routeDecision.route_kind} / ${routeDecision.intent_kind}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIngressContextRefs(body: IngressRouteRequest): Record<string, unknown> {
|
||||||
|
const payload = asRecord(body.payload) ?? {};
|
||||||
|
const context = asRecord(body.context_refs) ?? {};
|
||||||
|
return compactRecord({
|
||||||
|
...context,
|
||||||
|
entrypoint: body.entrypoint ?? context.entrypoint,
|
||||||
|
plan_id: stringValue(context.plan_id) || stringValue(payload.plan_id) || stringValue(payload.planId),
|
||||||
|
task_id: stringValue(context.task_id) || stringValue(payload.task_id) || stringValue(payload.taskId),
|
||||||
|
run_id: stringValue(context.run_id) || stringValue(payload.run_id) || stringValue(payload.runId),
|
||||||
|
step_id: stringValue(context.step_id) || stringValue(payload.step_id) || stringValue(payload.stepId),
|
||||||
|
schedule_id: stringValue(context.schedule_id) || stringValue(payload.schedule_id) || stringValue(payload.scheduleId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyIngressRoute(
|
||||||
|
body: IngressRouteRequest,
|
||||||
|
contextRefs: Record<string, unknown>,
|
||||||
|
snapshot: QimingclawSnapshot | null,
|
||||||
|
): { routeKind: string; intentKind: string; reasonCodes: string[]; candidateSkills: string[]; createsPlan: boolean; createsTaskRun: boolean } {
|
||||||
|
const payload = asRecord(body.payload) ?? {};
|
||||||
|
const text = `${body.text ?? ''} ${stringValue(payload.action)} ${stringValue(payload.command)} ${stringValue(payload.intent)}`.toLowerCase();
|
||||||
|
const hasPayload = Object.keys(payload).length > 0;
|
||||||
|
const hasContext = ['plan_id', 'task_id', 'step_id', 'schedule_id'].some((key) => Boolean(stringValue(contextRefs[key])));
|
||||||
|
const stepId = stringValue(contextRefs.step_id);
|
||||||
|
const readyStep = stepId && runtimePlanSteps(snapshot).some((step) => step.id === stepId && step.status.toLowerCase() === 'ready');
|
||||||
|
|
||||||
|
if (hasContext && matchesRouteText(text, ['start', 'run', 'retry', 'skip', 'cancel', 'pause', 'resume', 'approve', '启动', '开始', '执行', '重试', '跳过', '取消', '暂停', '恢复', '批准'])) {
|
||||||
|
return routeClassification('PlanControl', 'control', ['context_control_action'], contextRefs, payload, false, false);
|
||||||
|
}
|
||||||
|
if (body.entrypoint === 'cron' || matchesRouteText(text, ['cron', 'schedule', 'scheduled', '定时', '调度', '周期'])) {
|
||||||
|
return routeClassification('PlanExecution', 'schedule', ['scheduled_entrypoint'], contextRefs, payload, false, true);
|
||||||
|
}
|
||||||
|
if (matchesRouteText(text, ['status', 'progress', 'result', 'report', 'artifact', 'memory', '状态', '进度', '结果', '日报', '产物', '记忆'])) {
|
||||||
|
return routeClassification('ProjectionQuery', 'query', ['projection_query_text'], contextRefs, payload, false, false);
|
||||||
|
}
|
||||||
|
if (readyStep || matchesRouteText(text, ['execute', 'dispatch', 'delegate', 'run task', '执行', '开始', '委托', '派发'])) {
|
||||||
|
return routeClassification('PlanExecution', 'execute', readyStep ? ['ready_step_context'] : ['execution_text'], contextRefs, payload, false, true);
|
||||||
|
}
|
||||||
|
if (!body.text && !hasPayload && !hasContext) {
|
||||||
|
return routeClassification('AskClarification', 'chat', ['empty_ingress'], contextRefs, payload, false, false);
|
||||||
|
}
|
||||||
|
return routeClassification('ChatOnly', 'chat', ['default_chat'], contextRefs, payload, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeClassification(
|
||||||
|
routeKind: string,
|
||||||
|
intentKind: string,
|
||||||
|
reasonCodes: string[],
|
||||||
|
contextRefs: Record<string, unknown>,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
createsPlan: boolean,
|
||||||
|
createsTaskRun: boolean,
|
||||||
|
): { routeKind: string; intentKind: string; reasonCodes: string[]; candidateSkills: string[]; createsPlan: boolean; createsTaskRun: boolean } {
|
||||||
|
return {
|
||||||
|
routeKind,
|
||||||
|
intentKind,
|
||||||
|
reasonCodes,
|
||||||
|
candidateSkills: routeCandidateSkills(routeKind, contextRefs, payload),
|
||||||
|
createsPlan,
|
||||||
|
createsTaskRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeCandidateSkills(routeKind: string, contextRefs: Record<string, unknown>, payload: Record<string, unknown>): string[] {
|
||||||
|
const explicit = uniqueStrings([
|
||||||
|
...arrayValue(contextRefs.skill_ids),
|
||||||
|
...arrayValue(contextRefs.skills),
|
||||||
|
...arrayValue(contextRefs.candidate_skills),
|
||||||
|
...arrayValue(payload.skill_ids),
|
||||||
|
...arrayValue(payload.skills),
|
||||||
|
...arrayValue(payload.candidate_skills),
|
||||||
|
stringValue(contextRefs.skill_id),
|
||||||
|
stringValue(payload.skill_id),
|
||||||
|
]);
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
if (routeKind === 'PlanControl') return ['qimingclaw-task-agent'];
|
||||||
|
if (routeKind === 'ProjectionQuery') return ['qimingclaw-artifact-report'];
|
||||||
|
if (routeKind === 'PlanExecution') return ['qimingclaw-computer-control'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: unknown[]): string[] {
|
||||||
|
return [...new Set(values.map(stringValue).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRouteText(text: string, keywords: string[]): boolean {
|
||||||
|
return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactRecord(record: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGovernanceCommand(
|
async function handleGovernanceCommand(
|
||||||
body: GovernanceCommandRequest,
|
body: GovernanceCommandRequest,
|
||||||
snapshot: QimingclawSnapshot | null,
|
snapshot: QimingclawSnapshot | null,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
|||||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="./assets/index-CRHKnsH7.js"></script>
|
<script type="module" crossorigin src="./assets/index-BflGTsfz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ import {
|
|||||||
upsertDigitalEmployeeMemory,
|
upsertDigitalEmployeeMemory,
|
||||||
deleteDigitalEmployeeMemory,
|
deleteDigitalEmployeeMemory,
|
||||||
syncQimingclawMemoryEntries,
|
syncQimingclawMemoryEntries,
|
||||||
|
recordDigitalEmployeeRouteDecision,
|
||||||
|
listDigitalEmployeeRouteDecisions,
|
||||||
type DigitalEmployeeManagedService,
|
type DigitalEmployeeManagedService,
|
||||||
type DigitalEmployeeGovernanceCommandRecord,
|
type DigitalEmployeeGovernanceCommandRecord,
|
||||||
type DigitalEmployeeMemoryUpsertInput,
|
type DigitalEmployeeMemoryUpsertInput,
|
||||||
|
type DigitalEmployeeRouteDecisionInput,
|
||||||
type DigitalEmployeeServiceStatus,
|
type DigitalEmployeeServiceStatus,
|
||||||
type DigitalEmployeeSnapshot,
|
type DigitalEmployeeSnapshot,
|
||||||
type DigitalEmployeeUiStateUpdate,
|
type DigitalEmployeeUiStateUpdate,
|
||||||
@@ -155,6 +158,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return readDigitalEmployeeScheduleRuns(scheduleId, limit ?? 20);
|
return readDigitalEmployeeScheduleRuns(scheduleId, limit ?? 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:listRouteDecisions", async (_, options?: { limit?: number }) => {
|
||||||
|
return listDigitalEmployeeRouteDecisions(options ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:recordRouteDecision", async (_, input: DigitalEmployeeRouteDecisionInput) => {
|
||||||
|
return recordDigitalEmployeeRouteDecision(input);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"digitalEmployee:recordGovernanceCommand",
|
"digitalEmployee:recordGovernanceCommand",
|
||||||
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||||
|
|||||||
@@ -83,6 +83,91 @@ describe("digital employee state service", () => {
|
|||||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records and lists route decisions as syncable events", async () => {
|
||||||
|
const {
|
||||||
|
listDigitalEmployeeRouteDecisions,
|
||||||
|
recordDigitalEmployeeRouteDecision,
|
||||||
|
} = await import("./stateService");
|
||||||
|
|
||||||
|
const first = recordDigitalEmployeeRouteDecision({
|
||||||
|
idempotencyKey: "route-key-1",
|
||||||
|
correlationId: "corr-1",
|
||||||
|
routeKind: "PlanExecution",
|
||||||
|
intentKind: "execute",
|
||||||
|
reasonCodes: ["ready_step_context"],
|
||||||
|
candidateSkills: ["qimingclaw-computer-control"],
|
||||||
|
contextRefs: { plan_id: "plan-1", step_id: "step-ready" },
|
||||||
|
createsTaskRun: true,
|
||||||
|
entrypoint: "task_center",
|
||||||
|
actor: "operator",
|
||||||
|
envelope: { text: "开始执行这个步骤" },
|
||||||
|
recordedAt: "2026-06-07T08:01:00.000Z",
|
||||||
|
});
|
||||||
|
const second = recordDigitalEmployeeRouteDecision({
|
||||||
|
idempotencyKey: "route-key-2",
|
||||||
|
correlationId: "corr-2",
|
||||||
|
routeKind: "ProjectionQuery",
|
||||||
|
intentKind: "query",
|
||||||
|
reasonCodes: ["projection_query_text"],
|
||||||
|
candidateSkills: ["qimingclaw-artifact-report"],
|
||||||
|
contextRefs: { plan_id: "plan-1" },
|
||||||
|
recordedAt: "2026-06-07T08:02:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
id: "route-decision:route-key-1",
|
||||||
|
route_kind: "PlanExecution",
|
||||||
|
creates_task_run: true,
|
||||||
|
context_refs: { step_id: "step-ready" },
|
||||||
|
});
|
||||||
|
expect(mockState.db?.events.get(first.id)).toMatchObject({
|
||||||
|
kind: "route_decision",
|
||||||
|
run_id: null,
|
||||||
|
plan_id: null,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(mockState.db?.events.get(first.id)?.payload ?? "{}")).toMatchObject({
|
||||||
|
source: "qimingclaw-route-decision",
|
||||||
|
routeDecision: {
|
||||||
|
id: first.id,
|
||||||
|
route_kind: "PlanExecution",
|
||||||
|
intent_kind: "execute",
|
||||||
|
},
|
||||||
|
envelope: { text: "开始执行这个步骤" },
|
||||||
|
});
|
||||||
|
expect(mockState.db?.outbox.get(`event:insert:${first.id}`)).toMatchObject({
|
||||||
|
entity_type: "event",
|
||||||
|
operation: "insert",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
expect(listDigitalEmployeeRouteDecisions()).toEqual([
|
||||||
|
expect.objectContaining({ id: second.id, route_kind: "ProjectionQuery" }),
|
||||||
|
expect.objectContaining({ id: first.id, route_kind: "PlanExecution" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps route decision ids stable for idempotency keys", async () => {
|
||||||
|
const { listDigitalEmployeeRouteDecisions, recordDigitalEmployeeRouteDecision } = await import("./stateService");
|
||||||
|
|
||||||
|
const first = recordDigitalEmployeeRouteDecision({
|
||||||
|
idempotencyKey: "repeat-route-key",
|
||||||
|
routeKind: "ChatOnly",
|
||||||
|
intentKind: "chat",
|
||||||
|
recordedAt: "2026-06-07T08:01:00.000Z",
|
||||||
|
});
|
||||||
|
const second = recordDigitalEmployeeRouteDecision({
|
||||||
|
idempotencyKey: "repeat-route-key",
|
||||||
|
routeKind: "PlanExecution",
|
||||||
|
intentKind: "execute",
|
||||||
|
recordedAt: "2026-06-07T08:02:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.id).toBe(first.id);
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? []).filter((event) => event.kind === "route_decision")).toHaveLength(1);
|
||||||
|
expect(listDigitalEmployeeRouteDecisions()).toEqual([
|
||||||
|
expect.objectContaining({ id: first.id, route_kind: "ChatOnly" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates formal approval records when UI approval decisions are saved", async () => {
|
it("updates formal approval records when UI approval decisions are saved", async () => {
|
||||||
const { saveDigitalEmployeeUiState } = await import("./stateService");
|
const { saveDigitalEmployeeUiState } = await import("./stateService");
|
||||||
mockState.db?.approvals.set("approval-1", {
|
mockState.db?.approvals.set("approval-1", {
|
||||||
@@ -1166,7 +1251,13 @@ class TestDb {
|
|||||||
if (sql.includes("FROM digital_schedule_runs")) return Array.from(this.scheduleRuns.values());
|
if (sql.includes("FROM digital_schedule_runs")) return Array.from(this.scheduleRuns.values());
|
||||||
if (sql.includes("FROM digital_tasks")) return Array.from(this.tasks.values());
|
if (sql.includes("FROM digital_tasks")) return Array.from(this.tasks.values());
|
||||||
if (sql.includes("FROM digital_runs")) return Array.from(this.runs.values());
|
if (sql.includes("FROM digital_runs")) return Array.from(this.runs.values());
|
||||||
if (sql.includes("FROM digital_events")) return Array.from(this.events.values());
|
if (sql.includes("FROM digital_events")) {
|
||||||
|
const rows = Array.from(this.events.values());
|
||||||
|
const filtered = sql.includes("WHERE kind = 'route_decision'")
|
||||||
|
? rows.filter((event) => event.kind === "route_decision")
|
||||||
|
: rows;
|
||||||
|
return filtered.sort((left, right) => right.occurred_at.localeCompare(left.occurred_at) || right.created_at.localeCompare(left.created_at));
|
||||||
|
}
|
||||||
if (sql.includes("FROM digital_artifacts")) return Array.from(this.artifacts.values());
|
if (sql.includes("FROM digital_artifacts")) return Array.from(this.artifacts.values());
|
||||||
if (sql.includes("FROM digital_approvals")) return Array.from(this.approvals.values());
|
if (sql.includes("FROM digital_approvals")) return Array.from(this.approvals.values());
|
||||||
if (sql.includes("FROM digital_memories")) return Array.from(this.memories.values());
|
if (sql.includes("FROM digital_memories")) return Array.from(this.memories.values());
|
||||||
|
|||||||
@@ -186,6 +186,45 @@ export interface DigitalEmployeeEventRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeRouteDecisionRecord {
|
||||||
|
id: string;
|
||||||
|
route_kind: string;
|
||||||
|
intent_kind: string;
|
||||||
|
reason_codes: string[];
|
||||||
|
candidate_skills: string[];
|
||||||
|
context_refs: Record<string, unknown>;
|
||||||
|
created_command_id: string | null;
|
||||||
|
correlation_id: string;
|
||||||
|
creates_plan: boolean;
|
||||||
|
creates_task_run: boolean;
|
||||||
|
approval_mode: string | null;
|
||||||
|
policy_result: string | null;
|
||||||
|
recorded_at: string;
|
||||||
|
entrypoint?: string | null;
|
||||||
|
actor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeRouteDecisionInput {
|
||||||
|
id?: string | null;
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
correlationId?: string | null;
|
||||||
|
routeKind: string;
|
||||||
|
intentKind: string;
|
||||||
|
reasonCodes?: string[];
|
||||||
|
candidateSkills?: string[];
|
||||||
|
contextRefs?: Record<string, unknown>;
|
||||||
|
createdCommandId?: string | null;
|
||||||
|
createsPlan?: boolean;
|
||||||
|
createsTaskRun?: boolean;
|
||||||
|
approvalMode?: string | null;
|
||||||
|
policyResult?: string | null;
|
||||||
|
entrypoint?: string | null;
|
||||||
|
actor?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
envelope?: Record<string, unknown> | null;
|
||||||
|
recordedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeeArtifactRecord {
|
export interface DigitalEmployeeArtifactRecord {
|
||||||
id: string;
|
id: string;
|
||||||
remoteId?: string | null;
|
remoteId?: string | null;
|
||||||
@@ -773,6 +812,96 @@ export function readDigitalEmployeeRuntimeRecords(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordDigitalEmployeeRouteDecision(
|
||||||
|
input: DigitalEmployeeRouteDecisionInput,
|
||||||
|
): DigitalEmployeeRouteDecisionRecord {
|
||||||
|
const now = input.recordedAt || new Date().toISOString();
|
||||||
|
const idSource = stringValue(input.idempotencyKey) || stringValue(input.correlationId) || stringValue(input.id) || `${Date.now()}`;
|
||||||
|
const id = stringValue(input.id) || `route-decision:${safeId(idSource)}`;
|
||||||
|
const correlationId = stringValue(input.correlationId) || stringValue(input.idempotencyKey) || id;
|
||||||
|
const routeDecision: DigitalEmployeeRouteDecisionRecord = {
|
||||||
|
id,
|
||||||
|
route_kind: stringValue(input.routeKind) || "ChatOnly",
|
||||||
|
intent_kind: stringValue(input.intentKind) || "chat",
|
||||||
|
reason_codes: normalizeSkillIds(input.reasonCodes ?? []),
|
||||||
|
candidate_skills: normalizeSkillIds(input.candidateSkills ?? []),
|
||||||
|
context_refs: objectRecord(input.contextRefs) ?? {},
|
||||||
|
created_command_id: stringValue(input.createdCommandId) || null,
|
||||||
|
correlation_id: correlationId,
|
||||||
|
creates_plan: Boolean(input.createsPlan),
|
||||||
|
creates_task_run: Boolean(input.createsTaskRun),
|
||||||
|
approval_mode: stringValue(input.approvalMode) || null,
|
||||||
|
policy_result: stringValue(input.policyResult) || null,
|
||||||
|
recorded_at: now,
|
||||||
|
entrypoint: stringValue(input.entrypoint) || null,
|
||||||
|
actor: stringValue(input.actor) || null,
|
||||||
|
};
|
||||||
|
const db = getDigitalEmployeeDb();
|
||||||
|
if (!db) return routeDecision;
|
||||||
|
insertEvent(
|
||||||
|
{
|
||||||
|
event_id: id,
|
||||||
|
kind: "route_decision",
|
||||||
|
occurred_at: now,
|
||||||
|
message: input.message || routeDecisionMessage(routeDecision),
|
||||||
|
plan_id: null,
|
||||||
|
task_id: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
source: "qimingclaw-route-decision",
|
||||||
|
routeDecision,
|
||||||
|
envelope: objectRecord(input.envelope) ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return routeDecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDigitalEmployeeRouteDecisions(options: { limit?: number } = {}): DigitalEmployeeRouteDecisionRecord[] {
|
||||||
|
const db = getDigitalEmployeeDb();
|
||||||
|
if (!db) return [];
|
||||||
|
const safeLimit = Math.max(1, Math.min(Math.floor(options.limit ?? 80), 300));
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT payload
|
||||||
|
FROM digital_events
|
||||||
|
WHERE kind = 'route_decision'
|
||||||
|
ORDER BY occurred_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
return rows
|
||||||
|
.map((row) => routeDecisionFromEventPayload(parseStoredPayload(row.payload)))
|
||||||
|
.filter((decision): decision is DigitalEmployeeRouteDecisionRecord => Boolean(decision));
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeDecisionFromEventPayload(payload: unknown): DigitalEmployeeRouteDecisionRecord | null {
|
||||||
|
const record = objectRecord(payload);
|
||||||
|
const decision = objectRecord(record?.routeDecision ?? record?.route_decision ?? payload);
|
||||||
|
if (!decision) return null;
|
||||||
|
const id = stringValue(decision.id);
|
||||||
|
if (!id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
route_kind: stringValue(decision.route_kind ?? decision.routeKind) || "ChatOnly",
|
||||||
|
intent_kind: stringValue(decision.intent_kind ?? decision.intentKind) || "chat",
|
||||||
|
reason_codes: parseStringArray(decision.reason_codes ?? decision.reasonCodes),
|
||||||
|
candidate_skills: parseStringArray(decision.candidate_skills ?? decision.candidateSkills),
|
||||||
|
context_refs: objectRecord(decision.context_refs ?? decision.contextRefs) ?? {},
|
||||||
|
created_command_id: stringValue(decision.created_command_id ?? decision.createdCommandId) || null,
|
||||||
|
correlation_id: stringValue(decision.correlation_id ?? decision.correlationId) || id,
|
||||||
|
creates_plan: Boolean(decision.creates_plan ?? decision.createsPlan),
|
||||||
|
creates_task_run: Boolean(decision.creates_task_run ?? decision.createsTaskRun),
|
||||||
|
approval_mode: stringValue(decision.approval_mode ?? decision.approvalMode) || null,
|
||||||
|
policy_result: stringValue(decision.policy_result ?? decision.policyResult) || null,
|
||||||
|
recorded_at: stringValue(decision.recorded_at ?? decision.recordedAt) || new Date().toISOString(),
|
||||||
|
entrypoint: stringValue(decision.entrypoint) || null,
|
||||||
|
actor: stringValue(decision.actor) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeDecisionMessage(decision: DigitalEmployeeRouteDecisionRecord): string {
|
||||||
|
return `入口路由决策:${decision.route_kind} / ${decision.intent_kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function listDigitalEmployeeMemories(options: {
|
export function listDigitalEmployeeMemories(options: {
|
||||||
query?: string | null;
|
query?: string | null;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async getScheduleRuns(scheduleId: string, limit?: number) {
|
async getScheduleRuns(scheduleId: string, limit?: number) {
|
||||||
return ipcRenderer.invoke("digitalEmployee:getScheduleRuns", scheduleId, limit);
|
return ipcRenderer.invoke("digitalEmployee:getScheduleRuns", scheduleId, limit);
|
||||||
},
|
},
|
||||||
|
async listRouteDecisions(options?: unknown) {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:listRouteDecisions", options);
|
||||||
|
},
|
||||||
|
async recordRouteDecision(input: unknown) {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:recordRouteDecision", input);
|
||||||
|
},
|
||||||
async recordGovernanceCommand(command: unknown) {
|
async recordGovernanceCommand(command: unknown) {
|
||||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -586,10 +586,11 @@ GET /api/digital-employee/approvals
|
|||||||
- 缺口:sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply 尚未由 qimingclaw adapter 接管;没有统一数字员工 Inbox。
|
- 缺口:sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply 尚未由 qimingclaw adapter 接管;没有统一数字员工 Inbox。
|
||||||
- 建议:把 approval、同步失败、服务异常和任务完成统一投射为通知。
|
- 建议:把 approval、同步失败、服务异常和任务完成统一投射为通知。
|
||||||
|
|
||||||
10. **入口路由 / 任务分流**
|
10. **入口路由 / 任务分流(路由决策事件闭环已开始)**
|
||||||
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实。
|
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实;embedded `/api/ingress/route` 和 `/api/route-decisions` 已由 qimingclaw adapter 接管。
|
||||||
- 缺口:`/api/ingress/route`、`/api/route-decisions` 类能力未吸收;缺少按任务类型、技能、风险、上下文自动路由到 Plan/Task/Skill 的决策记录。
|
- 当前能力:入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision,并写入 `digital_events.kind = route_decision` 与现有 event outbox;路由时间线优先从 qimingclaw 本地事件读取。
|
||||||
- 建议:先把每次远程任务的路由决策写成 Event,再补可视化时间线。
|
- 后续缺口:基于路由决策自动生成治理命令、ready PlanStep 安全执行触发、管理端路由时间线视图和风险策略下发仍待吸收。
|
||||||
|
- 建议:下一轮围绕 action endpoint,把低风险 route decision 映射为明确的 governance command。
|
||||||
|
|
||||||
11. **诊断 / 成本 / 审计视图**
|
11. **诊断 / 成本 / 审计视图**
|
||||||
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在。
|
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在。
|
||||||
|
|||||||
Reference in New Issue
Block a user