吸收数字员工多步审批跨端回写

This commit is contained in:
baiyanyun
2026-06-11 10:44:12 +08:00
parent e9c76c59ca
commit 43418d0453
16 changed files with 1240 additions and 200 deletions

View File

@@ -861,7 +861,7 @@ export function setArtifactRetention(
export function recordApprovalAction(
approvalId: string,
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk',
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'approve' | 'reject',
options: Record<string, string | null | undefined> = {},
): Promise<any> {
return apiFetch(`/api/digital-employee/approvals/${encodeURIComponent(approvalId)}/actions`, {
@@ -870,6 +870,13 @@ export function recordApprovalAction(
});
}
export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
return apiFetch<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>('/api/approval/updates/pull', {
method: 'POST',
body: JSON.stringify({}),
});
}
export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []);

View File

@@ -56,6 +56,8 @@ declare global {
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
@@ -323,6 +325,16 @@ interface QimingclawApprovalActionResult {
payload: Record<string, unknown>;
}
interface ApprovalWorkflowSummary {
workflow_id: string | null;
current_step_id: string | null;
current_step_label: string | null;
current_participant: string | null;
step_index: number | null;
step_count: number | null;
next_step_label: string | null;
}
interface QimingclawManagedServiceActionResponse {
ok: boolean;
service_id: string;
@@ -1010,6 +1022,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/risk-approval/policy/pull') {
return await pullBridgeRiskApprovalPolicy() as T;
}
if (method === 'POST' && pathname === '/api/approval/updates/pull') {
return await pullBridgeApprovalUpdates() as T;
}
if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || '');
const body = parseJsonBody<{ enabled?: boolean }>(options.body);
@@ -1162,6 +1177,25 @@ async function pullBridgeRiskApprovalPolicy(): Promise<{ ok: boolean; skipped?:
}
}
async function pullBridgeApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullApprovalUpdates) {
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullApprovalUpdates();
return {
ok: result?.ok !== false,
applied: Number(result?.applied ?? 0),
ignored: Number(result?.ignored ?? 0),
...(result?.skipped ? { skipped: true } : {}),
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function evaluateBridgeRiskPolicy(input: QimingclawRiskPolicyInput): Promise<QimingclawRiskPolicyResult> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.evaluateRiskPolicy) {
@@ -3281,6 +3315,7 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
const approvalId = stringValue(approval.approval_id);
const runtime = runtimeById.get(approvalId);
const summary = approvalActionSummary(snapshot, approvalId, approval);
const workflow = approvalWorkflowSummary(approval);
const entity = businessEntityFromRefs({
plan_id: approval.plan_id,
task_id: approval.task_id,
@@ -3302,6 +3337,13 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
last_actor: summary.last_actor,
comment_count: summary.comment_count,
delegate_to: summary.delegate_to,
workflow_id: workflow.workflow_id,
current_step_id: workflow.current_step_id,
current_step_label: workflow.current_step_label,
current_participant: workflow.current_participant,
approval_step_index: workflow.step_index,
approval_step_count: workflow.step_count,
next_step_label: workflow.next_step_label,
};
});
}
@@ -3423,6 +3465,25 @@ function filterApprovalHistoryRows(rows: BusinessViewRow[], searchParams: URLSea
.filter((row) => !riskLevel || stringValue(row.risk_level) === riskLevel);
}
function approvalWorkflowSummary(approval: Record<string, unknown>): ApprovalWorkflowSummary {
const payload = asRecord(approval.payload) ?? {};
const workflow = asRecord(payload.workflow) ?? {};
const steps = Array.isArray(workflow.steps) ? workflow.steps.map((step) => asRecord(step) ?? {}) : [];
const currentStepId = stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
const currentIndex = Math.max(0, steps.findIndex((step) => stringValue(step.step_id ?? step.stepId ?? step.id) === currentStepId));
const current = steps[currentIndex] ?? null;
const next = steps.slice(currentIndex + 1).find((step) => ['pending', 'delegated', 'timeout'].includes(stringValue(step.status) || 'pending')) ?? null;
return {
workflow_id: stringValue(workflow.workflow_id ?? workflow.workflowId) || null,
current_step_id: currentStepId || null,
current_step_label: stringValue(current?.label ?? current?.title ?? current?.name) || null,
current_participant: stringValue(current?.participant_label ?? current?.participantLabel ?? current?.participant_id ?? current?.participantId) || null,
step_index: steps.length > 0 ? currentIndex + 1 : null,
step_count: steps.length || null,
next_step_label: stringValue(next?.label ?? next?.title ?? next?.name) || null,
};
}
function approvalActionSummary(
snapshot: QimingclawSnapshot | null,
approvalId: string,
@@ -4581,6 +4642,13 @@ function buildWorkdayDecisions(
last_actor: stringValue(summary?.last_actor) || null,
comment_count: numberValue(summary?.comment_count) ?? 0,
delegate_to: stringValue(summary?.delegate_to) || null,
workflow_id: stringValue(summary?.workflow_id) || null,
current_step_id: stringValue(summary?.current_step_id) || null,
current_step_label: stringValue(summary?.current_step_label) || null,
current_participant: stringValue(summary?.current_participant) || null,
approval_step_index: numberValue(summary?.approval_step_index),
approval_step_count: numberValue(summary?.approval_step_count),
next_step_label: stringValue(summary?.next_step_label) || null,
actions: isDecisionOpenStatus(status)
? [
{ id: 'approved', label: '同意', tone: 'primary' },
@@ -5375,6 +5443,16 @@ async function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest, sn
: undefined,
);
const nextSnapshot = { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState };
if (body.action === 'decide_approval') {
await window.QimingClawBridge?.digital?.submitApprovalDecision?.({
approval_id: body.decision_id,
decision: body.decision,
date,
actor: 'digital_employee_operator',
source: 'sgrobot-digital-adapter',
acp_permission: acpPermissionResult,
}).catch(() => null);
}
const projection = buildWorkday(date, nextSnapshot);
if (body.action === 'generate_daily_report' && projection.daily_report) {
await recordDailyReportArtifact(projection.daily_report);

View File

@@ -11,6 +11,7 @@ import {
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
pullApprovalUpdates,
pullPlanStepDispatchPolicy,
pullRiskApprovalPolicy,
recordApprovalAction,
@@ -1413,6 +1414,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const refreshApprovalUpdates = useCallback(async () => {
setActionBusy('pull_approval_updates');
setActionError(null);
setActionNotice(null);
try {
const result = await pullApprovalUpdates();
if (!mountedRef.current) return;
await fetchProjection();
if (!mountedRef.current) return;
setActionNotice(result.ok
? `审批更新已拉取:应用 ${result.applied ?? 0} 条,忽略 ${result.ignored ?? 0} 条。`
: `审批更新拉取失败:${result.error || '未知错误'}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '拉取审批更新失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
try {
@@ -2081,6 +2103,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<ShieldAlert size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_risk_approval_policy' ? '拉取中...' : '风险策略'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'pull_approval_updates'}
onClick={refreshApprovalUpdates}
title="从管理端拉取审批更新"
>
<RefreshCw size={13} aria-hidden="true" />
<span>{actionBusy === 'pull_approval_updates' ? '拉取中...' : '审批更新'}</span>
</button>
</div>
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>
@@ -3227,6 +3259,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span>{decision.comment_count ? `评论 ${decision.comment_count}` : '暂无评论'}</span>
<span>{decision.delegate_to ? `转交 ${decision.delegate_to}` : '未转交'}</span>
<span>{decision.last_action ? `最近 ${approvalActionLabel(decision.last_action)} · ${decision.last_actor || '未知'}` : '暂无审批动作'}</span>
<span>{decision.approval_step_count ? `步骤 ${decision.approval_step_index || 1}/${decision.approval_step_count}` : '单步审批'}</span>
<span>{decision.current_step_label ? `当前 ${decision.current_step_label}` : '当前步骤待确认'}</span>
<span>{decision.current_participant ? `处理人 ${decision.current_participant}` : '未指定处理人'}</span>
<span>{decision.next_step_label ? `下一步 ${decision.next_step_label}` : '无后续步骤'}</span>
</div>
<div className="de-approval-action-fields">
<input

View File

@@ -874,6 +874,13 @@ export interface DigitalEmployeeDecision {
last_actor?: string | null;
comment_count?: number | null;
delegate_to?: string | null;
workflow_id?: string | null;
current_step_id?: string | null;
current_step_label?: string | null;
current_participant?: string | null;
approval_step_index?: number | null;
approval_step_count?: number | null;
next_step_label?: string | null;
actions: DigitalEmployeeDecisionAction[];
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-COcOCjV1.js"></script>
<script type="module" crossorigin src="./assets/index-DxdWKD94.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head>
<body>

View File

@@ -283,6 +283,62 @@ function checkDigitalApprovalHistorySubscriptions() {
console.log("[DigitalEmployeeCheck] approval history/subscription hooks OK");
}
function checkDigitalApprovalWorkflowSync() {
console.log("\n[DigitalEmployeeCheck] Verify approval workflow/sync hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const ipcHandlersPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const requiredSnippets = [
[stateService, "DigitalEmployeeApprovalWorkflow", "approval workflow type"],
[stateService, "normalizeDigitalEmployeeApprovalWorkflow", "approval workflow normalizer"],
[stateService, "applyApprovalWorkflowDecision", "approval workflow decision advancer"],
[stateService, "applyApprovalWorkflowAction", "approval workflow action updater"],
[stateService, "upsertDigitalEmployeeApprovalFromRemote", "remote approval update upsert helper"],
[stateService, "decision_history", "approval workflow decision history field"],
[syncService, "pullDigitalEmployeeApprovalUpdates", "remote approval updates pull service"],
[syncService, "submitDigitalEmployeeApprovalDecision", "approval decision submit service"],
[syncService, "/api/digital-employee/approvals/updates", "remote approval updates default path"],
[syncService, "/api/digital-employee/approvals/decisions", "remote approval decisions default path"],
[syncService, "approvalUpdatesEndpoint", "approval updates endpoint config"],
[syncService, "approvalDecisionsEndpoint", "approval decisions endpoint config"],
[syncService, "approvalSyncBusinessView", "approval sync business view"],
[syncService, "workflow_id", "approval workflow sync business field"],
[ipcHandlers, "digitalEmployee:pullApprovalUpdates", "IPC approval updates pull handler"],
[ipcHandlers, "digitalEmployee:submitApprovalDecision", "IPC approval decision submit handler"],
[preload, "pullApprovalUpdates", "preload approval updates bridge"],
[preload, "submitApprovalDecision", "preload approval decision bridge"],
[adapter, "pullApprovalUpdates", "adapter approval updates bridge declaration"],
[adapter, "submitApprovalDecision", "adapter approval decision bridge declaration"],
[adapter, "/api/approval/updates/pull", "adapter approval updates pull endpoint"],
[adapter, "approvalWorkflowSummary", "adapter approval workflow summary"],
[api, "pullApprovalUpdates", "embedded approval updates API"],
[types, "current_step_label", "approval decision current step type field"],
[types, "approval_step_count", "approval decision step count type field"],
[commandDeck, "pull_approval_updates", "home approval updates pull action"],
[commandDeck, "审批更新", "home approval updates pull button"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing approval workflow/sync hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] approval workflow/sync hooks OK");
}
function checkDigitalPlanStepGraphDispatchPolicy() {
console.log("\n[DigitalEmployeeCheck] Verify PlanStep graph/dispatch policy hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
@@ -557,6 +613,7 @@ function main() {
checkDigitalDiagnosticAuditProjection();
checkDigitalNotificationPreferences();
checkDigitalApprovalHistorySubscriptions();
checkDigitalApprovalWorkflowSync();
checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy();
checkDigitalRiskApprovalPolicy();

View File

@@ -98,6 +98,8 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
pullDigitalEmployeeSkillPolicies: vi.fn(async () => ({ ok: true, policies: { version: 1, local_skills: {}, remote_skills: {}, skills: {} } })),
pullDigitalEmployeeRiskApprovalPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, approval_required_risk_levels: ["high"], approval_required_actions: [], auto_reject_actions: [] } })),
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
}));
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -311,4 +313,25 @@ describe("digital employee managed service actions", () => {
comment: "需要复核",
});
});
it("registers approval update pull and decision submit through IPC", async () => {
const electron = await import("electron");
const syncService = await import("../services/digitalEmployee/syncService");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullApprovalUpdates");
const submitCall = calls.find(([channel]) => channel === "digitalEmployee:submitApprovalDecision");
expect(pullCall).toBeTruthy();
expect(submitCall).toBeTruthy();
const pullResult = await (pullCall?.[1] as (_event: unknown) => Promise<unknown>)({});
const submitResult = await (submitCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>)({}, { approval_id: "approval-1", decision: "approved" });
expect(pullResult).toMatchObject({ ok: true, applied: 1 });
expect(submitResult).toMatchObject({ ok: true });
expect(syncService.pullDigitalEmployeeApprovalUpdates).toHaveBeenCalledWith({ force: true });
expect(syncService.submitDigitalEmployeeApprovalDecision).toHaveBeenCalledWith({ approval_id: "approval-1", decision: "approved" });
});
});

View File

@@ -58,9 +58,11 @@ import {
import {
flushDigitalEmployeeSyncOutbox,
getDigitalEmployeeSyncStatus,
pullDigitalEmployeeApprovalUpdates,
pullDigitalEmployeePlanStepDispatchPolicy,
pullDigitalEmployeeSkillPolicies,
pullDigitalEmployeeRiskApprovalPolicy,
submitDigitalEmployeeApprovalDecision,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import {
@@ -204,6 +206,14 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeeRiskApprovalPolicy({ force: true });
});
ipcMain.handle("digitalEmployee:pullApprovalUpdates", async () => {
return pullDigitalEmployeeApprovalUpdates({ force: true });
});
ipcMain.handle("digitalEmployee:submitApprovalDecision", async (_, input: unknown) => {
return submitDigitalEmployeeApprovalDecision(typeof input === "object" && input ? input as Record<string, unknown> : {});
});
ipcMain.handle("digitalEmployee:evaluateRiskPolicy", async (_, input: unknown) => {
return evaluateDigitalEmployeeRiskPolicy(typeof input === "object" && input ? input as Parameters<typeof evaluateDigitalEmployeeRiskPolicy>[0] : {});
});

View File

@@ -783,6 +783,107 @@ describe("digital employee state service", () => {
expect(mockState.ensureSchemaCalls).toBeGreaterThan(0);
});
it("advances multi-step approval workflow without completing the approval early", async () => {
const { saveDigitalEmployeeUiState } = await import("./stateService");
mockState.db?.approvals.set("approval-workflow-1", {
id: "approval-workflow-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "高风险动作审批",
status: "pending",
payload: JSON.stringify({
workflow: {
workflow_id: "workflow-1",
current_step_id: "step-1",
steps: [
{ step_id: "step-1", label: "主管审批", status: "pending", participant_id: "lead", participant_label: "主管" },
{ step_id: "step-2", label: "风控复核", status: "pending", participant_id: "risk", participant_label: "风控" },
],
},
}),
sync_status: "synced",
created_at: "2026-06-07T07:00:00.000Z",
updated_at: "2026-06-07T07:00:00.000Z",
});
saveDigitalEmployeeUiState({
action: "decide_approval",
date: "2026-06-07",
metadata: { decision_id: "approval-workflow-1", decision: "approved", actor: "lead" },
state: {
selectedDutyIdsByDate: {},
confirmedDates: {},
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: { "2026-06-07": { "approval-workflow-1": "approved" } },
profile: { name: "飞天数字员工", company: "qimingclaw 客户端", position: "客户端任务执行员", currentStatus: "working" },
},
});
const approval = mockState.db?.approvals.get("approval-workflow-1");
const payload = JSON.parse(approval?.payload ?? "{}");
expect(approval).toMatchObject({ status: "pending", sync_status: "pending" });
expect(payload.workflow).toMatchObject({
workflow_id: "workflow-1",
status: "pending",
current_step_id: "step-2",
steps: [
expect.objectContaining({ step_id: "step-1", status: "approved", actor: "lead" }),
expect.objectContaining({ step_id: "step-2", status: "pending" }),
],
});
const decisionEvent = Array.from(mockState.db?.events.values() ?? []).find((event) => event.kind === "approval_decision");
expect(JSON.parse(decisionEvent?.payload ?? "{}")).toMatchObject({
nextStatus: "pending",
step_id: "step-1",
current_step_id: "step-2",
next_step_id: "step-2",
participant_id: "lead",
});
});
it("updates current approval workflow step when governance actions are recorded", async () => {
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
mockState.db?.approvals.set("approval-workflow-2", {
id: "approval-workflow-2",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
title: "多步审批",
status: "pending",
payload: JSON.stringify({ workflow: { workflow_id: "workflow-2", current_step_id: "step-1", steps: [{ step_id: "step-1", label: "一审", status: "pending" }] } }),
sync_status: "synced",
created_at: "2026-06-07T07:00:00.000Z",
updated_at: "2026-06-07T07:00:00.000Z",
});
const result = recordDigitalEmployeeApprovalAction({
approvalId: "approval-workflow-2",
action: "delegate",
delegateTo: "risk-owner",
delegateLabel: "风控负责人",
actor: "operator",
occurredAt: "2026-06-07T08:05:00.000Z",
});
const payload = JSON.parse(mockState.db?.approvals.get("approval-workflow-2")?.payload ?? "{}");
expect(result?.kind).toBe("approval_action_recorded");
expect(mockState.db?.approvals.get("approval-workflow-2")).toMatchObject({ status: "pending", sync_status: "pending" });
expect(payload.workflow.steps[0]).toMatchObject({
step_id: "step-1",
status: "delegated",
participant_id: "risk-owner",
participant_label: "风控负责人",
});
expect(JSON.parse(mockState.db?.events.get(result!.eventId)?.payload ?? "{}")).toMatchObject({
workflow: expect.objectContaining({ workflow_id: "workflow-2" }),
step_id: "step-1",
step_status: "delegated",
participant_id: "risk-owner",
});
});
it("persists console role and role preferences in UI state", async () => {
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");

View File

@@ -265,7 +265,34 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
payload: Record<string, unknown>;
}
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk";
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk" | "approve" | "reject";
export interface DigitalEmployeeApprovalWorkflowStep {
step_id: string;
label: string;
status: string;
participant_id: string | null;
participant_label: string | null;
decision: string | null;
actor: string | null;
decided_at: string | null;
due_at: string | null;
risk_level: string | null;
sla_status: string | null;
}
export interface DigitalEmployeeApprovalWorkflow {
workflow_id: string;
status: string;
current_step_id: string | null;
steps: DigitalEmployeeApprovalWorkflowStep[];
participants: Array<{ participant_id: string; label: string | null }>;
decision_history: Array<Record<string, unknown>>;
updated_at: string | null;
source?: string | null;
remote_updated_at?: string | null;
last_pull_error?: string | null;
}
export interface DigitalEmployeeApprovalActionInput {
approvalId?: string | null;
@@ -291,6 +318,20 @@ export interface DigitalEmployeeApprovalActionRecord {
payload: Record<string, unknown>;
}
export interface DigitalEmployeeApprovalUpdateInput {
approvalId?: string | null;
remoteId?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
title?: string | null;
status?: string | null;
payload?: Record<string, unknown> | null;
updatedAt?: string | null;
createdAt?: string | null;
source?: string | null;
}
export interface DigitalEmployeeReadyPlanStepDispatchCandidate {
stepId: string;
planId: string;
@@ -1015,6 +1056,14 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工PlanStep派发策略已拉取";
case "pull_risk_approval_policy":
return "数字员工风险审批策略已拉取";
case "approval_updates_pulled":
return "数字员工审批更新已拉取";
case "approval_updates_pull_failed":
return "数字员工审批更新拉取失败";
case "approval_decision_submitted":
return "数字员工审批处理已回写管理端";
case "approval_decision_submit_failed":
return "数字员工审批处理回写失败";
default:
return `数字员工页面状态已更新${suffix}`;
}
@@ -1122,16 +1171,26 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
const planId = stringField(existing, "plan_id");
const taskId = stringField(existing, "task_id");
const runId = stringField(existing, "run_id");
const title = stringField(existing, "title") || "待确认事项";
const title = existing ? stringField(existing, "title") || "待确认事项" : "待确认事项";
const storedPayload = parseStoredPayload(existing["payload"]);
const storedPayloadRecord = objectRecord(storedPayload);
const acpPermission = objectRecord(metadata?.acp_permission);
const actor = stringValue(metadata?.actor) || "digital_employee_operator";
const workflowDecision = applyApprovalWorkflowDecision(
normalizeDigitalEmployeeApprovalWorkflow(storedPayloadRecord ?? {}, decisionId, title, now),
decision,
actor,
now,
);
const payload = storedPayloadRecord
? {
...storedPayloadRecord,
decision,
decidedAt: now,
previousStatus,
workflow: workflowDecision.workflow,
current_step_id: workflowDecision.workflow.current_step_id,
decision_history: workflowDecision.workflow.decision_history,
...(acpPermission ? { acpPermission } : {}),
}
: {
@@ -1139,6 +1198,9 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
decision,
decidedAt: now,
previousStatus,
workflow: workflowDecision.workflow,
current_step_id: workflowDecision.workflow.current_step_id,
decision_history: workflowDecision.workflow.decision_history,
...(acpPermission ? { acpPermission } : {}),
};
upsertApproval({
@@ -1147,7 +1209,7 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
taskId,
runId,
title,
status: nextStatus,
status: workflowDecision.nextStatus || nextStatus,
payload,
now,
});
@@ -1156,7 +1218,15 @@ function recordUiApprovalDecision(update: DigitalEmployeeUiStateUpdate): void {
approvalId: decisionId,
decision,
previousStatus,
nextStatus,
nextStatus: workflowDecision.nextStatus || nextStatus,
workflow: approvalWorkflowSnapshot(workflowDecision.workflow),
step_id: workflowDecision.step?.step_id ?? null,
step_status: workflowDecision.step?.status ?? null,
current_step_id: workflowDecision.workflow.current_step_id,
next_step_id: workflowDecision.nextStep?.step_id ?? null,
participant_id: workflowDecision.step?.participant_id ?? null,
participant_label: workflowDecision.step?.participant_label ?? null,
actor,
title,
planId,
taskId,
@@ -1186,6 +1256,218 @@ function approvalDecisionStatus(decision: string): string {
return "completed";
}
export function normalizeDigitalEmployeeApprovalWorkflow(
payload: Record<string, unknown> | null | undefined,
approvalId: string,
title = "待确认事项",
now = new Date().toISOString(),
): DigitalEmployeeApprovalWorkflow {
const workflow = objectRecord(payload?.workflow ?? payload?.approval_workflow) ?? {};
const rawSteps = Array.isArray(workflow.steps) ? workflow.steps : Array.isArray(payload?.steps) ? payload.steps : [];
const steps = rawSteps
.map((step, index) => normalizeApprovalWorkflowStep(step, index, title))
.filter((step): step is DigitalEmployeeApprovalWorkflowStep => Boolean(step));
const safeSteps = steps.length > 0
? steps
: [normalizeApprovalWorkflowStep({ step_id: `${approvalId}:step:1`, label: title }, 0, title)!];
const currentStepId = stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload?.current_step_id ?? payload?.currentStepId);
const current = safeSteps.find((step) => step.step_id === currentStepId)
?? safeSteps.find((step) => isApprovalWorkflowStepOpen(step.status))
?? safeSteps[safeSteps.length - 1];
const participants = Array.isArray(workflow.participants)
? workflow.participants
.map((participant) => objectRecord(participant))
.filter((participant): participant is Record<string, unknown> => Boolean(participant))
.map((participant) => ({
participant_id: stringValue(participant.participant_id ?? participant.participantId ?? participant.id) || "operator",
label: stringValue(participant.label ?? participant.name) || null,
}))
: approvalWorkflowParticipants(safeSteps);
const history = Array.isArray(workflow.decision_history ?? workflow.decisionHistory ?? payload?.decision_history ?? payload?.decisionHistory)
? [...(workflow.decision_history ?? workflow.decisionHistory ?? payload?.decision_history ?? payload?.decisionHistory) as unknown[]]
.map((item) => objectRecord(item) ?? { value: item })
: [];
return {
workflow_id: stringValue(workflow.workflow_id ?? workflow.workflowId ?? payload?.workflow_id ?? payload?.workflowId) || `approval-workflow:${safeId(approvalId)}`,
status: normalizeApprovalWorkflowStatus(stringValue(workflow.status ?? payload?.workflow_status ?? payload?.status) || "pending"),
current_step_id: current?.step_id ?? null,
steps: safeSteps,
participants,
decision_history: history,
updated_at: stringValue(workflow.updated_at ?? workflow.updatedAt ?? payload?.updated_at ?? payload?.updatedAt) || now,
source: stringValue(workflow.source ?? payload?.source) || null,
remote_updated_at: stringValue(workflow.remote_updated_at ?? workflow.remoteUpdatedAt ?? payload?.remote_updated_at ?? payload?.remoteUpdatedAt) || null,
last_pull_error: stringValue(workflow.last_pull_error ?? workflow.lastPullError ?? payload?.last_pull_error ?? payload?.lastPullError) || null,
};
}
function normalizeApprovalWorkflowStep(
value: unknown,
index: number,
fallbackLabel: string,
): DigitalEmployeeApprovalWorkflowStep | null {
const record = objectRecord(value) ?? {};
const stepId = stringValue(record.step_id ?? record.stepId ?? record.id) || `step-${index + 1}`;
return {
step_id: stepId,
label: stringValue(record.label ?? record.title ?? record.name) || (index === 0 ? fallbackLabel : `审批步骤 ${index + 1}`),
status: normalizeApprovalWorkflowStatus(stringValue(record.status) || "pending"),
participant_id: stringValue(record.participant_id ?? record.participantId ?? record.assignee_id ?? record.assigneeId) || null,
participant_label: stringValue(record.participant_label ?? record.participantLabel ?? record.assignee_label ?? record.assigneeLabel ?? record.assignee) || null,
decision: stringValue(record.decision) || null,
actor: stringValue(record.actor) || null,
decided_at: stringValue(record.decided_at ?? record.decidedAt) || null,
due_at: stringValue(record.due_at ?? record.dueAt) || null,
risk_level: stringValue(record.risk_level ?? record.riskLevel) || null,
sla_status: stringValue(record.sla_status ?? record.slaStatus) || null,
};
}
function normalizeApprovalWorkflowStatus(status: string): string {
const normalized = status.toLowerCase();
if (["approve", "approved", "accept", "accepted"].includes(normalized)) return "approved";
if (["reject", "rejected", "deny", "denied"].includes(normalized)) return "rejected";
if (["dismiss", "dismissed", "skip", "skipped"].includes(normalized)) return "dismissed";
if (["timeout", "timed_out", "overdue"].includes(normalized)) return "timeout";
if (["delegate", "delegated"].includes(normalized)) return "delegated";
if (["completed", "done"].includes(normalized)) return "completed";
return "pending";
}
function isApprovalWorkflowStepOpen(status: string): boolean {
return ["pending", "delegated", "timeout"].includes(normalizeApprovalWorkflowStatus(status));
}
function approvalWorkflowParticipants(steps: DigitalEmployeeApprovalWorkflowStep[]): Array<{ participant_id: string; label: string | null }> {
const map = new Map<string, string | null>();
for (const step of steps) {
if (!step.participant_id) continue;
map.set(step.participant_id, step.participant_label);
}
return Array.from(map, ([participant_id, label]) => ({ participant_id, label }));
}
function applyApprovalWorkflowDecision(
workflow: DigitalEmployeeApprovalWorkflow,
decision: string,
actor: string,
now: string,
): { workflow: DigitalEmployeeApprovalWorkflow; nextStatus: string; step: DigitalEmployeeApprovalWorkflowStep | null; nextStep: DigitalEmployeeApprovalWorkflowStep | null } {
const decisionStatus = approvalDecisionStatus(decision);
const currentIndex = Math.max(0, workflow.steps.findIndex((step) => step.step_id === workflow.current_step_id));
const steps = workflow.steps.map((step) => ({ ...step }));
const step = steps[currentIndex] ?? null;
if (step) {
step.status = decisionStatus;
step.decision = decision;
step.actor = actor || null;
step.decided_at = now;
}
let nextStep: DigitalEmployeeApprovalWorkflowStep | null = null;
let nextStatus = decisionStatus;
if (decisionStatus === "approved") {
nextStep = steps.slice(currentIndex + 1).find((candidate) => isApprovalWorkflowStepOpen(candidate.status)) ?? null;
nextStatus = nextStep ? "pending" : "approved";
}
const historyEntry = {
action: "decision",
decision,
status: decisionStatus,
step_id: step?.step_id ?? null,
participant_id: step?.participant_id ?? null,
actor: actor || null,
occurred_at: now,
};
return {
workflow: {
...workflow,
status: nextStatus,
current_step_id: nextStep?.step_id ?? (nextStatus === "pending" ? step?.step_id ?? null : null),
steps,
participants: approvalWorkflowParticipants(steps),
decision_history: [...workflow.decision_history, historyEntry],
updated_at: now,
},
nextStatus,
step,
nextStep,
};
}
function applyApprovalWorkflowAction(
workflow: DigitalEmployeeApprovalWorkflow,
action: string,
input: DigitalEmployeeApprovalActionInput,
now: string,
): { workflow: DigitalEmployeeApprovalWorkflow; step: DigitalEmployeeApprovalWorkflowStep | null } {
const currentIndex = Math.max(0, workflow.steps.findIndex((step) => step.step_id === workflow.current_step_id));
const steps = workflow.steps.map((step) => ({ ...step }));
const step = steps[currentIndex] ?? null;
if (step) {
if (action === "delegate") {
step.status = "delegated";
step.participant_id = stringValue(input.delegateTo) || step.participant_id;
step.participant_label = stringValue(input.delegateLabel) || stringValue(input.delegateTo) || step.participant_label;
}
if (action === "mark_timeout") {
step.sla_status = "timeout";
step.due_at = stringValue(input.dueAt) || step.due_at;
}
if (action === "mark_risk") step.risk_level = stringValue(input.riskLevel) || "high";
if (action === "clear_risk") step.risk_level = null;
}
return {
workflow: {
...workflow,
status: workflow.status === "approved" || workflow.status === "rejected" ? workflow.status : "pending",
current_step_id: step?.step_id ?? workflow.current_step_id,
steps,
participants: approvalWorkflowParticipants(steps),
decision_history: [...workflow.decision_history, {
action,
step_id: step?.step_id ?? null,
participant_id: step?.participant_id ?? null,
actor: stringValue(input.actor) || null,
occurred_at: now,
}],
updated_at: now,
},
step,
};
}
function approvalWorkflowSnapshot(workflow: DigitalEmployeeApprovalWorkflow): Record<string, unknown> {
return {
workflow_id: workflow.workflow_id,
status: workflow.status,
current_step_id: workflow.current_step_id,
step_count: workflow.steps.length,
steps: workflow.steps,
participants: workflow.participants,
decision_history: workflow.decision_history,
updated_at: workflow.updated_at,
source: workflow.source ?? null,
remote_updated_at: workflow.remote_updated_at ?? null,
last_pull_error: workflow.last_pull_error ?? null,
};
}
function payloadWithApprovalWorkflow(
payload: unknown,
approvalId: string,
title: string,
now: string,
): Record<string, unknown> {
const record = objectRecord(payload) ?? { value: payload };
const workflow = normalizeDigitalEmployeeApprovalWorkflow(record, approvalId, title, now);
return {
...record,
workflow: approvalWorkflowSnapshot(workflow),
current_step_id: workflow.current_step_id,
decision_history: workflow.decision_history,
};
}
export function readDigitalEmployeeRuntimeRecords(
limit = 120,
): DigitalEmployeeRuntimeRecords {
@@ -1684,6 +1966,28 @@ export function recordDigitalEmployeeApprovalAction(
? "approval_action_recorded"
: "approval_action_rejected";
const eventId = `approval-action:${safeId(approvalId)}:${safeId(action)}:${safeId(now)}`;
const existing = db.prepare(`
SELECT id, plan_id, task_id, run_id, title, status, payload, created_at
FROM digital_approvals
WHERE id = ?
`).get(approvalId) as Record<string, unknown> | undefined;
const existingPayload = objectRecord(parseStoredPayload(existing?.payload));
const title = existing ? stringField(existing, "title") || "待确认事项" : "待确认事项";
const workflowChange = accepted
? action === "approve" || action === "reject"
? applyApprovalWorkflowDecision(
normalizeDigitalEmployeeApprovalWorkflow(existingPayload ?? {}, approvalId, title, now),
action,
stringValue(input.actor) || "digital_employee_operator",
now,
)
: applyApprovalWorkflowAction(
normalizeDigitalEmployeeApprovalWorkflow(existingPayload ?? {}, approvalId, title, now),
action,
input,
now,
)
: null;
const payload: Record<string, unknown> = {
approval_id: approvalId,
action,
@@ -1698,7 +2002,32 @@ export function recordDigitalEmployeeApprovalAction(
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
workflow: workflowChange ? approvalWorkflowSnapshot(workflowChange.workflow) : null,
step_id: workflowChange?.step?.step_id ?? null,
step_status: workflowChange?.step?.status ?? null,
participant_id: workflowChange?.step?.participant_id ?? null,
participant_label: workflowChange?.step?.participant_label ?? null,
};
if (accepted && existing && workflowChange) {
const nextPayload = {
...(existingPayload ?? {}),
workflow: approvalWorkflowSnapshot(workflowChange.workflow),
current_step_id: workflowChange.workflow.current_step_id,
decision_history: workflowChange.workflow.decision_history,
last_action: action,
last_action_at: now,
};
upsertApproval({
id: approvalId,
planId: stringValue(input.planId) || stringField(existing, "plan_id"),
taskId: stringValue(input.taskId) || stringField(existing, "task_id"),
runId: stringValue(input.runId) || stringField(existing, "run_id"),
title,
status: workflowChange.workflow.status,
payload: nextPayload,
now,
});
}
insertEvent(
{
event_id: eventId,
@@ -1714,6 +2043,46 @@ export function recordDigitalEmployeeApprovalAction(
return { eventId, kind, payload };
}
export function upsertDigitalEmployeeApprovalFromRemote(
input: DigitalEmployeeApprovalUpdateInput,
): { ok: boolean; skipped?: boolean; approvalId?: string; reason?: string } {
const db = getDigitalEmployeeDb();
if (!db) return { ok: false, reason: "database_unavailable" };
const approvalId = stringValue(input.approvalId ?? input.remoteId) || "";
if (!approvalId) return { ok: false, reason: "approval_id_required" };
const existing = db.prepare(`
SELECT id, plan_id, task_id, run_id, title, status, payload, created_at, updated_at
FROM digital_approvals
WHERE id = ?
`).get(approvalId) as Record<string, unknown> | undefined;
const remoteUpdatedAt = stringValue(input.updatedAt) || new Date().toISOString();
const existingUpdatedAt = existing ? stringField(existing, "updated_at") : "";
if (existingUpdatedAt && Date.parse(existingUpdatedAt) > Date.parse(remoteUpdatedAt)) {
return { ok: true, skipped: true, approvalId, reason: "local_newer" };
}
const existingPayload = objectRecord(parseStoredPayload(existing?.payload)) ?? {};
const remotePayload = objectRecord(input.payload) ?? {};
const title = stringValue(input.title) || (existing ? stringField(existing, "title") : "") || "待确认事项";
const payload = {
...existingPayload,
...remotePayload,
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
remote_id: stringValue(input.remoteId) || stringValue(remotePayload.remote_id) || null,
remote_updated_at: remoteUpdatedAt,
};
upsertApproval({
id: approvalId,
planId: stringValue(input.planId) || (existing ? stringField(existing, "plan_id") : ""),
taskId: stringValue(input.taskId) || (existing ? stringField(existing, "task_id") : ""),
runId: stringValue(input.runId) || (existing ? stringField(existing, "run_id") : ""),
title,
status: stringValue(input.status) || (existing ? stringField(existing, "status") : "pending") || "pending",
payload,
now: remoteUpdatedAt,
});
return { ok: true, approvalId };
}
export function activeDigitalEmployeePlanStepLease(
payload: unknown,
now = new Date().toISOString(),
@@ -2167,7 +2536,7 @@ function planStepDispatchMessage(status: string, stepId: string, reason: string)
}
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk", "approve", "reject"].includes(action);
}
function sanitizeApprovalComment(comment: string | null | undefined): Record<string, unknown> | null {
@@ -5113,6 +5482,7 @@ function upsertApproval(input: {
}): void {
const db = getDigitalEmployeeDb();
if (!db) return;
const payload = payloadWithApprovalWorkflow(input.payload, input.id, input.title, input.now);
db.prepare(`
INSERT INTO digital_approvals (
id, plan_id, task_id, run_id, title, status, payload, sync_status, created_at, updated_at
@@ -5136,11 +5506,11 @@ function upsertApproval(input: {
input.runId,
input.title,
input.status,
stringify(input.payload),
stringify(payload),
input.now,
input.now,
);
markOutbox("approval", input.id, "upsert", input, input.now);
markOutbox("approval", input.id, "upsert", { ...input, payload }, input.now);
}
function insertEvent(

View File

@@ -297,6 +297,84 @@ describe("digital employee sync service", () => {
expect(result.policy.approval_required_actions).toEqual(["managed_service.restart.fileServer"]);
});
it("pulls remote approval updates into local approval workflow records", async () => {
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
approvals: [
{
approval_id: "approval-remote-1",
title: "高风险动作审批",
status: "pending",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
updated_at: "2026-06-07T08:03:00.000Z",
payload: {
workflow: {
workflow_id: "workflow-remote-1",
current_step_id: "step-2",
steps: [
{ step_id: "step-1", label: "主管审批", status: "approved" },
{ step_id: "step-2", label: "风控复核", status: "pending", participant_label: "风控" },
],
},
},
},
],
},
}));
const { pullDigitalEmployeeApprovalUpdates } = await import("./syncService");
const result = await pullDigitalEmployeeApprovalUpdates({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/approvals/updates",
applied: 1,
ignored: 0,
});
expect(mockState.fetch).toHaveBeenCalledWith(
"https://manage.example.com/api/digital-employee/approvals/updates",
expect.objectContaining({ method: "GET" }),
);
const approval = mockState.db?.approvals.get("approval-remote-1");
expect(approval).toMatchObject({ title: "高风险动作审批", status: "pending", sync_status: "pending" });
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
workflow: {
workflow_id: "workflow-remote-1",
current_step_id: "step-2",
},
remote_updated_at: "2026-06-07T08:03:00.000Z",
});
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "digital_workday_approval_updates_pulled" }),
]));
});
it("submits approval decisions to management and records submission events", async () => {
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));
const { submitDigitalEmployeeApprovalDecision } = await import("./syncService");
const result = await submitDigitalEmployeeApprovalDecision({ approval_id: "approval-1", decision: "approved" });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/approvals/decisions",
submitted_at: "2026-06-07T08:00:00.000Z",
});
expect(mockState.fetch).toHaveBeenCalledWith(
"https://manage.example.com/api/digital-employee/approvals/decisions",
expect.objectContaining({
method: "POST",
body: expect.stringContaining("approval-1"),
}),
);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "digital_workday_approval_decision_submitted" }),
]));
});
it.each([
["skillPolicies"],
["policies"],
@@ -1501,6 +1579,8 @@ interface TestSyncEntityRow {
sync_status: string;
sync_error: string | null;
last_synced_at: string | null;
created_at?: string;
updated_at?: string;
}
interface TestAttemptRow {
@@ -1646,6 +1726,11 @@ class TestDb {
return this.approvals.get(id);
}
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at, updated_at FROM digital_approvals")) {
const [id] = args as [string];
return this.approvals.get(id);
}
if (sql.startsWith("SELECT key AS title, status, content AS summary, payload FROM digital_memories")) {
const [id] = args as [string];
return this.memories.get(id);
@@ -1817,6 +1902,37 @@ class TestDb {
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_approvals")) {
const [id, planId, taskId, runId, title, status, payload, createdAt, updatedAt] = args as [
string,
string,
string,
string,
string,
string,
string,
string,
string,
];
const previous = this.approvals.get(id);
this.approvals.set(id, {
id,
plan_id: planId,
task_id: taskId,
run_id: runId,
title,
status,
payload,
remote_id: previous?.remote_id ?? null,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
sync_error: previous?.sync_error ?? null,
last_synced_at: previous?.last_synced_at ?? null,
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
});
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
string,

View File

@@ -7,6 +7,8 @@ import {
normalizeRiskApprovalPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
upsertDigitalEmployeeApprovalFromRemote,
type DigitalEmployeeApprovalUpdateInput,
type DigitalEmployeePlanStepDispatchPolicy,
type DigitalEmployeeRiskApprovalPolicy,
} from "./stateService";
@@ -21,6 +23,8 @@ const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
const DEFAULT_RISK_APPROVAL_POLICY_PATH = "/api/digital-employee/policies/risk-approval";
const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
@@ -43,6 +47,8 @@ let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
let riskApprovalPolicyPulling = false;
let lastRiskApprovalPolicyPullAttemptMs = 0;
let approvalUpdatesPulling = false;
let lastApprovalUpdatesPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null;
@@ -55,6 +61,8 @@ interface DigitalEmployeeSyncConfig {
policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
riskApprovalPolicyEndpoint: string | null;
approvalUpdatesEndpoint: string | null;
approvalDecisionsEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
@@ -88,6 +96,23 @@ export interface DigitalEmployeeRiskApprovalPolicyPullResult {
error?: string | null;
}
export interface DigitalEmployeeApprovalUpdatesPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
applied: number;
ignored: number;
error?: string | null;
}
export interface DigitalEmployeeApprovalDecisionSubmitResult {
ok: boolean;
endpoint: string | null;
submitted_at: string | null;
error?: string | null;
}
export interface DigitalEmployeePlanStepRemoteLease {
lease_id: string;
state: "active" | "released" | "expired";
@@ -440,6 +465,121 @@ export async function pullDigitalEmployeeRiskApprovalPolicy(
}
}
export async function pullDigitalEmployeeApprovalUpdates(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeApprovalUpdatesPullResult> {
if (approvalUpdatesPulling) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
}
const nowMs = Date.now();
if (!options.force && nowMs - lastApprovalUpdatesPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return { ok: true, skipped: true, endpoint: resolveSyncConfig().approvalUpdatesEndpoint, pulled_at: null, applied: 0, ignored: 0 };
}
lastApprovalUpdatesPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.approvalUpdatesEndpoint || missingCredentials.length > 0) {
return {
ok: false,
endpoint: config.approvalUpdatesEndpoint,
pulled_at: new Date().toISOString(),
applied: 0,
ignored: 0,
error: !config.approvalUpdatesEndpoint ? "management approval updates endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
};
}
approvalUpdatesPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.approvalUpdatesEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const updates = readRemoteApprovalUpdates(response);
const pulledAt = new Date().toISOString();
let applied = 0;
let ignored = 0;
for (const update of updates) {
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
if (result.ok && !result.skipped) applied += 1;
else ignored += 1;
}
recordApprovalSyncEvent("approval_updates_pulled", pulledAt, {
action: "pull_approval_updates",
endpoint: config.approvalUpdatesEndpoint,
ok: true,
applied,
ignored,
source: "management-api",
});
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
} catch (error) {
const pulledAt = new Date().toISOString();
const message = normalizeError(error);
recordApprovalSyncEvent("approval_updates_pull_failed", pulledAt, {
action: "pull_approval_updates",
endpoint: config.approvalUpdatesEndpoint,
ok: false,
error: message,
source: "management-api",
});
return { ok: false, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied: 0, ignored: 0, error: message };
} finally {
approvalUpdatesPulling = false;
}
}
export async function submitDigitalEmployeeApprovalDecision(
input: Record<string, unknown>,
): Promise<DigitalEmployeeApprovalDecisionSubmitResult> {
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
const submittedAt = new Date().toISOString();
if (!config.approvalDecisionsEndpoint || missingCredentials.length > 0) {
const error = !config.approvalDecisionsEndpoint ? "management approval decisions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
action: "submit_approval_decision",
ok: false,
endpoint: config.approvalDecisionsEndpoint,
error,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error };
}
try {
const response = await fetchJsonWithAuth(config, config.approvalDecisionsEndpoint, {
...input,
submitted_at: submittedAt,
device_id: getDeviceId(),
});
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
recordApprovalSyncEvent("approval_decision_submitted", submittedAt, {
action: "submit_approval_decision",
ok: true,
endpoint: config.approvalDecisionsEndpoint,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: true, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: null };
} catch (error) {
const message = normalizeError(error);
recordApprovalSyncEvent("approval_decision_submit_failed", submittedAt, {
action: "submit_approval_decision",
ok: false,
endpoint: config.approvalDecisionsEndpoint,
error: message,
approval_id: stringField(input.approval_id ?? input.approvalId) || null,
decision: stringField(input.decision) || null,
source: "qimingclaw-approval-sync",
});
return { ok: false, endpoint: config.approvalDecisionsEndpoint, submitted_at: submittedAt, error: message };
}
}
export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -654,6 +794,16 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.riskApprovalPolicyEndpoint.trim()
: null;
const riskApprovalPolicyEndpoint = riskApprovalPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_RISK_APPROVAL_POLICY_PATH);
const approvalUpdatesEndpointOverride =
typeof config.approvalUpdatesEndpoint === "string" && config.approvalUpdatesEndpoint.trim()
? config.approvalUpdatesEndpoint.trim()
: null;
const approvalUpdatesEndpoint = approvalUpdatesEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_UPDATES_PATH);
const approvalDecisionsEndpointOverride =
typeof config.approvalDecisionsEndpoint === "string" && config.approvalDecisionsEndpoint.trim()
? config.approvalDecisionsEndpoint.trim()
: null;
const approvalDecisionsEndpoint = approvalDecisionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_APPROVAL_DECISIONS_PATH);
const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim()
@@ -671,6 +821,8 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
policyEndpoint,
skillPolicyEndpoint,
riskApprovalPolicyEndpoint,
approvalUpdatesEndpoint,
approvalDecisionsEndpoint,
leaseEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
@@ -832,6 +984,34 @@ function readRemoteRiskApprovalPolicy(response: Record<string, unknown>): Record
?? (hasRiskApprovalPolicyFields(data) ? data : null);
}
function readRemoteApprovalUpdates(response: Record<string, unknown>): DigitalEmployeeApprovalUpdateInput[] {
const data = objectRecord(response.data) ?? response;
const raw = Array.isArray(data.approvals)
? data.approvals
: Array.isArray(data.updates)
? data.updates
: Array.isArray(data.items)
? data.items
: [];
return raw
.map((item) => objectRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item) => ({
approvalId: stringField(item.approval_id ?? item.approvalId ?? item.id) || null,
remoteId: stringField(item.remote_id ?? item.remoteId) || null,
planId: stringField(item.plan_id ?? item.planId) || null,
taskId: stringField(item.task_id ?? item.taskId) || null,
runId: stringField(item.run_id ?? item.runId) || null,
title: stringField(item.title) || null,
status: stringField(item.status) || null,
payload: objectRecord(item.payload) ?? item,
updatedAt: stringField(item.updated_at ?? item.updatedAt ?? item.remote_updated_at ?? item.remoteUpdatedAt) || null,
createdAt: stringField(item.created_at ?? item.createdAt) || null,
source: stringField(item.source) || "management-api",
}))
.filter((item) => Boolean(item.approvalId || item.remoteId));
}
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
@@ -989,6 +1169,15 @@ function recordRiskApprovalPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
}
function recordApprovalSyncEvent(action: string, occurredAt: string, metadata: Record<string, unknown>): void {
const state = readDigitalEmployeeUiState();
saveDigitalEmployeeUiState({
action,
metadata: { ...metadata, occurred_at: occurredAt },
state,
});
}
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id);
@@ -1177,6 +1366,7 @@ function eventSpecificBusinessView(
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (isApprovalSyncEvent(kind)) return approvalSyncBusinessView(payload);
if (isApprovalSubscriptionEvent(kind)) return approvalSubscriptionBusinessView(payload);
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind === "artifact_version_observed") {
return artifactEventBusinessView(payload);
@@ -1214,11 +1404,17 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
const delegate = objectRecord(payload.delegate_summary);
const sla = objectRecord(payload.sla_summary);
const risk = objectRecord(payload.risk_summary);
const workflow = objectRecord(payload.workflow) ?? {};
return {
approval_id: stringField(payload.approval_id ?? payload.approvalId) || null,
action: stringField(payload.action) || null,
status: stringField(payload.status) || null,
actor: stringField(payload.actor) || null,
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
step_id: stringField(payload.step_id ?? payload.stepId) || null,
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
risk_level: stringField(risk?.risk_level ?? payload.risk_level) || null,
due_at: stringField(sla?.due_at ?? payload.due_at) || null,
delegate_to: stringField(delegate?.target ?? delegate?.label ?? payload.delegate_to) || null,
@@ -1227,12 +1423,20 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
}
function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const workflow = objectRecord(payload.workflow) ?? {};
return {
approval_id: stringField(payload.approvalId ?? payload.approval_id) || null,
action: "decision",
decision: stringField(payload.decision) || null,
previous_status: stringField(payload.previousStatus ?? payload.previous_status) || null,
next_status: stringField(payload.nextStatus ?? payload.next_status) || null,
workflow_id: stringField(workflow.workflow_id ?? workflow.workflowId) || null,
step_id: stringField(payload.step_id ?? payload.stepId) || null,
step_status: stringField(payload.step_status ?? payload.stepStatus) || null,
current_step_id: stringField(payload.current_step_id ?? payload.currentStepId) || null,
next_step_id: stringField(payload.next_step_id ?? payload.nextStepId) || null,
participant_id: stringField(payload.participant_id ?? payload.participantId) || null,
participant_label: stringField(payload.participant_label ?? payload.participantLabel) || null,
actor: stringField(payload.actor ?? payload.source) || null,
decided_at: stringField(payload.decidedAt ?? payload.decided_at) || null,
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
@@ -1241,6 +1445,21 @@ function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<
};
}
function approvalSyncBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? payload;
return {
action: stringField(metadata.action ?? payload.action) || null,
approval_id: stringField(metadata.approval_id ?? metadata.approvalId) || null,
decision: stringField(metadata.decision) || null,
source: stringField(metadata.source) || null,
endpoint: stringField(metadata.endpoint) || null,
ok: typeof metadata.ok === "boolean" ? metadata.ok : null,
applied: numberField(metadata.applied),
ignored: numberField(metadata.ignored),
error: stringField(metadata.error) || null,
};
}
function approvalSubscriptionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
@@ -1410,6 +1629,7 @@ function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval";
if (kind.startsWith("risk_")) return "approval";
if (isApprovalSyncEvent(kind)) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";
@@ -1426,6 +1646,13 @@ function isApprovalSubscriptionEvent(kind: string): boolean {
return kind === "digital_workday_update_approval_subscriptions";
}
function isApprovalSyncEvent(kind: string): boolean {
return kind === "digital_workday_approval_updates_pulled"
|| kind === "digital_workday_approval_updates_pull_failed"
|| kind === "digital_workday_approval_decision_submitted"
|| kind === "digital_workday_approval_decision_submit_failed";
}
function isPlanStepDependencyEvent(kind: string): boolean {
return kind === "plan_step_ready" || kind === "plan_step_blocked" || kind === "plan_step_waiting";
}

