吸收数字员工安全服务重启入口
This commit is contained in:
@@ -26,6 +26,7 @@ import type {
|
|||||||
IngressRouteResponse,
|
IngressRouteResponse,
|
||||||
GovernanceCommandRequest,
|
GovernanceCommandRequest,
|
||||||
GovernanceCommandResponse,
|
GovernanceCommandResponse,
|
||||||
|
DigitalEmployeeManagedService,
|
||||||
DigitalEmployeeWorkdayProjection,
|
DigitalEmployeeWorkdayProjection,
|
||||||
DigitalEmployeeWorkdayActionRequest,
|
DigitalEmployeeWorkdayActionRequest,
|
||||||
PlanDispatchResponse,
|
PlanDispatchResponse,
|
||||||
@@ -613,6 +614,26 @@ export function postDigitalEmployeeWorkdayAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function restartManagedService(
|
||||||
|
serviceId: string,
|
||||||
|
body: { reason?: string; actor?: string } = {},
|
||||||
|
): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
service_id: string;
|
||||||
|
action: 'restart';
|
||||||
|
status: 'success' | 'failed' | 'rejected';
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
managed_service?: DigitalEmployeeManagedService | null;
|
||||||
|
event_id?: string | null;
|
||||||
|
}> {
|
||||||
|
return apiFetch(`/api/digital-employee/managed-services/${encodeURIComponent(serviceId)}/restart`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
||||||
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
|
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ declare global {
|
|||||||
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
|
||||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||||
|
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
|
||||||
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -236,6 +237,17 @@ interface QimingclawDailyReportRecordResult {
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QimingclawManagedServiceActionResponse {
|
||||||
|
ok: boolean;
|
||||||
|
service_id: string;
|
||||||
|
action: 'restart';
|
||||||
|
status: 'success' | 'failed' | 'rejected';
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
managed_service?: DigitalEmployeeManagedService | null;
|
||||||
|
event_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface QimingclawRouteDecisionInput {
|
interface QimingclawRouteDecisionInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
@@ -625,6 +637,13 @@ export async function handleQimingclawDigitalApi<T>(
|
|||||||
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
|
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
|
||||||
return await handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
|
return await handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
|
||||||
}
|
}
|
||||||
|
const managedServiceRestartMatch = pathname.match(/^\/api\/(?:digital-employee\/)?managed-services\/([^/]+)\/restart$/);
|
||||||
|
if (method === 'POST' && managedServiceRestartMatch) {
|
||||||
|
return await handleManagedServiceRestart(
|
||||||
|
decodeURIComponent(managedServiceRestartMatch[1] || ''),
|
||||||
|
parseOptionalJsonBody(options.body),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
const dailyReportDetailMatch = pathname.match(/^\/api\/digital-employee\/daily-reports\/([^/]+)$/);
|
const dailyReportDetailMatch = pathname.match(/^\/api\/digital-employee\/daily-reports\/([^/]+)$/);
|
||||||
if (method === 'GET' && dailyReportDetailMatch) {
|
if (method === 'GET' && dailyReportDetailMatch) {
|
||||||
const reportId = decodeURIComponent(dailyReportDetailMatch[1] || '');
|
const reportId = decodeURIComponent(dailyReportDetailMatch[1] || '');
|
||||||
@@ -1289,6 +1308,45 @@ function managedServiceSummary(services: DigitalEmployeeManagedService[]): strin
|
|||||||
return `ManagedService:${runningCount}/${services.length} 运行,${issueCount} 项需处理`;
|
return `ManagedService:${runningCount}/${services.length} 运行,${issueCount} 项需处理`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleManagedServiceRestart(
|
||||||
|
serviceId: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<QimingclawManagedServiceActionResponse> {
|
||||||
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
|
if (!bridge?.restartManagedService) {
|
||||||
|
const snapshot = await readQimingclawSnapshot();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
service_id: serviceId,
|
||||||
|
action: 'restart',
|
||||||
|
status: 'failed',
|
||||||
|
error: 'bridge_unavailable',
|
||||||
|
managed_service: buildManagedServices(snapshot).find((service) => service.service_id === serviceId) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await bridge.restartManagedService(serviceId, {
|
||||||
|
reason: stringValue(body.reason) || 'digital_employee_managed_service_restart',
|
||||||
|
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||||
|
});
|
||||||
|
const snapshot = await readQimingclawSnapshot();
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
managed_service: result.managed_service ?? buildManagedServices(snapshot).find((service) => service.service_id === serviceId) ?? null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const snapshot = await readQimingclawSnapshot();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
service_id: serviceId,
|
||||||
|
action: 'restart',
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
managed_service: buildManagedServices(snapshot).find((service) => service.service_id === serviceId) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncLine(snapshot: QimingclawSnapshot | null): string {
|
function syncLine(snapshot: QimingclawSnapshot | null): string {
|
||||||
const sync = snapshot?.sync;
|
const sync = snapshot?.sync;
|
||||||
if (!sync) return '管理端同步:未连接';
|
if (!sync) return '管理端同步:未连接';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getSchedulerJobs,
|
getSchedulerJobs,
|
||||||
governanceCommand,
|
governanceCommand,
|
||||||
postDigitalEmployeeWorkdayAction,
|
postDigitalEmployeeWorkdayAction,
|
||||||
|
restartManagedService,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
import type {
|
import type {
|
||||||
@@ -720,6 +721,10 @@ function managedServiceSummary(service: DigitalEmployeeManagedService): string {
|
|||||||
return `${service.name} ${service.status_label}${service.error ? `:${service.error}` : deps}`;
|
return `${service.name} ${service.status_label}${service.error ? `:${service.error}` : deps}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRestartManagedService(service: DigitalEmployeeManagedService): boolean {
|
||||||
|
return service.service_id === 'fileServer' || service.service_id === 'guiServer';
|
||||||
|
}
|
||||||
|
|
||||||
function taskAgentSummary(agent: DigitalEmployeeTaskAgentState): string {
|
function taskAgentSummary(agent: DigitalEmployeeTaskAgentState): string {
|
||||||
const next = agent.next_action === 'retry_or_skip' ? '重试或跳过' : agent.next_action === 'watch_progress' ? '观察进度' : agent.next_action;
|
const next = agent.next_action === 'retry_or_skip' ? '重试或跳过' : agent.next_action === 'watch_progress' ? '观察进度' : agent.next_action;
|
||||||
return `${agent.title} ${agent.status_label},下一步 ${next}`;
|
return `${agent.title} ${agent.status_label},下一步 ${next}`;
|
||||||
@@ -1387,6 +1392,30 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
}
|
}
|
||||||
}, [fetchProjection]);
|
}, [fetchProjection]);
|
||||||
|
|
||||||
|
const restartService = useCallback(async (service: DigitalEmployeeManagedService) => {
|
||||||
|
const actionKey = `managed-service:${service.service_id}:restart`;
|
||||||
|
setActionBusy(actionKey);
|
||||||
|
setActionError(null);
|
||||||
|
setActionNotice(null);
|
||||||
|
try {
|
||||||
|
const response = await restartManagedService(service.service_id, {
|
||||||
|
reason: 'digital_home_managed_service_issue',
|
||||||
|
actor: 'digital_employee_ui',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.error || response.message || `${service.name} 重启失败`);
|
||||||
|
}
|
||||||
|
setActionNotice(`${service.name} 已提交安全重启。`);
|
||||||
|
await fetchProjection();
|
||||||
|
} catch (error) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setActionError(error instanceof Error ? error.message : `${service.name} 重启失败`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setActionBusy(null);
|
||||||
|
}
|
||||||
|
}, [fetchProjection]);
|
||||||
|
|
||||||
const openDuty = useCallback((planItem: DigitalPlanItem) => {
|
const openDuty = useCallback((planItem: DigitalPlanItem) => {
|
||||||
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
|
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
|
||||||
}, [onNavigate, selectedDate]);
|
}, [onNavigate, selectedDate]);
|
||||||
@@ -1679,6 +1708,37 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
|
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{managedServiceIssues.length > 0 && (
|
||||||
|
<div className="de-management-sync-failures" aria-label="服务状态处理" style={{ marginBottom: 10 }}>
|
||||||
|
{managedServiceIssues.map((service) => {
|
||||||
|
const actionKey = `managed-service:${service.service_id}:restart`;
|
||||||
|
const restartable = canRestartManagedService(service);
|
||||||
|
return (
|
||||||
|
<div key={service.service_id} className="de-management-sync-failure" title={service.error || managedServiceSummary(service)}>
|
||||||
|
<span>{service.kind}</span>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<em>{service.status_label}</em>
|
||||||
|
<small>{managedServiceSummary(service)}</small>
|
||||||
|
{restartable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="de-btn-secondary de-btn-sm"
|
||||||
|
disabled={actionBusy === actionKey}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void restartService(service);
|
||||||
|
}}
|
||||||
|
title={`安全重启 ${service.name}`}
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} aria-hidden="true" />
|
||||||
|
<span>{actionBusy === actionKey ? '重启中...' : '重启'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectionDirty && !actionNotice && !actionError && (
|
{selectionDirty && !actionNotice && !actionError && (
|
||||||
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 10 }}>
|
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 10 }}>
|
||||||
当前选择尚未确认,自动刷新不会覆盖你的勾选。
|
当前选择尚未确认,自动刷新不会覆盖你的勾选。
|
||||||
|
|||||||
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-P9lyYQrr.js"></script>
|
<script type="module" crossorigin src="./assets/index-CRPlZfKF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const requiredDigitalTables = [
|
|||||||
const testFiles = [
|
const testFiles = [
|
||||||
"src/main/services/digitalEmployee/stateService.test.ts",
|
"src/main/services/digitalEmployee/stateService.test.ts",
|
||||||
"src/main/services/digitalEmployee/schedulerService.test.ts",
|
"src/main/services/digitalEmployee/schedulerService.test.ts",
|
||||||
|
"src/main/ipc/digitalEmployeeHandlers.test.ts",
|
||||||
"src/main/ipc/eventForwarders.test.ts",
|
"src/main/ipc/eventForwarders.test.ts",
|
||||||
"src/main/services/digitalEmployee/syncService.test.ts",
|
"src/main/services/digitalEmployee/syncService.test.ts",
|
||||||
"src/main/services/digitalEmployee/skillCatalogService.test.ts",
|
"src/main/services/digitalEmployee/skillCatalogService.test.ts",
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { HandlerContext } from "@shared/types/ipc";
|
||||||
|
|
||||||
|
const mockStateService = vi.hoisted(() => ({
|
||||||
|
recordManagedServiceAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockServiceManager = vi.hoisted(() => ({
|
||||||
|
createServiceManager: vi.fn(),
|
||||||
|
startFileServer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
ipcMain: { handle: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@shared/featureFlags", () => ({
|
||||||
|
FEATURES: { ENABLE_GUI_AGENT_SERVER: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../window/serviceManager", () => ({
|
||||||
|
createServiceManager: mockServiceManager.createServiceManager,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/startupPorts", () => ({
|
||||||
|
getConfiguredPorts: () => ({ fileServer: 61234 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/system/shellEnv", () => ({
|
||||||
|
isWindows: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/packages/guiAgentServer", () => ({
|
||||||
|
getGuiAgentServerStatus: () => ({ running: true }),
|
||||||
|
startGuiAgentServer: vi.fn(async () => ({ success: true })),
|
||||||
|
stopGuiAgentServer: vi.fn(async () => ({ success: true })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/packages/windowsMcp", () => ({
|
||||||
|
getWindowsMcpStatus: () => ({ running: false }),
|
||||||
|
startWindowsMcp: vi.fn(async () => ({ success: true })),
|
||||||
|
stopWindowsMcp: vi.fn(async () => ({ success: true })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/engines/unifiedAgent", () => ({
|
||||||
|
agentService: {
|
||||||
|
isReady: true,
|
||||||
|
getEngineType: () => "acp",
|
||||||
|
listAllSessionsDetailed: () => [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/packages/mcp", () => ({
|
||||||
|
mcpProxyManager: { getStatus: () => ({ running: true }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/computerServer", () => ({
|
||||||
|
getComputerServerStatus: () => ({ running: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/stateService", () => ({
|
||||||
|
recordDigitalEmployeeSnapshot: vi.fn(() => ({ updatedAt: null, events: [] })),
|
||||||
|
recordDigitalEmployeeGovernanceCommand: vi.fn(),
|
||||||
|
recordDigitalEmployeeDailyReport: vi.fn(),
|
||||||
|
recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction,
|
||||||
|
readDigitalEmployeeRuntimeRecords: vi.fn(),
|
||||||
|
readDigitalEmployeeState: vi.fn(),
|
||||||
|
readDigitalEmployeeUiState: vi.fn(),
|
||||||
|
readDigitalEmployeeCronSettings: vi.fn(),
|
||||||
|
saveDigitalEmployeeCronSettings: vi.fn(),
|
||||||
|
readDigitalEmployeeScheduleRuns: vi.fn(),
|
||||||
|
saveDigitalEmployeeUiState: vi.fn(),
|
||||||
|
listDigitalEmployeeMemories: vi.fn(),
|
||||||
|
upsertDigitalEmployeeMemory: vi.fn(),
|
||||||
|
deleteDigitalEmployeeMemory: vi.fn(),
|
||||||
|
syncQimingclawMemoryEntries: vi.fn(),
|
||||||
|
recordDigitalEmployeeRouteDecision: vi.fn(),
|
||||||
|
listDigitalEmployeeRouteDecisions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/schedulerService", () => ({
|
||||||
|
runDigitalEmployeeScheduleNow: vi.fn(),
|
||||||
|
runDigitalEmployeeSchedulerCheck: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||||
|
flushDigitalEmployeeSyncOutbox: vi.fn(),
|
||||||
|
getDigitalEmployeeSyncStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
|
||||||
|
listDigitalEmployeePlanTemplates: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/digitalEmployee/skillCatalogService", () => ({
|
||||||
|
listDigitalEmployeeSkillCapabilities: vi.fn(),
|
||||||
|
setDigitalEmployeeSkillEnabled: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/memory", () => ({
|
||||||
|
memoryService: {
|
||||||
|
isInitialized: () => false,
|
||||||
|
listMemories: vi.fn(),
|
||||||
|
addMemory: vi.fn(),
|
||||||
|
deleteMemory: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createCtx(): HandlerContext {
|
||||||
|
return {
|
||||||
|
getMainWindow: () => null,
|
||||||
|
lanproxy: { status: () => ({ running: true }) } as any,
|
||||||
|
fileServer: {
|
||||||
|
stop: vi.fn(() => ({ success: true })),
|
||||||
|
status: vi.fn(() => ({ running: false, port: 61234 })),
|
||||||
|
} as any,
|
||||||
|
agentRunner: {} as any,
|
||||||
|
guiServer: {} as any,
|
||||||
|
agentRunnerPorts: null,
|
||||||
|
setAgentRunnerPorts: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("digital employee managed service actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
|
||||||
|
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
|
||||||
|
mockServiceManager.createServiceManager.mockReturnValue({
|
||||||
|
startFileServer: mockServiceManager.startFileServer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts allowlisted file server without touching core services", async () => {
|
||||||
|
const { restartManagedService } = await import("./digitalEmployeeHandlers");
|
||||||
|
const ctx = createCtx();
|
||||||
|
|
||||||
|
const result = await restartManagedService(ctx, { serviceId: "fileServer", reason: "test", actor: "operator" });
|
||||||
|
|
||||||
|
expect(ctx.fileServer.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockServiceManager.createServiceManager).toHaveBeenCalledWith({
|
||||||
|
lanproxy: ctx.lanproxy,
|
||||||
|
fileServer: ctx.fileServer,
|
||||||
|
agentRunner: ctx.agentRunner,
|
||||||
|
});
|
||||||
|
expect(mockServiceManager.startFileServer).toHaveBeenCalledWith(61234);
|
||||||
|
expect(result).toMatchObject({ ok: true, service_id: "fileServer", status: "success", event_id: "event-1" });
|
||||||
|
expect(mockStateService.recordManagedServiceAction).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
serviceId: "fileServer",
|
||||||
|
action: "restart",
|
||||||
|
status: "success",
|
||||||
|
allowlisted: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects core service restart requests", async () => {
|
||||||
|
const { restartManagedService } = await import("./digitalEmployeeHandlers");
|
||||||
|
const ctx = createCtx();
|
||||||
|
|
||||||
|
const result = await restartManagedService(ctx, { serviceId: "computerServer" });
|
||||||
|
|
||||||
|
expect(ctx.fileServer.stop).not.toHaveBeenCalled();
|
||||||
|
expect(mockServiceManager.createServiceManager).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ ok: false, service_id: "computerServer", status: "rejected", error: "service_restart_not_allowed" });
|
||||||
|
expect(mockStateService.recordManagedServiceAction).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
serviceId: "computerServer",
|
||||||
|
status: "rejected",
|
||||||
|
allowlisted: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns failed status when allowlisted restart fails", async () => {
|
||||||
|
const { restartManagedService } = await import("./digitalEmployeeHandlers");
|
||||||
|
const ctx = createCtx();
|
||||||
|
mockServiceManager.startFileServer.mockResolvedValue({ success: false, error: "port busy" });
|
||||||
|
|
||||||
|
const result = await restartManagedService(ctx, { serviceId: "fileServer" });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, service_id: "fileServer", status: "failed", error: "port busy" });
|
||||||
|
expect(mockStateService.recordManagedServiceAction).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
serviceId: "fileServer",
|
||||||
|
status: "failed",
|
||||||
|
allowlisted: true,
|
||||||
|
result: expect.objectContaining({ error: "port busy" }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,13 +4,24 @@ import { FEATURES } from "@shared/featureFlags";
|
|||||||
import { agentService } from "../services/engines/unifiedAgent";
|
import { agentService } from "../services/engines/unifiedAgent";
|
||||||
import { mcpProxyManager } from "../services/packages/mcp";
|
import { mcpProxyManager } from "../services/packages/mcp";
|
||||||
import { getComputerServerStatus } from "../services/computerServer";
|
import { getComputerServerStatus } from "../services/computerServer";
|
||||||
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
|
import {
|
||||||
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
getGuiAgentServerStatus,
|
||||||
|
startGuiAgentServer,
|
||||||
|
stopGuiAgentServer,
|
||||||
|
} from "../services/packages/guiAgentServer";
|
||||||
|
import {
|
||||||
|
getWindowsMcpStatus,
|
||||||
|
startWindowsMcp,
|
||||||
|
stopWindowsMcp,
|
||||||
|
} from "../services/packages/windowsMcp";
|
||||||
import { isWindows } from "../services/system/shellEnv";
|
import { isWindows } from "../services/system/shellEnv";
|
||||||
|
import { createServiceManager } from "../window/serviceManager";
|
||||||
|
import { getConfiguredPorts } from "../services/startupPorts";
|
||||||
import {
|
import {
|
||||||
recordDigitalEmployeeSnapshot,
|
recordDigitalEmployeeSnapshot,
|
||||||
recordDigitalEmployeeGovernanceCommand,
|
recordDigitalEmployeeGovernanceCommand,
|
||||||
recordDigitalEmployeeDailyReport,
|
recordDigitalEmployeeDailyReport,
|
||||||
|
recordDigitalEmployeeManagedServiceAction,
|
||||||
readDigitalEmployeeRuntimeRecords,
|
readDigitalEmployeeRuntimeRecords,
|
||||||
readDigitalEmployeeState,
|
readDigitalEmployeeState,
|
||||||
readDigitalEmployeeUiState,
|
readDigitalEmployeeUiState,
|
||||||
@@ -25,6 +36,7 @@ import {
|
|||||||
recordDigitalEmployeeRouteDecision,
|
recordDigitalEmployeeRouteDecision,
|
||||||
listDigitalEmployeeRouteDecisions,
|
listDigitalEmployeeRouteDecisions,
|
||||||
type DigitalEmployeeManagedService,
|
type DigitalEmployeeManagedService,
|
||||||
|
type DigitalEmployeeManagedServiceActionInput,
|
||||||
type DigitalEmployeeGovernanceCommandRecord,
|
type DigitalEmployeeGovernanceCommandRecord,
|
||||||
type DigitalEmployeeDailyReportRecordInput,
|
type DigitalEmployeeDailyReportRecordInput,
|
||||||
type DigitalEmployeeMemoryUpsertInput,
|
type DigitalEmployeeMemoryUpsertInput,
|
||||||
@@ -48,6 +60,26 @@ import {
|
|||||||
} from "../services/digitalEmployee/skillCatalogService";
|
} from "../services/digitalEmployee/skillCatalogService";
|
||||||
import { memoryService } from "../services/memory";
|
import { memoryService } from "../services/memory";
|
||||||
|
|
||||||
|
interface RestartManagedServiceRequest {
|
||||||
|
serviceId?: string;
|
||||||
|
service_id?: string;
|
||||||
|
reason?: string | null;
|
||||||
|
actor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestartManagedServiceResponse {
|
||||||
|
ok: boolean;
|
||||||
|
service_id: string;
|
||||||
|
action: "restart";
|
||||||
|
status: "success" | "failed" | "rejected";
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
managed_service?: DigitalEmployeeManagedService | null;
|
||||||
|
event_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESTARTABLE_MANAGED_SERVICES = new Set(["fileServer", "guiServer"]);
|
||||||
|
|
||||||
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||||
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
|
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
|
||||||
const snapshot = buildSnapshot(ctx);
|
const snapshot = buildSnapshot(ctx);
|
||||||
@@ -188,6 +220,105 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return recordDigitalEmployeeDailyReport(input);
|
return recordDigitalEmployeeDailyReport(input);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"digitalEmployee:restartManagedService",
|
||||||
|
async (_, input: RestartManagedServiceRequest) => {
|
||||||
|
return restartManagedService(ctx, input ?? {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartManagedService(
|
||||||
|
ctx: HandlerContext,
|
||||||
|
input: RestartManagedServiceRequest,
|
||||||
|
): Promise<RestartManagedServiceResponse> {
|
||||||
|
const serviceId = String(input.serviceId || input.service_id || "").trim();
|
||||||
|
const base = {
|
||||||
|
service_id: serviceId,
|
||||||
|
action: "restart" as const,
|
||||||
|
};
|
||||||
|
if (!RESTARTABLE_MANAGED_SERVICES.has(serviceId)) {
|
||||||
|
const event = recordManagedServiceAction({
|
||||||
|
serviceId,
|
||||||
|
action: "restart",
|
||||||
|
status: "rejected",
|
||||||
|
reason: input.reason,
|
||||||
|
actor: input.actor,
|
||||||
|
allowlisted: false,
|
||||||
|
result: { error: "service_restart_not_allowed" },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
ok: false,
|
||||||
|
status: "rejected",
|
||||||
|
error: "service_restart_not_allowed",
|
||||||
|
managed_service: managedServiceById(ctx, serviceId),
|
||||||
|
event_id: event?.eventId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: { success?: boolean; error?: string; message?: string };
|
||||||
|
try {
|
||||||
|
if (serviceId === "fileServer") {
|
||||||
|
ctx.fileServer.stop();
|
||||||
|
const serviceManager = createServiceManager({
|
||||||
|
lanproxy: ctx.lanproxy,
|
||||||
|
fileServer: ctx.fileServer,
|
||||||
|
agentRunner: ctx.agentRunner,
|
||||||
|
});
|
||||||
|
result = await serviceManager.startFileServer(getConfiguredPorts().fileServer);
|
||||||
|
} else {
|
||||||
|
result = await restartGuiManagedService();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result = { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = result.success ? "success" : "failed";
|
||||||
|
const event = recordManagedServiceAction({
|
||||||
|
serviceId,
|
||||||
|
action: "restart",
|
||||||
|
status,
|
||||||
|
reason: input.reason,
|
||||||
|
actor: input.actor,
|
||||||
|
allowlisted: true,
|
||||||
|
result: result as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
ok: Boolean(result.success),
|
||||||
|
status,
|
||||||
|
message: result.message,
|
||||||
|
error: result.error,
|
||||||
|
managed_service: managedServiceById(ctx, serviceId),
|
||||||
|
event_id: event?.eventId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartGuiManagedService(): Promise<{ success: boolean; error?: string; message?: string }> {
|
||||||
|
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||||
|
return { success: false, error: "gui_service_not_available" };
|
||||||
|
}
|
||||||
|
if (isWindows()) {
|
||||||
|
await stopWindowsMcp();
|
||||||
|
return startWindowsMcp();
|
||||||
|
}
|
||||||
|
await stopGuiAgentServer();
|
||||||
|
return startGuiAgentServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordManagedServiceAction(
|
||||||
|
input: DigitalEmployeeManagedServiceActionInput,
|
||||||
|
): ReturnType<typeof recordDigitalEmployeeManagedServiceAction> {
|
||||||
|
return recordDigitalEmployeeManagedServiceAction(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedServiceById(
|
||||||
|
ctx: HandlerContext,
|
||||||
|
serviceId: string,
|
||||||
|
): DigitalEmployeeManagedService | null {
|
||||||
|
return buildSnapshot(ctx).managedServices?.find((service) => service.serviceId === serviceId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
|
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
|
||||||
|
|||||||
@@ -264,6 +264,59 @@ describe("digital employee state service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records managed service restart actions as syncable events", async () => {
|
||||||
|
const { recordDigitalEmployeeManagedServiceAction } = await import("./stateService");
|
||||||
|
|
||||||
|
const success = recordDigitalEmployeeManagedServiceAction({
|
||||||
|
serviceId: "fileServer",
|
||||||
|
action: "restart",
|
||||||
|
status: "success",
|
||||||
|
reason: "operator_retry",
|
||||||
|
actor: "operator",
|
||||||
|
allowlisted: true,
|
||||||
|
result: { success: true, message: "started" },
|
||||||
|
recordedAt: "2026-06-07T08:30:00.000Z",
|
||||||
|
});
|
||||||
|
const rejected = recordDigitalEmployeeManagedServiceAction({
|
||||||
|
serviceId: "computerServer",
|
||||||
|
action: "restart",
|
||||||
|
status: "rejected",
|
||||||
|
allowlisted: false,
|
||||||
|
result: { error: "service_restart_not_allowed" },
|
||||||
|
recordedAt: "2026-06-07T08:31:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(success?.eventId).toBe("managed-service:fileServer:restart:success:2026-06-07T08-30-00-000Z");
|
||||||
|
expect(rejected?.eventId).toBe("managed-service:computerServer:restart:rejected:2026-06-07T08-31-00-000Z");
|
||||||
|
expect(mockState.db?.events.get(success!.eventId)).toMatchObject({
|
||||||
|
kind: "managed_service_restart",
|
||||||
|
message: "服务重启成功:fileServer",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(mockState.db?.events.get(success!.eventId)?.payload ?? "{}")).toMatchObject({
|
||||||
|
service_id: "fileServer",
|
||||||
|
action: "restart",
|
||||||
|
status: "success",
|
||||||
|
reason: "operator_retry",
|
||||||
|
actor: "operator",
|
||||||
|
allowlisted: true,
|
||||||
|
result: { success: true, message: "started" },
|
||||||
|
});
|
||||||
|
expect(mockState.db?.events.get(rejected!.eventId)).toMatchObject({
|
||||||
|
kind: "managed_service_restart_rejected",
|
||||||
|
message: "服务重启已拒绝:computerServer",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
|
||||||
|
service_id: "computerServer",
|
||||||
|
status: "rejected",
|
||||||
|
allowlisted: false,
|
||||||
|
result: { error: "service_restart_not_allowed" },
|
||||||
|
});
|
||||||
|
expect(mockState.db?.outbox.get(`event:insert:${success!.eventId}`)).toMatchObject({
|
||||||
|
entity_type: "event",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("records and lists route decisions as syncable events", async () => {
|
it("records and lists route decisions as syncable events", async () => {
|
||||||
const {
|
const {
|
||||||
listDigitalEmployeeRouteDecisions,
|
listDigitalEmployeeRouteDecisions,
|
||||||
|
|||||||
@@ -417,6 +417,21 @@ export interface DigitalEmployeeDailyReportRecordResult {
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeManagedServiceActionInput {
|
||||||
|
serviceId: string;
|
||||||
|
action: "restart";
|
||||||
|
status: "success" | "failed" | "rejected";
|
||||||
|
reason?: string | null;
|
||||||
|
actor?: string | null;
|
||||||
|
result?: Record<string, unknown> | null;
|
||||||
|
allowlisted: boolean;
|
||||||
|
recordedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeManagedServiceActionResult {
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeeGovernanceCommandRecord {
|
export interface DigitalEmployeeGovernanceCommandRecord {
|
||||||
commandKind: string;
|
commandKind: string;
|
||||||
commandId: string;
|
commandId: string;
|
||||||
@@ -1559,6 +1574,39 @@ export function recordDigitalEmployeeDailyReport(
|
|||||||
return { reportId, planId, taskId, runId, artifactId, eventId };
|
return { reportId, planId, taskId, runId, artifactId, eventId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordDigitalEmployeeManagedServiceAction(
|
||||||
|
input: DigitalEmployeeManagedServiceActionInput,
|
||||||
|
): DigitalEmployeeManagedServiceActionResult | null {
|
||||||
|
const db = getDigitalEmployeeDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const now = input.recordedAt || new Date().toISOString();
|
||||||
|
const normalizedServiceId = safeId(input.serviceId || "unknown-service");
|
||||||
|
const eventId = `managed-service:${normalizedServiceId}:${input.action}:${input.status}:${safeId(now)}`;
|
||||||
|
const kind = input.status === "rejected"
|
||||||
|
? "managed_service_restart_rejected"
|
||||||
|
: "managed_service_restart";
|
||||||
|
const payload = {
|
||||||
|
service_id: input.serviceId,
|
||||||
|
action: input.action,
|
||||||
|
status: input.status,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
actor: input.actor ?? null,
|
||||||
|
result: input.result ?? null,
|
||||||
|
allowlisted: input.allowlisted,
|
||||||
|
};
|
||||||
|
insertEvent({
|
||||||
|
event_id: eventId,
|
||||||
|
kind,
|
||||||
|
occurred_at: now,
|
||||||
|
message: input.status === "rejected"
|
||||||
|
? `服务重启已拒绝:${input.serviceId}`
|
||||||
|
: `服务重启${input.status === "success" ? "成功" : "失败"}:${input.serviceId}`,
|
||||||
|
plan_id: null,
|
||||||
|
task_id: null,
|
||||||
|
}, null, payload);
|
||||||
|
return { eventId };
|
||||||
|
}
|
||||||
|
|
||||||
export function recordDigitalEmployeeGovernanceCommand(
|
export function recordDigitalEmployeeGovernanceCommand(
|
||||||
command: DigitalEmployeeGovernanceCommandRecord,
|
command: DigitalEmployeeGovernanceCommandRecord,
|
||||||
): { planId: string; taskId: string; runId: string } {
|
): { planId: string; taskId: string; runId: string } {
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async recordDailyReport(input: unknown) {
|
async recordDailyReport(input: unknown) {
|
||||||
return ipcRenderer.invoke("digitalEmployee:recordDailyReport", input);
|
return ipcRenderer.invoke("digitalEmployee:recordDailyReport", input);
|
||||||
},
|
},
|
||||||
|
async restartManagedService(serviceId: string, options?: unknown) {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
|
||||||
|
},
|
||||||
async respondPermission(sessionId: string, permissionId: string, response: "once" | "always" | "reject") {
|
async respondPermission(sessionId: string, permissionId: string, response: "once" | "always" | "reject") {
|
||||||
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
|
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -563,9 +563,9 @@ GET /api/digital-employee/approvals
|
|||||||
- 建议:下一轮围绕治理审计和低风险 action policy,把 route decision 到 governance command 的自动映射补齐。
|
- 建议:下一轮围绕治理审计和低风险 action policy,把 route decision 到 governance command 的自动映射补齐。
|
||||||
|
|
||||||
5. **ManagedService 控制面**
|
5. **ManagedService 控制面**
|
||||||
- 已吸收:Agent、MCP、Computer Server、Lanproxy、File Server、GUI Server 状态已进入 snapshot / projection。
|
- 已吸收:Agent、MCP、Computer Server、Lanproxy、File Server、GUI Server 状态已进入 snapshot / projection;File Server 与 GUI Server 已开放白名单式 restart bridge,并会写入 `managed_service_restart` / `managed_service_restart_rejected` 事件,安全服务 restart bridge 已开始闭环。
|
||||||
- 缺口:仍是只读状态,缺少 start / stop / restart、lease 持久化、重启历史、服务异常恢复策略和人机介入入口。
|
- 缺口:仍缺少 start / stop、lease 持久化、重启历史、自动恢复策略和完整人机介入入口;Agent、MCP、Computer Server、Lanproxy 等高风险服务不暴露数字员工重启动作。
|
||||||
- 建议:先做只允许安全服务的 restart bridge,保持高风险命令不暴露。
|
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
|
||||||
|
|
||||||
6. **技能生命周期与策略**
|
6. **技能生命周期与策略**
|
||||||
- 已吸收:ACP / MCP / GUI / file server 能力已投射到 Skill Catalog,技能库不再使用 demo fallback;Skill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤。
|
- 已吸收:ACP / MCP / GUI / file server 能力已投射到 Skill Catalog,技能库不再使用 demo fallback;Skill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤。
|
||||||
|
|||||||
Reference in New Issue
Block a user