吸收数字员工计划步骤依赖图
This commit is contained in:
@@ -22,6 +22,7 @@ import type {
|
|||||||
GovernanceCommandRequest,
|
GovernanceCommandRequest,
|
||||||
GovernanceCommandResponse,
|
GovernanceCommandResponse,
|
||||||
PlanDispatchResponse,
|
PlanDispatchResponse,
|
||||||
|
PlanStepSummary,
|
||||||
SkillSpec,
|
SkillSpec,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
@@ -178,6 +179,7 @@ interface QimingclawGovernanceCommandRecord {
|
|||||||
interface QimingclawRuntimeRecords {
|
interface QimingclawRuntimeRecords {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
plans: QimingclawPlanRecord[];
|
plans: QimingclawPlanRecord[];
|
||||||
|
planSteps?: QimingclawPlanStepRecord[];
|
||||||
tasks: QimingclawTaskRecord[];
|
tasks: QimingclawTaskRecord[];
|
||||||
runs: QimingclawRunRecord[];
|
runs: QimingclawRunRecord[];
|
||||||
events: QimingclawEventRecord[];
|
events: QimingclawEventRecord[];
|
||||||
@@ -200,6 +202,23 @@ interface QimingclawPlanRecord {
|
|||||||
updatedAt: string;
|
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 {
|
interface QimingclawTaskRecord {
|
||||||
id: string;
|
id: string;
|
||||||
remoteId?: string | null;
|
remoteId?: string | null;
|
||||||
@@ -774,6 +793,10 @@ function runtimePlans(snapshot: QimingclawSnapshot | null): QimingclawPlanRecord
|
|||||||
return snapshot?.runtime?.plans ?? [];
|
return snapshot?.runtime?.plans ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimePlanSteps(snapshot: QimingclawSnapshot | null): QimingclawPlanStepRecord[] {
|
||||||
|
return snapshot?.runtime?.planSteps ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function runtimeTasks(snapshot: QimingclawSnapshot | null): QimingclawTaskRecord[] {
|
function runtimeTasks(snapshot: QimingclawSnapshot | null): QimingclawTaskRecord[] {
|
||||||
return snapshot?.runtime?.tasks ?? [];
|
return snapshot?.runtime?.tasks ?? [];
|
||||||
}
|
}
|
||||||
@@ -796,6 +819,7 @@ function runtimeApprovals(snapshot: QimingclawSnapshot | null): QimingclawApprov
|
|||||||
|
|
||||||
function hasRuntimeRecords(snapshot: QimingclawSnapshot | null): boolean {
|
function hasRuntimeRecords(snapshot: QimingclawSnapshot | null): boolean {
|
||||||
return runtimePlans(snapshot).length > 0
|
return runtimePlans(snapshot).length > 0
|
||||||
|
|| runtimePlanSteps(snapshot).length > 0
|
||||||
|| runtimeTasks(snapshot).length > 0
|
|| runtimeTasks(snapshot).length > 0
|
||||||
|| runtimeArtifacts(snapshot).length > 0
|
|| runtimeArtifacts(snapshot).length > 0
|
||||||
|| runtimeApprovals(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);
|
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 {
|
function planRecordStatus(plan: QimingclawPlanRecord, tasks: QimingclawTaskRecord[]): string {
|
||||||
if (tasks.some((task) => taskStatusTone(task.status) === 'danger')) return 'failed';
|
if (tasks.some((task) => taskStatusTone(task.status) === 'danger')) return 'failed';
|
||||||
if (tasks.some((task) => taskStatusTone(task.status) === 'running')) return 'running';
|
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 {
|
function runtimePlanToDebugPlan(plan: QimingclawPlanRecord, snapshot: QimingclawSnapshot | null): DebugPlan {
|
||||||
const tasks = runtimePlanTasks(snapshot, plan.id);
|
const tasks = runtimePlanTasks(snapshot, plan.id);
|
||||||
|
const planSteps = runtimeStepsForPlan(snapshot, plan.id);
|
||||||
const status = planRecordStatus(plan, tasks);
|
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 {
|
return {
|
||||||
plan_id: plan.id,
|
plan_id: plan.id,
|
||||||
title: plan.title,
|
title: plan.title,
|
||||||
@@ -904,19 +948,43 @@ function runtimePlanToDebugPlan(plan: QimingclawPlanRecord, snapshot: Qimingclaw
|
|||||||
updated_at: latestRuntimeTime(plan, tasks),
|
updated_at: latestRuntimeTime(plan, tasks),
|
||||||
source: plan.source,
|
source: plan.source,
|
||||||
sync_status: plan.syncStatus,
|
sync_status: plan.syncStatus,
|
||||||
steps: [
|
steps,
|
||||||
{
|
};
|
||||||
step_id: `${plan.id}:runtime`,
|
}
|
||||||
title: 'qimingclaw 执行记录',
|
|
||||||
depends_on: [],
|
function runtimePlanStepToSummary(
|
||||||
preferred_skills: [...new Set(tasks.map((task) => task.assignedSkillId).filter((value): value is string => Boolean(value)))],
|
step: QimingclawPlanStepRecord,
|
||||||
tasks: tasks.map((task) => ({
|
tasks: QimingclawTaskRecord[],
|
||||||
task_id: task.id,
|
): PlanStepSummary {
|
||||||
title: task.title,
|
return {
|
||||||
status: task.status,
|
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,
|
title: task.title,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
plan_id: task.planId ?? null,
|
plan_id: task.planId ?? null,
|
||||||
step_id: task.planId ? `${task.planId}:runtime` : 'qimingclaw-runtime',
|
step_id: taskStepId(task),
|
||||||
assigned_skill_id: task.assignedSkillId ?? '',
|
assigned_skill_id: task.assignedSkillId ?? '',
|
||||||
description: task.syncError ?? '',
|
description: task.syncError ?? '',
|
||||||
created_at: task.createdAt,
|
created_at: task.createdAt,
|
||||||
@@ -1260,13 +1328,16 @@ function buildDebugStore(snapshot: QimingclawSnapshot | null): DebugStoreRespons
|
|||||||
const plans = filterPlans(null, snapshot);
|
const plans = filterPlans(null, snapshot);
|
||||||
const tasks = buildTasks(null, snapshot);
|
const tasks = buildTasks(null, snapshot);
|
||||||
const runs = buildTasksResponse(null, snapshot).runs;
|
const runs = buildTasksResponse(null, snapshot).runs;
|
||||||
|
const realPlanSteps = runtimePlanSteps(snapshot);
|
||||||
return {
|
return {
|
||||||
tables: {
|
tables: {
|
||||||
canonical_events: buildCanonicalEvents(snapshot, tasks, runs),
|
canonical_events: buildCanonicalEvents(snapshot, tasks, runs),
|
||||||
tasks,
|
tasks,
|
||||||
runs,
|
runs,
|
||||||
plans,
|
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),
|
approvals: buildApprovals(snapshot),
|
||||||
artifacts: buildArtifacts(snapshot),
|
artifacts: buildArtifacts(snapshot),
|
||||||
},
|
},
|
||||||
@@ -2274,10 +2345,45 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
|
|||||||
if (hasRuntimeRecords(snapshot)) {
|
if (hasRuntimeRecords(snapshot)) {
|
||||||
const plan = runtimePlans(snapshot).find((candidate) => candidate.id === planId);
|
const plan = runtimePlans(snapshot).find((candidate) => candidate.id === planId);
|
||||||
const tasks = buildTasks(planId, snapshot);
|
const tasks = buildTasks(planId, snapshot);
|
||||||
|
const planSteps = runtimeStepsForPlan(snapshot, planId);
|
||||||
const runs = buildTasksResponse(planId, snapshot).runs;
|
const runs = buildTasksResponse(planId, snapshot).runs;
|
||||||
const events = runtimeEvents(snapshot).filter((event) => event.planId === planId || tasks.some((task) => task.task_id === event.taskId));
|
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 title = plan?.title ?? tasks[0]?.title ?? planId;
|
||||||
const objective = plan?.objective ?? 'qimingclaw 本地运行记录';
|
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 {
|
return {
|
||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
plan_title: title,
|
plan_title: title,
|
||||||
@@ -2298,17 +2404,9 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
|
|||||||
color: '#38bdf8',
|
color: '#38bdf8',
|
||||||
source_type: 'plan',
|
source_type: 'plan',
|
||||||
},
|
},
|
||||||
...tasks.map((task, index) => ({
|
...qimingclawFlowSteps,
|
||||||
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',
|
|
||||||
})),
|
|
||||||
...runs.map((run, index) => ({
|
...runs.map((run, index) => ({
|
||||||
seq: tasks.length + index + 2,
|
seq: nextSeq + index,
|
||||||
lane: 'execution',
|
lane: 'execution',
|
||||||
label: run.lifecycle || 'Run',
|
label: run.lifecycle || 'Run',
|
||||||
sub_label: run.run_id,
|
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 {
|
function buildTaskFlow(taskId: string, snapshot: QimingclawSnapshot | null = null): FlowResponse {
|
||||||
const task = runtimeTasks(snapshot).find((candidate) => candidate.id === taskId);
|
const task = runtimeTasks(snapshot).find((candidate) => candidate.id === taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
|||||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { spawnSync } = require("child_process");
|
|||||||
const packageRoot = path.resolve(__dirname, "..");
|
const packageRoot = path.resolve(__dirname, "..");
|
||||||
const requiredDigitalTables = [
|
const requiredDigitalTables = [
|
||||||
"digital_plans",
|
"digital_plans",
|
||||||
|
"digital_plan_steps",
|
||||||
"digital_tasks",
|
"digital_tasks",
|
||||||
"digital_runs",
|
"digital_runs",
|
||||||
"digital_events",
|
"digital_events",
|
||||||
@@ -25,6 +26,28 @@ const testFiles = [
|
|||||||
"src/main/services/digitalEmployee/planTemplateService.test.ts",
|
"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 = {}) {
|
function runStep(title, command, args, options = {}) {
|
||||||
console.log(`\n[DigitalEmployeeCheck] ${title}`);
|
console.log(`\n[DigitalEmployeeCheck] ${title}`);
|
||||||
const result = spawnSync(command, args, {
|
const result = spawnSync(command, args, {
|
||||||
@@ -65,14 +88,22 @@ function checkLocalDatabaseSchema() {
|
|||||||
return;
|
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"], {
|
const result = spawnSync("sqlite3", [dbPath, ".tables"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
if (result.error) {
|
|
||||||
console.log(`[DigitalEmployeeCheck] Skip DB schema check; sqlite3 unavailable: ${result.error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
throw new Error(result.stderr || "sqlite3 .tables failed");
|
throw new Error(result.stderr || "sqlite3 .tables failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,24 @@ const DIGITAL_EMPLOYEE_SCHEMA_SQL = `
|
|||||||
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
|
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 (
|
CREATE TABLE IF NOT EXISTS digital_runs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
remote_id TEXT,
|
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_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_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_runs_task ON digital_runs(task_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at);
|
CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at);
|
||||||
|
|||||||
@@ -255,6 +255,16 @@ describe("digital employee state service", () => {
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
assigned_skill_id: "qimingclaw-mcp-tool-crm-crm-search",
|
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({
|
expect(mockState.db?.runs.get("governance-run-command-1")).toMatchObject({
|
||||||
plan_id: "plan-customer-followup",
|
plan_id: "plan-customer-followup",
|
||||||
task_id: "governance-task-command-1",
|
task_id: "governance-task-command-1",
|
||||||
@@ -270,9 +280,54 @@ describe("digital employee state service", () => {
|
|||||||
entity_id: "plan-customer-followup",
|
entity_id: "plan-customer-followup",
|
||||||
status: "pending",
|
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);
|
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 () => {
|
it("preserves created plan title across follow-up governance commands", async () => {
|
||||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||||
|
|
||||||
@@ -324,6 +379,20 @@ interface TestTaskRow {
|
|||||||
updated_at: string;
|
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 {
|
interface TestRunRow {
|
||||||
id: string;
|
id: string;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
@@ -391,6 +460,7 @@ interface TestOutboxRow {
|
|||||||
|
|
||||||
class TestDb {
|
class TestDb {
|
||||||
plans = new Map<string, TestPlanRow>();
|
plans = new Map<string, TestPlanRow>();
|
||||||
|
planSteps = new Map<string, TestPlanStepRow>();
|
||||||
tasks = new Map<string, TestTaskRow>();
|
tasks = new Map<string, TestTaskRow>();
|
||||||
runs = new Map<string, TestRunRow>();
|
runs = new Map<string, TestRunRow>();
|
||||||
events = new Map<string, TestEventRow>();
|
events = new Map<string, TestEventRow>();
|
||||||
@@ -409,12 +479,23 @@ class TestDb {
|
|||||||
} {
|
} {
|
||||||
const normalized = sql.replace(/\s+/g, " ").trim();
|
const normalized = sql.replace(/\s+/g, " ").trim();
|
||||||
return {
|
return {
|
||||||
all: () => [],
|
all: (...args: unknown[]) => this.all(normalized, args),
|
||||||
get: (...args: unknown[]) => this.get(normalized, args),
|
get: (...args: unknown[]) => this.get(normalized, args),
|
||||||
run: (...args: unknown[]) => this.run(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 {
|
private get(sql: string, args: unknown[]): unknown {
|
||||||
if (sql.startsWith("SELECT title, objective FROM digital_plans")) {
|
if (sql.startsWith("SELECT title, objective FROM digital_plans")) {
|
||||||
return this.plans.get(args[0] as string);
|
return this.plans.get(args[0] as string);
|
||||||
@@ -450,6 +531,36 @@ class TestDb {
|
|||||||
return { changes: 1 };
|
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")) {
|
if (sql.startsWith("INSERT INTO digital_tasks")) {
|
||||||
const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [
|
const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ export interface DigitalEmployeeTaskRecord {
|
|||||||
updatedAt: string;
|
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 {
|
export interface DigitalEmployeeRunRecord {
|
||||||
id: string;
|
id: string;
|
||||||
remoteId?: string | null;
|
remoteId?: string | null;
|
||||||
@@ -155,6 +172,7 @@ export interface DigitalEmployeeApprovalRecord {
|
|||||||
export interface DigitalEmployeeRuntimeRecords {
|
export interface DigitalEmployeeRuntimeRecords {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
plans: DigitalEmployeePlanRecord[];
|
plans: DigitalEmployeePlanRecord[];
|
||||||
|
planSteps: DigitalEmployeePlanStepRecord[];
|
||||||
tasks: DigitalEmployeeTaskRecord[];
|
tasks: DigitalEmployeeTaskRecord[];
|
||||||
runs: DigitalEmployeeRunRecord[];
|
runs: DigitalEmployeeRunRecord[];
|
||||||
events: DigitalEmployeeEventRecord[];
|
events: DigitalEmployeeEventRecord[];
|
||||||
@@ -479,6 +497,7 @@ export function readDigitalEmployeeRuntimeRecords(
|
|||||||
return {
|
return {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
plans: [],
|
plans: [],
|
||||||
|
planSteps: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
runs: [],
|
runs: [],
|
||||||
events: [],
|
events: [],
|
||||||
@@ -494,6 +513,14 @@ export function readDigitalEmployeeRuntimeRecords(
|
|||||||
ORDER BY updated_at DESC, created_at DESC
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(safeLimit) as Array<Record<string, unknown>>;
|
`).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(`
|
const tasks = db.prepare(`
|
||||||
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
|
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
|
||||||
sync_status, last_synced_at, sync_error, created_at, updated_at
|
sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||||
@@ -533,6 +560,7 @@ export function readDigitalEmployeeRuntimeRecords(
|
|||||||
return {
|
return {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
plans: plans.map(toPlanRecord),
|
plans: plans.map(toPlanRecord),
|
||||||
|
planSteps: planSteps.map(toPlanStepRecord),
|
||||||
tasks: tasks.map(toTaskRecord),
|
tasks: tasks.map(toTaskRecord),
|
||||||
runs: runs.map(toRunRecord),
|
runs: runs.map(toRunRecord),
|
||||||
events: events.map(toEventRecord),
|
events: events.map(toEventRecord),
|
||||||
@@ -802,6 +830,13 @@ export function recordDigitalEmployeeGovernanceCommand(
|
|||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
if (command.commandKind === "create_plan") {
|
if (command.commandKind === "create_plan") {
|
||||||
|
upsertCreatedPlanSteps({
|
||||||
|
planId: ids.planId,
|
||||||
|
payload,
|
||||||
|
status: "pending",
|
||||||
|
commandId,
|
||||||
|
now,
|
||||||
|
});
|
||||||
upsertCreatedPlanTasks({
|
upsertCreatedPlanTasks({
|
||||||
planId: ids.planId,
|
planId: ids.planId,
|
||||||
payload,
|
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: {
|
function upsertCreatedPlanTasks(input: {
|
||||||
planId: string;
|
planId: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
@@ -885,7 +948,7 @@ function upsertCreatedPlanTasks(input: {
|
|||||||
commandId: string;
|
commandId: string;
|
||||||
now: string;
|
now: string;
|
||||||
}): void {
|
}): void {
|
||||||
extractPlanTaskInputs(input.payload).forEach((task, index) => {
|
extractPlanTaskInputs(input.payload, input.planId).forEach((task, index) => {
|
||||||
const title = task.title || `计划任务 ${index + 1}`;
|
const title = task.title || `计划任务 ${index + 1}`;
|
||||||
const taskId = task.id || `${input.planId}:task:${safeId(`${index + 1}-${title}`)}`;
|
const taskId = task.id || `${input.planId}:task:${safeId(`${index + 1}-${title}`)}`;
|
||||||
upsertTask({
|
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;
|
id: string;
|
||||||
stepId: string;
|
stepId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -918,9 +1034,11 @@ function extractPlanTaskInputs(payload: Record<string, unknown>): Array<{
|
|||||||
skills: string[];
|
skills: string[];
|
||||||
original: unknown;
|
original: unknown;
|
||||||
}> {
|
}> {
|
||||||
|
const rawSteps = unknownArray(payload.steps);
|
||||||
|
const defaultStepId = rawSteps.length > 0 ? "" : `${planId}:step:default`;
|
||||||
const explicitTasks = unknownArray(payload.tasks)
|
const explicitTasks = unknownArray(payload.tasks)
|
||||||
.map((task, index) => planTaskInputFromRecord(objectRecord(task), index, ""));
|
.map((task, index) => planTaskInputFromRecord(objectRecord(task), index, defaultStepId));
|
||||||
const stepTasks = unknownArray(payload.steps).flatMap((step, stepIndex) => {
|
const stepTasks = rawSteps.flatMap((step, stepIndex) => {
|
||||||
const stepRecord = objectRecord(step);
|
const stepRecord = objectRecord(step);
|
||||||
if (!stepRecord) return [];
|
if (!stepRecord) return [];
|
||||||
const nestedTasks = unknownArray(stepRecord.tasks);
|
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 {
|
function toPlanRecord(row: Record<string, unknown>): DigitalEmployeePlanRecord {
|
||||||
return {
|
return {
|
||||||
id: stringField(row, "id"),
|
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 {
|
function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
|
||||||
return {
|
return {
|
||||||
id: stringField(row, "id"),
|
id: stringField(row, "id"),
|
||||||
@@ -1279,6 +1422,52 @@ function upsertPlan(input: {
|
|||||||
markOutbox("plan", input.id, "upsert", input, input.now);
|
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: {
|
function upsertTask(input: {
|
||||||
id: string;
|
id: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
|
|||||||
@@ -320,6 +320,80 @@ describe("digital employee sync service", () => {
|
|||||||
sync_error: null,
|
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 {
|
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, {
|
mockState.db?.outbox.set(id, {
|
||||||
id,
|
id,
|
||||||
entity_type: entityType,
|
entity_type: entityType,
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
operation: "upsert",
|
operation: "upsert",
|
||||||
payload: '{"ok":true}',
|
payload,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
last_attempt_at: null,
|
last_attempt_at: null,
|
||||||
@@ -376,6 +455,7 @@ function entityMap(
|
|||||||
): Map<string, TestSyncEntityRow> | undefined {
|
): Map<string, TestSyncEntityRow> | undefined {
|
||||||
if (!mockState.db) return undefined;
|
if (!mockState.db) return undefined;
|
||||||
if (entityType === "plan") return mockState.db.plans;
|
if (entityType === "plan") return mockState.db.plans;
|
||||||
|
if (entityType === "plan_step") return mockState.db.planSteps;
|
||||||
if (entityType === "artifact") return mockState.db.artifacts;
|
if (entityType === "artifact") return mockState.db.artifacts;
|
||||||
if (entityType === "approval") return mockState.db.approvals;
|
if (entityType === "approval") return mockState.db.approvals;
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -441,6 +521,7 @@ interface TestAttemptRow {
|
|||||||
class TestDb {
|
class TestDb {
|
||||||
outbox = new Map<string, TestOutboxRow>();
|
outbox = new Map<string, TestOutboxRow>();
|
||||||
plans = new Map<string, TestSyncEntityRow>();
|
plans = new Map<string, TestSyncEntityRow>();
|
||||||
|
planSteps = new Map<string, TestSyncEntityRow>();
|
||||||
artifacts = new Map<string, TestSyncEntityRow>();
|
artifacts = new Map<string, TestSyncEntityRow>();
|
||||||
approvals = new Map<string, TestSyncEntityRow>();
|
approvals = new Map<string, TestSyncEntityRow>();
|
||||||
attempts: TestAttemptRow[] = [];
|
attempts: TestAttemptRow[] = [];
|
||||||
@@ -543,6 +624,11 @@ class TestDb {
|
|||||||
return this.approvals.get(id);
|
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 (
|
if (
|
||||||
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
||||||
) {
|
) {
|
||||||
@@ -640,7 +726,7 @@ class TestDb {
|
|||||||
return { changes: before - this.attempts.length };
|
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 [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
||||||
const row = this.entityRowsForUpdate(sql).get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -652,7 +738,7 @@ class TestDb {
|
|||||||
return { changes: row ? 1 : 0 };
|
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 [syncError, id] = args as [string, string];
|
||||||
const row = this.entityRowsForUpdate(sql).get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -668,6 +754,7 @@ class TestDb {
|
|||||||
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
|
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
|
||||||
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
|
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
|
||||||
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
||||||
|
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
||||||
return this.plans;
|
return this.plans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,19 @@ function buildBusinessView(
|
|||||||
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
||||||
assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_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":
|
case "run":
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -747,6 +760,8 @@ function entitySummarySelect(entityType: string): string | null {
|
|||||||
return "SELECT title, status, objective, payload FROM digital_plans WHERE id = ?";
|
return "SELECT title, status, objective, payload FROM digital_plans WHERE id = ?";
|
||||||
case "task":
|
case "task":
|
||||||
return "SELECT title, status, NULL AS objective, payload FROM digital_tasks WHERE id = ?";
|
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":
|
case "run":
|
||||||
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
|
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
|
||||||
case "event":
|
case "event":
|
||||||
@@ -843,6 +858,8 @@ function entityTable(entityType: string): string | null {
|
|||||||
return "digital_plans";
|
return "digital_plans";
|
||||||
case "task":
|
case "task":
|
||||||
return "digital_tasks";
|
return "digital_tasks";
|
||||||
|
case "plan_step":
|
||||||
|
return "digital_plan_steps";
|
||||||
case "run":
|
case "run":
|
||||||
return "digital_runs";
|
return "digital_runs";
|
||||||
case "event":
|
case "event":
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
||||||
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
|
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 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。
|
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings;保存时会记录 `digital_workday_*` 本地事件并进入 outbox,避免这些页面操作只留在 webview localStorage。
|
||||||
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
|
- 数字员工技能库通过 `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 / Approval;Run/Event payload 中的 `artifacts`、`outputs`、`files`、`outputPath`、`uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
|
- 数字员工调试存储、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 / Approval;Run/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 同步、技能投影和同步诊断。
|
以下清单按当前 qimingclaw 已落地状态盘点,不重复列出已完成的前端嵌入、本地 Plan / Task / Run / Event / Artifact / Approval、outbox 同步、技能投影和同步诊断。
|
||||||
|
|
||||||
1. **PlanStep 与依赖图**
|
1. **PlanStep 与依赖图(已开始)**
|
||||||
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务。
|
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务;PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`。
|
||||||
- 缺口:没有正式 `digital_plan_steps` 表、步骤依赖、步骤级状态、步骤级重试/跳过/取消和跨任务编排关系。
|
- 当前能力:PlanStep 会进入 `digital_sync_outbox`,entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图。
|
||||||
- 建议:先补 PlanStep 本地表和 projection,再让任务中心 flow 使用真实步骤图。
|
- 后续缺口:步骤级重试/跳过/取消、步骤依赖调度和跨任务编排执行仍待吸收。
|
||||||
|
- 建议:下一轮围绕 Task Agent 状态机补步骤级治理命令。
|
||||||
|
|
||||||
2. **调度 / 定时 / 周期任务**
|
2. **调度 / 定时 / 周期任务**
|
||||||
- 已吸收:`/api/scheduler/jobs` 有 qimingclaw 兼容投影,`create_schedule`、`run_schedule_now` 能返回本地可解释结果。
|
- 已吸收:`/api/scheduler/jobs` 有 qimingclaw 兼容投影,`create_schedule`、`run_schedule_now` 能返回本地可解释结果。
|
||||||
@@ -618,6 +619,7 @@ GET /api/digital-employee/approvals
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
digital_plans
|
digital_plans
|
||||||
|
digital_plan_steps
|
||||||
digital_tasks
|
digital_tasks
|
||||||
digital_runs
|
digital_runs
|
||||||
digital_events
|
digital_events
|
||||||
|
|||||||
Reference in New Issue
Block a user