吸收数字员工周期调度闭环
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user