吸收数字员工步骤治理闭环
This commit is contained in:
@@ -425,8 +425,241 @@ describe("digital employee state service", () => {
|
||||
scheduleRuns: [expect.objectContaining({ scheduleId: "schedule-morning", triggerKind: "manual" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies skip governance to the real task, plan step, event, and outbox", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
|
||||
const ids = recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "skip_task",
|
||||
commandId: "command-skip-task",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
message: "跳过当前任务",
|
||||
payload: { reason: "用户选择跳过" },
|
||||
result: { task_id: "task-governance", run_id: "run-governance", step_id: "step-governance" },
|
||||
});
|
||||
|
||||
expect(ids).toEqual({
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
});
|
||||
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({
|
||||
status: "skipped",
|
||||
sync_status: "pending",
|
||||
});
|
||||
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({
|
||||
status: "skipped",
|
||||
sync_status: "pending",
|
||||
});
|
||||
expect(mockState.db?.runs.get("run-governance")).toMatchObject({
|
||||
status: "skipped",
|
||||
finished_at: "2026-06-07T08:00:00.000Z",
|
||||
sync_status: "pending",
|
||||
});
|
||||
expect(JSON.parse(mockState.db?.tasks.get("task-governance")?.payload ?? "{}")).toMatchObject({
|
||||
lastGovernanceCommand: {
|
||||
commandKind: "skip_task",
|
||||
taskId: "task-governance",
|
||||
stepId: "step-governance",
|
||||
status: "skipped",
|
||||
},
|
||||
});
|
||||
expect(mockState.db?.events.get("run-governance:skip_task:command-skip-task")).toMatchObject({
|
||||
kind: "governance_skip_task",
|
||||
message: "跳过当前任务",
|
||||
task_id: "task-governance",
|
||||
});
|
||||
expect(mockState.db?.outbox.get("task:upsert:task-governance")).toMatchObject({ entity_type: "task" });
|
||||
expect(mockState.db?.outbox.get("plan_step:upsert:step-governance")).toMatchObject({ entity_type: "plan_step" });
|
||||
expect(mockState.db?.outbox.get("run:upsert:run-governance")).toMatchObject({ entity_type: "run" });
|
||||
});
|
||||
|
||||
it("creates a retry run and preserves the failed source run", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask({ taskStatus: "failed", runStatus: "failed", finishedAt: "2026-06-07T07:30:00.000Z" });
|
||||
|
||||
const ids = recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "retry_task",
|
||||
commandId: "command-retry-task",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "失败后重试", prompt: "重新执行任务" },
|
||||
result: { task_id: "task-governance", run_id: "run-governance", step_id: "step-governance" },
|
||||
});
|
||||
|
||||
expect(ids.runId).toBe("task-governance:run:retry_task:command-retry-task");
|
||||
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.runs.get("run-governance")).toMatchObject({
|
||||
status: "failed",
|
||||
finished_at: "2026-06-07T07:30:00.000Z",
|
||||
});
|
||||
expect(mockState.db?.runs.get(ids.runId)).toMatchObject({
|
||||
plan_id: "plan-governance",
|
||||
task_id: "task-governance",
|
||||
status: "running",
|
||||
finished_at: null,
|
||||
});
|
||||
expect(JSON.parse(mockState.db?.runs.get(ids.runId)?.payload ?? "{}")).toMatchObject({
|
||||
commandKind: "retry_task",
|
||||
sourceRunId: "run-governance",
|
||||
attemptNo: 2,
|
||||
});
|
||||
expect(mockState.db?.outbox.get(`run:upsert:${ids.runId}`)).toMatchObject({ entity_type: "run" });
|
||||
});
|
||||
|
||||
it("cancels the current task run without deleting run history", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
mockState.db?.runs.set("run-history", {
|
||||
id: "run-history",
|
||||
plan_id: "plan-governance",
|
||||
task_id: "task-governance",
|
||||
status: "completed",
|
||||
started_at: "2026-06-07T07:00:00.000Z",
|
||||
finished_at: "2026-06-07T07:05:00.000Z",
|
||||
payload: JSON.stringify({ source: "history" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:05:00.000Z",
|
||||
});
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "cancel_task",
|
||||
commandId: "command-cancel-task",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "用户取消" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({ status: "cancelled" });
|
||||
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "cancelled" });
|
||||
expect(mockState.db?.runs.get("run-governance")).toMatchObject({
|
||||
status: "cancelled",
|
||||
finished_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
expect(mockState.db?.runs.get("run-history")).toMatchObject({
|
||||
status: "completed",
|
||||
finished_at: "2026-06-07T07:05:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes paused tasks and records provided task input", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask({ taskStatus: "paused", stepStatus: "paused", runStatus: "paused" });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "provide_task_input",
|
||||
commandId: "command-provide-input",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { input: "继续使用最新客户名单" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({ status: "paused" });
|
||||
expect(JSON.parse(mockState.db?.tasks.get("task-governance")?.payload ?? "{}")).toMatchObject({
|
||||
lastGovernanceCommand: {
|
||||
commandKind: "provide_task_input",
|
||||
payload: { input: "继续使用最新客户名单" },
|
||||
},
|
||||
});
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "resume_task",
|
||||
commandId: "command-resume-task",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "资料已补充" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.tasks.get("task-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.runs.get("run-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.events.get("run-governance:provide_task_input:command-provide-input")).toMatchObject({
|
||||
kind: "governance_provide_task_input",
|
||||
});
|
||||
expect(mockState.db?.events.get("run-governance:resume_task:command-resume-task")).toMatchObject({
|
||||
kind: "governance_resume_task",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createGovernableTask(options: {
|
||||
taskStatus?: string;
|
||||
stepStatus?: string;
|
||||
runStatus?: string;
|
||||
finishedAt?: string | null;
|
||||
} = {}): void {
|
||||
mockState.db?.plans.set("plan-governance", {
|
||||
id: "plan-governance",
|
||||
title: "步骤治理计划",
|
||||
objective: "验证任务治理闭环",
|
||||
status: "running",
|
||||
payload: JSON.stringify({ source: "test" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
mockState.db?.planSteps.set("step-governance", {
|
||||
id: "step-governance",
|
||||
plan_id: "plan-governance",
|
||||
title: "执行治理步骤",
|
||||
status: options.stepStatus ?? options.taskStatus ?? "running",
|
||||
seq: 1,
|
||||
depends_on: JSON.stringify([]),
|
||||
preferred_skill_ids: JSON.stringify(["qimingclaw-task-agent"]),
|
||||
payload: JSON.stringify({ source: "test" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
mockState.db?.tasks.set("task-governance", {
|
||||
id: "task-governance",
|
||||
plan_id: "plan-governance",
|
||||
title: "执行治理任务",
|
||||
status: options.taskStatus ?? "running",
|
||||
assigned_skill_id: "qimingclaw-task-agent",
|
||||
payload: JSON.stringify({ source: "test", stepId: "step-governance" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
mockState.db?.runs.set("run-governance", {
|
||||
id: "run-governance",
|
||||
plan_id: "plan-governance",
|
||||
task_id: "task-governance",
|
||||
status: options.runStatus ?? "running",
|
||||
started_at: "2026-06-07T07:10:00.000Z",
|
||||
finished_at: options.finishedAt ?? null,
|
||||
payload: JSON.stringify({ source: "test" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:10:00.000Z",
|
||||
updated_at: "2026-06-07T07:10:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
interface TestPlanRow {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -615,9 +848,31 @@ class TestDb {
|
||||
}
|
||||
|
||||
private get(sql: string, args: unknown[]): unknown {
|
||||
if (sql.startsWith("SELECT COUNT(*) AS count FROM digital_runs")) {
|
||||
return { count: Array.from(this.runs.values()).filter((run) => run.task_id === args[0]).length };
|
||||
}
|
||||
if (sql.startsWith("SELECT title, objective FROM digital_plans")) {
|
||||
return this.plans.get(args[0] as string);
|
||||
}
|
||||
if (sql.includes("FROM digital_tasks") && sql.includes("WHERE id = ?")) {
|
||||
return this.tasks.get(args[0] as string);
|
||||
}
|
||||
if (sql.includes("FROM digital_runs") && sql.includes("WHERE id = ?")) {
|
||||
return this.runs.get(args[0] as string);
|
||||
}
|
||||
if (sql.includes("FROM digital_runs") && sql.includes("WHERE task_id = ?")) {
|
||||
return Array.from(this.runs.values())
|
||||
.filter((run) => run.task_id === args[0])
|
||||
.sort((left, right) => right.updated_at.localeCompare(left.updated_at) || right.created_at.localeCompare(left.created_at))[0];
|
||||
}
|
||||
if (sql.startsWith("SELECT id FROM digital_plan_steps")) {
|
||||
return Array.from(this.planSteps.values())
|
||||
.filter((step) => step.plan_id === args[0])
|
||||
.sort((left, right) => left.seq - right.seq || left.created_at.localeCompare(right.created_at))[0];
|
||||
}
|
||||
if (sql.includes("FROM digital_plan_steps") && sql.includes("WHERE id = ?")) {
|
||||
return this.planSteps.get(args[0] as string);
|
||||
}
|
||||
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) {
|
||||
return this.approvals.get(args[0] as string);
|
||||
}
|
||||
@@ -826,6 +1081,67 @@ class TestDb {
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_tasks")) {
|
||||
const [status, payload, updatedAt, id] = args as [string, string, string, string];
|
||||
const previous = this.tasks.get(id);
|
||||
if (previous) {
|
||||
this.tasks.set(id, {
|
||||
...previous,
|
||||
status,
|
||||
payload,
|
||||
updated_at: updatedAt,
|
||||
sync_status: "pending",
|
||||
});
|
||||
}
|
||||
return { changes: previous ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_plan_steps")) {
|
||||
const [status, payload, updatedAt, id] = args as [string, string, string, string];
|
||||
const previous = this.planSteps.get(id);
|
||||
if (previous) {
|
||||
this.planSteps.set(id, {
|
||||
...previous,
|
||||
status,
|
||||
payload,
|
||||
updated_at: updatedAt,
|
||||
sync_status: "pending",
|
||||
});
|
||||
}
|
||||
return { changes: previous ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_runs") && sql.includes("payload") && sql.includes("finished_at")) {
|
||||
const [status, payload, finishedAt, updatedAt, id] = args as [string, string, string, string, string];
|
||||
const previous = this.runs.get(id);
|
||||
if (previous) {
|
||||
this.runs.set(id, {
|
||||
...previous,
|
||||
status,
|
||||
payload,
|
||||
finished_at: finishedAt,
|
||||
updated_at: updatedAt,
|
||||
sync_status: "pending",
|
||||
});
|
||||
}
|
||||
return { changes: previous ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_runs") && sql.includes("payload")) {
|
||||
const [status, payload, updatedAt, id] = args as [string, string, string, string];
|
||||
const previous = this.runs.get(id);
|
||||
if (previous) {
|
||||
this.runs.set(id, {
|
||||
...previous,
|
||||
status,
|
||||
payload,
|
||||
updated_at: updatedAt,
|
||||
sync_status: "pending",
|
||||
});
|
||||
}
|
||||
return { changes: previous ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_runs")) {
|
||||
const [status, finishedAt, updatedAt, id] = args as [string, string, string, string];
|
||||
const previous = this.runs.get(id);
|
||||
|
||||
Reference in New Issue
Block a user