2510 lines
101 KiB
TypeScript
2510 lines
101 KiB
TypeScript
import log from "electron-log";
|
|
import { getDeviceId } from "../system/deviceId";
|
|
import { getDomainTokenKey } from "@shared/utils/domain";
|
|
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
|
|
import {
|
|
normalizePlanStepDispatchPolicy,
|
|
normalizeRiskApprovalPolicy,
|
|
normalizeRouteDecisionPolicy,
|
|
readDigitalEmployeeUiState,
|
|
saveDigitalEmployeeUiState,
|
|
upsertDigitalEmployeeApprovalFromRemote,
|
|
type DigitalEmployeeApprovalUpdateInput,
|
|
type DigitalEmployeePlanStepDispatchPolicy,
|
|
type DigitalEmployeeRiskApprovalPolicy,
|
|
type DigitalEmployeeRouteDecisionPolicy,
|
|
} from "./stateService";
|
|
import {
|
|
SKILL_POLICIES_SETTING_KEY,
|
|
normalizeDigitalEmployeeSkillPolicies,
|
|
readDigitalEmployeeSkillPolicies,
|
|
type DigitalEmployeeSkillPolicies,
|
|
} from "./skillExecutionPolicy";
|
|
|
|
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_ROUTE_DECISION_POLICY_PATH = "/api/digital-employee/policies/route-decision";
|
|
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_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
|
const MANAGEMENT_SYNC_RECORD_PATH =
|
|
"/system/content/content-digital-employee-sync";
|
|
const DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION = "qimingclaw.digital_employee.sync.v1";
|
|
const DEFAULT_INTERVAL_MS = 30_000;
|
|
const DEFAULT_BATCH_SIZE = 50;
|
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
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;
|
|
|
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
let syncing = false;
|
|
let lastSyncAt: string | null = null;
|
|
let lastSyncError: string | null = null;
|
|
let policyPulling = false;
|
|
let lastPolicyPullAttemptMs = 0;
|
|
let skillPolicyPulling = false;
|
|
let lastSkillPolicyPullAttemptMs = 0;
|
|
let riskApprovalPolicyPulling = false;
|
|
let lastRiskApprovalPolicyPullAttemptMs = 0;
|
|
let routeDecisionPolicyPulling = false;
|
|
let lastRouteDecisionPolicyPullAttemptMs = 0;
|
|
let approvalUpdatesPulling = false;
|
|
let lastApprovalUpdatesPullAttemptMs = 0;
|
|
let notificationUpdatesPulling = false;
|
|
let lastNotificationUpdatesPullAttemptMs = 0;
|
|
|
|
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
|
if (!ensureDigitalEmployeeSchema()) return null;
|
|
return getDb();
|
|
}
|
|
|
|
interface DigitalEmployeeSyncConfig {
|
|
enabled: boolean;
|
|
endpoint: string | null;
|
|
policyEndpoint: string | null;
|
|
skillPolicyEndpoint: string | null;
|
|
riskApprovalPolicyEndpoint: string | null;
|
|
routeDecisionPolicyEndpoint: string | null;
|
|
approvalUpdatesEndpoint: string | null;
|
|
approvalDecisionsEndpoint: string | null;
|
|
notificationUpdatesEndpoint: string | null;
|
|
notificationActionsEndpoint: string | null;
|
|
leaseEndpoint: string | null;
|
|
clientKey: string | null;
|
|
authToken: string | null;
|
|
batchSize: number;
|
|
}
|
|
|
|
export interface DigitalEmployeeSkillPolicyPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
policies: DigitalEmployeeSkillPolicies;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
policy: DigitalEmployeePlanStepDispatchPolicy;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeRiskApprovalPolicyPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
policy: DigitalEmployeeRiskApprovalPolicy;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeRouteDecisionPolicyPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
policy: DigitalEmployeeRouteDecisionPolicy;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeApprovalUpdatesPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
applied: number;
|
|
ignored: number;
|
|
conflicted?: number;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeApprovalDecisionSubmitResult {
|
|
ok: boolean;
|
|
endpoint: string | null;
|
|
submitted_at: string | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeNotificationUpdatesPullResult {
|
|
ok: boolean;
|
|
skipped?: boolean;
|
|
endpoint: string | null;
|
|
pulled_at: string | null;
|
|
applied: number;
|
|
ignored: number;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeNotificationActionSubmitResult {
|
|
ok: boolean;
|
|
endpoint: string | null;
|
|
submitted_at: string | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeePlanStepRemoteLease {
|
|
lease_id: string;
|
|
state: "active" | "released" | "expired";
|
|
owner_device_id: string;
|
|
acquired_at: string;
|
|
expires_at: string;
|
|
released_at?: string | null;
|
|
release_reason?: string | null;
|
|
remote_lease_id?: string | null;
|
|
remote_owner_device_id?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeePlanStepRemoteLeaseResult {
|
|
ok: boolean;
|
|
rejected?: boolean;
|
|
fallback?: boolean;
|
|
endpoint: string | null;
|
|
lease?: DigitalEmployeePlanStepRemoteLease | null;
|
|
reason?: string | null;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeePlanStepRemoteLeaseAcquireInput {
|
|
planId?: string | null;
|
|
stepId: string;
|
|
taskId?: string | null;
|
|
triggerSource?: string | null;
|
|
ttlMs?: number | null;
|
|
occurredAt?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeePlanStepRemoteLeaseReleaseInput {
|
|
planId?: string | null;
|
|
stepId: string;
|
|
taskId?: string | null;
|
|
runId?: string | null;
|
|
leaseId?: string | null;
|
|
remoteLeaseId?: string | null;
|
|
reason?: string | null;
|
|
occurredAt?: string | null;
|
|
}
|
|
|
|
interface OutboxRow {
|
|
id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
operation: string;
|
|
payload: string;
|
|
attempts: number;
|
|
last_attempt_at?: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncAttempt {
|
|
attemptNo: number;
|
|
status: string;
|
|
error: string | null;
|
|
endpoint: string | null;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncFailureGroup {
|
|
entityType: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncFailureSummary {
|
|
total: number;
|
|
dueForRetry: number;
|
|
backoff: number;
|
|
byEntityType: DigitalEmployeeSyncFailureGroup[];
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncFailure {
|
|
id: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
entitySummary: DigitalEmployeeSyncEntitySummary | null;
|
|
operation: string;
|
|
attempts: number;
|
|
lastAttemptAt: string | null;
|
|
nextRetryAt: string | null;
|
|
dueForRetry: boolean;
|
|
managementRecordUrl: string | null;
|
|
attemptHistory: DigitalEmployeeSyncAttempt[];
|
|
error: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncEntitySummary {
|
|
title: string | null;
|
|
status: string | null;
|
|
summary: string | null;
|
|
}
|
|
|
|
interface SyncAck {
|
|
outbox_id?: string;
|
|
id?: string;
|
|
entity_type?: string;
|
|
entity_id?: string;
|
|
remote_id?: string;
|
|
}
|
|
|
|
interface SyncResponse {
|
|
success?: boolean;
|
|
code?: string | number;
|
|
status?: string | number;
|
|
acked?: SyncAck[];
|
|
data?: {
|
|
acked?: SyncAck[];
|
|
};
|
|
message?: string;
|
|
}
|
|
|
|
interface EventBusinessDetail {
|
|
plan_id: string | null;
|
|
task_id: string | null;
|
|
run_id: string | null;
|
|
kind: string | null;
|
|
message: string | null;
|
|
payload: Record<string, unknown>;
|
|
occurred_at: string | null;
|
|
}
|
|
|
|
export interface DigitalEmployeeSyncStatus {
|
|
running: boolean;
|
|
syncing: boolean;
|
|
enabled: boolean;
|
|
endpoint: string | null;
|
|
missingCredentials: string[];
|
|
pending: number;
|
|
failed: number;
|
|
lastSyncAt: string | null;
|
|
lastSyncError: string | null;
|
|
failureSummary: DigitalEmployeeSyncFailureSummary;
|
|
recentFailures: DigitalEmployeeSyncFailure[];
|
|
}
|
|
|
|
export function startDigitalEmployeeSyncWorker(): void {
|
|
if (syncTimer) return;
|
|
syncTimer = setInterval(() => {
|
|
void flushDigitalEmployeeSyncOutbox().catch((error) => {
|
|
lastSyncError = normalizeError(error);
|
|
log.warn("[DigitalEmployeeSync] Scheduled sync failed:", error);
|
|
});
|
|
}, DEFAULT_INTERVAL_MS);
|
|
void flushDigitalEmployeeSyncOutbox().catch((error) => {
|
|
lastSyncError = normalizeError(error);
|
|
log.debug("[DigitalEmployeeSync] Initial sync skipped/failed:", error);
|
|
});
|
|
log.info("[DigitalEmployeeSync] Worker started");
|
|
}
|
|
|
|
export function stopDigitalEmployeeSyncWorker(): void {
|
|
if (!syncTimer) return;
|
|
clearInterval(syncTimer);
|
|
syncTimer = null;
|
|
log.info("[DigitalEmployeeSync] Worker stopped");
|
|
}
|
|
|
|
export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
|
|
const config = resolveSyncConfig();
|
|
return {
|
|
running: Boolean(syncTimer),
|
|
syncing,
|
|
enabled: config.enabled,
|
|
endpoint: config.endpoint,
|
|
missingCredentials: missingSyncCredentialLabels(config),
|
|
pending: countOutbox("pending"),
|
|
failed: countOutbox("failed"),
|
|
lastSyncAt,
|
|
lastSyncError,
|
|
failureSummary: readFailureSummary(),
|
|
recentFailures: readRecentFailures(config),
|
|
};
|
|
}
|
|
|
|
export async function pullDigitalEmployeePlanStepDispatchPolicy(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeePlanStepDispatchPolicyPullResult> {
|
|
const currentState = readDigitalEmployeeUiState();
|
|
const currentPolicy = currentState.planStepDispatchPolicy ?? normalizePlanStepDispatchPolicy(null);
|
|
if (policyPulling) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
const nowMs = Date.now();
|
|
if (!options.force && nowMs - lastPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().policyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
lastPolicyPullAttemptMs = nowMs;
|
|
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!config.policyEndpoint || missingCredentials.length > 0) {
|
|
const error = !config.policyEndpoint ? "management policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, error);
|
|
}
|
|
|
|
policyPulling = true;
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.policyEndpoint);
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const remotePolicy = readRemotePlanStepDispatchPolicy(response);
|
|
if (!remotePolicy) throw new Error("management policy response missing plan step dispatch policy");
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizePlanStepDispatchPolicy({
|
|
...remotePolicy,
|
|
source: "remote",
|
|
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.remote_updated_at) || null,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: null,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_plan_step_dispatch_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint: config.policyEndpoint,
|
|
ok: true,
|
|
plan_step_dispatch_policy: policy,
|
|
},
|
|
state: {
|
|
...currentState,
|
|
planStepDispatchPolicy: policy,
|
|
},
|
|
});
|
|
return { ok: true, endpoint: config.policyEndpoint, pulled_at: pulledAt, policy, error: null };
|
|
} catch (error) {
|
|
return recordPlanStepDispatchPolicyPullFailure(currentState, currentPolicy, config.policyEndpoint, normalizeError(error));
|
|
} finally {
|
|
policyPulling = false;
|
|
}
|
|
}
|
|
|
|
export async function pullDigitalEmployeeSkillPolicies(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeeSkillPolicyPullResult> {
|
|
const currentPolicies = readDigitalEmployeeSkillPolicies();
|
|
if (skillPolicyPulling) {
|
|
return {
|
|
ok: true,
|
|
skipped: true,
|
|
endpoint: resolveSyncConfig().skillPolicyEndpoint,
|
|
pulled_at: currentPolicies.last_pulled_at ?? null,
|
|
policies: currentPolicies,
|
|
};
|
|
}
|
|
const nowMs = Date.now();
|
|
if (!options.force && nowMs - lastSkillPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
|
return {
|
|
ok: true,
|
|
skipped: true,
|
|
endpoint: resolveSyncConfig().skillPolicyEndpoint,
|
|
pulled_at: currentPolicies.last_pulled_at ?? null,
|
|
policies: currentPolicies,
|
|
};
|
|
}
|
|
lastSkillPolicyPullAttemptMs = nowMs;
|
|
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!config.skillPolicyEndpoint || missingCredentials.length > 0) {
|
|
const error = !config.skillPolicyEndpoint ? "management skill policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, error);
|
|
}
|
|
|
|
skillPolicyPulling = true;
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.skillPolicyEndpoint);
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const remotePayload = readRemoteSkillPolicyPayload(response);
|
|
if (!remotePayload) throw new Error("management policy response missing skill policies");
|
|
const remoteSkills = readRemoteSkillPolicyRecords(remotePayload);
|
|
if (!remoteSkills) throw new Error("management policy response missing skill policy records");
|
|
const pulledAt = new Date().toISOString();
|
|
const policies = normalizeDigitalEmployeeSkillPolicies({
|
|
version: 1,
|
|
local_skills: currentPolicies.local_skills,
|
|
remote_skills: remoteSkills,
|
|
source: "remote",
|
|
remote_updated_at: stringField(remotePayload.updated_at) || stringField(remotePayload.updatedAt) || stringField(remotePayload.remote_updated_at) || null,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: null,
|
|
});
|
|
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
|
|
return { ok: true, endpoint: config.skillPolicyEndpoint, pulled_at: pulledAt, policies, error: null };
|
|
} catch (error) {
|
|
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, normalizeError(error));
|
|
} finally {
|
|
skillPolicyPulling = false;
|
|
}
|
|
}
|
|
|
|
export async function pullDigitalEmployeeRiskApprovalPolicy(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeeRiskApprovalPolicyPullResult> {
|
|
const currentState = readDigitalEmployeeUiState();
|
|
const currentPolicy = currentState.riskApprovalPolicy ?? normalizeRiskApprovalPolicy(null);
|
|
if (riskApprovalPolicyPulling) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().riskApprovalPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
const nowMs = Date.now();
|
|
if (!options.force && nowMs - lastRiskApprovalPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().riskApprovalPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
lastRiskApprovalPolicyPullAttemptMs = nowMs;
|
|
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!config.riskApprovalPolicyEndpoint || missingCredentials.length > 0) {
|
|
const error = !config.riskApprovalPolicyEndpoint ? "management risk approval policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
return recordRiskApprovalPolicyPullFailure(currentState, currentPolicy, config.riskApprovalPolicyEndpoint, error);
|
|
}
|
|
|
|
riskApprovalPolicyPulling = true;
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.riskApprovalPolicyEndpoint);
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const remotePolicy = readRemoteRiskApprovalPolicy(response);
|
|
if (!remotePolicy) throw new Error("management policy response missing risk approval policy");
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizeRiskApprovalPolicy({
|
|
...remotePolicy,
|
|
source: "remote",
|
|
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.updatedAt) || stringField(remotePolicy.remote_updated_at) || null,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: null,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_risk_approval_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint: config.riskApprovalPolicyEndpoint,
|
|
ok: true,
|
|
risk_approval_policy: policy,
|
|
},
|
|
state: {
|
|
...currentState,
|
|
riskApprovalPolicy: policy,
|
|
},
|
|
});
|
|
return { ok: true, endpoint: config.riskApprovalPolicyEndpoint, pulled_at: pulledAt, policy, error: null };
|
|
} catch (error) {
|
|
return recordRiskApprovalPolicyPullFailure(currentState, currentPolicy, config.riskApprovalPolicyEndpoint, normalizeError(error));
|
|
} finally {
|
|
riskApprovalPolicyPulling = false;
|
|
}
|
|
}
|
|
|
|
export async function pullDigitalEmployeeRouteDecisionPolicy(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeeRouteDecisionPolicyPullResult> {
|
|
const currentState = readDigitalEmployeeUiState();
|
|
const currentPolicy = currentState.routeDecisionPolicy ?? normalizeRouteDecisionPolicy(null);
|
|
if (routeDecisionPolicyPulling) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().routeDecisionPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
const nowMs = Date.now();
|
|
if (!options.force && nowMs - lastRouteDecisionPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().routeDecisionPolicyEndpoint, pulled_at: currentPolicy.last_pulled_at ?? null, policy: currentPolicy };
|
|
}
|
|
lastRouteDecisionPolicyPullAttemptMs = nowMs;
|
|
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!config.routeDecisionPolicyEndpoint || missingCredentials.length > 0) {
|
|
const error = !config.routeDecisionPolicyEndpoint ? "management route decision policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
return recordRouteDecisionPolicyPullFailure(currentState, currentPolicy, config.routeDecisionPolicyEndpoint, error);
|
|
}
|
|
|
|
routeDecisionPolicyPulling = true;
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.routeDecisionPolicyEndpoint);
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const remotePolicy = readRemoteRouteDecisionPolicy(response);
|
|
if (!remotePolicy) throw new Error("management policy response missing route decision policy");
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizeRouteDecisionPolicy({
|
|
...remotePolicy,
|
|
source: "remote",
|
|
remote_updated_at: stringField(remotePolicy.updated_at) || stringField(remotePolicy.updatedAt) || stringField(remotePolicy.remote_updated_at) || null,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: null,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_route_decision_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint: config.routeDecisionPolicyEndpoint,
|
|
ok: true,
|
|
route_decision_policy: policy,
|
|
},
|
|
state: {
|
|
...currentState,
|
|
routeDecisionPolicy: policy,
|
|
},
|
|
});
|
|
return { ok: true, endpoint: config.routeDecisionPolicyEndpoint, pulled_at: pulledAt, policy, error: null };
|
|
} catch (error) {
|
|
return recordRouteDecisionPolicyPullFailure(currentState, currentPolicy, config.routeDecisionPolicyEndpoint, normalizeError(error));
|
|
} finally {
|
|
routeDecisionPolicyPulling = false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
let conflicted = 0;
|
|
for (const update of updates) {
|
|
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
|
|
if (result.ok && result.conflict) conflicted += 1;
|
|
else 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,
|
|
conflicted,
|
|
source: "management-api",
|
|
});
|
|
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, conflicted, 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 pullDigitalEmployeeNotificationUpdates(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeeNotificationUpdatesPullResult> {
|
|
if (notificationUpdatesPulling) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
|
}
|
|
const nowMs = Date.now();
|
|
if (!options.force && nowMs - lastNotificationUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
|
return { ok: true, skipped: true, endpoint: resolveSyncConfig().notificationUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
|
|
}
|
|
lastNotificationUpdatesPullAttemptMs = nowMs;
|
|
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!config.notificationUpdatesEndpoint || missingCredentials.length > 0) {
|
|
const pulledAt = new Date().toISOString();
|
|
const error = !config.notificationUpdatesEndpoint ? "management notification updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
|
action: "pull_notification_updates",
|
|
endpoint: config.notificationUpdatesEndpoint,
|
|
ok: false,
|
|
applied: 0,
|
|
ignored: 0,
|
|
error,
|
|
source: "management-api",
|
|
});
|
|
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error };
|
|
}
|
|
|
|
notificationUpdatesPulling = true;
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.notificationUpdatesEndpoint);
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const updates = readRemoteNotificationUpdates(response);
|
|
const pulledAt = new Date().toISOString();
|
|
const currentState = readDigitalEmployeeUiState();
|
|
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
|
|
let applied = 0;
|
|
let ignored = 0;
|
|
for (const update of updates) {
|
|
if (!update.notificationId) {
|
|
ignored += 1;
|
|
continue;
|
|
}
|
|
const currentOverlay = objectRecord(notificationStateById[update.notificationId]) ?? {};
|
|
notificationStateById[update.notificationId] = {
|
|
...currentOverlay,
|
|
...update.overlay,
|
|
};
|
|
applied += 1;
|
|
}
|
|
saveDigitalEmployeeUiState({
|
|
action: "notification_updates_pulled",
|
|
metadata: {
|
|
action: "pull_notification_updates",
|
|
endpoint: config.notificationUpdatesEndpoint,
|
|
ok: true,
|
|
applied,
|
|
ignored,
|
|
source: "management-api",
|
|
},
|
|
state: {
|
|
...currentState,
|
|
notificationStateById,
|
|
},
|
|
});
|
|
return { ok: true, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
|
|
} catch (error) {
|
|
const pulledAt = new Date().toISOString();
|
|
const message = normalizeError(error);
|
|
recordNotificationSyncEvent("notification_updates_pull_failed", pulledAt, {
|
|
action: "pull_notification_updates",
|
|
endpoint: config.notificationUpdatesEndpoint,
|
|
ok: false,
|
|
applied: 0,
|
|
ignored: 0,
|
|
error: message,
|
|
source: "management-api",
|
|
});
|
|
return { ok: false, endpoint: config.notificationUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
|
|
} finally {
|
|
notificationUpdatesPulling = false;
|
|
}
|
|
}
|
|
|
|
export async function submitDigitalEmployeeNotificationAction(
|
|
input: Record<string, unknown>,
|
|
): Promise<DigitalEmployeeNotificationActionSubmitResult> {
|
|
const config = resolveSyncConfig();
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
const submittedAt = new Date().toISOString();
|
|
const notificationId = stringField(input.notification_id ?? input.notificationId) || null;
|
|
const notificationAction = stringField(input.action) || null;
|
|
const status = stringField(input.status) || null;
|
|
const reply = stringField(input.reply ?? input.content ?? input.message);
|
|
if (!config.notificationActionsEndpoint || missingCredentials.length > 0) {
|
|
const error = !config.notificationActionsEndpoint ? "management notification actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
|
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
|
action: "submit_notification_action",
|
|
ok: false,
|
|
endpoint: config.notificationActionsEndpoint,
|
|
error,
|
|
notification_id: notificationId,
|
|
notification_action: notificationAction,
|
|
status,
|
|
reply_length: reply ? reply.length : null,
|
|
source: "qimingclaw-notification-sync",
|
|
});
|
|
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error };
|
|
}
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, config.notificationActionsEndpoint, {
|
|
...input,
|
|
acted_at: stringField(input.acted_at ?? input.actedAt) || submittedAt,
|
|
submitted_at: submittedAt,
|
|
device_id: getDeviceId(),
|
|
});
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
recordNotificationSyncEvent("notification_action_submitted", submittedAt, {
|
|
action: "submit_notification_action",
|
|
ok: true,
|
|
endpoint: config.notificationActionsEndpoint,
|
|
notification_id: notificationId,
|
|
notification_action: notificationAction,
|
|
status,
|
|
reply_length: reply ? reply.length : null,
|
|
source: "qimingclaw-notification-sync",
|
|
});
|
|
return { ok: true, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: null };
|
|
} catch (error) {
|
|
const message = normalizeError(error);
|
|
recordNotificationSyncEvent("notification_action_submit_failed", submittedAt, {
|
|
action: "submit_notification_action",
|
|
ok: false,
|
|
endpoint: config.notificationActionsEndpoint,
|
|
error: message,
|
|
notification_id: notificationId,
|
|
notification_action: notificationAction,
|
|
status,
|
|
reply_length: reply ? reply.length : null,
|
|
source: "qimingclaw-notification-sync",
|
|
});
|
|
return { ok: false, endpoint: config.notificationActionsEndpoint, submitted_at: submittedAt, error: message };
|
|
}
|
|
}
|
|
|
|
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
|
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
|
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
|
const config = resolveSyncConfig();
|
|
const endpoint = buildLeaseOperationEndpoint(config.leaseEndpoint, "acquire");
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!endpoint || missingCredentials.length > 0) {
|
|
return {
|
|
ok: false,
|
|
fallback: true,
|
|
endpoint,
|
|
error: !endpoint ? "management lease endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
|
|
};
|
|
}
|
|
const occurredAt = input.occurredAt || new Date().toISOString();
|
|
const ttlMs = Math.max(60_000, Math.min(Number(input.ttlMs) || 5 * 60_000, 30 * 60_000));
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, endpoint, {
|
|
client_key: config.clientKey,
|
|
device_id: getDeviceId(),
|
|
plan_id: stringField(input.planId) || null,
|
|
step_id: input.stepId,
|
|
task_id: stringField(input.taskId) || null,
|
|
trigger_source: stringField(input.triggerSource) || null,
|
|
ttl_ms: ttlMs,
|
|
occurred_at: occurredAt,
|
|
});
|
|
const rejectedReason = readPlanStepLeaseRejectionReason(response);
|
|
if (rejectedReason) return { ok: false, rejected: true, endpoint, reason: rejectedReason, lease: readRemotePlanStepLease(response, occurredAt, ttlMs) };
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
const lease = readRemotePlanStepLease(response, occurredAt, ttlMs);
|
|
if (!lease) throw new Error("management lease response missing plan step lease");
|
|
return { ok: true, endpoint, lease };
|
|
} catch (error) {
|
|
return { ok: false, fallback: true, endpoint, error: normalizeError(error) };
|
|
}
|
|
}
|
|
|
|
export async function releaseDigitalEmployeePlanStepRemoteLease(
|
|
input: DigitalEmployeePlanStepRemoteLeaseReleaseInput,
|
|
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
|
const config = resolveSyncConfig();
|
|
const endpoint = buildLeaseOperationEndpoint(config.leaseEndpoint, "release");
|
|
const missingCredentials = missingSyncCredentialLabels(config);
|
|
if (!endpoint || missingCredentials.length > 0) {
|
|
return {
|
|
ok: false,
|
|
endpoint,
|
|
error: !endpoint ? "management lease endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
|
|
};
|
|
}
|
|
const occurredAt = input.occurredAt || new Date().toISOString();
|
|
try {
|
|
const response = await fetchJsonWithAuth(config, endpoint, {
|
|
client_key: config.clientKey,
|
|
device_id: getDeviceId(),
|
|
plan_id: stringField(input.planId) || null,
|
|
step_id: input.stepId,
|
|
task_id: stringField(input.taskId) || null,
|
|
run_id: stringField(input.runId) || null,
|
|
lease_id: stringField(input.leaseId) || null,
|
|
remote_lease_id: stringField(input.remoteLeaseId) || stringField(input.leaseId) || null,
|
|
reason: stringField(input.reason) || null,
|
|
occurred_at: occurredAt,
|
|
});
|
|
const responseError = readSyncResponseError(response as SyncResponse);
|
|
if (responseError) throw new Error(responseError);
|
|
return { ok: true, endpoint, lease: readRemotePlanStepLease(response, occurredAt, 0) };
|
|
} catch (error) {
|
|
return { ok: false, endpoint, error: normalizeError(error) };
|
|
}
|
|
}
|
|
|
|
export async function flushDigitalEmployeeSyncOutbox(
|
|
options: { force?: boolean } = {},
|
|
): Promise<DigitalEmployeeSyncStatus> {
|
|
if (syncing) return getDigitalEmployeeSyncStatus();
|
|
|
|
const config = resolveSyncConfig();
|
|
if (
|
|
!config.enabled ||
|
|
!config.endpoint ||
|
|
missingSyncCredentialLabels(config).length > 0
|
|
) {
|
|
lastSyncError = null;
|
|
return getDigitalEmployeeSyncStatus();
|
|
}
|
|
|
|
const rows = readPendingOutbox(config.batchSize, options.force === true);
|
|
if (rows.length === 0) {
|
|
lastSyncError = null;
|
|
return getDigitalEmployeeSyncStatus();
|
|
}
|
|
|
|
syncing = true;
|
|
const startedAt = new Date().toISOString();
|
|
markRowsSyncing(rows, startedAt);
|
|
|
|
try {
|
|
const response = await postOutbox(config, rows);
|
|
const finishedAt = new Date().toISOString();
|
|
const acked = response.data?.acked || response.acked || [];
|
|
const responseError = readSyncResponseError(response);
|
|
if (responseError) throw new Error(responseError);
|
|
const syncedRows = acked.length > 0
|
|
? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row)))
|
|
: rows;
|
|
const unackedRows = acked.length > 0
|
|
? rows.filter((row) => !acked.some((ack) => ackMatchesRow(ack, row)))
|
|
: [];
|
|
recordSyncAttempts(syncedRows, config, "synced", null, startedAt, finishedAt);
|
|
if (acked.length > 0) {
|
|
markRowsSynced(acked, finishedAt, rows);
|
|
} else {
|
|
markRowsSynced(
|
|
rows.map((row) => ({
|
|
outbox_id: row.id,
|
|
entity_type: row.entity_type,
|
|
entity_id: row.entity_id,
|
|
})),
|
|
finishedAt,
|
|
);
|
|
}
|
|
if (unackedRows.length > 0) {
|
|
const unackedError = "management sync response did not acknowledge item";
|
|
recordSyncAttempts(
|
|
unackedRows,
|
|
config,
|
|
"failed",
|
|
unackedError,
|
|
startedAt,
|
|
finishedAt,
|
|
);
|
|
markRowsFailed(unackedRows, finishedAt, unackedError);
|
|
lastSyncError = unackedError;
|
|
} else {
|
|
lastSyncError = null;
|
|
}
|
|
lastSyncAt = finishedAt;
|
|
} catch (error) {
|
|
const finishedAt = new Date().toISOString();
|
|
lastSyncError = normalizeError(error);
|
|
recordSyncAttempts(rows, config, "failed", lastSyncError, startedAt, finishedAt);
|
|
markRowsFailed(rows, finishedAt, lastSyncError);
|
|
log.warn("[DigitalEmployeeSync] Flush failed:", lastSyncError);
|
|
} finally {
|
|
syncing = false;
|
|
}
|
|
|
|
return getDigitalEmployeeSyncStatus();
|
|
}
|
|
|
|
function readSyncResponseError(response: SyncResponse): string | null {
|
|
if (response.success === false) {
|
|
return response.message || "digital employee sync rejected";
|
|
}
|
|
|
|
const code = normalizeResponseCode(response.code);
|
|
if (code && code !== "0000") {
|
|
return response.message || `digital employee sync rejected: ${code}`;
|
|
}
|
|
|
|
const status = normalizeResponseCode(response.status);
|
|
if (status && ["error", "fail", "failed", "failure"].includes(status.toLowerCase())) {
|
|
return response.message || "digital employee sync rejected";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeResponseCode(value: unknown): string | null {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed || null;
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return String(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
|
const config = (readSetting("digital_employee_sync_config") || {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const step1 = (readSetting("step1_config") || {}) as Record<string, unknown>;
|
|
const endpointOverride =
|
|
typeof config.endpoint === "string" && config.endpoint.trim()
|
|
? config.endpoint.trim()
|
|
: null;
|
|
const serverHost =
|
|
typeof step1.serverHost === "string" && step1.serverHost.trim()
|
|
? step1.serverHost.trim()
|
|
: null;
|
|
const endpoint = endpointOverride || buildEndpoint(serverHost);
|
|
const policyEndpointOverride =
|
|
typeof config.planStepDispatchPolicyEndpoint === "string" && config.planStepDispatchPolicyEndpoint.trim()
|
|
? config.planStepDispatchPolicyEndpoint.trim()
|
|
: typeof config.policyEndpoint === "string" && config.policyEndpoint.trim()
|
|
? config.policyEndpoint.trim()
|
|
: null;
|
|
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
|
|
const skillPolicyEndpointOverride =
|
|
typeof config.skillPolicyEndpoint === "string" && config.skillPolicyEndpoint.trim()
|
|
? config.skillPolicyEndpoint.trim()
|
|
: null;
|
|
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH);
|
|
const riskApprovalPolicyEndpointOverride =
|
|
typeof config.riskApprovalPolicyEndpoint === "string" && config.riskApprovalPolicyEndpoint.trim()
|
|
? config.riskApprovalPolicyEndpoint.trim()
|
|
: null;
|
|
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
|
|
const routeDecisionPolicyEndpointOverride =
|
|
typeof config.routeDecisionPolicyEndpoint === "string" && config.routeDecisionPolicyEndpoint.trim()
|
|
? config.routeDecisionPolicyEndpoint.trim()
|
|
: null;
|
|
const routeDecisionPolicyEndpoint = routeDecisionPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_ROUTE_DECISION_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 notificationUpdatesEndpointOverride =
|
|
typeof config.notificationUpdatesEndpoint === "string" && config.notificationUpdatesEndpoint.trim()
|
|
? config.notificationUpdatesEndpoint.trim()
|
|
: null;
|
|
const notificationUpdatesEndpoint = notificationUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_UPDATES_PATH);
|
|
const notificationActionsEndpointOverride =
|
|
typeof config.notificationActionsEndpoint === "string" && config.notificationActionsEndpoint.trim()
|
|
? config.notificationActionsEndpoint.trim()
|
|
: null;
|
|
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
|
|
const leaseEndpointOverride =
|
|
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
|
? config.planStepDispatchLeaseEndpoint.trim()
|
|
: null;
|
|
const leaseEndpoint = leaseEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH);
|
|
const disabled = config.enabled === false;
|
|
const batchSize =
|
|
typeof config.batchSize === "number" && config.batchSize > 0
|
|
? Math.min(Math.floor(config.batchSize), 200)
|
|
: DEFAULT_BATCH_SIZE;
|
|
|
|
return {
|
|
enabled: !disabled,
|
|
endpoint,
|
|
policyEndpoint,
|
|
skillPolicyEndpoint,
|
|
riskApprovalPolicyEndpoint,
|
|
routeDecisionPolicyEndpoint,
|
|
approvalUpdatesEndpoint,
|
|
approvalDecisionsEndpoint,
|
|
notificationUpdatesEndpoint,
|
|
notificationActionsEndpoint,
|
|
leaseEndpoint,
|
|
clientKey: readClientKey(serverHost),
|
|
authToken: readAuthToken(serverHost),
|
|
batchSize,
|
|
};
|
|
}
|
|
|
|
function buildLeaseOperationEndpoint(baseEndpoint: string | null, operation: "acquire" | "release"): string | null {
|
|
if (!baseEndpoint) return null;
|
|
return `${baseEndpoint.replace(/\/+$/, "")}/${operation}`;
|
|
}
|
|
|
|
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
|
|
if (!serverHost) return null;
|
|
const normalized = serverHost.startsWith("http")
|
|
? serverHost
|
|
: `https://${serverHost}`;
|
|
return `${normalized.replace(/\/+$/, "")}${path}`;
|
|
}
|
|
|
|
function missingSyncCredentialLabels(
|
|
config: DigitalEmployeeSyncConfig,
|
|
): string[] {
|
|
return [
|
|
config.clientKey ? "" : "客户端 Key",
|
|
config.authToken ? "" : "登录 token",
|
|
].filter((value): value is string => Boolean(value));
|
|
}
|
|
|
|
function readClientKey(serverHost: string | null): string | null {
|
|
const username = readSetting("auth.username");
|
|
const globalKey = readSetting("auth.saved_key");
|
|
if (serverHost && typeof username === "string" && username.trim()) {
|
|
const domain = serverHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
const domainKey = readSetting(`auth.saved_keys.${domain}_${username}`);
|
|
if (typeof domainKey === "string" && domainKey.trim()) return domainKey;
|
|
}
|
|
return typeof globalKey === "string" && globalKey.trim() ? globalKey : null;
|
|
}
|
|
|
|
function readAuthToken(serverHost: string | null): string | null {
|
|
const oneShotToken = readSetting("auth.token");
|
|
if (typeof oneShotToken === "string" && oneShotToken.trim()) {
|
|
return oneShotToken;
|
|
}
|
|
if (serverHost) {
|
|
const domainToken = readSetting(getDomainTokenKey(serverHost));
|
|
if (typeof domainToken === "string" && domainToken.trim()) {
|
|
return domainToken;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readPendingOutbox(limit: number, force: boolean): OutboxRow[] {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return [];
|
|
const candidateLimit = force
|
|
? limit
|
|
: limit * FAILED_RETRY_CANDIDATE_MULTIPLIER;
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT id, entity_type, entity_id, operation, payload, attempts, last_attempt_at
|
|
FROM digital_sync_outbox
|
|
WHERE status IN ('pending', 'failed')
|
|
ORDER BY created_at ASC
|
|
LIMIT ?
|
|
`,
|
|
)
|
|
.all(candidateLimit) as OutboxRow[];
|
|
if (force) return rows.slice(0, limit);
|
|
return rows.filter(isOutboxRowDueForRetry).slice(0, limit);
|
|
}
|
|
|
|
function isOutboxRowDueForRetry(row: OutboxRow): boolean {
|
|
if (!row.last_attempt_at) return true;
|
|
const lastAttemptAt = Date.parse(row.last_attempt_at);
|
|
if (!Number.isFinite(lastAttemptAt)) return true;
|
|
return Date.now() >= lastAttemptAt + retryDelayMs(row.attempts);
|
|
}
|
|
|
|
function retryDelayMs(attempts: number): number {
|
|
const exponent = Math.max(0, Math.min(attempts - 1, 10));
|
|
return Math.min(DEFAULT_INTERVAL_MS * 2 ** exponent, MAX_RETRY_DELAY_MS);
|
|
}
|
|
|
|
async function postOutbox(
|
|
config: DigitalEmployeeSyncConfig,
|
|
rows: OutboxRow[],
|
|
): Promise<SyncResponse> {
|
|
return fetchJsonWithAuth(config, config.endpoint!, {
|
|
client_key: config.clientKey,
|
|
device_id: getDeviceId(),
|
|
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
|
items: rows.map(buildSyncItem),
|
|
}) as Promise<SyncResponse>;
|
|
}
|
|
|
|
async function fetchJsonWithAuth(
|
|
config: DigitalEmployeeSyncConfig,
|
|
endpoint: string,
|
|
requestBody?: Record<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: requestBody ? "POST" : "GET",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${config.authToken}`,
|
|
"X-Qiming-Client-Key": config.clientKey!,
|
|
"X-Qiming-Device-Id": getDeviceId(),
|
|
},
|
|
body: requestBody ? JSON.stringify(requestBody) : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
const text = await response.text();
|
|
const responseBody = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
if (!response.ok) {
|
|
throw new Error(stringField(responseBody.message) || `HTTP ${response.status}`);
|
|
}
|
|
return responseBody;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
|
|
const data = objectRecord(response.data) ?? response;
|
|
return objectRecord(data.plan_step_dispatch_policy)
|
|
?? objectRecord(data.planStepDispatchPolicy)
|
|
?? objectRecord(data.policy)
|
|
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
|
|
}
|
|
|
|
function readRemoteSkillPolicyPayload(response: Record<string, unknown>): Record<string, unknown> | null {
|
|
const data = objectRecord(response.data) ?? response;
|
|
return objectRecord(data.skill_policies)
|
|
?? objectRecord(data.skillPolicies)
|
|
?? objectRecord(data.policies)
|
|
?? objectRecord(data.policy)
|
|
?? (hasSkillPolicyWrapperFields(data) || looksLikeSkillPolicyMap(data) ? data : null);
|
|
}
|
|
|
|
function readRemoteSkillPolicyRecords(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
return objectRecord(payload.remote_skills)
|
|
?? objectRecord(payload.remoteSkills)
|
|
?? objectRecord(payload.skills)
|
|
?? (looksLikeSkillPolicyMap(payload) ? payload : null);
|
|
}
|
|
|
|
function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
|
|
const data = objectRecord(response.data) ?? response;
|
|
return objectRecord(data.risk_approval_policy)
|
|
?? objectRecord(data.riskApprovalPolicy)
|
|
?? objectRecord(data.policy)
|
|
?? (hasRiskApprovalPolicyFields(data) ? data : null);
|
|
}
|
|
|
|
function readRemoteRouteDecisionPolicy(response: Record<string, unknown>): Record<string, unknown> | null {
|
|
const data = objectRecord(response.data) ?? response;
|
|
return objectRecord(data.route_decision_policy)
|
|
?? objectRecord(data.routeDecisionPolicy)
|
|
?? objectRecord(data.policy)
|
|
?? (hasRouteDecisionPolicyFields(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 readRemoteNotificationUpdates(response: Record<string, unknown>): Array<{ notificationId: string | null; overlay: Record<string, unknown> }> {
|
|
const data = objectRecord(response.data) ?? response;
|
|
const raw = Array.isArray(data.notifications)
|
|
? data.notifications
|
|
: Array.isArray(data.updates)
|
|
? data.updates
|
|
: Array.isArray(data.items)
|
|
? data.items
|
|
: Array.isArray(response)
|
|
? response
|
|
: [];
|
|
return raw
|
|
.map((item) => objectRecord(item))
|
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
|
.map((item) => {
|
|
const notificationId = stringField(item.notification_id ?? item.notificationId ?? item.id) || null;
|
|
const overlay: Record<string, unknown> = {};
|
|
const status = stringField(item.status);
|
|
const readAt = stringField(item.read_at ?? item.readAt);
|
|
const dismissedAt = stringField(item.dismissed_at ?? item.dismissedAt);
|
|
const reply = stringField(item.reply ?? item.content ?? item.message);
|
|
const repliedAt = stringField(item.replied_at ?? item.repliedAt);
|
|
const remoteUpdatedAt = stringField(item.remote_updated_at ?? item.remoteUpdatedAt ?? item.updated_at ?? item.updatedAt);
|
|
const source = stringField(item.source) || "management-api";
|
|
if (status) overlay.status = status;
|
|
if (readAt) overlay.read_at = readAt;
|
|
if (dismissedAt) overlay.dismissed_at = dismissedAt;
|
|
if (reply) overlay.reply = reply;
|
|
if (repliedAt) overlay.replied_at = repliedAt;
|
|
if (remoteUpdatedAt) overlay.remote_updated_at = remoteUpdatedAt;
|
|
overlay.source = source;
|
|
return { notificationId, overlay };
|
|
});
|
|
}
|
|
|
|
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";
|
|
}
|
|
const data = objectRecord(response.data);
|
|
if (data && (data.ok === false || data.success === false || data.accepted === false || data.granted === false)) {
|
|
return stringField(data.reason ?? data.error ?? data.message) || "remote_lease_rejected";
|
|
}
|
|
const status = normalizeResponseCode(response.status ?? data?.status);
|
|
if (status && ["rejected", "denied", "conflict", "locked"].includes(status.toLowerCase())) {
|
|
return stringField(response.reason ?? response.message ?? data?.reason ?? data?.message) || "remote_lease_rejected";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readRemotePlanStepLease(
|
|
response: Record<string, unknown>,
|
|
occurredAt: string,
|
|
ttlMs: number,
|
|
): DigitalEmployeePlanStepRemoteLease | null {
|
|
const data = objectRecord(response.data) ?? response;
|
|
const lease = objectRecord(data.lease)
|
|
?? objectRecord(data.plan_step_dispatch_lease)
|
|
?? objectRecord(data.planStepDispatchLease)
|
|
?? (hasPlanStepLeaseFields(data) ? data : null);
|
|
if (!lease) return null;
|
|
const remoteLeaseId = stringField(lease.remote_lease_id ?? lease.remoteLeaseId ?? lease.lease_id ?? lease.leaseId);
|
|
const leaseId = stringField(lease.lease_id ?? lease.leaseId) || remoteLeaseId;
|
|
const ownerDeviceId = stringField(lease.owner_device_id ?? lease.ownerDeviceId) || getDeviceId();
|
|
const expiresAt = stringField(lease.expires_at ?? lease.expiresAt)
|
|
|| (ttlMs > 0 ? new Date(new Date(occurredAt).getTime() + ttlMs).toISOString() : occurredAt);
|
|
if (!leaseId || !ownerDeviceId || !expiresAt) return null;
|
|
return {
|
|
lease_id: leaseId,
|
|
state: normalizePlanStepRemoteLeaseState(lease.state),
|
|
owner_device_id: ownerDeviceId,
|
|
acquired_at: stringField(lease.acquired_at ?? lease.acquiredAt) || occurredAt,
|
|
expires_at: expiresAt,
|
|
released_at: stringField(lease.released_at ?? lease.releasedAt) || null,
|
|
release_reason: stringField(lease.release_reason ?? lease.releaseReason) || null,
|
|
remote_lease_id: remoteLeaseId || leaseId,
|
|
remote_owner_device_id: stringField(lease.remote_owner_device_id ?? lease.remoteOwnerDeviceId ?? lease.owner_device_id ?? lease.ownerDeviceId) || null,
|
|
};
|
|
}
|
|
|
|
function hasPlanStepLeaseFields(record: Record<string, unknown>): boolean {
|
|
return "lease_id" in record || "leaseId" in record || "remote_lease_id" in record || "remoteLeaseId" in record;
|
|
}
|
|
|
|
function normalizePlanStepRemoteLeaseState(value: unknown): DigitalEmployeePlanStepRemoteLease["state"] {
|
|
if (value === "released" || value === "expired") return value;
|
|
return "active";
|
|
}
|
|
|
|
function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boolean {
|
|
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
|
|
}
|
|
|
|
function hasSkillPolicyWrapperFields(record: Record<string, unknown>): boolean {
|
|
return "remote_skills" in record
|
|
|| "remoteSkills" in record
|
|
|| "skills" in record
|
|
|| "skill_policies" in record
|
|
|| "skillPolicies" in record
|
|
|| "policies" in record;
|
|
}
|
|
|
|
function hasRiskApprovalPolicyFields(record: Record<string, unknown>): boolean {
|
|
return "approval_required_risk_levels" in record
|
|
|| "approvalRequiredRiskLevels" in record
|
|
|| "approval_required_actions" in record
|
|
|| "approvalRequiredActions" in record
|
|
|| "auto_reject_actions" in record
|
|
|| "autoRejectActions" in record;
|
|
}
|
|
|
|
function hasRouteDecisionPolicyFields(record: Record<string, unknown>): boolean {
|
|
return "approval_required_intents" in record
|
|
|| "approvalRequiredIntents" in record
|
|
|| "blocked_intents" in record
|
|
|| "blockedIntents" in record
|
|
|| "preferred_route_kinds" in record
|
|
|| "preferredRouteKinds" in record
|
|
|| "auto_create_governance_commands" in record
|
|
|| "autoCreateGovernanceCommands" in record;
|
|
}
|
|
|
|
function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
|
|
return Object.values(record).some((value) => {
|
|
if (typeof value === "boolean") return true;
|
|
const policy = objectRecord(value);
|
|
return Boolean(policy && typeof policy.enabled === "boolean");
|
|
});
|
|
}
|
|
|
|
function recordPlanStepDispatchPolicyPullFailure(
|
|
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
|
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
|
|
endpoint: string | null,
|
|
error: string,
|
|
): DigitalEmployeePlanStepDispatchPolicyPullResult {
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizePlanStepDispatchPolicy({
|
|
...currentPolicy,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: error,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_plan_step_dispatch_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint,
|
|
ok: false,
|
|
error,
|
|
plan_step_dispatch_policy: policy,
|
|
},
|
|
state: {
|
|
...state,
|
|
planStepDispatchPolicy: policy,
|
|
},
|
|
});
|
|
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
|
}
|
|
|
|
function recordSkillPolicyPullFailure(
|
|
currentPolicies: DigitalEmployeeSkillPolicies,
|
|
endpoint: string | null,
|
|
error: string,
|
|
): DigitalEmployeeSkillPolicyPullResult {
|
|
const pulledAt = new Date().toISOString();
|
|
const policies = normalizeDigitalEmployeeSkillPolicies({
|
|
...currentPolicies,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: error,
|
|
});
|
|
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
|
|
return { ok: false, endpoint, pulled_at: pulledAt, policies, error };
|
|
}
|
|
|
|
function recordRiskApprovalPolicyPullFailure(
|
|
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
|
currentPolicy: DigitalEmployeeRiskApprovalPolicy,
|
|
endpoint: string | null,
|
|
error: string,
|
|
): DigitalEmployeeRiskApprovalPolicyPullResult {
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizeRiskApprovalPolicy({
|
|
...currentPolicy,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: error,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_risk_approval_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint,
|
|
ok: false,
|
|
error,
|
|
risk_approval_policy: policy,
|
|
},
|
|
state: {
|
|
...state,
|
|
riskApprovalPolicy: policy,
|
|
},
|
|
});
|
|
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
|
}
|
|
|
|
function recordRouteDecisionPolicyPullFailure(
|
|
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
|
currentPolicy: DigitalEmployeeRouteDecisionPolicy,
|
|
endpoint: string | null,
|
|
error: string,
|
|
): DigitalEmployeeRouteDecisionPolicyPullResult {
|
|
const pulledAt = new Date().toISOString();
|
|
const policy = normalizeRouteDecisionPolicy({
|
|
...currentPolicy,
|
|
last_pulled_at: pulledAt,
|
|
last_pull_error: error,
|
|
});
|
|
saveDigitalEmployeeUiState({
|
|
action: "pull_route_decision_policy",
|
|
metadata: {
|
|
source: "management-api",
|
|
endpoint,
|
|
ok: false,
|
|
error,
|
|
route_decision_policy: policy,
|
|
},
|
|
state: {
|
|
...state,
|
|
routeDecisionPolicy: policy,
|
|
},
|
|
});
|
|
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 recordNotificationSyncEvent(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);
|
|
return {
|
|
outbox_id: row.id,
|
|
entity_type: row.entity_type,
|
|
entity_id: row.entity_id,
|
|
operation: row.operation,
|
|
contract_version: DIGITAL_EMPLOYEE_SYNC_CONTRACT_VERSION,
|
|
entity_summary: summary,
|
|
business_view: buildBusinessView(row, payload, summary),
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function buildBusinessView(
|
|
row: OutboxRow,
|
|
payload: unknown,
|
|
summary: DigitalEmployeeSyncEntitySummary | null,
|
|
): Record<string, unknown> {
|
|
const record = objectRecord(payload) ?? {};
|
|
const base = {
|
|
entity_type: row.entity_type,
|
|
local_id: row.entity_id,
|
|
operation: row.operation,
|
|
title: stringField(record.title) || summary?.title || null,
|
|
status: stringField(record.status) || summary?.status || null,
|
|
summary: stringField(record.summary) || summary?.summary || null,
|
|
};
|
|
|
|
switch (row.entity_type) {
|
|
case "plan":
|
|
return {
|
|
...base,
|
|
objective: stringField(record.objective) || summary?.summary || null,
|
|
source: stringField(record.source) || "qimingclaw",
|
|
};
|
|
case "task":
|
|
return {
|
|
...base,
|
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
|
assigned_skill_id: stringField(record.assignedSkillId ?? record.assigned_skill_id) || null,
|
|
};
|
|
case "plan_step":
|
|
return planStepBusinessView(base, row, record);
|
|
case "run":
|
|
return {
|
|
...base,
|
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
|
task_id: stringField(record.taskId ?? record.task_id) || null,
|
|
started_at: stringField(record.startedAt ?? record.started_at ?? record.now) || null,
|
|
finished_at: stringField(record.finishedAt ?? record.finished_at) || null,
|
|
};
|
|
case "event":
|
|
return buildEventBusinessView(base, row, record, summary);
|
|
case "artifact":
|
|
return {
|
|
...base,
|
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
|
task_id: stringField(record.taskId ?? record.task_id) || null,
|
|
run_id: stringField(record.runId ?? record.run_id) || null,
|
|
kind: stringField(record.kind) || summary?.status || null,
|
|
name: stringField(record.name) || summary?.title || null,
|
|
uri: stringField(record.uri) || summary?.summary || null,
|
|
};
|
|
case "approval":
|
|
return {
|
|
...base,
|
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
|
task_id: stringField(record.taskId ?? record.task_id) || null,
|
|
run_id: stringField(record.runId ?? record.run_id) || null,
|
|
decision: stringField(record.decision) || null,
|
|
};
|
|
case "memory":
|
|
return {
|
|
...base,
|
|
key: stringField(record.key) || row.entity_id,
|
|
category: stringField(record.category) || null,
|
|
source: stringField(record.source) || null,
|
|
content_preview: compactPreview(stringField(record.content) || summary?.summary || ""),
|
|
};
|
|
default:
|
|
return base;
|
|
}
|
|
}
|
|
|
|
function planStepBusinessView(
|
|
base: Record<string, unknown>,
|
|
row: OutboxRow,
|
|
record: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const payload = objectRecord(record.payload) ?? {};
|
|
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
|
const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
|
|
const dispatchLease = objectRecord(payload.dispatchLease ?? payload.dispatch_lease) ?? {};
|
|
const dependsOn = stringArrayField(record.dependsOn ?? record.depends_on);
|
|
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
|
|
const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies);
|
|
const leaseState = stringField(dispatchLease.state) || null;
|
|
const leaseExpiresAt = stringField(dispatchLease.expires_at ?? dispatchLease.expiresAt) || null;
|
|
const leaseActive = leaseState === "active" && leaseExpiresAt !== null && leaseExpiresAt > new Date().toISOString();
|
|
const skipReason = stringField(payload.skipReason ?? payload.skip_reason) || null;
|
|
return {
|
|
...base,
|
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
|
step_id: row.entity_id,
|
|
seq: numberField(record.seq),
|
|
depends_on: dependsOn,
|
|
dependency_count: dependsOn.length,
|
|
dependency_status: stringField(dependencyGraph.status) || null,
|
|
blocked_dependencies: blockedDependencies,
|
|
blocked_dependency_count: blockedDependencies.length,
|
|
waiting_dependencies: waitingDependencies,
|
|
waiting_dependency_count: waitingDependencies.length,
|
|
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
|
|
dispatchable: !leaseActive && stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
|
|
skip_reason: leaseActive ? "lease_active" : skipReason,
|
|
last_dispatch_status: stringField(lastDispatch.status) || null,
|
|
lease: Object.keys(dispatchLease).length > 0 ? dispatchLease : null,
|
|
lease_id: stringField(dispatchLease.lease_id ?? dispatchLease.leaseId) || null,
|
|
lease_state: leaseState,
|
|
lease_owner_device_id: stringField(dispatchLease.owner_device_id ?? dispatchLease.ownerDeviceId) || null,
|
|
lease_acquired_at: stringField(dispatchLease.acquired_at ?? dispatchLease.acquiredAt) || null,
|
|
lease_expires_at: leaseExpiresAt,
|
|
lease_released_at: stringField(dispatchLease.released_at ?? dispatchLease.releasedAt) || null,
|
|
lease_release_reason: stringField(dispatchLease.release_reason ?? dispatchLease.releaseReason) || null,
|
|
lease_source: stringField(dispatchLease.source) || null,
|
|
remote_lease_id: stringField(dispatchLease.remote_lease_id ?? dispatchLease.remoteLeaseId) || null,
|
|
remote_lease_error: stringField(dispatchLease.remote_error ?? dispatchLease.remoteError) || null,
|
|
remote_release_error: stringField(dispatchLease.remote_release_error ?? dispatchLease.remoteReleaseError) || null,
|
|
remote_lease_rejected: skipReason === "remote_lease_rejected",
|
|
lease_active: leaseActive,
|
|
};
|
|
}
|
|
|
|
function buildEventBusinessView(
|
|
base: Record<string, unknown>,
|
|
row: OutboxRow,
|
|
record: Record<string, unknown>,
|
|
summary: DigitalEmployeeSyncEntitySummary | null,
|
|
): Record<string, unknown> {
|
|
const detail = readEventBusinessDetail(row.entity_id, record, summary);
|
|
const kind = detail.kind || "event";
|
|
return {
|
|
...base,
|
|
category: eventBusinessCategory(kind),
|
|
plan_id: detail.plan_id,
|
|
task_id: detail.task_id,
|
|
run_id: detail.run_id,
|
|
kind,
|
|
message: detail.message,
|
|
occurred_at: detail.occurred_at,
|
|
...eventSpecificBusinessView(kind, detail.payload, row.entity_id),
|
|
};
|
|
}
|
|
|
|
function readEventBusinessDetail(
|
|
eventId: string,
|
|
fallback: Record<string, unknown>,
|
|
summary: DigitalEmployeeSyncEntitySummary | null,
|
|
): EventBusinessDetail {
|
|
const db = getDigitalEmployeeDb();
|
|
const row = db?.prepare(`
|
|
SELECT plan_id, task_id, run_id, kind, message, payload, occurred_at
|
|
FROM digital_events
|
|
WHERE id = ?
|
|
`).get(eventId) as Record<string, unknown> | undefined;
|
|
const eventPayload = parsePayload(stringField(row?.payload) || "{}");
|
|
const fallbackPayload = objectRecord(fallback.payload) ?? fallback;
|
|
return {
|
|
plan_id: stringField(row?.plan_id ?? fallback.plan_id ?? fallback.planId) || null,
|
|
task_id: stringField(row?.task_id ?? fallback.task_id ?? fallback.taskId) || null,
|
|
run_id: stringField(row?.run_id ?? fallback.run_id ?? fallback.runId) || null,
|
|
kind: stringField(row?.kind ?? fallback.kind) || summary?.status || null,
|
|
message: stringField(row?.message ?? fallback.message) || summary?.summary || null,
|
|
payload: objectRecord(eventPayload) ?? fallbackPayload,
|
|
occurred_at: stringField(row?.occurred_at ?? fallback.occurred_at ?? fallback.occurredAt) || null,
|
|
};
|
|
}
|
|
|
|
function eventSpecificBusinessView(
|
|
kind: string,
|
|
payload: Record<string, unknown>,
|
|
eventId: string,
|
|
): Record<string, unknown> {
|
|
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
|
|
if (kind === "route_decision_blocked" || kind === "route_decision_approval_required") return routeDecisionPolicyBusinessView(payload);
|
|
if (kind === "route_decision_intervention_resolved") return routeDecisionInterventionBusinessView(payload);
|
|
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);
|
|
}
|
|
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
|
|
if (kind === "risk_approval_required" || kind === "risk_policy_rejected") return riskPolicyBusinessView(payload);
|
|
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
|
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
|
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
|
|
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
|
|
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
|
|
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
|
|
return {};
|
|
}
|
|
|
|
function routeDecisionBusinessView(payload: Record<string, unknown>, eventId: string): Record<string, unknown> {
|
|
const decision = objectRecord(payload.routeDecision ?? payload.route_decision) ?? payload;
|
|
const policyResult = stringField(decision.policy_result ?? decision.policyResult) || "accepted";
|
|
const reasonCodes = stringArrayField(decision.reason_codes ?? decision.reasonCodes);
|
|
const createdCommandId = stringField(decision.created_command_id ?? decision.createdCommandId) || null;
|
|
return {
|
|
route_decision_id: stringField(decision.id) || eventId,
|
|
route_kind: stringField(decision.route_kind ?? decision.routeKind) || "ChatOnly",
|
|
intent_kind: stringField(decision.intent_kind ?? decision.intentKind) || "chat",
|
|
policy_result: policyResult,
|
|
reason_codes: reasonCodes,
|
|
candidate_skills: stringArrayField(decision.candidate_skills ?? decision.candidateSkills),
|
|
created_command_id: createdCommandId,
|
|
policy_source: stringField(decision.policy_source ?? decision.policySource) || null,
|
|
blocked_reason: stringField(decision.blocked_reason ?? decision.blockedReason) || null,
|
|
approval_id: stringField(decision.approval_id ?? decision.approvalId) || null,
|
|
matched_intent: stringField(decision.matched_intent ?? decision.matchedIntent) || null,
|
|
attention_level: routeDecisionAttentionLevel(policyResult, reasonCodes, createdCommandId),
|
|
};
|
|
}
|
|
|
|
function routeDecisionPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const policyResult = stringField(payload.policy_result ?? payload.decision) || stringField(payload.status) || "blocked";
|
|
const reasonCodes = stringArrayField(payload.reason_codes ?? payload.reasonCodes);
|
|
return {
|
|
route_kind: stringField(payload.route_kind ?? payload.routeKind) || "ChatOnly",
|
|
intent_kind: stringField(payload.intent_kind ?? payload.intentKind) || "chat",
|
|
policy_result: policyResult,
|
|
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
|
|
blocked_reason: stringField(payload.blocked_reason ?? payload.blockedReason) || null,
|
|
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
|
|
matched_intent: stringField(payload.matched_intent ?? payload.matchedIntent) || null,
|
|
reason_codes: reasonCodes,
|
|
candidate_skills: stringArrayField(payload.candidate_skills ?? payload.candidateSkills),
|
|
attention_level: policyResult === "approval_required" ? "action" : "error",
|
|
};
|
|
}
|
|
|
|
function routeDecisionInterventionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
route_decision_id: stringField(payload.route_decision_id ?? payload.routeDecisionId) || null,
|
|
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
|
|
route_kind: stringField(payload.route_kind ?? payload.routeKind) || "ChatOnly",
|
|
intent_kind: stringField(payload.intent_kind ?? payload.intentKind) || "chat",
|
|
decision: stringField(payload.decision) || null,
|
|
status: stringField(payload.status) || null,
|
|
policy_result: stringField(payload.policy_result ?? payload.policyResult) || null,
|
|
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
|
|
blocked_reason: stringField(payload.blocked_reason ?? payload.blockedReason) || null,
|
|
matched_intent: stringField(payload.matched_intent ?? payload.matchedIntent) || null,
|
|
created_command_id: stringField(payload.created_command_id ?? payload.createdCommandId) || null,
|
|
command_accepted: payload.command_accepted === true || payload.commandAccepted === true,
|
|
command_error: stringField(payload.command_error ?? payload.commandError) || null,
|
|
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
|
|
attention_level: stringField(payload.command_error ?? payload.commandError) ? "error" : "action",
|
|
};
|
|
}
|
|
|
|
function approvalActionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const comment = objectRecord(payload.comment_summary);
|
|
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,
|
|
comment_length: numberField(comment?.length ?? payload.comment_length),
|
|
};
|
|
}
|
|
|
|
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,
|
|
task_id: stringField(payload.taskId ?? payload.task_id) || null,
|
|
run_id: stringField(payload.runId ?? payload.run_id) || null,
|
|
};
|
|
}
|
|
|
|
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) ?? {};
|
|
const preferences = objectRecord(metadata.approval_subscriptions)
|
|
?? objectRecord(state.approvalSubscriptionPreferences)
|
|
?? {};
|
|
return {
|
|
action: stringField(metadata.action ?? payload.action) || "update_approval_subscriptions",
|
|
preferences_enabled: typeof preferences.enabled === "boolean" ? preferences.enabled : null,
|
|
subscribed_actions: stringArrayField(preferences.subscribed_actions ?? preferences.subscribedActions),
|
|
subscribed_risk_levels: stringArrayField(preferences.subscribed_risk_levels ?? preferences.subscribedRiskLevels),
|
|
notify_on_timeout: typeof preferences.notify_on_timeout === "boolean" ? preferences.notify_on_timeout : null,
|
|
updated_at: stringField(preferences.updated_at ?? preferences.updatedAt) || null,
|
|
};
|
|
}
|
|
|
|
function riskPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
action_kind: stringField(payload.action_kind ?? payload.actionKind) || null,
|
|
action_label: stringField(payload.action_label ?? payload.actionLabel) || null,
|
|
risk_level: stringField(payload.risk_level ?? payload.riskLevel) || null,
|
|
decision: stringField(payload.decision) || null,
|
|
status: stringField(payload.status) || null,
|
|
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
|
|
policy_source: stringField(payload.policy_source ?? payload.policySource) || null,
|
|
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
|
|
correlation_id: stringField(payload.correlation_id ?? payload.correlationId) || null,
|
|
actor: stringField(payload.actor) || null,
|
|
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
|
|
task_id: stringField(payload.task_id ?? payload.taskId) || null,
|
|
run_id: stringField(payload.run_id ?? payload.runId) || null,
|
|
};
|
|
}
|
|
|
|
function artifactEventBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const retention = objectRecord(payload.retention_summary);
|
|
return {
|
|
artifact_id: stringField(payload.artifact_id ?? payload.artifactId) || null,
|
|
action: stringField(payload.action) || null,
|
|
status: stringField(payload.status) || null,
|
|
retention_status: stringField(payload.retention_status ?? retention?.retention_status) || null,
|
|
retention_until: stringField(payload.retention_until ?? retention?.retention_until) || null,
|
|
actor: stringField(payload.actor) || null,
|
|
};
|
|
}
|
|
|
|
function skillCallBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
call_id: stringField(payload.call_id ?? payload.callId) || null,
|
|
source: stringField(payload.source) || null,
|
|
server_id: stringField(payload.server_id ?? payload.serverId) || null,
|
|
tool_name: stringField(payload.tool_name ?? payload.toolName) || null,
|
|
skill_id: stringField(payload.skill_id ?? payload.skillId) || null,
|
|
decision: stringField(payload.decision) || null,
|
|
reason_codes: stringArrayField(payload.reason_codes ?? payload.reasonCodes),
|
|
};
|
|
}
|
|
|
|
function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
step_id: stringField(payload.step_id ?? payload.stepId) || null,
|
|
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
|
|
task_id: stringField(payload.task_id ?? payload.taskId) || null,
|
|
run_id: stringField(payload.run_id ?? payload.runId) || null,
|
|
status: stringField(payload.status) || null,
|
|
reason: stringField(payload.reason) || null,
|
|
trigger_source: stringField(payload.trigger_source ?? payload.triggerSource) || null,
|
|
orchestration_id: stringField(payload.orchestration_id ?? payload.orchestrationId) || null,
|
|
};
|
|
}
|
|
|
|
function planStepLeaseBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const lease = objectRecord(payload.lease) ?? payload;
|
|
return {
|
|
step_id: stringField(payload.step_id ?? payload.stepId) || null,
|
|
plan_id: stringField(payload.plan_id ?? payload.planId) || null,
|
|
task_id: stringField(payload.task_id ?? payload.taskId) || null,
|
|
run_id: stringField(payload.run_id ?? payload.runId) || null,
|
|
lease_id: stringField(payload.lease_id ?? payload.leaseId ?? lease.lease_id ?? lease.leaseId) || null,
|
|
lease_state: stringField(payload.lease_state ?? payload.leaseState ?? lease.state) || null,
|
|
lease_owner_device_id: stringField(payload.lease_owner_device_id ?? payload.leaseOwnerDeviceId ?? lease.owner_device_id ?? lease.ownerDeviceId) || null,
|
|
lease_acquired_at: stringField(lease.acquired_at ?? lease.acquiredAt) || null,
|
|
lease_expires_at: stringField(payload.lease_expires_at ?? payload.leaseExpiresAt ?? lease.expires_at ?? lease.expiresAt) || null,
|
|
lease_released_at: stringField(lease.released_at ?? lease.releasedAt) || null,
|
|
lease_release_reason: stringField(lease.release_reason ?? lease.releaseReason) || null,
|
|
lease_source: stringField(payload.lease_source ?? payload.leaseSource ?? lease.source) || null,
|
|
remote_lease_id: stringField(payload.remote_lease_id ?? payload.remoteLeaseId ?? lease.remote_lease_id ?? lease.remoteLeaseId) || null,
|
|
remote_lease_error: stringField(payload.remote_lease_error ?? payload.remoteLeaseError ?? lease.remote_error ?? lease.remoteError) || null,
|
|
remote_release_error: stringField(payload.remote_release_error ?? payload.remoteReleaseError ?? lease.remote_release_error ?? lease.remoteReleaseError) || null,
|
|
remote_lease_rejected: payload.remote_lease_rejected === true || stringField(payload.reason) === "remote_lease_rejected",
|
|
reason: stringField(payload.reason) || null,
|
|
};
|
|
}
|
|
|
|
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
|
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
|
|
return {
|
|
step_id: stringField(payload.stepId ?? payload.step_id) || null,
|
|
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
|
|
status: stringField(payload.status ?? dependencyGraph.status) || null,
|
|
dependency_count: dependencies.length,
|
|
blocked_dependencies: stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies),
|
|
waiting_dependencies: stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies),
|
|
satisfied_dependencies: stringArrayField(dependencyGraph.satisfiedDependencies ?? dependencyGraph.satisfied_dependencies),
|
|
};
|
|
}
|
|
|
|
function planStepDispatchPolicyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const metadata = objectRecord(payload.metadata) ?? {};
|
|
const state = objectRecord(payload.state) ?? {};
|
|
const policy = objectRecord(metadata.plan_step_dispatch_policy)
|
|
?? objectRecord(state.planStepDispatchPolicy)
|
|
?? {};
|
|
return {
|
|
action: stringField(metadata.action ?? payload.action) || "update_plan_step_dispatch_policy",
|
|
source: stringField(metadata.source ?? policy.source) || null,
|
|
pull_ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
|
|
pull_error: stringField(metadata.error ?? policy.last_pull_error) || null,
|
|
pull_endpoint: stringField(metadata.endpoint) || null,
|
|
policy_enabled: typeof policy.enabled === "boolean" ? policy.enabled : null,
|
|
max_per_sweep: numberField(policy.max_per_sweep),
|
|
auto_dispatch_ready_steps: typeof policy.auto_dispatch_ready_steps === "boolean" ? policy.auto_dispatch_ready_steps : null,
|
|
updated_at: stringField(policy.updated_at) || null,
|
|
remote_updated_at: stringField(policy.remote_updated_at) || null,
|
|
last_pulled_at: stringField(policy.last_pulled_at) || null,
|
|
};
|
|
}
|
|
|
|
function governanceProvideTaskInputBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const commandPayload = objectRecord(payload.payload) ?? {};
|
|
const result = objectRecord(payload.result) ?? {};
|
|
const input = stringField(commandPayload.input ?? payload.input);
|
|
return {
|
|
command_kind: stringField(payload.commandKind ?? payload.command_kind) || "provide_task_input",
|
|
plan_id: stringField(payload.planId ?? payload.plan_id ?? commandPayload.plan_id ?? result.plan_id) || null,
|
|
task_id: stringField(payload.taskId ?? payload.task_id ?? commandPayload.task_id ?? result.task_id) || null,
|
|
source: stringField(commandPayload.source ?? result.source ?? payload.source) || null,
|
|
notification_id: stringField(commandPayload.notification_id ?? payload.notification_id) || null,
|
|
input_length: input ? input.length : null,
|
|
};
|
|
}
|
|
|
|
function notificationBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const metadata = objectRecord(payload.metadata) ?? {};
|
|
const state = objectRecord(payload.state) ?? {};
|
|
const preferences = objectRecord(metadata.notification_preferences) ?? objectRecord(state.notificationPreferences) ?? {};
|
|
const overlays = objectRecord(state.notificationStateById) ?? {};
|
|
const notificationId = stringField(metadata.notification_id ?? payload.notification_id ?? payload.notificationId) || null;
|
|
const overlay = notificationId ? objectRecord(overlays[notificationId]) ?? {} : {};
|
|
const reply = stringField(overlay.reply ?? metadata.reply ?? payload.reply);
|
|
return {
|
|
notification_id: notificationId,
|
|
action: stringField(metadata.action ?? payload.action) || null,
|
|
notification_action: stringField(metadata.notification_action ?? payload.notification_action) || null,
|
|
status: stringField(overlay.status ?? payload.status) || null,
|
|
read_at: stringField(overlay.read_at ?? payload.read_at ?? payload.readAt) || null,
|
|
dismissed_at: stringField(overlay.dismissed_at ?? payload.dismissed_at ?? payload.dismissedAt) || null,
|
|
replied_at: stringField(overlay.replied_at ?? payload.replied_at ?? payload.repliedAt) || 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,
|
|
reply_length: numberField(metadata.reply_length) ?? (reply ? reply.length : null),
|
|
preferences_enabled: typeof preferences.enabled === "boolean" ? preferences.enabled : null,
|
|
muted_kinds: stringArrayField(preferences.muted_kinds ?? preferences.mutedKinds),
|
|
muted_levels: stringArrayField(preferences.muted_levels ?? preferences.mutedLevels),
|
|
};
|
|
}
|
|
|
|
function eventBusinessCategory(kind: string): string {
|
|
if (kind === "route_decision" || kind === "route_decision_blocked" || kind === "route_decision_approval_required" || kind === "route_decision_intervention_resolved") 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";
|
|
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
|
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
|
if (isPlanStepLeaseEvent(kind)) return "dispatch";
|
|
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
|
|
if (isNotificationEvent(kind)) return "notification";
|
|
if (kind.startsWith("governance_")) return "governance";
|
|
return "runtime";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
|
return kind === "digital_workday_update_plan_step_dispatch_policy"
|
|
|| kind === "digital_workday_pull_plan_step_dispatch_policy";
|
|
}
|
|
|
|
function isPlanStepLeaseEvent(kind: string): boolean {
|
|
return kind === "plan_step_lease_acquired" || kind === "plan_step_lease_released" || kind === "plan_step_lease_skipped";
|
|
}
|
|
|
|
function isNotificationEvent(kind: string): boolean {
|
|
return kind === "digital_workday_update_notification_preferences"
|
|
|| kind === "digital_workday_notification_read"
|
|
|| kind === "digital_workday_notification_dismiss"
|
|
|| kind === "digital_workday_notification_reply"
|
|
|| kind === "digital_workday_notification_updates_pulled"
|
|
|| kind === "digital_workday_notification_updates_pull_failed"
|
|
|| kind === "digital_workday_notification_action_submitted"
|
|
|| kind === "digital_workday_notification_action_submit_failed";
|
|
}
|
|
|
|
function routeDecisionAttentionLevel(policyResult: string, reasonCodes: string[], createdCommandId: string | null): "info" | "action" | "warn" | "error" {
|
|
if (policyResult && policyResult !== "accepted") return "error";
|
|
if (reasonCodes.includes("candidate_skills_disabled") || reasonCodes.includes("route_action_missing_context")) return "warn";
|
|
if (createdCommandId) return "action";
|
|
return "info";
|
|
}
|
|
|
|
function compactPreview(value: string, maxLength = 120): string | null {
|
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
if (!compact) return null;
|
|
return compact.length > maxLength ? `${compact.slice(0, maxLength)}...` : compact;
|
|
}
|
|
|
|
function markRowsSyncing(rows: OutboxRow[], now: string): void {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return;
|
|
const tx = db.transaction(() => {
|
|
const stmt = db.prepare(`
|
|
UPDATE digital_sync_outbox
|
|
SET status = 'syncing', attempts = attempts + 1, last_attempt_at = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`);
|
|
for (const row of rows) stmt.run(now, now, row.id);
|
|
});
|
|
tx();
|
|
}
|
|
|
|
function markRowsSynced(
|
|
acks: SyncAck[],
|
|
now: string,
|
|
sourceRows: OutboxRow[] = [],
|
|
): void {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return;
|
|
const tx = db.transaction(() => {
|
|
const outboxStmt = db.prepare(`
|
|
UPDATE digital_sync_outbox
|
|
SET status = 'synced', error = NULL, updated_at = ?
|
|
WHERE id = ?
|
|
`);
|
|
for (const ack of acks) {
|
|
const outboxId =
|
|
ack.outbox_id ||
|
|
ack.id ||
|
|
sourceRows.find((row) => ackMatchesRow(ack, row))?.id;
|
|
if (!outboxId) continue;
|
|
outboxStmt.run(now, outboxId);
|
|
updateEntitySyncStatus(ack, now);
|
|
}
|
|
});
|
|
tx();
|
|
}
|
|
|
|
function ackMatchesRow(ack: SyncAck, row: OutboxRow): boolean {
|
|
const outboxId = ack.outbox_id || ack.id;
|
|
if (outboxId) return outboxId === row.id;
|
|
return ack.entity_type === row.entity_type && ack.entity_id === row.entity_id;
|
|
}
|
|
|
|
function recordSyncAttempts(
|
|
rows: OutboxRow[],
|
|
config: DigitalEmployeeSyncConfig,
|
|
status: "synced" | "failed",
|
|
error: string | null,
|
|
startedAt: string,
|
|
finishedAt: string,
|
|
): void {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return;
|
|
const tx = db.transaction(() => {
|
|
const stmt = db.prepare(`
|
|
INSERT INTO digital_sync_attempts (
|
|
outbox_id, entity_type, entity_id, operation, attempt_no,
|
|
status, error, endpoint, started_at, finished_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
for (const row of rows) {
|
|
stmt.run(
|
|
row.id,
|
|
row.entity_type,
|
|
row.entity_id,
|
|
row.operation,
|
|
row.attempts + 1,
|
|
status,
|
|
error,
|
|
config.endpoint,
|
|
startedAt,
|
|
finishedAt,
|
|
);
|
|
}
|
|
db.prepare("DELETE FROM digital_sync_attempts WHERE finished_at < ?").run(
|
|
new Date(Date.parse(finishedAt) - ATTEMPT_HISTORY_RETENTION_MS).toISOString(),
|
|
);
|
|
});
|
|
tx();
|
|
}
|
|
|
|
function updateEntitySyncStatus(ack: SyncAck, now: string): void {
|
|
if (!ack.entity_type || !ack.entity_id) return;
|
|
const table = entityTable(ack.entity_type);
|
|
if (!table) return;
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return;
|
|
db.prepare(`
|
|
UPDATE ${table}
|
|
SET sync_status = 'synced',
|
|
remote_id = COALESCE(?, remote_id),
|
|
last_synced_at = ?,
|
|
sync_error = NULL
|
|
WHERE id = ?
|
|
`).run(ack.remote_id || null, now, ack.entity_id);
|
|
}
|
|
|
|
function markRowsFailed(rows: OutboxRow[], now: string, error: string): void {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return;
|
|
const tx = db.transaction(() => {
|
|
const stmt = db.prepare(`
|
|
UPDATE digital_sync_outbox
|
|
SET status = 'failed', error = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`);
|
|
for (const row of rows) {
|
|
stmt.run(error, now, row.id);
|
|
markEntityFailed(row.entity_type, row.entity_id, error);
|
|
}
|
|
});
|
|
tx();
|
|
}
|
|
|
|
function markEntityFailed(entityType: string, entityId: string, error: string): void {
|
|
const table = entityTable(entityType);
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db || !table) return;
|
|
db.prepare(`
|
|
UPDATE ${table}
|
|
SET sync_status = 'failed', sync_error = ?
|
|
WHERE id = ?
|
|
`).run(error, entityId);
|
|
}
|
|
|
|
function countOutbox(status: string): number {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return 0;
|
|
const row = db
|
|
.prepare("SELECT COUNT(*) AS count FROM digital_sync_outbox WHERE status = ?")
|
|
.get(status) as { count: number } | undefined;
|
|
return row?.count ?? 0;
|
|
}
|
|
|
|
function readFailureSummary(): DigitalEmployeeSyncFailureSummary {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return { total: 0, dueForRetry: 0, backoff: 0, byEntityType: [] };
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT entity_type, attempts, last_attempt_at
|
|
FROM digital_sync_outbox
|
|
WHERE status = 'failed'
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
entity_type: string;
|
|
attempts: number;
|
|
last_attempt_at: string | null;
|
|
}>;
|
|
const groups = new Map<string, number>();
|
|
let dueForRetry = 0;
|
|
|
|
for (const row of rows) {
|
|
groups.set(row.entity_type, (groups.get(row.entity_type) || 0) + 1);
|
|
const nextRetryAt = nextRetryAtForFailure(row.last_attempt_at, row.attempts);
|
|
if (!nextRetryAt || Date.now() >= Date.parse(nextRetryAt)) {
|
|
dueForRetry += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: rows.length,
|
|
dueForRetry,
|
|
backoff: rows.length - dueForRetry,
|
|
byEntityType: Array.from(groups.entries())
|
|
.map(([entityType, count]) => ({ entityType, count }))
|
|
.sort((left, right) => right.count - left.count),
|
|
};
|
|
}
|
|
|
|
function readRecentFailures(
|
|
config: DigitalEmployeeSyncConfig,
|
|
limit = 3,
|
|
): DigitalEmployeeSyncFailure[] {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return [];
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT id, entity_type, entity_id, operation, attempts,
|
|
last_attempt_at, error, created_at, updated_at
|
|
FROM digital_sync_outbox
|
|
WHERE status = 'failed'
|
|
ORDER BY COALESCE(last_attempt_at, updated_at, created_at) DESC
|
|
LIMIT ?
|
|
`,
|
|
)
|
|
.all(limit) as Array<{
|
|
id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
operation: string;
|
|
attempts: number;
|
|
last_attempt_at: string | null;
|
|
error: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}>;
|
|
|
|
return rows.map((row) => {
|
|
const nextRetryAt = nextRetryAtForFailure(row.last_attempt_at, row.attempts);
|
|
return {
|
|
id: row.id,
|
|
entityType: row.entity_type,
|
|
entityId: row.entity_id,
|
|
entitySummary: readEntitySummary(row.entity_type, row.entity_id),
|
|
operation: row.operation,
|
|
attempts: row.attempts,
|
|
lastAttemptAt: row.last_attempt_at,
|
|
nextRetryAt,
|
|
dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt),
|
|
managementRecordUrl: buildManagementRecordUrl(config, row),
|
|
attemptHistory: readAttemptHistory(row.id),
|
|
error: row.error,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
});
|
|
}
|
|
|
|
function readEntitySummary(
|
|
entityType: string,
|
|
entityId: string,
|
|
): DigitalEmployeeSyncEntitySummary | null {
|
|
const table = entityTable(entityType);
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db || !table) return null;
|
|
const select = entitySummarySelect(entityType);
|
|
if (!select) return null;
|
|
const row = db.prepare(select).get(entityId) as Record<string, unknown> | undefined;
|
|
if (!row) return null;
|
|
const payload = parsePayload(String(row.payload ?? "{}"));
|
|
const payloadSummary = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
? payload as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
title: stringField(row.title) || stringField(payloadSummary.title) || null,
|
|
status: stringField(row.status) || stringField(payloadSummary.status) || null,
|
|
summary:
|
|
stringField(row.summary) ||
|
|
stringField(row.objective) ||
|
|
stringField(row.message) ||
|
|
stringField(payloadSummary.objective) ||
|
|
stringField(payloadSummary.message) ||
|
|
stringField(payloadSummary.summary) ||
|
|
null,
|
|
};
|
|
}
|
|
|
|
function entitySummarySelect(entityType: string): string | null {
|
|
switch (entityType) {
|
|
case "plan":
|
|
return "SELECT title, status, objective, payload FROM digital_plans WHERE id = ?";
|
|
case "task":
|
|
return "SELECT title, status, NULL AS objective, payload FROM digital_tasks WHERE id = ?";
|
|
case "plan_step":
|
|
return "SELECT title, status, NULL AS objective, payload FROM digital_plan_steps WHERE id = ?";
|
|
case "schedule":
|
|
return "SELECT name AS title, status, description AS objective, payload FROM digital_schedules WHERE id = ?";
|
|
case "schedule_run":
|
|
return "SELECT id AS title, status, error AS objective, payload FROM digital_schedule_runs WHERE id = ?";
|
|
case "run":
|
|
return "SELECT id AS title, status, NULL AS objective, payload FROM digital_runs WHERE id = ?";
|
|
case "event":
|
|
return "SELECT kind AS title, kind AS status, message, payload FROM digital_events WHERE id = ?";
|
|
case "artifact":
|
|
return "SELECT name AS title, kind AS status, uri AS summary, payload FROM digital_artifacts WHERE id = ?";
|
|
case "approval":
|
|
return "SELECT title, status, NULL AS objective, payload FROM digital_approvals WHERE id = ?";
|
|
case "memory":
|
|
return "SELECT key AS title, status, content AS summary, payload FROM digital_memories WHERE id = ?";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function stringField(value: unknown): string {
|
|
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
}
|
|
|
|
function stringArrayField(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map(stringField).filter(Boolean);
|
|
}
|
|
|
|
function numberField(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function objectRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function readAttemptHistory(
|
|
outboxId: string,
|
|
limit = 5,
|
|
): DigitalEmployeeSyncAttempt[] {
|
|
const db = getDigitalEmployeeDb();
|
|
if (!db) return [];
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT attempt_no, status, error, endpoint, started_at, finished_at
|
|
FROM digital_sync_attempts
|
|
WHERE outbox_id = ?
|
|
ORDER BY finished_at DESC, id DESC
|
|
LIMIT ?
|
|
`,
|
|
)
|
|
.all(outboxId, limit) as Array<{
|
|
attempt_no: number;
|
|
status: string;
|
|
error: string | null;
|
|
endpoint: string | null;
|
|
started_at: string;
|
|
finished_at: string;
|
|
}>;
|
|
|
|
return rows.map((row) => ({
|
|
attemptNo: row.attempt_no,
|
|
status: row.status,
|
|
error: row.error,
|
|
endpoint: row.endpoint,
|
|
startedAt: row.started_at,
|
|
finishedAt: row.finished_at,
|
|
}));
|
|
}
|
|
|
|
function buildManagementRecordUrl(
|
|
config: DigitalEmployeeSyncConfig,
|
|
row: {
|
|
id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
},
|
|
): string | null {
|
|
if (!config.endpoint) return null;
|
|
try {
|
|
const endpoint = new URL(config.endpoint);
|
|
const url = new URL(MANAGEMENT_SYNC_RECORD_PATH, endpoint.origin);
|
|
url.searchParams.set("device_id", getDeviceId());
|
|
url.searchParams.set("entity_type", row.entity_type);
|
|
url.searchParams.set("entity_id", row.entity_id);
|
|
url.searchParams.set("outbox_id", row.id);
|
|
return url.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nextRetryAtForFailure(
|
|
lastAttemptAt: string | null,
|
|
attempts: number,
|
|
): string | null {
|
|
if (!lastAttemptAt) return null;
|
|
const lastAttemptTime = Date.parse(lastAttemptAt);
|
|
if (!Number.isFinite(lastAttemptTime)) return null;
|
|
return new Date(lastAttemptTime + retryDelayMs(attempts)).toISOString();
|
|
}
|
|
|
|
function entityTable(entityType: string): string | null {
|
|
switch (entityType) {
|
|
case "plan":
|
|
return "digital_plans";
|
|
case "task":
|
|
return "digital_tasks";
|
|
case "plan_step":
|
|
return "digital_plan_steps";
|
|
case "schedule":
|
|
return "digital_schedules";
|
|
case "schedule_run":
|
|
return "digital_schedule_runs";
|
|
case "run":
|
|
return "digital_runs";
|
|
case "event":
|
|
return "digital_events";
|
|
case "artifact":
|
|
return "digital_artifacts";
|
|
case "approval":
|
|
return "digital_approvals";
|
|
case "memory":
|
|
return "digital_memories";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parsePayload(payload: string): unknown {
|
|
try {
|
|
return JSON.parse(payload);
|
|
} catch {
|
|
return payload;
|
|
}
|
|
}
|
|
|
|
function normalizeError(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|