吸收数字员工PlanStep跨任务编排执行
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
||||
BusinessViewResponse,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDispatchPolicy,
|
||||
ReadyStepDispatchResult,
|
||||
NotificationPreferences,
|
||||
UserNotification,
|
||||
DebugStoreResponse,
|
||||
@@ -713,6 +714,13 @@ export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
||||
});
|
||||
}
|
||||
|
||||
export function dispatchPlanReadySteps(planId: string): Promise<ReadyStepDispatchResult> {
|
||||
return apiFetch<ReadyStepDispatchResult>(`/api/digital-employee/plans/${encodeURIComponent(planId)}/dispatch-ready-steps`, {
|
||||
method: 'POST',
|
||||
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDebugPlans(): Promise<DebugPlan[]> {
|
||||
return apiFetch<{ plans: DebugPlan[] }>('/api/debug/plans?limit=80')
|
||||
.then((data) => data.plans ?? []);
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
MemoryEntry,
|
||||
NotificationPreferences,
|
||||
PlanDispatchResponse,
|
||||
ReadyStepDispatchResult,
|
||||
PlanStepDispatchPolicy,
|
||||
PlanStepSummary,
|
||||
RouteDecisionRecord,
|
||||
@@ -63,6 +64,7 @@ declare global {
|
||||
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
|
||||
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
||||
runScheduleNow?: (scheduleId: string) => Promise<{ ok?: boolean; scheduleRunId?: string; error?: string }>;
|
||||
dispatchPlanReadySteps?: (planId: string) => Promise<ReadyStepDispatchResult>;
|
||||
getScheduleRuns?: (scheduleId: string, limit?: number) => Promise<QimingclawScheduleRunRecord[]>;
|
||||
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
|
||||
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||
@@ -925,6 +927,10 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname.startsWith('/api/debug/task/') && pathname.endsWith('/flow')) {
|
||||
return buildTaskFlow(decodeURIComponent(pathname.split('/')[4] || ''), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const planReadyDispatchMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)\/dispatch-ready-steps$/);
|
||||
if (method === 'POST' && planReadyDispatchMatch) {
|
||||
return await dispatchPlanReadySteps(decodeURIComponent(planReadyDispatchMatch[1] || ''), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname.startsWith('/api/debug/plan/') && pathname.endsWith('/dispatch')) {
|
||||
return await dispatchPlan(decodeURIComponent(pathname.split('/')[4] || ''), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
@@ -6123,6 +6129,89 @@ async function dispatchPlan(
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchPlanReadySteps(
|
||||
planId: string,
|
||||
snapshot: QimingclawSnapshot | null = null,
|
||||
): Promise<ReadyStepDispatchResult> {
|
||||
const targetPlanId = stringValue(planId);
|
||||
const bridgeResult = targetPlanId ? await window.QimingClawBridge?.digital?.dispatchPlanReadySteps?.(targetPlanId).catch(() => null) : null;
|
||||
if (bridgeResult) return bridgeResult;
|
||||
const now = new Date().toISOString();
|
||||
const orchestrationId = `manual-ready-steps-${slugifyIdPart(targetPlanId || 'unknown')}-${slugifyIdPart(now)}`;
|
||||
const rows = planStepRows(snapshot).filter((row) => stringValue(row.plan_id) === targetPlanId && stringValue(row.status).toLowerCase() === 'ready');
|
||||
const state = notificationAdapterState(snapshot);
|
||||
const policy = planStepDispatchPolicy(state);
|
||||
const result: ReadyStepDispatchResult = {
|
||||
ok: true,
|
||||
plan_id: targetPlanId || null,
|
||||
trigger_source: 'manual',
|
||||
orchestration_id: orchestrationId,
|
||||
candidates: rows.length,
|
||||
dispatched: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
dispatched_step_ids: [],
|
||||
skipped_step_ids: [],
|
||||
failed_step_ids: [],
|
||||
errors: [],
|
||||
};
|
||||
if (!targetPlanId || !policy.enabled) {
|
||||
result.skipped = rows.length;
|
||||
result.skipped_step_ids = rows.map((row) => stringValue(row.step_id)).filter(Boolean);
|
||||
return result;
|
||||
}
|
||||
const maxPerSweep = Math.max(1, Math.min(Math.floor(policy.max_per_sweep), 10));
|
||||
let attempts = 0;
|
||||
for (const row of rows) {
|
||||
const stepId = stringValue(row.step_id);
|
||||
const taskId = stringValue(row.task_id);
|
||||
if (attempts >= maxPerSweep || row.dispatchable !== true || !taskId) {
|
||||
result.skipped += 1;
|
||||
if (stepId) result.skipped_step_ids?.push(stepId);
|
||||
continue;
|
||||
}
|
||||
attempts += 1;
|
||||
try {
|
||||
const runId = stringValue(row.latest_run_id) || `${taskId}:run:start_run:${slugifyIdPart(orchestrationId)}`;
|
||||
const commandId = `manual-ready-step-${slugifyIdPart(stepId)}-${Date.now()}`;
|
||||
await recordGovernanceCommand(
|
||||
{
|
||||
command_kind: 'start_run',
|
||||
context_refs: { plan_id: targetPlanId, task_id: taskId, run_id: runId, step_id: stepId },
|
||||
payload: {
|
||||
source: 'digital-home-plan-orchestration',
|
||||
plan_id: targetPlanId,
|
||||
task_id: taskId,
|
||||
run_id: runId,
|
||||
step_id: stepId,
|
||||
trigger_source: 'manual',
|
||||
orchestration_id: orchestrationId,
|
||||
candidate_count: rows.length,
|
||||
},
|
||||
correlation_id: commandId,
|
||||
},
|
||||
governanceAccepted(commandId, commandId, '已手动推进 Ready PlanStep。', {
|
||||
plan_id: targetPlanId,
|
||||
task_id: taskId,
|
||||
run_id: runId,
|
||||
step_id: stepId,
|
||||
status: 'running',
|
||||
trigger_source: 'manual',
|
||||
orchestration_id: orchestrationId,
|
||||
source: 'qimingclaw-plan-orchestration',
|
||||
}),
|
||||
);
|
||||
result.dispatched += 1;
|
||||
if (stepId) result.dispatched_step_ids?.push(stepId);
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
if (stepId) result.failed_step_ids?.push(stepId);
|
||||
result.errors?.push({ stepId, error: error instanceof Error ? error.message : 'dispatch failed' });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPlanMessages(planId: string, snapshot: QimingclawSnapshot | null = null): Array<{ role: string; content: string; created_at?: string | null }> {
|
||||
const runtimePlan = runtimePlans(snapshot).find((candidate) => candidate.id === planId);
|
||||
if (runtimePlan) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import Avatar3D from '@/components/digital/Avatar3D';
|
||||
import { basePath } from '@/lib/basePath';
|
||||
import {
|
||||
dispatchPlanNow,
|
||||
dispatchPlanReadySteps,
|
||||
getDigitalEmployeeWorkday,
|
||||
getPlanStepGraph,
|
||||
getSchedulerJobs,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
recordApprovalAction,
|
||||
restartManagedService,
|
||||
triggerCronCheck,
|
||||
} from '@/lib/api';
|
||||
import { isTauri } from '@/lib/tauri';
|
||||
import type {
|
||||
@@ -60,10 +60,19 @@ interface DigitalPlanItem {
|
||||
conversationId?: string | null;
|
||||
stepCount: number;
|
||||
taskCount: number;
|
||||
readyStepCount: number;
|
||||
runningStepCount: number;
|
||||
blockedStepCount: number;
|
||||
schedulerJob?: CronJob | null;
|
||||
source: 'plan_template';
|
||||
}
|
||||
|
||||
interface PlanStepOrchestrationSummary {
|
||||
ready: number;
|
||||
running: number;
|
||||
blocked: number;
|
||||
}
|
||||
|
||||
interface BlockedPlanStepInputTarget {
|
||||
step: PlanStepBusinessRow;
|
||||
dependency: PlanStepDependencyDetail | null;
|
||||
@@ -405,6 +414,9 @@ function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], sele
|
||||
conversationId: linkedDuty?.conversation_id ?? templateId,
|
||||
stepCount,
|
||||
taskCount: skillCount,
|
||||
readyStepCount: 0,
|
||||
runningStepCount: 0,
|
||||
blockedStepCount: 0,
|
||||
schedulerJob: job,
|
||||
source: 'plan_template',
|
||||
};
|
||||
@@ -419,6 +431,26 @@ function planExecutionDisabledReason(planItem: DigitalPlanItem): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function planReadyStepActionLabel(planItem: DigitalPlanItem): string {
|
||||
if (planItem.readyStepCount > 0) return `推进 ${planItem.readyStepCount}`;
|
||||
return '推进';
|
||||
}
|
||||
|
||||
function planStepOrchestrationSummary(rows: PlanStepBusinessRow[]): Map<string, PlanStepOrchestrationSummary> {
|
||||
const summary = new Map<string, PlanStepOrchestrationSummary>();
|
||||
rows.forEach((step) => {
|
||||
const planId = step.plan_id || '';
|
||||
if (!planId) return;
|
||||
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0 };
|
||||
const status = step.status.toLowerCase();
|
||||
if (status === 'ready') current.ready += 1;
|
||||
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
|
||||
else if (status === 'blocked') current.blocked += 1;
|
||||
summary.set(planId, current);
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function eventMatchesPlanItem(event: DigitalEmployeeWorkEvent, planItem: DigitalPlanItem): boolean {
|
||||
const haystack = `${event.conversation_id ?? ''} ${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`;
|
||||
return haystack.includes(planItem.id) || haystack.includes(planItem.title);
|
||||
@@ -946,6 +978,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
|
||||
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
|
||||
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [planStepGraphRows, setPlanStepGraphRows] = useState<PlanStepBusinessRow[]>([]);
|
||||
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
|
||||
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
|
||||
const [blockedInputDraft, setBlockedInputDraft] = useState('');
|
||||
@@ -1019,14 +1052,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
setManagementSyncStatus(status);
|
||||
});
|
||||
|
||||
const planStepGraphPromise = getPlanStepGraph({ status: 'blocked', page_size: 6 })
|
||||
const planStepGraphPromise = getPlanStepGraph({ page_size: 80 })
|
||||
.then((response) => {
|
||||
if (!mountedRef.current) return;
|
||||
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked'));
|
||||
setPlanStepGraphRows(response.items);
|
||||
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked').slice(0, 6));
|
||||
setBlockedPlanStepError(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!mountedRef.current) return;
|
||||
setPlanStepGraphRows([]);
|
||||
setBlockedPlanSteps([]);
|
||||
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
|
||||
});
|
||||
@@ -1066,6 +1101,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
[isLiveProjection, workdayProjection?.duties],
|
||||
);
|
||||
const planItems = useMemo(() => {
|
||||
const orchestrationByPlan = planStepOrchestrationSummary(planStepGraphRows);
|
||||
return schedulerJobs
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
@@ -1084,9 +1120,20 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
})
|
||||
.map((job) => {
|
||||
const templateId = templateIdForSchedulerJob(job);
|
||||
return buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
|
||||
const item = buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
|
||||
const orchestration = orchestrationByPlan.get(item.planId) ?? orchestrationByPlan.get(templateId);
|
||||
if (!orchestration) return item;
|
||||
return {
|
||||
...item,
|
||||
readyStepCount: orchestration.ready,
|
||||
runningStepCount: orchestration.running,
|
||||
blockedStepCount: orchestration.blocked,
|
||||
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0
|
||||
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}`
|
||||
: item.resultText,
|
||||
};
|
||||
});
|
||||
}, [liveProjectionDuties, schedulerJobs, selectedDutyIds]);
|
||||
}, [liveProjectionDuties, planStepGraphRows, schedulerJobs, selectedDutyIds]);
|
||||
const selectedDuties = useMemo(
|
||||
() => planItems.filter((planItem) => selectedDutyIds.has(planItem.id)),
|
||||
[planItems, selectedDutyIds],
|
||||
@@ -1717,13 +1764,14 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
throw new Error(response.error || '执行接口未接受请求');
|
||||
}
|
||||
const dispatchedCount = response.dispatched_tasks?.length ?? 0;
|
||||
const schedulerResult = await triggerCronCheck();
|
||||
const readyDispatched = schedulerResult.readyStepDispatch?.dispatched ?? 0;
|
||||
const readyFailed = schedulerResult.readyStepDispatch?.failed ?? 0;
|
||||
const readyResult = await dispatchPlanReadySteps(planItem.planId);
|
||||
const readyDispatched = readyResult.dispatched ?? 0;
|
||||
const readySkipped = readyResult.skipped ?? 0;
|
||||
const readyFailed = readyResult.failed ?? 0;
|
||||
setExecutePlanItem(null);
|
||||
setActionNotice(
|
||||
readyDispatched > 0 || readyFailed > 0
|
||||
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,Ready 步骤自动派发 ${readyDispatched} 个${readyFailed > 0 ? `,失败 ${readyFailed} 个` : ''}。`
|
||||
readyDispatched > 0 || readySkipped > 0 || readyFailed > 0
|
||||
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,推进 ${readyDispatched} 个${readySkipped > 0 ? `,跳过 ${readySkipped} 个` : ''}${readyFailed > 0 ? `,失败 ${readyFailed} 个` : ''}。`
|
||||
: `已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`,
|
||||
);
|
||||
await fetchProjection();
|
||||
@@ -1736,6 +1784,32 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const controlPlanReadySteps = useCallback(async (planItem: DigitalPlanItem) => {
|
||||
if (!isPlanItemExecutable(planItem)) {
|
||||
setActionError(planExecutionDisabledReason(planItem));
|
||||
setActionNotice(null);
|
||||
return;
|
||||
}
|
||||
const actionKey = `orchestrate:${planItem.id}`;
|
||||
setActionBusy(actionKey);
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await dispatchPlanReadySteps(planItem.planId);
|
||||
if (result.ok === false) {
|
||||
throw new Error(result.errors?.[0]?.error || '计划推进未被接受');
|
||||
}
|
||||
setActionNotice(`已推进:${planItem.title},派发 ${result.dispatched ?? 0} 个,跳过 ${result.skipped ?? 0} 个${(result.failed ?? 0) > 0 ? `,失败 ${result.failed}` : ''}。`);
|
||||
await fetchProjection();
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '计划推进失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const dismissWorkdayGuide = useCallback(() => {
|
||||
setGuideOpen(false);
|
||||
setGuideDismissedDate(selectedDate);
|
||||
@@ -1782,6 +1856,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
进入处理
|
||||
</button>
|
||||
<div className="de-duty-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
|
||||
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
|
||||
onClick={() => { void controlPlanReadySteps(planItem); }}
|
||||
>
|
||||
<TimerReset size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : planReadyStepActionLabel(planItem)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
@@ -1807,7 +1891,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [actionBusy, openDuty, selectedDutyIds, toggleDuty]);
|
||||
}, [actionBusy, controlPlanReadySteps, openDuty, selectedDutyIds, toggleDuty]);
|
||||
|
||||
const openEventDetail = useCallback((event: DigitalEmployeeWorkEvent) => {
|
||||
setSelectedEvent(event);
|
||||
@@ -2043,6 +2127,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span>{planItem.resultText}</span>
|
||||
</button>
|
||||
<div className="de-template-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
|
||||
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
|
||||
onClick={() => { void controlPlanReadySteps(planItem); }}
|
||||
>
|
||||
<TimerReset size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : '推进'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
|
||||
@@ -445,10 +445,17 @@ export interface GovernanceCommandResponse {
|
||||
}
|
||||
|
||||
export interface ReadyStepDispatchResult {
|
||||
ok?: boolean;
|
||||
plan_id?: string | null;
|
||||
trigger_source?: 'auto' | 'manual' | string;
|
||||
orchestration_id?: string;
|
||||
candidates: number;
|
||||
dispatched: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
dispatched_step_ids?: string[];
|
||||
skipped_step_ids?: string[];
|
||||
failed_step_ids?: string[];
|
||||
errors?: Array<{ stepId: string; error: string }>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user