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

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

View File

@@ -3,6 +3,7 @@ import type {
ArtifactEntry,
CanonicalEvent,
CronJob,
CronRun,
DashboardResponse,
DebugPlan,
DebugRun,
@@ -39,6 +40,11 @@ declare global {
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
flushSync?: () => Promise<QimingclawSyncStatus>;
getCronSettings?: () => Promise<QimingclawCronSettings>;
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
};
@@ -180,6 +186,8 @@ interface QimingclawRuntimeRecords {
generatedAt: string;
plans: QimingclawPlanRecord[];
planSteps?: QimingclawPlanStepRecord[];
schedules?: QimingclawScheduleRecord[];
scheduleRuns?: QimingclawScheduleRunRecord[];
tasks: QimingclawTaskRecord[];
runs: QimingclawRunRecord[];
events: QimingclawEventRecord[];
@@ -187,6 +195,57 @@ interface QimingclawRuntimeRecords {
approvals?: QimingclawApprovalRecord[];
}
interface QimingclawCronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
max_catch_up_runs: number;
max_concurrent_schedule_runs: number;
}
interface QimingclawScheduleRecord {
id: string;
remoteId?: string | null;
planId?: string | null;
name: string;
description?: string | null;
cronExpr: string;
timezone: string;
status: string;
enabled: boolean;
prompt?: string | null;
command?: string | null;
payload: unknown;
nextRunAt?: string | null;
lastRunAt?: string | null;
catchUpOnStartup: boolean;
maxCatchUpRuns: number;
maxRunHistory: number;
failureCount: number;
lastError?: string | null;
syncStatus: string;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
interface QimingclawScheduleRunRecord {
id: string;
scheduleId: string;
planId?: string | null;
runId?: string | null;
dueAt: string;
startedAt?: string | null;
finishedAt?: string | null;
status: string;
attemptNo: number;
triggerKind: string;
error?: string | null;
payload: unknown;
createdAt: string;
updatedAt: string;
}
interface QimingclawPlanRecord {
id: string;
remoteId?: string | null;
@@ -458,6 +517,37 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/scheduler/jobs') {
return { jobs: buildSchedulerJobs(await readQimingclawSnapshot()) } as T;
}
if (method === 'GET' && pathname === '/api/cron') {
return { jobs: buildSchedulerJobs(await readQimingclawSnapshot()) } as T;
}
if (method === 'POST' && pathname === '/api/cron') {
return await handleCronCreate(parseJsonBody<Record<string, unknown>>(options.body), await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname === '/api/cron/settings') {
return await readCronSettings() as T;
}
if (method === 'PATCH' && pathname === '/api/cron/settings') {
const patch = parseJsonBody<Partial<QimingclawCronSettings>>(options.body);
return await saveCronSettings(patch) as T;
}
if (method === 'POST' && pathname === '/api/cron/check') {
const result = await window.QimingClawBridge?.digital?.runSchedulerCheck?.();
return { ok: true, activated: result?.activated ?? 0, ...result } as T;
}
const cronRunMatch = pathname.match(/^\/api\/cron\/([^/]+)\/runs$/);
if (method === 'GET' && cronRunMatch) {
const runs = await readCronRuns(decodeURIComponent(cronRunMatch[1] || ''), Number(url.searchParams.get('limit')) || 20);
return { runs } as T;
}
const cronJobMatch = pathname.match(/^\/api\/cron\/([^/]+)$/);
if ((method === 'PATCH' || method === 'DELETE') && cronJobMatch) {
const scheduleId = decodeURIComponent(cronJobMatch[1] || '');
if (method === 'DELETE') {
await handleCronCommand('delete_schedule', scheduleId, {}, await readQimingclawSnapshot());
return undefined as T;
}
return await handleCronPatch(scheduleId, parseJsonBody<Record<string, unknown>>(options.body), await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname === '/api/debug/plans') {
return { plans: filterPlans(url.searchParams.get('q'), await readQimingclawSnapshot()) } as T;
}
@@ -620,6 +710,122 @@ async function recordGovernanceCommand(
}
}
async function readCronSettings(): Promise<QimingclawCronSettings> {
try {
return await window.QimingClawBridge?.digital?.getCronSettings?.() ?? defaultCronSettings();
} catch {
return defaultCronSettings();
}
}
async function saveCronSettings(patch: Partial<QimingclawCronSettings>): Promise<QimingclawCronSettings> {
try {
return await window.QimingClawBridge?.digital?.saveCronSettings?.(patch) ?? { ...defaultCronSettings(), ...patch };
} catch {
return { ...defaultCronSettings(), ...patch };
}
}
function defaultCronSettings(): QimingclawCronSettings {
return {
enabled: true,
catch_up_on_startup: true,
max_run_history: 100,
max_catch_up_runs: 5,
max_concurrent_schedule_runs: 1,
};
}
async function handleCronCreate(
body: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<{ status: string; job: CronJob }> {
const stamp = Date.now();
const scheduleId = stringFromUnknown(body.id) || stringFromUnknown(body.schedule_id) || `cron-${slugifyIdPart(stringFromUnknown(body.name) || 'job')}-${stamp}`;
await handleGovernanceCommand({
command_kind: 'create_schedule',
context_refs: { schedule_id: scheduleId, plan_id: stringFromUnknown(body.plan_id) || stringFromUnknown(body.template_id) || undefined },
payload: {
...body,
schedule_id: scheduleId,
cron_expr: stringFromUnknown(body.schedule) || stringFromUnknown(body.cron_expr),
},
correlation_id: `qimingclaw-cron-create-${stamp}`,
idempotency_key: `qimingclaw-cron-create-${scheduleId}-${stamp}`,
}, snapshot);
const refreshed = await readQimingclawSnapshot();
return { status: 'created', job: buildSchedulerJobs(refreshed).find((job) => job.id === scheduleId) ?? cronJobFromBody(scheduleId, body) };
}
async function handleCronPatch(
scheduleId: string,
patch: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<{ status: string; job: CronJob }> {
await handleCronCommand('update_schedule', scheduleId, patch, snapshot);
const refreshed = await readQimingclawSnapshot();
return { status: 'updated', job: buildSchedulerJobs(refreshed).find((job) => job.id === scheduleId) ?? cronJobFromBody(scheduleId, patch) };
}
async function handleCronCommand(
commandKind: GovernanceCommandRequest['command_kind'],
scheduleId: string,
payload: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<GovernanceCommandResponse> {
const stamp = Date.now();
return handleGovernanceCommand({
command_kind: commandKind,
context_refs: { schedule_id: scheduleId, plan_id: stringFromUnknown(payload.plan_id) || stringFromUnknown(payload.template_id) || undefined },
payload: {
...payload,
schedule_id: scheduleId,
cron_expr: stringFromUnknown(payload.schedule) || stringFromUnknown(payload.cron_expr),
},
correlation_id: `qimingclaw-cron-${commandKind}-${stamp}`,
idempotency_key: `qimingclaw-cron-${commandKind}-${scheduleId}-${stamp}`,
}, snapshot);
}
function cronJobFromBody(id: string, body: Record<string, unknown>): CronJob {
const now = new Date().toISOString();
const expression = stringFromUnknown(body.schedule) || stringFromUnknown(body.cron_expr) || '0 9 * * *';
return {
id,
name: stringFromUnknown(body.name) || id,
expression,
command: stringFromUnknown(body.command) || `digital-employee:${id}`,
prompt: stringFromUnknown(body.prompt) || null,
job_type: stringFromUnknown(body.job_type) || 'qimingclaw_schedule',
schedule: { expression, schedule_id: id },
enabled: body.enabled !== false,
delivery: { mode: 'qimingclaw_schedule', schedule_id: id },
template_id: stringFromUnknown(body.template_id) || stringFromUnknown(body.plan_id) || id,
trigger_kind: 'scheduled',
delete_after_run: body.delete_after_run === true,
created_at: now,
next_run: now,
last_run: null,
last_status: null,
last_output: null,
};
}
async function readCronRuns(scheduleId: string, limit: number): Promise<CronRun[]> {
const runs = await window.QimingClawBridge?.digital?.getScheduleRuns?.(scheduleId, limit).catch(() => []) ?? [];
return runs.map((run, index) => ({
id: Number.isFinite(Date.parse(run.createdAt)) ? Date.parse(run.createdAt) + index : index + 1,
job_id: run.scheduleId,
started_at: run.startedAt || run.createdAt,
finished_at: run.finishedAt || '',
status: run.status,
output: run.error || null,
duration_ms: run.startedAt && run.finishedAt
? Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt))
: null,
}));
}
function parseJsonBody<T>(body: BodyInit | null | undefined): T {
if (typeof body !== 'string' || !body.trim()) return {} as T;
return JSON.parse(body) as T;
@@ -1126,8 +1332,56 @@ function planTemplateJob(plan: DebugPlan, snapshot: QimingclawSnapshot | null):
};
}
function runtimeScheduleJob(schedule: QimingclawScheduleRecord, snapshot: QimingclawSnapshot | null): CronJob {
const plan = schedule.planId
? mergedDebugPlans(snapshot).find((item) => item.plan_id === schedule.planId)
: null;
const runs = snapshot?.runtime?.scheduleRuns ?? [];
const latestRun = runs.find((run) => run.scheduleId === schedule.id);
return {
id: schedule.id,
name: schedule.name,
expression: schedule.cronExpr,
command: schedule.command || `digital-employee:${schedule.planId || schedule.id}`,
prompt: schedule.prompt ?? schedule.description ?? null,
job_type: 'qimingclaw_schedule',
schedule: {
mode: 'qimingclaw_schedule',
schedule_id: schedule.id,
plan_id: schedule.planId,
expression: schedule.cronExpr,
timezone: schedule.timezone,
status: schedule.status,
failure_count: schedule.failureCount,
last_error: schedule.lastError,
},
enabled: schedule.enabled && schedule.status === 'enabled' && !schedule.deletedAt,
delivery: { mode: 'qimingclaw_schedule', schedule_id: schedule.id, plan_id: schedule.planId },
template_id: schedule.planId ?? schedule.id,
trigger_kind: 'scheduled',
step_count: plan?.steps?.length ?? 0,
skill_ids: [...new Set((plan?.steps ?? []).flatMap((step) => step.preferred_skills ?? []))],
delete_after_run: false,
created_at: schedule.createdAt,
next_run: schedule.nextRunAt || schedule.updatedAt,
last_run: schedule.lastRunAt ?? latestRun?.finishedAt ?? latestRun?.startedAt ?? null,
last_status: latestRun?.status ?? (schedule.lastError ? 'failed' : null),
last_output: latestRun?.error ?? schedule.lastError ?? null,
};
}
function buildSchedulerJobs(snapshot: QimingclawSnapshot | null = null): CronJob[] {
const realScheduleJobs = (snapshot?.runtime?.schedules ?? [])
.filter((schedule) => schedule.status !== 'deleted')
.map((schedule) => runtimeScheduleJob(schedule, snapshot));
const templateJobs = templatePlans(snapshot).map((plan) => planTemplateJob(plan, snapshot));
if (realScheduleJobs.length > 0) {
const realTemplateIds = new Set<string | null | undefined>(realScheduleJobs.map((job) => job.template_id));
return [
...realScheduleJobs,
...templateJobs.filter((job) => !realTemplateIds.has(job.template_id)),
];
}
if (hasRuntimeRecords(snapshot)) {
const runtimeJobs = runtimeDebugPlans(snapshot).map((plan) => ({
id: `runtime-${plan.plan_id}`,
@@ -2243,6 +2497,22 @@ async function handleGovernanceCommand(
return response;
}
if (['update_schedule', 'pause_schedule', 'resume_schedule', 'delete_schedule'].includes(body.command_kind)) {
const status = body.command_kind === 'pause_schedule'
? 'paused'
: body.command_kind === 'delete_schedule'
? 'deleted'
: 'enabled';
const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},并更新 qimingclaw 本地定时任务状态。`, {
schedule_id: scheduleId || `qimingclaw-schedule-${Date.now()}`,
plan_id: planId,
status,
source: 'qimingclaw-adapter',
});
await recordGovernanceCommand(body, response);
return response;
}
if (['submit_plan', 'approve_plan', 'amend_plan', 'resume_plan', 'pause_plan', 'cancel_plan'].includes(body.command_kind)) {
const response = governanceAccepted(commandId, correlationId, `已接受 ${body.command_kind},当前由 qimingclaw 本地 adapter 记录兼容状态。`, {
plan_id: planId,

View File

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