feat(client): materialize restarted plans

This commit is contained in:
baiyanyun
2026-06-08 18:58:39 +08:00
parent 913c6da8f3
commit 451cda7aa6
6 changed files with 233 additions and 45 deletions

View File

@@ -2146,7 +2146,7 @@ async function handleGovernanceCommand(
if (body.command_kind === 'activate_plan' || body.command_kind === 'run_schedule_now') {
const targetPlanId = planId || runtimeDebugPlans(snapshot)[0]?.plan_id || templatePlans(snapshot)[0]?.plan_id || 'qimingclaw-client-readiness';
const dispatch = await dispatchPlan(targetPlanId, snapshot);
const dispatch = await dispatchPlan(targetPlanId, snapshot, { recordCommand: false });
const response = governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
plan_id: targetPlanId,
status: 'active',
@@ -2429,7 +2429,11 @@ function buildTaskFlow(taskId: string, snapshot: QimingclawSnapshot | null = nul
};
}
async function dispatchPlan(planId: string, snapshot: QimingclawSnapshot | null = null): Promise<PlanDispatchResponse> {
async function dispatchPlan(
planId: string,
snapshot: QimingclawSnapshot | null = null,
options: { recordCommand?: boolean } = {},
): Promise<PlanDispatchResponse> {
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
const date = todayInputDate();
const plan = mergedDebugPlans(snapshot).find((candidate) => candidate.plan_id === planId);
@@ -2445,30 +2449,32 @@ async function dispatchPlan(planId: string, snapshot: QimingclawSnapshot | null
title: task.title,
status: task.status === 'pending' ? 'running' : task.status,
}));
await recordGovernanceCommand(
{
command_kind: 'activate_plan',
context_refs: { plan_id: planId },
payload: {
plan_id: planId,
title: plan?.title,
objective: plan?.objective,
requested_action: 'dispatch_plan',
},
correlation_id: correlationId,
},
governanceAccepted(
`qimingclaw-digital-dispatch-${slugifyIdPart(planId)}-${activatedAt}`,
correlationId,
'已将数字员工模板执行写入 qimingclaw 本地运行记录。',
if (options.recordCommand !== false) {
await recordGovernanceCommand(
{
plan_id: planId,
status: 'active',
dispatched_tasks: dispatchedTasks,
source: 'qimingclaw-adapter',
command_kind: 'activate_plan',
context_refs: { plan_id: planId },
payload: {
plan_id: planId,
title: plan?.title,
objective: plan?.objective,
requested_action: 'dispatch_plan',
},
correlation_id: correlationId,
},
),
);
governanceAccepted(
`qimingclaw-digital-dispatch-${slugifyIdPart(planId)}-${activatedAt}`,
correlationId,
'已将数字员工模板执行写入 qimingclaw 本地运行记录。',
{
plan_id: planId,
status: 'active',
dispatched_tasks: dispatchedTasks,
source: 'qimingclaw-adapter',
},
),
);
}
return {
ok: true,

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-B-gnIbwp.js"></script>
<script type="module" crossorigin src="./assets/index-Cz6nAvzJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
</head>
<body>

View File

@@ -168,6 +168,20 @@ describe("digital employee state service", () => {
payload: {
title: "生成今日客户跟进计划",
objective: "把今日客户跟进事项整理成可执行计划",
steps: [
{
step_id: "collect-crm",
title: "整理客户列表",
skills: ["qimingclaw-mcp-tool-crm-crm-search"],
tasks: [
{
task_id: "task-collect-crm",
title: "拉取 CRM 客户记录",
description: "读取今日需要跟进的客户",
},
],
},
],
},
result: {
plan_id: "plan-customer-followup",
@@ -191,6 +205,12 @@ describe("digital employee state service", () => {
title: "创建数字员工计划",
assigned_skill_id: "qimingclaw-governance-command",
});
expect(mockState.db?.tasks.get("task-collect-crm")).toMatchObject({
plan_id: "plan-customer-followup",
title: "拉取 CRM 客户记录",
status: "pending",
assigned_skill_id: "qimingclaw-mcp-tool-crm-crm-search",
});
expect(mockState.db?.runs.get("governance-run-command-1")).toMatchObject({
plan_id: "plan-customer-followup",
task_id: "governance-task-command-1",
@@ -208,6 +228,33 @@ describe("digital employee state service", () => {
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("preserves created plan title across follow-up governance commands", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
recordDigitalEmployeeGovernanceCommand({
commandKind: "create_plan",
commandId: "command-create",
accepted: true,
message: "已创建计划草稿",
payload: { title: "客户回访重新执行", objective: "重新执行客户回访流程" },
result: { plan_id: "plan-restart", status: "draft" },
});
recordDigitalEmployeeGovernanceCommand({
commandKind: "approve_plan",
commandId: "command-approve",
accepted: true,
message: "已批准计划",
result: { plan_id: "plan-restart", status: "approved" },
});
expect(mockState.db?.plans.get("plan-restart")).toMatchObject({
title: "客户回访重新执行",
objective: "重新执行客户回访流程",
status: "approved",
});
});
});
interface TestPlanRow {
@@ -325,6 +372,9 @@ class TestDb {
}
private get(sql: string, args: unknown[]): unknown {
if (sql.startsWith("SELECT title, objective FROM digital_plans")) {
return this.plans.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);
}

View File

@@ -739,8 +739,6 @@ export function recordDigitalEmployeeGovernanceCommand(
taskId: `governance-task-${safeId(commandId)}`,
runId: `governance-run-${safeId(commandId)}`,
};
const title = stringValue(payload.title ?? result.title)
|| governanceCommandTitle(command.commandKind);
const status = governanceCommandStatus(command);
const payloadEnvelope = {
source: "qimingclaw-governance-command",
@@ -759,13 +757,20 @@ export function recordDigitalEmployeeGovernanceCommand(
const db = getDigitalEmployeeDb();
if (!db) return ids;
const existingPlan = readExistingPlanSummary(planId);
const title = stringValue(payload.title ?? result.title)
|| existingPlan?.title
|| governanceCommandTitle(command.commandKind);
const objective = stringValue(payload.objective ?? payload.description ?? payload.requested_action)
|| existingPlan?.objective
|| command.message
|| title;
const tx = db.transaction(() => {
upsertPlan({
id: ids.planId,
title,
objective: stringValue(payload.objective ?? payload.description ?? payload.requested_action)
|| command.message
|| title,
objective,
status,
payload: payloadEnvelope,
now,
@@ -779,6 +784,15 @@ export function recordDigitalEmployeeGovernanceCommand(
payload: payloadEnvelope,
now,
});
if (command.commandKind === "create_plan") {
upsertCreatedPlanTasks({
planId: ids.planId,
payload,
status: "pending",
commandId,
now,
});
}
upsertRun({
id: ids.runId,
planId: ids.planId,
@@ -832,6 +846,122 @@ function governanceCommandTitle(commandKind: string): string {
}
}
function readExistingPlanSummary(planId: string): { title: string; objective: string } | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
try {
const row = db.prepare("SELECT title, objective FROM digital_plans WHERE id = ?").get(planId) as Record<string, unknown> | undefined;
if (!row) return null;
return {
title: stringValue(row.title),
objective: stringValue(row.objective),
};
} catch {
return null;
}
}
function upsertCreatedPlanTasks(input: {
planId: string;
payload: Record<string, unknown>;
status: string;
commandId: string;
now: string;
}): void {
extractPlanTaskInputs(input.payload).forEach((task, index) => {
const title = task.title || `计划任务 ${index + 1}`;
const taskId = task.id || `${input.planId}:task:${safeId(`${index + 1}-${title}`)}`;
upsertTask({
id: taskId,
planId: input.planId,
title,
status: input.status,
assignedSkillId: task.assignedSkillId,
payload: {
source: "qimingclaw-create-plan",
commandId: input.commandId,
stepId: task.stepId,
description: task.description,
dependsOn: task.dependsOn,
skills: task.skills,
original: task.original,
},
now: input.now,
});
});
}
function extractPlanTaskInputs(payload: Record<string, unknown>): Array<{
id: string;
stepId: string;
title: string;
description: string;
assignedSkillId: string;
dependsOn: string[];
skills: string[];
original: unknown;
}> {
const explicitTasks = unknownArray(payload.tasks)
.map((task, index) => planTaskInputFromRecord(objectRecord(task), index, ""));
const stepTasks = unknownArray(payload.steps).flatMap((step, stepIndex) => {
const stepRecord = objectRecord(step);
if (!stepRecord) return [];
const nestedTasks = unknownArray(stepRecord.tasks);
if (nestedTasks.length > 0) {
return nestedTasks.map((task, taskIndex) => planTaskInputFromRecord(
objectRecord(task),
taskIndex,
stringValue(stepRecord.step_id ?? stepRecord.id) || `step-${stepIndex + 1}`,
stepRecord,
));
}
return [planTaskInputFromRecord(stepRecord, stepIndex, stringValue(stepRecord.step_id ?? stepRecord.id) || `step-${stepIndex + 1}`)];
});
return dedupeByKey(
[...explicitTasks, ...stepTasks].filter((task): task is NonNullable<typeof task> => Boolean(task)),
(task) => task.id || `${task.stepId}:${task.title}`,
);
}
function planTaskInputFromRecord(
record: Record<string, unknown> | null,
index: number,
fallbackStepId: string,
parentStep?: Record<string, unknown>,
): ReturnType<typeof extractPlanTaskInputs>[number] | null {
if (!record) return null;
const stepId = stringValue(record.step_id ?? record.stepId) || fallbackStepId;
const title = stringValue(record.title ?? record.name ?? record.task_title)
|| stringValue(parentStep?.title ?? parentStep?.name)
|| `计划任务 ${index + 1}`;
const description = stringValue(record.description ?? record.summary)
|| stringValue(parentStep?.description ?? parentStep?.summary)
|| title;
const skills = normalizeSkillIds([
...unknownArray(record.skills),
...unknownArray(record.preferred_skills),
...unknownArray(parentStep?.skills),
...unknownArray(parentStep?.preferred_skills),
stringValue(record.assigned_skill_id ?? record.assignedSkillId),
]);
return {
id: stringValue(record.task_id ?? record.id),
stepId,
title,
description,
assignedSkillId: skills[0] ?? "qimingclaw-plan-template",
dependsOn: normalizeSkillIds(unknownArray(record.depends_on ?? parentStep?.depends_on)),
skills,
original: record,
};
}
function normalizeSkillIds(values: unknown[]): string[] {
return [...new Set(values
.map((value) => stringValue(value))
.filter(Boolean))];
}
function governanceCommandStatus(
command: DigitalEmployeeGovernanceCommandRecord,
): string {

View File

@@ -820,6 +820,8 @@ POST /api/plan-templates/:id/activate
- 调度:模板生成 `plan_template` 类型 job。
- 详情/聊天:无运行态时以模板内容生成计划流和消息。
- 执行:点击模板执行会写入正式 `activate_plan` governance command生成 Plan / Task / Run / Event / outbox同步给管理后端。
- 创建 / 重新执行:`create_plan` payload 中的 `steps` / `tasks` 会展开为真实 pending Task后续 `submit_plan``approve_plan``activate_plan` 保留原计划标题和目标,不再用治理动作名覆盖业务计划。
- 调度去重:任务中心通过 `/api/governance/command` 激活计划时只记录一次治理命令,直接 `/api/debug/plan/:id/dispatch` 入口仍会自行记录本地激活。
### 产出