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

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>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {

View File

@@ -78,6 +78,9 @@ export interface SkillSummary {
allowTools?: string[];
denyTools?: string[];
governancePolicy?: 'allow' | 'deny' | 'open' | 'disabled' | null;
policySource?: 'remote' | 'local' | 'open' | null;
remotePolicy?: Record<string, unknown> | null;
localPolicy?: Record<string, unknown> | null;
recentCalls?: Array<{
eventId: 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),
denyTools: stringList(s.deny_tools ?? governance?.server_deny_tools ?? governance?.global_deny_tools),
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) => {
const c = obj(call) ?? {};
return {
@@ -533,6 +539,12 @@ function governancePolicy(value: unknown): SkillSummary['governancePolicy'] {
: null;
}
function policySource(value: unknown): SkillSummary['policySource'] {
return value === 'remote' || value === 'local' || value === 'open'
? value
: null;
}
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj;

View File

@@ -54,6 +54,7 @@ declare global {
getUiState?: () => Promise<AdapterState>;
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
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>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -975,6 +976,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/skill') {
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')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || '');
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[] {
const seen = new Set<string>();
const result: SkillSpec[] = [];

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, setSkillEnabled } from '@/lib/api';
import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
import { domSafeId } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
@@ -81,6 +81,8 @@ function skillMetaBadges(skill: SkillSummary): string[] {
const badges: string[] = [];
if (skill.serverId) badges.push(skill.serverId);
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 (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`);
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 [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
const [pullingPolicies, setPullingPolicies] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
@@ -217,6 +221,7 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const nextEnabled = !skill.enabled;
setUpdatingSkillId(skill.id);
setActionError(null);
setActionNotice(null);
try {
const result = await setSkillEnabled(skill.id, nextEnabled);
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
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => {
@@ -322,6 +343,9 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<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>
</CardTitleBar>
<div className="de-card-body">
@@ -330,6 +354,11 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
{actionError}
</div>
)}
{actionNotice && (
<div className="de-schedule-result" style={{ marginBottom: 12 }}>
{actionNotice}
</div>
)}
<div className="de-skill-toolbar">
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
<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);
}
</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">
</head>
<body>

View File

@@ -409,6 +409,55 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
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() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -455,6 +504,7 @@ function main() {
checkDigitalNotificationPreferences();
checkDigitalApprovalHistorySubscriptions();
checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -95,6 +95,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
flushDigitalEmployeeSyncOutbox: 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 } })),
pullDigitalEmployeeSkillPolicies: vi.fn(async () => ({ ok: true, policies: { version: 1, local_skills: {}, remote_skills: {}, skills: {} } })),
}));
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
@@ -250,6 +251,23 @@ describe("digital employee managed service actions", () => {
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 () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");

View File

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

View File

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

View File

@@ -3,11 +3,28 @@ import { readSetting } from "../../db";
export interface DigitalEmployeeSkillPolicyRecord {
enabled: boolean;
updatedAt: string;
source?: "local" | "remote";
reason?: string | null;
}
export interface DigitalEmployeeSkillPolicies {
version: 1;
local_skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
remote_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 {
@@ -47,6 +64,14 @@ export interface DigitalEmployeeSkillCallPolicyResult {
policy_snapshot: {
skill_enabled?: boolean;
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;
global_allow_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)}`;
}
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(
config: DigitalEmployeeMcpPolicyConfig,
): DigitalEmployeeMcpPolicyConfig {
@@ -148,8 +209,8 @@ export function evaluateDigitalEmployeeSkillCall(
const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
const reasonCodes: string[] = [];
const matchedSkillId = skillId || null;
const matchedPolicy = matchedSkillId ? policies.skills[matchedSkillId] : undefined;
const serverPolicy = match.serverSkillId ? policies.skills[match.serverSkillId] : undefined;
const matchedPolicy = matchedSkillId ? effectiveSkillPolicyFor(matchedSkillId, policies) : null;
const serverPolicy = match.serverSkillId ? effectiveSkillPolicyFor(match.serverSkillId, policies) : null;
if (!matchedSkillId && !match.serverId) {
reasonCodes.push("skill_not_matched");
@@ -167,6 +228,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
skill_enabled: false,
matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: serverEntry?.enabled !== false && serverPolicy?.enabled !== false,
global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools,
@@ -180,6 +242,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
skill_enabled: true,
matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true,
global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools,
@@ -193,6 +256,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
skill_enabled: true,
matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true,
global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools,
@@ -205,6 +269,7 @@ export function evaluateDigitalEmployeeSkillCall(
return result("allowed", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
skill_enabled: true,
matched_skill_id: matchedSkillId,
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
server_enabled: true,
global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools,
@@ -286,18 +351,67 @@ function result(
}
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 {
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> = {};
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
const skillId = normalizeString(rawSkillId);
if (!skillId) continue;
if (typeof rawPolicy === "boolean") {
skills[skillId] = { enabled: rawPolicy, updatedAt: "" };
skills[skillId] = { enabled: rawPolicy, updatedAt: "", source: defaultSource, reason: null };
continue;
}
const policy = objectRecord(rawPolicy);
@@ -305,9 +419,38 @@ function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
skills[skillId] = {
enabled: policy.enabled,
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[] {
@@ -325,6 +468,19 @@ function normalizeString(value: unknown): string {
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 {
return value && typeof value === "object" && !Array.isArray(value)
? 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 () => {
mockState.fetch.mockResolvedValue(jsonResponse({
ok: true,

View File

@@ -1,16 +1,23 @@
import log from "electron-log";
import { getDeviceId } from "../system/deviceId";
import { getDomainTokenKey } from "@shared/utils/domain";
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
import {
normalizePlanStepDispatchPolicy,
readDigitalEmployeeUiState,
saveDigitalEmployeeUiState,
type DigitalEmployeePlanStepDispatchPolicy,
} from "./stateService";
import {
SKILL_POLICIES_SETTING_KEY,
normalizeDigitalEmployeeSkillPolicies,
readDigitalEmployeeSkillPolicies,
type DigitalEmployeeSkillPolicies,
} from "./skillExecutionPolicy";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
@@ -29,6 +36,8 @@ let lastSyncAt: string | null = null;
let lastSyncError: string | null = null;
let policyPulling = false;
let lastPolicyPullAttemptMs = 0;
let skillPolicyPulling = false;
let lastSkillPolicyPullAttemptMs = 0;
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
if (!ensureDigitalEmployeeSchema()) return null;
@@ -39,12 +48,22 @@ interface DigitalEmployeeSyncConfig {
enabled: boolean;
endpoint: string | null;
policyEndpoint: string | null;
skillPolicyEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
batchSize: number;
}
export interface DigitalEmployeeSkillPolicyPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
policies: DigitalEmployeeSkillPolicies;
error?: string | null;
}
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
ok: boolean;
skipped?: boolean;
@@ -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(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -493,6 +572,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.policyEndpoint.trim()
: null;
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
const skillPolicyEndpointOverride =
typeof config.skillPolicyEndpoint === "string" && config.skillPolicyEndpoint.trim()
? config.skillPolicyEndpoint.trim()
: null;
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH);
const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim()
@@ -508,6 +592,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
enabled: !disabled,
endpoint,
policyEndpoint,
skillPolicyEndpoint,
leaseEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
@@ -645,6 +730,22 @@ function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Re
?? (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 {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
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;
}
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(
state: ReturnType<typeof readDigitalEmployeeUiState>,
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
@@ -732,6 +850,21 @@ function recordPlanStepDispatchPolicyPullFailure(
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> {
const payload = parsePayload(row.payload);
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", {
version: 1,
skills: {
remote_skills: {
"qimingclaw-mcp-tool-crm-crm-delete": {
enabled: false,
updatedAt: "2026-06-07T08:00:00.000Z",
source: "remote",
reason: "management_disabled",
},
},
});
@@ -558,6 +560,14 @@ describe("AcpEngine.handlePermissionRequest(strict)", () => {
decision: "rejected",
reasonCodes: ["skill_disabled"],
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");
});

View File

@@ -131,6 +131,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullPlanStepDispatchPolicy() {
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
},
async pullSkillPolicies() {
return ipcRenderer.invoke("digitalEmployee:pullSkillPolicies");
},
async 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 固定适配任务。
- 数字员工页面的任务确认、资料设置、角色选择、运营偏好、日报状态通过 `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 记录为空时的兼容产物入口展示。
- 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。
@@ -471,12 +471,14 @@ digitalEmployee:getRuntimeRecords
digitalEmployee:getUiState
digitalEmployee:saveUiState
digitalEmployee:getSkillCapabilities
digitalEmployee:pullSkillPolicies
window.QimingClawBridge.digital.getSyncStatus()
window.QimingClawBridge.digital.flushSync()
window.QimingClawBridge.digital.getRuntimeRecords()
window.QimingClawBridge.digital.getUiState()
window.QimingClawBridge.digital.saveUiState(update)
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`
- 当前能力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 或高风险动作审批策略,把本地可治理调度继续推进到跨端治理策略。
2. **调度 / 定时 / 周期任务**
- 已吸收:`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. **技能生命周期与策略**
- 已吸收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. **产物交付闭环**
- 已吸收:运行 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 allow / deny 策略
- 查看 MCP proxy 运行状态
@@ -1009,13 +1012,14 @@ create_workspace / upload_file / download_all_files / export_project / ...
- 工具从“技术配置”变成“数字员工能力”。
- PlanTemplate 可以绑定 Skill。
- MCP server 和工具能按治理策略投射成可扫描的技能卡。
- Skill Library 的启用 / 禁用操作会通过 `digitalEmployee:setSkillEnabled` 写入 qimingclaw 本地策略`/api/skill` 后续读取真实目录状态。
- Skill Library 的启用 / 禁用操作会通过 `digitalEmployee:setSkillEnabled` 写入 qimingclaw 本地策略`拉取策略` 会通过 `digitalEmployee:pullSkillPolicies` 更新管理端远端策略;`/api/skill` 后续读取真实目录状态和本地/远端治理命中
### 验收
- MCP tool 能出现在技能库。
- MCP server 能显示 allow / deny、运行状态和最近调用。
- 禁用技能后,入口路由不会再把它作为候选技能;若候选全部被禁用,路由决策会记录 `candidate_skills_disabled`
- 管理端远端策略禁用某个 skill 后本地目录、MCP 注入和 ACP permission 审计都会显示远端策略命中并拒绝调用。
## Phase 7Approval / Human-in-the-loop