吸收数字员工技能远端策略下发

This commit is contained in:
baiyanyun
2026-06-11 09:04:08 +08:00
parent 751ce45619
commit 8ab3d73fb1
18 changed files with 876 additions and 207 deletions

View File

@@ -619,6 +619,12 @@ export function pullPlanStepDispatchPolicy(): Promise<PlanStepDispatchPolicyPull
}); });
} }
export function pullSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
return apiFetch<{ ok: boolean; skipped?: boolean; error?: string | null }>('/api/skill/policies/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> { export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams(); const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {

View File

@@ -78,6 +78,9 @@ export interface SkillSummary {
allowTools?: string[]; allowTools?: string[];
denyTools?: string[]; denyTools?: string[];
governancePolicy?: 'allow' | 'deny' | 'open' | 'disabled' | null; governancePolicy?: 'allow' | 'deny' | 'open' | 'disabled' | null;
policySource?: 'remote' | 'local' | 'open' | null;
remotePolicy?: Record<string, unknown> | null;
localPolicy?: Record<string, unknown> | null;
recentCalls?: Array<{ recentCalls?: Array<{
eventId: string; eventId: string;
message: string; message: string;
@@ -496,6 +499,9 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
allowTools: stringList(s.allow_tools ?? governance?.server_allow_tools ?? governance?.global_allow_tools), allowTools: stringList(s.allow_tools ?? governance?.server_allow_tools ?? governance?.global_allow_tools),
denyTools: stringList(s.deny_tools ?? governance?.server_deny_tools ?? governance?.global_deny_tools), denyTools: stringList(s.deny_tools ?? governance?.server_deny_tools ?? governance?.global_deny_tools),
governancePolicy: governancePolicy(s.governance_policy ?? governance?.effective_policy), governancePolicy: governancePolicy(s.governance_policy ?? governance?.effective_policy),
policySource: policySource(s.policy_source),
remotePolicy: obj(s.remote_policy),
localPolicy: obj(s.local_policy),
recentCalls: arr(s.recent_calls).map((call) => { recentCalls: arr(s.recent_calls).map((call) => {
const c = obj(call) ?? {}; const c = obj(call) ?? {};
return { return {
@@ -533,6 +539,12 @@ function governancePolicy(value: unknown): SkillSummary['governancePolicy'] {
: null; : null;
} }
function policySource(value: unknown): SkillSummary['policySource'] {
return value === 'remote' || value === 'local' || value === 'open'
? value
: null;
}
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] { export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
if (!storeData) return []; if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj; const tables = (storeData.tables ?? {}) as Obj;

View File

@@ -54,6 +54,7 @@ declare global {
getUiState?: () => Promise<AdapterState>; getUiState?: () => Promise<AdapterState>;
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>; getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>; setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>; getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>; listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>; addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -975,6 +976,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/skill') { if (method === 'GET' && pathname === '/api/skill') {
return { skills: await buildSkills() } as T; return { skills: await buildSkills() } as T;
} }
if (method === 'POST' && pathname === '/api/skill/policies/pull') {
return await pullBridgeSkillPolicies() as T;
}
if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) { if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || ''); const skillId = decodeURIComponent(pathname.split('/')[3] || '');
const body = parseJsonBody<{ enabled?: boolean }>(options.body); const body = parseJsonBody<{ enabled?: boolean }>(options.body);
@@ -1093,6 +1097,23 @@ async function setBridgeSkillEnabled(
} }
} }
async function pullBridgeSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullSkillPolicies) {
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullSkillPolicies();
return {
ok: result?.ok !== false,
...(result?.skipped ? { skipped: true } : {}),
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
function dedupeSkills(skills: SkillSpec[]): SkillSpec[] { function dedupeSkills(skills: SkillSpec[]): SkillSpec[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: SkillSpec[] = []; const result: SkillSpec[] = [];

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard'; import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar'; import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter'; import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, setSkillEnabled } from '@/lib/api'; import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
import { domSafeId } from './navigation'; import { domSafeId } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc'; type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
@@ -81,6 +81,8 @@ function skillMetaBadges(skill: SkillSummary): string[] {
const badges: string[] = []; const badges: string[] = [];
if (skill.serverId) badges.push(skill.serverId); if (skill.serverId) badges.push(skill.serverId);
if (skill.running !== null && skill.running !== undefined) badges.push(skill.running ? '运行中' : '未运行'); if (skill.running !== null && skill.running !== undefined) badges.push(skill.running ? '运行中' : '未运行');
if (skill.policySource === 'remote') badges.push('远端策略');
if (skill.remotePolicy && skill.remotePolicy.enabled === false) badges.push('管理端禁用');
if (skill.governancePolicy) badges.push(policyLabels[skill.governancePolicy]); if (skill.governancePolicy) badges.push(policyLabels[skill.governancePolicy]);
if (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`); if (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`);
if (skill.allowTools?.length) badges.push(`allow ${skill.allowTools.length}`); if (skill.allowTools?.length) badges.push(`allow ${skill.allowTools.length}`);
@@ -174,7 +176,9 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all'); const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null); const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null); const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
const [pullingPolicies, setPullingPolicies] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const mountedRef = useRef(false); const mountedRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -217,6 +221,7 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const nextEnabled = !skill.enabled; const nextEnabled = !skill.enabled;
setUpdatingSkillId(skill.id); setUpdatingSkillId(skill.id);
setActionError(null); setActionError(null);
setActionNotice(null);
try { try {
const result = await setSkillEnabled(skill.id, nextEnabled); const result = await setSkillEnabled(skill.id, nextEnabled);
if (!result.ok) { if (!result.ok) {
@@ -233,6 +238,22 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
} }
}, []); }, []);
const pullPolicies = useCallback(async () => {
setPullingPolicies(true);
setActionError(null);
setActionNotice(null);
try {
const result = await pullSkillPolicies();
if (!result.ok) throw new Error(result.error || '技能策略拉取失败');
await fetchSkills();
setActionNotice(result.skipped ? '技能策略无需重复拉取。' : '技能策略已更新。');
} catch (err) {
setActionError(err instanceof Error ? err.message : '技能策略拉取失败');
} finally {
setPullingPolicies(false);
}
}, [fetchSkills]);
const filtered = skills const filtered = skills
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter) .filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => { .filter(s => {
@@ -322,6 +343,9 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
<div style={{ padding: '14px 18px' }}> <div style={{ padding: '14px 18px' }}>
<GlassCard> <GlassCard>
<CardTitleBar title="学习技能"> <CardTitleBar title="学习技能">
<button onClick={pullPolicies} disabled={pullingPolicies} className="de-btn-secondary de-btn-sm">
{pullingPolicies ? '拉取中...' : '拉取策略'}
</button>
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm"></button> <button onClick={fetchSkills} className="de-btn-secondary de-btn-sm"></button>
</CardTitleBar> </CardTitleBar>
<div className="de-card-body"> <div className="de-card-body">
@@ -330,6 +354,11 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
{actionError} {actionError}
</div> </div>
)} )}
{actionNotice && (
<div className="de-schedule-result" style={{ marginBottom: 12 }}>
{actionNotice}
</div>
)}
<div className="de-skill-toolbar"> <div className="de-skill-toolbar">
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} /> <input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="de-filter-row"> <div className="de-filter-row">

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); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </script>
<script type="module" crossorigin src="./assets/index-LEtlcmt1.js"></script> <script type="module" crossorigin src="./assets/index-1hmQ8OoX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css"> <link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head> </head>
<body> <body>

View File

@@ -409,6 +409,55 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
console.log("[DigitalEmployeeCheck] PlanStep graph/dispatch policy hooks OK"); console.log("[DigitalEmployeeCheck] PlanStep graph/dispatch policy hooks OK");
} }
function checkDigitalSkillRemotePolicy() {
console.log("\n[DigitalEmployeeCheck] Verify skill remote policy 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 skillLibraryPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "SkillLibrary.tsx");
const skillExecutionPolicyPath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "skillExecutionPolicy.ts");
const skillCatalogPath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "skillCatalogService.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 skillLibrary = fs.readFileSync(skillLibraryPath, "utf8");
const skillExecutionPolicy = fs.readFileSync(skillExecutionPolicyPath, "utf8");
const skillCatalog = fs.readFileSync(skillCatalogPath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const requiredSnippets = [
[skillExecutionPolicy, "local_skills", "local skill policy map"],
[skillExecutionPolicy, "remote_skills", "remote skill policy map"],
[skillExecutionPolicy, "last_pull_error", "skill policy pull error metadata"],
[skillExecutionPolicy, "effectiveSkillPolicyFor", "effective skill policy merge helper"],
[skillExecutionPolicy, "matched_remote_policy", "skill call remote policy audit snapshot"],
[skillCatalog, "remote_policy", "skill catalog remote policy projection"],
[skillCatalog, "local_policy", "skill catalog local policy projection"],
[skillCatalog, "policy_source", "skill catalog policy source projection"],
[syncService, "pullDigitalEmployeeSkillPolicies", "remote skill policy pull service"],
[syncService, "skillPolicyEndpoint", "remote skill policy endpoint config"],
[syncService, "/api/digital-employee/policies/skills", "remote skill policy default path"],
[syncService, "readRemoteSkillPolicyPayload", "remote skill policy response parser"],
[syncService, "recordSkillPolicyPullFailure", "remote skill policy pull failure recorder"],
[ipcHandlers, "digitalEmployee:pullSkillPolicies", "IPC skill policy pull handler"],
[preload, "pullSkillPolicies", "preload skill policy pull bridge"],
[adapter, "pullSkillPolicies", "adapter skill policy pull bridge"],
[adapter, "/api/skill/policies/pull", "adapter skill policy pull endpoint"],
[api, "pullSkillPolicies", "embedded skill policy pull API"],
[skillLibrary, "拉取策略", "skill library pull policy action"],
[skillLibrary, "远端策略", "skill library remote policy marker"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing skill remote policy hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] skill remote policy hooks OK");
}
function checkLocalDatabaseSchema() { function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema"); console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db"); const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -455,6 +504,7 @@ function main() {
checkDigitalNotificationPreferences(); checkDigitalNotificationPreferences();
checkDigitalApprovalHistorySubscriptions(); checkDigitalApprovalHistorySubscriptions();
checkDigitalPlanStepGraphDispatchPolicy(); checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy();
checkLocalDatabaseSchema(); checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed"); console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) { } catch (error) {

View File

@@ -95,6 +95,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
flushDigitalEmployeeSyncOutbox: vi.fn(), flushDigitalEmployeeSyncOutbox: vi.fn(),
getDigitalEmployeeSyncStatus: vi.fn(), getDigitalEmployeeSyncStatus: vi.fn(),
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })), 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: {} } })),
})); }));
vi.mock("../services/digitalEmployee/planTemplateService", () => ({ vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -250,6 +251,23 @@ describe("digital employee managed service actions", () => {
expect(syncService.pullDigitalEmployeePlanStepDispatchPolicy).toHaveBeenCalledWith({ force: true }); expect(syncService.pullDigitalEmployeePlanStepDispatchPolicy).toHaveBeenCalledWith({ force: true });
}); });
it("registers skill policy pull through the digital employee IPC boundary", 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 policyPullCall = calls.find(([channel]) => channel === "digitalEmployee:pullSkillPolicies");
expect(policyPullCall).toBeTruthy();
const handler = policyPullCall?.[1] as () => Promise<unknown>;
const result = await handler();
expect(result).toMatchObject({ ok: true });
expect(syncService.pullDigitalEmployeeSkillPolicies).toHaveBeenCalledWith({ force: true });
});
it("registers approval action history through the digital employee IPC boundary", async () => { it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron"); const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers"); const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

@@ -58,6 +58,7 @@ import {
flushDigitalEmployeeSyncOutbox, flushDigitalEmployeeSyncOutbox,
getDigitalEmployeeSyncStatus, getDigitalEmployeeSyncStatus,
pullDigitalEmployeePlanStepDispatchPolicy, pullDigitalEmployeePlanStepDispatchPolicy,
pullDigitalEmployeeSkillPolicies,
} from "../services/digitalEmployee/syncService"; } from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService"; import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import { import {
@@ -193,6 +194,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeePlanStepDispatchPolicy({ force: true }); return pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
}); });
ipcMain.handle("digitalEmployee:pullSkillPolicies", async () => {
return pullDigitalEmployeeSkillPolicies({ force: true });
});
ipcMain.handle("digitalEmployee:getCronSettings", async () => { ipcMain.handle("digitalEmployee:getCronSettings", async () => {
return readDigitalEmployeeCronSettings(); return readDigitalEmployeeCronSettings();
}); });

