吸收数字员工步骤依赖调度
This commit is contained in:
@@ -481,6 +481,158 @@ describe("digital employee state service", () => {
|
||||
expect(mockState.db?.outbox.get("run:upsert:run-governance")).toMatchObject({ entity_type: "run" });
|
||||
});
|
||||
|
||||
it("marks dependent plan steps ready when skipped dependencies are satisfied", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
addPlanStep({ id: "step-dependent", title: "后续执行步骤", seq: 2, dependsOn: ["step-governance"] });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "skip_task",
|
||||
commandId: "command-unlock-dependent",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "跳过前置步骤" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({
|
||||
status: "ready",
|
||||
sync_status: "pending",
|
||||
});
|
||||
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
|
||||
dependencyGraph: {
|
||||
status: "ready",
|
||||
triggeredByStepId: "step-governance",
|
||||
satisfiedDependencies: ["step-governance"],
|
||||
},
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "plan_step_ready", plan_id: "plan-governance" }),
|
||||
]));
|
||||
expect(mockState.db?.outbox.get("plan_step:upsert:step-dependent")).toMatchObject({
|
||||
entity_type: "plan_step",
|
||||
entity_id: "step-dependent",
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks dependent plan steps on cancellation and returns them to pending on retry", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
addPlanStep({ id: "step-dependent", title: "后续执行步骤", seq: 2, dependsOn: ["step-governance"] });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "cancel_task",
|
||||
commandId: "command-block-dependent",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "取消前置步骤" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "blocked" });
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "plan_step_blocked" }),
|
||||
]));
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "retry_task",
|
||||
commandId: "command-wait-dependent",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
payload: { reason: "重试前置步骤" },
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-governance")).toMatchObject({ status: "running" });
|
||||
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "pending" });
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "plan_step_waiting" }),
|
||||
]));
|
||||
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
|
||||
dependencyGraph: {
|
||||
status: "pending",
|
||||
waitingDependencies: ["step-governance"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for all dependencies before marking a plan step ready", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
addPlanStep({ id: "step-other", title: "第二个前置步骤", seq: 2 });
|
||||
addGovernableTaskForStep({ taskId: "task-other", runId: "run-other", stepId: "step-other" });
|
||||
addPlanStep({ id: "step-dependent", title: "汇总步骤", seq: 3, dependsOn: ["step-governance", "step-other"] });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "skip_task",
|
||||
commandId: "command-first-dependency",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "pending" });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "skip_task",
|
||||
commandId: "command-second-dependency",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-other",
|
||||
runId: "run-other",
|
||||
stepId: "step-other",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-dependent")).toMatchObject({ status: "ready" });
|
||||
expect(JSON.parse(mockState.db?.planSteps.get("step-dependent")?.payload ?? "{}")).toMatchObject({
|
||||
dependencyGraph: {
|
||||
status: "ready",
|
||||
satisfiedDependencies: ["step-governance", "step-other"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite already running or completed dependent plan steps", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
createGovernableTask();
|
||||
addPlanStep({ id: "step-running-dependent", status: "running", seq: 2, dependsOn: ["step-governance"] });
|
||||
addPlanStep({ id: "step-completed-dependent", status: "completed", seq: 3, dependsOn: ["step-governance"] });
|
||||
|
||||
recordDigitalEmployeeGovernanceCommand({
|
||||
commandKind: "skip_task",
|
||||
commandId: "command-preserve-dependent",
|
||||
planId: "plan-governance",
|
||||
taskId: "task-governance",
|
||||
runId: "run-governance",
|
||||
stepId: "step-governance",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
expect(mockState.db?.planSteps.get("step-running-dependent")).toMatchObject({
|
||||
status: "running",
|
||||
sync_status: "synced",
|
||||
});
|
||||
expect(mockState.db?.planSteps.get("step-completed-dependent")).toMatchObject({
|
||||
status: "completed",
|
||||
sync_status: "synced",
|
||||
});
|
||||
expect(mockState.db?.outbox.has("plan_step:upsert:step-running-dependent")).toBe(false);
|
||||
expect(mockState.db?.outbox.has("plan_step:upsert:step-completed-dependent")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a retry run and preserves the failed source run", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
@@ -754,6 +906,59 @@ function createGovernableTask(options: {
|
||||
});
|
||||
}
|
||||
|
||||
function addPlanStep(options: {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
seq?: number;
|
||||
dependsOn?: string[];
|
||||
}): void {
|
||||
mockState.db?.planSteps.set(options.id, {
|
||||
id: options.id,
|
||||
plan_id: "plan-governance",
|
||||
title: options.title ?? options.id,
|
||||
status: options.status ?? "pending",
|
||||
seq: options.seq ?? 2,
|
||||
depends_on: JSON.stringify(options.dependsOn ?? []),
|
||||
preferred_skill_ids: JSON.stringify([]),
|
||||
payload: JSON.stringify({ source: "test" }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
function addGovernableTaskForStep(options: {
|
||||
taskId: string;
|
||||
runId: string;
|
||||
stepId: string;
|
||||
status?: string;
|
||||
}): void {
|
||||
mockState.db?.tasks.set(options.taskId, {
|
||||
id: options.taskId,
|
||||
plan_id: "plan-governance",
|
||||
title: `${options.stepId} 任务`,
|
||||
status: options.status ?? "running",
|
||||
assigned_skill_id: "qimingclaw-task-agent",
|
||||
payload: JSON.stringify({ source: "test", stepId: options.stepId }),
|
||||
sync_status: "synced",
|
||||
created_at: "2026-06-07T07:00:00.000Z",
|
||||
updated_at: "2026-06-07T07:00:00.000Z",
|
||||
});
|
||||
mockState.db?.runs.set(options.runId, {
|
||||
id: options.runId,
|
||||
plan_id: "plan-governance",
|
||||
task_id: options.taskId,
|
||||
status: options.status ?? "running",
|
||||
started_at: "2026-06-07T07:10:00.000Z",
|
||||
finished_at: 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;
|
||||
@@ -808,7 +1013,7 @@ interface TestEventRow {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
task_id: string;
|
||||
run_id: string;
|
||||
run_id: string | null;
|
||||
kind: string;
|
||||
message: string;
|
||||
payload: string;
|
||||
@@ -946,9 +1151,17 @@ class TestDb {
|
||||
};
|
||||
}
|
||||
|
||||
private all(sql: string, _args: unknown[]): unknown[] {
|
||||
private all(sql: string, args: unknown[]): unknown[] {
|
||||
if (sql.includes("FROM digital_plans")) return Array.from(this.plans.values());
|
||||
if (sql.includes("FROM digital_plan_steps")) return Array.from(this.planSteps.values());
|
||||
if (sql.includes("FROM digital_plan_steps")) {
|
||||
const rows = Array.from(this.planSteps.values());
|
||||
if (sql.includes("WHERE plan_id = ?")) {
|
||||
return rows
|
||||
.filter((step) => step.plan_id === args[0])
|
||||
.sort((left, right) => left.seq - right.seq || left.created_at.localeCompare(right.created_at));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
if (sql.includes("FROM digital_schedules")) return Array.from(this.schedules.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());
|
||||
@@ -1304,7 +1517,7 @@ class TestDb {
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
|
||||
Reference in New Issue
Block a user