吸收数字员工周期调度闭环
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
stopSandboxService,
|
||||
} from "../services/sandbox/serviceBootstrap";
|
||||
import { startDigitalEmployeeSyncWorker } from "../services/digitalEmployee/syncService";
|
||||
import { startDigitalEmployeeScheduler } from "../services/digitalEmployee/schedulerService";
|
||||
|
||||
/** 依赖同步是否正在进行 */
|
||||
let _depsSyncInProgress = false;
|
||||
@@ -92,6 +93,10 @@ export async function runStartupTasks(): Promise<void> {
|
||||
startDigitalEmployeeSyncWorker();
|
||||
});
|
||||
|
||||
setImmediate(() => {
|
||||
startDigitalEmployeeScheduler();
|
||||
});
|
||||
|
||||
// 客户端升级后:若 appVersion 或 installVersion 变化,后台同步初始化依赖到写死版本
|
||||
// 同步检查是否需要 dep sync,提前设置标志,避免 renderer 在 setImmediate 之前
|
||||
// 读到 syncInProgress=false 而过早启动服务(竞态条件)
|
||||
|
||||
@@ -60,6 +60,59 @@ const DIGITAL_EMPLOYEE_SCHEMA_SQL = `
|
||||
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS digital_schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
remote_id TEXT,
|
||||
plan_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cron_expr TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL DEFAULT 'system',
|
||||
status TEXT NOT NULL DEFAULT 'enabled',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
prompt TEXT,
|
||||
command TEXT,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
next_run_at TEXT,
|
||||
last_run_at TEXT,
|
||||
catch_up_on_startup INTEGER NOT NULL DEFAULT 1,
|
||||
max_catch_up_runs INTEGER NOT NULL DEFAULT 5,
|
||||
max_run_history INTEGER NOT NULL DEFAULT 100,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_synced_at TEXT,
|
||||
sync_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY(plan_id) REFERENCES digital_plans(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS digital_schedule_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
remote_id TEXT,
|
||||
schedule_id TEXT NOT NULL,
|
||||
plan_id TEXT,
|
||||
run_id TEXT,
|
||||
due_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_kind TEXT NOT NULL DEFAULT 'scheduled',
|
||||
error TEXT,
|
||||
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(schedule_id) REFERENCES digital_schedules(id),
|
||||
FOREIGN KEY(plan_id) REFERENCES digital_plans(id),
|
||||
FOREIGN KEY(run_id) REFERENCES digital_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS digital_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
remote_id TEXT,
|
||||
@@ -160,6 +213,12 @@ 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_schedules_due ON digital_schedules(enabled, status, next_run_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_digital_schedules_plan ON digital_schedules(plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_digital_schedules_sync ON digital_schedules(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_digital_schedule_runs_schedule ON digital_schedule_runs(schedule_id, due_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_digital_schedule_runs_attempt ON digital_schedule_runs(schedule_id, due_at, attempt_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_digital_schedule_runs_sync ON digital_schedule_runs(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);
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
readDigitalEmployeeRuntimeRecords,
|
||||
readDigitalEmployeeState,
|
||||
readDigitalEmployeeUiState,
|
||||
readDigitalEmployeeCronSettings,
|
||||
saveDigitalEmployeeCronSettings,
|
||||
readDigitalEmployeeScheduleRuns,
|
||||
saveDigitalEmployeeUiState,
|
||||
type DigitalEmployeeManagedService,
|
||||
type DigitalEmployeeGovernanceCommandRecord,
|
||||
@@ -20,6 +23,10 @@ import {
|
||||
type DigitalEmployeeSnapshot,
|
||||
type DigitalEmployeeUiStateUpdate,
|
||||
} from "../services/digitalEmployee/stateService";
|
||||
import {
|
||||
runDigitalEmployeeScheduleNow,
|
||||
runDigitalEmployeeSchedulerCheck,
|
||||
} from "../services/digitalEmployee/schedulerService";
|
||||
import {
|
||||
flushDigitalEmployeeSyncOutbox,
|
||||
getDigitalEmployeeSyncStatus,
|
||||
@@ -78,6 +85,26 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
|
||||
return readDigitalEmployeeCronSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:saveCronSettings", async (_, patch: Partial<ReturnType<typeof readDigitalEmployeeCronSettings>>) => {
|
||||
return saveDigitalEmployeeCronSettings(patch);
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:runSchedulerCheck", async () => {
|
||||
return runDigitalEmployeeSchedulerCheck();
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:runScheduleNow", async (_, scheduleId: string) => {
|
||||
return runDigitalEmployeeScheduleNow(scheduleId);
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:getScheduleRuns", async (_, scheduleId: string, limit?: number) => {
|
||||
return readDigitalEmployeeScheduleRuns(scheduleId, limit ?? 20);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"digitalEmployee:recordGovernanceCommand",
|
||||
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { migrateDataDir, migrateSettingsPaths } from "./bootstrap/migrate";
|
||||
import { getDeviceId, logSystemInfo } from "./services/system/deviceId";
|
||||
import { initWebviewPolicy } from "./services/system/webviewPolicy";
|
||||
import { stopDigitalEmployeeSyncWorker } from "./services/digitalEmployee/syncService";
|
||||
import { stopDigitalEmployeeScheduler } from "./services/digitalEmployee/schedulerService";
|
||||
|
||||
// macOS 26 Tahoe 兼容性:禁用 Fontations 字体后端
|
||||
// 参考: https://github.com/electron/electron/issues/49522
|
||||
@@ -374,6 +375,10 @@ async function cleanupAllProcesses(): Promise<void> {
|
||||
]);
|
||||
};
|
||||
|
||||
await runCleanupStep("Digital employee scheduler stop", () => {
|
||||
stopDigitalEmployeeScheduler();
|
||||
});
|
||||
|
||||
await runCleanupStep("Computer server stop", async () => {
|
||||
await stopComputerServer();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user