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
101 lines
4.0 KiB
Markdown
101 lines
4.0 KiB
Markdown
# 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.
|