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
4.0 KiB
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) errorClearAccountErrors(ctx, accountID int64) errorListAccountErrors(ctx, accountID int64) ([]AccountError, error)— ordered by id.AccountErrorstruct mirrors the row.
Orchestrator (internal/orchestrator/orchestrator.go):
- At account start (next to
ResetAccountCounters):ClearAccountErrors. - New
CopyDeps.OnError(kind, folder, ref, msg string)callback.CopyFolderinvokes it on every message-level error (theres.Errors++sites incopy.go) and folder-level error, passing UID/subject where available. The orchestrator'sOnErrorimpl persists viaAddAccountError, enforcing the 500-row cap with an in-worker counter. - Folder-level copy error (already persisted to
last_error) also emitsOnError(kind="folder", ...). accountFailed(connect/login/decrypt) emitsOnError(kind="account", ...).last_errorbehavior 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; opensAccountErrorsModal. web/src/components/AccountErrorsModal.tsx(built on existingModal, styled likeRunLogModal): fetches on open, renders rowstime · folder · kind · message_ref · error, with loading / empty / fetch-error states.
Testing
- Store:
AddAccountError/ClearAccountErrors/ListAccountErrorsagainst Postgres (existing store test harness). CopyFolder:OnErroris invoked when a per-message step fails (e.g.IsMigratedreturns 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.