吸收数字员工PlanStep依赖图派发策略
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-PvwLx0FH.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Dar5kQHh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DqdYYbRq.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -283,6 +283,50 @@ function checkDigitalApprovalHistorySubscriptions() {
|
||||
console.log("[DigitalEmployeeCheck] approval history/subscription hooks OK");
|
||||
}
|
||||
|
||||
function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
console.log("\n[DigitalEmployeeCheck] Verify PlanStep graph/dispatch policy hooks");
|
||||
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
|
||||
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
|
||||
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
|
||||
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
|
||||
const schedulerServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "schedulerService.ts");
|
||||
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
|
||||
const adapter = fs.readFileSync(adapterPath, "utf8");
|
||||
const api = fs.readFileSync(apiPath, "utf8");
|
||||
const types = fs.readFileSync(typesPath, "utf8");
|
||||
const stateService = fs.readFileSync(stateServicePath, "utf8");
|
||||
const schedulerService = fs.readFileSync(schedulerServicePath, "utf8");
|
||||
const syncService = fs.readFileSync(syncServicePath, "utf8");
|
||||
const requiredSnippets = [
|
||||
[adapter, "/api/digital-employee/plan-steps", "adapter plan step endpoint"],
|
||||
[adapter, "/api/digital-employee/plan-step-graph", "adapter plan step graph endpoint"],
|
||||
[adapter, "/api/plan-step/dispatch-policy", "adapter plan step dispatch policy endpoint"],
|
||||
[adapter, "planStepRows", "management plan step rows"],
|
||||
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
||||
[adapter, "filterPlanStepRows", "management plan step filters"],
|
||||
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
|
||||
[api, "getPlanStepDispatchPolicy", "plan step dispatch policy API getter"],
|
||||
[api, "patchPlanStepDispatchPolicy", "plan step dispatch policy API patcher"],
|
||||
[api, "getPlanSteps", "plan steps API getter"],
|
||||
[api, "getPlanStepGraph", "plan step graph API getter"],
|
||||
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
|
||||
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
||||
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
|
||||
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
|
||||
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
|
||||
[syncService, "planStepBusinessView", "plan step sync business view"],
|
||||
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
|
||||
[syncService, "digital_workday_update_plan_step_dispatch_policy", "plan step dispatch policy sync event kind"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
.map(([, , label]) => label);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing PlanStep graph/dispatch policy hooks: ${missing.join(", ")}`);
|
||||
}
|
||||
console.log("[DigitalEmployeeCheck] PlanStep graph/dispatch policy hooks OK");
|
||||
}
|
||||
|
||||
function checkLocalDatabaseSchema() {
|
||||
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
|
||||
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
|
||||
@@ -328,6 +372,7 @@ function main() {
|
||||
checkDigitalDiagnosticAuditProjection();
|
||||
checkDigitalNotificationPreferences();
|
||||
checkDigitalApprovalHistorySubscriptions();
|
||||
checkDigitalPlanStepGraphDispatchPolicy();
|
||||
checkLocalDatabaseSchema();
|
||||
console.log("\n[DigitalEmployeeCheck] All checks passed");
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,12 @@ const mockState = vi.hoisted(() => ({
|
||||
max_catch_up_runs: 5,
|
||||
max_concurrent_schedule_runs: 1,
|
||||
},
|
||||
dispatchPolicy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null as string | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../startupPorts", () => ({
|
||||
@@ -26,6 +32,7 @@ vi.mock("../constants", () => ({
|
||||
|
||||
vi.mock("./stateService", () => ({
|
||||
readDigitalEmployeeCronSettings: () => mockState.settings,
|
||||
readDigitalEmployeePlanStepDispatchPolicy: () => mockState.dispatchPolicy,
|
||||
listDigitalEmployeeReadyPlanStepDispatchCandidates: () => mockState.readyCandidates,
|
||||
listDigitalEmployeeSchedules: () => mockState.schedules,
|
||||
listDueDigitalEmployeeSchedules: () => mockState.schedules,
|
||||
@@ -62,6 +69,12 @@ describe("digital employee scheduler service", () => {
|
||||
mockState.finishedRuns = [];
|
||||
mockState.dispatchEvents = [];
|
||||
mockState.governanceCommands = [];
|
||||
mockState.dispatchPolicy = {
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null,
|
||||
};
|
||||
mockState.schedules = [{
|
||||
id: "schedule-1",
|
||||
planId: "plan-1",
|
||||
@@ -165,6 +178,45 @@ describe("digital employee scheduler service", () => {
|
||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
|
||||
expect(mockState.governanceCommands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("honors disabled ready step dispatch policy", async () => {
|
||||
mockState.readyCandidates = [readyStepCandidate()];
|
||||
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false };
|
||||
const { dispatchReadyPlanSteps } = await import("./schedulerService");
|
||||
|
||||
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
|
||||
|
||||
expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("honors ready step auto dispatch switch", async () => {
|
||||
mockState.readyCandidates = [readyStepCandidate()];
|
||||
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, auto_dispatch_ready_steps: false };
|
||||
const { dispatchReadyPlanSteps } = await import("./schedulerService");
|
||||
|
||||
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
|
||||
|
||||
expect(result).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("limits ready step dispatches by policy max per sweep", async () => {
|
||||
mockState.readyCandidates = [
|
||||
readyStepCandidate(),
|
||||
{ ...readyStepCandidate(), stepId: "step-ready-2", taskId: "task-2" },
|
||||
];
|
||||
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, max_per_sweep: 1 };
|
||||
const { dispatchReadyPlanSteps } = await import("./schedulerService");
|
||||
|
||||
const result = await dispatchReadyPlanSteps(new Date("2026-06-07T01:00:10.000Z"));
|
||||
|
||||
expect(result).toMatchObject({ candidates: 2, dispatched: 1, skipped: 1, failed: 0 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.dispatchEvents.map((event) => event.stepId)).toEqual(["step-ready", "step-ready"]);
|
||||
});
|
||||
});
|
||||
|
||||
function readyStepCandidate(): Record<string, unknown> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
listDigitalEmployeeSchedules,
|
||||
listDueDigitalEmployeeSchedules,
|
||||
readDigitalEmployeeCronSettings,
|
||||
readDigitalEmployeePlanStepDispatchPolicy,
|
||||
readDigitalEmployeeScheduleRuns,
|
||||
recordDigitalEmployeeGovernanceCommand,
|
||||
recordDigitalEmployeePlanStepDispatch,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
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];
|
||||
const MAX_READY_STEP_DISPATCH_PER_SWEEP = 3;
|
||||
|
||||
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let schedulerSweeping = false;
|
||||
@@ -126,13 +126,19 @@ export async function dispatchReadyPlanSteps(
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
const policy = readDigitalEmployeePlanStepDispatchPolicy();
|
||||
if (!policy.enabled || !policy.auto_dispatch_ready_steps) {
|
||||
result.skipped = candidates.length;
|
||||
return result;
|
||||
}
|
||||
const maxPerSweep = Math.max(1, Math.min(Math.floor(policy.max_per_sweep), 10));
|
||||
let attempts = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.dispatchable || !candidate.taskId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (attempts >= MAX_READY_STEP_DISPATCH_PER_SWEEP) {
|
||||
if (attempts >= maxPerSweep) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -938,6 +938,70 @@ describe("digital employee state service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists plan step dispatch policy and records runtime events", async () => {
|
||||
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
|
||||
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "update_plan_step_dispatch_policy",
|
||||
metadata: { source: "plan-step-settings" },
|
||||
state: {
|
||||
selectedDutyIdsByDate: {},
|
||||
confirmedDates: {},
|
||||
reportScheduleTime: "18:00",
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
notificationStateById: {},
|
||||
notificationPreferences: {
|
||||
enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
},
|
||||
approvalSubscriptionPreferences: {
|
||||
enabled: true,
|
||||
subscribed_actions: ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk", "decision"],
|
||||
subscribed_risk_levels: ["high", "critical"],
|
||||
notify_on_timeout: true,
|
||||
updated_at: null,
|
||||
},
|
||||
planStepDispatchPolicy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 11,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:07:00.000Z",
|
||||
},
|
||||
consoleRole: "sales",
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
name: "飞天数字员工",
|
||||
company: "qimingclaw 客户端",
|
||||
position: "客户端任务执行员",
|
||||
currentStatus: "working",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readDigitalEmployeeUiState()).toMatchObject({
|
||||
planStepDispatchPolicy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 10,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:07:00.000Z",
|
||||
},
|
||||
});
|
||||
const policyEvent = Array.from(mockState.db?.events.values() ?? [])
|
||||
.find((event) => event.kind === "digital_workday_update_plan_step_dispatch_policy");
|
||||
expect(policyEvent).toMatchObject({
|
||||
kind: "digital_workday_update_plan_step_dispatch_policy",
|
||||
message: "数字员工PlanStep派发策略已更新",
|
||||
});
|
||||
expect(mockState.db?.outbox.get(`event:insert:${policyEvent?.id}`)).toMatchObject({
|
||||
entity_type: "event",
|
||||
operation: "insert",
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("records governance commands as formal runtime records", async () => {
|
||||
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");
|
||||
|
||||
|
||||
@@ -323,6 +323,13 @@ export interface DigitalEmployeePlanStepDispatchRecord {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepDispatchPolicy {
|
||||
enabled: boolean;
|
||||
max_per_sweep: number;
|
||||
auto_dispatch_ready_steps: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionRecord {
|
||||
id: string;
|
||||
route_kind: string;
|
||||
@@ -480,6 +487,7 @@ export interface DigitalEmployeeUiState {
|
||||
notify_on_timeout: boolean;
|
||||
updated_at: string | null;
|
||||
};
|
||||
planStepDispatchPolicy?: DigitalEmployeePlanStepDispatchPolicy;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
profile: {
|
||||
@@ -657,6 +665,7 @@ function defaultUiState(): DigitalEmployeeUiState {
|
||||
notificationStateById: {},
|
||||
notificationPreferences: defaultNotificationPreferences(),
|
||||
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
|
||||
planStepDispatchPolicy: defaultPlanStepDispatchPolicy(),
|
||||
consoleRole: "sales",
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
@@ -729,6 +738,15 @@ function defaultApprovalSubscriptionPreferences(): NonNullable<DigitalEmployeeUi
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy {
|
||||
return {
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))];
|
||||
@@ -768,6 +786,25 @@ function normalizeApprovalSubscriptionPreferences(
|
||||
};
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const raw = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(raw)) return fallback;
|
||||
return Math.max(min, Math.min(Math.floor(raw), max));
|
||||
}
|
||||
|
||||
function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
|
||||
const fallback = defaultPlanStepDispatchPolicy();
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
enabled: record.enabled !== false,
|
||||
max_per_sweep: boundedInteger(record.max_per_sweep, fallback.max_per_sweep, 1, 10),
|
||||
auto_dispatch_ready_steps: record.auto_dispatch_ready_steps !== false,
|
||||
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUiState(
|
||||
value: Partial<DigitalEmployeeUiState>,
|
||||
): DigitalEmployeeUiState {
|
||||
@@ -784,6 +821,7 @@ function normalizeUiState(
|
||||
notificationStateById: normalizeRecord(value.notificationStateById),
|
||||
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
|
||||
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
|
||||
planStepDispatchPolicy: normalizePlanStepDispatchPolicy(value.planStepDispatchPolicy),
|
||||
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
|
||||
? value.consoleRole
|
||||
: fallback.consoleRole,
|
||||
@@ -826,6 +864,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return "数字员工通知订阅已更新";
|
||||
case "update_approval_subscriptions":
|
||||
return "数字员工审批订阅已更新";
|
||||
case "update_plan_step_dispatch_policy":
|
||||
return "数字员工PlanStep派发策略已更新";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
@@ -855,6 +895,10 @@ export function readDigitalEmployeeUiState(): DigitalEmployeeUiState {
|
||||
return normalizeUiState(state as Partial<DigitalEmployeeUiState>);
|
||||
}
|
||||
|
||||
export function readDigitalEmployeePlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy {
|
||||
return readDigitalEmployeeUiState().planStepDispatchPolicy ?? defaultPlanStepDispatchPolicy();
|
||||
}
|
||||
|
||||
export function saveDigitalEmployeeUiState(
|
||||
update: DigitalEmployeeUiStateUpdate,
|
||||
): DigitalEmployeeUiState {
|
||||
|
||||
@@ -334,6 +334,15 @@ describe("digital employee sync service", () => {
|
||||
seq: 2,
|
||||
dependsOn: ["step-0"],
|
||||
preferredSkillIds: ["qimingclaw-mcp-tool-crm-crm-search"],
|
||||
payload: {
|
||||
dependencyGraph: {
|
||||
status: "blocked",
|
||||
blockedDependencies: ["step-0"],
|
||||
waitingDependencies: ["step-2"],
|
||||
},
|
||||
lastDispatch: { status: "failed" },
|
||||
skipReason: "approval_pending",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockState.fetch.mockResolvedValue(
|
||||
@@ -365,7 +374,16 @@ describe("digital employee sync service", () => {
|
||||
status: "pending",
|
||||
seq: 2,
|
||||
depends_on: ["step-0"],
|
||||
dependency_count: 1,
|
||||
dependency_status: "blocked",
|
||||
blocked_dependencies: ["step-0"],
|
||||
blocked_dependency_count: 1,
|
||||
waiting_dependencies: ["step-2"],
|
||||
waiting_dependency_count: 1,
|
||||
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
|
||||
dispatchable: false,
|
||||
skip_reason: "approval_pending",
|
||||
last_dispatch_status: "failed",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
@@ -541,9 +559,24 @@ describe("digital employee sync service", () => {
|
||||
});
|
||||
insertEventEntity("event-dispatch", "plan_step_dispatch_failed", {
|
||||
step_id: "step-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
status: "failed",
|
||||
reason: "computer chat failed",
|
||||
});
|
||||
insertEventEntity("event-plan-step-ready", "plan_step_ready", {
|
||||
stepId: "step-2",
|
||||
planId: "plan-1",
|
||||
status: "ready",
|
||||
dependsOn: ["step-1"],
|
||||
dependencyGraph: {
|
||||
status: "ready",
|
||||
blockedDependencies: [],
|
||||
waitingDependencies: [],
|
||||
satisfiedDependencies: ["step-1"],
|
||||
},
|
||||
});
|
||||
insertEventEntity("event-governance-input", "governance_provide_task_input", {
|
||||
commandKind: "provide_task_input",
|
||||
commandId: "command-provide-input",
|
||||
@@ -648,6 +681,25 @@ describe("digital employee sync service", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
insertEventEntity("event-plan-step-policy", "digital_workday_update_plan_step_dispatch_policy", {
|
||||
metadata: {
|
||||
action: "update_plan_step_dispatch_policy",
|
||||
plan_step_dispatch_policy: {
|
||||
enabled: false,
|
||||
max_per_sweep: 1,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:07:00.000Z",
|
||||
},
|
||||
},
|
||||
state: {
|
||||
planStepDispatchPolicy: {
|
||||
enabled: false,
|
||||
max_per_sweep: 1,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:07:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
@@ -658,12 +710,14 @@ describe("digital employee sync service", () => {
|
||||
{ entity_type: "event", entity_id: "event-artifact" },
|
||||
{ entity_type: "event", entity_id: "event-skill" },
|
||||
{ entity_type: "event", entity_id: "event-dispatch" },
|
||||
{ entity_type: "event", entity_id: "event-plan-step-ready" },
|
||||
{ entity_type: "event", entity_id: "event-governance-input" },
|
||||
{ entity_type: "event", entity_id: "event-notification-read" },
|
||||
{ entity_type: "event", entity_id: "event-notification-dismiss" },
|
||||
{ entity_type: "event", entity_id: "event-notification-reply" },
|
||||
{ entity_type: "event", entity_id: "event-notification-preferences" },
|
||||
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
||||
{ entity_type: "event", entity_id: "event-plan-step-policy" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -736,9 +790,22 @@ describe("digital employee sync service", () => {
|
||||
expect(businessView("event-dispatch")).toMatchObject({
|
||||
category: "dispatch",
|
||||
step_id: "step-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
status: "failed",
|
||||
reason: "computer chat failed",
|
||||
});
|
||||
expect(businessView("event-plan-step-ready")).toMatchObject({
|
||||
category: "dispatch",
|
||||
step_id: "step-2",
|
||||
plan_id: "plan-1",
|
||||
status: "ready",
|
||||
dependency_count: 1,
|
||||
blocked_dependencies: [],
|
||||
waiting_dependencies: [],
|
||||
satisfied_dependencies: ["step-1"],
|
||||
});
|
||||
expect(businessView("event-governance-input")).toMatchObject({
|
||||
category: "governance",
|
||||
command_kind: "provide_task_input",
|
||||
@@ -790,6 +857,16 @@ describe("digital employee sync service", () => {
|
||||
});
|
||||
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
|
||||
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
|
||||
expect(businessView("event-plan-step-policy")).toMatchObject({
|
||||
category: "dispatch",
|
||||
action: "update_plan_step_dispatch_policy",
|
||||
policy_enabled: false,
|
||||
max_per_sweep: 1,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:07:00.000Z",
|
||||
});
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
||||
expect(readEntity("event", "event-route")).toMatchObject({
|
||||
sync_status: "synced",
|
||||
sync_error: null,
|
||||
|
||||
@@ -458,18 +458,7 @@ function buildBusinessView(
|
||||
assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_id) || null,
|
||||
};
|
||||
case "plan_step":
|
||||
return {
|
||||
...base,
|
||||
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
||||
step_id: row.entity_id,
|
||||
seq: typeof record.seq === "number" ? record.seq : null,
|
||||
depends_on: Array.isArray(record.dependsOn ?? record.depends_on)
|
||||
? record.dependsOn ?? record.depends_on
|
||||
: [],
|
||||
preferred_skill_ids: Array.isArray(record.preferredSkillIds ?? record.preferred_skill_ids)
|
||||
? record.preferredSkillIds ?? record.preferred_skill_ids
|
||||
: [],
|
||||
};
|
||||
return planStepBusinessView(base, row, record);
|
||||
case "run":
|
||||
return {
|
||||
...base,
|
||||
@@ -511,6 +500,36 @@ function buildBusinessView(
|
||||
}
|
||||
}
|
||||
|
||||
function planStepBusinessView(
|
||||
base: Record<string, unknown>,
|
||||
row: OutboxRow,
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const payload = objectRecord(record.payload) ?? {};
|
||||
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||
const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
|
||||
const dependsOn = stringArrayField(record.dependsOn ?? record.depends_on);
|
||||
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
|
||||
const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies);
|
||||
return {
|
||||
...base,
|
||||
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
||||
step_id: row.entity_id,
|
||||
seq: numberField(record.seq),
|
||||
depends_on: dependsOn,
|
||||
dependency_count: dependsOn.length,
|
||||
dependency_status: stringField(dependencyGraph.status) || null,
|
||||
blocked_dependencies: blockedDependencies,
|
||||
blocked_dependency_count: blockedDependencies.length,
|
||||
waiting_dependencies: waitingDependencies,
|
||||
waiting_dependency_count: waitingDependencies.length,
|
||||
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
|
||||
dispatchable: stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
|
||||
skip_reason: stringField(payload.skipReason ?? payload.skip_reason) || null,
|
||||
last_dispatch_status: stringField(lastDispatch.status) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventBusinessView(
|
||||
base: Record<string, unknown>,
|
||||
row: OutboxRow,
|
||||
@@ -569,7 +588,9 @@ function eventSpecificBusinessView(
|
||||
return artifactEventBusinessView(payload);
|
||||
}
|
||||
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
|
||||
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
||||
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
||||
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
|
||||
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
|
||||
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
|
||||
return {};
|
||||
@@ -667,11 +688,43 @@ function skillCallBusinessView(payload: Record<string, unknown>): Record<string,
|
||||
function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
step_id: stringField(payload.step_id ?? payload.stepId) || null,
|
||||
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
|
||||
task_id: stringField(payload.task_id ?? payload.taskId) || null,
|
||||
run_id: stringField(payload.run_id ?? payload.runId) || null,
|
||||
status: stringField(payload.status) || null,
|
||||
reason: stringField(payload.reason) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
|
||||
return {
|
||||
step_id: stringField(payload.stepId ?? payload.step_id) || null,
|
||||
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
|
||||
status: stringField(payload.status ?? dependencyGraph.status) || null,
|
||||
dependency_count: dependencies.length,
|
||||
blocked_dependencies: stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies),
|
||||
waiting_dependencies: stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies),
|
||||
satisfied_dependencies: stringArrayField(dependencyGraph.satisfiedDependencies ?? dependencyGraph.satisfied_dependencies),
|
||||
};
|
||||
}
|
||||
|
||||
function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata = objectRecord(payload.metadata) ?? {};
|
||||
const state = objectRecord(payload.state) ?? {};
|
||||
const policy = objectRecord(metadata.plan_step_dispatch_policy)
|
||||
?? objectRecord(state.planStepDispatchPolicy)
|
||||
?? {};
|
||||
return {
|
||||
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
|
||||
policy_enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
|
||||
max_per_sweep: numberField(policy.max_per_sweep),
|
||||
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : null,
|
||||
updated_at: stringField(policy.updated_at) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function governanceProvideTaskInputBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const commandPayload = objectRecord(payload.payload) ?? {};
|
||||
const result = objectRecord(payload.result) ?? {};
|
||||
@@ -714,7 +767,9 @@ function eventBusinessCategory(kind: string): string {
|
||||
if (isApprovalSubscriptionEvent(kind)) return "approval";
|
||||
if (kind.startsWith("artifact_")) return "artifact";
|
||||
if (kind.startsWith("skill_call_")) return "skill";
|
||||
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
||||
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
||||
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
|
||||
if (isNotificationEvent(kind)) return "notification";
|
||||
if (kind.startsWith("governance_")) return "governance";
|
||||
return "runtime";
|
||||
@@ -724,6 +779,14 @@ function isApprovalSubscriptionEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_approval_subscriptions";
|
||||
}
|
||||
|
||||
function isPlanStepDependencyEvent(kind: string): boolean {
|
||||
return kind === "plan_step_ready" || kind === "plan_step_blocked" || kind === "plan_step_waiting";
|
||||
}
|
||||
|
||||
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_plan_step_dispatch_policy";
|
||||
}
|
||||
|
||||
function isNotificationEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_notification_preferences"
|
||||
|| kind === "digital_workday_notification_read"
|
||||
|
||||
@@ -540,9 +540,9 @@ GET /api/digital-employee/approvals
|
||||
|
||||
1. **PlanStep 与依赖图(依赖图调度已开始)**
|
||||
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务;PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`。
|
||||
- 当前能力:PlanStep 会进入 `digital_sync_outbox`,entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图;Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox;前置步骤跳过、取消或重试后会重新评估后继步骤,推进为 ready / blocked / pending 并写入依赖图事件;scheduler sweep 会识别可派发的 ready PlanStep,调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*` 与 `governance_start_run` 事件,ready PlanStep 安全派发已开始闭环。
|
||||
- 后续缺口:跨任务编排执行、依赖阻塞人工处理 UI、派发并发策略下发和管理端依赖图视图仍待吸收。
|
||||
- 建议:下一轮围绕管理端依赖图和派发策略下发,把 ready PlanStep 从本地安全派发推进到跨端可治理调度。
|
||||
- 当前能力:PlanStep 会进入 `digital_sync_outbox`,entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图;Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox;前置步骤跳过、取消或重试后会重新评估后继步骤,推进为 ready / blocked / pending 并写入依赖图事件;scheduler sweep 会识别可派发的 ready PlanStep,调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*` 与 `governance_start_run` 事件;embedded 已提供 `/api/digital-employee/plan-steps`、`/api/digital-employee/plan-step-graph` 和 `/api/plan-step/dispatch-policy`,ready PlanStep 安全派发、管理端依赖图视图和本地派发策略已开始闭环。
|
||||
- 后续缺口:跨任务编排执行、依赖阻塞人工处理 UI、多设备 lease 和远端策略拉取仍待吸收。
|
||||
- 建议:下一轮围绕依赖阻塞人工处理 UI 或跨任务编排,把本地可治理调度推进到更完整的执行控制面。
|
||||
|
||||
2. **调度 / 定时 / 周期任务**
|
||||
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
||||
|
||||
Reference in New Issue
Block a user