stage-33d pulse: CLI commands

This commit is contained in:
m17hr1l
2026-06-06 16:05:14 +02:00
parent 26fbe08b65
commit e071f289f2

122
src/psyc/_pulse_cli.py Normal file
View 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")