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

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

@@ -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;