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

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