Files
imap-copier/docs/superpowers/plans/2026-07-08-selective-account-run.md
T
2026-07-08 13:55:02 +07:00

18 KiB

Selective Account Run Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let the user run a migration for only the checkbox-selected accounts; with no selection, run all (unchanged).

Architecture: A pure selectAccounts filter narrows the orchestrator's account slice before the test gate; the run HTTP handler parses an optional account_ids body and passes it through. The accounts table gains row checkboxes and a "select all" header, and the single "Run migration" button becomes "Run selected (N)" when a selection exists. Copy is already idempotent (dedup by message key), so a re-run of selected accounts skips copied messages and delivers the missing tail.

Tech Stack: Go 1.x (net/http, encoding/json, standard testing), React + TypeScript + Vite, existing REST/WS client.

Global Constraints

  • Backend module path: github.com/vasyansk/imap-copier.
  • Run with an empty/nil account-ID set MUST behave exactly as today (all accounts) — the scheduler depends on it.
  • Preserve existing handleRun error mapping: ErrNotTested → 409 "accounts must pass connection tests first"; ErrAlreadyRunning → 409 "task is already running".
  • Frontend has no unit-test harness; frontend tasks verify via npm run build + tsc --noEmit + manual app check.
  • Follow existing code style: no new dependencies.

Task 1: Orchestrator account filter + Run signature

Files:

  • Modify: internal/orchestrator/orchestrator.go
  • Test: internal/orchestrator/orchestrator_test.go

Interfaces:

  • Produces:

    • func selectAccounts(accs []store.Account, ids []int64) []store.Account — returns accs unchanged when ids is empty/nil; otherwise returns only the accounts whose ID is in ids, preserving input order.
    • var ErrNoAccountsSelected = errors.New("no matching accounts selected")
    • func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string, accountIDs []int64) (int64, error) — new trailing accountIDs param.
  • Step 1: Write the failing test

Add to internal/orchestrator/orchestrator_test.go:

