mirror of
https://github.com/usestrix/strix.git
synced 2026-10-06 02:48:10 +00:00
feat(cli): pick a prior run interactively when --resume has no name
strix --resume with no run name now lists the runs in ./strix_runs inline in the terminal (started, target, status, findings, run name), newest first, with arrow-key selection, type-to-search and esc to cancel. Picking a run continues through the same path as --resume <name>. Headless or non-TTY launches error with the run list instead.
This commit is contained in:
parent
deb6d81d79
commit
e954301a09
8 changed files with 1050 additions and 25 deletions
|
|
@ -9,7 +9,7 @@ from pathlib import Path
|
|||
|
||||
from strix.config import apply_config_override
|
||||
from strix.config.settings import DEFAULT_MAX_TURNS
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.paths import RUNS_DIR_NAME, run_dir_for, runtime_state_dir
|
||||
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
|
||||
from strix.interface.update_check import self_update
|
||||
from strix.interface.utils import (
|
||||
|
|
@ -18,6 +18,7 @@ from strix.interface.utils import (
|
|||
resolve_workspace_files,
|
||||
validate_config_file,
|
||||
)
|
||||
from strix.report.runs import list_run_summaries
|
||||
|
||||
|
||||
# Severities ``--fail-on`` accepts, most severe first.
|
||||
|
|
@ -300,11 +301,14 @@ Strix Cloud:
|
|||
parser.add_argument(
|
||||
"--resume",
|
||||
type=str,
|
||||
nargs="?",
|
||||
const="",
|
||||
metavar="RUN_NAME",
|
||||
help=(
|
||||
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
|
||||
"Picks up the root + every non-terminal subagent's full LLM history "
|
||||
"and agent topology. Skips fresh run-name generation."
|
||||
"and agent topology. Skips fresh run-name generation. Without a "
|
||||
"name, opens a picker of the prior runs."
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -312,6 +316,7 @@ Strix Cloud:
|
|||
# Startup-resolved state lives alongside the parsed flags. The full schema
|
||||
# is established here so downstream code reads attributes directly.
|
||||
args.needs_setup = False
|
||||
args.resume_picker = False
|
||||
args.targets_info = []
|
||||
args.local_sources = []
|
||||
args.diff_scope = {"active": False}
|
||||
|
|
@ -366,22 +371,28 @@ Strix Cloud:
|
|||
# transcript shows as their opening message.
|
||||
args.user_instruction = args.instruction or None
|
||||
|
||||
if args.resume:
|
||||
if args.resume is not None:
|
||||
if args.target or args.target_list:
|
||||
parser.error(
|
||||
"Cannot combine --resume with --target/--target-list. "
|
||||
"--resume picks up where the prior run left off, including the "
|
||||
"original target list."
|
||||
)
|
||||
_load_resume_state(args, parser)
|
||||
agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json"
|
||||
if not agents_path.exists():
|
||||
parser.error(
|
||||
f"--resume {args.resume}: missing {agents_path}. The run was "
|
||||
f"persisted but never reached its first agent snapshot — "
|
||||
f"there's nothing to resume from. Pick a fresh --run-name "
|
||||
f"or remove --resume to start over with the same targets."
|
||||
)
|
||||
if not args.resume.strip():
|
||||
# A bare --resume: main() opens the inline picker of prior runs
|
||||
# before anything launches; headless has nobody to pick, so it
|
||||
# lists them.
|
||||
args.resume = None
|
||||
args.resume_picker = True
|
||||
if args.non_interactive:
|
||||
parser.error(resume_run_list_message("--resume needs a run name in headless mode."))
|
||||
if not list_run_summaries():
|
||||
parser.error(f"--resume: no runs in ./{RUNS_DIR_NAME} to resume")
|
||||
return args
|
||||
try:
|
||||
load_resume_state(args)
|
||||
except ResumeError as exc:
|
||||
parser.error(str(exc))
|
||||
else:
|
||||
if not args.target and not args.target_list:
|
||||
if args.non_interactive:
|
||||
|
|
@ -403,21 +414,45 @@ Strix Cloud:
|
|||
return args
|
||||
|
||||
|
||||
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
||||
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
|
||||
class ResumeError(ValueError):
|
||||
"""A prior run cannot be resumed as recorded; the message names why."""
|
||||
|
||||
|
||||
def resume_run_list_message(lead: str) -> str:
|
||||
"""``lead`` followed by the runs that ``--resume <name>`` would accept."""
|
||||
runs = list_run_summaries()
|
||||
if not runs:
|
||||
return f"{lead} There are no runs in ./{RUNS_DIR_NAME}."
|
||||
name_width = max(len(run.run_name) for run in runs)
|
||||
status_width = max(len(run.status) for run in runs)
|
||||
lines = [f"{lead} Runs in ./{RUNS_DIR_NAME}:"]
|
||||
lines.extend(
|
||||
f" {run.run_name:<{name_width}} {run.status:<{status_width}} "
|
||||
f"{run.started_at[:19]:<19} {run.target}".rstrip()
|
||||
for run in runs
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_resume_state(args: argparse.Namespace) -> None:
|
||||
"""Populate ``args.targets_info`` and friends from a prior run's run.json.
|
||||
|
||||
Raises :class:`ResumeError` when the run is missing, unreadable, or its
|
||||
recorded workspace is gone.
|
||||
"""
|
||||
from strix.report.writer import read_run_record
|
||||
|
||||
run_dir = run_dir_for(args.resume)
|
||||
state_path = run_dir / "run.json"
|
||||
if not state_path.exists():
|
||||
parser.error(
|
||||
raise ResumeError(
|
||||
f"--resume {args.resume}: no such run "
|
||||
f"(missing {state_path}; remove --resume for a fresh start)"
|
||||
)
|
||||
try:
|
||||
state = read_run_record(run_dir)
|
||||
except (RuntimeError, TypeError) as exc:
|
||||
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
||||
raise ResumeError(f"--resume {args.resume}: run.json unreadable: {exc}") from exc
|
||||
|
||||
args.targets_info = state.get("targets_info") or []
|
||||
# A target-less run has no targets_info at all. It is driven by its
|
||||
|
|
@ -425,7 +460,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||
# mount was declined, so either of those is enough to resume it.
|
||||
workspace_mount = state.get("workspace_mount") or None
|
||||
if not args.targets_info and not workspace_mount and not state.get("user_instruction"):
|
||||
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
||||
raise ResumeError(f"--resume {args.resume}: run.json has no targets_info")
|
||||
|
||||
for target in args.targets_info:
|
||||
if not isinstance(target, dict):
|
||||
|
|
@ -435,7 +470,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||
try:
|
||||
check_mountable_dir(Path(details["target_path"]).expanduser())
|
||||
except ValueError as exc:
|
||||
parser.error(f"--resume {args.resume}: {exc}")
|
||||
raise ResumeError(f"--resume {args.resume}: {exc}") from exc
|
||||
continue
|
||||
if target.get("type") != "repository":
|
||||
continue
|
||||
|
|
@ -443,7 +478,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||
if not cloned:
|
||||
continue
|
||||
if not Path(cloned).expanduser().exists():
|
||||
parser.error(
|
||||
raise ResumeError(
|
||||
f"--resume {args.resume}: cloned repo at {cloned} is missing. "
|
||||
f"It was deleted between runs. Pick a fresh --run-name to "
|
||||
f"re-clone, or restore the directory before resuming."
|
||||
|
|
@ -474,10 +509,10 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||
try:
|
||||
args.workspace_files = resolve_workspace_files(restored)
|
||||
except ValueError as error:
|
||||
parser.error(f"--resume {args.resume}: invalid workspace file: {error}")
|
||||
raise ResumeError(f"--resume {args.resume}: invalid workspace file: {error}") from error
|
||||
if workspace_mount:
|
||||
if not Path(workspace_mount).expanduser().is_dir():
|
||||
parser.error(
|
||||
raise ResumeError(
|
||||
f"--resume {args.resume}: the working directory {workspace_mount} "
|
||||
f"is missing. Restore it before resuming, or start a fresh run."
|
||||
)
|
||||
|
|
@ -487,3 +522,11 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||
persisted_scan_mode = state.get("scan_mode")
|
||||
if persisted_scan_mode and args.scan_mode == "deep":
|
||||
args.scan_mode = persisted_scan_mode
|
||||
agents_path = runtime_state_dir(run_dir) / "agents.json"
|
||||
if not agents_path.exists():
|
||||
raise ResumeError(
|
||||
f"--resume {args.resume}: missing {agents_path}. The run was "
|
||||
f"persisted but never reached its first agent snapshot — "
|
||||
f"there's nothing to resume from. Pick a fresh --run-name "
|
||||
f"or remove --resume to start over with the same targets."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@ from rich.panel import Panel
|
|||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings, persist_current
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.interface.cli_args import FAIL_ON_SEVERITIES, parse_arguments
|
||||
from strix.core.paths import RUNS_DIR_NAME, run_dir_for
|
||||
from strix.interface.cli_args import (
|
||||
FAIL_ON_SEVERITIES,
|
||||
ResumeError,
|
||||
load_resume_state,
|
||||
parse_arguments,
|
||||
resume_run_list_message,
|
||||
)
|
||||
from strix.interface.environment import (
|
||||
check_docker_installed,
|
||||
pull_docker_image,
|
||||
|
|
@ -391,6 +397,33 @@ def _print_model_connection_error(exc: BaseException, model_name: str) -> None:
|
|||
console.print()
|
||||
|
||||
|
||||
def _print_cli_error(message: str) -> None:
|
||||
Console(stderr=True, soft_wrap=True).print(
|
||||
f"strix: error: {message}", markup=False, highlight=False
|
||||
)
|
||||
|
||||
|
||||
def _pick_run_to_resume(args: argparse.Namespace) -> None:
|
||||
"""A bare --resume: let the user pick a run, then load it like --resume <name>."""
|
||||
from strix.interface.resume_picker import PickerUnavailableError, pick_run
|
||||
from strix.report.runs import list_run_summaries
|
||||
|
||||
try:
|
||||
chosen = pick_run(list_run_summaries(), runs_dir=RUNS_DIR_NAME)
|
||||
except PickerUnavailableError as exc:
|
||||
_print_cli_error(resume_run_list_message(f"{exc}."))
|
||||
sys.exit(2)
|
||||
if chosen is None:
|
||||
Console().print("No run selected.", style="dim")
|
||||
sys.exit(0)
|
||||
args.resume = chosen.run_name
|
||||
try:
|
||||
load_resume_state(args)
|
||||
except ResumeError as exc:
|
||||
_print_cli_error(str(exc))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _bootstrap_scan(args: argparse.Namespace) -> None:
|
||||
"""Warm up the model and prepare the run before the interface starts.
|
||||
|
||||
|
|
@ -475,6 +508,9 @@ def main() -> None:
|
|||
restart_after_update()
|
||||
sys.exit(0)
|
||||
|
||||
if args.resume_picker:
|
||||
_pick_run_to_resume(args)
|
||||
|
||||
check_docker_installed()
|
||||
pull_docker_image()
|
||||
validate_environment()
|
||||
|
|
|
|||
392
strix/interface/resume_picker.py
Normal file
392
strix/interface/resume_picker.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""Inline arrow-key picker for ``strix --resume`` with no run name.
|
||||
|
||||
Draws a short list in the normal terminal (no alternate screen), redraws it in
|
||||
place on every key, and returns the chosen run. Keys: up/down, page up/down,
|
||||
home/end, enter, esc, typing filters the rows, backspace edits the filter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, TextIO
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from strix.report.runs import RunSummary
|
||||
|
||||
|
||||
KEY_UP = "up"
|
||||
KEY_DOWN = "down"
|
||||
KEY_PAGE_UP = "pageup"
|
||||
KEY_PAGE_DOWN = "pagedown"
|
||||
KEY_HOME = "home"
|
||||
KEY_END = "end"
|
||||
KEY_ENTER = "enter"
|
||||
KEY_ESCAPE = "escape"
|
||||
KEY_BACKSPACE = "backspace"
|
||||
KEY_INTERRUPT = "interrupt"
|
||||
|
||||
_CSI_KEYS = {
|
||||
"A": KEY_UP,
|
||||
"B": KEY_DOWN,
|
||||
"H": KEY_HOME,
|
||||
"F": KEY_END,
|
||||
"1~": KEY_HOME,
|
||||
"4~": KEY_END,
|
||||
"5~": KEY_PAGE_UP,
|
||||
"6~": KEY_PAGE_DOWN,
|
||||
"7~": KEY_HOME,
|
||||
"8~": KEY_END,
|
||||
}
|
||||
_WINDOWS_KEYS = {
|
||||
"H": KEY_UP,
|
||||
"P": KEY_DOWN,
|
||||
"I": KEY_PAGE_UP,
|
||||
"Q": KEY_PAGE_DOWN,
|
||||
"G": KEY_HOME,
|
||||
"O": KEY_END,
|
||||
}
|
||||
_CONTROL_KEYS = {
|
||||
"\r": KEY_ENTER,
|
||||
"\n": KEY_ENTER,
|
||||
"\x1b": KEY_ESCAPE,
|
||||
"\x7f": KEY_BACKSPACE,
|
||||
"\x08": KEY_BACKSPACE,
|
||||
"\x03": KEY_INTERRUPT,
|
||||
}
|
||||
|
||||
_CURSOR_HIDE = "\x1b[?25l"
|
||||
_CURSOR_SHOW = "\x1b[?25h"
|
||||
_CLEAR_BELOW = "\x1b[J"
|
||||
|
||||
_GREEN = "#22c55e"
|
||||
_AMBER = "#f59e0b"
|
||||
_STATUS_STYLES = {
|
||||
"completed": _GREEN,
|
||||
"running": _GREEN,
|
||||
"interrupted": _AMBER,
|
||||
"stopped": _AMBER,
|
||||
"failed": "#ef4444",
|
||||
}
|
||||
_NO_STATE = "no state"
|
||||
_CURSOR = " \u276f "
|
||||
_STARTED_WIDTH = 14
|
||||
_FINDINGS_WIDTH = 8
|
||||
_MIN_TARGET_WIDTH = 12
|
||||
_MAX_RUN_WIDTH = 40
|
||||
_CHROME_LINES = 8
|
||||
|
||||
|
||||
class PickerUnavailableError(RuntimeError):
|
||||
"""The picker needs an interactive terminal on stdin and stdout."""
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
def read_raw_key(_stream: TextIO) -> str:
|
||||
char = str(msvcrt.getwch())
|
||||
if char in ("\x00", "\xe0"):
|
||||
return _WINDOWS_KEYS.get(str(msvcrt.getwch()), "")
|
||||
return char
|
||||
|
||||
else:
|
||||
import select
|
||||
import termios
|
||||
import tty
|
||||
|
||||
def read_raw_key(stream: TextIO) -> str:
|
||||
fd = stream.fileno()
|
||||
saved = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
char = os.read(fd, 1).decode("utf-8", "replace")
|
||||
if char != "\x1b":
|
||||
return char
|
||||
sequence = ""
|
||||
while select.select([fd], [], [], 0.05)[0]:
|
||||
sequence += os.read(fd, 1).decode("utf-8", "replace")
|
||||
if (sequence.startswith("[") and sequence[-1].isalpha()) or sequence.endswith("~"):
|
||||
break
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
||||
if not sequence:
|
||||
return "\x1b"
|
||||
return _CSI_KEYS.get(sequence[1:], "") if sequence[0] in "[O" else ""
|
||||
|
||||
|
||||
def read_key(stream: TextIO) -> str:
|
||||
return translate_key(read_raw_key(stream))
|
||||
|
||||
|
||||
def translate_key(raw: str) -> str:
|
||||
return _CONTROL_KEYS.get(raw, raw)
|
||||
|
||||
|
||||
def relative_time(stamp: str, now: datetime | None = None) -> str:
|
||||
try:
|
||||
started = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
return stamp[:16] if stamp else "unknown"
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=UTC)
|
||||
now = now or datetime.now(UTC)
|
||||
seconds = (now - started).total_seconds()
|
||||
for limit, unit, label in (
|
||||
(60, 1, ""),
|
||||
(3600, 60, "min"),
|
||||
(86400, 3600, "h"),
|
||||
(7 * 86400, 86400, "d"),
|
||||
):
|
||||
if seconds < limit:
|
||||
return "just now" if not label else f"{int(seconds // unit)} {label} ago"
|
||||
local = started.astimezone()
|
||||
if local.year == now.astimezone().year:
|
||||
return f"{local:%b} {local.day}, {local:%H:%M}"
|
||||
return f"{local:%b} {local.day}, {local.year}"
|
||||
|
||||
|
||||
def filter_runs(runs: list[RunSummary], needle: str) -> list[RunSummary]:
|
||||
needle = needle.strip().lower()
|
||||
if not needle:
|
||||
return list(runs)
|
||||
return [
|
||||
run
|
||||
for run in runs
|
||||
if needle in run.run_name.lower()
|
||||
or needle in run.target.lower()
|
||||
or needle in run.status.lower()
|
||||
]
|
||||
|
||||
|
||||
def _fit(text: str, width: int) -> str:
|
||||
if len(text) <= width:
|
||||
return text.ljust(width)
|
||||
return text[: width - 1] + "\u2026"
|
||||
|
||||
|
||||
def _status_text(run: RunSummary) -> str:
|
||||
return run.status if run.resumable else f"{run.status} \u00b7 {_NO_STATE}"
|
||||
|
||||
|
||||
class ResumePicker:
|
||||
def __init__(
|
||||
self,
|
||||
runs: list[RunSummary],
|
||||
*,
|
||||
console: Console,
|
||||
runs_dir: str,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
self.runs = runs
|
||||
self.console = console
|
||||
self.runs_dir = runs_dir
|
||||
self.now = now
|
||||
self.filter = ""
|
||||
self.cursor = 0
|
||||
self.offset = 0
|
||||
self.notice = ""
|
||||
self._drawn = 0
|
||||
|
||||
@property
|
||||
def rows(self) -> list[RunSummary]:
|
||||
return filter_runs(self.runs, self.filter)
|
||||
|
||||
def _visible(self) -> int:
|
||||
return max(3, min(len(self.rows), self.console.height - _CHROME_LINES))
|
||||
|
||||
def _columns(self) -> tuple[int, int, int]:
|
||||
width = max(40, self.console.width - 1)
|
||||
status_width = max(len("status"), *(len(_status_text(run)) for run in self.runs))
|
||||
run_width = min(_MAX_RUN_WIDTH, max(len("run"), *(len(run.run_name) for run in self.runs)))
|
||||
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 4 * 2
|
||||
target_width = width - fixed - run_width
|
||||
if target_width < _MIN_TARGET_WIDTH:
|
||||
run_width = max(8, run_width + target_width - _MIN_TARGET_WIDTH)
|
||||
target_width = width - fixed - run_width
|
||||
return max(_MIN_TARGET_WIDTH, target_width), status_width, run_width
|
||||
|
||||
def _scroll(self) -> range:
|
||||
rows = self.rows
|
||||
visible = self._visible()
|
||||
self.cursor = max(0, min(self.cursor, len(rows) - 1))
|
||||
if self.cursor < self.offset:
|
||||
self.offset = self.cursor
|
||||
elif self.cursor >= self.offset + visible:
|
||||
self.offset = self.cursor - visible + 1
|
||||
self.offset = max(0, min(self.offset, max(0, len(rows) - visible)))
|
||||
return range(self.offset, min(len(rows), self.offset + visible))
|
||||
|
||||
def render(self) -> list[Text]:
|
||||
rows = self.rows
|
||||
widths = self._columns()
|
||||
window = self._scroll()
|
||||
|
||||
title = Text()
|
||||
title.append(" Resume a run", style="bold")
|
||||
title.append(f" {len(self.runs)} runs in ./{self.runs_dir}", style="dim")
|
||||
if self.filter:
|
||||
title.append(" search: ", style="dim")
|
||||
title.append(self.filter)
|
||||
header = Text(
|
||||
" " * len(_CURSOR)
|
||||
+ self._cells("started", "target", "status", "findings", "run", widths),
|
||||
style="dim",
|
||||
)
|
||||
lines = [title, Text(), header]
|
||||
if not rows:
|
||||
lines.append(Text(f" no runs match {self.filter!r}", style="dim"))
|
||||
if window.start:
|
||||
lines.append(Text(f" \u2026 {window.start} more above", style="dim"))
|
||||
lines.extend(self._row(rows[index], index == self.cursor, widths) for index in window)
|
||||
if window.stop < len(rows):
|
||||
lines.append(Text(f" \u2026 {len(rows) - window.stop} more below", style="dim"))
|
||||
footer = Text(" ")
|
||||
if self.notice:
|
||||
footer.append(self.notice, style=_AMBER)
|
||||
else:
|
||||
footer.append(
|
||||
"\u2191\u2193 move enter resume type to search esc cancel", style="dim"
|
||||
)
|
||||
lines.extend([Text(), footer])
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _cells(
|
||||
started: str,
|
||||
target: str,
|
||||
status: str,
|
||||
findings: str,
|
||||
run: str,
|
||||
widths: tuple[int, int, int],
|
||||
) -> str:
|
||||
target_width, status_width, run_width = widths
|
||||
return " ".join(
|
||||
[
|
||||
_fit(started, _STARTED_WIDTH),
|
||||
_fit(target, target_width),
|
||||
_fit(status, status_width),
|
||||
_fit(findings, _FINDINGS_WIDTH),
|
||||
_fit(run, run_width),
|
||||
]
|
||||
)
|
||||
|
||||
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int]) -> Text:
|
||||
target_width, status_width, run_width = widths
|
||||
primary = "bold" if selected else ""
|
||||
muted = "" if selected else "dim"
|
||||
status_style = _STATUS_STYLES.get(run.status, "")
|
||||
if not run.resumable:
|
||||
primary = muted = status_style = "dim"
|
||||
line = Text()
|
||||
line.append(_CURSOR if selected else " " * len(_CURSOR), style=_GREEN)
|
||||
line.append(_fit(relative_time(run.started_at, self.now), _STARTED_WIDTH), style=muted)
|
||||
line.append(" ")
|
||||
line.append(_fit(run.target, target_width), style=primary)
|
||||
line.append(" ")
|
||||
line.append(_fit(_status_text(run), status_width), style=status_style)
|
||||
line.append(" ")
|
||||
line.append(_fit(str(run.findings), _FINDINGS_WIDTH), style=muted)
|
||||
line.append(" ")
|
||||
line.append(_fit(run.run_name, run_width), style=muted)
|
||||
return line
|
||||
|
||||
def draw(self) -> None:
|
||||
self.clear()
|
||||
lines = self.render()
|
||||
for line in lines:
|
||||
self.console.print(line, soft_wrap=True, overflow="crop", end="\n")
|
||||
self.console.file.flush()
|
||||
self._drawn = len(lines)
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._drawn:
|
||||
self.console.file.write(f"\x1b[{self._drawn}A\r{_CLEAR_BELOW}")
|
||||
self.console.file.flush()
|
||||
self._drawn = 0
|
||||
|
||||
def handle(self, key: str) -> tuple[bool, RunSummary | None]:
|
||||
"""Apply one key: (done, run), where done with no run means cancelled."""
|
||||
self.notice = ""
|
||||
if key == KEY_INTERRUPT:
|
||||
raise KeyboardInterrupt
|
||||
if key == KEY_ESCAPE:
|
||||
if not self.filter:
|
||||
return True, None
|
||||
self.filter = ""
|
||||
self.cursor = 0
|
||||
return False, None
|
||||
if key == KEY_ENTER:
|
||||
return self._choose()
|
||||
if key == KEY_BACKSPACE:
|
||||
self.filter = self.filter[:-1]
|
||||
self.cursor = 0
|
||||
elif len(key) == 1 and key.isprintable():
|
||||
self.filter += key
|
||||
self.cursor = 0
|
||||
else:
|
||||
self._move(key)
|
||||
self.cursor = max(0, min(self.cursor, max(0, len(self.rows) - 1)))
|
||||
return False, None
|
||||
|
||||
def _choose(self) -> tuple[bool, RunSummary | None]:
|
||||
rows = self.rows
|
||||
if not rows:
|
||||
return False, None
|
||||
run = rows[self.cursor]
|
||||
if not run.resumable:
|
||||
self.notice = f"{run.run_name} has no saved agent state to resume from"
|
||||
return False, None
|
||||
return True, run
|
||||
|
||||
def _move(self, key: str) -> None:
|
||||
steps = {
|
||||
KEY_UP: -1,
|
||||
KEY_DOWN: 1,
|
||||
KEY_PAGE_UP: -self._visible(),
|
||||
KEY_PAGE_DOWN: self._visible(),
|
||||
}
|
||||
if key in steps:
|
||||
self.cursor += steps[key]
|
||||
elif key == KEY_HOME:
|
||||
self.cursor = 0
|
||||
elif key == KEY_END:
|
||||
self.cursor = len(self.rows) - 1
|
||||
|
||||
def run(self, next_key: Callable[[], str]) -> RunSummary | None:
|
||||
self.console.file.write(_CURSOR_HIDE)
|
||||
try:
|
||||
self.draw()
|
||||
while True:
|
||||
done, chosen = self.handle(next_key())
|
||||
if done:
|
||||
return chosen
|
||||
self.draw()
|
||||
finally:
|
||||
self.clear()
|
||||
self.console.file.write(_CURSOR_SHOW)
|
||||
self.console.file.flush()
|
||||
|
||||
|
||||
def pick_run(
|
||||
runs: list[RunSummary],
|
||||
*,
|
||||
runs_dir: str,
|
||||
console: Console | None = None,
|
||||
stdin: TextIO | None = None,
|
||||
) -> RunSummary | None:
|
||||
stdin = stdin or sys.stdin
|
||||
console = console or Console()
|
||||
if not (hasattr(stdin, "isatty") and stdin.isatty() and console.is_terminal):
|
||||
raise PickerUnavailableError(
|
||||
"--resume needs a run name when there is no interactive terminal"
|
||||
)
|
||||
picker = ResumePicker(runs, console=console, runs_dir=runs_dir)
|
||||
return picker.run(lambda: read_key(stdin))
|
||||
86
strix/report/runs.py
Normal file
86
strix/report/runs.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Summaries of the runs under ./strix_runs, for pickers and listings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.core.paths import run_record_path, runs_base_dir, runtime_state_dir
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunSummary:
|
||||
run_name: str
|
||||
target: str
|
||||
started_at: str
|
||||
ended_at: str
|
||||
status: str
|
||||
findings: int
|
||||
resumable: bool
|
||||
|
||||
|
||||
def list_run_summaries(*, cwd: Path | None = None) -> list[RunSummary]:
|
||||
"""Every run with a run.json, newest activity first (run.json mtime)."""
|
||||
base = runs_base_dir(cwd=cwd)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
rows: list[tuple[float, RunSummary]] = []
|
||||
for child in base.iterdir():
|
||||
record_path = run_record_path(child)
|
||||
try:
|
||||
if not record_path.is_file():
|
||||
continue
|
||||
modified = record_path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
rows.append((modified, _summarize(child, _load_json(record_path, default={}))))
|
||||
rows.sort(key=lambda row: (row[0], row[1].run_name), reverse=True)
|
||||
return [summary for _, summary in rows]
|
||||
|
||||
|
||||
def _summarize(run_dir: Path, record: Any) -> RunSummary:
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
findings = _load_json(run_dir / "vulnerabilities.json", default=[])
|
||||
return RunSummary(
|
||||
run_name=run_dir.name,
|
||||
target=_describe_target(record),
|
||||
started_at=str(record.get("start_time") or ""),
|
||||
ended_at=str(record.get("end_time") or ""),
|
||||
status=str(record.get("status") or "unknown"),
|
||||
findings=len(findings) if isinstance(findings, list) else 0,
|
||||
resumable=(runtime_state_dir(run_dir) / "agents.json").is_file(),
|
||||
)
|
||||
|
||||
|
||||
def _describe_target(record: dict[str, Any]) -> str:
|
||||
targets = record.get("targets_info")
|
||||
originals = [
|
||||
str(entry["original"])
|
||||
for entry in targets or []
|
||||
if isinstance(entry, dict) and entry.get("original")
|
||||
]
|
||||
if originals:
|
||||
if len(originals) == 1:
|
||||
return originals[0]
|
||||
return f"{originals[0]} +{len(originals) - 1} more"
|
||||
mount = record.get("workspace_mount")
|
||||
if isinstance(mount, str) and mount:
|
||||
return f"{mount} (workspace)"
|
||||
instruction = record.get("user_instruction") or record.get("instruction")
|
||||
return str(instruction or "").strip().splitlines()[0] if instruction else ""
|
||||
|
||||
|
||||
def _load_json(path: Path, *, default: Any) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
__all__ = ["RunSummary", "list_run_summaries"]
|
||||
111
tests/test_cli_resume_picker.py
Normal file
111
tests/test_cli_resume_picker.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Tests for `strix --resume` without a run name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
cli_main: Any = importlib.import_module("strix.interface.main")
|
||||
|
||||
|
||||
def _write_run(base: Path, name: str, *, state: bool = True) -> None:
|
||||
run_dir = base / name
|
||||
(run_dir / ".state").mkdir(parents=True)
|
||||
(run_dir / "run.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_name": name,
|
||||
"status": "completed",
|
||||
"start_time": "2026-10-04T10:00:00+00:00",
|
||||
"targets_info": [
|
||||
{
|
||||
"type": "web_application",
|
||||
"details": {"target_url": "https://example.com"},
|
||||
"original": "https://example.com",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if state:
|
||||
(run_dir / ".state" / "agents.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_bare_resume_defers_to_the_picker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111")
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume"])
|
||||
|
||||
args = cli_main.parse_arguments()
|
||||
|
||||
assert args.resume is None
|
||||
assert args.resume_picker is True
|
||||
assert args.needs_setup is False
|
||||
assert args.resume is None
|
||||
assert args.targets_info == []
|
||||
|
||||
|
||||
def test_bare_resume_with_no_runs_is_an_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "no runs in ./strix_runs" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_bare_resume_headless_lists_the_runs_instead_of_prompting(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111")
|
||||
_write_run(tmp_path / "strix_runs", "example-com_2222", state=False)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "-n", "--resume"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "--resume needs a run name in headless mode" in err
|
||||
assert "example-com_1111 completed 2026-10-04T10:00:00 https://example.com" in err
|
||||
assert "example-com_2222 completed 2026-10-04T10:00:00 https://example.com" in err
|
||||
assert "example-com_2222" in err
|
||||
|
||||
|
||||
def test_bare_resume_still_rejects_targets(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111")
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "-t", "https://example.com"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "Cannot combine --resume with --target" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_named_resume_without_agent_state_is_an_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111", state=False)
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "example-com_1111"])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_main.parse_arguments()
|
||||
|
||||
assert "never reached its first agent snapshot" in capsys.readouterr().err
|
||||
|
|
@ -13,11 +13,14 @@ cli_main: Any = importlib.import_module("strix.interface.main")
|
|||
report_state_module: Any = importlib.import_module("strix.report.state")
|
||||
|
||||
|
||||
def _launch(monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool) -> list[str]:
|
||||
def _launch(
|
||||
monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool, resume_picker: bool = False
|
||||
) -> list[str]:
|
||||
calls: list[str] = []
|
||||
args = argparse.Namespace(
|
||||
non_interactive=False,
|
||||
needs_setup=needs_setup,
|
||||
resume_picker=resume_picker,
|
||||
run_name=None,
|
||||
fail_on=None,
|
||||
)
|
||||
|
|
@ -36,6 +39,7 @@ def _launch(monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool) -> list[str]:
|
|||
monkeypatch.setattr(cli_main, "validate_environment", lambda: None)
|
||||
monkeypatch.setattr(cli_main, "wait_for_import_warmup", lambda: None)
|
||||
monkeypatch.setattr(cli_main, "_bootstrap_scan", lambda _args: calls.append("bootstrap"))
|
||||
monkeypatch.setattr(cli_main, "_pick_run_to_resume", lambda _args: calls.append("pick"))
|
||||
monkeypatch.setattr(cli_main, "run_tui", run_tui)
|
||||
monkeypatch.setattr(cli_main, "notify_update", lambda _console: None)
|
||||
|
||||
|
|
@ -53,6 +57,14 @@ def test_start_screen_launch_defers_the_model_check_to_the_tui(
|
|||
assert _launch(monkeypatch, needs_setup=True) == ["tui"]
|
||||
|
||||
|
||||
def test_bare_resume_picks_a_run_before_the_preflight(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert _launch(monkeypatch, needs_setup=False, resume_picker=True) == [
|
||||
"pick",
|
||||
"bootstrap",
|
||||
"tui",
|
||||
]
|
||||
|
||||
|
||||
def test_direct_launch_with_a_bad_key_prints_the_panel_and_exits_before_the_tui(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -84,7 +96,7 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M
|
|||
calls: list[str] = []
|
||||
report_state = SimpleNamespace(cleanup=lambda status: calls.append(f"cleanup:{status}"))
|
||||
args = argparse.Namespace(
|
||||
non_interactive=False, needs_setup=False, run_name="run", fail_on=None
|
||||
non_interactive=False, needs_setup=False, resume_picker=False, run_name="run", fail_on=None
|
||||
)
|
||||
|
||||
async def run_tui(_args: argparse.Namespace) -> None:
|
||||
|
|
|
|||
221
tests/test_resume_picker.py
Normal file
221
tests/test_resume_picker.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from strix.interface.resume_picker import (
|
||||
KEY_BACKSPACE,
|
||||
KEY_DOWN,
|
||||
KEY_END,
|
||||
KEY_ENTER,
|
||||
KEY_ESCAPE,
|
||||
KEY_INTERRUPT,
|
||||
KEY_UP,
|
||||
PickerUnavailableError,
|
||||
ResumePicker,
|
||||
filter_runs,
|
||||
pick_run,
|
||||
relative_time,
|
||||
translate_key,
|
||||
)
|
||||
from strix.report.runs import RunSummary
|
||||
|
||||
|
||||
NOW = datetime(2026, 10, 4, 16, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _run(
|
||||
name: str,
|
||||
target: str,
|
||||
*,
|
||||
minutes_ago: int,
|
||||
status: str = "completed",
|
||||
findings: int = 0,
|
||||
resumable: bool = True,
|
||||
) -> RunSummary:
|
||||
started = (NOW - timedelta(minutes=minutes_ago)).isoformat()
|
||||
return RunSummary(
|
||||
run_name=name,
|
||||
target=target,
|
||||
started_at=started,
|
||||
ended_at=started,
|
||||
status=status,
|
||||
findings=findings,
|
||||
resumable=resumable,
|
||||
)
|
||||
|
||||
|
||||
RUNS = [
|
||||
_run("example-com_e5e6", "https://example.com", minutes_ago=12),
|
||||
_run(
|
||||
"example-com_223e", "https://example.com", minutes_ago=40, status="interrupted", findings=2
|
||||
),
|
||||
_run(
|
||||
"juice-shop_8a1f",
|
||||
"https://juice-shop.herokuapp.com",
|
||||
minutes_ago=60 * 26,
|
||||
status="stopped",
|
||||
findings=7,
|
||||
),
|
||||
_run(
|
||||
"strix_41c0",
|
||||
"/home/me/code/strix (workspace)",
|
||||
minutes_ago=60 * 50,
|
||||
status="failed",
|
||||
resumable=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _console(width: int = 110, height: int = 30) -> tuple[Console, io.StringIO]:
|
||||
buffer = io.StringIO()
|
||||
console = Console(
|
||||
file=buffer, width=width, height=height, force_terminal=True, color_system="truecolor"
|
||||
)
|
||||
return console, buffer
|
||||
|
||||
|
||||
def _picker(runs: list[RunSummary] = RUNS) -> ResumePicker:
|
||||
console, _ = _console()
|
||||
return ResumePicker(runs, console=console, runs_dir="strix_runs", now=NOW)
|
||||
|
||||
|
||||
def _plain(picker: ResumePicker) -> str:
|
||||
return "\n".join(line.plain for line in picker.render())
|
||||
|
||||
|
||||
def test_translate_key_maps_control_characters() -> None:
|
||||
assert translate_key("\r") == KEY_ENTER
|
||||
assert translate_key("\n") == KEY_ENTER
|
||||
assert translate_key("\x1b") == KEY_ESCAPE
|
||||
assert translate_key("\x7f") == KEY_BACKSPACE
|
||||
assert translate_key("\x03") == KEY_INTERRUPT
|
||||
assert translate_key("a") == "a"
|
||||
|
||||
|
||||
def test_relative_time_buckets() -> None:
|
||||
assert relative_time((NOW - timedelta(seconds=5)).isoformat(), NOW) == "just now"
|
||||
assert relative_time((NOW - timedelta(minutes=12)).isoformat(), NOW) == "12 min ago"
|
||||
assert relative_time((NOW - timedelta(hours=3, minutes=1)).isoformat(), NOW) == "3 h ago"
|
||||
assert relative_time((NOW - timedelta(days=2)).isoformat(), NOW) == "2 d ago"
|
||||
assert relative_time("2026-01-02T15:04:00+00:00", NOW).startswith("Jan 2")
|
||||
assert relative_time("2025-01-02T15:04:00+00:00", NOW).endswith("2025")
|
||||
assert relative_time("not a date", NOW) == "not a date"
|
||||
assert relative_time("", NOW) == "unknown"
|
||||
|
||||
|
||||
def test_filter_matches_name_target_and_status() -> None:
|
||||
assert [run.run_name for run in filter_runs(RUNS, "juice")] == ["juice-shop_8a1f"]
|
||||
assert [run.run_name for run in filter_runs(RUNS, "INTERRUPTED")] == ["example-com_223e"]
|
||||
assert [run.run_name for run in filter_runs(RUNS, "example.com")] == [
|
||||
"example-com_e5e6",
|
||||
"example-com_223e",
|
||||
]
|
||||
assert filter_runs(RUNS, "") == RUNS
|
||||
|
||||
|
||||
def test_render_lists_every_run_with_its_metadata() -> None:
|
||||
text = _plain(_picker())
|
||||
assert "Resume a run" in text
|
||||
assert "4 runs in ./strix_runs" in text
|
||||
for run in RUNS:
|
||||
assert run.run_name in text
|
||||
assert "12 min ago" in text
|
||||
assert "https://juice-shop.herokuapp.com" in text
|
||||
assert "interrupted" in text
|
||||
assert "failed · no state" in text
|
||||
assert text.splitlines()[3].startswith(" \u276f ")
|
||||
|
||||
|
||||
def test_enter_returns_the_highlighted_run() -> None:
|
||||
keys = iter([KEY_DOWN, KEY_ENTER])
|
||||
assert _picker().run(lambda: next(keys)) == RUNS[1]
|
||||
|
||||
|
||||
def test_cursor_is_clamped_and_end_jumps_to_the_last_run() -> None:
|
||||
picker = _picker()
|
||||
picker.handle(KEY_UP)
|
||||
assert picker.cursor == 0
|
||||
picker.handle(KEY_END)
|
||||
assert picker.cursor == len(RUNS) - 1
|
||||
for _ in range(10):
|
||||
picker.handle(KEY_DOWN)
|
||||
assert picker.cursor == len(RUNS) - 1
|
||||
|
||||
|
||||
def test_escape_cancels_and_clears_the_search_first() -> None:
|
||||
keys = iter(["j", "u", KEY_ESCAPE, KEY_ESCAPE])
|
||||
picker = _picker()
|
||||
assert picker.run(lambda: next(keys)) is None
|
||||
assert picker.filter == ""
|
||||
|
||||
|
||||
def test_typing_filters_and_backspace_restores() -> None:
|
||||
picker = _picker()
|
||||
for char in "juice":
|
||||
picker.handle(char)
|
||||
assert [run.run_name for run in picker.rows] == ["juice-shop_8a1f"]
|
||||
assert "search: juice" in _plain(picker)
|
||||
for _ in range(5):
|
||||
picker.handle(KEY_BACKSPACE)
|
||||
assert len(picker.rows) == len(RUNS)
|
||||
|
||||
|
||||
def test_runs_without_agent_state_cannot_be_picked() -> None:
|
||||
picker = _picker()
|
||||
picker.handle(KEY_END)
|
||||
assert picker.handle(KEY_ENTER) == (False, None)
|
||||
assert "no saved agent state" in _plain(picker)
|
||||
|
||||
|
||||
def test_no_match_renders_a_hint_and_enter_does_nothing() -> None:
|
||||
picker = _picker()
|
||||
for char in "zzz":
|
||||
picker.handle(char)
|
||||
assert "no runs match 'zzz'" in _plain(picker)
|
||||
assert picker.handle(KEY_ENTER) == (False, None)
|
||||
|
||||
|
||||
def test_ctrl_c_raises_keyboard_interrupt() -> None:
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
_picker().handle(KEY_INTERRUPT)
|
||||
|
||||
|
||||
def test_long_lists_scroll_to_keep_the_cursor_visible() -> None:
|
||||
runs = [
|
||||
_run(f"run_{index:02d}", f"https://host{index}.example", minutes_ago=index)
|
||||
for index in range(40)
|
||||
]
|
||||
console, _ = _console(height=12)
|
||||
picker = ResumePicker(runs, console=console, runs_dir="strix_runs", now=NOW)
|
||||
text = _plain(picker)
|
||||
assert "run_00" in text
|
||||
assert "more above" not in text
|
||||
assert "more below" in text
|
||||
picker.handle(KEY_END)
|
||||
text = _plain(picker)
|
||||
assert "run_39" in text
|
||||
assert "run_00" not in text
|
||||
assert "more above" in text
|
||||
assert "more below" not in text
|
||||
|
||||
|
||||
def test_run_redraws_in_place_and_restores_the_cursor() -> None:
|
||||
console, buffer = _console()
|
||||
picker = ResumePicker(RUNS, console=console, runs_dir="strix_runs", now=NOW)
|
||||
keys = iter([KEY_DOWN, KEY_ENTER])
|
||||
picker.run(lambda: next(keys))
|
||||
output = buffer.getvalue()
|
||||
assert output.startswith("\x1b[?25l")
|
||||
assert output.endswith("\x1b[?25h")
|
||||
assert "\x1b[J" in output
|
||||
assert "example-com_223e" in output
|
||||
|
||||
|
||||
def test_pick_run_needs_a_terminal() -> None:
|
||||
console, _ = _console()
|
||||
with pytest.raises(PickerUnavailableError):
|
||||
pick_run(RUNS, runs_dir="strix_runs", console=console, stdin=io.StringIO())
|
||||
124
tests/test_run_summaries.py
Normal file
124
tests/test_run_summaries.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Tests for the run listing behind the resume picker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.report.runs import list_run_summaries
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _write_run(
|
||||
base: Path,
|
||||
name: str,
|
||||
record: object,
|
||||
*,
|
||||
modified: float,
|
||||
state: bool = True,
|
||||
findings: int | None = None,
|
||||
) -> None:
|
||||
run_dir = base / name
|
||||
run_dir.mkdir(parents=True)
|
||||
path = run_dir / "run.json"
|
||||
path.write_text(record if isinstance(record, str) else json.dumps(record), encoding="utf-8")
|
||||
if state:
|
||||
(run_dir / ".state").mkdir()
|
||||
(run_dir / ".state" / "agents.json").write_text("{}", encoding="utf-8")
|
||||
if findings is not None:
|
||||
(run_dir / "vulnerabilities.json").write_text(
|
||||
json.dumps([{"id": index} for index in range(findings)]), encoding="utf-8"
|
||||
)
|
||||
os.utime(path, (modified, modified))
|
||||
|
||||
|
||||
def test_lists_runs_newest_first_with_target_status_and_findings(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_write_run(
|
||||
base,
|
||||
"example-com_1111",
|
||||
{
|
||||
"status": "completed",
|
||||
"start_time": "2026-10-04T10:00:00+00:00",
|
||||
"end_time": "2026-10-04T10:05:00+00:00",
|
||||
"targets_info": [{"type": "web_application", "original": "https://example.com"}],
|
||||
},
|
||||
modified=1_000,
|
||||
findings=2,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
"example-com_2222",
|
||||
{
|
||||
"status": "interrupted",
|
||||
"start_time": "2026-10-04T11:00:00+00:00",
|
||||
"targets_info": [
|
||||
{"type": "web_application", "original": "https://example.com"},
|
||||
{"type": "web_application", "original": "https://api.example.com"},
|
||||
],
|
||||
},
|
||||
modified=3_000,
|
||||
)
|
||||
(base / "not-a-run").mkdir()
|
||||
|
||||
runs = list_run_summaries(cwd=tmp_path)
|
||||
|
||||
assert [run.run_name for run in runs] == ["example-com_2222", "example-com_1111"]
|
||||
newest, oldest = runs
|
||||
assert newest.target == "https://example.com +1 more"
|
||||
assert newest.status == "interrupted"
|
||||
assert newest.started_at == "2026-10-04T11:00:00+00:00"
|
||||
assert newest.ended_at == ""
|
||||
assert newest.findings == 0
|
||||
assert newest.resumable is True
|
||||
assert oldest.target == "https://example.com"
|
||||
assert oldest.findings == 2
|
||||
assert oldest.ended_at == "2026-10-04T10:05:00+00:00"
|
||||
|
||||
|
||||
def test_target_less_runs_describe_their_workspace_or_instruction(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_write_run(
|
||||
base,
|
||||
"pentest_aaaa",
|
||||
{"status": "stopped", "targets_info": [], "workspace_mount": "/home/me/app"},
|
||||
modified=2_000,
|
||||
)
|
||||
_write_run(
|
||||
base,
|
||||
"pentest_bbbb",
|
||||
{"status": "failed", "targets_info": [], "user_instruction": "audit the login\nflow"},
|
||||
modified=1_000,
|
||||
)
|
||||
|
||||
runs = {run.run_name: run for run in list_run_summaries(cwd=tmp_path)}
|
||||
|
||||
assert runs["pentest_aaaa"].target == "/home/me/app (workspace)"
|
||||
assert runs["pentest_bbbb"].target == "audit the login"
|
||||
|
||||
|
||||
def test_runs_without_agent_state_or_with_a_broken_record_are_listed_not_resumable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_write_run(base, "no-state_cccc", {"status": "failed"}, modified=2_000, state=False)
|
||||
_write_run(base, "broken_dddd", "{not json", modified=1_000)
|
||||
_write_run(base, "list_eeee", "[]", modified=500)
|
||||
|
||||
runs = list_run_summaries(cwd=tmp_path)
|
||||
|
||||
assert [(run.run_name, run.resumable) for run in runs] == [
|
||||
("no-state_cccc", False),
|
||||
("broken_dddd", True),
|
||||
("list_eeee", True),
|
||||
]
|
||||
assert runs[1].status == "unknown"
|
||||
assert runs[1].target == ""
|
||||
|
||||
|
||||
def test_missing_runs_dir_lists_nothing(tmp_path: Path) -> None:
|
||||
assert list_run_summaries(cwd=tmp_path) == []
|
||||
Loading…
Add table
Reference in a new issue