|
|
|
|
@@ -6,7 +6,12 @@ import {
|
|
|
|
|
normalizePlanStepDispatchPolicy,
|
|
|
|
|
normalizeRiskApprovalPolicy,
|
|
|
|
|
normalizeRouteDecisionPolicy,
|
|
|
|
|
recordDigitalEmployeeArtifactLifecycle,
|
|
|
|
|
recordDigitalEmployeeDailyReportDelivery,
|
|
|
|
|
recordDigitalEmployeeRouteInterventionResolution,
|
|
|
|
|
recordDigitalEmployeeRuntimeEvent,
|
|
|
|
|
readDigitalEmployeeUiState,
|
|
|
|
|
resolveDigitalEmployeeApprovalConflict,
|
|
|
|
|
saveDigitalEmployeeUiState,
|
|
|
|
|
upsertDigitalEmployeeApprovalFromRemote,
|
|
|
|
|
type DigitalEmployeeApprovalUpdateInput,
|
|
|
|
|
@@ -30,6 +35,7 @@ const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
|
|
|
|
|
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
|
|
|
|
|
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
|
|
|
|
|
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
|
|
|
|
|
const DEFAULT_ADMIN_ACTIONS_PATH = "/api/digital-employee/admin/actions";
|
|
|
|
|
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";
|
|
|
|
|
@@ -41,6 +47,7 @@ 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;
|
|
|
|
|
const HIGH_RISK_ADMIN_ACTIONS = new Set(["accept_remote_approval", "cleanup_file_batch", "start_service", "stop_service", "hard_interrupt"]);
|
|
|
|
|
|
|
|
|
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
let syncing = false;
|
|
|
|
|
@@ -75,6 +82,7 @@ interface DigitalEmployeeSyncConfig {
|
|
|
|
|
approvalDecisionsEndpoint: string | null;
|
|
|
|
|
notificationUpdatesEndpoint: string | null;
|
|
|
|
|
notificationActionsEndpoint: string | null;
|
|
|
|
|
adminActionsEndpoint: string | null;
|
|
|
|
|
leaseEndpoint: string | null;
|
|
|
|
|
clientKey: string | null;
|
|
|
|
|
authToken: string | null;
|
|
|
|
|
@@ -152,6 +160,26 @@ export interface DigitalEmployeeNotificationActionSubmitResult {
|
|
|
|
|
error?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DigitalEmployeeAdminActionPullResult {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
skipped?: boolean;
|
|
|
|
|
endpoint: string | null;
|
|
|
|
|
pulled_at: string | null;
|
|
|
|
|
applied: number;
|
|
|
|
|
rejected: number;
|
|
|
|
|
failed: number;
|
|
|
|
|
ignored: number;
|
|
|
|
|
error?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DigitalEmployeeAdminActionItem {
|
|
|
|
|
id: string;
|
|
|
|
|
entityType: string;
|
|
|
|
|
entityId: string;
|
|
|
|
|
action: string;
|
|
|
|
|
requestPayload: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DigitalEmployeePlanStepRemoteLease {
|
|
|
|
|
lease_id: string;
|
|
|
|
|
state: "active" | "released" | "expired";
|
|
|
|
|
@@ -827,6 +855,64 @@ export async function submitDigitalEmployeeNotificationAction(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function pullAndApplyDigitalEmployeeAdminActions(
|
|
|
|
|
options: { force?: boolean } = {},
|
|
|
|
|
): Promise<DigitalEmployeeAdminActionPullResult> {
|
|
|
|
|
const config = resolveSyncConfig();
|
|
|
|
|
const endpoint = buildAdminActionsPendingEndpoint(config.adminActionsEndpoint);
|
|
|
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
|
|
|
const pulledAt = new Date().toISOString();
|
|
|
|
|
if (!endpoint || missingCredentials.length > 0) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
endpoint,
|
|
|
|
|
pulled_at: pulledAt,
|
|
|
|
|
applied: 0,
|
|
|
|
|
rejected: 0,
|
|
|
|
|
failed: 0,
|
|
|
|
|
ignored: 0,
|
|
|
|
|
error: !endpoint ? "management admin actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetchJsonWithAuth(config, endpoint);
|
|
|
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
|
|
|
if (responseError) throw new Error(responseError);
|
|
|
|
|
const actions = readRemoteAdminActions(response);
|
|
|
|
|
let applied = 0;
|
|
|
|
|
let rejected = 0;
|
|
|
|
|
let failed = 0;
|
|
|
|
|
let ignored = 0;
|
|
|
|
|
for (const action of actions) {
|
|
|
|
|
if (!action.id) {
|
|
|
|
|
ignored += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const result = await applyDigitalEmployeeAdminAction(action);
|
|
|
|
|
if (result.status === "applied") applied += 1;
|
|
|
|
|
else if (result.status === "rejected") rejected += 1;
|
|
|
|
|
else failed += 1;
|
|
|
|
|
await postDigitalEmployeeAdminActionResult(config, action.id, result.status, result.reasonCodes);
|
|
|
|
|
}
|
|
|
|
|
recordDigitalEmployeeRuntimeEvent({
|
|
|
|
|
kind: "admin_actions_pulled",
|
|
|
|
|
status: "completed",
|
|
|
|
|
message: "管理端远程运营动作已拉取",
|
|
|
|
|
payload: { action: "pull_admin_actions", endpoint, applied, rejected, failed, ignored, source: "management-api" },
|
|
|
|
|
});
|
|
|
|
|
return { ok: true, endpoint, pulled_at: pulledAt, applied, rejected, failed, ignored, error: null };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = normalizeError(error);
|
|
|
|
|
recordDigitalEmployeeRuntimeEvent({
|
|
|
|
|
kind: "admin_action_failed",
|
|
|
|
|
status: "failed",
|
|
|
|
|
message: "管理端远程运营动作拉取失败",
|
|
|
|
|
payload: { action: "pull_admin_actions", endpoint, error: message, source: "management-api" },
|
|
|
|
|
});
|
|
|
|
|
return { ok: false, endpoint, pulled_at: pulledAt, applied: 0, rejected: 0, failed: 0, ignored: 0, error: message };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
|
|
|
|
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
|
|
|
|
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
|
|
|
|
@@ -1066,6 +1152,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|
|
|
|
? config.notificationActionsEndpoint.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
|
|
|
|
const adminActionsEndpointOverride =
|
|
|
|
|
typeof config.adminActionsEndpoint === "string" && config.adminActionsEndpoint.trim()
|
|
|
|
|
? config.adminActionsEndpoint.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const adminActionsEndpoint = adminActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_ADMIN_ACTIONS_PATH);
|
|
|
|
|
const leaseEndpointOverride =
|
|
|
|
|
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
|
|
|
|
? config.planStepDispatchLeaseEndpoint.trim()
|
|
|
|
|
@@ -1088,6 +1179,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|
|
|
|
approvalDecisionsEndpoint,
|
|
|
|
|
notificationUpdatesEndpoint,
|
|
|
|
|
notificationActionsEndpoint,
|
|
|
|
|
adminActionsEndpoint,
|
|
|
|
|
leaseEndpoint,
|
|
|
|
|
clientKey: readClientKey(serverHost),
|
|
|
|
|
authToken: readAuthToken(serverHost),
|
|
|
|
|
@@ -1100,6 +1192,17 @@ function buildLeaseOperationEndpoint(baseEndpoint: string | null, operation: "ac
|
|
|
|
|
return `${baseEndpoint.replace(/\/+$/, "")}/${operation}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAdminActionsPendingEndpoint(baseEndpoint: string | null): string | null {
|
|
|
|
|
if (!baseEndpoint) return null;
|
|
|
|
|
const params = new URLSearchParams({ device_id: getDeviceId() });
|
|
|
|
|
return `${baseEndpoint.replace(/\/+$/, "")}/pending?${params.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAdminActionResultEndpoint(baseEndpoint: string | null, actionId: string): string | null {
|
|
|
|
|
if (!baseEndpoint) return null;
|
|
|
|
|
return `${baseEndpoint.replace(/\/+$/, "")}/${encodeURIComponent(actionId)}/result`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
|
|
|
|
if (!serverHost) return null;
|
|
|
|
|
const normalized = serverHost.startsWith("http")
|
|
|
|
|
@@ -1320,6 +1423,145 @@ function readRemoteNotificationUpdates(response: Record<string, unknown>): Array
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readRemoteAdminActions(response: Record<string, unknown>): DigitalEmployeeAdminActionItem[] {
|
|
|
|
|
const data = objectRecord(response.data) ?? response;
|
|
|
|
|
const raw = Array.isArray(data.records)
|
|
|
|
|
? data.records
|
|
|
|
|
: Array.isArray(data.actions)
|
|
|
|
|
? data.actions
|
|
|
|
|
: Array.isArray(data.items)
|
|
|
|
|
? data.items
|
|
|
|
|
: [];
|
|
|
|
|
return raw
|
|
|
|
|
.map((item) => objectRecord(item))
|
|
|
|
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
id: stringField(item.id) || numericIdField(item.id),
|
|
|
|
|
entityType: normalizeAdminActionText(item.entity_type ?? item.entityType),
|
|
|
|
|
entityId: stringField(item.entity_id ?? item.entityId) || "",
|
|
|
|
|
action: normalizeAdminActionText(item.action),
|
|
|
|
|
requestPayload: objectRecord(item.request_payload ?? item.requestPayload) ?? {},
|
|
|
|
|
}))
|
|
|
|
|
.filter((item) => Boolean(item.id && item.entityType && item.entityId && item.action));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyDigitalEmployeeAdminAction(action: DigitalEmployeeAdminActionItem): Promise<{ status: "applied" | "rejected" | "failed"; reasonCodes: string[] }> {
|
|
|
|
|
try {
|
|
|
|
|
if (!isAllowedAdminAction(action)) {
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "rejected", ["admin_action_rejected", "unsupported_or_high_risk_action"]);
|
|
|
|
|
}
|
|
|
|
|
const payload = action.requestPayload;
|
|
|
|
|
if (action.entityType === "approval") {
|
|
|
|
|
const result = resolveDigitalEmployeeApprovalConflict({
|
|
|
|
|
approvalId: action.entityId,
|
|
|
|
|
resolution: action.action,
|
|
|
|
|
actor: "management-admin-action",
|
|
|
|
|
comment: stringField(payload.reason ?? payload.comment) || null,
|
|
|
|
|
});
|
|
|
|
|
const ok = result?.status !== "rejected";
|
|
|
|
|
return recordAdminActionRuntimeResult(action, ok ? "applied" : "rejected", ok ? ["admin_action_applied"] : ["admin_action_rejected", ...(result?.reason_codes ?? ["approval_action_rejected"])]);
|
|
|
|
|
}
|
|
|
|
|
if (action.entityType === "artifact") {
|
|
|
|
|
const artifactAction = action.action === "mark_reviewed" ? "mark_review" : action.action;
|
|
|
|
|
recordDigitalEmployeeArtifactLifecycle({
|
|
|
|
|
artifactId: action.entityId,
|
|
|
|
|
action: artifactAction,
|
|
|
|
|
actor: "management-admin-action",
|
|
|
|
|
reason: stringField(payload.reason) || "admin_action",
|
|
|
|
|
retentionUntil: stringField(payload.retention_until ?? payload.retentionUntil) || null,
|
|
|
|
|
source: "management-admin-action",
|
|
|
|
|
});
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
|
|
|
|
}
|
|
|
|
|
if (action.entityType === "daily_report") {
|
|
|
|
|
const deliveryAction = action.action === "mark_confirmed" ? "confirm" : action.action === "request_approval" ? "request_approval" : "send";
|
|
|
|
|
recordDigitalEmployeeDailyReportDelivery({
|
|
|
|
|
reportId: action.entityId,
|
|
|
|
|
action: deliveryAction,
|
|
|
|
|
status: action.action.replace(/^mark_/, ""),
|
|
|
|
|
actor: "management-admin-action",
|
|
|
|
|
message: stringField(payload.message ?? payload.reason) || null,
|
|
|
|
|
});
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
|
|
|
|
}
|
|
|
|
|
if (action.entityType === "route" || action.entityType === "route_decision") {
|
|
|
|
|
recordDigitalEmployeeRouteInterventionResolution({
|
|
|
|
|
routeDecisionId: action.entityId,
|
|
|
|
|
decision: action.action,
|
|
|
|
|
actor: "management-admin-action",
|
|
|
|
|
reasonCodes: ["admin_action_applied", action.action],
|
|
|
|
|
commandAccepted: false,
|
|
|
|
|
});
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
|
|
|
|
}
|
|
|
|
|
if (action.entityType === "notification") {
|
|
|
|
|
const currentState = readDigitalEmployeeUiState();
|
|
|
|
|
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
|
|
|
|
|
const current = objectRecord(notificationStateById[action.entityId]) ?? {};
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
notificationStateById[action.entityId] = {
|
|
|
|
|
...current,
|
|
|
|
|
status: action.action === "dismiss" ? "dismissed" : "read",
|
|
|
|
|
read_at: action.action === "mark_read" ? now : current.read_at,
|
|
|
|
|
dismissed_at: action.action === "dismiss" ? now : current.dismissed_at,
|
|
|
|
|
source: "management-admin-action",
|
|
|
|
|
};
|
|
|
|
|
saveDigitalEmployeeUiState({ action: "admin_notification_action", metadata: { admin_action_id: action.id, notification_id: action.entityId }, state: { ...currentState, notificationStateById } });
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
|
|
|
|
|
}
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied", "audit_overlay_recorded"]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return recordAdminActionRuntimeResult(action, "failed", ["admin_action_failed", normalizeError(error)]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAllowedAdminAction(action: DigitalEmployeeAdminActionItem): boolean {
|
|
|
|
|
if (HIGH_RISK_ADMIN_ACTIONS.has(action.action)) return false;
|
|
|
|
|
return new Set([
|
|
|
|
|
"approval:keep_local", "approval:mark_reviewed",
|
|
|
|
|
"route:mark_reviewed", "route:dismiss", "route:retry_policy_check",
|
|
|
|
|
"route_decision:mark_reviewed", "route_decision:dismiss", "route_decision:retry_policy_check",
|
|
|
|
|
"notification:mark_read", "notification:dismiss",
|
|
|
|
|
"artifact:mark_keep", "artifact:mark_expire", "artifact:mark_reviewed",
|
|
|
|
|
"daily_report:mark_delivered", "daily_report:mark_confirmed", "daily_report:request_approval",
|
|
|
|
|
"audit:mark_reviewed", "audit:dismiss",
|
|
|
|
|
"event:mark_reviewed", "event:dismiss",
|
|
|
|
|
]).has(`${action.entityType}:${action.action}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordAdminActionRuntimeResult(
|
|
|
|
|
action: DigitalEmployeeAdminActionItem,
|
|
|
|
|
status: "applied" | "rejected" | "failed",
|
|
|
|
|
reasonCodes: string[],
|
|
|
|
|
): { status: "applied" | "rejected" | "failed"; reasonCodes: string[] } {
|
|
|
|
|
recordDigitalEmployeeRuntimeEvent({
|
|
|
|
|
kind: status === "applied" ? "admin_action_applied" : status === "rejected" ? "admin_action_rejected" : "admin_action_failed",
|
|
|
|
|
status: status === "applied" ? "completed" : "failed",
|
|
|
|
|
message: `管理端动作${status}: ${action.action}`,
|
|
|
|
|
payload: {
|
|
|
|
|
admin_action_id: action.id,
|
|
|
|
|
entity_type: action.entityType,
|
|
|
|
|
entity_id: action.entityId,
|
|
|
|
|
action: action.action,
|
|
|
|
|
status,
|
|
|
|
|
reason_codes: reasonCodes,
|
|
|
|
|
source: "management-admin-action",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return { status, reasonCodes };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function postDigitalEmployeeAdminActionResult(
|
|
|
|
|
config: DigitalEmployeeSyncConfig,
|
|
|
|
|
actionId: string,
|
|
|
|
|
status: "applied" | "rejected" | "failed",
|
|
|
|
|
reasonCodes: string[],
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const endpoint = buildAdminActionResultEndpoint(config.adminActionsEndpoint, actionId);
|
|
|
|
|
if (!endpoint) return;
|
|
|
|
|
await fetchJsonWithAuth(config, endpoint, { status, reason_codes: reasonCodes });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
@@ -2489,6 +2731,14 @@ function stringField(value: unknown): string {
|
|
|
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeAdminActionText(value: unknown): string {
|
|
|
|
|
return stringField(value).toLowerCase().replace(/-/g, "_");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numericIdField(value: unknown): string {
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringArrayField(value: unknown): string[] {
|
|
|
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
|
return value.map(stringField).filter(Boolean);
|
|
|
|
|
|