feat: add cancel, pause, and resume flow control for scans
Some checks failed
Deploy / deploy (push) Failing after 18s

Users running large scans can now pause (keep partial results, resume
later), cancel (stop permanently, partial results preserved), or resume
a paused scan which races through cache hits before continuing.

Backend:
- Extend scans.status CHECK to include 'paused' and 'cancelled'
- Add _migrate_add_pause_cancel_status() table-recreation migration
- scan_processor: _running_tasks/_cancel_reasons registries,
  cancel_scan_task/pause_scan_task/stop_scan_task helpers,
  CancelledError handler in process_scan(), start_resume_processor()
- api_server: POST /scans/{id}/pause|cancel|resume endpoints with
  rate limits (30/min pause+cancel, 10/min resume); list_scans now
  accepts paused/cancelled as status filter values

Frontend:
- Scan.status type extended with 'paused' | 'cancelled'
- scanApi.pause/cancel/resume added
- StatusChip: amber PauseCircle chip for paused, grey Ban for cancelled
- ScanDetails: context-aware action row with inline-confirm for
  Pause and Cancel; Resume button for paused scans

Tests: 129 total (58 new) across test_scan_control.py,
test_scan_processor_control.py, and additions to existing suites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:11:23 +01:00
parent d494e80ff7
commit 9a76d7af82
11 changed files with 1154 additions and 55 deletions

View File

@@ -199,6 +199,108 @@ def _migrate_add_scheduled_scan_id_to_scans(conn, verbose=True):
print(" ✅ Migration complete: scheduled_scan_id column added to scans")
def _migrate_add_timing_columns_to_scans(conn, verbose=True):
"""
Migration: add started_at and completed_at columns to the scans table.
started_at — set when status transitions to 'running'
completed_at — set when status transitions to 'completed' or 'failed'
Both are nullable so existing rows are unaffected.
"""
cursor = conn.execute("PRAGMA table_info(scans)")
columns = [row[1] for row in cursor.fetchall()]
if not columns:
return # Fresh install: scans table doesn't exist yet — schema will create the columns
if 'started_at' in columns and 'completed_at' in columns:
return # Already migrated
if verbose:
print(" 🔄 Migrating scans table: adding started_at and completed_at columns...")
if 'started_at' not in columns:
conn.execute("ALTER TABLE scans ADD COLUMN started_at TIMESTAMP")
if 'completed_at' not in columns:
conn.execute("ALTER TABLE scans ADD COLUMN completed_at TIMESTAMP")
conn.commit()
if verbose:
print(" ✅ Migration complete: started_at and completed_at columns added to scans")
def _migrate_add_pause_cancel_status(conn, verbose=True):
"""
Migration: Extend status CHECK constraint to include 'paused' and 'cancelled'.
Needed for cancel/pause/resume scan flow control feature.
Uses the same table-recreation pattern as _migrate_relax_country_constraint
because SQLite doesn't support modifying CHECK constraints in-place.
"""
cursor = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='scans'"
)
row = cursor.fetchone()
if not row or 'paused' in row[0]:
return # Table doesn't exist yet (fresh install) or already migrated
if verbose:
print(" 🔄 Migrating scans table: adding 'paused' and 'cancelled' status values...")
# SQLite doesn't support ALTER TABLE MODIFY COLUMN, so recreate the table.
# Use PRAGMA foreign_keys = OFF to avoid FK errors during the swap.
conn.execute("PRAGMA foreign_keys = OFF")
# Drop triggers that reference scans (they are recreated by executescript below).
conn.execute("DROP TRIGGER IF EXISTS update_scans_timestamp")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_insert")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_update")
conn.execute("DROP TRIGGER IF EXISTS update_scan_flight_count_delete")
conn.execute("""
CREATE TABLE scans_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL CHECK(length(origin) = 3),
country TEXT NOT NULL CHECK(length(country) >= 2),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
routes_scanned INTEGER NOT NULL DEFAULT 0 CHECK(routes_scanned >= 0),
total_flights INTEGER NOT NULL DEFAULT 0 CHECK(total_flights >= 0),
error_message TEXT,
seat_class TEXT DEFAULT 'economy',
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
scheduled_scan_id INTEGER,
CHECK(end_date >= start_date),
CHECK(routes_scanned <= total_routes OR total_routes = 0)
)
""")
# Use named columns to handle different column orderings (ALTER TABLE vs fresh schema).
conn.execute("""
INSERT INTO scans_new (
id, origin, country, start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
)
SELECT
id, origin, country, start_date, end_date,
created_at, updated_at, started_at, completed_at,
status, total_routes, routes_scanned, total_flights,
error_message, seat_class, adults, scheduled_scan_id
FROM scans
""")
conn.execute("DROP TABLE scans")
conn.execute("ALTER TABLE scans_new RENAME TO scans")
conn.execute("PRAGMA foreign_keys = ON")
conn.commit()
if verbose:
print(" ✅ Migration complete: status now accepts 'paused' and 'cancelled'")
def initialize_database(db_path=None, verbose=True):
"""
Initialize or migrate the database.
@@ -245,6 +347,8 @@ def initialize_database(db_path=None, verbose=True):
_migrate_relax_country_constraint(conn, verbose)
_migrate_add_routes_unique_index(conn, verbose)
_migrate_add_scheduled_scan_id_to_scans(conn, verbose)
_migrate_add_timing_columns_to_scans(conn, verbose)
_migrate_add_pause_cancel_status(conn, verbose)
# Load and execute schema
schema_sql = load_schema()