stage-33d pulse: CLI commands
This commit is contained in:
122
src/psyc/_pulse_cli.py
Normal file
122
src/psyc/_pulse_cli.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user