mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
91 lines
3.3 KiB
Python
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)
|