吸收数字员工入口路由决策
This commit is contained in:
@@ -21,9 +21,12 @@ import {
|
||||
upsertDigitalEmployeeMemory,
|
||||
deleteDigitalEmployeeMemory,
|
||||
syncQimingclawMemoryEntries,
|
||||
recordDigitalEmployeeRouteDecision,
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
type DigitalEmployeeManagedService,
|
||||
type DigitalEmployeeGovernanceCommandRecord,
|
||||
type DigitalEmployeeMemoryUpsertInput,
|
||||
type DigitalEmployeeRouteDecisionInput,
|
||||
type DigitalEmployeeServiceStatus,
|
||||
type DigitalEmployeeSnapshot,
|
||||
type DigitalEmployeeUiStateUpdate,
|
||||
@@ -155,6 +158,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
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(
|
||||
"digitalEmployee:recordGovernanceCommand",
|
||||
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||
|
||||
@@ -83,6 +83,91 @@ describe("digital employee state service", () => {
|
||||
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 () => {
|
||||
const { saveDigitalEmployeeUiState } = await import("./stateService");
|
||||
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_tasks")) return Array.from(this.tasks.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_approvals")) return Array.from(this.approvals.values());
|
||||
if (sql.includes("FROM digital_memories")) return Array.from(this.memories.values());
|
||||
|
||||
@@ -186,6 +186,45 @@ export interface DigitalEmployeeEventRecord {
|
||||
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 {
|
||||
id: string;
|
||||
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: {
|
||||
query?: string | null;
|
||||
category?: string | null;
|
||||
|
||||
@@ -140,6 +140,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async getScheduleRuns(scheduleId: string, limit?: number) {
|
||||
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) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user