吸收数字员工计划步骤依赖图
This commit is contained in:
@@ -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) {
|
||||
|
||||
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-C3UI1hbu.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Ddxmu09V.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 / 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 同步、技能投影和同步诊断。
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user