吸收数字员工多步审批跨端回写
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user