Compare commits
6
Commits
692a83a468
...
5d296c39b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d296c39b1
|
||
|
|
fc335dee3f
|
||
|
|
b439eee164
|
||
|
|
2e5036aad5
|
||
|
|
599c66189c
|
||
|
|
315e56046b
|
@@ -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<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:
|
||||
|
||||
```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
|
||||
<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:
|
||||
|
||||
```tsx
|
||||
<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>`:
|
||||
|
||||
```tsx
|
||||
<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`:
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```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.
|
||||
+32
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
||||
const [showRuns, setShowRuns] = useState(false)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 }) {
|
||||
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
|
||||
{busy === 'test' ? 'Testing…' : 'Test connections'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
|
||||
{busy === 'run' ? 'Starting…' : 'Run migration'}
|
||||
<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>
|
||||
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
<div className="sched-row">
|
||||
<label htmlFor="sched">Schedule</label>
|
||||
@@ -540,6 +578,18 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th>Account</th>
|
||||
<th>Src test</th>
|
||||
<th>Dst test</th>
|
||||
@@ -554,11 +604,20 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
<tbody>
|
||||
{accounts.length === 0 ? (
|
||||
<tr className="empty-row">
|
||||
<td colSpan={9}>no accounts yet — add one or import a CSV above</td>
|
||||
<td colSpan={10}>no accounts yet — add one or import a CSV above</td>
|
||||
</tr>
|
||||
) : (
|
||||
accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<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>
|
||||
<td>
|
||||
<div className="acct-ident">
|
||||
<span>{a.src_login}</span>
|
||||
|
||||
Reference in New Issue
Block a user