吸收数字员工管理端低风险动作闭环

This commit is contained in:
baiyanyun
2026-06-12 21:17:51 +08:00
parent be901ee112
commit b3f21adecb
27 changed files with 1186 additions and 11 deletions

View File

@@ -1249,6 +1249,13 @@ export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number;
});
}
export function pullAdminActions(): Promise<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
return apiFetch<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }>('/api/digital-employee/admin/actions/pull', {
method: 'POST',
body: JSON.stringify({}),
});
}
export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []);

View File

@@ -64,6 +64,7 @@ declare global {
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>;
pullAdminActions?: () => Promise<{ ok?: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
resolveApprovalConflict?: (input: QimingclawApprovalConflictResolutionInput) => Promise<QimingclawApprovalConflictResolutionResponse>;
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
@@ -1183,6 +1184,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/notifications/updates/pull') {
return await pullBridgeNotificationUpdates() as T;
}
if (method === 'POST' && pathname === '/api/digital-employee/admin/actions/pull') {
return await pullBridgeAdminActions() as T;
}
if (method === 'POST' && pathname === '/api/notifications/desktop') {
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) as T;
}
@@ -1501,6 +1505,27 @@ async function pullBridgeNotificationUpdates(): Promise<{ ok: boolean; applied?:
}
}
async function pullBridgeAdminActions(): Promise<{ ok: boolean; applied?: number; rejected?: number; failed?: number; ignored?: number; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullAdminActions) {
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullAdminActions();
return {
ok: result?.ok !== false,
applied: Number(result?.applied ?? 0),
rejected: Number(result?.rejected ?? 0),
failed: Number(result?.failed ?? 0),
ignored: Number(result?.ignored ?? 0),
...(result?.skipped ? { skipped: true } : {}),
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function showBridgeDesktopNotification(input: Record<string, unknown>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.showDesktopNotification) {

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-DUKd5ydN.js"></script>
<script type="module" crossorigin src="./assets/index-KaWiGpaZ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D7Rysr2C.css">
</head>
<body>

View File

@@ -1149,6 +1149,38 @@ function checkDigitalPolicySchedulerWorkbenches() {
console.log("[DigitalEmployeeCheck] policy center and scheduler workbench hooks OK");
}
function checkDigitalAdminActionLoop() {
console.log("\n[DigitalEmployeeCheck] Verify admin action pull/apply loop hooks");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const ipcPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
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 syncService = fs.readFileSync(syncServicePath, "utf8");
const ipc = fs.readFileSync(ipcPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const requiredSnippets = [
[syncService, "pullAndApplyDigitalEmployeeAdminActions", "admin action pull service"],
[syncService, "admin_action_applied", "admin action applied event"],
[syncService, "admin_action_rejected", "admin action rejected event"],
[syncService, "admin_action_failed", "admin action failed event"],
[syncService, "cleanup_file_batch", "high risk cleanup rejection guard"],
[ipc, "digitalEmployee:pullAdminActions", "admin action IPC"],
[preload, "pullAdminActions", "admin action preload bridge"],
[adapter, "/api/digital-employee/admin/actions/pull", "admin action adapter endpoint"],
[api, "pullAdminActions", "embedded admin action API helper"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing admin action loop hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] admin action pull/apply loop hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -1207,6 +1239,7 @@ function main() {
checkDigitalMemoryGovernanceAudit();
checkDigitalOpsNotificationReportWorkbenches();
checkDigitalPolicySchedulerWorkbenches();
checkDigitalAdminActionLoop();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -116,6 +116,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
pullDigitalEmployeeApprovalUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
submitDigitalEmployeeApprovalDecision: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
pullDigitalEmployeeNotificationUpdates: vi.fn(async () => ({ ok: true, applied: 1, ignored: 0 })),
pullAndApplyDigitalEmployeeAdminActions: vi.fn(async () => ({ ok: true, applied: 1, rejected: 0, failed: 0, ignored: 0 })),
submitDigitalEmployeeNotificationAction: vi.fn(async () => ({ ok: true, submitted_at: "2026-06-07T08:00:00.000Z" })),
}));
@@ -481,6 +482,22 @@ describe("digital employee managed service actions", () => {
expect(syncService.submitDigitalEmployeeNotificationAction).toHaveBeenCalledWith({});
});
it("registers admin action pull through IPC", 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 pullCall = calls.find(([channel]) => channel === "digitalEmployee:pullAdminActions");
expect(pullCall).toBeTruthy();
const result = await (pullCall?.[1] as (_event: unknown) => Promise<unknown>)({});
expect(result).toMatchObject({ ok: true, applied: 1 });
expect(syncService.pullAndApplyDigitalEmployeeAdminActions).toHaveBeenCalledWith({ force: true });
});
it("registers desktop notification requests through IPC", async () => {
const electron = await import("electron");
const {

View File

@@ -73,6 +73,7 @@ import {
pullDigitalEmployeeRouteDecisionPolicy,
submitDigitalEmployeeApprovalDecision,
pullDigitalEmployeeNotificationUpdates,
pullAndApplyDigitalEmployeeAdminActions,
submitDigitalEmployeeNotificationAction,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
@@ -240,6 +241,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return pullDigitalEmployeeNotificationUpdates({ force: true });
});
ipcMain.handle("digitalEmployee:pullAdminActions", async () => {
return pullAndApplyDigitalEmployeeAdminActions({ force: true });
});
ipcMain.handle("digitalEmployee:submitNotificationAction", async (_, input: unknown) => {
return submitDigitalEmployeeNotificationAction(typeof input === "object" && input ? input as Record<string, unknown> : {});
});

View File

@@ -650,6 +650,73 @@ describe("digital employee sync service", () => {
]));
});
it("pulls low-risk admin actions, applies them locally, and reports results", async () => {
mockState.fetch
.mockResolvedValueOnce(jsonResponse({
success: true,
data: {
records: [
{
id: 10,
entity_type: "artifact",
entity_id: "artifact-1",
action: "mark_expire",
request_payload: { reason: "管理端标记到期" },
},
],
},
}))
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
expect(result).toMatchObject({
ok: true,
endpoint: "https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
applied: 1,
rejected: 0,
failed: 0,
ignored: 0,
});
expect(mockState.fetch).toHaveBeenNthCalledWith(
1,
"https://manage.example.com/api/digital-employee/admin/actions/pending?device_id=device-001",
expect.objectContaining({ method: "GET" }),
);
expect(mockState.fetch).toHaveBeenNthCalledWith(
2,
"https://manage.example.com/api/digital-employee/admin/actions/10/result",
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_applied") }),
);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "artifact_retention_marked" }),
expect.objectContaining({ kind: "admin_action_applied" }),
]));
});
it("rejects high-risk admin actions without changing local facts", async () => {
mockState.fetch
.mockResolvedValueOnce(jsonResponse({
success: true,
data: { records: [{ id: 11, entity_type: "artifact", entity_id: "artifact-1", action: "cleanup_file_batch" }] },
}))
.mockResolvedValueOnce(jsonResponse({ success: true, data: { ok: true } }));
const { pullAndApplyDigitalEmployeeAdminActions } = await import("./syncService");
const result = await pullAndApplyDigitalEmployeeAdminActions({ force: true });
expect(result).toMatchObject({ ok: true, applied: 0, rejected: 1, failed: 0 });
expect(mockState.fetch).toHaveBeenNthCalledWith(
2,
"https://manage.example.com/api/digital-employee/admin/actions/11/result",
expect.objectContaining({ method: "POST", body: expect.stringContaining("admin_action_rejected") }),
);
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "admin_action_rejected" }),
]));
});
it.each([
["skillPolicies"],
["policies"],

View File

@@ -6,7 +6,12 @@ import {
normalizePlanStepDispatchPolicy,
normalizeRiskApprovalPolicy,
normalizeRouteDecisionPolicy,
recordDigitalEmployeeArtifactLifecycle,
recordDigitalEmployeeDailyReportDelivery,
recordDigitalEmployeeRouteInterventionResolution,
recordDigitalEmployeeRuntimeEvent,
readDigitalEmployeeUiState,
resolveDigitalEmployeeApprovalConflict,
saveDigitalEmployeeUiState,
upsertDigitalEmployeeApprovalFromRemote,
type DigitalEmployeeApprovalUpdateInput,
@@ -30,6 +35,7 @@ const DEFAULT_APPROVAL_UPDATES_PATH = "/api/digital-employee/approvals/updates";
const DEFAULT_APPROVAL_DECISIONS_PATH = "/api/digital-employee/approvals/decisions";
const DEFAULT_NOTIFICATION_UPDATES_PATH = "/api/digital-employee/notifications/updates";
const DEFAULT_NOTIFICATION_ACTIONS_PATH = "/api/digital-employee/notifications/actions";
const DEFAULT_ADMIN_ACTIONS_PATH = "/api/digital-employee/admin/actions";
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
const MANAGEMENT_SYNC_RECORD_PATH =
"/system/content/content-digital-employee-sync";
@@ -41,6 +47,7 @@ const POLICY_PULL_THROTTLE_MS = 60_000;
const MAX_RETRY_DELAY_MS = 30 * 60_000;
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
const HIGH_RISK_ADMIN_ACTIONS = new Set(["accept_remote_approval", "cleanup_file_batch", "start_service", "stop_service", "hard_interrupt"]);
let syncTimer: ReturnType<typeof setInterval> | null = null;
let syncing = false;
@@ -75,6 +82,7 @@ interface DigitalEmployeeSyncConfig {
approvalDecisionsEndpoint: string | null;
notificationUpdatesEndpoint: string | null;
notificationActionsEndpoint: string | null;
adminActionsEndpoint: string | null;
leaseEndpoint: string | null;
clientKey: string | null;
authToken: string | null;
@@ -152,6 +160,26 @@ export interface DigitalEmployeeNotificationActionSubmitResult {
error?: string | null;
}
export interface DigitalEmployeeAdminActionPullResult {
ok: boolean;
skipped?: boolean;
endpoint: string | null;
pulled_at: string | null;
applied: number;
rejected: number;
failed: number;
ignored: number;
error?: string | null;
}
interface DigitalEmployeeAdminActionItem {
id: string;
entityType: string;
entityId: string;
action: string;
requestPayload: Record<string, unknown>;
}
export interface DigitalEmployeePlanStepRemoteLease {
lease_id: string;
state: "active" | "released" | "expired";
@@ -827,6 +855,64 @@ export async function submitDigitalEmployeeNotificationAction(
}
}
export async function pullAndApplyDigitalEmployeeAdminActions(
options: { force?: boolean } = {},
): Promise<DigitalEmployeeAdminActionPullResult> {
const config = resolveSyncConfig();
const endpoint = buildAdminActionsPendingEndpoint(config.adminActionsEndpoint);
const missingCredentials = missingSyncCredentialLabels(config);
const pulledAt = new Date().toISOString();
if (!endpoint || missingCredentials.length > 0) {
return {
ok: false,
endpoint,
pulled_at: pulledAt,
applied: 0,
rejected: 0,
failed: 0,
ignored: 0,
error: !endpoint ? "management admin actions endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`,
};
}
try {
const response = await fetchJsonWithAuth(config, endpoint);
const responseError = readSyncResponseError(response as SyncResponse);
if (responseError) throw new Error(responseError);
const actions = readRemoteAdminActions(response);
let applied = 0;
let rejected = 0;
let failed = 0;
let ignored = 0;
for (const action of actions) {
if (!action.id) {
ignored += 1;
continue;
}
const result = await applyDigitalEmployeeAdminAction(action);
if (result.status === "applied") applied += 1;
else if (result.status === "rejected") rejected += 1;
else failed += 1;
await postDigitalEmployeeAdminActionResult(config, action.id, result.status, result.reasonCodes);
}
recordDigitalEmployeeRuntimeEvent({
kind: "admin_actions_pulled",
status: "completed",
message: "管理端远程运营动作已拉取",
payload: { action: "pull_admin_actions", endpoint, applied, rejected, failed, ignored, source: "management-api" },
});
return { ok: true, endpoint, pulled_at: pulledAt, applied, rejected, failed, ignored, error: null };
} catch (error) {
const message = normalizeError(error);
recordDigitalEmployeeRuntimeEvent({
kind: "admin_action_failed",
status: "failed",
message: "管理端远程运营动作拉取失败",
payload: { action: "pull_admin_actions", endpoint, error: message, source: "management-api" },
});
return { ok: false, endpoint, pulled_at: pulledAt, applied: 0, rejected: 0, failed: 0, ignored: 0, error: message };
}
}
export async function acquireDigitalEmployeePlanStepRemoteLease(
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
@@ -1066,6 +1152,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
? config.notificationActionsEndpoint.trim()
: null;
const notificationActionsEndpoint = notificationActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_NOTIFICATION_ACTIONS_PATH);
const adminActionsEndpointOverride =
typeof config.adminActionsEndpoint === "string" && config.adminActionsEndpoint.trim()
? config.adminActionsEndpoint.trim()
: null;
const adminActionsEndpoint = adminActionsEndpointOverride || buildEndpoint(serverHost, DEFAULT_ADMIN_ACTIONS_PATH);
const leaseEndpointOverride =
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
? config.planStepDispatchLeaseEndpoint.trim()
@@ -1088,6 +1179,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
approvalDecisionsEndpoint,
notificationUpdatesEndpoint,
notificationActionsEndpoint,
adminActionsEndpoint,
leaseEndpoint,
clientKey: readClientKey(serverHost),
authToken: readAuthToken(serverHost),
@@ -1100,6 +1192,17 @@ function buildLeaseOperationEndpoint(baseEndpoint: string | null, operation: "ac
return `${baseEndpoint.replace(/\/+$/, "")}/${operation}`;
}
function buildAdminActionsPendingEndpoint(baseEndpoint: string | null): string | null {
if (!baseEndpoint) return null;
const params = new URLSearchParams({ device_id: getDeviceId() });
return `${baseEndpoint.replace(/\/+$/, "")}/pending?${params.toString()}`;
}
function buildAdminActionResultEndpoint(baseEndpoint: string | null, actionId: string): string | null {
if (!baseEndpoint) return null;
return `${baseEndpoint.replace(/\/+$/, "")}/${encodeURIComponent(actionId)}/result`;
}
function buildEndpoint(serverHost: string | null, path = DEFAULT_SYNC_PATH): string | null {
if (!serverHost) return null;
const normalized = serverHost.startsWith("http")
@@ -1320,6 +1423,145 @@ function readRemoteNotificationUpdates(response: Record<string, unknown>): Array
});
}
function readRemoteAdminActions(response: Record<string, unknown>): DigitalEmployeeAdminActionItem[] {
const data = objectRecord(response.data) ?? response;
const raw = Array.isArray(data.records)
? data.records
: Array.isArray(data.actions)
? data.actions
: Array.isArray(data.items)
? data.items
: [];
return raw
.map((item) => objectRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item) => ({
id: stringField(item.id) || numericIdField(item.id),
entityType: normalizeAdminActionText(item.entity_type ?? item.entityType),
entityId: stringField(item.entity_id ?? item.entityId) || "",
action: normalizeAdminActionText(item.action),
requestPayload: objectRecord(item.request_payload ?? item.requestPayload) ?? {},
}))
.filter((item) => Boolean(item.id && item.entityType && item.entityId && item.action));
}
async function applyDigitalEmployeeAdminAction(action: DigitalEmployeeAdminActionItem): Promise<{ status: "applied" | "rejected" | "failed"; reasonCodes: string[] }> {
try {
if (!isAllowedAdminAction(action)) {
return recordAdminActionRuntimeResult(action, "rejected", ["admin_action_rejected", "unsupported_or_high_risk_action"]);
}
const payload = action.requestPayload;
if (action.entityType === "approval") {
const result = resolveDigitalEmployeeApprovalConflict({
approvalId: action.entityId,
resolution: action.action,
actor: "management-admin-action",
comment: stringField(payload.reason ?? payload.comment) || null,
});
const ok = result?.status !== "rejected";
return recordAdminActionRuntimeResult(action, ok ? "applied" : "rejected", ok ? ["admin_action_applied"] : ["admin_action_rejected", ...(result?.reason_codes ?? ["approval_action_rejected"])]);
}
if (action.entityType === "artifact") {
const artifactAction = action.action === "mark_reviewed" ? "mark_review" : action.action;
recordDigitalEmployeeArtifactLifecycle({
artifactId: action.entityId,
action: artifactAction,
actor: "management-admin-action",
reason: stringField(payload.reason) || "admin_action",
retentionUntil: stringField(payload.retention_until ?? payload.retentionUntil) || null,
source: "management-admin-action",
});
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
}
if (action.entityType === "daily_report") {
const deliveryAction = action.action === "mark_confirmed" ? "confirm" : action.action === "request_approval" ? "request_approval" : "send";
recordDigitalEmployeeDailyReportDelivery({
reportId: action.entityId,
action: deliveryAction,
status: action.action.replace(/^mark_/, ""),
actor: "management-admin-action",
message: stringField(payload.message ?? payload.reason) || null,
});
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
}
if (action.entityType === "route" || action.entityType === "route_decision") {
recordDigitalEmployeeRouteInterventionResolution({
routeDecisionId: action.entityId,
decision: action.action,
actor: "management-admin-action",
reasonCodes: ["admin_action_applied", action.action],
commandAccepted: false,
});
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
}
if (action.entityType === "notification") {
const currentState = readDigitalEmployeeUiState();
const notificationStateById: Record<string, unknown> = { ...(currentState.notificationStateById ?? {}) };
const current = objectRecord(notificationStateById[action.entityId]) ?? {};
const now = new Date().toISOString();
notificationStateById[action.entityId] = {
...current,
status: action.action === "dismiss" ? "dismissed" : "read",
read_at: action.action === "mark_read" ? now : current.read_at,
dismissed_at: action.action === "dismiss" ? now : current.dismissed_at,
source: "management-admin-action",
};
saveDigitalEmployeeUiState({ action: "admin_notification_action", metadata: { admin_action_id: action.id, notification_id: action.entityId }, state: { ...currentState, notificationStateById } });
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied"]);
}
return recordAdminActionRuntimeResult(action, "applied", ["admin_action_applied", "audit_overlay_recorded"]);
} catch (error) {
return recordAdminActionRuntimeResult(action, "failed", ["admin_action_failed", normalizeError(error)]);
}
}
function isAllowedAdminAction(action: DigitalEmployeeAdminActionItem): boolean {
if (HIGH_RISK_ADMIN_ACTIONS.has(action.action)) return false;
return new Set([
"approval:keep_local", "approval:mark_reviewed",
"route:mark_reviewed", "route:dismiss", "route:retry_policy_check",
"route_decision:mark_reviewed", "route_decision:dismiss", "route_decision:retry_policy_check",
"notification:mark_read", "notification:dismiss",
"artifact:mark_keep", "artifact:mark_expire", "artifact:mark_reviewed",
"daily_report:mark_delivered", "daily_report:mark_confirmed", "daily_report:request_approval",
"audit:mark_reviewed", "audit:dismiss",
"event:mark_reviewed", "event:dismiss",
]).has(`${action.entityType}:${action.action}`);
}
function recordAdminActionRuntimeResult(
action: DigitalEmployeeAdminActionItem,
status: "applied" | "rejected" | "failed",
reasonCodes: string[],
): { status: "applied" | "rejected" | "failed"; reasonCodes: string[] } {
recordDigitalEmployeeRuntimeEvent({
kind: status === "applied" ? "admin_action_applied" : status === "rejected" ? "admin_action_rejected" : "admin_action_failed",
status: status === "applied" ? "completed" : "failed",
message: `管理端动作${status}: ${action.action}`,
payload: {
admin_action_id: action.id,
entity_type: action.entityType,
entity_id: action.entityId,
action: action.action,
status,
reason_codes: reasonCodes,
source: "management-admin-action",
},
});
return { status, reasonCodes };
}
async function postDigitalEmployeeAdminActionResult(
config: DigitalEmployeeSyncConfig,
actionId: string,
status: "applied" | "rejected" | "failed",
reasonCodes: string[],
): Promise<void> {
const endpoint = buildAdminActionResultEndpoint(config.adminActionsEndpoint, actionId);
if (!endpoint) return;
await fetchJsonWithAuth(config, endpoint, { status, reason_codes: reasonCodes });
}
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
@@ -2489,6 +2731,14 @@ function stringField(value: unknown): string {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function normalizeAdminActionText(value: unknown): string {
return stringField(value).toLowerCase().replace(/-/g, "_");
}
function numericIdField(value: unknown): string {
return typeof value === "number" && Number.isFinite(value) ? String(value) : "";
}
function stringArrayField(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(stringField).filter(Boolean);

View File

@@ -152,6 +152,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async pullNotificationUpdates() {
return ipcRenderer.invoke("digitalEmployee:pullNotificationUpdates");
},
async pullAdminActions() {
return ipcRenderer.invoke("digitalEmployee:pullAdminActions");
},
async submitNotificationAction(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:submitNotificationAction", input);
},

View File

@@ -609,7 +609,7 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
12. **管理端业务查询模型**
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version``entity_summary``business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans``/plans/{plan_id}``/tasks``/runs``/events``/artifacts``/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 KeyPlan 视图会聚合 task/run 计数与最近事件Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。
- 已吸收:管理端已有通用 sync record 接收、查询、payload 摘要和深链;后端 sync record 已开始保存客户端顶层 `contract_version``entity_summary``business_view`,并基于现有 `digital_employee_sync_record` 拆出 `/api/digital-employee/plans``/plans/{plan_id}``/tasks``/runs``/events``/artifacts``/approvals` 管理端业务查询模型;列表支持客户端 Key、设备、实体 ID、状态、同步状态和同步时间范围过滤业务 row 会提炼 title/status/summary、Plan/Task/Run 关联、发生/开始/结束时间、sync record 深链和脱敏客户端 KeyPlan 视图会聚合 task/run 计数与最近事件Plan 详情会返回关联 tasks/runs/events/artifacts/approvals 摘要。embedded adapter 已拆出同名只读业务查询接口,从 qimingclaw runtime、artifact、approval 和 canonical event 投影生成统一分页视图embedded 数字员工页已新增“业务视图”入口,支持计划/任务/运行/事件/产物/审批分段、实体/状态/同步状态/时间筛选、分页和详情 JSON 查看;客户端 outbox 的 event `business_view` 已开始按 route decision、governance command、approval action、artifact lifecycle/access、skill call audit 和 ready PlanStep dispatch 细分常用字段本地与管理端业务视图投影已开始闭环。正式外部管理端台面已开始吸收qiming-backend 新增 `/api/digital-employee/admin/workbench/{view}`qiming 管理端新增“数字员工运营台”,统一展示业务视图、介入台、产物治理、通知运营、审计诊断、日报运营和路由治理,只读消费 sync record `business_view` 并提供同步记录深链。管理端低风险动作闭环已开始吸收:后端新增 `/api/digital-employee/admin/actions` 记录动作意图和 `/pending` 拉取队列qimingclaw 只应用保留本地、标记复核、通知已读/忽略、产物保留/到期、日报交付/确认/审批请求等低风险动作并写入 `admin_action_applied` / `admin_action_rejected` / `admin_action_failed`qiming 运营台抽屉只提示“已记录,等待客户端拉取”。
- 缺口:复杂组合检索、跨设备聚合图、远程裁决/策略编辑动作和物化业务表仍待吸收。
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。