func TestSelectAccounts(t *testing.T) {
	accs := []store.Account{{ID: 1}, {ID: 2}, {ID: 3}}

	if got := selectAccounts(accs, nil); len(got) != 3 {
		t.Fatalf("nil ids must return all, got %d", len(got))
	}
	if got := selectAccounts(accs, []int64{}); len(got) != 3 {
		t.Fatalf("empty ids must return all, got %d", len(got))
	}

	got := selectAccounts(accs, []int64{3, 1})
	if len(got) != 2 || got[0].ID != 1 || got[1].ID != 3 {
		t.Fatalf("must keep matching ids in input order, got %+v", got)
	}

	if got := selectAccounts(accs, []int64{99}); len(got) != 0 {
		t.Fatalf("unknown ids must yield empty, got %d", len(got))
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/orchestrator/ -run TestSelectAccounts -v Expected: FAIL — undefined: selectAccounts.

  • Step 3: Add the filter helper and sentinel error

In internal/orchestrator/orchestrator.go, add the sentinel next to the existing var ErrNotTested/ErrAlreadyRunning:

var ErrNoAccountsSelected = errors.New("no matching accounts selected")

Add the helper (place it near gateOK):

// selectAccounts narrows accs to those whose ID is in ids, preserving input
// order. An empty or nil ids means "all accounts" — the scheduler and the
// unfiltered manual run rely on this.
func selectAccounts(accs []store.Account, ids []int64) []store.Account {
	if len(ids) == 0 {
		return accs
	}
	want := make(map[int64]struct{}, len(ids))
	for _, id := range ids {
		want[id] = struct{}{}
	}
	out := make([]store.Account, 0, len(ids))
	for _, a := range accs {
		if _, ok := want[a.ID]; ok {
			out = append(out, a)
		}
	}
	return out
}
  • Step 4: Run test to verify it passes

Run: go test ./internal/orchestrator/ -run TestSelectAccounts -v Expected: PASS.

  • Step 5: Thread accountIDs through Run

In internal/orchestrator/orchestrator.go, change the Run signature and apply the filter after loading accounts. Replace:

func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (int64, error) {
	task, err := o.store.GetTask(ctx, taskID)
	if err != nil {
		return 0, err
	}
	accs, err := o.store.ListAccountsByTask(ctx, taskID)
	if err != nil {
		return 0, err
	}
	if !gateOK(accs) {
		return 0, ErrNotTested
	}

with:

func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string, accountIDs []int64) (int64, error) {
	task, err := o.store.GetTask(ctx, taskID)
	if err != nil {
		return 0, err
	}
	accs, err := o.store.ListAccountsByTask(ctx, taskID)
	if err != nil {
		return 0, err
	}
	accs = selectAccounts(accs, accountIDs)
	// A non-empty request that matched nothing is a client error, distinct
	// from "not tested".
	if len(accountIDs) > 0 && len(accs) == 0 {
		return 0, ErrNoAccountsSelected
	}
	if !gateOK(accs) {
		return 0, ErrNotTested
	}

The rest of Run (endpoints, TryMarkTaskRunning, CreateRun, go o.runAll(...)) is unchanged — it already operates over the accs slice.

  • Step 6: Update the two Run call sites so the package compiles

In internal/scheduler/scheduler.go, change:

if _, err := s.orch.Run(ctx, id, "scheduled"); err != nil {

to:

if _, err := s.orch.Run(ctx, id, "scheduled", nil); err != nil {

In internal/httpapi/run.go, inside handleRun, change:

runID, err := s.orch.Run(r.Context(), taskID, "manual")

to (IDs wired in Task 2; for now pass nil to keep it compiling):

runID, err := s.orch.Run(r.Context(), taskID, "manual", nil)
  • Step 7: Verify the whole backend builds and tests pass

Run: go build ./... && go test ./internal/orchestrator/ ./internal/scheduler/ -v Expected: build succeeds; all tests PASS.

  • Step 8: Commit
git add internal/orchestrator/orchestrator.go internal/orchestrator/orchestrator_test.go internal/scheduler/scheduler.go internal/httpapi/run.go
git commit -m "Add selectAccounts filter and accountIDs param to orchestrator.Run"

Task 2: handleRun parses optional account_ids

Files:

  • Modify: internal/httpapi/run.go
  • Test: internal/httpapi/run_test.go

Interfaces:

  • Consumes: orchestrator.Run(ctx, taskID, "manual", accountIDs), orchestrator.ErrNoAccountsSelected (Task 1).

  • Produces:

    • func parseRunAccountIDs(r *http.Request) ([]int64, error) — returns nil for an empty body; otherwise decodes {"account_ids":[...]} and returns the slice (possibly empty). Returns an error on malformed JSON.
  • Step 1: Write the failing test

Add to internal/httpapi/run_test.go (the strings import is already present):

func TestParseRunAccountIDs(t *testing.T) {
	// empty body => nil (run all)
	req := httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(""))
	ids, err := parseRunAccountIDs(req)
	if err != nil || ids != nil {
		t.Fatalf("empty body must yield nil ids, got %v err=%v", ids, err)
	}

	// explicit selection
	req = httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(`{"account_ids":[3,7]}`))
	ids, err = parseRunAccountIDs(req)
	if err != nil || len(ids) != 2 || ids[0] != 3 || ids[1] != 7 {
		t.Fatalf("must parse account_ids, got %v err=%v", ids, err)
	}

	// malformed JSON => error
	req = httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(`{bad`))
	if _, err := parseRunAccountIDs(req); err == nil {
		t.Fatal("malformed body must error")
	}
}
  • Step 2: Run test to verify it fails

Run: go test ./internal/httpapi/ -run TestParseRunAccountIDs -v Expected: FAIL — undefined: parseRunAccountIDs.

  • Step 3: Implement parseRunAccountIDs and wire it into handleRun

In internal/httpapi/run.go, add the imports encoding/json and io to the existing import block, then add the helper:

// parseRunAccountIDs reads an optional {"account_ids":[...]} run body. An empty
// body means "all accounts" and yields a nil slice. Malformed JSON is an error.
func parseRunAccountIDs(r *http.Request) ([]int64, error) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		return nil, err
	}
	if len(bytes.TrimSpace(raw)) == 0 {
		return nil, nil
	}
	var body struct {
		AccountIDs []int64 `json:"account_ids"`
	}
	if err := json.Unmarshal(raw, &body); err != nil {
		return nil, err
	}
	return body.AccountIDs, nil
}

