diff --git a/.project-docs/30-worklog/tasks/20260907-implement-leader-agentbus-copy-b7e31a94.md b/.project-docs/30-worklog/tasks/20260907-implement-leader-agentbus-copy-b7e31a94.md new file mode 100644 index 0000000..a9572b8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-implement-leader-agentbus-copy-b7e31a94.md @@ -0,0 +1,64 @@ +# Task: Implement leader task summaries via AgentBus + +## Identity + +- Task ID: 20260907-implement-leader-agentbus-copy-b7e31a94 +- Mode: Feature +- Branch: codex/20260907-implement-leader-agentbus-copy-b7e31a94-implement-leader-agentbus-copy +- Worktree: /Users/inmanx/Documents/lwltAPI-implement-leader-agentbus-copy-b7e31a94 +- Base commit: f4664997a81722459f8d3e14acfbbf44cc6bbbe1 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement administrator-managed, default-off organization-wide subscriptions that copy deterministic summaries of other non-administrator employees' manual and AgentBus tasks to a bound team-lead AgentBus/WeChat route. +- Add a separate encrypted durable notification outbox, idempotent projection from existing `task.updated` outbox events, lower-priority AgentBus proactive delivery, retry/release behavior, health projection, and explicit administration UI. +- Cover stable completed, failed, cancelled, uncertain, and uncertain-then-resolved outcomes without copying transient progress, historical outcomes, raw instructions, attachments, passenger/customer details, technical payloads, or ERP execution authority. +- Update the active AgentBus/control-plane contract and focused regressions. Do not modify business Skills, ERP schemas/mappings, Chrome extension source/releases, task queues, confirmation, execution, deployment, runtime database, channels, or live external messages. + +## Intent And Constraints + +- Base implementation on current `origin/main` commit `f4664997a81722459f8d3e14acfbbf44cc6bbbe1` in this isolated worktree; preserve the occupied dirty `main` worktree. +- Preserve AUTH-002 immutable assignee, per-account FIFO, assignee-only executable feeds/results, and the separation between organization-wide read projections and ERP mutation authority. +- The user confirmed fixed-organization scope, both `manual` and `agentbus` sources, and implementation. Exclude administrator, system/unassigned, and recipient-owned tasks; do not invent team membership. +- Do not reuse `agentbus_deliveries` or fabricate `reply_to`. Proactive frames use a separately verified recipient/conversation route and `task.summary` contract with stable frame IDs. +- Destination and payload remain encrypted at rest; operational logs expose only bounded identifiers and fingerprints. Delivery failure must never change task state. +- No historical backfill. A new or materially changed subscription starts at the change time, and target/channel/role invalidation cancels or blocks pending delivery without automatic rerouting. +- Feature mode changes only implementation files, active component documentation, this task record, and any task-prefixed supporting record; canonical project memory remains an Integration Gate promotion candidate. + +## Outcome + +- Added migration `020_leader_task_summary_notifications` with a default-off, organization-scoped team-lead subscription and a dedicated durable delivery outbox. Recipient address, conversation ID, and summary payload are encrypted at rest; only SHA-256 fingerprints are exposed to administration and diagnostics. Composite organization foreign keys, bounded projection/claim indexes, revisioned deduplication, and no data backfill keep the feature isolated from existing employee reply rows. +- Added `LeaderNotificationService` as a read-only projection over existing `task.updated` outbox events. It includes future manual and AgentBus tasks assigned to other non-administrator employees, excludes recipient-owned/admin/system tasks, projects only stable outcome milestones, uses transaction advisory locks plus `SKIP LOCKED`, cancels obsolete subscription revisions, and retries delivery failures without changing task state. +- Added deterministic privacy-safe summary generation for completed, failed, cancelled, uncertain, and uncertain-then-resolved outcomes. Messages contain only employee username, registered business label, public task ID, submission time, generic status/result wording, and allowlisted group/order identifiers; raw instructions, attachments, customer/traveller fields, URLs, and technical errors are not copied. +- Integrated the separate summary queue into each team lead's existing AgentBus channel after the employee reply queue. Proactive `task.summary` frames use a stable delivery-derived ID plus explicit recipient/conversation routing and never fabricate `reply_to`; inbound `task.summary` events are reserved and ignored so an echo cannot create another task. Operational logs contain delivery/task/channel IDs and route fingerprints, not the frame or destination plaintext. +- Added administrator-only list/update APIs and a `/channels` management panel. The panel requires explicit target verification and a second enable confirmation, explains default-off/future-only/at-least-once/non-retractable behavior, preserves encrypted routes when their fields are left blank, and shows eligibility plus pending/failed/sent health counts. There is deliberately no live test-send endpoint. +- Updated the active AgentBus reply contract and control-plane README for proactive leader summaries and required schema `020`. Existing task ownership, executable feeds, ERP confirmation/execution, business Skills, schemas/mappings, and Chrome extension sources/releases were not changed. +- No runtime database was migrated, service deployed/restarted, AgentBus channel changed, or real AgentBus/WeChat message sent. + +## Verification + +- `npm run check:repo`: passed (10/10). +- `npm run check`: passed. +- `npm run test:control-plane`: passed (174/174), including summary privacy/status projection, migration/authorization/UI contracts, proactive no-`reply_to` framing, echo rejection, employee-first queue ordering, redacted logging, retry gateway behavior, and existing control-plane regressions. +- `npm run test:legacy`: passed (270/270). +- `npm run build`: passed. +- `node --check LianSyn-platform/app.js`: passed. +- `git diff --check`: passed. +- Fresh disposable PostgreSQL 16 integration: applied all 19 repository migrations through `020`; confirmed zero default subscription rows, administrator-created verified configuration without plaintext route exposure, no historical backfill, encrypted projection/claim, and a pending uncertainty reminder being cancelled when the task resolved so only one final `completed` summary was claimed. The temporary cluster was stopped and moved to Trash afterward. + +## Follow-ups + +- Integration/deployment remains separately authorized: merge this Feature branch, back up and apply migration `020`, then restart the control plane. None of those live actions occurred here. +- Before production enablement, choose the exact team-lead channel and controlled WeChat conversation, validate that the deployed AgentBus bridge accepts the proactive `task.summary` envelope and routes its explicit `to`/`conversation_id` without echoing it as inbound work, then manually mark that target verified and enable the subscription. +- Observe one controlled manual task and one controlled AgentBus task end to end before widening use. Delivery is intentionally at-least-once, so downstream deduplication must honor the stable `leader-summary-` frame ID and operators must understand that a message already accepted by AgentBus/WeChat cannot be retracted by deleting platform data. + +## Promotion Candidates + +- Target documents: canonical system architecture, authorization/data-isolation memory, current-state snapshot, and production operations guidance. +- Proposed durable fact: team-lead WeChat summaries are a default-off organization-wide read projection, not task ownership or ERP authority. They use revisioned subscriptions and their own encrypted outbox; existing employee AgentBus replies remain the higher-priority queue and are never repurposed. +- Proposed safety invariant: only future stable outcomes for other non-admin employees may be projected; route or role invalidation cancels unsent work without rerouting, stale uncertainty reminders are superseded before send, and delivery failures never mutate task state. +- Proposed protocol fact: proactive frames use event `task.summary`, stable ID `leader-summary-`, explicit `to` and `conversation_id`, and no `reply_to`; echoed `task.summary` frames are ignored inbound. +- Evidence: migration `020`, leader notification/projector sources, AgentBus integration, administration UI/API, focused tests, full regression results, and the disposable PostgreSQL integration recorded by this task. +- Human confirmation required: exact production team-lead recipient/channel binding, controlled external AgentBus/WeChat canary result, and authorization to integrate, migrate, restart, and enable. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 602fe55..f145664 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -44,6 +44,9 @@ let automationSettingsError = ''; let automationSettingsSyncInFlight = null; let channelList = []; let channelSettingsBusy = false; +let leaderSummarySubscriptions = []; +let leaderSummarySettingsBusy = false; +let leaderSummaryEditingChannelId = ''; let parserRoutingRows = []; let parserRoutingBusy = false; let accountList = []; @@ -802,6 +805,7 @@ async function syncChannels() { channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); renderChannelOwnerOptions(); + renderLeaderSummaryChannelOptions(); return channelList; } @@ -858,6 +862,7 @@ async function updateChannelEnabled(channelId, enabled) { body: { enabled } }); await syncChannels(); + await syncLeaderSummarySubscriptions().catch(() => {}); } finally { channelSettingsBusy = false; } @@ -872,6 +877,7 @@ async function updateChannelOwner(channelId, ownerUserId) { body: { owner_user_id: ownerUserId } }); await syncChannels(); + await syncLeaderSummarySubscriptions().catch(() => {}); renderChannelOwnerOptions(); } finally { channelSettingsBusy = false; @@ -923,7 +929,7 @@ async function deleteChannel(channelId) { if (!channel || channel.deletable === false) return; const confirmed = window.confirm( `确认删除 AgentBus 渠道“${channel.display_name || '未命名渠道'}”?\n\n` - + '删除后连接会立即停止,服务端保存的 key 和该渠道的持久化 AgentBus 回执记录(包括未发送回执)会被移除;历史任务不会被删除。此操作不可撤销。' + + '删除后连接会立即停止,服务端保存的 key、持久化 AgentBus 回执记录(包括未发送回执)会被移除;组长摘要设置及其待发送记录也会被移除;历史任务不会被删除,已经到达微信的摘要无法撤回。此操作不可撤销。' ); if (!confirmed) return; const message = $('#channelMessage'); @@ -935,6 +941,7 @@ async function deleteChannel(channelId) { method: 'DELETE' }); channelList = channelList.filter((item) => item.id !== channelId); + await syncLeaderSummarySubscriptions().catch(() => {}); } finally { channelSettingsBusy = false; renderChannelList(); @@ -942,6 +949,241 @@ async function deleteChannel(channelId) { if (message) message.textContent = `“${channel.display_name || '未命名渠道'}”已删除。`; } +function leaderSummarySubscriptionForChannel(channelId) { + const channel = channelList.find((item) => item.id === channelId); + return leaderSummarySubscriptions.find((item) => ( + item.channel_id === channelId + || (channel?.owner_user_id && item.leader_user_id === channel.owner_user_id) + )); +} + +function populateLeaderSummaryForm(channelId) { + leaderSummaryEditingChannelId = channelId || ''; + const subscription = leaderSummarySubscriptionForChannel(channelId); + const recipient = $('#leaderSummaryRecipientAddress'); + const conversation = $('#leaderSummaryConversationId'); + if (recipient) recipient.value = ''; + if (conversation) conversation.value = ''; + if ($('#leaderSummaryIncludeManual')) { + $('#leaderSummaryIncludeManual').checked = subscription?.include_manual !== false; + } + if ($('#leaderSummaryIncludeAgentbus')) { + $('#leaderSummaryIncludeAgentbus').checked = subscription?.include_agentbus !== false; + } + if ($('#leaderSummaryTargetVerified')) { + $('#leaderSummaryTargetVerified').checked = subscription?.target_verified === true; + } + if ($('#leaderSummaryEnabled')) { + $('#leaderSummaryEnabled').checked = subscription?.enabled === true; + } + const hint = $('#leaderSummaryMessage'); + if (hint && subscription) { + hint.textContent = '已有目标不会回显;地址和会话 ID 留空保存会继续使用原目标。输入任一新值会要求重新核对。'; + } else if (hint && channelId) { + hint.textContent = '首次配置必须同时填写 AgentBus 收件地址和微信会话 ID,保存后不会回显。'; + } +} + +function renderLeaderSummaryChannelOptions() { + const select = $('#leaderSummaryChannelId'); + if (!select) return; + const subscribedChannelIds = new Set(leaderSummarySubscriptions.map((item) => item.channel_id)); + const channels = channelList.filter((channel) => ( + channel.owner_user_id + && (channel.owner_role === 'team_lead' || subscribedChannelIds.has(channel.id)) + )); + const preferred = String(select.value || leaderSummaryEditingChannelId || ''); + select.replaceChildren(new Option('请选择组长渠道', '')); + for (const channel of channels) { + const state = channel.enabled ? channelStatusLabel(channel.status) : '已停用'; + select.append(new Option( + `${channel.owner_username || '身份已变化'} · ${channel.display_name || '未命名渠道'} · ${state}`, + channel.id + )); + } + const next = channels.some((channel) => channel.id === preferred) + ? preferred + : leaderSummarySubscriptions.find((item) => channels.some((channel) => channel.id === item.channel_id))?.channel_id + || channels[0]?.id + || ''; + select.value = next; + select.disabled = leaderSummarySettingsBusy || !channels.length; + $('#leaderSummarySave').disabled = leaderSummarySettingsBusy || !next; + if (leaderSummaryEditingChannelId !== next) populateLeaderSummaryForm(next); +} + +function renderLeaderSummarySubscriptions() { + const container = $('#leaderSummaryList'); + if (!container) return; + container.replaceChildren(); + if (!leaderSummarySubscriptions.length) { + container.append(el('p', 'muted channel-empty', '尚未配置组长摘要抄送。设置保存后仍默认关闭,启用前必须核对主动投递目标。')); + return; + } + for (const subscription of leaderSummarySubscriptions) { + const row = el('article', 'channel-row'); + const main = el('div', 'channel-row-main'); + const heading = el('div', 'channel-row-heading'); + const stateClass = !subscription.enabled + ? 'state-warn' + : subscription.eligible ? 'state-ok' : 'state-bad'; + const stateLabel = !subscription.enabled + ? '已关闭' + : subscription.eligible ? '已启用' : '已暂停'; + heading.append(el('strong', '', subscription.leader_username || '未知组长')); + heading.append(el('span', `state ${stateClass}`, stateLabel)); + main.append(heading); + const sources = [ + subscription.include_manual ? '人工任务' : '', + subscription.include_agentbus ? 'AgentBus 任务' : '' + ].filter(Boolean).join('、'); + main.append(el('p', 'muted', `渠道:${subscription.channel_name || '未命名渠道'} · 范围:同组织其他非管理员员工 · 来源:${sources || '未选择'}`)); + main.append(el( + 'p', + subscription.target_verified ? 'channel-key-state' : 'channel-error', + subscription.target_verified + ? `目标已核对 · 收件指纹 ${subscription.recipient_fingerprint || '—'} · 会话指纹 ${subscription.conversation_fingerprint || '—'}` + : '主动投递目标尚未核对' + )); + main.append(el('p', 'muted', `当前队列:待发 ${subscription.pending_count || 0} · 失败待重试 ${subscription.failed_count || 0} · 累计已交给 AgentBus ${subscription.delivered_count || 0}`)); + main.append(el('p', subscription.eligible || !subscription.enabled ? 'muted' : 'channel-error', subscription.eligibility_message || '')); + main.append(el('p', 'muted', `本轮起点:${formatDateTime(subscription.starts_at)} · 最近投递:${formatDateTime(subscription.last_delivered_at)}`)); + const actions = el('div', 'channel-row-actions'); + const edit = el('button', 'secondary-button', '编辑'); + edit.type = 'button'; + edit.dataset.leaderSummaryAction = 'edit'; + edit.dataset.leaderSummaryChannelId = subscription.channel_id; + edit.disabled = leaderSummarySettingsBusy; + const toggle = el('button', subscription.enabled ? 'danger-button' : 'secondary-button', subscription.enabled ? '关闭' : '启用'); + toggle.type = 'button'; + toggle.dataset.leaderSummaryAction = 'toggle'; + toggle.dataset.leaderSummaryId = subscription.id; + toggle.disabled = leaderSummarySettingsBusy; + actions.append(edit, toggle); + row.append(main, actions); + container.append(row); + } +} + +async function syncLeaderSummarySubscriptions() { + if (!isAdministrator()) return []; + const result = await apiRequest('/api/settings/leader-summary-subscriptions'); + leaderSummarySubscriptions = Array.isArray(result.subscriptions) ? result.subscriptions : []; + renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); + return leaderSummarySubscriptions; +} + +function confirmLeaderSummaryEnable(subscription) { + return window.confirm( + `确认启用“${subscription?.leader_username || '所选组长'}”的任务摘要抄送?\n\n` + + '只会发送本次保存后的稳定结果,不补发历史;消息正文不含原始指令、客户/游客资料或技术错误。AgentBus 主动投递按至少一次处理,极少情况下可能重复,已经到达微信的消息无法撤回。' + ); +} + +async function saveLeaderSummaryFromForm() { + if (!isAdministrator() || leaderSummarySettingsBusy) return; + const channelId = String($('#leaderSummaryChannelId')?.value || ''); + const channel = channelList.find((item) => item.id === channelId); + const subscription = leaderSummarySubscriptionForChannel(channelId); + const leaderUserId = channel?.owner_user_id || subscription?.leader_user_id || ''; + const recipientAddress = normalizeText($('#leaderSummaryRecipientAddress')?.value).slice(0, 500); + const conversationId = normalizeText($('#leaderSummaryConversationId')?.value).slice(0, 500); + const includeManual = $('#leaderSummaryIncludeManual')?.checked === true; + const includeAgentbus = $('#leaderSummaryIncludeAgentbus')?.checked === true; + const targetVerified = $('#leaderSummaryTargetVerified')?.checked === true; + const enabled = $('#leaderSummaryEnabled')?.checked === true; + const message = $('#leaderSummaryMessage'); + if (!channelId || !leaderUserId) { + if (message) message.textContent = '请选择有效的组长 AgentBus 渠道。'; + return; + } + if (!includeManual && !includeAgentbus) { + if (message) message.textContent = '人工任务和 AgentBus 任务至少选择一种。'; + return; + } + if (!subscription && (!recipientAddress || !conversationId)) { + if (message) message.textContent = '首次配置必须填写 AgentBus 收件地址和微信会话 ID。'; + return; + } + if (enabled && !targetVerified) { + if (message) message.textContent = '启用前请先核对主动投递目标并勾选确认。'; + return; + } + if ((recipientAddress || conversationId) && targetVerified && !window.confirm( + '你正在保存新的主动投递目标。请确认 AgentBus 收件地址与微信会话 ID 已在当前桥接环境中核对无误;系统不会自动发送测试消息。' + )) return; + if (enabled && !subscription?.enabled && !confirmLeaderSummaryEnable(subscription || { + leader_username: channel.owner_username + })) return; + + leaderSummarySettingsBusy = true; + renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); + if (message) message.textContent = '正在保存组长摘要设置…'; + try { + const body = { + channel_id: channelId, + include_manual: includeManual, + include_agentbus: includeAgentbus, + target_verified: targetVerified, + enabled, + ...(recipientAddress ? { recipient_address: recipientAddress } : {}), + ...(conversationId ? { conversation_id: conversationId } : {}), + ...(subscription ? { expected_revision: subscription.revision } : {}) + }; + await apiRequest(`/api/settings/leader-summary-subscriptions/${encodeURIComponent(leaderUserId)}`, { + method: 'PUT', + body + }); + leaderSummaryEditingChannelId = ''; + await syncLeaderSummarySubscriptions(); + const saved = leaderSummarySubscriptionForChannel(channelId); + if (message) { + message.textContent = saved?.enabled + ? '设置已启用;只会从本轮起点之后的稳定任务结果开始抄送。' + : '设置已保存但仍处于关闭状态,不会发送消息。'; + } + } finally { + leaderSummarySettingsBusy = false; + renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); + } +} + +async function toggleLeaderSummarySubscription(subscriptionId) { + const subscription = leaderSummarySubscriptions.find((item) => item.id === subscriptionId); + if (!subscription || leaderSummarySettingsBusy) return; + const nextEnabled = !subscription.enabled; + if (nextEnabled && !confirmLeaderSummaryEnable(subscription)) return; + leaderSummarySettingsBusy = true; + renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); + try { + await apiRequest(`/api/settings/leader-summary-subscriptions/${encodeURIComponent(subscription.leader_user_id)}`, { + method: 'PUT', + body: { + channel_id: subscription.channel_id, + include_manual: subscription.include_manual, + include_agentbus: subscription.include_agentbus, + target_verified: subscription.target_verified, + enabled: nextEnabled, + expected_revision: subscription.revision + } + }); + leaderSummaryEditingChannelId = ''; + await syncLeaderSummarySubscriptions(); + const message = $('#leaderSummaryMessage'); + if (message) message.textContent = nextEnabled + ? '摘要抄送已启用;不会补发此前的任务。' + : '摘要抄送已关闭;尚未发送的旧目标摘要已取消。'; + } finally { + leaderSummarySettingsBusy = false; + renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); + } +} + function accountRoleLabel(role) { if (role === 'admin') return '管理员'; if (role === 'team_lead') return '组长'; @@ -1176,6 +1418,7 @@ async function syncAccounts() { accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; renderAccounts(); renderChannelOwnerOptions(); + renderLeaderSummaryChannelOptions(); } async function createAccountFromForm() { @@ -5045,6 +5288,9 @@ async function refreshBackgroundState() { try { const operations = [pingAi(), pingBridge()]; if (isAdministrator()) operations.push(syncAutomationSettings({ background: true })); + if (IS_CHANNELS_PAGE && isAdministrator()) { + operations.push(syncChannels(), syncLeaderSummarySubscriptions()); + } if (IS_TASK_PAGE) operations.push(syncRemoteTasks()); await Promise.allSettled(operations); } finally { @@ -5083,7 +5329,7 @@ function confirmTaskHardDelete(tasks) { ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; return window.confirm( - `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统会向任务所属账号的在线插件发送停止与清理指令,不会误发给当前管理员插件。` + `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统会向任务所属账号的在线插件发送停止与清理指令,不会误发给当前管理员插件。已经交给 AgentBus 并到达组长微信的任务摘要也无法撤回。` ); } @@ -5556,6 +5802,10 @@ async function initializeSession() { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; }); + await syncLeaderSummarySubscriptions().catch((error) => { + const message = $('#leaderSummaryMessage'); + if (message) message.textContent = `组长摘要设置读取失败:${error.message || String(error)}`; + }); } if (IS_ACCOUNTS_PAGE && isAdministrator()) { await syncAccounts().catch((error) => { @@ -5727,6 +5977,9 @@ document.addEventListener('DOMContentLoaded', async () => { taskAttachmentDrafts.clear(); accountList = []; accountTaskTypes = []; + channelList = []; + leaderSummarySubscriptions = []; + leaderSummaryEditingChannelId = ''; accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); @@ -6019,6 +6272,40 @@ document.addEventListener('DOMContentLoaded', async () => { void syncChannels(); }); }); + $('#leaderSummaryForm')?.addEventListener('submit', (event) => { + event.preventDefault(); + void saveLeaderSummaryFromForm().catch((error) => { + const message = $('#leaderSummaryMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#leaderSummaryChannelId')?.addEventListener('change', (event) => { + populateLeaderSummaryForm(event.target.value); + }); + for (const inputId of ['leaderSummaryRecipientAddress', 'leaderSummaryConversationId']) { + $(`#${inputId}`)?.addEventListener('input', () => { + const verified = $('#leaderSummaryTargetVerified'); + if (verified) verified.checked = false; + }); + } + $('#leaderSummaryList')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-leader-summary-action]'); + if (!button) return; + if (button.dataset.leaderSummaryAction === 'edit') { + const channelId = button.dataset.leaderSummaryChannelId || ''; + const select = $('#leaderSummaryChannelId'); + if (select) select.value = channelId; + populateLeaderSummaryForm(channelId); + $('#leaderSummaryForm')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + return; + } + if (button.dataset.leaderSummaryAction === 'toggle') { + void toggleLeaderSummarySubscription(button.dataset.leaderSummaryId).catch((error) => { + const message = $('#leaderSummaryMessage'); + if (message) message.textContent = error.message || String(error); + }); + } + }); $('#accountRole')?.addEventListener('change', (event) => { const erpInput = $('#accountErpAccount'); const admin = event.target.value === 'admin'; diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 1f1201e..14546f0 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@ AI操作台 · LianSyn-platform - +
@@ -277,6 +277,33 @@

