Add config sync cycle stop and surface pause/stop controls in the UI.

Cancel remaining tasks on stop and keep finalize from overwriting a cancelled cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 17:04:56 +08:00
parent 559cc14d2c
commit 1c8d512701
6 changed files with 124 additions and 5 deletions

View file

@ -24,6 +24,7 @@ from .config_sync_service import (
list_snapshots,
pause_cycle,
resume_cycle,
stop_cycle,
update_policy,
)
from .db import get_db
@ -137,6 +138,23 @@ def api_resume_cycle(
return out.model_dump()
@router.post("/cycles/{cycle_id}/stop")
def api_stop_cycle(cycle_id: str, request: Request, db: Session = Depends(get_db)):
out = stop_cycle(db, cycle_id)
uid, uname = _actor(request)
write_audit(
db,
action="config_sync.stop",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/config-sync/cycles/{cycle_id}/stop",
status_code=200,
detail={"cycle_id": cycle_id},
)
return out.model_dump()
@router.get("/snapshots")
def api_list_snapshots(
page: int = Query(default=1, ge=1),

View file

@ -377,7 +377,7 @@ def pause_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
row = db.get(ConfigSyncCycle, cycle_id)
if not row:
raise HTTPException(status_code=404, detail="cycle_not_found")
if str(row.status) != "running":
if str(row.status) not in ("running", "pending"):
raise HTTPException(status_code=400, detail="cycle_not_running")
row.status = "paused"
db.commit()
@ -407,6 +407,45 @@ def resume_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
return cycle_to_out(row)
def stop_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
"""Cancel remaining work and close the cycle (running/paused/pending)."""
row = db.get(ConfigSyncCycle, cycle_id)
if not row:
raise HTTPException(status_code=404, detail="cycle_not_found")
if str(row.status) not in ("running", "paused", "pending"):
raise HTTPException(status_code=400, detail="cycle_not_active")
now = _utcnow()
pending = (
db.query(ConfigSyncTask)
.filter(
ConfigSyncTask.cycle_id == cycle_id,
ConfigSyncTask.status.in_(("pending", "running")),
)
.all()
)
for task in pending:
# In-flight workers may still finish and overwrite; pending must not start.
if str(task.status) == "pending":
task.status = "cancelled"
task.message = "stopped_by_user"
task.ended_at = now
else:
task.message = (str(task.message or "") + " · stop_requested")[:1020]
row.status = "cancelled"
row.error_message = "stopped_by_user"
row.ended_at = now
db.commit()
sync_cycle_progress(db, cycle_id)
db.refresh(row)
try:
from .config_sync_runner import _release_pool
_release_pool(cycle_id)
except Exception:
pass
return cycle_to_out(row)
def dashboard(db: Session) -> ConfigSyncDashboardOut:
policy = ensure_policy(db)
snap_count = int(db.query(func.count()).select_from(NeConfigSnapshot).scalar() or 0)
@ -629,7 +668,7 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
cycle = db.get(ConfigSyncCycle, cycle_id)
if not cycle:
return
if str(cycle.status) == "paused":
if str(cycle.status) in ("paused", "cancelled"):
return
pending = (
db.query(func.count())
@ -642,6 +681,8 @@ def finalize_cycle(db: Session, cycle_id: str) -> None:
return
sync_cycle_progress(db, cycle_id)
db.refresh(cycle)
if str(cycle.status) in ("paused", "cancelled"):
return
# Cycle outcome is about finishing the run, not per-NE results.
# Individual task failures stay in fail_count for retry/dashboard.
cycle.status = "success"