吸收数字员工周期调度闭环
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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 而过早启动服务(竞态条件)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 / Approval;Run/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 / Approval;Run/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 与 outbox,entity 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
|
||||||
|
|||||||
Reference in New Issue
Block a user