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

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

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
schedules: [] as Array<Record<string, unknown>>,
startedRuns: [] as Array<Record<string, unknown>>,
finishedRuns: [] as Array<Record<string, unknown>>,
settings: {
enabled: true,
catch_up_on_startup: true,
max_run_history: 100,
max_catch_up_runs: 5,
max_concurrent_schedule_runs: 1,
},
}));
vi.mock("../startupPorts", () => ({
getConfiguredPorts: () => ({ agent: 60006 }),
}));
vi.mock("../constants", () => ({
LOCALHOST_HOSTNAME: "127.0.0.1",
}));
vi.mock("./stateService", () => ({
readDigitalEmployeeCronSettings: () => mockState.settings,
listDigitalEmployeeSchedules: () => mockState.schedules,
listDueDigitalEmployeeSchedules: () => mockState.schedules,
readDigitalEmployeeScheduleRuns: () => [],
upsertDigitalEmployeeSchedule: vi.fn(),
recordDigitalEmployeeScheduleRunStarted: (input: Record<string, unknown>) => {
const run = { id: `${input.scheduleId}:${input.dueAt}:1`, ...input };
mockState.startedRuns.push(run);
return run;
},
finishDigitalEmployeeScheduleRun: (id: string, status: string, options: Record<string, unknown>) => {
mockState.finishedRuns.push({ id, status, ...options });
return { id, status, ...options };
},
}));
describe("digital employee scheduler service", () => {
beforeEach(() => {
vi.resetModules();
mockState.startedRuns = [];
mockState.finishedRuns = [];
mockState.schedules = [{
id: "schedule-1",
planId: "plan-1",
name: "晨间巡检",
description: "每日巡检",
cronExpr: "0 9 * * *",
timezone: "system",
status: "enabled",
enabled: true,
prompt: "执行晨间巡检",
command: null,
payload: {},
nextRunAt: "2026-06-07T01:00:00.000Z",
lastRunAt: null,
catchUpOnStartup: true,
maxCatchUpRuns: 5,
maxRunHistory: 100,
failureCount: 0,
lastError: null,
syncStatus: "pending",
createdAt: "2026-06-07T00:00:00.000Z",
updatedAt: "2026-06-07T00:00:00.000Z",
deletedAt: null,
}];
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: true,
text: async () => JSON.stringify({ code: "0000", success: true, data: { request_id: "r1" } }),
})));
});
it("computes the next 5-field cron run", async () => {
const { nextCronRun } = await import("./schedulerService");
expect(nextCronRun("30 9 * * *", new Date("2026-06-07T01:29:30.000Z")).toISOString())
.toBe("2026-06-07T01:30:00.000Z");
});
it("activates due schedules through local ComputerServer", async () => {
const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService");
const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z"));
expect(result.activated).toBe(1);
expect(mockState.startedRuns).toHaveLength(1);
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:60006/computer/chat",
expect.objectContaining({ method: "POST" }),
);
expect(mockState.finishedRuns[0]).toMatchObject({ status: "completed" });
});
it("records failed runs with retry next run", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: false,
status: 500,
text: async () => "boom",
})));
const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService");
const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z"));
expect(result.errors).toHaveLength(1);
expect(mockState.finishedRuns[0]).toMatchObject({ status: "failed", error: "ComputerServer 500: boom" });
expect(mockState.finishedRuns[0].nextRunAt).toBeTruthy();
});
});

View File