View File

@@ -312,6 +312,111 @@ describe("digital employee skill catalog service", () => {
])); ]));
}); });
it("normalizes old skills policies as local policies", async () => {
mockSettings.values.set("digital_employee_skill_policies_v1", {
version: 1,
skills: {
"qimingclaw-computer-control": {
enabled: false,
updatedAt: "2026-06-07T08:00:00.000Z",
},
},
});
const { readDigitalEmployeeSkillPolicies, listDigitalEmployeeSkillCapabilities } = await import("./skillCatalogService");
expect(readDigitalEmployeeSkillPolicies()).toMatchObject({
local_skills: {
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
},
remote_skills: {},
skills: {
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
},
});
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
expect.objectContaining({
skill_id: "qimingclaw-computer-control",
enabled: false,
policy_source: "local",
}),
]));
});
it("applies remote disabled skill policy before local enabled preference", async () => {
mockSettings.values.set("digital_employee_skill_policies_v1", {
version: 1,
local_skills: {
"qimingclaw-computer-control": { enabled: true, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
},
remote_skills: {
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote", reason: "management_disabled" },
},
});
const { listDigitalEmployeeSkillCapabilities, evaluateDigitalEmployeeSkillCall } = await import("./skillCatalogService");
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
expect.objectContaining({
skill_id: "qimingclaw-computer-control",
enabled: false,
policy_source: "remote",
remote_policy: expect.objectContaining({ reason: "management_disabled" }),
}),
]));
expect(evaluateDigitalEmployeeSkillCall({ skillId: "qimingclaw-computer-control" })).toMatchObject({
decision: "rejected",
reason_codes: ["skill_disabled"],
policy_snapshot: {
matched_policy_source: "remote",
matched_policy_disabled_by: "remote",
},
});
});
it("applies local disabled policy when remote allows or is absent", async () => {
mockSettings.values.set("digital_employee_skill_policies_v1", {
version: 1,
local_skills: {
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
},
remote_skills: {
"qimingclaw-computer-control": { enabled: true, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote" },
},
});
const { listDigitalEmployeeSkillCapabilities } = await import("./skillCatalogService");
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
expect.objectContaining({
skill_id: "qimingclaw-computer-control",
enabled: false,
policy_source: "local",
local_policy: expect.objectContaining({ enabled: false }),
}),
]));
});
it("preserves remote skills when updating a local skill preference", async () => {
mockSettings.values.set("digital_employee_skill_policies_v1", {
version: 1,
remote_skills: {
"qimingclaw-mcp-tool-crm-crm-delete": { enabled: false, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote" },
},
last_pulled_at: "2026-06-07T08:00:00.000Z",
});
const { setDigitalEmployeeSkillEnabled, readDigitalEmployeeSkillPolicies } = await import("./skillCatalogService");
setDigitalEmployeeSkillEnabled("qimingclaw-computer-control", false);
expect(readDigitalEmployeeSkillPolicies()).toMatchObject({
local_skills: {
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
},
remote_skills: {
"qimingclaw-mcp-tool-crm-crm-delete": expect.objectContaining({ enabled: false, source: "remote" }),
},
last_pulled_at: "2026-06-07T08:00:00.000Z",
});
});
it("removes disabled MCP server skills from agent execution config", async () => { it("removes disabled MCP server skills from agent execution config", async () => {
const { const {
applyDigitalEmployeeMcpExecutionPolicy, applyDigitalEmployeeMcpExecutionPolicy,

View File

@@ -13,9 +13,11 @@ import {
normalizeDigitalEmployeeSkillPolicies as normalizeDigitalEmployeeSkillPoliciesImpl, normalizeDigitalEmployeeSkillPolicies as normalizeDigitalEmployeeSkillPoliciesImpl,
skillIdForMcpServer, skillIdForMcpServer,
skillIdForMcpTool, skillIdForMcpTool,
effectiveSkillPolicyFor,
applyDigitalEmployeeMcpExecutionPolicy as applyDigitalEmployeeMcpExecutionPolicyImpl, applyDigitalEmployeeMcpExecutionPolicy as applyDigitalEmployeeMcpExecutionPolicyImpl,
evaluateDigitalEmployeeSkillCall as evaluateDigitalEmployeeSkillCallImpl, evaluateDigitalEmployeeSkillCall as evaluateDigitalEmployeeSkillCallImpl,
type DigitalEmployeeSkillPolicies, type DigitalEmployeeSkillPolicies,
type DigitalEmployeeSkillPolicyRecord,
type DigitalEmployeeMcpPolicyConfig, type DigitalEmployeeMcpPolicyConfig,
type DigitalEmployeeSkillCallPolicyInput, type DigitalEmployeeSkillCallPolicyInput,
type DigitalEmployeeSkillCallPolicyResult, type DigitalEmployeeSkillCallPolicyResult,
@@ -62,6 +64,9 @@ export interface DigitalEmployeeSkillCapability {
allow_tools?: string[]; allow_tools?: string[];
deny_tools?: string[]; deny_tools?: string[];
project_ids?: string[]; project_ids?: string[];
remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
local_policy?: DigitalEmployeeSkillPolicyRecord | null;
policy_source?: "remote" | "local" | "open";
} }
type McpServerEntryWithRuntimeFields = McpServerEntry & { type McpServerEntryWithRuntimeFields = McpServerEntry & {
@@ -314,16 +319,26 @@ export function setDigitalEmployeeSkillEnabled(
throw new Error("skill_id is required"); throw new Error("skill_id is required");
} }
const current = readDigitalEmployeeSkillPolicies(); const current = readDigitalEmployeeSkillPolicies();
const updatedAt = new Date().toISOString();
const next: DigitalEmployeeSkillPolicies = { const next: DigitalEmployeeSkillPolicies = {
version: 1, version: 1,
skills: { local_skills: {
...current.skills, ...current.local_skills,
[normalizedSkillId]: { [normalizedSkillId]: {
enabled: enabled === true, enabled: enabled === true,
updatedAt: new Date().toISOString(), updatedAt,
source: "local",
reason: null,
}, },
}, },
remote_skills: current.remote_skills,
skills: {},
source: current.remote_skills && Object.keys(current.remote_skills).length > 0 ? "mixed" : "local",
remote_updated_at: current.remote_updated_at ?? null,
last_pulled_at: current.last_pulled_at ?? null,
last_pull_error: current.last_pull_error ?? null,
}; };
next.skills = normalizeDigitalEmployeeSkillPolicies(next).skills;
writeSetting(SKILL_POLICIES_SETTING_KEY, next); writeSetting(SKILL_POLICIES_SETTING_KEY, next);
return { return {
ok: true, ok: true,
@@ -338,12 +353,14 @@ function applySkillPolicies(
policies: DigitalEmployeeSkillPolicies, policies: DigitalEmployeeSkillPolicies,
): DigitalEmployeeSkillCapability[] { ): DigitalEmployeeSkillCapability[] {
return skills.map((skill) => { return skills.map((skill) => {
const policy = policies.skills[skill.skill_id]; const policy = effectiveSkillPolicyFor(skill.skill_id, policies);
if (!policy) return skill; const enabled = skill.enabled && policy.enabled;
const enabled = skill.enabled && policy.enabled !== false;
return { return {
...skill, ...skill,
enabled, enabled,
remote_policy: policy.remote_policy,
local_policy: policy.local_policy,
policy_source: policy.policy_source,
governance: { governance: {
...skill.governance, ...skill.governance,
effective_policy: enabled ? skill.governance.effective_policy : "disabled", effective_policy: enabled ? skill.governance.effective_policy : "disabled",

View File

@@ -3,11 +3,28 @@ import { readSetting } from "../../db";
export interface DigitalEmployeeSkillPolicyRecord { export interface DigitalEmployeeSkillPolicyRecord {
enabled: boolean; enabled: boolean;
updatedAt: string; updatedAt: string;
source?: "local" | "remote";
reason?: string | null;
} }
export interface DigitalEmployeeSkillPolicies { export interface DigitalEmployeeSkillPolicies {
version: 1; version: 1;
local_skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
remote_skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
skills: Record<string, DigitalEmployeeSkillPolicyRecord>; skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
source?: "local" | "remote" | "mixed";
remote_updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
}
export interface DigitalEmployeeEffectiveSkillPolicy {
enabled: boolean;
skill_id: string;
local_policy: DigitalEmployeeSkillPolicyRecord | null;
remote_policy: DigitalEmployeeSkillPolicyRecord | null;
policy_source: "remote" | "local" | "open";
disabled_by: "remote" | "local" | null;
} }
export interface DigitalEmployeeMcpServerPolicyEntry { export interface DigitalEmployeeMcpServerPolicyEntry {
@@ -47,6 +64,14 @@ export interface DigitalEmployeeSkillCallPolicyResult {
policy_snapshot: { policy_snapshot: {
skill_enabled?: boolean; skill_enabled?: boolean;
matched_skill_id?: string | null; matched_skill_id?: string | null;
matched_policy_source?: "remote" | "local" | "open";
matched_policy_disabled_by?: "remote" | "local" | null;
matched_remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
matched_local_policy?: DigitalEmployeeSkillPolicyRecord | null;
server_policy_source?: "remote" | "local" | "open";
server_policy_disabled_by?: "remote" | "local" | null;
server_remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
server_local_policy?: DigitalEmployeeSkillPolicyRecord | null;
server_enabled?: boolean; server_enabled?: boolean;
global_allow_tools: string[]; global_allow_tools: string[];
global_deny_tools: string[]; global_deny_tools: string[];
@@ -73,6 +98,42 @@ export function skillIdForMcpTool(serverId: string, toolName: string): string {
return `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`; return `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`;
} }
export function effectiveSkillPolicyFor(
skillId: string,
policies: DigitalEmployeeSkillPolicies,
): DigitalEmployeeEffectiveSkillPolicy {
const localPolicy = policies.local_skills[skillId] ?? null;
const remotePolicy = policies.remote_skills[skillId] ?? null;
if (remotePolicy?.enabled === false) {
return {
enabled: false,
skill_id: skillId,
local_policy: localPolicy,
remote_policy: remotePolicy,
policy_source: "remote",
disabled_by: "remote",
};
}
if (localPolicy?.enabled === false) {
return {
enabled: false,
skill_id: skillId,
local_policy: localPolicy,
remote_policy: remotePolicy,
policy_source: "local",
disabled_by: "local",
};
}
return {
enabled: true,
skill_id: skillId,
local_policy: localPolicy,
remote_policy: remotePolicy,
policy_source: remotePolicy ? "remote" : localPolicy ? "local" : "open",
disabled_by: null,
};
}
export function applyDigitalEmployeeMcpExecutionPolicy( export function applyDigitalEmployeeMcpExecutionPolicy(
config: DigitalEmployeeMcpPolicyConfig, config: DigitalEmployeeMcpPolicyConfig,
): DigitalEmployeeMcpPolicyConfig { ): DigitalEmployeeMcpPolicyConfig {
@@ -148,8 +209,8 @@ export function evaluateDigitalEmployeeSkillCall(
const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools; const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
const reasonCodes: string[] = []; const reasonCodes: string[] = [];
const matchedSkillId = skillId || null; const matchedSkillId = skillId || null;
const matchedPolicy = matchedSkillId ? policies.skills[matchedSkillId] : undefined; const matchedPolicy = matchedSkillId ? effectiveSkillPolicyFor(matchedSkillId, policies) : null;
const serverPolicy = match.serverSkillId ? policies.skills[match.serverSkillId] : undefined; const serverPolicy = match.serverSkillId ? effectiveSkillPolicyFor(match.serverSkillId, policies) : null;
if (!matchedSkillId && !match.serverId) { if (!matchedSkillId && !match.serverId) {
reasonCodes.push("skill_not_matched"); reasonCodes.push("skill_not_matched");
@@ -167,6 +228,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, { return result("rejected", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
skill_enabled: false, skill_enabled: false,
matched_skill_id: matchedSkillId, matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: serverEntry?.enabled !== false && serverPolicy?.enabled !== false, server_enabled: serverEntry?.enabled !== false && serverPolicy?.enabled !== false,
global_allow_tools: globalAllowTools, global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools, global_deny_tools: globalDenyTools,
@@ -180,6 +242,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, { return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
skill_enabled: true, skill_enabled: true,
matched_skill_id: matchedSkillId, matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true, server_enabled: true,
global_allow_tools: globalAllowTools, global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools, global_deny_tools: globalDenyTools,
@@ -193,6 +256,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, { return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
skill_enabled: true, skill_enabled: true,
matched_skill_id: matchedSkillId, matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true, server_enabled: true,
global_allow_tools: globalAllowTools, global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools, global_deny_tools: globalDenyTools,
@@ -205,6 +269,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("allowed", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, { return result("allowed", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
skill_enabled: true, skill_enabled: true,
matched_skill_id: matchedSkillId, matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true, server_enabled: true,
global_allow_tools: globalAllowTools, global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools, global_deny_tools: globalDenyTools,
@@ -286,18 +351,67 @@ function result(
} }
function isSkillDisabled(policies: DigitalEmployeeSkillPolicies, skillId: string): boolean { function isSkillDisabled(policies: DigitalEmployeeSkillPolicies, skillId: string): boolean {
return policies.skills[skillId]?.enabled === false; return effectiveSkillPolicyFor(skillId, policies).enabled === false;
}
function skillPolicySnapshotFields(
matchedPolicy: DigitalEmployeeEffectiveSkillPolicy | null,
serverPolicy: DigitalEmployeeEffectiveSkillPolicy | null,
): Partial<DigitalEmployeeSkillCallPolicyResult["policy_snapshot"]> {
return {
...(matchedPolicy ? {
matched_policy_source: matchedPolicy.policy_source,
matched_policy_disabled_by: matchedPolicy.disabled_by,
matched_remote_policy: matchedPolicy.remote_policy,
matched_local_policy: matchedPolicy.local_policy,
} : {}),
...(serverPolicy ? {
server_policy_source: serverPolicy.policy_source,
server_policy_disabled_by: serverPolicy.disabled_by,
server_remote_policy: serverPolicy.remote_policy,
server_local_policy: serverPolicy.local_policy,
} : {}),
};
} }
function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies { function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
const record = objectRecord(value); const record = objectRecord(value);
const rawSkills = objectRecord(record?.skills) ?? record; const hasStructuredMaps = Boolean(record && (
"local_skills" in record
|| "localSkills" in record
|| "remote_skills" in record
|| "remoteSkills" in record
|| "skills" in record
));
const rawLocalSkills = objectRecord(record?.local_skills ?? record?.localSkills)
?? objectRecord(record?.skills)
?? (hasStructuredMaps ? {} : record);
const rawRemoteSkills = objectRecord(record?.remote_skills ?? record?.remoteSkills) ?? {};
const localSkills = normalizePolicyMap(rawLocalSkills, "local");
const remoteSkills = normalizePolicyMap(rawRemoteSkills, "remote");
const skills = mergeEffectiveSkillPolicies(localSkills, remoteSkills);
return {
version: 1,
local_skills: localSkills,
remote_skills: remoteSkills,
skills,
source: normalizePolicySource(record?.source),
remote_updated_at: normalizeNullableString(record?.remote_updated_at ?? record?.remoteUpdatedAt),
last_pulled_at: normalizeNullableString(record?.last_pulled_at ?? record?.lastPulledAt),
last_pull_error: normalizeNullableString(record?.last_pull_error ?? record?.lastPullError),
};
}
function normalizePolicyMap(
rawSkills: Record<string, unknown> | null,
defaultSource: "local" | "remote",
): Record<string, DigitalEmployeeSkillPolicyRecord> {
const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {}; const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) { for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
const skillId = normalizeString(rawSkillId); const skillId = normalizeString(rawSkillId);
if (!skillId) continue; if (!skillId) continue;
if (typeof rawPolicy === "boolean") { if (typeof rawPolicy === "boolean") {
skills[skillId] = { enabled: rawPolicy, updatedAt: "" }; skills[skillId] = { enabled: rawPolicy, updatedAt: "", source: defaultSource, reason: null };
continue; continue;
} }
const policy = objectRecord(rawPolicy); const policy = objectRecord(rawPolicy);
@@ -305,9 +419,38 @@ function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
skills[skillId] = { skills[skillId] = {
enabled: policy.enabled, enabled: policy.enabled,
updatedAt: normalizeString(policy.updatedAt ?? policy.updated_at), updatedAt: normalizeString(policy.updatedAt ?? policy.updated_at),
source: normalizeSkillPolicyRecordSource(policy.source) ?? defaultSource,
reason: normalizeNullableString(policy.reason),
}; };
} }
return { version: 1, skills }; return skills;
}
function mergeEffectiveSkillPolicies(
localSkills: Record<string, DigitalEmployeeSkillPolicyRecord>,
remoteSkills: Record<string, DigitalEmployeeSkillPolicyRecord>,
): Record<string, DigitalEmployeeSkillPolicyRecord> {
const result: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
for (const skillId of uniqueStrings([...Object.keys(localSkills), ...Object.keys(remoteSkills)])) {
const effective = effectiveSkillPolicyFor(skillId, {
version: 1,
local_skills: localSkills,
remote_skills: remoteSkills,
skills: {},
});
const sourcePolicy = effective.disabled_by === "remote"
? effective.remote_policy
: effective.disabled_by === "local"
? effective.local_policy
: effective.remote_policy ?? effective.local_policy;
result[skillId] = {
enabled: effective.enabled,
updatedAt: sourcePolicy?.updatedAt ?? "",
source: effective.policy_source === "open" ? undefined : effective.policy_source,
reason: sourcePolicy?.reason ?? null,
};
}
return result;
} }
function normalizeToolList(value: unknown): string[] { function normalizeToolList(value: unknown): string[] {
@@ -325,6 +468,19 @@ function normalizeString(value: unknown): string {
return typeof value === "string" && value.trim() ? value.trim() : ""; return typeof value === "string" && value.trim() ? value.trim() : "";
} }
function normalizeNullableString(value: unknown): string | null {
const normalized = normalizeString(value);
return normalized || null;
}
function normalizePolicySource(value: unknown): DigitalEmployeeSkillPolicies["source"] {
return value === "remote" || value === "mixed" ? value : "local";
}
function normalizeSkillPolicyRecordSource(value: unknown): DigitalEmployeeSkillPolicyRecord["source"] | null {
return value === "remote" || value === "local" ? value : null;
}
function objectRecord(value: unknown): Record<string, unknown> | null { function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> ? value as Record<string, unknown>

View File

@@ -137,6 +137,106 @@ describe("digital employee sync service", () => {
}); });
}); });
it("pulls remote skill policies and preserves local skill preferences", async () => {
mockState.settings.set("digital_employee_skill_policies_v1", {
version: 1,
local_skills: {
"qimingclaw-acp-session": { enabled: false, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
},
});
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
skill_policies: {
updated_at: "2026-06-07T08:01:00.000Z",
skills: {
"qimingclaw-mcp-tool-crm-crm-delete": {
enabled: false,
updated_at: "2026-06-07T08:01:00.000Z",
reason: "management_disabled",
},
},
},
},
}));
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/policies/skills",
policies: {
source: "remote",
remote_updated_at: "2026-06-07T08:01:00.000Z",
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: null,
local_skills: {
"qimingclaw-acp-session": expect.objectContaining({ enabled: false, source: "local" }),
},
remote_skills: {
"qimingclaw-mcp-tool-crm-crm-delete": expect.objectContaining({ enabled: false, source: "remote", reason: "management_disabled" }),
},
},
});
expect(mockState.fetch).toHaveBeenCalledWith(
"https://manage.example.com/api/digital-employee/policies/skills",
expect.objectContaining({ method: "GET" }),
);
expect(mockState.settings.get("digital_employee_skill_policies_v1")).toMatchObject(result.policies);
});
it("keeps current skill policies when remote skill policy pull fails", async () => {
mockState.settings.set("digital_employee_skill_policies_v1", {
version: 1,
remote_skills: {
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T07:30:00.000Z", source: "remote" },
},
});
mockState.fetch.mockResolvedValue({
ok: false,
status: 503,
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "skill policy service unavailable" })),
} as unknown as Response);
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
expect(result).toMatchObject({
ok: false,
error: "skill policy service unavailable",
policies: {
remote_skills: {
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "remote" }),
},
last_pulled_at: "2026-06-07T08:00:00.000Z",
last_pull_error: "skill policy service unavailable",
},
});
});
it.each([
["skillPolicies"],
["policies"],
])("pulls remote skill policies from data.%s", async (fieldName) => {
mockState.fetch.mockResolvedValue(jsonResponse({
success: true,
data: {
[fieldName]: {
"qimingclaw-acp-session": false,
},
},
}));
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
expect(result.policies.remote_skills["qimingclaw-acp-session"]).toEqual(expect.objectContaining({
enabled: false,
source: "remote",
}));
});
it("acquires a remote plan step dispatch lease through the management endpoint", async () => { it("acquires a remote plan step dispatch lease through the management endpoint", async () => {
mockState.fetch.mockResolvedValue(jsonResponse({ mockState.fetch.mockResolvedValue(jsonResponse({
ok: true, ok: true,

View File

@@ -1,16 +1,23 @@
import log from "electron-log"; import log from "electron-log";
import { getDeviceId } from "../system/deviceId"; import { getDeviceId } from "../system/deviceId";
import { getDomainTokenKey } from "@shared/utils/domain"; import { getDomainTokenKey } from "@shared/utils/domain";
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db"; import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
import { import {
normalizePlanStepDispatchPolicy, normalizePlanStepDispatchPolicy,
readDigitalEmployeeUiState, readDigitalEmployeeUiState,
saveDigitalEmployeeUiState, saveDigitalEmployeeUiState,
type DigitalEmployeePlanStepDispatchPolicy, type DigitalEmployeePlanStepDispatchPolicy,
} from "./stateService"; } from "./stateService";
import {
SKILL_POLICIES_SETTING_KEY,
normalizeDigitalEmployeeSkillPolicies,
readDigitalEmployeeSkillPolicies,
type DigitalEmployeeSkillPolicies,
} from "./skillExecutionPolicy";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox"; const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch"; const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch"; const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH = const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync"; "/system/content/content-digital-employee-sync";
@@ -29,6 +36,8 @@ let lastSyncAt: string | null = null;
let lastSyncError: string | null = null; let lastSyncError: string | null = null;
let policyPulling = false; let policyPulling = false;
let lastPolicyPullAttemptMs = 0; let lastPolicyPullAttemptMs = 0;
let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> { function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null; if (!ensureDigitalEmployeeSchema()) return null;
@@ -39,12 +48,22 @@ interface DigitalEmployeeSyncConfig {
enabled: boolean; enabled: boolean;
endpoint: string | null; endpoint: string | null;
policyEndpoint: string | null; policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
leaseEndpoint: string | null; leaseEndpoint: string | null;
clientKey: string | null; clientKey: string | null;
authToken: string | null; authToken: string | null;
batchSize: number; batchSize: number;
} }
export interface DigitalEmployeeSkillPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policies: DigitalEmployeeSkillPolicies;
error?: string | null;
}
export interface DigitalEmployeePlanStepDispatchPolicyPullResult { export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
ok: boolean; ok: boolean;
skipped?: boolean; skipped?: boolean;
@@ -289,6 +308,66 @@ export async function pullDigitalEmployeePlanStepDispatchPolicy(
} }
} }
export async function pullDigitalEmployeeSkillPolicies(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeSkillPolicyPullResult> {
const currentPolicies = readDigitalEmployeeSkillPolicies();
if (skillPolicyPulling) {
return {
ok: true,
skipped: true,
endpoint: resolveSyncConfig().skillPolicyEndpoint,
pulled_at: currentPolicies.last_pulled_at ?? null,
policies: currentPolicies,
};
}
const nowMs = Date.now();
if (!options.force && nowMs - lastSkillPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
return {
ok: true,
skipped: true,
endpoint: resolveSyncConfig().skillPolicyEndpoint,
pulled_at: currentPolicies.last_pulled_at ?? null,
policies: currentPolicies,
};
}
lastSkillPolicyPullAttemptMs = nowMs;
const config = resolveSyncConfig();
const missingCredentials = missingSyncCredentialLabels(config);
if (!config.skillPolicyEndpoint || missingCredentials.length > 0) {
const error = !config.skillPolicyEndpoint ? "management skill policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, error);
}
skillPolicyPulling = true;
try {
const response = await fetchJsonWithAuth(config, config.skillPolicyEndpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const remotePayload = readRemoteSkillPolicyPayload(response);
if (!remotePayload) throw new Error("management policy response missing skill policies");
const remoteSkills = readRemoteSkillPolicyRecords(remotePayload);
if (!remoteSkills) throw new Error("management policy response missing skill policy records");
const pulledAt = new Date().toISOString();
const policies = normalizeDigitalEmployeeSkillPolicies({
version: 1,
local_skills: currentPolicies.local_skills,
remote_skills: remoteSkills,
source: "remote",
remote_updated_at: stringField(remotePayload.updated_at) || stringField(remotePayload.updatedAt) || stringField(remotePayload.remote_updated_at) || null,
last_pulled_at: pulledAt,
last_pull_error: null,
});
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
return { ok: true, endpoint: config.skillPolicyEndpoint, pulled_at: pulledAt, policies, error: null };
} catch (error) {
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, normalizeError(error));
} finally {
skillPolicyPulling = false;
}
}
export async function acquireDigitalEmployeePlanStepRemoteLease( export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput, input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> { ): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -493,6 +572,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.policyEndpoint.trim() ? config.policyEndpoint.trim()
: null; : null;
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH); const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
const skillPolicyEndpointOverride =
typeof config.skillPolicyEndpoint === "string" && config.skillPolicyEndpoint.trim()
? config.skillPolicyEndpoint.trim()
: null;
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH);
const leaseEndpointOverride = const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim() typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim() ? config.planStepDispatchLeaseEndpoint.trim()
@@ -508,6 +592,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
enabled: !disabled, enabled: !disabled,
endpoint, endpoint,
policyEndpoint, policyEndpoint,
skillPolicyEndpoint,
leaseEndpoint, leaseEndpoint,
clientKey: readClientKey(serverHost), clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost), authToken: readAuthToken(serverHost),
@@ -645,6 +730,22 @@ function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Re
?? (hasPlanStepDispatchPolicyFields(data) ? data : null); ?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
} }
function readRemoteSkillPolicyPayload(response: Record<string, unknown>): Record<string, unknown> | null {
const data = objectRecord(response.data) ?? response;
return objectRecord(data.skill_policies)
?? objectRecord(data.skillPolicies)
?? objectRecord(data.policies)
?? objectRecord(data.policy)
?? (hasSkillPolicyWrapperFields(data) || looksLikeSkillPolicyMap(data) ? data : null);
}
function readRemoteSkillPolicyRecords(payload: Record<string, unknown>): Record<string, unknown> | null {
return objectRecord(payload.remote_skills)
?? objectRecord(payload.remoteSkills)
?? objectRecord(payload.skills)
?? (looksLikeSkillPolicyMap(payload) ? payload : null);
}
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null { function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) { if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected"; return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
@@ -703,6 +804,23 @@ function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boole
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record; return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
} }
function hasSkillPolicyWrapperFields(record: Record<string, unknown>): boolean {
return "remote_skills" in record
|| "remoteSkills" in record
|| "skills" in record
|| "skill_policies" in record
|| "skillPolicies" in record
|| "policies" in record;
}
function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
return Object.values(record).some((value) => {
if (typeof value === "boolean") return true;
const policy = objectRecord(value);
return Boolean(policy && typeof policy.enabled === "boolean");
});
}
function recordPlanStepDispatchPolicyPullFailure( function recordPlanStepDispatchPolicyPullFailure(
state: ReturnType<typeof readDigitalEmployeeUiState>, state: ReturnType<typeof readDigitalEmployeeUiState>,
currentPolicy: DigitalEmployeePlanStepDispatchPolicy, currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
@@ -732,6 +850,21 @@ function recordPlanStepDispatchPolicyPullFailure(
return { ok: false, endpoint, pulled_at: pulledAt, policy, error }; return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
} }
function recordSkillPolicyPullFailure(
currentPolicies: DigitalEmployeeSkillPolicies,
endpoint: string | null,
error: string,
): DigitalEmployeeSkillPolicyPullResult {
const pulledAt = new Date().toISOString();
const policies = normalizeDigitalEmployeeSkillPolicies({
...currentPolicies,
last_pulled_at: pulledAt,
last_pull_error: error,
});
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
return { ok: false, endpoint, pulled_at: pulledAt, policies, error };
}
function buildSyncItem(row: OutboxRow): Record<string, unknown> { function buildSyncItem(row: OutboxRow): Record<string, unknown> {
const payload = parsePayload(row.payload); const payload = parsePayload(row.payload);
const summary = readEntitySummary(row.entity_type, row.entity_id); const summary = readEntitySummary(row.entity_type, row.entity_id);

View File

@@ -525,10 +525,12 @@ describe("AcpEngine.handlePermissionRequest(strict)", () => {
}; };
mockDigitalEmployee.settings.set("digital_employee_skill_policies_v1", { mockDigitalEmployee.settings.set("digital_employee_skill_policies_v1", {
version: 1, version: 1,
skills: { remote_skills: {
"qimingclaw-mcp-tool-crm-crm-delete": { "qimingclaw-mcp-tool-crm-crm-delete": {
enabled: false, enabled: false,
updatedAt: "2026-06-07T08:00:00.000Z", updatedAt: "2026-06-07T08:00:00.000Z",
source: "remote",
reason: "management_disabled",
}, },
}, },
}); });
@@ -558,6 +560,14 @@ describe("AcpEngine.handlePermissionRequest(strict)", () => {
decision: "rejected", decision: "rejected",
reasonCodes: ["skill_disabled"], reasonCodes: ["skill_disabled"],
sessionId, sessionId,
policySnapshot: expect.objectContaining({
matched_policy_source: "remote",
matched_policy_disabled_by: "remote",
matched_remote_policy: expect.objectContaining({
enabled: false,
reason: "management_disabled",
}),
}),
})); }));
expect(JSON.stringify(mockDigitalEmployee.recordSkillCallAudit.mock.calls[0][0])).not.toContain("secret-customer"); expect(JSON.stringify(mockDigitalEmployee.recordSkillCallAudit.mock.calls[0][0])).not.toContain("secret-customer");
}); });

View File

@@ -131,6 +131,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullPlanStepDispatchPolicy() { async pullPlanStepDispatchPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy"); return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
}, },
async pullSkillPolicies() {
return ipcRenderer.invoke("digitalEmployee:pullSkillPolicies");
},
async getCronSettings() { async getCronSettings() {
return ipcRenderer.invoke("digitalEmployee:getCronSettings"); return ipcRenderer.invoke("digitalEmployee:getCronSettings");
}, },

View File

@@ -447,7 +447,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。 - 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans``digital_plan_steps``digital_schedules``digital_schedule_runs``digital_tasks``digital_runs``digital_events``digital_artifacts``digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情、调度列表和流程投影本地记录为空时才回落到 qimingclaw 固定适配任务。 - 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans``digital_plan_steps``digital_schedules``digital_schedule_runs``digital_tasks``digital_runs``digital_events``digital_artifacts``digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情、调度列表和流程投影本地记录为空时才回落到 qimingclaw 固定适配任务。
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings保存时会记录 `digital_workday_*` 本地事件并进入 outbox避免这些页面操作只留在 webview localStorage。 - 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings保存时会记录 `digital_workday_*` 本地事件并进入 outbox避免这些页面操作只留在 webview localStorage。
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。 - 数字员工技能库通过 `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 记录为空时的兼容产物入口展示。 - 数字员工调试存储、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 同步。 - 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同时生成 `digital_workday_decide_approval` runtime event。同步失败 review 只保留在 dashboard/同步诊断中,不作为可审批 workday decision。
@@ -471,12 +471,14 @@ digitalEmployee:getRuntimeRecords
digitalEmployee:getUiState digitalEmployee:getUiState
digitalEmployee:saveUiState digitalEmployee:saveUiState
digitalEmployee:getSkillCapabilities digitalEmployee:getSkillCapabilities
digitalEmployee:pullSkillPolicies
window.QimingClawBridge.digital.getSyncStatus() window.QimingClawBridge.digital.getSyncStatus()
window.QimingClawBridge.digital.flushSync() window.QimingClawBridge.digital.flushSync()
window.QimingClawBridge.digital.getRuntimeRecords() window.QimingClawBridge.digital.getRuntimeRecords()
window.QimingClawBridge.digital.getUiState() window.QimingClawBridge.digital.getUiState()
window.QimingClawBridge.digital.saveUiState(update) window.QimingClawBridge.digital.saveUiState(update)
window.QimingClawBridge.digital.getSkillCapabilities() window.QimingClawBridge.digital.getSkillCapabilities()
window.QimingClawBridge.digital.pullSkillPolicies()
``` ```
当前边界: 当前边界:
@@ -542,7 +544,7 @@ GET /api/digital-employee/approvals
- 已吸收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派发前先通过管理端 `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 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。 - 当前能力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 或高风险动作审批策略,把本地可治理调度继续推进到跨端治理策略。
2. **调度 / 定时 / 周期任务** 2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。 - 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
@@ -568,9 +570,9 @@ GET /api/digital-employee/approvals
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。 - 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
6. **技能生命周期与策略** 6. **技能生命周期与策略**
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤MCP 注入 Agent 前会应用本地技能策略ACP permission / tool update 会写入 `skill_call_allowed` / `skill_call_rejected` / `skill_call_observed` 审计事件embedded 已提供 `/api/digital-employee/skill-call-audits` 只读投影,技能调用策略与审计已开始闭环。 - 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1.local_skills`,管理端下发策略写入 `remote_skills`,远端禁用优先生效且不会被本地按钮覆盖;入口路由候选技能会按有效策略过滤MCP 注入 Agent 前会应用有效技能策略ACP permission / tool update 会写入 `skill_call_allowed` / `skill_call_rejected` / `skill_call_observed` 审计事件,`policy_snapshot` 会包含本地/远端策略命中字段;embedded 已提供 `/api/digital-employee/skill-call-audits` 只读投影`POST /api/skill/policies/pull` 手动拉取入口,技能调用策略与审计已开始闭环。
- 缺口:仍缺少技能安装、版本管理、细粒度权限范围管理端调用审计视图和远端策略下发 - 缺口:仍缺少技能安装、版本管理、细粒度权限范围管理端调用审计视图。
- 建议:下一轮围绕管理端审计消费和远端策略下发,把本地调用审计推进到跨端治理闭环。 - 建议:下一轮围绕管理端审计消费、技能安装和权限范围,把本地调用审计推进到更完整的跨端治理闭环。
7. **产物交付闭环** 7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件Artifact 版本/保留策略已开始闭环,业务产物视图可读取 version / retention 摘要,保留标记会写入 `artifact_retention_marked` / `artifact_retention_rejected` / `artifact_version_observed` 事件。 - 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件Artifact 版本/保留策略已开始闭环,业务产物视图可读取 version / retention 摘要,保留标记会写入 `artifact_retention_marked` / `artifact_retention_rejected` / `artifact_version_observed` 事件。
@@ -995,7 +997,8 @@ create_workspace / upload_file / download_all_files / export_project / ...
用户可以: 用户可以:
- 查看技能 - 查看技能
- 启用 / 禁用(本地持久化到 `digital_employee_skill_policies_v1`,并影响入口路由候选技能) - 启用 / 禁用(本地持久化到 `digital_employee_skill_policies_v1.local_skills`,并影响入口路由候选技能)
- 手动拉取管理端技能策略(远端策略写入 `remote_skills`,远端禁用优先生效)
- 查看所属 MCP server - 查看所属 MCP server
- 查看 MCP allow / deny 策略 - 查看 MCP allow / deny 策略
- 查看 MCP proxy 运行状态 - 查看 MCP proxy 运行状态
@@ -1009,13 +1012,14 @@ create_workspace / upload_file / download_all_files / export_project / ...
- 工具从“技术配置”变成“数字员工能力”。 - 工具从“技术配置”变成“数字员工能力”。
- PlanTemplate 可以绑定 Skill。 - PlanTemplate 可以绑定 Skill。
- MCP server 和工具能按治理策略投射成可扫描的技能卡。 - MCP server 和工具能按治理策略投射成可扫描的技能卡。
- Skill Library 的启用 / 禁用操作会通过 `digitalEmployee:setSkillEnabled` 写入 qimingclaw 本地策略`/api/skill` 后续读取真实目录状态。 - Skill Library 的启用 / 禁用操作会通过 `digitalEmployee:setSkillEnabled` 写入 qimingclaw 本地策略`拉取策略` 会通过 `digitalEmployee:pullSkillPolicies` 更新管理端远端策略;`/api/skill` 后续读取真实目录状态和本地/远端治理命中
### 验收 ### 验收
- MCP tool 能出现在技能库。 - MCP tool 能出现在技能库。
- MCP server 能显示 allow / deny、运行状态和最近调用。 - MCP server 能显示 allow / deny、运行状态和最近调用。
- 禁用技能后,入口路由不会再把它作为候选技能;若候选全部被禁用,路由决策会记录 `candidate_skills_disabled` - 禁用技能后,入口路由不会再把它作为候选技能;若候选全部被禁用,路由决策会记录 `candidate_skills_disabled`
- 管理端远端策略禁用某个 skill 后本地目录、MCP 注入和 ACP permission 审计都会显示远端策略命中并拒绝调用。
## Phase 7Approval / Human-in-the-loop ## Phase 7Approval / Human-in-the-loop