吸收数字员工PlanStep远端策略拉取
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
||||
BusinessViewResponse,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDispatchPolicy,
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
ReadyStepDispatchResult,
|
||||
NotificationPreferences,
|
||||
UserNotification,
|
||||
@@ -612,6 +613,12 @@ export function patchPlanStepDispatchPolicy(
|
||||
});
|
||||
}
|
||||
|
||||
export function pullPlanStepDispatchPolicy(): Promise<PlanStepDispatchPolicyPullResult> {
|
||||
return apiFetch<PlanStepDispatchPolicyPullResult>('/api/plan-step/dispatch-policy/pull', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
PlanDispatchResponse,
|
||||
ReadyStepDispatchResult,
|
||||
PlanStepDispatchPolicy,
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
PlanStepSummary,
|
||||
RouteDecisionRecord,
|
||||
RouteDecisionTimelineResponse,
|
||||
@@ -60,6 +61,7 @@ declare global {
|
||||
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||
pullPlanStepDispatchPolicy?: () => Promise<PlanStepDispatchPolicyPullResult>;
|
||||
getCronSettings?: () => Promise<QimingclawCronSettings>;
|
||||
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
|
||||
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
||||
@@ -791,6 +793,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/plan-step/dispatch-policy') {
|
||||
return planStepDispatchPolicy(notificationAdapterState(await readQimingclawSnapshot())) as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/plan-step/dispatch-policy/pull') {
|
||||
return await handlePlanStepDispatchPolicyPull(await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'PATCH' && pathname === '/api/plan-step/dispatch-policy') {
|
||||
return await handlePlanStepDispatchPolicyPatch(
|
||||
parseJsonBody<Partial<PlanStepDispatchPolicy>>(options.body),
|
||||
@@ -3883,6 +3888,10 @@ function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null,
|
||||
source: 'local',
|
||||
remote_updated_at: null,
|
||||
last_pulled_at: null,
|
||||
last_pull_error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3915,6 +3924,10 @@ function planStepDispatchPolicy(state: Partial<AdapterState> | null | undefined)
|
||||
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,
|
||||
source: stored.source === 'remote' ? 'remote' : 'local',
|
||||
remote_updated_at: typeof stored.remote_updated_at === 'string' ? stored.remote_updated_at : null,
|
||||
last_pulled_at: typeof stored.last_pulled_at === 'string' ? stored.last_pulled_at : null,
|
||||
last_pull_error: typeof stored.last_pull_error === 'string' ? stored.last_pull_error : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4021,6 +4034,27 @@ async function handlePlanStepDispatchPolicyPatch(
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handlePlanStepDispatchPolicyPull(
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<PlanStepDispatchPolicyPullResult> {
|
||||
const fallbackPolicy = planStepDispatchPolicy(snapshot?.uiState ?? readState());
|
||||
const bridgeResult = await window.QimingClawBridge?.digital?.pullPlanStepDispatchPolicy?.().catch((error) => ({
|
||||
ok: false,
|
||||
endpoint: null,
|
||||
pulled_at: new Date().toISOString(),
|
||||
policy: fallbackPolicy,
|
||||
error: error instanceof Error ? error.message : 'plan step dispatch policy pull failed',
|
||||
}));
|
||||
if (bridgeResult) return bridgeResult;
|
||||
return {
|
||||
ok: false,
|
||||
endpoint: null,
|
||||
pulled_at: new Date().toISOString(),
|
||||
policy: fallbackPolicy,
|
||||
error: 'qimingclaw_bridge_unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
||||
return buildApprovals(snapshot)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSchedulerJobs,
|
||||
governanceCommand,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
pullPlanStepDispatchPolicy,
|
||||
recordApprovalAction,
|
||||
restartManagedService,
|
||||
} from '@/lib/api';
|
||||
@@ -1368,6 +1369,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const refreshPlanStepDispatchPolicy = useCallback(async () => {
|
||||
setActionBusy('pull_plan_step_dispatch_policy');
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await pullPlanStepDispatchPolicy();
|
||||
if (!mountedRef.current) return;
|
||||
await fetchProjection();
|
||||
if (!mountedRef.current) return;
|
||||
const policy = result.policy;
|
||||
setActionNotice(result.ok
|
||||
? `PlanStep 派发策略已拉取:${policy.enabled ? '启用' : '停用'},每轮最多 ${policy.max_per_sweep} 个。`
|
||||
: `PlanStep 派发策略拉取失败,已沿用本地策略:${result.error || '未知错误'}。`);
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '拉取PlanStep派发策略失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, [fetchProjection]);
|
||||
|
||||
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
|
||||
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
||||
try {
|
||||
@@ -2016,6 +2039,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<RefreshCw size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === 'flush_management_sync' ? '同步中...' : '同步管理端'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={projectionLoading || actionBusy === 'pull_plan_step_dispatch_policy'}
|
||||
onClick={refreshPlanStepDispatchPolicy}
|
||||
title="从管理端拉取 PlanStep 派发策略"
|
||||
>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
<span>{actionBusy === 'pull_plan_step_dispatch_policy' ? '拉取中...' : '拉取策略'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>
|
||||
|
||||
@@ -200,6 +200,19 @@ export interface PlanStepDispatchPolicy {
|
||||
max_per_sweep: number;
|
||||
auto_dispatch_ready_steps: boolean;
|
||||
updated_at: string | null;
|
||||
source?: 'local' | 'remote';
|
||||
remote_updated_at?: string | null;
|
||||
last_pulled_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanStepDispatchPolicyPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
policy: PlanStepDispatchPolicy;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PlanStepDependencyDetail {
|
||||
|
||||
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-6K5_HhCb.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BXlmpGIQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -311,6 +311,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[adapter, "dispatch-ready-steps", "adapter plan-scoped ready step dispatch endpoint"],
|
||||
[adapter, "dispatchPlanReadySteps", "adapter plan-scoped ready step dispatcher"],
|
||||
[adapter, "/api/plan-step/dispatch-policy", "adapter plan step dispatch policy endpoint"],
|
||||
[adapter, "/api/plan-step/dispatch-policy/pull", "adapter plan step dispatch policy pull endpoint"],
|
||||
[adapter, "pullPlanStepDispatchPolicy", "adapter plan step dispatch policy pull bridge"],
|
||||
[adapter, "planStepRows", "management plan step rows"],
|
||||
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
||||
[adapter, "filterPlanStepRows", "management plan step filters"],
|
||||
@@ -322,6 +324,7 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[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, "pullPlanStepDispatchPolicy", "plan step dispatch policy API puller"],
|
||||
[api, "getPlanSteps", "plan steps API getter"],
|
||||
[api, "getPlanStepGraph", "plan step graph API getter"],
|
||||
[api, "dispatchPlanReadySteps", "plan-scoped ready step dispatch API"],
|
||||
@@ -329,6 +332,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[types, "trigger_source", "ready step dispatch trigger source type"],
|
||||
[types, "orchestration_id", "ready step dispatch orchestration id type"],
|
||||
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
|
||||
[types, "PlanStepDispatchPolicyPullResult", "plan step dispatch policy pull result type"],
|
||||
[types, "last_pull_error", "plan step dispatch policy pull error field"],
|
||||
[types, "PlanStepLeaseSummary", "plan step lease summary type"],
|
||||
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
|
||||
[types, "lease_active", "plan step lease active type field"],
|
||||
@@ -338,12 +343,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
|
||||
[commandDeck, "leasedStepCount", "home plan step lease count"],
|
||||
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
|
||||
[commandDeck, "pull_plan_step_dispatch_policy", "home plan step dispatch policy pull action"],
|
||||
[commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"],
|
||||
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
|
||||
[commandDeck, "provide_task_input", "home blocked plan step input command"],
|
||||
[css, "de-blocked-input-field", "home blocked plan step input styles"],
|
||||
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
||||
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
|
||||
[stateService, "pull_plan_step_dispatch_policy", "plan step dispatch policy pull runtime event"],
|
||||
[stateService, "last_pulled_at", "plan step dispatch policy last pulled field"],
|
||||
[stateService, "last_pull_error", "plan step dispatch policy last pull error field"],
|
||||
[stateService, "planIdFilter", "plan-scoped ready step candidate filter"],
|
||||
[stateService, "dispatchLease", "plan step payload lease state"],
|
||||
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
|
||||
@@ -358,10 +367,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
|
||||
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
|
||||
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
|
||||
[schedulerService, "pullDigitalEmployeePlanStepDispatchPolicy", "scheduler remote plan step dispatch policy pull"],
|
||||
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
|
||||
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
|
||||
[ipcHandlers, "digitalEmployee:pullPlanStepDispatchPolicy", "IPC plan step dispatch policy pull handler"],
|
||||
[preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
|
||||
[preload, "pullPlanStepDispatchPolicy", "preload plan step dispatch policy pull bridge"],
|
||||
[syncService, "planStepBusinessView", "plan step sync business view"],
|
||||
[syncService, "pullDigitalEmployeePlanStepDispatchPolicy", "remote plan step dispatch policy pull service"],
|
||||
[syncService, "planStepDispatchPolicyEndpoint", "remote plan step dispatch policy endpoint config"],
|
||||
[syncService, "/api/digital-employee/policies/plan-step-dispatch", "remote plan step dispatch policy default path"],
|
||||
[syncService, "lease_active", "plan step sync lease active field"],
|
||||
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
|
||||
[syncService, "plan_step_lease_acquired", "plan step lease acquired sync event kind"],
|
||||
@@ -369,6 +384,7 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
||||
[syncService, "orchestration_id", "plan step dispatch sync orchestration id"],
|
||||
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
|
||||
[syncService, "digital_workday_update_plan_step_dispatch_policy", "plan step dispatch policy sync event kind"],
|
||||
[syncService, "digital_workday_pull_plan_step_dispatch_policy", "plan step dispatch policy pull sync event kind"],
|
||||
];
|
||||
const missing = requiredSnippets
|
||||
.filter(([content, snippet]) => !content.includes(snippet))
|
||||
|
||||
@@ -94,6 +94,7 @@ vi.mock("../services/digitalEmployee/schedulerService", () => ({
|
||||
vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||
flushDigitalEmployeeSyncOutbox: vi.fn(),
|
||||
getDigitalEmployeeSyncStatus: vi.fn(),
|
||||
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
|
||||
@@ -232,6 +233,23 @@ describe("digital employee managed service actions", () => {
|
||||
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
|
||||
});
|
||||
|
||||
it("registers plan step dispatch policy pull through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const syncService = await import("../services/digitalEmployee/syncService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const policyPullCall = calls.find(([channel]) => channel === "digitalEmployee:pullPlanStepDispatchPolicy");
|
||||
expect(policyPullCall).toBeTruthy();
|
||||
|
||||
const handler = policyPullCall?.[1] as () => Promise<unknown>;
|
||||
const result = await handler();
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(syncService.pullDigitalEmployeePlanStepDispatchPolicy).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("registers approval action history through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import {
|
||||
flushDigitalEmployeeSyncOutbox,
|
||||
getDigitalEmployeeSyncStatus,
|
||||
pullDigitalEmployeePlanStepDispatchPolicy,
|
||||
} from "../services/digitalEmployee/syncService";
|
||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||
import {
|
||||
@@ -188,6 +189,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:pullPlanStepDispatchPolicy", async () => {
|
||||
return pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
|
||||
return readDigitalEmployeeCronSettings();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
|
||||
governanceCommands: [] as Array<Record<string, unknown>>,
|
||||
acquiredLeases: [] as Array<Record<string, unknown>>,
|
||||
releasedLeases: [] as Array<Record<string, unknown>>,
|
||||
policyPulls: [] as Array<Record<string, unknown>>,
|
||||
settings: {
|
||||
enabled: true,
|
||||
catch_up_on_startup: true,
|
||||
@@ -85,6 +86,13 @@ vi.mock("./stateService", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./syncService", () => ({
|
||||
pullDigitalEmployeePlanStepDispatchPolicy: (input: Record<string, unknown> = {}) => {
|
||||
mockState.policyPulls.push(input);
|
||||
return Promise.resolve({ ok: true, endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch" });
|
||||
},
|
||||
}));
|
||||
|
||||
describe("digital employee scheduler service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -95,6 +103,7 @@ describe("digital employee scheduler service", () => {
|
||||
mockState.governanceCommands = [];
|
||||
mockState.acquiredLeases = [];
|
||||
mockState.releasedLeases = [];
|
||||
mockState.policyPulls = [];
|
||||
mockState.dispatchPolicy = {
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
@@ -179,6 +188,7 @@ describe("digital employee scheduler service", () => {
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
|
||||
expect(mockState.policyPulls).toEqual([{}]);
|
||||
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
|
||||
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
|
||||
expect(mockState.governanceCommands[0]).toMatchObject({
|
||||
@@ -260,6 +270,7 @@ describe("digital employee scheduler service", () => {
|
||||
|
||||
expect(result).toMatchObject({ plan_id: "plan-1", trigger_source: "manual", candidates: 1, dispatched: 1, skipped: 0, failed: 0 });
|
||||
expect(mockState.dispatchEvents[0]).toMatchObject({ status: "started", payload: expect.objectContaining({ trigger_source: "manual" }) });
|
||||
expect(mockState.policyPulls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the enabled switch as a manual plan dispatch kill switch", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
||||
type DigitalEmployeeScheduleRecord,
|
||||
} from "./stateService";
|
||||
import { pullDigitalEmployeePlanStepDispatchPolicy } from "./syncService";
|
||||
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
||||
@@ -157,6 +158,11 @@ export async function dispatchReadyPlanSteps(
|
||||
failed_step_ids: [],
|
||||
errors: [],
|
||||
};
|
||||
if (triggerSource === "auto") {
|
||||
await pullDigitalEmployeePlanStepDispatchPolicy().catch((error) => {
|
||||
log.debug("[DigitalEmployeeScheduler] PlanStep dispatch policy pull skipped/failed:", error);
|
||||
});
|
||||
}
|
||||
const policy = readDigitalEmployeePlanStepDispatchPolicy();
|
||||
if (!policy.enabled || (!policy.auto_dispatch_ready_steps && !bypassAutoDispatchSwitch)) {
|
||||
result.skipped = candidates.length;
|
||||
|
||||
@@ -361,6 +361,10 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
|
||||
max_per_sweep: number;
|
||||
auto_dispatch_ready_steps: boolean;
|
||||
updated_at: string | null;
|
||||
source?: "local" | "remote";
|
||||
remote_updated_at?: string | null;
|
||||
last_pulled_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionRecord {
|
||||
@@ -777,6 +781,10 @@ function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null,
|
||||
source: "local",
|
||||
remote_updated_at: null,
|
||||
last_pulled_at: null,
|
||||
last_pull_error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -825,16 +833,21 @@ function boundedInteger(value: unknown, fallback: number, min: number, max: numb
|
||||
return Math.max(min, Math.min(Math.floor(raw), max));
|
||||
}
|
||||
|
||||
function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
|
||||
export function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
|
||||
const fallback = defaultPlanStepDispatchPolicy();
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
const source = record.source === "remote" ? "remote" : "local";
|
||||
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,
|
||||
source,
|
||||
remote_updated_at: typeof record.remote_updated_at === "string" ? record.remote_updated_at : null,
|
||||
last_pulled_at: typeof record.last_pulled_at === "string" ? record.last_pulled_at : null,
|
||||
last_pull_error: typeof record.last_pull_error === "string" ? record.last_pull_error : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -899,6 +912,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return "数字员工审批订阅已更新";
|
||||
case "update_plan_step_dispatch_policy":
|
||||
return "数字员工PlanStep派发策略已更新";
|
||||
case "pull_plan_step_dispatch_policy":
|
||||
return "数字员工PlanStep派发策略已拉取";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../../db", () => ({
|
||||
},
|
||||
getDb: () => mockState.db,
|
||||
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||
}));
|
||||
|
||||
describe("digital employee sync service", () => {
|
||||
@@ -70,6 +71,72 @@ describe("digital employee sync service", () => {
|
||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("pulls remote plan step dispatch policy and records policy state", async () => {
|
||||
mockState.fetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
plan_step_dispatch_policy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 5,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
||||
const { readDigitalEmployeeUiState } = await import("./stateService");
|
||||
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
policy: {
|
||||
source: "remote",
|
||||
enabled: true,
|
||||
max_per_sweep: 5,
|
||||
auto_dispatch_ready_steps: false,
|
||||
remote_updated_at: "2026-06-07T08:01:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: null,
|
||||
},
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(readDigitalEmployeeUiState().planStepDispatchPolicy).toMatchObject(result.policy);
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_pull_plan_step_dispatch_policy" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps the current plan step dispatch policy when remote policy pull fails", async () => {
|
||||
mockState.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "policy service unavailable" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: "policy service unavailable",
|
||||
policy: {
|
||||
source: "local",
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: "policy service unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks entity-only acknowledgements as synced", async () => {
|
||||
insertPlan("plan-1");
|
||||
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
|
||||
@@ -738,6 +805,25 @@ describe("digital employee sync service", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
insertEventEntity("event-plan-step-policy-pull", "digital_workday_pull_plan_step_dispatch_policy", {
|
||||
metadata: {
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
source: "management-api",
|
||||
ok: false,
|
||||
error: "policy service unavailable",
|
||||
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
plan_step_dispatch_policy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 2,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: "2026-06-07T08:08:00.000Z",
|
||||
source: "remote",
|
||||
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
||||
last_pull_error: "policy service unavailable",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
@@ -757,6 +843,7 @@ describe("digital employee sync service", () => {
|
||||
{ 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" },
|
||||
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -923,6 +1010,18 @@ describe("digital employee sync service", () => {
|
||||
});
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
||||
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
|
||||
category: "dispatch",
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
source: "management-api",
|
||||
pull_ok: false,
|
||||
pull_error: "policy service unavailable",
|
||||
policy_enabled: true,
|
||||
max_per_sweep: 2,
|
||||
auto_dispatch_ready_steps: true,
|
||||
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
||||
});
|
||||
expect(readEntity("event", "event-route")).toMatchObject({
|
||||
sync_status: "synced",
|
||||
sync_error: null,
|
||||
@@ -1032,7 +1131,9 @@ function entityMap(
|
||||
): Map<string, TestSyncEntityRow> | undefined {
|
||||
if (!mockState.db) return undefined;
|
||||
if (entityType === "plan") return mockState.db.plans;
|
||||
if (entityType === "task") return mockState.db.tasks;
|
||||
if (entityType === "plan_step") return mockState.db.planSteps;
|
||||
if (entityType === "run") return mockState.db.runs;
|
||||
if (entityType === "schedule") return mockState.db.schedules;
|
||||
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
|
||||
if (entityType === "artifact") return mockState.db.artifacts;
|
||||
@@ -1108,7 +1209,9 @@ interface TestAttemptRow {
|
||||
class TestDb {
|
||||
outbox = new Map<string, TestOutboxRow>();
|
||||
plans = new Map<string, TestSyncEntityRow>();
|
||||
tasks = new Map<string, TestSyncEntityRow>();
|
||||
planSteps = new Map<string, TestSyncEntityRow>();
|
||||
runs = new Map<string, TestSyncEntityRow>();
|
||||
schedules = new Map<string, TestSyncEntityRow>();
|
||||
scheduleRuns = new Map<string, TestSyncEntityRow>();
|
||||
artifacts = new Map<string, TestSyncEntityRow>();
|
||||
@@ -1205,6 +1308,23 @@ class TestDb {
|
||||
return this.plans.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_tasks")) {
|
||||
const [id] = args as [string];
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT id AS title, status, NULL AS objective, payload FROM digital_runs")) {
|
||||
const [id] = args as [string];
|
||||
const row = this.runs.get(id);
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
title: row.id,
|
||||
status: row.status,
|
||||
objective: null,
|
||||
payload: row.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) {
|
||||
const [id] = args as [string];
|
||||
return this.artifacts.get(id);
|
||||
@@ -1267,6 +1387,152 @@ class TestDb {
|
||||
}
|
||||
|
||||
private run(sql: string, args: unknown[]): unknown {
|
||||
if (sql.startsWith("INSERT INTO digital_plans")) {
|
||||
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.plans.get(id);
|
||||
this.plans.set(id, {
|
||||
id,
|
||||
title,
|
||||
objective,
|
||||
status,
|
||||
payload,
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_tasks")) {
|
||||
const [id, planId, title, status, assignedSkillId, payload] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.tasks.get(id);
|
||||
this.tasks.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
title,
|
||||
status,
|
||||
payload: JSON.stringify({ ...JSON.parse(payload), assigned_skill_id: assignedSkillId }),
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_runs")) {
|
||||
const [id, planId, taskId, status, startedAt, payload] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.runs.get(id);
|
||||
this.runs.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
task_id: taskId,
|
||||
status,
|
||||
payload: JSON.stringify({ ...JSON.parse(payload), started_at: startedAt }),
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_runs SET status = ?, finished_at = ?, updated_at = ?")) {
|
||||
const [status, finishedAt, _updatedAt, id] = args as [string, string, string, string];
|
||||
const row = this.runs.get(id);
|
||||
if (row) {
|
||||
row.status = status;
|
||||
row.payload = JSON.stringify({ ...JSON.parse(row.payload ?? "{}"), finished_at: finishedAt });
|
||||
row.sync_status = "pending";
|
||||
}
|
||||
return { changes: row ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
|
||||
const [id, planId, taskId, runId, kind, message, payload, occurredAt] = args as [
|
||||
string,
|
||||
string | null,
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
if (!this.events.has(id)) {
|
||||
this.events.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
task_id: taskId,
|
||||
run_id: runId,
|
||||
kind,
|
||||
message,
|
||||
payload,
|
||||
remote_id: null,
|
||||
sync_status: "pending",
|
||||
sync_error: null,
|
||||
last_synced_at: null,
|
||||
occurred_at: occurredAt,
|
||||
});
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
|
||||
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.outbox.get(id);
|
||||
this.outbox.set(id, {
|
||||
id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
operation,
|
||||
payload,
|
||||
status: "pending",
|
||||
attempts: previous?.attempts ?? 0,
|
||||
last_attempt_at: previous?.last_attempt_at ?? null,
|
||||
error: null,
|
||||
created_at: previous?.created_at ?? createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
|
||||
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
|
||||
const row = this.outbox.get(id);
|
||||
@@ -1349,7 +1615,7 @@ class TestDb {
|
||||
return { changes: before - this.attempts.length };
|
||||
}
|
||||
|
||||
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
|
||||
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
|
||||
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
||||
const row = this.entityRowsForUpdate(sql).get(id);
|
||||
if (row) {
|
||||
@@ -1361,7 +1627,7 @@ class TestDb {
|
||||
return { changes: row ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
|
||||
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
|
||||
const [syncError, id] = args as [string, string];
|
||||
const row = this.entityRowsForUpdate(sql).get(id);
|
||||
if (row) {
|
||||
@@ -1379,9 +1645,11 @@ class TestDb {
|
||||
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
||||
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
|
||||
if (sql.startsWith("UPDATE digital_events")) return this.events;
|
||||
if (sql.startsWith("UPDATE digital_runs")) return this.runs;
|
||||
if (sql.startsWith("UPDATE digital_schedule_runs")) return this.scheduleRuns;
|
||||
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
|
||||
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
||||
if (sql.startsWith("UPDATE digital_tasks")) return this.tasks;
|
||||
return this.plans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@ import log from "electron-log";
|
||||
import { getDeviceId } from "../system/deviceId";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
|
||||
import {
|
||||
normalizePlanStepDispatchPolicy,
|
||||
readDigitalEmployeeUiState,
|
||||
saveDigitalEmployeeUiState,
|
||||
type DigitalEmployeePlanStepDispatchPolicy,
|
||||
} from "./stateService";
|
||||
|
||||
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
|
||||
const DEFAULT_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_BATCH_SIZE = 50;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const POLICY_PULL_THROTTLE_MS = 60_000;
|
||||
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
||||
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
||||
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
@@ -18,6 +26,8 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let syncing = false;
|
||||
let lastSyncAt: string | null = null;
|
||||
let lastSyncError: string | null = null;
|
||||
let policyPulling = false;
|
||||
let lastPolicyPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -27,11 +37,21 @@ function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
interface DigitalEmployeeSyncConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string | null;
|
||||
policyEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
policy: DigitalEmployeePlanStepDispatchPolicy;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface OutboxRow {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
@@ -168,6 +188,63 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
|
||||
};
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeePlanStepDispatchPolicy(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeePlanStepDispatchPolicyPullResult> {
|
||||
const currentState = readDigitalEmployeeUiState();
|
||||
const currentPolicy = currentState.planStepDispatchPolicy ?? normalizePlanStepDispatchPolicy(null);
|
||||
if (policyPulling) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
||||
}
|
||||
lastPolicyPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.policyEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.policyEndpoint ? "management policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, error);
|
||||
}
|
||||
|
||||
policyPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.policyEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const remotePolicy = readRemotePlanStepDispatchPolicy(response);
|
||||
if (!remotePolicy) throw new Error("management policy response missing plan step dispatch policy");
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policy = normalizePlanStepDispatchPolicy({
|
||||
...remotePolicy,
|
||||
source: "remote",
|
||||
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.remote_updated_at) || null,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: null,
|
||||
});
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
metadata: {
|
||||
source: "management-api",
|
||||
endpoint: config.policyEndpoint,
|
||||
ok: true,
|
||||
plan_step_dispatch_policy: policy,
|
||||
},
|
||||
state: {
|
||||
...currentState,
|
||||
planStepDispatchPolicy: policy,
|
||||
},
|
||||
});
|
||||
return { ok: true, endpoint: config.policyEndpoint, pulled_at: pulledAt, policy, error: null };
|
||||
} catch (error) {
|
||||
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, normalizeError(error));
|
||||
} finally {
|
||||
policyPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushDigitalEmployeeSyncOutbox(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeSyncStatus> {
|
||||
@@ -291,6 +368,13 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? step1.serverHost.trim()
|
||||
: null;
|
||||
const endpoint = endpointOverride || buildEndpoint(serverHost);
|
||||
const policyEndpointOverride =
|
||||
typeof config.planStepDispatchPolicyEndpoint === "string" && config.planStepDispatchPolicyEndpoint.trim()
|
||||
? config.planStepDispatchPolicyEndpoint.trim()
|
||||
: typeof config.policyEndpoint === "string" && config.policyEndpoint.trim()
|
||||
? config.policyEndpoint.trim()
|
||||
: null;
|
||||
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
|
||||
const disabled = config.enabled === false;
|
||||
const batchSize =
|
||||
typeof config.batchSize === "number" && config.batchSize > 0
|
||||
@@ -300,18 +384,19 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
return {
|
||||
enabled: !disabled,
|
||||
endpoint,
|
||||
policyEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
batchSize,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEndpoint(serverHost: string | null): string | null {
|
||||
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
||||
if (!serverHost) return null;
|
||||
const normalized = serverHost.startsWith("http")
|
||||
? serverHost
|
||||
: `https://${serverHost}`;
|
||||
return `${normalized.replace(/\/+$/, "")}${DEFAULT_SYNC_PATH}`;
|
||||
return `${normalized.replace(/\/+$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function missingSyncCredentialLabels(
|
||||
@@ -385,35 +470,85 @@ async function postOutbox(
|
||||
config: DigitalEmployeeSyncConfig,
|
||||
rows: OutboxRow[],
|
||||
): Promise<SyncResponse> {
|
||||
return fetchJsonWithAuth(config, config.endpoint!, {
|
||||
client_key: config.clientKey,
|
||||
device_id: getDeviceId(),
|
||||
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
||||
items: rows.map(buildSyncItem),
|
||||
}) as Promise<SyncResponse>;
|
||||
}
|
||||
|
||||
async function fetchJsonWithAuth(
|
||||
config: DigitalEmployeeSyncConfig,
|
||||
endpoint: string,
|
||||
requestBody?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(config.endpoint!, {
|
||||
method: "POST",
|
||||
const response = await fetch(endpoint, {
|
||||
method: requestBody ? "POST" : "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.authToken}`,
|
||||
"X-Qiming-Client-Key": config.clientKey!,
|
||||
"X-Qiming-Device-Id": getDeviceId(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_key: config.clientKey,
|
||||
device_id: getDeviceId(),
|
||||
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
||||
items: rows.map(buildSyncItem),
|
||||
}),
|
||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const body = text ? (JSON.parse(text) as SyncResponse) : {};
|
||||
const responseBody = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || `HTTP ${response.status}`);
|
||||
throw new Error(stringField(responseBody.message) || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
return responseBody;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
return objectRecord(data.plan_step_dispatch_policy)
|
||||
?? objectRecord(data.planStepDispatchPolicy)
|
||||
?? objectRecord(data.policy)
|
||||
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
|
||||
}
|
||||
|
||||
function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boolean {
|
||||
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
|
||||
}
|
||||
|
||||
function recordPlanStepDispatchPolicyPullFailure(
|
||||
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
||||
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
|
||||
endpoint: string | null,
|
||||
error: string,
|
||||
): DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policy = normalizePlanStepDispatchPolicy({
|
||||
...currentPolicy,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: error,
|
||||
});
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
metadata: {
|
||||
source: "management-api",
|
||||
endpoint,
|
||||
ok: false,
|
||||
error,
|
||||
plan_step_dispatch_policy: policy,
|
||||
},
|
||||
state: {
|
||||
...state,
|
||||
planStepDispatchPolicy: policy,
|
||||
},
|
||||
});
|
||||
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
@@ -753,10 +888,16 @@ function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): R
|
||||
?? {};
|
||||
return {
|
||||
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
|
||||
source: stringField(metadata.source ?? policy.source) || null,
|
||||
pull_ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||
pull_error: stringField(metadata.error ?? policy.last_pull_error) || null,
|
||||
pull_endpoint: stringField(metadata.endpoint) || null,
|
||||
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,
|
||||
remote_updated_at: stringField(policy.remote_updated_at) || null,
|
||||
last_pulled_at: stringField(policy.last_pulled_at) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -820,7 +961,8 @@ function isPlanStepDependencyEvent(kind: string): boolean {
|
||||
}
|
||||
|
||||
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_plan_step_dispatch_policy";
|
||||
return kind === "digital_workday_update_plan_step_dispatch_policy"
|
||||
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
|
||||
}
|
||||
|
||||
function isPlanStepLeaseEvent(kind: string): boolean {
|
||||
|
||||
@@ -128,6 +128,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async flushSync() {
|
||||
return ipcRenderer.invoke("digitalEmployee:flushSync");
|
||||
},
|
||||
async pullPlanStepDispatchPolicy() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
|
||||
},
|
||||
async getCronSettings() {
|
||||
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
|
||||
},
|
||||
|
||||
@@ -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` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件;embedded 已提供 `/api/digital-employee/plan-steps`、`/api/digital-employee/plan-step-graph`、`/api/digital-employee/plans/:planId/dispatch-ready-steps` 和 `/api/plan-step/dispatch-policy`,ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
|
||||
- 后续缺口:真正由管理端仲裁的跨设备强租约和远端策略拉取仍待吸收。
|
||||
- 建议:下一轮围绕管理端 lease 仲裁或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
|
||||
- 当前能力: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` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件;auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`;embedded 已提供 `/api/digital-employee/plan-steps`、`/api/digital-employee/plan-step-graph`、`/api/digital-employee/plans/:planId/dispatch-ready-steps`、`/api/plan-step/dispatch-policy` 和 `/api/plan-step/dispatch-policy/pull`,ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
|
||||
- 后续缺口:真正由管理端仲裁的跨设备强租约仍待吸收。
|
||||
- 建议:下一轮围绕管理端 lease 仲裁,把本地可治理调度推进到跨端治理闭环。
|
||||
|
||||
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