diff --git a/.project-docs/30-worklog/tasks/20260907-auto-leader-summary-routing-5e8c1a73.md b/.project-docs/30-worklog/tasks/20260907-auto-leader-summary-routing-5e8c1a73.md new file mode 100644 index 0000000..bc5792b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-auto-leader-summary-routing-5e8c1a73.md @@ -0,0 +1,56 @@ +# Task: Auto-enable leader summaries from AgentBus routing + +## Identity + +- Task ID: 20260907-auto-leader-summary-routing-5e8c1a73 +- Mode: Feature +- Branch: codex/20260907-auto-leader-summary-routing-5e8c1a73-auto-leader-summary-routing +- Worktree: /Users/inmanx/Documents/lwltAPI-auto-leader-summary-routing-5e8c1a73 +- Base commit: 8fb066f3453dde079b3b58f67c95cd57de60923d +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace administrator-managed leader summary subscriptions with role-driven automatic activation for active `team_lead` accounts. +- Resolve the proactive AgentBus recipient and conversation from the leader-owned channel, preferring the latest valid inbound route and falling back to the channel external user reference. +- Keep encrypted route snapshots, durable low-priority delivery, future-only projection, source coverage, privacy filtering, and role/channel fail-closed behavior. +- Reduce the channels page to read-only automatic-delivery status and remove the manual mutation API and form controls. +- Update active component documentation and contract tests. No database migration or production send is in scope. + +## Intent And Constraints + +- User explicitly superseded the accepted default-off/manual-target product behavior: an active team lead with an enabled owned AgentBus channel should receive summaries automatically. +- Include both manual and AgentBus tasks assigned to other non-administrator employees in the same organization; never backfill history. +- A channel disable, ownership change, inactive account, or role change must stop delivery and cancel unsent rows rather than reroute them. +- Never expose or log plaintext routing targets, task instructions, traveler/customer data, attachments, or technical errors in leader summaries. +- Preserve the existing schema version 020 tables so the fix can deploy without another database change. + +## Outcome + +- Active team-lead accounts now receive an automatic organization-wide summary subscription whenever their owned AgentBus channel is enabled and has a safe route. +- Route resolution prefers the latest accepted inbound route whose task is assigned to the current channel owner, falls back to the channel external user reference, and learns exact future routes after successful AgentBus ingestion. +- Route/policy changes reset the future-only boundary and revision; invalid roles, accounts, ownership, or disabled channels cancel unsent rows and fail closed. +- Channel owner changes clear the previous external user reference, preventing a new owner from inheriting the former owner's proactive route. +- The manual subscription mutation API and all recipient/conversation/source/verification/enable controls were removed. `/channels` now displays automatic status only. +- Existing migration 020 remains sufficient; no database migration or production data change was introduced. + +## Verification + +- `node --run check:repo` — passed (10 tests). +- `node --run check` — passed. +- `node --run test:control-plane` — passed (176 tests). +- `node --run test:legacy` — passed (270 tests). +- `node --run build` — passed. +- `node --check LianSyn-platform/app.js` — passed. +- Targeted AgentBus and leader-summary contract suite — passed (28 tests). +- Disposable PostgreSQL 16.14 validation applied migrations 001–020, confirmed first reconciliation creates one enabled encrypted automatic row, the second reconciliation is idempotent, an employee terminal result projects and claims on the leader's exact inbound route, and a leader-to-user role change disables the row and cancels its pending delivery. The temporary database was stopped and moved to Trash. +- No real AgentBus/WeChat message, production database mutation, deployment, or restart was performed. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- Revise accepted decision `AUTH-003-leader-task-summary-notifications.md` during Integration: activation and routing are automatic consequences of an active `team_lead` role plus its owned AgentBus channel, rather than an administrator-managed default-off subscription. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index f145664..9046554 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -45,8 +45,6 @@ let automationSettingsSyncInFlight = null; let channelList = []; let channelSettingsBusy = false; let leaderSummarySubscriptions = []; -let leaderSummarySettingsBusy = false; -let leaderSummaryEditingChannelId = ''; let parserRoutingRows = []; let parserRoutingBusy = false; let accountList = []; @@ -805,7 +803,7 @@ async function syncChannels() { channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); renderChannelOwnerOptions(); - renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); return channelList; } @@ -847,6 +845,7 @@ async function createChannelFromForm() { $('#channelBotAddress').value = ''; renderChannelList(); renderChannelOwnerOptions(); + renderLeaderSummarySubscriptions(); if (message) message.textContent = '渠道已保存,连接状态会在服务端异步更新。'; } finally { channelSettingsBusy = false; @@ -929,7 +928,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'); @@ -952,115 +951,58 @@ async function deleteChannel(channelId) { 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); + channel?.owner_user_id && item.leader_user_id === channel.owner_user_id + )) || leaderSummarySubscriptions.find((item) => item.channel_id === channelId); } function renderLeaderSummarySubscriptions() { const container = $('#leaderSummaryList'); if (!container) return; container.replaceChildren(); - if (!leaderSummarySubscriptions.length) { - container.append(el('p', 'muted channel-empty', '尚未配置组长摘要抄送。设置保存后仍默认关闭,启用前必须核对主动投递目标。')); + const leaderChannels = channelList.filter((channel) => ( + channel.owner_user_id && channel.owner_role === 'team_lead' + )); + if (!leaderChannels.length) { + container.append(el('p', 'muted channel-empty', '当前没有绑定 AgentBus 渠道的有效组长账号。账号设为组长并绑定渠道后,摘要抄送会自动生效。')); return; } - for (const subscription of leaderSummarySubscriptions) { + for (const channel of leaderChannels) { + const subscription = leaderSummarySubscriptionForChannel(channel.id); 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 || '未知组长')); + const routeReady = subscription?.route_ready === true || subscription?.target_verified === true; + const active = subscription?.enabled === true && subscription?.eligible === true; + let stateClass = active ? 'state-ok' : 'state-warn'; + let stateLabel = active ? '自动推送' : '自动同步中'; + if (!channel.enabled) stateLabel = '渠道已停用'; + else if (!channel.routing_ready) { + stateClass = 'state-bad'; + stateLabel = '渠道未就绪'; + } else if (!routeReady) stateLabel = '等待路由'; + heading.append(el('strong', '', channel.owner_username || 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', 'muted', `渠道:${channel.display_name || subscription?.channel_name || '未命名渠道'} · 范围:同组织其他非管理员员工 · 来源:人工任务、AgentBus 任务`)); main.append(el( 'p', - subscription.target_verified ? 'channel-key-state' : 'channel-error', - subscription.target_verified - ? `目标已核对 · 收件指纹 ${subscription.recipient_fingerprint || '—'} · 会话指纹 ${subscription.conversation_fingerprint || '—'}` - : '主动投递目标尚未核对' + active ? 'channel-key-state' : channel.routing_ready ? 'muted' : 'channel-error', + active + ? '组长身份和 AgentBus 路由已自动识别,无需维护收件地址或微信会话 ID。' + : !channel.enabled + ? '重新启用该组长渠道后,系统会自动恢复摘要抄送;停用期间不会发送。' + : !channel.routing_ready + ? '请先补齐该渠道的平台账号与 ERP 账号绑定。' + : !routeReady && !channel.external_user_ref + ? '渠道未设置外部用户标识。组长从微信向该 AgentBus 渠道发送首条有效消息后,系统会自动学习路由并启用。' + : 'AgentBus 路由正在自动同步,通常会在几秒内生效。' )); - 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); + if (subscription) { + main.append(el('p', 'muted', `当前队列:待发 ${subscription.pending_count || 0} · 失败待重试 ${subscription.failed_count || 0} · 累计已交给 AgentBus ${subscription.delivered_count || 0}`)); + main.append(el('p', subscription.eligible ? 'muted' : 'channel-error', subscription.eligibility_message || '')); + main.append(el('p', 'muted', `自动生效起点:${formatDateTime(subscription.starts_at)} · 最近投递:${formatDateTime(subscription.last_delivered_at)}`)); + } + row.append(main); container.append(row); } } @@ -1069,121 +1011,10 @@ 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 '组长'; @@ -1418,7 +1249,7 @@ async function syncAccounts() { accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; renderAccounts(); renderChannelOwnerOptions(); - renderLeaderSummaryChannelOptions(); + renderLeaderSummarySubscriptions(); } async function createAccountFromForm() { @@ -5804,7 +5635,7 @@ async function initializeSession() { }); await syncLeaderSummarySubscriptions().catch((error) => { const message = $('#leaderSummaryMessage'); - if (message) message.textContent = `组长摘要设置读取失败:${error.message || String(error)}`; + if (message) message.textContent = `组长摘要状态读取失败:${error.message || String(error)}`; }); } if (IS_ACCOUNTS_PAGE && isAdministrator()) { @@ -5979,7 +5810,6 @@ document.addEventListener('DOMContentLoaded', async () => { accountTaskTypes = []; channelList = []; leaderSummarySubscriptions = []; - leaderSummaryEditingChannelId = ''; accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); @@ -6272,40 +6102,6 @@ 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 14546f0..530d569 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@ AI操作台 · LianSyn-platform - +
@@ -282,25 +282,12 @@

