吸收数字员工安全服务重启入口
This commit is contained in:
@@ -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 { mcpProxyManager } from "../services/packages/mcp";
|
||||
import { getComputerServerStatus } from "../services/computerServer";
|
||||
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
|
||||
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
||||
import {
|
||||
getGuiAgentServerStatus,
|
||||
startGuiAgentServer,
|
||||
stopGuiAgentServer,
|
||||
} from "../services/packages/guiAgentServer";
|
||||
import {
|
||||
getWindowsMcpStatus,
|
||||
startWindowsMcp,
|
||||
stopWindowsMcp,
|
||||
} from "../services/packages/windowsMcp";
|
||||
import { isWindows } from "../services/system/shellEnv";
|
||||
import { createServiceManager } from "../window/serviceManager";
|
||||
import { getConfiguredPorts } from "../services/startupPorts";
|
||||
import {
|
||||
recordDigitalEmployeeSnapshot,
|
||||
recordDigitalEmployeeGovernanceCommand,
|
||||
recordDigitalEmployeeDailyReport,
|
||||
recordDigitalEmployeeManagedServiceAction,
|
||||
readDigitalEmployeeRuntimeRecords,
|
||||
readDigitalEmployeeState,
|
||||
readDigitalEmployeeUiState,
|
||||
@@ -25,6 +36,7 @@ import {
|
||||
recordDigitalEmployeeRouteDecision,
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
type DigitalEmployeeManagedService,
|
||||
type DigitalEmployeeManagedServiceActionInput,
|
||||
type DigitalEmployeeGovernanceCommandRecord,
|
||||
type DigitalEmployeeDailyReportRecordInput,
|
||||
type DigitalEmployeeMemoryUpsertInput,
|
||||
@@ -48,6 +60,26 @@ import {
|
||||
} from "../services/digitalEmployee/skillCatalogService";
|
||||
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 {
|
||||
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
|
||||
const snapshot = buildSnapshot(ctx);
|
||||
@@ -188,6 +220,105 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
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 {
|
||||
|
||||
@@ -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 () => {
|
||||
const {
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
|
||||
@@ -417,6 +417,21 @@ export interface DigitalEmployeeDailyReportRecordResult {
|
||||
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 {
|
||||
commandKind: string;
|
||||
commandId: string;
|
||||
@@ -1559,6 +1574,39 @@ export function recordDigitalEmployeeDailyReport(
|
||||
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(
|
||||
command: DigitalEmployeeGovernanceCommandRecord,
|
||||
): { planId: string; taskId: string; runId: string } {
|
||||
|
||||
@@ -155,6 +155,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async recordDailyReport(input: unknown) {
|
||||
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") {
|
||||
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user