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