Add "bytes" to the import block as well (used by bytes.TrimSpace).

Then replace the body of handleRun's run call. Change:

	runID, err := s.orch.Run(r.Context(), taskID, "manual", nil)
	if errors.Is(err, orchestrator.ErrNotTested) {
		http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
		return
	}
	if errors.Is(err, orchestrator.ErrAlreadyRunning) {
		http.Error(w, "task is already running", http.StatusConflict)
		return
	}

to:

	accountIDs, err := parseRunAccountIDs(r)
	if err != nil {
		http.Error(w, "bad request body", http.StatusBadRequest)
		return
	}
	runID, err := s.orch.Run(r.Context(), taskID, "manual", accountIDs)
	if errors.Is(err, orchestrator.ErrNoAccountsSelected) {
		http.Error(w, "no matching accounts selected", http.StatusBadRequest)
		return
	}
	if errors.Is(err, orchestrator.ErrNotTested) {
		http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
		return
	}
	if errors.Is(err, orchestrator.ErrAlreadyRunning) {
		http.Error(w, "task is already running", http.StatusConflict)
		return
	}
  • Step 4: Run test to verify it passes

Run: go test ./internal/httpapi/ -run TestParseRunAccountIDs -v Expected: PASS.

  • Step 5: Verify the whole backend builds and the httpapi suite passes

Run: go build ./... && go test ./internal/httpapi/ -v Expected: build succeeds; all tests PASS.

  • Step 6: Commit
git add internal/httpapi/run.go internal/httpapi/run_test.go
git commit -m "Parse optional account_ids in run handler"

Task 3: Frontend runTask accepts account IDs

Files:

  • Modify: web/src/api.ts:141

Interfaces:

  • Produces: runTask(id: number, accountIds?: number[]) => Promise<...> — sends {account_ids} JSON only when a non-empty list is given; otherwise a bare POST (all accounts).

  • Step 1: Update runTask

In web/src/api.ts, replace:

export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' })

with:

export const runTask = (id: number, accountIds?: number[]) =>
  api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' })
  • Step 2: Verify typecheck

Run: cd web && npx tsc --noEmit Expected: no errors from api.ts (pre-existing warnings elsewhere, if any, are unrelated).

  • Step 3: Commit
git add web/src/api.ts
git commit -m "runTask accepts optional accountIds"

Task 4: Accounts table checkboxes + selective Run button

Files:

  • Modify: web/src/pages/TaskDetail.tsx
  • Modify: web/src/app.css

Interfaces:

  • Consumes: runTask(id, accountIds?) (Task 3).

  • Produces: no exported interface; internal UI state only.

  • Step 1: Add selection state and helpers

In web/src/pages/TaskDetail.tsx, add a state hook next to the other useState declarations (near line 90):

  const [selected, setSelected] = useState<Set<number>>(new Set())
  • Step 2: Compute selectable rows and the effective run set

After the existing const { task, accounts } = data / allTested block (near line 369), add:

  const isRunning = task.status === 'running'
  // A row is selectable only when both connection tests pass and no run is live.
  const selectableIds = accounts
    .filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
    .map((a) => a.id)
  const selectableSet = new Set(selectableIds)
  // Effective set: the checked accounts, or all accounts when nothing is checked.
  const effectiveSelected = accounts.filter((a) => selected.has(a.id))
  const runSet = effectiveSelected.length > 0 ? effectiveSelected : accounts
  const runReady =
    runSet.length > 0 && runSet.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
  const allSelectableChecked =
    selectableIds.length > 0 && selectableIds.every((id) => selected.has(id))
  const someSelectableChecked = selectableIds.some((id) => selected.has(id))

  function toggleOne(accId: number, checked: boolean) {
    setSelected((prev) => {
      const next = new Set(prev)
      if (checked) next.add(accId)
      else next.delete(accId)
      return next
    })
  }

  function toggleAll(checked: boolean) {
    setSelected(checked ? new Set(selectableIds) : new Set())
  }
  • Step 3: Make onRun send the selection

