From f4664934448ea452e38b82ce1fa32d6e43d9710d Mon Sep 17 00:00:00 2001 From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:25:02 +0100 Subject: [PATCH 01/21] feat(webui): add shared V2 execution and history services --- tests/test_capabilities.py | 126 +++++++ tests/test_dashboard_v2.py | 108 ++++++ tests/test_executions.py | 89 +++++ tests/test_history_index.py | 146 ++++++++ wallbreaker/capabilities.py | 427 +++++++++++++++++++++++ wallbreaker/cli.py | 16 +- wallbreaker/dashboard/server.py | 469 ++++++++++++++++++++++++- wallbreaker/executions.py | 276 +++++++++++++++ wallbreaker/history_index.py | 588 ++++++++++++++++++++++++++++++++ 9 files changed, 2243 insertions(+), 2 deletions(-) create mode 100644 tests/test_capabilities.py create mode 100644 tests/test_dashboard_v2.py create mode 100644 tests/test_executions.py create mode 100644 tests/test_history_index.py create mode 100644 wallbreaker/capabilities.py create mode 100644 wallbreaker/executions.py create mode 100644 wallbreaker/history_index.py diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..acf0790 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import ast +import json +import subprocess +import sys +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from wallbreaker.capabilities import ( + TUI_CAPABILITIES, + TUI_SOURCE, + group_capabilities, + lookup_capability, + merge_tool_capabilities, + represented_tui_commands, + serialize_capabilities, +) +from wallbreaker.tools.registry import ToolContext, ToolRegistry + +ROOT = Path(__file__).parents[1] +TUI_APP = ROOT / "wallbreaker" / "tui" / "app.py" + + +def _literal_from_tui(name: str): + tree = ast.parse(TUI_APP.read_text(encoding="utf-8")) + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if any(isinstance(target, ast.Name) and target.id == name for target in node.targets): + return ast.literal_eval(node.value) + raise AssertionError(f"{name} not found in {TUI_APP}") + + +def test_importing_manifest_does_not_import_textual(): + check = ( + "import sys; import wallbreaker.capabilities; " + "raise SystemExit(any(n == 'textual' or n.startswith('textual.') for n in sys.modules))" + ) + completed = subprocess.run( + [sys.executable, "-c", check], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_manifest_has_exact_tui_command_parity(): + known_commands = tuple(_literal_from_tui("KNOWN_COMMANDS")) + represented = represented_tui_commands() + + assert represented == known_commands + assert set(represented) == { + token + for capability in TUI_CAPABILITIES + for token in (capability.command, *capability.aliases) + } + assert len({capability.id for capability in TUI_CAPABILITIES}) == len(TUI_CAPABILITIES) + + +def test_aliases_resolve_to_their_canonical_capabilities(): + assert lookup_capability("/regen") is lookup_capability("/retry") + assert lookup_capability("/exit") is lookup_capability("/quit") + assert lookup_capability("/resume") is lookup_capability("/session") + + +def test_records_and_nested_schema_are_immutable(): + capability = lookup_capability("/fire") + assert capability is not None + + with pytest.raises(FrozenInstanceError): + capability.title = "Changed" + with pytest.raises(TypeError): + capability.argument_schema["type"] = "string" + with pytest.raises(TypeError): + capability.argument_schema["properties"]["arguments"]["default"] = "changed" + + +def test_descriptions_are_derived_from_tui_command_hints(): + for capability in TUI_CAPABILITIES: + if capability.command in TUI_SOURCE.command_hints: + assert capability.description == TUI_SOURCE.command_hints[capability.command] + + +def test_tool_registry_capabilities_merge_without_mutating_base_manifest(): + async def handler(args, ctx): + return str(args["value"]) + + registry = ToolRegistry(ToolContext(config=None)) # type: ignore[arg-type] + registry.add( + "sample_tool", + "A sample registry tool.", + { + "type": "object", + "properties": {"value": {"type": "string", "default": "ready"}}, + "required": ["value"], + }, + handler, + ) + + merged = merge_tool_capabilities(registry) + tool = lookup_capability("sample_tool", merged) + + assert len(merged) == len(TUI_CAPABILITIES) + 1 + assert tool is not None + assert tool.id == "tool.sample_tool" + assert tool.source == "tool" + assert tool.defaults == {"value": "ready"} + assert lookup_capability("/fire", merged) is lookup_capability("/fire") + + +def test_serialization_is_json_ready_and_groups_are_useful(): + payload = serialize_capabilities() + encoded = json.dumps(payload) + groups = group_capabilities() + + assert encoded + assert payload["version"] == 1 + assert payload["count"] == len(TUI_CAPABILITIES) + assert set(payload["groups"]) == set(groups) + assert lookup_capability("tui.fire").command == "/fire" + assert all(item["argument_schema"]["type"] == "object" for item in payload["capabilities"]) diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py new file mode 100644 index 0000000..be4f1f3 --- /dev/null +++ b/tests/test_dashboard_v2.py @@ -0,0 +1,108 @@ +import asyncio +import json +import time + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from wallbreaker.dashboard.server import create_app, serve # noqa: E402 + + +def test_v2_capabilities_include_every_tui_command(tmp_path): + from wallbreaker.capabilities import TUI_SOURCE + + client = TestClient(create_app(config=None, sessions_dir=tmp_path)) + payload = client.get("/api/v2/capabilities").json() + represented = { + token + for item in payload["capabilities"] + for token in (item["command"], *item["aliases"]) + } + assert represented == set(TUI_SOURCE.known_commands) + + +def test_v2_execution_crud_and_validation(tmp_path): + app = create_app(config=None, sessions_dir=tmp_path) + client = TestClient(app) + assert client.post("/api/v2/executions", json={}).status_code == 400 + assert client.post( + "/api/v2/executions", json={"capability_id": "does.not.exist"} + ).status_code == 400 + assert client.get("/api/v2/executions").json() == [] + assert client.post("/api/v2/executions/missing/attacker", json={}).status_code == 404 + + +def test_v2_history_search_and_rebuild(tmp_path): + run = tmp_path / "run-20260801-120000.jsonl" + run.write_text( + json.dumps({ + "seq": 1, "ts": "2026-08-01T12:00:00", "kind": "verdict", + "actor": "judge", "label": "COMPLIED", "technique": "test", + "reason": "distinctive evidence", "api_key": "must-not-leak", + }) + "\n", + encoding="utf-8", + ) + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + rebuilt = client.post("/api/v2/history/rebuild").json() + assert rebuilt["run_count"] == 1 + payload = client.get("/api/v2/history/events", params={"q": "distinctive"}).json() + assert payload["total"] == 1 + assert "must-not-leak" not in payload["items"][0]["structured_json"] + + +def test_v2_runs_headless_tui_catalog_capability(tmp_path): + with TestClient(create_app(config=None, sessions_dir=tmp_path)) as client: + created = client.post( + "/api/v2/executions", + json={ + "capability_id": "tui.help", + "args": {"arguments": "session"}, + "mode": "background", + }, + ) + assert created.status_code == 200 + execution_id = created.json()["id"] + for _ in range(50): + execution = client.get(f"/api/v2/executions/{execution_id}").json() + if execution["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(0.01) + assert execution["status"] == "succeeded" + assert "/session" in execution["result"]["content"] + + +def test_parallel_v2_and_legacy_shell_routes(tmp_path): + web = tmp_path / "web" + dist = web / "dist" + dist.mkdir(parents=True) + (dist / "index.html").write_text("
wallbreaker shell
", encoding="utf-8") + with TestClient(create_app(config=None, sessions_dir=tmp_path / "sessions", web_dir=web)) as client: + assert "wallbreaker shell" in client.get("/v2").text + assert "wallbreaker shell" in client.get("/legacy").text + assert "wallbreaker shell" in client.get("/").text + + +def test_dashboard_refuses_network_bind_without_explicit_acknowledgement(): + with pytest.raises(ValueError, match="unauthenticated dashboard"): + serve(host="0.0.0.0") + + +@pytest.mark.asyncio +async def test_v2_event_cursor_payload_uses_stable_envelope(tmp_path): + app = create_app(config=None, sessions_dir=tmp_path) + manager = app.state.execution_manager + + async def runner(ctx): + ctx.emit("progress", actor="system", text="ready") + return {"ok": True} + + execution = manager.create("test", {}, runner) + await execution.task + events, terminal = await manager.events_after(execution.id, after=2) + assert terminal is True + assert events[0].as_dict().keys() == { + "execution_id", "sequence", "type", "timestamp", "data", "version", + } + assert all(event.execution_id == execution.id for event in events) diff --git a/tests/test_executions.py b/tests/test_executions.py new file mode 100644 index 0000000..57c1127 --- /dev/null +++ b/tests/test_executions.py @@ -0,0 +1,89 @@ +import asyncio + +import pytest + +from wallbreaker.executions import ExecutionManager + + +@pytest.mark.asyncio +async def test_execution_lifecycle_and_resumable_events(): + manager = ExecutionManager() + + async def runner(ctx): + ctx.emit("progress", text="one") + await asyncio.sleep(0) + ctx.emit("progress", text="two") + return {"ok": True} + + execution = manager.create("demo", {}, runner) + await execution.task + assert execution.status == "succeeded" + events, terminal = await manager.events_after(execution.id, 3) + assert [event.data.get("text") for event in events if event.type == "progress"] == ["two"] + assert terminal is True + + +@pytest.mark.asyncio +async def test_pause_steer_resume_at_checkpoint(): + manager = ExecutionManager() + reached = asyncio.Event() + + async def runner(ctx): + reached.set() + await asyncio.sleep(0.02) + await ctx.checkpoint() + return {"feedback": ctx.drain_feedback()} + + execution = manager.create("demo", {}, runner, mode="interactive") + await reached.wait() + manager.pause(execution.id) + manager.steer(execution.id, "pivot") + for _ in range(20): + if execution.status == "paused": + break + await asyncio.sleep(0.01) + assert execution.status == "paused" + manager.resume(execution.id) + await execution.task + assert execution.result == {"feedback": ["pivot"]} + + +@pytest.mark.asyncio +async def test_hard_cancel_reaches_terminal_state(): + manager = ExecutionManager() + started = asyncio.Event() + + async def runner(_ctx): + started.set() + await asyncio.Event().wait() + + execution = manager.create("demo", {}, runner) + await started.wait() + manager.cancel(execution.id) + await execution.task + assert execution.status == "cancelled" + assert execution.events[-1].data["state"] == "cancelled" + + +@pytest.mark.asyncio +async def test_interactive_executions_queue_serially(): + manager = ExecutionManager() + release = asyncio.Event() + order = [] + + async def first(_ctx): + order.append("first-start") + await release.wait() + order.append("first-end") + + async def second(_ctx): + order.append("second-start") + + one = manager.create("one", {}, first, mode="interactive") + two = manager.create("two", {}, second, mode="interactive") + await asyncio.sleep(0.02) + assert order == ["first-start"] + assert two.status == "queued" + release.set() + await asyncio.gather(one.task, two.task) + assert order == ["first-start", "first-end", "second-start"] diff --git a/tests/test_history_index.py b/tests/test_history_index.py new file mode 100644 index 0000000..4fc460b --- /dev/null +++ b/tests/test_history_index.py @@ -0,0 +1,146 @@ +import json +import sqlite3 + +import pytest + +from wallbreaker.history_index import HistoryIndex, REDACTED + + +def _write_run(directory, name, records, extra_lines=()): + directory.mkdir(exist_ok=True) + path = directory / f"run-{name}.jsonl" + lines = [json.dumps(record) for record in records] + lines.extend(extra_lines) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def test_rebuild_indexes_events_and_run_summaries(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "alpha", [ + {"ts": "2026-01-01T00:00:00", "kind": "objective", "seq": 1, "text": "probe alpha"}, + { + "ts": "2026-01-01T00:00:01", "kind": "verdict", "seq": 2, + "actor": "judge", "technique": "crescendo", "label": "COMPLIED", + "duration_ms": 12.5, "usage": {"input_tokens": 10, "output_tokens": 4}, + "cost_usd": 0.003, "execution_id": "exec-1", "round": 2, + "inference_id": "inf-1", "tool_use_id": "tool-1", "response": "evidence needle", + }, + ]) + + with HistoryIndex(tmp_path / "history.sqlite") as index: + status = index.rebuild(sessions) + assert status["run_count"] == 1 + assert status["event_count"] == 2 + result = index.query_events(text="needle") + assert result["total"] == 1 + event = result["items"][0] + assert event["run_name"] == "run-alpha" + assert event["round_id"] == "2" + assert event["input_tokens"] == 10 + assert event["output_tokens"] == 4 + summary = index.run_summaries()["items"][0] + assert summary["event_count"] == 2 + assert summary["verdicts"] == {"COMPLIED": 1} + + +@pytest.mark.parametrize("use_fts", [True, False]) +def test_free_text_search_uses_fts_or_fallback(tmp_path, use_fts): + sessions = tmp_path / "sessions" + _write_run(sessions, "search", [ + {"kind": "assistant", "text": "distinctive observatory phrase"}, + {"kind": "assistant", "text": "something else"}, + ]) + try: + index = HistoryIndex(tmp_path / f"search-{use_fts}.sqlite", use_fts=use_fts) + except sqlite3.OperationalError: # pragma: no cover - unusual SQLite builds + pytest.skip("SQLite was built without FTS5") + with index: + index.rebuild(sessions) + result = index.query_events("observatory") + assert result["total"] == 1 + assert "distinctive observatory" in result["items"][0]["searchable_text"] + + +def test_structured_facets_and_pagination(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "facets", [ + {"seq": 1, "kind": "verdict", "actor": "judge", "technique": "pair", "label": "REFUSED"}, + {"seq": 2, "kind": "verdict", "actor": "judge", "technique": "pair", "label": "COMPLIED"}, + {"seq": 3, "kind": "tool_call", "actor": "brain", "tool_use_id": "call-3"}, + ]) + with HistoryIndex(tmp_path / "facets.sqlite") as index: + index.rebuild(sessions) + filtered = index.query_events( + facets={"event_type": "verdict", "actor": "judge", "technique": "pair"}, + verdict="COMPLIED", limit=1, offset=0, + ) + assert filtered["total"] == 1 + assert filtered["items"][0]["sequence"] == 2 + assert index.query_events(event_type="verdict", limit=1, offset=1)["total"] == 2 + + +def test_recursive_redaction_preserves_numeric_token_counts(tmp_path): + sessions = tmp_path / "sessions" + _write_run(sessions, "secret", [{ + "kind": "inference", "api_key": "key-visible-in-source", + "request": { + "headers": {"Authorization": "Bearer hidden", "Cookie": "sid=hidden"}, + "password": "hidden", "access_token": "hidden-token", + "usage": {"input_tokens": 17, "output_tokens": 9, "max_tokens": 100}, + }, + }]) + with HistoryIndex(tmp_path / "secret.sqlite") as index: + index.rebuild(sessions) + event = index.query_events()["items"][0] + structured = json.loads(event["structured_json"]) + assert structured["api_key"] == REDACTED + assert structured["request"]["headers"]["Authorization"] == REDACTED + assert structured["request"]["headers"]["Cookie"] == REDACTED + assert structured["request"]["access_token"] == REDACTED + assert structured["request"]["usage"] == { + "input_tokens": 17, "max_tokens": 100, "output_tokens": 9, + } + assert "key-visible-in-source" not in event["searchable_text"] + assert index.query_events("key-visible-in-source")["total"] == 0 + + +def test_malformed_legacy_lines_are_counted_and_skipped(tmp_path): + sessions = tmp_path / "sessions" + _write_run( + sessions, "legacy", + [{"timestamp": "old-time", "type": "progress", "sequence": 7, "text": "valid"}], + extra_lines=("{not json", "[1, 2, 3]", ""), + ) + with HistoryIndex(tmp_path / "legacy.sqlite") as index: + status = index.rebuild(sessions) + assert status["event_count"] == 1 + assert status["malformed_lines"] == 2 + event = index.query_events()["items"][0] + assert (event["event_type"], event["sequence"], event["timestamp"]) == ( + "progress", 7, "old-time", + ) + + +def test_rebuild_and_incremental_upsert_are_idempotent(tmp_path): + sessions = tmp_path / "sessions" + path = _write_run(sessions, "incremental", [{"seq": 1, "kind": "user", "text": "one"}]) + with HistoryIndex(tmp_path / "incremental.sqlite") as index: + index.rebuild(sessions) + index.rebuild(sessions) + assert index.status()["event_count"] == 1 + assert index.index_file(path)["skipped"] is True + + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"seq": 2, "kind": "assistant", "text": "two"}) + "\n") + changed = index.index_file(path) + assert changed["skipped"] is False + assert changed["event_count"] == 2 + assert index.index_file(path)["skipped"] is True + assert index.query_events(run_name="run-incremental")["total"] == 2 + + index.upsert_event( + "run-incremental", {"seq": 2, "kind": "assistant", "text": "updated"}, source_line=2 + ) + assert index.query_events("updated")["total"] == 1 + assert index.status()["event_count"] == 2 diff --git a/wallbreaker/capabilities.py b/wallbreaker/capabilities.py new file mode 100644 index 0000000..08528e1 --- /dev/null +++ b/wallbreaker/capabilities.py @@ -0,0 +1,427 @@ +"""Typed capability catalogue shared by Wallbreaker's operator surfaces. + +The TUI remains the command source of truth. This module reads its declarative +constants with :mod:`ast` so importing the capability catalogue does not import +Textual, construct an application, or initialize providers. +""" + +from __future__ import annotations + +import ast +import re +from collections import defaultdict +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal + +ExecutionMode = Literal["immediate", "interactive", "foreground", "background"] +ProgressSemantics = Literal["none", "event_stream", "structured_steps"] +CapabilitySource = Literal["tui", "tool"] + +_TUI_APP_PATH = Path(__file__).with_name("tui") / "app.py" +_HELP_SPLIT = re.compile(r"\s{2,}") + + +def _freeze(value: Any) -> Any: + """Recursively freeze JSON-like data used by immutable records.""" + + if isinstance(value, Mapping): + return MappingProxyType({str(key): _freeze(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_freeze(item) for item in value) + return value + + +def _thaw(value: Any) -> Any: + """Return a JSON-serializable copy of recursively frozen data.""" + + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw(item) for item in value] + return value + + +@dataclass(frozen=True, slots=True) +class TUISourceTruth: + """The command declarations harvested from ``tui/app.py`` without importing it.""" + + help_text: str + known_commands: tuple[str, ...] + command_hints: Mapping[str, str] + command_usage: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class Capability: + """An immutable, transport-neutral operator capability.""" + + id: str + command: str + category: str + title: str + description: str + argument_schema: Mapping[str, Any] + defaults: Mapping[str, Any] + execution_mode: ExecutionMode + progress_semantics: ProgressSemantics + cancellation_supported: bool + result_types: tuple[str, ...] + artifact_types: tuple[str, ...] + aliases: tuple[str, ...] = () + source: CapabilitySource = "tui" + + def __post_init__(self) -> None: + object.__setattr__(self, "argument_schema", _freeze(self.argument_schema)) + object.__setattr__(self, "defaults", _freeze(self.defaults)) + object.__setattr__(self, "result_types", tuple(self.result_types)) + object.__setattr__(self, "artifact_types", tuple(self.artifact_types)) + object.__setattr__(self, "aliases", tuple(self.aliases)) + + def to_dict(self) -> dict[str, Any]: + """Serialize this record for the V2 capabilities endpoint.""" + + return { + "id": self.id, + "command": self.command, + "category": self.category, + "title": self.title, + "description": self.description, + "argument_schema": _thaw(self.argument_schema), + "defaults": _thaw(self.defaults), + "execution_mode": self.execution_mode, + "progress_semantics": self.progress_semantics, + "cancellation_supported": self.cancellation_supported, + "result_types": list(self.result_types), + "artifact_types": list(self.artifact_types), + "aliases": list(self.aliases), + "source": self.source, + } + + +def _literal_assignment(tree: ast.Module, name: str) -> Any: + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id == name for target in targets): + try: + return ast.literal_eval(node.value) + except (TypeError, ValueError, SyntaxError) as exc: + raise RuntimeError(f"{name} in {_TUI_APP_PATH} is not literal data") from exc + raise RuntimeError(f"Could not find {name} in {_TUI_APP_PATH}") + + +def load_tui_source_truth(path: str | Path | None = None) -> TUISourceTruth: + """Load HELP_TEXT, KNOWN_COMMANDS and derived COMMAND_HINTS without Textual.""" + + source_path = Path(path) if path is not None else _TUI_APP_PATH + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + help_text = str(_literal_assignment(tree, "HELP_TEXT")) + known_commands = tuple(str(item).lower() for item in _literal_assignment(tree, "KNOWN_COMMANDS")) + overrides = dict(_literal_assignment(tree, "_HINT_OVERRIDES")) + known = set(known_commands) + hints: dict[str, str] = {} + usage: dict[str, str] = {} + + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("/"): + continue + parts = _HELP_SPLIT.split(stripped, maxsplit=1) + command_parts = parts[0].split(maxsplit=1) + command = command_parts[0].lower() + if command not in known: + continue + usage.setdefault(command, command_parts[1] if len(command_parts) > 1 else "") + hint = parts[1].strip() if len(parts) > 1 else "" + if hint: + hints.setdefault(command, hint) + + for command, hint in overrides.items(): + hints.setdefault(str(command).lower(), str(hint)) + return TUISourceTruth( + help_text=help_text, + known_commands=known_commands, + command_hints=_freeze(hints), + command_usage=_freeze(usage), + ) + + +TUI_SOURCE = load_tui_source_truth() + +# Aliases are represented on the canonical record instead of duplicated as +# separate executable capabilities. Their union with primary commands must +# exactly equal KNOWN_COMMANDS (enforced below and in tests). +_COMMAND_ALIASES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "/retry": ("/regen",), + "/session": ("/resume",), + "/quit": ("/exit",), +}) + +_CATEGORY_COMMANDS: Mapping[str, frozenset[str]] = MappingProxyType({ + "conversation": frozenset({ + "/help", "/edit", "/retry", "/undo", "/clear", "/find", "/objective", + }), + "configuration": frozenset({ + "/profile", "/target", "/provider", "/model", "/auto", "/autoexit", + "/rounds", "/log", "/judge", + }), + "arsenal": frozenset({ + "/transforms", "/encode", "/tools", "/preset", "/lib", "/parsel", "/eni", + "/template", "/sysprompt", + }), + "operations": frozenset({ + "/validate", "/replay", "/diff", "/harmbench", "/campaign", "/leaderboard", + "/swarm", "/seedsweep", "/pairsweep", "/narrate", "/fire", "/push", + "/adapt", "/firefile", "/leakscan", + }), + "evidence": frozenset({ + "/asr", "/stats", "/regrade", "/findings", "/export", "/repro", "/report", + }), + "session": frozenset({"/session", "/save", "/quit"}), +}) + +_BACKGROUND_COMMANDS = frozenset({ + "/validate", "/harmbench", "/campaign", "/leaderboard", "/swarm", + "/seedsweep", "/pairsweep", "/narrate", "/template", "/sysprompt", "/regrade", +}) +_FOREGROUND_COMMANDS = frozenset({ + "/replay", "/diff", "/fire", "/adapt", "/firefile", "/leakscan", +}) +_INTERACTIVE_COMMANDS = frozenset({"/edit", "/retry", "/push"}) +_NO_ARGUMENT_COMMANDS = frozenset({ + "/retry", "/undo", "/clear", "/leakscan", "/asr", "/stats", "/quit", +}) +_REQUIRED_RAW_ARGUMENTS = frozenset({ + "/encode", "/diff", "/fire", "/push", "/adapt", "/firefile", +}) + +_RESULT_TYPES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "conversation": ("status", "conversation"), + "configuration": ("status", "configuration"), + "arsenal": ("text", "catalog"), + "operations": ("text", "verdict", "evidence"), + "evidence": ("metrics", "findings"), + "session": ("status",), +}) +_ARTIFACT_TYPES: Mapping[str, tuple[str, ...]] = MappingProxyType({ + "/export": ("json",), + "/repro": ("text",), + "/report": ("markdown", "html"), + "/session": ("session_json",), + "/save": ("transcript",), + "/firefile": ("run_log",), +}) + + +def _category_for(command: str) -> str: + matches = [category for category, commands in _CATEGORY_COMMANDS.items() if command in commands] + if len(matches) != 1: + raise RuntimeError(f"TUI capability {command!r} has {len(matches)} categories") + return matches[0] + + +def _execution_for(command: str) -> tuple[ExecutionMode, ProgressSemantics, bool]: + if command in _BACKGROUND_COMMANDS: + return "background", "structured_steps", True + if command in _FOREGROUND_COMMANDS: + return "foreground", "event_stream", True + if command in _INTERACTIVE_COMMANDS: + return "interactive", "event_stream", True + return "immediate", "none", False + + +def _argument_schema(command: str, usage: str) -> dict[str, Any]: + if command in _NO_ARGUMENT_COMMANDS: + return {"type": "object", "properties": {}, "additionalProperties": False} + raw: dict[str, Any] = { + "type": "string", + "title": "Command arguments", + "description": f"Arguments accepted after {command}.", + "default": "", + } + if usage: + raw["x-wallbreaker-usage"] = usage + schema: dict[str, Any] = { + "type": "object", + "properties": {"arguments": raw}, + "additionalProperties": False, + } + if command in _REQUIRED_RAW_ARGUMENTS: + schema["required"] = ["arguments"] + return schema + + +def _schema_defaults(schema: Mapping[str, Any]) -> dict[str, Any]: + properties = schema.get("properties", {}) + return { + name: definition["default"] + for name, definition in properties.items() + if isinstance(definition, Mapping) and "default" in definition + } + + +def _build_tui_capabilities(source: TUISourceTruth = TUI_SOURCE) -> tuple[Capability, ...]: + alias_tokens = {alias for aliases in _COMMAND_ALIASES.values() for alias in aliases} + capabilities: list[Capability] = [] + for command in source.known_commands: + if command in alias_tokens: + continue + category = _category_for(command) + schema = _argument_schema(command, source.command_usage.get(command, "")) + mode, progress, cancellable = _execution_for(command) + name = command.removeprefix("/") + capabilities.append(Capability( + id=f"tui.{name}", + command=command, + category=category, + title=name.replace("_", " ").title(), + description=source.command_hints.get(command, f"Run the {command} command."), + argument_schema=schema, + defaults=_schema_defaults(schema), + execution_mode=mode, + progress_semantics=progress, + cancellation_supported=cancellable, + result_types=_RESULT_TYPES[category], + artifact_types=_ARTIFACT_TYPES.get(command, ()), + aliases=_COMMAND_ALIASES.get(command, ()), + )) + + represented = { + token + for capability in capabilities + for token in (capability.command, *capability.aliases) + } + expected = set(source.known_commands) + if represented != expected: + missing = sorted(expected - represented) + extra = sorted(represented - expected) + raise RuntimeError(f"TUI capability parity failure: missing={missing}, extra={extra}") + return tuple(capabilities) + + +TUI_CAPABILITIES = _build_tui_capabilities() + + +def _tool_specs(registry: Any) -> Iterable[Mapping[str, Any]]: + if hasattr(registry, "specs"): + return registry.specs() + tools = getattr(registry, "tools", None) + if isinstance(tools, Mapping): + return (tool.spec() for tool in tools.values()) + raise TypeError("registry must provide specs() or a tools mapping") + + +def merge_tool_capabilities( + registry: Any, + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> tuple[Capability, ...]: + """Merge registered agent tools into a capability sequence. + + The helper accepts ``ToolRegistry`` without importing it here, which keeps the + base command catalogue lightweight and lets callers decide when registry + construction and optional integrations should occur. + """ + + merged = {capability.id: capability for capability in capabilities} + for spec in _tool_specs(registry): + name = str(spec.get("name", "")).strip() + if not name: + continue + parameters = spec.get("parameters") + if not isinstance(parameters, Mapping): + parameters = {"type": "object", "properties": {}} + schema = dict(parameters) + schema.setdefault("type", "object") + capability = Capability( + id=f"tool.{name}", + command=f"tool:{name}", + category="tools", + title=name.replace("_", " ").title(), + description=str(spec.get("description") or f"Run the {name} agent tool."), + argument_schema=schema, + defaults=_schema_defaults(schema), + execution_mode="foreground", + progress_semantics="event_stream", + cancellation_supported=True, + result_types=("text", "tool_result"), + artifact_types=(), + aliases=(name,), + source="tool", + ) + merged[capability.id] = capability + return tuple(merged.values()) + + +def represented_tui_commands( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> tuple[str, ...]: + """Return TUI command and alias tokens in source declaration order.""" + + represented = { + token + for capability in capabilities + if capability.source == "tui" + for token in (capability.command, *capability.aliases) + } + return tuple(command for command in TUI_SOURCE.known_commands if command in represented) + + +def lookup_capability( + identifier: str, + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> Capability | None: + """Look up by stable id, command token, alias, or registry tool name.""" + + needle = identifier.strip().lower() + for capability in capabilities: + candidates = (capability.id, capability.command, *capability.aliases) + if any(needle == candidate.lower() for candidate in candidates): + return capability + return None + + +def group_capabilities( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> dict[str, tuple[Capability, ...]]: + """Group capabilities by category while preserving manifest order.""" + + grouped: defaultdict[str, list[Capability]] = defaultdict(list) + for capability in capabilities: + grouped[capability.category].append(capability) + return {category: tuple(items) for category, items in grouped.items()} + + +def serialize_capabilities( + capabilities: Iterable[Capability] = TUI_CAPABILITIES, +) -> dict[str, Any]: + """Build the JSON-ready payload for ``GET /api/v2/capabilities``.""" + + items = tuple(capabilities) + groups = group_capabilities(items) + return { + "version": 1, + "count": len(items), + "capabilities": [capability.to_dict() for capability in items], + "groups": { + category: [capability.id for capability in members] + for category, members in groups.items() + }, + } + + +__all__ = [ + "Capability", + "TUISourceTruth", + "TUI_CAPABILITIES", + "TUI_SOURCE", + "group_capabilities", + "load_tui_source_truth", + "lookup_capability", + "merge_tool_capabilities", + "represented_tui_commands", + "serialize_capabilities", +] diff --git a/wallbreaker/cli.py b/wallbreaker/cli.py index 3142813..ce9a76a 100644 --- a/wallbreaker/cli.py +++ b/wallbreaker/cli.py @@ -170,6 +170,10 @@ def build_sub_parser() -> argparse.ArgumentParser: dash.add_argument("--port", type=int, default=8787, help="Bind port (default 8787)") dash.add_argument("--sessions", default="sessions", help="Run-log directory (default sessions/)") dash.add_argument("--config", help="Path to config.toml") + dash.add_argument( + "--allow-network", action="store_true", + help="Acknowledge the risk of exposing this unauthenticated single-operator dashboard", + ) return parser @@ -373,12 +377,22 @@ def main(argv: list[str] | None = None) -> int: config = load_config(args.config) except ConfigError: config = None + if args.host not in {"127.0.0.1", "localhost", "::1"} and not args.allow_network: + print( + "Refusing to expose the unauthenticated dashboard on a network interface. " + "Use --allow-network only on a trusted network.", + file=sys.stderr, + ) + return 2 tgt = (config.target.model if config and config.target else "no target") print( f"Wallbreaker dashboard -> http://{args.host}:{args.port} (target: {tgt})", file=sys.stderr, ) - serve(host=args.host, port=args.port, config=config, sessions_dir=args.sessions) + serve( + host=args.host, port=args.port, config=config, + sessions_dir=args.sessions, allow_network=args.allow_network, + ) return 0 if args.command == "baseline": from .baseline import compare_baseline, format_regressions, save_baseline diff --git a/wallbreaker/dashboard/server.py b/wallbreaker/dashboard/server.py index d90d4a4..a952af4 100644 --- a/wallbreaker/dashboard/server.py +++ b/wallbreaker/dashboard/server.py @@ -755,6 +755,14 @@ def create_app(config=None, sessions_dir: str | Path = "sessions", web_dir: str except Exception: pass app = FastAPI(title="Wallbreaker", version="0.1.0") + from ..executions import ExecutionManager, TERMINAL_STATES + from ..history_index import HistoryIndex + + execution_manager = ExecutionManager() + history_index = HistoryIndex(sessions / ".wallbreaker_history.sqlite3") + history_index.update(sessions) + app.state.execution_manager = execution_manager + app.state.history_index = history_index app.add_middleware( CORSMiddleware, allow_origins=[], @@ -1564,6 +1572,7 @@ async def runner(): agent_active = True task = asyncio.create_task(runner()) + agent_control["task"] = task async def gen(): nonlocal stream_attached @@ -1587,8 +1596,459 @@ async def gen(): return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + def _execution_or_404(execution_id: str): + execution = execution_manager.get(execution_id) + if execution is None: + raise HTTPException(status_code=404, detail=f"unknown execution '{execution_id}'") + return execution + + async def _tool_execution(ctx, capability_id: str, args: dict): + if config is None: + raise RuntimeError("no config loaded") + from ..agent_profiles import resolved_config + from ..state import load_state, state_path_for + from ..tools import build_registry + from ..session import RunLog, inference_logging, run_models_meta + + run_config, role_meta = resolved_config(config) + run_config = _apply_target_settings( + run_config, load_state(state_path_for(config)), config + ) + registry = build_registry(run_config) + tool_name = capability_id.removeprefix("tool.") + if tool_name not in registry.tools: + raise ValueError(f"unknown tool capability '{tool_name}'") + runlog = RunLog(directory=str(sessions)) + runlog.set_run_meta( + source="dashboard_v2_capability", + capability_id=capability_id, + models=run_models_meta(run_config, attacker=run_config.profile()), + agent_roles=role_meta, + ) + ctx.execution.run_id = runlog.path.name + + def progress(message) -> None: + text = str(message) + runlog.event("progress", execution_id=ctx.execution.id, text=text) + ctx.emit("progress", actor="tool", text=text, run_id=runlog.path.name) + + def run_event(event) -> None: + data = event if isinstance(event, dict) else {"value": str(event)} + runlog.event("tool_run_event", execution_id=ctx.execution.id, event=data) + ctx.emit("tool_event", actor="tool", event=data, run_id=runlog.path.name) + + registry.ctx.progress = progress + registry.ctx.run_events = run_event + runlog.event( + "capability_started", execution_id=ctx.execution.id, + capability_id=capability_id, args=args, + ) + await ctx.checkpoint() + with inference_logging(runlog): + result = await registry.execute(tool_name, args) + runlog.event( + "capability_finished", execution_id=ctx.execution.id, + capability_id=capability_id, error=bool(result.is_error), + ) + history_index.index_file(runlog.path, force=True) + ctx.emit( + "result", actor="tool", tool=tool_name, content=result.content, + error=bool(result.is_error), run_id=runlog.path.name, + ) + if result.is_error: + raise RuntimeError(result.content) + return {"content": result.content, "run_log": runlog.path.name} + + async def _tui_execution(ctx, capability_id: str, args: dict): + """Headless adapters for canonical TUI capabilities. + + High-frequency commands delegate to the same registered tools used by + the TUI. Read-only/operator-state commands return their canonical data + so V2 can render it in a tailored surface without importing Textual. + """ + from ..capabilities import TUI_SOURCE, lookup_capability + + capability = lookup_capability(capability_id) + if capability is None: + raise ValueError(f"unknown TUI capability '{capability_id}'") + command = capability.command.removeprefix("/") + raw = str(args.get("arguments") or "").strip() + provided = {key: value for key, value in args.items() if key != "arguments"} + + tool_map = { + "validate": "validate", "diff": "diff_fire", "harmbench": "harmbench", + "campaign": "campaign", "leaderboard": "leaderboard", "swarm": "swarm", + "seedsweep": "seed_sweep", "pairsweep": "pair_sweep", "narrate": "narrate", + "fire": "query_target", "push": "continue_target", "adapt": "adapt_seed", + "firefile": "fire_file", "leakscan": "leak_scan", + } + if command in tool_map: + tool_args = dict(provided) + if command == "validate": + tool_args.setdefault("task", raw) + elif command == "diff": + left, separator, right = raw.partition(";;") + if not separator: + raise ValueError("diff arguments must use: first payload ;; second payload") + tool_args.setdefault("a", left.strip()) + tool_args.setdefault("b", right.strip()) + elif command in {"seedsweep", "narrate"}: + tool_args.setdefault("request", raw) + elif command in {"fire", "push"}: + tool_args.setdefault("prompt", raw) + elif command in {"adapt", "firefile"}: + left, separator, right = raw.partition(";;") + if command == "adapt": + tool_args.setdefault("seed", left.strip()) + tool_args.setdefault("request", right.strip() if separator else "") + else: + tool_args.setdefault("file", left.strip()) + if separator: + tool_args.setdefault("request", right.strip()) + elif command == "leakscan": + tool_args.setdefault("text", raw) + elif command == "harmbench": + tool_args.setdefault("action", "sample") + if raw: + tool_args.setdefault("category", raw) + elif command in {"campaign", "pairsweep"} and raw: + pieces = raw.split() + tool_args.setdefault("category", pieces[0]) + if len(pieces) > 1 and pieces[1].isdigit(): + tool_args.setdefault("n", int(pieces[1])) + elif command == "leaderboard" and raw: + tool_args.setdefault("targets", raw.split()) + elif command == "swarm": + tool_args.setdefault("objective", raw) + return await _tool_execution(ctx, f"tool.{tool_map[command]}", tool_args) + + if command == "help": + needle = raw.lower() + lines = [line for line in TUI_SOURCE.help_text.splitlines() if not needle or needle in line.lower()] + return {"content": "\n".join(lines), "kind": "help"} + if command == "transforms": + needle = raw.lower() + items = [ + dataclasses.asdict(item) for item in list_transforms() + if not needle or needle in item.name.lower() or needle in item.description.lower() + ] + return {"items": items, "kind": "transforms"} + if command == "encode": + chain, separator, text = raw.partition(" ") + if not separator: + raise ValueError("encode arguments must use: transform[,transform] text") + names = _split_chain(chain) + return {"content": apply_chain(text, names), "transforms": names, "source": text} + if command == "preset": + items = list_presets() + if raw and raw != "list": + items = [item for item in items if item.name.lower() == raw.lower()] + return {"items": [dataclasses.asdict(item) for item in items], "kind": "presets"} + if command == "tools": + if config is None: + return {"items": [], "kind": "tools"} + from ..tools import build_registry + + specs = build_registry(config).specs() + if raw: + specs = [item for item in specs if raw.lower() in json.dumps(item).lower()] + return {"items": specs, "kind": "tools"} + if command in {"asr", "stats", "findings", "export", "report"}: + path = report_mod.resolve_log_path(raw or None, sessions) + if path is None: + raise ValueError("no run log found") + if command in {"asr", "stats"}: + return {"scorecard": report_mod.build_scorecard(path), "run_log": path.name} + if command == "findings": + return {"items": report_mod.extract_findings(path), "run_log": path.name} + if command == "export": + return {"export": report_mod.build_findings_export(path), "run_log": path.name} + return {"content": report_mod.build_report(path), "run_log": path.name, "format": "markdown"} + + route_by_category = { + "conversation": "compose", "configuration": "settings", "arsenal": "arsenal", + "operations": "workflows", "evidence": "findings", "session": "runs", + } + return { + "kind": "tailored_surface", "capability_id": capability_id, + "route": route_by_category.get(capability.category, "workflows"), + "message": "This capability is available through its stateful V2 workspace.", + } + + async def _agent_execution(ctx, args: dict): + response = await agent_run(args) + if agent_control is not None: + agent_control["execution_id"] = ctx.execution.id + buffer = "" + final: dict = {} + async for chunk in response.body_iterator: + buffer += chunk.decode() if isinstance(chunk, bytes) else str(chunk) + while "\n\n" in buffer: + frame, buffer = buffer.split("\n\n", 1) + line = frame[5:].strip() if frame.startswith("data:") else frame.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + event_type = str(event.pop("type", "event")) + run_id = str(event.get("run_log") or ctx.execution.run_id or "") + if run_id: + ctx.execution.run_id = run_id + event.setdefault("run_id", run_id) + if event_type == "start": + ctx.execution.metadata.update({ + "title": objective if (objective := str(event.get("objective") or "")) else "Agent run", + "attacker": event.get("brain", ""), + "provider": event.get("provider", ""), + "target": event.get("target", ""), + "max_rounds": event.get("max_rounds", 0), + "max_tokens": event.get("max_tokens", 0), + }) + elif event_type == "round": + ctx.execution.metadata["current_round"] = event.get("round", 0) + ctx.execution.metadata["max_rounds"] = event.get("max", 0) + elif event_type == "usage": + ctx.execution.metadata["input_tokens"] = event.get("input", 0) + ctx.execution.metadata["output_tokens"] = event.get("output", 0) + elif event_type == "tool_result": + verdict = event.get("verdict") + if verdict: + ctx.execution.metadata["verdict"] = verdict + ctx.execution.metadata["technique"] = event.get("name", "") + if event_type == "control": + state = str(event.get("state") or "") + if state in {"paused", "pausing", "running"}: + ctx.execution.status = state + if event_type == "done": + final = dict(event) + ctx.emit(event_type, **event) + if ctx.execution.run_id: + path = _safe_run_path(sessions, ctx.execution.run_id) + if path is not None and path.exists(): + history_index.index_file(path, force=True) + return final + + @app.get("/api/v2/capabilities") + def capabilities_get(): + try: + from ..capabilities import merge_tool_capabilities, serialize_capabilities + from ..tools import build_registry + + capabilities = ( + merge_tool_capabilities(build_registry(config)) + if config is not None else None + ) + return serialize_capabilities(capabilities) if capabilities is not None else serialize_capabilities() + except ImportError: + # The endpoint remains useful during source-only installations where + # the optional TUI dependency is unavailable. + from ..tools import build_registry + + if config is None: + return [] + return [ + { + "id": f"tool.{name}", "title": name.replace("_", " ").title(), + "category": "tools", "execution_mode": "background", + "cancellable": True, + } + for name in build_registry(config).names() + ] + + @app.post("/api/v2/executions") + async def execution_create(body: dict): + capability_id = str(body.get("capability_id") or "").strip() + if not capability_id: + raise HTTPException(status_code=400, detail="capability_id is required") + args = body.get("args") or {} + if not isinstance(args, dict): + raise HTTPException(status_code=400, detail="args must be an object") + mode = str(body.get("mode") or ("interactive" if capability_id == "agent.run" else "background")) + + if capability_id == "agent.run": + runner = lambda ctx: _agent_execution(ctx, args) + elif capability_id.startswith("tool."): + runner = lambda ctx: _tool_execution(ctx, capability_id, args) + elif capability_id.startswith("tui."): + runner = lambda ctx: _tui_execution(ctx, capability_id, args) + else: + raise HTTPException(status_code=400, detail=f"capability '{capability_id}' is not executable") + try: + execution = execution_manager.create(capability_id, args, runner, mode=mode) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return execution.as_dict() + + @app.get("/api/v2/executions") + def executions_get(status: str | None = None, limit: int = 100): + return execution_manager.list(status=status, limit=limit) + + @app.get("/api/v2/executions/{execution_id}") + def execution_get(execution_id: str): + return _execution_or_404(execution_id).as_dict() + + @app.get("/api/v2/executions/{execution_id}/events") + async def execution_events( + execution_id: str, after: int = 0, stream: bool = True, + ): + from fastapi.responses import StreamingResponse + + execution = _execution_or_404(execution_id) + if not stream: + events, terminal = await execution_manager.events_after(execution_id, after) + return { + "events": [event.as_dict() for event in events], + "terminal": terminal, + "next": events[-1].sequence if events else after, + } + + async def event_stream(): + cursor = max(0, after) + while True: + events, terminal = await execution_manager.events_after( + execution_id, cursor, wait=True, timeout=15, + ) + if not events: + yield ": keepalive\n\n" + for event in events: + cursor = event.sequence + yield ( + f"id: {event.sequence}\n" + f"data: {json.dumps(event.as_dict(), ensure_ascii=False)}\n\n" + ) + if terminal and not events: + break + + return StreamingResponse( + event_stream(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @app.post("/api/v2/executions/{execution_id}/pause") + async def execution_pause(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_pause() + try: + execution_manager.pause(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/resume") + async def execution_resume(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_resume() + try: + execution_manager.resume(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/steer") + async def execution_steer(execution_id: str, body: dict): + execution = _execution_or_404(execution_id) + message = str(body.get("message") or "") + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + await agent_steer({"message": message}) + try: + execution_manager.steer(execution_id, message) + except (RuntimeError, ValueError) as exc: + raise HTTPException(status_code=409 if isinstance(exc, RuntimeError) else 400, detail=str(exc)) from exc + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/attacker") + async def execution_attacker_switch(execution_id: str, body: dict): + execution = _execution_or_404(execution_id) + if execution.capability_id != "agent.run": + raise HTTPException(status_code=409, detail="attacker switching applies only to agent runs") + if agent_control is None or agent_control.get("execution_id") != execution_id: + raise HTTPException(status_code=409, detail="this agent execution is not active") + result = await agent_attacker_switch(body) + execution.metadata["attacker"] = result.get("attacker", "") + execution.metadata["provider"] = result.get("provider", "") + execution.emit( + "control", state="attacker_switched", + attacker=result.get("attacker", ""), provider=result.get("provider", ""), + ) + return execution.as_dict() + + @app.post("/api/v2/executions/{execution_id}/cancel") + async def execution_cancel(execution_id: str): + execution = _execution_or_404(execution_id) + if ( + execution.capability_id == "agent.run" and agent_control is not None + and agent_control.get("execution_id") == execution_id + ): + task = agent_control.get("task") + if task is not None and not task.done(): + task.cancel() + try: + execution_manager.cancel(execution_id) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return execution.as_dict() + + @app.get("/api/v2/history/status") + async def history_status(): + return history_index.status() + + @app.post("/api/v2/history/rebuild") + async def history_rebuild(): + return history_index.rebuild(sessions) + + @app.get("/api/v2/history/runs") + async def history_runs(limit: int = 50, offset: int = 0): + history_index.update(sessions) + return history_index.run_summaries(limit=limit, offset=offset) + + @app.get("/api/v2/history/events") + async def history_events( + q: str = "", run_name: str | None = None, event_type: str | None = None, + actor: str | None = None, technique: str | None = None, + verdict: str | None = None, execution_id: str | None = None, + round_id: str | None = None, inference_id: str | None = None, + tool_id: str | None = None, timestamp_from: str | None = None, + timestamp_to: str | None = None, limit: int = 100, offset: int = 0, + order: str = "desc", + ): + history_index.update(sessions) + return history_index.query_events( + q, run_name=run_name, event_type=event_type, actor=actor, + technique=technique, verdict=verdict, execution_id=execution_id, + round_id=round_id, inference_id=inference_id, tool_id=tool_id, + timestamp_from=timestamp_from, timestamp_to=timestamp_to, + limit=limit, offset=offset, order=order, + ) + + @app.on_event("shutdown") + def close_history_index(): + history_index.close() + dist = _web_dist(web_dir) if dist is not None: + from fastapi.responses import FileResponse + + @app.get("/v2", include_in_schema=False) + def v2_shell(): + return FileResponse(dist / "index.html") + + @app.get("/legacy", include_in_schema=False) + def legacy_shell(): + return FileResponse(dist / "index.html") + app.mount("/", StaticFiles(directory=str(dist), html=True), name="web") else: @app.get("/") @@ -1602,7 +2062,14 @@ def _no_build(): return app -def serve(host: str = "127.0.0.1", port: int = 8787, config=None, sessions_dir="sessions"): +def serve( + host: str = "127.0.0.1", port: int = 8787, config=None, + sessions_dir="sessions", *, allow_network: bool = False, +): + if host not in {"127.0.0.1", "localhost", "::1"} and not allow_network: + raise ValueError( + "refusing to expose the unauthenticated dashboard; pass allow_network=True explicitly" + ) import uvicorn app = create_app(config=config, sessions_dir=sessions_dir) diff --git a/wallbreaker/executions.py b/wallbreaker/executions.py new file mode 100644 index 0000000..6c0bc37 --- /dev/null +++ b/wallbreaker/executions.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + + +TERMINAL_STATES = {"succeeded", "failed", "cancelled"} +VALID_MODES = {"interactive", "background"} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +@dataclasses.dataclass(frozen=True) +class ExecutionEvent: + execution_id: str + sequence: int + type: str + timestamp: str + data: dict[str, Any] + version: int = 1 + + def as_dict(self) -> dict[str, Any]: + return dataclasses.asdict(self) + + +class ExecutionContext: + """Control surface passed to a server-owned execution runner.""" + + def __init__(self, execution: "Execution") -> None: + self.execution = execution + + def emit(self, event_type: str, **data: Any) -> ExecutionEvent: + return self.execution.emit(event_type, **data) + + async def checkpoint(self) -> None: + """Pause at a safe boundary, if requested, or raise on cancellation.""" + if self.execution.cancel_requested: + raise asyncio.CancelledError + if not self.execution.pause_requested: + return + self.execution.status = "paused" + self.execution.updated_at = _now() + self.execution.emit("control", state="paused") + await self.execution.resume_event.wait() + if self.execution.cancel_requested: + raise asyncio.CancelledError + self.execution.status = "running" + self.execution.updated_at = _now() + self.execution.emit("control", state="running") + + def drain_feedback(self) -> list[str]: + values = self.execution.feedback[:] + self.execution.feedback.clear() + return values + + +Runner = Callable[[ExecutionContext], Awaitable[dict[str, Any] | None]] + + +@dataclasses.dataclass +class Execution: + capability_id: str + args: dict[str, Any] + mode: str + id: str = dataclasses.field(default_factory=lambda: uuid4().hex) + status: str = "queued" + created_at: str = dataclasses.field(default_factory=_now) + updated_at: str = dataclasses.field(default_factory=_now) + run_id: str = "" + result: dict[str, Any] | None = None + error: str = "" + events: list[ExecutionEvent] = dataclasses.field(default_factory=list) + feedback: list[str] = dataclasses.field(default_factory=list) + pause_requested: bool = False + cancel_requested: bool = False + resume_event: asyncio.Event = dataclasses.field(default_factory=asyncio.Event, repr=False) + task: asyncio.Task | None = dataclasses.field(default=None, repr=False) + condition: asyncio.Condition = dataclasses.field(default_factory=asyncio.Condition, repr=False) + metadata: dict[str, Any] = dataclasses.field(default_factory=dict) + _sequence: int = dataclasses.field(default=0, repr=False) + + def __post_init__(self) -> None: + self.resume_event.set() + + def emit(self, event_type: str, **data: Any) -> ExecutionEvent: + self._sequence += 1 + event = ExecutionEvent( + execution_id=self.id, + sequence=self._sequence, + type=event_type, + timestamp=_now(), + data=data, + ) + self.events.append(event) + self.updated_at = event.timestamp + + async def wake() -> None: + async with self.condition: + self.condition.notify_all() + + try: + asyncio.get_running_loop().create_task(wake()) + except RuntimeError: + pass + return event + + def as_dict(self, *, include_args: bool = True) -> dict[str, Any]: + data = { + "id": self.id, + "capability_id": self.capability_id, + "mode": self.mode, + "status": self.status, + "created_at": self.created_at, + "updated_at": self.updated_at, + "run_id": self.run_id, + "result": self.result, + "error": self.error, + "event_count": len(self.events), + "pause_requested": self.pause_requested, + "cancel_requested": self.cancel_requested, + "metadata": self.metadata, + "title": str(self.metadata.get("title") or self.args.get("objective") or self.capability_id), + "objective": str(self.args.get("objective") or ""), + } + for key in ( + "current_round", "max_rounds", "max_tokens", "attacker", "target", + "judge", "provider", "input_tokens", "output_tokens", "verdict", + "technique", "latency_ms", + ): + if key in self.metadata: + data[key] = self.metadata[key] + if include_args: + data["args"] = self.args + return data + + +class ExecutionManager: + """Owns execution lifecycles independently of HTTP connections.""" + + def __init__(self, *, background_concurrency: int = 2, max_events: int = 50_000) -> None: + self.executions: dict[str, Execution] = {} + self.background_semaphore = asyncio.Semaphore(max(1, background_concurrency)) + self.interactive_semaphore = asyncio.Semaphore(1) + self.max_events = max(100, max_events) + + def get(self, execution_id: str) -> Execution | None: + return self.executions.get(execution_id) + + def list(self, *, status: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + values = list(reversed(self.executions.values())) + if status: + values = [item for item in values if item.status == status] + return [item.as_dict() for item in values[: max(1, min(limit, 1000))]] + + def create( + self, + capability_id: str, + args: dict[str, Any], + runner: Runner, + *, + mode: str = "background", + ) -> Execution: + if mode not in VALID_MODES: + raise ValueError(f"mode must be one of: {', '.join(sorted(VALID_MODES))}") + execution = Execution(capability_id=capability_id, args=dict(args), mode=mode) + self.executions[execution.id] = execution + execution.emit("lifecycle", state="queued") + execution.task = asyncio.create_task(self._run(execution, runner)) + return execution + + async def _run(self, execution: Execution, runner: Runner) -> None: + semaphore = ( + self.interactive_semaphore + if execution.mode == "interactive" + else self.background_semaphore + ) + try: + async with semaphore: + if execution.cancel_requested: + raise asyncio.CancelledError + execution.status = "running" + execution.emit("lifecycle", state="running") + result = await runner(ExecutionContext(execution)) + if execution.cancel_requested: + raise asyncio.CancelledError + execution.result = result or {} + execution.status = "succeeded" + execution.emit("lifecycle", state="succeeded", result=execution.result) + except asyncio.CancelledError: + execution.status = "cancelled" + execution.emit("lifecycle", state="cancelled") + except Exception as exc: # noqa: BLE001 - execution errors are event data + execution.status = "failed" + execution.error = f"{type(exc).__name__}: {exc}" + execution.emit("error", error=execution.error) + execution.emit("lifecycle", state="failed") + finally: + execution.updated_at = _now() + execution.resume_event.set() + if len(execution.events) > self.max_events: + execution.events[:] = execution.events[-self.max_events :] + async with execution.condition: + execution.condition.notify_all() + + def pause(self, execution_id: str) -> Execution: + execution = self._active(execution_id) + execution.pause_requested = True + execution.resume_event.clear() + if execution.status == "running": + execution.status = "pausing" + execution.emit("control", state="pausing") + return execution + + def resume(self, execution_id: str) -> Execution: + execution = self._active(execution_id) + execution.pause_requested = False + execution.resume_event.set() + execution.emit("control", state="resuming") + return execution + + def steer(self, execution_id: str, message: str) -> Execution: + execution = self._active(execution_id) + text = message.strip() + if not text: + raise ValueError("steering message is required") + execution.feedback.append(text) + execution.emit("operator", action="steer_queued", text=text) + return execution + + def cancel(self, execution_id: str) -> Execution: + execution = self._active(execution_id) + execution.cancel_requested = True + execution.pause_requested = False + execution.resume_event.set() + execution.emit("control", state="cancelling") + if execution.task is not None and not execution.task.done(): + execution.task.cancel() + return execution + + async def events_after( + self, + execution_id: str, + after: int = 0, + *, + wait: bool = False, + timeout: float = 15.0, + ) -> tuple[list[ExecutionEvent], bool]: + execution = self.executions.get(execution_id) + if execution is None: + raise KeyError(execution_id) + + def current() -> list[ExecutionEvent]: + return [event for event in execution.events if event.sequence > after] + + events = current() + if not events and wait and execution.status not in TERMINAL_STATES: + with contextlib.suppress(asyncio.TimeoutError): + async with execution.condition: + await asyncio.wait_for(execution.condition.wait(), timeout=max(0.1, timeout)) + events = current() + return events, execution.status in TERMINAL_STATES + + def _active(self, execution_id: str) -> Execution: + execution = self.executions.get(execution_id) + if execution is None: + raise KeyError(execution_id) + if execution.status in TERMINAL_STATES: + raise RuntimeError(f"execution is already {execution.status}") + return execution diff --git a/wallbreaker/history_index.py b/wallbreaker/history_index.py new file mode 100644 index 0000000..f443618 --- /dev/null +++ b/wallbreaker/history_index.py @@ -0,0 +1,588 @@ +"""Disposable SQLite search index for canonical Wallbreaker JSONL run logs. + +The JSONL files remain the source of truth. This module deliberately owns no +history: its database can be deleted and rebuilt from ``sessions/run-*.jsonl`` +at any time. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + + +SCHEMA_VERSION = 1 +REDACTED = "[REDACTED]" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _normalise_key(key: object) -> str: + return re.sub(r"[^a-z0-9]+", "_", str(key).lower()).strip("_") + + +def _is_token_count(key: str, value: object) -> bool: + """Distinguish numeric usage/budget fields from authentication tokens.""" + if not isinstance(value, (int, float)) or isinstance(value, bool): + return False + return ( + key == "tokens" + or key.endswith("_tokens") + or key.endswith("_token_count") + or key in {"token_count", "max_tokens", "budget_tokens"} + ) + + +def _is_sensitive_key(key: object, value: object) -> bool: + normalised = _normalise_key(key) + if "token" in normalised and not _is_token_count(normalised, value): + return True + return any( + marker in normalised + for marker in ("api_key", "authorization", "secret", "password", "cookie") + ) + + +def redact(value: Any) -> Any: + """Return a recursively redacted, JSON-compatible copy of *value*.""" + if isinstance(value, Mapping): + return { + str(key): REDACTED if _is_sensitive_key(key, item) else redact(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [redact(item) for item in value] + if value is None or isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def _at(record: Mapping[str, Any], *paths: tuple[str, ...]) -> Any: + for path in paths: + value: Any = record + for part in path: + if not isinstance(value, Mapping) or part not in value: + break + value = value[part] + else: + if value not in (None, ""): + return value + return None + + +def _number(value: Any) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + try: + return float(value) if value not in (None, "") else None + except (TypeError, ValueError): + return None + + +def _integer(value: Any) -> int | None: + number = _number(value) + return int(number) if number is not None else None + + +def _numeric_values(value: Any, names: set[str]) -> Iterable[float]: + if isinstance(value, Mapping): + for key, item in value.items(): + if _normalise_key(key) in names: + number = _number(item) + if number is not None: + yield number + if isinstance(item, (Mapping, list, tuple)): + yield from _numeric_values(item, names) + elif isinstance(value, (list, tuple)): + for item in value: + yield from _numeric_values(item, names) + + +def _usage_value(record: Mapping[str, Any], names: set[str]) -> float | None: + values = list(_numeric_values(record, names)) + # Stream usage records may repeat cumulative counts. The maximum avoids + # double-counting while still handling both compact and legacy schemas. + return max(values) if values else None + + +def _searchable_values(value: Any) -> Iterable[str]: + if isinstance(value, Mapping): + for item in value.values(): + yield from _searchable_values(item) + elif isinstance(value, (list, tuple)): + for item in value: + yield from _searchable_values(item) + elif value is not None and value != REDACTED: + yield str(value) + + +def _event_fields(record: Mapping[str, Any], source_line: int) -> dict[str, Any]: + event_type = str(_at(record, ("kind",), ("event_type",), ("type",)) or "unknown") + actor = _at( + record, + ("actor",), + ("source",), + ("role",), + ("request", "endpoint", "name"), + ("endpoint", "name"), + ("event", "actor"), + ) + if actor is None and event_type in {"user", "assistant", "target", "judge", "attack", "art"}: + actor = event_type + + redacted = redact(record) + structured_json = json.dumps(redacted, ensure_ascii=False, sort_keys=True, default=str) + return { + "source_line": source_line, + "sequence": _integer(_at(record, ("seq",), ("sequence",))) or source_line, + "timestamp": str(_at(record, ("ts",), ("timestamp",), ("created_at",), ("time",)) or ""), + "event_type": event_type, + "actor": str(actor or ""), + "technique": str(_at(record, ("technique",), ("strategy",), ("preset",), ("metadata", "technique")) or ""), + "verdict": str(_at(record, ("verdict",), ("label",), ("result", "label"), ("event", "verdict")) or ""), + "latency_ms": _usage_value(record, {"latency_ms", "duration_ms", "elapsed_ms"}), + "input_tokens": _integer(_usage_value(record, {"input_tokens", "prompt_tokens"})), + "output_tokens": _integer(_usage_value(record, {"output_tokens", "completion_tokens"})), + "cost": _usage_value(record, {"cost", "cost_usd", "total_cost", "total_cost_usd"}), + "execution_id": str(_at(record, ("execution_id",), ("job_id",), ("correlation", "execution_id")) or ""), + "round_id": str(_at(record, ("round_id",), ("round",), ("correlation", "round_id")) or ""), + "inference_id": str(_at(record, ("inference_id",), ("correlation", "inference_id")) or ""), + "tool_id": str(_at( + record, + ("tool_id",), + ("tool_use_id",), + ("tool_call_id",), + ("call_id",), + ("correlation", "tool_id"), + ) or ""), + "searchable_text": "\n".join(_searchable_values(redacted)), + "structured_json": structured_json, + } + + +class HistoryIndex: + """Rebuildable, queryable index over Wallbreaker run JSONL files.""" + + def __init__(self, database: str | Path, *, use_fts: bool | None = None): + self.path = Path(database) + self.path.parent.mkdir(parents=True, exist_ok=True) + # FastAPI may construct the app and service sync endpoints on different + # worker threads. Access remains serialized by the API adapter, while + # disabling the sqlite creator-thread guard keeps lifecycle shutdown safe. + self._connection = sqlite3.connect(self.path, check_same_thread=False) + self._connection.row_factory = sqlite3.Row + self._connection.execute("PRAGMA foreign_keys = ON") + self._connection.execute("PRAGMA journal_mode = WAL") + self._create_schema() + self.fts_enabled = self._configure_fts(use_fts) + + def __enter__(self) -> "HistoryIndex": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def close(self) -> None: + self._connection.close() + + def _create_schema(self) -> None: + self._connection.executescript( + """ + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS runs ( + run_name TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_size INTEGER NOT NULL DEFAULT 0, + source_mtime_ns INTEGER NOT NULL DEFAULT 0, + event_count INTEGER NOT NULL DEFAULT 0, + malformed_lines INTEGER NOT NULL DEFAULT 0, + first_timestamp TEXT NOT NULL DEFAULT '', + last_timestamp TEXT NOT NULL DEFAULT '', + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_cost REAL NOT NULL DEFAULT 0, + indexed_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + run_name TEXT NOT NULL REFERENCES runs(run_name) ON DELETE CASCADE, + source_line INTEGER NOT NULL, + sequence INTEGER NOT NULL, + timestamp TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL DEFAULT 'unknown', + actor TEXT NOT NULL DEFAULT '', + technique TEXT NOT NULL DEFAULT '', + verdict TEXT NOT NULL DEFAULT '', + latency_ms REAL, + input_tokens INTEGER, + output_tokens INTEGER, + cost REAL, + execution_id TEXT NOT NULL DEFAULT '', + round_id TEXT NOT NULL DEFAULT '', + inference_id TEXT NOT NULL DEFAULT '', + tool_id TEXT NOT NULL DEFAULT '', + searchable_text TEXT NOT NULL DEFAULT '', + structured_json TEXT NOT NULL, + UNIQUE(run_name, source_line) + ); + CREATE INDEX IF NOT EXISTS events_run_sequence ON events(run_name, sequence); + CREATE INDEX IF NOT EXISTS events_timestamp ON events(timestamp); + CREATE INDEX IF NOT EXISTS events_type ON events(event_type); + CREATE INDEX IF NOT EXISTS events_actor ON events(actor); + CREATE INDEX IF NOT EXISTS events_technique ON events(technique); + CREATE INDEX IF NOT EXISTS events_verdict ON events(verdict); + CREATE INDEX IF NOT EXISTS events_execution ON events(execution_id); + CREATE INDEX IF NOT EXISTS events_inference ON events(inference_id); + """ + ) + self._connection.execute( + "INSERT INTO metadata(key, value) VALUES('schema_version', ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (str(SCHEMA_VERSION),), + ) + self._connection.commit() + + def _configure_fts(self, requested: bool | None) -> bool: + if requested is False: + return False + try: + self._connection.executescript( + """ + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + searchable_text, content='events', content_rowid='id' + ); + CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, searchable_text) + VALUES (new.id, new.searchable_text); + END; + CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, searchable_text) + VALUES ('delete', old.id, old.searchable_text); + END; + CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, searchable_text) + VALUES ('delete', old.id, old.searchable_text); + INSERT INTO events_fts(rowid, searchable_text) + VALUES (new.id, new.searchable_text); + END; + """ + ) + self._connection.execute("INSERT INTO events_fts(events_fts) VALUES('rebuild')") + self._connection.commit() + return True + except sqlite3.OperationalError: + self._connection.rollback() + if requested is True: + raise + return False + + def rebuild(self, sessions: str | Path = "sessions") -> dict[str, Any]: + """Discard indexed data and rebuild it from ``run-*.jsonl`` files.""" + directory = Path(sessions) + with self._connection: + self._connection.execute("DELETE FROM events") + self._connection.execute("DELETE FROM runs") + self._set_meta("source_directory", str(directory.resolve())) + self._set_meta("last_rebuild_at", _utc_now()) + for path in sorted(directory.glob("run-*.jsonl")): + if path.is_file(): + self.index_file(path, force=True) + return self.status() + + def update(self, sessions: str | Path = "sessions") -> dict[str, Any]: + """Incrementally index new or changed canonical run files.""" + directory = Path(sessions) + changed = 0 + skipped = 0 + for path in sorted(directory.glob("run-*.jsonl")): + result = self.index_file(path) + changed += int(not result["skipped"]) + skipped += int(result["skipped"]) + result = self.status() + result.update({"changed_runs": changed, "skipped_runs": skipped}) + return result + + def index_file(self, path: str | Path, *, force: bool = False) -> dict[str, Any]: + """Upsert one run file, replacing only that run when its source changed.""" + source = Path(path) + stat = source.stat() + run_name = source.stem + existing = self._connection.execute( + "SELECT source_size, source_mtime_ns, event_count, malformed_lines " + "FROM runs WHERE run_name = ?", + (run_name,), + ).fetchone() + if ( + not force + and existing is not None + and existing["source_size"] == stat.st_size + and existing["source_mtime_ns"] == stat.st_mtime_ns + ): + return { + "run_name": run_name, + "event_count": existing["event_count"], + "malformed_lines": existing["malformed_lines"], + "skipped": True, + } + + records: list[tuple[int, Mapping[str, Any]]] = [] + malformed = 0 + with source.open("r", encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, 1): + if not line.strip(): + continue + try: + record = json.loads(line) + except (json.JSONDecodeError, UnicodeError): + malformed += 1 + continue + if not isinstance(record, Mapping): + malformed += 1 + continue + records.append((line_number, record)) + + indexed_at = _utc_now() + with self._connection: + self._connection.execute("DELETE FROM runs WHERE run_name = ?", (run_name,)) + self._connection.execute( + """INSERT INTO runs( + run_name, source_path, source_size, source_mtime_ns, + malformed_lines, indexed_at + ) VALUES (?, ?, ?, ?, ?, ?)""", + (run_name, str(source.resolve()), stat.st_size, stat.st_mtime_ns, malformed, indexed_at), + ) + for line_number, record in records: + self._insert_event(run_name, record, line_number) + self._refresh_run(run_name, len(records), malformed, indexed_at) + + return { + "run_name": run_name, + "event_count": len(records), + "malformed_lines": malformed, + "skipped": False, + } + + # Friendly aliases for callers that describe this operation as an upsert. + upsert_run = index_file + incremental_update = update + + def upsert_event( + self, + run_name: str, + record: Mapping[str, Any], + *, + source_line: int | None = None, + source_path: str = "", + ) -> dict[str, Any]: + """Incrementally upsert an already-parsed event by run and source line.""" + if not isinstance(record, Mapping): + raise TypeError("record must be a mapping") + line = source_line or _integer(record.get("source_line")) or _integer(record.get("seq")) + if line is None: + row = self._connection.execute( + "SELECT COALESCE(MAX(source_line), 0) + 1 FROM events WHERE run_name = ?", + (run_name,), + ).fetchone() + line = int(row[0]) + now = _utc_now() + with self._connection: + self._connection.execute( + """INSERT INTO runs(run_name, source_path, indexed_at) + VALUES (?, ?, ?) + ON CONFLICT(run_name) DO UPDATE SET indexed_at=excluded.indexed_at""", + (run_name, source_path, now), + ) + self._insert_event(run_name, record, line) + count = self._connection.execute( + "SELECT COUNT(*) FROM events WHERE run_name = ?", (run_name,) + ).fetchone()[0] + malformed = self._connection.execute( + "SELECT malformed_lines FROM runs WHERE run_name = ?", (run_name,) + ).fetchone()[0] + self._refresh_run(run_name, count, malformed, now) + return dict(self._connection.execute( + "SELECT * FROM events WHERE run_name = ? AND source_line = ?", (run_name, line) + ).fetchone()) + + def _insert_event(self, run_name: str, record: Mapping[str, Any], source_line: int) -> None: + fields = _event_fields(record, source_line) + columns = ", ".join(fields) + placeholders = ", ".join("?" for _ in fields) + updates = ", ".join( + f"{column}=excluded.{column}" for column in fields if column != "source_line" + ) + self._connection.execute( + f"""INSERT INTO events(run_name, {columns}) + VALUES (?, {placeholders}) + ON CONFLICT(run_name, source_line) DO UPDATE SET {updates}""", + (run_name, *fields.values()), + ) + + def _refresh_run(self, run_name: str, count: int, malformed: int, indexed_at: str) -> None: + aggregate = self._connection.execute( + """SELECT + COALESCE(MIN(NULLIF(timestamp, '')), ''), + COALESCE(MAX(timestamp), ''), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost), 0) + FROM events WHERE run_name = ?""", + (run_name,), + ).fetchone() + self._connection.execute( + """UPDATE runs SET + event_count=?, malformed_lines=?, first_timestamp=?, last_timestamp=?, + total_input_tokens=?, total_output_tokens=?, total_cost=?, indexed_at=? + WHERE run_name=?""", + (count, malformed, *aggregate, indexed_at, run_name), + ) + + def _set_meta(self, key: str, value: str) -> None: + self._connection.execute( + "INSERT INTO metadata(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value), + ) + + @staticmethod + def _fts_query(text: str) -> str: + terms = re.findall(r"[\w-]+", text, flags=re.UNICODE) + return " AND ".join('"' + term.replace('"', '""') + '"' for term in terms) + + def query_events( + self, + text: str = "", + *, + facets: Mapping[str, Any] | None = None, + run_name: str | None = None, + event_type: str | None = None, + actor: str | None = None, + technique: str | None = None, + verdict: str | None = None, + execution_id: str | None = None, + round_id: str | int | None = None, + inference_id: str | None = None, + tool_id: str | None = None, + timestamp_from: str | None = None, + timestamp_to: str | None = None, + limit: int = 50, + offset: int = 0, + order: str = "desc", + ) -> dict[str, Any]: + """Search events with pagination and exact-match structured facets.""" + selected = { + "run_name": run_name, + "event_type": event_type, + "actor": actor, + "technique": technique, + "verdict": verdict, + "execution_id": execution_id, + "round_id": round_id, + "inference_id": inference_id, + "tool_id": tool_id, + } + for key, value in (facets or {}).items(): + if key in selected and value not in (None, ""): + selected[key] = value + + clauses: list[str] = [] + parameters: list[Any] = [] + for column, value in selected.items(): + if value not in (None, ""): + clauses.append(f"e.{column} = ?") + parameters.append(str(value)) + if timestamp_from: + clauses.append("e.timestamp >= ?") + parameters.append(timestamp_from) + if timestamp_to: + clauses.append("e.timestamp <= ?") + parameters.append(timestamp_to) + + join = "" + if text and self.fts_enabled and self._fts_query(text): + join = " JOIN events_fts ON events_fts.rowid = e.id" + clauses.append("events_fts MATCH ?") + parameters.append(self._fts_query(text)) + elif text: + clauses.append("LOWER(e.searchable_text) LIKE LOWER(?)") + parameters.append(f"%{text}%") + + where = " WHERE " + " AND ".join(clauses) if clauses else "" + total = self._connection.execute( + f"SELECT COUNT(*) FROM events e{join}{where}", parameters + ).fetchone()[0] + direction = "ASC" if order.lower() == "asc" else "DESC" + safe_limit = min(max(int(limit), 1), 1000) + safe_offset = max(int(offset), 0) + rows = self._connection.execute( + f"""SELECT e.* FROM events e{join}{where} + ORDER BY e.timestamp {direction}, e.sequence {direction}, e.id {direction} + LIMIT ? OFFSET ?""", + (*parameters, safe_limit, safe_offset), + ).fetchall() + return { + "items": [dict(row) for row in rows], + "total": total, + "limit": safe_limit, + "offset": safe_offset, + } + + # A shorter name is convenient for API adapters. + query = query_events + + def run_summaries(self, *, limit: int = 50, offset: int = 0) -> dict[str, Any]: + safe_limit = min(max(int(limit), 1), 1000) + safe_offset = max(int(offset), 0) + total = self._connection.execute("SELECT COUNT(*) FROM runs").fetchone()[0] + rows = self._connection.execute( + """SELECT * FROM runs + ORDER BY last_timestamp DESC, run_name DESC LIMIT ? OFFSET ?""", + (safe_limit, safe_offset), + ).fetchall() + items = [] + for row in rows: + item = dict(row) + verdict_rows = self._connection.execute( + """SELECT verdict, COUNT(*) AS count FROM events + WHERE run_name=? AND verdict<>'' GROUP BY verdict ORDER BY verdict""", + (item["run_name"],), + ).fetchall() + item["verdicts"] = {value["verdict"]: value["count"] for value in verdict_rows} + items.append(item) + return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset} + + def status(self) -> dict[str, Any]: + values = { + row["key"]: row["value"] + for row in self._connection.execute("SELECT key, value FROM metadata") + } + aggregate = self._connection.execute( + """SELECT COUNT(*) AS runs, COALESCE(SUM(event_count), 0) AS events, + COALESCE(SUM(malformed_lines), 0) AS malformed FROM runs""" + ).fetchone() + latest = self._connection.execute("SELECT MAX(indexed_at) FROM runs").fetchone()[0] + return { + "database": str(self.path), + "schema_version": int(values.get("schema_version", SCHEMA_VERSION)), + "fts_enabled": self.fts_enabled, + "run_count": aggregate["runs"], + "event_count": aggregate["events"], + "malformed_lines": aggregate["malformed"], + "last_indexed_at": latest, + "last_rebuild_at": values.get("last_rebuild_at"), + "source_directory": values.get("source_directory"), + } + + +__all__ = ["HistoryIndex", "REDACTED", "SCHEMA_VERSION", "redact"] From 7bc25aa09e8cfae9abd2996db50eaf5785bee72f Mon Sep 17 00:00:00 2001 From: Rovo Dev <1616421+KillAllTheHippies@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:25:22 +0100 Subject: [PATCH 02/21] feat(webui): add unified V2 operator surface --- wallbreaker/dashboard/web/src/main.tsx | 7 +- wallbreaker/dashboard/web/src/v2.css | 450 ++++++++++++++++++ .../dashboard/web/src/v2/CommandPalette.tsx | 94 ++++ wallbreaker/dashboard/web/src/v2/LiveView.tsx | 415 ++++++++++++++++ wallbreaker/dashboard/web/src/v2/V2App.tsx | 152 ++++++ wallbreaker/dashboard/web/src/v2/Views.tsx | 303 ++++++++++++ wallbreaker/dashboard/web/src/v2/api.ts | 314 ++++++++++++ .../dashboard/web/src/v2/components.tsx | 101 ++++ wallbreaker/dashboard/web/src/v2/index.ts | 2 + wallbreaker/dashboard/web/src/v2/types.ts | 203 ++++++++ wallbreaker/dashboard/web/vite.config.ts | 2 +- 11 files changed, 2041 insertions(+), 2 deletions(-) create mode 100644 wallbreaker/dashboard/web/src/v2.css create mode 100644 wallbreaker/dashboard/web/src/v2/CommandPalette.tsx create mode 100644 wallbreaker/dashboard/web/src/v2/LiveView.tsx create mode 100644 wallbreaker/dashboard/web/src/v2/V2App.tsx create mode 100644 wallbreaker/dashboard/web/src/v2/Views.tsx create mode 100644 wallbreaker/dashboard/web/src/v2/api.ts create mode 100644 wallbreaker/dashboard/web/src/v2/components.tsx create mode 100644 wallbreaker/dashboard/web/src/v2/index.ts create mode 100644 wallbreaker/dashboard/web/src/v2/types.ts diff --git a/wallbreaker/dashboard/web/src/main.tsx b/wallbreaker/dashboard/web/src/main.tsx index bd34b2a..5baeb94 100644 --- a/wallbreaker/dashboard/web/src/main.tsx +++ b/wallbreaker/dashboard/web/src/main.tsx @@ -1,10 +1,15 @@ import React from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { V2App } from "./v2"; import "./styles.css"; +import "./v2.css"; + +const path = window.location.pathname.replace(/\/+$/, "") || "/"; +const useV2 = path === "/v2" || window.location.hash.startsWith("#v2/"); createRoot(document.getElementById("root")!).render( - + {useV2 ? : } ); diff --git a/wallbreaker/dashboard/web/src/v2.css b/wallbreaker/dashboard/web/src/v2.css new file mode 100644 index 0000000..a30ae82 --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2.css @@ -0,0 +1,450 @@ +.v2-root { + --v2-bg: #080b0d; + --v2-bg-soft: #0d1012; + --v2-surface: #111214; + --v2-surface-raised: #171517; + --v2-line: #3a2426; + --v2-line-soft: #252123; + --v2-text: #e9e4e1; + --v2-muted: #a39a96; + --v2-dim: #706967; + --v2-red: #f04444; + --v2-red-strong: #c5262d; + --v2-green: #35cc83; + --v2-teal: #34d9cc; + --v2-amber: #e9ad3d; + --v2-violet: #c879ec; + --v2-blue: #74a5ff; + --v2-mono: ui-monospace, "SFMono-Regular", "Cascadia Code", Consolas, monospace; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + width: 100%; + min-width: 0; + height: 100vh; + overflow: hidden; + background: var(--v2-bg); + color: var(--v2-text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1.4; + color-scheme: dark; +} + +.v2-root, .v2-root * { box-sizing: border-box; } +.v2-root button, .v2-root input, .v2-root select, .v2-root textarea { font: inherit; } +.v2-root button { color: inherit; } +.v2-root button, .v2-root select { cursor: pointer; } +.v2-root button:disabled { cursor: not-allowed; opacity: .48; } +.v2-root :focus-visible { outline: 2px solid var(--v2-teal); outline-offset: 2px; } +.v2-root ::selection { background: rgba(240, 68, 68, .34); } +.v2-sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.v2-skip { position: fixed; z-index: 1000; top: -60px; left: 12px; padding: 10px 14px; background: var(--v2-text); color: var(--v2-bg); } +.v2-skip:focus { top: 12px; } +.v2-mono { font-family: var(--v2-mono); } +.v2-muted { color: var(--v2-muted); } + +.v2-rail { + position: relative; + z-index: 20; + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--v2-line); + background: #0b0e10; +} +.v2-brand { display: flex; align-items: center; min-height: 64px; gap: 9px; padding: 0 16px; border-bottom: 1px solid var(--v2-line-soft); letter-spacing: .09em; } +.v2-brand > span { color: var(--v2-red); font-size: 15px; } +.v2-brand strong { font-size: 13px; white-space: nowrap; } +.v2-brand strong b { color: var(--v2-red); } +.v2-brand small { margin-left: auto; color: var(--v2-dim); font-family: var(--v2-mono); font-size: 10px; } +.v2-brand > button { display: none; margin-left: auto; border: 0; background: transparent; color: var(--v2-muted); } +.v2-rail nav { display: grid; gap: 2px; padding: 10px 9px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-rail nav button { display: flex; align-items: center; gap: 11px; min-height: 37px; padding: 0 12px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--v2-muted); text-align: left; } +.v2-rail nav button span { color: var(--v2-dim); font-size: 8px; } +.v2-rail nav button:hover { color: var(--v2-text); background: var(--v2-surface); } +.v2-rail nav button.active { border-color: #51282b; background: #1b1416; color: var(--v2-text); } +.v2-rail nav button.active span { color: var(--v2-green); } +.v2-rail-section { padding: 11px 9px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-rail-section header { display: flex; justify-content: space-between; padding: 0 5px 8px; color: var(--v2-muted); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; } +.v2-rail-section header span:last-child { min-width: 18px; padding: 1px 5px; border: 1px solid var(--v2-line); border-radius: 9px; text-align: center; } +.v2-rail-section > p { margin: 5px; color: var(--v2-dim); font-size: 11px; } +.v2-rail-section > button { display: grid; width: 100%; min-width: 0; gap: 5px; padding: 9px; border: 1px solid transparent; border-radius: 5px; background: transparent; text-align: left; } +.v2-rail-section > button:hover, .v2-rail-section > button.selected { border-color: var(--v2-line); background: var(--v2-surface); } +.v2-rail-section > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--v2-mono); font-size: 10px; font-weight: 500; } +.v2-rail-section > button small { color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-rail-section .v2-status { justify-self: start; } +.v2-run-queue { min-height: 0; overflow-y: auto; } +.v2-run-queue > button { grid-template-columns: 10px minmax(0, 1fr); } +.v2-run-queue > button > span { grid-row: 1 / 3; align-self: start; color: var(--v2-green); font-size: 7px; } +.v2-run-queue > button small { grid-column: 2; } +.v2-rail-foot { display: grid; gap: 7px; margin-top: auto; padding: 13px 14px; border-top: 1px solid var(--v2-line-soft); color: var(--v2-muted); } +.v2-rail-foot small { font-family: var(--v2-mono); font-size: 9px; } + +.v2-shell { display: flex; min-width: 0; min-height: 0; flex-direction: column; } +.v2-mobile-header { display: none; } +.v2-main { min-width: 0; min-height: 0; flex: 1; overflow: auto; } +.v2-main-live { overflow: hidden; } +.v2-page-header { display: flex; align-items: center; justify-content: space-between; min-height: 92px; padding: 18px 24px; border-bottom: 1px solid var(--v2-line); background: #0c0e10; } +.v2-page-header span { color: var(--v2-red); font-family: var(--v2-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; } +.v2-page-header h1 { margin: 2px 0 0; font-size: 21px; font-weight: 650; } +.v2-page-header p { margin: 2px 0 0; color: var(--v2-muted); font-size: 12px; } +.v2-command-button { display: flex; align-items: center; gap: 12px; padding: 8px 9px 8px 13px; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-surface); color: var(--v2-muted); } +.v2-command-button kbd { padding: 3px 7px; border: 1px solid #4b3b3d; border-radius: 3px; background: var(--v2-bg); color: var(--v2-text); font-family: var(--v2-mono); font-size: 10px; } +.v2-operator-bar { position: relative; z-index: 30; min-height: 78px; gap: 18px; padding-block: 10px; } +.v2-route-heading { min-width: 145px; } +.v2-operator-controls { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 7px; margin-left: auto; } +.v2-operator-controls .role-chip { max-width: 205px; border-radius: 4px; } +.v2-operator-controls .role-menu { color: var(--v2-text); } +.v2-active-run { display: grid; min-width: 125px; max-width: 185px; gap: 3px; padding: 6px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: var(--v2-surface); text-align: left; } +.v2-active-run strong { overflow: hidden; font-family: var(--v2-mono); font-size: 9px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-active-run:hover { border-color: #765056; background: #19181a; } +.v2-page { display: grid; gap: 13px; min-height: 100%; padding: 16px; align-content: start; } + +.v2-panel { min-width: 0; overflow: hidden; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-bg-soft); } +.v2-panel-header { display: flex; min-height: 42px; align-items: center; gap: 12px; padding: 0 13px; border-bottom: 1px solid var(--v2-line); background: #0e1012; } +.v2-panel-title { display: flex; min-width: 0; align-items: baseline; gap: 6px; } +.v2-panel-title h2 { margin: 0; font-size: 12px; font-weight: 650; letter-spacing: .01em; } +.v2-panel-title span { overflow: hidden; color: var(--v2-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-panel-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.v2-button { min-height: 31px; padding: 0 12px; border: 1px solid #493235; border-radius: 4px; background: #131416; color: var(--v2-text); font-size: 11px; } +.v2-button:hover:not(:disabled) { border-color: #765056; background: #19181a; } +.v2-button-small { min-height: 27px; padding: 0 8px; font-size: 10px; } +.v2-button-primary { border-color: var(--v2-red-strong); background: var(--v2-red-strong); color: white; } +.v2-button-primary:hover:not(:disabled) { background: #dc3037; } +.v2-button-danger { border-color: #8c2e32; color: #ff7479; } +.v2-text-button { padding: 0; border: 0; background: transparent; color: var(--v2-teal); text-decoration: underline; } +.v2-actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; } + +.v2-status { display: inline-flex; align-items: center; gap: 5px; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; text-transform: uppercase; } +.v2-status span { font-size: 7px; } +.v2-status-running, .v2-status-succeeded { color: var(--v2-green); } +.v2-status-queued, .v2-status-pausing { color: var(--v2-amber); } +.v2-status-paused { color: var(--v2-blue); } +.v2-status-failed, .v2-status-cancelled { color: var(--v2-red); } +.v2-verdict { display: inline-flex; width: fit-content; padding: 2px 6px; border: 1px solid #6e292c; border-radius: 3px; background: #251010; color: #ff5b60; font-family: var(--v2-mono); font-size: 9px; letter-spacing: .05em; text-transform: uppercase; } +.v2-verdict-refused, .v2-verdict-held, .v2-verdict-pass { border-color: #225c44; background: #0e251c; color: var(--v2-green); } +.v2-verdict-partial, .v2-verdict-inconclusive { border-color: #624b21; background: #261e0d; color: var(--v2-amber); } + +.v2-live { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr) auto; background: var(--v2-bg); } +.v2-live:has(> .v2-launcher[open]) { overflow-y: auto; grid-template-rows: auto auto minmax(440px, 1fr) auto; } +.v2-run-strip { display: grid; min-width: 0; min-height: 71px; grid-template-columns: minmax(125px, 1.1fr) minmax(110px, .85fr) minmax(110px, 1fr) minmax(125px, .9fr) 82px minmax(118px, .85fr) 95px auto; align-items: stretch; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; } +.v2-strip-field, .v2-strip-progress, .v2-strip-state { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; padding: 0 12px; border-right: 1px solid var(--v2-line-soft); } +.v2-strip-field span, .v2-strip-progress > span, .v2-strip-state > span:first-child { color: var(--v2-muted); font-size: 9px; } +.v2-strip-field strong { overflow: hidden; font-family: var(--v2-mono); font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.v2-strip-progress div { width: 100%; height: 5px; overflow: hidden; border-radius: 3px; background: #38292a; } +.v2-strip-progress i { display: block; height: 100%; background: var(--v2-teal); } +.v2-strip-actions { display: flex; align-items: center; gap: 7px; padding: 0 12px; } +.v2-live > .v2-error { margin: 8px 10px 0; } +.v2-live-grid { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(600px, 1fr) minmax(320px, 390px); overflow: hidden; } +.v2-observatory { display: grid; min-width: 0; min-height: 0; grid-template-rows: minmax(210px, 45%) minmax(230px, 55%); overflow: hidden; } +.v2-observatory > .v2-panel { border-width: 0 1px 1px 0; border-radius: 0; } +.v2-matrix-panel { min-height: 0; } +.v2-matrix-panel > .v2-empty { height: calc(100% - 42px); } +.v2-legend { display: flex; flex-wrap: wrap; gap: 11px; font-family: var(--v2-mono); font-size: 9px; } +.v2-legend .pass { color: var(--v2-green); } +.v2-legend .fail { color: #ff777b; } +.v2-legend .bypass { color: var(--v2-red); } +.v2-legend .inconclusive { color: var(--v2-amber); } +.v2-matrix-scroll { width: 100%; height: calc(100% - 42px); overflow: auto; } +.v2-matrix { width: max-content; min-width: 100%; border-collapse: collapse; font-family: var(--v2-mono); font-size: 9px; } +.v2-matrix th, .v2-matrix td { height: 27px; padding: 0; border-right: 1px solid #201d1e; border-bottom: 1px solid #201d1e; text-align: center; } +.v2-matrix thead th { position: sticky; z-index: 2; top: 0; min-width: 28px; background: #101214; color: var(--v2-muted); font-weight: 500; } +.v2-matrix thead th:first-child { z-index: 3; left: 0; min-width: 175px; text-align: left; padding-left: 9px; } +.v2-matrix tbody th { position: sticky; z-index: 1; left: 0; max-width: 210px; padding: 0 9px; background: #101214; color: var(--v2-text); font-weight: 500; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-matrix tbody th span { display: inline-block; width: 25px; color: var(--v2-dim); } +.v2-matrix-cell { width: 100%; height: 100%; padding: 0; border: 0; border-radius: 0; background: #153b2c; color: #9fe3bd; font-family: var(--v2-mono); font-size: 9px; } +.v2-matrix-cell.pass { background: #174631; color: #b2e6c7; } +.v2-matrix-cell.fail { background: #713334; color: #ffd0d1; } +.v2-matrix-cell.bypass { background: #a83b3c; color: white; } +.v2-matrix-cell.inconclusive { background: #775a21; color: #fff0c4; } +.v2-matrix-cell.selected { box-shadow: inset 0 0 0 2px white; } +.v2-matrix-empty { color: #514a48; } + +.v2-timeline-panel { display: flex; min-height: 0; flex-direction: column; } +.v2-switch { display: inline-flex; align-items: center; gap: 5px; color: var(--v2-muted); font-size: 10px; } +.v2-switch input { accent-color: var(--v2-green); } +.v2-filterbar { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; padding: 8px; border-bottom: 1px solid var(--v2-line-soft); background: #0c0e10; } +.v2-filterbar input, .v2-filterbar select { min-height: 29px; border: 1px solid var(--v2-line); border-radius: 4px; background: #0b0d0f; color: var(--v2-muted); font-size: 10px; } +.v2-filterbar input { min-width: 220px; flex: 1; padding: 0 9px; } +.v2-filterbar select { max-width: 160px; padding: 0 24px 0 8px; } +.v2-timeline { min-height: 0; flex: 1; overflow: auto; } +.v2-timeline-head, .v2-event-row { display: grid; min-width: 780px; grid-template-columns: 90px 100px 150px minmax(190px, 1fr) 100px 70px; align-items: center; } +.v2-timeline-head { position: sticky; z-index: 2; top: 0; min-height: 29px; border-bottom: 1px solid var(--v2-line-soft); background: #0f1113; color: var(--v2-muted); font-size: 9px; } +.v2-timeline-head span, .v2-event-row > span { min-width: 0; padding: 0 8px; } +.v2-event-row { width: 100%; min-height: 34px; border: 0; border-bottom: 1px solid #211d1e; background: transparent; color: var(--v2-muted); text-align: left; } +.v2-event-row:hover { background: #131315; } +.v2-event-row.selected { box-shadow: inset 0 0 0 1px var(--v2-teal); background: #10201e; color: var(--v2-text); } +.v2-event-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; } +.v2-event-row > span:nth-child(3) { display: flex; align-items: center; gap: 6px; text-transform: capitalize; } +.v2-event-actor { display: flex; align-items: center; gap: 7px; color: var(--v2-text); } +.v2-event-actor i { color: var(--v2-muted); font-size: 7px; font-style: normal; } +.v2-actor-attacker .v2-event-actor, .v2-actor-attacker .v2-event-actor i { color: var(--v2-red); } +.v2-actor-target .v2-event-actor, .v2-actor-target .v2-event-actor i { color: var(--v2-teal); } +.v2-actor-judge .v2-event-actor, .v2-actor-judge .v2-event-actor i { color: var(--v2-violet); } +.v2-actor-tool .v2-event-actor, .v2-actor-tool .v2-event-actor i { color: var(--v2-amber); } + +.v2-inspector { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--v2-line); background: #0c0e10; } +.v2-inspector > header { min-height: 42px; padding: 0 13px; border-bottom: 1px solid var(--v2-line); } +.v2-inspector > header > div { display: flex; height: 100%; align-items: center; justify-content: space-between; gap: 10px; } +.v2-inspector h2 { margin: 0; font-size: 12px; } +.v2-inspector > header span { overflow: hidden; max-width: 145px; color: var(--v2-muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.v2-inspector-tabs { display: flex; min-height: 39px; overflow-x: auto; border-bottom: 1px solid var(--v2-line); } +.v2-inspector-tabs button { flex: 0 0 auto; padding: 0 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--v2-muted); font-size: 9px; } +.v2-inspector-tabs button.active { border-bottom-color: var(--v2-red); color: var(--v2-text); } +.v2-inspector-body { min-height: 0; flex: 1; overflow: auto; } +.v2-inspector-summary { padding: 13px; } +.v2-inspector-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 13px; } +.v2-inspector-heading span { color: var(--v2-muted); font-size: 9px; } +.v2-inspector-heading h3 { margin: 4px 0 0; font-size: 12px; font-weight: 550; } +.v2-inspector-section { padding: 13px 0; border-top: 1px solid var(--v2-line-soft); } +.v2-inspector-section h4, .v2-result-stack h3 { margin: 0 0 8px; color: var(--v2-text); font-size: 10px; font-weight: 550; } +.v2-kv { display: grid; margin: 0 0 13px; } +.v2-kv div { display: grid; grid-template-columns: minmax(85px, .7fr) minmax(0, 1.3fr); gap: 9px; padding: 4px 0; } +.v2-kv dt { color: var(--v2-muted); font-size: 9px; } +.v2-kv dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-family: var(--v2-mono); font-size: 9px; } + +.v2-steer { display: grid; min-height: 151px; grid-template-rows: auto 1fr; padding: 11px 12px 12px; border-top: 1px solid var(--v2-line); background: #0b0d0f; } +.v2-steer-head { display: flex; align-items: center; gap: 14px; min-height: 25px; color: var(--v2-muted); font-size: 10px; } +.v2-steer-head strong { color: var(--v2-red); font-size: 11px; } +.v2-steer-head span:last-child { overflow: hidden; margin-left: auto; color: var(--v2-green); text-overflow: ellipsis; white-space: nowrap; } +.v2-steer-row { display: grid; min-height: 0; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; padding: 7px; border: 1px solid #712e31; border-radius: 5px; background: #111012; } +.v2-steer-row label { height: 100%; } +.v2-steer-row textarea { width: 100%; height: 100%; min-height: 66px; resize: none; border: 0; background: transparent; color: var(--v2-text); font-family: var(--v2-mono); font-size: 11px; } +.v2-steer-row textarea:focus { outline: 0; } + +.v2-empty { display: flex; min-height: 108px; align-items: center; justify-content: center; flex-direction: column; gap: 5px; padding: 20px; color: var(--v2-muted); text-align: center; } +.v2-empty strong { color: var(--v2-text); font-size: 11px; font-weight: 550; } +.v2-empty span { max-width: 420px; font-size: 10px; } +.v2-loading { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 8px; color: var(--v2-muted); } +.v2-loading span { color: var(--v2-teal); font-size: 7px; } +.v2-error { display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid #6f282b; border-radius: 4px; background: #211011; color: #ff9699; font-size: 10px; } +.v2-error span { min-width: 0; flex: 1; } +.v2-code { max-height: 420px; margin: 0; overflow: auto; padding: 11px; background: #090b0d; color: #c9c2bf; font-family: var(--v2-mono); font-size: 10px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } + +.v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: minmax(360px, .9fr) minmax(440px, 1.1fr); } +.v2-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; padding: 14px; } +.v2-field { display: grid; min-width: 0; gap: 5px; color: var(--v2-muted); font-size: 10px; } +.v2-field > span { color: var(--v2-text); font-size: 10px; } +.v2-field small { color: var(--v2-dim); } +.v2-field-wide { grid-column: 1 / -1; } +.v2-field input, .v2-field select, .v2-field textarea { width: 100%; min-height: 34px; padding: 7px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: #090b0d; color: var(--v2-text); } +.v2-field textarea { min-height: 106px; resize: vertical; font-family: var(--v2-mono); font-size: 10px; } +.v2-checkbox-field { display: flex; align-items: center; gap: 8px; min-height: 34px; } +.v2-checkbox-field input, .v2-check-grid input { accent-color: var(--v2-teal); } +.v2-check-grid { display: grid; max-height: 195px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 0 14px; padding: 10px; overflow: auto; border: 1px solid var(--v2-line-soft); } +.v2-check-grid legend { padding: 0 6px; color: var(--v2-muted); font-size: 10px; } +.v2-check-grid label { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-check-grid label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-panel > .v2-actions { padding: 0 14px 14px; } +.v2-result-stack { display: grid; gap: 12px; padding: 14px; } +.v2-result-stack section { min-width: 0; overflow: hidden; border: 1px solid var(--v2-line-soft); } +.v2-result-stack section h3 { padding: 9px 10px 0; } +.v2-result-stack .v2-verdict { margin: 0 10px 8px; } + +.v2-capability-list, .v2-library-list, .v2-finding-list, .v2-run-list { max-height: calc(100vh - 230px); overflow: auto; } +.v2-capability-list button, .v2-library-list button, .v2-finding-list button, .v2-run-list button { display: grid; width: 100%; min-width: 0; gap: 4px; padding: 10px 12px; border: 0; border-bottom: 1px solid var(--v2-line-soft); background: transparent; text-align: left; } +.v2-capability-list button:hover, .v2-library-list button:hover, .v2-finding-list button:hover, .v2-run-list button:hover { background: #131416; } +.v2-capability-list button.active, .v2-library-list button.active, .v2-finding-list button.active, .v2-run-list button.active { box-shadow: inset 3px 0 var(--v2-teal); background: #10201e; } +.v2-capability-list strong, .v2-library-list strong, .v2-finding-list strong, .v2-run-list strong { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } +.v2-capability-list span, .v2-library-list span, .v2-finding-list span, .v2-run-list span { color: var(--v2-muted); font-size: 10px; } +.v2-capability-list small, .v2-library-list small, .v2-finding-list small, .v2-run-list small { color: var(--v2-dim); font-family: var(--v2-mono); font-size: 9px; } +.v2-library-list button { grid-template-columns: 64px minmax(120px, .45fr) minmax(180px, 1fr); align-items: center; } +.v2-kind { width: fit-content; padding: 2px 5px; border: 1px solid var(--v2-line); border-radius: 3px; font-family: var(--v2-mono); font-size: 8px !important; text-transform: uppercase; } +.v2-kind-preset { color: var(--v2-red) !important; } +.v2-kind-transform { color: var(--v2-teal) !important; } +.v2-kind-tool { color: var(--v2-amber) !important; } +.v2-finding-list button > div { display: flex; align-items: center; gap: 8px; } +.v2-finding-list button strong { white-space: normal; } + +.v2-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.v2-metric-grid article { display: grid; gap: 7px; padding: 16px; border: 1px solid var(--v2-line); border-radius: 5px; background: var(--v2-bg-soft); } +.v2-metric-grid span { color: var(--v2-muted); font-size: 10px; text-transform: uppercase; } +.v2-metric-grid strong { font-family: var(--v2-mono); font-size: 25px; font-weight: 500; } +.v2-report-actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px; } +.v2-report-actions p { max-width: 630px; margin: 0; color: var(--v2-muted); } +.v2-provider-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; padding: 13px; } +.v2-provider-grid article { padding: 12px; border: 1px solid var(--v2-line-soft); border-radius: 4px; background: #0b0d0f; } +.v2-provider-grid article > div:first-child { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.v2-provider-grid h3 { margin: 0; font-size: 12px; } +.v2-provider-grid .v2-button { width: 100%; margin-top: 8px; } +.v2-inline-status { margin: 12px 14px; color: var(--v2-green); font-size: 10px; } +.v2-settings-page { grid-template-columns: minmax(480px, 1.2fr) minmax(300px, .8fr); } +.v2-safeguards { display: grid; gap: 0; } +.v2-safeguards p { display: grid; gap: 4px; margin: 0; padding: 13px; border-bottom: 1px solid var(--v2-line-soft); } +.v2-safeguards strong { font-size: 10px; } +.v2-safeguards span { color: var(--v2-muted); font-size: 10px; } + +.v2-palette-backdrop { position: fixed; z-index: 100; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding: 10vh 20px; background: rgba(0, 0, 0, .72); } +.v2-palette { display: flex; width: min(680px, 100%); max-height: 72vh; flex-direction: column; overflow: hidden; border: 1px solid #634044; border-radius: 7px; background: #0d0f11; box-shadow: 0 24px 70px rgba(0, 0, 0, .55); } +.v2-palette-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--v2-line); } +.v2-palette-search label { grid-column: 1 / -1; color: var(--v2-muted); font-size: 9px; text-transform: uppercase; } +.v2-palette-search input { min-height: 39px; padding: 0; border: 0; background: transparent; color: var(--v2-text); font-size: 15px; } +.v2-palette-search input:focus { outline: 0; } +.v2-palette-search kbd { padding: 4px 7px; border: 1px solid var(--v2-line); border-radius: 3px; color: var(--v2-muted); font-family: var(--v2-mono); font-size: 9px; } +.v2-palette-results { min-height: 0; overflow: auto; padding: 8px; } +.v2-palette-group h3 { margin: 8px 8px 4px; color: var(--v2-dim); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; } +.v2-palette-group button { display: grid; width: 100%; grid-template-columns: minmax(150px, .7fr) minmax(200px, 1fr); gap: 12px; padding: 9px; border: 0; border-radius: 4px; background: transparent; text-align: left; } +.v2-palette-group button:hover, .v2-palette-group button:focus-visible { background: #171719; } +.v2-palette-group button span { color: var(--v2-text); } +.v2-palette-group button small { overflow: hidden; color: var(--v2-muted); text-overflow: ellipsis; white-space: nowrap; } +.v2-palette footer { padding: 8px 12px; border-top: 1px solid var(--v2-line); color: var(--v2-dim); font-size: 9px; } + + @media (max-width: 1240px) { + .v2-root { grid-template-columns: 196px minmax(0, 1fr); } + .v2-live-grid { grid-template-columns: minmax(560px, 1fr) 320px; } + .v2-run-strip { grid-template-columns: minmax(120px, 1fr) minmax(105px, .8fr) minmax(105px, .8fr) minmax(120px, 1fr) 82px 100px auto; } + .v2-run-strip .v2-strip-field:nth-of-type(6) { display: none; } + .v2-strip-actions { padding-inline: 8px; } + .v2-provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-operator-controls .role-chip { grid-template-columns: auto minmax(55px, 105px); } + .v2-operator-controls .role-chip small { display: none; } + .v2-active-run { display: none; } +} + +@media (max-width: 1024px) { + .v2-root { grid-template-columns: 176px minmax(0, 1fr); } + .v2-rail nav button { padding-inline: 8px; } + .v2-live-grid { grid-template-columns: minmax(520px, 1fr) 300px; } + .v2-run-strip { grid-template-columns: minmax(115px, 1fr) minmax(100px, .8fr) minmax(115px, .9fr) 78px auto; } + .v2-run-strip .v2-strip-field:nth-of-type(2), .v2-run-strip .v2-strip-field:nth-of-type(3) { display: none; } + .v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: minmax(320px, .85fr) minmax(380px, 1.15fr); } + .v2-check-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-settings-page { grid-template-columns: 1fr; } +} + +@media (max-width: 860px) { + .v2-root { display: block; overflow: auto; } + .v2-rail { position: fixed; inset: 0 auto 0 0; width: min(280px, 88vw); transform: translateX(-102%); transition: transform 160ms ease; box-shadow: 18px 0 50px rgba(0, 0, 0, .5); } + .v2-rail.open { transform: translateX(0); } + .v2-rail .v2-brand > button { display: block; } + .v2-shell { min-height: 100vh; } + .v2-mobile-header { display: flex; position: sticky; z-index: 15; top: 0; min-height: 52px; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--v2-line); background: #0b0d0f; } + .v2-mobile-header > button { padding: 7px 9px; border: 1px solid var(--v2-line); border-radius: 4px; background: var(--v2-surface); font-size: 10px; } + .v2-mobile-header .v2-brand { min-height: 0; padding: 0; border: 0; } + .v2-page-header { min-height: 80px; padding: 13px 16px; } + .v2-operator-bar { position: relative; z-index: 30; top: auto; } + .v2-operator-controls { overflow-x: auto; justify-content: flex-start; margin-left: 0; padding-bottom: 2px; } + .v2-operator-controls .role-chip { min-width: 145px; } + .v2-route-heading { display: none; } + .v2-command-button { display: none; } + .v2-main-live { overflow: visible; } + .v2-live { display: block; height: auto; } + .v2-run-strip { position: sticky; z-index: 10; top: 52px; min-height: 64px; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto; } + .v2-run-strip .v2-strip-progress, .v2-run-strip .v2-strip-field, .v2-run-strip .v2-strip-state { display: none; } + .v2-run-strip .v2-strip-field:first-child, .v2-run-strip .v2-strip-progress { display: flex; } + .v2-live-grid { display: flex; flex-direction: column; overflow: visible; } + .v2-observatory { display: grid; height: 730px; grid-template-rows: 340px 390px; } + .v2-inspector { min-height: 480px; border-top: 1px solid var(--v2-line); } + .v2-steer { position: sticky; z-index: 9; bottom: 0; min-height: 125px; } + .v2-compose-grid, .v2-workflow-grid, .v2-library-grid, .v2-runs-grid { grid-template-columns: 1fr; } + .v2-capability-list, .v2-library-list, .v2-finding-list, .v2-run-list { max-height: 330px; } + .v2-provider-grid { grid-template-columns: 1fr; } +} + + @media (max-width: 620px) { + .v2-page { padding: 9px; } + .v2-page-header p { display: none; } + .v2-operator-bar { min-height: 68px; padding: 8px 9px; } + .v2-form-grid { grid-template-columns: 1fr; } + .v2-field-wide { grid-column: auto; } + .v2-check-grid { grid-template-columns: 1fr; } + .v2-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .v2-report-actions { align-items: stretch; flex-direction: column; } + .v2-library-list button { grid-template-columns: 62px minmax(0, 1fr); } + .v2-library-list button > span:last-child { grid-column: 1 / -1; } + .v2-steer-head > span:not(:last-child) { display: none; } + .v2-steer-row { grid-template-columns: 1fr; } + .v2-steer-row .v2-button { width: 100%; } + .v2-palette-group button { grid-template-columns: 1fr; gap: 2px; } +} + +@media (prefers-reduced-motion: reduce) { + .v2-root *, .v2-root *::before, .v2-root *::after { scroll-behavior: auto !important; transition: none !important; } +} +.v2-launcher { + margin: 0 12px 10px; + border: 1px solid var(--v2-line); + border-radius: 8px; + background: var(--v2-surface-raised); +} +.v2-attacker-switch { + display: grid; + grid-template-columns: auto minmax(120px, 180px) minmax(180px, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 8px; + margin: 0 12px 10px; + padding: 9px 10px; + border: 1px solid var(--v2-amber); + border-radius: 6px; + background: #19150f; +} +.v2-history-filters { + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)) auto; + gap: 7px; + margin-bottom: 10px; +} +.v2-history-browser { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(0, 1.2fr); min-height: 420px; border: 1px solid var(--v2-line-soft); } +.v2-history-events { max-height: 580px; overflow: auto; border-right: 1px solid var(--v2-line-soft); } +.v2-history-events > button { display: grid; width: 100%; gap: 4px; padding: 9px 10px; border: 0; border-bottom: 1px solid var(--v2-line-soft); background: transparent; text-align: left; } +.v2-history-events > button:hover, .v2-history-events > button.active { background: var(--v2-surface-raised); } +.v2-history-events > button > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.v2-history-events > button > span:nth-child(2), .v2-history-events small { color: var(--v2-muted); font: 10px var(--v2-mono); } +.v2-history-inspector { min-width: 0; max-height: 580px; overflow: auto; padding: 12px; } +@media (max-width: 1100px) { + .v2-history-filters { grid-template-columns: 1fr 1fr; } + .v2-history-browser { grid-template-columns: 1fr; } + .v2-history-events { border-right: 0; border-bottom: 1px solid var(--v2-line-soft); } +} +.v2-attacker-switch > strong { color: var(--v2-amber); } +.v2-attacker-switch > span { color: var(--v2-muted); font-size: 11px; } +@media (max-width: 900px) { + .v2-attacker-switch { grid-template-columns: 1fr 1fr; } + .v2-attacker-switch > span { grid-column: 1 / -1; } +} +.v2-launcher > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + cursor: pointer; + color: var(--v2-text); +} +.v2-launcher > summary > span:first-child { display: grid; gap: 2px; } +.v2-launcher > summary small { color: var(--v2-muted); font-weight: 400; } +.v2-launcher > summary > span:last-child { + color: var(--v2-accent); + font: 600 11px var(--v2-mono); + text-transform: uppercase; +} +.v2-launcher-body { display: grid; gap: 12px; padding: 0 12px 12px; } +.v2-launcher-body > .v2-field textarea { min-height: 64px; } +.v2-techniques { + border: 1px solid var(--v2-line-soft); + border-radius: 6px; + background: var(--v2-surface); +} +.v2-techniques > summary { padding: 9px 10px; cursor: pointer; font-weight: 600; } +.v2-techniques > summary span { margin-left: 8px; color: var(--v2-muted); font: 11px var(--v2-mono); } +.v2-techniques > div { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px; + max-height: 260px; + overflow: auto; + padding: 8px; + border-top: 1px solid var(--v2-line-soft); +} +.v2-techniques label { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 7px; + border-radius: 5px; + background: var(--v2-surface-raised); +} +.v2-techniques label > span { display: grid; gap: 2px; min-width: 0; } +.v2-techniques label strong { overflow: hidden; text-overflow: ellipsis; } +.v2-techniques label small { color: var(--v2-muted); line-height: 1.3; } diff --git a/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx b/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx new file mode 100644 index 0000000..9fd3830 --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2/CommandPalette.tsx @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Capability, V2Route } from "./types"; + +const ROUTES: Array<{ id: V2Route; label: string; description: string }> = [ + { id: "live", label: "Live", description: "Monitor and steer active engagements" }, + { id: "compose", label: "Compose", description: "Build and inspect a payload" }, + { id: "workflows", label: "Workflows", description: "Run any registered capability" }, + { id: "arsenal", label: "Arsenal", description: "Browse presets, transforms, and tools" }, + { id: "findings", label: "Findings", description: "Investigate recorded evidence" }, + { id: "runs", label: "Runs and Logs", description: "Inspect historical event records" }, + { id: "reports", label: "Reports", description: "Summarize and export evidence" }, + { id: "models", label: "Models", description: "Inspect providers and model roles" }, + { id: "settings", label: "Settings", description: "Tune operator defaults" }, +]; + +export function CommandPalette({ + open, + capabilities, + onClose, + onNavigate, + onCapability, +}: { + open: boolean; + capabilities: Capability[]; + onClose: () => void; + onNavigate: (route: V2Route) => void; + onCapability: (capability: Capability) => void; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setQuery(""); + window.setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const close = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", close); + return () => window.removeEventListener("keydown", close); + }, [open, onClose]); + + const lower = query.trim().toLowerCase(); + const routes = useMemo(() => ROUTES.filter((item) => + !lower || `${item.label} ${item.description}`.toLowerCase().includes(lower), + ), [lower]); + const matches = useMemo(() => capabilities.filter((item) => + !lower || `${item.title} ${item.description || ""} ${item.category}`.toLowerCase().includes(lower), + ).slice(0, 24), [capabilities, lower]); + + if (!open) return null; + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+
+ + setQuery(event.target.value)} + placeholder="Search views, workflows, tools, and transforms" + /> + Esc +
+
+ {routes.length > 0 &&
+

Navigate

+ {routes.map((item) => )} +
} + {matches.length > 0 &&
+

Capabilities

+ {matches.map((item) => )} +
} + {!routes.length && !matches.length &&
No matching command
} +
+
Tip: press Ctrl K from anywhere to reopen this menu.
+
+
+ ); +} + +export { ROUTES }; diff --git a/wallbreaker/dashboard/web/src/v2/LiveView.tsx b/wallbreaker/dashboard/web/src/v2/LiveView.tsx new file mode 100644 index 0000000..3da4fb7 --- /dev/null +++ b/wallbreaker/dashboard/web/src/v2/LiveView.tsx @@ -0,0 +1,415 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { v2Api } from "./api"; +import { + actorLabel, + EmptyState, + ErrorBanner, + formatDuration, + formatTime, + formatTokens, + JsonBlock, + Panel, + StatusBadge, + VerdictBadge, +} from "./components"; +import type { EventEnvelope, ExecutionSummary } from "./types"; + +interface TechniqueChoice { name: string; description?: string; control?: boolean } + +type InspectorTab = "summary" | "conversation" | "evidence" | "judge" | "request" | "raw" | "artifacts"; + +const INSPECTOR_TABS: Array<{ id: InspectorTab; label: string }> = [ + { id: "summary", label: "Summary" }, + { id: "conversation", label: "Conversation" }, + { id: "evidence", label: "Evidence" }, + { id: "judge", label: "Judge" }, + { id: "request", label: "Request" }, + { id: "raw", label: "Raw" }, + { id: "artifacts", label: "Artifacts" }, +]; + +function eventStatus(event: EventEnvelope): "pass" | "fail" | "bypass" | "inconclusive" { + const value = `${event.verdict || ""} ${event.kind}`.toLowerCase(); + if (value.includes("bypass") || value.includes("complied")) return "bypass"; + if (value.includes("partial") || value.includes("inconclusive")) return "inconclusive"; + if (value.includes("error") || value.includes("fail")) return "fail"; + return "pass"; +} + +function eventTitle(event: EventEnvelope): string { + return event.summary || event.text || event.kind.replace(/_/g, " "); +} + +function valueAt(event: EventEnvelope, key: string): unknown { + return event.data?.[key] ?? (event.raw && typeof event.raw === "object" ? (event.raw as Record)[key] : undefined); +} + +function Inspector({ event }: { event: EventEnvelope | null }) { + const [tab, setTab] = useState("summary"); + useEffect(() => setTab("summary"), [event?.id]); + + if (!event) return ; + const content = (() => { + if (tab === "raw") return ; + if (tab === "conversation") return ; + if (tab === "evidence") return ; + if (tab === "judge") return ; + if (tab === "request") return ; + if (tab === "artifacts") return ; + return ( +
+
+
Selected event

{eventTitle(event)}

+ +
+
+
Actor
{actorLabel(event)}
+
Round
{event.round ?? "--"}
+
Strategy
{event.strategy || "Unclassified"}
+
Time
{formatTime(event.timestamp)}
+
Latency
{formatDuration(event.latency_ms)}
+
Tokens in / out
{formatTokens(event.input_tokens, event.output_tokens)}
+
+

Content

+ {event.verdict &&

Verdict

} +
+ ); + })(); + + return ( + + ); +} + +function StrategyMatrix({ events, selected, onSelect, maxRounds }: { + events: EventEnvelope[]; + selected: EventEnvelope | null; + onSelect: (event: EventEnvelope) => void; + maxRounds?: number; +}) { + const strategies = useMemo(() => { + const rows = new Map>(); + events.forEach((event) => { + if (!event.strategy || !event.round) return; + if (!rows.has(event.strategy)) rows.set(event.strategy, new Map()); + const existing = rows.get(event.strategy)?.get(event.round); + if (!existing || event.sequence > existing.sequence) rows.get(event.strategy)?.set(event.round, event); + }); + return [...rows.entries()]; + }, [events]); + const observedMax = Math.max(0, ...events.map((event) => event.round || 0)); + const rounds = Math.max(1, Math.min(20, maxRounds || observedMax || 8)); + + if (!strategies.length) return ; + return ( +
+ + {Array.from({ length: rounds }, (_, index) => )} + {strategies.map(([strategy, row], rowIndex) => { + const bypasses = [...row.values()].filter((event) => eventStatus(event) === "bypass").length; + return + + {Array.from({ length: rounds }, (_, index) => { + const event = row.get(index + 1); + const status = event ? eventStatus(event) : "not-run"; + const label = event ? `${status}, round ${index + 1}` : `Not run, round ${index + 1}`; + return ; + })} + + ; + })} +
Strategy{index + 1}Bypass
{String(rowIndex + 1).padStart(2, "0")}{strategy} + {event ? : -} + {bypasses}
+
+ ); +} + +function Timeline({ events, selected, onSelect, liveTail, setLiveTail, unread, markRead }: { + events: EventEnvelope[]; + selected: EventEnvelope | null; + onSelect: (event: EventEnvelope) => void; + liveTail: boolean; + setLiveTail: (value: boolean) => void; + unread: number; + markRead: () => void; +}) { + const [search, setSearch] = useState(""); + const [actor, setActor] = useState("all"); + const [kind, setKind] = useState("all"); + const [verdict, setVerdict] = useState("all"); + const bodyRef = useRef(null); + const actors = useMemo(() => [...new Set(events.map(actorLabel))].sort(), [events]); + const kinds = useMemo(() => [...new Set(events.map((event) => event.kind))].sort(), [events]); + const verdicts = useMemo(() => [...new Set(events.map((event) => event.verdict).filter(Boolean) as string[])].sort(), [events]); + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return events.filter((event) => { + if (actor !== "all" && actorLabel(event) !== actor) return false; + if (kind !== "all" && event.kind !== kind) return false; + if (verdict !== "all" && event.verdict !== verdict) return false; + return !query || `${eventTitle(event)} ${event.strategy || ""} ${actorLabel(event)} ${event.verdict || ""}`.toLowerCase().includes(query); + }); + }, [events, search, actor, kind, verdict]); + + useEffect(() => { + if (liveTail) bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight }); + }, [filtered.length, liveTail]); + + return ( + + + {unread > 0 && } + } + > +
+ + + + +
+
{ + const node = event.currentTarget; + if (node.scrollHeight - node.scrollTop - node.clientHeight > 24 && liveTail) setLiveTail(false); + }}> +
TimeActorEventDetailsTokensLatency
+ {!filtered.length && } + {filtered.map((event) => )} +
+
+ ); +} + +function AttackerSwitcher({ execution, onRefresh }: { execution: ExecutionSummary; onRefresh: () => void }) { + const [providers, setProviders] = useState>([]); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [status, setStatus] = useState(""); + const [working, setWorking] = useState(false); + useEffect(() => { v2Api.providers().then((items) => setProviders(items.map((item) => ({ name: item.name })))).catch(() => setProviders([])); }, []); + const submit = async () => { + if (!provider || !model.trim()) return; + setWorking(true); setStatus(""); + try { + await v2Api.switchAttacker(execution, { provider, model: model.trim() }); + setStatus("Attacker switched; the conversation context is preserved."); + onRefresh(); + } catch (reason) { setStatus(reason instanceof Error ? reason.message : "Unable to switch attacker"); } + finally { setWorking(false); } + }; + return
Hot-switch attacker setModel(event.target.value)} placeholder="Model ID" />{status && {status}}
; +} + +function RunStrip({ execution, onRefresh }: { execution: ExecutionSummary | null; onRefresh: () => void }) { + const [working, setWorking] = useState(false); + const [error, setError] = useState(""); + const act = async (action: "pause" | "resume" | "cancel") => { + if (!execution) return; + if (action === "cancel" && !window.confirm("Hard stop this execution? In-flight work will be cancelled.")) return; + setWorking(true); + setError(""); + try { + if (action === "pause") await v2Api.pause(execution); + if (action === "resume") await v2Api.resume(execution); + if (action === "cancel") await v2Api.cancel(execution); + onRefresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Control action failed"); + } finally { + setWorking(false); + } + }; + const progress = execution?.max_rounds ? Math.min(100, ((execution.current_round || 0) / execution.max_rounds) * 100) : 0; + return <> +
+
Target{execution?.target || "No active target"}
+
Attacker{execution?.attacker || "--"}
+
Judge{execution?.judge || "--"}
+
Round {execution?.current_round ?? "--"} of {execution?.max_rounds ?? "--"}
+
Elapsed{formatDuration(execution?.elapsed_ms)}
+
Tokens in / out{formatTokens(execution?.input_tokens, execution?.output_tokens)}
+
Connection{execution ? : ● Offline}
+
+ {execution?.status === "paused" ? : } + +
+
+ {error && setError("")} />} + {execution?.status === "paused" && execution.source !== "legacy" && } + ; +} + +function RunLauncher({ execution, onRefresh }: { execution: ExecutionSummary | null; onRefresh: () => void }) { + const [objective, setObjective] = useState(""); + const [maxRounds, setMaxRounds] = useState(20); + const [maxTokens, setMaxTokens] = useState(8192); + const [concurrency, setConcurrency] = useState(4); + const [requestDelay, setRequestDelay] = useState(0); + const [techniques, setTechniques] = useState([]); + const [selected, setSelected] = useState(null); + const [working, setWorking] = useState(false); + const [message, setMessage] = useState(""); + useEffect(() => { v2Api.tools().then((items) => setTechniques(items.filter((item) => !item.control).map((item) => ({ name: String(item.name || ""), description: typeof item.description === "string" ? item.description : undefined, control: Boolean(item.control) })).filter((item) => item.name))).catch(() => setTechniques([])); }, []); + const active = execution && ["queued", "running", "pausing", "paused"].includes(execution.status); + const start = async () => { + if (!objective.trim() || active) return; + setWorking(true); setMessage(""); + try { + await v2Api.createExecution("agent.run", { + objective: objective.trim(), max_rounds: maxRounds, max_tokens: maxTokens, + concurrency, request_delay_ms: requestDelay, + ...(selected == null ? {} : { enabled_techniques: selected }), + }, "interactive"); + setMessage("Execution queued. Live events will attach automatically."); + onRefresh(); + } catch (reason) { setMessage(reason instanceof Error ? reason.message : "Unable to start execution"); } + finally { setWorking(false); } + }; + return
+ New interactive engagement{active ? "One foreground engagement is already active" : "Configure and start without leaving Live"}{active ? "Occupied" : "Ready"} +
+