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

This commit is contained in:
baiyanyun
2026-06-09 11:06:25 +08:00
parent f35d945d9c
commit 5ebc0a640b
18 changed files with 2083 additions and 145 deletions

View File

@@ -3,6 +3,7 @@ import type {
ArtifactEntry, ArtifactEntry,
CanonicalEvent, CanonicalEvent,
CronJob, CronJob,
CronRun,
DashboardResponse, DashboardResponse,
DebugPlan, DebugPlan,
DebugRun, DebugRun,
@@ -39,6 +40,11 @@ declare global {
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>; saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
getSyncStatus?: () => Promise<QimingclawSyncStatus>; getSyncStatus?: () => Promise<QimingclawSyncStatus>;
flushSync?: () => 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>; recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>; respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
}; };
@@ -180,6 +186,8 @@ interface QimingclawRuntimeRecords {
generatedAt: string; generatedAt: string;
plans: QimingclawPlanRecord[]; plans: QimingclawPlanRecord[];
planSteps?: QimingclawPlanStepRecord[]; planSteps?: QimingclawPlanStepRecord[];
schedules?: QimingclawScheduleRecord[];
scheduleRuns?: QimingclawScheduleRunRecord[];
tasks: QimingclawTaskRecord[]; tasks: QimingclawTaskRecord[];
runs: QimingclawRunRecord[]; runs: QimingclawRunRecord[];
events: QimingclawEventRecord[]; events: QimingclawEventRecord[];
@@ -187,6 +195,57 @@ interface QimingclawRuntimeRecords {
approvals?: QimingclawApprovalRecord[]; 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 { interface QimingclawPlanRecord {
id: string; id: string;
remoteId?: string | null; remoteId?: string | null;
@@ -458,6 +517,37 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/scheduler/jobs') { if (method === 'GET' && pathname === '/api/scheduler/jobs') {
return { jobs: buildSchedulerJobs(await readQimingclawSnapshot()) } as T; 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') { if (method === 'GET' && pathname === '/api/debug/plans') {
return { plans: filterPlans(url.searchParams.get('q'), await readQimingclawSnapshot()) } as T; 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 { function parseJsonBody<T>(body: BodyInit | null | undefined): T {
if (typeof body !== 'string' || !body.trim()) return {} as T; if (typeof body !== 'string' || !body.trim()) return {} as T;
return JSON.parse(body) 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[] { 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)); 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)) { if (hasRuntimeRecords(snapshot)) {
const runtimeJobs = runtimeDebugPlans(snapshot).map((plan) => ({ const runtimeJobs = runtimeDebugPlans(snapshot).map((plan) => ({
id: `runtime-${plan.plan_id}`, id: `runtime-${plan.plan_id}`,
@@ -2243,6 +2497,22 @@ async function handleGovernanceCommand(
return response; 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)) { 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 记录兼容状态。`, { const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},当前由 qimingclaw 本地 adapter 记录兼容状态。`, {
plan_id: planId, plan_id: planId,

View File

@@ -340,7 +340,10 @@ export type GovernanceCommandKind =
| 'approve_approval' | 'approve_approval'
| 'reject_approval' | 'reject_approval'
| 'create_schedule' | 'create_schedule'
| 'update_schedule'
| 'pause_schedule' | 'pause_schedule'
| 'resume_schedule'
| 'delete_schedule'
| 'run_schedule_now'; | 'run_schedule_now';
export type GovernanceCommandErrorCode = 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

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </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"> <link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
</head> </head>
<body> <body>

View File

@@ -9,6 +9,8 @@ const packageRoot = path.resolve(__dirname, "..");
const requiredDigitalTables = [ const requiredDigitalTables = [
"digital_plans", "digital_plans",
"digital_plan_steps", "digital_plan_steps",
"digital_schedules",
"digital_schedule_runs",
"digital_tasks", "digital_tasks",
"digital_runs", "digital_runs",
"digital_events", "digital_events",
@@ -20,6 +22,7 @@ const requiredDigitalTables = [
const testFiles = [ const testFiles = [
"src/main/services/digitalEmployee/stateService.test.ts", "src/main/services/digitalEmployee/stateService.test.ts",
"src/main/services/digitalEmployee/schedulerService.test.ts",
"src/main/ipc/eventForwarders.test.ts", "src/main/ipc/eventForwarders.test.ts",
"src/main/services/digitalEmployee/syncService.test.ts", "src/main/services/digitalEmployee/syncService.test.ts",
"src/main/services/digitalEmployee/skillCatalogService.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_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_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 = {}) { function runStep(title, command, args, options = {}) {

View File

@@ -14,6 +14,7 @@ import {
stopSandboxService, stopSandboxService,
} from "../services/sandbox/serviceBootstrap"; } from "../services/sandbox/serviceBootstrap";
import { startDigitalEmployeeSyncWorker } from "../services/digitalEmployee/syncService"; import { startDigitalEmployeeSyncWorker } from "../services/digitalEmployee/syncService";
import { startDigitalEmployeeScheduler } from "../services/digitalEmployee/schedulerService";
/** 依赖同步是否正在进行 */ /** 依赖同步是否正在进行 */
let _depsSyncInProgress = false; let _depsSyncInProgress = false;
@@ -92,6 +93,10 @@ export async function runStartupTasks(): Promise<void> {
startDigitalEmployeeSyncWorker(); startDigitalEmployeeSyncWorker();
}); });
setImmediate(() => {
startDigitalEmployeeScheduler();
});
// 客户端升级后:若 appVersion 或 installVersion 变化,后台同步初始化依赖到写死版本 // 客户端升级后:若 appVersion 或 installVersion 变化,后台同步初始化依赖到写死版本
// 同步检查是否需要 dep sync提前设置标志避免 renderer 在 setImmediate 之前 // 同步检查是否需要 dep sync提前设置标志避免 renderer 在 setImmediate 之前
// 读到 syncInProgress=false 而过早启动服务(竞态条件) // 读到 syncInProgress=false 而过早启动服务(竞态条件)

View File

@@ -60,6 +60,59 @@ const DIGITAL_EMPLOYEE_SCHEMA_SQL = `
FOREIGN KEY(plan_id) REFERENCES digital_plans(id) 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 ( CREATE TABLE IF NOT EXISTS digital_runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
remote_id TEXT, 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_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_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_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_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_runs_task ON digital_runs(task_id);
CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at); CREATE INDEX IF NOT EXISTS idx_digital_events_plan ON digital_events(plan_id, occurred_at);

View File

@@ -13,6 +13,9 @@ import {
readDigitalEmployeeRuntimeRecords, readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState, readDigitalEmployeeState,
readDigitalEmployeeUiState, readDigitalEmployeeUiState,
readDigitalEmployeeCronSettings,
saveDigitalEmployeeCronSettings,
readDigitalEmployeeScheduleRuns,
saveDigitalEmployeeUiState, saveDigitalEmployeeUiState,
type DigitalEmployeeManagedService, type DigitalEmployeeManagedService,
type DigitalEmployeeGovernanceCommandRecord, type DigitalEmployeeGovernanceCommandRecord,
@@ -20,6 +23,10 @@ import {
type DigitalEmployeeSnapshot, type DigitalEmployeeSnapshot,
type DigitalEmployeeUiStateUpdate, type DigitalEmployeeUiStateUpdate,
} from "../services/digitalEmployee/stateService"; } from "../services/digitalEmployee/stateService";
import {
runDigitalEmployeeScheduleNow,
runDigitalEmployeeSchedulerCheck,
} from "../services/digitalEmployee/schedulerService";
import { import {
flushDigitalEmployeeSyncOutbox, flushDigitalEmployeeSyncOutbox,
getDigitalEmployeeSyncStatus, getDigitalEmployeeSyncStatus,
@@ -78,6 +85,26 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return flushDigitalEmployeeSyncOutbox({ force: true }); 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( ipcMain.handle(
"digitalEmployee:recordGovernanceCommand", "digitalEmployee:recordGovernanceCommand",
async (_, command: DigitalEmployeeGovernanceCommandRecord) => { async (_, command: DigitalEmployeeGovernanceCommandRecord) => {

View File

@@ -40,6 +40,7 @@ import { migrateDataDir, migrateSettingsPaths } from "./bootstrap/migrate";
import { getDeviceId, logSystemInfo } from "./services/system/deviceId"; import { getDeviceId, logSystemInfo } from "./services/system/deviceId";
import { initWebviewPolicy } from "./services/system/webviewPolicy"; import { initWebviewPolicy } from "./services/system/webviewPolicy";
import { stopDigitalEmployeeSyncWorker } from "./services/digitalEmployee/syncService"; import { stopDigitalEmployeeSyncWorker } from "./services/digitalEmployee/syncService";
import { stopDigitalEmployeeScheduler } from "./services/digitalEmployee/schedulerService";
// macOS 26 Tahoe 兼容性:禁用 Fontations 字体后端 // macOS 26 Tahoe 兼容性:禁用 Fontations 字体后端
// 参考: https://github.com/electron/electron/issues/49522 // 参考: 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 runCleanupStep("Computer server stop", async () => {
await stopComputerServer(); await stopComputerServer();
}); });

View File

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

View File

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

View File

@@ -354,6 +354,77 @@ describe("digital employee state service", () => {
status: "approved", 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 { interface TestPlanRow {
@@ -445,6 +516,49 @@ interface TestApprovalRow {
updated_at: string; 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 { interface TestOutboxRow {
id: string; id: string;
entity_type: string; entity_type: string;
@@ -466,6 +580,8 @@ class TestDb {
events = new Map<string, TestEventRow>(); events = new Map<string, TestEventRow>();
artifacts = new Map<string, TestArtifactRow>(); artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>(); approvals = new Map<string, TestApprovalRow>();
schedules = new Map<string, TestScheduleRow>();
scheduleRuns = new Map<string, TestScheduleRunRow>();
outbox = new Map<string, TestOutboxRow>(); outbox = new Map<string, TestOutboxRow>();
transaction<T>(callback: () => T): () => T { transaction<T>(callback: () => T): () => T {
@@ -488,6 +604,8 @@ class TestDb {
private all(sql: string, _args: unknown[]): unknown[] { private all(sql: string, _args: unknown[]): unknown[] {
if (sql.includes("FROM digital_plans")) return Array.from(this.plans.values()); 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_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_tasks")) return Array.from(this.tasks.values());
if (sql.includes("FROM digital_runs")) return Array.from(this.runs.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()); 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")) { 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); 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 }; return { count: 0 };
} }
@@ -561,6 +685,94 @@ class TestDb {
return { changes: 1 }; 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")) { if (sql.startsWith("INSERT INTO digital_tasks")) {
const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [ const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [
string, string,
@@ -629,6 +841,23 @@ class TestDb {
return { changes: previous ? 1 : 0 }; 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")) { if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [ const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [
string, string,

View File

@@ -105,6 +105,55 @@ export interface DigitalEmployeePlanStepRecord {
updatedAt: string; 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 { export interface DigitalEmployeeRunRecord {
id: string; id: string;
remoteId?: string | null; remoteId?: string | null;
@@ -173,6 +222,8 @@ export interface DigitalEmployeeRuntimeRecords {
generatedAt: string; generatedAt: string;
plans: DigitalEmployeePlanRecord[]; plans: DigitalEmployeePlanRecord[];
planSteps: DigitalEmployeePlanStepRecord[]; planSteps: DigitalEmployeePlanStepRecord[];
schedules: DigitalEmployeeScheduleRecord[];
scheduleRuns: DigitalEmployeeScheduleRunRecord[];
tasks: DigitalEmployeeTaskRecord[]; tasks: DigitalEmployeeTaskRecord[];
runs: DigitalEmployeeRunRecord[]; runs: DigitalEmployeeRunRecord[];
events: DigitalEmployeeEventRecord[]; events: DigitalEmployeeEventRecord[];
@@ -262,6 +313,45 @@ export interface DigitalEmployeeGovernanceCommandRecord {
result?: Record<string, unknown> | null; 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 { function defaultState(): DigitalEmployeeState {
return { return {
version: 1, version: 1,
@@ -433,6 +523,32 @@ export function saveDigitalEmployeeUiState(
return state; 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 { function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
if (update.action !== "decide_approval") return; if (update.action !== "decide_approval") return;
const metadata = objectRecord(update.metadata); const metadata = objectRecord(update.metadata);
@@ -498,6 +614,8 @@ export function readDigitalEmployeeRuntimeRecords(
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
plans: [], plans: [],
planSteps: [], planSteps: [],
schedules: [],
scheduleRuns: [],
tasks: [], tasks: [],
runs: [], runs: [],
events: [], events: [],
@@ -521,6 +639,24 @@ export function readDigitalEmployeeRuntimeRecords(
ORDER BY plan_id ASC, seq ASC, created_at ASC ORDER BY plan_id ASC, seq ASC, created_at ASC
LIMIT ? LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>; `).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(` const tasks = db.prepare(`
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload, SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
sync_status, last_synced_at, sync_error, created_at, updated_at sync_status, last_synced_at, sync_error, created_at, updated_at
@@ -561,6 +697,8 @@ export function readDigitalEmployeeRuntimeRecords(
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
plans: plans.map(toPlanRecord), plans: plans.map(toPlanRecord),
planSteps: planSteps.map(toPlanStepRecord), planSteps: planSteps.map(toPlanStepRecord),
schedules: schedules.map(toScheduleRecord),
scheduleRuns: scheduleRuns.map(toScheduleRunRecord),
tasks: tasks.map(toTaskRecord), tasks: tasks.map(toTaskRecord),
runs: runs.map(toRunRecord), runs: runs.map(toRunRecord),
events: events.map(toEventRecord), 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( export function recordDigitalEmployeeSnapshot(
snapshot: DigitalEmployeeSnapshot, snapshot: DigitalEmployeeSnapshot,
): DigitalEmployeeState { ): DigitalEmployeeState {
@@ -845,6 +1225,18 @@ export function recordDigitalEmployeeGovernanceCommand(
now, 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({ upsertRun({
id: ids.runId, id: ids.runId,
planId: ids.planId, planId: ids.planId,
@@ -887,6 +1279,14 @@ function governanceCommandTitle(commandKind: string): string {
return "激活数字员工计划"; return "激活数字员工计划";
case "create_schedule": case "create_schedule":
return "创建数字员工定时任务"; return "创建数字员工定时任务";
case "update_schedule":
return "更新数字员工定时任务";
case "pause_schedule":
return "暂停数字员工定时任务";
case "resume_schedule":
return "恢复数字员工定时任务";
case "delete_schedule":
return "删除数字员工定时任务";
case "run_schedule_now": case "run_schedule_now":
return "立即运行数字员工计划"; return "立即运行数字员工计划";
case "pause_plan": 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( function extractPlanStepInputs(
payload: Record<string, unknown>, payload: Record<string, unknown>,
planId: string, planId: string,
@@ -1113,6 +1594,10 @@ function governanceCommandStatus(
return "reviewing"; return "reviewing";
case "approve_plan": case "approve_plan":
return "approved"; return "approved";
case "pause_schedule":
return "paused";
case "delete_schedule":
return "deleted";
case "pause_plan": case "pause_plan":
return "paused"; return "paused";
case "cancel_plan": case "cancel_plan":
@@ -1200,6 +1685,34 @@ function optionalStringField(
return typeof value === "string" && value.trim() ? value : null; 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 { function parseStoredPayload(value: unknown): unknown {
if (typeof value !== "string" || !value.trim()) return {}; if (typeof value !== "string" || !value.trim()) return {};
try { 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 { function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
return { return {
id: stringField(row, "id"), 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: { function upsertPlan(input: {
id: string; id: string;
title: string; title: string;

View File

@@ -394,6 +394,51 @@ describe("digital employee sync service", () => {
sync_error: "步骤同步被拒绝", 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 { function insertPlan(id: string): void {
@@ -456,6 +501,8 @@ function entityMap(
if (!mockState.db) return undefined; if (!mockState.db) return undefined;
if (entityType === "plan") return mockState.db.plans; if (entityType === "plan") return mockState.db.plans;
if (entityType === "plan_step") return mockState.db.planSteps; 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 === "artifact") return mockState.db.artifacts;
if (entityType === "approval") return mockState.db.approvals; if (entityType === "approval") return mockState.db.approvals;
return undefined; return undefined;
@@ -522,6 +569,8 @@ class TestDb {
outbox = new Map<string, TestOutboxRow>(); outbox = new Map<string, TestOutboxRow>();
plans = new Map<string, TestSyncEntityRow>(); plans = new Map<string, TestSyncEntityRow>();
planSteps = new Map<string, TestSyncEntityRow>(); planSteps = new Map<string, TestSyncEntityRow>();
schedules = new Map<string, TestSyncEntityRow>();
scheduleRuns = new Map<string, TestSyncEntityRow>();
artifacts = new Map<string, TestSyncEntityRow>(); artifacts = new Map<string, TestSyncEntityRow>();
approvals = new Map<string, TestSyncEntityRow>(); approvals = new Map<string, TestSyncEntityRow>();
attempts: TestAttemptRow[] = []; attempts: TestAttemptRow[] = [];
@@ -629,6 +678,16 @@ class TestDb {
return this.planSteps.get(id); 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 ( if (
sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?") sql.includes("COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
) { ) {
@@ -726,7 +785,7 @@ class TestDb {
return { changes: before - this.attempts.length }; 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 [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
const row = this.entityRowsForUpdate(sql).get(id); const row = this.entityRowsForUpdate(sql).get(id);
if (row) { if (row) {
@@ -738,7 +797,7 @@ class TestDb {
return { changes: row ? 1 : 0 }; 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 [syncError, id] = args as [string, string];
const row = this.entityRowsForUpdate(sql).get(id); const row = this.entityRowsForUpdate(sql).get(id);
if (row) { if (row) {
@@ -754,6 +813,8 @@ class TestDb {
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> { private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts; if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals; 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; if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
return this.plans; return this.plans;
} }

View File

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

View File

@@ -116,6 +116,21 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async flushSync() { async flushSync() {
return ipcRenderer.invoke("digitalEmployee: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) { async recordGovernanceCommand(command: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command); return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
}, },

View File

@@ -445,7 +445,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。 - 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。 - 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 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。 - 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings保存时会记录 `digital_workday_*` 本地事件并进入 outbox避免这些页面操作只留在 webview localStorage。
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。 - 数字员工技能库通过 `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 / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。 - 数字员工调试存储、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 / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
@@ -545,9 +545,10 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕 Task Agent 状态机补步骤级治理命令。 - 建议:下一轮围绕 Task Agent 状态机补步骤级治理命令。
2. **调度 / 定时 / 周期任务** 2. **调度 / 定时 / 周期任务**
- 已吸收:`/api/scheduler/jobs` qimingclaw 兼容投影,`create_schedule``run_schedule_now` 能返回本地可解释结果 - 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录
- 缺口:没有数字员工自己的 schedule 表、cron 创建/更新/删除、定时触发 `/computer/chat`、调度运行历史和失败补偿 - 当前能力:主进程 `DigitalEmployeeScheduler` 可按本机 5 字段 cron 到点触发本地 `/computer/chat`,支持启动 catch-up、立即运行、暂停/恢复/删除、失败重试、运行历史和同步诊断
- 建议:优先落地 `digital_schedules``digital_schedule_runs`,把周期任务变成真实 Plan/Task 入口 - 后续缺口:更细的秒级 cron、复杂日历排除规则、多调度并发策略 UI 和管理端调度策略下发仍待吸收
- 建议:下一轮围绕入口路由/任务分流,把 schedule 触发的计划与具体 Skill Policy、Task Agent 状态机更精确地绑定。
3. **MemoryEntry / 长期记忆** 3. **MemoryEntry / 长期记忆**
- 已吸收qimingclaw 主客户端已有 memory 服务和 scheduler但尚未纳入数字员工模型。 - 已吸收qimingclaw 主客户端已有 memory 服务和 scheduler但尚未纳入数字员工模型。
@@ -620,6 +621,8 @@ GET /api/digital-employee/approvals
```text ```text
digital_plans digital_plans
digital_plan_steps digital_plan_steps
digital_schedules
digital_schedule_runs
digital_tasks digital_tasks
digital_runs digital_runs
digital_events digital_events