diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go
index 0e842ae..e4b8d9e 100644
--- a/internal/httpapi/router.go
+++ b/internal/httpapi/router.go
@@ -31,6 +31,9 @@ func (s *Server) Router() http.Handler {
api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV)
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun)
+ api.HandleFunc("POST /api/tasks/{id}/pause", s.handlePauseRun)
+ api.HandleFunc("POST /api/tasks/{id}/cancel", s.handleCancelRun)
+ api.HandleFunc("POST /api/tasks/{id}/resume", s.handleResumeRun)
api.HandleFunc("POST /api/tasks/{id}/accounts/{accountId}/cancel", s.handleCancelAccount)
api.HandleFunc("POST /api/tasks/{id}/accounts/{accountId}/probe", s.handleProbeAccountFolders)
api.HandleFunc("PUT /api/tasks/{id}/accounts/{accountId}/folder-mapping", s.handleSetAccountFolderMapping)
diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go
index dbcecaa..a658d74 100644
--- a/internal/httpapi/run.go
+++ b/internal/httpapi/run.go
@@ -127,6 +127,56 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID})
}
+// handlePauseRun stops the live run but keeps the unfinished accounts
+// resumable; handleCancelRun stops it for good. Both are no-ops (409) when the
+// task has no run in flight.
+func (s *Server) handlePauseRun(w http.ResponseWriter, r *http.Request) {
+ s.stopRun(w, r, s.orch.PauseTask)
+}
+
+func (s *Server) handleCancelRun(w http.ResponseWriter, r *http.Request) {
+ s.stopRun(w, r, s.orch.CancelTask)
+}
+
+func (s *Server) stopRun(w http.ResponseWriter, r *http.Request, stop func(int64) bool) {
+ taskID, err := pathID(r, "id")
+ if err != nil {
+ http.Error(w, "bad id", http.StatusBadRequest)
+ return
+ }
+ if !stop(taskID) {
+ http.Error(w, "task is not running", http.StatusConflict)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+}
+
+// handleResumeRun restarts a paused task with the accounts its pause left
+// unfinished; already-copied messages are skipped by the migration journal.
+func (s *Server) handleResumeRun(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.ResumeTask(r.Context(), taskID)
+ switch {
+ case errors.Is(err, orchestrator.ErrNothingToResume):
+ http.Error(w, "no paused accounts to resume", http.StatusConflict)
+ return
+ case errors.Is(err, orchestrator.ErrNotTested):
+ http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
+ return
+ case errors.Is(err, orchestrator.ErrAlreadyRunning):
+ http.Error(w, "task is already running", http.StatusConflict)
+ return
+ case err != nil:
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID})
+}
+
func (s *Server) handleCancelAccount(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 21e6feb..b27325e 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -17,6 +17,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")
+var ErrNothingToResume = errors.New("no paused accounts to resume")
// maxAccountErrors caps how many individual error rows one account records per
// run, so a corrupt mailbox producing thousands of failures can't bloat the
@@ -62,6 +63,38 @@ func planFolders(folders []string, mapping map[string]string, excluded []string)
return plan
}
+// A run stops either on its own or because the operator intervened. Pausing and
+// cancelling take the same path — stop the in-flight work — and differ only in
+// the status left behind: paused accounts are what Resume picks up again.
+type stopReason int32
+
+const (
+ stopNone stopReason = iota
+ stopPaused
+ stopCancelled
+)
+
+func (r stopReason) accountStatus() string {
+ if r == stopPaused {
+ return "paused"
+ }
+ return "cancelled"
+}
+
+// runHandle is the live state of one task's run: the cancel that stops every
+// account under it, plus why it was stopped.
+type runHandle struct {
+ cancel context.CancelFunc
+ reason atomic.Int32
+}
+
+func (h *runHandle) stopWith(r stopReason) {
+ h.reason.CompareAndSwap(int32(stopNone), int32(r))
+ h.cancel()
+}
+
+func (h *runHandle) stopReason() stopReason { return stopReason(h.reason.Load()) }
+
type Orchestrator struct {
store *store.Store
hub *wshub.Hub
@@ -70,10 +103,66 @@ type Orchestrator struct {
mu sync.Mutex
cancels map[int64]context.CancelFunc // account_id -> cancel of its in-flight copy
+ runs map[int64]*runHandle // task_id -> live run
}
func New(s *store.Store, hub *wshub.Hub, encKey []byte, concurrency int) *Orchestrator {
- return &Orchestrator{store: s, hub: hub, encKey: encKey, concurrency: concurrency, cancels: map[int64]context.CancelFunc{}}
+ return &Orchestrator{
+ store: s, hub: hub, encKey: encKey, concurrency: concurrency,
+ cancels: map[int64]context.CancelFunc{},
+ runs: map[int64]*runHandle{},
+ }
+}
+
+// PauseTask stops the task's live run, leaving every unfinished account
+// "paused" so ResumeTask can pick them up. Returns false if nothing is running.
+func (o *Orchestrator) PauseTask(taskID int64) bool { return o.stopRun(taskID, stopPaused) }
+
+// CancelTask stops the task's live run and marks every unfinished account
+// "cancelled". Returns false if nothing is running.
+func (o *Orchestrator) CancelTask(taskID int64) bool { return o.stopRun(taskID, stopCancelled) }
+
+func (o *Orchestrator) stopRun(taskID int64, reason stopReason) bool {
+ o.mu.Lock()
+ h, ok := o.runs[taskID]
+ o.mu.Unlock()
+ if !ok {
+ return false
+ }
+ h.stopWith(reason)
+ return true
+}
+
+func (o *Orchestrator) registerRun(taskID int64, h *runHandle) {
+ o.mu.Lock()
+ o.runs[taskID] = h
+ o.mu.Unlock()
+}
+
+func (o *Orchestrator) unregisterRun(taskID int64) {
+ o.mu.Lock()
+ delete(o.runs, taskID)
+ o.mu.Unlock()
+}
+
+// ResumeTask restarts a paused task with exactly the accounts the pause left
+// unfinished. Everything already copied is skipped by the migration journal, so
+// each account continues where it stopped.
+func (o *Orchestrator) ResumeTask(ctx context.Context, taskID int64) (int64, error) {
+ accs, err := o.store.ListAccountsByTask(ctx, taskID)
+ if err != nil {
+ return 0, err
+ }
+ ids := make([]int64, 0, len(accs))
+ for _, a := range accs {
+ if a.Status == "paused" {
+ ids = append(ids, a.ID)
+ }
+ }
+ if len(ids) == 0 {
+ return 0, ErrNothingToResume
+ }
+ return o.Run(ctx, taskID, "manual", ids)
}
// CancelAccount aborts the in-flight copy for one account, if it is running.
@@ -231,11 +320,21 @@ func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string, ac
}
o.hub.Publish(wshub.Event{Type: "run_started", TaskID: taskID, Data: map[string]any{"run_id": runID}})
- go o.runAll(context.WithoutCancel(ctx), task, runID, accs, srcEP, dstEP, trigger)
+ // dbCtx outlives the request so status/counter writes still land after a
+ // pause or cancel; runCtx is what Pause/Cancel actually stop, and every
+ // account's IMAP work hangs off it.
+ dbCtx := context.WithoutCancel(ctx)
+ runCtx, runCancel := context.WithCancel(dbCtx)
+ h := &runHandle{cancel: runCancel}
+ o.registerRun(taskID, h)
+
+ go o.runAll(dbCtx, runCtx, h, task, runID, accs, srcEP, dstEP, trigger)
return runID, nil
}
-func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, accs []store.Account, srcEP, dstEP imapx.Endpoint, trigger string) {
+func (o *Orchestrator) runAll(ctx, runCtx context.Context, h *runHandle, task store.Task, runID int64, accs []store.Account, srcEP, dstEP imapx.Endpoint, trigger string) {
+ defer o.unregisterRun(task.ID)
+ defer h.cancel()
defer func() {
if r := recover(); r != nil {
slog.Error("run coordinator panicked", "task", task.ID, "run", runID, "panic", r)
@@ -256,7 +355,17 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64,
sem := make(chan struct{}, o.concurrency)
var wg sync.WaitGroup
- for _, a := range accs {
+ for i, a := range accs {
+ // Stopped mid-queue: the accounts that never started are marked with the
+ // same status as the ones that were interrupted, so a pause leaves the
+ // whole remainder resumable and a cancel leaves it cancelled.
+ if runCtx.Err() != nil {
+ st := h.stopReason().accountStatus()
+ for _, rest := range accs[i:] {
+ _ = o.store.SetAccountStatus(ctx, rest.ID, st)
+ }
+ break
+ }
wg.Add(1)
sem <- struct{}{}
go func(a store.Account) {
@@ -273,7 +382,7 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64,
mu.Unlock()
}
}()
- c, s, e := o.runAccount(ctx, task, runID, a, srcEP, dstEP)
+ c, s, e := o.runAccount(ctx, runCtx, h, task, runID, a, srcEP, dstEP)
mu.Lock()
totCopied += c
totSkipped += s
@@ -283,23 +392,32 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64,
}
wg.Wait()
+ reason := h.stopReason()
status := "done"
- if totErr > 0 {
+ switch {
+ case reason == stopPaused:
+ status = "paused"
+ case reason == stopCancelled:
+ status = "cancelled"
+ case totErr > 0:
status = "done_with_errors"
}
_ = o.store.FinishRun(ctx, runID, status, totCopied, totSkipped, totErr)
_ = o.store.SetTaskStatus(ctx, task.ID, status)
o.hub.Publish(wshub.Event{Type: "run_done", TaskID: task.ID,
- Data: map[string]any{"run_id": runID, "copied": totCopied, "skipped": totSkipped, "errors": totErr}})
+ Data: map[string]any{"run_id": runID, "status": status,
+ "copied": totCopied, "skipped": totSkipped, "errors": totErr}})
- if shouldBreak(trigger, totErr) {
+ // An operator stopping the run is not a schedule failure, so leave the
+ // breaker alone even when the accounts that did run reported errors.
+ if reason == stopNone && shouldBreak(trigger, totErr) {
_ = o.store.SetTaskBroken(ctx, task.ID)
o.hub.Publish(wshub.Event{Type: "task_broken", TaskID: task.ID,
Data: map[string]any{"task_id": task.ID, "errors": totErr}})
}
}
-func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint) (int64, int64, int64) {
+func (o *Orchestrator) runAccount(ctx, runCtx context.Context, h *runHandle, task store.Task, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint) (int64, int64, int64) {
o.hub.Publish(wshub.Event{Type: "account_started", TaskID: task.ID, Data: map[string]any{
"account_id": a.ID,
"src_login": a.SrcLogin, "src_host": srcEP.Host, "src_port": srcEP.Port,
@@ -310,10 +428,11 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
_ = o.store.ResetAccountCounters(ctx, a.ID) // start from zero; IncAccountCounters is additive
_ = o.store.ClearAccountErrors(ctx, a.ID) // drop last run's per-error rows
- // Per-account cancellable context: IMAP work uses actx (so CancelAccount
- // stops it); DB writes keep the parent ctx so status/counters persist even
- // after cancellation. ctx is context.WithoutCancel from runAll.
- actx, cancel := context.WithCancel(ctx)
+ // Per-account cancellable context: IMAP work uses actx, so both CancelAccount
+ // and a task-wide pause/cancel (which cancels runCtx) stop it. DB writes keep
+ // ctx — the uncancellable one from runAll — so status/counters persist even
+ // after cancellation.
+ actx, cancel := context.WithCancel(runCtx)
o.registerCancel(a.ID, cancel)
defer func() {
o.unregisterCancel(a.ID)
@@ -322,20 +441,20 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc)
if err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "src", err)
}
dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc)
if err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
src, err := imapx.Connect(actx, srcEP)
if err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "src", err)
}
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
_ = src.Logout().Wait()
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "src", err)
}
// srcClient holds the LIVE source connection. A body-read timeout (server
// under-delivering a literal) forces a mid-run reconnect via reconnectSrc,
@@ -348,11 +467,11 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
dst, err := imapx.Connect(actx, dstEP)
if err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "dst", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "dst", err)
}
// reconnectSrc dials and logs in a fresh source client, swaps it in as the
@@ -422,7 +541,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
folders, err := imapx.ListFolders(src)
touch()
if err != nil {
- return o.accountFailed(ctx, task.ID, runID, a, srcEP, dstEP, "src", err)
+ return o.accountFailed(ctx, runCtx, h, task.ID, runID, a, srcEP, dstEP, "src", err)
}
// Planning pass: decide folders from the account's own config, then EXAMINE
@@ -547,11 +666,18 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
}
if actx.Err() != nil {
- _ = o.store.SetAccountStatus(ctx, a.ID, "cancelled")
- o.hub.Publish(wshub.Event{Type: "cancelled", TaskID: task.ID,
+ // A task-wide pause leaves the account resumable; anything else (per-account
+ // cancel, stall watchdog, task-wide cancel) leaves it cancelled.
+ st := "cancelled"
+ if runCtx.Err() != nil {
+ st = h.stopReason().accountStatus()
+ }
+ _ = o.store.SetAccountStatus(ctx, a.ID, st)
+ o.hub.Publish(wshub.Event{Type: st, TaskID: task.ID,
Data: map[string]any{"account_id": a.ID, "src_login": a.SrcLogin,
"copied": copied, "skipped": skipped, "errors": errs}})
- slog.Info("account cancelled", "account", a.ID, "src_login", a.SrcLogin, "copied", copied, "skipped", skipped)
+ slog.Info("account stopped", "account", a.ID, "src_login", a.SrcLogin,
+ "status", st, "copied", copied, "skipped", skipped)
return copied, skipped, errs
}
@@ -567,11 +693,16 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in
return copied, skipped, errs
}
-func (o *Orchestrator) accountFailed(ctx context.Context, taskID, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) {
- // A cancellation surfacing as an error is a cancel, not a failure.
+func (o *Orchestrator) accountFailed(ctx, runCtx context.Context, h *runHandle, taskID, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) {
+ // A cancellation surfacing as an error is a stop, not a failure — and a
+ // task-wide pause must still leave the account resumable.
if errors.Is(err, context.Canceled) {
- _ = o.store.SetAccountStatus(ctx, a.ID, "cancelled")
- o.hub.Publish(wshub.Event{Type: "cancelled", TaskID: taskID,
+ st := "cancelled"
+ if runCtx.Err() != nil {
+ st = h.stopReason().accountStatus()
+ }
+ _ = o.store.SetAccountStatus(ctx, a.ID, st)
+ o.hub.Publish(wshub.Event{Type: st, TaskID: taskID,
Data: map[string]any{"account_id": a.ID, "src_login": a.SrcLogin}})
return 0, 0, 0
}
diff --git a/internal/orchestrator/stop_test.go b/internal/orchestrator/stop_test.go
new file mode 100644
index 0000000..a196e51
--- /dev/null
+++ b/internal/orchestrator/stop_test.go
@@ -0,0 +1,83 @@
+package orchestrator
+
+import (
+ "context"
+ "testing"
+)
+
+func TestStopReasonAccountStatus(t *testing.T) {
+ if got := stopPaused.accountStatus(); got != "paused" {
+ t.Fatalf("stopPaused = %q want paused", got)
+ }
+ if got := stopCancelled.accountStatus(); got != "cancelled" {
+ t.Fatalf("stopCancelled = %q want cancelled", got)
+ }
+ // A run stopped without an operator reason (per-account cancel, stall
+ // watchdog) must not look like a pause, or Resume would pick it up.
+ if got := stopNone.accountStatus(); got != "cancelled" {
+ t.Fatalf("stopNone = %q want cancelled", got)
+ }
+}
+
+// The first stop wins: a cancel arriving after a pause must not downgrade the
+// accounts a pause already promised to keep resumable, and vice versa.
+func TestRunHandleFirstStopWins(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ first, later stopReason
+ }{
+ {"pause then cancel", stopPaused, stopCancelled},
+ {"cancel then pause", stopCancelled, stopPaused},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ h := &runHandle{cancel: cancel}
+ h.stopWith(tc.first)
+ h.stopWith(tc.later)
+ if got := h.stopReason(); got != tc.first {
+ t.Fatalf("reason = %v want %v", got, tc.first)
+ }
+ })
+ }
+}
+
+func TestRunHandleStopCancelsContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ h := &runHandle{cancel: cancel}
+ if ctx.Err() != nil {
+ t.Fatal("context cancelled before stop")
+ }
+ h.stopWith(stopPaused)
+ if ctx.Err() == nil {
+ t.Fatal("stop must cancel the run context")
+ }
+}
+
+// Pause/Cancel report false for a task with no live run, which the HTTP layer
+// turns into 409 instead of pretending it stopped something.
+func TestStopRunWithoutLiveRun(t *testing.T) {
+ o := &Orchestrator{runs: map[int64]*runHandle{}}
+ if o.PauseTask(1) {
+ t.Fatal("PauseTask must report false with no live run")
+ }
+ if o.CancelTask(1) {
+ t.Fatal("CancelTask must report false with no live run")
+ }
+
+ _, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ h := &runHandle{cancel: cancel}
+ o.registerRun(1, h)
+ if !o.PauseTask(1) {
+ t.Fatal("PauseTask must report true for a live run")
+ }
+ if got := h.stopReason(); got != stopPaused {
+ t.Fatalf("reason = %v want stopPaused", got)
+ }
+ o.unregisterRun(1)
+ if o.CancelTask(1) {
+ t.Fatal("unregistered run must not be stoppable")
+ }
+}
diff --git a/internal/store/tasks.go b/internal/store/tasks.go
index 4646aa6..dfd1186 100644
--- a/internal/store/tasks.go
+++ b/internal/store/tasks.go
@@ -120,13 +120,16 @@ type SchedulableTask struct {
}
// ListSchedulableTasks returns tasks eligible to auto-run: schedule on, not
-// broken, not currently running — each joined with its last finished run time.
+// broken, neither running nor paused — each joined with its last finished run
+// time. A paused task waits for the operator to resume it; auto-starting a full
+// run behind their back would defeat the pause.
func (s *Store) ListSchedulableTasks(ctx context.Context) ([]SchedulableTask, error) {
rows, err := s.Pool.Query(ctx,
`SELECT t.id, t.schedule_interval_seconds, t.schedule_anchor,
(SELECT max(finished_at) FROM runs r WHERE r.task_id=t.id AND r.finished_at IS NOT NULL)
FROM tasks t
- WHERE t.schedule_interval_seconds > 0 AND NOT t.broken AND t.status <> 'running'`)
+ WHERE t.schedule_interval_seconds > 0 AND NOT t.broken
+ AND t.status <> 'running' AND t.status <> 'paused'`)
if err != nil {
return nil, err
}
diff --git a/web/src/api.ts b/web/src/api.ts
index 2891f51..9bcf2d6 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -151,6 +151,14 @@ export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { metho
export const runTask = (id: number, accountIds?: number[]) =>
api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' })
+// Pause stops the run but leaves its unfinished accounts resumable; cancel ends
+// it and marks them cancelled. Resume re-runs exactly the paused accounts.
+export const pauseTask = (id: number) => api(`/api/tasks/${id}/pause`, { method: 'POST' })
+
+export const cancelTask = (id: number) => api(`/api/tasks/${id}/cancel`, { method: 'POST' })
+
+export const resumeTask = (id: number) => api<{ run_id: number }>(`/api/tasks/${id}/resume`, { method: 'POST' })
+
export interface Run {
id: number
task_id: number
diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx
index 8ab5faf..968bc3e 100644
--- a/web/src/pages/TaskDetail.tsx
+++ b/web/src/pages/TaskDetail.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
-import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, type TaskDetail as TaskDetailData } from '../api'
+import { cancelAccount, cancelTask, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, pauseTask, probeAccountFolders, probeFolders, resumeTask, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, type TaskDetail as TaskDetailData } from '../api'
import { connectTaskWS, type TaskEvent } from '../ws'
import { StatusBadge } from '../components/StatusBadge'
import { useConfirm } from '../components/ConfirmProvider'
@@ -59,6 +59,8 @@ function describeEvent(ev: TaskEvent): string {
}
case 'cancelled':
return `CANCELLED #${d.account_id} (${d.src_login}): copied ${d.copied ?? 0}, skipped ${d.skipped ?? 0}`
+ case 'paused':
+ return `PAUSED #${d.account_id} (${d.src_login}): copied ${d.copied ?? 0}, skipped ${d.skipped ?? 0} — resumable`
case 'error': {
const where = d.folder ? ` folder "${d.folder}"` : d.side ? ` (${d.side} ${at})` : ''
return `ERROR #${d.account_id}${where}: ${d.error}`
@@ -66,7 +68,7 @@ function describeEvent(ev: TaskEvent): string {
case 'run_started':
return `RUN started (run #${d.run_id})`
case 'run_done':
- return `RUN finished: copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}`
+ return `RUN ${String(d.status ?? 'finished')}: copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}`
default:
return JSON.stringify(ev.data)
}
@@ -163,7 +165,7 @@ export function TaskDetail({ id }: { id: number }) {
},
}
})
- } else if (accId != null && (ev.type === 'account_started' || ev.type === 'account_done' || ev.type === 'cancelled' || (ev.type === 'error' && d.folder == null))) {
+ } else if (accId != null && (ev.type === 'account_started' || ev.type === 'account_done' || ev.type === 'cancelled' || ev.type === 'paused' || (ev.type === 'error' && d.folder == null))) {
// terminal/reset for this account — drop live overlay, fall back to DB
setLive((prev) => {
if (!(accId in prev)) return prev
@@ -174,7 +176,7 @@ export function TaskDetail({ id }: { id: number }) {
}
// Structural events refresh the persisted view; `progress` is covered by live state.
- if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'plan', 'task_broken'].includes(ev.type)) {
+ if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'paused', 'plan', 'task_broken'].includes(ev.type)) {
reload()
}
}),
@@ -395,6 +397,51 @@ export function TaskDetail({ id }: { id: number }) {
}
}
+ async function onPause() {
+ setBusy('run')
+ setError(null)
+ try {
+ await pauseTask(id)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to pause the run')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function onCancelRun() {
+ const ok = await confirm({
+ title: 'Cancel migration',
+ message: 'Stop the run and mark every unfinished account as cancelled? Copied messages are kept.',
+ confirmLabel: 'Cancel migration',
+ cancelLabel: 'Keep running',
+ danger: true,
+ })
+ if (!ok) return
+ setBusy('run')
+ setError(null)
+ try {
+ await cancelTask(id)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to cancel the run')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function onResume() {
+ setBusy('run')
+ setError(null)
+ try {
+ await resumeTask(id)
+ reload()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to resume the run')
+ } finally {
+ setBusy(null)
+ }
+ }
+
async function onSchedule(intervalSeconds: number) {
setError(null)
try {
@@ -422,6 +469,8 @@ export function TaskDetail({ id }: { id: number }) {
const { task, accounts } = data
const isRunning = task.status === 'running'
+ // Accounts a pause left unfinished — what Resume picks up.
+ const pausedCount = accounts.filter((a) => a.status === 'paused').length
// A failed connection test is the entry point for fixing the credentials that
// caused it — an imported account is otherwise only deletable.
@@ -515,14 +564,37 @@ export function TaskDetail({ id }: { id: number }) {
-
- {!runReady && accounts.length > 0 && (
+ {isRunning ? (
+ <>
+
+
+ pause keeps the unfinished accounts resumable
+ >
+ ) : (
+ <>
+ {pausedCount > 0 && (
+
+ )}
+
+ >
+ )}
+ {!isRunning && !runReady && accounts.length > 0 && (
{effectiveSelected.length > 0
? 'selected accounts must pass both connection tests'