+
+
+
+

LEADER SUMMARY COPY

+

组长任务摘要抄送

+

把同组织内其他非管理员员工的稳定任务结果,经组长自己的 AgentBus 渠道主动投递到指定微信会话。人工任务与 AgentBus 任务均可纳入。

+
+
+
+ 默认关闭;只处理保存设置后的新结果,不补发历史任务。收件地址、会话 ID 和摘要正文均加密保存且不回显。AgentBus 主动投递可能极少量重复,已经到达微信的消息无法撤回。 +
+
+ + + +
+ 抄送范围 + + + + +
+ +
+
+

+
- + diff --git a/LianSyn-platform/styles.css b/LianSyn-platform/styles.css index a8b5e96..aeab8ff 100644 --- a/LianSyn-platform/styles.css +++ b/LianSyn-platform/styles.css @@ -634,6 +634,80 @@ textarea { font-size: 11px; } +.leader-summary-panel { + min-height: 0 !important; + grid-template-rows: auto auto auto minmax(120px, auto) auto !important; +} + +.leader-summary-safety { + padding: 9px 11px; + border: 1px solid #d6c89d; + border-radius: 7px; + background: #fffaf0; + color: #725b18; + font-size: 11px; + line-height: 1.55; +} + +.leader-summary-form { + display: grid; + grid-template-columns: minmax(150px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.2fr) auto; + align-items: end; + gap: 8px; +} + +.leader-summary-form > label, +.leader-summary-options { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.leader-summary-form > label input, +.leader-summary-form > label select { + min-height: 34px; + padding: 6px 8px; + font-size: 12px; +} + +.leader-summary-form > button { + min-height: 34px; + white-space: nowrap; +} + +.leader-summary-options { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; + padding: 5px 8px 7px; + border: 1px solid var(--line); + border-radius: 7px; +} + +.leader-summary-options legend { + padding: 0 4px; +} + +.leader-summary-options label { + display: flex; + align-items: center; + gap: 5px; + color: var(--ink); + font-size: 11px; + font-weight: 500; +} + +.leader-summary-options input { + width: 14px; + height: 14px; + margin: 0; +} + +.leader-summary-list .channel-row-main p { + overflow-wrap: anywhere; +} + .account-authorization-summary { color: var(--ink) !important; } @@ -1986,10 +2060,18 @@ textarea { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .leader-summary-form { + grid-template-columns: minmax(0, 1fr); + } + .channel-form button { width: 100%; } + .leader-summary-form > button { + width: 100%; + } + .channel-row { align-items: flex-start; flex-direction: column; @@ -3037,6 +3119,8 @@ textarea { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 12px; overflow: auto; } diff --git a/agent设计规范/agentbus-reply-contract.md b/agent设计规范/agentbus-reply-contract.md index 6808d33..d2e2255 100644 --- a/agent设计规范/agentbus-reply-contract.md +++ b/agent设计规范/agentbus-reply-contract.md @@ -2,6 +2,24 @@ 本契约只约束执行完成后发给 AgentBus 用户的业务回执,不改变 Agent/Skill 的解析 JSON 契约。 +## 组长任务摘要主动通知 + +组长任务摘要与员工入站消息的受理/结果回复是两套独立契约、两套持久化队列。员工回复仍绑定原始入站 `frame.id`、`from` 和 `reply_to`;组长摘要不得复用 `agentbus_deliveries`,不得伪造入站帧或 `reply_to`,也不得改变任务状态、归属、确认权、ERP 领取权或员工自己的最终回复。 + +管理员可以在 `/channels` 为有效组长的一对一 AgentBus 渠道配置一个组织范围订阅。订阅固定覆盖同组织中除该组长本人以外、任务归属角色不是管理员的员工;可分别纳入人工任务和 AgentBus 任务。当前没有可证明的分组成员关系,因此不得按看板筛选、在线账号或临时渠道推断组员。订阅默认关闭,启用前必须显式核对 AgentBus 收件地址和微信 `conversation_id`;每次保存都建立新的生效时间和 revision,只处理此后的新稳定结果,不扫描或补发历史。渠道、收件目标、角色或订阅 revision 变化时,旧目标尚未发送的摘要取消,不能改投新目标。 + +主动通知帧使用稳定 ID `leader-summary-`,`type=event`、`payload.event=task.summary`,显式携带 `to` 和 `conversation_id`,并且没有 `reply_to`。监听器把 `task.summary` 视为保留事件,桥接器回显该帧时也不能创建任务。员工受理/结果队列每轮优先发送,组长摘要使用独立的小批量低优先级出队。 + +每个任务只在下列稳定里程碑形成摘要: + +1. `completed`/`dry_run`、终态失败或 `cancelled` 形成一次 `final`; +2. `reconciliation_pending`、`saved_unverified`、`execution_uncertain` 或 `uncertain` 形成一次 `needs_review`,明确提示不要重复提交; +3. 已产生 `needs_review` 的任务后来进入确定终态时,再形成一次 `resolved` 结果更新。 + +摘要只包含员工账号、登记业务名称、归一化业务状态、公共任务编号、上海时区提交时间,以及成功回执中经过白名单提取的团号/订单号。不得包含原始或补充指令、客户/游客/联系人信息、附件、Agent/插件/ERP 技术错误、堆栈、URL、token、内部 UUID、生命周期细节或未验证的执行结果。失败只使用统一业务文案;写入不确定不得包装成完成。 + +订阅的收件地址、微信会话 ID 和每条待发正文必须字段加密;列表、审计和日志只展示截短 SHA-256 指纹、投递 ID、任务公共编号、状态和次数,不回显目标或正文。发送采用耐久 outbox、`FOR UPDATE SKIP LOCKED` 领取、短租约恢复和指数退避;WebSocket send 成功只表示已经交给 AgentBus,不证明微信最终展示。该链路是至少一次语义,极少情况下可能重复;已经到达微信的摘要无法由平台撤回,管理员界面和永久删除确认必须明确提示这一点。 + ## 归属和执行路由 每个 AgentBus 渠道必须一对一绑定一个有效的普通用户或组长平台账号;管理员负责配置和查看,但不能成为员工渠道的归属账号,也不能代替归属账号确认或领取 ERP 执行。渠道只有在归属账号已配置唯一 ERP 账号后才能启用 listener。AgentBus 入站任务在创建时同时固化渠道与 `assigned_user_id`,使用归属账号当时有效的业务类型白名单;不得在领取时按当前在线浏览器、用户名、管理员身份或任意空闲云电脑重新推断归属。 diff --git a/control-plane/README.md b/control-plane/README.md index d6d48b8..d040f20 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -89,7 +89,7 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解 - `POST /api/tasks/:taskId/reparse` - `PUT /api/parser-decisions/:decisionId/review` -迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision;迁移 `018_agentbus_account_workers` 增加 ERP 账号、渠道归属、任务执行归属、唯一在线 worker 与 ERP 身份核验字段。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。 +迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision;迁移 `018_agentbus_account_workers` 增加 ERP 账号、渠道归属、任务执行归属、唯一在线 worker 与 ERP 身份核验字段;迁移 `020_leader_task_summary_notifications` 增加默认关闭的组长订阅和独立加密通知 outbox。原文、完整程序/AI 候选、名单 canonical 中间文本、人工说明和待发组长摘要使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。 ## AgentBus Bot 接入 @@ -99,6 +99,8 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解 每个启用渠道会连接 `AGENTBUS_WS_URL?ready=1`,使用该渠道自己的 `Authorization: Bearer `,等待 `session.ready` 后接收普通 `event` 消息。普通任务发送一次持久化受理通知(`task.progress`,`status=accepted`)并在完成时返回一次 `task.result`。名单任务的首次文字指令改为返回明确的等待附件提示,附件入站改为返回“名单附件已收到,正在校验并处理”;最终结果只归属触发解析的最新附件消息,因此不会因文字与附件两条入站帧重复发送成功回执。解析完成、等待确认或进入 ERP 等内部进度不外发。`GET /health/ready` 和 `GET /api/status` 的 `agentbus.channels` 字段可用于确认每个 listener 与 session 是否建立。 +管理员还可以在 `/channels` 配置组长任务摘要抄送。`GET /api/settings/leader-summary-subscriptions` 返回不含明文目标的设置与投递健康状态,`PUT /api/settings/leader-summary-subscriptions/:leaderUserId` 使用 revision 乐观并发保存。订阅固定读取同组织其他非管理员归属人的人工/AgentBus 任务,只从保存后的 `task.updated` 稳定结果投影,不回补历史。主动帧为无 `reply_to` 的 `task.summary`,使用组长自己的渠道、显式收件地址和微信会话 ID;员工回执始终优先。摘要正文只含员工、业务、稳定状态、公共任务编号、上海提交时间以及白名单团号/订单号,不含原始指令、人员资料、附件或技术错误。目标与正文加密,日志只留指纹。该功能启用前要求人工核对桥接目标,发送为至少一次语义且微信消息不可撤回;当前实现不提供会产生真实外发的“测试发送”接口。 + 对微信来源,listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator;`Conversation` 只属于传输路由,不会再污染业务字段签名。 AgentBus 全链路日志使用控制平面 stdout/Pino 输出,同时带 `diagnostic_event=agentbus.` 与原有 `agentbus_event`。日志覆盖连接尝试、socket 生命周期、session.ready、每个收发帧、帧忽略原因、任务入队、解析队列、持久化回执出队、最终回复和发送错误。名单附件另外记录元数据存在性、DNS 开始/通过、解析地址数量与 IP family、HTTPS 状态、重定向次数、接收字节数、大小/摘要校验和各阶段耗时;不记录 URL、hostname、IP、文件名、附件字节或名单值。开发/测试环境可临时设置 `AGENTBUS_LOG_PAYLOADS=true` 记录最多 2,000 个字符的正文预览,生产环境会拒绝以该值启动。渠道 key、WebSocket Token、Invoke Token 和 Authorization header 永不写入日志。 @@ -141,7 +143,7 @@ npm run data:retention npm run dev ``` -`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `018_agentbus_account_workers`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。 +`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `020_leader_task_summary_notifications`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。 `/health/ready` 同时检查 PostgreSQL 可用性和必需 schema 版本;迁移未完成时返回 503,并标明 `required_migration`,避免任务在数据库结构未升级时进入解析队列。 diff --git a/control-plane/migrations/020_leader_task_summary_notifications.sql b/control-plane/migrations/020_leader_task_summary_notifications.sql new file mode 100644 index 0000000..9fe72ca --- /dev/null +++ b/control-plane/migrations/020_leader_task_summary_notifications.sql @@ -0,0 +1,131 @@ +-- Default-off, organization-wide task-summary subscriptions for team leads. +-- This is a read-only projection: it never grants task mutation or ERP +-- execution authority and it does not reuse employee AgentBus reply rows. + +CREATE UNIQUE INDEX IF NOT EXISTS user_channels_organization_id_id_notification_idx + ON user_channels (organization_id, id); + +CREATE UNIQUE INDEX IF NOT EXISTS tasks_organization_id_id_notification_idx + ON tasks (organization_id, id); + +CREATE INDEX IF NOT EXISTS outbox_events_leader_summary_projection_idx + ON outbox_events (organization_id, aggregate_id, created_at DESC) + WHERE topic = 'task.updated' AND aggregate_type = 'task'; + +CREATE INDEX IF NOT EXISTS tasks_leader_summary_projection_idx + ON tasks (organization_id, source, status, assigned_user_id, id) + WHERE assigned_user_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS leader_task_summary_subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + leader_user_id uuid NOT NULL, + channel_id uuid NOT NULL, + scope text NOT NULL DEFAULT 'organization', + include_manual boolean NOT NULL DEFAULT true, + include_agentbus boolean NOT NULL DEFAULT true, + enabled boolean NOT NULL DEFAULT false, + starts_at timestamptz NOT NULL DEFAULT now(), + revision integer NOT NULL DEFAULT 0, + recipient_address_ciphertext text NOT NULL, + recipient_address_fingerprint text NOT NULL, + conversation_id_ciphertext text NOT NULL, + conversation_id_fingerprint text NOT NULL, + target_verified_at timestamptz, + target_verified_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT leader_task_summary_subscriptions_scope_check + CHECK (scope = 'organization'), + CONSTRAINT leader_task_summary_subscriptions_sources_check + CHECK (include_manual OR include_agentbus), + CONSTRAINT leader_task_summary_subscriptions_revision_check + CHECK (revision >= 0), + CONSTRAINT leader_task_summary_subscriptions_recipient_fingerprint_check + CHECK (recipient_address_fingerprint ~ '^[a-f0-9]{64}$'), + CONSTRAINT leader_task_summary_subscriptions_conversation_fingerprint_check + CHECK (conversation_id_fingerprint ~ '^[a-f0-9]{64}$'), + CONSTRAINT leader_task_summary_subscriptions_leader_scope_fkey + FOREIGN KEY (organization_id, leader_user_id) + REFERENCES users (organization_id, id) + ON DELETE CASCADE, + CONSTRAINT leader_task_summary_subscriptions_channel_scope_fkey + FOREIGN KEY (organization_id, channel_id) + REFERENCES user_channels (organization_id, id) + ON DELETE CASCADE, + UNIQUE (organization_id, leader_user_id) +); + +CREATE INDEX IF NOT EXISTS leader_task_summary_subscriptions_active_idx + ON leader_task_summary_subscriptions + (organization_id, enabled, starts_at, channel_id) + WHERE enabled = true; + +CREATE UNIQUE INDEX IF NOT EXISTS leader_task_summary_subscriptions_organization_id_id_idx + ON leader_task_summary_subscriptions (organization_id, id); + +CREATE TABLE IF NOT EXISTS leader_task_summary_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + subscription_id uuid NOT NULL, + subscription_revision integer NOT NULL, + task_id uuid NOT NULL, + leader_user_id uuid NOT NULL, + channel_id uuid NOT NULL, + source_outbox_event_id bigint NOT NULL, + milestone text NOT NULL, + recipient_address_ciphertext text NOT NULL, + recipient_address_fingerprint text NOT NULL, + conversation_id_ciphertext text NOT NULL, + conversation_id_fingerprint text NOT NULL, + payload_ciphertext text NOT NULL, + payload_fingerprint text NOT NULL, + delivery_status text NOT NULL DEFAULT 'pending', + attempt_count integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT leader_task_summary_deliveries_milestone_check + CHECK (milestone IN ('final', 'needs_review', 'resolved')), + CONSTRAINT leader_task_summary_deliveries_status_check + CHECK (delivery_status IN ('pending', 'sending', 'delivered', 'failed', 'cancelled')), + CONSTRAINT leader_task_summary_deliveries_attempt_check + CHECK (attempt_count >= 0), + CONSTRAINT leader_task_summary_deliveries_revision_check + CHECK (subscription_revision >= 0), + CONSTRAINT leader_task_summary_deliveries_payload_fingerprint_check + CHECK (payload_fingerprint ~ '^[a-f0-9]{64}$'), + CONSTRAINT leader_task_summary_deliveries_recipient_fingerprint_check + CHECK (recipient_address_fingerprint ~ '^[a-f0-9]{64}$'), + CONSTRAINT leader_task_summary_deliveries_conversation_fingerprint_check + CHECK (conversation_id_fingerprint ~ '^[a-f0-9]{64}$'), + CONSTRAINT leader_task_summary_deliveries_subscription_scope_fkey + FOREIGN KEY (organization_id, subscription_id) + REFERENCES leader_task_summary_subscriptions (organization_id, id) + ON DELETE CASCADE, + CONSTRAINT leader_task_summary_deliveries_task_scope_fkey + FOREIGN KEY (organization_id, task_id) + REFERENCES tasks (organization_id, id) + ON DELETE CASCADE, + CONSTRAINT leader_task_summary_deliveries_leader_scope_fkey + FOREIGN KEY (organization_id, leader_user_id) + REFERENCES users (organization_id, id) + ON DELETE CASCADE, + CONSTRAINT leader_task_summary_deliveries_channel_scope_fkey + FOREIGN KEY (organization_id, channel_id) + REFERENCES user_channels (organization_id, id) + ON DELETE CASCADE, + UNIQUE (subscription_id, subscription_revision, task_id, milestone) +); + +CREATE INDEX IF NOT EXISTS leader_task_summary_deliveries_pending_idx + ON leader_task_summary_deliveries + (channel_id, delivery_status, next_attempt_at, created_at, id) + WHERE delivery_status IN ('pending', 'sending', 'failed'); + +CREATE INDEX IF NOT EXISTS leader_task_summary_deliveries_task_idx + ON leader_task_summary_deliveries + (organization_id, task_id, leader_user_id, created_at DESC); diff --git a/control-plane/src/agentbus-channels.ts b/control-plane/src/agentbus-channels.ts index 36701f8..3bc70f4 100644 --- a/control-plane/src/agentbus-channels.ts +++ b/control-plane/src/agentbus-channels.ts @@ -498,7 +498,11 @@ export class AgentBusChannelService { const related = await client.query( `SELECT (SELECT count(*)::int FROM tasks WHERE channel_id = $1) AS linked_task_count, - (SELECT count(*)::int FROM agentbus_deliveries WHERE channel_id = $1) AS delivery_count`, + (SELECT count(*)::int FROM agentbus_deliveries WHERE channel_id = $1) AS delivery_count, + (SELECT count(*)::int FROM leader_task_summary_subscriptions WHERE channel_id = $1) + AS leader_subscription_count, + (SELECT count(*)::int FROM leader_task_summary_deliveries WHERE channel_id = $1) + AS leader_delivery_count`, [channelId] ); const relatedRow = related.rows[0] as Record; @@ -507,7 +511,9 @@ export class AgentBusChannelService { display_name: text(row.display_name), enabled: booleanValue(row.enabled), linked_task_count: Number(relatedRow.linked_task_count || 0), - removed_delivery_count: Number(relatedRow.delivery_count || 0) + removed_delivery_count: Number(relatedRow.delivery_count || 0), + removed_leader_subscription_count: Number(relatedRow.leader_subscription_count || 0), + removed_leader_delivery_count: Number(relatedRow.leader_delivery_count || 0) }); const deleted = await client.query( 'DELETE FROM user_channels WHERE organization_id = $1 AND id = $2 RETURNING id', @@ -644,6 +650,7 @@ export interface AgentBusManagerOptions { scheduleParseQueue: () => Promise; logger?: AgentBusLogger; socketFactory?: AgentBusSocketFactory; + leaderNotifications?: AgentBusListenerOptions['leaderNotifications']; } export class AgentBusManager { @@ -711,6 +718,7 @@ export class AgentBusManager { scheduleParseQueue: this.options.scheduleParseQueue, socketFactory: this.options.socketFactory, logger: this.options.logger, + leaderNotifications: this.options.leaderNotifications, channel: { id: channel.id, displayName: channel.display_name, diff --git a/control-plane/src/agentbus.ts b/control-plane/src/agentbus.ts index 422a18c..d451d39 100644 --- a/control-plane/src/agentbus.ts +++ b/control-plane/src/agentbus.ts @@ -25,6 +25,7 @@ import { AGENTBUS_ROSTER_ATTACHMENT_RECEIVED_TEXT, publicAgentBusDeliveryPayload } from './agentbus-delivery.js'; +import type { LeaderTaskSummaryDelivery } from './leader-notification-service.js'; const OPEN_READY_STATE = 1; const MAX_COMPLETED_TASK_IDS = 2_048; @@ -133,6 +134,17 @@ export interface AgentBusTaskGateway { releaseAgentBusDeliveries?(channelId: string, leaseOwner: string): Promise; } +export interface LeaderNotificationGateway { + claimDeliveries( + channelId: string, + leaseOwner: string, + limit?: number + ): Promise; + markDeliveryDelivered(deliveryId: string): Promise; + markDeliveryFailed(deliveryId: string, errorMessage: string): Promise; + releaseDeliveries(channelId: string, leaseOwner: string): Promise; +} + export interface AgentBusChannelConnection { id: string; displayName: string; @@ -151,6 +163,7 @@ export interface AgentBusListenerOptions { socketFactory?: AgentBusSocketFactory; logger?: AgentBusLogger; channel?: AgentBusChannelConnection; + leaderNotifications?: LeaderNotificationGateway; onStatusChange?: ( status: 'disabled' | 'connecting' | 'connected' | 'error', errorMessage: string | null, @@ -369,7 +382,9 @@ function inboundFrameIgnoreReason(frame: AgentBusFrame): string | null { if (!text(frame.from)) return 'missing_from'; const hasAttachment = Array.isArray(frame.payload?.attachments) && frame.payload!.attachments!.length > 0; if (!text(frame.payload?.text) && !hasAttachment) return 'missing_payload_content'; - if (['session.ready', 'task.progress', 'task.result'].includes(event)) return `reserved_event:${event}`; + if (['session.ready', 'task.progress', 'task.result', 'task.summary'].includes(event)) { + return `reserved_event:${event}`; + } return null; } @@ -516,6 +531,22 @@ function createDurableDeliveryFrame( return frame; } +export function createLeaderTaskSummaryFrame( + delivery: LeaderTaskSummaryDelivery, + session: AgentBusSession +): AgentBusFrame { + return { + id: `leader-summary-${delivery.id}`, + type: 'event', + from: session.address, + to: delivery.recipient_address, + session_id: session.id, + epoch: session.epoch, + conversation_id: delivery.conversation_id, + payload: { ...delivery.payload } + }; +} + export function taskResultStatus(task: PublicTask): 'completed' | 'failed' { return FAILED_TASK_STATUSES.has(text(task.status).toLowerCase()) ? 'failed' : 'completed'; } @@ -615,6 +646,7 @@ export class AgentBusListener { private readonly socketFactory: AgentBusSocketFactory; private readonly logger: AgentBusLogger; private readonly channel: AgentBusChannelConnection | null; + private readonly leaderNotifications: LeaderNotificationGateway | null; private readonly onStatusChange: AgentBusListenerOptions['onStatusChange']; private readonly leaseOwner: string; private socket: AgentBusSocket | null = null; @@ -626,6 +658,7 @@ export class AgentBusListener { private readonly pendingFinalReplies = new Map(); private deliveryTimer: NodeJS.Timeout | null = null; private durableFlushInFlight: Promise | null = null; + private leaderFlushInFlight: Promise | null = null; constructor(options: AgentBusListenerOptions) { this.config = options.config; @@ -635,6 +668,7 @@ export class AgentBusListener { this.socketFactory = options.socketFactory || defaultSocketFactory; this.logger = options.logger || noopLogger; this.channel = options.channel || null; + this.leaderNotifications = options.leaderNotifications || null; this.onStatusChange = options.onStatusChange; this.leaseOwner = `agentbus:${this.channel?.id || 'legacy'}`; } @@ -726,7 +760,7 @@ export class AgentBusListener { task_timeout_ms: this.config.AGENTBUS_TASK_TIMEOUT_MS, log_payloads: this.config.AGENTBUS_LOG_PAYLOADS }, 'AgentBus listener starting'); - this.deliveryTimer = setInterval(() => void this.flushDurableDeliveries(), 1_000); + this.deliveryTimer = setInterval(() => void this.flushOutboundDeliveries(), 1_000); this.connect(); } @@ -753,6 +787,9 @@ export class AgentBusListener { if (this.channel && this.tasks.releaseAgentBusDeliveries) { void this.tasks.releaseAgentBusDeliveries(this.channel.id, this.leaseOwner).catch(() => undefined); } + if (this.channel && this.leaderNotifications) { + void this.leaderNotifications.releaseDeliveries(this.channel.id, this.leaseOwner).catch(() => undefined); + } if (persistStatus) this.setRuntimeStatus('disabled', null, sessionEpoch); } @@ -833,6 +870,9 @@ export class AgentBusListener { if (this.channel && this.tasks.releaseAgentBusDeliveries) { void this.tasks.releaseAgentBusDeliveries(this.channel.id, this.leaseOwner).catch(() => undefined); } + if (this.channel && this.leaderNotifications) { + void this.leaderNotifications.releaseDeliveries(this.channel.id, this.leaseOwner).catch(() => undefined); + } for (const pending of this.pendingFinalReplies.values()) pending.sending = false; this.logger.warn({ agentbus_event: 'socket_close', @@ -882,7 +922,7 @@ export class AgentBusListener { session_address: ready.address }, 'AgentBus session ready'); this.flushPendingReplies(); - void this.flushDurableDeliveries(); + void this.flushOutboundDeliveries(); return; } const ignoreReason = inboundFrameIgnoreReason(frame); @@ -1182,6 +1222,93 @@ export class AgentBusListener { return this.durableFlushInFlight; } + private async flushOutboundDeliveries(): Promise { + // Employee replies always get the first claim/send opportunity on a tick. + // Leader summaries use a separate, smaller queue and cannot delay task replies. + await this.flushDurableDeliveries(); + await this.flushLeaderDeliveries(); + } + + private async flushLeaderDeliveries(): Promise { + if (!this.channel || !this.leaderNotifications) return; + if (this.leaderFlushInFlight) return this.leaderFlushInFlight; + if (!this.socket || this.socket.readyState !== OPEN_READY_STATE || !this.session) return; + this.leaderFlushInFlight = (async () => { + const deliveries = await this.leaderNotifications!.claimDeliveries( + this.channel!.id, + this.leaseOwner, + 5 + ); + for (const delivery of deliveries) void this.sendLeaderDelivery(delivery); + })() + .catch((error) => { + this.logger.warn({ + agentbus_event: 'leader_summary_delivery_flush_failed', + channel_id: this.channel?.id || null, + ...diagnosticError(error, 'leader_summary_delivery_flush_failed') + }, 'AgentBus leader task summary flush failed'); + }) + .finally(() => { + this.leaderFlushInFlight = null; + }); + return this.leaderFlushInFlight; + } + + private async sendLeaderDelivery(delivery: LeaderTaskSummaryDelivery): Promise { + if (!this.socket || this.socket.readyState !== OPEN_READY_STATE || !this.session || !this.leaderNotifications) { + return; + } + const socket = this.socket; + const session = this.session; + const notifications = this.leaderNotifications; + const frame = createLeaderTaskSummaryFrame(delivery, session); + this.logger.info({ + agentbus_event: 'leader_summary_delivery_sending', + channel_id: delivery.channel_id, + delivery_id: delivery.id, + task_id: delivery.task_id, + recipient_fingerprint: delivery.recipient_fingerprint, + conversation_fingerprint: delivery.conversation_fingerprint, + attempt_count: delivery.attempt_count + }, 'AgentBus leader task summary sending'); + const fail = (error: unknown) => { + const errorMetadata = diagnosticError(error, 'leader_summary_delivery_failed'); + void notifications.markDeliveryFailed( + delivery.id, + `${String(errorMetadata.error_code)}:${String(errorMetadata.error_fingerprint)}` + ).catch(() => undefined); + this.logger.warn({ + agentbus_event: 'leader_summary_delivery_failed', + channel_id: delivery.channel_id, + delivery_id: delivery.id, + task_id: delivery.task_id, + recipient_fingerprint: delivery.recipient_fingerprint, + conversation_fingerprint: delivery.conversation_fingerprint, + ...errorMetadata + }, 'AgentBus leader task summary failed'); + }; + try { + socket.send(JSON.stringify(frame), (error) => { + if (error) { + fail(error); + return; + } + void notifications.markDeliveryDelivered(delivery.id) + .then(() => this.logger.info({ + agentbus_event: 'leader_summary_delivery_sent', + channel_id: delivery.channel_id, + delivery_id: delivery.id, + task_id: delivery.task_id, + recipient_fingerprint: delivery.recipient_fingerprint, + conversation_fingerprint: delivery.conversation_fingerprint + }, 'AgentBus leader task summary sent')) + .catch(fail); + }); + } catch (error) { + fail(error); + } + } + private async sendDurableDelivery( delivery: AgentBusDelivery, markDelivered: (deliveryId: string) => Promise, diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts index fe94de2..da569f6 100644 --- a/control-plane/src/db.ts +++ b/control-plane/src/db.ts @@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js'; const { Pool } = pg; let pool: pg.Pool | null = null; -export const REQUIRED_SCHEMA_VERSION = '018_agentbus_account_workers'; +export const REQUIRED_SCHEMA_VERSION = '020_leader_task_summary_notifications'; export interface DatabaseReadiness { ready: boolean; diff --git a/control-plane/src/leader-notification-service.ts b/control-plane/src/leader-notification-service.ts new file mode 100644 index 0000000..82327b8 --- /dev/null +++ b/control-plane/src/leader-notification-service.ts @@ -0,0 +1,873 @@ +import type { AppConfig } from './config.js'; +import { decryptText, encryptText, sha256Text } from './crypto.js'; +import { getPool, withTransaction } from './db.js'; +import { diagnosticError, diagnosticMetadataKeys } from './diagnostics.js'; +import { + buildLeaderTaskSummary, + isLeaderTaskSummaryStatus, + type LeaderTaskSummaryStatus +} from './leadership-task-summary.js'; +import { TaskError, type TaskContext } from './task-service.js'; + +export interface LeaderNotificationLogger { + info(metadata: Record, message?: string): void; + warn(metadata: Record, message?: string): void; + error(metadata: Record, message?: string): void; +} + +export interface PublicLeaderTaskSummarySubscription { + id: string; + organization_id: string; + leader_user_id: string; + leader_username: string; + channel_id: string; + channel_name: string; + scope: 'organization'; + include_manual: boolean; + include_agentbus: boolean; + enabled: boolean; + eligible: boolean; + eligibility_message: string; + target_verified: boolean; + recipient_fingerprint: string; + conversation_fingerprint: string; + starts_at: string; + revision: number; + pending_count: number; + failed_count: number; + delivered_count: number; + last_delivered_at: string | null; + created_at: string; + updated_at: string; +} + +export interface LeaderTaskSummarySubscriptionInput { + leaderUserId: string; + channelId: string; + recipientAddress?: string; + conversationId?: string; + includeManual: boolean; + includeAgentBus: boolean; + enabled: boolean; + targetVerified?: boolean; + expectedRevision?: number; +} + +export interface LeaderTaskSummaryDelivery { + id: string; + channel_id: string; + task_id: string; + recipient_address: string; + recipient_fingerprint: string; + conversation_id: string; + conversation_fingerprint: string; + payload: { + event: 'task.summary'; + status: LeaderTaskSummaryStatus; + task_id: string; + text: string; + }; + attempt_count: number; +} + +const PROJECTABLE_STATUSES = [ + 'completed', + 'dry_run', + 'failed', + 'parse_failed', + 'parse_blocked', + 'agent_parse_blocked', + 'blocked', + 'operation_blocked', + 'cancelled', + 'reconciliation_pending', + 'saved_unverified', + 'execution_uncertain', + 'uncertain' +] as const; + +const NEEDS_REVIEW_STATUSES = [ + 'reconciliation_pending', + 'saved_unverified', + 'execution_uncertain', + 'uncertain' +] as const; + +const noopLogger: LeaderNotificationLogger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined +}; + +function text(value: unknown): string { + return value == null ? '' : String(value).trim(); +} + +function booleanValue(value: unknown): boolean { + return value === true || text(value) === 'true'; +} + +function iso(value: unknown): string | null { + if (!value) return null; + const date = new Date(String(value)); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function jsonObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function eligibility( + row: Record, + agentBusEnabled: boolean +): { eligible: boolean; message: string } { + if (!agentBusEnabled) { + return { eligible: false, message: 'AgentBus 全局开关未启用,通知不会发送。' }; + } + if (!booleanValue(row.leader_is_active) || text(row.leader_role) !== 'team_lead') { + return { eligible: false, message: '组长账号已停用或角色已变化,通知不会发送。' }; + } + if (text(row.channel_owner_user_id) !== text(row.leader_user_id)) { + return { eligible: false, message: 'AgentBus 渠道已不再归属该组长,通知不会发送。' }; + } + if (!booleanValue(row.channel_enabled)) { + return { eligible: false, message: 'AgentBus 渠道已停用,通知不会发送。' }; + } + if (!row.target_verified_at) { + return { eligible: false, message: '主动投递目标尚未验证,通知不会发送。' }; + } + return { eligible: true, message: '投递条件已就绪。' }; +} + +function publicSubscription( + row: Record, + agentBusEnabled: boolean +): PublicLeaderTaskSummarySubscription { + const state = eligibility(row, agentBusEnabled); + return { + id: text(row.id), + organization_id: text(row.organization_id), + leader_user_id: text(row.leader_user_id), + leader_username: text(row.leader_username) || '未知组长', + channel_id: text(row.channel_id), + channel_name: text(row.channel_name) || '未命名渠道', + scope: 'organization', + include_manual: booleanValue(row.include_manual), + include_agentbus: booleanValue(row.include_agentbus), + enabled: booleanValue(row.enabled), + eligible: state.eligible, + eligibility_message: state.message, + target_verified: Boolean(row.target_verified_at), + recipient_fingerprint: text(row.recipient_address_fingerprint).slice(0, 12), + conversation_fingerprint: text(row.conversation_id_fingerprint).slice(0, 12), + starts_at: iso(row.starts_at) || new Date(0).toISOString(), + revision: Number(row.revision || 0), + pending_count: Number(row.pending_count || 0), + failed_count: Number(row.failed_count || 0), + delivered_count: Number(row.delivered_count || 0), + last_delivered_at: iso(row.last_delivered_at), + created_at: iso(row.created_at) || new Date(0).toISOString(), + updated_at: iso(row.updated_at) || new Date(0).toISOString() + }; +} + +export class LeaderNotificationService { + private projectorTimer: NodeJS.Timeout | null = null; + private projectorInFlight: Promise | null = null; + private projectorOrganizationId = ''; + + constructor( + private readonly config: AppConfig, + private readonly logger: LeaderNotificationLogger = noopLogger + ) {} + + private log( + level: 'info' | 'warn' | 'error', + metadata: Record, + message: string + ): void { + try { + this.logger[level](metadata, message); + } catch { + // Notification persistence and task state never depend on logging. + } + } + + startProjector(organizationId: string): void { + if (this.projectorTimer) return; + this.projectorOrganizationId = organizationId; + this.projectorTimer = setInterval(() => void this.projectTick(), 2_000); + void this.projectTick(); + } + + async stopProjector(): Promise { + if (this.projectorTimer) clearInterval(this.projectorTimer); + this.projectorTimer = null; + if (this.projectorInFlight) await this.projectorInFlight.catch(() => undefined); + this.projectorOrganizationId = ''; + } + + private async projectTick(): Promise { + if (!this.projectorOrganizationId) return; + if (this.projectorInFlight) return this.projectorInFlight; + this.projectorInFlight = this.projectPending(this.projectorOrganizationId, 100) + .then((count) => { + if (count > 0) { + this.log('info', { + agentbus_event: 'leader_summary_projected', + organization_id: this.projectorOrganizationId, + projected_count: count + }, 'Leader task summaries projected'); + } + }) + .catch((error) => { + this.log('warn', { + agentbus_event: 'leader_summary_projection_failed', + organization_id: this.projectorOrganizationId, + ...diagnosticError(error, 'leader_summary_projection_failed') + }, 'Leader task summary projection failed'); + }) + .finally(() => { + this.projectorInFlight = null; + }); + return this.projectorInFlight; + } + + async listSubscriptions(organizationId: string): Promise { + const result = await getPool(this.config).query( + `SELECT subscription.*, + leader.username AS leader_username, + leader.role AS leader_role, + leader.is_active AS leader_is_active, + channel.display_name AS channel_name, + channel.owner_user_id AS channel_owner_user_id, + channel.enabled AS channel_enabled, + COALESCE(delivery.pending_count, 0)::int AS pending_count, + COALESCE(delivery.failed_count, 0)::int AS failed_count, + COALESCE(delivery.delivered_count, 0)::int AS delivered_count, + delivery.last_delivered_at + FROM leader_task_summary_subscriptions subscription + JOIN users leader + ON leader.id = subscription.leader_user_id + AND leader.organization_id = subscription.organization_id + JOIN user_channels channel + ON channel.id = subscription.channel_id + AND channel.organization_id = subscription.organization_id + LEFT JOIN LATERAL ( + SELECT count(*) FILTER ( + WHERE d.delivery_status IN ('pending', 'sending') + AND d.subscription_revision = subscription.revision + ) AS pending_count, + count(*) FILTER ( + WHERE d.delivery_status = 'failed' + AND d.subscription_revision = subscription.revision + ) AS failed_count, + count(*) FILTER (WHERE d.delivery_status = 'delivered') AS delivered_count, + max(d.delivered_at) AS last_delivered_at + FROM leader_task_summary_deliveries d + WHERE d.subscription_id = subscription.id + ) delivery ON true + WHERE subscription.organization_id = $1 + ORDER BY leader.username, subscription.id`, + [organizationId] + ); + return (result.rows as Record[]) + .map((row) => publicSubscription(row, this.config.agentBusEnabled)); + } + + async upsertSubscription( + context: TaskContext, + input: LeaderTaskSummarySubscriptionInput + ): Promise { + const leaderUserId = text(input.leaderUserId); + const channelId = text(input.channelId); + const recipientAddress = text(input.recipientAddress).slice(0, 500); + const conversationId = text(input.conversationId).slice(0, 500); + if (!leaderUserId || !channelId) { + throw new TaskError('leader_summary_target_required', '请选择组长及其 AgentBus 渠道。', 400); + } + if (!input.includeManual && !input.includeAgentBus) { + throw new TaskError('leader_summary_source_required', '人工任务和 AgentBus 任务至少选择一种。', 400); + } + if (input.enabled && !this.config.agentBusEnabled) { + throw new TaskError( + 'leader_summary_agentbus_disabled', + 'AgentBus 全局开关未启用,暂时不能启用组长摘要抄送。', + 409 + ); + } + + const subscriptionId = await withTransaction(this.config, async (client) => { + await client.query( + `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':leader-summary:' || $2::text, 0))`, + [context.organizationId, leaderUserId] + ); + const existingResult = await client.query( + `SELECT * + FROM leader_task_summary_subscriptions + WHERE organization_id = $1 AND leader_user_id = $2 + FOR UPDATE`, + [context.organizationId, leaderUserId] + ); + const existing = existingResult.rows[0] as Record | undefined; + const safeDisable = Boolean( + existing + && !input.enabled + && channelId === text(existing.channel_id) + ); + const leaderResult = await client.query( + `SELECT id, role, is_active + FROM users + WHERE organization_id = $1 AND id = $2 + FOR SHARE`, + [context.organizationId, leaderUserId] + ); + if (!leaderResult.rowCount) { + throw new TaskError('leader_summary_leader_not_found', '组长账号不存在。', 404); + } + const leader = leaderResult.rows[0] as Record; + if ((!booleanValue(leader.is_active) || text(leader.role) !== 'team_lead') && !safeDisable) { + throw new TaskError('leader_summary_leader_invalid', '只有有效的组长账号可以接收任务摘要。', 409); + } + const channelResult = await client.query( + `SELECT id, owner_user_id, enabled + FROM user_channels + WHERE organization_id = $1 AND id = $2 + FOR SHARE`, + [context.organizationId, channelId] + ); + if (!channelResult.rowCount) { + throw new TaskError('leader_summary_channel_not_found', 'AgentBus 渠道不存在。', 404); + } + const channel = channelResult.rows[0] as Record; + if (text(channel.owner_user_id) !== leaderUserId && !safeDisable) { + throw new TaskError('leader_summary_channel_owner_mismatch', '所选 AgentBus 渠道不属于该组长。', 409); + } + if (input.enabled && !booleanValue(channel.enabled)) { + throw new TaskError('leader_summary_channel_disabled', '请先启用该组长的 AgentBus 渠道。', 409); + } + + const currentRevision = Number(existing?.revision || 0); + if (existing && input.expectedRevision !== undefined && input.expectedRevision !== currentRevision) { + throw new TaskError( + 'leader_summary_revision_conflict', + '组长摘要设置已被其他管理员修改,请刷新后重试。', + 409, + { current_revision: currentRevision } + ); + } + if (!existing && (!recipientAddress || !conversationId)) { + throw new TaskError( + 'leader_summary_route_required', + '首次配置必须填写 AgentBus 收件地址和微信会话 ID。', + 400 + ); + } + + const recipientFingerprint = recipientAddress + ? sha256Text(recipientAddress) + : text(existing?.recipient_address_fingerprint); + const conversationFingerprint = conversationId + ? sha256Text(conversationId) + : text(existing?.conversation_id_fingerprint); + const recipientCiphertext = recipientAddress + ? encryptText(this.config, recipientAddress) + : text(existing?.recipient_address_ciphertext); + const conversationCiphertext = conversationId + ? encryptText(this.config, conversationId) + : text(existing?.conversation_id_ciphertext); + const routeChanged = !existing + || channelId !== text(existing.channel_id) + || recipientFingerprint !== text(existing.recipient_address_fingerprint) + || conversationFingerprint !== text(existing.conversation_id_fingerprint); + const targetVerified = input.targetVerified === undefined + ? !routeChanged && Boolean(existing?.target_verified_at) + : input.targetVerified === true; + if (input.enabled && !targetVerified) { + throw new TaskError( + 'leader_summary_target_unverified', + '启用前必须确认该收件地址和微信会话已经过主动投递验证。', + 409 + ); + } + + const nextRevision = existing ? currentRevision + 1 : 0; + let id: string; + if (existing) { + const updated = await client.query( + `UPDATE leader_task_summary_subscriptions + SET channel_id = $1, + include_manual = $2, + include_agentbus = $3, + enabled = $4, + starts_at = now(), + revision = $5, + recipient_address_ciphertext = $6, + recipient_address_fingerprint = $7, + conversation_id_ciphertext = $8, + conversation_id_fingerprint = $9, + target_verified_at = CASE WHEN $10 THEN now() ELSE NULL END, + target_verified_by = CASE WHEN $10 THEN $11::uuid ELSE NULL END, + updated_at = now() + WHERE id = $12 + RETURNING id`, + [ + channelId, + input.includeManual, + input.includeAgentBus, + input.enabled, + nextRevision, + recipientCiphertext, + recipientFingerprint, + conversationCiphertext, + conversationFingerprint, + targetVerified, + context.userId || null, + existing.id + ] + ); + id = text(updated.rows[0].id); + await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'cancelled', + last_error = '订阅设置已变化,旧目标待发送摘要已取消。', + updated_at = now() + WHERE subscription_id = $1 + AND subscription_revision <> $2 + AND delivery_status IN ('pending', 'sending', 'failed')`, + [id, nextRevision] + ); + } else { + const inserted = await client.query( + `INSERT INTO leader_task_summary_subscriptions + (organization_id, leader_user_id, channel_id, include_manual, + include_agentbus, enabled, starts_at, revision, + recipient_address_ciphertext, recipient_address_fingerprint, + conversation_id_ciphertext, conversation_id_fingerprint, + target_verified_at, target_verified_by, created_by) + VALUES ($1, $2, $3, $4, $5, $6, now(), 0, + $7, $8, $9, $10, + CASE WHEN $11 THEN now() ELSE NULL END, + CASE WHEN $11 THEN $12::uuid ELSE NULL END, + $12::uuid) + RETURNING id`, + [ + context.organizationId, + leaderUserId, + channelId, + input.includeManual, + input.includeAgentBus, + input.enabled, + recipientCiphertext, + recipientFingerprint, + conversationCiphertext, + conversationFingerprint, + targetVerified, + context.userId || null + ] + ); + id = text(inserted.rows[0].id); + } + + await client.query( + `INSERT INTO audit_events + (organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata) + VALUES ($1, $2, $3, 'leader_task_summary_subscription', $4, $5, $6)`, + [ + context.organizationId, + context.userId || null, + input.enabled ? 'leader_summary_subscription.enabled' : 'leader_summary_subscription.disabled', + id, + context.requestId, + { + leader_user_id: leaderUserId, + channel_id: channelId, + include_manual: input.includeManual, + include_agentbus: input.includeAgentBus, + enabled: input.enabled, + target_verified: targetVerified, + recipient_fingerprint: recipientFingerprint.slice(0, 12), + conversation_fingerprint: conversationFingerprint.slice(0, 12), + revision: nextRevision, + historical_backfill: false + } + ] + ); + this.log('info', { + agentbus_event: 'leader_summary_subscription_audit_staged', + request_id: context.requestId, + entity_id: id, + metadata_keys: diagnosticMetadataKeys({ + leader_user_id: leaderUserId, + channel_id: channelId, + enabled: input.enabled, + revision: nextRevision + }) + }, 'Leader summary subscription audit event staged'); + return id; + }); + + const subscriptions = await this.listSubscriptions(context.organizationId); + const subscription = subscriptions.find((item) => item.id === subscriptionId); + if (!subscription) throw new TaskError('leader_summary_subscription_not_found', '组长摘要设置不存在。', 404); + return subscription; + } + + async projectPending(organizationId: string, limit = 100): Promise { + const boundedLimit = Math.max(1, Math.min(500, Math.trunc(limit))); + return withTransaction(this.config, async (client) => { + const lock = await client.query( + `SELECT pg_try_advisory_xact_lock( + hashtextextended($1::text || ':leader-summary-projector', 0) + ) AS acquired`, + [organizationId] + ); + if (!booleanValue(lock.rows[0]?.acquired)) return 0; + const candidates = await client.query( + `SELECT subscription.id AS subscription_id, + subscription.revision AS subscription_revision, + subscription.leader_user_id, + subscription.channel_id, + subscription.recipient_address_ciphertext, + subscription.recipient_address_fingerprint, + subscription.conversation_id_ciphertext, + subscription.conversation_id_fingerprint, + task.id AS task_row_id, + task.task_id, + task.status, + task.business_route_id, + task.success_receipt, + task.created_at, + assignee.username AS assignee_username, + source_event.id AS source_outbox_event_id, + COALESCE(delivery_state.has_needs_review, false) AS has_needs_review, + COALESCE(delivery_state.has_exposed_needs_review, false) AS has_exposed_needs_review + FROM leader_task_summary_subscriptions subscription + JOIN users leader + ON leader.id = subscription.leader_user_id + AND leader.organization_id = subscription.organization_id + AND leader.role = 'team_lead' + AND leader.is_active = true + JOIN user_channels channel + ON channel.id = subscription.channel_id + AND channel.organization_id = subscription.organization_id + AND channel.owner_user_id = subscription.leader_user_id + AND channel.enabled = true + JOIN tasks task + ON task.organization_id = subscription.organization_id + AND task.assigned_user_id IS NOT NULL + AND task.assigned_user_id <> subscription.leader_user_id + AND task.source IN ('manual', 'agentbus') + AND ((task.source = 'manual' AND subscription.include_manual) + OR (task.source = 'agentbus' AND subscription.include_agentbus)) + JOIN users assignee + ON assignee.id = task.assigned_user_id + AND assignee.organization_id = task.organization_id + AND assignee.role <> 'admin' + JOIN LATERAL ( + SELECT event.id + FROM outbox_events event + WHERE event.organization_id = subscription.organization_id + AND event.topic = 'task.updated' + AND event.aggregate_type = 'task' + AND event.aggregate_id = task.task_id + AND event.created_at >= subscription.starts_at + AND event.payload ->> 'status' = ANY($2::text[]) + AND (event.payload ->> 'archived') IS DISTINCT FROM 'true' + AND (event.payload ->> 'restored') IS DISTINCT FROM 'true' + ORDER BY event.id DESC + LIMIT 1 + ) source_event ON true + LEFT JOIN LATERAL ( + SELECT bool_or(delivery.milestone = 'needs_review' AND delivery.delivery_status <> 'cancelled') AS has_needs_review, + bool_or(delivery.milestone = 'needs_review' AND delivery.delivery_status IN ('sending', 'delivered')) + AS has_exposed_needs_review, + bool_or(delivery.milestone = 'final' AND delivery.delivery_status <> 'cancelled') AS has_final, + bool_or(delivery.milestone = 'resolved' AND delivery.delivery_status <> 'cancelled') AS has_resolved + FROM leader_task_summary_deliveries delivery + WHERE delivery.subscription_id = subscription.id + AND delivery.subscription_revision = subscription.revision + AND delivery.task_id = task.id + ) delivery_state ON true + WHERE subscription.organization_id = $1 + AND subscription.enabled = true + AND subscription.target_verified_at IS NOT NULL + AND task.status = ANY($2::text[]) + AND ( + (task.status = ANY($3::text[]) AND NOT COALESCE(delivery_state.has_needs_review, false)) + OR + (NOT (task.status = ANY($3::text[])) AND ( + (COALESCE(delivery_state.has_exposed_needs_review, false) AND NOT COALESCE(delivery_state.has_resolved, false)) + OR + (NOT COALESCE(delivery_state.has_exposed_needs_review, false) AND NOT COALESCE(delivery_state.has_final, false)) + )) + ) + ORDER BY source_event.id ASC, subscription.id, task.id + LIMIT $4`, + [organizationId, [...PROJECTABLE_STATUSES], [...NEEDS_REVIEW_STATUSES], boundedLimit] + ); + + let projected = 0; + for (const row of candidates.rows as Record[]) { + if (!isLeaderTaskSummaryStatus(row.status)) continue; + const currentNeedsReview = (NEEDS_REVIEW_STATUSES as readonly string[]).includes(text(row.status)); + let hadExposedNeedsReview = booleanValue(row.has_exposed_needs_review); + if (!currentNeedsReview) { + await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'cancelled', + last_error = '任务已形成确定结果,未发送的旧核验提醒已取消。', + updated_at = now() + WHERE subscription_id = $1 + AND subscription_revision = $2 + AND task_id = $3 + AND milestone = 'needs_review' + AND delivery_status IN ('pending', 'failed')`, + [row.subscription_id, row.subscription_revision, row.task_row_id] + ); + const exposure = await client.query( + `SELECT EXISTS ( + SELECT 1 + FROM leader_task_summary_deliveries + WHERE subscription_id = $1 + AND subscription_revision = $2 + AND task_id = $3 + AND milestone = 'needs_review' + AND delivery_status IN ('sending', 'delivered') + ) AS exposed`, + [row.subscription_id, row.subscription_revision, row.task_row_id] + ); + hadExposedNeedsReview = booleanValue(exposure.rows[0]?.exposed); + } + const projection = buildLeaderTaskSummary({ + taskId: text(row.task_id), + status: text(row.status), + businessRouteId: text(row.business_route_id) || null, + assigneeUsername: text(row.assignee_username), + createdAt: String(row.created_at), + successReceipt: jsonObject(row.success_receipt), + hadNeedsReview: hadExposedNeedsReview + }); + if (!projection) continue; + const payload = { + event: 'task.summary' as const, + status: projection.deliveryStatus, + task_id: text(row.task_id), + text: projection.messageText + }; + const payloadText = JSON.stringify(payload); + const inserted = await client.query( + `INSERT INTO leader_task_summary_deliveries + (organization_id, subscription_id, subscription_revision, task_id, + leader_user_id, channel_id, source_outbox_event_id, milestone, + recipient_address_ciphertext, recipient_address_fingerprint, + conversation_id_ciphertext, conversation_id_fingerprint, + payload_ciphertext, payload_fingerprint) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, + $9, $10, $11, $12, $13, $14) + ON CONFLICT (subscription_id, subscription_revision, task_id, milestone) DO NOTHING + RETURNING id`, + [ + organizationId, + row.subscription_id, + row.subscription_revision, + row.task_row_id, + row.leader_user_id, + row.channel_id, + row.source_outbox_event_id, + projection.milestone, + row.recipient_address_ciphertext, + row.recipient_address_fingerprint, + row.conversation_id_ciphertext, + row.conversation_id_fingerprint, + encryptText(this.config, payloadText), + sha256Text(payloadText) + ] + ); + projected += Number(inserted.rowCount || 0); + } + return projected; + }); + } + + async claimDeliveries( + channelId: string, + leaseOwner: string, + limit = 5 + ): Promise { + const boundedLimit = Math.max(1, Math.min(10, Math.trunc(limit))); + return withTransaction(this.config, async (client) => { + await client.query( + `UPDATE leader_task_summary_deliveries delivery + SET delivery_status = 'cancelled', + last_error = '订阅或目标已失效,摘要未改投其他渠道。', + updated_at = now() + WHERE delivery.channel_id = $1 + AND delivery.delivery_status IN ('pending', 'sending', 'failed') + AND NOT EXISTS ( + SELECT 1 + FROM leader_task_summary_subscriptions subscription + JOIN users leader + ON leader.id = subscription.leader_user_id + AND leader.organization_id = subscription.organization_id + AND leader.role = 'team_lead' + AND leader.is_active = true + JOIN user_channels channel + ON channel.id = subscription.channel_id + AND channel.organization_id = subscription.organization_id + AND channel.owner_user_id = subscription.leader_user_id + AND channel.enabled = true + WHERE subscription.id = delivery.subscription_id + AND subscription.enabled = true + AND subscription.target_verified_at IS NOT NULL + AND subscription.revision = delivery.subscription_revision + AND subscription.channel_id = delivery.channel_id + )`, + [channelId] + ); + await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'pending', + last_error = COALESCE(last_error, 'delivery lease expired'), + updated_at = now() + WHERE channel_id = $1 + AND delivery_status = 'sending' + AND updated_at < now() - interval '1 minute'`, + [channelId] + ); + const pending = await client.query( + `SELECT delivery.*, + task.task_id AS public_task_id + FROM leader_task_summary_deliveries delivery + JOIN leader_task_summary_subscriptions subscription + ON subscription.id = delivery.subscription_id + AND subscription.enabled = true + AND subscription.target_verified_at IS NOT NULL + AND subscription.revision = delivery.subscription_revision + AND subscription.channel_id = delivery.channel_id + JOIN users leader + ON leader.id = subscription.leader_user_id + AND leader.organization_id = subscription.organization_id + AND leader.role = 'team_lead' + AND leader.is_active = true + JOIN user_channels channel + ON channel.id = subscription.channel_id + AND channel.organization_id = subscription.organization_id + AND channel.owner_user_id = subscription.leader_user_id + AND channel.enabled = true + JOIN tasks task + ON task.id = delivery.task_id + AND task.organization_id = delivery.organization_id + WHERE delivery.channel_id = $1 + AND delivery.delivery_status IN ('pending', 'failed') + AND delivery.next_attempt_at <= now() + ORDER BY delivery.created_at ASC, delivery.id ASC + FOR UPDATE OF delivery SKIP LOCKED + LIMIT $2`, + [channelId, boundedLimit] + ); + const deliveries: LeaderTaskSummaryDelivery[] = []; + for (const row of pending.rows as Record[]) { + try { + const payloadText = decryptText(this.config, text(row.payload_ciphertext)); + const payload = JSON.parse(payloadText) as LeaderTaskSummaryDelivery['payload']; + const recipientAddress = decryptText(this.config, text(row.recipient_address_ciphertext)).trim(); + const conversationId = decryptText(this.config, text(row.conversation_id_ciphertext)).trim(); + if ( + payload.event !== 'task.summary' + || !['completed', 'failed', 'cancelled', 'needs_review'].includes(text(payload.status)) + || text(payload.task_id) !== text(row.public_task_id) + || !text(payload.text) + || !recipientAddress + || !conversationId + || sha256Text(payloadText) !== text(row.payload_fingerprint) + || sha256Text(recipientAddress) !== text(row.recipient_address_fingerprint) + || sha256Text(conversationId) !== text(row.conversation_id_fingerprint) + ) { + throw new Error('invalid leader summary delivery payload'); + } + const updated = await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'sending', + attempt_count = attempt_count + 1, + updated_at = now(), + last_error = $2 + WHERE id = $1 + RETURNING attempt_count`, + [row.id, `sending:${leaseOwner}`] + ); + deliveries.push({ + id: text(row.id), + channel_id: text(row.channel_id), + task_id: text(row.public_task_id), + recipient_address: recipientAddress, + recipient_fingerprint: text(row.recipient_address_fingerprint).slice(0, 12), + conversation_id: conversationId, + conversation_fingerprint: text(row.conversation_id_fingerprint).slice(0, 12), + payload, + attempt_count: Number(updated.rows[0]?.attempt_count || 1) + }); + } catch (error) { + await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'cancelled', + last_error = '摘要投递密文或结构无效,已停止重试。', + updated_at = now() + WHERE id = $1`, + [row.id] + ); + this.log('error', { + agentbus_event: 'leader_summary_delivery_invalid', + delivery_id: text(row.id), + channel_id: text(row.channel_id), + task_id: text(row.public_task_id), + ...diagnosticError(error, 'leader_summary_delivery_invalid') + }, 'Leader task summary delivery is invalid'); + } + } + return deliveries; + }); + } + + async markDeliveryDelivered(deliveryId: string): Promise { + await getPool(this.config).query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'delivered', + delivered_at = now(), + updated_at = now(), + last_error = NULL + WHERE id = $1 AND delivery_status = 'sending'`, + [deliveryId] + ); + } + + async markDeliveryFailed(deliveryId: string, errorMessage: string): Promise { + const normalized = text(errorMessage).slice(0, 500) || 'leader_summary_delivery_failed'; + await getPool(this.config).query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'failed', + next_attempt_at = now() + + LEAST(300, GREATEST(5, power(2, LEAST(attempt_count, 8)))) * interval '1 second', + last_error = $2, + updated_at = now() + WHERE id = $1 AND delivery_status = 'sending'`, + [deliveryId, normalized] + ); + } + + async releaseDeliveries(channelId: string, leaseOwner: string): Promise { + await getPool(this.config).query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'pending', + next_attempt_at = now(), + last_error = 'AgentBus 连接已断开,等待重发。', + updated_at = now() + WHERE channel_id = $1 + AND delivery_status = 'sending' + AND last_error = $2`, + [channelId, `sending:${leaseOwner}`] + ); + } +} diff --git a/control-plane/src/leadership-task-summary.ts b/control-plane/src/leadership-task-summary.ts new file mode 100644 index 0000000..4c45cf6 --- /dev/null +++ b/control-plane/src/leadership-task-summary.ts @@ -0,0 +1,160 @@ +import { businessRouteById } from './business-routes.js'; + +export type LeaderTaskSummaryMilestone = 'final' | 'needs_review' | 'resolved'; +export type LeaderTaskSummaryStatus = 'completed' | 'failed' | 'cancelled' | 'needs_review'; + +export interface LeaderTaskSummaryInput { + taskId: string; + status: string; + businessRouteId: string | null; + assigneeUsername: string; + createdAt: string | Date; + successReceipt?: Record | null; + hadNeedsReview?: boolean; +} + +export interface LeaderTaskSummaryProjection { + milestone: LeaderTaskSummaryMilestone; + deliveryStatus: LeaderTaskSummaryStatus; + statusLabel: '已完成' | '未完成' | '已取消' | '需人工核验'; + businessLabel: string; + resultText: string; + messageText: string; +} + +const COMPLETED_STATUSES = new Set(['completed', 'dry_run']); +const FAILED_STATUSES = new Set([ + 'failed', + 'parse_failed', + 'parse_blocked', + 'agent_parse_blocked', + 'blocked', + 'operation_blocked' +]); +const NEEDS_REVIEW_STATUSES = new Set([ + 'reconciliation_pending', + 'saved_unverified', + 'execution_uncertain', + 'uncertain' +]); + +function singleLine(value: unknown, maxLength: number): string { + return String(value ?? '') + .normalize('NFKC') + .replace(/[\u0000-\u001f\u007f]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, maxLength); +} + +function identifierValues(...values: unknown[]): string[] { + const output: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (typeof value !== 'string' && typeof value !== 'number') return; + const candidate = singleLine(value, 80); + if (!candidate || !/^(?=.*\d)[A-Za-z0-9._/()-]{4,80}$/u.test(candidate)) return; + if (/^1[3-9]\d{9}$/u.test(candidate) || /^\d{15,18}[xX]?$/u.test(candidate)) return; + if (!output.includes(candidate)) output.push(candidate); + }; + values.forEach(visit); + return output.slice(0, 10); +} + +function completedResult(status: string, receipt: Record): string { + if (status === 'dry_run') return '业务方案已生成,尚未正式提交。'; + const groupNumbers = identifierValues(receipt.group_numbers, receipt.group_number); + const orderNumbers = identifierValues(receipt.order_numbers, receipt.order_number); + const details = [ + groupNumbers.length ? `团号:${groupNumbers.join('、')}` : '', + orderNumbers.length ? `订单号:${orderNumbers.join('、')}` : '' + ].filter(Boolean); + return details.length + ? `业务处理已完成。${details.join(';')}`.slice(0, 300) + : '业务处理已完成,结果已记录。'; +} + +export function formatShanghaiTimestamp(value: string | Date): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '时间未记录'; + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => ( + parts.find((item) => item.type === type)?.value || '' + ); + return `${part('year')}-${part('month')}-${part('day')} ${part('hour')}:${part('minute')}:${part('second')}`; +} + +export function buildLeaderTaskSummary( + input: LeaderTaskSummaryInput +): LeaderTaskSummaryProjection | null { + const status = singleLine(input.status, 80).toLowerCase(); + let deliveryStatus: LeaderTaskSummaryStatus; + let statusLabel: LeaderTaskSummaryProjection['statusLabel']; + let resultText: string; + if (COMPLETED_STATUSES.has(status)) { + deliveryStatus = 'completed'; + statusLabel = '已完成'; + resultText = completedResult(status, input.successReceipt || {}); + } else if (FAILED_STATUSES.has(status)) { + deliveryStatus = 'failed'; + statusLabel = '未完成'; + resultText = '本次工作未完成,请在平台查看业务结果。'; + } else if (status === 'cancelled') { + deliveryStatus = 'cancelled'; + statusLabel = '已取消'; + resultText = '这项工作已取消。'; + } else if (NEEDS_REVIEW_STATUSES.has(status)) { + deliveryStatus = 'needs_review'; + statusLabel = '需人工核验'; + resultText = '当前结果需要人工核验,请勿重复提交。'; + } else { + return null; + } + + const milestone: LeaderTaskSummaryMilestone = deliveryStatus === 'needs_review' + ? 'needs_review' + : input.hadNeedsReview + ? 'resolved' + : 'final'; + const route = businessRouteById(input.businessRouteId); + const businessLabel = singleLine(route?.directive || '其他任务', 120) || '其他任务'; + const assignee = singleLine(input.assigneeUsername, 160) || '未知员工'; + const taskId = singleLine(input.taskId, 200) || '任务编号未记录'; + const header = milestone === 'resolved' ? '【员工任务摘要·结果更新】' : '【员工任务摘要】'; + return { + milestone, + deliveryStatus, + statusLabel, + businessLabel, + resultText: resultText.slice(0, 300), + messageText: [ + header, + `员工:${assignee}`, + `业务:${businessLabel}`, + `状态:${statusLabel}`, + `任务:${taskId}`, + `提交:${formatShanghaiTimestamp(input.createdAt)}`, + `结果:${resultText.slice(0, 300)}` + ].join('\n') + }; +} + +export function isLeaderTaskSummaryStatus(value: unknown): boolean { + const status = singleLine(value, 80).toLowerCase(); + return COMPLETED_STATUSES.has(status) + || FAILED_STATUSES.has(status) + || NEEDS_REVIEW_STATUSES.has(status) + || status === 'cancelled'; +} diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 82b7831..b1e454b 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -18,6 +18,7 @@ import { AgentBusChannelService, mergeRuntimeChannelStatuses } from './agentbus-channels.js'; +import { LeaderNotificationService } from './leader-notification-service.js'; import { TaskError, TaskService, @@ -156,6 +157,16 @@ const channelUpdateSchema = z.object({ enabled: z.boolean().optional() }); const channelRotateKeySchema = z.object({ agentbus_key: z.string().min(1).max(4_000) }); +const leaderSummarySubscriptionSchema = z.object({ + channel_id: z.string().uuid(), + recipient_address: z.string().trim().min(1).max(500).optional(), + conversation_id: z.string().trim().min(1).max(500).optional(), + include_manual: z.boolean(), + include_agentbus: z.boolean(), + enabled: z.boolean(), + target_verified: z.boolean().optional(), + expected_revision: z.number().int().min(0).optional() +}); const listTasksQuerySchema = z.object({ status: z.string().max(80).optional(), search: z.string().max(200).optional(), @@ -524,6 +535,11 @@ export async function buildServer({ warn: (metadata, message) => app.log.warn(agentBusDiagnosticMetadata(metadata), message), error: (metadata, message) => app.log.error(agentBusDiagnosticMetadata(metadata), message) }); + const leaderNotificationService = new LeaderNotificationService(config, { + info: (metadata, message) => app.log.info(agentBusDiagnosticMetadata(metadata), message), + warn: (metadata, message) => app.log.warn(agentBusDiagnosticMetadata(metadata), message), + error: (metadata, message) => app.log.error(agentBusDiagnosticMetadata(metadata), message) + }); let agentBus: AgentBusManager | null = null; const getSession = async (request: FastifyRequest): Promise => { @@ -805,6 +821,7 @@ export async function buildServer({ tasks, organizationId: organization.id, scheduleParseQueue, + leaderNotifications: leaderNotificationService, logger: { info: (metadata, message) => app.log.info(agentBusDiagnosticMetadata(metadata), message), warn: (metadata, message) => app.log.warn(agentBusDiagnosticMetadata(metadata), message), @@ -812,7 +829,7 @@ export async function buildServer({ } }); await agentBus.start(); - app.addHook('onClose', async () => agentBus?.stop()); + leaderNotificationService.startProjector(organization.id); } async function getAiProbe(): Promise { @@ -1166,6 +1183,35 @@ export async function buildServer({ return { ok: true, ...result }; }); + app.get('/api/settings/leader-summary-subscriptions', async (request) => { + const session = await requireAdminSession(request); + return { + ok: true, + subscriptions: await leaderNotificationService.listSubscriptions(session.user.organizationId) + }; + }); + + app.put('/api/settings/leader-summary-subscriptions/:leaderUserId', async (request) => { + const session = await requireAdminMutationSession(request); + const params = request.params as { leaderUserId: string }; + const body = leaderSummarySubscriptionSchema.parse(request.body); + const subscription = await leaderNotificationService.upsertSubscription( + contextFor(session, request), + { + leaderUserId: params.leaderUserId, + channelId: body.channel_id, + recipientAddress: body.recipient_address, + conversationId: body.conversation_id, + includeManual: body.include_manual, + includeAgentBus: body.include_agentbus, + enabled: body.enabled, + targetVerified: body.target_verified, + expectedRevision: body.expected_revision + } + ); + return { ok: true, subscription }; + }); + app.post('/api/auth/logout', async (request, reply) => { setAuthNoStore(reply); const session = await requireAuthenticatedMutationSession(request); @@ -1539,9 +1585,11 @@ export async function buildServer({ diagnostic_event: 'service.closing', diagnostic_stage: 'shutdown' }, 'control plane closing'); + await leaderNotificationService.stopProjector(); + await agentBus?.stop(); await closePool(); }); - return { app, auth, tasks, agentBus, channelService }; + return { app, auth, tasks, agentBus, channelService, leaderNotificationService }; } function installProcessDiagnostics(app: Awaited>['app']): void { diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 1b32451..81dbde1 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -453,6 +453,6 @@ test('account authorization editor uses a scroll-safe open layout without overri assert.match(openLayoutSource, /overflow:\s*visible/); assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/); - assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/); - assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/); + assert.match(index, /styles\.css\?v=20260907-leader-summary-1/); + assert.match(index, /app\.js\?v=20260907-leader-summary-1/); }); diff --git a/control-plane/test/agentbus.test.ts b/control-plane/test/agentbus.test.ts index 67d6578..8fa335b 100644 --- a/control-plane/test/agentbus.test.ts +++ b/control-plane/test/agentbus.test.ts @@ -6,6 +6,8 @@ import { AgentBusListener, type AgentBusSocket, type AgentBusTaskGateway, + type LeaderNotificationGateway, + createLeaderTaskSummaryFrame, createTaskResultFrame, createTaskProgressFrame, extractAgentBusBusinessText, @@ -31,6 +33,7 @@ import { } from '../src/agentbus-delivery.js'; import { resolveBusinessRoute } from '../src/business-routes.js'; import type { AgentBusDelivery, PublicTask } from '../src/task-service.js'; +import type { LeaderTaskSummaryDelivery } from '../src/leader-notification-service.js'; function makeTask(status: string, overrides: Partial = {}): PublicTask { return { @@ -410,6 +413,142 @@ test('AgentBus protocol helpers preserve reply routing fields', () => { assert.equal(fallbackProgress.conversation_id, 'agentbus:channel:wechat:user-2'); }); +test('leader summary frame uses explicit proactive routing and cannot become an inbound task', () => { + const delivery: LeaderTaskSummaryDelivery = { + id: '11111111-1111-4111-8111-111111111111', + channel_id: '22222222-2222-4222-8222-222222222222', + task_id: 'TASK-20260907-001', + recipient_address: 'channel:wechat:leader-a', + recipient_fingerprint: 'abc123def456', + conversation_id: 'wechat-conversation-a', + conversation_fingerprint: 'def456abc123', + payload: { + event: 'task.summary', + status: 'completed', + task_id: 'TASK-20260907-001', + text: '【员工任务摘要】\n员工:employee-a' + }, + attempt_count: 1 + }; + const frame = createLeaderTaskSummaryFrame(delivery, { + id: 'session-leader-1', + epoch: 9, + address: 'bot:leader-a:listener' + }); + assert.equal(frame.id, `leader-summary-${delivery.id}`); + assert.equal(frame.from, 'bot:leader-a:listener'); + assert.equal(frame.to, delivery.recipient_address); + assert.equal(frame.conversation_id, delivery.conversation_id); + assert.equal(Object.hasOwn(frame, 'reply_to'), false); + assert.deepEqual(frame.payload, delivery.payload); + assert.equal(isInboundAgentBusTask(frame), false); +}); + +test('listener sends employee outbox first, then a redacted-log leader summary batch', async (t) => { + const socket = new FakeSocket(); + const order: string[] = []; + const delivered: string[] = []; + const released: string[] = []; + const logs: Array> = []; + const delivery: LeaderTaskSummaryDelivery = { + id: '33333333-3333-4333-8333-333333333333', + channel_id: 'channel-leader-a', + task_id: 'TASK-PRIVATE-1', + recipient_address: 'channel:wechat:private-leader-address', + recipient_fingerprint: 'a1b2c3d4e5f6', + conversation_id: 'private-wechat-conversation', + conversation_fingerprint: 'f6e5d4c3b2a1', + payload: { + event: 'task.summary', + status: 'completed', + task_id: 'TASK-PRIVATE-1', + text: '【员工任务摘要】\n员工:private-employee' + }, + attempt_count: 1 + }; + let claimed = false; + const tasks: AgentBusTaskGateway = { + events: new EventEmitter(), + async ingestMessage() { + return { task: makeTask('failed'), attached: false, created: true }; + }, + async getTask() { + return makeTask('failed'); + }, + async listAgentBusFinalizationCandidates() { + return []; + }, + async enqueueAgentBusResult() {}, + async claimAgentBusDeliveries() { + order.push('employee'); + return []; + }, + async markAgentBusDeliveryDelivered() {}, + async markAgentBusDeliveryFailed() {}, + async releaseAgentBusDeliveries() {} + }; + const leaderNotifications: LeaderNotificationGateway = { + async claimDeliveries() { + order.push('leader'); + if (claimed) return []; + claimed = true; + return [delivery]; + }, + async markDeliveryDelivered(deliveryId) { + delivered.push(deliveryId); + }, + async markDeliveryFailed() {}, + async releaseDeliveries(channelId) { + released.push(channelId); + } + }; + const listener = new AgentBusListener({ + config: testConfig(), + tasks, + leaderNotifications, + organizationId: 'org-1', + scheduleParseQueue: async () => {}, + socketFactory: () => socket as unknown as AgentBusSocket, + channel: { + id: 'channel-leader-a', + displayName: '组长 A', + wsUrl: 'wss://mesh.nianxx.cn/ws', + wsToken: 'leader-channel-token', + botAddress: 'bot:leader-a:listener', + ownerUserId: 'leader-a', + ownerRole: 'team_lead' + }, + logger: { + info(metadata) { logs.push(metadata); }, + warn(metadata) { logs.push(metadata); }, + error(metadata) { logs.push(metadata); } + } + }); + t.after(() => listener.stop()); + listener.start(); + socket.readyState = 1; + socket.emit('open'); + socket.emit('message', JSON.stringify({ + id: 'ready-leader-summary', + type: 'event', + session_id: 'session-leader-summary', + epoch: 1, + to: 'bot:leader-a:listener', + payload: { event: 'session.ready' } + })); + + await waitFor(() => socket.sent.length === 1 && delivered.length === 1); + assert.deepEqual(order.slice(0, 2), ['employee', 'leader']); + assert.equal(delivered[0], delivery.id); + assert.equal(socket.sent[0].reply_to, undefined); + assert.equal((socket.sent[0].payload as Record).event, 'task.summary'); + const logText = JSON.stringify(logs); + assert.doesNotMatch(logText, /private-leader-address|private-wechat-conversation|private-employee/); + assert.match(logText, /a1b2c3d4e5f6/); + listener.stop(); + await waitFor(() => released.includes('channel-leader-a')); +}); + test('AgentBus result text uses the unified important message and preserves the confirmation gate', () => { const needsInput = makeTask('awaiting_user_input', { input_request: { diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index 9520376..c6f0be5 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -317,7 +317,7 @@ test('control plane requires the latest durable task-outcome migration before re const { readFile } = await import('node:fs/promises'); const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8'); const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8'); - assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers'); + assert.equal(REQUIRED_SCHEMA_VERSION, '020_leader_task_summary_notifications'); assert.match(db, /schema_migrations/); assert.match(db, /databaseReadiness/); assert.match(db, /assertDatabaseSchema/); @@ -1231,8 +1231,8 @@ test('operator page has a login gate and uses the durable task API', async () => const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); assert.match(index, /id="loginPanel"/); assert.match(index, /id="workbench"[^>]*hidden/); - assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/); - assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/); + assert.match(index, /styles\.css\?v=20260907-leader-summary-1/); + assert.match(index, /app\.js\?v=20260907-leader-summary-1/); assert.match(index, /id="statusDetailsPopover"/); assert.match(index, /id="statusDetailsRefresh"/); assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/); diff --git a/control-plane/test/leader-notification-contract.test.ts b/control-plane/test/leader-notification-contract.test.ts new file mode 100644 index 0000000..5a6fb88 --- /dev/null +++ b/control-plane/test/leader-notification-contract.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +async function source(relativePath: string): Promise { + return readFile(new URL(relativePath, import.meta.url), 'utf8'); +} + +test('migration creates a default-off encrypted outbox separate from employee replies', async () => { + const sql = await source('../migrations/020_leader_task_summary_notifications.sql'); + assert.match(sql, /CREATE TABLE IF NOT EXISTS leader_task_summary_subscriptions/); + assert.match(sql, /enabled boolean NOT NULL DEFAULT false/); + assert.match(sql, /scope text NOT NULL DEFAULT 'organization'/); + assert.match(sql, /CHECK \(include_manual OR include_agentbus\)/); + assert.match(sql, /recipient_address_ciphertext text NOT NULL/); + assert.match(sql, /conversation_id_ciphertext text NOT NULL/); + assert.match(sql, /payload_ciphertext text NOT NULL/); + assert.match(sql, /target_verified_at timestamptz/); + assert.match(sql, /UNIQUE \(organization_id, leader_user_id\)/); + assert.match(sql, /UNIQUE \(subscription_id, subscription_revision, task_id, milestone\)/); + assert.doesNotMatch(sql, /INSERT\s+INTO/iu, 'schema migration must not backfill or send historical tasks'); + assert.doesNotMatch(sql, /REFERENCES\s+agentbus_deliveries/iu); + assert.doesNotMatch(sql, /recipient_address\s+text/iu); + assert.doesNotMatch(sql, /conversation_id\s+text/iu); + assert.doesNotMatch(sql, /payload\s+jsonb/iu); +}); + +test('projection is organization-scoped, future-only, role-safe and source-selective', async () => { + const service = await source('../src/leader-notification-service.ts'); + assert.match(service, /event\.topic = 'task\.updated'/); + assert.match(service, /event\.created_at >= subscription\.starts_at/); + assert.match(service, /task\.assigned_user_id <> subscription\.leader_user_id/); + assert.match(service, /assignee\.role <> 'admin'/); + assert.match(service, /task\.source IN \('manual', 'agentbus'\)/); + assert.match(service, /subscription\.include_manual/); + assert.match(service, /subscription\.include_agentbus/); + assert.match(service, /event\.payload ->> 'archived'.*IS DISTINCT FROM 'true'/s); + assert.match(service, /event\.payload ->> 'restored'.*IS DISTINCT FROM 'true'/s); + assert.match(service, /subscription\.target_verified_at IS NOT NULL/); + assert.match(service, /pg_try_advisory_xact_lock/); + assert.match(service, /encryptText\(this\.config, payloadText\)/); + assert.match(service, /FOR UPDATE OF delivery SKIP LOCKED/); + assert.match(service, /sha256Text\(payloadText\).*payload_fingerprint/s); + assert.match(service, /sha256Text\(recipientAddress\).*recipient_address_fingerprint/s); + assert.match(service, /sha256Text\(conversationId\).*conversation_id_fingerprint/s); + assert.match(service, /delivery_status = 'cancelled'.*订阅设置已变化/s); + assert.match(service, /leader_summary_agentbus_disabled/); + assert.doesNotMatch(service, /reply_to/); +}); + +test('HTTP configuration is administrator-only and has no live test-send endpoint', async () => { + const server = await source('../src/server.ts'); + const routeStart = server.indexOf("app.get('/api/settings/leader-summary-subscriptions'"); + const routeEnd = server.indexOf("app.post('/api/auth/logout'", routeStart); + const routes = server.slice(routeStart, routeEnd); + assert.ok(routeStart > 0 && routeEnd > routeStart); + assert.match(routes, /requireAdminSession/); + assert.match(routes, /requireAdminMutationSession/); + assert.match(routes, /expectedRevision: body\.expected_revision/); + assert.doesNotMatch(server, /leader-summary-subscriptions.*test-send|leader-summary-subscriptions.*test\/send/s); +}); + +test('AgentBus sends summaries as reserved low-priority proactive events without plaintext frame logging', async () => { + const agentbus = await source('../src/agentbus.ts'); + const ignoreStart = agentbus.indexOf('function inboundFrameIgnoreReason'); + const ignoreEnd = agentbus.indexOf('export function parseAgentBusFrame', ignoreStart); + assert.match(agentbus.slice(ignoreStart, ignoreEnd), /'task\.summary'/); + assert.match(agentbus.slice(ignoreStart, ignoreEnd), /reserved_event/); + assert.match(agentbus, /id: `leader-summary-\$\{delivery\.id\}`/); + assert.match(agentbus, /to: delivery\.recipient_address/); + assert.match(agentbus, /conversation_id: delivery\.conversation_id/); + const frameStart = agentbus.indexOf('export function createLeaderTaskSummaryFrame'); + const frameEnd = agentbus.indexOf('export function taskResultStatus', frameStart); + assert.doesNotMatch(agentbus.slice(frameStart, frameEnd), /reply_to/); + const flushStart = agentbus.indexOf('private async flushOutboundDeliveries'); + const sendEnd = agentbus.indexOf('private async sendDurableDelivery', flushStart); + const leaderDelivery = agentbus.slice(flushStart, sendEnd); + assert.match(leaderDelivery, /await this\.flushDurableDeliveries\(\)/); + assert.match(leaderDelivery, /await this\.flushLeaderDeliveries\(\)/); + assert.match(leaderDelivery, /recipient_fingerprint/); + assert.match(leaderDelivery, /conversation_fingerprint/); + assert.doesNotMatch(leaderDelivery, /this\.logFrame/); +}); + +test('administrator UI explains safety boundaries and never asks the API to backfill', async () => { + const html = await source('../../LianSyn-platform/index.html'); + const app = await source('../../LianSyn-platform/app.js'); + assert.match(html, /组长任务摘要抄送/); + assert.match(html, /默认关闭/); + assert.match(html, /只处理保存设置后的新结果,不补发历史任务/); + assert.match(html, /已经到达微信的消息无法撤回/); + assert.match(app, /目标已核对/); + assert.match(app, /expected_revision/); + assert.match(app, /消息正文不含原始指令、客户\/游客资料或技术错误/); + assert.doesNotMatch(app, /leader-summary-subscriptions[^'"\n]*backfill/); +}); diff --git a/control-plane/test/leadership-task-summary.test.ts b/control-plane/test/leadership-task-summary.test.ts new file mode 100644 index 0000000..a6f3ee5 --- /dev/null +++ b/control-plane/test/leadership-task-summary.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildLeaderTaskSummary, + formatShanghaiTimestamp, + isLeaderTaskSummaryStatus +} from '../src/leadership-task-summary.js'; + +const base = { + taskId: 'TASK-20260907-001', + businessRouteId: 'arrangement_hotel_create', + assigneeUsername: 'employee-a', + createdAt: '2026-09-07T00:05:06.000Z' +}; + +test('leader summary projects only stable outcomes', () => { + assert.equal(buildLeaderTaskSummary({ ...base, status: 'running' }), null); + assert.equal(buildLeaderTaskSummary({ ...base, status: 'awaiting_confirmation' }), null); + assert.equal(isLeaderTaskSummaryStatus('completed'), true); + assert.equal(isLeaderTaskSummaryStatus('execution_uncertain'), true); + assert.equal(isLeaderTaskSummaryStatus('running'), false); +}); + +test('completed leader summary exposes only whitelisted business identifiers', () => { + const result = buildLeaderTaskSummary({ + ...base, + status: 'completed', + successReceipt: { + group_numbers: ['LW-260907A-B', '张三', '13800138000'], + order_number: 'D12345', + customer_name: '绝密客户', + passenger_names: ['游客甲', '游客乙'], + phone: '13900139000', + url: 'https://secret.example/token', + technical_error: 'stack trace' + } + }); + assert.ok(result); + assert.equal(result.milestone, 'final'); + assert.equal(result.deliveryStatus, 'completed'); + assert.match(result.messageText, /employee-a/); + assert.match(result.messageText, /安排酒店/); + assert.match(result.messageText, /LW-260907A-B/); + assert.match(result.messageText, /D12345/); + assert.match(result.messageText, /2026-09-07 08:05:06/); + assert.doesNotMatch(result.messageText, /绝密客户|游客甲|13800138000|13900139000|secret\.example|stack trace|张三/); +}); + +test('failure, uncertainty, cancellation and dry-run use stable safe wording', () => { + const failed = buildLeaderTaskSummary({ ...base, status: 'operation_blocked' }); + const uncertain = buildLeaderTaskSummary({ ...base, status: 'saved_unverified' }); + const cancelled = buildLeaderTaskSummary({ ...base, status: 'cancelled' }); + const dryRun = buildLeaderTaskSummary({ ...base, status: 'dry_run' }); + assert.equal(failed?.resultText, '本次工作未完成,请在平台查看业务结果。'); + assert.equal(uncertain?.milestone, 'needs_review'); + assert.match(uncertain?.messageText || '', /需人工核验|请勿重复提交/); + assert.equal(cancelled?.deliveryStatus, 'cancelled'); + assert.match(dryRun?.resultText || '', /尚未正式提交/); +}); + +test('a stable result after needs-review produces one result-update milestone', () => { + const result = buildLeaderTaskSummary({ + ...base, + status: 'completed', + hadNeedsReview: true, + successReceipt: { group_number: 'LW-260907A-B' } + }); + assert.equal(result?.milestone, 'resolved'); + assert.match(result?.messageText || '', /员工任务摘要·结果更新/); +}); + +test('Shanghai timestamp formatting is deterministic and invalid-safe', () => { + assert.equal(formatShanghaiTimestamp(base.createdAt), '2026-09-07 08:05:06'); + assert.equal(formatShanghaiTimestamp('not-a-date'), '时间未记录'); +});