吸收数字员工PlanStep远端策略拉取

This commit is contained in:
baiyanyun
2026-06-10 23:15:51 +08:00
parent 6f2ccdf783
commit c400367d22
17 changed files with 776 additions and 205 deletions

View File

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

View File

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

View File

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

View File

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

View File

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