steam-backlog-enforcer/steam_backlog_enforcer/jobs/_control.py
2026-10-10 16:07:42 +02:00

91 lines
3.3 KiB
Python

"""Act on a running job: answer its prompt, or cancel it.
Both only touch files and signals the job already watches, so they work
the same whether the job was started by this server process or a previous
one.
"""
from __future__ import annotations
import os
import signal
from steam_backlog_enforcer.jobs import _store
from steam_backlog_enforcer.jobs._errors import JobError
from steam_backlog_enforcer.jobs._events import (
ANSWERS_NAME,
CANCEL_NAME,
TERMINAL_STATES,
append_jsonl,
read_events,
read_jsonl,
)
from steam_backlog_enforcer.jobs._specs import JOB_FLAGS
from steam_backlog_enforcer.jobs._view import pid_of, read_job
def _open_prompt_id(job_id: str) -> str | None:
"""The id of the prompt the job is waiting on, if it is waiting."""
path = _store.job_dir(job_id)
prompts = [e for e in read_events(path) if e.get("type") == "prompt"]
if not prompts:
return None
answered = {a.get("prompt_id") for a in read_jsonl(path / ANSWERS_NAME)}
latest = str(prompts[-1]["prompt_id"])
return None if latest in answered else latest
def write_answer(job_id: str, prompt_id: str, value: str) -> None:
"""Deliver the user's answer to the prompt the job is waiting on.
The answer is not judged here — the job re-checks it — but it must be
for the prompt that is actually open, so a stale or replayed answer
cannot land on a later question.
Raises:
JobError: ``not_found`` for an unknown job; ``invalid_params`` for a
finished job or a ``prompt_id`` that is not the open prompt.
"""
job = read_job(job_id)
if job["state"] in TERMINAL_STATES:
msg = f"Job {job_id} has already finished."
raise JobError(msg, code="invalid_params")
if _open_prompt_id(job_id) != prompt_id:
msg = f"Prompt {prompt_id!r} is not waiting for an answer."
raise JobError(msg, code="invalid_params")
path = _store.job_dir(job_id)
append_jsonl(path / ANSWERS_NAME, {"prompt_id": prompt_id, "value": value})
def cancel(job_id: str) -> None:
"""Ask a job to stop: any job waiting on a prompt, else cancellable ones.
A cancellable job gets SIGTERM, to its own process only.
Only the job's pid is signalled, never its process group: a job may
have started Steam, and cancelling a scan must not take Steam down.
Raises:
JobError: ``not_cancellable`` for a command that is unsafe to stop
midway; ``not_found`` for an unknown job; ``invalid_params`` for
a finished one.
"""
job = read_job(job_id)
if job["state"] == "waiting_input":
# Blocked on a prompt, so nothing is half-done: any command may stop
# here. The job's own prompt loop sees the marker and cancels itself.
(_store.job_dir(job_id) / CANCEL_NAME).touch()
return
flags = JOB_FLAGS.get(job["command"])
if flags is None or not flags.cancellable:
msg = f"{job['command']} cannot be cancelled once started."
raise JobError(msg, code="not_cancellable")
if job["state"] in TERMINAL_STATES:
msg = f"Job {job_id} has already finished."
raise JobError(msg, code="invalid_params")
pid = pid_of(_store.job_dir(job_id))
if pid is None:
msg = f"Job {job_id} has no running process."
raise JobError(msg, code="invalid_params")
os.kill(pid, signal.SIGTERM)