吸收数字员工PlanStep本地软租约
This commit is contained in:
@@ -2582,6 +2582,7 @@ function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
|||||||
const payload = asRecord(step.payload) ?? {};
|
const payload = asRecord(step.payload) ?? {};
|
||||||
const dependencyGraph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
const dependencyGraph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||||
const lastDispatch = asRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
|
const lastDispatch = asRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
|
||||||
|
const lease = activePlanStepDispatchLease(payload);
|
||||||
const readiness = planStepDispatchReadiness(step, snapshot, payload);
|
const readiness = planStepDispatchReadiness(step, snapshot, payload);
|
||||||
const blockedDependencies = uniqueStrings(arrayValue(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies));
|
const blockedDependencies = uniqueStrings(arrayValue(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies));
|
||||||
const waitingDependencies = uniqueStrings(arrayValue(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies));
|
const waitingDependencies = uniqueStrings(arrayValue(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_dependencies));
|
||||||
@@ -2610,6 +2611,15 @@ function planStepRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
|||||||
skip_reason: readiness.skipReason,
|
skip_reason: readiness.skipReason,
|
||||||
last_dispatch_status: stringValue(lastDispatch.status) || null,
|
last_dispatch_status: stringValue(lastDispatch.status) || null,
|
||||||
last_dispatch_at: stringValue(lastDispatch.occurredAt ?? lastDispatch.occurred_at) || null,
|
last_dispatch_at: stringValue(lastDispatch.occurredAt ?? lastDispatch.occurred_at) || null,
|
||||||
|
lease,
|
||||||
|
lease_id: stringValue(lease?.lease_id ?? lease?.leaseId) || null,
|
||||||
|
lease_state: stringValue(lease?.state) || null,
|
||||||
|
lease_owner_device_id: stringValue(lease?.owner_device_id ?? lease?.ownerDeviceId) || null,
|
||||||
|
lease_acquired_at: stringValue(lease?.acquired_at ?? lease?.acquiredAt) || null,
|
||||||
|
lease_expires_at: stringValue(lease?.expires_at ?? lease?.expiresAt) || null,
|
||||||
|
lease_released_at: stringValue(lease?.released_at ?? lease?.releasedAt) || null,
|
||||||
|
lease_release_reason: stringValue(lease?.release_reason ?? lease?.releaseReason) || null,
|
||||||
|
lease_active: Boolean(lease),
|
||||||
sync_status: step.syncStatus,
|
sync_status: step.syncStatus,
|
||||||
last_synced_at: step.lastSyncedAt ?? null,
|
last_synced_at: step.lastSyncedAt ?? null,
|
||||||
sync_error: step.syncError ?? null,
|
sync_error: step.syncError ?? null,
|
||||||
@@ -2641,6 +2651,15 @@ function planStepGraphRows(snapshot: QimingclawSnapshot | null): BusinessViewRow
|
|||||||
latest_run_id: stringValue(row.latest_run_id) || null,
|
latest_run_id: stringValue(row.latest_run_id) || null,
|
||||||
dispatchable: row.dispatchable === true,
|
dispatchable: row.dispatchable === true,
|
||||||
skip_reason: stringValue(row.skip_reason) || null,
|
skip_reason: stringValue(row.skip_reason) || null,
|
||||||
|
lease: asRecord(row.lease) ?? null,
|
||||||
|
lease_id: stringValue(row.lease_id) || null,
|
||||||
|
lease_state: stringValue(row.lease_state) || null,
|
||||||
|
lease_owner_device_id: stringValue(row.lease_owner_device_id) || null,
|
||||||
|
lease_acquired_at: stringValue(row.lease_acquired_at) || null,
|
||||||
|
lease_expires_at: stringValue(row.lease_expires_at) || null,
|
||||||
|
lease_released_at: stringValue(row.lease_released_at) || null,
|
||||||
|
lease_release_reason: stringValue(row.lease_release_reason) || null,
|
||||||
|
lease_active: row.lease_active === true,
|
||||||
sync_status: stringValue(row.sync_status) || null,
|
sync_status: stringValue(row.sync_status) || null,
|
||||||
updated_at: stringValue(row.updated_at) || null,
|
updated_at: stringValue(row.updated_at) || null,
|
||||||
entity_type: 'plan_step_graph_node',
|
entity_type: 'plan_step_graph_node',
|
||||||
@@ -2679,8 +2698,10 @@ function planStepDispatchReadiness(
|
|||||||
?? null;
|
?? null;
|
||||||
const taskId = task?.id ?? null;
|
const taskId = task?.id ?? null;
|
||||||
const latestRunId = task ? latestRunForTask(runtimeRuns(snapshot), task.id)?.id ?? null : null;
|
const latestRunId = task ? latestRunForTask(runtimeRuns(snapshot), task.id)?.id ?? null : null;
|
||||||
|
const lease = activePlanStepDispatchLease(payload);
|
||||||
let skipReason: string | null = null;
|
let skipReason: string | null = null;
|
||||||
if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready';
|
if (lease) skipReason = 'lease_active';
|
||||||
|
else if (step.status.toLowerCase() !== 'ready') skipReason = 'not_ready';
|
||||||
else if (!task) skipReason = 'missing_task';
|
else if (!task) skipReason = 'missing_task';
|
||||||
else if (isTerminalRuntimeTaskStatus(task.status)) skipReason = 'task_terminal';
|
else if (isTerminalRuntimeTaskStatus(task.status)) skipReason = 'task_terminal';
|
||||||
else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running';
|
else if (runtimeRuns(snapshot).some((run) => run.taskId === task.id && isActiveRuntimeRunStatus(run.status))) skipReason = 'task_already_running';
|
||||||
@@ -2707,6 +2728,7 @@ function planStepDependencyDetails(dependencyIds: string[], snapshot: Qimingclaw
|
|||||||
}
|
}
|
||||||
const payload = asRecord(dependency.payload) ?? {};
|
const payload = asRecord(dependency.payload) ?? {};
|
||||||
const graph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
const graph = asRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||||
|
const lease = activePlanStepDispatchLease(payload);
|
||||||
const readiness = planStepDispatchReadiness(dependency, snapshot, payload);
|
const readiness = planStepDispatchReadiness(dependency, snapshot, payload);
|
||||||
return {
|
return {
|
||||||
step_id: dependency.id,
|
step_id: dependency.id,
|
||||||
@@ -2717,10 +2739,23 @@ function planStepDependencyDetails(dependencyIds: string[], snapshot: Qimingclaw
|
|||||||
dependency_status: stringValue(graph.status) || dependency.status,
|
dependency_status: stringValue(graph.status) || dependency.status,
|
||||||
dispatchable: readiness.dispatchable,
|
dispatchable: readiness.dispatchable,
|
||||||
skip_reason: readiness.skipReason,
|
skip_reason: readiness.skipReason,
|
||||||
|
lease_state: stringValue(lease?.state) || null,
|
||||||
|
lease_active: Boolean(lease),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activePlanStepDispatchLease(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
const lease = asRecord(payload.dispatchLease ?? payload.dispatch_lease);
|
||||||
|
if (!lease) return null;
|
||||||
|
const state = stringValue(lease.state) || 'active';
|
||||||
|
const expiresAt = stringValue(lease.expires_at ?? lease.expiresAt);
|
||||||
|
const leaseId = stringValue(lease.lease_id ?? lease.leaseId);
|
||||||
|
const ownerDeviceId = stringValue(lease.owner_device_id ?? lease.ownerDeviceId);
|
||||||
|
if (state !== 'active' || !expiresAt || expiresAt <= new Date().toISOString() || !leaseId || !ownerDeviceId) return null;
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
|
||||||
function isTerminalRuntimeTaskStatus(status: string): boolean {
|
function isTerminalRuntimeTaskStatus(status: string): boolean {
|
||||||
return ['completed', 'succeeded', 'success', 'done', 'skipped', 'cancelled', 'canceled'].includes(status.toLowerCase());
|
return ['completed', 'succeeded', 'success', 'done', 'skipped', 'cancelled', 'canceled'].includes(status.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ interface DigitalPlanItem {
|
|||||||
readyStepCount: number;
|
readyStepCount: number;
|
||||||
runningStepCount: number;
|
runningStepCount: number;
|
||||||
blockedStepCount: number;
|
blockedStepCount: number;
|
||||||
|
leasedStepCount: number;
|
||||||
schedulerJob?: CronJob | null;
|
schedulerJob?: CronJob | null;
|
||||||
source: 'plan_template';
|
source: 'plan_template';
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,7 @@ interface PlanStepOrchestrationSummary {
|
|||||||
ready: number;
|
ready: number;
|
||||||
running: number;
|
running: number;
|
||||||
blocked: number;
|
blocked: number;
|
||||||
|
leased: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockedPlanStepInputTarget {
|
interface BlockedPlanStepInputTarget {
|
||||||
@@ -417,6 +419,7 @@ function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], sele
|
|||||||
readyStepCount: 0,
|
readyStepCount: 0,
|
||||||
runningStepCount: 0,
|
runningStepCount: 0,
|
||||||
blockedStepCount: 0,
|
blockedStepCount: 0,
|
||||||
|
leasedStepCount: 0,
|
||||||
schedulerJob: job,
|
schedulerJob: job,
|
||||||
source: 'plan_template',
|
source: 'plan_template',
|
||||||
};
|
};
|
||||||
@@ -441,9 +444,12 @@ function planStepOrchestrationSummary(rows: PlanStepBusinessRow[]): Map<string,
|
|||||||
rows.forEach((step) => {
|
rows.forEach((step) => {
|
||||||
const planId = step.plan_id || '';
|
const planId = step.plan_id || '';
|
||||||
if (!planId) return;
|
if (!planId) return;
|
||||||
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0 };
|
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0, leased: 0 };
|
||||||
const status = step.status.toLowerCase();
|
const status = step.status.toLowerCase();
|
||||||
if (status === 'ready') current.ready += 1;
|
if (step.lease_active || step.skip_reason === 'lease_active') {
|
||||||
|
current.leased += 1;
|
||||||
|
current.running += 1;
|
||||||
|
} else if (status === 'ready' && step.dispatchable) current.ready += 1;
|
||||||
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
|
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
|
||||||
else if (status === 'blocked') current.blocked += 1;
|
else if (status === 'blocked') current.blocked += 1;
|
||||||
summary.set(planId, current);
|
summary.set(planId, current);
|
||||||
@@ -1128,8 +1134,9 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
readyStepCount: orchestration.ready,
|
readyStepCount: orchestration.ready,
|
||||||
runningStepCount: orchestration.running,
|
runningStepCount: orchestration.running,
|
||||||
blockedStepCount: orchestration.blocked,
|
blockedStepCount: orchestration.blocked,
|
||||||
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0
|
leasedStepCount: orchestration.leased,
|
||||||
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}`
|
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0 || orchestration.leased > 0
|
||||||
|
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}${orchestration.leased > 0 ? ` · 租约 ${orchestration.leased}` : ''}`
|
||||||
: item.resultText,
|
: item.resultText,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -211,6 +211,21 @@ export interface PlanStepDependencyDetail {
|
|||||||
dependency_status: string | null;
|
dependency_status: string | null;
|
||||||
dispatchable: boolean;
|
dispatchable: boolean;
|
||||||
skip_reason: string | null;
|
skip_reason: string | null;
|
||||||
|
lease_state?: string | null;
|
||||||
|
lease_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStepLeaseSummary {
|
||||||
|
lease_id?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
owner_device_id?: string | null;
|
||||||
|
acquired_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
released_at?: string | null;
|
||||||
|
release_reason?: string | null;
|
||||||
|
trigger_source?: string | null;
|
||||||
|
orchestration_id?: string | null;
|
||||||
|
candidate_count?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanStepBusinessRow {
|
export interface PlanStepBusinessRow {
|
||||||
@@ -230,6 +245,15 @@ export interface PlanStepBusinessRow {
|
|||||||
blocked_dependency_details?: PlanStepDependencyDetail[];
|
blocked_dependency_details?: PlanStepDependencyDetail[];
|
||||||
dispatchable: boolean;
|
dispatchable: boolean;
|
||||||
skip_reason: string | null;
|
skip_reason: string | null;
|
||||||
|
lease?: PlanStepLeaseSummary | null;
|
||||||
|
lease_id?: string | null;
|
||||||
|
lease_state?: string | null;
|
||||||
|
lease_owner_device_id?: string | null;
|
||||||
|
lease_acquired_at?: string | null;
|
||||||
|
lease_expires_at?: string | null;
|
||||||
|
lease_released_at?: string | null;
|
||||||
|
lease_release_reason?: string | null;
|
||||||
|
lease_active?: boolean;
|
||||||
sync_status?: string | null;
|
sync_status?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
|||||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="./assets/index-DxiObdwW.js"></script>
|
<script type="module" crossorigin src="./assets/index-6K5_HhCb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -314,6 +314,9 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[adapter, "planStepRows", "management plan step rows"],
|
[adapter, "planStepRows", "management plan step rows"],
|
||||||
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
[adapter, "planStepGraphRows", "management plan step graph rows"],
|
||||||
[adapter, "filterPlanStepRows", "management plan step filters"],
|
[adapter, "filterPlanStepRows", "management plan step filters"],
|
||||||
|
[adapter, "activePlanStepDispatchLease", "adapter active plan step lease detector"],
|
||||||
|
[adapter, "lease_active", "adapter lease-active readiness skip reason"],
|
||||||
|
[adapter, "lease_state", "adapter plan step lease state projection"],
|
||||||
[adapter, "blocked_dependency_details", "plan step blocked dependency details"],
|
[adapter, "blocked_dependency_details", "plan step blocked dependency details"],
|
||||||
[adapter, "planStepDependencyDetails", "plan step blocked dependency detail mapper"],
|
[adapter, "planStepDependencyDetails", "plan step blocked dependency detail mapper"],
|
||||||
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
|
[adapter, "update_plan_step_dispatch_policy", "adapter plan step dispatch policy save action"],
|
||||||
@@ -326,11 +329,14 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[types, "trigger_source", "ready step dispatch trigger source type"],
|
[types, "trigger_source", "ready step dispatch trigger source type"],
|
||||||
[types, "orchestration_id", "ready step dispatch orchestration id type"],
|
[types, "orchestration_id", "ready step dispatch orchestration id type"],
|
||||||
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
|
[types, "PlanStepDispatchPolicy", "plan step dispatch policy type"],
|
||||||
|
[types, "PlanStepLeaseSummary", "plan step lease summary type"],
|
||||||
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
|
[types, "PlanStepDependencyDetail", "plan step blocked dependency detail type"],
|
||||||
|
[types, "lease_active", "plan step lease active type field"],
|
||||||
[types, "PlanStepBusinessRow", "plan step business row type"],
|
[types, "PlanStepBusinessRow", "plan step business row type"],
|
||||||
[types, "BusinessViewResponse", "business view response type"],
|
[types, "BusinessViewResponse", "business view response type"],
|
||||||
[commandDeck, "getPlanStepGraph", "home blocked plan step graph fetch"],
|
[commandDeck, "getPlanStepGraph", "home blocked plan step graph fetch"],
|
||||||
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
|
[commandDeck, "planStepOrchestrationSummary", "home plan step orchestration summary"],
|
||||||
|
[commandDeck, "leasedStepCount", "home plan step lease count"],
|
||||||
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
|
[commandDeck, "controlPlanReadySteps", "home plan-scoped ready step control"],
|
||||||
[commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"],
|
[commandDeck, "controlBlockedPlanStep", "home blocked plan step control handler"],
|
||||||
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
|
[commandDeck, "de-blocked-step-control-strip", "home blocked plan step control strip"],
|
||||||
@@ -339,14 +345,26 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
|
|||||||
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
[stateService, "planStepDispatchPolicy", "plan step dispatch policy UI state"],
|
||||||
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
|
[stateService, "update_plan_step_dispatch_policy", "plan step dispatch policy runtime event"],
|
||||||
[stateService, "planIdFilter", "plan-scoped ready step candidate filter"],
|
[stateService, "planIdFilter", "plan-scoped ready step candidate filter"],
|
||||||
|
[stateService, "dispatchLease", "plan step payload lease state"],
|
||||||
|
[stateService, "activeDigitalEmployeePlanStepLease", "plan step active lease helper"],
|
||||||
|
[stateService, "acquireDigitalEmployeePlanStepLease", "plan step lease acquire helper"],
|
||||||
|
[stateService, "releaseDigitalEmployeePlanStepLease", "plan step lease release helper"],
|
||||||
|
[stateService, "plan_step_lease_acquired", "plan step lease acquired event"],
|
||||||
|
[stateService, "plan_step_lease_released", "plan step lease released event"],
|
||||||
|
[stateService, "plan_step_lease_skipped", "plan step lease skipped event"],
|
||||||
[schedulerService, "bypassAutoDispatchSwitch", "manual plan dispatch auto switch bypass"],
|
[schedulerService, "bypassAutoDispatchSwitch", "manual plan dispatch auto switch bypass"],
|
||||||
[schedulerService, "runDigitalEmployeePlanReadyStepsNow", "manual plan-scoped ready step dispatcher"],
|
[schedulerService, "runDigitalEmployeePlanReadyStepsNow", "manual plan-scoped ready step dispatcher"],
|
||||||
|
[schedulerService, "acquireDigitalEmployeePlanStepLease", "scheduler acquire plan step lease"],
|
||||||
|
[schedulerService, "releaseDigitalEmployeePlanStepLease", "scheduler release plan step lease"],
|
||||||
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
|
[schedulerService, "triggerSource", "ready step dispatch trigger source"],
|
||||||
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
|
[schedulerService, "readDigitalEmployeePlanStepDispatchPolicy", "scheduler plan step dispatch policy reader"],
|
||||||
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
|
[schedulerService, "max_per_sweep", "scheduler max per sweep policy"],
|
||||||
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
|
[ipcHandlers, "digitalEmployee:dispatchPlanReadySteps", "IPC plan-scoped ready step dispatch handler"],
|
||||||
[preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
|
[preload, "dispatchPlanReadySteps", "preload plan-scoped ready step bridge"],
|
||||||
[syncService, "planStepBusinessView", "plan step sync business view"],
|
[syncService, "planStepBusinessView", "plan step sync business view"],
|
||||||
|
[syncService, "lease_active", "plan step sync lease active field"],
|
||||||
|
[syncService, "planStepLeaseBusinessView", "plan step lease sync event view"],
|
||||||
|
[syncService, "plan_step_lease_acquired", "plan step lease acquired sync event kind"],
|
||||||
[syncService, "trigger_source", "plan step dispatch sync trigger source"],
|
[syncService, "trigger_source", "plan step dispatch sync trigger source"],
|
||||||
[syncService, "orchestration_id", "plan step dispatch sync orchestration id"],
|
[syncService, "orchestration_id", "plan step dispatch sync orchestration id"],
|
||||||
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
|
[syncService, "planStepDispatchPolicyBusinessView", "plan step dispatch policy sync view"],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const mockState = vi.hoisted(() => ({
|
|||||||
finishedRuns: [] as Array<Record<string, unknown>>,
|
finishedRuns: [] as Array<Record<string, unknown>>,
|
||||||
dispatchEvents: [] as Array<Record<string, unknown>>,
|
dispatchEvents: [] as Array<Record<string, unknown>>,
|
||||||
governanceCommands: [] as Array<Record<string, unknown>>,
|
governanceCommands: [] as Array<Record<string, unknown>>,
|
||||||
|
acquiredLeases: [] as Array<Record<string, unknown>>,
|
||||||
|
releasedLeases: [] as Array<Record<string, unknown>>,
|
||||||
settings: {
|
settings: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
catch_up_on_startup: true,
|
catch_up_on_startup: true,
|
||||||
@@ -54,6 +56,25 @@ vi.mock("./stateService", () => ({
|
|||||||
mockState.dispatchEvents.push(input);
|
mockState.dispatchEvents.push(input);
|
||||||
return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input };
|
return { eventId: `dispatch-${input.status}`, kind: `plan_step_dispatch_${input.status}`, payload: input };
|
||||||
},
|
},
|
||||||
|
acquireDigitalEmployeePlanStepLease: (input: Record<string, unknown>) => {
|
||||||
|
mockState.acquiredLeases.push(input);
|
||||||
|
const candidate = mockState.readyCandidates.find((item) => item.stepId === input.stepId);
|
||||||
|
if (candidate?.leaseActive || candidate?.skipReason === "lease_active") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "lease_active",
|
||||||
|
lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-remote", acquired_at: "2026-06-07T00:59:00.000Z", expires_at: "2026-06-07T01:04:00.000Z" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
lease: { lease_id: `lease-${input.stepId}`, state: "active", owner_device_id: "device-001", acquired_at: input.occurredAt, expires_at: "2026-06-07T01:05:10.000Z" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
releaseDigitalEmployeePlanStepLease: (input: Record<string, unknown>) => {
|
||||||
|
mockState.releasedLeases.push(input);
|
||||||
|
return { ok: true, lease: { lease_id: input.leaseId, state: "released", owner_device_id: "device-001", acquired_at: "2026-06-07T01:00:10.000Z", expires_at: "2026-06-07T01:05:10.000Z", released_at: input.occurredAt, release_reason: input.reason } };
|
||||||
|
},
|
||||||
recordDigitalEmployeeGovernanceCommand: (input: Record<string, unknown>) => {
|
recordDigitalEmployeeGovernanceCommand: (input: Record<string, unknown>) => {
|
||||||
mockState.governanceCommands.push(input);
|
mockState.governanceCommands.push(input);
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +93,8 @@ describe("digital employee scheduler service", () => {
|
|||||||
mockState.finishedRuns = [];
|
mockState.finishedRuns = [];
|
||||||
mockState.dispatchEvents = [];
|
mockState.dispatchEvents = [];
|
||||||
mockState.governanceCommands = [];
|
mockState.governanceCommands = [];
|
||||||
|
mockState.acquiredLeases = [];
|
||||||
|
mockState.releasedLeases = [];
|
||||||
mockState.dispatchPolicy = {
|
mockState.dispatchPolicy = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max_per_sweep: 3,
|
max_per_sweep: 3,
|
||||||
@@ -156,6 +179,8 @@ describe("digital employee scheduler service", () => {
|
|||||||
expect.objectContaining({ method: "POST" }),
|
expect.objectContaining({ method: "POST" }),
|
||||||
);
|
);
|
||||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
|
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "completed"]);
|
||||||
|
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
|
||||||
|
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "completed" })]);
|
||||||
expect(mockState.governanceCommands[0]).toMatchObject({
|
expect(mockState.governanceCommands[0]).toMatchObject({
|
||||||
commandKind: "start_run",
|
commandKind: "start_run",
|
||||||
planId: "plan-1",
|
planId: "plan-1",
|
||||||
@@ -179,9 +204,24 @@ describe("digital employee scheduler service", () => {
|
|||||||
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, failed: 1 });
|
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, failed: 1 });
|
||||||
expect(result.readyStepDispatch.errors[0]).toMatchObject({ stepId: "step-ready", error: "ComputerServer 502: bad gateway" });
|
expect(result.readyStepDispatch.errors[0]).toMatchObject({ stepId: "step-ready", error: "ComputerServer 502: bad gateway" });
|
||||||
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
|
expect(mockState.dispatchEvents.map((event) => event.status)).toEqual(["started", "failed"]);
|
||||||
|
expect(mockState.releasedLeases).toEqual([expect.objectContaining({ stepId: "step-ready", leaseId: "lease-step-ready", reason: "failed" })]);
|
||||||
expect(mockState.governanceCommands).toHaveLength(0);
|
expect(mockState.governanceCommands).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips ready plan steps with active leases", async () => {
|
||||||
|
mockState.schedules = [];
|
||||||
|
mockState.readyCandidates = [readyStepCandidate({ dispatchable: false, skipReason: "lease_active", leaseActive: true })];
|
||||||
|
const { runDigitalEmployeeSchedulerCheck } = await import("./schedulerService");
|
||||||
|
|
||||||
|
const result = await runDigitalEmployeeSchedulerCheck(new Date("2026-06-07T01:00:10.000Z"));
|
||||||
|
|
||||||
|
expect(result.readyStepDispatch).toMatchObject({ candidates: 1, dispatched: 0, skipped: 1, failed: 0 });
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(mockState.acquiredLeases).toEqual([expect.objectContaining({ stepId: "step-ready", triggerSource: "auto" })]);
|
||||||
|
expect(mockState.releasedLeases).toHaveLength(0);
|
||||||
|
expect(mockState.dispatchEvents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("honors disabled ready step dispatch policy", async () => {
|
it("honors disabled ready step dispatch policy", async () => {
|
||||||
mockState.readyCandidates = [readyStepCandidate()];
|
mockState.readyCandidates = [readyStepCandidate()];
|
||||||
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false };
|
mockState.dispatchPolicy = { ...mockState.dispatchPolicy, enabled: false };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import log from "electron-log";
|
|||||||
import { LOCALHOST_HOSTNAME } from "../constants";
|
import { LOCALHOST_HOSTNAME } from "../constants";
|
||||||
import { getConfiguredPorts } from "../startupPorts";
|
import { getConfiguredPorts } from "../startupPorts";
|
||||||
import {
|
import {
|
||||||
|
acquireDigitalEmployeePlanStepLease,
|
||||||
finishDigitalEmployeeScheduleRun,
|
finishDigitalEmployeeScheduleRun,
|
||||||
listDigitalEmployeeReadyPlanStepDispatchCandidates,
|
listDigitalEmployeeReadyPlanStepDispatchCandidates,
|
||||||
listDigitalEmployeeSchedules,
|
listDigitalEmployeeSchedules,
|
||||||
@@ -12,7 +13,9 @@ import {
|
|||||||
recordDigitalEmployeeGovernanceCommand,
|
recordDigitalEmployeeGovernanceCommand,
|
||||||
recordDigitalEmployeePlanStepDispatch,
|
recordDigitalEmployeePlanStepDispatch,
|
||||||
recordDigitalEmployeeScheduleRunStarted,
|
recordDigitalEmployeeScheduleRunStarted,
|
||||||
|
releaseDigitalEmployeePlanStepLease,
|
||||||
upsertDigitalEmployeeSchedule,
|
upsertDigitalEmployeeSchedule,
|
||||||
|
type DigitalEmployeePlanStepLease,
|
||||||
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
type DigitalEmployeeReadyPlanStepDispatchCandidate,
|
||||||
type DigitalEmployeeScheduleRecord,
|
type DigitalEmployeeScheduleRecord,
|
||||||
} from "./stateService";
|
} from "./stateService";
|
||||||
@@ -164,6 +167,17 @@ export async function dispatchReadyPlanSteps(
|
|||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate.dispatchable || !candidate.taskId) {
|
if (!candidate.dispatchable || !candidate.taskId) {
|
||||||
|
if (candidate.skipReason === "lease_active") {
|
||||||
|
acquireDigitalEmployeePlanStepLease({
|
||||||
|
stepId: candidate.stepId,
|
||||||
|
planId: candidate.planId,
|
||||||
|
taskId: candidate.taskId,
|
||||||
|
triggerSource,
|
||||||
|
orchestrationId,
|
||||||
|
candidateCount: candidates.length,
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
result.skipped += 1;
|
result.skipped += 1;
|
||||||
result.skipped_step_ids?.push(candidate.stepId);
|
result.skipped_step_ids?.push(candidate.stepId);
|
||||||
continue;
|
continue;
|
||||||
@@ -173,9 +187,23 @@ export async function dispatchReadyPlanSteps(
|
|||||||
result.skipped_step_ids?.push(candidate.stepId);
|
result.skipped_step_ids?.push(candidate.stepId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
attempts += 1;
|
const leaseResult = acquireDigitalEmployeePlanStepLease({
|
||||||
|
stepId: candidate.stepId,
|
||||||
|
planId: candidate.planId,
|
||||||
|
taskId: candidate.taskId,
|
||||||
|
triggerSource,
|
||||||
|
orchestrationId,
|
||||||
|
candidateCount: candidates.length,
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
if (!leaseResult.ok || !leaseResult.lease) {
|
||||||
|
result.skipped += 1;
|
||||||
|
result.skipped_step_ids?.push(candidate.stepId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await dispatchReadyPlanStep(candidate, now, { triggerSource, orchestrationId, candidateCount: candidates.length });
|
attempts += 1;
|
||||||
|
await dispatchReadyPlanStep(candidate, now, { triggerSource, orchestrationId, candidateCount: candidates.length }, leaseResult.lease);
|
||||||
result.dispatched += 1;
|
result.dispatched += 1;
|
||||||
result.dispatched_step_ids?.push(candidate.stepId);
|
result.dispatched_step_ids?.push(candidate.stepId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,6 +211,13 @@ export async function dispatchReadyPlanSteps(
|
|||||||
result.failed += 1;
|
result.failed += 1;
|
||||||
result.failed_step_ids?.push(candidate.stepId);
|
result.failed_step_ids?.push(candidate.stepId);
|
||||||
result.errors.push({ stepId: candidate.stepId, error: message });
|
result.errors.push({ stepId: candidate.stepId, error: message });
|
||||||
|
releaseDigitalEmployeePlanStepLease({
|
||||||
|
stepId: candidate.stepId,
|
||||||
|
leaseId: leaseResult.lease.lease_id,
|
||||||
|
reason: "failed",
|
||||||
|
taskId: candidate.taskId,
|
||||||
|
occurredAt: now.toISOString(),
|
||||||
|
});
|
||||||
recordDigitalEmployeePlanStepDispatch({
|
recordDigitalEmployeePlanStepDispatch({
|
||||||
stepId: candidate.stepId,
|
stepId: candidate.stepId,
|
||||||
planId: candidate.planId,
|
planId: candidate.planId,
|
||||||
@@ -215,6 +250,7 @@ async function dispatchReadyPlanStep(
|
|||||||
candidate: DigitalEmployeeReadyPlanStepDispatchCandidate,
|
candidate: DigitalEmployeeReadyPlanStepDispatchCandidate,
|
||||||
now: Date,
|
now: Date,
|
||||||
orchestration: { triggerSource: DigitalEmployeeReadyStepDispatchTriggerSource; orchestrationId: string; candidateCount: number },
|
orchestration: { triggerSource: DigitalEmployeeReadyStepDispatchTriggerSource; orchestrationId: string; candidateCount: number },
|
||||||
|
lease: DigitalEmployeePlanStepLease,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const occurredAt = now.toISOString();
|
const occurredAt = now.toISOString();
|
||||||
const commandId = `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`;
|
const commandId = `ready-step-${safeId(candidate.stepId)}-${safeId(occurredAt)}`;
|
||||||
@@ -276,6 +312,14 @@ async function dispatchReadyPlanStep(
|
|||||||
},
|
},
|
||||||
occurredAt,
|
occurredAt,
|
||||||
});
|
});
|
||||||
|
releaseDigitalEmployeePlanStepLease({
|
||||||
|
stepId: candidate.stepId,
|
||||||
|
leaseId: lease.lease_id,
|
||||||
|
reason: "completed",
|
||||||
|
taskId: candidate.taskId,
|
||||||
|
runId: ids.runId,
|
||||||
|
occurredAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDigitalEmployeeScheduleNow(
|
export async function runDigitalEmployeeScheduleNow(
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ vi.mock("../../db", () => ({
|
|||||||
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../system/deviceId", () => ({
|
||||||
|
getDeviceId: vi.fn(() => "device-001"),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("digital employee state service", () => {
|
describe("digital employee state service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -1405,6 +1409,71 @@ describe("digital employee state service", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("acquires, respects, expires, and releases ready plan step leases", async () => {
|
||||||
|
const {
|
||||||
|
acquireDigitalEmployeePlanStepLease,
|
||||||
|
activeDigitalEmployeePlanStepLease,
|
||||||
|
listDigitalEmployeeReadyPlanStepDispatchCandidates,
|
||||||
|
releaseDigitalEmployeePlanStepLease,
|
||||||
|
} = await import("./stateService");
|
||||||
|
|
||||||
|
createGovernableTask({ taskStatus: "pending", stepStatus: "ready", runStatus: "completed", finishedAt: "2026-06-07T07:20:00.000Z" });
|
||||||
|
|
||||||
|
const acquired = acquireDigitalEmployeePlanStepLease({
|
||||||
|
stepId: "step-governance",
|
||||||
|
planId: "plan-governance",
|
||||||
|
taskId: "task-governance",
|
||||||
|
triggerSource: "manual",
|
||||||
|
orchestrationId: "manual-ready-steps-plan-governance",
|
||||||
|
candidateCount: 1,
|
||||||
|
occurredAt: "2026-06-07T08:10:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(acquired).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "active", owner_device_id: "device-001" }) });
|
||||||
|
const leasedPayload = JSON.parse(mockState.db?.planSteps.get("step-governance")?.payload ?? "{}");
|
||||||
|
expect(activeDigitalEmployeePlanStepLease(leasedPayload, "2026-06-07T08:11:00.000Z")).toMatchObject({ lease_id: acquired.lease?.lease_id });
|
||||||
|
expect(listDigitalEmployeeReadyPlanStepDispatchCandidates()[0]).toMatchObject({ dispatchable: false, skipReason: "lease_active" });
|
||||||
|
expect(mockState.db?.outbox.get("plan_step:upsert:step-governance")).toMatchObject({ entity_type: "plan_step", status: "pending" });
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "plan_step_lease_acquired", plan_id: "plan-governance", task_id: "task-governance" }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const blocked = acquireDigitalEmployeePlanStepLease({
|
||||||
|
stepId: "step-governance",
|
||||||
|
planId: "plan-governance",
|
||||||
|
taskId: "task-governance",
|
||||||
|
occurredAt: "2026-06-07T08:11:00.000Z",
|
||||||
|
});
|
||||||
|
expect(blocked).toMatchObject({ ok: false, reason: "lease_active", lease: expect.objectContaining({ lease_id: acquired.lease?.lease_id }) });
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "plan_step_lease_skipped" }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const overwritten = acquireDigitalEmployeePlanStepLease({
|
||||||
|
stepId: "step-governance",
|
||||||
|
planId: "plan-governance",
|
||||||
|
taskId: "task-governance",
|
||||||
|
occurredAt: "2026-06-07T08:16:01.000Z",
|
||||||
|
});
|
||||||
|
expect(overwritten).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "active" }) });
|
||||||
|
expect(overwritten.lease?.lease_id).not.toBe(acquired.lease?.lease_id);
|
||||||
|
|
||||||
|
const released = releaseDigitalEmployeePlanStepLease({
|
||||||
|
stepId: "step-governance",
|
||||||
|
leaseId: overwritten.lease?.lease_id,
|
||||||
|
reason: "completed",
|
||||||
|
taskId: "task-governance",
|
||||||
|
runId: "run-dispatch",
|
||||||
|
occurredAt: "2026-06-07T08:17:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(released).toMatchObject({ ok: true, lease: expect.objectContaining({ state: "released", release_reason: "completed" }) });
|
||||||
|
expect(activeDigitalEmployeePlanStepLease(JSON.parse(mockState.db?.planSteps.get("step-governance")?.payload ?? "{}"), "2026-06-07T08:17:01.000Z")).toBeNull();
|
||||||
|
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "plan_step_lease_released", run_id: "run-dispatch" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it("records ready plan step dispatch events and updates step status", async () => {
|
it("records ready plan step dispatch events and updates step status", async () => {
|
||||||
const { recordDigitalEmployeePlanStepDispatch } = await import("./stateService");
|
const { recordDigitalEmployeePlanStepDispatch } = await import("./stateService");
|
||||||
|
|
||||||
@@ -2431,6 +2500,20 @@ class TestDb {
|
|||||||
return { changes: previous ? 1 : 0 };
|
return { changes: previous ? 1 : 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.startsWith("UPDATE digital_plan_steps SET payload")) {
|
||||||
|
const [payload, updatedAt, id] = args as [string, string, string];
|
||||||
|
const previous = this.planSteps.get(id);
|
||||||
|
if (previous) {
|
||||||
|
this.planSteps.set(id, {
|
||||||
|
...previous,
|
||||||
|
payload,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
sync_status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { changes: previous ? 1 : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.startsWith("UPDATE digital_plan_steps")) {
|
if (sql.startsWith("UPDATE digital_plan_steps")) {
|
||||||
const [status, payload, updatedAt, id] = args as [string, string, string, string];
|
const [status, payload, updatedAt, id] = args as [string, string, string, string];
|
||||||
const previous = this.planSteps.get(id);
|
const previous = this.planSteps.get(id);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
|
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
|
||||||
|
import { getDeviceId } from "../system/deviceId";
|
||||||
|
|
||||||
const STATE_KEY = "digital_employee_state_v1";
|
const STATE_KEY = "digital_employee_state_v1";
|
||||||
const UI_STATE_KEY = "digital_employee_ui_state_v1";
|
const UI_STATE_KEY = "digital_employee_ui_state_v1";
|
||||||
const MAX_EVENTS = 200;
|
const MAX_EVENTS = 200;
|
||||||
|
const PLAN_STEP_LEASE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export interface DigitalEmployeeServiceStatus {
|
export interface DigitalEmployeeServiceStatus {
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
@@ -304,6 +306,37 @@ export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
|
|||||||
plan?: DigitalEmployeePlanRecord | null;
|
plan?: DigitalEmployeePlanRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePlanStepLease {
|
||||||
|
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;
|
||||||
|
trigger_source?: string | null;
|
||||||
|
orchestration_id?: string | null;
|
||||||
|
candidate_count?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePlanStepLeaseResult {
|
||||||
|
ok: boolean;
|
||||||
|
lease?: DigitalEmployeePlanStepLease;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePlanStepLeaseInput {
|
||||||
|
stepId: string;
|
||||||
|
planId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
triggerSource?: string | null;
|
||||||
|
orchestrationId?: string | null;
|
||||||
|
candidateCount?: number | null;
|
||||||
|
occurredAt?: string | null;
|
||||||
|
ttlMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeePlanStepDispatchInput {
|
export interface DigitalEmployeePlanStepDispatchInput {
|
||||||
stepId: string;
|
stepId: string;
|
||||||
planId?: string | null;
|
planId?: string | null;
|
||||||
@@ -1384,6 +1417,124 @@ export function recordDigitalEmployeeApprovalAction(
|
|||||||
return { eventId, kind, payload };
|
return { eventId, kind, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function activeDigitalEmployeePlanStepLease(
|
||||||
|
payload: unknown,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): DigitalEmployeePlanStepLease | null {
|
||||||
|
const record = objectRecord(payload) ?? {};
|
||||||
|
const lease = objectRecord(record.dispatchLease ?? record.dispatch_lease);
|
||||||
|
if (!lease) return null;
|
||||||
|
const state = stringValue(lease.state) || "active";
|
||||||
|
const expiresAt = stringValue(lease.expires_at ?? lease.expiresAt);
|
||||||
|
if (state !== "active" || !expiresAt || expiresAt <= now) return null;
|
||||||
|
const leaseId = stringValue(lease.lease_id ?? lease.leaseId);
|
||||||
|
const ownerDeviceId = stringValue(lease.owner_device_id ?? lease.ownerDeviceId);
|
||||||
|
if (!leaseId || !ownerDeviceId) return null;
|
||||||
|
return {
|
||||||
|
lease_id: leaseId,
|
||||||
|
state: "active",
|
||||||
|
owner_device_id: ownerDeviceId,
|
||||||
|
acquired_at: stringValue(lease.acquired_at ?? lease.acquiredAt) || now,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
released_at: stringValue(lease.released_at ?? lease.releasedAt) || null,
|
||||||
|
release_reason: stringValue(lease.release_reason ?? lease.releaseReason) || null,
|
||||||
|
trigger_source: stringValue(lease.trigger_source ?? lease.triggerSource) || null,
|
||||||
|
orchestration_id: stringValue(lease.orchestration_id ?? lease.orchestrationId) || null,
|
||||||
|
candidate_count: numberValue(lease.candidate_count ?? lease.candidateCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquireDigitalEmployeePlanStepLease(
|
||||||
|
input: DigitalEmployeePlanStepLeaseInput,
|
||||||
|
): DigitalEmployeePlanStepLeaseResult {
|
||||||
|
const db = getDigitalEmployeeDb();
|
||||||
|
if (!db) return { ok: false, reason: "database_unavailable" };
|
||||||
|
const now = input.occurredAt || new Date().toISOString();
|
||||||
|
const stepId = stringValue(input.stepId);
|
||||||
|
if (!stepId) return { ok: false, reason: "missing_step" };
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, plan_id, title, status, seq, depends_on, preferred_skill_ids, payload, created_at
|
||||||
|
FROM digital_plan_steps
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(stepId) as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return { ok: false, reason: "missing_step" };
|
||||||
|
const previousPayload = objectRecord(parseStoredPayload(row.payload)) ?? {};
|
||||||
|
const activeLease = activeDigitalEmployeePlanStepLease(previousPayload, now);
|
||||||
|
if (activeLease) {
|
||||||
|
recordDigitalEmployeePlanStepLeaseEvent({ row, lease: activeLease, kind: "plan_step_lease_skipped", reason: "lease_active", now, taskId: input.taskId, runId: input.runId });
|
||||||
|
return { ok: false, lease: activeLease, reason: "lease_active" };
|
||||||
|
}
|
||||||
|
const ttlMs = Math.max(60_000, Math.min(Number(input.ttlMs) || PLAN_STEP_LEASE_TTL_MS, 30 * 60 * 1000));
|
||||||
|
const lease: DigitalEmployeePlanStepLease = {
|
||||||
|
lease_id: `plan-step-lease:${safeId(stepId)}:${safeId(now)}`,
|
||||||
|
state: "active",
|
||||||
|
owner_device_id: getDeviceId(),
|
||||||
|
acquired_at: now,
|
||||||
|
expires_at: new Date(new Date(now).getTime() + ttlMs).toISOString(),
|
||||||
|
trigger_source: stringValue(input.triggerSource) || null,
|
||||||
|
orchestration_id: stringValue(input.orchestrationId) || null,
|
||||||
|
candidate_count: numberValue(input.candidateCount),
|
||||||
|
};
|
||||||
|
const payload = { ...previousPayload, dispatchLease: lease };
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE digital_plan_steps
|
||||||
|
SET payload = ?, sync_status = 'pending', updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(stringify(payload), now, stepId);
|
||||||
|
markPlanStepLeaseOutbox(row, payload, now);
|
||||||
|
recordDigitalEmployeePlanStepLeaseEvent({ row, lease, kind: "plan_step_lease_acquired", now, taskId: input.taskId, runId: input.runId });
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseDigitalEmployeePlanStepLease(input: {
|
||||||
|
stepId: string;
|
||||||
|
leaseId?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
occurredAt?: string | null;
|
||||||
|
}): DigitalEmployeePlanStepLeaseResult {
|
||||||
|
const db = getDigitalEmployeeDb();
|
||||||
|
if (!db) return { ok: false, reason: "database_unavailable" };
|
||||||
|
const now = input.occurredAt || new Date().toISOString();
|
||||||
|
const stepId = stringValue(input.stepId);
|
||||||
|
if (!stepId) return { ok: false, reason: "missing_step" };
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, plan_id, title, status, seq, depends_on, preferred_skill_ids, payload, created_at
|
||||||
|
FROM digital_plan_steps
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(stepId) as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return { ok: false, reason: "missing_step" };
|
||||||
|
const previousPayload = objectRecord(parseStoredPayload(row.payload)) ?? {};
|
||||||
|
const currentLease = objectRecord(previousPayload.dispatchLease ?? previousPayload.dispatch_lease);
|
||||||
|
const currentLeaseId = stringValue(currentLease?.lease_id ?? currentLease?.leaseId);
|
||||||
|
const expectedLeaseId = stringValue(input.leaseId);
|
||||||
|
if (!currentLease || (expectedLeaseId && currentLeaseId !== expectedLeaseId)) {
|
||||||
|
return { ok: false, reason: "lease_mismatch" };
|
||||||
|
}
|
||||||
|
const lease: DigitalEmployeePlanStepLease = {
|
||||||
|
lease_id: currentLeaseId || `plan-step-lease:${safeId(stepId)}:${safeId(now)}`,
|
||||||
|
state: "released",
|
||||||
|
owner_device_id: stringValue(currentLease.owner_device_id ?? currentLease.ownerDeviceId) || getDeviceId(),
|
||||||
|
acquired_at: stringValue(currentLease.acquired_at ?? currentLease.acquiredAt) || now,
|
||||||
|
expires_at: stringValue(currentLease.expires_at ?? currentLease.expiresAt) || now,
|
||||||
|
released_at: now,
|
||||||
|
release_reason: stringValue(input.reason) || "completed",
|
||||||
|
trigger_source: stringValue(currentLease.trigger_source ?? currentLease.triggerSource) || null,
|
||||||
|
orchestration_id: stringValue(currentLease.orchestration_id ?? currentLease.orchestrationId) || null,
|
||||||
|
candidate_count: numberValue(currentLease.candidate_count ?? currentLease.candidateCount),
|
||||||
|
};
|
||||||
|
const payload = { ...previousPayload, dispatchLease: lease };
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE digital_plan_steps
|
||||||
|
SET payload = ?, sync_status = 'pending', updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(stringify(payload), now, stepId);
|
||||||
|
markPlanStepLeaseOutbox(row, payload, now);
|
||||||
|
recordDigitalEmployeePlanStepLeaseEvent({ row, lease, kind: "plan_step_lease_released", reason: lease.release_reason, now, taskId: input.taskId, runId: input.runId });
|
||||||
|
return { ok: true, lease };
|
||||||
|
}
|
||||||
|
|
||||||
export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|
export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|
||||||
options: number | { limit?: number; planId?: string | null } = 20,
|
options: number | { limit?: number; planId?: string | null } = 20,
|
||||||
): DigitalEmployeeReadyPlanStepDispatchCandidate[] {
|
): DigitalEmployeeReadyPlanStepDispatchCandidate[] {
|
||||||
@@ -1408,6 +1559,7 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|
|||||||
.map((run) => stringValue(run.taskId))
|
.map((run) => stringValue(run.taskId))
|
||||||
.filter(Boolean));
|
.filter(Boolean));
|
||||||
const openApprovals = records.approvals.filter((approval) => isOpenApprovalStatus(approval.status));
|
const openApprovals = records.approvals.filter((approval) => isOpenApprovalStatus(approval.status));
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
return records.planSteps
|
return records.planSteps
|
||||||
.filter((step) => !planIdFilter || step.planId === planIdFilter)
|
.filter((step) => !planIdFilter || step.planId === planIdFilter)
|
||||||
@@ -1431,6 +1583,8 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|
|||||||
|| stringValue(payload.stepId ?? payload.step_id) === step.id;
|
|| stringValue(payload.stepId ?? payload.step_id) === step.id;
|
||||||
});
|
});
|
||||||
const skipReason = readyPlanStepSkipReason({ step, task, activeRunsByTaskId, approvalOpen });
|
const skipReason = readyPlanStepSkipReason({ step, task, activeRunsByTaskId, approvalOpen });
|
||||||
|
const lease = activeDigitalEmployeePlanStepLease(stepPayload, now);
|
||||||
|
const effectiveSkipReason = lease ? "lease_active" : skipReason;
|
||||||
const title = step.title || task?.title || step.id;
|
const title = step.title || task?.title || step.id;
|
||||||
const prompt = stringValue(stepPayload.prompt ?? stepPayload.description)
|
const prompt = stringValue(stepPayload.prompt ?? stepPayload.description)
|
||||||
|| stringValue(taskPayload.prompt ?? taskPayload.description)
|
|| stringValue(taskPayload.prompt ?? taskPayload.description)
|
||||||
@@ -1443,8 +1597,8 @@ export function listDigitalEmployeeReadyPlanStepDispatchCandidates(
|
|||||||
taskTitle: task?.title ?? null,
|
taskTitle: task?.title ?? null,
|
||||||
prompt,
|
prompt,
|
||||||
preferredSkillIds,
|
preferredSkillIds,
|
||||||
dispatchable: !skipReason,
|
dispatchable: !effectiveSkipReason,
|
||||||
skipReason,
|
skipReason: effectiveSkipReason,
|
||||||
step,
|
step,
|
||||||
task,
|
task,
|
||||||
plan,
|
plan,
|
||||||
@@ -1578,6 +1732,66 @@ function updatePlanStepDispatchStatus(
|
|||||||
}, now);
|
}, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markPlanStepLeaseOutbox(row: Record<string, unknown>, payload: Record<string, unknown>, now: string): void {
|
||||||
|
const stepId = stringValue(row.id);
|
||||||
|
markOutbox("plan_step", stepId, "upsert", {
|
||||||
|
id: stepId,
|
||||||
|
planId: stringValue(row.plan_id),
|
||||||
|
title: stringValue(row.title),
|
||||||
|
status: stringValue(row.status),
|
||||||
|
seq: typeof row.seq === "number" ? row.seq : Number(row.seq) || 0,
|
||||||
|
dependsOn: parseStringArray(row.depends_on),
|
||||||
|
preferredSkillIds: parseStringArray(row.preferred_skill_ids),
|
||||||
|
payload,
|
||||||
|
now,
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDigitalEmployeePlanStepLeaseEvent(input: {
|
||||||
|
row: Record<string, unknown>;
|
||||||
|
lease: DigitalEmployeePlanStepLease;
|
||||||
|
kind: "plan_step_lease_acquired" | "plan_step_lease_released" | "plan_step_lease_skipped";
|
||||||
|
reason?: string | null;
|
||||||
|
now: string;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
}): void {
|
||||||
|
const stepId = stringValue(input.row.id);
|
||||||
|
const planId = stringValue(input.row.plan_id) || null;
|
||||||
|
const taskId = stringValue(input.taskId) || null;
|
||||||
|
const payload = {
|
||||||
|
source: "qimingclaw-plan-step-lease",
|
||||||
|
step_id: stepId,
|
||||||
|
plan_id: planId,
|
||||||
|
task_id: taskId,
|
||||||
|
run_id: stringValue(input.runId) || null,
|
||||||
|
lease: input.lease,
|
||||||
|
lease_id: input.lease.lease_id,
|
||||||
|
lease_state: input.lease.state,
|
||||||
|
lease_owner_device_id: input.lease.owner_device_id,
|
||||||
|
lease_expires_at: input.lease.expires_at,
|
||||||
|
reason: stringValue(input.reason) || input.lease.release_reason || null,
|
||||||
|
};
|
||||||
|
insertEvent(
|
||||||
|
{
|
||||||
|
event_id: `${stepId}:lease:${input.kind}:${safeId(input.lease.lease_id)}:${safeId(input.now)}`,
|
||||||
|
kind: input.kind,
|
||||||
|
occurred_at: input.now,
|
||||||
|
message: planStepLeaseMessage(input.kind, stepId, stringValue(input.reason) || input.lease.release_reason),
|
||||||
|
plan_id: planId,
|
||||||
|
task_id: taskId,
|
||||||
|
},
|
||||||
|
stringValue(input.runId) || null,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planStepLeaseMessage(kind: string, stepId: string, reason?: string | null): string {
|
||||||
|
if (kind === "plan_step_lease_acquired") return `计划步骤租约已获取:${stepId}`;
|
||||||
|
if (kind === "plan_step_lease_released") return `计划步骤租约已释放:${stepId}${reason ? `(${reason})` : ""}`;
|
||||||
|
return `计划步骤租约跳过派发:${stepId}${reason ? `(${reason})` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
function planStepDispatchMessage(status: string, stepId: string, reason: string): string {
|
function planStepDispatchMessage(status: string, stepId: string, reason: string): string {
|
||||||
if (status === "started") return `计划步骤已自动派发:${stepId}`;
|
if (status === "started") return `计划步骤已自动派发:${stepId}`;
|
||||||
if (status === "completed") return `计划步骤派发已触发执行:${stepId}`;
|
if (status === "completed") return `计划步骤派发已触发执行:${stepId}`;
|
||||||
|
|||||||
@@ -342,6 +342,16 @@ describe("digital employee sync service", () => {
|
|||||||
},
|
},
|
||||||
lastDispatch: { status: "failed" },
|
lastDispatch: { status: "failed" },
|
||||||
skipReason: "approval_pending",
|
skipReason: "approval_pending",
|
||||||
|
dispatchLease: {
|
||||||
|
lease_id: "lease-step-1",
|
||||||
|
state: "active",
|
||||||
|
owner_device_id: "device-001",
|
||||||
|
acquired_at: "2026-06-07T07:59:00.000Z",
|
||||||
|
expires_at: "2026-06-07T08:04:00.000Z",
|
||||||
|
trigger_source: "manual",
|
||||||
|
orchestration_id: "manual-ready-steps-plan-1",
|
||||||
|
candidate_count: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -382,8 +392,14 @@ describe("digital employee sync service", () => {
|
|||||||
waiting_dependency_count: 1,
|
waiting_dependency_count: 1,
|
||||||
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
|
preferred_skill_ids: ["qimingclaw-mcp-tool-crm-crm-search"],
|
||||||
dispatchable: false,
|
dispatchable: false,
|
||||||
skip_reason: "approval_pending",
|
skip_reason: "lease_active",
|
||||||
last_dispatch_status: "failed",
|
last_dispatch_status: "failed",
|
||||||
|
lease_id: "lease-step-1",
|
||||||
|
lease_state: "active",
|
||||||
|
lease_owner_device_id: "device-001",
|
||||||
|
lease_acquired_at: "2026-06-07T07:59:00.000Z",
|
||||||
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
||||||
|
lease_active: true,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -567,6 +583,26 @@ describe("digital employee sync service", () => {
|
|||||||
trigger_source: "manual",
|
trigger_source: "manual",
|
||||||
orchestration_id: "manual-ready-steps-plan-1",
|
orchestration_id: "manual-ready-steps-plan-1",
|
||||||
});
|
});
|
||||||
|
insertEventEntity("event-lease", "plan_step_lease_released", {
|
||||||
|
step_id: "step-1",
|
||||||
|
plan_id: "plan-1",
|
||||||
|
task_id: "task-1",
|
||||||
|
run_id: "run-1",
|
||||||
|
lease_id: "lease-step-1",
|
||||||
|
lease_state: "released",
|
||||||
|
lease_owner_device_id: "device-001",
|
||||||
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
||||||
|
reason: "completed",
|
||||||
|
lease: {
|
||||||
|
lease_id: "lease-step-1",
|
||||||
|
state: "released",
|
||||||
|
owner_device_id: "device-001",
|
||||||
|
acquired_at: "2026-06-07T07:59:00.000Z",
|
||||||
|
expires_at: "2026-06-07T08:04:00.000Z",
|
||||||
|
released_at: "2026-06-07T08:01:00.000Z",
|
||||||
|
release_reason: "completed",
|
||||||
|
},
|
||||||
|
});
|
||||||
insertEventEntity("event-plan-step-ready", "plan_step_ready", {
|
insertEventEntity("event-plan-step-ready", "plan_step_ready", {
|
||||||
stepId: "step-2",
|
stepId: "step-2",
|
||||||
planId: "plan-1",
|
planId: "plan-1",
|
||||||
@@ -712,6 +748,7 @@ describe("digital employee sync service", () => {
|
|||||||
{ entity_type: "event", entity_id: "event-artifact" },
|
{ entity_type: "event", entity_id: "event-artifact" },
|
||||||
{ entity_type: "event", entity_id: "event-skill" },
|
{ entity_type: "event", entity_id: "event-skill" },
|
||||||
{ entity_type: "event", entity_id: "event-dispatch" },
|
{ entity_type: "event", entity_id: "event-dispatch" },
|
||||||
|
{ entity_type: "event", entity_id: "event-lease" },
|
||||||
{ entity_type: "event", entity_id: "event-plan-step-ready" },
|
{ entity_type: "event", entity_id: "event-plan-step-ready" },
|
||||||
{ entity_type: "event", entity_id: "event-governance-input" },
|
{ entity_type: "event", entity_id: "event-governance-input" },
|
||||||
{ entity_type: "event", entity_id: "event-notification-read" },
|
{ entity_type: "event", entity_id: "event-notification-read" },
|
||||||
@@ -800,6 +837,21 @@ describe("digital employee sync service", () => {
|
|||||||
trigger_source: "manual",
|
trigger_source: "manual",
|
||||||
orchestration_id: "manual-ready-steps-plan-1",
|
orchestration_id: "manual-ready-steps-plan-1",
|
||||||
});
|
});
|
||||||
|
expect(businessView("event-lease")).toMatchObject({
|
||||||
|
category: "dispatch",
|
||||||
|
step_id: "step-1",
|
||||||
|
plan_id: "plan-1",
|
||||||
|
task_id: "task-1",
|
||||||
|
run_id: "run-1",
|
||||||
|
lease_id: "lease-step-1",
|
||||||
|
lease_state: "released",
|
||||||
|
lease_owner_device_id: "device-001",
|
||||||
|
lease_acquired_at: "2026-06-07T07:59:00.000Z",
|
||||||
|
lease_expires_at: "2026-06-07T08:04:00.000Z",
|
||||||
|
lease_released_at: "2026-06-07T08:01:00.000Z",
|
||||||
|
lease_release_reason: "completed",
|
||||||
|
reason: "completed",
|
||||||
|
});
|
||||||
expect(businessView("event-plan-step-ready")).toMatchObject({
|
expect(businessView("event-plan-step-ready")).toMatchObject({
|
||||||
category: "dispatch",
|
category: "dispatch",
|
||||||
step_id: "step-2",
|
step_id: "step-2",
|
||||||
|
|||||||
@@ -508,9 +508,14 @@ function planStepBusinessView(
|
|||||||
const payload = objectRecord(record.payload) ?? {};
|
const payload = objectRecord(record.payload) ?? {};
|
||||||
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||||
const lastDispatch = objectRecord(payload.lastDispatch ?? payload.last_dispatch) ?? {};
|
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 dependsOn = stringArrayField(record.dependsOn ?? record.depends_on);
|
||||||
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
|
const blockedDependencies = stringArrayField(dependencyGraph.blockedDependencies ?? dependencyGraph.blocked_dependencies);
|
||||||
const waitingDependencies = stringArrayField(dependencyGraph.waitingDependencies ?? dependencyGraph.waiting_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 {
|
return {
|
||||||
...base,
|
...base,
|
||||||
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
plan_id: stringField(record.planId ?? record.plan_id) || null,
|
||||||
@@ -524,9 +529,18 @@ function planStepBusinessView(
|
|||||||
waiting_dependencies: waitingDependencies,
|
waiting_dependencies: waitingDependencies,
|
||||||
waiting_dependency_count: waitingDependencies.length,
|
waiting_dependency_count: waitingDependencies.length,
|
||||||
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
|
preferred_skill_ids: stringArrayField(record.preferredSkillIds ?? record.preferred_skill_ids),
|
||||||
dispatchable: stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
|
dispatchable: !leaseActive && stringField(record.status) === "ready" && blockedDependencies.length === 0 && waitingDependencies.length === 0,
|
||||||
skip_reason: stringField(payload.skipReason ?? payload.skip_reason) || null,
|
skip_reason: leaseActive ? "lease_active" : skipReason,
|
||||||
last_dispatch_status: stringField(lastDispatch.status) || null,
|
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_active: leaseActive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +604,7 @@ function eventSpecificBusinessView(
|
|||||||
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
|
if (kind.startsWith("skill_call_")) return skillCallBusinessView(payload);
|
||||||
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
if (isPlanStepDependencyEvent(kind)) return planStepDependencyBusinessView(payload);
|
||||||
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
if (kind.startsWith("plan_step_dispatch_")) return planStepDispatchBusinessView(payload);
|
||||||
|
if (isPlanStepLeaseEvent(kind)) return planStepLeaseBusinessView(payload);
|
||||||
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
|
if (isPlanStepDispatchPolicyEvent(kind)) return planStepDispatchPolicyBusinessView(payload);
|
||||||
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
|
if (kind === "governance_provide_task_input") return governanceProvideTaskInputBusinessView(payload);
|
||||||
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
|
if (isNotificationEvent(kind)) return notificationBusinessView(payload);
|
||||||
@@ -698,6 +713,24 @@ function planStepDispatchBusinessView(payload: Record<string, unknown>): Record<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
reason: stringField(payload.reason) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
function planStepDependencyBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
const dependencyGraph = objectRecord(payload.dependencyGraph ?? payload.dependency_graph) ?? {};
|
||||||
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
|
const dependencies = stringArrayField(payload.dependsOn ?? payload.depends_on);
|
||||||
@@ -771,6 +804,7 @@ function eventBusinessCategory(kind: string): string {
|
|||||||
if (kind.startsWith("skill_call_")) return "skill";
|
if (kind.startsWith("skill_call_")) return "skill";
|
||||||
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
if (isPlanStepDependencyEvent(kind)) return "dispatch";
|
||||||
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
|
||||||
|
if (isPlanStepLeaseEvent(kind)) return "dispatch";
|
||||||
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
|
if (isPlanStepDispatchPolicyEvent(kind)) return "dispatch";
|
||||||
if (isNotificationEvent(kind)) return "notification";
|
if (isNotificationEvent(kind)) return "notification";
|
||||||
if (kind.startsWith("governance_")) return "governance";
|
if (kind.startsWith("governance_")) return "governance";
|
||||||
@@ -789,6 +823,10 @@ function isPlanStepDispatchPolicyEvent(kind: string): boolean {
|
|||||||
return kind === "digital_workday_update_plan_step_dispatch_policy";
|
return kind === "digital_workday_update_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 {
|
function isNotificationEvent(kind: string): boolean {
|
||||||
return kind === "digital_workday_update_notification_preferences"
|
return kind === "digital_workday_update_notification_preferences"
|
||||||
|| kind === "digital_workday_notification_read"
|
|| kind === "digital_workday_notification_read"
|
||||||
|
|||||||
@@ -540,9 +540,9 @@ GET /api/digital-employee/approvals
|
|||||||
|
|
||||||
1. **PlanStep 与依赖图(依赖图调度已开始)**
|
1. **PlanStep 与依赖图(依赖图调度已开始)**
|
||||||
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务;PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`。
|
- 已吸收:Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务;PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`。
|
||||||
- 当前能力:PlanStep 会进入 `digital_sync_outbox`,entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图;Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox;前置步骤跳过、取消或重试后会重新评估后继步骤,推进为 ready / blocked / pending 并写入依赖图事件;scheduler sweep 会识别可派发的 ready PlanStep,调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*` 与 `governance_start_run` 事件;embedded 已提供 `/api/digital-employee/plan-steps`、`/api/digital-employee/plan-step-graph`、`/api/digital-employee/plans/:planId/dispatch-ready-steps` 和 `/api/plan-step/dispatch-policy`,ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略和首页阻塞依赖处理入口已开始闭环。
|
- 当前能力:PlanStep 会进入 `digital_sync_outbox`,entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图;Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox;前置步骤跳过、取消或重试后会重新评估后继步骤,推进为 ready / blocked / pending 并写入依赖图事件;scheduler sweep 会识别可派发的 ready PlanStep,调用本地 `/computer/chat` 并写入 `plan_step_dispatch_*` 与 `governance_start_run` 事件;派发前会写入 5 分钟本地软租约 `payload.dispatchLease`,已有未过期租约的步骤会以 `lease_active` 跳过,派发完成或失败后释放租约并写入 `plan_step_lease_*` 事件;embedded 已提供 `/api/digital-employee/plan-steps`、`/api/digital-employee/plan-step-graph`、`/api/digital-employee/plans/:planId/dispatch-ready-steps` 和 `/api/plan-step/dispatch-policy`,ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
|
||||||
- 后续缺口:多设备 lease 和远端策略拉取仍待吸收。
|
- 后续缺口:真正由管理端仲裁的跨设备强租约和远端策略拉取仍待吸收。
|
||||||
- 建议:下一轮围绕多设备 lease 或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
|
- 建议:下一轮围绕管理端 lease 仲裁或远端策略拉取,把本地可治理调度推进到跨端治理闭环。
|
||||||
|
|
||||||
2. **调度 / 定时 / 周期任务**
|
2. **调度 / 定时 / 周期任务**
|
||||||
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
||||||
|
|||||||
Reference in New Issue
Block a user