LEADER SUMMARY COPY

组长任务摘要抄送

-

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

+

身份为组长且已有可用 AgentBus 渠道时,系统自动把同组织内其他非管理员员工的稳定任务结果抄送到组长微信。人工任务与 AgentBus 任务都会纳入。

- 默认关闭;只处理保存设置后的新结果,不补发历史任务。收件地址、会话 ID 和摘要正文均加密保存且不回显。AgentBus 主动投递可能极少量重复,已经到达微信的消息无法撤回。 + 无需单独配置或启用。收件路由优先使用 AgentBus 已记录的组长会话,并可从渠道的外部用户标识自动建立;只处理自动启用后的新结果,不补发历史任务。路由和摘要正文均加密保存且不回显。AgentBus 主动投递可能极少量重复,已经到达微信的消息无法撤回。
-
- - - -
- 抄送范围 - - - - -
- -

@@ -436,6 +423,6 @@
- + diff --git a/LianSyn-platform/styles.css b/LianSyn-platform/styles.css index aeab8ff..4abc1c5 100644 --- a/LianSyn-platform/styles.css +++ b/LianSyn-platform/styles.css @@ -636,7 +636,7 @@ textarea { .leader-summary-panel { min-height: 0 !important; - grid-template-rows: auto auto auto minmax(120px, auto) auto !important; + grid-template-rows: auto auto minmax(120px, auto) auto !important; } .leader-summary-safety { @@ -649,61 +649,6 @@ textarea { 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; } @@ -2060,18 +2005,10 @@ 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; diff --git a/agent设计规范/agentbus-reply-contract.md b/agent设计规范/agentbus-reply-contract.md index d2e2255..33ebc7b 100644 --- a/agent设计规范/agentbus-reply-contract.md +++ b/agent设计规范/agentbus-reply-contract.md @@ -6,7 +6,7 @@ 组长任务摘要与员工入站消息的受理/结果回复是两套独立契约、两套持久化队列。员工回复仍绑定原始入站 `frame.id`、`from` 和 `reply_to`;组长摘要不得复用 `agentbus_deliveries`,不得伪造入站帧或 `reply_to`,也不得改变任务状态、归属、确认权、ERP 领取权或员工自己的最终回复。 -管理员可以在 `/channels` 为有效组长的一对一 AgentBus 渠道配置一个组织范围订阅。订阅固定覆盖同组织中除该组长本人以外、任务归属角色不是管理员的员工;可分别纳入人工任务和 AgentBus 任务。当前没有可证明的分组成员关系,因此不得按看板筛选、在线账号或临时渠道推断组员。订阅默认关闭,启用前必须显式核对 AgentBus 收件地址和微信 `conversation_id`;每次保存都建立新的生效时间和 revision,只处理此后的新稳定结果,不扫描或补发历史。渠道、收件目标、角色或订阅 revision 变化时,旧目标尚未发送的摘要取消,不能改投新目标。 +组长摘要由身份自动启用,不另设人工订阅开关:有效 `team_lead` 账号拥有一对一、已启用且路由就绪的 AgentBus 渠道时,系统固定覆盖同组织中除该组长本人以外、任务归属角色不是管理员的员工,同时纳入人工任务和 AgentBus 任务。当前没有可证明的分组成员关系,因此不得按看板筛选、在线账号或临时渠道推断组员。主动投递路由优先采用当前组长账号经该渠道最近一次有效入站帧的 `from` 与 `conversation_id`;尚无入站记录时采用渠道 `external_user_ref`,并以 `agentbus:` 作为普通回复兼容的会话键。两者均不存在时保持等待,首条有效组长消息到达后自动学习。渠道换绑会清空旧外部用户标识,历史入站路由也必须关联到当前组长归属的任务,不能把前任渠道所有者的地址用于新组长。首次启用及渠道、路由、角色或账号有效性变化都建立新的生效时间和 revision,只处理此后的新稳定结果,不扫描或补发历史;旧路由尚未发送的摘要取消,不能改投新路由。 主动通知帧使用稳定 ID `leader-summary-`,`type=event`、`payload.event=task.summary`,显式携带 `to` 和 `conversation_id`,并且没有 `reply_to`。监听器把 `task.summary` 视为保留事件,桥接器回显该帧时也不能创建任务。员工受理/结果队列每轮优先发送,组长摘要使用独立的小批量低优先级出队。 diff --git a/control-plane/README.md b/control-plane/README.md index d040f20..0614b1e 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 身份核验字段;迁移 `020_leader_task_summary_notifications` 增加默认关闭的组长订阅和独立加密通知 outbox。原文、完整程序/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,7 +99,7 @@ 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;员工回执始终优先。摘要正文只含员工、业务、稳定状态、公共任务编号、上海提交时间以及白名单团号/订单号,不含原始指令、人员资料、附件或技术错误。目标与正文加密,日志只留指纹。该功能启用前要求人工核对桥接目标,发送为至少一次语义且微信消息不可撤回;当前实现不提供会产生真实外发的“测试发送”接口。 +`/channels` 只读展示组长任务摘要抄送状态,不再维护单独的订阅开关、收件地址或微信会话 ID。有效 `team_lead` 账号拥有已启用且可连接的 AgentBus 渠道时,服务会自动启用组织范围摘要;优先采用该账号经此渠道最近一次有效入站帧的 `from` 与 `conversation_id`,尚无入站记录时使用渠道 `external_user_ref` 并按普通回复规则生成会话键。没有任何可用路由时保持等待,首条有效组长消息到达后自动学习;渠道换绑账号时会清空旧外部用户标识,且历史入站路由只有任务归属仍是当前组长时才能复用,避免抄送到原渠道所有者。`GET /api/settings/leader-summary-subscriptions` 只返回不含明文目标的投递健康状态,没有人工修改或真实测试发送接口。角色、账号、渠道归属、渠道启停或路由变化会建立新的生效时间与 revision,取消旧路由未发送摘要,且永不回补历史。摘要固定读取同组织其他非管理员归属人的人工和 AgentBus 任务,只从自动生效后的 `task.updated` 稳定结果投影。主动帧为无 `reply_to` 的 `task.summary`,员工回执始终优先。正文只含员工、业务、稳定状态、公共任务编号、上海提交时间以及白名单团号/订单号,不含原始指令、人员资料、附件或技术错误;路由与正文加密,日志只留指纹。发送为至少一次语义且微信消息不可撤回。 对微信来源,listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator;`Conversation` 只属于传输路由,不会再污染业务字段签名。 diff --git a/control-plane/src/agentbus-channels.ts b/control-plane/src/agentbus-channels.ts index 3bc70f4..0f154e8 100644 --- a/control-plane/src/agentbus-channels.ts +++ b/control-plane/src/agentbus-channels.ts @@ -377,11 +377,6 @@ export class AgentBusChannelService { const displayName = input.displayName === undefined ? text(current.display_name) : text(input.displayName).slice(0, 120); if (!displayName) throw new TaskError('channel_name_required', '渠道名称不能为空。', 400); const currentExternalUserRef = text(current.external_user_ref) || null; - const externalUserRef = currentExternalUserRef === LEGACY_CHANNEL_REF - ? LEGACY_CHANNEL_REF - : input.externalUserRef === undefined - ? currentExternalUserRef - : (text(input.externalUserRef).slice(0, 200) || null); const enabled = input.enabled === undefined ? current.enabled === true || text(current.enabled) === 'true' : input.enabled; @@ -391,6 +386,14 @@ export class AgentBusChannelService { ? text(current.owner_user_id) : text(input.ownerUserId); const owner = await this.requireAssignableOwner(client, context.organizationId, ownerUserId); + const ownerChanged = ownerUserId !== text(current.owner_user_id); + const externalUserRef = currentExternalUserRef === LEGACY_CHANNEL_REF + ? LEGACY_CHANNEL_REF + : input.externalUserRef !== undefined + ? (text(input.externalUserRef).slice(0, 200) || null) + : ownerChanged + ? null + : currentExternalUserRef; if (enabled) { const keyResult = await client.query( `SELECT agentbus_ws_token_ciphertext @@ -436,7 +439,9 @@ export class AgentBusChannelService { await this.audit(client, context, 'agentbus_channel.updated', channelId, { enabled, display_name: displayName, - owner_user_id: text(owner.id) + owner_user_id: text(owner.id), + owner_changed: ownerChanged, + external_user_ref_present: Boolean(externalUserRef) }); return text(updated.rows[0].id); }); diff --git a/control-plane/src/agentbus.ts b/control-plane/src/agentbus.ts index d451d39..ec82cd2 100644 --- a/control-plane/src/agentbus.ts +++ b/control-plane/src/agentbus.ts @@ -135,6 +135,13 @@ export interface AgentBusTaskGateway { } export interface LeaderNotificationGateway { + observeLeaderRoute?(input: { + organizationId: string; + leaderUserId: string; + channelId: string; + recipientAddress: string; + conversationId?: string; + }): Promise; claimDeliveries( channelId: string, leaseOwner: string, @@ -930,6 +937,13 @@ export class AgentBusListener { this.logFrame('frame_ignored', frame, 'AgentBus frame ignored', { ignore_reason: ignoreReason }); return; } + if (!this.session) { + this.logger.warn({ + agentbus_event: 'task_before_session_ready', + ...frameLogData(frame, this.config.AGENTBUS_LOG_PAYLOADS) + }, 'Ignoring AgentBus task before session.ready'); + return; + } const taskId = text(frame.id); if (this.inFlightTaskIds.has(taskId) || this.completedTaskIds.has(taskId) || this.pendingFinalReplies.has(taskId)) { this.logFrame('task_duplicate_ignored', frame, 'AgentBus duplicate task ignored', { @@ -939,13 +953,6 @@ export class AgentBusListener { }); return; } - if (!this.session) { - this.logger.warn({ - agentbus_event: 'task_before_session_ready', - ...frameLogData(frame, this.config.AGENTBUS_LOG_PAYLOADS) - }, 'Ignoring AgentBus task before session.ready'); - return; - } this.logFrame('inbound_task_accepted', frame, 'AgentBus inbound task accepted'); const processingStartedAt = process.hrtime.bigint(); const processing = this.processInboundTask(frame) @@ -1059,6 +1066,25 @@ export class AgentBusListener { ...(this.channel ? { channelId: this.channel.id } : {}) }; const result = await this.tasks.ingestMessage(context, input); + if ( + this.channel?.ownerRole === 'team_lead' + && this.leaderNotifications?.observeLeaderRoute + ) { + void this.leaderNotifications.observeLeaderRoute({ + organizationId: this.organizationId, + leaderUserId: this.channel.ownerUserId, + channelId: this.channel.id, + recipientAddress: text(frame.from), + conversationId + }).catch((error) => { + this.logger.warn({ + agentbus_event: 'leader_summary_route_observation_failed', + channel_id: this.channel?.id, + owner_user_id: this.channel?.ownerUserId, + ...diagnosticError(error, 'leader_summary_route_observation_failed') + }, 'AgentBus leader summary route observation failed'); + }); + } this.logger.info({ agentbus_event: 'task_ingested', inbound_frame_id: taskId, diff --git a/control-plane/src/leader-notification-service.ts b/control-plane/src/leader-notification-service.ts index 82327b8..39cc006 100644 --- a/control-plane/src/leader-notification-service.ts +++ b/control-plane/src/leader-notification-service.ts @@ -7,7 +7,6 @@ import { 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; @@ -25,9 +24,11 @@ export interface PublicLeaderTaskSummarySubscription { scope: 'organization'; include_manual: boolean; include_agentbus: boolean; + automatic: true; enabled: boolean; eligible: boolean; eligibility_message: string; + route_ready: boolean; target_verified: boolean; recipient_fingerprint: string; conversation_fingerprint: string; @@ -41,16 +42,12 @@ export interface PublicLeaderTaskSummarySubscription { updated_at: string; } -export interface LeaderTaskSummarySubscriptionInput { +export interface ObservedLeaderAgentBusRoute { + organizationId: string; leaderUserId: string; channelId: string; - recipientAddress?: string; + recipientAddress: string; conversationId?: string; - includeManual: boolean; - includeAgentBus: boolean; - enabled: boolean; - targetVerified?: boolean; - expectedRevision?: number; } export interface LeaderTaskSummaryDelivery { @@ -93,6 +90,17 @@ const NEEDS_REVIEW_STATUSES = [ 'uncertain' ] as const; +const LEGACY_CHANNEL_REF = 'legacy-env'; + +interface AutomaticLeaderRoute { + organizationId: string; + leaderUserId: string; + channelId: string; + recipientAddress: string; + conversationId: string; + source: 'agentbus_inbound' | 'channel_external_user_ref'; +} + const noopLogger: LeaderNotificationLogger = { info: () => undefined, warn: () => undefined, @@ -135,10 +143,16 @@ function eligibility( if (!booleanValue(row.channel_enabled)) { return { eligible: false, message: 'AgentBus 渠道已停用,通知不会发送。' }; } - if (!row.target_verified_at) { - return { eligible: false, message: '主动投递目标尚未验证,通知不会发送。' }; + if (!text(row.leader_erp_account)) { + return { eligible: false, message: '组长渠道尚未满足账号绑定条件,通知不会发送。' }; } - return { eligible: true, message: '投递条件已就绪。' }; + if (!row.target_verified_at) { + return { eligible: false, message: 'AgentBus 尚未提供可用路由;收到组长消息后会自动同步。' }; + } + if (!booleanValue(row.enabled)) { + return { eligible: false, message: '自动订阅正在同步,暂时不会发送。' }; + } + return { eligible: true, message: '组长身份和 AgentBus 路由已就绪,摘要会自动推送。' }; } function publicSubscription( @@ -156,9 +170,11 @@ function publicSubscription( scope: 'organization', include_manual: booleanValue(row.include_manual), include_agentbus: booleanValue(row.include_agentbus), + automatic: true, enabled: booleanValue(row.enabled), eligible: state.eligible, eligibility_message: state.message, + route_ready: Boolean(row.target_verified_at), 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), @@ -212,7 +228,17 @@ export class LeaderNotificationService { private async projectTick(): Promise { if (!this.projectorOrganizationId) return; if (this.projectorInFlight) return this.projectorInFlight; - this.projectorInFlight = this.projectPending(this.projectorOrganizationId, 100) + this.projectorInFlight = this.reconcileAutomaticSubscriptions(this.projectorOrganizationId) + .then((changed) => { + if (changed > 0) { + this.log('info', { + agentbus_event: 'leader_summary_automatic_subscriptions_reconciled', + organization_id: this.projectorOrganizationId, + changed_count: changed + }, 'Automatic leader task summary subscriptions reconciled'); + } + return this.projectPending(this.projectorOrganizationId, 100); + }) .then((count) => { if (count > 0) { this.log('info', { @@ -241,6 +267,7 @@ export class LeaderNotificationService { leader.username AS leader_username, leader.role AS leader_role, leader.is_active AS leader_is_active, + leader.erp_account AS leader_erp_account, channel.display_name AS channel_name, channel.owner_user_id AS channel_owner_user_id, channel.enabled AS channel_enabled, @@ -277,242 +304,332 @@ export class LeaderNotificationService { .map((row) => publicSubscription(row, this.config.agentBusEnabled)); } - async upsertSubscription( - context: TaskContext, - input: LeaderTaskSummarySubscriptionInput - ): Promise { - const leaderUserId = text(input.leaderUserId); - const channelId = text(input.channelId); + private automaticRoute( + input: Omit & { + recipientAddress: unknown; + conversationId?: unknown; + } + ): AutomaticLeaderRoute | null { 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 (!recipientAddress || recipientAddress === LEGACY_CHANNEL_REF) return null; + const conversationId = ( + text(input.conversationId) + || `agentbus:${recipientAddress}` + ).slice(0, 500); + if (!conversationId) return null; + return { ...input, recipientAddress, conversationId }; + } + + private async auditAutomaticChange( + client: import('pg').PoolClient, + input: { + organizationId: string; + subscriptionId: string; + eventType: string; + metadata: Record; } - 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 + ): Promise { + await client.query( + `INSERT INTO audit_events + (organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata) + VALUES ($1, NULL, $2, 'leader_task_summary_subscription', $3, NULL, $4)`, + [input.organizationId, input.eventType, input.subscriptionId, input.metadata] + ); + this.log('info', { + agentbus_event: 'leader_summary_automatic_subscription_audit_staged', + entity_id: input.subscriptionId, + metadata_keys: diagnosticMetadataKeys(input.metadata) + }, 'Automatic leader summary subscription audit event staged'); + } + + private async syncAutomaticSubscription( + client: import('pg').PoolClient, + route: AutomaticLeaderRoute, + existing?: Record + ): Promise { + const recipientFingerprint = sha256Text(route.recipientAddress); + const conversationFingerprint = sha256Text(route.conversationId); + const routeChanged = !existing + || route.channelId !== text(existing.channel_id) + || recipientFingerprint !== text(existing.recipient_address_fingerprint) + || conversationFingerprint !== text(existing.conversation_id_fingerprint); + const policyChanged = !existing + || !booleanValue(existing.enabled) + || !booleanValue(existing.include_manual) + || !booleanValue(existing.include_agentbus) + || !existing.target_verified_at; + if (!routeChanged && !policyChanged) return false; + + const nextRevision = existing ? Number(existing.revision || 0) + 1 : 0; + let subscriptionId: string; + if (existing) { + const updated = await client.query( + `UPDATE leader_task_summary_subscriptions + SET channel_id = $1, + include_manual = true, + include_agentbus = true, + enabled = true, + starts_at = now(), + revision = $2, + recipient_address_ciphertext = $3, + recipient_address_fingerprint = $4, + conversation_id_ciphertext = $5, + conversation_id_fingerprint = $6, + target_verified_at = now(), + target_verified_by = NULL, + updated_at = now() + WHERE id = $7 + RETURNING id`, + [ + route.channelId, + nextRevision, + encryptText(this.config, route.recipientAddress), + recipientFingerprint, + encryptText(this.config, route.conversationId), + conversationFingerprint, + existing.id + ] ); + subscriptionId = text(updated.rows[0]?.id); + } 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, true, true, true, now(), 0, + $4, $5, $6, $7, now(), NULL, NULL) + RETURNING id`, + [ + route.organizationId, + route.leaderUserId, + route.channelId, + encryptText(this.config, route.recipientAddress), + recipientFingerprint, + encryptText(this.config, route.conversationId), + conversationFingerprint + ] + ); + subscriptionId = text(inserted.rows[0]?.id); } - const subscriptionId = await withTransaction(this.config, async (client) => { + if (existing) { await client.query( - `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':leader-summary:' || $2::text, 0))`, - [context.organizationId, leaderUserId] + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'cancelled', + last_error = '组长身份或 AgentBus 路由已变化,旧路由待发送摘要已取消。', + updated_at = now() + WHERE subscription_id = $1 + AND subscription_revision <> $2 + AND delivery_status IN ('pending', 'sending', 'failed')`, + [subscriptionId, nextRevision] ); + } + await this.auditAutomaticChange(client, { + organizationId: route.organizationId, + subscriptionId, + eventType: existing && routeChanged + ? 'leader_summary_subscription.automatic_route_updated' + : 'leader_summary_subscription.automatic_enabled', + metadata: { + leader_user_id: route.leaderUserId, + channel_id: route.channelId, + automatic: true, + include_manual: true, + include_agentbus: true, + enabled: true, + route_source: route.source, + recipient_fingerprint: recipientFingerprint.slice(0, 12), + conversation_fingerprint: conversationFingerprint.slice(0, 12), + revision: nextRevision, + historical_backfill: false + } + }); + return true; + } + + private async disableAutomaticSubscription( + client: import('pg').PoolClient, + existing: Record, + reason: 'channel_disabled' | 'route_unavailable' | 'role_or_channel_invalid' + ): Promise { + if (!booleanValue(existing.enabled) && !existing.target_verified_at) return false; + const nextRevision = Number(existing.revision || 0) + 1; + const updated = await client.query( + `UPDATE leader_task_summary_subscriptions + SET enabled = false, + starts_at = now(), + revision = $2, + target_verified_at = NULL, + target_verified_by = NULL, + updated_at = now() + WHERE id = $1 + RETURNING id`, + [existing.id, nextRevision] + ); + const subscriptionId = text(updated.rows[0]?.id); + await client.query( + `UPDATE leader_task_summary_deliveries + SET delivery_status = 'cancelled', + last_error = '组长身份或 AgentBus 路由已失效,待发送摘要已取消。', + updated_at = now() + WHERE subscription_id = $1 + AND delivery_status IN ('pending', 'sending', 'failed')`, + [subscriptionId] + ); + await this.auditAutomaticChange(client, { + organizationId: text(existing.organization_id), + subscriptionId, + eventType: 'leader_summary_subscription.automatic_disabled', + metadata: { + leader_user_id: text(existing.leader_user_id), + channel_id: text(existing.channel_id), + automatic: true, + enabled: false, + reason, + revision: nextRevision, + historical_backfill: false + } + }); + return true; + } + + async reconcileAutomaticSubscriptions(organizationId: string): Promise { + return withTransaction(this.config, async (client) => { + await client.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':leader-summary-automatic', 0) + )`, + [organizationId] + ); + const existingResult = await client.query( + `SELECT * + FROM leader_task_summary_subscriptions + WHERE organization_id = $1 + FOR UPDATE`, + [organizationId] + ); + const existingByLeader = new Map>( + (existingResult.rows as Record[]) + .map((row) => [text(row.leader_user_id), row]) + ); + const candidates = await client.query( + `SELECT leader.id AS leader_user_id, + channel.id AS channel_id, + channel.enabled AS channel_enabled, + channel.external_user_ref, + latest_route.inbound_from, + latest_route.conversation_id + FROM users leader + JOIN user_channels channel + ON channel.organization_id = leader.organization_id + AND channel.owner_user_id = leader.id + LEFT JOIN LATERAL ( + SELECT delivery.inbound_from, delivery.conversation_id + FROM agentbus_deliveries delivery + JOIN tasks route_task + ON route_task.id = delivery.task_id + AND route_task.organization_id = delivery.organization_id + AND route_task.assigned_user_id = leader.id + WHERE delivery.organization_id = leader.organization_id + AND delivery.channel_id = channel.id + AND btrim(delivery.inbound_from) <> '' + ORDER BY delivery.created_at DESC, delivery.id DESC + LIMIT 1 + ) latest_route ON true + WHERE leader.organization_id = $1 + AND leader.role = 'team_lead' + AND leader.is_active = true + AND leader.erp_account IS NOT NULL + ORDER BY leader.id`, + [organizationId] + ); + + const currentLeaders = new Set(); + let changed = 0; + for (const row of candidates.rows as Record[]) { + const leaderUserId = text(row.leader_user_id); + const channelId = text(row.channel_id); + currentLeaders.add(leaderUserId); + const existing = existingByLeader.get(leaderUserId); + if (!booleanValue(row.channel_enabled)) { + if (existing && await this.disableAutomaticSubscription(client, existing, 'channel_disabled')) changed += 1; + continue; + } + const inboundRecipient = text(row.inbound_from); + const externalRecipient = text(row.external_user_ref); + const route = this.automaticRoute({ + organizationId, + leaderUserId, + channelId, + recipientAddress: inboundRecipient || externalRecipient, + conversationId: inboundRecipient ? row.conversation_id : undefined, + source: inboundRecipient ? 'agentbus_inbound' : 'channel_external_user_ref' + }); + if (!route) { + if (existing && await this.disableAutomaticSubscription(client, existing, 'route_unavailable')) changed += 1; + continue; + } + if (await this.syncAutomaticSubscription(client, route, existing)) changed += 1; + } + + for (const [leaderUserId, existing] of existingByLeader) { + if (currentLeaders.has(leaderUserId)) continue; + if (await this.disableAutomaticSubscription(client, existing, 'role_or_channel_invalid')) changed += 1; + } + return changed; + }); + } + + async observeLeaderRoute(input: ObservedLeaderAgentBusRoute): Promise { + const route = this.automaticRoute({ + organizationId: text(input.organizationId), + leaderUserId: text(input.leaderUserId), + channelId: text(input.channelId), + recipientAddress: input.recipientAddress, + conversationId: input.conversationId, + source: 'agentbus_inbound' + }); + if (!route || !route.organizationId || !route.leaderUserId || !route.channelId) return; + await withTransaction(this.config, async (client) => { + await client.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':leader-summary-automatic', 0) + )`, + [route.organizationId] + ); + const eligible = await client.query( + `SELECT channel.id + FROM user_channels channel + JOIN users leader + ON leader.id = channel.owner_user_id + AND leader.organization_id = channel.organization_id + AND leader.role = 'team_lead' + AND leader.is_active = true + AND leader.erp_account IS NOT NULL + WHERE channel.organization_id = $1 + AND channel.id = $2 + AND channel.owner_user_id = $3 + AND channel.enabled = true + FOR SHARE OF channel, leader`, + [route.organizationId, route.channelId, route.leaderUserId] + ); + if (!eligible.rowCount) return; 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] + [route.organizationId, route.leaderUserId] ); - const existing = existingResult.rows[0] as Record | undefined; - const safeDisable = Boolean( - existing - && !input.enabled - && channelId === text(existing.channel_id) + await this.syncAutomaticSubscription( + client, + route, + existingResult.rows[0] as Record | undefined ); - 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 { @@ -550,6 +667,7 @@ export class LeaderNotificationService { AND leader.organization_id = subscription.organization_id AND leader.role = 'team_lead' AND leader.is_active = true + AND leader.erp_account IS NOT NULL JOIN user_channels channel ON channel.id = subscription.channel_id AND channel.organization_id = subscription.organization_id @@ -714,6 +832,7 @@ export class LeaderNotificationService { AND leader.organization_id = subscription.organization_id AND leader.role = 'team_lead' AND leader.is_active = true + AND leader.erp_account IS NOT NULL JOIN user_channels channel ON channel.id = subscription.channel_id AND channel.organization_id = subscription.organization_id @@ -752,6 +871,7 @@ export class LeaderNotificationService { AND leader.organization_id = subscription.organization_id AND leader.role = 'team_lead' AND leader.is_active = true + AND leader.erp_account IS NOT NULL JOIN user_channels channel ON channel.id = subscription.channel_id AND channel.organization_id = subscription.organization_id diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index b1e454b..a599256 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -157,16 +157,6 @@ 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(), @@ -1191,27 +1181,6 @@ export async function buildServer({ }; }); - 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); diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 81dbde1..02dd0a0 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=20260907-leader-summary-1/); - assert.match(index, /app\.js\?v=20260907-leader-summary-1/); + assert.match(index, /styles\.css\?v=20260907-leader-summary-2/); + assert.match(index, /app\.js\?v=20260907-leader-summary-2/); }); diff --git a/control-plane/test/agentbus.test.ts b/control-plane/test/agentbus.test.ts index 8fa335b..70f049d 100644 --- a/control-plane/test/agentbus.test.ts +++ b/control-plane/test/agentbus.test.ts @@ -175,6 +175,78 @@ test('AgentBus configuration stays disabled until connection fields are supplied assert.equal(config.AGENTBUS_LOG_PAYLOADS, false); }); +test('team-lead listener automatically observes its inbound AgentBus route', async (t) => { + const socket = new FakeSocket(); + const observed: Array> = []; + let ingested = 0; + const tasks: AgentBusTaskGateway = { + events: new EventEmitter(), + async ingestMessage() { + ingested += 1; + return { task: makeTask('completed'), attached: false, created: true }; + }, + async getTask() { + return makeTask('completed'); + } + }; + const leaderNotifications: LeaderNotificationGateway = { + async observeLeaderRoute(input) { + observed.push(input as unknown as Record); + }, + async claimDeliveries() { + return []; + }, + async markDeliveryDelivered() {}, + async markDeliveryFailed() {}, + async releaseDeliveries() {} + }; + const listener = new AgentBusListener({ + config: testConfig(), + tasks, + leaderNotifications, + organizationId: 'org-1', + scheduleParseQueue: async () => {}, + socketFactory: () => socket as unknown as AgentBusSocket, + channel: { + id: 'channel-leader-route', + displayName: '组长微信', + wsUrl: 'wss://mesh.nianxx.cn/ws', + wsToken: 'leader-route-token', + botAddress: 'bot:leader-route:listener', + ownerUserId: 'leader-route-user', + ownerRole: 'team_lead' + } + }); + t.after(() => listener.stop()); + + listener.start(); + socket.readyState = 1; + socket.emit('open'); + socket.emit('message', JSON.stringify({ + id: 'ready-leader-route', + type: 'event', + session_id: 'session-leader-route', + epoch: 1, + to: 'bot:leader-route:listener', + payload: { event: 'session.ready' } + })); + socket.emit('message', JSON.stringify({ + id: 'leader-route-message-1', + type: 'event', + from: 'channel:wechat:leader-route-user', + payload: { text: '查询今天的任务' } + })); + + await waitFor(() => observed.length === 1 && ingested === 1); + assert.deepEqual(observed, [{ + organizationId: 'org-1', + leaderUserId: 'leader-route-user', + channelId: 'channel-leader-route', + recipientAddress: 'channel:wechat:leader-route-user', + conversationId: 'agentbus:channel:wechat:leader-route-user' + }]); +}); + test('AgentBus accepted delivery payloads keep final ownership metadata server-only', () => { const waitingPayload = createAgentBusAcceptedDeliveryPayload({ text: AGENTBUS_ROSTER_WAITING_TEXT, diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index c6f0be5..5ff1ae2 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -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=20260907-leader-summary-1/); - assert.match(index, /app\.js\?v=20260907-leader-summary-1/); + assert.match(index, /styles\.css\?v=20260907-leader-summary-2/); + assert.match(index, /app\.js\?v=20260907-leader-summary-2/); 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 index 5a6fb88..a87d87e 100644 --- a/control-plane/test/leader-notification-contract.test.ts +++ b/control-plane/test/leader-notification-contract.test.ts @@ -6,9 +6,11 @@ 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 () => { +test('migration keeps the automatic feature fail-closed in storage and 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/); + // The runtime reconciler is the only component that activates rows. A raw + // insert must remain inert when identity or routing checks have not run. 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\)/); @@ -25,7 +27,28 @@ test('migration creates a default-off encrypted outbox separate from employee re assert.doesNotMatch(sql, /payload\s+jsonb/iu); }); -test('projection is organization-scoped, future-only, role-safe and source-selective', async () => { +test('automatic reconciliation derives enabled subscriptions from leader identity and AgentBus routing', async () => { + const service = await source('../src/leader-notification-service.ts'); + const channels = await source('../src/agentbus-channels.ts'); + assert.match(service, /reconcileAutomaticSubscriptions/); + assert.match(service, /leader\.role = 'team_lead'/); + assert.match(service, /leader\.is_active = true/); + assert.match(service, /leader\.erp_account IS NOT NULL/); + assert.match(service, /channel\.owner_user_id = leader\.id/); + assert.match(service, /latest_route\.inbound_from/); + assert.match(service, /channel\.external_user_ref/); + assert.match(service, /route_task\.assigned_user_id = leader\.id/); + assert.match(service, /source: inboundRecipient \? 'agentbus_inbound' : 'channel_external_user_ref'/); + assert.match(service, /`agentbus:\$\{recipientAddress\}`/); + assert.match(service, /VALUES \(\$1, \$2, \$3, true, true, true, now\(\), 0/); + assert.match(service, /target_verified_at = now\(\)/); + assert.match(service, /automatic_disabled/); + assert.match(service, /channel_disabled.*route_unavailable.*role_or_channel_invalid/s); + assert.doesNotMatch(service, /upsertSubscription|expectedRevision|leader_summary_target_unverified/); + assert.match(channels, /ownerChanged[\s\S]*?\? null[\s\S]*?: currentExternalUserRef/); +}); + +test('projection remains organization-scoped, future-only, role-safe and encrypted', 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/); @@ -43,20 +66,19 @@ test('projection is organization-scoped, future-only, role-safe and source-selec 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.match(service, /delivery_status = 'cancelled'.*旧路由待发送摘要已取消/s); assert.doesNotMatch(service, /reply_to/); }); -test('HTTP configuration is administrator-only and has no live test-send endpoint', async () => { +test('HTTP exposes administrator status only and no manual mutation or 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(routes, /app\.(put|post|patch|delete)/); + assert.doesNotMatch(server, /leaderSummarySubscriptionSchema|upsertSubscription/); assert.doesNotMatch(server, /leader-summary-subscriptions.*test-send|leader-summary-subscriptions.*test\/send/s); }); @@ -80,17 +102,22 @@ test('AgentBus sends summaries as reserved low-priority proactive events without assert.match(leaderDelivery, /recipient_fingerprint/); assert.match(leaderDelivery, /conversation_fingerprint/); assert.doesNotMatch(leaderDelivery, /this\.logFrame/); + assert.match(agentbus, /ownerRole === 'team_lead'/); + assert.match(agentbus, /observeLeaderRoute/); + assert.match(agentbus, /recipientAddress: text\(frame\.from\),\s*conversationId/s); }); -test('administrator UI explains safety boundaries and never asks the API to backfill', async () => { +test('administrator UI is read-only and explains role-driven automatic delivery', 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(html, /只处理自动启用后的新结果,不补发历史任务/); assert.match(html, /已经到达微信的消息无法撤回/); - assert.match(app, /目标已核对/); - assert.match(app, /expected_revision/); - assert.match(app, /消息正文不含原始指令、客户\/游客资料或技术错误/); + assert.doesNotMatch(html, /leaderSummary(Form|ChannelId|RecipientAddress|ConversationId|TargetVerified|Enabled|Save)/); + assert.match(app, /自动推送/); + assert.match(app, /等待路由/); + assert.match(app, /无需维护收件地址或微信会话 ID/); + assert.doesNotMatch(app, /leader-summary-subscriptions\/\$\{|expected_revision: subscription\.revision/); assert.doesNotMatch(app, /leader-summary-subscriptions[^'"\n]*backfill/); });