diff --git a/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md b/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md new file mode 100644 index 0000000..8e42d29 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md @@ -0,0 +1,69 @@ +# Task: Scope ERP queues per account and restore hard delete + +## Identity + +- Task ID: 20260902-per-account-queue-hard-delete-a6d9f2c1 +- Mode: Feature +- Branch: codex/20260902-per-account-queue-hard-delete-a6d9f2c1-per-account-queue-hard-delete +- Worktree: /Users/inmanx/Documents/lwltAPI-per-account-queue-hard-delete-a6d9f2c1 +- Base commit: 69ea6d25178d75abf8d7fd728bd31c61764caa7d +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace organization-wide ERP claim serialization with an account-scoped claim lock and account-scoped active/confirmed FIFO checks. +- Preserve FIFO ordering for tasks assigned to the same platform/ERP account while allowing tasks assigned to different accounts to be claimed and executed independently. +- Keep reversible archive/restore as a separate operation, and restore `DELETE /api/tasks/:taskId` plus bulk-delete as permanent physical task deletion. +- Allow permanent deletion regardless of parse, handoff, queue, or ERP execution state, while retaining organization/account access control. +- Remove task-owned database rows through existing cascades, request post-commit OSS artifact cleanup, and invoke the existing local extension hard-delete bridge on a best-effort basis. +- Update the operator UI and regressions so archive/restore and irreversible force-delete are visibly distinct. +- Separate administrator-wide read visibility from executable routing: task SSE, automatic handoff, plugin result ingestion, and force-delete cleanup commands must be delivered only to the task's immutable `assigned_user_id`. + +## Intent And Constraints + +- The user explicitly superseded AUTH-001's organization-wide ERP FIFO and archive-only removal decisions for this task. Canonical decision and architecture updates remain Promotion Candidates for a later Integration task. +- A single account still has at most one active ERP execution and a deterministic FIFO of confirmed tasks; one account's active or queued task must not affect another account's claim result or queue position. +- Force delete is authoritative physical deletion and must not call the archive state gate. It is not an ERP rollback: already-written ERP data cannot be undone by deleting the platform record. +- A minimal `task.hard_deleted` audit event may remain as evidence of the destructive action; task content, events, attempts, sessions, inputs, artifacts, and delivery rows must be removed with the task. +- OSS cleanup is post-commit and best effort so storage unavailability cannot resurrect or block an already-authorized database deletion. +- Do not change Chrome extension source/version: its existing `DELETE_TASK` / `LTJT_HARD_DELETE_TASK` path already performs local cancellation and cache removal. +- An administrator may still view organization tasks, but viewing authority must never authorize confirmation, claim, result submission, plugin-result handling, or receipt of another account's execution event/command. +- Do not deploy, restart services, mutate production tasks, operate ERP, or inspect secrets in this Feature task. + +## Outcome + +- `claimForBrowser` now uses an advisory transaction lock keyed by organization and executing user instead of locking the organization row. Both active-execution detection and confirmed FIFO selection are filtered by the locked task's account, so same-account work remains serialized while different accounts do not block or affect queue positions. +- Added `hardDeleteTask` and atomic `hardDeleteTasks` service operations. They preserve organization/owner access checks, intentionally omit status/handoff gates, write a minimal `task.hard_deleted` audit marker, remove task outbox entries, physically delete task rows, rely on existing cascades for all task-owned database records, and request post-commit OSS cleanup. +- `DELETE /api/tasks/:taskId` and `POST /api/tasks/bulk-delete` now use physical deletion; `/archive`, `/restore`, and `/bulk-archive` remain reversible and retain their active-task archive guard. +- The task detail and history UI now expose archive/restore separately from red permanent force-delete controls. Force delete works for active, waiting, archived, and terminal tasks, presents an explicit irreversible/ERP-write warning, removes local caches, and uses the existing `DELETE_TASK` extension bridge as best-effort cancellation/cleanup after server deletion. +- History selection and bulk force-delete are available for both active and archived views. Active tasks that cannot be archived can still be permanently deleted. +- `/api/events` is now explicitly an executable wake-up feed. Historical and live task events are unconditionally filtered by the authenticated account's `assigned_user_id`, including for administrators; the administrator's organization-wide task-list visibility no longer enters SSE dispatch routing. The reconnect cursor is likewise derived only from the current account's tasks. +- Plugin `TASK_RESULT_CHANGED` broadcasts, queued result persistence, and direct result polling now fail closed in the page unless the local task is assigned to the current authenticated account. This prevents an administrator page from attempting to persist another user's extension result and surfacing the resulting authorization rejection as a false task error. +- Hard delete emits a post-commit `browser-command` addressed only to each deleted task's assigned account. An online owner page uses the existing `DELETE_TASK` bridge to stop/clean its own plugin state; an administrator deleting an employee task never sends that cleanup command to the administrator's local plugin. Direct best-effort cleanup is also filtered to locally assigned tasks. +- Updated control-plane implementation documentation and regression coverage. No database migration or Chrome extension source/version change was required. + +## Verification + +- `node --check LianSyn-platform/app.js` — passed. +- `node --run check` — passed. +- `node --run test:control-plane` — passed, 162/162. +- `node --run test:legacy` — passed, 268/268. +- `node --run check:repo` — passed, 10/10. +- `node --run build` — passed. +- Focused account/routing/queue/delete regression — passed, 14/14. +- Focused account plus core control-plane regression — passed after the added account-isolation coverage. +- `git diff --check` — passed. + +## Follow-ups + +- Run an Integration Gate to resolve the intentional conflict with AUTH-001 and promote the accepted per-account FIFO plus explicit physical-delete behavior into canonical project memory. +- Merge/integrate this Feature branch before any rollout. Production deployment, service restart, and live task mutation were not performed and still require explicit user authorization. +- After an authorized rollout, smoke-test with an administrator plus two distinct assigned accounts: keep one employee account executing while confirming that the other employee account claims immediately; verify the administrator receives no employee executable SSE/result handoff; then force-delete a waiting and an active disposable employee task and verify database absence plus cleanup only in the owning employee plugin. + +## Promotion Candidates + +- Supersede AUTH-001's organization-wide ERP FIFO clause with account-scoped FIFO: each `assigned_user_id` owns one serialized ERP queue, while different assigned accounts execute independently. +- Supersede AUTH-001's archive-only removal clause: archive/restore remains the routine reversible path, while explicitly confirmed force delete permanently removes a task regardless of state and cannot roll back prior ERP effects. +- Record the invariant that administrator-wide read visibility is never execution authority: executable lists, SSE wake-ups, browser claims, plugin results, and browser cleanup commands are all scoped to immutable task assignment. +- Update `.project-docs/20-architecture/system-overview.md`, `.project-docs/20-architecture/data-flow.md`, `.project-docs/40-domain/business-rules.md`, `.project-docs/40-domain/success-criteria.md`, and `.project-docs/30-worklog/current-state.md` after Integration acceptance. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index dfbc02d..e90ad98 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -80,10 +80,12 @@ let historyStatus = ''; let historyArchive = 'active'; let historyOffset = 0; const lifecycleScrollPositions = new Map(); +const taskArchiveStates = new Map(); const taskDeleteStates = new Map(); const historySelectedTaskIds = new Set(); const locallyDeletedTaskIds = new Set(); let historyBulkDeleteBusy = false; +let historyBulkAction = ''; let historyBatchMessage = ''; let historyBatchMessageKind = ''; const taskReconciliationStates = new Map(); @@ -1847,10 +1849,10 @@ async function syncRemoteTasks() { function startRemoteEventStream() { if (eventStream) eventStream.close(); - const lastEventId = runtimeTasks().reduce((highest, task) => ( + const lastEventId = runtimeTasks().filter(taskAssignedToCurrentAccount).reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); - eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`); + eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}&executable_by=me`); eventStream.addEventListener('open', () => { syncAutomationSettings({ background: true }).catch(() => {}); }); @@ -1859,6 +1861,9 @@ function startRemoteEventStream() { setOutput({ status: 'sync_error', message: error.message }); }); }); + eventStream.addEventListener('browser-command', (event) => { + handleBrowserCommand(event).catch(() => {}); + }); eventStream.onerror = () => { // EventSource reconnects automatically; polling remains the fallback authority refresh. }; @@ -2256,6 +2261,11 @@ function taskAssignedToCurrentAccount(task) { return Boolean(authUser?.id && task?.assignee?.id === authUser.id); } +function extensionTaskBelongsToCurrentAccount(taskId) { + const task = findTaskById(String(taskId || '').trim()); + return Boolean(task && taskAssignedToCurrentAccount(task)); +} + function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } @@ -2538,7 +2548,7 @@ function isTaskPollable(task) { ); } -function canDeleteTask(task) { +function canArchiveTask(task) { if (!task) return false; if (task.archived_at) return true; return ![ @@ -2547,6 +2557,11 @@ function canDeleteTask(task) { && !['awaiting_handoff', 'accepted', 'running'].includes(String(task.handoff_status || '')); } +function taskMutationInProgress(taskId) { + return taskArchiveStates.get(taskId) === 'updating' + || taskDeleteStates.get(taskId) === 'deleting'; +} + function canStartConfirmedTask(task) { return Boolean( task?.operation @@ -3634,6 +3649,7 @@ function renderTaskDetail() { const task = selectedTask(); const taskState = $('#taskState'); const confirmButton = $('#confirmTaskButton'); + const archiveButton = $('#archiveTaskButton'); const deleteButton = $('#deleteTaskButton'); const status = task?.status || task?.result?.status || 'created'; const importantMessagePanel = renderTaskImportantMessage(task); @@ -3658,12 +3674,19 @@ function renderTaskDetail() { ? RETRY_HANDOFF_LABEL : lifecycleReview ? '审核并授权本次测试写入' : MANUAL_HANDOFF_LABEL; } + if (archiveButton) { + const archiveState = task ? taskArchiveStates.get(task.task_id) : ''; + archiveButton.disabled = !canArchiveTask(task) || taskMutationInProgress(task?.task_id); + if (archiveState === 'updating') archiveButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; + else if (archiveState === 'failed') archiveButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; + else archiveButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; + } if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; - deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting'; - if (deleteState === 'deleting') deleteButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; - else if (deleteState === 'failed') deleteButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; - else deleteButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; + deleteButton.disabled = !task || taskMutationInProgress(task?.task_id); + if (deleteState === 'deleting') deleteButton.textContent = '强制删除中…'; + else if (deleteState === 'failed') deleteButton.textContent = '重试强制删除'; + else deleteButton.textContent = '强制删除任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; @@ -4058,14 +4081,15 @@ function resetHistoryTaskSelection({ clearMessage = true } = {}) { function renderHistoryBatchActions(visibleTasks = []) { const actions = $('#historyBatchActions'); if (!actions) return; - actions.hidden = !IS_HISTORY_PAGE || historyArchive === 'archived'; - if (!IS_HISTORY_PAGE || historyArchive === 'archived') return; + actions.hidden = !IS_HISTORY_PAGE; + if (!IS_HISTORY_PAGE) return; const visibleTaskIds = new Set(visibleTasks.map((task) => task?.task_id).filter(Boolean)); for (const taskId of [...historySelectedTaskIds]) { if (!visibleTaskIds.has(taskId)) historySelectedTaskIds.delete(taskId); } const selectedCount = historySelectedTaskIds.size; + const selectedTasks = visibleTasks.filter((task) => historySelectedTaskIds.has(task.task_id)); const allVisibleSelected = visibleTaskIds.size > 0 && selectedCount === visibleTaskIds.size; const selectAll = $('#historySelectAll'); if (selectAll) { @@ -4081,14 +4105,26 @@ function renderHistoryBatchActions(visibleTasks = []) { message.classList.toggle('is-error', historyBatchMessageKind === 'error'); message.classList.toggle('is-success', historyBatchMessageKind === 'success'); } - const deleteButton = $('#historyDeleteSelectedButton'); - if (deleteButton) { - deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; - deleteButton.textContent = historyBulkDeleteBusy + const archiveButton = $('#historyArchiveSelectedButton'); + if (archiveButton) { + const archiveBlocked = selectedTasks.some((task) => !canArchiveTask(task)); + archiveButton.hidden = historyArchive === 'archived'; + archiveButton.disabled = historyBulkDeleteBusy || selectedCount === 0 || archiveBlocked; + archiveButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'archive' ? `正在归档 ${selectedCount} 个…` : selectedCount > 0 ? `归档所选 (${selectedCount})` : '归档所选'; + archiveButton.title = archiveBlocked ? '所选任务包含正在处理或等待 ERP 执行的任务,不能归档;可改用强制删除。' : ''; + } + const deleteButton = $('#historyDeleteSelectedButton'); + if (deleteButton) { + deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; + deleteButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'delete' + ? `正在强制删除 ${selectedCount} 个…` + : selectedCount > 0 + ? `强制删除所选 (${selectedCount})` + : '强制删除所选'; } actions.setAttribute('aria-busy', historyBulkDeleteBusy ? 'true' : 'false'); } @@ -4123,6 +4159,7 @@ function renderTaskCards() { return; } for (const task of visibleTasks) { + const archiveState = taskArchiveStates.get(task.task_id) || ''; const deleteState = taskDeleteStates.get(task.task_id) || ''; const card = document.createElement('article'); card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}${historySelectedTaskIds.has(task.task_id) ? ' is-checked' : ''}`; @@ -4162,32 +4199,46 @@ function renderTaskCards() { card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); if (IS_HISTORY_PAGE) { card.classList.add('has-history-actions'); - if (historyArchive !== 'archived') { - const selection = document.createElement('label'); - selection.className = 'task-card-select'; - selection.title = `选择任务 ${task.task_id}`; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.dataset.historyTaskSelect = task.task_id; - checkbox.checked = historySelectedTaskIds.has(task.task_id); - checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; - checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); - selection.append(checkbox); - card.prepend(selection); - } + const selection = document.createElement('label'); + selection.className = 'task-card-select'; + selection.title = `选择任务 ${task.task_id}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.historyTaskSelect = task.task_id; + checkbox.checked = historySelectedTaskIds.has(task.task_id); + checkbox.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); + checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); + selection.append(checkbox); + card.prepend(selection); + + const actions = document.createElement('div'); + actions.className = 'task-card-actions'; + const archiveButton = document.createElement('button'); + archiveButton.type = 'button'; + archiveButton.className = 'secondary-button task-card-archive'; + archiveButton.dataset.historyTaskArchive = task.task_id; + archiveButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id) || !canArchiveTask(task); + archiveButton.textContent = archiveState === 'updating' + ? (task.archived_at ? '恢复中…' : '归档中…') + : archiveState === 'failed' + ? (task.archived_at ? '重试恢复' : '重试归档') + : (task.archived_at ? '恢复' : '归档'); + archiveButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); + actions.append(archiveButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'danger-button task-card-delete'; deleteButton.dataset.historyTaskDelete = task.task_id; - deleteButton.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; + deleteButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); deleteButton.textContent = deleteState === 'deleting' - ? (task.archived_at ? '恢复中…' : '归档中…') + ? '删除中…' : deleteState === 'failed' - ? (task.archived_at ? '重试恢复' : '重试归档') - : (task.archived_at ? '恢复' : '归档'); - deleteButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); - card.append(deleteButton); + ? '重试删除' + : '强制删除'; + deleteButton.setAttribute('aria-label', `永久强制删除任务 ${task.task_id}`); + actions.append(deleteButton); + card.append(actions); } container.appendChild(card); } @@ -4525,7 +4576,7 @@ window.addEventListener('message', (event) => { if (message.type === 'TASK_RESULT_CHANGED') { const taskId = String(message.payload?.task_id || '').trim(); const result = message.payload?.result; - if (taskId && result && typeof result === 'object') { + if (taskId && result && typeof result === 'object' && extensionTaskBelongsToCurrentAccount(taskId)) { queueExtensionTaskResult(taskId, result).catch(() => {}); } return; @@ -4759,8 +4810,8 @@ async function handoffTaskToExtension(task) { stage: 'handoff', handoff_status: 'awaiting_handoff', message: queuePosition > 0 - ? `插件当前正在处理其他任务,本任务排队第 ${queuePosition} 位。` - : '插件当前正在处理其他任务,任务已持久化排队。' + ? `当前账号的插件正在处理其他任务,本任务在该账号队列第 ${queuePosition} 位。` + : '当前账号的插件正在处理其他任务,任务已进入该账号的持久化队列。' }); return { ok: true, @@ -4769,7 +4820,7 @@ async function handoffTaskToExtension(task) { queue_status: 'waiting', queue_position: claim.queue_position, dispatched: false, - message: '插件同一时间只处理一个任务,本任务已进入服务端队列。' + message: '同一账号的插件同一时间只处理一个任务;不同账号互不排队。' }; } startPolling(); @@ -5025,8 +5076,20 @@ function confirmTaskArchive(tasks) { ); } -function removeDeletedTaskLocally(taskId) { +function confirmTaskHardDelete(tasks) { + const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; + if (!selectedTasks.length) return false; + const scope = selectedTasks.length === 1 + ? `任务 ${selectedTasks[0].task_id}` + : `所选 ${selectedTasks.length} 个任务`; + return window.confirm( + `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统会向任务所属账号的在线插件发送停止与清理指令,不会误发给当前管理员插件。` + ); +} + +function removeTaskLocally(taskId) { const wasVisible = taskStore.some((item) => item.task_id === taskId); + taskArchiveStates.delete(taskId); taskDeleteStates.delete(taskId); historySelectedTaskIds.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); @@ -5049,7 +5112,7 @@ function removeDeletedTaskLocally(taskId) { if (wasVisible) taskListMeta.total = Math.max(0, taskListMeta.total - 1); } -function finishLocalTaskDeletions() { +function finishLocalTaskMutations() { if (!taskStore.some((item) => item.task_id === currentTaskId)) { currentTaskId = taskStore[0]?.task_id || ''; } @@ -5060,12 +5123,47 @@ function finishLocalTaskDeletions() { renderTaskCards(); } -async function deleteTask(taskId) { +async function cleanupDeletedTasksInExtension(tasks) { + const selectedTasks = Array.isArray(tasks) ? tasks.filter((task) => task?.task_id) : []; + const executableTasks = selectedTasks.filter(taskAssignedToCurrentAccount); + const results = await Promise.allSettled( + executableTasks.map((task) => sendToExtension('DELETE_TASK', { task_id: task.task_id }, 3_000)) + ); + return { + requested: executableTasks.length, + skipped: selectedTasks.length - executableTasks.length, + failed: results.filter((result) => result.status === 'rejected').length + }; +} + +async function handleBrowserCommand(event) { + let command = null; + try { + command = JSON.parse(String(event?.data || '')); + } catch (error) { + return; + } + if (command?.action !== 'hard_delete' || command?.target_user_id !== authUser?.id) return; + const taskId = String(command.task_id || '').trim(); + if (!taskId) return; + try { + await sendToExtension('DELETE_TASK', { task_id: taskId }, 3_000); + } catch (error) { + // The server-side force delete remains authoritative even if this online + // browser cannot acknowledge cancellation of its own local plugin task. + } + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); +} + +async function toggleTaskArchive(taskId) { const task = findTaskById(taskId); if (!task) return; - if (taskDeleteStates.get(taskId) === 'deleting') return; + if (taskMutationInProgress(taskId)) return; - taskDeleteStates.set(taskId, 'deleting'); + taskArchiveStates.set(taskId, 'updating'); renderTaskCards(); try { @@ -5078,15 +5176,15 @@ async function deleteTask(taskId) { body: {}, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); - removeDeletedTaskLocally(taskId); + removeTaskLocally(taskId); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 已${restoring ? '恢复' : '归档'}。`; historyBatchMessageKind = 'success'; } - finishLocalTaskDeletions(); + finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { - taskDeleteStates.set(taskId, 'failed'); + taskArchiveStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 操作失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; @@ -5103,16 +5201,58 @@ async function deleteTask(taskId) { } } -async function deleteHistorySelectedTasks() { +async function hardDeleteTask(taskId) { + const task = findTaskById(taskId); + if (!task || taskMutationInProgress(taskId)) return; + + taskDeleteStates.set(taskId, 'deleting'); + renderTaskCards(); + + try { + await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { + method: 'DELETE', + timeoutMs: TASK_DELETE_API_TIMEOUT_MS + }); + const extensionCleanup = await cleanupDeletedTasksInExtension([task]); + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + if (IS_HISTORY_PAGE) { + historyBatchMessage = extensionCleanup.failed + ? `任务 ${taskId} 已永久删除;本机插件未确认清理,请确认 ERP 当前状态。` + : `任务 ${taskId} 已永久删除。`; + historyBatchMessageKind = 'success'; + } + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); + } catch (error) { + taskDeleteStates.set(taskId, 'failed'); + if (IS_HISTORY_PAGE) { + historyBatchMessage = `任务 ${taskId} 强制删除失败:${error.message || String(error)}`; + historyBatchMessageKind = 'error'; + } + if (currentTaskId === taskId) setTaskState('强制删除失败'); + setOutput({ + status: 'hard_delete_error', + task_id: taskId, + message: `任务强制删除失败:${error.message || String(error)}` + }); + renderTaskCards(); + if (currentTaskId === taskId) setTaskState('强制删除失败'); + throw error; + } +} + +async function archiveHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); if (!selectedTasks.length || !confirmTaskArchive(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; + historyBulkAction = 'archive'; historyBatchMessage = `正在归档 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const task of selectedTasks) { - taskDeleteStates.set(task.task_id, 'deleting'); + taskArchiveStates.set(task.task_id, 'updating'); } renderTaskCards(); @@ -5122,14 +5262,14 @@ async function deleteHistorySelectedTasks() { body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); - for (const taskId of taskIds) removeDeletedTaskLocally(taskId); + for (const taskId of taskIds) removeTaskLocally(taskId); resetHistoryTaskSelection({ clearMessage: false }); historyBatchMessage = `已归档 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; - finishLocalTaskDeletions(); + finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { - for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); + for (const taskId of taskIds) taskArchiveStates.set(taskId, 'failed'); historyBatchMessage = `批量归档失败,所选任务均未变更:${error.message || String(error)}`; historyBatchMessageKind = 'error'; setTaskState('批量归档失败'); @@ -5141,14 +5281,69 @@ async function deleteHistorySelectedTasks() { void syncRemoteTasks().catch(() => {}); } finally { historyBulkDeleteBusy = false; + historyBulkAction = ''; renderTaskCards(); } } +async function deleteHistorySelectedTasks() { + if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; + const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); + if (!selectedTasks.length || !confirmTaskHardDelete(selectedTasks)) return; + const taskIds = selectedTasks.map((task) => task.task_id); + historyBulkDeleteBusy = true; + historyBulkAction = 'delete'; + historyBatchMessage = `正在强制删除 ${taskIds.length} 个任务…`; + historyBatchMessageKind = ''; + for (const taskId of taskIds) taskDeleteStates.set(taskId, 'deleting'); + renderTaskCards(); + + try { + await apiRequest('/api/tasks/bulk-delete', { + method: 'POST', + body: { task_ids: taskIds }, + timeoutMs: TASK_DELETE_API_TIMEOUT_MS + }); + const extensionCleanup = await cleanupDeletedTasksInExtension(selectedTasks); + for (const taskId of taskIds) { + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + } + resetHistoryTaskSelection({ clearMessage: false }); + historyBatchMessage = extensionCleanup.failed + ? `已永久删除 ${taskIds.length} 个任务;其中 ${extensionCleanup.failed} 个未获得本机插件清理确认,请确认 ERP 当前状态。` + : `已永久删除 ${taskIds.length} 个任务。`; + historyBatchMessageKind = 'success'; + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); + } catch (error) { + for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); + historyBatchMessage = `批量强制删除失败,所选任务均未删除:${error.message || String(error)}`; + historyBatchMessageKind = 'error'; + setTaskState('批量强制删除失败'); + setOutput({ + status: 'bulk_hard_delete_error', + task_ids: taskIds, + message: historyBatchMessage + }); + void syncRemoteTasks().catch(() => {}); + } finally { + historyBulkDeleteBusy = false; + historyBulkAction = ''; + renderTaskCards(); + } +} + +async function archiveSelectedTask() { + const task = selectedTask(); + if (!task) return; + return toggleTaskArchive(task.task_id); +} + async function deleteSelectedTask() { const task = selectedTask(); if (!task) return; - return deleteTask(task.task_id); + return hardDeleteTask(task.task_id); } function extensionResultVersion(result) { @@ -5161,6 +5356,7 @@ function extensionResultVersion(result) { } async function persistExtensionTaskResult(taskId, result) { + if (!extensionTaskBelongsToCurrentAccount(taskId)) return false; try { const executionId = result.execution_id; if (!executionId) { @@ -5190,7 +5386,8 @@ async function persistExtensionTaskResult(taskId, result) { function queueExtensionTaskResult(taskId, result) { const normalizedTaskId = String(taskId || '').trim(); - if (!normalizedTaskId || !result || typeof result !== 'object') return Promise.resolve(false); + if (!normalizedTaskId || !result || typeof result !== 'object' + || !extensionTaskBelongsToCurrentAccount(normalizedTaskId)) return Promise.resolve(false); const version = extensionResultVersion(result); pendingExtensionResults.set(normalizedTaskId, { result, version }); const existing = extensionResultPersistQueues.get(normalizedTaskId); @@ -5220,6 +5417,7 @@ function queueExtensionTaskResult(taskId, result) { async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { const knownTask = findTaskById(taskId); + if (!knownTask || !taskAssignedToCurrentAccount(knownTask)) return null; if (!allowReconciliation && knownTask) { const localStatuses = [knownTask.status, knownTask.result?.status, knownTask.handoff_status] .map((value) => String(value || '').toLowerCase()); @@ -5522,6 +5720,7 @@ document.addEventListener('DOMContentLoaded', async () => { extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); persistedExtensionResultVersions.clear(); + taskArchiveStates.clear(); taskDeleteStates.clear(); taskReplyStates.clear(); taskReplyDrafts.clear(); @@ -5532,6 +5731,7 @@ document.addEventListener('DOMContentLoaded', async () => { accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); + historyBulkAction = ''; historyBatchMessage = ''; historyBatchMessageKind = ''; currentTaskId = ''; @@ -5608,6 +5808,9 @@ document.addEventListener('DOMContentLoaded', async () => { $('#historyDeleteSelectedButton').addEventListener('click', () => { void deleteHistorySelectedTasks(); }); + $('#historyArchiveSelectedButton').addEventListener('click', () => { + void archiveHistorySelectedTasks(); + }); $('#automationToggleButton').addEventListener('click', () => { toggleAutomationSetting().catch((error) => { setTaskState('全自动化设置失败'); @@ -5863,16 +6066,25 @@ document.addEventListener('DOMContentLoaded', async () => { message: error.message })); }); - $('#deleteTaskButton').addEventListener('click', () => { + $('#archiveTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; if (!task.archived_at && !confirmTaskArchive([task])) return; - deleteSelectedTask().catch((error) => setOutput({ + archiveSelectedTask().catch((error) => setOutput({ status: 'archive_error', task_id: task.task_id, message: error.message || String(error) })); }); + $('#deleteTaskButton').addEventListener('click', () => { + const task = selectedTask(); + if (!task || !confirmTaskHardDelete([task])) return; + deleteSelectedTask().catch((error) => setOutput({ + status: 'hard_delete_error', + task_id: task.task_id, + message: error.message || String(error) + })); + }); $('#taskCards').addEventListener('change', (event) => { const checkbox = event.target.closest('[data-history-task-select]'); const taskId = checkbox?.dataset.historyTaskSelect; @@ -5884,12 +6096,21 @@ document.addEventListener('DOMContentLoaded', async () => { renderTaskCards(); }); $('#taskCards').addEventListener('click', (event) => { + const archiveButton = event.target.closest('[data-history-task-archive]'); + if (archiveButton) { + const taskId = archiveButton.dataset.historyTaskArchive; + const task = taskStore.find((item) => item.task_id === taskId); + if (task && (task.archived_at || confirmTaskArchive([task]))) { + void toggleTaskArchive(taskId).catch(() => {}); + } + return; + } const deleteButton = event.target.closest('[data-history-task-delete]'); if (deleteButton) { const taskId = deleteButton.dataset.historyTaskDelete; const task = taskStore.find((item) => item.task_id === taskId); - if (task && (task.archived_at || confirmTaskArchive([task]))) { - void deleteTask(taskId).catch(() => {}); + if (task && confirmTaskHardDelete([task])) { + void hardDeleteTask(taskId).catch(() => {}); } return; } diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index a1297f0..1f1201e 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@ AI操作台 · LianSyn-platform - +
@@ -380,7 +380,8 @@ 已选 0 个 - + +
@@ -407,6 +409,6 @@
- + diff --git a/LianSyn-platform/styles.css b/LianSyn-platform/styles.css index 9332713..a8b5e96 100644 --- a/LianSyn-platform/styles.css +++ b/LianSyn-platform/styles.css @@ -3012,7 +3012,8 @@ textarea { color: var(--ok); } -.history-batch-actions > .danger-button { +.history-batch-actions > .danger-button, +.history-batch-actions > .secondary-button { flex: 0 0 auto; white-space: nowrap; } @@ -4889,7 +4890,15 @@ body.operations-dashboard-detail-open .operations-dashboard-page { cursor: pointer; } -.task-card-delete { +.task-card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.task-card-delete, +.task-card-archive { min-height: 30px; align-self: center; padding: 0 9px; diff --git a/control-plane/README.md b/control-plane/README.md index 9562b44..d6d48b8 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -25,11 +25,11 @@ - 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`,listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态,并返回“附件内容未传到平台”;不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要,仍不回显 URL、文件字节或名单内容。当前生产部署位于受信内网,入站附件 URL 可以使用内网域名、私网 IPv4/IPv6 或 localhost;因此 AgentBus 渠道和上游桥接器必须被视为受信输入边界。 - AgentBus 入站消息会复用 `TaskService` 的任务/会话/解析队列,并以渠道绑定员工写入 `created_by` 与不可变的 `assigned_user_id`,解析完成后通过同一 WebSocket 返回一次 `task.result`。组织级“全自动化”关闭时,手工与 AgentBus 新任务都需要人工确认;开启后,两种来源的合法解析结果都自动进入 ERP 队列,不再按来源或创建、名单、安排、修改、取消/恢复、导出等业务类型保留人工例外。历史未归属 AgentBus 任务不会自动执行。操作台在 EventSource 建连/重连、30 秒后台刷新以及页面重新可见或聚焦时重新读取数据库权威开关。缺资料、解析失败、歧义、插件校验失败或 ERP 回查不确定时仍会停止,不会绕过校验或重试不确定写入。 - 单一部署范围可以维护多个“用户渠道”。每个渠道代表一个外部 AgentBus 用户身份,并且必须一对一绑定一个有效的非管理员平台账号;一个平台账号也只能绑定一个渠道。管理员在 `/channels` 创建、绑定、停用、启用、轮换或删除渠道。只有绑定账号有效且已配置 ERP 账号的启用渠道才启动 listener;未绑定渠道失败关闭。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 置空而 `assigned_user_id` 不变。每个渠道独立保存加密后的 AgentBus key,同一 key 不能被多个渠道复用;列表和日志都不会回显 key。`AGENTBUS_WS_URL`、重连策略和客户端类型仍是全局连接配置,`AGENTBUS_BOT_ADDRESS` 可作为渠道 bot address 的默认值。 -- `/history` 使用可恢复的归档/恢复,不提供物理删除。单条兼容路由 `DELETE /api/tasks/:taskId` 与批量兼容路由 `POST /api/tasks/bulk-delete` 也只执行归档;普通用户和组长只能归档/恢复本人任务,管理员可以处理全部授权任务。任务、输入、事件、尝试、附件元数据与审计记录继续保留,物理清除必须等待单独批准的保留期限和不可逆清除设计。 +- `/history` 同时提供可恢复的归档/恢复与显式的永久强制删除。`POST /api/tasks/:taskId/archive`、`POST /api/tasks/:taskId/restore` 和 `POST /api/tasks/bulk-archive` 保留归档语义及运行状态门禁;`DELETE /api/tasks/:taskId` 与 `POST /api/tasks/bulk-delete` 会绕过任务状态门禁并物理删除任务及其输入、事件、尝试、会话、投递和附件记录,同时清理任务 outbox,并在提交后尽力清理 OSS 对象。普通用户和组长只能操作本人任务,管理员可以处理全部授权任务;不可逆删除仍保留最小化的删除审计事件。 - `/operations-dashboard` 是组长和管理员专用的只读业务操作看板。它支持从结果状态、操作人、上海业务日期和业务类型逐层穿透,并可在选定范围内查询姓名、完整初始/补充指令、业务结果、团号或订单号。列表和详情只回答“谁提交了什么指令、完成了什么结果”:详情返回操作人、业务类型、完整指令轮次、输入附件名称/行数和可读业务结果,不返回任务生命周期、解析/执行 JSON、技术阶段、错误码或产物地址。关键词查询先受日期、人员、业务和状态约束,单次解密匹配候选最多 2,000 条,超过时要求继续缩小范围。该路径不授予他人任务修改、ERP 执行、SSE、产物下载、账号维护或全局安全审计权限,并排除 AgentBus/system 任务。 - 使用数据库渠道时设置 `AGENTBUS_ENABLED=true`;此模式不要求 `AGENTBUS_WS_TOKEN` 或 `AGENTBUS_BOT_ADDRESS`,但启用的渠道仍需要全局 `AGENTBUS_WS_URL`,并可在渠道上覆盖 bot address。保留旧环境变量配置时,服务会按需创建“默认 AgentBus 渠道”兼容旧单渠道部署;兼容渠道初始为未绑定且不启动,管理员必须在 `/channels` 绑定员工账号后再启用。`AGENTBUS_ENABLED=auto` 仅由完整的旧环境连接字段自动启用。 - `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。 -- ERP 插件领取由组织级数据库锁和 FIFO confirmed 队列统一串行化:同一组织/同一 ERP 浏览器会话在任意时刻最多一个 ERP execution,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。 +- ERP 插件领取按任务 `assigned_user_id` 使用账户级数据库锁和 FIFO confirmed 队列:同一平台/ERP 账户在任意时刻最多一个 ERP execution,该账户的其他任务留在服务端等待;不同账户的活跃或待执行任务互不占用队列位置、可独立领取执行。管理员的组织级查看权限与执行权限完全分离:实时执行事件、插件领取、执行回执以及强制删除后的浏览器清理命令都只发送或接受任务 `assigned_user_id` 对应的登录账号,管理员不会因为能查看员工任务而收到或处理该员工的插件任务。已开始写入但结果不确定的任务只阻塞同一账户的后续领取,直到人工回查收敛或任务被明确强制删除。 ## 任务级会话续接 diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 947b194..82b7831 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -21,10 +21,10 @@ import { import { TaskError, TaskService, - canAccessTask, canViewOperationsDashboard, type ParseDecisionInput, type ParseTaskClaim, + type TaskBrowserCommand, type TaskContext, type TaskEvent } from './task-service.js'; @@ -168,8 +168,19 @@ const listTasksQuerySchema = z.object({ z.boolean() ).default(true) }); +const taskEventsQuerySchema = z.object({ + since: z.coerce.number().int().min(0).default(0), + executable_by: z.literal('me').default('me') +}); +const taskIdListSchema = z.array(z.string().trim().min(1).max(200)).min(1).max(100); const taskBulkDeleteSchema = z.object({ - task_ids: z.array(z.string().trim().min(1).max(200)).min(1).max(100), + task_ids: taskIdListSchema +}).refine( + (body) => new Set(body.task_ids).size === body.task_ids.length, + { message: '任务编号不能重复。', path: ['task_ids'] } +); +const taskBulkArchiveSchema = z.object({ + task_ids: taskIdListSchema, reason: z.string().trim().max(500).optional() }).refine( (body) => new Set(body.task_ids).size === body.task_ids.length, @@ -1302,14 +1313,14 @@ export async function buildServer({ const body = taskBulkDeleteSchema.parse(request.body); return { ok: true, - archived: true, - ...(await tasks.archiveTasks(contextFor(session, request), body.task_ids, body.reason)) + deleted: true, + ...(await tasks.hardDeleteTasks(contextFor(session, request), body.task_ids)) }; }); app.post('/api/tasks/bulk-archive', async (request) => { const session = await requireMutationSession(request); - const body = taskBulkDeleteSchema.parse(request.body); + const body = taskBulkArchiveSchema.parse(request.body); return { ok: true, archived: true, @@ -1320,11 +1331,9 @@ export async function buildServer({ app.delete('/api/tasks/:taskId', async (request) => { const session = await requireMutationSession(request); const params = request.params as { taskId: string }; - const body = taskArchiveSchema.parse(request.body || {}); return { ok: true, - archived: true, - task: await tasks.archiveTask(contextFor(session, request), params.taskId, body.reason) + ...(await tasks.hardDeleteTask(contextFor(session, request), params.taskId)) }; }); @@ -1422,8 +1431,8 @@ export async function buildServer({ app.get('/api/events', async (request, reply) => { const session = await getSession(request); - const query = (request.query || {}) as Record; - const querySince = Number(query.since || 0); + const query = taskEventsQuerySchema.parse(request.query || {}); + const querySince = query.since; const reconnectSince = Number(request.headers['last-event-id'] || 0); const since = Math.max( Number.isFinite(querySince) ? querySince : 0, @@ -1439,9 +1448,10 @@ export async function buildServer({ }); const send = (event: TaskEvent) => { if (event.organization_id !== session.user.organizationId) return; - if (!canAccessTask(contextFor(session, request), { - assignedUserId: event.owner_user_id - })) return; + // This is the executable wake-up feed, not the administrator's read + // model. Every role, including admin, receives only its own assigned + // task events so visibility can never turn into plugin dispatch. + if (event.owner_user_id !== session.user.id) return; const publicEvent = { id: event.id, organization_id: event.organization_id, @@ -1454,13 +1464,26 @@ export async function buildServer({ }; response.write(`id: ${event.id}\nevent: task\ndata: ${JSON.stringify(publicEvent)}\n\n`); }; - for (const event of await tasks.eventsSince(session.user.organizationId, since, contextFor(session, request))) send(event); + const sendBrowserCommand = (command: TaskBrowserCommand) => { + if (command.organization_id !== session.user.organizationId) return; + if (command.assigned_user_id !== session.user.id) return; + response.write(`event: browser-command\ndata: ${JSON.stringify({ + action: command.action, + task_id: command.task_id, + target_user_id: command.assigned_user_id, + created_at: command.created_at + })}\n\n`); + }; + for (const event of await tasks.eventsSince(session.user.organizationId, session.user.id, since)) send(event); const heartbeat = setInterval(() => response.write(': heartbeat\n\n'), 20_000); const onTask = (event: TaskEvent) => send(event); + const onBrowserCommand = (command: TaskBrowserCommand) => sendBrowserCommand(command); tasks.events.on('task', onTask); + tasks.events.on('browser-command', onBrowserCommand); request.raw.on('close', () => { clearInterval(heartbeat); tasks.events.off('task', onTask); + tasks.events.off('browser-command', onBrowserCommand); }); }); diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index f7ed34c..3ea6917 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -63,6 +63,14 @@ export interface TaskEvent { task_source?: TaskSource; } +export interface TaskBrowserCommand { + organization_id: string; + task_id: string; + assigned_user_id: string; + action: 'hard_delete'; + created_at: string; +} + export interface PublicTaskEvent { id: number; status: string; @@ -2925,6 +2933,17 @@ export class TaskService { this.events.emit('task', event); } + private notifyBrowserCommand(command: TaskBrowserCommand): void { + this.log('info', { + diagnostic_event: 'task.browser_command.emitted', + diagnostic_stage: 'browser_routing', + task_id: command.task_id, + command_action: command.action, + assigned_user_id: command.assigned_user_id + }, 'task browser command emitted to assigned account'); + this.events.emit('browser-command', command); + } + private async audit( client: import('pg').PoolClient, context: TaskContext, @@ -6173,12 +6192,23 @@ export class TaskService { const leaseOwner = `browser:${connectionId}`; const outcome = await withTransaction(this.config, async (client) => { const organization = await client.query( - `SELECT id FROM organizations WHERE id = $1 FOR UPDATE`, + `SELECT id FROM organizations WHERE id = $1`, [context.organizationId] ); if (!organization.rowCount) throw new TaskError('organization_not_found', '组织不存在。', 404); + if (!context.userId) { + throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止领取执行。', 403); + } + // Serialize claims only for this execution account. Other accounts use + // different advisory locks and may claim their own FIFO concurrently. + await client.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':erp-account-queue:' || $2::text, 0) + )`, + [context.organizationId, context.userId] + ); const row = await this.lockTaskForAccess(client, context, taskId); - if (!context.userId || text(row.assigned_user_id) !== context.userId) { + if (text(row.assigned_user_id) !== context.userId) { throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止领取执行。', 403); } await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId); @@ -6226,12 +6256,13 @@ export class TaskService { FROM tasks t JOIN task_attempts a ON a.task_id = t.id AND a.phase = 'erp' WHERE t.organization_id = $1 + AND t.assigned_user_id = $2 AND a.status IN ('accepted', 'running') AND t.lease_expires_at IS NOT NULL AND t.lease_expires_at > now() ORDER BY t.created_at ASC, t.id ASC FOR UPDATE OF t, a`, - [context.organizationId] + [context.organizationId, context.userId] ); const activeOther = (activeExecutions.rows as Record[]) .find((active) => text(active.id) !== text(row.id)); @@ -6239,6 +6270,7 @@ export class TaskService { `SELECT id, task_id FROM tasks WHERE organization_id = $1 + AND assigned_user_id = $2 AND status = 'confirmed' AND handoff_status = 'awaiting_handoff' AND ( @@ -6265,7 +6297,7 @@ export class TaskService { ) ORDER BY created_at ASC, id ASC FOR UPDATE`, - [context.organizationId] + [context.organizationId, context.userId] ); const queueRows = queue.rows as Record[]; const queueIndex = queueRows.findIndex((candidate) => text(candidate.id) === text(row.id)); @@ -7067,6 +7099,127 @@ export class TaskService { return this.getTask(context.organizationId, taskId, context); } + async hardDeleteTask( + context: TaskContext, + taskId: string + ): Promise<{ task_id: string; deleted: boolean }> { + const outcome = await this.hardDeleteTasks(context, [taskId]); + return { task_id: taskId, deleted: outcome.deleted_count === 1 }; + } + + async hardDeleteTasks( + context: TaskContext, + taskIds: string[] + ): Promise<{ task_ids: string[]; deleted_count: number }> { + const normalizedTaskIds = [...new Set(taskIds.map((taskId) => text(taskId).trim()).filter(Boolean))]; + if (!normalizedTaskIds.length) throw new TaskError('invalid_task_ids', '请至少选择一个待删除任务。', 400); + const outcome = await withTransaction(this.config, async (client) => { + const lookup = await client.query( + `SELECT * FROM tasks + WHERE organization_id = $1 + AND task_id = ANY($2::text[]) + AND ($3::boolean = false OR assigned_user_id = $4) + ORDER BY task_id + FOR UPDATE`, + [context.organizationId, normalizedTaskIds, this.isOwnerRestrictedUser(context), context.userId || null] + ); + const rows = lookup.rows as Record[]; + const foundTaskIds = new Set(rows.map((row) => text(row.task_id))); + const missingTaskIds = normalizedTaskIds.filter((taskId) => !foundTaskIds.has(taskId)); + if (missingTaskIds.length) throw new TaskError('task_not_found', '任务不存在。', 404); + + // Hard delete intentionally has no status or handoff-state gate. The row + // locks settle concurrent transitions; existing task foreign keys then + // remove every task-owned record through ON DELETE CASCADE. + + const internalTaskIds = rows.map((row) => text(row.id)); + const artifactLookup = await client.query( + `SELECT artifact.id, artifact.organization_id, task.task_id AS public_task_id, + artifact.execution_id, artifact.artifact_index, artifact.artifact_type, + artifact.file_name, artifact.content_type, artifact.byte_size, artifact.sha256, + artifact.storage_backend, artifact.storage_key, artifact.created_at + FROM task_artifacts artifact + JOIN tasks task ON task.id = artifact.task_id + WHERE artifact.task_id = ANY($1::uuid[]) + ORDER BY artifact.created_at, artifact.id`, + [internalTaskIds] + ); + const artifacts: StoredTaskArtifact[] = (artifactLookup.rows as Record[]).map((row) => ({ + id: text(row.id), + organization_id: text(row.organization_id), + task_id: text(row.public_task_id), + execution_id: text(row.execution_id), + artifact_index: Number(row.artifact_index), + type: text(row.artifact_type), + file_name: text(row.file_name), + content_type: text(row.content_type), + byte_size: Number(row.byte_size), + sha256: text(row.sha256), + storage_backend: text(row.storage_backend), + storage_key: text(row.storage_key) || null, + public_url: null, + created_at: new Date(String(row.created_at)).toISOString() + })); + + // Retain only a minimal audit marker for the destructive action itself; + // it has no foreign key to the task and contains no task input/output. + for (const row of rows) { + await this.audit(client, context, 'task.hard_deleted', text(row.task_id), { + forced: true, + previous_status: text(row.status), + previous_handoff_status: text(row.handoff_status) || null, + was_archived: Boolean(row.archived_at) + }); + } + await client.query( + `DELETE FROM outbox_events + WHERE organization_id = $1 + AND aggregate_type = 'task' + AND aggregate_id = ANY($2::text[])`, + [context.organizationId, normalizedTaskIds] + ); + const deleted = await client.query( + `DELETE FROM tasks + WHERE id = ANY($1::uuid[]) + RETURNING task_id`, + [internalTaskIds] + ); + const deletedCount = Number(deleted.rowCount || 0); + if (deletedCount !== rows.length) { + throw new TaskError('task_delete_incomplete', '任务未能完整删除,请重试。', 500); + } + const browserCommands: TaskBrowserCommand[] = rows.flatMap((row) => { + const assignedUserId = text(row.assigned_user_id); + if (!assignedUserId) return []; + return [{ + organization_id: context.organizationId, + task_id: text(row.task_id), + assigned_user_id: assignedUserId, + action: 'hard_delete' as const, + created_at: new Date().toISOString() + }]; + }); + return { artifacts, browserCommands, deletedCount }; + }); + + for (const command of outcome.browserCommands) this.notifyBrowserCommand(command); + if (outcome.artifacts.length && this.artifactStore.cleanup) { + try { + await this.artifactStore.cleanup(outcome.artifacts); + } catch (error) { + this.log('error', { + diagnostic_event: 'task.hard_delete_artifact_cleanup_failed', + diagnostic_stage: 'artifact_cleanup', + request_id: context.requestId, + task_count: normalizedTaskIds.length, + artifact_count: outcome.artifacts.length, + error_type: error instanceof Error ? error.name : 'unknown_error' + }, 'hard-deleted task artifact cleanup failed'); + } + } + return { task_ids: normalizedTaskIds, deleted_count: outcome.deletedCount }; + } + async cancelTask(context: TaskContext, taskId: string): Promise { const outcome = await withTransaction(this.config, async (client) => { const row = await this.lockTaskForAccess(client, context, taskId); @@ -7213,15 +7366,15 @@ export class TaskService { }); } - async eventsSince(organizationId: string, since = 0, access?: TaskAccessScope): Promise { + async eventsSince(organizationId: string, assignedUserId: string, since = 0): Promise { const result = await getPool(this.config).query( `SELECT e.id, e.organization_id, t.task_id, t.assigned_user_id, t.source, e.status, e.stage, e.message, e.payload, e.created_at FROM task_events e JOIN tasks t ON t.id = e.task_id WHERE e.organization_id = $1 AND e.id > $2 - AND ($3::boolean = false OR t.assigned_user_id = $4) + AND t.assigned_user_id = $3 ORDER BY e.id ASC LIMIT 500`, - [organizationId, since, isTaskOwnerRestricted(access?.role), access?.userId || null] + [organizationId, since, assignedUserId] ); return result.rows.map((row) => ({ id: Number(row.id), diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 232f836..1b32451 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -221,7 +221,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event assert.match(body, /lockTaskForAccess\(/, `${mutation} uses the task access lock`); } assert.match(tasks, /async getTaskArtifact[\s\S]+assigned_user_id = \$4/); - assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$4/); + assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$3/); assert.match(tasks, /async getTaskInputHistory[\s\S]+actor_user_id/); assert.match(tasks, /connection\.organization_id = \$1[\s\S]+connection\.user_id = \$2[\s\S]+connection\.connection_id = \$3/); assert.match(tasks, /WHERE browser_connections\.user_id = EXCLUDED\.user_id/); @@ -238,10 +238,92 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event assert.match(tasks, /pg_advisory_xact_lock/); assert.match(server, /tasks\.listTasksPage[\s\S]+access: contextFor\(session, request\)/); assert.match(server, /tasks\.getTaskArtifact[\s\S]+contextFor\(session, request\)/); - assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, since, contextFor\(session, request\)\)/); + assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/); }); -test('operator UI exposes role-aware accounts, executive drill-through, original input, final output, and reversible archive', async () => { +test('administrator visibility is isolated from executable events and plugin result routing', async () => { + const [tasks, server, app] = await Promise.all([ + source('../src/task-service.ts'), + source('../src/server.ts'), + source('../../LianSyn-platform/app.js') + ]); + const eventHistory = tasks.slice(tasks.indexOf('async eventsSince(')); + assert.match(eventHistory, /assignedUserId: string/); + assert.match(eventHistory, /AND t\.assigned_user_id = \$3/); + assert.doesNotMatch(eventHistory, /isTaskOwnerRestricted\(access\?\.role\)/); + + const eventRoute = server.slice( + server.indexOf("app.get('/api/events'"), + server.indexOf('app.setErrorHandler') + ); + assert.match(eventRoute, /event\.owner_user_id !== session\.user\.id/); + assert.match(eventRoute, /command\.assigned_user_id !== session\.user\.id/); + assert.match(eventRoute, /event: browser-command/); + assert.match(eventRoute, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/); + + const eventStream = app.slice( + app.indexOf('function startRemoteEventStream()'), + app.indexOf('function cacheRuntimeTask(') + ); + assert.match(eventStream, /filter\(taskAssignedToCurrentAccount\)/); + assert.match(eventStream, /executable_by=me/); + assert.match(eventStream, /addEventListener\('browser-command'/); + + const bridgeListener = app.slice( + app.indexOf("window.addEventListener('message'"), + app.indexOf('async function parseRawInstruction') + ); + assert.match(bridgeListener, /TASK_RESULT_CHANGED/); + assert.match(bridgeListener, /extensionTaskBelongsToCurrentAccount\(taskId\)/); + const resultQueue = app.slice( + app.indexOf('async function persistExtensionTaskResult('), + app.indexOf('async function reconcileTaskReceipt(') + ); + assert.match(resultQueue, /if \(!extensionTaskBelongsToCurrentAccount\(taskId\)\) return false/); + assert.match(resultQueue, /!extensionTaskBelongsToCurrentAccount\(normalizedTaskId\)/); + assert.match(resultQueue, /!knownTask \|\| !taskAssignedToCurrentAccount\(knownTask\)/); +}); + +test('ERP browser claims serialize only the assigned account queue', async () => { + const tasks = await source('../src/task-service.ts'); + const claim = tasks.slice(tasks.indexOf('async claimForBrowser('), tasks.indexOf('async recordExecutionResult(')); + assert.match(claim, /pg_advisory_xact_lock\([\s\S]+erp-account-queue:[\s\S]+context\.organizationId, context\.userId/); + assert.doesNotMatch(claim, /SELECT id FROM organizations WHERE id = \$1 FOR UPDATE/); + assert.match(claim, /WHERE t\.organization_id = \$1\s+AND t\.assigned_user_id = \$2[\s\S]+a\.status IN \('accepted', 'running'\)/); + assert.match(claim, /WHERE organization_id = \$1\s+AND assigned_user_id = \$2\s+AND status = 'confirmed'/); + assert.equal((claim.match(/assigned_user_id = \$2/g) || []).length, 2); + assert.match(claim, /\[context\.organizationId, context\.userId\]/); +}); + +test('force delete physically removes accessible tasks without the archive state gate', async () => { + const [tasks, server] = await Promise.all([ + source('../src/task-service.ts'), + source('../src/server.ts') + ]); + const hardDelete = tasks.slice(tasks.indexOf('async hardDeleteTask('), tasks.indexOf('async cancelTask(')); + assert.match(hardDelete, /async hardDeleteTasks\(/); + assert.match(hardDelete, /AND \(\$3::boolean = false OR assigned_user_id = \$4\)/); + assert.match(hardDelete, /DELETE FROM outbox_events[\s\S]+aggregate_id = ANY\(\$2::text\[\]\)/); + assert.match(hardDelete, /DELETE FROM tasks[\s\S]+WHERE id = ANY\(\$1::uuid\[\]\)/); + assert.match(hardDelete, /task\.hard_deleted/); + assert.match(hardDelete, /assigned_user_id: assignedUserId/); + assert.match(hardDelete, /this\.notifyBrowserCommand\(command\)/); + assert.match(hardDelete, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/); + assert.doesNotMatch(hardDelete, /task_archive_blocked|正在处理或等待 ERP 执行,不能归档/); + const bulkDeleteRoute = server.slice( + server.indexOf("app.post('/api/tasks/bulk-delete'"), + server.indexOf("app.post('/api/tasks/bulk-archive'") + ); + const singleDeleteRoute = server.slice( + server.indexOf("app.delete('/api/tasks/:taskId'"), + server.indexOf("app.post('/api/tasks/:taskId/archive'") + ); + assert.match(bulkDeleteRoute, /tasks\.hardDeleteTasks/); + assert.match(singleDeleteRoute, /tasks\.hardDeleteTask/); + assert.doesNotMatch(`${bulkDeleteRoute}\n${singleDeleteRoute}`, /tasks\.archiveTask|tasks\.archiveTasks/); +}); + +test('operator UI exposes role-aware accounts, executive drill-through, archive, and explicit permanent deletion', async () => { const [app, index, retention] = await Promise.all([ source('../../LianSyn-platform/app.js'), source('../../LianSyn-platform/index.html'), @@ -273,6 +355,10 @@ test('operator UI exposes role-aware accounts, executive drill-through, original assert.match(index, /id="operationsDashboardDetail"/); assert.doesNotMatch(index, /平台运行全景|operations-dashboard-hero|OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/); assert.match(index, /id="historyArchiveInput"/); + assert.match(index, /id="historyArchiveSelectedButton"/); + assert.match(index, /id="historyDeleteSelectedButton"[^>]*>强制删除所选]*>强制删除任务 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-authorization-layout-1/); - assert.match(index, /app\.js\?v=20260902-account-authorization-layout-1/); + 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, /id="statusDetailsPopover"/); assert.match(index, /id="statusDetailsRefresh"/); assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/); @@ -1242,6 +1242,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(app, /historyPagination/); assert.match(index, /id="historyBatchActions"/); assert.match(index, /id="historySelectAll"/); + assert.match(index, /id="historyArchiveSelectedButton"/); assert.match(index, /id="historyDeleteSelectedButton"/); assert.match(app, /IS_HISTORY_PAGE = CURRENT_PAGE === '\/history'/); assert.match(index, /href="\/history"/); @@ -1392,28 +1393,39 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(app, /operation_contract_validation/); assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/); assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/); + assert.match(app, /const taskArchiveStates = new Map\(\)/); assert.match(app, /const taskDeleteStates = new Map\(\)/); assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/); assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/); - assert.match(app, /deleteButton\.textContent = task\?\.archived_at \? '恢复中…' : '归档中…'/); - assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/); + assert.match(app, /deleteButton\.textContent = '强制删除中…'/); + assert.match(app, /sendToExtension\('DELETE_TASK'/); + assert.match(app, /method: 'DELETE'/); + assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/); assert.match(taskService, /async archiveTask\(/); assert.match(taskService, /async archiveTasks\(/); assert.match(taskService, /async restoreTask\(/); + assert.match(taskService, /async hardDeleteTask\(/); + assert.match(taskService, /async hardDeleteTasks\(/); assert.match(taskService, /archived_at = now\(\), archived_by = \$1/); assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/); assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/); - assert.doesNotMatch(taskService, /async hardDeleteTask\(/); + assert.match(taskService, /task\.hard_deleted/); + assert.match(taskService, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/); assert.doesNotMatch(taskService, /DELETE FROM audit_events/); - assert.doesNotMatch(taskService, /DELETE FROM tasks/); + assert.match(taskService, /DELETE FROM tasks/); + assert.match(taskService, /erp-account-queue/); + assert.match(taskService, /t\.assigned_user_id = \$2/); + assert.match(taskService, /AND assigned_user_id = \$2\s+AND status = 'confirmed'/); assert.match(server, /taskBulkDeleteSchema/); + assert.match(server, /taskBulkArchiveSchema/); assert.match(server, /\.max\(100\)/); assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/); assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/); assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/); assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/); assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/); - assert.doesNotMatch(server, /tasks\.hardDelete/); + assert.match(server, /tasks\.hardDeleteTasks/); + assert.match(server, /tasks\.hardDeleteTask/); assert.match(bridge, /status: 'deleted'/); assert.match(background, /LTJT_HARD_DELETE_TASK/); assert.doesNotMatch(app, /task-json-output|taskResponseJson/); @@ -1428,6 +1440,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(styles, /\.task-stage-card \{[\s\S]*display: flex;[\s\S]*justify-content: space-between;/); assert.match(styles, /\.workbench-grid \{[\s\S]*min-width: 0;[\s\S]*overflow: hidden;/); assert.match(styles, /overflow-x: hidden/); + assert.match(styles, /\.task-card-actions/); assert.match(styles, /\.task-card-delete/); assert.match(styles, /\.history-batch-actions/); assert.match(app, /currentOption = stage\.options\.find/);