diff --git a/docs/superpowers/plans/2026-07-08-selective-account-run.md b/docs/superpowers/plans/2026-07-08-selective-account-run.md new file mode 100644 index 0000000..d0b6b62 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-selective-account-run.md @@ -0,0 +1,546 @@ +# 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`: + +```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`: + +```go +var ErrNoAccountsSelected = errors.New("no matching accounts selected") +``` + +Add the helper (place it near `gateOK`): + +```go +// 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: + +```go +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: + +```go +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: + +```go +if _, err := s.orch.Run(ctx, id, "scheduled"); err != nil { +``` + +to: + +```go +if _, err := s.orch.Run(ctx, id, "scheduled", nil); err != nil { +``` + +In `internal/httpapi/run.go`, inside `handleRun`, change: + +```go +runID, err := s.orch.Run(r.Context(), taskID, "manual") +``` + +to (IDs wired in Task 2; for now pass `nil` to keep it compiling): + +```go +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** + +```bash +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): + +```go +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: + +```go +// 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: + +```go + 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: + +```go + 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** + +```bash +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: + +```ts +export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' }) +``` + +with: + +```ts +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** + +```bash +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): + +```tsx + const [selected, setSelected] = useState>(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: + +```tsx + 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): + +```tsx + 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: + +```tsx + 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): + +```tsx + + {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} +``` + +with: + +```tsx + + {!runReady && accounts.length > 0 && ( + + {effectiveSelected.length > 0 + ? 'selected accounts must pass both connection tests' + : 'run unlocks once every account tests OK on both sides'} + + )} +``` + +- [ ] **Step 5: Add the checkbox header column** + +In the accounts table `` (near line 542), add a leading `` before `Account`: + +```tsx + + { + if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked + }} + disabled={isRunning || selectableIds.length === 0} + onChange={(e) => toggleAll(e.target.checked)} + /> + +``` + +- [ ] **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 `` map (near line 561), add a leading `` as the first child, before the `` with `acct-ident`: + +```tsx + + toggleOne(a.id, e.target.checked)} + /> + +``` + +- [ ] **Step 7: Add checkbox column CSS** + +In `web/src/app.css`, append: + +```css +.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** + +```bash +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. diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index 2195673..79583df 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -1,8 +1,11 @@ package httpapi import ( + "bytes" "context" + "encoding/json" "errors" + "io" "net/http" "github.com/vasyansk/imap-copier/internal/crypto" @@ -63,13 +66,41 @@ func (s *Server) handleTestAccounts(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) } +// 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 +} + func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { taskID, err := pathID(r, "id") if err != nil { http.Error(w, "bad id", http.StatusBadRequest) return } - runID, err := s.orch.Run(r.Context(), taskID, "manual") + 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 diff --git a/internal/httpapi/run_test.go b/internal/httpapi/run_test.go index 2e7e04b..fa23914 100644 --- a/internal/httpapi/run_test.go +++ b/internal/httpapi/run_test.go @@ -26,3 +26,25 @@ func TestImportCSVFailsOnBadEncKey(t *testing.T) { t.Fatalf("import must fail on bad EncKey, got %d", rw.Code) } } + +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") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index eb461cb..96cfff1 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -16,6 +16,7 @@ import ( var ErrNotTested = errors.New("accounts not fully tested") var ErrAlreadyRunning = errors.New("task already running") +var ErrNoAccountsSelected = errors.New("no matching accounts selected") // maxAccountErrors caps how many individual error rows one account records per // run, so a corrupt mailbox producing thousands of failures can't bloat the @@ -99,6 +100,26 @@ func (o *Orchestrator) unregisterCancel(accountID int64) { o.mu.Unlock() } +// 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 +} + func gateOK(accs []store.Account) bool { if len(accs) == 0 { return false @@ -173,7 +194,7 @@ func shouldBreak(trigger string, totErr int64) bool { return trigger == "scheduled" && totErr > 0 } -func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (int64, error) { +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 @@ -182,6 +203,12 @@ func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (i 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 } diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index d83ea6c..2dc363c 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -22,3 +22,23 @@ func TestGateOK(t *testing.T) { t.Fatal("empty accounts must fail gate") } } + +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)) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 6844ced..2d6ae27 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -67,7 +67,7 @@ func (s *Scheduler) tick(ctx context.Context) { return } for _, id := range dueTaskIDs(tasks, time.Now()) { - if _, err := s.orch.Run(ctx, id, "scheduled"); err != nil { + if _, err := s.orch.Run(ctx, id, "scheduled", nil); err != nil { // ErrAlreadyRunning / ErrNotTested are expected races/edge cases, not fatal. slog.Info("scheduler: run skipped", "task", id, "err", err) } diff --git a/web/src/api.ts b/web/src/api.ts index 93e3e94..36a0073 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -138,7 +138,8 @@ export const createAccount = ( export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' }) -export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' }) +export const runTask = (id: number, accountIds?: number[]) => + api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' }) export interface Run { id: number diff --git a/web/src/app.css b/web/src/app.css index caf9ef0..6eaf533 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1056,3 +1056,16 @@ table.tbl a.rowlink:hover { padding: 12px 14px; } } + +.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; +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index a5291d6..1ce3f64 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -90,6 +90,7 @@ export function TaskDetail({ id }: { id: number }) { const [live, setLive] = useState>({}) const [showRuns, setShowRuns] = useState(false) const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null) + const [selected, setSelected] = useState>(new Set()) const fileInputRef = useRef(null) function reload() { @@ -332,7 +333,8 @@ export function TaskDetail({ id }: { id: number }) { setBusy('run') setError(null) try { - await runTask(id) + 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 { @@ -366,7 +368,33 @@ export function TaskDetail({ id }: { id: number }) { } const { task, accounts } = data - const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') + 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()) + } // Prefer live (WS) copied/skipped over the DB values, which only advance per // folder — so the summary moves in real time during a large folder. const totals = accounts.reduce( @@ -418,10 +446,20 @@ export function TaskDetail({ id }: { id: number }) { - - {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} + {!runReady && accounts.length > 0 && ( + + {effectiveSelected.length > 0 + ? 'selected accounts must pass both connection tests' + : 'run unlocks once every account tests OK on both sides'} + + )}
@@ -540,6 +578,18 @@ export function TaskDetail({ id }: { id: number }) { + @@ -554,11 +604,20 @@ export function TaskDetail({ id }: { id: number }) { {accounts.length === 0 ? ( - + ) : ( accounts.map((a) => ( +
+ { + if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked + }} + disabled={isRunning || selectableIds.length === 0} + onChange={(e) => toggleAll(e.target.checked)} + /> + Account Src test Dst test
no accounts yet — add one or import a CSV aboveno accounts yet — add one or import a CSV above
+ toggleOne(a.id, e.target.checked)} + /> +
{a.src_login}