feat(errors): per-account error modal with persisted error list

Accounts finishing done_with_errors showed only a count and a single
last_error. This adds a modal listing every concrete error of the
account's most recent run.

- migration 0005: account_errors table (kind folder|message|account,
  folder, message_ref, error, created_at; ON DELETE CASCADE; indexed)
- store: AddAccountError / ClearAccountErrors / ListAccountErrors
- copy: OnError callback captures per-message error text (previously
  only counted), with a "UID N: subject" reference
- orchestrator: clear errors at run start; persist folder/message/
  account errors; cap 500 rows/account/run with a suppressed-note row
- api: GET /api/tasks/{id}/accounts/{accountId}/errors
- web: AccountErrorsModal, clickable ERRORS count, api + styles

Verified: migration applies on Postgres 18; store add/list/clear and
cascade tests pass against real pg; backend build/vet/test green; web
tsc+vite build and oxlint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9Eq4JtWjyNTv5qat3B3mM
This commit is contained in:
2026-07-05 12:16:12 +07:00
co-authored by Claude Opus 4.8
parent 2623bc8815
commit 45b0ff2358
15 changed files with 448 additions and 12 deletions
@@ -0,0 +1,100 @@
# Account Errors Modal — Design
Date: 2026-07-05
Status: approved
## Problem
When an account finishes `done_with_errors`, the UI shows only an error **count**
(e.g. `2`) and a single persisted `last_error`. There is no way to see the
individual errors — which folder, which message, and the actual error text. The
event log holds them live but is lost on reload, and message-level errors
(`res.Errors`) currently record only a counter, discarding their text entirely.
## Goal
Click the ERRORS count of an account (when `> 0`) to open a modal listing the
concrete errors of that account's **most recent run**: folder, kind, message
reference, error text, and timestamp.
## Scope decisions
- **Coverage:** errors of the latest run only. The list is cleared at the start
of each run (like the per-account counters).
- **Granularity:** every error is its own record — folder-level, message-level,
and account-level (connect/login) are distinct rows.
- **Cap:** at most 500 error rows per account per run (guard against a corrupt
mailbox producing thousands). On overflow a final synthetic row records
"… N more errors suppressed".
## Data model
New table (chosen over a JSONB column on `accounts`): a table gives atomic
`INSERT` per error with no read-modify-write races, trivial per-run clearing,
`ON DELETE CASCADE` with the account, and matches the existing `runs` /
`migrated_messages` shape.
Migration `0005_account_errors`:
```
account_errors(
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
run_id BIGINT, -- run the error belongs to (context)
kind TEXT NOT NULL, -- 'folder' | 'message' | 'account'
folder TEXT NOT NULL DEFAULT '',
message_ref TEXT NOT NULL DEFAULT '', -- e.g. "UID 42: <subject>"
error TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
CREATE INDEX ON account_errors(account_id);
```
Down migration: `DROP TABLE account_errors`.
## Backend
**Store** (`internal/store/account_errors.go`):
- `AddAccountError(ctx, accountID, runID int64, kind, folder, ref, msg string) error`
- `ClearAccountErrors(ctx, accountID int64) error`
- `ListAccountErrors(ctx, accountID int64) ([]AccountError, error)` — ordered by id.
- `AccountError` struct mirrors the row.
**Orchestrator** (`internal/orchestrator/orchestrator.go`):
- At account start (next to `ResetAccountCounters`): `ClearAccountErrors`.
- New `CopyDeps.OnError(kind, folder, ref, msg string)` callback. `CopyFolder`
invokes it on every message-level error (the `res.Errors++` sites in
`copy.go`) and folder-level error, passing UID/subject where available. The
orchestrator's `OnError` impl persists via `AddAccountError`, enforcing the
500-row cap with an in-worker counter.
- Folder-level copy error (already persisted to `last_error`) also emits
`OnError(kind="folder", ...)`.
- `accountFailed` (connect/login/decrypt) emits `OnError(kind="account", ...)`.
- `last_error` behavior is unchanged (still the latest single error for the
inline row hint).
**HTTP** (`internal/httpapi`):
- `GET /api/tasks/{id}/accounts/{accountId}/errors` → JSON `[]AccountError`.
## Frontend
- `web/src/api.ts`: `listAccountErrors(taskId, accountId)`.
- ERRORS cell becomes a button when the count `> 0`; opens `AccountErrorsModal`.
- `web/src/components/AccountErrorsModal.tsx` (built on existing `Modal`,
styled like `RunLogModal`): fetches on open, renders rows
`time · folder · kind · message_ref · error`, with loading / empty /
fetch-error states.
## Testing
- Store: `AddAccountError` / `ClearAccountErrors` / `ListAccountErrors` against
Postgres (existing store test harness).
- `CopyFolder`: `OnError` is invoked when a per-message step fails (e.g.
`IsMigrated` returns an error) — unit test with fakes, no server needed.
- HTTP: handler returns the account's errors as JSON.
## Out of scope
- Cross-run error history.
- Retrying individual failed messages from the modal.
- Exporting errors.