吸收数字员工审批历史订阅策略

This commit is contained in:
baiyanyun
2026-06-10 18:22:53 +08:00
parent 5b78189c9c
commit 3ac350eb27
12 changed files with 615 additions and 196 deletions

View File

@@ -12,6 +12,7 @@ import type {
Session,
ChannelDetail,
BrowserSession,
ApprovalSubscriptionPreferences,
NotificationPreferences,
UserNotification,
DebugStoreResponse,
@@ -543,7 +544,7 @@ export function getEventLog(params: {
// Notifications
// ---------------------------------------------------------------------------
export { type NotificationPreferences, type UserNotification };
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type UserNotification };
export function getNotifications(
status: string = 'unread',
@@ -572,6 +573,28 @@ export function patchNotificationPreferences(
});
}
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
}
export function patchApprovalSubscriptionPreferences(
patch: Partial<ApprovalSubscriptionPreferences>,
): Promise<ApprovalSubscriptionPreferences> {
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function getApprovalHistory(params: Record<string, string | number | null | undefined> = {}): Promise<any> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
});
const suffix = query.toString();
return apiFetch(`/api/digital-employee/approval-history${suffix ? `?${suffix}` : ''}`);
}
export function markNotificationRead(id: string): Promise<boolean> {
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',

View File

@@ -1,5 +1,6 @@
import type {
ApprovalEntry,
ApprovalSubscriptionPreferences,
ApprovalTimelineResponse,
ArtifactEntry,
CanonicalEvent,
@@ -87,6 +88,7 @@ interface AdapterState {
decisionResultsByDate: Record<string, Record<string, string>>;
notificationStateById?: Record<string, NotificationState>;
notificationPreferences?: NotificationPreferences;
approvalSubscriptionPreferences?: ApprovalSubscriptionPreferences;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -116,6 +118,8 @@ const NOTIFICATION_KINDS: UserNotification['kind'][] = [
'confirmation_pending',
'confirmation_resolved',
];
const APPROVAL_SUBSCRIPTION_ACTIONS: ApprovalSubscriptionPreferences['subscribed_actions'] = ['comment', 'delegate', 'mark_timeout', 'mark_risk', 'clear_risk', 'decision'];
const APPROVAL_SUBSCRIPTION_RISK_LEVELS: ApprovalSubscriptionPreferences['subscribed_risk_levels'] = ['normal', 'medium', 'high', 'critical'];
interface QimingclawServiceStatus {
running?: boolean;
@@ -687,7 +691,7 @@ const PLAN_SCHEDULES: Record<string, { expression: string; nextRun: string; sche
const BUSINESS_VIEW_SOURCE = 'qimingclaw-runtime-projection';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals' | 'approval-history' | 'route-decisions' | 'notifications' | 'audits';
type BusinessViewRow = Record<string, unknown>;
export function isQimingclawDigitalApiEnabled(): boolean {
@@ -771,7 +775,20 @@ export async function handleQimingclawDigitalApi<T>(
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|route-decisions|notifications|audits)$/);
if (method === 'GET' && pathname === '/api/approval/subscriptions') {
return approvalSubscriptionPreferences(notificationAdapterState(await readQimingclawSnapshot())) as T;
}
if (method === 'PATCH' && pathname === '/api/approval/subscriptions') {
return await handleApprovalSubscriptionPreferencesPatch(
parseJsonBody<Partial<ApprovalSubscriptionPreferences>>(options.body),
await readQimingclawSnapshot(),
) as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/approval-history') {
const rows = filterApprovalHistoryRows(approvalHistoryRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals|approval-history|route-decisions|notifications|audits)$/);
if (method === 'GET' && businessViewMatch) {
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
}
@@ -946,6 +963,7 @@ function defaultState(): AdapterState {
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -971,6 +989,7 @@ function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
...defaultState(),
...value,
notificationPreferences: notificationPreferences(value),
approvalSubscriptionPreferences: approvalSubscriptionPreferences(value),
};
}
@@ -2506,17 +2525,20 @@ function buildBusinessViewResponse(kind: BusinessViewKind, snapshot: QimingclawS
events: businessEventRows(snapshot),
artifacts: businessArtifactRows(snapshot),
approvals: businessApprovalRows(snapshot),
'approval-history': approvalHistoryRows(snapshot),
'route-decisions': routeDecisionRows(snapshot),
notifications: notificationRows(snapshot, notificationAdapterState(snapshot)),
audits: auditRows(snapshot),
};
const rows = kind === 'route-decisions'
? filterRouteDecisionRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
: kind === 'approval-history'
? filterApprovalHistoryRows(rowsByKind[kind], searchParams)
: kind === 'notifications'
? filterNotificationRows(rowsByKind[kind], searchParams)
: kind === 'audits'
? filterAuditRows(rowsByKind[kind], searchParams)
: filterBusinessRows(rowsByKind[kind], searchParams);
return {
...paginateBusinessRows(rows, searchParams),
source: BUSINESS_VIEW_SOURCE,
@@ -2887,6 +2909,7 @@ function approvalActionRows(snapshot: QimingclawSnapshot | null): BusinessViewRo
action: stringValue(payload.action) || null,
status: stringValue(payload.status) || null,
comment_preview: stringValue(comment?.preview) || null,
comment_length: numberValue(comment?.length),
delegate_to: stringValue(delegate?.label ?? delegate?.target) || null,
sla_status: stringValue(sla?.sla_status) || null,
due_at: stringValue(sla?.due_at) || null,
@@ -2905,6 +2928,77 @@ function approvalActionRows(snapshot: QimingclawSnapshot | null): BusinessViewRo
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
}
function approvalHistoryRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
const approvalRows = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
const actionRows = approvalActionRows(snapshot).map((action) => {
const approvalId = stringValue(action.approval_id);
const approval = approvalRows.get(approvalId);
return {
history_id: stringValue(action.action_id),
event_id: stringValue(action.event_id),
approval_id: approvalId || null,
action: stringValue(action.action) || null,
status: stringValue(action.status) || null,
actor: stringValue(action.actor) || null,
risk_level: stringValue(action.risk_level) || null,
due_at: stringValue(action.due_at) || null,
delegate_to: stringValue(action.delegate_to) || null,
comment_length: numberValue(action.comment_length),
message: stringValue(action.message) || null,
plan_id: stringValue(action.plan_id) || stringValue(approval?.plan_id) || null,
task_id: stringValue(action.task_id) || stringValue(approval?.task_id) || null,
run_id: stringValue(action.run_id) || stringValue(approval?.run_id) || null,
occurred_at: stringValue(action.occurred_at) || null,
updated_at: stringValue(action.occurred_at) || null,
sync_status: stringValue(action.sync_status) || null,
entity_type: 'approval_history',
entity_id: stringValue(action.action_id),
source: BUSINESS_VIEW_SOURCE,
};
});
const decisionRows = runtimeEvents(snapshot)
.filter((event) => event.kind === 'approval_decision')
.map((event) => {
const payload = asRecord(event.payload) ?? {};
const approvalId = stringValue(payload.approvalId ?? payload.approval_id) || event.id.split(':decision:')[0] || '';
const approval = approvalRows.get(approvalId);
return {
history_id: event.id,
event_id: event.id,
approval_id: approvalId || null,
action: 'decision',
status: stringValue(payload.nextStatus ?? payload.next_status ?? payload.decision) || null,
actor: stringValue(payload.actor) || stringValue(payload.source) || 'qimingclaw-approval-decision',
risk_level: stringValue(approval?.risk_level) || null,
due_at: stringValue(approval?.due_at) || null,
delegate_to: stringValue(approval?.delegate_to) || null,
comment_length: null,
message: event.message || null,
plan_id: event.planId || stringValue(payload.planId ?? payload.plan_id) || stringValue(approval?.plan_id) || null,
task_id: event.taskId || stringValue(payload.taskId ?? payload.task_id) || stringValue(approval?.task_id) || null,
run_id: event.runId || stringValue(payload.runId ?? payload.run_id) || stringValue(approval?.run_id) || null,
occurred_at: event.occurredAt,
updated_at: event.occurredAt,
sync_status: event.syncStatus ?? null,
entity_type: 'approval_history',
entity_id: event.id,
source: BUSINESS_VIEW_SOURCE,
};
});
return [...actionRows, ...decisionRows]
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)) || stringValue(right.history_id).localeCompare(stringValue(left.history_id)));
}
function filterApprovalHistoryRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const approvalId = stringValue(searchParams.get('approval_id')) || stringValue(searchParams.get('id'));
const action = stringValue(searchParams.get('action'));
const riskLevel = stringValue(searchParams.get('risk_level'));
return filterBusinessRows(rows, searchParams)
.filter((row) => !approvalId || stringValue(row.approval_id) === approvalId || stringValue(row.history_id) === approvalId || stringValue(row.event_id) === approvalId)
.filter((row) => !action || stringValue(row.action) === action)
.filter((row) => !riskLevel || stringValue(row.risk_level) === riskLevel);
}
function approvalActionSummary(
snapshot: QimingclawSnapshot | null,
approvalId: string,
@@ -3230,6 +3324,7 @@ function businessRowEntityIds(row: BusinessViewRow): string[] {
row.task_id,
row.run_id,
row.event_id,
row.history_id,
row.artifact_id,
row.approval_id,
row.notification_id,
@@ -3547,6 +3642,35 @@ function defaultNotificationPreferences(): NotificationPreferences {
};
}
function defaultApprovalSubscriptionPreferences(): ApprovalSubscriptionPreferences {
return {
enabled: true,
subscribed_actions: [...APPROVAL_SUBSCRIPTION_ACTIONS],
subscribed_risk_levels: ['high', 'critical'],
notify_on_timeout: true,
updated_at: null,
};
}
function approvalSubscriptionPreferences(state: Partial<AdapterState> | null | undefined): ApprovalSubscriptionPreferences {
const stored = state?.approvalSubscriptionPreferences;
const fallback = defaultApprovalSubscriptionPreferences();
if (!stored || typeof stored !== 'object') return fallback;
const actions = Array.isArray(stored.subscribed_actions)
? stored.subscribed_actions.filter((action): action is ApprovalSubscriptionPreferences['subscribed_actions'][number] => APPROVAL_SUBSCRIPTION_ACTIONS.includes(action as ApprovalSubscriptionPreferences['subscribed_actions'][number]))
: fallback.subscribed_actions;
const riskLevels = Array.isArray(stored.subscribed_risk_levels)
? stored.subscribed_risk_levels.filter((level): level is ApprovalSubscriptionPreferences['subscribed_risk_levels'][number] => APPROVAL_SUBSCRIPTION_RISK_LEVELS.includes(level as ApprovalSubscriptionPreferences['subscribed_risk_levels'][number]))
: fallback.subscribed_risk_levels;
return {
enabled: stored.enabled !== false,
subscribed_actions: [...new Set(actions)],
subscribed_risk_levels: [...new Set(riskLevels)],
notify_on_timeout: stored.notify_on_timeout !== false,
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
};
}
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
const stored = state?.notificationPreferences;
const fallback = defaultNotificationPreferences();
@@ -3600,6 +3724,31 @@ async function handleNotificationPreferencesPatch(
return next;
}
async function handleApprovalSubscriptionPreferencesPatch(
patch: Partial<ApprovalSubscriptionPreferences>,
snapshot: QimingclawSnapshot | null,
): Promise<ApprovalSubscriptionPreferences> {
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
const current = approvalSubscriptionPreferences(state);
const next = approvalSubscriptionPreferences({
approvalSubscriptionPreferences: {
...current,
...patch,
updated_at: new Date().toISOString(),
},
});
await saveState(
{
...normalizeAdapterState(state),
approvalSubscriptionPreferences: next,
},
'update_approval_subscriptions',
undefined,
{ approval_subscriptions: next },
);
return next;
}
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
return buildApprovals(snapshot)

View File

@@ -187,6 +187,14 @@ export interface NotificationPreferences {
updated_at: string | null;
}
export interface ApprovalSubscriptionPreferences {
enabled: boolean;
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;
subscribed_risk_levels: Array<'normal' | 'medium' | 'high' | 'critical'>;
notify_on_timeout: boolean;
updated_at: string | null;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface WsMessage {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -244,6 +244,45 @@ function checkDigitalNotificationPreferences() {
console.log("[DigitalEmployeeCheck] notification preference hooks OK");
}
function checkDigitalApprovalHistorySubscriptions() {
console.log("\n[DigitalEmployeeCheck] Verify approval history/subscription 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 typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const requiredSnippets = [
[adapter, "/api/approval/subscriptions", "adapter approval subscription endpoint"],
[adapter, "/api/digital-employee/approval-history", "adapter approval history endpoint"],
[adapter, "approvalHistoryRows", "management approval history rows"],
[adapter, "filterApprovalHistoryRows", "management approval history filters"],
[adapter, "approvalSubscriptionPreferences", "adapter approval subscription preferences"],
[adapter, "update_approval_subscriptions", "adapter approval subscription save action"],
[adapter, "approval-history", "approval history business route"],
[api, "getApprovalSubscriptionPreferences", "approval subscription API getter"],
[api, "patchApprovalSubscriptionPreferences", "approval subscription API patcher"],
[api, "getApprovalHistory", "approval history API getter"],
[types, "ApprovalSubscriptionPreferences", "approval subscription preferences type"],
[stateService, "approvalSubscriptionPreferences", "approval subscription UI state"],
[stateService, "update_approval_subscriptions", "approval subscription runtime event"],
[syncService, "approvalDecisionBusinessView", "approval decision sync business view"],
[syncService, "approvalSubscriptionBusinessView", "approval subscription sync business view"],
[syncService, "digital_workday_update_approval_subscriptions", "approval subscription sync event kind"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing approval history/subscription hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] approval history/subscription hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -288,6 +327,7 @@ function main() {
checkSgrobotDigitalAssets();
checkDigitalDiagnosticAuditProjection();
checkDigitalNotificationPreferences();
checkDigitalApprovalHistorySubscriptions();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -879,6 +879,65 @@ describe("digital employee state service", () => {
});
});
it("persists approval subscription preferences and records runtime events", async () => {
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
saveDigitalEmployeeUiState({
action: "update_approval_subscriptions",
metadata: { source: "approval-settings" },
state: {
selectedDutyIdsByDate: {},
confirmedDates: {},
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: {
enabled: true,
muted_kinds: [],
muted_levels: [],
updated_at: null,
},
approvalSubscriptionPreferences: {
enabled: true,
subscribed_actions: ["delegate", "mark_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: false,
updated_at: "2026-06-07T08:05:00.000Z",
},
consoleRole: "sales",
rolePreferences: {},
profile: {
name: "飞天数字员工",
company: "qimingclaw 客户端",
position: "客户端任务执行员",
currentStatus: "working",
},
},
});
expect(readDigitalEmployeeUiState()).toMatchObject({
approvalSubscriptionPreferences: {
enabled: true,
subscribed_actions: ["delegate", "mark_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: false,
updated_at: "2026-06-07T08:05:00.000Z",
},
});
const preferenceEvent = Array.from(mockState.db?.events.values() ?? [])
.find((event) => event.kind === "digital_workday_update_approval_subscriptions");
expect(preferenceEvent).toMatchObject({
kind: "digital_workday_update_approval_subscriptions",
message: "数字员工审批订阅已更新",
});
expect(mockState.db?.outbox.get(`event:insert:${preferenceEvent?.id}`)).toMatchObject({
entity_type: "event",
operation: "insert",
status: "pending",
});
});
it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -473,6 +473,13 @@ export interface DigitalEmployeeUiState {
muted_levels: string[];
updated_at: string | null;
};
approvalSubscriptionPreferences?: {
enabled: boolean;
subscribed_actions: string[];
subscribed_risk_levels: string[];
notify_on_timeout: boolean;
updated_at: string | null;
};
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -649,6 +656,7 @@ function defaultUiState(): DigitalEmployeeUiState {
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
approvalSubscriptionPreferences: defaultApprovalSubscriptionPreferences(),
consoleRole: "sales",
rolePreferences: {},
profile: {
@@ -711,6 +719,16 @@ function defaultNotificationPreferences(): NonNullable<DigitalEmployeeUiState["n
};
}
function defaultApprovalSubscriptionPreferences(): NonNullable<DigitalEmployeeUiState["approvalSubscriptionPreferences"]> {
return {
enabled: true,
subscribed_actions: ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: true,
updated_at: null,
};
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))];
@@ -730,6 +748,26 @@ function normalizeNotificationPreferences(
};
}
function normalizeApprovalSubscriptionPreferences(
value: unknown,
): NonNullable<DigitalEmployeeUiState["approvalSubscriptionPreferences"]> {
const fallback = defaultApprovalSubscriptionPreferences();
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
return {
enabled: record.enabled !== false,
subscribed_actions: normalizeStringArray(record.subscribed_actions).length > 0
? normalizeStringArray(record.subscribed_actions)
: fallback.subscribed_actions,
subscribed_risk_levels: normalizeStringArray(record.subscribed_risk_levels).length > 0
? normalizeStringArray(record.subscribed_risk_levels)
: fallback.subscribed_risk_levels,
notify_on_timeout: record.notify_on_timeout !== false,
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
};
}
function normalizeUiState(
value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState {
@@ -745,6 +783,7 @@ function normalizeUiState(
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
notificationStateById: normalizeRecord(value.notificationStateById),
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
approvalSubscriptionPreferences: normalizeApprovalSubscriptionPreferences(value.approvalSubscriptionPreferences),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole
: fallback.consoleRole,
@@ -785,6 +824,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工运营偏好已更新";
case "update_notification_preferences":
return "数字员工通知订阅已更新";
case "update_approval_subscriptions":
return "数字员工审批订阅已更新";
default:
return `数字员工页面状态已更新${suffix}`;
}

View File

@@ -511,6 +511,17 @@ describe("digital employee sync service", () => {
sla_summary: { due_at: "2026-06-08T09:00:00.000Z", sla_status: "tracked" },
risk_summary: { risk_level: "high" },
});
insertEventEntity("event-approval-decision", "approval_decision", {
approvalId: "approval-1",
decision: "approved",
previousStatus: "pending",
nextStatus: "approved",
actor: "operator",
decidedAt: "2026-06-07T08:05:00.000Z",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
});
insertEventEntity("event-artifact", "artifact_retention_marked", {
artifact_id: "artifact-1",
action: "mark_keep",
@@ -616,12 +627,34 @@ describe("digital employee sync service", () => {
},
},
});
insertEventEntity("event-approval-subscriptions", "digital_workday_update_approval_subscriptions", {
metadata: {
action: "update_approval_subscriptions",
approval_subscriptions: {
enabled: false,
subscribed_actions: ["mark_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: false,
updated_at: "2026-06-07T08:06:00.000Z",
},
},
state: {
approvalSubscriptionPreferences: {
enabled: false,
subscribed_actions: ["mark_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: false,
updated_at: "2026-06-07T08:06:00.000Z",
},
},
});
mockState.fetch.mockResolvedValue(
jsonResponse({
success: true,
acked: [
{ entity_type: "event", entity_id: "event-route" },
{ entity_type: "event", entity_id: "event-approval" },
{ entity_type: "event", entity_id: "event-approval-decision" },
{ entity_type: "event", entity_id: "event-artifact" },
{ entity_type: "event", entity_id: "event-skill" },
{ entity_type: "event", entity_id: "event-dispatch" },
@@ -630,6 +663,7 @@ describe("digital employee sync service", () => {
{ entity_type: "event", entity_id: "event-notification-dismiss" },
{ entity_type: "event", entity_id: "event-notification-reply" },
{ entity_type: "event", entity_id: "event-notification-preferences" },
{ entity_type: "event", entity_id: "event-approval-subscriptions" },
],
}),
);
@@ -665,6 +699,21 @@ describe("digital employee sync service", () => {
delegate_to: "manager-1",
comment_length: 4,
});
expect(businessView("event-approval")).not.toHaveProperty("comment");
expect(businessView("event-approval")).not.toHaveProperty("comment_preview");
expect(businessView("event-approval-decision")).toMatchObject({
category: "approval",
approval_id: "approval-1",
action: "decision",
decision: "approved",
previous_status: "pending",
next_status: "approved",
actor: "operator",
decided_at: "2026-06-07T08:05:00.000Z",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
expect(businessView("event-artifact")).toMatchObject({
category: "artifact",
artifact_id: "artifact-1",
@@ -730,6 +779,17 @@ describe("digital employee sync service", () => {
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
});
expect(businessView("event-approval-subscriptions")).toMatchObject({
category: "approval",
action: "update_approval_subscriptions",
preferences_enabled: false,
subscribed_actions: ["mark_risk", "decision"],
subscribed_risk_levels: ["high", "critical"],
notify_on_timeout: false,
updated_at: "2026-06-07T08:06:00.000Z",
});
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("state");
expect(businessView("event-approval-subscriptions")).not.toHaveProperty("approvalSubscriptionPreferences");
expect(readEntity("event", "event-route")).toMatchObject({
sync_status: "synced",
sync_error: null,

View File

@@ -562,7 +562,9 @@ function eventSpecificBusinessView(
eventId: string,
): Record<string, unknown> {
if (kind === "route_decision") return routeDecisionBusinessView(payload, eventId);
if (kind === "approval_decision") return approvalDecisionBusinessView(payload);
if (kind.startsWith("approval_action_")) return approvalActionBusinessView(payload);
if (isApprovalSubscriptionEvent(kind)) return approvalSubscriptionBusinessView(payload);
if (kind.startsWith("artifact_access_") || kind.startsWith("artifact_retention_") || kind === "artifact_version_observed") {
return artifactEventBusinessView(payload);
}
@@ -607,6 +609,37 @@ function approvalActionBusinessView(payload: Record<string, unknown>): Record<st
};
}
function approvalDecisionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
return {
approval_id: stringField(payload.approvalId ?? payload.approval_id) || null,
action: "decision",
decision: stringField(payload.decision) || null,
previous_status: stringField(payload.previousStatus ?? payload.previous_status) || null,
next_status: stringField(payload.nextStatus ?? payload.next_status) || null,
actor: stringField(payload.actor ?? payload.source) || null,
decided_at: stringField(payload.decidedAt ?? payload.decided_at) || null,
plan_id: stringField(payload.planId ?? payload.plan_id) || null,
task_id: stringField(payload.taskId ?? payload.task_id) || null,
run_id: stringField(payload.runId ?? payload.run_id) || null,
};
}
function approvalSubscriptionBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const metadata = objectRecord(payload.metadata) ?? {};
const state = objectRecord(payload.state) ?? {};
const preferences = objectRecord(metadata.approval_subscriptions)
?? objectRecord(state.approvalSubscriptionPreferences)
?? {};
return {
action: stringField(metadata.action ?? payload.action) || "update_approval_subscriptions",
preferences_enabled: typeof preferences.enabled === "boolean" ? preferences.enabled : null,
subscribed_actions: stringArrayField(preferences.subscribed_actions ?? preferences.subscribedActions),
subscribed_risk_levels: stringArrayField(preferences.subscribed_risk_levels ?? preferences.subscribedRiskLevels),
notify_on_timeout: typeof preferences.notify_on_timeout === "boolean" ? preferences.notify_on_timeout : null,
updated_at: stringField(preferences.updated_at ?? preferences.updatedAt) || null,
};
}
function artifactEventBusinessView(payload: Record<string, unknown>): Record<string, unknown> {
const retention = objectRecord(payload.retention_summary);
return {
@@ -678,6 +711,7 @@ function notificationBusinessView(payload: Record<string, unknown>): Record<stri
function eventBusinessCategory(kind: string): string {
if (kind === "route_decision") return "route";
if (kind.startsWith("approval_")) return "approval";
if (isApprovalSubscriptionEvent(kind)) return "approval";
if (kind.startsWith("artifact_")) return "artifact";
if (kind.startsWith("skill_call_")) return "skill";
if (kind.startsWith("plan_step_dispatch_")) return "dispatch";
@@ -686,6 +720,10 @@ function eventBusinessCategory(kind: string): string {
return "runtime";
}
function isApprovalSubscriptionEvent(kind: string): boolean {
return kind === "digital_workday_update_approval_subscriptions";
}
function isNotificationEvent(kind: string): boolean {
return kind === "digital_workday_update_notification_preferences"
|| kind === "digital_workday_notification_read"

View File

@@ -578,9 +578,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。
8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要和 notification 投影,审批动作历史与通知联动已开始闭环。
- 缺口:缺少系统级桌面通知、管理端审批历史视图、审批订阅策略、风险策略下发和多步审批流。
- 建议:下一轮先补管理端审批历史与通知订阅策略,再做多步审批和风险策略联动
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影,审批动作历史与通知联动已开始闭环。
- 缺口:缺少系统级桌面通知、风险策略下发和多步审批流。
- 建议:下一轮先补风险策略下发和多步审批流,再评估系统级桌面通知的跨端策略
9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、等级和常用类型过滤 Inbox`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox管理端 sync `business_view` 只同步来源、通知 ID 和输入长度,通知投影、本地订阅策略与任务输入链路已开始闭环。
@@ -1058,6 +1058,7 @@ Approval
- 用户点击同意 / 驳回时adapter 从 approval payload 取出 `sessionId` / `permissionId`,通过 preload bridge 调用 `agent:respondPermission`
- 本地 approval payload 会记录 `acpPermission` 回复结果,供管理端同步和审计。
- 用户处理审批后,主进程会同步写入 `approval_decision` runtime eventembedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
- 管理端可通过 `/api/digital-employee/approval-history` 查询审批动作历史,客户端可通过 `/api/approval/subscriptions` 维护审批订阅策略;订阅更新会写入 `digital_workday_update_approval_subscriptions` runtime event 与同步业务视图。
用户可操作: