吸收数字员工PlanStep远端策略拉取
This commit is contained in:
@@ -16,6 +16,7 @@ import type {
|
|||||||
BusinessViewResponse,
|
BusinessViewResponse,
|
||||||
PlanStepBusinessRow,
|
PlanStepBusinessRow,
|
||||||
PlanStepDispatchPolicy,
|
PlanStepDispatchPolicy,
|
||||||
|
PlanStepDispatchPolicyPullResult,
|
||||||
ReadyStepDispatchResult,
|
ReadyStepDispatchResult,
|
||||||
NotificationPreferences,
|
NotificationPreferences,
|
||||||
UserNotification,
|
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>> {
|
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
PlanDispatchResponse,
|
PlanDispatchResponse,
|
||||||
ReadyStepDispatchResult,
|
ReadyStepDispatchResult,
|
||||||
PlanStepDispatchPolicy,
|
PlanStepDispatchPolicy,
|
||||||
|
PlanStepDispatchPolicyPullResult,
|
||||||
PlanStepSummary,
|
PlanStepSummary,
|
||||||
RouteDecisionRecord,
|
RouteDecisionRecord,
|
||||||
RouteDecisionTimelineResponse,
|
RouteDecisionTimelineResponse,
|
||||||
@@ -60,6 +61,7 @@ declare global {
|
|||||||
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
||||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||||
|
pullPlanStepDispatchPolicy?: () => Promise<PlanStepDispatchPolicyPullResult>;
|
||||||
getCronSettings?: () => Promise<QimingclawCronSettings>;
|
getCronSettings?: () => Promise<QimingclawCronSettings>;
|
||||||
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
|
saveCronSettings?: (patch: Partial<QimingclawCronSettings>) => Promise<QimingclawCronSettings>;
|
||||||
runSchedulerCheck?: () => Promise<{ activated: number; due?: number; skipped?: number }>;
|
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') {
|
if (method === 'GET' && pathname === '/api/plan-step/dispatch-policy') {
|
||||||
return planStepDispatchPolicy(notificationAdapterState(await readQimingclawSnapshot())) as T;
|
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') {
|
if (method === 'PATCH' && pathname === '/api/plan-step/dispatch-policy') {
|
||||||
return await handlePlanStepDispatchPolicyPatch(
|
return await handlePlanStepDispatchPolicyPatch(
|
||||||
parseJsonBody<Partial<PlanStepDispatchPolicy>>(options.body),
|
parseJsonBody<Partial<PlanStepDispatchPolicy>>(options.body),
|
||||||
@@ -3883,6 +3888,10 @@ function defaultPlanStepDispatchPolicy(): PlanStepDispatchPolicy {
|
|||||||
max_per_sweep: 3,
|
max_per_sweep: 3,
|
||||||
auto_dispatch_ready_steps: true,
|
auto_dispatch_ready_steps: true,
|
||||||
updated_at: null,
|
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,
|
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,
|
auto_dispatch_ready_steps: stored.auto_dispatch_ready_steps !== false,
|
||||||
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
|
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;
|
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[] {
|
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||||
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
||||||
return buildApprovals(snapshot)
|
return buildApprovals(snapshot)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getSchedulerJobs,
|
getSchedulerJobs,
|
||||||
governanceCommand,
|
governanceCommand,
|
||||||
postDigitalEmployeeWorkdayAction,
|
postDigitalEmployeeWorkdayAction,
|
||||||
|
pullPlanStepDispatchPolicy,
|
||||||
recordApprovalAction,
|
recordApprovalAction,
|
||||||
restartManagedService,
|
restartManagedService,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -1368,6 +1369,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
}
|
}
|
||||||
}, [selectedDate]);
|
}, [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 copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
|
||||||
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
||||||
try {
|
try {
|
||||||
@@ -2016,6 +2039,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
<RefreshCw size={13} aria-hidden="true" />
|
<RefreshCw size={13} aria-hidden="true" />
|
||||||
<span>{actionBusy === 'flush_management_sync' ? '同步中...' : '同步管理端'}</span>
|
<span>{actionBusy === 'flush_management_sync' ? '同步中...' : '同步管理端'}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>
|
<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;
|
max_per_sweep: number;
|
||||||
auto_dispatch_ready_steps: boolean;
|
auto_dispatch_ready_steps: boolean;
|
||||||
updated_at: string | null;
|
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 {
|
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);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -311,6 +311,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[adapter, "dispatch-ready-steps", "adapter plan-scoped ready step dispatch endpoint"],
|
[adapter, "dispatch-ready-steps", "adapter plan-scoped ready step dispatch endpoint"],
|
||||||
[adapter, "dispatchPlanReadySteps", "adapter plan-scoped ready step dispatcher"],
|
[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", "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, "planStepRows", "management plan step rows"],
|
||||||
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
||||||
[adapter, "filterPlanStepRows", "management plan step filters"],
|
[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"],
|
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
|
||||||
[api, "getPlanStepDispatchPolicy", "plan step dispatch policy API getter"],
|
[api, "getPlanStepDispatchPolicy", "plan step dispatch policy API getter"],
|
||||||
[api, "patchPlanStepDispatchPolicy", "plan step dispatch policy API patcher"],
|
[api, "patchPlanStepDispatchPolicy", "plan step dispatch policy API patcher"],
|
||||||
|
[api, "pullPlanStepDispatchPolicy", "plan step dispatch policy API puller"],
|
||||||
[api, "getPlanSteps", "plan steps API getter"],
|
[api, "getPlanSteps", "plan steps API getter"],
|
||||||
[api, "getPlanStepGraph", "plan step graph API getter"],
|
[api, "getPlanStepGraph", "plan step graph API getter"],
|
||||||
[api, "dispatchPlanReadySteps", "plan-scoped ready step dispatch API"],
|
[api, "dispatchPlanReadySteps", "plan-scoped ready step dispatch API"],
|
||||||
@@ -329,6 +332,8 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[types, "trigger_source", "ready step dispatch trigger source type"],
|
[types, "trigger_source", "ready step dispatch trigger source type"],
|
||||||
[types, "orchestration_id", "ready step dispatch orchestration id type"],
|
[types, "orchestration_id", "ready step dispatch orchestration id type"],
|
||||||
[types, "PlanStepDispatchPolicy", "plan step dispatch policy 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, "PlanStepLeaseSummary", "plan step lease summary type"],
|
||||||
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
|
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
|
||||||
[types, "lease_active", "plan step lease active type field"],
|
[types, "lease_active", "plan step lease active type field"],
|
||||||
@@ -338,12 +343,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
|
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
|
||||||
[commandDeck, "leasedStepCount", "home plan step lease count"],
|
[commandDeck, "leasedStepCount", "home plan step lease count"],
|
||||||
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
|
[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, "controlBlockedPlanStep", "home blocked plan step control handler"],
|
||||||
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
|
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
|
||||||
[commandDeck, "provide_task_input", "home blocked plan step input command"],
|
[commandDeck, "provide_task_input", "home blocked plan step input command"],
|
||||||
[css, "de-blocked-input-field", "home blocked plan step input styles"],
|
[css, "de-blocked-input-field", "home blocked plan step input styles"],
|
||||||
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
||||||
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
|
[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, "planIdFilter", "plan-scoped ready step candidate filter"],
|
||||||
[stateService, "dispatchLease", "plan step payload lease state"],
|
[stateService, "dispatchLease", "plan step payload lease state"],
|
||||||
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
|
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
|
||||||
@@ -358,10 +367,16 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
|
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
|
||||||
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
|
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
|
||||||
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
|
[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"],
|
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
|
||||||
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
|
[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, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
|
||||||
|
[preload, "pullPlanStepDispatchPolicy", "preload plan step dispatch policy pull bridge"],
|
||||||
[syncService, "planStepBusinessView", "plan step sync business view"],
|
[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, "lease_active", "plan step sync lease active field"],
|
||||||
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
|
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
|
||||||
[syncService, "plan_step_lease_acquired", "plan step lease acquired sync event kind"],
|
[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, "orchestration_id", "plan step dispatch sync orchestration id"],
|
||||||
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
|
[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_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
|
const missing = requiredSnippets
|
||||||
.filter(([content, snippet]) => !content.includes(snippet))
|
.filter(([content, snippet]) => !content.includes(snippet))
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ vi.mock("../services/digitalEmployee/schedulerService", () => ({
|
|||||||
vi.mock("../services/digitalEmployee/syncService", () => ({
|
vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||||
flushDigitalEmployeeSyncOutbox: vi.fn(),
|
flushDigitalEmployeeSyncOutbox: vi.fn(),
|
||||||
getDigitalEmployeeSyncStatus: 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", () => ({
|
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" });
|
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 () => {
|
it("registers approval action history through the digital employee IPC boundary", async () => {
|
||||||
const electron = await import("electron");
|
const electron = await import("electron");
|
||||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
flushDigitalEmployeeSyncOutbox,
|
flushDigitalEmployeeSyncOutbox,
|
||||||
getDigitalEmployeeSyncStatus,
|
getDigitalEmployeeSyncStatus,
|
||||||
|
pullDigitalEmployeePlanStepDispatchPolicy,
|
||||||
} from "../services/digitalEmployee/syncService";
|
} from "../services/digitalEmployee/syncService";
|
||||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||||
import {
|
import {
|
||||||
@@ -188,6 +189,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:pullPlanStepDispatchPolicy", async () => {
|
||||||
|
return pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
|
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
|
||||||
return readDigitalEmployeeCronSettings();
|
return readDigitalEmployeeCronSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
|
|||||||
governanceCommands: [] as Array<Record<string, unknown>>,
|
governanceCommands: [] as Array<Record<string, unknown>>,
|
||||||
acquiredLeases: [] as Array<Record<string, unknown>>,
|
acquiredLeases: [] as Array<Record<string, unknown>>,
|
||||||
releasedLeases: [] as Array<Record<string, unknown>>,
|
releasedLeases: [] as Array<Record<string, unknown>>,
|
||||||
|
policyPulls: [] as Array<Record<string, unknown>>,
|
||||||
settings: {
|
settings: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
catch_up_on_startup: 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", () => {
|
describe("digital employee scheduler service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -95,6 +103,7 @@ describe("digital employee scheduler service", () => {
|
|||||||
mockState.governanceCommands = [];
|
mockState.governanceCommands = [];
|
||||||
mockState.acquiredLeases = [];
|
mockState.acquiredLeases = [];
|
||||||
mockState.releasedLeases = [];
|
mockState.releasedLeases = [];
|
||||||
|
mockState.policyPulls = [];
|
||||||
mockState.dispatchPolicy = {
|
mockState.dispatchPolicy = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max_per_sweep: 3,
|
max_per_sweep: 3,
|
||||||
@@ -179,6 +188,7 @@ describe("digital employee scheduler service", () => {
|
|||||||
expect.objectContaining({ method: "POST" }),
|
expect.objectContaining({ method: "POST" }),
|
||||||
);
|
);
|
||||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
|
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.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.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
|
||||||
expect(mockState.governanceCommands[0]).toMatchObject({
|
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(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.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 () => {
|
it("keeps the enabled switch as a manual plan dispatch kill switch", async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
||||||
type DigitalEmployeeScheduleRecord,
|
type DigitalEmployeeScheduleRecord,
|
||||||
} from "./stateService";
|
} from "./stateService";
|
||||||
|
import { pullDigitalEmployeePlanStepDispatchPolicy } from "./syncService";
|
||||||
|
|
||||||
const SWEEP_INTERVAL_MS = 30_000;
|
const SWEEP_INTERVAL_MS = 30_000;
|
||||||
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
||||||
@@ -157,6 +158,11 @@ export async function dispatchReadyPlanSteps(
|
|||||||
failed_step_ids: [],
|
failed_step_ids: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
if (triggerSource === "auto") {
|
||||||
|
await pullDigitalEmployeePlanStepDispatchPolicy().catch((error) => {
|
||||||
|
log.debug("[DigitalEmployeeScheduler] PlanStep dispatch policy pull skipped/failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
const policy = readDigitalEmployeePlanStepDispatchPolicy();
|
const policy = readDigitalEmployeePlanStepDispatchPolicy();
|
||||||
if (!policy.enabled || (!policy.auto_dispatch_ready_steps && !bypassAutoDispatchSwitch)) {
|
if (!policy.enabled || (!policy.auto_dispatch_ready_steps && !bypassAutoDispatchSwitch)) {
|
||||||
result.skipped = candidates.length;
|
result.skipped = candidates.length;
|
||||||
|
|||||||
@@ -361,6 +361,10 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
|
|||||||
max_per_sweep: number;
|
max_per_sweep: number;
|
||||||
auto_dispatch_ready_steps: boolean;
|
auto_dispatch_ready_steps: boolean;
|
||||||
updated_at: string | null;
|
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 {
|
export interface DigitalEmployeeRouteDecisionRecord {
|
||||||
@@ -777,6 +781,10 @@ function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy
|
|||||||
max_per_sweep: 3,
|
max_per_sweep: 3,
|
||||||
auto_dispatch_ready_steps: true,
|
auto_dispatch_ready_steps: true,
|
||||||
updated_at: null,
|
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));
|
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 fallback = defaultPlanStepDispatchPolicy();
|
||||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
|
const source = record.source === "remote" ? "remote" : "local";
|
||||||
return {
|
return {
|
||||||
enabled: record.enabled !== false,
|
enabled: record.enabled !== false,
|
||||||
max_per_sweep: boundedInteger(record.max_per_sweep, fallback.max_per_sweep, 1, 10),
|
max_per_sweep: boundedInteger(record.max_per_sweep, fallback.max_per_sweep, 1, 10),
|
||||||
auto_dispatch_ready_steps: record.auto_dispatch_ready_steps !== false,
|
auto_dispatch_ready_steps: record.auto_dispatch_ready_steps !== false,
|
||||||
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
|
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 "数字员工审批订阅已更新";
|
return "数字员工审批订阅已更新";
|
||||||
case "update_plan_step_dispatch_policy":
|
case "update_plan_step_dispatch_policy":
|
||||||
return "数字员工PlanStep派发策略已更新";
|
return "数字员工PlanStep派发策略已更新";
|
||||||
|
case "pull_plan_step_dispatch_policy":
|
||||||
|
return "数字员工PlanStep派发策略已拉取";
|
||||||
default:
|
default:
|
||||||
return `数字员工页面状态已更新${suffix}`;
|
return `数字员工页面状态已更新${suffix}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ vi.mock("../../db", () => ({
|
|||||||
},
|
},
|
||||||
getDb: () => mockState.db,
|
getDb: () => mockState.db,
|
||||||
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||||
|
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("digital employee sync service", () => {
|
describe("digital employee sync service", () => {
|
||||||
@@ -70,6 +71,72 @@ describe("digital employee sync service", () => {
|
|||||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
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 () => {
|
it("marks entity-only acknowledgements as synced", async () => {
|
||||||
insertPlan("plan-1");
|
insertPlan("plan-1");
|
||||||
insertOutbox("plan:upsert:plan-1", "plan", "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(
|
mockState.fetch.mockResolvedValue(
|
||||||
jsonResponse({
|
jsonResponse({
|
||||||
success: true,
|
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-notification-preferences" },
|
||||||
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
{ 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" },
|
||||||
|
{ 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("state");
|
||||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
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({
|
expect(readEntity("event", "event-route")).toMatchObject({
|
||||||
sync_status: "synced",
|
sync_status: "synced",
|
||||||
sync_error: null,
|
sync_error: null,
|
||||||
@@ -1032,7 +1131,9 @@ function entityMap(
|
|||||||
): Map<string, TestSyncEntityRow> | undefined {
|
): Map<string, TestSyncEntityRow> | undefined {
|
||||||
if (!mockState.db) return undefined;
|
if (!mockState.db) return undefined;
|
||||||
if (entityType === "plan") return mockState.db.plans;
|
if (entityType === "plan") return mockState.db.plans;
|
||||||
|
if (entityType === "task") return mockState.db.tasks;
|
||||||
if (entityType === "plan_step") return mockState.db.planSteps;
|
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") return mockState.db.schedules;
|
||||||
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
|
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
|
||||||
if (entityType === "artifact") return mockState.db.artifacts;
|
if (entityType === "artifact") return mockState.db.artifacts;
|
||||||
@@ -1108,7 +1209,9 @@ interface TestAttemptRow {
|
|||||||
class TestDb {
|
class TestDb {
|
||||||
outbox = new Map<string, TestOutboxRow>();
|
outbox = new Map<string, TestOutboxRow>();
|
||||||
plans = new Map<string, TestSyncEntityRow>();
|
plans = new Map<string, TestSyncEntityRow>();
|
||||||
|
tasks = new Map<string, TestSyncEntityRow>();
|
||||||
planSteps = new Map<string, TestSyncEntityRow>();
|
planSteps = new Map<string, TestSyncEntityRow>();
|
||||||
|
runs = new Map<string, TestSyncEntityRow>();
|
||||||
schedules = new Map<string, TestSyncEntityRow>();
|
schedules = new Map<string, TestSyncEntityRow>();
|
||||||
scheduleRuns = new Map<string, TestSyncEntityRow>();
|
scheduleRuns = new Map<string, TestSyncEntityRow>();
|
||||||
artifacts = new Map<string, TestSyncEntityRow>();
|
artifacts = new Map<string, TestSyncEntityRow>();
|
||||||
@@ -1205,6 +1308,23 @@ class TestDb {
|
|||||||
return this.plans.get(id);
|
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")) {
|
if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) {
|
||||||
const [id] = args as [string];
|
const [id] = args as [string];
|
||||||
return this.artifacts.get(id);
|
return this.artifacts.get(id);
|
||||||
@@ -1267,6 +1387,152 @@ class TestDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private run(sql: string, args: unknown[]): unknown {
|
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'")) {
|
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
|
||||||
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
|
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
|
||||||
const row = this.outbox.get(id);
|
const row = this.outbox.get(id);
|
||||||
@@ -1349,7 +1615,7 @@ class TestDb {
|
|||||||
return { changes: before - this.attempts.length };
|
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 [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
||||||
const row = this.entityRowsForUpdate(sql).get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -1361,7 +1627,7 @@ class TestDb {
|
|||||||
return { changes: row ? 1 : 0 };
|
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 [syncError, id] = args as [string, string];
|
||||||
const row = this.entityRowsForUpdate(sql).get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -1379,9 +1645,11 @@ class TestDb {
|
|||||||
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
||||||
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
|
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
|
||||||
if (sql.startsWith("UPDATE digital_events")) return this.events;
|
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_schedule_runs")) return this.scheduleRuns;
|
||||||
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
|
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
|
||||||
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
||||||
|
if (sql.startsWith("UPDATE digital_tasks")) return this.tasks;
|
||||||
return this.plans;
|
return this.plans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import log from "electron-log";
|
|||||||
import { getDeviceId } from "../system/deviceId";
|
import { getDeviceId } from "../system/deviceId";
|
||||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||||
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
|
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_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 =
|
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||||
"/system/content/content-digital-employee-sync";
|
"/system/content/content-digital-employee-sync";
|
||||||
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
|
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
|
||||||
const DEFAULT_INTERVAL_MS = 30_000;
|
const DEFAULT_INTERVAL_MS = 30_000;
|
||||||
const DEFAULT_BATCH_SIZE = 50;
|
const DEFAULT_BATCH_SIZE = 50;
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const POLICY_PULL_THROTTLE_MS = 60_000;
|
||||||
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
||||||
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
||||||
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
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 syncing = false;
|
||||||
let lastSyncAt: string | null = null;
|
let lastSyncAt: string | null = null;
|
||||||
let lastSyncError: string | null = null;
|
let lastSyncError: string | null = null;
|
||||||
|
let policyPulling = false;
|
||||||
|
let lastPolicyPullAttemptMs = 0;
|
||||||
|
|
||||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||||
if (!ensureDigitalEmployeeSchema()) return null;
|
if (!ensureDigitalEmployeeSchema()) return null;
|
||||||
@@ -27,11 +37,21 @@ function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
|||||||
interface DigitalEmployeeSyncConfig {
|
interface DigitalEmployeeSyncConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
endpoint: string | null;
|
endpoint: string | null;
|
||||||
|
policyEndpoint: string | null;
|
||||||
clientKey: string | null;
|
clientKey: string | null;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
batchSize: number;
|
batchSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||||
|
ok: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
endpoint: string | null;
|
||||||
|
pulled_at: string | null;
|
||||||
|
policy: DigitalEmployeePlanStepDispatchPolicy;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface OutboxRow {
|
interface OutboxRow {
|
||||||
id: string;
|
id: string;
|
||||||
entity_type: 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(
|
export async function flushDigitalEmployeeSyncOutbox(
|
||||||
options: { force?: boolean } = {},
|
options: { force?: boolean } = {},
|
||||||
): Promise<DigitalEmployeeSyncStatus> {
|
): Promise<DigitalEmployeeSyncStatus> {
|
||||||
@@ -291,6 +368,13 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|||||||
? step1.serverHost.trim()
|
? step1.serverHost.trim()
|
||||||
: null;
|
: null;
|
||||||
const endpoint = endpointOverride || buildEndpoint(serverHost);
|
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 disabled = config.enabled === false;
|
||||||
const batchSize =
|
const batchSize =
|
||||||
typeof config.batchSize === "number" && config.batchSize > 0
|
typeof config.batchSize === "number" && config.batchSize > 0
|
||||||
@@ -300,18 +384,19 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|||||||
return {
|
return {
|
||||||
enabled: !disabled,
|
enabled: !disabled,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
policyEndpoint,
|
||||||
clientKey: readClientKey(serverHost),
|
clientKey: readClientKey(serverHost),
|
||||||
authToken: readAuthToken(serverHost),
|
authToken: readAuthToken(serverHost),
|
||||||
batchSize,
|
batchSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEndpoint(serverHost: string | null): string | null {
|
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
||||||
if (!serverHost) return null;
|
if (!serverHost) return null;
|
||||||
const normalized = serverHost.startsWith("http")
|
const normalized = serverHost.startsWith("http")
|
||||||
? serverHost
|
? serverHost
|
||||||
: `https://${serverHost}`;
|
: `https://${serverHost}`;
|
||||||
return `${normalized.replace(/\/+$/, "")}${DEFAULT_SYNC_PATH}`;
|
return `${normalized.replace(/\/+$/, "")}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function missingSyncCredentialLabels(
|
function missingSyncCredentialLabels(
|
||||||
@@ -385,35 +470,85 @@ async function postOutbox(
|
|||||||
config: DigitalEmployeeSyncConfig,
|
config: DigitalEmployeeSyncConfig,
|
||||||
rows: OutboxRow[],
|
rows: OutboxRow[],
|
||||||
): Promise<SyncResponse> {
|
): 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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.endpoint!, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: requestBody ? "POST" : "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${config.authToken}`,
|
Authorization: `Bearer ${config.authToken}`,
|
||||||
"X-Qiming-Client-Key": config.clientKey!,
|
"X-Qiming-Client-Key": config.clientKey!,
|
||||||
|
"X-Qiming-Device-Id": getDeviceId(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||||
client_key: config.clientKey,
|
|
||||||
device_id: getDeviceId(),
|
|
||||||
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
|
||||||
items: rows.map(buildSyncItem),
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
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) {
|
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 {
|
} finally {
|
||||||
clearTimeout(timer);
|
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> {
|
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||||
const payload = parsePayload(row.payload);
|
const payload = parsePayload(row.payload);
|
||||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||||
@@ -753,10 +888,16 @@ function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): R
|
|||||||
?? {};
|
?? {};
|
||||||
return {
|
return {
|
||||||
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
|
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,
|
policy_enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
|
||||||
max_per_sweep: numberField(policy.max_per_sweep),
|
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,
|
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : null,
|
||||||
updated_at: stringField(policy.updated_at) || 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 {
|
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 {
|
function isPlanStepLeaseEvent(kind: string): boolean {
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async flushSync() {
|
async flushSync() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:flushSync");
|
return ipcRenderer.invoke("digitalEmployee:flushSync");
|
||||||
},
|
},
|
||||||
|
async pullPlanStepDispatchPolicy() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
|
||||||
|
},
|
||||||
async getCronSettings() {
|
async getCronSettings() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
|
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -540,9 +540,9 @@ GET /api/digital-employee/approvals
|
|||||||
|
|
||||||
1. **PlanStep 与依赖图(依赖图调度已开始)**
|
1. **PlanStep 与依赖图(依赖图调度已开始)**
|
||||||
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务;PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`。
|
- 已吸收: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 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
|
- 当前能力: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 仲裁或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
|
- 建议:下一轮围绕管理端 lease 仲裁,把本地可治理调度推进到跨端治理闭环。
|
||||||
|
|
||||||
2. **调度 / 定时 / 周期任务**
|
2. **调度 / 定时 / 周期任务**
|
||||||
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
- 已吸收:`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