吸收数字员工技能远端策略下发
This commit is contained in:
@@ -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]) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 / Approval;Run/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 / Approval;Run/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 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
- 已吸收:`digital_schedules`、`digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outbox,entity type 为 `schedule` / `schedule_run`;旧 `/api/cron`、`/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
|
||||||
@@ -568,9 +570,9 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
|
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
|
||||||
|
|
||||||
6. **技能生命周期与策略**
|
6. **技能生命周期与策略**
|
||||||
- 已吸收:ACP / MCP / GUI / file server 能力已投射到 Skill Catalog,技能库不再使用 demo fallback;Skill 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 fallback;Skill 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 7:Approval / Human-in-the-loop
|
## Phase 7:Approval / Human-in-the-loop
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user