View File

@@ -137,6 +137,12 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullRiskApprovalPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullRiskApprovalPolicy");
},
async pullApprovalUpdates() {
return ipcRenderer.invoke("digitalEmployee:pullApprovalUpdates");
},
async submitApprovalDecision(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:submitApprovalDecision", input);
},
async evaluateRiskPolicy(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:evaluateRiskPolicy", input);
},

View File

@@ -450,7 +450,8 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项;技能策略已拆分为本地偏好 `local_skills` 与管理端治理 `remote_skills`,可通过 `POST /api/skill/policies/pull` 拉取管理端 `GET /api/digital-employee/policies/skills` 下发策略,远端禁用优先生效且拉取失败会保留现有策略并记录 `last_pull_error`
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store``/api/dashboard``/api/debug/plan/:id/flow``/api/debug/task/:id/flow``/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / ApprovalRun/Event payload 中的 `artifacts``outputs``files``outputPath``uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts``outputs``files``attachments``outputPath``uri` 以及 `approvals``approval_requests``needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `digital_workday_decide_approval` runtime event。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action把处理结果写入 SQLite UI state回写正式 approval 状态并进入 approval outbox同时生成 `approval_decision` / `digital_workday_decide_approval` runtime event。approval payload 已支持 `workflow``steps``current_step_id``participants``decision_history`,旧单步审批会自动规范化为单步 workflow多步审批未完成时保持 `pending`,全部通过才进入 `approved`,任一步拒绝进入 `rejected`同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
- 审批跨端更新与回写已开始闭环:客户端可通过 `POST /api/approval/updates/pull` 拉取管理端 `GET /api/digital-employee/approvals/updates` 审批更新,也会在本地处理审批后通过 `POST /api/digital-employee/approvals/decisions` 尝试回写管理端;远端异常会写入 `digital_workday_approval_*` 事件,不阻断本地审批状态推进。
- 高风险动作审批策略已沉淀到 UI state 的 `riskApprovalPolicy`,可通过 `POST /api/risk-approval/policy/pull` 拉取管理端 `GET /api/digital-employee/policies/risk-approval` 下发策略;受控的服务重启、产物访问/保留、审批动作、治理命令和 ready PlanStep 派发前会先评估风险策略,命中审批会写入 `risk_approval_required` approval/event命中拒绝会写入 `risk_policy_rejected` event管理端同步业务视图会提炼 action、risk level、decision、approval ID 和策略来源。
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan``submit_plan``approve_plan``activate_plan``create_schedule``run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
@@ -548,8 +549,8 @@ GET /api/digital-employee/approvals
1. **PlanStep 与依赖图(依赖图调度已开始)**
- 已吸收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派发前先通过管理端 `POST /api/digital-employee/leases/plan-step-dispatch/acquire` 做跨设备强租约仲裁,管理端拒绝会以 `remote_lease_rejected` 跳过,管理端异常会降级为 5 分钟本地软租约 `payload.dispatchLease.source = local_fallback` 并记录 `remote_error`;调用本地 `/computer/chat` 后会写入 `plan_step_dispatch_*``governance_start_run` 事件,派发完成或失败会先尝试 `release` 管理端租约再释放本地 lease 并写入 `plan_step_lease_*` 事件;已有未过期租约的步骤仍会以 `lease_active` 跳过auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`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``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:管理端正式路由 UI、派发策略管理 UI 和多步审批流仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 或多步审批流,把本地可治理调度继续推进到跨端治理策略。
- 后续缺口:管理端正式路由 UI、派发策略管理 UI 和跨端审批策略 UI 仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 或跨端审批策略 UI,把本地可治理调度继续推进到跨端治理策略。
2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
@@ -585,9 +586,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。
8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影;风险审批策略已支持管理端远端下发、手动拉取、本地策略评估和高风险动作前置拦截,审批动作历史、风险策略与通知联动已开始闭环。
- 缺口:缺少系统级桌面通知、多步审批流、审批策略管理 UI跨端审批处理回写
- 建议:下一轮先补多步审批流和跨端审批处理回写,再评估系统级桌面通知的跨端策略。
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影;风险审批策略已支持管理端远端下发、手动拉取、本地策略评估和高风险动作前置拦截;多步审批 workflow 已复用 `digital_approvals.payload`,支持步骤推进、转交/超时/风险动作更新当前步骤、管理端审批更新拉取和审批处理回写,审批动作历史、风险策略、多步流程与通知联动已开始闭环。
- 缺口:缺少系统级桌面通知、审批策略管理 UI跨端审批冲突可视化和正式管理端审批工作台
- 建议:下一轮先补审批策略管理 UI 或管理端审批工作台,再评估系统级桌面通知的跨端策略。
9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、等级和常用类型过滤 Inbox`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。
@@ -1066,8 +1067,9 @@ Approval
- embedded digital 页面展示这些 runtime approval。
- 用户点击同意 / 驳回时adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission`
- 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime eventembedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime eventembedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线;多步审批 workflow 会在 timeline 和首页决策卡展示当前步骤、总步骤数、处理人和下一步
- 管理端可通过 `/api/digital-employee/approval-history` 查询审批动作历史,客户端可通过 `/api/approval/subscriptions` 维护审批订阅策略;订阅更新会写入 `digital_workday_update_approval_subscriptions` runtime event 与同步业务视图。
- 客户端可通过 `POST /api/approval/updates/pull` 手动拉取管理端审批更新,审批处理后会尝试回写 `POST /api/digital-employee/approvals/decisions`;回写失败只进入 runtime event 和 outbox 诊断,不回滚本地决策。
用户可操作: