吸收数字员工计划步骤依赖图

This commit is contained in:
baiyanyun
2026-06-09 10:21:12 +08:00
parent e4715bbabb
commit f35d945d9c
10 changed files with 641 additions and 74 deletions

View File

@@ -22,6 +22,7 @@ import type {
GovernanceCommandRequest,
GovernanceCommandResponse,
PlanDispatchResponse,
PlanStepSummary,
SkillSpec,
} from '@/types/api';
@@ -178,6 +179,7 @@ interface QimingclawGovernanceCommandRecord {
interface QimingclawRuntimeRecords {
generatedAt: string;
plans: QimingclawPlanRecord[];
planSteps?: QimingclawPlanStepRecord[];
tasks: QimingclawTaskRecord[];
runs: QimingclawRunRecord[];
events: QimingclawEventRecord[];
@@ -200,6 +202,23 @@ interface QimingclawPlanRecord {
updatedAt: string;
}
interface QimingclawPlanStepRecord {
id: string;
remoteId?: string | null;
planId: string;
title: string;
status: string;
seq: number;
dependsOn: string[];
preferredSkillIds: string[];
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
interface QimingclawTaskRecord {
id: string;
remoteId?: string | null;
@@ -774,6 +793,10 @@ function runtimePlans(snapshot: QimingclawSnapshot | null): QimingclawPlanRecord
return snapshot?.runtime?.plans ?? [];
}
function runtimePlanSteps(snapshot: QimingclawSnapshot | null): QimingclawPlanStepRecord[] {
return snapshot?.runtime?.planSteps ?? [];
}
function runtimeTasks(snapshot: QimingclawSnapshot | null): QimingclawTaskRecord[] {
return snapshot?.runtime?.tasks ?? [];
}
@@ -796,6 +819,7 @@ function runtimeApprovals(snapshot: QimingclawSnapshot | null): QimingclawApprov
function hasRuntimeRecords(snapshot: QimingclawSnapshot | null): boolean {
return runtimePlans(snapshot).length > 0
|| runtimePlanSteps(snapshot).length > 0
|| runtimeTasks(snapshot).length > 0
|| runtimeArtifacts(snapshot).length > 0
|| runtimeApprovals(snapshot).length > 0;
@@ -828,6 +852,17 @@ function runtimePlanTasks(snapshot: QimingclawSnapshot | null, planId: string):
return runtimeTasks(snapshot).filter((task) => task.planId === planId);
}
function runtimeStepsForPlan(snapshot: QimingclawSnapshot | null, planId: string): QimingclawPlanStepRecord[] {
return runtimePlanSteps(snapshot)
.filter((step) => step.planId === planId)
.sort((left, right) => left.seq - right.seq || left.createdAt.localeCompare(right.createdAt));
}
function taskStepId(task: QimingclawTaskRecord): string {
const record = asRecord(task.payload);
return stringValue(record?.stepId ?? record?.step_id) || (task.planId ? `${task.planId}:runtime` : 'qimingclaw-runtime');
}
function planRecordStatus(plan: QimingclawPlanRecord, tasks: QimingclawTaskRecord[]): string {
if (tasks.some((task) => taskStatusTone(task.status) === 'danger')) return 'failed';
if (tasks.some((task) => taskStatusTone(task.status) === 'running')) return 'running';
@@ -894,7 +929,16 @@ function latestRunForTask(runs: QimingclawRunRecord[], taskId: string): Qimingcl
function runtimePlanToDebugPlan(plan: QimingclawPlanRecord, snapshot: QimingclawSnapshot | null): DebugPlan {
const tasks = runtimePlanTasks(snapshot, plan.id);
const planSteps = runtimeStepsForPlan(snapshot, plan.id);
const status = planRecordStatus(plan, tasks);
const knownStepIds = new Set(planSteps.map((step) => step.id));
const orphanTasks = tasks.filter((task) => !knownStepIds.has(taskStepId(task)));
const steps = planSteps.length > 0
? [
...planSteps.map((step) => runtimePlanStepToSummary(step, tasks)),
...(orphanTasks.length > 0 ? [runtimeFallbackStepToSummary(plan.id, orphanTasks)] : []),
]
: [runtimeFallbackStepToSummary(plan.id, tasks)];
return {
plan_id: plan.id,
title: plan.title,
@@ -904,19 +948,43 @@ function runtimePlanToDebugPlan(plan: QimingclawPlanRecord, snapshot: Qimingclaw
updated_at: latestRuntimeTime(plan, tasks),
source: plan.source,
sync_status: plan.syncStatus,
steps: [
{
step_id: `${plan.id}:runtime`,
title: 'qimingclaw 执行记录',
depends_on: [],
preferred_skills: [...new Set(tasks.map((task) => task.assignedSkillId).filter((value): value is string => Boolean(value)))],
tasks: tasks.map((task) => ({
task_id: task.id,
title: task.title,
status: task.status,
})),
},
],
steps,
};
}
function runtimePlanStepToSummary(
step: QimingclawPlanStepRecord,
tasks: QimingclawTaskRecord[],
): PlanStepSummary {
return {
step_id: step.id,
title: step.title,
depends_on: step.dependsOn,
preferred_skills: step.preferredSkillIds,
tasks: tasks
.filter((task) => taskStepId(task) === step.id)
.map((task) => ({
task_id: task.id,
title: task.title,
status: task.status,
})),
};
}
function runtimeFallbackStepToSummary(
planId: string,
tasks: QimingclawTaskRecord[],
): PlanStepSummary {
return {
step_id: `${planId}:runtime`,
title: 'qimingclaw 执行记录',
depends_on: [],
preferred_skills: [...new Set(tasks.map((task) => task.assignedSkillId).filter((value): value is string => Boolean(value)))],
tasks: tasks.map((task) => ({
task_id: task.id,
title: task.title,
status: task.status,
})),
};
}
@@ -926,7 +994,7 @@ function runtimeTaskToDebugTask(task: QimingclawTaskRecord): DebugTask {
title: task.title,
status: task.status,
plan_id: task.planId ?? null,
step_id: task.planId ? `${task.planId}:runtime` : 'qimingclaw-runtime',
step_id: taskStepId(task),
assigned_skill_id: task.assignedSkillId ?? '',
description: task.syncError ?? '',
created_at: task.createdAt,
@@ -1260,13 +1328,16 @@ function buildDebugStore(snapshot: QimingclawSnapshot | null): DebugStoreRespons
const plans = filterPlans(null, snapshot);
const tasks = buildTasks(null, snapshot);
const runs = buildTasksResponse(null, snapshot).runs;
const realPlanSteps = runtimePlanSteps(snapshot);
return {
tables: {
canonical_events: buildCanonicalEvents(snapshot, tasks, runs),
tasks,
runs,
plans,
plan_steps: plans.flatMap((plan) => plan.steps ?? []),
plan_steps: realPlanSteps.length > 0
? realPlanSteps.map((step) => runtimePlanStepToSummary(step, runtimeTasks(snapshot)))
: plans.flatMap((plan) => plan.steps ?? []),
approvals: buildApprovals(snapshot),
artifacts: buildArtifacts(snapshot),
},
@@ -2274,10 +2345,45 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
if (hasRuntimeRecords(snapshot)) {
const plan = runtimePlans(snapshot).find((candidate) => candidate.id === planId);
const tasks = buildTasks(planId, snapshot);
const planSteps = runtimeStepsForPlan(snapshot, planId);
const runs = buildTasksResponse(planId, snapshot).runs;
const events = runtimeEvents(snapshot).filter((event) => event.planId === planId || tasks.some((task) => task.task_id === event.taskId));
const title = plan?.title ?? tasks[0]?.title ?? planId;
const objective = plan?.objective ?? 'qimingclaw 本地运行记录';
const qimingclawFlowSteps: FlowResponse['steps'] = [];
let nextSeq = 2;
if (planSteps.length > 0) {
const knownStepIds = new Set(planSteps.map((step) => step.id));
for (const step of planSteps) {
qimingclawFlowSteps.push({
seq: nextSeq++,
lane: 'qimingclaw',
label: step.title,
sub_label: `${taskStatusLabel(step.status)} · 依赖 ${step.dependsOn.length} · 技能 ${step.preferredSkillIds.length}`,
timestamp: step.updatedAt,
color: '#22c55e',
source_type: 'plan_step',
});
for (const task of tasks.filter((candidate) => candidate.step_id === step.id)) {
qimingclawFlowSteps.push(runtimeTaskFlowStep(task, nextSeq++));
}
}
const orphanTasks = tasks.filter((task) => !knownStepIds.has(task.step_id || ''));
if (orphanTasks.length > 0) {
qimingclawFlowSteps.push({
seq: nextSeq++,
lane: 'qimingclaw',
label: '未归属步骤',
sub_label: `包含 ${orphanTasks.length} 个任务`,
timestamp: orphanTasks[0]?.updated_at ?? new Date().toISOString(),
color: '#22c55e',
source_type: 'plan_step',
});
for (const task of orphanTasks) qimingclawFlowSteps.push(runtimeTaskFlowStep(task, nextSeq++));
}
} else {
for (const task of tasks) qimingclawFlowSteps.push(runtimeTaskFlowStep(task, nextSeq++));
}
return {
plan_id: planId,
plan_title: title,
@@ -2298,17 +2404,9 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
color: '#38bdf8',
source_type: 'plan',
},
...tasks.map((task, index) => ({
seq: index + 2,
lane: 'qimingclaw',
label: task.title,
sub_label: `${taskStatusLabel(task.status)} · ${task.assigned_skill_id || 'qimingclaw-runtime'}`,
timestamp: task.updated_at,
color: '#22c55e',
source_type: 'task',
})),
...qimingclawFlowSteps,
...runs.map((run, index) => ({
seq: tasks.length + index + 2,
seq: nextSeq + index,
lane: 'execution',
label: run.lifecycle || 'Run',
sub_label: run.run_id,
@@ -2377,6 +2475,18 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
};
}
function runtimeTaskFlowStep(task: DebugTask, seq: number): FlowResponse['steps'][number] {
return {
seq,
lane: 'qimingclaw',
label: task.title,
sub_label: `${taskStatusLabel(task.status)} · ${task.assigned_skill_id || 'qimingclaw-runtime'}`,
timestamp: task.updated_at,
color: '#22c55e',
source_type: 'task',
};
}
function buildTaskFlow(taskId: string, snapshot: QimingclawSnapshot | null = null): FlowResponse {
const task = runtimeTasks(snapshot).find((candidate) => candidate.id === taskId);
if (!task) {

View File

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

View File

@@ -8,6 +8,7 @@ const { spawnSync } = require("child_process");
const packageRoot = path.resolve(__dirname, "..");
const requiredDigitalTables = [
"digital_plans",
"digital_plan_steps",
"digital_tasks",
"digital_runs",
"digital_events",
@@ -25,6 +26,28 @@ const testFiles = [
"src/main/services/digitalEmployee/planTemplateService.test.ts",
];
const selfHealingDigitalSchemaSql = `
CREATE TABLE IF NOT EXISTS digital_plan_steps (
id TEXT PRIMARY KEY,
remote_id TEXT,
plan_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
seq INTEGER NOT NULL,
depends_on TEXT NOT NULL DEFAULT '[]',
preferred_skill_ids TEXT NOT NULL DEFAULT '[]',
payload TEXT NOT NULL DEFAULT '{}',
sync_status TEXT NOT NULL DEFAULT 'pending',
last_synced_at TEXT,
sync_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
);
CREATE INDEX IF NOT EXISTS idx_digital_plan_steps_plan ON digital_plan_steps(plan_id, seq);
CREATE INDEX IF NOT EXISTS idx_digital_plan_steps_sync ON digital_plan_steps(sync_status);
`;
function runStep(title, command, args, options = {}) {
console.log(`\n[DigitalEmployeeCheck] ${title}`);
const result = spawnSync(command, args, {
@@ -65,14 +88,22 @@ function checkLocalDatabaseSchema() {
return;
}
const healResult = spawnSync("sqlite3", [dbPath, selfHealingDigitalSchemaSql], {
encoding: "utf8",
shell: process.platform === "win32",
});
if (healResult.error) {
console.log(`[DigitalEmployeeCheck] Skip DB schema check; sqlite3 unavailable: ${healResult.error.message}`);
return;
}
if (healResult.status !== 0) {
throw new Error(healResult.stderr || "sqlite3 digital schema self-heal failed");
}
const result = spawnSync("sqlite3", [dbPath, ".tables"], {
encoding: "utf8",
shell: process.platform === "win32",
});
if (result.error) {
console.log(`[DigitalEmployeeCheck] Skip DB schema check; sqlite3 unavailable: ${result.error.message}`);
return;
}
if (result.status !== 0) {
throw new Error(result.stderr || "sqlite3 .tables failed");
}

View File

@@ -42,6 +42,24 @@ const DIGITAL_EMPLOYEE_SCHEMA_SQL = `
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
);
CREATE TABLE IF NOT EXISTS digital_plan_steps (
id TEXT PRIMARY KEY,
remote_id TEXT,
plan_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
seq INTEGER NOT NULL,
depends_on TEXT NOT NULL DEFAULT '[]',
preferred_skill_ids TEXT NOT NULL DEFAULT '[]',
payload TEXT NOT NULL DEFAULT '{}',
sync_status TEXT NOT NULL DEFAULT 'pending',
last_synced_at TEXT,
sync_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
);
CREATE TABLE IF NOT EXISTS digital_runs (
id TEXT PRIMARY KEY,
remote_id TEXT,
@@ -140,6 +158,8 @@ const DIGITAL_EMPLOYEE_SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_digital_plans_sync ON digital_plans(sync_status);
CREATE INDEX IF NOT EXISTS idx_digital_plan_steps_plan ON digital_plan_steps(plan_id, seq);
CREATE INDEX IF NOT EXISTS idx_digital_plan_steps_sync ON digital_plan_steps(sync_status);
CREATE INDEX IF NOT EXISTS idx_digital_tasks_plan ON digital_tasks(plan_id);
CREATE INDEX IF NOT EXISTS idx_digital_runs_task ON digital_runs(task_id);
CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at);

View File

@@ -255,6 +255,16 @@ describe("digital employee state service", () => {
status: "pending",
assigned_skill_id: "qimingclaw-mcp-tool-crm-crm-search",
});
expect(mockState.db?.planSteps.get("collect-crm")).toMatchObject({
plan_id: "plan-customer-followup",
title: "整理客户列表",
status: "pending",
seq: 1,
preferred_skill_ids: JSON.stringify(["qimingclaw-mcp-tool-crm-crm-search"]),
});
expect(JSON.parse(mockState.db?.tasks.get("task-collect-crm")?.payload ?? "{}")).toMatchObject({
stepId: "collect-crm",
});
expect(mockState.db?.runs.get("governance-run-command-1")).toMatchObject({
plan_id: "plan-customer-followup",
task_id: "governance-task-command-1",
@@ -270,9 +280,54 @@ describe("digital employee state service", () => {
entity_id: "plan-customer-followup",
status: "pending",
});
expect(mockState.db?.outbox.get("plan_step:upsert:collect-crm")).toMatchObject({
entity_type: "plan_step",
entity_id: "collect-crm",
status: "pending",
});
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("creates a default plan step for top-level plan tasks", async () => {
const {
readDigitalEmployeeRuntimeRecords,
recordDigitalEmployeeGovernanceCommand,
} = await import("./stateService");
recordDigitalEmployeeGovernanceCommand({
commandKind: "create_plan",
commandId: "command-default-step",
accepted: true,
payload: {
title: "客户回访计划",
tasks: [{ task_id: "task-call", title: "拨打客户电话" }],
},
result: { plan_id: "plan-default-step", status: "draft" },
});
expect(mockState.db?.planSteps.get("plan-default-step:step:default")).toMatchObject({
plan_id: "plan-default-step",
title: "默认执行步骤",
seq: 1,
});
expect(JSON.parse(mockState.db?.tasks.get("task-call")?.payload ?? "{}")).toMatchObject({
stepId: "plan-default-step:step:default",
});
expect(mockState.db?.outbox.get("plan_step:upsert:plan-default-step:step:default")).toMatchObject({
entity_type: "plan_step",
entity_id: "plan-default-step:step:default",
});
expect(readDigitalEmployeeRuntimeRecords()).toMatchObject({
planSteps: [
expect.objectContaining({
id: "plan-default-step:step:default",
planId: "plan-default-step",
title: "默认执行步骤",
}),
],
});
});
it("preserves created plan title across follow-up governance commands", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
@@ -324,6 +379,20 @@ interface TestTaskRow {
updated_at: string;
}
interface TestPlanStepRow {
id: string;
plan_id: string;
title: string;
status: string;
seq: number;
depends_on: string;
preferred_skill_ids: string;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestRunRow {
id: string;
plan_id: string;
@@ -391,6 +460,7 @@ interface TestOutboxRow {
class TestDb {
plans = new Map<string, TestPlanRow>();
planSteps = new Map<string, TestPlanStepRow>();
tasks = new Map<string, TestTaskRow>();
runs = new Map<string, TestRunRow>();
events = new Map<string, TestEventRow>();
@@ -409,12 +479,23 @@ class TestDb {
} {
const normalized = sql.replace(/\s+/g, " ").trim();
return {
all: () => [],
all: (...args: unknown[]) => this.all(normalized, args),
get: (...args: unknown[]) => this.get(normalized, args),
run: (...args: unknown[]) => this.run(normalized, args),
};
}
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_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_artifacts")) return Array.from(this.artifacts.values());
if (sql.includes("FROM digital_approvals")) return Array.from(this.approvals.values());
return [];
}
private get(sql: string, args: unknown[]): unknown {
if (sql.startsWith("SELECT title, objective FROM digital_plans")) {
return this.plans.get(args[0] as string);
@@ -450,6 +531,36 @@ class TestDb {
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_plan_steps")) {
const [
id,
planId,
title,
status,
seq,
dependsOn,
preferredSkillIds,
payload,
createdAt,
updatedAt,
] = args as [string, string, string, string, number, string, string, string, string, string];
const previous = this.planSteps.get(id);
this.planSteps.set(id, {
id,
plan_id: planId,
title,
status,
seq,
depends_on: dependsOn,
preferred_skill_ids: preferredSkillIds,
payload,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_tasks")) {
const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [
string,

View File

@@ -88,6 +88,23 @@ export interface DigitalEmployeeTaskRecord {
updatedAt: string;
}
export interface DigitalEmployeePlanStepRecord {
id: string;
remoteId?: string | null;
planId: string;
title: string;
status: string;
seq: number;
dependsOn: string[];
preferredSkillIds: string[];
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeRunRecord {
id: string;
remoteId?: string | null;
@@ -155,6 +172,7 @@ export interface DigitalEmployeeApprovalRecord {
export interface DigitalEmployeeRuntimeRecords {
generatedAt: string;
plans: DigitalEmployeePlanRecord[];
planSteps: DigitalEmployeePlanStepRecord[];
tasks: DigitalEmployeeTaskRecord[];
runs: DigitalEmployeeRunRecord[];
events: DigitalEmployeeEventRecord[];
@@ -479,6 +497,7 @@ export function readDigitalEmployeeRuntimeRecords(
return {
generatedAt: new Date().toISOString(),
plans: [],
planSteps: [],
tasks: [],
runs: [],
events: [],
@@ -494,6 +513,14 @@ export function readDigitalEmployeeRuntimeRecords(
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const planSteps = db.prepare(`
SELECT id, remote_id, plan_id, title, status, seq, depends_on,
preferred_skill_ids, payload, sync_status, last_synced_at,
sync_error, created_at, updated_at
FROM digital_plan_steps
ORDER BY plan_id ASC, seq ASC, created_at ASC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const tasks = db.prepare(`
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
@@ -533,6 +560,7 @@ export function readDigitalEmployeeRuntimeRecords(
return {
generatedAt: new Date().toISOString(),
plans: plans.map(toPlanRecord),
planSteps: planSteps.map(toPlanStepRecord),
tasks: tasks.map(toTaskRecord),
runs: runs.map(toRunRecord),
events: events.map(toEventRecord),
@@ -802,6 +830,13 @@ export function recordDigitalEmployeeGovernanceCommand(
now,
});
if (command.commandKind === "create_plan") {
upsertCreatedPlanSteps({
planId: ids.planId,
payload,
status: "pending",
commandId,
now,
});
upsertCreatedPlanTasks({
planId: ids.planId,
payload,
@@ -878,6 +913,34 @@ function readExistingPlanSummary(planId: string): { title: string; objective: st
}
}
function upsertCreatedPlanSteps(input: {
planId: string;
payload: Record<string, unknown>;
status: string;
commandId: string;
now: string;
}): void {
extractPlanStepInputs(input.payload, input.planId).forEach((step) => {
upsertPlanStep({
id: step.id,
planId: input.planId,
title: step.title,
status: step.status || input.status,
seq: step.seq,
dependsOn: step.dependsOn,
preferredSkillIds: step.preferredSkillIds,
payload: {
source: "qimingclaw-create-plan",
commandId: input.commandId,
dependsOn: step.dependsOn,
preferredSkillIds: step.preferredSkillIds,
original: step.original,
},
now: input.now,
});
});
}
function upsertCreatedPlanTasks(input: {
planId: string;
payload: Record<string, unknown>;
@@ -885,7 +948,7 @@ function upsertCreatedPlanTasks(input: {
commandId: string;
now: string;
}): void {
extractPlanTaskInputs(input.payload).forEach((task, index) => {
extractPlanTaskInputs(input.payload, input.planId).forEach((task, index) => {
const title = task.title || `计划任务 ${index + 1}`;
const taskId = task.id || `${input.planId}:task:${safeId(`${index + 1}-${title}`)}`;
upsertTask({
@@ -908,7 +971,60 @@ function upsertCreatedPlanTasks(input: {
});
}
function extractPlanTaskInputs(payload: Record<string, unknown>): Array<{
function extractPlanStepInputs(
payload: Record<string, unknown>,
planId: string,
): Array<{
id: string;
title: string;
status: string;
seq: number;
dependsOn: string[];
preferredSkillIds: string[];
original: unknown;
}> {
const steps = unknownArray(payload.steps)
.map((step, index) => planStepInputFromRecord(objectRecord(step), index, planId))
.filter((step): step is NonNullable<typeof step> => Boolean(step));
if (steps.length > 0) return steps;
if (unknownArray(payload.tasks).length === 0) return [];
return [{
id: `${planId}:step:default`,
title: "默认执行步骤",
status: "pending",
seq: 1,
dependsOn: [],
preferredSkillIds: [],
original: { source: "default_step", task_count: unknownArray(payload.tasks).length },
}];
}
function planStepInputFromRecord(
record: Record<string, unknown> | null,
index: number,
planId: string,
): ReturnType<typeof extractPlanStepInputs>[number] | null {
if (!record) return null;
const title = stringValue(record.title ?? record.name ?? record.summary)
|| `计划步骤 ${index + 1}`;
const stepId = stringValue(record.step_id ?? record.stepId ?? record.id)
|| `${planId}:step:${index + 1}-${safeId(title)}`;
return {
id: stepId,
title,
status: stringValue(record.status) || "pending",
seq: typeof record.seq === "number" ? record.seq : Number(record.seq) || index + 1,
dependsOn: normalizeSkillIds(unknownArray(record.depends_on ?? record.dependsOn)),
preferredSkillIds: normalizeSkillIds([
...unknownArray(record.skills),
...unknownArray(record.preferred_skills),
...unknownArray(record.preferredSkills),
]),
original: record,
};
}
function extractPlanTaskInputs(payload: Record<string, unknown>, planId: string): Array<{
id: string;
stepId: string;
title: string;
@@ -918,9 +1034,11 @@ function extractPlanTaskInputs(payload: Record<string, unknown>): Array<{
skills: string[];
original: unknown;
}> {
const rawSteps = unknownArray(payload.steps);
const defaultStepId = rawSteps.length > 0 ? "" : `${planId}:step:default`;
const explicitTasks = unknownArray(payload.tasks)
.map((task, index) => planTaskInputFromRecord(objectRecord(task), index, ""));
const stepTasks = unknownArray(payload.steps).flatMap((step, stepIndex) => {
.map((task, index) => planTaskInputFromRecord(objectRecord(task), index, defaultStepId));
const stepTasks = rawSteps.flatMap((step, stepIndex) => {
const stepRecord = objectRecord(step);
if (!stepRecord) return [];
const nestedTasks = unknownArray(stepRecord.tasks);
@@ -1091,6 +1209,12 @@ function parseStoredPayload(value: unknown): unknown {
}
}
function parseStringArray(value: unknown): string[] {
const parsed = parseStoredPayload(value);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
}
function toPlanRecord(row: Record<string, unknown>): DigitalEmployeePlanRecord {
return {
id: stringField(row, "id"),
@@ -1125,6 +1249,25 @@ function toTaskRecord(row: Record<string, unknown>): DigitalEmployeeTaskRecord {
};
}
function toPlanStepRecord(row: Record<string, unknown>): DigitalEmployeePlanStepRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: stringField(row, "plan_id"),
title: stringField(row, "title"),
status: stringField(row, "status"),
seq: typeof row.seq === "number" ? row.seq : Number(row.seq) || 0,
dependsOn: parseStringArray(row.depends_on),
preferredSkillIds: parseStringArray(row.preferred_skill_ids),
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
};
}
function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
return {
id: stringField(row, "id"),
@@ -1279,6 +1422,52 @@ function upsertPlan(input: {
markOutbox("plan", input.id, "upsert", input, input.now);
}
function upsertPlanStep(input: {
id: string;
planId: string;
title: string;
status: string;
seq: number;
dependsOn: string[];
preferredSkillIds: string[];
payload: unknown;
now: string;
}): void {
const db = getDigitalEmployeeDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_plan_steps (
id, plan_id, title, status, seq, depends_on, preferred_skill_ids,
payload, sync_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
ON CONFLICT(id) DO UPDATE SET
plan_id = excluded.plan_id,
title = excluded.title,
status = excluded.status,
seq = excluded.seq,
depends_on = excluded.depends_on,
preferred_skill_ids = excluded.preferred_skill_ids,
payload = excluded.payload,
sync_status = CASE
WHEN digital_plan_steps.sync_status = 'synced' THEN 'pending'
ELSE digital_plan_steps.sync_status
END,
updated_at = excluded.updated_at
`).run(
input.id,
input.planId,
input.title,
input.status,
input.seq,
stringify(input.dependsOn),
stringify(input.preferredSkillIds),
stringify(input.payload),
input.now,
input.now,
);
markOutbox("plan_step", input.id, "upsert", input, input.now);
}
function upsertTask(input: {
id: string;
planId: string;

View File

@@ -320,6 +320,80 @@ describe("digital employee sync service", () => {
sync_error: null,
});
});
it("syncs plan step business views and acknowledgements", async () => {
insertEntity("plan_step", "step-1");
insertOutbox(
"plan_step:upsert:step-1",
"plan_step",
"step-1",
JSON.stringify({
planId: "plan-1",
title: "整理客户列表",
status: "pending",
seq: 2,
dependsOn: ["step-0"],
preferredSkillIds: ["qimingclaw-mcp-tool-crm-crm-search"],
}),
);
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{
entity_type: "plan_step",
entity_id: "step-1",
remote_id: "remote-step-1",
},
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(lastSyncRequestBody()).toMatchObject({
items: [
expect.objectContaining({
entity_type: "plan_step",
business_view: expect.objectContaining({
entity_type: "plan_step",
local_id: "step-1",
plan_id: "plan-1",
step_id: "step-1",
title: "整理客户列表",
status: "pending",
seq: 2,
depends_on: ["step-0"],
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
}),
}),
],
});
expect(status.failed).toBe(0);
expect(readEntity("plan_step", "step-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-step-1",
sync_error: null,
});
});
it("marks plan step business failures as failed", async () => {
insertEntity("plan_step", "step-rejected");
insertOutbox("plan_step:upsert:step-rejected", "plan_step", "step-rejected");
mockState.fetch.mockResolvedValue(
jsonResponse({ code: "0001", message: "步骤同步被拒绝", data: null }),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(1);
expect(readEntity("plan_step", "step-rejected")).toMatchObject({
sync_status: "failed",
sync_error: "步骤同步被拒绝",
});
});
});
function insertPlan(id: string): void {
@@ -340,13 +414,18 @@ function insertEntity(entityType: string, id: string): void {
});
}
function insertOutbox(id: string, entityType: string, entityId: string): void {
function insertOutbox(
id: string,
entityType: string,
entityId: string,
payload = '{"ok":true}',
): void {
mockState.db?.outbox.set(id, {
id,
entity_type: entityType,
entity_id: entityId,
operation: "upsert",
payload: '{"ok":true}',
payload,
status: "pending",
attempts: 0,
last_attempt_at: null,
@@ -376,6 +455,7 @@ function entityMap(
): Map<string, TestSyncEntityRow> | undefined {
if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans;
if (entityType === "plan_step") return mockState.db.planSteps;
if (entityType === "artifact") return mockState.db.artifacts;
if (entityType === "approval") return mockState.db.approvals;
return undefined;
@@ -441,6 +521,7 @@ interface TestAttemptRow {
class TestDb {
outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestSyncEntityRow>();
planSteps = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>();
approvals = new Map<string, TestSyncEntityRow>();
attempts: TestAttemptRow[] = [];
@@ -543,6 +624,11 @@ class TestDb {
return this.approvals.get(id);
}
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_plan_steps")) {
const [id] = args as [string];
return this.planSteps.get(id);
}
if (
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
) {
@@ -640,7 +726,7 @@ class TestDb {
return { changes: before - this.attempts.length };
}
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'synced'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|artifacts|approvals) SET sync_status = 'synced'/.test(sql)) {
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
@@ -652,7 +738,7 @@ class TestDb {
return { changes: row ? 1 : 0 };
}
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
const [syncError, id] = args as [string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
@@ -668,6 +754,7 @@ class TestDb {
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
return this.plans;
}
}

View File

@@ -447,6 +447,19 @@ function buildBusinessView(
plan_id: stringField(record.planId ?? record.plan_id) || null,
assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_id) || null,
};
case "plan_step":
return {
...base,
plan_id: stringField(record.planId ?? record.plan_id) || null,
step_id: row.entity_id,
seq: typeof record.seq === "number" ? record.seq : null,
depends_on: Array.isArray(record.dependsOn ?? record.depends_on)
? record.dependsOn ?? record.depends_on
: [],
preferred_skill_ids: Array.isArray(record.preferredSkillIds ?? record.preferred_skill_ids)
? record.preferredSkillIds ?? record.preferred_skill_ids
: [],
};
case "run":
return {
...base,
@@ -747,6 +760,8 @@ function entitySummarySelect(entityType: string): string | null {
return "SELECT title, status, objective, payload FROM digital_plans WHERE id = ?";
case "task":
return "SELECT title, status, NULL AS objective, payload FROM digital_tasks WHERE id = ?";
case "plan_step":
return "SELECT title, status, NULL AS objective, payload FROM digital_plan_steps WHERE id = ?";
case "run":
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
case "event":
@@ -843,6 +858,8 @@ function entityTable(entityType: string): string | null {
return "digital_plans";
case "task":
return "digital_tasks";
case "plan_step":
return "digital_plan_steps";
case "run":
return "digital_runs";
case "event":

View File

@@ -445,7 +445,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans``digital_tasks``digital_runs``digital_events``digital_artifacts``digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情和流程投影本地记录为空时才回落到 qimingclaw 固定适配任务。
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans``digital_plan_steps``digital_tasks``digital_runs``digital_events``digital_artifacts``digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情和流程投影本地记录为空时才回落到 qimingclaw 固定适配任务。
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings保存时会记录 `digital_workday_*` 本地事件并进入 outbox避免这些页面操作只留在 webview localStorage。
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store``/api/dashboard``/api/debug/plan/:id/flow``/api/debug/task/:id/flow``/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
@@ -538,10 +538,11 @@ GET /api/digital-employee/approvals
以下清单按当前 qimingclaw 已落地状态盘点,不重复列出已完成的前端嵌入、本地 Plan / Task / Run / Event / Artifact / Approval、outbox 同步、技能投影和同步诊断。
1. **PlanStep 与依赖图**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务。
- 缺口:没有正式 `digital_plan_steps` 表、步骤依赖、步骤级状态、步骤级重试/跳过/取消和跨任务编排关系
- 建议:先补 PlanStep 本地表和 projection再让任务中心 flow 使用真实步骤图
1. **PlanStep 与依赖图(已开始)**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图
- 后续缺口:步骤级重试/跳过/取消、步骤依赖调度和跨任务编排执行仍待吸收
- 建议:下一轮围绕 Task Agent 状态机补步骤级治理命令。
2. **调度 / 定时 / 周期任务**
- 已吸收:`/api/scheduler/jobs` 有 qimingclaw 兼容投影,`create_schedule``run_schedule_now` 能返回本地可解释结果。
@@ -618,6 +619,7 @@ GET /api/digital-employee/approvals
```text
digital_plans
digital_plan_steps
digital_tasks
digital_runs
digital_events