吸收数字员工周期调度闭环

This commit is contained in:
baiyanyun
2026-06-09 11:06:25 +08:00
parent f35d945d9c
commit 5ebc0a640b
18 changed files with 2083 additions and 145 deletions

View File

@@ -354,6 +354,77 @@ describe("digital employee state service", () => {
status: "approved",
});
});
it("records schedule lifecycle commands and manual runs", async () => {
const {
readDigitalEmployeeRuntimeRecords,
recordDigitalEmployeeGovernanceCommand,
} = await import("./stateService");
recordDigitalEmployeeGovernanceCommand({
commandKind: "create_schedule",
commandId: "command-schedule-create",
planId: "plan-scheduled",
scheduleId: "schedule-morning",
accepted: true,
payload: {
name: "晨间巡检",
prompt: "执行晨间巡检",
cron_expr: "0 9 * * *",
},
result: { schedule_id: "schedule-morning", status: "enabled" },
});
expect(mockState.db?.schedules.get("schedule-morning")).toMatchObject({
id: "schedule-morning",
plan_id: "plan-scheduled",
name: "晨间巡检",
cron_expr: "0 9 * * *",
status: "enabled",
enabled: 1,
});
expect(mockState.db?.outbox.get("schedule:upsert:schedule-morning")).toMatchObject({
entity_type: "schedule",
entity_id: "schedule-morning",
});
recordDigitalEmployeeGovernanceCommand({
commandKind: "pause_schedule",
commandId: "command-schedule-pause",
planId: "plan-scheduled",
scheduleId: "schedule-morning",
accepted: true,
result: { schedule_id: "schedule-morning", status: "paused" },
});
expect(mockState.db?.schedules.get("schedule-morning")).toMatchObject({
status: "paused",
enabled: 0,
});
recordDigitalEmployeeGovernanceCommand({
commandKind: "run_schedule_now",
commandId: "command-schedule-run",
planId: "plan-scheduled",
scheduleId: "schedule-morning",
accepted: true,
payload: { source: "test" },
result: { schedule_id: "schedule-morning", status: "running" },
});
expect(mockState.db?.scheduleRuns.get("schedule-morning:2026-06-07T08-00-00-000Z:1")).toMatchObject({
schedule_id: "schedule-morning",
plan_id: "plan-scheduled",
status: "running",
trigger_kind: "manual",
});
expect(mockState.db?.outbox.get("schedule_run:upsert:schedule-morning:2026-06-07T08-00-00-000Z:1")).toMatchObject({
entity_type: "schedule_run",
});
expect(readDigitalEmployeeRuntimeRecords()).toMatchObject({
schedules: [expect.objectContaining({ id: "schedule-morning", status: "paused" })],
scheduleRuns: [expect.objectContaining({ scheduleId: "schedule-morning", triggerKind: "manual" })],
});
});
});
interface TestPlanRow {
@@ -445,6 +516,49 @@ interface TestApprovalRow {
updated_at: string;
}
interface TestScheduleRow {
id: string;
plan_id: string | null;
name: string;
description: string | null;
cron_expr: string;
timezone: string;
status: string;
enabled: number;
prompt: string | null;
command: string | null;
payload: string;
next_run_at: string | null;
last_run_at: string | null;
catch_up_on_startup: number;
max_catch_up_runs: number;
max_run_history: number;
failure_count: number;
last_error: string | null;
sync_status: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
interface TestScheduleRunRow {
id: string;
schedule_id: string;
plan_id: string | null;
run_id: string | null;
due_at: string;
started_at: string | null;
finished_at: string | null;
status: string;
attempt_no: number;
trigger_kind: string;
error: string | null;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
}
interface TestOutboxRow {
id: string;
entity_type: string;
@@ -466,6 +580,8 @@ class TestDb {
events = new Map<string, TestEventRow>();
artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>();
schedules = new Map<string, TestScheduleRow>();
scheduleRuns = new Map<string, TestScheduleRunRow>();
outbox = new Map<string, TestOutboxRow>();
transaction<T>(callback: () => T): () => T {
@@ -488,6 +604,8 @@ class TestDb {
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_schedules")) return Array.from(this.schedules.values());
if (sql.includes("FROM digital_schedule_runs")) return Array.from(this.scheduleRuns.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());
@@ -503,6 +621,12 @@ class TestDb {
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) {
return this.approvals.get(args[0] as string);
}
if (sql.includes("FROM digital_schedules") && sql.includes("WHERE id = ?")) {
return this.schedules.get(args[0] as string);
}
if (sql.includes("FROM digital_schedule_runs") && sql.includes("WHERE id = ?")) {
return this.scheduleRuns.get(args[0] as string);
}
return { count: 0 };
}
@@ -561,6 +685,94 @@ class TestDb {
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_schedules")) {
const [
id,
planId,
name,
description,
cronExpr,
timezone,
status,
enabled,
prompt,
command,
payload,
nextRunAt,
lastRunAt,
catchUpOnStartup,
maxCatchUpRuns,
maxRunHistory,
failureCount,
lastError,
createdAt,
updatedAt,
deletedAt,
] = args as [string, string | null, string, string | null, string, string, string, number, string | null, string | null, string, string | null, string | null, number, number, number, number, string | null, string, string, string | null];
const previous = this.schedules.get(id);
this.schedules.set(id, {
id,
plan_id: planId,
name,
description,
cron_expr: cronExpr,
timezone,
status,
enabled,
prompt,
command,
payload,
next_run_at: nextRunAt,
last_run_at: lastRunAt,
catch_up_on_startup: catchUpOnStartup,
max_catch_up_runs: maxCatchUpRuns,
max_run_history: maxRunHistory,
failure_count: failureCount,
last_error: lastError,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
deleted_at: deletedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_schedule_runs")) {
const [id, scheduleId, planId, runId, dueAt, startedAt, status, attemptNo, triggerKind, payload, createdAt, updatedAt] = args as [
string,
string,
string | null,
string | null,
string,
string,
string,
number,
string,
string,
string,
string,
];
const previous = this.scheduleRuns.get(id);
this.scheduleRuns.set(id, {
id,
schedule_id: scheduleId,
plan_id: planId,
run_id: runId,
due_at: dueAt,
started_at: previous?.started_at ?? startedAt,
finished_at: previous?.finished_at ?? null,
status,
attempt_no: attemptNo,
trigger_kind: triggerKind,
error: previous?.error ?? null,
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,
@@ -629,6 +841,23 @@ class TestDb {
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("UPDATE digital_schedule_runs")) {
const [status, finishedAt, error, payload, updatedAt, id] = args as [string, string, string | null, string, string, string];
const previous = this.scheduleRuns.get(id);
if (previous) {
this.scheduleRuns.set(id, {
...previous,
status,
finished_at: finishedAt,
error,
payload,
updated_at: updatedAt,
sync_status: "pending",
});
}
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [
string,