refactor: consolidate run state layout
This commit is contained in:
@@ -142,6 +142,7 @@ hiddenimports = [
|
||||
'strix.core.agents',
|
||||
'strix.core.execution',
|
||||
'strix.core.inputs',
|
||||
'strix.core.paths',
|
||||
'strix.core.runner',
|
||||
'strix.core.sessions',
|
||||
'strix.report',
|
||||
|
||||
+15
-9
@@ -120,15 +120,20 @@ class AgentCoordinator:
|
||||
session = runtime.session
|
||||
stream = runtime.stream
|
||||
interrupt = runtime.interrupt_on_message
|
||||
if session is not None:
|
||||
try:
|
||||
await session.add_items([self._message_to_session_item(message)])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"agent.send failed to append to SDK session target=%s",
|
||||
target_agent_id,
|
||||
)
|
||||
return False
|
||||
if session is None:
|
||||
logger.warning(
|
||||
"agent.send dropped target=%s because its SDK session is not attached",
|
||||
target_agent_id,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
await session.add_items([self._message_to_session_item(message)])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"agent.send failed to append to SDK session target=%s",
|
||||
target_agent_id,
|
||||
)
|
||||
return False
|
||||
async with self._lock:
|
||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
||||
@@ -158,6 +163,7 @@ class AgentCoordinator:
|
||||
session = self.runtimes.get(agent_id, AgentRuntime()).session
|
||||
if count <= 0:
|
||||
return 0, []
|
||||
await self._maybe_snapshot()
|
||||
if not include_items or session is None:
|
||||
return count, []
|
||||
items = await session.get_items()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Run directory path helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNS_DIR_NAME = "strix_runs"
|
||||
RUNTIME_STATE_DIR_NAME = ".state"
|
||||
RUN_RECORD_FILENAME = "run.json"
|
||||
|
||||
|
||||
def run_dir_for(run_name: str, *, cwd: Path | None = None) -> Path:
|
||||
base = cwd or Path.cwd()
|
||||
return base / RUNS_DIR_NAME / run_name
|
||||
|
||||
|
||||
def runtime_state_dir(run_dir: Path) -> Path:
|
||||
return run_dir / RUNTIME_STATE_DIR_NAME
|
||||
|
||||
|
||||
def run_record_path(run_dir: Path) -> Path:
|
||||
return run_dir / RUN_RECORD_FILENAME
|
||||
@@ -12,7 +12,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunConfig
|
||||
@@ -35,6 +34,7 @@ from strix.core.inputs import (
|
||||
build_scope_context,
|
||||
make_model_settings,
|
||||
)
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.sessions import open_agent_session
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
@@ -69,13 +69,15 @@ async def run_strix_scan(
|
||||
|
||||
# Resolve run_dir before any heavy bring-up so the log file captures
|
||||
# everything from sandbox start onwards.
|
||||
run_dir = Path.cwd() / "strix_runs" / scan_id
|
||||
run_dir = run_dir_for(scan_id)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
teardown_logging = setup_scan_logging(run_dir)
|
||||
set_scan_id(scan_id)
|
||||
|
||||
agents_path = run_dir / "agents.json"
|
||||
agents_db = run_dir / "agents.db"
|
||||
agents_path = state_dir / "agents.json"
|
||||
agents_db = state_dir / "agents.db"
|
||||
is_resume = agents_path.exists()
|
||||
|
||||
logger.info(
|
||||
@@ -103,14 +105,14 @@ async def run_strix_scan(
|
||||
coordinator = AgentCoordinator()
|
||||
coordinator.set_snapshot_path(agents_path)
|
||||
|
||||
# Wire the per-agent todo store to ``{run_dir}/todos.json`` (mirrored
|
||||
# Wire the per-agent todo store to ``{run_dir}/.state/todos.json`` (mirrored
|
||||
# on every CRUD) and reload any prior todos so respawned subagents
|
||||
# find their lists intact. Same for the shared notes store.
|
||||
from strix.tools.notes.tools import hydrate_notes_from_disk
|
||||
from strix.tools.todo.tools import hydrate_todos_from_disk
|
||||
|
||||
hydrate_todos_from_disk(run_dir)
|
||||
hydrate_notes_from_disk(run_dir)
|
||||
hydrate_todos_from_disk(state_dir)
|
||||
hydrate_notes_from_disk(state_dir)
|
||||
|
||||
root_id: str | None = None
|
||||
if is_resume:
|
||||
|
||||
@@ -89,6 +89,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
"run_name": args.run_name,
|
||||
"diff_scope": getattr(args, "diff_scope", {"active": False}),
|
||||
"scan_mode": scan_mode,
|
||||
"non_interactive": bool(getattr(args, "non_interactive", False)),
|
||||
"local_sources": getattr(args, "local_sources", None) or [],
|
||||
"scope_mode": getattr(args, "scope_mode", "auto"),
|
||||
"diff_base": getattr(args, "diff_base", None),
|
||||
# Forward the new --instruction (if any) to the resume path so it
|
||||
# can deliver it as a fresh user message after SDK session replay.
|
||||
# Empty string when the user didn't pass one on resume — no-op.
|
||||
@@ -96,8 +100,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
}
|
||||
|
||||
report_state = ReportState(args.run_name)
|
||||
report_state.set_scan_config(scan_config)
|
||||
report_state.hydrate_from_run_dir()
|
||||
report_state.set_scan_config(scan_config)
|
||||
report_state.save_run_data()
|
||||
|
||||
def display_vulnerability(report: dict[str, Any]) -> None:
|
||||
report_id = report.get("id", "unknown")
|
||||
@@ -121,7 +126,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
report_state.cleanup()
|
||||
|
||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||
report_state.cleanup()
|
||||
report_state.cleanup(status="interrupted")
|
||||
sys.exit(1)
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
|
||||
+31
-35
@@ -5,9 +5,9 @@ Strix Agent Interface
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from agents.model_settings import ModelSettings
|
||||
@@ -24,6 +24,7 @@ from strix.config import (
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.models import configure_sdk_model_defaults, normalize_model_name
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
from strix.interface.utils import (
|
||||
@@ -42,6 +43,7 @@ from strix.interface.utils import (
|
||||
validate_config_file,
|
||||
)
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.report.writer import read_run_record, write_run_record
|
||||
from strix.telemetry import posthog
|
||||
from strix.telemetry.logging import configure_dependency_logging
|
||||
|
||||
@@ -433,7 +435,7 @@ Examples:
|
||||
"the prior run left off, including the original target list."
|
||||
)
|
||||
_load_resume_state(args, parser)
|
||||
agents_path = Path("strix_runs") / args.resume / "agents.json"
|
||||
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 "
|
||||
@@ -469,17 +471,16 @@ Examples:
|
||||
return args
|
||||
|
||||
|
||||
def _persist_scan_state(args: argparse.Namespace) -> None:
|
||||
"""Dump the resolved scan inputs to ``{run_dir}/scan_state.json``.
|
||||
|
||||
Called once at the end of fresh-run setup. ``--resume <run_name>`` on
|
||||
a future invocation reads this file to repopulate targets, scan_mode,
|
||||
instruction, local_sources, and diff_scope without the user having to
|
||||
retype them.
|
||||
"""
|
||||
run_dir = Path("strix_runs") / args.run_name
|
||||
def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
"""Write the single public run descriptor used by resume and reporting."""
|
||||
run_dir = run_dir_for(args.run_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
state = {
|
||||
run_record = {
|
||||
"run_id": args.run_name,
|
||||
"run_name": args.run_name,
|
||||
"status": "running",
|
||||
"start_time": datetime.now(UTC).isoformat(),
|
||||
"end_time": None,
|
||||
"targets_info": args.targets_info,
|
||||
"scan_mode": args.scan_mode,
|
||||
"instruction": args.instruction,
|
||||
@@ -489,34 +490,26 @@ def _persist_scan_state(args: argparse.Namespace) -> None:
|
||||
"scope_mode": args.scope_mode,
|
||||
"diff_base": args.diff_base,
|
||||
}
|
||||
(run_dir / "scan_state.json").write_text(
|
||||
json.dumps(state, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_run_record(run_dir, run_record)
|
||||
|
||||
|
||||
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
||||
"""Populate ``args.targets_info`` and friends from a prior run's scan state.
|
||||
|
||||
Reads ``strix_runs/<run_name>/scan_state.json`` written at the end of the
|
||||
fresh-run setup in ``main()``. Only fields the user did not explicitly
|
||||
set on this invocation are restored — passing ``--instruction`` on
|
||||
resume, for example, overrides the persisted instruction.
|
||||
"""
|
||||
state_path = Path("strix_runs") / args.resume / "scan_state.json"
|
||||
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
|
||||
run_dir = run_dir_for(args.resume)
|
||||
state_path = run_dir / "run.json"
|
||||
if not state_path.exists():
|
||||
parser.error(
|
||||
f"--resume {args.resume}: no such run "
|
||||
f"(missing {state_path}; remove --resume for a fresh start)"
|
||||
)
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
parser.error(f"--resume {args.resume}: scan_state.json unreadable: {exc}")
|
||||
state = read_run_record(run_dir)
|
||||
except RuntimeError as exc:
|
||||
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
||||
|
||||
args.targets_info = state.get("targets_info") or []
|
||||
if not args.targets_info:
|
||||
parser.error(f"--resume {args.resume}: scan_state.json has no targets_info")
|
||||
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
||||
|
||||
# Validate any persisted ``cloned_repo_path`` still exists on disk.
|
||||
# The resume path skips re-cloning, so a missing dir would mean the
|
||||
@@ -540,8 +533,6 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
|
||||
if args.instruction is None:
|
||||
args.instruction = state.get("instruction")
|
||||
if state.get("instruction_file") and args.instruction_file is None:
|
||||
args.instruction_file = state.get("instruction_file")
|
||||
if state.get("local_sources"):
|
||||
args.local_sources = state.get("local_sources")
|
||||
if state.get("diff_scope"):
|
||||
@@ -561,8 +552,8 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
report_state = get_global_report_state()
|
||||
|
||||
scan_completed = False
|
||||
if report_state and report_state.scan_results:
|
||||
scan_completed = report_state.scan_results.get("scan_completed", False)
|
||||
if report_state:
|
||||
scan_completed = report_state.run_record.get("status") == "completed"
|
||||
|
||||
completion_text = Text()
|
||||
if scan_completed:
|
||||
@@ -739,10 +730,10 @@ def main() -> None:
|
||||
else:
|
||||
args.instruction = diff_scope.instruction_block
|
||||
|
||||
# Persist the fully-resolved scan state so a future
|
||||
# Persist the fully-resolved run descriptor so a future
|
||||
# ``--resume <run_name>`` invocation can pick up without
|
||||
# re-supplying targets / instructions / scope.
|
||||
_persist_scan_state(args)
|
||||
_persist_run_record(args)
|
||||
|
||||
posthog.start(
|
||||
model=load_settings().llm.model,
|
||||
@@ -767,9 +758,14 @@ def main() -> None:
|
||||
finally:
|
||||
report_state = get_global_report_state()
|
||||
if report_state:
|
||||
status = {"interrupted": "interrupted", "error": "failed"}.get(
|
||||
exit_reason,
|
||||
"stopped",
|
||||
)
|
||||
report_state.cleanup(status=status)
|
||||
posthog.end(report_state, exit_reason=exit_reason)
|
||||
|
||||
results_path = Path("strix_runs") / args.run_name
|
||||
results_path = run_dir_for(args.run_name)
|
||||
display_completion_message(args, results_path)
|
||||
|
||||
if args.non_interactive:
|
||||
|
||||
@@ -708,8 +708,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self.scan_config = self._build_scan_config(args)
|
||||
|
||||
self.report_state = ReportState(self.scan_config["run_name"])
|
||||
self.report_state.set_scan_config(self.scan_config)
|
||||
self.report_state.hydrate_from_run_dir()
|
||||
self.report_state.set_scan_config(self.scan_config)
|
||||
self.report_state.save_run_data()
|
||||
set_global_report_state(self.report_state)
|
||||
self.live_view = TuiLiveView()
|
||||
self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir())
|
||||
@@ -759,6 +760,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
"run_name": args.run_name,
|
||||
"diff_scope": getattr(args, "diff_scope", {"active": False}),
|
||||
"scan_mode": getattr(args, "scan_mode", "deep"),
|
||||
"non_interactive": bool(getattr(args, "non_interactive", False)),
|
||||
"local_sources": getattr(args, "local_sources", None) or [],
|
||||
"scope_mode": getattr(args, "scope_mode", "auto"),
|
||||
"diff_base": getattr(args, "diff_base", None),
|
||||
# Forward the new --instruction (if any) so the resume path
|
||||
# can deliver it as a fresh user message after session replay.
|
||||
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
|
||||
@@ -769,7 +774,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self.report_state.cleanup()
|
||||
|
||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||
self.report_state.cleanup()
|
||||
self.report_state.cleanup(status="interrupted")
|
||||
sys.exit(0)
|
||||
|
||||
atexit.register(cleanup_on_exit)
|
||||
@@ -1611,13 +1616,16 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
)
|
||||
target_agent_id = self.selected_agent_id
|
||||
|
||||
send_user_message_to_agent(
|
||||
submitted = send_user_message_to_agent(
|
||||
coordinator=self.coordinator,
|
||||
loop=self._scan_loop,
|
||||
live_view=self.live_view,
|
||||
target_agent_id=target_agent_id,
|
||||
message=message,
|
||||
)
|
||||
if not submitted:
|
||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||
return
|
||||
|
||||
self._displayed_events.clear()
|
||||
self._update_chat_view()
|
||||
|
||||
@@ -8,6 +8,8 @@ import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.core.paths import runtime_state_dir
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -17,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[str, Any], str]]:
|
||||
agents_db = run_dir / "agents.db"
|
||||
agents_db = runtime_state_dir(run_dir) / "agents.db"
|
||||
session_ids = [aid for aid in agent_ids if isinstance(aid, str)]
|
||||
if not agents_db.exists() or not session_ids:
|
||||
return []
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from strix.core.paths import runtime_state_dir
|
||||
from strix.interface.tui.history import load_session_history
|
||||
|
||||
|
||||
@@ -24,7 +25,8 @@ class TuiLiveView:
|
||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||
agents_path = run_dir / "agents.json"
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
agents_path = state_dir / "agents.json"
|
||||
if not agents_path.exists():
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_user_message_to_agent(
|
||||
*,
|
||||
coordinator: Any,
|
||||
@@ -15,16 +19,26 @@ def send_user_message_to_agent(
|
||||
message: str,
|
||||
) -> bool:
|
||||
"""Record a local user message and enqueue it into the target SDK session."""
|
||||
live_view.record_user_message(target_agent_id, message)
|
||||
|
||||
if loop is None or loop.is_closed():
|
||||
return False
|
||||
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
live_view.record_user_message(target_agent_id, message)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
coordinator.send(
|
||||
target_agent_id,
|
||||
{"from": "user", "content": message, "type": "instruction"},
|
||||
),
|
||||
loop,
|
||||
)
|
||||
future.add_done_callback(_log_delivery_failure)
|
||||
return True
|
||||
|
||||
|
||||
def _log_delivery_failure(future: Any) -> None:
|
||||
try:
|
||||
delivered = bool(future.result())
|
||||
except Exception:
|
||||
logger.exception("TUI user message delivery failed")
|
||||
return
|
||||
if not delivered:
|
||||
logger.warning("TUI user message was not persisted to the SDK session")
|
||||
|
||||
+75
-59
@@ -6,7 +6,13 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from strix.report.writer import write_executive_report, write_run_metadata, write_vulnerabilities
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.report.writer import (
|
||||
read_run_record,
|
||||
write_executive_report,
|
||||
write_run_record,
|
||||
write_vulnerabilities,
|
||||
)
|
||||
from strix.telemetry import posthog
|
||||
|
||||
|
||||
@@ -45,13 +51,13 @@ class ReportState:
|
||||
|
||||
self.scan_results: dict[str, Any] | None = None
|
||||
self.scan_config: dict[str, Any] | None = None
|
||||
self.run_metadata: dict[str, Any] = {
|
||||
self.run_record: dict[str, Any] = {
|
||||
"run_id": self.run_id,
|
||||
"run_name": self.run_name,
|
||||
"start_time": self.start_time,
|
||||
"end_time": None,
|
||||
"targets": [],
|
||||
"status": "running",
|
||||
"targets_info": [],
|
||||
}
|
||||
self._run_dir: Path | None = None
|
||||
self._saved_vuln_ids: set[str] = set()
|
||||
@@ -61,28 +67,22 @@ class ReportState:
|
||||
|
||||
def get_run_dir(self) -> Path:
|
||||
if self._run_dir is None:
|
||||
runs_dir = Path.cwd() / "strix_runs"
|
||||
runs_dir.mkdir(exist_ok=True)
|
||||
|
||||
run_dir_name = self.run_name if self.run_name else self.run_id
|
||||
self._run_dir = runs_dir / run_dir_name
|
||||
self._run_dir.mkdir(exist_ok=True)
|
||||
self._run_dir = run_dir_for(run_dir_name)
|
||||
self._run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return self._run_dir
|
||||
|
||||
def hydrate_from_run_dir(self) -> None:
|
||||
"""Reload prior-scan state from ``{run_dir}/`` for resume.
|
||||
|
||||
Called by :func:`run_strix_scan` before any new agent runs.
|
||||
Restores:
|
||||
|
||||
- ``vulnerability_reports`` from ``vulnerabilities.json`` so
|
||||
:meth:`add_vulnerability_report` doesn't allocate a colliding
|
||||
``vuln-0001`` and overwrite the prior on-disk MD.
|
||||
- ``run_metadata`` (start_time, run_id, targets, status) from
|
||||
``run_metadata.json`` so audit-trail timestamps + the final
|
||||
report's duration calc reflect the original scan, not just
|
||||
this resume segment.
|
||||
- ``run_record`` from ``run.json`` so timestamps, run inputs,
|
||||
status, and final report state have one public source of truth.
|
||||
|
||||
Idempotent on missing files (fresh runs land here too via the
|
||||
same code path). **Raises on corruption** — silently swallowing
|
||||
@@ -93,25 +93,18 @@ class ReportState:
|
||||
"""
|
||||
run_dir = self.get_run_dir()
|
||||
|
||||
meta_path = run_dir / "run_metadata.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(
|
||||
f"run_metadata.json at {meta_path} is unreadable: {exc}",
|
||||
) from exc
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("start_time"), str):
|
||||
self.start_time = data["start_time"]
|
||||
self.run_metadata.update(
|
||||
{
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k in {"run_id", "run_name", "start_time", "targets", "status"}
|
||||
},
|
||||
)
|
||||
logger.info("scan store hydrated run_metadata from %s", meta_path)
|
||||
data = read_run_record(run_dir)
|
||||
if data:
|
||||
self.run_record.update(data)
|
||||
if isinstance(data.get("start_time"), str):
|
||||
self.start_time = data["start_time"]
|
||||
if isinstance(data.get("end_time"), str):
|
||||
self.end_time = data["end_time"]
|
||||
scan_results = data.get("scan_results")
|
||||
if isinstance(scan_results, dict):
|
||||
self.scan_results = scan_results
|
||||
self.final_scan_result = self._format_final_scan_result(scan_results)
|
||||
logger.info("report state hydrated run.json from %s", run_dir)
|
||||
|
||||
json_path = run_dir / "vulnerabilities.json"
|
||||
if json_path.exists():
|
||||
@@ -133,7 +126,8 @@ class ReportState:
|
||||
if isinstance(rid, str):
|
||||
self._saved_vuln_ids.add(rid)
|
||||
logger.info(
|
||||
"scan store hydrated %d vulnerability report(s)", len(self.vulnerability_reports)
|
||||
"report state hydrated %d vulnerability report(s)",
|
||||
len(self.vulnerability_reports),
|
||||
)
|
||||
|
||||
def add_vulnerability_report(
|
||||
@@ -228,22 +222,8 @@ class ReportState:
|
||||
"success": True,
|
||||
}
|
||||
|
||||
self.final_scan_result = f"""# Executive Summary
|
||||
|
||||
{executive_summary.strip()}
|
||||
|
||||
# Methodology
|
||||
|
||||
{methodology.strip()}
|
||||
|
||||
# Technical Analysis
|
||||
|
||||
{technical_analysis.strip()}
|
||||
|
||||
# Recommendations
|
||||
|
||||
{recommendations.strip()}
|
||||
"""
|
||||
self.final_scan_result = self._format_final_scan_result(self.scan_results)
|
||||
self.run_record["scan_results"] = self.scan_results
|
||||
|
||||
logger.info("Updated scan final fields")
|
||||
self.save_run_data(mark_complete=True)
|
||||
@@ -251,25 +231,61 @@ class ReportState:
|
||||
|
||||
def set_scan_config(self, config: dict[str, Any]) -> None:
|
||||
self.scan_config = config
|
||||
self.run_metadata.update(
|
||||
self.run_record["status"] = "running"
|
||||
self.run_record["end_time"] = None
|
||||
self.run_record.pop("scan_results", None)
|
||||
self.end_time = None
|
||||
self.scan_results = None
|
||||
self.final_scan_result = None
|
||||
self.run_record.update(
|
||||
{
|
||||
"targets": config.get("targets", []),
|
||||
"user_instructions": config.get("user_instructions", ""),
|
||||
"max_iterations": config.get("max_iterations", 200),
|
||||
"targets_info": config.get("targets", []),
|
||||
"instruction": config.get("user_instructions", ""),
|
||||
"scan_mode": config.get("scan_mode", "deep"),
|
||||
"diff_scope": config.get("diff_scope", {"active": False}),
|
||||
"non_interactive": bool(config.get("non_interactive", False)),
|
||||
"local_sources": config.get("local_sources", []),
|
||||
"scope_mode": config.get("scope_mode", "auto"),
|
||||
"diff_base": config.get("diff_base"),
|
||||
}
|
||||
)
|
||||
|
||||
def save_run_data(self, mark_complete: bool = False) -> None:
|
||||
def save_run_data(self, mark_complete: bool = False, status: str | None = None) -> None:
|
||||
if mark_complete:
|
||||
self.end_time = datetime.now(UTC).isoformat()
|
||||
self.run_record["end_time"] = self.end_time
|
||||
self.run_record["status"] = "completed"
|
||||
elif status and self.run_record.get("status") != "completed":
|
||||
current_status = self.run_record.get("status")
|
||||
if status == "stopped" and current_status in {"failed", "interrupted"}:
|
||||
status = str(current_status)
|
||||
if self.end_time is None:
|
||||
self.end_time = datetime.now(UTC).isoformat()
|
||||
self.run_metadata["end_time"] = self.end_time
|
||||
self.run_metadata["status"] = "completed"
|
||||
self.run_record["end_time"] = self.end_time
|
||||
self.run_record["status"] = status
|
||||
|
||||
self._save_artifacts()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.save_run_data(mark_complete=True)
|
||||
def cleanup(self, status: str = "stopped") -> None:
|
||||
self.save_run_data(status=status)
|
||||
|
||||
def _format_final_scan_result(self, scan_results: dict[str, Any]) -> str:
|
||||
return f"""# Executive Summary
|
||||
|
||||
{str(scan_results.get("executive_summary", "")).strip()}
|
||||
|
||||
# Methodology
|
||||
|
||||
{str(scan_results.get("methodology", "")).strip()}
|
||||
|
||||
# Technical Analysis
|
||||
|
||||
{str(scan_results.get("technical_analysis", "")).strip()}
|
||||
|
||||
# Recommendations
|
||||
|
||||
{str(scan_results.get("recommendations", "")).strip()}
|
||||
"""
|
||||
|
||||
def _save_artifacts(self) -> None:
|
||||
"""Write scan artifacts under ``run_dir``."""
|
||||
@@ -283,7 +299,7 @@ class ReportState:
|
||||
if self.vulnerability_reports:
|
||||
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
|
||||
|
||||
write_run_metadata(run_dir, self.run_metadata)
|
||||
write_run_record(run_dir, self.run_record)
|
||||
|
||||
logger.info("Essential scan data saved to: %s", run_dir)
|
||||
except (OSError, RuntimeError):
|
||||
|
||||
+18
-3
@@ -10,16 +10,31 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.core.paths import run_record_path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
|
||||
def write_run_metadata(run_dir: Path, run_metadata: dict[str, Any]) -> None:
|
||||
def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
path = run_record_path(run_dir)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"run.json at {path} is not an object")
|
||||
return data
|
||||
|
||||
|
||||
def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
|
||||
_atomic_write_text(
|
||||
run_dir / "run_metadata.json",
|
||||
json.dumps(run_metadata, ensure_ascii=False, indent=2, default=str),
|
||||
run_record_path(run_dir),
|
||||
json.dumps(run_record, ensure_ascii=False, indent=2, default=str),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Per-run notes (shared across agents).
|
||||
|
||||
Module-level dict shared across every agent in the same scan process.
|
||||
Mirrored to ``{run_dir}/notes.json`` after every CRUD via :func:`_persist`
|
||||
Mirrored to ``{state_dir}/notes.json`` after every CRUD via :func:`_persist`
|
||||
so a process restart can :func:`hydrate_notes_from_disk` and the resumed
|
||||
scan picks up exactly where it left off. Concurrent writers are
|
||||
serialised by ``_notes_lock`` since each tool entry-point dispatches
|
||||
@@ -37,8 +37,8 @@ _DEFAULT_CONTENT_PREVIEW_CHARS = 280
|
||||
_notes_path: Path | None = None
|
||||
|
||||
|
||||
def hydrate_notes_from_disk(run_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{run_dir}/notes.json`` and reload it.
|
||||
def hydrate_notes_from_disk(state_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{state_dir}/notes.json`` and reload it.
|
||||
|
||||
Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD
|
||||
calls auto-persist after every mutation. Idempotent on missing file.
|
||||
@@ -46,7 +46,7 @@ def hydrate_notes_from_disk(run_dir: Path) -> None:
|
||||
the scan over a broken sidecar artifact.
|
||||
"""
|
||||
global _notes_path # noqa: PLW0603
|
||||
_notes_path = run_dir / "notes.json"
|
||||
_notes_path = state_dir / "notes.json"
|
||||
with _notes_lock:
|
||||
_notes_storage.clear()
|
||||
if not _notes_path.exists():
|
||||
@@ -76,7 +76,7 @@ def hydrate_notes_from_disk(run_dir: Path) -> None:
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
"""Atomic-rename mirror of ``_notes_storage`` → ``{run_dir}/notes.json``.
|
||||
"""Atomic-rename mirror of ``_notes_storage`` → ``{state_dir}/notes.json``.
|
||||
|
||||
No-op when ``_notes_path`` isn't wired (tests). Errors are logged
|
||||
and swallowed — a disk hiccup must never tear down the agent's call.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Per-agent todo tools.
|
||||
|
||||
Per-agent in-memory dict, scoped via ``ctx.context['agent_id']``. The
|
||||
table is mirrored to ``{run_dir}/todos.json`` after every mutation so a
|
||||
table is mirrored to ``{state_dir}/todos.json`` after every mutation so a
|
||||
process restart can ``hydrate_todos_from_disk`` and each respawned
|
||||
agent finds its prior list intact. The persistence is best-effort —
|
||||
errors are logged and swallowed so a disk failure can't kill the agent
|
||||
@@ -54,8 +54,8 @@ _todos_path: Path | None = None
|
||||
_todos_io_lock = threading.RLock()
|
||||
|
||||
|
||||
def hydrate_todos_from_disk(run_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{run_dir}/todos.json`` and reload it.
|
||||
def hydrate_todos_from_disk(state_dir: Path) -> None:
|
||||
"""Wire the on-disk mirror at ``{state_dir}/todos.json`` and reload it.
|
||||
|
||||
Called by :func:`run_strix_scan` once at scan setup. Subsequent CRUD
|
||||
calls auto-persist after every mutation. Idempotent on missing file.
|
||||
@@ -63,7 +63,7 @@ def hydrate_todos_from_disk(run_dir: Path) -> None:
|
||||
the scan over a broken sidecar artifact.
|
||||
"""
|
||||
global _todos_path # noqa: PLW0603
|
||||
_todos_path = run_dir / "todos.json"
|
||||
_todos_path = state_dir / "todos.json"
|
||||
with _todos_io_lock:
|
||||
_todos_storage.clear()
|
||||
if not _todos_path.exists():
|
||||
@@ -99,7 +99,7 @@ def hydrate_todos_from_disk(run_dir: Path) -> None:
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
"""Atomic-rename mirror of ``_todos_storage`` → ``{run_dir}/todos.json``.
|
||||
"""Atomic-rename mirror of ``_todos_storage`` → ``{state_dir}/todos.json``.
|
||||
|
||||
No-op when ``_todos_path`` isn't wired (tests). Errors are logged
|
||||
and swallowed.
|
||||
|
||||
Reference in New Issue
Block a user