Replace the existing onRun function (near line 331):

  async function onRun() {
    setBusy('run')
    setError(null)
    try {
      await runTask(id)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to start run')
    } finally {
      setBusy(null)
    }
  }

with:

  async function onRun() {
    setBusy('run')
    setError(null)
    try {
      const ids = accounts.filter((a) => selected.has(a.id)).map((a) => a.id)
      await runTask(id, ids.length ? ids : undefined)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to start run')
    } finally {
      setBusy(null)
    }
  }
  • Step 4: Update the Run button label and gate

Replace the run button (near line 421):

          <button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
            {busy === 'run' ? 'Starting…' : 'Run migration'}
          </button>
          {!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}

with:

          <button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !runReady || isRunning}>
            {busy === 'run'
              ? 'Starting…'
              : effectiveSelected.length > 0
                ? `Run selected (${effectiveSelected.length})`
                : 'Run migration'}
          </button>
          {!runReady && accounts.length > 0 && (
            <span className="hint">
              {effectiveSelected.length > 0
                ? 'selected accounts must pass both connection tests'
                : 'run unlocks once every account tests OK on both sides'}
            </span>
          )}
  • Step 5: Add the checkbox header column

In the accounts table <thead><tr> (near line 542), add a leading <th> before <th>Account</th>:

                <th className="chk-col">
                  <input
                    type="checkbox"
                    aria-label="Select all accounts"
                    checked={allSelectableChecked}
                    ref={(el) => {
                      if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked
                    }}
                    disabled={isRunning || selectableIds.length === 0}
                    onChange={(e) => toggleAll(e.target.checked)}
                  />
                </th>
  • Step 6: Add the per-row checkbox cell and fix the empty-row colspan

In the empty-row branch (near line 557), change colSpan={9} to colSpan={10}.

In the account <tr> map (near line 561), add a leading <td> as the first child, before the <td> with acct-ident:

                    <td className="chk-col">
                      <input
                        type="checkbox"
                        aria-label={`Select ${a.src_login}`}
                        checked={selected.has(a.id)}
                        disabled={isRunning || !selectableSet.has(a.id)}
                        onChange={(e) => toggleOne(a.id, e.target.checked)}
                      />
                    </td>
  • Step 7: Add checkbox column CSS

In web/src/app.css, append:

.tbl .chk-col {
  width: 32px;
  text-align: center;
  padding-right: 0;
}
.tbl .chk-col input[type='checkbox'] {
  cursor: pointer;
}
.tbl .chk-col input[type='checkbox']:disabled {
  cursor: not-allowed;
  opacity: 0.4;
}
  • Step 8: Build and typecheck

Run: cd web && npx tsc --noEmit && npm run build Expected: typecheck clean (any pre-existing oxlint/fast-refresh warnings are unrelated); Vite build succeeds.

  • Step 9: Manual verification in the running app

Start the app, open a task with ≥2 accounts that have passed both tests:

  1. With nothing checked, the button reads "Run migration" and runs all accounts (confirm account_started WS events for every account).
  2. Check a subset; the button reads "Run selected (N)". Run it; confirm only the checked accounts start, and the unchecked accounts' Copied/Skipped/Status are untouched.
  3. Confirm checkboxes for untested accounts are disabled, and the header checkbox toggles only the selectable rows.
  • Step 10: Commit
git add web/src/pages/TaskDetail.tsx web/src/app.css
git commit -m "Add account selection checkboxes and selective run button"

Notes for the executor

  • Tasks 1 and 2 are backend and must land in order (Task 2 depends on the new Run signature and ErrNoAccountsSelected). Task 3 and 4 are frontend; Task 4 depends on Task 3.
  • The orchestrator's Run is integration-heavy (spawns goroutines, needs a real store/hub), so it is not unit-tested directly; the pure selectAccounts filter and the parseRunAccountIDs helper carry the backend test coverage, and Task 4 Step 9 covers the end-to-end behavior manually.