feat: add cancel, pause, and resume flow control for scans
Some checks failed
Deploy / deploy (push) Failing after 18s
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:
@@ -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()
|
||||
|
||||
@@ -28,10 +28,12 @@ CREATE TABLE IF NOT EXISTS scans (
|
||||
-- Timestamps (auto-managed)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP, -- Set when status transitions to 'running'
|
||||
completed_at TIMESTAMP, -- Set when status transitions to 'completed' or 'failed'
|
||||
|
||||
-- Scan status (enforced enum via CHECK)
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
||||
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
|
||||
|
||||
-- Progress tracking
|
||||
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
|
||||
|
||||
Reference in New Issue
Block a user