diff --git a/src/psyc/_pulse_cli.py b/src/psyc/_pulse_cli.py new file mode 100644 index 0000000..97fee7b --- /dev/null +++ b/src/psyc/_pulse_cli.py @@ -0,0 +1,122 @@ +"""Typer commands for the Pulse scheduler. + +Imported and wired by `cli.py` via `register(app)`. Kept as a separate module +so the main CLI surface stays grep-friendly and the scheduler can grow its own +verbs without bloating cli.py. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +import typer + +from psyc import db +from psyc.lines import pulse + + +def _relative(dt: Optional[datetime]) -> str: + if dt is None: + return "—" + now = datetime.now(timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = int((dt - now).total_seconds()) + past = delta < 0 + secs = abs(delta) + if secs < 5: + return "now" + if secs < 60: + unit = f"{secs}s" + elif secs < 3600: + unit = f"{secs // 60}m" + elif secs < 86400: + unit = f"{secs // 3600}h" + else: + unit = f"{secs // 86400}d" + return f"{unit} ago" if past else f"in {unit}" + + +def register(typer_app: typer.Typer) -> None: + """Add pulse-* commands to the given Typer app.""" + + @typer_app.command("pulse-status") + def pulse_status() -> None: + """Print the pipeline table (mode · cadence · last-fired · next-fire · last-result).""" + db.init_db() + ks = pulse.kill_switch_state() + typer.echo(f"kill switch: {'ARMED' if ks else 'OFF'}") + rows = pulse.state() + if not rows: + typer.echo("(no pipelines registered)") + return + typer.echo(f"{'name':<16} {'mode':<14} {'cadence':>8} {'enabled':<7} {'last':<10} {'next':<10} result") + for p in rows: + typer.echo( + f"{p.name:<16} {p.mode.value:<14} {p.cadence_seconds:>6}s " + f"{'yes' if p.enabled else 'no':<7} " + f"{_relative(p.last_fired):<10} {_relative(p.next_fire):<10} " + f"{(p.last_result or '')[:60]}" + ) + + @typer_app.command("pulse-tick") + def pulse_tick() -> None: + """Run one scheduler heartbeat and print the per-pipeline outcomes.""" + db.init_db() + results = pulse.tick() + for name, outcome, result in results: + marker = {"ok": "✓", "err": "✗", "skipped": "⊘"}.get(outcome, "·") + typer.echo(f" {marker} {name:<16} {outcome:<8} {result[:120]}") + + @typer_app.command("pulse-set-mode") + def pulse_set_mode( + name: str = typer.Argument(..., help="pipeline name"), + mode: str = typer.Argument(..., help="manual | auto-propose | auto-execute"), + ) -> None: + db.init_db() + try: + pulse.set_mode(name, pulse.PulseMode(mode)) + except ValueError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) + typer.echo(f"{name} mode → {mode}") + + @typer_app.command("pulse-set-cadence") + def pulse_set_cadence( + name: str = typer.Argument(..., help="pipeline name"), + seconds: int = typer.Argument(..., help="cadence in seconds (>0)"), + ) -> None: + db.init_db() + try: + pulse.set_cadence(name, seconds) + except ValueError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) + typer.echo(f"{name} cadence → {seconds}s") + + @typer_app.command("pulse-run") + def pulse_run(name: str = typer.Argument(..., help="pipeline name")) -> None: + """Manually fire one pipeline (bypasses cadence; honors kill switch).""" + db.init_db() + try: + outcome, result = pulse.run_now(name) + except ValueError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) + marker = {"ok": "✓", "err": "✗", "skipped": "⊘"}.get(outcome, "·") + typer.echo(f" {marker} {name}: {outcome} — {result}") + + @typer_app.command("pulse-kill") + def pulse_kill() -> None: + """Arm the kill switch — every pipeline halts on the next tick.""" + db.init_db() + pulse.set_kill_switch(True) + typer.echo("kill switch ARMED — all pipelines halted") + + @typer_app.command("pulse-unkill") + def pulse_unkill() -> None: + """Disarm the kill switch — pulse resumes on the next tick.""" + db.init_db() + pulse.set_kill_switch(False) + typer.echo("kill switch disarmed — pulse resumes")