@@ -0,0 +1,327 @@
import log from "electron-log";
import { LOCALHOST_HOSTNAME } from "../constants";
import { getConfiguredPorts } from "../startupPorts";
import {
finishDigitalEmployeeScheduleRun,
listDigitalEmployeeSchedules,
listDueDigitalEmployeeSchedules,
readDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns,
recordDigitalEmployeeScheduleRunStarted,
upsertDigitalEmployeeSchedule,
type DigitalEmployeeScheduleRecord,
} from "./stateService";
const SWEEP_INTERVAL_MS = 30_000;
const MAX_SCAN_MINUTES = 366 * 24 * 60;
const RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerSweeping = false;
let activeScheduleRuns = 0;
export interface DigitalEmployeeSchedulerCheckResult {
checkedAt: string;
due: number;
activated: number;
skipped: number;
errors: Array<{ scheduleId: string; error: string }>;
}
export function startDigitalEmployeeScheduler(): void {
if (schedulerTimer) return;
schedulerTimer = setInterval(() => {
void runDigitalEmployeeSchedulerCheck().catch((error) => {
log.warn("[DigitalEmployeeScheduler] Sweep failed:", error);
});
}, SWEEP_INTERVAL_MS);
void initializeScheduleNextRuns().then(() => runDigitalEmployeeSchedulerCheck()).catch((error) => {
log.warn("[DigitalEmployeeScheduler] Initial check failed:", error);
});
log.info("[DigitalEmployeeScheduler] Worker started");
}
export function stopDigitalEmployeeScheduler(): void {
if (!schedulerTimer) return;
clearInterval(schedulerTimer);
schedulerTimer = null;
log.info("[DigitalEmployeeScheduler] Worker stopped");
}
export async function runDigitalEmployeeSchedulerCheck(
now = new Date(),
): Promise<DigitalEmployeeSchedulerCheckResult> {
if (schedulerSweeping) {
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [] };
}
const settings = readDigitalEmployeeCronSettings();
if (!settings.enabled) {
return { checkedAt: now.toISOString(), due: 0, activated: 0, skipped: 0, errors: [] };
}
schedulerSweeping = true;
const due = listDueDigitalEmployeeSchedules(now.toISOString(), 50);
const result: DigitalEmployeeSchedulerCheckResult = {
checkedAt: now.toISOString(),
due: due.length,
activated: 0,
skipped: 0,
errors: [],
};
try {
for (const schedule of due) {
if (activeScheduleRuns >= settings.max_concurrent_schedule_runs) {
result.skipped += 1;
continue;
}
const dueRuns = computeDueRuns(schedule, now);
for (const dueAt of dueRuns) {
if (activeScheduleRuns >= settings.max_concurrent_schedule_runs) {
result.skipped += 1;
break;
}
try {
activeScheduleRuns += 1;
await executeSchedule(schedule, dueAt, "scheduled");
result.activated += 1;
} catch (error) {
result.errors.push({ scheduleId: schedule.id, error: normalizeError(error) });
} finally {
activeScheduleRuns = Math.max(0, activeScheduleRuns - 1);
}
}
}
} finally {
schedulerSweeping = false;
}
return result;
}
export async function runDigitalEmployeeScheduleNow(
scheduleId: string,
): Promise<{ ok: boolean; scheduleRunId?: string; error?: string }> {
const schedule = listDigitalEmployeeSchedules().find((item) => item.id === scheduleId);
if (!schedule) return { ok: false, error: "schedule not found" };
try {
const run = await executeSchedule(schedule, new Date().toISOString(), "manual");
return { ok: true, scheduleRunId: run?.id };
} catch (error) {
return { ok: false, error: normalizeError(error) };
}
}
async function initializeScheduleNextRuns(now = new Date()): Promise<void> {
for (const schedule of listDigitalEmployeeSchedules()) {
if (!schedule.enabled || schedule.status !== "enabled" || schedule.deletedAt) continue;
if (schedule.nextRunAt && schedule.catchUpOnStartup) continue;
const nextRunAt = nextCronRun(schedule.cronExpr, now).toISOString();
upsertDigitalEmployeeSchedule({
id: schedule.id,
planId: schedule.planId,
name: schedule.name,
description: schedule.description,
cronExpr: schedule.cronExpr,
timezone: schedule.timezone,
status: schedule.status,
enabled: schedule.enabled,
prompt: schedule.prompt,
command: schedule.command,
payload: objectPayload(schedule.payload),
nextRunAt,
catchUpOnStartup: schedule.catchUpOnStartup,
maxCatchUpRuns: schedule.maxCatchUpRuns,
maxRunHistory: schedule.maxRunHistory,
});
}
}
function computeDueRuns(schedule: DigitalEmployeeScheduleRecord, now: Date): string[] {
const dueAt = schedule.nextRunAt || now.toISOString();
const runs: string[] = [];
let cursor = new Date(dueAt);
const maxRuns = Math.max(1, Math.min(schedule.maxCatchUpRuns, 20));
while (cursor.getTime() <= now.getTime() && runs.length < maxRuns) {
runs.push(cursor.toISOString());
cursor = nextCronRun(schedule.cronExpr, cursor);
}
return runs.length > 0 ? runs : [now.toISOString()];
}
async function executeSchedule(
schedule: DigitalEmployeeScheduleRecord,
dueAt: string,
triggerKind: "scheduled" | "manual",
) {
const attemptNo = Math.min((schedule.failureCount || 0) + 1, 3);
const started = recordDigitalEmployeeScheduleRunStarted({
scheduleId: schedule.id,
planId: schedule.planId,
dueAt,
triggerKind,
attemptNo,
payload: {
chatRequest: buildScheduleChatRequest(schedule, dueAt, triggerKind),
},
});
if (!started) throw new Error("failed to record schedule run");
try {
const response = await postComputerChat(buildScheduleChatRequest(schedule, dueAt, triggerKind));
const nextRunAt = nextCronRun(schedule.cronExpr, new Date(dueAt)).toISOString();
finishDigitalEmployeeScheduleRun(started.id, "completed", { nextRunAt, response });
pruneScheduleRunHistory(schedule);
return started;
} catch (error) {
const message = normalizeError(error);
const retryDelay = RETRY_DELAYS_MS[Math.min(attemptNo - 1, RETRY_DELAYS_MS.length - 1)];
const canRetry = attemptNo < 3;
const nextRunAt = canRetry
? new Date(Date.now() + retryDelay).toISOString()
: nextCronRun(schedule.cronExpr, new Date(dueAt)).toISOString();
finishDigitalEmployeeScheduleRun(started.id, "failed", { error: message, nextRunAt });
throw error;
}
}
function buildScheduleChatRequest(
schedule: DigitalEmployeeScheduleRecord,
dueAt: string,
triggerKind: string,
) {
const payload = objectPayload(schedule.payload);
const prompt = schedule.prompt || schedule.command || stringFromUnknown(payload.prompt) || stringFromUnknown(payload.command) || schedule.name;
const stamp = Date.now();
return {
user_id: stringFromUnknown(payload.user_id) || "qimingclaw-scheduler",
project_id: schedule.planId || schedule.id,
prompt,
session_id: stringFromUnknown(payload.session_id) || `schedule-${schedule.id}`,
request_id: `schedule-${safeId(schedule.id)}-${safeId(dueAt)}-${stamp}`,
system_prompt: stringFromUnknown(payload.system_prompt) || "你是 qimingclaw 本地数字员工,请执行调度计划并记录必要的结果。",
original_user_prompt: prompt,
open_long_memory: payload.open_long_memory === true,
agent_config: objectPayload(payload.agent_config),
model_provider: objectPayload(payload.model_provider),
schedule_context: {
schedule_id: schedule.id,
due_at: dueAt,
trigger_kind: triggerKind,
},
};
}
async function postComputerChat(body: Record<string, unknown>): Promise<unknown> {
const { agent: agentPort } = getConfiguredPorts();
const response = await fetch(`http://${LOCALHOST_HOSTNAME}:${agentPort}/computer/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await response.text();
let payload: unknown = text;
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(`ComputerServer ${response.status}: ${text}`);
}
const record = objectPayload(payload);
if (record.success === false || (typeof record.code === "string" && record.code !== "0000")) {
throw new Error(stringFromUnknown(record.message) || "ComputerServer rejected schedule run");
}
return payload;
}
function pruneScheduleRunHistory(schedule: DigitalEmployeeScheduleRecord): void {
const runs = readDigitalEmployeeScheduleRuns(schedule.id, schedule.maxRunHistory + 20);
if (runs.length <= schedule.maxRunHistory) return;
// Retention is intentionally conservative for now; the schema keeps history for audit.
}
export function nextCronRun(expression: string, after: Date): Date {
const parsed = parseCronExpression(expression);
const candidate = new Date(after.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
for (let i = 0; i < MAX_SCAN_MINUTES; i += 1) {
if (matchesCron(parsed, candidate)) return candidate;
candidate.setMinutes(candidate.getMinutes() + 1);
}
throw new Error(`No cron run found within scan window: ${expression}`);
}
function parseCronExpression(expression: string): CronParts {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) throw new Error(`Only 5-field cron expressions are supported: ${expression}`);
return {
minute: parseCronField(parts[0], 0, 59),
hour: parseCronField(parts[1], 0, 23),
dayOfMonth: parseCronField(parts[2], 1, 31),
month: parseCronField(parts[3], 1, 12),
dayOfWeek: parseCronField(parts[4], 0, 7),
domAny: parts[2] === "*",
dowAny: parts[4] === "*",
};
}
interface CronParts {
minute: Set<number>;
hour: Set<number>;
dayOfMonth: Set<number>;
month: Set<number>;
dayOfWeek: Set<number>;
domAny: boolean;
dowAny: boolean;
}
function parseCronField(field: string, min: number, max: number): Set<number> {
const values = new Set<number>();
for (const segment of field.split(",")) {
const [rangePart, stepPart] = segment.split("/");
const step = stepPart ? Number(stepPart) : 1;
if (!Number.isInteger(step) || step <= 0) throw new Error(`Invalid cron step: ${field}`);
const [startText, endText] = (rangePart || "*").split("-");
const start = startText === "*" ? min : Number(startText);
const end = startText === "*" ? max : endText ? Number(endText) : start;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
throw new Error(`Invalid cron field: ${field}`);
}
for (let value = start; value <= end; value += step) {
values.add(value === 7 && max === 7 ? 0 : value);
}
}
return values;
}
function matchesCron(parts: CronParts, date: Date): boolean {
const dayOfWeek = date.getDay();
const domMatch = parts.dayOfMonth.has(date.getDate());
const dowMatch = parts.dayOfWeek.has(dayOfWeek);
const dayMatch = parts.domAny || parts.dowAny ? domMatch && dowMatch : domMatch || dowMatch;
return parts.minute.has(date.getMinutes())
&& parts.hour.has(date.getHours())
&& parts.month.has(date.getMonth() + 1)
&& dayMatch;
}
function objectPayload(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringFromUnknown(value: unknown): string {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function safeId(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "item";
}
function normalizeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

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,

View File

@@ -105,6 +105,55 @@ export interface DigitalEmployeePlanStepRecord {
updatedAt: string;
}
export interface DigitalEmployeeScheduleRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
name: string;
description?: string | null;
cronExpr: string;
timezone: string;
status: string;
enabled: boolean;
prompt?: string | null;
command?: string | null;
payload: unknown;
nextRunAt?: string | null;
lastRunAt?: string | null;
catchUpOnStartup: boolean;
maxCatchUpRuns: number;
maxRunHistory: number;
failureCount: number;
lastError?: string | null;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface DigitalEmployeeScheduleRunRecord {
id: string;
remoteId?: string | null;
scheduleId: string;
planId?: string | null;
runId?: string | null;
dueAt: string;
startedAt?: string | null;
finishedAt?: string | null;
status: string;
attemptNo: number;
triggerKind: string;
error?: string | null;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DigitalEmployeeRunRecord {
id: string;
remoteId?: string | null;
@@ -173,6 +222,8 @@ export interface DigitalEmployeeRuntimeRecords {
generatedAt: string;
plans: DigitalEmployeePlanRecord[];
planSteps: DigitalEmployeePlanStepRecord[];
schedules: DigitalEmployeeScheduleRecord[];
scheduleRuns: DigitalEmployeeScheduleRunRecord[];
tasks: DigitalEmployeeTaskRecord[];
runs: DigitalEmployeeRunRecord[];
events: DigitalEmployeeEventRecord[];
@@ -262,6 +313,45 @@ export interface DigitalEmployeeGovernanceCommandRecord {
result?: Record<string, unknown> | null;
}
export interface DigitalEmployeeCronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
max_catch_up_runs: number;
max_concurrent_schedule_runs: number;
}
export interface DigitalEmployeeScheduleUpsertInput {
id?: string;
planId?: string | null;
name?: string;
description?: string | null;
cronExpr?: string;
timezone?: string;
status?: string;
enabled?: boolean;
prompt?: string | null;
command?: string | null;
payload?: Record<string, unknown>;
nextRunAt?: string | null;
catchUpOnStartup?: boolean;
maxCatchUpRuns?: number;
maxRunHistory?: number;
}
export interface DigitalEmployeeScheduleRunInput {
scheduleId: string;
planId?: string | null;
dueAt: string;
triggerKind: string;
status?: string;
attemptNo?: number;
runId?: string | null;
payload?: Record<string, unknown>;
}
const CRON_SETTINGS_KEY = "digital_employee_cron_settings_v1";
function defaultState(): DigitalEmployeeState {
return {
version: 1,
@@ -433,6 +523,32 @@ export function saveDigitalEmployeeUiState(
return state;
}
export function readDigitalEmployeeCronSettings(): DigitalEmployeeCronSettings {
const stored = objectRecord(readSetting(CRON_SETTINGS_KEY));
return {
enabled: stored?.enabled !== false,
catch_up_on_startup: stored?.catch_up_on_startup !== false,
max_run_history: positiveInteger(stored?.max_run_history, 100),
max_catch_up_runs: positiveInteger(stored?.max_catch_up_runs, 5),
max_concurrent_schedule_runs: positiveInteger(stored?.max_concurrent_schedule_runs, 1),
};
}
export function saveDigitalEmployeeCronSettings(
patch: Partial<DigitalEmployeeCronSettings>,
): DigitalEmployeeCronSettings {
const current = readDigitalEmployeeCronSettings();
const next: DigitalEmployeeCronSettings = {
enabled: typeof patch.enabled === "boolean" ? patch.enabled : current.enabled,
catch_up_on_startup: typeof patch.catch_up_on_startup === "boolean" ? patch.catch_up_on_startup : current.catch_up_on_startup,
max_run_history: positiveInteger(patch.max_run_history, current.max_run_history),
max_catch_up_runs: positiveInteger(patch.max_catch_up_runs, current.max_catch_up_runs),
max_concurrent_schedule_runs: positiveInteger(patch.max_concurrent_schedule_runs, current.max_concurrent_schedule_runs),
};
writeSetting(CRON_SETTINGS_KEY, next);
return next;
}
function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
if (update.action !== "decide_approval") return;
const metadata = objectRecord(update.metadata);
@@ -498,6 +614,8 @@ export function readDigitalEmployeeRuntimeRecords(
generatedAt: new Date().toISOString(),
plans: [],
planSteps: [],
schedules: [],
scheduleRuns: [],
tasks: [],
runs: [],
events: [],
@@ -521,6 +639,24 @@ export function readDigitalEmployeeRuntimeRecords(
ORDER BY plan_id ASC, seq ASC, created_at ASC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const schedules = db.prepare(`
SELECT id, remote_id, plan_id, name, description, cron_expr, timezone,
status, enabled, prompt, command, payload, next_run_at, last_run_at,
catch_up_on_startup, max_catch_up_runs, max_run_history,
failure_count, last_error, sync_status, last_synced_at, sync_error,
created_at, updated_at, deleted_at
FROM digital_schedules
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const scheduleRuns = db.prepare(`
SELECT id, remote_id, schedule_id, plan_id, run_id, due_at, started_at,
finished_at, status, attempt_no, trigger_kind, error, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_schedule_runs
ORDER BY due_at DESC, created_at DESC
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
@@ -561,6 +697,8 @@ export function readDigitalEmployeeRuntimeRecords(
generatedAt: new Date().toISOString(),
plans: plans.map(toPlanRecord),
planSteps: planSteps.map(toPlanStepRecord),
schedules: schedules.map(toScheduleRecord),
scheduleRuns: scheduleRuns.map(toScheduleRunRecord),
tasks: tasks.map(toTaskRecord),
runs: runs.map(toRunRecord),
events: events.map(toEventRecord),
@@ -569,6 +707,248 @@ export function readDigitalEmployeeRuntimeRecords(
};
}
export function listDueDigitalEmployeeSchedules(
nowIso: string,
limit = 20,
): DigitalEmployeeScheduleRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const rows = db.prepare(`
SELECT id, remote_id, plan_id, name, description, cron_expr, timezone,
status, enabled, prompt, command, payload, next_run_at, last_run_at,
catch_up_on_startup, max_catch_up_runs, max_run_history,
failure_count, last_error, sync_status, last_synced_at, sync_error,
created_at, updated_at, deleted_at
FROM digital_schedules
WHERE enabled = 1
AND status = 'enabled'
AND deleted_at IS NULL
AND next_run_at IS NOT NULL
AND next_run_at <= ?
ORDER BY next_run_at ASC, updated_at ASC
LIMIT ?
`).all(nowIso, Math.max(1, Math.min(Math.floor(limit), 100))) as Array<Record<string, unknown>>;
return rows.map(toScheduleRecord);
}
export function listDigitalEmployeeSchedules(limit = 1000): DigitalEmployeeScheduleRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const rows = db.prepare(`
SELECT id, remote_id, plan_id, name, description, cron_expr, timezone,
status, enabled, prompt, command, payload, next_run_at, last_run_at,
catch_up_on_startup, max_catch_up_runs, max_run_history,
failure_count, last_error, sync_status, last_synced_at, sync_error,
created_at, updated_at, deleted_at
FROM digital_schedules
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(Math.max(1, Math.min(Math.floor(limit), 5000))) as Array<Record<string, unknown>>;
return rows.map(toScheduleRecord);
}
export function upsertDigitalEmployeeSchedule(
input: DigitalEmployeeScheduleUpsertInput,
): DigitalEmployeeScheduleRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = new Date().toISOString();
const schedule = normalizeScheduleInput(input, now);
const tx = db.transaction(() => {
upsertSchedule(schedule);
});
tx();
return readDigitalEmployeeSchedule(schedule.id);
}
export function updateDigitalEmployeeScheduleStatus(
id: string,
status: string,
options: { enabled?: boolean; deleted?: boolean; nextRunAt?: string | null; error?: string | null } = {},
): DigitalEmployeeScheduleRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = new Date().toISOString();
const existing = readDigitalEmployeeSchedule(id);
if (!existing) return null;
const next: DigitalEmployeeScheduleUpsertInput = {
id: existing.id,
planId: existing.planId,
name: existing.name,
description: existing.description,
cronExpr: existing.cronExpr,
timezone: existing.timezone,
status,
enabled: options.enabled ?? (status === "enabled"),
prompt: existing.prompt,
command: existing.command,
payload: objectRecord(existing.payload) ?? { value: existing.payload },
nextRunAt: options.nextRunAt === undefined ? existing.nextRunAt : options.nextRunAt,
catchUpOnStartup: existing.catchUpOnStartup,
maxCatchUpRuns: existing.maxCatchUpRuns,
maxRunHistory: existing.maxRunHistory,
};
const schedule = normalizeScheduleInput(next, now);
const tx = db.transaction(() => {
upsertSchedule({
...schedule,
lastRunAt: existing.lastRunAt,
failureCount: existing.failureCount,
lastError: options.error ?? existing.lastError,
deletedAt: options.deleted ? now : existing.deletedAt,
});
});
tx();
return readDigitalEmployeeSchedule(id);
}
export function readDigitalEmployeeSchedule(id: string): DigitalEmployeeScheduleRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const row = db.prepare(`
SELECT id, remote_id, plan_id, name, description, cron_expr, timezone,
status, enabled, prompt, command, payload, next_run_at, last_run_at,
catch_up_on_startup, max_catch_up_runs, max_run_history,
failure_count, last_error, sync_status, last_synced_at, sync_error,
created_at, updated_at, deleted_at
FROM digital_schedules
WHERE id = ?
`).get(id) as Record<string, unknown> | undefined;
return row ? toScheduleRecord(row) : null;
}
export function readDigitalEmployeeScheduleRuns(
scheduleId: string,
limit = 20,
): DigitalEmployeeScheduleRunRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const rows = db.prepare(`
SELECT id, remote_id, schedule_id, plan_id, run_id, due_at, started_at,
finished_at, status, attempt_no, trigger_kind, error, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_schedule_runs
WHERE schedule_id = ?
ORDER BY due_at DESC, attempt_no DESC
LIMIT ?
`).all(scheduleId, Math.max(1, Math.min(Math.floor(limit), 200))) as Array<Record<string, unknown>>;
return rows.map(toScheduleRunRecord);
}
export function recordDigitalEmployeeScheduleRunStarted(
input: DigitalEmployeeScheduleRunInput,
): DigitalEmployeeScheduleRunRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = new Date().toISOString();
const schedule = readDigitalEmployeeSchedule(input.scheduleId);
const planId = input.planId || schedule?.planId || `schedule-plan-${safeId(input.scheduleId)}`;
const runId = input.runId || `schedule-run-${safeId(input.scheduleId)}-${safeId(input.dueAt)}-${input.attemptNo || 1}`;
const taskId = `schedule-task-${safeId(input.scheduleId)}`;
const payload = {
source: "qimingclaw-schedule-run",
scheduleId: input.scheduleId,
dueAt: input.dueAt,
triggerKind: input.triggerKind,
attemptNo: input.attemptNo || 1,
schedule,
...(input.payload ?? {}),
};
const rowInput = {
id: `${input.scheduleId}:${safeId(input.dueAt)}:${input.attemptNo || 1}`,
scheduleId: input.scheduleId,
planId,
runId,
dueAt: input.dueAt,
status: input.status || "running",
attemptNo: input.attemptNo || 1,
triggerKind: input.triggerKind,
payload,
now,
};
const tx = db.transaction(() => {
upsertPlan({
id: planId,
title: schedule?.name || "数字员工定时任务",
objective: schedule?.description || schedule?.prompt || schedule?.command || "按本地调度计划执行数字员工任务。",
status: "running",
payload,
now,
});
upsertTask({
id: taskId,
planId,
title: schedule?.name || "执行数字员工定时任务",
status: "running",
assignedSkillId: "qimingclaw-scheduler",
payload,
now,
});
upsertRun({ id: runId, planId, taskId, status: "running", payload, now });
upsertScheduleRun(rowInput);
insertEvent({
event_id: `${runId}:schedule_started`,
kind: "schedule_run_started",
occurred_at: now,
message: `定时任务开始执行:${schedule?.name || input.scheduleId}`,
plan_id: planId,
task_id: taskId,
}, runId, payload);
});
tx();
return readDigitalEmployeeScheduleRun(rowInput.id);
}
export function finishDigitalEmployeeScheduleRun(
scheduleRunId: string,
status: "completed" | "failed",
options: { error?: string | null; nextRunAt?: string | null; response?: unknown } = {},
): DigitalEmployeeScheduleRunRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const existing = readDigitalEmployeeScheduleRun(scheduleRunId);
if (!existing) return null;
const now = new Date().toISOString();
const payloadRecord = objectRecord(existing.payload) ?? {};
const schedule = readDigitalEmployeeSchedule(existing.scheduleId);
const tx = db.transaction(() => {
db.prepare(`
UPDATE digital_schedule_runs
SET status = ?, finished_at = ?, error = ?, payload = ?, sync_status = CASE
WHEN sync_status = 'synced' THEN 'pending'
ELSE sync_status
END, updated_at = ?
WHERE id = ?
`).run(status, now, options.error ?? null, stringify({
...payloadRecord,
response: options.response,
error: options.error ?? null,
finishedAt: now,
}), now, scheduleRunId);
if (existing.runId) finishRun(existing.runId, status, now);
if (schedule) {
upsertSchedule({
...scheduleToUpsertInput(schedule, now),
lastRunAt: now,
nextRunAt: options.nextRunAt === undefined ? schedule.nextRunAt : options.nextRunAt,
failureCount: status === "failed" ? schedule.failureCount + 1 : 0,
lastError: status === "failed" ? options.error ?? "schedule run failed" : null,
});
}
insertEvent({
event_id: `${existing.runId || scheduleRunId}:schedule_${status}`,
kind: `schedule_run_${status}`,
occurred_at: now,
message: status === "completed" ? "定时任务执行完成" : `定时任务执行失败:${options.error || "unknown"}`,
plan_id: existing.planId ?? null,
task_id: null,
}, existing.runId || "", { scheduleRunId, error: options.error, response: options.response });
markOutbox("schedule_run", scheduleRunId, "upsert", { id: scheduleRunId, status, error: options.error }, now);
});
tx();
return readDigitalEmployeeScheduleRun(scheduleRunId);
}
export function recordDigitalEmployeeSnapshot(
snapshot: DigitalEmployeeSnapshot,
): DigitalEmployeeState {
@@ -845,6 +1225,18 @@ export function recordDigitalEmployeeGovernanceCommand(
now,
});
}
persistScheduleGovernanceCommand({
commandKind: command.commandKind,
commandId,
planId: ids.planId,
runId: ids.runId,
scheduleId: command.scheduleId || stringValue(result.schedule_id) || stringValue(payload.schedule_id),
payload,
result,
payloadEnvelope,
status,
now,
});
upsertRun({
id: ids.runId,
planId: ids.planId,
@@ -887,6 +1279,14 @@ function governanceCommandTitle(commandKind: string): string {
return "激活数字员工计划";
case "create_schedule":
return "创建数字员工定时任务";
case "update_schedule":
return "更新数字员工定时任务";
case "pause_schedule":
return "暂停数字员工定时任务";
case "resume_schedule":
return "恢复数字员工定时任务";
case "delete_schedule":
return "删除数字员工定时任务";
case "run_schedule_now":
return "立即运行数字员工计划";
case "pause_plan":
@@ -971,6 +1371,87 @@ function upsertCreatedPlanTasks(input: {
});
}
function persistScheduleGovernanceCommand(input: {
commandKind: string;
commandId: string;
planId: string;
runId: string;
scheduleId: string;
payload: Record<string, unknown>;
result: Record<string, unknown>;
payloadEnvelope: Record<string, unknown>;
status: string;
now: string;
}): void {
if (![
"create_schedule",
"update_schedule",
"pause_schedule",
"resume_schedule",
"delete_schedule",
"run_schedule_now",
].includes(input.commandKind)) return;
const existing = input.scheduleId ? readDigitalEmployeeSchedule(input.scheduleId) : null;
const scheduleId = input.scheduleId || existing?.id || `schedule-${safeId(input.commandId)}`;
const cronExpr = stringValue(input.payload.cron_expr ?? input.payload.cron ?? input.payload.schedule)
|| existing?.cronExpr
|| "0 9 * * *";
const enabled = input.commandKind === "pause_schedule" || input.commandKind === "delete_schedule"
? false
: input.payload.enabled !== false;
const scheduleStatus = input.commandKind === "delete_schedule"
? "deleted"
: input.commandKind === "pause_schedule"
? "paused"
: "enabled";
if (input.commandKind === "run_schedule_now") {
upsertScheduleRun({
id: `${scheduleId}:${safeId(input.now)}:1`,
scheduleId,
planId: input.planId,
runId: input.runId,
dueAt: input.now,
status: "running",
attemptNo: 1,
triggerKind: "manual",
payload: input.payloadEnvelope,
now: input.now,
});
return;
}
upsertSchedule({
id: scheduleId,
planId: input.planId,
name: stringValue(input.payload.name ?? input.result.name ?? input.payload.title)
|| existing?.name
|| governanceCommandTitle(input.commandKind),
description: stringValue(input.payload.description ?? input.result.description) || existing?.description || null,
cronExpr,
timezone: stringValue(input.payload.timezone ?? input.result.timezone) || existing?.timezone || "system",
status: scheduleStatus,
enabled,
prompt: stringValue(input.payload.prompt) || existing?.prompt || null,
command: stringValue(input.payload.command) || existing?.command || null,
payload: {
...input.payloadEnvelope,
previous: existing,
},
nextRunAt: stringValue(input.result.next_run_at ?? input.payload.next_run_at) || existing?.nextRunAt || null,
catchUpOnStartup: existing?.catchUpOnStartup ?? readDigitalEmployeeCronSettings().catch_up_on_startup,
maxCatchUpRuns: existing?.maxCatchUpRuns ?? readDigitalEmployeeCronSettings().max_catch_up_runs,
maxRunHistory: existing?.maxRunHistory ?? readDigitalEmployeeCronSettings().max_run_history,
createdAt: existing?.createdAt || input.now,
updatedAt: input.now,
lastRunAt: existing?.lastRunAt,
failureCount: existing?.failureCount ?? 0,
lastError: existing?.lastError,
deletedAt: input.commandKind === "delete_schedule" ? input.now : existing?.deletedAt,
});
}
function extractPlanStepInputs(
payload: Record<string, unknown>,
planId: string,
@@ -1113,6 +1594,10 @@ function governanceCommandStatus(
return "reviewing";
case "approve_plan":
return "approved";
case "pause_schedule":
return "paused";
case "delete_schedule":
return "deleted";
case "pause_plan":
return "paused";
case "cancel_plan":
@@ -1200,6 +1685,34 @@ function optionalStringField(
return typeof value === "string" && value.trim() ? value : null;
}
function numberField(
row: Record<string, unknown>,
key: string,
fallback: number,
): number {
const value = row[key];
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function booleanField(
row: Record<string, unknown>,
key: string,
fallback: boolean,
): boolean {
const value = row[key];
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") return !["0", "false", "no"].includes(value.toLowerCase());
return fallback;
}
function positiveInteger(value: unknown, fallback: number): number {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
return Math.floor(numeric);
}
function parseStoredPayload(value: unknown): unknown {
if (typeof value !== "string" || !value.trim()) return {};
try {
@@ -1268,6 +1781,59 @@ function toPlanStepRecord(row: Record<string, unknown>): DigitalEmployeePlanStep
};
}
function toScheduleRecord(row: Record<string, unknown>): DigitalEmployeeScheduleRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
planId: optionalStringField(row, "plan_id"),
name: stringField(row, "name"),
description: optionalStringField(row, "description"),
cronExpr: stringField(row, "cron_expr"),
timezone: stringField(row, "timezone") || "system",
status: stringField(row, "status") || "enabled",
enabled: booleanField(row, "enabled", true),
prompt: optionalStringField(row, "prompt"),
command: optionalStringField(row, "command"),
payload: parseStoredPayload(row.payload),
nextRunAt: optionalStringField(row, "next_run_at"),
lastRunAt: optionalStringField(row, "last_run_at"),
catchUpOnStartup: booleanField(row, "catch_up_on_startup", true),
maxCatchUpRuns: numberField(row, "max_catch_up_runs", 5),
maxRunHistory: numberField(row, "max_run_history", 100),
failureCount: numberField(row, "failure_count", 0),
lastError: optionalStringField(row, "last_error"),
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"),
deletedAt: optionalStringField(row, "deleted_at"),
};
}
function toScheduleRunRecord(row: Record<string, unknown>): DigitalEmployeeScheduleRunRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
scheduleId: stringField(row, "schedule_id"),
planId: optionalStringField(row, "plan_id"),
runId: optionalStringField(row, "run_id"),
dueAt: stringField(row, "due_at"),
startedAt: optionalStringField(row, "started_at"),
finishedAt: optionalStringField(row, "finished_at"),
status: stringField(row, "status"),
attemptNo: numberField(row, "attempt_no", 1),
triggerKind: stringField(row, "trigger_kind") || "scheduled",
error: optionalStringField(row, "error"),
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"),
@@ -1386,6 +1952,195 @@ function markOutbox(
);
}
function normalizeScheduleInput(
input: DigitalEmployeeScheduleUpsertInput,
now: string,
): DigitalEmployeeScheduleUpsertInput & {
id: string;
name: string;
cronExpr: string;
timezone: string;
status: string;
enabled: boolean;
payload: Record<string, unknown>;
catchUpOnStartup: boolean;
maxCatchUpRuns: number;
maxRunHistory: number;
createdAt: string;
updatedAt: string;
lastRunAt?: string | null;
failureCount?: number;
lastError?: string | null;
deletedAt?: string | null;
} {
const settings = readDigitalEmployeeCronSettings();
const name = stringValue(input.name) || "数字员工定时任务";
const cronExpr = stringValue(input.cronExpr) || stringValue(input.payload?.cron_expr) || stringValue(input.payload?.schedule) || "0 9 * * *";
return {
...input,
id: stringValue(input.id) || `schedule-${safeId(input.planId || name)}-${safeId(cronExpr)}`,
name,
cronExpr,
timezone: stringValue(input.timezone) || "system",
status: stringValue(input.status) || (input.enabled === false ? "paused" : "enabled"),
enabled: input.enabled !== false,
payload: input.payload ?? {},
catchUpOnStartup: input.catchUpOnStartup ?? settings.catch_up_on_startup,
maxCatchUpRuns: positiveInteger(input.maxCatchUpRuns, settings.max_catch_up_runs),
maxRunHistory: positiveInteger(input.maxRunHistory, settings.max_run_history),
createdAt: now,
updatedAt: now,
};
}
function scheduleToUpsertInput(
schedule: DigitalEmployeeScheduleRecord,
now: string,
): ReturnType<typeof normalizeScheduleInput> {
return {
id: schedule.id,
planId: schedule.planId,
name: schedule.name,
description: schedule.description,
cronExpr: schedule.cronExpr,
timezone: schedule.timezone,
status: schedule.status,
enabled: schedule.enabled,
prompt: schedule.prompt,
command: schedule.command,
payload: objectRecord(schedule.payload) ?? { value: schedule.payload },
nextRunAt: schedule.nextRunAt,
catchUpOnStartup: schedule.catchUpOnStartup,
maxCatchUpRuns: schedule.maxCatchUpRuns,
maxRunHistory: schedule.maxRunHistory,
createdAt: schedule.createdAt || now,
updatedAt: now,
lastRunAt: schedule.lastRunAt,
failureCount: schedule.failureCount,
lastError: schedule.lastError,
deletedAt: schedule.deletedAt,
};
}
function upsertSchedule(input: ReturnType<typeof normalizeScheduleInput>): void {
const db = getDigitalEmployeeDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_schedules (
id, plan_id, name, description, cron_expr, timezone, status, enabled,
prompt, command, payload, next_run_at, last_run_at, catch_up_on_startup,
max_catch_up_runs, max_run_history, failure_count, last_error,
sync_status, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
plan_id = excluded.plan_id,
name = excluded.name,
description = excluded.description,
cron_expr = excluded.cron_expr,
timezone = excluded.timezone,
status = excluded.status,
enabled = excluded.enabled,
prompt = excluded.prompt,
command = excluded.command,
payload = excluded.payload,
next_run_at = excluded.next_run_at,
last_run_at = excluded.last_run_at,
catch_up_on_startup = excluded.catch_up_on_startup,
max_catch_up_runs = excluded.max_catch_up_runs,
max_run_history = excluded.max_run_history,
failure_count = excluded.failure_count,
last_error = excluded.last_error,
deleted_at = excluded.deleted_at,
sync_status = CASE
WHEN digital_schedules.sync_status = 'synced' THEN 'pending'
ELSE digital_schedules.sync_status
END,
updated_at = excluded.updated_at
`).run(
input.id,
input.planId ?? null,
input.name,
input.description ?? null,
input.cronExpr,
input.timezone,
input.status,
input.enabled ? 1 : 0,
input.prompt ?? null,
input.command ?? null,
stringify(input.payload),
input.nextRunAt ?? null,
input.lastRunAt ?? null,
input.catchUpOnStartup ? 1 : 0,
input.maxCatchUpRuns,
input.maxRunHistory,
input.failureCount ?? 0,
input.lastError ?? null,
input.createdAt,
input.updatedAt,
input.deletedAt ?? null,
);
markOutbox("schedule", input.id, input.deletedAt ? "delete" : "upsert", input, input.updatedAt);
}
function upsertScheduleRun(input: {
id: string;
scheduleId: string;
planId: string;
runId: string;
dueAt: string;
status: string;
attemptNo: number;
triggerKind: string;
payload: unknown;
now: string;
}): void {
const db = getDigitalEmployeeDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_schedule_runs (
id, schedule_id, plan_id, run_id, due_at, started_at, status, attempt_no,
trigger_kind, payload, sync_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
ON CONFLICT(id) DO UPDATE SET
plan_id = excluded.plan_id,
run_id = excluded.run_id,
status = excluded.status,
payload = excluded.payload,
sync_status = CASE
WHEN digital_schedule_runs.sync_status = 'synced' THEN 'pending'
ELSE digital_schedule_runs.sync_status
END,
updated_at = excluded.updated_at
`).run(
input.id,
input.scheduleId,
input.planId,
input.runId,
input.dueAt,
input.now,
input.status,
input.attemptNo,
input.triggerKind,
stringify(input.payload),
input.now,
input.now,
);
markOutbox("schedule_run", input.id, "upsert", input, input.now);
}
function readDigitalEmployeeScheduleRun(id: string): DigitalEmployeeScheduleRunRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const row = db.prepare(`
SELECT id, remote_id, schedule_id, plan_id, run_id, due_at, started_at,
finished_at, status, attempt_no, trigger_kind, error, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at
FROM digital_schedule_runs
WHERE id = ?
`).get(id) as Record<string, unknown> | undefined;
return row ? toScheduleRunRecord(row) : null;
}
function upsertPlan(input: {
id: string;
title: string;

View File

@@ -394,6 +394,51 @@ describe("digital employee sync service", () => {
sync_error: "步骤同步被拒绝",
});
});
it("syncs schedule and schedule run business views", async () => {
insertEntity("schedule", "schedule-1");
insertEntity("schedule_run", "schedule-run-1");
insertOutbox("schedule:upsert:schedule-1", "schedule", "schedule-1");
insertOutbox("schedule_run:upsert:schedule-run-1", "schedule_run", "schedule-run-1");
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{ entity_type: "schedule", entity_id: "schedule-1", remote_id: "remote-schedule-1" },
{ entity_type: "schedule_run", entity_id: "schedule-run-1", remote_id: "remote-schedule-run-1" },
],
}),
);
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
expect(status.failed).toBe(0);
expect(lastSyncRequestBody().items).toEqual([
expect.objectContaining({
entity_type: "schedule",
business_view: expect.objectContaining({
title: "同步测试计划 schedule-1",
status: "pending",
}),
}),
expect.objectContaining({
entity_type: "schedule_run",
business_view: expect.objectContaining({
title: "同步测试计划 schedule-run-1",
status: "pending",
}),
}),
]);
expect(readEntity("schedule", "schedule-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-schedule-1",
});
expect(readEntity("schedule_run", "schedule-run-1")).toMatchObject({
sync_status: "synced",
remote_id: "remote-schedule-run-1",
});
});
});
function insertPlan(id: string): void {
@@ -456,6 +501,8 @@ function entityMap(
if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans;
if (entityType === "plan_step") return mockState.db.planSteps;
if (entityType === "schedule") return mockState.db.schedules;
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
if (entityType === "artifact") return mockState.db.artifacts;
if (entityType === "approval") return mockState.db.approvals;
return undefined;
@@ -522,6 +569,8 @@ class TestDb {
outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestSyncEntityRow>();
planSteps = new Map<string, TestSyncEntityRow>();
schedules = new Map<string, TestSyncEntityRow>();
scheduleRuns = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>();
approvals = new Map<string, TestSyncEntityRow>();
attempts: TestAttemptRow[] = [];
@@ -629,6 +678,16 @@ class TestDb {
return this.planSteps.get(id);
}
if (sql.startsWith("SELECT name AS title, status, description AS objective, payload FROM digital_schedules")) {
const [id] = args as [string];
return this.schedules.get(id);
}
if (sql.startsWith("SELECT id AS title, status, error AS objective, payload FROM digital_schedule_runs")) {
const [id] = args as [string];
return this.scheduleRuns.get(id);
}
if (
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
) {
@@ -726,7 +785,7 @@ class TestDb {
return { changes: before - this.attempts.length };
}
if (/^UPDATE digital_(plans|plan_steps|artifacts|approvals) SET sync_status = 'synced'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|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) {
@@ -738,7 +797,7 @@ class TestDb {
return { changes: row ? 1 : 0 };
}
if (/^UPDATE digital_(plans|plan_steps|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
const [syncError, id] = args as [string, string];
const row = this.entityRowsForUpdate(sql).get(id);
if (row) {
@@ -754,6 +813,8 @@ 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_schedule_runs")) return this.scheduleRuns;
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
return this.plans;
}

View File

@@ -762,6 +762,10 @@ function entitySummarySelect(entityType: string): string | null {
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 "schedule":
return "SELECT name AS title, status, description AS objective, payload FROM digital_schedules WHERE id = ?";
case "schedule_run":
return "SELECT id AS title, status, error AS objective, payload FROM digital_schedule_runs WHERE id = ?";
case "run":
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
case "event":
@@ -860,6 +864,10 @@ function entityTable(entityType: string): string | null {
return "digital_tasks";
case "plan_step":
return "digital_plan_steps";
case "schedule":
return "digital_schedules";
case "schedule_run":
return "digital_schedule_runs";
case "run":
return "digital_runs";
case "event":