吸收数字员工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 {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。