吸收数字员工多步审批跨端回写

This commit is contained in:
baiyanyun
2026-06-11 10:44:12 +08:00
parent e9c76c59ca
commit 43418d0453
16 changed files with 1240 additions and 200 deletions

View File

@@ -7,6 +7,8 @@ import {
normalizeRiskApprovalPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
upsertDigitalEmployeeApprovalFromRemote,
type DigitalEmployeeApprovalUpdateInput,
type DigitalEmployeePlanStepDispatchPolicy,
type DigitalEmployeeRiskApprovalPolicy,
} from "./stateService";
@@ -21,6 +23,8 @@ const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-approval";
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
@@ -43,6 +47,8 @@ let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
let riskApprovalPolicyPulling = false;
let lastRiskApprovalPolicyPullAttemptMs = 0;
let approvalUpdatesPulling = false;
let lastApprovalUpdatesPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null;
@@ -55,6 +61,8 @@ interface DigitalEmployeeSyncConfig {
policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
riskApprovalPolicyEndpoint: string | null;
approvalUpdatesEndpoint: string | null;
approvalDecisionsEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
@@ -88,6 +96,23 @@ export interface DigitalEmployeeRiskApprovalPolicyPullResult {
error?: string | null;
}
export interface DigitalEmployeeApprovalUpdatesPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
applied: number;
ignored: number;
error?: string | null;
}
export interface DigitalEmployeeApprovalDecisionSubmitResult {
ok: boolean;
endpoint: string | null;
submitted_at: string | null;
error?: string | null;
}
export interface DigitalEmployeePlanStepRemoteLease {
lease_id: string;
state: "active" | "released" | "expired";
@@ -440,6 +465,121 @@ export async function pullDigitalEmployeeRiskApprovalPolicy(
}
}
export async function pullDigitalEmployeeApprovalUpdates(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeApprovalUpdatesPullResult> {
if (approvalUpdatesPulling) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
}
const nowMs = Date.now();
if (!options.force && nowMs - lastApprovalUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
}
lastApprovalUpdatesPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.approvalUpdatesEndpoint || missingCredentials.length > 0) {
return {
ok: false,
endpoint: config.approvalUpdatesEndpoint,
pulled_at: new Date().toISOString(),
applied: 0,
ignored: 0,
error: !config.approvalUpdatesEndpoint ? "management approval updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
};
}
approvalUpdatesPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.approvalUpdatesEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const updates = readRemoteApprovalUpdates(response);
const pulledAt = new Date().toISOString();
let applied = 0;
let ignored = 0;
for (const update of updates) {
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
if (result.ok && !result.skipped) applied += 1;
else ignored += 1;
}
recordApprovalSyncEvent("approval_updates_pulled", pulledAt, {
action: "pull_approval_updates",
endpoint: config.approvalUpdatesEndpoint,
ok: true,
applied,
ignored,
source: "management-api",
});
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
} catch (error) {
const pulledAt = new Date().toISOString();
const message = normalizeError(error);
recordApprovalSyncEvent("approval_updates_pull_failed", pulledAt, {
action: "pull_approval_updates",
endpoint: config.approvalUpdatesEndpoint,
ok: false,
error: message,
source: "management-api",
});
return { ok: false, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
} finally {
approvalUpdatesPulling = false;
}
}
export async function submitDigitalEmployeeApprovalDecision(
input: Record<string, unknown>,
): Promise<DigitalEmployeeApprovalDecisionSubmitResult> {
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
const submittedAt = new Date().toISOString();
if (!config.approvalDecisionsEndpoint || missingCredentials.length > 0) {
const error = !config.approvalDecisionsEndpoint ? "management approval decisions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
action: "submit_approval_decision",
ok: false,
endpoint: config.approvalDecisionsEndpoint,
error,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error };
}
try {
const response = await fetchJsonWithAuth(config, config.approvalDecisionsEndpoint, {
...input,
submitted_at: submittedAt,
device_id: getDeviceId(),
});
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
recordApprovalSyncEvent("approval_decision_submitted", submittedAt, {
action: "submit_approval_decision",
ok: true,
endpoint: config.approvalDecisionsEndpoint,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: true, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: null };
} catch (error) {
const message = normalizeError(error);
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
action: "submit_approval_decision",
ok: false,
endpoint: config.approvalDecisionsEndpoint,
error: message,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: message };
}
}
export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -654,6 +794,16 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.riskApprovalPolicyEndpoint.trim()
: null;
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
const approvalUpdatesEndpointOverride =
typeof config.approvalUpdatesEndpoint === "string" && config.approvalUpdatesEndpoint.trim()
? config.approvalUpdatesEndpoint.trim()
: null;
const approvalUpdatesEndpoint = approvalUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_UPDATES_PATH);
const approvalDecisionsEndpointOverride =
typeof config.approvalDecisionsEndpoint === "string" && config.approvalDecisionsEndpoint.trim()
? config.approvalDecisionsEndpoint.trim()
: null;
const approvalDecisionsEndpoint = approvalDecisionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_DECISIONS_PATH);
const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim()
@@ -671,6 +821,8 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
policyEndpoint,
skillPolicyEndpoint,
riskApprovalPolicyEndpoint,
approvalUpdatesEndpoint,
approvalDecisionsEndpoint,
leaseEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
@@ -832,6 +984,34 @@ function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record
?? (hasRiskApprovalPolicyFields(data) ? data : null);
}
function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEmployeeApprovalUpdateInput[] {
const data = objectRecord(response.data) ?? response;
const raw = Array.isArray(data.approvals)
? data.approvals
: Array.isArray(data.updates)
? data.updates
: Array.isArray(data.items)
? data.items
: [];
return raw
.map((item) => objectRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item) => ({
approvalId: stringField(item.approval_id ?? item.approvalId ?? item.id) || null,
remoteId: stringField(item.remote_id ?? item.remoteId) || null,
planId: stringField(item.plan_id ?? item.planId) || null,
taskId: stringField(item.task_id ?? item.taskId) || null,
runId: stringField(item.run_id ?? item.runId) || null,
title: stringField(item.title) || null,
status: stringField(item.status) || null,
payload: objectRecord(item.payload) ?? item,
updatedAt: stringField(item.updated_at ?? item.updatedAt ?? item.remote_updated_at ?? item.remoteUpdatedAt) || null,
createdAt: stringField(item.created_at ?? item.createdAt) || null,
source: stringField(item.source) || "management-api",
}))
.filter((item) => Boolean(item.approvalId || item.remoteId));
}
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
@@ -989,6 +1169,15 @@ function recordRiskApprovalPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
const state = readDigitalEmployeeUiState();
saveDigitalEmployeeUiState({
action,
metadata: { ...metadata, occurred_at: occurredAt },
state,
});
}
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id);
@@ -1177,6 +1366,7 @@ function eventSpecificBusinessView(
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
if (isApprovalSubscriptionEvent(kind)) return approvalSubscriptionBusinessView(payload);
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind === "artifact_version_observed") {
return artifactEventBusinessView(payload);
@@ -1214,11 +1404,17 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
const delegate = objectRecord(payload.delegate_summary);
const sla = objectRecord(payload.sla_summary);
const risk = objectRecord(payload.risk_summary);
const workflow = objectRecord(payload.workflow) ?? {};
return {
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
action: stringField(payload.action) || null,
status: stringField(payload.status) || null,
actor: stringField(payload.actor) || null,
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
step_id: stringField(payload.step_id ?? payload.stepId) || null,
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
risk_level: stringField(risk?.risk_level ?? payload.risk_level) || null,
due_at: stringField(sla?.due_at ?? payload.due_at) || null,
delegate_to: stringField(delegate?.target ?? delegate?.label ?? payload.delegate_to) || null,
@@ -1227,12 +1423,20 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
}
function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const workflow = objectRecord(payload.workflow) ?? {};
return {
approval_id: stringField(payload.approvalId ?? payload.approval_id) || null,
action: "decision",
decision: stringField(payload.decision) || null,
previous_status: stringField(payload.previousStatus ?? payload.previous_status) || null,
next_status: stringField(payload.nextStatus ?? payload.next_status) || null,
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
step_id: stringField(payload.step_id ?? payload.stepId) || null,
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
current_step_id: stringField(payload.current_step_id ?? payload.currentStepId) || null,
next_step_id: stringField(payload.next_step_id ?? payload.nextStepId) || null,
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
actor: stringField(payload.actor ?? payload.source) || null,
decided_at: stringField(payload.decidedAt ?? payload.decided_at) || null,
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
@@ -1241,6 +1445,21 @@ function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<
};
}
function approvalSyncBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? payload;
return {
action: stringField(metadata.action ?? payload.action) || null,
approval_id: stringField(metadata.approval_id ?? metadata.approvalId) || null,
decision: stringField(metadata.decision) || null,
source: stringField(metadata.source) || null,
endpoint: stringField(metadata.endpoint) || null,
ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
applied: numberField(metadata.applied),
ignored: numberField(metadata.ignored),
error: stringField(metadata.error) || null,
};
}
function approvalSubscriptionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
@@ -1410,6 +1629,7 @@ function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSyncEvent(kind)) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";
@@ -1426,6 +1646,13 @@ function isApprovalSubscriptionEvent(kind: string): boolean {
return kind === "digital_workday_update_approval_subscriptions";
}
function isApprovalSyncEvent(kind: string): boolean {
return kind === "digital_workday_approval_updates_pulled"
|| kind === "digital_workday_approval_updates_pull_failed"
|| kind === "digital_workday_approval_decision_submitted"
|| kind === "digital_workday_approval_decision_submit_failed";
}
function isPlanStepDependencyEvent(kind: string): boolean {
return kind === "plan_step_ready" || kind === "plan_step_blocked" || kind === "plan_step_waiting";
}