吸收数字员工审批历史订阅策略
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 event;embedded adapter 新增 `/api/approval/:approvalId/timeline`,可从 approval 记录和相关 runtime event 投影只读审批动作时间线。
|
||||
- 管理端可通过 `/api/digital-employee/approval-history` 查询审批动作历史,客户端可通过 `/api/approval/subscriptions` 维护审批订阅策略;订阅更新会写入 `digital_workday_update_approval_subscriptions` runtime event 与同步业务视图。
|
||||
|
||||
用户可操作:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user