feat(client): materialize restarted plans
This commit is contained in:
@@ -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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` 入口仍会自行记录本地激活。
|
||||
|
||||
### 产出
|
||||
|
||||
|
||||
Reference in New Issue
Block a user