You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/automation/progress.py

217 lines
8.3 KiB

"""Automation progress tracking.
Owns the in-memory progress state dict that backs both
`/api/automations/progress` polling and the WebSocket
`automation:progress` push emitter. State is per-automation, capped at
50 log entries each, and finished/error states are reaped 60s after
they finish so the frontend has a window to show the final state.
Functions are written so the route layer / engine callbacks can pass
their own socketio emitter, db handle, and shutdown flag. The progress
state dict (`progress_states`) and its lock (`progress_lock`) are
module-level so all callers share one view — same as the original
web_server.py globals.
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# Shared mutable state — module globals so every caller (routes, engine
# progress callbacks, emit loop) sees the same dict. Mirrors the original
# `automation_progress_states` / `automation_progress_lock` in web_server.
progress_states: dict[int, dict] = {}
progress_lock = threading.Lock()
def init_progress(automation_id: int, automation_name: str, action_type: str) -> None:
"""Initialize progress state when an automation starts running."""
with progress_lock:
progress_states[automation_id] = {
'status': 'running',
'action_type': action_type,
'progress': 0,
'phase': 'Starting...',
'current_item': '',
'processed': 0,
'total': 0,
'log': [{'type': 'info', 'text': f'Starting {automation_name}'}],
'started_at': datetime.now(timezone.utc).isoformat(),
'finished_at': None,
}
def update_progress(
automation_id: Optional[int],
*,
socketio_emit: Optional[Callable[[str, Any], None]] = None,
**kwargs,
) -> None:
"""Update progress state from handler threads. Thread-safe.
`socketio_emit` lets callers wire in the live socketio.emit so that
finished/error transitions push immediately without waiting for the
1s emitter loop. Falls back to no-op if not provided.
"""
if automation_id is None:
return
with progress_lock:
state = progress_states.get(automation_id)
if not state:
return
for k, v in kwargs.items():
if k == 'log_line':
state['log'].append({'type': kwargs.get('log_type', 'info'), 'text': v})
if len(state['log']) > 50:
state['log'] = state['log'][-50:]
elif k != 'log_type':
state[k] = v
if kwargs.get('status') in ('finished', 'error'):
state['finished_at'] = datetime.now(timezone.utc).isoformat()
if socketio_emit is not None:
try:
socketio_emit('automation:progress', {str(automation_id): dict(state)})
except Exception:
pass
def get_running_progress() -> dict[str, dict]:
"""Snapshot of running/finished/error states for the polling endpoint."""
with progress_lock:
result: dict[str, dict] = {}
for aid, state in progress_states.items():
if state['status'] in ('running', 'finished', 'error'):
cp = dict(state)
cp['log'] = list(state['log'])
result[str(aid)] = cp
return result
def record_history(
automation_id: int,
result: dict,
database,
) -> None:
"""Capture progress state into run history before cleanup clears it.
`database` is passed in so the function works without a `get_database()`
global.
"""
try:
with progress_lock:
state = progress_states.get(automation_id)
if state:
started_at = state.get('started_at')
finished_at = state.get('finished_at') or datetime.now(timezone.utc).isoformat()
log_entries = list(state.get('log', []))
else:
started_at = datetime.now(timezone.utc).isoformat()
finished_at = datetime.now(timezone.utc).isoformat()
log_entries = []
duration = None
if started_at and finished_at:
try:
t0 = datetime.fromisoformat(started_at)
t1 = datetime.fromisoformat(finished_at)
duration = (t1 - t0).total_seconds()
except Exception:
pass
r_status = result.get('status', 'completed') if result else 'completed'
if r_status == 'error':
status = 'error'
elif r_status == 'skipped':
status = 'skipped'
elif r_status == 'timeout':
status = 'timeout'
else:
status = 'completed'
summary = None
for entry in reversed(log_entries):
if entry.get('type') in ('success', 'error'):
summary = entry.get('text', '')
break
if not summary and log_entries:
summary = log_entries[-1].get('text', '')
if not summary and result:
summary = result.get('reason') or result.get('error') or result.get('status', '')
result_json = json.dumps({k: v for k, v in result.items() if not k.startswith('_')}) if result else None
log_json = json.dumps(log_entries) if log_entries else None
database.insert_automation_run_history(
automation_id=automation_id,
started_at=started_at,
finished_at=finished_at,
duration_seconds=duration,
status=status,
summary=summary,
result_json=result_json,
log_lines=log_json,
)
except Exception as e:
logger.error(f"Error recording automation history for {automation_id}: {e}")
def emit_progress_loop(
socketio,
*,
is_shutting_down: Callable[[], bool],
poll_interval: float = 1.0,
timeout_seconds: int = 7200,
cleanup_after_seconds: int = 60,
) -> None:
"""Push `automation:progress` events for active automations.
Long-running loop — caller wires this into a socketio background task.
- Times out zombie running states after `timeout_seconds` (default 2h).
- Reaps finished/error states `cleanup_after_seconds` after finish so the
frontend has a final-state window before they disappear.
"""
while not is_shutting_down():
socketio.sleep(poll_interval)
try:
with progress_lock:
active: dict[str, dict] = {}
stale: list[int] = []
now = datetime.now()
for aid, state in progress_states.items():
if state['status'] == 'running':
try:
started = datetime.fromisoformat(state.get('started_at', ''))
if (now - started).total_seconds() > timeout_seconds:
state['status'] = 'error'
state['phase'] = 'Timed out'
state['finished_at'] = now.isoformat()
state['log'].append({'type': 'error', 'text': f'Timed out after {timeout_seconds // 3600} hours'})
cp = dict(state)
cp['log'] = list(state['log'])
active[str(aid)] = cp
continue
except (ValueError, TypeError):
pass
cp = dict(state)
cp['log'] = list(state['log'])
active[str(aid)] = cp
elif state['status'] in ('finished', 'error') and state.get('finished_at'):
try:
finished_time = datetime.fromisoformat(state['finished_at'])
if (now - finished_time).total_seconds() > cleanup_after_seconds:
stale.append(aid)
except (ValueError, TypeError):
stale.append(aid)
for aid in stale:
del progress_states[aid]
if active:
socketio.emit('automation:progress', active)
except Exception as e:
logger.debug(f"Error emitting automation progress: {e}")