mirror of
https://github.com/usestrix/strix.git
synced 2026-10-07 02:58:26 +00:00
feat(cli): add -r for --resume and a title column to the resume picker
This commit is contained in:
parent
d6db4f1f89
commit
f1386cad37
7 changed files with 68 additions and 15 deletions
|
|
@ -95,7 +95,7 @@ Key flags:
|
|||
| `--max-budget USD` | Hard LLM spend cap; scan wraps up cleanly at the limit. |
|
||||
| `--max-turns N` | Per-agent turn cap (default 500). |
|
||||
| `--fail-on SEVERITY` | Headless only: exit `2` only for findings at or above `critical`/`high`/`medium`/`low`/`info`. Default: any finding. |
|
||||
| `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
|
||||
| `-r`, `--resume RUN_NAME` | Resume a prior run from `strix_runs/`, with its agent history and targets. Cannot be combined with `-t`. |
|
||||
| `--scope-mode` | For code targets: `auto` (diff-scope in CI/headless), `diff` (force changed files only), `full` (whole tree). |
|
||||
| `--diff-base REF` | Branch or commit that `diff` scope compares against. Defaults to the repo's default branch. |
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ Strix Cloud:
|
|||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--resume",
|
||||
type=str,
|
||||
nargs="?",
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ def filter_runs(runs: list[RunSummary], needle: str) -> list[RunSummary]:
|
|||
for run in runs
|
||||
if needle in run.run_name.lower()
|
||||
or needle in run.target.lower()
|
||||
or needle in run.title.lower()
|
||||
or needle in run.status.lower()
|
||||
]
|
||||
|
||||
|
|
@ -215,16 +216,18 @@ class ResumePicker:
|
|||
def _visible(self) -> int:
|
||||
return max(3, min(len(self.rows), _MAX_VISIBLE, self.console.height - _CHROME_LINES))
|
||||
|
||||
def _columns(self) -> tuple[int, int, int]:
|
||||
def _columns(self) -> tuple[int, 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
|
||||
fixed = len(_CURSOR) + _STARTED_WIDTH + _FINDINGS_WIDTH + status_width + 5 * 2
|
||||
target_width = width - fixed - run_width
|
||||
if target_width < _MIN_TARGET_WIDTH:
|
||||
run_width = max(8, run_width + target_width - _MIN_TARGET_WIDTH)
|
||||
if target_width < 2 * _MIN_TARGET_WIDTH:
|
||||
run_width = max(8, run_width + target_width - 2 * _MIN_TARGET_WIDTH)
|
||||
target_width = width - fixed - run_width
|
||||
return max(_MIN_TARGET_WIDTH, target_width), status_width, run_width
|
||||
title_width = max(_MIN_TARGET_WIDTH, target_width // 2)
|
||||
target_width = max(_MIN_TARGET_WIDTH, target_width - title_width)
|
||||
return title_width, target_width, status_width, run_width
|
||||
|
||||
def _scroll(self) -> range:
|
||||
rows = self.rows
|
||||
|
|
@ -250,7 +253,7 @@ class ResumePicker:
|
|||
title.append(self.filter)
|
||||
header = Text(
|
||||
" " * len(_CURSOR)
|
||||
+ self._cells("started", "target", "status", "findings", "run", widths),
|
||||
+ self._cells("started", "title", "target", "status", "findings", "run", widths),
|
||||
style="dim",
|
||||
)
|
||||
lines = [title, Text(), header]
|
||||
|
|
@ -276,16 +279,18 @@ class ResumePicker:
|
|||
@staticmethod
|
||||
def _cells(
|
||||
started: str,
|
||||
title: str,
|
||||
target: str,
|
||||
status: str,
|
||||
findings: str,
|
||||
run: str,
|
||||
widths: tuple[int, int, int],
|
||||
widths: tuple[int, int, int, int],
|
||||
) -> str:
|
||||
target_width, status_width, run_width = widths
|
||||
title_width, target_width, status_width, run_width = widths
|
||||
return " ".join(
|
||||
[
|
||||
_fit(started, _STARTED_WIDTH),
|
||||
_fit(title, title_width),
|
||||
_fit(target, target_width),
|
||||
_fit(status, status_width),
|
||||
_fit(findings, _FINDINGS_WIDTH),
|
||||
|
|
@ -293,8 +298,8 @@ class ResumePicker:
|
|||
]
|
||||
)
|
||||
|
||||
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int]) -> Text:
|
||||
target_width, status_width, run_width = widths
|
||||
def _row(self, run: RunSummary, selected: bool, widths: tuple[int, int, int, int]) -> Text:
|
||||
title_width, target_width, status_width, run_width = widths
|
||||
primary = "bold" if selected else ""
|
||||
muted = "" if selected else "dim"
|
||||
status_style = _STATUS_STYLES.get(run.status, "")
|
||||
|
|
@ -304,7 +309,9 @@ class ResumePicker:
|
|||
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(_fit(run.title, title_width), style=primary)
|
||||
line.append(" ")
|
||||
line.append(_fit(run.target, target_width), style=muted)
|
||||
line.append(" ")
|
||||
line.append(_fit(_status_text(run), status_width), style=status_style)
|
||||
line.append(" ")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ class RunSummary:
|
|||
status: str
|
||||
findings: int
|
||||
resumable: bool
|
||||
title: str = ""
|
||||
|
||||
|
||||
def list_run_summaries(*, cwd: Path | None = None) -> list[RunSummary]:
|
||||
|
|
@ -55,9 +57,23 @@ def _summarize(run_dir: Path, record: Any) -> RunSummary:
|
|||
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(),
|
||||
title=_title(record, findings),
|
||||
)
|
||||
|
||||
|
||||
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
|
||||
|
||||
def _title(record: dict[str, Any], findings: Any) -> str:
|
||||
"""The instruction's first line, else the most severe finding's title, shortened."""
|
||||
instruction = str(record.get("user_instruction") or record.get("instruction") or "").strip()
|
||||
if instruction:
|
||||
return instruction.splitlines()[0]
|
||||
found = [f for f in findings if isinstance(f, dict)] if isinstance(findings, list) else []
|
||||
worst = min(found, key=lambda f: _SEVERITY_RANK.get(str(f.get("severity")), 4), default={})
|
||||
return re.split(r" via | on | \(", str(worst.get("title") or ""))[0]
|
||||
|
||||
|
||||
def _describe_target(record: dict[str, Any]) -> str:
|
||||
targets = record.get("targets_info")
|
||||
originals = [
|
||||
|
|
|
|||
|
|
@ -41,10 +41,13 @@ def _write_run(base: Path, name: str, *, state: bool = True) -> None:
|
|||
(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:
|
||||
@pytest.mark.parametrize("flag", ["--resume", "-r"])
|
||||
def test_bare_resume_defers_to_the_picker(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, flag: str
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_write_run(tmp_path / "strix_runs", "example-com_1111")
|
||||
monkeypatch.setattr(sys, "argv", ["strix", "--resume"])
|
||||
monkeypatch.setattr(sys, "argv", ["strix", flag])
|
||||
|
||||
args = cli_main.parse_arguments()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ def _run(
|
|||
status: str = "completed",
|
||||
findings: int = 0,
|
||||
resumable: bool = True,
|
||||
title: str = "",
|
||||
) -> RunSummary:
|
||||
started = (NOW - timedelta(minutes=minutes_ago)).isoformat()
|
||||
return RunSummary(
|
||||
|
|
@ -48,6 +49,7 @@ def _run(
|
|||
status=status,
|
||||
findings=findings,
|
||||
resumable=resumable,
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ RUNS = [
|
|||
minutes_ago=60 * 26,
|
||||
status="stopped",
|
||||
findings=7,
|
||||
title="SQL injection",
|
||||
),
|
||||
_run(
|
||||
"strix_41c0",
|
||||
|
|
@ -127,7 +130,8 @@ def test_render_lists_every_run_with_its_metadata() -> None:
|
|||
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 "https://juice-shop" in text
|
||||
assert "SQL injection" in text
|
||||
assert "interrupted" in text
|
||||
assert "failed · no state" in text
|
||||
assert text.splitlines()[3].startswith(" \u276f ")
|
||||
|
|
|
|||
|
|
@ -139,3 +139,25 @@ def test_blank_instruction_does_not_break_the_listing(
|
|||
[summary] = list_run_summaries()
|
||||
|
||||
assert summary.target == ""
|
||||
|
||||
|
||||
def test_title_is_the_instruction_else_the_most_severe_finding(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_write_run(
|
||||
base, "instructed_1111", {"user_instruction": "focus on auth\nand billing"}, modified=2
|
||||
)
|
||||
_write_run(base, "found_2222", {}, modified=1)
|
||||
(base / "found_2222" / "vulnerabilities.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"title": "Verbose errors", "severity": "low"},
|
||||
{"title": "SQL injection via id on /login", "severity": "critical"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert [run.title for run in list_run_summaries(cwd=tmp_path)] == [
|
||||
"focus on auth",
|
||||
"SQL injection",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue