吸收数字员工PlanStep远端策略拉取
This commit is contained in:
@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
|
||||
governanceCommands: [] as Array<Record<string, unknown>>,
|
||||
acquiredLeases: [] as Array<Record<string, unknown>>,
|
||||
releasedLeases: [] as Array<Record<string, unknown>>,
|
||||
policyPulls: [] as Array<Record<string, unknown>>,
|
||||
settings: {
|
||||
enabled: true,
|
||||
catch_up_on_startup: true,
|
||||
@@ -85,6 +86,13 @@ vi.mock("./stateService", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./syncService", () => ({
|
||||
pullDigitalEmployeePlanStepDispatchPolicy: (input: Record<string, unknown> = {}) => {
|
||||
mockState.policyPulls.push(input);
|
||||
return Promise.resolve({ ok: true, endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch" });
|
||||
},
|
||||
}));
|
||||
|
||||
describe("digital employee scheduler service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -95,6 +103,7 @@ describe("digital employee scheduler service", () => {
|
||||
mockState.governanceCommands = [];
|
||||
mockState.acquiredLeases = [];
|
||||
mockState.releasedLeases = [];
|
||||
mockState.policyPulls = [];
|
||||
mockState.dispatchPolicy = {
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
@@ -179,6 +188,7 @@ describe("digital employee scheduler service", () => {
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
|
||||
expect(mockState.policyPulls).toEqual([{}]);
|
||||
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
|
||||
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
|
||||
expect(mockState.governanceCommands[0]).toMatchObject({
|
||||
@@ -260,6 +270,7 @@ describe("digital employee scheduler service", () => {
|
||||
|
||||
expect(result).toMatchObject({ plan_id: "plan-1", trigger_source: "manual", candidates: 1, dispatched: 1, skipped: 0, failed: 0 });
|
||||
expect(mockState.dispatchEvents[0]).toMatchObject({ status: "started", payload: expect.objectContaining({ trigger_source: "manual" }) });
|
||||
expect(mockState.policyPulls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the enabled switch as a manual plan dispatch kill switch", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
||||
type DigitalEmployeeScheduleRecord,
|
||||
} from "./stateService";
|
||||
import { pullDigitalEmployeePlanStepDispatchPolicy } from "./syncService";
|
||||
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
||||
@@ -157,6 +158,11 @@ export async function dispatchReadyPlanSteps(
|
||||
failed_step_ids: [],
|
||||
errors: [],
|
||||
};
|
||||
if (triggerSource === "auto") {
|
||||
await pullDigitalEmployeePlanStepDispatchPolicy().catch((error) => {
|
||||
log.debug("[DigitalEmployeeScheduler] PlanStep dispatch policy pull skipped/failed:", error);
|
||||
});
|
||||
}
|
||||
const policy = readDigitalEmployeePlanStepDispatchPolicy();
|
||||
if (!policy.enabled || (!policy.auto_dispatch_ready_steps && !bypassAutoDispatchSwitch)) {
|
||||
result.skipped = candidates.length;
|
||||
|
||||
@@ -361,6 +361,10 @@ export interface DigitalEmployeePlanStepDispatchPolicy {
|
||||
max_per_sweep: number;
|
||||
auto_dispatch_ready_steps: boolean;
|
||||
updated_at: string | null;
|
||||
source?: "local" | "remote";
|
||||
remote_updated_at?: string | null;
|
||||
last_pulled_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionRecord {
|
||||
@@ -777,6 +781,10 @@ function defaultPlanStepDispatchPolicy(): DigitalEmployeePlanStepDispatchPolicy
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: null,
|
||||
source: "local",
|
||||
remote_updated_at: null,
|
||||
last_pulled_at: null,
|
||||
last_pull_error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -825,16 +833,21 @@ function boundedInteger(value: unknown, fallback: number, min: number, max: numb
|
||||
return Math.max(min, Math.min(Math.floor(raw), max));
|
||||
}
|
||||
|
||||
function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
|
||||
export function normalizePlanStepDispatchPolicy(value: unknown): DigitalEmployeePlanStepDispatchPolicy {
|
||||
const fallback = defaultPlanStepDispatchPolicy();
|
||||
const record = value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
const source = record.source === "remote" ? "remote" : "local";
|
||||
return {
|
||||
enabled: record.enabled !== false,
|
||||
max_per_sweep: boundedInteger(record.max_per_sweep, fallback.max_per_sweep, 1, 10),
|
||||
auto_dispatch_ready_steps: record.auto_dispatch_ready_steps !== false,
|
||||
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
|
||||
source,
|
||||
remote_updated_at: typeof record.remote_updated_at === "string" ? record.remote_updated_at : null,
|
||||
last_pulled_at: typeof record.last_pulled_at === "string" ? record.last_pulled_at : null,
|
||||
last_pull_error: typeof record.last_pull_error === "string" ? record.last_pull_error : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -899,6 +912,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||
return "数字员工审批订阅已更新";
|
||||
case "update_plan_step_dispatch_policy":
|
||||
return "数字员工PlanStep派发策略已更新";
|
||||
case "pull_plan_step_dispatch_policy":
|
||||
return "数字员工PlanStep派发策略已拉取";
|
||||
default:
|
||||
return `数字员工页面状态已更新${suffix}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../../db", () => ({
|
||||
},
|
||||
getDb: () => mockState.db,
|
||||
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||
}));
|
||||
|
||||
describe("digital employee sync service", () => {
|
||||
@@ -70,6 +71,72 @@ describe("digital employee sync service", () => {
|
||||
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("pulls remote plan step dispatch policy and records policy state", async () => {
|
||||
mockState.fetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
plan_step_dispatch_policy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 5,
|
||||
auto_dispatch_ready_steps: false,
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
||||
const { readDigitalEmployeeUiState } = await import("./stateService");
|
||||
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
policy: {
|
||||
source: "remote",
|
||||
enabled: true,
|
||||
max_per_sweep: 5,
|
||||
auto_dispatch_ready_steps: false,
|
||||
remote_updated_at: "2026-06-07T08:01:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: null,
|
||||
},
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(readDigitalEmployeeUiState().planStepDispatchPolicy).toMatchObject(result.policy);
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "digital_workday_pull_plan_step_dispatch_policy" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps the current plan step dispatch policy when remote policy pull fails", async () => {
|
||||
mockState.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "policy service unavailable" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const { pullDigitalEmployeePlanStepDispatchPolicy } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: "policy service unavailable",
|
||||
policy: {
|
||||
source: "local",
|
||||
enabled: true,
|
||||
max_per_sweep: 3,
|
||||
auto_dispatch_ready_steps: true,
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: "policy service unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks entity-only acknowledgements as synced", async () => {
|
||||
insertPlan("plan-1");
|
||||
insertOutbox("plan:upsert:plan-1", "plan", "plan-1");
|
||||
@@ -738,6 +805,25 @@ describe("digital employee sync service", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
insertEventEntity("event-plan-step-policy-pull", "digital_workday_pull_plan_step_dispatch_policy", {
|
||||
metadata: {
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
source: "management-api",
|
||||
ok: false,
|
||||
error: "policy service unavailable",
|
||||
endpoint: "https://manage.example.com/api/digital-employee/policies/plan-step-dispatch",
|
||||
plan_step_dispatch_policy: {
|
||||
enabled: true,
|
||||
max_per_sweep: 2,
|
||||
auto_dispatch_ready_steps: true,
|
||||
updated_at: "2026-06-07T08:08:00.000Z",
|
||||
source: "remote",
|
||||
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
||||
last_pull_error: "policy service unavailable",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
@@ -757,6 +843,7 @@ describe("digital employee sync service", () => {
|
||||
{ entity_type: "event", entity_id: "event-notification-preferences" },
|
||||
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
|
||||
{ entity_type: "event", entity_id: "event-plan-step-policy" },
|
||||
{ entity_type: "event", entity_id: "event-plan-step-policy-pull" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -923,6 +1010,18 @@ describe("digital employee sync service", () => {
|
||||
});
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("state");
|
||||
expect(businessView("event-plan-step-policy")).not.toHaveProperty("planStepDispatchPolicy");
|
||||
expect(businessView("event-plan-step-policy-pull")).toMatchObject({
|
||||
category: "dispatch",
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
source: "management-api",
|
||||
pull_ok: false,
|
||||
pull_error: "policy service unavailable",
|
||||
policy_enabled: true,
|
||||
max_per_sweep: 2,
|
||||
auto_dispatch_ready_steps: true,
|
||||
remote_updated_at: "2026-06-07T08:08:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:09:00.000Z",
|
||||
});
|
||||
expect(readEntity("event", "event-route")).toMatchObject({
|
||||
sync_status: "synced",
|
||||
sync_error: null,
|
||||
@@ -1032,7 +1131,9 @@ function entityMap(
|
||||
): Map<string, TestSyncEntityRow> | undefined {
|
||||
if (!mockState.db) return undefined;
|
||||
if (entityType === "plan") return mockState.db.plans;
|
||||
if (entityType === "task") return mockState.db.tasks;
|
||||
if (entityType === "plan_step") return mockState.db.planSteps;
|
||||
if (entityType === "run") return mockState.db.runs;
|
||||
if (entityType === "schedule") return mockState.db.schedules;
|
||||
if (entityType === "schedule_run") return mockState.db.scheduleRuns;
|
||||
if (entityType === "artifact") return mockState.db.artifacts;
|
||||
@@ -1108,7 +1209,9 @@ interface TestAttemptRow {
|
||||
class TestDb {
|
||||
outbox = new Map<string, TestOutboxRow>();
|
||||
plans = new Map<string, TestSyncEntityRow>();
|
||||
tasks = new Map<string, TestSyncEntityRow>();
|
||||
planSteps = new Map<string, TestSyncEntityRow>();
|
||||
runs = new Map<string, TestSyncEntityRow>();
|
||||
schedules = new Map<string, TestSyncEntityRow>();
|
||||
scheduleRuns = new Map<string, TestSyncEntityRow>();
|
||||
artifacts = new Map<string, TestSyncEntityRow>();
|
||||
@@ -1205,6 +1308,23 @@ class TestDb {
|
||||
return this.plans.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT title, status, NULL AS objective, payload FROM digital_tasks")) {
|
||||
const [id] = args as [string];
|
||||
return this.tasks.get(id);
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT id AS title, status, NULL AS objective, payload FROM digital_runs")) {
|
||||
const [id] = args as [string];
|
||||
const row = this.runs.get(id);
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
title: row.id,
|
||||
status: row.status,
|
||||
objective: null,
|
||||
payload: row.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.startsWith("SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts")) {
|
||||
const [id] = args as [string];
|
||||
return this.artifacts.get(id);
|
||||
@@ -1267,6 +1387,152 @@ class TestDb {
|
||||
}
|
||||
|
||||
private run(sql: string, args: unknown[]): unknown {
|
||||
if (sql.startsWith("INSERT INTO digital_plans")) {
|
||||
const [id, title, objective, status, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.plans.get(id);
|
||||
this.plans.set(id, {
|
||||
id,
|
||||
title,
|
||||
objective,
|
||||
status,
|
||||
payload,
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_tasks")) {
|
||||
const [id, planId, title, status, assignedSkillId, payload] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.tasks.get(id);
|
||||
this.tasks.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
title,
|
||||
status,
|
||||
payload: JSON.stringify({ ...JSON.parse(payload), assigned_skill_id: assignedSkillId }),
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_runs")) {
|
||||
const [id, planId, taskId, status, startedAt, payload] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.runs.get(id);
|
||||
this.runs.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
task_id: taskId,
|
||||
status,
|
||||
payload: JSON.stringify({ ...JSON.parse(payload), started_at: startedAt }),
|
||||
remote_id: previous?.remote_id ?? null,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
sync_error: previous?.sync_error ?? null,
|
||||
last_synced_at: previous?.last_synced_at ?? null,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_runs SET status = ?, finished_at = ?, updated_at = ?")) {
|
||||
const [status, finishedAt, _updatedAt, id] = args as [string, string, string, string];
|
||||
const row = this.runs.get(id);
|
||||
if (row) {
|
||||
row.status = status;
|
||||
row.payload = JSON.stringify({ ...JSON.parse(row.payload ?? "{}"), finished_at: finishedAt });
|
||||
row.sync_status = "pending";
|
||||
}
|
||||
return { changes: row ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) {
|
||||
const [id, planId, taskId, runId, kind, message, payload, occurredAt] = args as [
|
||||
string,
|
||||
string | null,
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
if (!this.events.has(id)) {
|
||||
this.events.set(id, {
|
||||
id,
|
||||
plan_id: planId,
|
||||
task_id: taskId,
|
||||
run_id: runId,
|
||||
kind,
|
||||
message,
|
||||
payload,
|
||||
remote_id: null,
|
||||
sync_status: "pending",
|
||||
sync_error: null,
|
||||
last_synced_at: null,
|
||||
occurred_at: occurredAt,
|
||||
});
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
|
||||
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const previous = this.outbox.get(id);
|
||||
this.outbox.set(id, {
|
||||
id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
operation,
|
||||
payload,
|
||||
status: "pending",
|
||||
attempts: previous?.attempts ?? 0,
|
||||
last_attempt_at: previous?.last_attempt_at ?? null,
|
||||
error: null,
|
||||
created_at: previous?.created_at ?? createdAt,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_sync_outbox SET status = 'syncing'")) {
|
||||
const [lastAttemptAt, updatedAt, id] = args as [string, string, string];
|
||||
const row = this.outbox.get(id);
|
||||
@@ -1349,7 +1615,7 @@ class TestDb {
|
||||
return { changes: before - this.attempts.length };
|
||||
}
|
||||
|
||||
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
|
||||
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'synced'/.test(sql)) {
|
||||
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
||||
const row = this.entityRowsForUpdate(sql).get(id);
|
||||
if (row) {
|
||||
@@ -1361,7 +1627,7 @@ class TestDb {
|
||||
return { changes: row ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (/^UPDATE digital_(plans|plan_steps|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
|
||||
if (/^UPDATE digital_(plans|tasks|plan_steps|runs|schedules|schedule_runs|events|artifacts|approvals|memories) SET sync_status = 'failed'/.test(sql)) {
|
||||
const [syncError, id] = args as [string, string];
|
||||
const row = this.entityRowsForUpdate(sql).get(id);
|
||||
if (row) {
|
||||
@@ -1379,9 +1645,11 @@ class TestDb {
|
||||
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
||||
if (sql.startsWith("UPDATE digital_memories")) return this.memories;
|
||||
if (sql.startsWith("UPDATE digital_events")) return this.events;
|
||||
if (sql.startsWith("UPDATE digital_runs")) return this.runs;
|
||||
if (sql.startsWith("UPDATE digital_schedule_runs")) return this.scheduleRuns;
|
||||
if (sql.startsWith("UPDATE digital_schedules")) return this.schedules;
|
||||
if (sql.startsWith("UPDATE digital_plan_steps")) return this.planSteps;
|
||||
if (sql.startsWith("UPDATE digital_tasks")) return this.tasks;
|
||||
return this.plans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@ import log from "electron-log";
|
||||
import { getDeviceId } from "../system/deviceId";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
|
||||
import {
|
||||
normalizePlanStepDispatchPolicy,
|
||||
readDigitalEmployeeUiState,
|
||||
saveDigitalEmployeeUiState,
|
||||
type DigitalEmployeePlanStepDispatchPolicy,
|
||||
} from "./stateService";
|
||||
|
||||
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
|
||||
const DEFAULT_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_BATCH_SIZE = 50;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const POLICY_PULL_THROTTLE_MS = 60_000;
|
||||
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
||||
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
||||
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
||||
@@ -18,6 +26,8 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let syncing = false;
|
||||
let lastSyncAt: string | null = null;
|
||||
let lastSyncError: string | null = null;
|
||||
let policyPulling = false;
|
||||
let lastPolicyPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -27,11 +37,21 @@ function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
interface DigitalEmployeeSyncConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string | null;
|
||||
policyEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
policy: DigitalEmployeePlanStepDispatchPolicy;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface OutboxRow {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
@@ -168,6 +188,63 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
|
||||
};
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeePlanStepDispatchPolicy(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeePlanStepDispatchPolicyPullResult> {
|
||||
const currentState = readDigitalEmployeeUiState();
|
||||
const currentPolicy = currentState.planStepDispatchPolicy ?? normalizePlanStepDispatchPolicy(null);
|
||||
if (policyPulling) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
||||
}
|
||||
lastPolicyPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.policyEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.policyEndpoint ? "management policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, error);
|
||||
}
|
||||
|
||||
policyPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.policyEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const remotePolicy = readRemotePlanStepDispatchPolicy(response);
|
||||
if (!remotePolicy) throw new Error("management policy response missing plan step dispatch policy");
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policy = normalizePlanStepDispatchPolicy({
|
||||
...remotePolicy,
|
||||
source: "remote",
|
||||
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.remote_updated_at) || null,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: null,
|
||||
});
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
metadata: {
|
||||
source: "management-api",
|
||||
endpoint: config.policyEndpoint,
|
||||
ok: true,
|
||||
plan_step_dispatch_policy: policy,
|
||||
},
|
||||
state: {
|
||||
...currentState,
|
||||
planStepDispatchPolicy: policy,
|
||||
},
|
||||
});
|
||||
return { ok: true, endpoint: config.policyEndpoint, pulled_at: pulledAt, policy, error: null };
|
||||
} catch (error) {
|
||||
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, normalizeError(error));
|
||||
} finally {
|
||||
policyPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushDigitalEmployeeSyncOutbox(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeSyncStatus> {
|
||||
@@ -291,6 +368,13 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? step1.serverHost.trim()
|
||||
: null;
|
||||
const endpoint = endpointOverride || buildEndpoint(serverHost);
|
||||
const policyEndpointOverride =
|
||||
typeof config.planStepDispatchPolicyEndpoint === "string" && config.planStepDispatchPolicyEndpoint.trim()
|
||||
? config.planStepDispatchPolicyEndpoint.trim()
|
||||
: typeof config.policyEndpoint === "string" && config.policyEndpoint.trim()
|
||||
? config.policyEndpoint.trim()
|
||||
: null;
|
||||
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
|
||||
const disabled = config.enabled === false;
|
||||
const batchSize =
|
||||
typeof config.batchSize === "number" && config.batchSize > 0
|
||||
@@ -300,18 +384,19 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
return {
|
||||
enabled: !disabled,
|
||||
endpoint,
|
||||
policyEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
batchSize,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEndpoint(serverHost: string | null): string | null {
|
||||
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
||||
if (!serverHost) return null;
|
||||
const normalized = serverHost.startsWith("http")
|
||||
? serverHost
|
||||
: `https://${serverHost}`;
|
||||
return `${normalized.replace(/\/+$/, "")}${DEFAULT_SYNC_PATH}`;
|
||||
return `${normalized.replace(/\/+$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function missingSyncCredentialLabels(
|
||||
@@ -385,35 +470,85 @@ async function postOutbox(
|
||||
config: DigitalEmployeeSyncConfig,
|
||||
rows: OutboxRow[],
|
||||
): Promise<SyncResponse> {
|
||||
return fetchJsonWithAuth(config, config.endpoint!, {
|
||||
client_key: config.clientKey,
|
||||
device_id: getDeviceId(),
|
||||
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
||||
items: rows.map(buildSyncItem),
|
||||
}) as Promise<SyncResponse>;
|
||||
}
|
||||
|
||||
async function fetchJsonWithAuth(
|
||||
config: DigitalEmployeeSyncConfig,
|
||||
endpoint: string,
|
||||
requestBody?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(config.endpoint!, {
|
||||
method: "POST",
|
||||
const response = await fetch(endpoint, {
|
||||
method: requestBody ? "POST" : "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.authToken}`,
|
||||
"X-Qiming-Client-Key": config.clientKey!,
|
||||
"X-Qiming-Device-Id": getDeviceId(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_key: config.clientKey,
|
||||
device_id: getDeviceId(),
|
||||
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
||||
items: rows.map(buildSyncItem),
|
||||
}),
|
||||
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const body = text ? (JSON.parse(text) as SyncResponse) : {};
|
||||
const responseBody = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || `HTTP ${response.status}`);
|
||||
throw new Error(stringField(responseBody.message) || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
return responseBody;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
return objectRecord(data.plan_step_dispatch_policy)
|
||||
?? objectRecord(data.planStepDispatchPolicy)
|
||||
?? objectRecord(data.policy)
|
||||
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
|
||||
}
|
||||
|
||||
function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boolean {
|
||||
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
|
||||
}
|
||||
|
||||
function recordPlanStepDispatchPolicyPullFailure(
|
||||
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
||||
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
|
||||
endpoint: string | null,
|
||||
error: string,
|
||||
): DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policy = normalizePlanStepDispatchPolicy({
|
||||
...currentPolicy,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: error,
|
||||
});
|
||||
saveDigitalEmployeeUiState({
|
||||
action: "pull_plan_step_dispatch_policy",
|
||||
metadata: {
|
||||
source: "management-api",
|
||||
endpoint,
|
||||
ok: false,
|
||||
error,
|
||||
plan_step_dispatch_policy: policy,
|
||||
},
|
||||
state: {
|
||||
...state,
|
||||
planStepDispatchPolicy: policy,
|
||||
},
|
||||
});
|
||||
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
@@ -753,10 +888,16 @@ function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): R
|
||||
?? {};
|
||||
return {
|
||||
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
|
||||
source: stringField(metadata.source ?? policy.source) || null,
|
||||
pull_ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
||||
pull_error: stringField(metadata.error ?? policy.last_pull_error) || null,
|
||||
pull_endpoint: stringField(metadata.endpoint) || null,
|
||||
policy_enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
|
||||
max_per_sweep: numberField(policy.max_per_sweep),
|
||||
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : null,
|
||||
updated_at: stringField(policy.updated_at) || null,
|
||||
remote_updated_at: stringField(policy.remote_updated_at) || null,
|
||||
last_pulled_at: stringField(policy.last_pulled_at) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -820,7 +961,8 @@ function isPlanStepDependencyEvent(kind: string): boolean {
|
||||
}
|
||||
|
||||
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
||||
return kind === "digital_workday_update_plan_step_dispatch_policy";
|
||||
return kind === "digital_workday_update_plan_step_dispatch_policy"
|
||||
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
|
||||
}
|
||||
|
||||
function isPlanStepLeaseEvent(kind: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user