吸收数字员工周期调度闭环
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
ArtifactEntry,
|
||||
CanonicalEvent,
|
||||
CronJob,
|
||||
CronRun,
|
||||
DashboardResponse,
|
||||
DebugPlan,
|
||||
DebugRun,
|
||||
@@ -39,6 +40,11 @@ declare global {
|
||||
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||
getCronSettings?: () => Promise<QimingclawCronSettings>;
|
||||
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
|
||||
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
||||
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
|
||||
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
||||
};
|
||||
@@ -180,6 +186,8 @@ interface QimingclawRuntimeRecords {
|
||||
generatedAt: string;
|
||||
plans: QimingclawPlanRecord[];
|
||||
planSteps?: QimingclawPlanStepRecord[];
|
||||
schedules?: QimingclawScheduleRecord[];
|
||||
scheduleRuns?: QimingclawScheduleRunRecord[];
|
||||
tasks: QimingclawTaskRecord[];
|
||||
runs: QimingclawRunRecord[];
|
||||
events: QimingclawEventRecord[];
|
||||
@@ -187,6 +195,57 @@ interface QimingclawRuntimeRecords {
|
||||
approvals?: QimingclawApprovalRecord[];
|
||||
}
|
||||
|
||||
interface QimingclawCronSettings {
|
||||
enabled: boolean;
|
||||
catch_up_on_startup: boolean;
|
||||
max_run_history: number;
|
||||
max_catch_up_runs: number;
|
||||
max_concurrent_schedule_runs: number;
|
||||
}
|
||||
|
||||
interface QimingclawScheduleRecord {
|
||||
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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawScheduleRunRecord {
|
||||
id: string;
|
||||
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;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface QimingclawPlanRecord {
|
||||
id: string;
|
||||
remoteId?: string | null;
|
||||
@@ -458,6 +517,37 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/scheduler/jobs') {
|
||||
return { jobs: buildSchedulerJobs(await readQimingclawSnapshot()) } as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/cron') {
|
||||
return { jobs: buildSchedulerJobs(await readQimingclawSnapshot()) } as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/cron') {
|
||||
return await handleCronCreate(parseJsonBody<Record<string, unknown>>(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/cron/settings') {
|
||||
return await readCronSettings() as T;
|
||||
}
|
||||
if (method === 'PATCH' && pathname === '/api/cron/settings') {
|
||||
const patch = parseJsonBody<Partial<QimingclawCronSettings>>(options.body);
|
||||
return await saveCronSettings(patch) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/cron/check') {
|
||||
const result = await window.QimingClawBridge?.digital?.runSchedulerCheck?.();
|
||||
return { ok: true, activated: result?.activated ?? 0, ...result } as T;
|
||||
}
|
||||
const cronRunMatch = pathname.match(/^\/api\/cron\/([^/]+)\/runs$/);
|
||||
if (method === 'GET' && cronRunMatch) {
|
||||
const runs = await readCronRuns(decodeURIComponent(cronRunMatch[1] || ''), Number(url.searchParams.get('limit')) || 20);
|
||||
return { runs } as T;
|
||||
}
|
||||
const cronJobMatch = pathname.match(/^\/api\/cron\/([^/]+)$/);
|
||||
if ((method === 'PATCH' || method === 'DELETE') && cronJobMatch) {
|
||||
const scheduleId = decodeURIComponent(cronJobMatch[1] || '');
|
||||
if (method === 'DELETE') {
|
||||
await handleCronCommand('delete_schedule', scheduleId, {}, await readQimingclawSnapshot());
|
||||
return undefined as T;
|
||||
}
|
||||
return await handleCronPatch(scheduleId, parseJsonBody<Record<string, unknown>>(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/debug/plans') {
|
||||
return { plans: filterPlans(url.searchParams.get('q'), await readQimingclawSnapshot()) } as T;
|
||||
}
|
||||
@@ -620,6 +710,122 @@ async function recordGovernanceCommand(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCronSettings(): Promise<QimingclawCronSettings> {
|
||||
try {
|
||||
return await window.QimingClawBridge?.digital?.getCronSettings?.() ?? defaultCronSettings();
|
||||
} catch {
|
||||
return defaultCronSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCronSettings(patch: Partial<QimingclawCronSettings>): Promise<QimingclawCronSettings> {
|
||||
try {
|
||||
return await window.QimingClawBridge?.digital?.saveCronSettings?.(patch) ?? { ...defaultCronSettings(), ...patch };
|
||||
} catch {
|
||||
return { ...defaultCronSettings(), ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCronSettings(): QimingclawCronSettings {
|
||||
return {
|
||||
enabled: true,
|
||||
catch_up_on_startup: true,
|
||||
max_run_history: 100,
|
||||
max_catch_up_runs: 5,
|
||||
max_concurrent_schedule_runs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCronCreate(
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<{ status: string; job: CronJob }> {
|
||||
const stamp = Date.now();
|
||||
const scheduleId = stringFromUnknown(body.id) || stringFromUnknown(body.schedule_id) || `cron-${slugifyIdPart(stringFromUnknown(body.name) || 'job')}-${stamp}`;
|
||||
await handleGovernanceCommand({
|
||||
command_kind: 'create_schedule',
|
||||
context_refs: { schedule_id: scheduleId, plan_id: stringFromUnknown(body.plan_id) || stringFromUnknown(body.template_id) || undefined },
|
||||
payload: {
|
||||
...body,
|
||||
schedule_id: scheduleId,
|
||||
cron_expr: stringFromUnknown(body.schedule) || stringFromUnknown(body.cron_expr),
|
||||
},
|
||||
correlation_id: `qimingclaw-cron-create-${stamp}`,
|
||||
idempotency_key: `qimingclaw-cron-create-${scheduleId}-${stamp}`,
|
||||
}, snapshot);
|
||||
const refreshed = await readQimingclawSnapshot();
|
||||
return { status: 'created', job: buildSchedulerJobs(refreshed).find((job) => job.id === scheduleId) ?? cronJobFromBody(scheduleId, body) };
|
||||
}
|
||||
|
||||
async function handleCronPatch(
|
||||
scheduleId: string,
|
||||
patch: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<{ status: string; job: CronJob }> {
|
||||
await handleCronCommand('update_schedule', scheduleId, patch, snapshot);
|
||||
const refreshed = await readQimingclawSnapshot();
|
||||
return { status: 'updated', job: buildSchedulerJobs(refreshed).find((job) => job.id === scheduleId) ?? cronJobFromBody(scheduleId, patch) };
|
||||
}
|
||||
|
||||
async function handleCronCommand(
|
||||
commandKind: GovernanceCommandRequest['command_kind'],
|
||||
scheduleId: string,
|
||||
payload: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<GovernanceCommandResponse> {
|
||||
const stamp = Date.now();
|
||||
return handleGovernanceCommand({
|
||||
command_kind: commandKind,
|
||||
context_refs: { schedule_id: scheduleId, plan_id: stringFromUnknown(payload.plan_id) || stringFromUnknown(payload.template_id) || undefined },
|
||||
payload: {
|
||||
...payload,
|
||||
schedule_id: scheduleId,
|
||||
cron_expr: stringFromUnknown(payload.schedule) || stringFromUnknown(payload.cron_expr),
|
||||
},
|
||||
correlation_id: `qimingclaw-cron-${commandKind}-${stamp}`,
|
||||
idempotency_key: `qimingclaw-cron-${commandKind}-${scheduleId}-${stamp}`,
|
||||
}, snapshot);
|
||||
}
|
||||
|
||||
function cronJobFromBody(id: string, body: Record<string, unknown>): CronJob {
|
||||
const now = new Date().toISOString();
|
||||
const expression = stringFromUnknown(body.schedule) || stringFromUnknown(body.cron_expr) || '0 9 * * *';
|
||||
return {
|
||||
id,
|
||||
name: stringFromUnknown(body.name) || id,
|
||||
expression,
|
||||
command: stringFromUnknown(body.command) || `digital-employee:${id}`,
|
||||
prompt: stringFromUnknown(body.prompt) || null,
|
||||
job_type: stringFromUnknown(body.job_type) || 'qimingclaw_schedule',
|
||||
schedule: { expression, schedule_id: id },
|
||||
enabled: body.enabled !== false,
|
||||
delivery: { mode: 'qimingclaw_schedule', schedule_id: id },
|
||||
template_id: stringFromUnknown(body.template_id) || stringFromUnknown(body.plan_id) || id,
|
||||
trigger_kind: 'scheduled',
|
||||
delete_after_run: body.delete_after_run === true,
|
||||
created_at: now,
|
||||
next_run: now,
|
||||
last_run: null,
|
||||
last_status: null,
|
||||
last_output: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCronRuns(scheduleId: string, limit: number): Promise<CronRun[]> {
|
||||
const runs = await window.QimingClawBridge?.digital?.getScheduleRuns?.(scheduleId, limit).catch(() => []) ?? [];
|
||||
return runs.map((run, index) => ({
|
||||
id: Number.isFinite(Date.parse(run.createdAt)) ? Date.parse(run.createdAt) + index : index + 1,
|
||||
job_id: run.scheduleId,
|
||||
started_at: run.startedAt || run.createdAt,
|
||||
finished_at: run.finishedAt || '',
|
||||
status: run.status,
|
||||
output: run.error || null,
|
||||
duration_ms: run.startedAt && run.finishedAt
|
||||
? Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt))
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseJsonBody<T>(body: BodyInit | null | undefined): T {
|
||||
if (typeof body !== 'string' || !body.trim()) return {} as T;
|
||||
return JSON.parse(body) as T;
|
||||
@@ -1126,8 +1332,56 @@ function planTemplateJob(plan: DebugPlan, snapshot: QimingclawSnapshot | null):
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeScheduleJob(schedule: QimingclawScheduleRecord, snapshot: QimingclawSnapshot | null): CronJob {
|
||||
const plan = schedule.planId
|
||||
? mergedDebugPlans(snapshot).find((item) => item.plan_id === schedule.planId)
|
||||
: null;
|
||||
const runs = snapshot?.runtime?.scheduleRuns ?? [];
|
||||
const latestRun = runs.find((run) => run.scheduleId === schedule.id);
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
expression: schedule.cronExpr,
|
||||
command: schedule.command || `digital-employee:${schedule.planId || schedule.id}`,
|
||||
prompt: schedule.prompt ?? schedule.description ?? null,
|
||||
job_type: 'qimingclaw_schedule',
|
||||
schedule: {
|
||||
mode: 'qimingclaw_schedule',
|
||||
schedule_id: schedule.id,
|
||||
plan_id: schedule.planId,
|
||||
expression: schedule.cronExpr,
|
||||
timezone: schedule.timezone,
|
||||
status: schedule.status,
|
||||
failure_count: schedule.failureCount,
|
||||
last_error: schedule.lastError,
|
||||
},
|
||||
enabled: schedule.enabled && schedule.status === 'enabled' && !schedule.deletedAt,
|
||||
delivery: { mode: 'qimingclaw_schedule', schedule_id: schedule.id, plan_id: schedule.planId },
|
||||
template_id: schedule.planId ?? schedule.id,
|
||||
trigger_kind: 'scheduled',
|
||||
step_count: plan?.steps?.length ?? 0,
|
||||
skill_ids: [...new Set((plan?.steps ?? []).flatMap((step) => step.preferred_skills ?? []))],
|
||||
delete_after_run: false,
|
||||
created_at: schedule.createdAt,
|
||||
next_run: schedule.nextRunAt || schedule.updatedAt,
|
||||
last_run: schedule.lastRunAt ?? latestRun?.finishedAt ?? latestRun?.startedAt ?? null,
|
||||
last_status: latestRun?.status ?? (schedule.lastError ? 'failed' : null),
|
||||
last_output: latestRun?.error ?? schedule.lastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSchedulerJobs(snapshot: QimingclawSnapshot | null = null): CronJob[] {
|
||||
const realScheduleJobs = (snapshot?.runtime?.schedules ?? [])
|
||||
.filter((schedule) => schedule.status !== 'deleted')
|
||||
.map((schedule) => runtimeScheduleJob(schedule, snapshot));
|
||||
const templateJobs = templatePlans(snapshot).map((plan) => planTemplateJob(plan, snapshot));
|
||||
if (realScheduleJobs.length > 0) {
|
||||
const realTemplateIds = new Set<string | null | undefined>(realScheduleJobs.map((job) => job.template_id));
|
||||
return [
|
||||
...realScheduleJobs,
|
||||
...templateJobs.filter((job) => !realTemplateIds.has(job.template_id)),
|
||||
];
|
||||
}
|
||||
if (hasRuntimeRecords(snapshot)) {
|
||||
const runtimeJobs = runtimeDebugPlans(snapshot).map((plan) => ({
|
||||
id: `runtime-${plan.plan_id}`,
|
||||
@@ -2243,6 +2497,22 @@ async function handleGovernanceCommand(
|
||||
return response;
|
||||
}
|
||||
|
||||
if (['update_schedule', 'pause_schedule', 'resume_schedule', 'delete_schedule'].includes(body.command_kind)) {
|
||||
const status = body.command_kind === 'pause_schedule'
|
||||
? 'paused'
|
||||
: body.command_kind === 'delete_schedule'
|
||||
? 'deleted'
|
||||
: 'enabled';
|
||||
const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},并更新 qimingclaw 本地定时任务状态。`, {
|
||||
schedule_id: scheduleId || `qimingclaw-schedule-${Date.now()}`,
|
||||
plan_id: planId,
|
||||
status,
|
||||
source: 'qimingclaw-adapter',
|
||||
});
|
||||
await recordGovernanceCommand(body, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (['submit_plan', 'approve_plan', 'amend_plan', 'resume_plan', 'pause_plan', 'cancel_plan'].includes(body.command_kind)) {
|
||||
const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},当前由 qimingclaw 本地 adapter 记录兼容状态。`, {
|
||||
plan_id: planId,
|
||||
|
||||
@@ -340,7 +340,10 @@ export type GovernanceCommandKind =
|
||||
| 'approve_approval'
|
||||
| 'reject_approval'
|
||||
| 'create_schedule'
|
||||
| 'update_schedule'
|
||||
| 'pause_schedule'
|
||||
| 'resume_schedule'
|
||||
| 'delete_schedule'
|
||||
| 'run_schedule_now';
|
||||
|
||||
export type GovernanceCommandErrorCode =
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-Ddxmu09V.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CQeDXxH3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -9,6 +9,8 @@ const packageRoot = path.resolve(__dirname, "..");
|
||||
const requiredDigitalTables = [
|
||||
"digital_plans",
|
||||
"digital_plan_steps",
|
||||
"digital_schedules",
|
||||
"digital_schedule_runs",
|
||||
"digital_tasks",
|
||||
"digital_runs",
|
||||
"digital_events",
|
||||
@@ -20,6 +22,7 @@ const requiredDigitalTables = [
|
||||
|
||||
const testFiles = [
|
||||
"src/main/services/digitalEmployee/stateService.test.ts",
|
||||
"src/main/services/digitalEmployee/schedulerService.test.ts",
|
||||
"src/main/ipc/eventForwarders.test.ts",
|
||||
"src/main/services/digitalEmployee/syncService.test.ts",
|
||||
"src/main/services/digitalEmployee/skillCatalogService.test.ts",
|
||||
@@ -46,6 +49,64 @@ const selfHealingDigitalSchemaSql = `
|
||||
);
|
||||
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 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 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);
|
||||
`;
|
||||
|
||||
function runStep(title, command, args, options = {}) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -116,6 +116,21 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async flushSync() {
|
||||
return ipcRenderer.invoke("digitalEmployee:flushSync");
|
||||
},
|
||||
async getCronSettings() {
|
||||
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
|
||||
},
|
||||
async saveCronSettings(patch: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:saveCronSettings", patch);
|
||||
},
|
||||
async runSchedulerCheck() {
|
||||
return ipcRenderer.invoke("digitalEmployee:runSchedulerCheck");
|
||||
},
|
||||
async runScheduleNow(scheduleId: string) {
|
||||
return ipcRenderer.invoke("digitalEmployee:runScheduleNow", scheduleId);
|
||||
},
|
||||
async getScheduleRuns(scheduleId: string, limit?: number) {
|
||||
return ipcRenderer.invoke("digitalEmployee:getScheduleRuns", scheduleId, limit);
|
||||
},
|
||||
async recordGovernanceCommand(command: unknown) {
|
||||
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
|
||||
},
|
||||
|
||||
@@ -445,7 +445,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
||||
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
||||
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
|
||||
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
||||
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans`、`digital_plan_steps`、`digital_tasks`、`digital_runs`、`digital_events`、`digital_artifacts`、`digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情和流程投影;本地记录为空时才回落到 qimingclaw 固定适配任务。
|
||||
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans`、`digital_plan_steps`、`digital_schedules`、`digital_schedule_runs`、`digital_tasks`、`digital_runs`、`digital_events`、`digital_artifacts`、`digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情、调度列表和流程投影;本地记录为空时才回落到 qimingclaw 固定适配任务。
|
||||
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings;保存时会记录 `digital_workday_*` 本地事件并进入 outbox,避免这些页面操作只留在 webview localStorage。
|
||||
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
|
||||
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store`、`/api/dashboard`、`/api/debug/plan/:id/flow`、`/api/debug/task/:id/flow`、`/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / Approval;Run/Event payload 中的 `artifacts`、`outputs`、`files`、`outputPath`、`uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
|
||||
@@ -545,9 +545,10 @@ GET /api/digital-employee/approvals
|
||||
- 建议:下一轮围绕 Task Agent 状态机补步骤级治理命令。
|
||||
|
||||
2. **调度 / 定时 / 周期任务**
|
||||
- 已吸收:`/api/scheduler/jobs` 有 qimingclaw 兼容投影,`create_schedule`、`run_schedule_now` 能返回本地可解释结果。
|
||||
- 缺口:没有数字员工自己的 schedule 表、cron 创建/更新/删除、定时触发 `/computer/chat`、调度运行历史和失败补偿。
|
||||
- 建议:优先落地 `digital_schedules`、`digital_schedule_runs`,把周期任务变成真实 Plan/Task 入口。
|
||||
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
||||
- 当前能力:主进程 `DigitalEmployeeScheduler` 可按本机 5 字段 cron 到点触发本地 `/computer/chat`,支持启动 catch-up、立即运行、暂停/恢复/删除、失败重试、运行历史和同步诊断。
|
||||
- 后续缺口:更细的秒级 cron、复杂日历排除规则、多调度并发策略 UI 和管理端调度策略下发仍待吸收。
|
||||
- 建议:下一轮围绕入口路由/任务分流,把 schedule 触发的计划与具体 Skill Policy、Task Agent 状态机更精确地绑定。
|
||||
|
||||
3. **MemoryEntry / 长期记忆**
|
||||
- 已吸收:qimingclaw 主客户端已有 memory 服务和 scheduler,但尚未纳入数字员工模型。
|
||||
@@ -620,6 +621,8 @@ GET /api/digital-employee/approvals
|
||||
```text
|
||||
digital_plans
|
||||
digital_plan_steps
|
||||
digital_schedules
|
||||
digital_schedule_runs
|
||||
digital_tasks
|
||||
digital_runs
|
||||
digital_events
|
||||
|
||||
Reference in New Issue
Block a user