吸收数字员工PlanStep依赖图派发策略

This commit is contained in:
baiyanyun
2026-06-10 18:41:33 +08:00
parent 3ac350eb27
commit d679639175
14 changed files with 795 additions and 212 deletions

View File

@@ -13,6 +13,7 @@ import type {
ChannelDetail,
BrowserSession,
ApprovalSubscriptionPreferences,
PlanStepDispatchPolicy,
NotificationPreferences,
UserNotification,
DebugStoreResponse,
@@ -544,7 +545,7 @@ export function getEventLog(params: {
// Notifications
// ---------------------------------------------------------------------------
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type UserNotification };
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type PlanStepDispatchPolicy, type UserNotification };
export function getNotifications(
status: string = 'unread',
@@ -595,6 +596,37 @@ export function getApprovalHistory(params: Record<string, string | number | null
return apiFetch(`/api/digital-employee/approval-history${suffix ? `?${suffix}` : ''}`);
}
export function getPlanStepDispatchPolicy(): Promise<PlanStepDispatchPolicy> {
return apiFetch<PlanStepDispatchPolicy>('/api/plan-step/dispatch-policy');
}
export function patchPlanStepDispatchPolicy(
patch: Partial<PlanStepDispatchPolicy>,
): Promise<PlanStepDispatchPolicy> {
return apiFetch<PlanStepDispatchPolicy>('/api/plan-step/dispatch-policy', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/plan-steps${suffix ? `?${suffix}` : ''}`);
}
export function getPlanStepGraph(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/plan-step-graph${suffix ? `?${suffix}` : ''}`);
}
export function markNotificationRead(id: string): Promise<boolean> {
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',

View File

@@ -34,6 +34,7 @@ import type {
MemoryEntry,
NotificationPreferences,
PlanDispatchResponse,
PlanStepDispatchPolicy,
PlanStepSummary,
RouteDecisionRecord,
RouteDecisionTimelineResponse,
@@ -89,6 +90,7 @@ interface AdapterState {
notificationStateById?: Record<string, NotificationState>;
notificationPreferences?: NotificationPreferences;
approvalSubscriptionPreferences?: ApprovalSubscriptionPreferences;
planStepDispatchPolicy?: PlanStepDispatchPolicy;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -691,7 +693,7 @@ const PLAN_SCHEDULES: Record<string, { expression: string; nextRun: string; sche
const BUSINESS_VIEW_SOURCE = 'qimingclaw-runtime-projection';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'approval-history' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewKind = 'plans' | 'plan-steps' | 'plan-step-graph' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'approval-history' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewRow = Record<string, unknown>;
export function isQimingclawDigitalApiEnabled(): boolean {
@@ -784,11 +786,28 @@ export async function handleQimingclawDigitalApi<T>(
await readQimingclawSnapshot(),
) as T;
}
if (method === 'GET' && pathname === '/api/plan-step/dispatch-policy') {
return planStepDispatchPolicy(notificationAdapterState(await readQimingclawSnapshot())) as T;
}
if (method === 'PATCH' && pathname === '/api/plan-step/dispatch-policy') {
return await handlePlanStepDispatchPolicyPatch(
parseJsonBody<Partial<PlanStepDispatchPolicy>>(options.body),
await readQimingclawSnapshot(),
) as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/plan-steps') {
const rows = filterPlanStepRows(planStepRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/plan-step-graph') {
const rows = filterPlanStepRows(planStepGraphRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/approval-history') {
const rows = filterApprovalHistoryRows(approvalHistoryRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|approval-history|route-decisions|notifications|audits)$/);
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|plan-steps|plan-step-graph|tasks|runs|events|artifacts|approvals|approval-history|route-decisions|notifications|audits)$/);
if (method === 'GET' && businessViewMatch) {
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
}
@@ -964,6 +983,7 @@ function defaultState(): AdapterState {
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -990,6 +1010,7 @@ function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
...value,
notificationPreferences: notificationPreferences(value),
approvalSubscriptionPreferences: approvalSubscriptionPreferences(value),
planStepDispatchPolicy: planStepDispatchPolicy(value),
};
}
@@ -2520,6 +2541,8 @@ function auditLevelLabel(level: string): string {
function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawSnapshot | null, searchParams: URLSearchParams): Record<string, unknown> {
const rowsByKind: Record<BusinessViewKind, BusinessViewRow[]> = {
plans: businessPlanRows(snapshot),
'plan-steps': planStepRows(snapshot),
'plan-step-graph': planStepGraphRows(snapshot),
tasks: businessTaskRows(snapshot),
runs: businessRunRows(snapshot),
events: businessEventRows(snapshot),
@@ -2532,19 +2555,140 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS
};
const rows = kind === 'route-decisions'
? filterRouteDecisionRows(rowsByKind[kind], searchParams)
: kind === 'approval-history'
? filterApprovalHistoryRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
: kind === 'plan-steps' || kind === 'plan-step-graph'
? filterPlanStepRows(rowsByKind[kind], searchParams)
: kind === 'approval-history'
? filterApprovalHistoryRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
return {
...paginateBusinessRows(rows, searchParams),
source: BUSINESS_VIEW_SOURCE,
};
}
function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimePlanSteps(snapshot)
.map((step) => {
const payload = asRecord(step.payload) ?? {};
const dependencyGraph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
const lastDispatch = asRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
const readiness = planStepDispatchReadiness(step, snapshot, payload);
const blockedDependencies = uniqueStrings(arrayValue(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies));
const waitingDependencies = uniqueStrings(arrayValue(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies));
const satisfiedDependencies = uniqueStrings(arrayValue(dependencyGraph.satisfiedDependencies ?? dependencyGraph.satisfied_dependencies));
return {
step_id: step.id,
node_id: step.id,
remote_id: step.remoteId ?? null,
plan_id: step.planId,
title: step.title,
status: step.status,
seq: step.seq,
depends_on: step.dependsOn,
dependency_count: step.dependsOn.length,
dependency_status: stringValue(dependencyGraph.status) || stepDependencyStatus(step, blockedDependencies, waitingDependencies),
blocked_dependencies: blockedDependencies,
blocked_dependency_count: blockedDependencies.length,
waiting_dependencies: waitingDependencies,
waiting_dependency_count: waitingDependencies.length,
satisfied_dependencies: satisfiedDependencies,
preferred_skill_ids: step.preferredSkillIds,
task_id: readiness.taskId,
dispatchable: readiness.dispatchable,
skip_reason: readiness.skipReason,
last_dispatch_status: stringValue(lastDispatch.status) || null,
last_dispatch_at: stringValue(lastDispatch.occurredAt ?? lastDispatch.occurred_at) || null,
sync_status: step.syncStatus,
last_synced_at: step.lastSyncedAt ?? null,
sync_error: step.syncError ?? null,
created_at: step.createdAt,
updated_at: step.updatedAt,
entity_type: 'plan_step',
entity_id: step.id,
source: BUSINESS_VIEW_SOURCE,
};
})
.sort((left, right) => stringValue(left.plan_id).localeCompare(stringValue(right.plan_id)) || Number(left.seq ?? 0) - Number(right.seq ?? 0) || stringValue(left.step_id).localeCompare(stringValue(right.step_id)));
}
function planStepGraphRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return planStepRows(snapshot).map((row) => ({
node_id: stringValue(row.node_id) || stringValue(row.step_id),
step_id: stringValue(row.step_id),
plan_id: stringValue(row.plan_id) || null,
title: stringValue(row.title),
status: stringValue(row.status),
seq: row.seq,
depends_on: arrayValue(row.depends_on),
dependency_status: stringValue(row.dependency_status) || null,
blocked_dependencies: arrayValue(row.blocked_dependencies),
waiting_dependencies: arrayValue(row.waiting_dependencies),
satisfied_dependencies: arrayValue(row.satisfied_dependencies),
dispatchable: row.dispatchable === true,
skip_reason: stringValue(row.skip_reason) || null,
sync_status: stringValue(row.sync_status) || null,
updated_at: stringValue(row.updated_at) || null,
entity_type: 'plan_step_graph_node',
entity_id: stringValue(row.step_id),
source: BUSINESS_VIEW_SOURCE,
}));
}
function filterPlanStepRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const planId = stringValue(searchParams.get('plan_id'));
const stepId = stringValue(searchParams.get('step_id')) || stringValue(searchParams.get('id'));
const status = stringValue(searchParams.get('status'));
const syncStatus = stringValue(searchParams.get('sync_status'));
return filterBusinessRows(rows, searchParams)
.filter((row) => !planId || stringValue(row.plan_id) === planId)
.filter((row) => !stepId || stringValue(row.step_id) === stepId || stringValue(row.node_id) === stepId || stringValue(row.entity_id) === stepId)
.filter((row) => !status || stringValue(row.status) === status)
.filter((row) => !syncStatus || stringValue(row.sync_status) === syncStatus);
}
function stepDependencyStatus(step: QimingclawPlanStepRecord, blockedDependencies: string[], waitingDependencies: string[]): string {
if (blockedDependencies.length > 0) return 'blocked';
if (waitingDependencies.length > 0) return 'waiting';
if (step.dependsOn.length > 0) return 'ready';
return 'independent';
}
function planStepDispatchReadiness(
step: QimingclawPlanStepRecord,
snapshot: QimingclawSnapshot | null,
payload: Record<string, unknown>,
): { taskId: string | null; dispatchable: boolean; skipReason: string | null } {
const tasks = runtimePlanTasks(snapshot, step.planId);
const task = tasks.find((candidate) => taskStepId(candidate) === step.id)
?? tasks.find((candidate) => !isTerminalRuntimeTaskStatus(candidate.status))
?? null;
const taskId = task?.id ?? null;
let skipReason: string | null = null;
if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready';
else if (!task) skipReason = 'missing_task';
else if (isTerminalRuntimeTaskStatus(task.status)) skipReason = 'task_terminal';
else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running';
else if (runtimeApprovals(snapshot).some((approval) => isOpenRuntimeApprovalStatus(approval.status) && (approval.planId === step.planId || approval.taskId === task.id || stringValue(asRecord(approval.payload)?.stepId ?? asRecord(approval.payload)?.step_id) === step.id))) skipReason = 'approval_pending';
else if (payload.auto_dispatch === false || payload.autoDispatch === false) skipReason = 'auto_dispatch_disabled';
return { taskId, dispatchable: !skipReason, skipReason };
}
function isTerminalRuntimeTaskStatus(status: string): boolean {
return ['completed', 'succeeded', 'success', 'done', 'skipped', 'cancelled', 'canceled'].includes(status.toLowerCase());
}
function isActiveRuntimeRunStatus(status: string): boolean {
return ['running', 'pending', 'queued', 'started'].includes(status.toLowerCase());
}
function isOpenRuntimeApprovalStatus(status: string): boolean {
return ['pending', 'reviewing', 'open', 'waiting'].includes(status.toLowerCase() || 'pending');
}
function routeDecisionRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimeEvents(snapshot)
.filter((event) => event.kind === 'route_decision')
@@ -3325,6 +3469,8 @@ function businessRowEntityIds(row: BusinessViewRow): string[] {
row.run_id,
row.event_id,
row.history_id,
row.step_id,
row.node_id,
row.artifact_id,
row.approval_id,
row.notification_id,
@@ -3652,6 +3798,15 @@ function defaultApprovalSubscriptionPreferences(): ApprovalSubscriptionPreferenc
};
}
function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
return {
enabled: true,
max_per_sweep: 3,
auto_dispatch_ready_steps: true,
updated_at: null,
};
}
function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | undefined): ApprovalSubscriptionPreferences {
const stored = state?.approvalSubscriptionPreferences;
const fallback = defaultApprovalSubscriptionPreferences();
@@ -3671,6 +3826,19 @@ function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | u
};
}
function planStepDispatchPolicy(state: Partial<AdapterState> | null | undefined): PlanStepDispatchPolicy {
const stored = state?.planStepDispatchPolicy;
const fallback = defaultPlanStepDispatchPolicy();
if (!stored || typeof stored !== 'object') return fallback;
const maxPerSweep = Number(stored.max_per_sweep);
return {
enabled: stored.enabled !== false,
max_per_sweep: Number.isFinite(maxPerSweep) ? Math.max(1, Math.min(Math.floor(maxPerSweep), 10)) : fallback.max_per_sweep,
auto_dispatch_ready_steps: stored.auto_dispatch_ready_steps !== false,
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
};
}
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
const stored = state?.notificationPreferences;
const fallback = defaultNotificationPreferences();
@@ -3749,6 +3917,31 @@ async function handleApprovalSubscriptionPreferencesPatch(
return next;
}
async function handlePlanStepDispatchPolicyPatch(
patch: Partial<PlanStepDispatchPolicy>,
snapshot: QimingclawSnapshot | null,
): Promise<PlanStepDispatchPolicy> {
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
const current = planStepDispatchPolicy(state);
const next = planStepDispatchPolicy({
planStepDispatchPolicy: {
...current,
...patch,
updated_at: new Date().toISOString(),
},
});
await saveState(
{
...normalizeAdapterState(state),
planStepDispatchPolicy: next,
},
'update_plan_step_dispatch_policy',
undefined,
{ plan_step_dispatch_policy: next },
);
return next;
}
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
return buildApprovals(snapshot)

View File

@@ -195,6 +195,13 @@ export interface ApprovalSubscriptionPreferences {
updated_at: string | null;
}
export interface PlanStepDispatchPolicy {
enabled: boolean;
max_per_sweep: number;
auto_dispatch_ready_steps: boolean;
updated_at: string | null;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface WsMessage {