吸收数字员工PlanStep管理端强租约

This commit is contained in:
baiyanyun
2026-06-10 23:59:54 +08:00
parent c400367d22
commit 751ce45619
12 changed files with 737 additions and 37 deletions

View File

@@ -11,6 +11,7 @@ import {
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_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";
@@ -38,6 +39,7 @@ interface DigitalEmployeeSyncConfig {
enabled: boolean;
endpoint: string | null;
policyEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
batchSize: number;
@@ -52,6 +54,48 @@ export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
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;
@@ -245,6 +289,80 @@ export async function pullDigitalEmployeePlanStepDispatchPolicy(
}
}
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> {
@@ -375,6 +493,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.policyEndpoint.trim()
: null;
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_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
@@ -385,12 +508,18 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
enabled: !disabled,
endpoint,
policyEndpoint,
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")
@@ -516,6 +645,60 @@ function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Re
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
}
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;
}
@@ -675,6 +858,11 @@ function planStepBusinessView(
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,
};
}
@@ -862,6 +1050,11 @@ function planStepLeaseBusinessView(payload: Record<string, unknown>): Record<str
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,
};
}