吸收数字员工PlanStep远端策略拉取
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user