diff --git a/.gitignore b/.gitignore index 9ba460fe..6c8e370f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ outputs/ # GSD was removed on 2026-08-06. Keep its planning tree from silently # reappearing at the repo root if a gsd-* command is ever run here again. /.planning/ + +# public site build (scripts/site/publish.sh); only vercel.json and README are tracked +site/record/ +site/static/ +site/index.html +site/.vercel/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0727173d..2f98eb4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,43 @@ autoMIL: F2-readiness framework refactor. the shared tracker's does; the tracker accepts a `patience` of zero (a one-epoch run, as the vendored stoppers already allowed) because a `--hparams` override lands after the attempt is charged. +- **The dashboard is the project site, and the agent's record is on it.** + `automil viz start` serves a static site that reads one record layout + (`record/index.json`, per-run `graph.json`, `timeline.json`, + `sessions.json`, `agent_links.json`, `nodes/.json`, + `sessions//turns/.json`) built from the project's own files; the + same layout is written to disk by the new `automil viz export --out DIR` + (`--single-file` inlines it into one HTML page), so the hosted site, a + local server and an exported copy are the same frontend. Views: the + lineage tree with the primary-value chart and a node drawer that states + the keep/discard verdict with the graph's own helpers, the discovery + timeline (agent actions, experiment runs, best value on one axis), the + transcript reader (every turn, tool call, result and subagent, linked to + the nodes it created), the nodes table, the notes, the runs page and a + home page. The look is a white page with black rules, a visible column grid on + the hero, Manrope headlines and JetBrains Mono labels, teal for data only; runs + of tool-only turns fold into one line in the transcript. The hosted site can + connect to a user's own server through an + SSH tunnel: the server answers CORS only for `viz.cors_origins` + (default automil.org) and loopback origins, on the record routes and the + event stream. The tree is drawn in three dimensions by default (a top-down + layout on the page's paper, the kept lineage in teal; a flat lineage tree is one + click away); the old dark glow style is gone. +- **Session transcripts are stored by the framework.** `automil activity + ingest` copies the runtime's transcript and sidecar into + `automil/sessions//` on `SessionEnd`, and the hidden + `automil activity store-sessions [--root]` stores every journaled session + (the campaign launcher's exit trap calls it; `store_session_record.sh` is + removed). Open sessions are read from the runtime's own file and streamed + as they grow (`transcript_delta` frames on `/events`). +- **Validation-only, one enforcement point.** `viz/record_graph.py` + projects every node and result through `firewall.is_held_out_metric_key`, + no loader has a path under `archive//certify/`, run logs are served + for terminal nodes only and re-redacted, and secrets in tool results are + masked. `tests/viz/test_firewall_forged.py` plants a `test_auc` everywhere + and checks every payload and every opened path. +- `tests/viz/test_app_js_metrics_reader.py` (source-text assertions on the + old `app.js`) is replaced by behaviour tests on the served payloads. - **Companion non-inferiority guard: a veto without a vote.** Single-metric selection stays exactly as it was — the argmax is taken over diff --git a/README.md b/README.md index 853bb936..b48ff356 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ autoMIL gives coding agents the infrastructure to run experiments autonomously: - Experiment tracking (directed tree, not flat log) - Knowledge persistence (learnings.md) - Result evaluation (val-firewall + Ladder keep/discard) -- 3D visualization (live dashboard) +- A site that shows the tree, the agent's transcript and every attempt's record, live or exported @@ -94,7 +94,7 @@ autoMIL gives coding agents the infrastructure to run experiments autonomously: | **Trajectory recorder** | Per-submit JSONL using OpenTelemetry `gen_ai.*` keys with secret redaction (`sk-…`, `hf_…`, AWS keys) and bounded rotation (5 MB soft / 50 MB hard). | | **Multi-GPU orchestrator** | Background daemon with bin packing, OOM detection, crash recovery, namespaced `running//`. | | **Experiment tree** | UCB-inspired scoring balances exploitation and exploration across branches; primary_value-dominance keep/discard gated by the Ladder keep-margin, on a **validation-only** primary_value scalar (the val-firewall). | -| **3D dashboard** | Interactive Three.js visualization with live SSE updates (`localhost:8420`). | +| **Dashboard and site** | `automil viz start` serves the project site at `localhost:8420`: the lineage tree with the value chart, the discovery timeline, the agent transcript (every tool call and result, linked to the nodes it created), every node's record and the notes, updated live. `automil viz export` writes the same site with the run recorded inside it. | | **Persistent learnings** | Knowledge accumulates across sessions. Agents don't repeat mistakes. | | **Setup validation** | `automil check` validates protected files, registry purity, backend directives, and `env.required` before experiments run. | @@ -223,7 +223,7 @@ automil orchestrator start # Terminal 2: visualization (optional) tmux new -s viz -automil viz start # dashboard at localhost:8420 +automil viz start # site at localhost:8420 (prints the ssh -L line when over SSH) # Ctrl-b d to detach # Terminal 3: agent loop @@ -237,7 +237,7 @@ claude --dangerously-skip-permissions # autonomous mode, no permission prompts ```bash automil status # quick summary automil rank # top proposals -# Open http://localhost:8420 # 3D experiment tree +# Open http://localhost:8420 # tree, timeline, agent transcript, nodes, notes ``` --- @@ -404,7 +404,7 @@ automil trajectory record / export JSONL trajectory capture and r # Loop + daemons automil start-loop / stop-loop Control agent loop flag automil orchestrator start / stop / status GPU scheduler daemon (best-fit bin packing) -automil viz start / stop / status 3D visualization dashboard at localhost:8420 +automil viz start / stop / status / export Dashboard site at localhost:8420; export writes it as a static site ``` Run `automil --help` for full flag listings. diff --git a/benchmarks/campaigns/preprint_130/README.md b/benchmarks/campaigns/preprint_130/README.md index 692fed6f..afb99651 100644 --- a/benchmarks/campaigns/preprint_130/README.md +++ b/benchmarks/campaigns/preprint_130/README.md @@ -395,15 +395,21 @@ only the fresh folds); a baseline whose retry cached every fold has no timing and is submitted with `--cell --e5-hours `. Nothing from a rehearsal set is mirrored, frozen into the campaign selections, or certified. -**Session record.** Whatever the outcome, the job's exit copies the -runtime's own transcript of the cell's session, +**Session record.** The runtime's own transcript of the cell's session, `~/.claude/projects//.jsonl` (every user, assistant and tool message) with its sidecar directory (subagent transcripts, fetched -tool results), from the submitter's home into `/operator/session/` -(`store_session_record.sh `; every session the activity journal -saw, including one that failed before binding; idempotent; run it by hand -for a cell whose job predates it). The home copy is pruned by the runtime after -its cleanup period, so the cell root is the record, beside +tool results), is copied from the submitter's home into +`/automil/sessions//` (`transcript.jsonl`, +`subagents/`, `record.json`). The framework's `SessionEnd` hook stores it as +the session ends, and the job's exit runs +`automil activity store-sessions --root ` for every session the +activity journal saw (including one that failed before binding; idempotent; +run it by hand for a cell whose job predates it). Records made before this +layout sit at `/operator/session/.jsonl` and move with +`mkdir -p automil/sessions/ && mv operator/session/.jsonl automil/sessions//transcript.jsonl && mv operator/session//* automil/sessions//`. +`automil viz start` in the cell root shows the session beside the tree. The +home copy is pruned by the runtime after its cleanup period, so the cell root +is the record, beside `automil/.activity.jsonl` (session open, bind and end), `automil/graph.json`, `automil/results.tsv`, `automil/learnings.md`, `automil/plan.md`, `automil/orchestrator/orchestrator.log`, `operator/usage_before.txt`, diff --git a/benchmarks/scripts/slurm/store_session_record.sh b/benchmarks/scripts/slurm/store_session_record.sh deleted file mode 100755 index 9a3e4aa4..00000000 --- a/benchmarks/scripts/slurm/store_session_record.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# Store the runtime's own record of a cell's agent session in the cell root. -# -# Claude Code writes every session's transcript (each user, assistant and -# tool message) to ~/.claude/projects//.jsonl, with a -# sidecar directory / holding subagent transcripts and fetched -# tool results, in the HOME of the user who ran it, and prunes them after -# its cleanup period (30 days by default). The cell root is the campaign's -# record, so this copies both into /operator/session/ for every -# session the cell's activity journal saw (opened, bound or ended: a session -# that failed before binding is still a session). Idempotent; group-readable. -# -# Usage: store_session_record.sh -# The discovery job runs it on exit (any outcome); run it by hand for a -# cell whose job predates it. -set -u -ROOT="${1:?usage: store_session_record.sh }" -JOURNAL="$ROOT/automil/.activity.jsonl" -[ -s "$JOURNAL" ] || { echo "store_session_record: no activity journal in $ROOT"; exit 1; } -DEST="$ROOT/operator/session" -rc=0 -found=0 -# The journal is written by campaign_operate, one JSON event per line. -for sid in $(grep -oE '"session_id":"[0-9a-f-]+"' "$JOURNAL" | cut -d'"' -f4 | awk '!seen[$0]++'); do - found=1 - src=$(find "$HOME/.claude/projects" -maxdepth 2 -name "$sid.jsonl" 2>/dev/null | head -1) - if [ -z "$src" ]; then - echo "store_session_record: transcript $sid.jsonl not found under $HOME/.claude/projects"; rc=1; continue - fi - mkdir -p "$DEST" || { rc=1; continue; } - cp -p "$src" "$DEST/" || { echo "store_session_record: copy of $sid.jsonl failed"; rc=1; continue; } - if [ -d "${src%.jsonl}" ]; then - cp -rp "${src%.jsonl}" "$DEST/" || { echo "store_session_record: copy of the $sid sidecar failed"; rc=1; } - fi - # The home copy carries the user's private group; the record takes the - # cell root's group so the team can read it and it bills the project. - chgrp -R "$(ls -ld "$ROOT" | awk '{print $4}')" "$DEST" 2>/dev/null || true - chmod -R g+rX "$DEST" - echo "store_session_record: $sid -> $DEST ($(wc -l < "$src") transcript lines, $(find "${src%.jsonl}" -type f 2>/dev/null | wc -l) sidecar files)" -done -[ "$found" = 1 ] || { echo "store_session_record: no session in $JOURNAL"; exit 1; } -exit $rc diff --git a/benchmarks/scripts/slurm/submit_discovery_campaign.sh b/benchmarks/scripts/slurm/submit_discovery_campaign.sh index 3119b7d2..2d369a9c 100644 --- a/benchmarks/scripts/slurm/submit_discovery_campaign.sh +++ b/benchmarks/scripts/slurm/submit_discovery_campaign.sh @@ -117,8 +117,11 @@ LOG="$LOG_DIR/${CELL}.log" OPDIR="$RUNTIME/$CELL/operator"; mkdir -p "$OPDIR" # The runtime's transcript of the session into the cell root, whatever the # outcome (the home copy is pruned by the runtime after its cleanup period). +# The SessionEnd hook stores it as the session ends; this covers a runtime +# that was killed first. Same implementation, one storage layout: +# /automil/sessions//. store_session_record() { # cell - "$PROJECT_DIR/benchmarks/scripts/slurm/store_session_record.sh" "$RUNTIME/$1" >> "$LOG" 2>&1 \ + uv run --frozen --no-sync automil activity store-sessions --root "$RUNTIME/$1" >> "$LOG" 2>&1 \ || echo "[$1] session record not stored (see $LOG)" } trap 'store_session_record "$CELL"; normalize_cell_modes "$CELL"' EXIT diff --git a/benchmarks/src/autobench/campaign.py b/benchmarks/src/autobench/campaign.py index 3068400e..552f7c22 100644 --- a/benchmarks/src/autobench/campaign.py +++ b/benchmarks/src/autobench/campaign.py @@ -1258,7 +1258,7 @@ def materialize_discovery_cells( json.dumps(cell, indent=2, sort_keys=True) + "\n" ) (staging_adir / ".gitignore").write_text( - "graph.json\nresults.tsv\nresult.json\norchestrator/\ncells/\n" + "graph.json\nresults.tsv\nresult.json\norchestrator/\ncells/\nsessions/\n" ".activity.jsonl\n.activity.samples.json\n.activity.lock\n" ".automil_active\n.automil_worktrees/\n*.log\n*.pid\n" ) diff --git a/docs/getting-started.md b/docs/getting-started.md index a229fe69..a3d8da89 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -282,7 +282,7 @@ automil status # quick summary automil rank # top-ranked proposals (UCB) automil cell list # cell budget state automil orchestrator status # daemon health -# Open http://localhost:8420 for the 3D dashboard +# Open http://localhost:8420 for the dashboard (over SSH: ssh -N -L 8420:127.0.0.1:8420 user@host) ``` ## How Experiments Run @@ -397,7 +397,7 @@ test-selection bias — the rigor the framework exists to provide. | `automil trajectory record / export` | JSONL trajectory capture + redacted export bundle | | `automil start-loop` / `automil stop-loop` | Control agent loop flag | | `automil orchestrator start / stop / status` | Manage GPU scheduler daemon | -| `automil viz start / stop / status` | Manage 3D dashboard | +| `automil viz start / stop / status / export` | The dashboard site (live), or exported as a static site | Run `automil --help` for the full flag listing. diff --git a/scripts/site/publish.sh b/scripts/site/publish.sh new file mode 100755 index 00000000..793b8628 --- /dev/null +++ b/scripts/site/publish.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Export recorded runs into site/ and deploy it to Vercel. +# +# Usage: scripts/site/publish.sh [--deploy] [:[:]] ... +# +# Each argument is a project or cell root (the directory holding automil/), +# optionally followed by the run id and title to show on the site. Cell roots +# copied from a cluster carry stamps in that host's zone: set SITE_TZ to its +# IANA name (default America/Vancouver, the campaign host). Without --deploy +# the site is only written, for a look with `python -m http.server -d site`. +set -euo pipefail +HERE="$(cd "$(dirname "$0")/../.." && pwd)" +SITE="$HERE/site" +TZ_NAME="${SITE_TZ:-America/Vancouver}" +deploy=0 +if [ "${1:-}" = "--deploy" ]; then deploy=1; shift; fi +[ $# -gt 0 ] || { echo "usage: $0 [--deploy] <cell-root>[:<run-id>[:<title>]] ..." >&2; exit 2; } + +rm -rf "$SITE/record" "$SITE/static" "$SITE/index.html" +for spec in "$@"; do + IFS=':' read -r root run_id title <<< "$spec" + args=(--out "$SITE" --tz "$TZ_NAME" --force) + [ -n "${run_id:-}" ] && args+=(--run-id "$run_id") + [ -n "${title:-}" ] && args+=(--title "$title") + (cd "$HERE" && uv run automil --project "$root" viz export "${args[@]}") +done +echo "site written to $SITE ($(du -sh "$SITE" | cut -f1))" +if [ $deploy = 1 ]; then + command -v vercel >/dev/null || { echo "vercel CLI not found: npm i -g vercel, then vercel login" >&2; exit 1; } + (cd "$SITE" && vercel deploy --prod) +fi diff --git a/site/README.md b/site/README.md new file mode 100644 index 00000000..a1fd4e38 --- /dev/null +++ b/site/README.md @@ -0,0 +1,7 @@ +# The public site + +`site/` is the deploy target for automil.org. Only `vercel.json` and this +file are tracked; the page, the static assets and the recorded runs are +written here by `scripts/site/publish.sh`, which exports each listed cell +root with `automil viz export` and deploys the directory with the Vercel CLI. +The site uses hash routes, so no rewrite rule is needed. diff --git a/site/vercel.json b/site/vercel.json new file mode 100644 index 00000000..42ea3e88 --- /dev/null +++ b/site/vercel.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "cleanUrls": false, + "trailingSlash": false, + "headers": [ + { + "source": "/record/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=300, must-revalidate" }] + }, + { + "source": "/static/fonts/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/static/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=3600" }] + } + ] +} diff --git a/src/automil/cells/activity.py b/src/automil/cells/activity.py index 60205da4..a0c98dc6 100644 --- a/src/automil/cells/activity.py +++ b/src/automil/cells/activity.py @@ -418,6 +418,48 @@ def ingest_prometheus_metrics( return observed_sessions +@dataclass(frozen=True) +class JournalSession: + """One journaled runtime session, as the dashboard and the record store see it.""" + + session_id: str + cell_id: str | None + opened_at: float + ended_at: float | None + ended_by: str | None + binding_sha256: str | None + + +def journal_sessions(automil_dir: Path | str) -> tuple[JournalSession, ...]: + """Every session the journal knows, in the order they were opened. + + ``ended_by`` is ``None`` while a session is open, ``"hook"`` when the + runtime's own SessionEnd hook closed it, and the recorded ``finalized_by`` + marker for an attested close. A corrupt journal raises ``ActivityError``. + """ + root = Path(automil_dir) + if not (root / ACTIVITY_JOURNAL_FILENAME).exists(): + return () + with _activity_lock(root, exclusive=False): + replay = _read_journal_unlocked(root) + sessions = [] + for session in replay.sessions.values(): + ended_by = None + if session.end_event is not None: + ended_by = session.end_event.get("finalized_by") or "hook" + sessions.append( + JournalSession( + session_id=session.session_id, + cell_id=session.cell_id, + opened_at=session.opened_at, + ended_at=session.ended_at, + ended_by=ended_by, + binding_sha256=session.binding_sha256, + ) + ) + return tuple(sessions) + + def read_activity_report( automil_dir: Path | str, cell_id: str, @@ -870,8 +912,10 @@ def _validate_journal_event(raw: object) -> dict[str, Any]: or not attestation or attestation.strip() != attestation ): - raise ActivityError("operator close requires a non-empty attestation") - event["finalized_by"] = "operator-close" + raise ActivityError("an attested close requires a non-empty attestation") + # Keep the marker as written: the two paths promote the same + # stored sample and the ledger exists to tell them apart. + event["finalized_by"] = raw["finalized_by"] event["attestation"] = attestation return event diff --git a/src/automil/cli/activity.py b/src/automil/cli/activity.py index e1fbc117..a3b29264 100644 --- a/src/automil/cli/activity.py +++ b/src/automil/cli/activity.py @@ -38,6 +38,13 @@ def ingest() -> None: record_hook_event, ) + store_warning = None + if payload.get("hook_event_name") == "SessionEnd": + # The transcript is the only record of the agent's reasoning and the + # runtime prunes its own copy; copy it first, so accounting trouble + # below never costs the record. + store_warning = _store_transcript(automil_dir, payload) + try: if payload.get("hook_event_name") == "SessionEnd": # Scrape and record under one activity lock: the old two-step @@ -93,7 +100,68 @@ def ingest() -> None: if payload.get("hook_event_name") == "SessionStart": observe_activity_metrics(automil_dir) except ActivityError as exc: + if store_warning: + click.echo(store_warning, err=True) raise click.ClickException(str(exc)) from exc + if store_warning: + click.echo(store_warning, err=True) + + +def _store_transcript(automil_dir, payload: dict) -> str | None: + """Copy the session transcript into the project; a problem is a warning.""" + from pathlib import Path + + from automil.session_record import SessionRecordError, store_session_record + + transcript_path = payload.get("transcript_path") + if not isinstance(transcript_path, str) or not transcript_path: + return "session record not stored: the hook payload carried no transcript_path" + try: + outcome = store_session_record(automil_dir, payload.get("session_id"), Path(transcript_path)) + except (SessionRecordError, OSError) as exc: + return f"session record not stored: {exc}" + if outcome.action == "missing": + return f"session record not stored: {outcome.detail}" + return None + + +@activity.command("store-sessions", hidden=True) +@click.option( + "--root", "root", default=None, type=click.Path(), + help="Project root (or its automil/ dir). Default: discover from the working directory.", +) +@click.option( + "--claude-config-dir", "config_dir", default=None, type=click.Path(), + help="The runtime's config dir holding projects/<slug>/<session>.jsonl (default: CLAUDE_CONFIG_DIR or ~/.claude).", +) +def store_sessions(root: str | None, config_dir: str | None) -> None: + """Copy every journaled session's transcript into automil/sessions/. + + The SessionEnd hook stores a session as it ends; this covers sessions + whose runtime was killed first. Exit status 1 when any session has no + transcript left to copy. + """ + from pathlib import Path + + from automil.session_record import store_journaled_sessions + + if root is not None: + candidate = Path(root).resolve() + automil_dir = candidate if candidate.name == "automil" else candidate / "automil" + if not (automil_dir / "config.yaml").exists(): + raise click.ClickException(f"{root}: no automil/config.yaml found") + else: + automil_dir = _find_automil_dir() + outcomes = store_journaled_sessions(automil_dir, config_dir=Path(config_dir) if config_dir else None) + if not outcomes: + click.echo(f"no session in {automil_dir / '.activity.jsonl'}") + raise SystemExit(1) + missing = False + for outcome in outcomes: + click.echo(f"{outcome.session_id}: {outcome.action} ({outcome.detail})") + missing = missing or outcome.action == "missing" + if missing: + raise SystemExit(1) @activity.command("close", hidden=True) diff --git a/src/automil/cli/viz.py b/src/automil/cli/viz.py index 0fe321cb..263b588d 100644 --- a/src/automil/cli/viz.py +++ b/src/automil/cli/viz.py @@ -27,8 +27,12 @@ def viz_group(): "only on trusted networks — the dashboard exposes PIDs and node " "descriptions and has no auth.", ) -def viz_start(port: int | None, host: str | None): - """Start the 3D visualization dashboard.""" +@click.option( + "--tz", "tz_name", default=None, + help="IANA zone of the host that ran the project, for a record copied from elsewhere (default: this host's zone).", +) +def viz_start(port: int | None, host: str | None, tz_name: str | None): + """Start the dashboard: the site with this project as its run.""" from automil.viz.server import DEFAULT_PORT, cmd_start # noqa: PLC0415 adir = _find_automil_dir() # Port resolution order: explicit --port > viz.port in config > DEFAULT_PORT (8420). @@ -47,7 +51,44 @@ def viz_start(port: int | None, host: str | None): except Exception: # noqa: BLE001 cfg_port = None port = cfg_port if cfg_port is not None else DEFAULT_PORT - cmd_start(port=port, project_root=adir.parent, host=host) + cmd_start(port=port, project_root=adir.parent, host=host, tz_name=tz_name) + + +@viz_group.command("export") +@click.option("--out", "out", required=True, type=click.Path(), help="Directory for the site (index.html, static/, record/).") +@click.option("--run-id", "run_id", default=None, help="Run id in the record (default: the project name).") +@click.option("--title", default=None, help="Run title shown on the site (default: the project description).") +@click.option("--tz", "tz_name", default=None, help="IANA zone of the host that ran the project (default: this host's zone).") +@click.option("--force", is_flag=True, help="Write into a non-empty directory that holds no record.") +@click.option("--frontend/--no-frontend", "with_frontend", default=True, help="Copy the page and static assets next to record/ (default) or write only record/.") +@click.option("--single-file", "single", default=None, type=click.Path(), help="Also write one self-contained HTML file.") +def viz_export(out: str, run_id: str | None, title: str | None, tz_name: str | None, force: bool, with_frontend: bool, single: str | None): + """Write this project's record (and the site around it) to a directory.""" + from pathlib import Path # noqa: PLC0415 + + from automil.viz.clock import host_clock # noqa: PLC0415 + from automil.viz.export import ExportError, export_site, single_file # noqa: PLC0415 + from automil.viz.record import RunSource # noqa: PLC0415 + + adir = _find_automil_dir() + try: + clock = host_clock(tz_name=tz_name) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + source = RunSource(adir, clock, run_id=run_id, title=title, mode="static") + try: + summaries = export_site([source], Path(out), force=force, with_frontend=with_frontend) + except ExportError as exc: + raise click.ClickException(str(exc)) from exc + for summary in summaries: + click.echo( + f"exported {summary.run_id}: {summary.nodes} nodes, {summary.sessions} session(s), " + f"{summary.files} files -> {summary.out}" + ) + for warning in summary.warnings: + click.echo(f"warning: {warning}", err=True) + if single: + click.echo(f"single file -> {single_file(Path(single), Path(out))}") @viz_group.command("stop") diff --git a/src/automil/session_record.py b/src/automil/session_record.py new file mode 100644 index 00000000..aab792ff --- /dev/null +++ b/src/automil/session_record.py @@ -0,0 +1,229 @@ +"""Store the runtime's transcript of an agent session inside the project. + +Claude Code writes every session to ``<config>/projects/<cwd-slug>/<id>.jsonl`` +with a sidecar directory ``<id>/`` (subagent transcripts, fetched tool +results) in the user's home, and prunes them after its cleanup period. The +project is the record, so the session is copied to +``automil/sessions/<id>/{transcript.jsonl, subagents/..., record.json}``: +once by the ``SessionEnd`` hook, and again by ``automil activity +store-sessions`` for any session the hook missed. Both call the one function +here, and copying is idempotent. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal, Mapping + +SESSIONS_DIRNAME = "sessions" +TRANSCRIPT_FILENAME = "transcript.jsonl" +RECORD_FILENAME = "record.json" +_SESSION_ID = re.compile(r"^[0-9a-f-]{8,64}$") + + +class SessionRecordError(ValueError): + """A session record request that cannot be honoured as stated.""" + + +@dataclass(frozen=True) +class StoredSession: + session_id: str + dir: Path + transcript: Path + sidecar: Path | None + manifest: Mapping[str, Any] + + +@dataclass(frozen=True) +class StoreOutcome: + session_id: str + action: Literal["stored", "unchanged", "missing"] + path: Path | None + detail: str + + +def claude_config_dir(env: Mapping[str, str] | None = None) -> Path: + """Where the runtime keeps its files: ``CLAUDE_CONFIG_DIR`` or ``~/.claude``.""" + environment = os.environ if env is None else env + configured = environment.get("CLAUDE_CONFIG_DIR") + if configured: + return Path(configured).expanduser() + return Path.home() / ".claude" + + +def validate_session_id(session_id: object) -> str: + if not isinstance(session_id, str) or not _SESSION_ID.match(session_id): + raise SessionRecordError(f"not a session id: {session_id!r}") + return session_id + + +def locate_home_transcript(session_id: str, *, config_dir: Path | None = None) -> Path | None: + """The runtime's own copy of a session, newest first when several exist.""" + validate_session_id(session_id) + projects = (config_dir or claude_config_dir()) / "projects" + if not projects.is_dir(): + return None + candidates = [] + for slug in projects.iterdir(): + candidate = slug / f"{session_id}.jsonl" + if candidate.is_file(): + candidates.append(candidate) + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) + + +def session_dir(automil_dir: Path, session_id: str) -> Path: + return Path(automil_dir) / SESSIONS_DIRNAME / validate_session_id(session_id) + + +def _sha256(path: Path) -> tuple[str, int, int]: + digest = hashlib.sha256() + lines = 0 + size = 0 + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + lines += chunk.count(b"\n") + size += len(chunk) + return digest.hexdigest(), size, lines + + +def _ends_with_newline(path: Path) -> bool: + with path.open("rb") as fh: + try: + fh.seek(-1, os.SEEK_END) + except OSError: + return False + return fh.read(1) == b"\n" + + +def _read_manifest(path: Path) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _copy_file_atomic(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".tmp-", dir=str(destination.parent)) + os.close(fd) + tmp = Path(tmp_name) + try: + shutil.copyfile(source, tmp) + os.chmod(tmp, 0o666 & ~_current_umask()) + os.replace(tmp, destination) + finally: + if tmp.exists(): + tmp.unlink() + + +def _current_umask() -> int: + from automil.runtime_helpers import _current_umask as read_umask + + return read_umask() + + +def _copy_sidecar(source: Path, destination: Path) -> int: + """Replace ``destination``'s entries with ``source``'s; returns the file count.""" + destination.mkdir(parents=True, exist_ok=True) + count = 0 + for entry in sorted(source.iterdir()): + target = destination / entry.name + if entry.is_dir(): + tmp = destination / f".tmp-{entry.name}" + if tmp.exists(): + shutil.rmtree(tmp) + shutil.copytree(entry, tmp) + if target.exists(): + shutil.rmtree(target) + os.replace(tmp, target) + count += sum(1 for p in target.rglob("*") if p.is_file()) + elif entry.is_file(): + _copy_file_atomic(entry, target) + count += 1 + return count + + +def store_session_record(automil_dir: Path, session_id: str, transcript_path: Path) -> StoreOutcome: + """Copy one session's transcript and sidecar into the project. Idempotent.""" + session_id = validate_session_id(session_id) + source = Path(transcript_path) + if source.name != f"{session_id}.jsonl": + raise SessionRecordError(f"{source} is not the transcript of session {session_id}") + if not source.is_file(): + return StoreOutcome(session_id, "missing", None, f"transcript not found at {source}") + target_dir = session_dir(automil_dir, session_id) + manifest_path = target_dir / RECORD_FILENAME + digest, size, lines = _sha256(source) + previous = _read_manifest(manifest_path) + sidecar_source = source.with_suffix("") + sidecar_count = sum(1 for p in sidecar_source.rglob("*") if p.is_file()) if sidecar_source.is_dir() else 0 + if ( + previous is not None + and previous.get("sha256") == digest + and previous.get("bytes") == size + and previous.get("sidecar_files") == sidecar_count + and (target_dir / TRANSCRIPT_FILENAME).is_file() + ): + return StoreOutcome(session_id, "unchanged", target_dir, "record already current") + target_dir.mkdir(parents=True, exist_ok=True) + _copy_file_atomic(source, target_dir / TRANSCRIPT_FILENAME) + copied = _copy_sidecar(sidecar_source, target_dir) if sidecar_source.is_dir() else 0 + manifest = { + "schema": 1, + "session_id": session_id, + "source_path": str(source), + "stored_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "bytes": size, + "lines": lines, + "sha256": digest, + "complete": _ends_with_newline(source), + "sidecar_files": copied, + } + fd, tmp_name = tempfile.mkstemp(prefix=".tmp-", dir=str(target_dir)) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, sort_keys=True) + fh.write("\n") + os.chmod(tmp_name, 0o666 & ~_current_umask()) + os.replace(tmp_name, manifest_path) + return StoreOutcome(session_id, "stored", target_dir, f"{lines} lines, {copied} sidecar files") + + +def store_journaled_sessions(automil_dir: Path, *, config_dir: Path | None = None) -> tuple[StoreOutcome, ...]: + """Store every session the activity journal knows; never raises per session.""" + from automil.cells.activity import journal_sessions + + outcomes = [] + for session in journal_sessions(automil_dir): + source = locate_home_transcript(session.session_id, config_dir=config_dir) + if source is None: + outcomes.append(StoreOutcome(session.session_id, "missing", None, "no transcript under the runtime's projects directory")) + continue + outcomes.append(store_session_record(automil_dir, session.session_id, source)) + return tuple(outcomes) + + +def read_stored_sessions(automil_dir: Path) -> tuple[StoredSession, ...]: + """Every stored session record under ``automil/sessions``.""" + folder = Path(automil_dir) / SESSIONS_DIRNAME + if not folder.is_dir(): + return () + records = [] + for entry in sorted(folder.iterdir()): + transcript = entry / TRANSCRIPT_FILENAME + if not entry.is_dir() or not _SESSION_ID.match(entry.name) or not transcript.is_file(): + continue + manifest = _read_manifest(entry / RECORD_FILENAME) or {} + sidecar = entry if (entry / "subagents").is_dir() else None + records.append(StoredSession(entry.name, entry, transcript, sidecar, manifest)) + return tuple(records) diff --git a/src/automil/templates/.gitignore.j2 b/src/automil/templates/.gitignore.j2 index c42ceade..f05cd3c7 100644 --- a/src/automil/templates/.gitignore.j2 +++ b/src/automil/templates/.gitignore.j2 @@ -3,6 +3,7 @@ graph.json results.tsv result.json orchestrator/ +sessions/ .automil_active .activity.jsonl .activity.samples.json diff --git a/src/automil/trajectory/redactor.py b/src/automil/trajectory/redactor.py index ca6b10e8..25fcf7b3 100644 --- a/src/automil/trajectory/redactor.py +++ b/src/automil/trajectory/redactor.py @@ -68,10 +68,21 @@ def _held_out_ids() -> frozenset: return _held_out_ids_cached(mtime) -def redact(s: str) -> str: - """Apply all compiled redaction patterns to a string. Returns a new string.""" +def redact_secrets(s: str) -> str: + """Mask credential-shaped substrings. Pure: no project lookup, no cwd. + + The dashboard record builders run outside any project cwd and must not + consult graph.json through ``_find_automil_dir``; they need only the + pattern half of :func:`redact`. + """ for pattern, replacement in _PATTERNS: s = pattern.sub(replacement, s) + return s + + +def redact(s: str) -> str: + """Apply all compiled redaction patterns to a string. Returns a new string.""" + s = redact_secrets(s) # D-139 dynamic: redact held-out node IDs to <HELD_OUT> placeholder held_out = _held_out_ids() if held_out: diff --git a/src/automil/viz/clock.py b/src/automil/viz/clock.py new file mode 100644 index 00000000..72ad163a --- /dev/null +++ b/src/automil/viz/clock.py @@ -0,0 +1,170 @@ +"""The run host's clock: put naive host-local stamps and UTC stamps on one axis. + +Transcripts carry UTC timestamps. ``graph.json`` ``created_at``, +``completed/<node>.json`` ``completed_at`` and the orchestrator log carry +naive host-local time. A record converts every stamp to UTC once, using the +run host's zone, so the frontend never has to guess. +""" +from __future__ import annotations + +import os +import re +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from statistics import median +from typing import Iterable, Literal, Mapping +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +ClockSource = Literal["host", "explicit"] + +_QUARTER_HOUR_S = 15 * 60 +_LOG_ASCTIME = re.compile( + r"^(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}),(?P<ms>\d{3})" +) + + +@dataclass(frozen=True) +class HostClock: + """Where a record's naive stamps come from.""" + + tz_name: str | None + utc_offset_s: int + source: ClockSource + + def as_dict(self) -> dict[str, object]: + return asdict(self) + + +def _zone(name: str) -> ZoneInfo | None: + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError, OSError): + return None + + +def _offset_of(zone: ZoneInfo, now: datetime) -> int: + offset = now.astimezone(zone).utcoffset() or timedelta(0) + return int(offset.total_seconds()) + + +def _zone_from_link(link: Path) -> str | None: + """The IANA key named by an ``/etc/localtime`` symlink, if any.""" + try: + target = os.readlink(link) + except OSError: + return None + marker = "zoneinfo/" + index = target.rfind(marker) + if index < 0: + return None + name = target[index + len(marker):] + return name if _zone(name) is not None else None + + +def host_clock( + *, + tz_name: str | None = None, + utc_offset_s: int | None = None, + env: Mapping[str, str] | None = None, + localtime_link: Path = Path("/etc/localtime"), + now: datetime | None = None, +) -> HostClock: + """Resolve the clock: an explicit zone or offset, else the host's own. + + Host resolution order: ``TZ`` (when it names a zone), the + ``/etc/localtime`` symlink, then the process offset without a name. + """ + at = now or datetime.now(timezone.utc) + if tz_name is not None: + zone = _zone(tz_name) + if zone is None: + raise ValueError(f"unknown time zone {tz_name!r}") + return HostClock(tz_name=tz_name, utc_offset_s=_offset_of(zone, at), source="explicit") + if utc_offset_s is not None: + return HostClock(tz_name=None, utc_offset_s=int(utc_offset_s), source="explicit") + + environment = os.environ if env is None else env + env_name = environment.get("TZ") or "" + zone = _zone(env_name) if env_name and not env_name.startswith(":") else None + name = env_name if zone is not None else _zone_from_link(localtime_link) + if name is not None: + zone = _zone(name) + if zone is not None: + return HostClock(tz_name=name, utc_offset_s=_offset_of(zone, at), source="host") + local_offset = at.astimezone().utcoffset() or timedelta(0) + return HostClock(tz_name=None, utc_offset_s=int(local_offset.total_seconds()), source="host") + + +def _parse_iso(value: str) -> datetime | None: + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + return datetime.fromisoformat(text) + except ValueError: + return None + + +def _localize(naive: datetime, clock: HostClock) -> datetime: + zone = _zone(clock.tz_name) if clock.tz_name else None + if zone is not None: + return naive.replace(tzinfo=zone) + return naive.replace(tzinfo=timezone(timedelta(seconds=clock.utc_offset_s))) + + +def format_utc(moment: datetime) -> str: + """UTC ISO with millisecond precision and a ``Z`` suffix.""" + utc = moment.astimezone(timezone.utc) + return utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z" + + +def to_utc_iso(value: object, clock: HostClock) -> str | None: + """Convert an aware ISO string, a naive host-local ISO string or an epoch. + + Anything unparseable yields ``None``; this never raises on content. + """ + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + try: + return format_utc(datetime.fromtimestamp(float(value), tz=timezone.utc)) + except (OverflowError, OSError, ValueError): + return None + if not isinstance(value, str): + return None + parsed = _parse_iso(value) + if parsed is None: + return None + if parsed.tzinfo is None: + parsed = _localize(parsed, clock) + return format_utc(parsed) + + +def parse_log_asctime(text: str) -> datetime | None: + """The naive stamp at the start of an orchestrator log line.""" + match = _LOG_ASCTIME.match(text) + if match is None: + return None + return datetime.fromisoformat( + f"{match['date']}T{match['time']}.{match['ms']}000" + ) + + +def offset_cross_check(pairs: Iterable[tuple[str, str]]) -> int | None: + """Infer the host offset from (naive local stamp, UTC stamp) pairs. + + Each pair comes from one event seen by both clocks (a node's ``created_at`` + and the transcript turn that created it). The median difference, rounded + to a quarter hour, is the offset; ``None`` without a usable pair. + """ + deltas: list[float] = [] + for local_text, utc_text in pairs: + local = _parse_iso(local_text) if isinstance(local_text, str) else None + utc = _parse_iso(utc_text) if isinstance(utc_text, str) else None + if local is None or utc is None or local.tzinfo is not None or utc.tzinfo is None: + continue + deltas.append((local - utc.astimezone(timezone.utc).replace(tzinfo=None)).total_seconds()) + if not deltas: + return None + return int(round(median(deltas) / _QUARTER_HOUR_S) * _QUARTER_HOUR_S) diff --git a/src/automil/viz/export.py b/src/automil/viz/export.py new file mode 100644 index 00000000..edb10d06 --- /dev/null +++ b/src/automil/viz/export.py @@ -0,0 +1,214 @@ +"""Write a run's record to disk, and the whole site around it. + +The output is the same tree the live server serves, so the frontend does not +know which one it is reading. Per-run files carry no generation time and are +written with sorted keys, so exporting an unchanged run again changes nothing. +""" +from __future__ import annotations + +import base64 +import json +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +from automil.viz.record import RunSource, build_index + +STATIC_DIR = Path(__file__).parent / "static" +RECORD_DIRNAME = "record" +_SAFE_PATH = re.compile(r"^[A-Za-z0-9._/-]+$") + + +class ExportError(RuntimeError): + """The export target cannot be used as asked.""" + + +@dataclass +class ExportSummary: + run_id: str + out: Path + files: int = 0 + sessions: int = 0 + nodes: int = 0 + warnings: list[str] = field(default_factory=list) + + +def dump_json(payload: Any) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n" + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _safe_relative(rel_path: str) -> str | None: + parts = Path(rel_path).parts + if not rel_path or rel_path.startswith("/") or ".." in parts or not _SAFE_PATH.match(rel_path): + return None + return rel_path + + +def export_run(source: RunSource, out: Path) -> ExportSummary: + """Write ``out/record/runs/<run_id>/...`` for one run.""" + run_dir = Path(out) / RECORD_DIRNAME / "runs" / source.run_id + summary = ExportSummary(run_id=source.run_id, out=run_dir) + + def put(rel: str, payload: Any) -> None: + _write(run_dir / rel, dump_json(payload)) + summary.files += 1 + + graph = source.build_graph() + put("graph.json", graph) + timeline = source.build_timeline() + summary.warnings.extend(timeline.get("warnings", [])) + put("timeline.json", timeline) + sessions = source.build_sessions() + put("sessions.json", sessions) + put("agent_links.json", source.build_links()) + put("notes.json", source.build_notes()) + certified = source.build_certified() + if certified is not None: + put("certified.json", certified) + + for node_id in graph["nodes"]: + detail = source.build_node(node_id) + if detail is None: + continue + put(f"nodes/{node_id}.json", detail) + summary.nodes += 1 + for entry in detail["overlay"]["files"]: + rel = _safe_relative(entry["path"]) + if rel is None: + continue + file = source.build_overlay_file(node_id, rel) + if file is not None: + put(f"nodes/{node_id}/files/{rel}.json", file) + + for session in sessions["sessions"]: + sid = session["session_id"] + summary.sessions += 1 + for chunk in range(session["n_chunks"]): + payload = source.build_chunk(sid, chunk) + if payload is None: + continue + put(f"sessions/{sid}/turns/{chunk}.json", payload) + for turn in payload["turns"]: + for call in turn.get("tool_calls", ()): + result = call.get("result") or {} + if result.get("truncated"): + full = source.build_full_result(sid, call["tool_use_id"]) + if full is not None: + put(f"sessions/{sid}/results/{call['tool_use_id']}.json", full) + for agent in session.get("subagents", ()): + payload = source.build_agent(sid, agent["agent_id"]) + if payload is not None: + put(f"sessions/{sid}/agents/{agent['agent_id']}.json", payload) + return summary + + +def merge_index(out: Path, sources: Iterable[RunSource], *, mode: str = "static") -> dict[str, Any]: + """Rewrite ``record/index.json`` keeping runs exported earlier into ``out``.""" + index_path = Path(out) / RECORD_DIRNAME / "index.json" + previous: dict[str, Any] = {} + if index_path.is_file(): + try: + previous = json.loads(index_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + previous = {} + fresh = build_index(list(sources), mode) + fresh_ids = {run["run_id"] for run in fresh["runs"]} + kept = [run for run in previous.get("runs", []) if isinstance(run, dict) and run.get("run_id") not in fresh_ids] + runs_dir = Path(out) / RECORD_DIRNAME / "runs" + kept = [run for run in kept if (runs_dir / str(run.get("run_id")) / "graph.json").is_file()] + fresh["runs"] = kept + fresh["runs"] + _write(index_path, json.dumps(fresh, indent=2, sort_keys=True) + "\n") + return fresh + + +def copy_frontend(out: Path, *, static_dir: Path = STATIC_DIR) -> None: + """Copy ``index.html`` and ``static/`` next to the record.""" + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + shutil.copyfile(static_dir / "index.html", out / "index.html") + target = out / "static" + if target.exists(): + shutil.rmtree(target) + shutil.copytree(static_dir, target, ignore=shutil.ignore_patterns("index.html", "__pycache__")) + + +def check_target(out: Path, *, force: bool) -> None: + """Refuse a non-empty directory that is not already a record, unless forced.""" + out = Path(out) + if not out.exists(): + return + if any(out.iterdir()) and not (out / RECORD_DIRNAME / "index.json").is_file() and not force: + raise ExportError(f"{out} is not empty and holds no record; pass --force to write into it") + + +def export_site( + sources: list[RunSource], + out: Path, + *, + force: bool = False, + static_dir: Path = STATIC_DIR, + with_frontend: bool = True, +) -> list[ExportSummary]: + check_target(out, force=force) + summaries = [export_run(source, out) for source in sources] + merge_index(out, sources) + if with_frontend: + copy_frontend(out, static_dir=static_dir) + return summaries + + +_FONT_TYPES = {".woff2": "font/woff2", ".woff": "font/woff"} + + +def _inline_fonts(css: str, css_dir: Path) -> str: + def replace(match: re.Match[str]) -> str: + rel = match.group(1) + target = (css_dir / rel).resolve() + if not target.is_file() or target.suffix not in _FONT_TYPES: + return match.group(0) + data = base64.b64encode(target.read_bytes()).decode("ascii") + return f"url(data:{_FONT_TYPES[target.suffix]};base64,{data})" + + return re.sub(r"url\((?:'|\")?([^'\")]+\.woff2?)(?:'|\")?\)", replace, css) + + +def single_file(out_html: Path, record_root: Path, *, static_dir: Path = STATIC_DIR) -> Path: + """One HTML file: stylesheets, scripts, fonts and the whole record inlined. + + The frontend reads the inlined record map before fetching anything, so + the file works from disk without a server. + """ + html = (static_dir / "index.html").read_text(encoding="utf-8") + + def inline_link(match: re.Match[str]) -> str: + rel = match.group(1) + css_path = static_dir / rel.removeprefix("./static/") + css = _inline_fonts(css_path.read_text(encoding="utf-8"), css_path.parent) + return f"<style>\n{css}\n</style>" + + def inline_script(match: re.Match[str]) -> str: + rel = match.group(1) + js_path = static_dir / rel.removeprefix("./static/") + return "<script>\n" + js_path.read_text(encoding="utf-8").replace("</script", "<\\/script") + "\n</script>" + + html = re.sub(r'<link rel="stylesheet" href="(\./static/[^"]+)">', inline_link, html) + html = re.sub(r'<script src="(\./static/[^"]+)"></script>', inline_script, html) + record: dict[str, Any] = {} + root = Path(record_root) / RECORD_DIRNAME + for path in sorted(root.rglob("*.json")): + rel = path.relative_to(root).as_posix() + record[rel] = json.loads(path.read_text(encoding="utf-8")) + blob = json.dumps(record, separators=(",", ":"), ensure_ascii=False).replace("</script", "<\\/script") + tag = f'<script type="application/json" id="automil-record">{blob}</script>' + html = html.replace("</head>", tag + "\n</head>", 1) + out_html = Path(out_html) + out_html.parent.mkdir(parents=True, exist_ok=True) + out_html.write_text(html, encoding="utf-8") + return out_html diff --git a/src/automil/viz/hostinfo.py b/src/automil/viz/hostinfo.py new file mode 100644 index 00000000..138b66fc --- /dev/null +++ b/src/automil/viz/hostinfo.py @@ -0,0 +1,47 @@ +"""What to print when the dashboard starts on a remote host. + +Most projects run on a GPU host reached over SSH. The dashboard binds the +loopback interface there, so the browser on the user's own machine needs a +port forward. The hint names the exact command. +""" +from __future__ import annotations + +import getpass +import os +import socket +from dataclasses import dataclass +from typing import Mapping + + +@dataclass(frozen=True) +class HostInfo: + user: str + host: str + over_ssh: bool + + def tunnel_command(self, port: int, *, local_port: int | None = None) -> str: + local = local_port or port + return f"ssh -N -L {local}:127.0.0.1:{port} {self.user}@{self.host}" + + +def host_info(env: Mapping[str, str] | None = None) -> HostInfo: + environment = os.environ if env is None else env + try: + user = getpass.getuser() + except (KeyError, OSError): + user = environment.get("USER") or "user" + host = socket.getfqdn() or socket.gethostname() or "host" + over_ssh = bool(environment.get("SSH_CONNECTION") or environment.get("SSH_TTY") or environment.get("SSH_CLIENT")) + return HostInfo(user=user, host=host, over_ssh=over_ssh) + + +def start_banner(host: str, port: int, *, info: HostInfo | None = None) -> str: + """The lines ``automil viz start`` prints once the server is bound.""" + info = info or host_info() + lines = [f"Dashboard: http://{host}:{port}"] + if info.over_ssh: + lines.append("From your own machine, forward the port and open http://localhost:%d" % port) + lines.append(f" {info.tunnel_command(port)}") + lines.append(" (or open https://automil.org and connect it to http://localhost:%d)" % port) + lines.append(f"Another server on this port? Start with --port {port + 1}") + return "\n".join(lines) diff --git a/src/automil/viz/live.py b/src/automil/viz/live.py new file mode 100644 index 00000000..f81686b8 --- /dev/null +++ b/src/automil/viz/live.py @@ -0,0 +1,175 @@ +"""Follow open sessions while they run and push their new turns over SSE. + +The runtime appends to its own transcript under ``~/.claude/projects``, +outside the project, so this polls the file's size every second (a watchdog +observer there would wake on every runtime write) and feeds the new complete +lines to a :class:`TurnBuilder` kept per session. Frames go into the same +subscriber queues as ``graph_update``. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from automil.viz.record import RunSource +from automil.viz.record_sessions import TURN_CHUNK_SIZE, SessionSource, build_links +from automil.viz.transcript import TurnBuilder, read_records + +logger = logging.getLogger(__name__) + +POLL_INTERVAL_S = 1.0 +RESCAN_INTERVAL_S = 5.0 +INVALIDATE_GAP_TURNS = 50 + + +@dataclass +class _Tail: + source: SessionSource + builder: TurnBuilder = field(default_factory=TurnBuilder) + offset: int = 0 + line_no: int = 1 + emitted: int = 0 + + +class LiveRecord: + """Per-server tailer for the run's open sessions.""" + + def __init__( + self, + source: RunSource, + subscribers: list[asyncio.Queue], + *, + interval_s: float = POLL_INTERVAL_S, + rescan_s: float = RESCAN_INTERVAL_S, + ) -> None: + self._source = source + self._subscribers = subscribers + self._interval = interval_s + self._rescan = rescan_s + self._tails: dict[str, _Tail] = {} + self._journal_sig: tuple[int, int] | None = None + self._sessions_sig: tuple[str, ...] | None = None + self._since_rescan = 0.0 + + # -- loop -------------------------------------------------------------- + + async def run(self) -> None: + while True: + try: + for event in self.poll_once(): + self.broadcast(event) + except Exception: # noqa: BLE001 - the loop must survive a bad file + logger.exception("live: poll failed") + await asyncio.sleep(self._interval) + + def broadcast(self, event: dict[str, Any]) -> None: + payload = json.dumps(event) + dead = [] + for queue in self._subscribers: + try: + queue.put_nowait(payload) + except asyncio.QueueFull: + dead.append(queue) + for queue in dead: + self._subscribers.remove(queue) + + # -- one pass ---------------------------------------------------------- + + def _journal_signature(self) -> tuple[int, int] | None: + try: + stat = os.stat(self._source.automil_dir / ".activity.jsonl") + except OSError: + return None + return (stat.st_size, stat.st_mtime_ns) + + def poll_once(self, *, elapsed_s: float | None = None) -> list[dict[str, Any]]: + """Read what changed since the last pass and return the frames to send.""" + events: list[dict[str, Any]] = [] + self._since_rescan += self._interval if elapsed_s is None else elapsed_s + journal_sig = self._journal_signature() + if journal_sig != self._journal_sig or self._sessions_sig is None or self._since_rescan >= self._rescan: + self._journal_sig = journal_sig + self._since_rescan = 0.0 + sessions_event = self._rediscover() + if sessions_event is not None: + events.append(sessions_event) + for tail in list(self._tails.values()): + event = self._advance(tail) + if event is not None: + events.append(event) + return events + + def _rediscover(self) -> dict[str, Any] | None: + sources, _ = self._source.sessions() + live = {s.session_id: s for s in sources if s.live and s.transcript is not None} + for session_id in list(self._tails): + if session_id not in live: + del self._tails[session_id] + for session_id, source in live.items(): + tail = self._tails.get(session_id) + if tail is None or tail.source.transcript != source.transcript: + self._tails[session_id] = _Tail(source=source) + signature = tuple(sorted(f"{s.session_id}:{s.origin}:{s.live}" for s in sources)) + if signature == self._sessions_sig: + return None + self._sessions_sig = signature + return {"type": "sessions_update", "run_id": self._source.run_id, "sessions": self._source.build_sessions()} + + def _advance(self, tail: _Tail) -> dict[str, Any] | None: + path: Path = tail.source.transcript # type: ignore[assignment] + try: + size = os.stat(path).st_size + except OSError: + return None + if size < tail.offset: + self._tails[tail.source.session_id] = _Tail(source=tail.source) + return { + "type": "transcript_invalidate", + "run_id": self._source.run_id, + "session_id": tail.source.session_id, + "reason": "file shrank", + } + if size == tail.offset: + return None + batch = read_records(path, start_offset=tail.offset, line_no=tail.line_no) + tail.offset = batch.resume_offset + tail.line_no = batch.next_line_no + closed: list[dict[str, Any]] = [] + patches: list[dict[str, Any]] = [] + open_changed = False + for record in batch.records: + delta = tail.builder.feed(record) + closed.extend(delta.closed) + patches.extend({"turn": t, "call": c, "result": r} for t, c, r in delta.patches) + open_changed = open_changed or delta.open_changed + if not closed and not patches and not open_changed: + return None + from_turn = tail.emitted + n_turns = len(tail.builder.turns) + tail.emitted = n_turns + if n_turns - from_turn > INVALIDATE_GAP_TURNS: + return { + "type": "transcript_invalidate", + "run_id": self._source.run_id, + "session_id": tail.source.session_id, + "reason": f"{n_turns - from_turn} turns arrived at once", + "n_turns": n_turns, + } + by_node, _ = build_links(tail.source.session_id, closed) + return { + "type": "transcript_delta", + "run_id": self._source.run_id, + "session_id": tail.source.session_id, + "from_turn": from_turn, + "turns": closed, + "patches": patches, + "open_turn": tail.builder.open_turn, + "n_turns": n_turns, + "n_chunks": (n_turns + TURN_CHUNK_SIZE - 1) // TURN_CHUNK_SIZE, + "links": by_node, + } diff --git a/src/automil/viz/record.py b/src/automil/viz/record.py new file mode 100644 index 00000000..57f59a93 --- /dev/null +++ b/src/automil/viz/record.py @@ -0,0 +1,429 @@ +"""One run's record: the payloads the dashboard reads, built from the project. + +``RunSource`` is the only composition point. The live server calls its +builders per request; ``automil viz export`` calls them once and writes the +results to disk. Both get the same dicts. +""" +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from automil.cells.registry import scan_cells +from automil.viz.clock import HostClock, offset_cross_check, to_utc_iso +from automil.viz.record_files import ( + RunPaths, + load_campaign_state, + load_certification, + load_completed, + load_config, + load_gpu_state, + load_graph, + load_notes, + load_orchestrator_log, + load_overlay_file, + load_result, + load_run_log_tail, + load_spec, + overlay_files, +) +from automil.viz.record_graph import ( + RECORD_SCHEMA, + node_verdict, + project_graph, + project_node, + project_result, + running_node_ids, + verdict_unavailable, +) +from automil.viz.record_sessions import ( + SessionSource, + agent_events, + build_links, + chunk_turns, + discover_sessions, + session_journal_events, + session_spans, + session_summary, +) +from automil.viz.record_timeline import build_timeline, node_timing, parse_orchestrator_log +from automil.viz.subagents import SubagentTranscript, attach_subagents, read_subagents +from automil.viz.transcript import ( + FullResultRef, + ParsedTranscript, + parse_transcript, + read_full_result, +) + +logger = logging.getLogger(__name__) + +_RUN_ID_CLEAN = re.compile(r"[^a-z0-9._-]+") +_TERMINAL = frozenset({"keep", "discard", "crash", "partial", "cancelled", "candidate", "registered", "oom", "timeout"}) + + +def run_id_from_config(config: Mapping[str, Any], fallback: str = "project") -> str: + name = (config.get("project") or {}).get("name") if isinstance(config.get("project"), Mapping) else None + slug = _RUN_ID_CLEAN.sub("-", str(name).strip().lower()).strip("-.") if name else "" + return slug or fallback + + +@dataclass +class _SessionCache: + source: SessionSource + parsed: ParsedTranscript + subagents: tuple[SubagentTranscript, ...] + size: int + mtime_ns: int + + +@dataclass +class RunSource: + """Everything needed to build one run's record, with light caching.""" + + automil_dir: Path + clock: HostClock + run_id: str | None = None + title: str | None = None + config_dir: Path | None = None + mode: str = "live" + _sessions: dict[str, _SessionCache] = field(default_factory=dict, repr=False) + + def __post_init__(self) -> None: + self.automil_dir = Path(self.automil_dir) + self.paths = RunPaths(self.automil_dir) + self.config = load_config(self.paths) + if self.run_id is None: + self.run_id = run_id_from_config(self.config) + if self.title is None: + project = self.config.get("project") if isinstance(self.config.get("project"), Mapping) else {} + self.title = str(project.get("description") or project.get("name") or self.run_id) + + # -- graph ------------------------------------------------------------ + + def raw_graph(self) -> dict[str, Any]: + return load_graph(self.paths) or {"nodes": {}, "meta": {}, "technique_stats": {}} + + def running_ids(self) -> tuple[str, ...]: + return running_node_ids(load_gpu_state(self.paths)) + + def build_graph(self) -> dict[str, Any]: + graph = project_graph(self.raw_graph(), self.clock, self.running_ids()) + graph["run_id"] = self.run_id + return graph + + # -- sessions --------------------------------------------------------- + + def sessions(self) -> tuple[tuple[SessionSource, ...], str | None]: + return discover_sessions(self.paths, config_dir=self.config_dir) + + def _parsed(self, source: SessionSource) -> tuple[ParsedTranscript | None, tuple[SubagentTranscript, ...]]: + if source.transcript is None: + return None, () + try: + stat = source.transcript.stat() + except OSError: + return None, () + cached = self._sessions.get(source.session_id) + if cached and cached.size == stat.st_size and cached.mtime_ns == stat.st_mtime_ns and cached.source.transcript == source.transcript: + return cached.parsed, cached.subagents + parsed = parse_transcript(source.transcript, ended=not source.live) + subagents = read_subagents(source.sidecar) + parsed = attach_subagents(parsed, subagents) + self._sessions[source.session_id] = _SessionCache(source, parsed, subagents, stat.st_size, stat.st_mtime_ns) + return parsed, subagents + + def _active_seconds(self) -> dict[str, float]: + from automil.viz.record_files import read_json + + payload = read_json(self.automil_dir / ".activity.samples.json") + sessions = payload.get("sessions") if isinstance(payload, dict) else None + if not isinstance(sessions, dict): + return {} + out = {} + for session_id, sample in sessions.items(): + if isinstance(sample, dict) and isinstance(sample.get("active_seconds"), (int, float)): + out[str(session_id)] = float(sample["active_seconds"]) + return out + + def build_sessions(self) -> dict[str, Any]: + sources, error = self.sessions() + active = self._active_seconds() + entries = [] + for source in sources: + parsed, subagents = self._parsed(source) + entries.append(session_summary(source, parsed, subagents, self.clock, active_seconds=active.get(source.session_id))) + scan = scan_cells(self.paths.cells_dir) + cells = [ + { + "cell_id": c.cell_id, "dataset": c.dataset, "encoder": c.encoder, "mil_model": c.mil_model, + "task": getattr(c, "task", None), "status": getattr(c.status, "value", str(c.status)), + "mode": c.mode, "started_at": to_utc_iso(c.started_at, self.clock), + "budget_seconds": c.budget_seconds, "eval_budget": c.eval_budget, + "consumed_evals": c.consumed_evals, "completed_evals": c.completed_evals, + } + for c in scan.cells + ] + return { + "schema": RECORD_SCHEMA, + "run_id": self.run_id, + "chunk_size": 100, + "journal_error": error, + "sessions": entries, + "cells": cells, + "cell_errors": [f"{e.path}: {e.message}" for e in scan.errors], + } + + def build_chunk(self, session_id: str, chunk: int) -> dict[str, Any] | None: + source = self._source(session_id) + if source is None: + return None + parsed, _ = self._parsed(source) + if parsed is None: + return None + chunks = chunk_turns(parsed.turns) + if chunk < 0 or chunk >= max(len(chunks), 1): + return None + turns = chunks[chunk] if chunks else [] + return { + "schema": RECORD_SCHEMA, + "session_id": session_id, + "chunk": chunk, + "from": chunk * 100, + "turns": list(turns), + "n_turns": len(parsed.turns), + "open_turn": parsed.open_turn if chunk == max(len(chunks) - 1, 0) else None, + } + + def build_full_result(self, session_id: str, tool_use_id: str) -> dict[str, Any] | None: + source = self._source(session_id) + if source is None or source.transcript is None: + return None + parsed, subagents = self._parsed(source) + if parsed is None: + return None + ref: FullResultRef | None = parsed.full_refs.get(tool_use_id) + path = source.transcript + if ref is None: + for agent in subagents: + if tool_use_id in agent.full_refs: + ref, path = agent.full_refs[tool_use_id], agent.path + break + if ref is None: + return None + return dict(read_full_result(path, ref), session_id=session_id) + + def build_agent(self, session_id: str, agent_id: str) -> dict[str, Any] | None: + source = self._source(session_id) + if source is None: + return None + parsed, subagents = self._parsed(source) + for agent in subagents: + if agent.agent_id == agent_id: + return { + "schema": RECORD_SCHEMA, + "session_id": session_id, + "agent_id": agent.agent_id, + "tool_use_id": agent.tool_use_id, + "agent_type": agent.agent_type, + "description": agent.description, + "turns": list(agent.turns), + "models": list(agent.models), + } + if parsed is not None and agent_id in parsed.inline_sidechains: + return { + "schema": RECORD_SCHEMA, "session_id": session_id, "agent_id": agent_id, + "tool_use_id": None, "agent_type": None, "description": None, + "turns": list(parsed.inline_sidechains[agent_id]), "models": [], + } + return None + + def _source(self, session_id: str) -> SessionSource | None: + sources, _ = self.sessions() + for source in sources: + if source.session_id == session_id: + return source + return None + + # -- links and timeline ---------------------------------------------- + + def _executed_ids(self, graph: Mapping[str, Any]) -> list[str]: + nodes = graph.get("nodes") or {} + return [nid for nid, node in nodes.items() if isinstance(node, Mapping) and node.get("type") == "executed"] + + def _timings(self, graph: Mapping[str, Any]) -> dict[str, dict[str, Any]]: + events = parse_orchestrator_log(load_orchestrator_log(self.paths), self.clock) + timings = {} + for node_id in self._executed_ids(graph): + timings[node_id] = node_timing( + node_id, spec=load_spec(self.paths, node_id), completed=load_completed(self.paths, node_id), + log_events=events, clock=self.clock, + ) + return timings + + def build_links(self) -> dict[str, Any]: + graph = self.raw_graph() + submitted = {nid: t.get("submitted_at") for nid, t in self._timings(graph).items()} + sources, _ = self.sessions() + nodes: dict[str, list[dict[str, Any]]] = {} + turns: dict[str, list[str]] = {} + for source in sources: + parsed, _ = self._parsed(source) + if parsed is None: + continue + by_node, by_turn = build_links(source.session_id, parsed.turns, submitted) + for node_id, entries in by_node.items(): + nodes.setdefault(node_id, []).extend(entries) + turns.update(by_turn) + return {"schema": RECORD_SCHEMA, "run_id": self.run_id, "nodes": nodes, "turns": turns} + + def build_timeline(self) -> dict[str, Any]: + graph = self.raw_graph() + timings = self._timings(graph) + raw_nodes = graph.get("nodes") or {} + running = set(self.running_ids()) + nodes = [] + for node_id, timing in timings.items(): + node = raw_nodes[node_id] + nodes.append({ + "node_id": node_id, + "parent_id": node.get("parent_id"), + "status": "running" if node_id in running else node.get("status"), + "primary_value": node.get("primary_value"), + "created_at": to_utc_iso(node.get("created_at"), self.clock), + **{k: timing.get(k) for k in ("submitted_at", "launched_at", "completed_at", "slot")}, + }) + sources, _ = self.sessions() + events = session_journal_events(sources, self.clock) + pairs = [] + for source in sources: + parsed, _ = self._parsed(source) + if parsed is None: + continue + events.extend(agent_events(source.session_id, parsed.turns)) + for turn in parsed.turns: + for call in turn.get("tool_calls", ()): + for created in (call.get("automil") or {}).get("created", ()): + raw_created = (raw_nodes.get(created["node_id"]) or {}).get("created_at") + if isinstance(raw_created, str) and turn.get("at"): + pairs.append((raw_created, turn["at"])) + for node_id, timing in timings.items(): + for key, kind in (("launched_at", "launched"), ("completed_at", "completed")): + if timing.get(key): + events.append({"at": timing[key], "kind": kind, "session_id": None, "turn": None, "node_id": node_id, "label": node_id}) + warnings = [] + inferred = offset_cross_check(pairs) + if inferred is not None and inferred != self.clock.utc_offset_s: + warnings.append( + f"the transcript implies a host offset of {inferred // 3600:+d} h; the record used {self.clock.utc_offset_s // 3600:+d} h" + ) + return build_timeline( + run_id=self.run_id, clock=self.clock, nodes=nodes, + sessions=session_spans(sources, self.clock), events=events, warnings=warnings, + ) + + # -- nodes ------------------------------------------------------------ + + def build_node(self, node_id: str) -> dict[str, Any] | None: + graph = self.raw_graph() + raw = (graph.get("nodes") or {}).get(node_id) + if not isinstance(raw, Mapping): + return None + running = set(self.running_ids()) + node = project_node(raw, self.clock) + if node_id in running: + node["status"] = "running" + parent = (graph.get("nodes") or {}).get(raw.get("parent_id")) if raw.get("parent_id") else None + verdict = node_verdict(graph.get("meta"), parent, raw) if node_id not in running else None + reason = "not terminal" if node_id in running else verdict_unavailable(raw) + completed = load_completed(self.paths, node_id) if raw.get("type") == "executed" else None + result_raw = completed or (load_result(self.paths, node_id) if raw.get("type") == "executed" else None) + terminal = node_id not in running and (completed is not None or raw.get("status") in _TERMINAL) + spec = load_spec(self.paths, node_id) if raw.get("type") == "executed" else None + run_log = load_run_log_tail(self.paths, node_id) if (terminal and raw.get("type") == "executed" and not raw.get("bootstrapped")) else None + if run_log is None: + run_log = {"available": False, "reason": "not terminal" if not terminal else "no run log", "tail": []} + events = parse_orchestrator_log(load_orchestrator_log(self.paths), self.clock) + timing = node_timing(node_id, spec=spec, completed=completed, log_events=events, clock=self.clock) + links = self.build_links()["nodes"].get(node_id, []) + return { + "schema": RECORD_SCHEMA, + "run_id": self.run_id, + "node_id": node_id, + "node": node, + "parent": project_node(parent, self.clock) if isinstance(parent, Mapping) else None, + "result": project_result(result_raw, self.clock), + "verdict": verdict, + "verdict_unavailable": reason, + "overlay": { + "base_commit": spec.get("base_commit") if spec else None, + "files": overlay_files(spec), + "deletions": list(spec.get("deletions") or []) if spec else [], + "run_command_override": spec.get("run_command_override") if spec else None, + }, + "run_log": run_log, + "timing": timing, + "agent": links, + } + + def build_overlay_file(self, node_id: str, rel_path: str) -> dict[str, Any] | None: + return load_overlay_file(self.paths, node_id, rel_path) + + # -- notes, certification, index -------------------------------------- + + def build_notes(self) -> dict[str, Any]: + notes = load_notes(self.paths) + return {"schema": RECORD_SCHEMA, "run_id": self.run_id, **notes} + + def build_certified(self) -> dict[str, Any] | None: + """The cell's own certification bundle, when the campaign produced one.""" + bundle = load_certification(self.paths) + if bundle is None: + return None + return {"schema": RECORD_SCHEMA, "run_id": self.run_id, "revealed": True, "bundle": bundle} + + def index_entry(self) -> dict[str, Any]: + graph = self.raw_graph() + nodes = graph.get("nodes") or {} + meta = graph.get("meta") or {} + stamps = sorted(s for s in (to_utc_iso(n.get("created_at"), self.clock) for n in nodes.values() if isinstance(n, Mapping)) if s) + sources, error = self.sessions() + state = load_campaign_state(self.paths) + executed = [n for n in nodes.values() if isinstance(n, Mapping) and n.get("type") == "executed"] + best_id = meta.get("best_node_id") + return { + "run_id": self.run_id, + "title": self.title, + "project": self.config.get("project") if isinstance(self.config.get("project"), Mapping) else {}, + "task": (self.config.get("task") or {}).get("name") if isinstance(self.config.get("task"), Mapping) else None, + "encoder": (self.config.get("encoders") or {}).get("primary") if isinstance(self.config.get("encoders"), Mapping) else None, + "mil_model": (self.config.get("run") or {}).get("mil_model") if isinstance(self.config.get("run"), Mapping) else None, + "n_nodes": len(nodes), + "n_executed": len(executed), + "n_running": len(self.running_ids()), + "best_primary_value": meta.get("best_primary_value"), + "best_node_id": best_id, + "baseline_primary_value": meta.get("baseline_primary_value"), + "primary_metric": (meta.get("scoring") or {}).get("formula"), + "started_at": stamps[0] if stamps else None, + "ended_at": stamps[-1] if stamps else None, + "cell_ids": sorted({str(n.get("cell_id")) for n in executed if n.get("cell_id")}), + "n_sessions": len(sources), + "live_sessions": sum(1 for s in sources if s.live), + "journal_error": error, + "campaign_phase": state.get("phase") if state else None, + "certified": load_certification(self.paths) is not None, + "clock": self.clock.as_dict(), + } + + +def build_index(sources: list[RunSource], mode: str) -> dict[str, Any]: + return { + "schema": RECORD_SCHEMA, + "mode": mode, + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + "runs": [source.index_entry() for source in sources], + } diff --git a/src/automil/viz/record_files.py b/src/automil/viz/record_files.py new file mode 100644 index 00000000..d10b0f0c --- /dev/null +++ b/src/automil/viz/record_files.py @@ -0,0 +1,260 @@ +"""Where a project's run record lives on disk, and read-only loaders for it. + +Every loader returns ``None`` (or an empty value) for a missing or unreadable +file and never raises on content. No loader has a path under +``archive/<node>/certify/``: the sealed directory is unknown to this module. +""" +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from automil.firewall import REDACTION, redact_held_out + +logger = logging.getLogger(__name__) + +NODE_ID_PATTERN = re.compile(r"^node_\d{4,}$") +RUN_LOG_TAIL_LINES = 200 +OVERLAY_FILE_MAX_BYTES = 256 * 1024 +_CERTIFY_DIRNAME = "certify" + + +@dataclass(frozen=True) +class RunPaths: + """The files one project's record is built from.""" + + automil_dir: Path + + @property + def root(self) -> Path: + return self.automil_dir.parent + + @property + def config(self) -> Path: + return self.automil_dir / "config.yaml" + + @property + def graph(self) -> Path: + return self.automil_dir / "graph.json" + + @property + def orchestrator_dir(self) -> Path: + return self.automil_dir / "orchestrator" + + @property + def gpu_state(self) -> Path: + return self.orchestrator_dir / "gpu_state.json" + + @property + def orchestrator_log(self) -> Path: + return self.orchestrator_dir / "orchestrator.log" + + @property + def queue_dir(self) -> Path: + return self.orchestrator_dir / "queue" + + @property + def completed_dir(self) -> Path: + return self.orchestrator_dir / "completed" + + @property + def archive_dir(self) -> Path: + return self.orchestrator_dir / "archive" + + @property + def cells_dir(self) -> Path: + return self.automil_dir / "cells" + + @property + def sessions_dir(self) -> Path: + return self.automil_dir / "sessions" + + @property + def plan_md(self) -> Path: + return self.automil_dir / "plan.md" + + @property + def learnings_md(self) -> Path: + return self.automil_dir / "learnings.md" + + @property + def campaign_state(self) -> Path: + return self.root / "campaign_state.json" + + @property + def certification(self) -> Path: + return self.root / "certification" / "certify.json" + + def node_archive(self, node_id: str) -> Path: + if not NODE_ID_PATTERN.match(node_id): + raise ValueError(f"not a node id: {node_id!r}") + return self.archive_dir / node_id + + +def read_json(path: Path) -> Any: + """Parse one JSON file; ``None`` when missing, unreadable or not JSON.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + logger.warning("record: cannot read %s: %s", path, exc) + return None + + +def read_text(path: Path, *, max_bytes: int | None = None) -> str | None: + """Read a text file with replacement for bad bytes; ``None`` when missing.""" + try: + data = path.read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + logger.warning("record: cannot read %s: %s", path, exc) + return None + if max_bytes is not None: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + + +def load_graph(paths: RunPaths) -> dict[str, Any] | None: + payload = read_json(paths.graph) + return payload if isinstance(payload, dict) else None + + +def load_gpu_state(paths: RunPaths) -> dict[str, Any] | None: + payload = read_json(paths.gpu_state) + return payload if isinstance(payload, dict) else None + + +def load_spec(paths: RunPaths, node_id: str) -> dict[str, Any] | None: + """The launched spec (archive copy first, then the queue entry).""" + for candidate in (paths.node_archive(node_id) / "spec.json", paths.queue_dir / f"{node_id}.json"): + payload = read_json(candidate) + if isinstance(payload, dict): + return payload + return None + + +def load_completed(paths: RunPaths, node_id: str) -> dict[str, Any] | None: + RunPaths.node_archive(paths, node_id) # validates the id + payload = read_json(paths.completed_dir / f"{node_id}.json") + return payload if isinstance(payload, dict) else None + + +def load_result(paths: RunPaths, node_id: str) -> dict[str, Any] | None: + """The agent-facing ``result.json`` at the archive root (validation only).""" + payload = read_json(paths.node_archive(node_id) / "result.json") + return payload if isinstance(payload, dict) else None + + +def load_run_log_tail(paths: RunPaths, node_id: str, *, lines: int = RUN_LOG_TAIL_LINES) -> dict[str, Any] | None: + """The last lines of a terminal node's run log, redacted again on the way out. + + Callers serve this only for terminal nodes (the orchestrator redacts the + file at completion); ``redacted_lines`` counts what this pass had to hide. + """ + text = read_text(paths.node_archive(node_id) / "run.log") + if text is None: + return None + all_lines = text.splitlines() + tail = all_lines[-lines:] + clean = redact_held_out("\n".join(tail)).split("\n") if tail else [] + redacted = sum(1 for before, after in zip(tail, clean) if before != after and after == REDACTION) + return { + "available": True, + "tail": clean, + "n_lines_total": len(all_lines), + "tail_lines": len(clean), + "redacted_lines": redacted, + } + + +def overlay_files(spec: dict[str, Any] | None) -> list[dict[str, Any]]: + """The changed files a spec declares (never a directory listing).""" + if not isinstance(spec, dict): + return [] + manifest = spec.get("overlay_manifest") + files = [] + if isinstance(manifest, dict): + for rel_path, digest in manifest.items(): + text = str(digest) + files.append({"path": str(rel_path), "sha256": text.split(":", 1)[1] if text.startswith("sha256:") else text}) + elif isinstance(manifest, list): + for entry in manifest: + if isinstance(entry, dict) and "path" in entry: + files.append({"path": str(entry["path"]), "sha256": entry.get("sha256")}) + elif isinstance(entry, str): + files.append({"path": entry, "sha256": None}) + return files + + +def load_overlay_file(paths: RunPaths, node_id: str, rel_path: str) -> dict[str, Any] | None: + """One changed file from a node's overlay, by its declared relative path.""" + archive = paths.node_archive(node_id) + if not rel_path or rel_path.startswith("/") or ".." in Path(rel_path).parts: + return None + if _CERTIFY_DIRNAME in Path(rel_path).parts: + return None + target = archive / rel_path + try: + target.resolve().relative_to(archive.resolve()) + except ValueError: + return None + text = read_text(target, max_bytes=OVERLAY_FILE_MAX_BYTES + 1) + if text is None: + return None + truncated = len(text.encode("utf-8")) > OVERLAY_FILE_MAX_BYTES + if truncated: + text = text.encode("utf-8")[:OVERLAY_FILE_MAX_BYTES].decode("utf-8", errors="ignore") + clean = redact_held_out(text) + if text.endswith("\n") and not clean.endswith("\n"): + clean += "\n" + return {"path": rel_path, "text": clean, "truncated": truncated} + + +def load_notes(paths: RunPaths) -> dict[str, Any]: + notes: dict[str, Any] = {"plan_md": read_text(paths.plan_md), "learnings_md": read_text(paths.learnings_md)} + mtimes = [] + for candidate in (paths.plan_md, paths.learnings_md): + try: + mtimes.append(candidate.stat().st_mtime) + except OSError: + continue + notes["mtime"] = max(mtimes) if mtimes else None + return notes + + +def load_orchestrator_log(paths: RunPaths) -> list[str]: + text = read_text(paths.orchestrator_log) + return text.splitlines() if text else [] + + +def load_campaign_state(paths: RunPaths) -> dict[str, Any] | None: + payload = read_json(paths.campaign_state) + return payload if isinstance(payload, dict) else None + + +def load_certification(paths: RunPaths) -> dict[str, Any] | None: + """The cell's own certification bundle, present only after ``certify-winner``.""" + payload = read_json(paths.certification) + return payload if isinstance(payload, dict) else None + + +def load_config(paths: RunPaths) -> dict[str, Any]: + try: + import yaml + except ImportError: # pragma: no cover - yaml is a core dependency + return {} + text = read_text(paths.config) + if text is None: + return {} + try: + payload = yaml.safe_load(text) + except yaml.YAMLError as exc: + logger.warning("record: cannot parse %s: %s", paths.config, exc) + return {} + return payload if isinstance(payload, dict) else {} diff --git a/src/automil/viz/record_graph.py b/src/automil/viz/record_graph.py new file mode 100644 index 00000000..b085bf57 --- /dev/null +++ b/src/automil/viz/record_graph.py @@ -0,0 +1,227 @@ +"""Validation-only projections of graph and result payloads, and the verdict. + +This is the record's one firewall enforcement point: every node, result and +graph the dashboard serves passes through here, and nothing here reads a +file. Held-out-named keys are dropped wherever the framework's own leak rule +(``automil.firewall``) looks for them, and error text is redacted line by line. +""" +from __future__ import annotations + +from typing import Any, Iterable, Mapping + +from automil.firewall import is_held_out_metric_key, redact_held_out +from automil.graph import ( + KEEP_CLASS, + effective_accept_margin, + guard_basis, + keep_or_discard, + margin_se_basis, + node_primary_se, +) +from automil.viz.clock import HostClock, to_utc_iso + +RECORD_SCHEMA = 1 +_SEALED_BLOCKS = ("held_out", "summary") +_JUDGED = frozenset(KEEP_CLASS | {"discard"}) + + +def project_metrics(metrics: object) -> dict[str, Any]: + """A metrics block without any held-out-named key.""" + if not isinstance(metrics, Mapping): + return {} + return {str(k): v for k, v in metrics.items() if not is_held_out_metric_key(str(k))} + + +def _project_folds(folds: object) -> list[Any] | None: + if not isinstance(folds, list): + return None + out = [] + for entry in folds: + if isinstance(entry, Mapping): + clean = {k: v for k, v in entry.items() if k != "metrics"} + if "metrics" in entry: + clean["metrics"] = project_metrics(entry.get("metrics")) + out.append(clean) + else: + out.append(entry) + return out + + +def project_node(node: Mapping[str, Any], clock: HostClock) -> dict[str, Any]: + """One graph node, validation-only, with stamps in UTC and a gate-child flag.""" + metadata = node.get("metadata") if isinstance(node.get("metadata"), Mapping) else {} + clean_meta = {k: v for k, v in metadata.items() if k not in _SEALED_BLOCKS} + if "validation_folds" in metadata: + clean_meta["validation_folds"] = _project_folds(metadata.get("validation_folds")) + projected = {k: v for k, v in node.items() if k not in _SEALED_BLOCKS} + projected["metrics"] = project_metrics(node.get("metrics")) + projected["metadata"] = clean_meta + projected["gate_child"] = bool(metadata.get("held_out", False)) + projected["created_at"] = to_utc_iso(node.get("created_at"), clock) + if isinstance(node.get("error"), str): + projected["error"] = redact_held_out(node["error"]) + return projected + + +def running_node_ids(gpu_state: Mapping[str, Any] | None) -> tuple[str, ...]: + """Node ids the orchestrator reports in flight (typed slots, legacy ``gpus``).""" + if not isinstance(gpu_state, Mapping): + return () + slots = gpu_state.get("execution_slots") + if not isinstance(slots, Mapping): + slots = gpu_state.get("gpus") if isinstance(gpu_state.get("gpus"), Mapping) else {} + ids: set[str] = set() + for slot in slots.values(): + if isinstance(slot, Mapping): + ids.update(str(n) for n in slot.get("running") or [] if isinstance(n, str)) + return tuple(sorted(ids)) + + +def project_graph( + raw: Mapping[str, Any], clock: HostClock, running: Iterable[str] = () +) -> dict[str, Any]: + """The graph the dashboard sees: projected nodes plus the running overlay.""" + running_ids = tuple(sorted(set(running))) + nodes_in = raw.get("nodes") if isinstance(raw.get("nodes"), Mapping) else {} + nodes = {} + for node_id, node in nodes_in.items(): + if not isinstance(node, Mapping): + continue + projected = project_node(node, clock) + if node_id in running_ids: + projected["status"] = "running" + nodes[str(node_id)] = projected + return { + "schema": RECORD_SCHEMA, + "nodes": nodes, + "meta": dict(raw.get("meta") or {}), + "technique_stats": dict(raw.get("technique_stats") or {}), + "running": list(running_ids), + } + + +def project_result(result: Mapping[str, Any] | None, clock: HostClock) -> dict[str, Any] | None: + """A terminal result (``completed/<node>.json`` or ``result.json``), val-only.""" + if not isinstance(result, Mapping): + return None + projected = {k: v for k, v in result.items() if k not in _SEALED_BLOCKS} + projected["metrics"] = project_metrics(result.get("metrics")) + if "validation_folds" in result: + projected["validation_folds"] = _project_folds(result.get("validation_folds")) + if isinstance(result.get("error"), str): + projected["error"] = redact_held_out(result["error"]) + if "completed_at" in result: + projected["completed_at"] = to_utc_iso(result.get("completed_at"), clock) + return projected + + +def _number(value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return 0.0 + return float(value) + + +def verdict_unavailable(node: Mapping[str, Any]) -> str | None: + """Why no keep/discard verdict applies to this node, or ``None`` if one does.""" + if node.get("type") != "executed": + return "not executed" + status = node.get("status") + if status in _JUDGED: + return None + if status in ("partial", "crash", "cancelled", "oom", "timeout"): + return str(status) + return "not terminal" + + +def node_verdict( + meta: Mapping[str, Any] | None, + parent: Mapping[str, Any] | None, + node: Mapping[str, Any], +) -> dict[str, Any] | None: + """The keep/discard decision, reproduced with the graph's own helpers. + + Mirrors what ``automil rank`` prints: the bar is + ``max(δ, k × SE)`` with the paired SE when both nodes carry the same folds, + and the companion guard can only veto. ``None`` when no verdict applies. + """ + if verdict_unavailable(node) is not None: + return None + meta = dict(meta or {}) + scoring = meta.get("scoring") if isinstance(meta.get("scoring"), Mapping) else {} + decision = keep_or_discard(meta, dict(parent) if parent else None, dict(node)) + stored = str(node.get("status")) + stored_class = "keep" if stored in KEEP_CLASS else "discard" + child_value = _number(node.get("primary_value")) + if parent is None: + explanation = ( + f"Root node: kept because its value {child_value:.4f} is above zero." + if decision == "keep" else "Root node: discarded, its value is not above zero." + ) + return { + "decision": decision, + "stored_status": stored, + "consistent": decision == stored_class, + "parent_id": None, + "parent_primary_value": None, + "child_primary_value": child_value, + "delta": None, + "bar": None, + "accept_margin": _number(scoring.get("accept_margin")), + "se_multiplier": _number(scoring.get("se_multiplier", 1.0)), + "basis": "none", + "basis_se": None, + "guard": {"verdict": "none", "delta": None, "metric": None, "margin": None, "decisive": False}, + "explanation": explanation, + } + parent_value = _number(parent.get("primary_value")) + delta = child_value - parent_value + basis, basis_se = margin_se_basis(meta, dict(parent), dict(node)) + bar = effective_accept_margin(meta, dict(parent), dict(node)) + g_verdict, g_delta, g_metric, g_margin = guard_basis(meta, dict(parent), dict(node)) + decisive = g_verdict == "fail" and delta > bar + se_text = { + "paired": f"paired SE {basis_se:.4f}" if basis_se is not None else "paired SE unavailable", + "marginal": f"marginal SE {basis_se:.4f}" if basis_se is not None else "marginal SE unavailable", + "none": "no SE", + }[basis] + if delta > bar: + head = f"{delta:+.4f} over the parent, above the bar of {bar:.4f} ({se_text}, floor {_number(scoring.get('accept_margin')):.4f})." + else: + head = f"{delta:+.4f} over the parent, not above the bar of {bar:.4f} ({se_text}, floor {_number(scoring.get('accept_margin')):.4f})." + if g_verdict == "none": + tail = "" + else: + g_value = f"{g_delta:+.4f}" if g_delta is not None else "unreported" + g_bar = f" against a bar of {g_margin:.4f}" if g_margin is not None else "" + tail = f" Guard {g_metric or 'companion'} {g_value}{g_bar}: {g_verdict}." + if decisive: + tail += " The guard decided." + return { + "decision": decision, + "stored_status": stored, + "consistent": decision == stored_class, + "parent_id": parent.get("id"), + "parent_primary_value": parent_value, + "child_primary_value": child_value, + "delta": delta, + "bar": bar, + "accept_margin": _number(scoring.get("accept_margin")), + "se_multiplier": _number(scoring.get("se_multiplier", 1.0)), + "basis": basis, + "basis_se": basis_se, + "child_se": node_primary_se(dict(node)), + "guard": {"verdict": g_verdict, "delta": g_delta, "metric": g_metric, "margin": g_margin, "decisive": decisive}, + "explanation": ("Kept: " if decision == "keep" else "Discarded: ") + head + tail, + } + + +def walk_keys(payload: object, path: str = "") -> Iterable[tuple[str, str]]: + """Every (path, key) pair in a nested payload; used by the firewall tests.""" + if isinstance(payload, Mapping): + for key, value in payload.items(): + here = f"{path}.{key}" if path else str(key) + yield here, str(key) + yield from walk_keys(value, here) + elif isinstance(payload, (list, tuple)): + for index, value in enumerate(payload): + yield from walk_keys(value, f"{path}[{index}]") diff --git a/src/automil/viz/record_routes.py b/src/automil/viz/record_routes.py new file mode 100644 index 00000000..75348bc1 --- /dev/null +++ b/src/automil/viz/record_routes.py @@ -0,0 +1,152 @@ +"""HTTP routes for the record, and the CORS policy for a hosted site. + +The live server answers the same paths the export writes, so one frontend +reads both. Cross-origin reads are allowed only from the listed site origins +and from loopback dev origins; the dashboard binds loopback and has no auth, +so a wildcard would let any open tab read the record through the tunnel. +""" +from __future__ import annotations + +from typing import Any, Callable, Iterable +from urllib.parse import urlsplit + +from aiohttp import web + +RECORD_PREFIX = "/record" +DEFAULT_CORS_ORIGINS = ("https://automil.org", "https://www.automil.org") +_RUN = r"[A-Za-z0-9._-]{1,120}" +_NODE = r"node_\d{4,}" +_SESSION = r"[0-9a-f-]{8,64}" +_TOKEN = r"[A-Za-z0-9_-]{1,120}" +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]"}) + + +def origin_allowed(origin: str | None, allowed: Iterable[str]) -> bool: + """Listed origins exactly; any loopback origin on any port.""" + if not origin: + return False + if origin in set(allowed): + return True + parts = urlsplit(origin) + return parts.scheme in ("http", "https") and parts.hostname in _LOOPBACK_HOSTS + + +def cors_headers(request: web.Request, allowed: Iterable[str]) -> dict[str, str]: + """The CORS headers a record or events response gets for this request's origin.""" + origin = request.headers.get("Origin") + if not origin or not origin_allowed(origin, allowed): + return {} + if not (request.path.startswith(RECORD_PREFIX) or request.path == "/events"): + return {} + return { + "Access-Control-Allow-Origin": origin, + "Vary": "Origin", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Cache-Control, Last-Event-ID", + "Access-Control-Allow-Private-Network": "true", + "Access-Control-Max-Age": "3600", + } + + +def cors_middleware(allowed: Iterable[str]) -> Callable[..., Any]: + allowed = tuple(allowed) + + @web.middleware + async def middleware(request: web.Request, handler: Callable[..., Any]) -> web.StreamResponse: + if request.method == "OPTIONS": + response: web.StreamResponse = web.Response(status=204) + else: + response = await handler(request) + # A streaming response (SSE) has already sent its headers: the handler + # merged them itself through cors_headers before preparing. + if not response.prepared: + for name, value in cors_headers(request, allowed).items(): + response.headers[name] = value + return response + + return middleware + + +def _source(request: web.Request, run_id: str): + source = request.app.get("run_source") + if source is None or source.run_id != run_id: + raise web.HTTPNotFound() + return source + + +def _json(payload: Any) -> web.Response: + if payload is None: + raise web.HTTPNotFound() + response = web.json_response(payload) + response.headers["Cache-Control"] = "no-store, must-revalidate" + return response + + +async def index_json(request: web.Request) -> web.Response: + from automil.viz.record import build_index + + source = request.app.get("run_source") + return _json(build_index([source] if source else [], "live")) + + +async def run_file(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + name = request.match_info["name"] + builders = { + "graph": source.build_graph, + "timeline": source.build_timeline, + "sessions": source.build_sessions, + "agent_links": source.build_links, + "notes": source.build_notes, + "certified": source.build_certified, + } + builder = builders.get(name) + if builder is None: + raise web.HTTPNotFound() + return _json(builder()) + + +async def node_json(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + return _json(source.build_node(request.match_info["node"])) + + +async def node_file(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + rel = request.match_info["path"] + if not rel.endswith(".json"): + raise web.HTTPNotFound() + return _json(source.build_overlay_file(request.match_info["node"], rel[: -len(".json")])) + + +async def turns_chunk(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + return _json(source.build_chunk(request.match_info["session"], int(request.match_info["chunk"]))) + + +async def full_result(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + return _json(source.build_full_result(request.match_info["session"], request.match_info["tool"])) + + +async def agent_json(request: web.Request) -> web.Response: + source = _source(request, request.match_info["run"]) + return _json(source.build_agent(request.match_info["session"], request.match_info["agent"])) + + +def register_record_routes(app: web.Application) -> None: + p = RECORD_PREFIX + app.router.add_get(f"{p}/index.json", index_json) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/{{name:[a-z_]+}}.json", run_file) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/nodes/{{node:{_NODE}}}.json", node_json) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/nodes/{{node:{_NODE}}}/files/{{path:.+}}", node_file) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/sessions/{{session:{_SESSION}}}/turns/{{chunk:\\d+}}.json", turns_chunk) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/sessions/{{session:{_SESSION}}}/results/{{tool:{_TOKEN}}}.json", full_result) + app.router.add_get(f"{p}/runs/{{run:{_RUN}}}/sessions/{{session:{_SESSION}}}/agents/{{agent:{_TOKEN}}}.json", agent_json) + app.router.add_route("OPTIONS", f"{p}/{{tail:.*}}", _preflight) + app.router.add_route("OPTIONS", "/events", _preflight) + + +async def _preflight(request: web.Request) -> web.Response: + return web.Response(status=204) + diff --git a/src/automil/viz/record_sessions.py b/src/automil/viz/record_sessions.py new file mode 100644 index 00000000..31a72d0d --- /dev/null +++ b/src/automil/viz/record_sessions.py @@ -0,0 +1,252 @@ +"""Which sessions a run has, their turns in chunks, and node-to-turn links. + +A run's sessions are the journaled ones (``.activity.jsonl``) joined with the +stored records under ``automil/sessions``. A session still open in the journal +is read from the runtime's own file so the dashboard can follow it live. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Literal, Mapping + +from automil.cells.activity import ActivityError, JournalSession, journal_sessions +from automil.session_record import locate_home_transcript, read_stored_sessions +from automil.viz.clock import HostClock, to_utc_iso +from automil.viz.record_files import RunPaths +from automil.viz.subagents import SubagentTranscript +from automil.viz.transcript import ParsedTranscript + +TURN_CHUNK_SIZE = 100 + + +@dataclass(frozen=True) +class SessionSource: + session_id: str + transcript: Path | None + sidecar: Path | None + origin: Literal["stored", "home", "missing"] + live: bool + journal: JournalSession | None + + +def discover_sessions(paths: RunPaths, *, config_dir: Path | None = None) -> tuple[tuple[SessionSource, ...], str | None]: + """Journaled sessions joined with stored records; returns (sources, journal error).""" + error = None + try: + journaled = journal_sessions(paths.automil_dir) + except ActivityError as exc: + journaled = () + error = str(exc) + stored = {record.session_id: record for record in read_stored_sessions(paths.automil_dir)} + sources: list[SessionSource] = [] + seen: set[str] = set() + for session in journaled: + seen.add(session.session_id) + live = session.ended_at is None + record = stored.get(session.session_id) + home = locate_home_transcript(session.session_id, config_dir=config_dir) if live or record is None else None + if live and home is not None: + sources.append(SessionSource(session.session_id, home, home.with_suffix(""), "home", True, session)) + elif record is not None: + sources.append(SessionSource(session.session_id, record.transcript, record.sidecar, "stored", live, session)) + elif home is not None: + sources.append(SessionSource(session.session_id, home, home.with_suffix(""), "home", live, session)) + else: + sources.append(SessionSource(session.session_id, None, None, "missing", live, session)) + for session_id, record in stored.items(): + if session_id not in seen: + sources.append(SessionSource(session_id, record.transcript, record.sidecar, "stored", False, None)) + return tuple(sources), error + + +def chunk_turns(turns: Iterable[Mapping[str, Any]], size: int = TURN_CHUNK_SIZE) -> list[list[Mapping[str, Any]]]: + items = list(turns) + return [items[i:i + size] for i in range(0, len(items), size)] or [] + + +def _usage_total(turns: Iterable[Mapping[str, Any]]) -> dict[str, int]: + total = {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0} + for turn in turns: + usage = turn.get("usage") or {} + for key in total: + value = usage.get(key) + if isinstance(value, int): + total[key] += value + return total + + +def _epoch_iso(value: float | None, clock: HostClock) -> str | None: + return to_utc_iso(value, clock) if value is not None else None + + +def session_summary( + source: SessionSource, + parsed: ParsedTranscript | None, + subagents: tuple[SubagentTranscript, ...], + clock: HostClock, + *, + active_seconds: float | None = None, +) -> dict[str, Any]: + """One entry of ``sessions.json``.""" + turns = parsed.turns if parsed else () + calls = [call for turn in turns for call in turn.get("tool_calls", ())] + journal = source.journal + stamps = [t["at"] for t in turns if t.get("at")] + return { + "session_id": source.session_id, + "cell_id": journal.cell_id if journal else None, + "opened_at": _epoch_iso(journal.opened_at, clock) if journal else None, + "ended_at": _epoch_iso(journal.ended_at, clock) if journal else None, + "ended_by": journal.ended_by if journal else None, + "source": source.origin, + "live": source.live, + "n_turns": len(turns), + "n_chunks": len(chunk_turns(turns)), + "first_at": min(stamps) if stamps else None, + "last_at": max(stamps) if stamps else None, + "models": list(parsed.models) if parsed else [], + "usage": _usage_total(turns), + "n_tool_calls": len(calls), + "n_automil_commands": sum(1 for call in calls if call.get("automil")), + "n_prompts": sum(1 for t in turns if t.get("kind") == "human"), + "active_seconds": active_seconds, + "cwd": parsed.cwd if parsed else None, + "subagents": [ + { + "agent_id": s.agent_id, + "tool_use_id": s.tool_use_id, + "agent_type": s.agent_type, + "description": s.description, + "n_turns": len(s.turns), + } + for s in subagents + ], + "stats": parsed.stats.as_dict() if parsed else None, + } + + +def _parse_stamp(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError: + return None + + +def _call_window(turn: Mapping[str, Any], call: Mapping[str, Any], next_turn_at: str | None) -> tuple[datetime | None, datetime | None]: + start = _parse_stamp(turn.get("at")) + result = call.get("result") or {} + end = _parse_stamp(result.get("at")) or _parse_stamp(next_turn_at) or _parse_stamp(turn.get("at_end")) + return start, end + + +def build_links( + session_id: str, + turns: Iterable[Mapping[str, Any]], + submitted_at: Mapping[str, str | None] | None = None, +) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[str]]]: + """Node -> turn links for one session, and the reverse map. + + Kinds: ``propose``/``submit``/``resubmit`` when a call's output reported + the node created; ``submit`` also when the id is on the command line or + when the node's ``submitted_at`` falls inside a submit call's window (the + agent often submits in shell loops whose ids never appear literally); + ``mention`` for ids on any other subcommand's command line. + """ + ordered = list(turns) + by_node: dict[str, list[dict[str, Any]]] = {} + by_turn: dict[str, list[str]] = {} + submitted = dict(submitted_at or {}) + + def add(node_id: str, turn_index: int, kind: str, at: str | None) -> None: + entry = {"session_id": session_id, "turn": turn_index, "kind": kind, "at": at} + bucket = by_node.setdefault(node_id, []) + if not any(e["turn"] == turn_index and e["kind"] == kind for e in bucket): + bucket.append(entry) + key = f"{session_id}:{turn_index}" + if node_id not in by_turn.setdefault(key, []): + by_turn[key].append(node_id) + + for position, turn in enumerate(ordered): + next_at = ordered[position + 1].get("at") if position + 1 < len(ordered) else None + for call in turn.get("tool_calls", ()): + automil = call.get("automil") + if not automil: + continue + at = turn.get("at") + for created in automil.get("created", ()): + add(created["node_id"], turn["index"], created["sub"], at) + subs = {c.get("sub") for c in automil.get("calls", ())} + for sub_call in automil.get("calls", ()): + kind = "submit" if sub_call.get("sub") == "submit" else "mention" + for node_id in sub_call.get("node_ids", ()): + add(node_id, turn["index"], kind, at) + if "submit" in subs and submitted: + start, end = _call_window(turn, call, next_at) + if start and end: + for node_id, stamp in submitted.items(): + moment = _parse_stamp(stamp) + if moment and start <= moment <= end: + add(node_id, turn["index"], "submit", at) + for bucket in by_node.values(): + bucket.sort(key=lambda e: (e["turn"], e["kind"])) + for ids in by_turn.values(): + ids.sort() + return by_node, by_turn + + +def agent_events(session_id: str, turns: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Dated timeline events from one session: creations, prompts, notifications, compactions.""" + events: list[dict[str, Any]] = [] + for turn in turns: + at = turn.get("at") + kind = turn.get("kind") + if kind == "human": + events.append({"at": at, "kind": "prompt", "session_id": session_id, "turn": turn["index"], "node_id": None, "label": (turn.get("text") or "")[:80]}) + elif kind == "notification": + note = turn.get("notification") or {} + events.append({"at": at, "kind": "notification", "session_id": session_id, "turn": turn["index"], "node_id": None, "label": note.get("title") or ""}) + elif kind == "system" and (turn.get("system") or {}).get("subtype") == "compact_boundary": + events.append({"at": at, "kind": "compact", "session_id": session_id, "turn": turn["index"], "node_id": None, "label": "context compacted"}) + for call in turn.get("tool_calls", ()): + automil = call.get("automil") + if not automil: + continue + for created in automil.get("created", ()): + events.append({ + "at": at, "kind": created["sub"], "session_id": session_id, "turn": turn["index"], + "node_id": created["node_id"], "label": created["node_id"], + }) + for sub_call in automil.get("calls", ()): + if sub_call.get("sub") in ("reconcile", "rank"): + events.append({"at": at, "kind": sub_call["sub"], "session_id": session_id, "turn": turn["index"], "node_id": None, "label": sub_call["sub"]}) + return events + + +def session_spans(sources: Iterable[SessionSource], clock: HostClock) -> list[dict[str, Any]]: + spans = [] + for source in sources: + journal = source.journal + spans.append({ + "session_id": source.session_id, + "cell_id": journal.cell_id if journal else None, + "opened_at": _epoch_iso(journal.opened_at, clock) if journal else None, + "ended_at": _epoch_iso(journal.ended_at, clock) if journal else None, + "live": source.live, + }) + return spans + + +def session_journal_events(sources: Iterable[SessionSource], clock: HostClock) -> list[dict[str, Any]]: + events = [] + for source in sources: + journal = source.journal + if journal is None: + continue + events.append({"at": _epoch_iso(journal.opened_at, clock), "kind": "session_open", "session_id": source.session_id, "turn": None, "node_id": None, "label": "session opened"}) + if journal.ended_at is not None: + events.append({"at": _epoch_iso(journal.ended_at, clock), "kind": "session_end", "session_id": source.session_id, "turn": None, "node_id": None, "label": f"session ended ({journal.ended_by})"}) + return events diff --git a/src/automil/viz/record_timeline.py b/src/automil/viz/record_timeline.py new file mode 100644 index 00000000..fc79239a --- /dev/null +++ b/src/automil/viz/record_timeline.py @@ -0,0 +1,136 @@ +"""When each node was submitted, launched and finished, and the run's event list. + +The orchestrator log is the only record of launch times; ``spec.json`` holds +the submission time and ``completed/<node>.json`` the finish time. Every stamp +leaves here in UTC. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from automil.viz.clock import HostClock, parse_log_asctime, to_utc_iso + +_LAUNCHED = re.compile( + r"^(?P<stamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[INFO\] Launched " + r"(?P<node>node_\d{4,}) on (?P<slot>.+?) \(PID" +) +_COMPLETED = re.compile( + r"^(?P<stamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[INFO\] Completed " + r"(?P<node>node_\d{4,}): status=(?P<status>[A-Za-z_]+), .*?, (?P<slot>[^,]+)$" +) +_GPU_SLOT = re.compile(r"^(?P<kind>[A-Za-z]+) GPU (?P<index>\d+)$") + + +@dataclass(frozen=True) +class LogEvent: + at: str + kind: str + node_id: str + slot: str | None + status: str | None = None + + +def normalize_slot(text: str | None) -> str | None: + """``CUDA GPU 0`` (log form) and ``cuda:0`` (state form) become ``cuda:0``.""" + if not text: + return None + match = _GPU_SLOT.match(text.strip()) + if match: + return f"{match['kind'].lower()}:{match['index']}" + return text.strip() + + +def parse_orchestrator_log(lines: Iterable[str], clock: HostClock) -> tuple[LogEvent, ...]: + """Launch and completion events from the daemon's log, in file order.""" + events = [] + for line in lines: + launched = _LAUNCHED.match(line) + if launched: + stamp = parse_log_asctime(launched["stamp"]) + at = to_utc_iso(stamp.isoformat(), clock) if stamp else None + if at: + events.append(LogEvent(at, "launched", launched["node"], normalize_slot(launched["slot"]))) + continue + completed = _COMPLETED.match(line) + if completed: + stamp = parse_log_asctime(completed["stamp"]) + at = to_utc_iso(stamp.isoformat(), clock) if stamp else None + if at: + events.append(LogEvent(at, "completed", completed["node"], normalize_slot(completed["slot"]), completed["status"])) + return tuple(events) + + +def _seconds_between(start: str | None, end: str | None) -> float | None: + from datetime import datetime + + if not start or not end: + return None + try: + a = datetime.fromisoformat(start.replace("Z", "+00:00")) + b = datetime.fromisoformat(end.replace("Z", "+00:00")) + except ValueError: + return None + return round((b - a).total_seconds(), 3) + + +def node_timing( + node_id: str, + *, + spec: Mapping[str, Any] | None, + completed: Mapping[str, Any] | None, + log_events: Iterable[LogEvent], + clock: HostClock, +) -> dict[str, Any]: + """Submission, launch and finish stamps for one node, with derived waits.""" + submitted_at = to_utc_iso(spec.get("submitted_at"), clock) if isinstance(spec, Mapping) else None + launched_at = None + finished_at = None + slot = None + for event in log_events: + if event.node_id != node_id: + continue + if event.kind == "launched" and launched_at is None: + launched_at, slot = event.at, event.slot + elif event.kind == "completed": + finished_at = event.at + slot = slot or event.slot + if isinstance(completed, Mapping): + completed_at = to_utc_iso(completed.get("completed_at"), clock) + if completed_at: + finished_at = completed_at + gpu = completed.get("gpu") + accelerator = completed.get("accelerator") + if slot is None and isinstance(gpu, int) and gpu >= 0 and isinstance(accelerator, str): + slot = f"{accelerator}:{gpu}" + return { + "submitted_at": submitted_at, + "launched_at": launched_at, + "completed_at": finished_at, + "slot": slot, + "queue_wait_s": _seconds_between(submitted_at, launched_at), + "run_s": _seconds_between(launched_at, finished_at), + } + + +def build_timeline( + *, + run_id: str, + clock: HostClock, + nodes: Iterable[Mapping[str, Any]], + sessions: Iterable[Mapping[str, Any]], + events: Iterable[Mapping[str, Any]], + warnings: Iterable[str] = (), +) -> dict[str, Any]: + """The record's timeline file: nodes, session spans and dated events.""" + ordered = sorted(events, key=lambda e: (e.get("at") or "", e.get("kind") or "")) + return { + "schema": 1, + "run_id": run_id, + "clock": clock.as_dict(), + "nodes": list(nodes), + "sessions": list(sessions), + "events": ordered, + "warnings": list(warnings), + } diff --git a/src/automil/viz/server.py b/src/automil/viz/server.py index 69fc85fc..107a399b 100644 --- a/src/automil/viz/server.py +++ b/src/automil/viz/server.py @@ -32,6 +32,8 @@ print("watchdog required: uv add watchdog") sys.exit(1) +from automil.viz.record_routes import DEFAULT_CORS_ORIGINS, cors_headers, cors_middleware, register_record_routes + VIZ_DIR = Path(__file__).parent STATIC_DIR = VIZ_DIR / "static" @@ -116,10 +118,24 @@ def __init__(self): self.subscribers: list[asyncio.Queue] = [] self._prev_data: dict | None = None self._loop: asyncio.AbstractEventLoop | None = None + self._clock = None def set_loop(self, loop: asyncio.AbstractEventLoop): self._loop = loop + def set_clock(self, clock) -> None: + """The run host's clock; frames carry the projected, UTC-stamped graph.""" + self._clock = clock + + def _project(self, data: dict) -> dict: + """The validation-only graph the frontend reads (see record_graph).""" + from automil.viz.clock import host_clock + from automil.viz.record_graph import project_graph + + clock = self._clock or host_clock() + running = [nid for nid, node in data.get("nodes", {}).items() if node.get("status") == "running"] + return project_graph(data, clock, running) + def _maybe_notify(self, path: str): name = Path(path).name if name in (GRAPH_FILE.name, GPU_STATE_FILE.name) and self._loop: @@ -169,6 +185,7 @@ async def _notify(self): return self._overlay_running_status(data) + data = self._project(data) changed, added, removed = [], [], [] meta_changed = False @@ -212,7 +229,9 @@ async def get_initial(self) -> str: data = await _read_graph_json(GRAPH_FILE) if data is None: data = {"nodes": {}, "meta": {}, "technique_stats": {}} - self._overlay_running_status(data) + else: + self._overlay_running_status(data) + data = self._project(data) self._prev_data = data return json.dumps({ "type": "graph_update", @@ -226,15 +245,15 @@ async def get_initial(self) -> str: async def sse_handler(request): - response = web.StreamResponse( - status=200, - headers={ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - }, - ) + headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + # The stream sends its headers now, so the cross-origin grant (a hosted + # site reading this host through a tunnel) has to be decided here. + headers.update(cors_headers(request, request.app.get("cors_origins", DEFAULT_CORS_ORIGINS))) + response = web.StreamResponse(status=200, headers=headers) await response.prepare(request) initial = await watcher.get_initial() @@ -271,6 +290,13 @@ async def index_handler(request): return web.FileResponse(STATIC_DIR / "index.html") +async def spa_fallback(request): + """Any path the router does not know is a frontend route: serve the page.""" + if request.path.startswith(("/static/", "/record/", "/api/")) or request.path == "/events": + raise web.HTTPNotFound() + return web.FileResponse(STATIC_DIR / "index.html") + + async def promotion_rate_handler(request): """GTE-06 / D-144: serve current promotion_rate + gate-health diagnostic. @@ -321,12 +347,22 @@ async def _no_cache_static(request, handler): return response -def create_app() -> web.Application: - app = web.Application(middlewares=[_no_cache_static]) +def create_app(run_source=None, cors_origins=DEFAULT_CORS_ORIGINS) -> web.Application: + """The dashboard application: page, record routes, SSE, and the gate stats. + + ``run_source`` is the project's :class:`automil.viz.record.RunSource`; + without one the record routes answer 404 (the promotion-rate route and the + page still work, which is what the older tests exercise). + """ + app = web.Application(middlewares=[cors_middleware(cors_origins), _no_cache_static]) + app["run_source"] = run_source + app["cors_origins"] = tuple(cors_origins) app.router.add_get("/", index_handler) app.router.add_get("/events", sse_handler) app.router.add_get("/api/promotion-rate", promotion_rate_handler) + register_record_routes(app) app.router.add_static("/static", STATIC_DIR) + app.router.add_get("/{tail:.*}", spa_fallback) app.on_shutdown.append(_on_shutdown) return app @@ -335,6 +371,7 @@ def cmd_start( port: int | None = None, project_root: Path | None = None, host: str | None = None, + tz_name: str | None = None, ): global GRAPH_FILE, GPU_STATE_FILE, PID_FILE, LOG_FILE if project_root is None: @@ -354,22 +391,26 @@ def cmd_start( # opts in explicitly. The SSE stream and gpu_state.json carry PIDs, # GPU utilization, and node descriptions; on a shared workstation those # should not be browseable by every host on the subnet. + cfg_loaded: dict = {} + config_path = automil_dir / "config.yaml" + if config_path.exists(): + try: + import yaml as _yaml # noqa: PLC0415 + cfg_loaded = _yaml.safe_load(config_path.read_text()) or {} + except Exception: + cfg_loaded = {} + viz_cfg = cfg_loaded.get("viz") if isinstance(cfg_loaded.get("viz"), dict) else {} + raw_origins = viz_cfg.get("cors_origins") + cors_origins = tuple(str(o) for o in raw_origins) if isinstance(raw_origins, list) else DEFAULT_CORS_ORIGINS + if host is None or port is None: - cfg_loaded: dict = {} - config_path = automil_dir / "config.yaml" - if config_path.exists(): - try: - import yaml as _yaml # noqa: PLC0415 - cfg_loaded = _yaml.safe_load(config_path.read_text()) or {} - except Exception: - cfg_loaded = {} if host is None: - cfg_host: str | None = (cfg_loaded.get("viz") or {}).get("host") + cfg_host: str | None = viz_cfg.get("host") host = cfg_host or os.environ.get("AUTOMIL_VIZ_HOST") or "127.0.0.1" if port is None: - raw_port = (cfg_loaded.get("viz") or {}).get("port") + raw_port = viz_cfg.get("port") try: port = int(raw_port) if raw_port is not None else DEFAULT_PORT except (TypeError, ValueError): @@ -398,8 +439,22 @@ def cmd_start( if orch_dir.exists(): observer.schedule(watcher, str(orch_dir), recursive=False) + from automil.viz.clock import host_clock # noqa: PLC0415 + from automil.viz.hostinfo import start_banner # noqa: PLC0415 + from automil.viz.record import RunSource # noqa: PLC0415 + + try: + clock = host_clock(tz_name=tz_name) + except ValueError as exc: + print(f"error: {exc}") + return + watcher.set_clock(clock) + run_source = RunSource(automil_dir, clock) + async def run_server(): - app = create_app() + from automil.viz.live import LiveRecord # noqa: PLC0415 + + app = create_app(run_source=run_source, cors_origins=cors_origins) runner = web.AppRunner(app) await runner.setup() try: @@ -418,6 +473,10 @@ async def run_server(): "intended.", host, port, ) logging.info(f"Viz server running on http://{host}:{port}") + print(start_banner(host, port), flush=True) + + live = LiveRecord(run_source, watcher.subscribers) + live_task = asyncio.ensure_future(live.run()) # Wait for shutdown signal stop_event = asyncio.Event() @@ -427,6 +486,7 @@ async def run_server(): await stop_event.wait() logging.info("Shutting down...") + live_task.cancel() finally: await runner.cleanup() diff --git a/src/automil/viz/static/css/base.css b/src/automil/viz/static/css/base.css new file mode 100644 index 00000000..c67cfd11 --- /dev/null +++ b/src/automil/viz/static/css/base.css @@ -0,0 +1,421 @@ +/* Base: reset, type, links, code, tables, buttons, forms. */ + +*, +*::before, +*::after { + box-sizing: border-box; +} +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: var(--fs-base); + line-height: var(--lh); + font-weight: 500; + font-feature-settings: "kern", "liga"; + overflow-x: hidden; +} +h1, +h2, +h3, +h4 { + margin: 0 0 var(--s-3); + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.03em; +} +h1 { + font-size: var(--fs-xl); +} +h2 { + font-size: var(--fs-lg); +} +h3 { + font-size: var(--fs-md); + letter-spacing: -0.02em; +} +h4 { + font-size: var(--fs-base); + letter-spacing: -0.01em; +} +p { + margin: 0 0 var(--s-4); + max-width: var(--measure); +} +.lead { + font-size: var(--fs-lead); + color: var(--ink-2); + line-height: 1.5; +} +.label { + font-family: var(--mono); + font-size: var(--fs-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +a { + color: var(--ink); + text-decoration: none; + border-bottom: 1px solid transparent; +} +a:hover { + color: var(--accent-ink); +} +small, +.small { + font-size: var(--fs-sm); +} +.muted { + color: var(--muted); +} +.faint { + color: var(--faint); +} +code, +pre, +.mono { + font-family: var(--mono); + font-size: 0.88em; +} +code { + background: var(--paper-2); + padding: 1px 5px; +} +pre { + margin: 0; + padding: var(--s-4) var(--s-5); + background: var(--paper-2); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; +} +pre code { + background: none; + padding: 0; + font-size: inherit; +} +hr { + border: 0; + border-top: 1px solid var(--rule); + margin: var(--s-6) 0; +} +ul, +ol { + padding-left: 1.3em; + margin: 0 0 var(--s-4); +} +li { + margin-bottom: var(--s-1); +} +table { + border-collapse: collapse; + width: 100%; + font-size: var(--fs-sm); +} +th, +td { + text-align: left; + padding: 10px var(--s-3); + border-bottom: 1px solid var(--rule-2); + vertical-align: top; +} +th { + font-family: var(--mono); + font-weight: 500; + font-size: var(--fs-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; + border-bottom: 1px solid var(--rule); +} +td.num, +th.num { + text-align: right; + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} +tr.clickable { + cursor: pointer; +} +tr.clickable:hover td { + background: var(--paper-2); +} +tr.selected td { + background: var(--accent-tint); +} +button, +.button { + font: inherit; + font-size: var(--fs-sm); + font-weight: 700; + color: var(--ink); + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 0; + padding: 10px 18px; + cursor: pointer; + line-height: 1.3; + transition: background 0.12s ease, color 0.12s ease, transform 0.08s ease; +} +button:hover, +.button:hover { + background: var(--paper-2); + color: var(--ink); +} +button:active { + transform: translateY(1px); +} +button.primary, +.button.primary { + background: var(--ink); + border-color: var(--ink); + color: var(--paper); +} +button.primary:hover, +.button.primary:hover { + background: var(--accent-ink); + border-color: var(--accent-ink); + color: #fff; +} +.button.large, +button.large { + font-size: var(--fs-base); + padding: 13px 24px; +} +button.quiet { + border-color: transparent; + background: transparent; + color: var(--muted); + padding: 6px 10px; + font-weight: 500; +} +button.quiet:hover { + color: var(--ink); + border-color: transparent; + background: var(--paper-2); +} +button[disabled] { + opacity: 0.5; + cursor: default; +} +.segmented { + display: inline-flex; + border: 1px solid var(--rule); +} +.segmented button { + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + padding: 5px 12px; + font-family: var(--mono); + font-size: var(--fs-xs); + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.segmented button.active { + background: var(--ink); + color: var(--paper); +} +input[type="text"], +input[type="url"], +select { + font: inherit; + font-size: var(--fs-sm); + color: var(--ink); + background: var(--paper); + border: 1px solid var(--rule); + border-radius: 0; + padding: 10px 12px; +} +input:focus, +select:focus, +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +label { + font-size: var(--fs-sm); + color: var(--muted); +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} +.tag { + display: inline-block; + font-size: var(--fs-xs); + font-family: var(--mono); + padding: 2px 7px; + border: 1px solid var(--rule); + color: var(--ink); + background: var(--paper); + white-space: nowrap; +} +.tag.link { + cursor: pointer; + border-color: var(--accent); + color: var(--accent-ink); +} +.tag.link:hover { + background: var(--accent-tint); +} +.status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 6px; + vertical-align: -1px; + border: 1px solid transparent; +} +.status-keep { + background: var(--status-keep); + border-color: var(--ink); +} +.status-candidate, +.status-registered { + background: var(--status-gate); + border-color: var(--ink); +} +.status-discard { + background: var(--status-discard); + border-color: var(--status-discard-stroke); +} +.status-pending { + background: var(--status-pending); + border-color: var(--ink); +} +.status-running { + background: var(--status-running); + border-color: var(--ink); + animation: pulse 1.6s ease-in-out infinite; +} +.status-crash, +.status-oom, +.status-timeout { + background: var(--status-crash); + border-color: var(--ink); +} +.status-partial { + background: var(--status-partial); + border-color: var(--ink); +} +.status-cancelled { + background: var(--status-cancelled); + border-color: var(--status-discard-stroke); + border-style: dashed; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} +.kv { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + font-size: var(--fs-sm); +} +.kv dt { + color: var(--muted); + margin: 0; +} +.kv dd { + margin: 0; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + word-break: break-word; +} +.empty { + color: var(--muted); + padding: var(--s-6) 0; + font-size: var(--fs-sm); +} +.error-box { + border-left: 3px solid var(--coral); + padding: var(--s-3) var(--s-4); + background: var(--paper-2); + font-size: var(--fs-sm); + margin: var(--s-3) 0; +} +.notice { + border-left: 3px solid var(--sand); + padding: var(--s-3) var(--s-4); + background: var(--paper-2); + font-size: var(--fs-sm); + margin: var(--s-3) 0; +} +.skeleton { + background: linear-gradient(90deg, var(--paper-2), var(--paper-3), var(--paper-2)); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + min-height: 1em; +} +@keyframes shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} +.prose h1, +.prose h2, +.prose h3 { + margin-top: var(--s-5); +} +.prose h1 { + font-size: var(--fs-lg); +} +.prose h2 { + font-size: var(--fs-md); +} +.prose h3 { + font-size: var(--fs-base); +} +.prose table { + margin-bottom: var(--s-4); + width: auto; +} +.prose pre { + margin-bottom: var(--s-4); +} +.prose img { + max-width: 100%; +} +.prose blockquote { + margin: 0 0 var(--s-4); + padding-left: var(--s-3); + border-left: 2px solid var(--rule); + color: var(--ink-2); +} diff --git a/src/automil/viz/static/css/layout.css b/src/automil/viz/static/css/layout.css new file mode 100644 index 00000000..09af3607 --- /dev/null +++ b/src/automil/viz/static/css/layout.css @@ -0,0 +1,366 @@ +/* Layout: header, run bar, page containers and bands, two-pane views, the node drawer. */ + +.header { + position: sticky; + top: 0; + z-index: 20; + height: var(--header-h); + display: flex; + align-items: center; + gap: var(--s-6); + padding: 0 var(--gutter); + background: var(--paper); + border-bottom: 1px solid var(--rule); +} +.wordmark { + font-weight: 800; + font-size: 1.25rem; + letter-spacing: -0.03em; + color: var(--ink); + border: 0; + white-space: nowrap; +} +.wordmark:hover { + border: 0; + color: var(--accent-ink); +} +.nav { + display: flex; + gap: var(--s-5); + align-items: center; + overflow-x: auto; + white-space: nowrap; + flex: 1; +} +.nav a { + color: var(--muted); + font-size: var(--fs-sm); + font-weight: 600; + padding: 6px 0; + border-bottom: 2px solid transparent; +} +.nav a:hover { + color: var(--ink); +} +.nav a.active { + color: var(--ink); + border-bottom-color: var(--ink); +} +.header-right { + display: flex; + align-items: center; + gap: var(--s-3); + margin-left: auto; +} +.conn { + font-family: var(--mono); + font-size: var(--fs-xs); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.conn .status-dot { + margin: 0; + border-color: transparent; +} +.conn.live .status-dot { + background: var(--status-keep); +} +.conn.offline .status-dot { + background: var(--coral); +} +.conn.recorded .status-dot { + background: var(--faint); +} + +.runbar[hidden] { + display: none; +} +.runbar { + position: sticky; + top: var(--header-h); + z-index: 19; + min-height: var(--runbar-h); + display: flex; + align-items: center; + gap: var(--s-4); + padding: 0 var(--gutter); + background: var(--paper); + border-bottom: 1px solid var(--rule); + overflow-x: auto; +} +.runbar .run-title { + font-weight: 800; + font-size: var(--fs-base); + letter-spacing: -0.02em; + white-space: nowrap; +} +.runbar .run-meta { + font-family: var(--mono); + color: var(--muted); + font-size: var(--fs-xs); + white-space: nowrap; +} +.runbar .tabs { + display: flex; + gap: var(--s-5); + margin-left: auto; +} +.runbar .tabs a { + font-family: var(--mono); + font-size: var(--fs-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + padding: 14px 0; + border-bottom: 2px solid transparent; +} +.runbar .tabs a.active { + color: var(--ink); + border-bottom-color: var(--ink); +} + +.page { + max-width: var(--page-max); + margin: 0 auto; + padding: var(--s-6) var(--gutter) var(--s-8); +} +.page.wide { + max-width: none; +} +.page.narrow { + max-width: 820px; +} +.page-title { + margin-bottom: var(--s-6); + padding-bottom: var(--s-4); + border-bottom: 1px solid var(--rule); +} +.page-title h1 { + margin-bottom: var(--s-2); +} +.page-title p { + color: var(--muted); + margin: 0; + font-size: var(--fs-md); +} + +/* home bands: white, separated by black rules, each with a mono index label */ +.band { + padding: var(--s-7) 0; + border-top: 1px solid var(--rule); +} +.band.hero-band { + border-top: 0; +} +.band > .inner { + max-width: var(--page-max); + margin: 0 auto; + padding: 0 var(--gutter); +} +.band h2 { + font-size: var(--fs-xl); + margin-bottom: var(--s-4); +} +.band .index { + font-family: var(--mono); + font-size: var(--fs-xs); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: var(--s-4); +} +.band .lead { + margin-bottom: var(--s-6); +} +.cols-2 { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-6); +} +.cols-2.uneven { + grid-template-columns: 1fr; +} +.cols-3 { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-5); +} +@media (min-width: 900px) { + .cols-2 { + grid-template-columns: 1fr 1fr; + } + .cols-2.uneven { + grid-template-columns: 5fr 7fr; + } + .cols-3 { + grid-template-columns: 1fr 1fr 1fr; + } +} + +/* two-pane dashboard views */ +.workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-5); + min-height: calc(100vh - var(--header-h) - var(--runbar-h) - 64px); +} +@media (min-width: 1000px) { + .workspace { + grid-template-columns: minmax(0, 3fr) minmax(380px, 2fr); + } +} +.pane { + display: flex; + flex-direction: column; + min-height: 360px; + min-width: 0; +} +.pane-head { + display: flex; + align-items: center; + gap: var(--s-3); + padding: 0 0 var(--s-2); + font-size: var(--fs-sm); +} +.pane-head h3 { + margin: 0; + font-family: var(--mono); + font-size: var(--fs-xs); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.pane-head .muted { + font-family: var(--mono); + font-size: var(--fs-xs); +} +.pane-head .spacer { + flex: 1; +} +.pane-body { + flex: 1; + min-height: 0; + position: relative; + background: var(--paper); + border: 1px solid var(--rule); + overflow: hidden; +} +.pane-body svg, +.pane-body .gl { + display: block; + width: 100%; + height: 100%; +} +.pane-body .gl canvas { + display: block; +} +.legend { + display: flex; + flex-wrap: wrap; + gap: var(--s-3) var(--s-4); + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); + padding: var(--s-2) 0 0; +} + +/* the node drawer */ +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(var(--drawer-w), 100vw); + background: var(--paper); + border-left: 1px solid var(--rule); + z-index: 30; + transform: translateX(100%); + transition: transform 0.22s ease-out; + display: flex; + flex-direction: column; +} +.drawer.open { + transform: translateX(0); +} +.drawer-head { + display: flex; + align-items: baseline; + gap: var(--s-3); + padding: var(--s-5) var(--s-6) var(--s-3); + border-bottom: 1px solid var(--rule); +} +.drawer-head h2 { + margin: 0; + font-size: var(--fs-md); + font-family: var(--mono); + font-weight: 500; + letter-spacing: 0; +} +.drawer-head .close { + margin-left: auto; +} +.drawer-body { + overflow-y: auto; + padding: var(--s-5) var(--s-6) var(--s-7); +} +.drawer-body h4 { + margin: var(--s-6) 0 var(--s-2); + font-family: var(--mono); + font-size: var(--fs-xs); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +.drawer-body h4:first-child { + margin-top: 0; +} +.big-number { + font-family: var(--mono); + font-size: 2.2rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--accent-ink); +} +.big-number small { + font-size: var(--fs-sm); + color: var(--muted); + font-family: var(--sans); + font-weight: 500; + margin-left: var(--s-2); +} + +.tooltip { + position: fixed; + z-index: 40; + pointer-events: none; + background: var(--ink); + color: var(--paper); + font-size: var(--fs-xs); + padding: 7px 10px; + max-width: 320px; + line-height: 1.45; + display: none; +} +.tooltip .mono { + color: var(--cyan-light); +} + +.footer { + border-top: 1px solid var(--rule); + padding: var(--s-6) var(--gutter); + color: var(--muted); + font-size: var(--fs-sm); + display: flex; + flex-wrap: wrap; + gap: var(--s-4) var(--s-6); +} +.footer a { + color: var(--ink); +} diff --git a/src/automil/viz/static/css/tokens.css b/src/automil/viz/static/css/tokens.css new file mode 100644 index 00000000..87899f5e --- /dev/null +++ b/src/automil/viz/static/css/tokens.css @@ -0,0 +1,130 @@ +/* autoMIL design tokens: white page, black rules, mono labels, teal for data. */ + +@font-face { font-family: "Manrope"; font-weight: 500; font-display: swap; src: url(../fonts/Manrope-500.woff2) format("woff2"); } +@font-face { font-family: "Manrope"; font-weight: 600; font-display: swap; src: url(../fonts/Manrope-600.woff2) format("woff2"); } +@font-face { font-family: "Manrope"; font-weight: 700; font-display: swap; src: url(../fonts/Manrope-700.woff2) format("woff2"); } +@font-face { font-family: "Manrope"; font-weight: 800; font-display: swap; src: url(../fonts/Manrope-800.woff2) format("woff2"); } +@font-face { font-family: "JetBrains Mono"; font-weight: 400; font-display: swap; src: url(../fonts/JetBrainsMono-400.woff2) format("woff2"); } +@font-face { font-family: "JetBrains Mono"; font-weight: 500; font-display: swap; src: url(../fonts/JetBrainsMono-500.woff2) format("woff2"); } + +:root { + /* palette (paper/preprint/PAPER-DESIGN-SYSTEM.md) */ + --seafoam: #bfdfd2; + --teal-muted: #51999f; + --teal-main: #4198ac; + --cyan-light: #7bc0cd; + --sand: #dbcb92; + --amber-light: #ecb66c; + --orange-warm: #ea9e58; + --coral: #ed8d5a; + + /* page */ + --paper: #ffffff; + --paper-2: #f4f4f4; + --paper-3: #e9e9e9; + --ink: #111111; + --ink-2: #3a3a3a; + --muted: #6e6e6e; + --faint: #9a9a9a; + --rule: #111111; + --rule-2: #e4e4e4; + --hairline: #111111; + --hairline-2: #e4e4e4; + --band: #ffffff; + + /* semantic: teal is for data only */ + --accent: var(--teal-main); + --accent-ink: #2b7386; + --accent-tint: #e4f0f3; + --status-keep: var(--teal-main); + --status-gate: var(--teal-muted); + --status-discard: #ffffff; + --status-discard-stroke: #8c8c8c; + --status-pending: var(--sand); + --status-running: var(--cyan-light); + --status-crash: var(--coral); + --status-partial: var(--amber-light); + --status-cancelled: #f4f4f4; + --lane-research: var(--cyan-light); + --lane-execution: var(--seafoam); + --lane-evidence: var(--teal-main); + --lane-test: var(--coral); + + /* type */ + --sans: "Manrope", -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --fs-xs: 0.75rem; + --fs-sm: 0.875rem; + --fs-base: 1rem; + --fs-md: 1.125rem; + --fs-lead: 1.15rem; + --fs-lg: 1.5rem; + --fs-xl: 2rem; + --fs-hero: clamp(2.4rem, 4.6vw, 3.5rem); + --lh: 1.55; + + /* spacing */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 56px; + --s-8: 88px; + --gutter: 20px; + --measure: 62ch; + --page-max: 1280px; + --header-h: 64px; + --runbar-h: 48px; + --drawer-w: 540px; + --radius: 0px; + --radius-sm: 0px; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --paper: #111111; + --paper-2: #1a1a1a; + --paper-3: #262626; + --ink: #f2f2f2; + --ink-2: #cfcfcf; + --muted: #a0a0a0; + --faint: #767676; + --rule: #f2f2f2; + --rule-2: #2c2c2c; + --hairline: #f2f2f2; + --hairline-2: #2c2c2c; + --band: #111111; + --accent-ink: #86c7d4; + --accent-tint: #1d3439; + --status-discard: #111111; + --status-discard-stroke: #8c8c8c; + --status-cancelled: #1a1a1a; + } +} +:root[data-theme="dark"] { + --paper: #111111; + --paper-2: #1a1a1a; + --paper-3: #262626; + --ink: #f2f2f2; + --ink-2: #cfcfcf; + --muted: #a0a0a0; + --faint: #767676; + --rule: #f2f2f2; + --rule-2: #2c2c2c; + --hairline: #f2f2f2; + --hairline-2: #2c2c2c; + --band: #111111; + --accent-ink: #86c7d4; + --accent-tint: #1d3439; + --status-discard: #111111; + --status-discard-stroke: #8c8c8c; + --status-cancelled: #1a1a1a; +} + +@media (min-width: 768px) { + :root { + --gutter: 40px; + } +} diff --git a/src/automil/viz/static/css/views.css b/src/automil/viz/static/css/views.css new file mode 100644 index 00000000..2c79e1b2 --- /dev/null +++ b/src/automil/viz/static/css/views.css @@ -0,0 +1,884 @@ +/* View-specific styles: home, runs, tree, progress, timeline, agent, nodes, notes, remote. */ + +/* ---- home ---------------------------------------------------------------- */ +.hero { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + column-gap: var(--s-5); + row-gap: var(--s-6); + padding: var(--s-6) 0 var(--s-7); + background-image: linear-gradient(90deg, var(--rule-2) 1px, transparent 1px); + background-size: calc(100% / 12) 100%; + background-position: -1px 0; +} +.hero-text { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: var(--s-5); + padding-right: var(--s-4); +} +.hero-figure { + grid-column: 1 / -1; +} +@media (min-width: 960px) { + .hero-text { + grid-column: 1 / span 6; + } + .hero-figure { + grid-column: 7 / span 6; + } +} +.hero h1 { + font-size: var(--fs-hero); + font-weight: 700; + max-width: 18ch; + margin: 0; + letter-spacing: -0.035em; + line-height: 1.02; +} +.hero .lead { + max-width: 42ch; + margin: 0; +} +.hero .actions { + display: flex; + gap: var(--s-3); + flex-wrap: wrap; +} +.hero-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--s-4); + border-top: 1px solid var(--rule); + padding-top: var(--s-4); + margin-top: var(--s-3); +} +.hero-stats .value { + font-family: var(--mono); + font-size: 1.6rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; +} +.hero-stats .value.teal { + color: var(--accent-ink); +} +.hero-stats .what { + font-size: var(--fs-xs); + color: var(--muted); + margin-top: 2px; +} +.hero-visual { + position: relative; + overflow: hidden; + border: 1px solid var(--rule); + background: var(--paper); + aspect-ratio: 6 / 5; + max-height: 560px; +} +.hero-visual .gl, +.hero-visual svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.hero-caption { + display: flex; + justify-content: space-between; + gap: var(--s-4); + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); + margin-top: var(--s-2); +} +.hero-caption a { + color: var(--accent-ink); +} +.hero-caption span:last-child { + white-space: nowrap; +} + +/* the four lanes as a strip */ +.lanes-strip { + display: grid; + border-top: 1px solid var(--rule); +} +.lane-row { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-3); + align-items: start; + padding: var(--s-4) 0; + border-bottom: 1px solid var(--rule-2); +} +.lane-row:last-child { + border-bottom: 1px solid var(--rule); +} +@media (min-width: 900px) { + .lane-row { + grid-template-columns: 200px 1fr; + gap: var(--s-6); + } +} +.lane-row .swatch { + display: inline-block; + width: 10px; + height: 10px; + margin-right: 8px; + vertical-align: 0; +} +.lane-row.research .swatch { + background: var(--lane-research); +} +.lane-row.execution .swatch { + background: var(--lane-execution); +} +.lane-row.evidence .swatch { + background: var(--lane-evidence); +} +.lane-row.test .swatch { + background: var(--lane-test); +} +.lane-row h3 { + margin: 0; + font-size: var(--fs-md); +} +.lane-row .actor { + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); + letter-spacing: 0.04em; + text-transform: uppercase; + margin-top: 4px; +} +.lane-steps { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-3); +} +@media (min-width: 700px) { + .lane-steps { + grid-template-columns: repeat(3, 1fr); + gap: var(--s-5); + } +} +.lane-step { + font-size: var(--fs-sm); + color: var(--ink-2); + line-height: 1.45; +} +.lane-step b { + display: block; + color: var(--ink); + font-weight: 700; + margin-bottom: 2px; +} +.lane-step .n { + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); + margin-right: 6px; +} +.figure-link { + margin-top: var(--s-4); + font-size: var(--fs-sm); + color: var(--muted); +} +.figure-link a { + color: var(--accent-ink); +} +.record-list { + display: grid; + grid-template-columns: 1fr; + border-top: 1px solid var(--rule); +} +.record-item { + padding: var(--s-4) 0; + border-bottom: 1px solid var(--rule-2); +} +.record-item .path { + font-family: var(--mono); + font-size: var(--fs-sm); + color: var(--ink); + margin-bottom: 4px; +} +.record-item .what { + color: var(--ink-2); + font-size: var(--fs-sm); +} +.excerpt { + padding: var(--s-5); + background: var(--paper); + font-size: var(--fs-sm); + max-height: 520px; + overflow: auto; + border: 1px solid var(--rule); +} +.stat-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--s-5); + margin: var(--s-5) 0 var(--s-6); + border-top: 1px solid var(--rule); + padding-top: var(--s-4); +} +@media (min-width: 700px) { + .stat-row { + grid-template-columns: repeat(4, 1fr); + } +} +.stat .value { + font-family: var(--mono); + font-size: 2.4rem; + font-weight: 500; + color: var(--ink); + line-height: 1.1; + letter-spacing: -0.02em; +} +.stat .label { + font-family: var(--sans); + font-size: var(--fs-sm); + color: var(--muted); + margin-top: 4px; + text-transform: none; + letter-spacing: 0; +} +.cell-grid { + display: block; + width: 100%; + max-width: 760px; + height: auto; +} +.cell-grid text { + font-family: var(--mono); + font-size: 11px; + fill: var(--muted); +} +.cell-grid .head { + font-family: var(--sans); + font-weight: 700; + fill: var(--ink); +} +.cell-grid .cell { + fill: var(--paper); + stroke: var(--ink); + stroke-width: 1; +} +.cell-grid .cell.recorded { + fill: var(--teal-main); + stroke: var(--ink); +} +.grid-note { + font-size: var(--fs-sm); + color: var(--muted); + margin-top: var(--s-3); +} +.steps { + counter-reset: step; + list-style: none; + padding: 0; + margin: 0; + border-top: 1px solid var(--rule); +} +.steps li { + position: relative; + padding: var(--s-4) 0 var(--s-4) 3em; + margin: 0; + border-bottom: 1px solid var(--rule-2); + font-size: var(--fs-md); + font-weight: 600; +} +.steps li::before { + counter-increment: step; + content: "0" counter(step); + position: absolute; + left: 0; + top: var(--s-4); + font-family: var(--mono); + font-size: var(--fs-sm); + font-weight: 500; + color: var(--muted); + line-height: 1.6; +} +.steps li .note { + display: block; + font-size: var(--fs-sm); + font-weight: 500; + color: var(--muted); + margin-top: 4px; +} +.code-panel { + background: var(--ink); + color: var(--paper); + padding: var(--s-5) var(--s-6); + font-family: var(--mono); + font-size: var(--fs-sm); + line-height: 1.7; + white-space: pre; + overflow-x: auto; + align-self: start; +} +.code-panel .c { + color: var(--cyan-light); +} +.authors { + font-size: var(--fs-md); + color: var(--ink-2); +} + +/* ---- runs ---------------------------------------------------------------- */ +.connect { + display: flex; + gap: var(--s-2); + flex-wrap: wrap; + align-items: center; + margin: var(--s-4) 0; +} +.connect input { + min-width: 280px; + flex: 1; +} +.delta-pos { + color: var(--accent-ink); +} +.delta-neg { + color: var(--coral); +} + +/* ---- tree + progress ---------------------------------------------------- */ +.tree-svg text { + font-family: var(--mono); + font-size: 12px; + fill: var(--ink-2); + pointer-events: none; +} +.tree-svg.labels-off text { + display: none; +} +.tree-svg .link { + fill: none; + stroke: #b5b5b5; + stroke-width: 1.2; +} +.tree-svg .link.spine { + stroke: var(--ink); + stroke-width: 2.5; +} +.tree-svg .node circle { + stroke: var(--ink); + stroke-width: 1.2; + cursor: pointer; +} +.tree-svg .node.spine circle { + stroke: var(--ink); + stroke-width: 2; +} +.tree-svg .node.selected circle { + stroke: var(--accent-ink); + stroke-width: 4; +} +.gl-tooltip { + font-family: var(--sans); + font-size: 12px; + background: var(--ink); + color: var(--paper); + padding: 6px 9px; + border-radius: 4px; + line-height: 1.4; + max-width: 280px; +} +.gl-tooltip .mono { + font-family: var(--mono); + color: var(--seafoam); +} +.chart-svg text { + font-family: var(--mono); + font-size: 11px; + fill: var(--muted); +} +.chart-svg .axis line, +.chart-svg .axis path { + stroke: var(--hairline); +} +.chart-svg .grid line { + stroke: var(--hairline-2); +} +.chart-svg .best { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; +} +.chart-svg .baseline { + stroke: var(--ink); + stroke-dasharray: 3 3; +} +.chart-svg .band { + fill: var(--accent); + fill-opacity: 0.16; +} +.chart-svg .point { + stroke: var(--status-discard-stroke); + stroke-width: 1.2; + cursor: pointer; +} +.chart-svg .point.kept { + stroke: var(--ink); + stroke-width: 1.5; +} +.chart-svg .point.selected { + stroke: var(--accent-ink); + stroke-width: 3.5; +} +.chart-svg .label { + fill: var(--ink-2); +} + +/* ---- timeline ---------------------------------------------------------- */ +.timeline-wrap { + overflow: hidden; +} +.timeline-svg text { + font-family: var(--mono); + font-size: 11px; + fill: var(--muted); +} +.timeline-svg .lane-label { + fill: var(--ink); + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.timeline-svg .lane-bg { + fill: var(--paper-2); +} +.timeline-svg .lane-bg.alt { + fill: var(--paper); + stroke: var(--rule-2); +} +.timeline-svg .axis path, +.timeline-svg .axis line { + stroke: var(--hairline); +} +.timeline-svg .bar { + cursor: pointer; +} +.timeline-svg .bar.queued { + fill: var(--hairline); +} +.timeline-svg .session-span { + fill: var(--accent-tint); + stroke: var(--accent); + stroke-width: 1; +} +.timeline-svg .mark { + cursor: pointer; +} +.timeline-svg .brush .selection { + fill: var(--accent); + fill-opacity: 0.14; + stroke: var(--accent); +} +.timeline-legend { + display: flex; + flex-wrap: wrap; + gap: var(--s-3) var(--s-4); + font-size: var(--fs-xs); + color: var(--muted); + padding: var(--s-2) var(--s-4) var(--s-3); +} +.glyph { + display: inline-block; + width: 10px; + height: 10px; + margin-right: 5px; + vertical-align: -1px; +} +.glyph.prompt { + background: var(--ink); +} +.glyph.propose { + background: var(--sand); +} +.glyph.submit { + background: var(--accent); +} +.glyph.reconcile, +.glyph.rank { + background: var(--cyan-light); +} +.glyph.notification { + background: var(--amber-light); +} +.glyph.compact { + background: var(--faint); +} + +/* ---- agent ---------------------------------------------------------------- */ +.agent { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-5); +} +@media (min-width: 1000px) { + .agent { + grid-template-columns: 300px minmax(0, 1fr); + } +} +.session-list { + border: 1px solid var(--rule); + background: var(--paper); + align-self: start; + position: sticky; + top: calc(var(--header-h) + var(--runbar-h) + 16px); + overflow: hidden; +} +.session-item { + display: block; + padding: var(--s-4); + border-bottom: 1px solid var(--hairline-2); + color: var(--ink); + border-left: 3px solid transparent; +} +.session-item:hover { + background: var(--paper-3); + border-bottom-color: var(--hairline-2); +} +.session-item.active { + border-left-color: var(--accent); + background: var(--accent-tint); +} +.session-item .sid { + font-family: var(--mono); + font-size: var(--fs-sm); + font-weight: 500; +} +.session-item .meta { + font-size: var(--fs-xs); + color: var(--muted); + margin-top: 2px; +} +.filters { + padding: var(--s-4); + font-size: var(--fs-xs); + color: var(--muted); + display: flex; + flex-direction: column; + gap: 6px; +} +.filters label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.session-head { + display: flex; + align-items: baseline; + gap: var(--s-3); + flex-wrap: wrap; + margin-bottom: var(--s-4); +} +.session-head h3 { + margin: 0; +} +.session-cwd { + word-break: break-all; +} +.turns { + min-width: 0; +} +.turn { + border-left: 3px solid var(--hairline-2); + padding: var(--s-3) 0 var(--s-4) var(--s-5); + margin-bottom: var(--s-3); + position: relative; +} +.turn.highlight { + border-left-color: var(--accent); + background: var(--accent-tint); +} +.turn.human { + border-left-color: var(--ink); + background: var(--paper-2); + padding-right: var(--s-4); +} +.fold { + border-left: 3px solid var(--rule-2); + margin-bottom: var(--s-3); +} +.fold > summary { + list-style: none; + cursor: pointer; + padding: var(--s-3) 0 var(--s-3) var(--s-5); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-3); + font-size: var(--fs-sm); + color: var(--ink-2); +} +.fold > summary::-webkit-details-marker { + display: none; +} +.fold > summary .caret { + font-family: var(--mono); + color: var(--muted); + width: 1em; +} +.fold[open] > summary .caret::before { + content: "\2212"; +} +.fold:not([open]) > summary .caret::before { + content: "+"; +} +.fold > summary .count { + font-family: var(--mono); + font-weight: 500; + color: var(--ink); +} +.fold > summary .tools { + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); +} +.fold > summary .span { + font-family: var(--mono); + font-size: var(--fs-xs); + color: var(--muted); +} +.fold > summary .chips { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.fold > .turns { + padding-left: var(--s-3); +} +.turn.notification { + border-left-color: var(--amber-light); +} +.turn.system { + border-left-color: transparent; + color: var(--muted); + font-size: var(--fs-sm); + padding-top: 0; + padding-bottom: var(--s-2); +} +.turn-head { + display: flex; + align-items: baseline; + gap: var(--s-3); + font-size: var(--fs-xs); + color: var(--muted); + margin-bottom: var(--s-2); +} +.turn-head .who { + color: var(--ink); + font-weight: 600; + font-size: var(--fs-sm); +} +.turn-head .idx { + font-family: var(--mono); +} +.turn-head a { + color: var(--muted); +} +.turn .text { + max-width: 78ch; +} +.turn .text p { + max-width: none; +} +.turn .thinking { + color: var(--muted); + font-style: italic; + font-size: var(--fs-sm); + max-width: 78ch; + white-space: pre-wrap; +} +.turn details.thinking-box summary { + font-size: var(--fs-xs); + color: var(--muted); + cursor: pointer; +} +.calls { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: var(--s-3); +} +.call { + border-radius: var(--radius-sm); + font-size: var(--fs-sm); + background: var(--paper-2); +} +.call summary { + display: flex; + align-items: center; + gap: var(--s-2); + padding: 8px 12px; + cursor: pointer; + list-style: none; + min-width: 0; +} +.call summary::-webkit-details-marker { + display: none; +} +.call .tool { + font-family: var(--mono); + font-size: var(--fs-xs); + font-weight: 500; + color: var(--ink); + flex-shrink: 0; + background: var(--paper); + border: 1px solid var(--rule-2); + padding: 1px 6px; +} +.call .summary-text { + color: var(--ink-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; + font-family: var(--mono); + font-size: var(--fs-xs); +} +.call .chips { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.call .body { + border-top: 1px solid var(--hairline-2); + padding: var(--s-3) var(--s-4) var(--s-4); +} +.call .body h5 { + margin: var(--s-2) 0 4px; + font-size: var(--fs-xs); + color: var(--muted); + font-weight: 500; +} +.call pre { + max-height: 360px; + overflow: auto; + font-size: var(--fs-xs); + background: var(--paper); +} +.result-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + background: var(--hairline); +} +.result-dot.ok { + background: var(--status-keep); +} +.result-dot.error { + background: var(--coral); +} +.result-dot.interrupted, +.result-dot.missing { + background: var(--sand); +} +.result-dot.pending { + background: var(--cyan-light); + animation: pulse 1.6s ease-in-out infinite; +} +.subagent { + margin-top: var(--s-3); + border: 1px dashed var(--hairline); + border-radius: var(--radius-sm); + padding: var(--s-3) var(--s-4); +} +.subagent summary { + cursor: pointer; + font-size: var(--fs-sm); +} +.subagent .turns { + margin-top: var(--s-3); + padding-left: var(--s-2); +} +.turn-more { + text-align: center; + padding: var(--s-5); +} + +/* ---- nodes table --------------------------------------------------------- */ +.table-tools { + display: flex; + gap: var(--s-3); + align-items: center; + margin-bottom: var(--s-4); + font-size: var(--fs-sm); + flex-wrap: wrap; +} +.table-wrap { + overflow-x: auto; +} +th.sortable { + cursor: pointer; +} +th.sortable.asc::after { + content: " \2191"; +} +th.sortable.desc::after { + content: " \2193"; +} +.desc-cell { + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- notes ---------------------------------------------------------------- */ +.notes-tabs { + display: flex; + gap: var(--s-4); + border-bottom: 1px solid var(--hairline); + margin-bottom: var(--s-5); +} +.notes-tabs button { + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: none; + padding: 10px 2px; +} +.notes-tabs button.active { + border-bottom-color: var(--accent); + color: var(--ink); +} + +/* ---- fold table ------------------------------------------------------------ */ +.fold-table td, +.fold-table th { + padding: 5px 8px; +} +.verdict { + border-left: 4px solid var(--hairline); + padding: var(--s-3) var(--s-4); + background: var(--paper-2); + font-size: var(--fs-sm); + border-radius: 0 var(--radius) var(--radius) 0; +} +.verdict.keep { + border-left-color: var(--status-keep); +} +.verdict.discard { + border-left-color: var(--status-discard-stroke); +} +.log-tail { + max-height: 300px; + overflow: auto; + font-size: var(--fs-xs); +} +.file-list button { + font-family: var(--mono); + font-size: var(--fs-xs); +} diff --git a/src/automil/viz/static/fonts/JetBrainsMono-400.woff2 b/src/automil/viz/static/fonts/JetBrainsMono-400.woff2 new file mode 100644 index 00000000..58588733 Binary files /dev/null and b/src/automil/viz/static/fonts/JetBrainsMono-400.woff2 differ diff --git a/src/automil/viz/static/fonts/JetBrainsMono-500.woff2 b/src/automil/viz/static/fonts/JetBrainsMono-500.woff2 new file mode 100644 index 00000000..be878e68 Binary files /dev/null and b/src/automil/viz/static/fonts/JetBrainsMono-500.woff2 differ diff --git a/src/automil/viz/static/fonts/Manrope-500.woff2 b/src/automil/viz/static/fonts/Manrope-500.woff2 new file mode 100644 index 00000000..70447ee8 Binary files /dev/null and b/src/automil/viz/static/fonts/Manrope-500.woff2 differ diff --git a/src/automil/viz/static/fonts/Manrope-600.woff2 b/src/automil/viz/static/fonts/Manrope-600.woff2 new file mode 100644 index 00000000..7cc09a3b Binary files /dev/null and b/src/automil/viz/static/fonts/Manrope-600.woff2 differ diff --git a/src/automil/viz/static/fonts/Manrope-700.woff2 b/src/automil/viz/static/fonts/Manrope-700.woff2 new file mode 100644 index 00000000..84f7af54 Binary files /dev/null and b/src/automil/viz/static/fonts/Manrope-700.woff2 differ diff --git a/src/automil/viz/static/fonts/Manrope-800.woff2 b/src/automil/viz/static/fonts/Manrope-800.woff2 new file mode 100644 index 00000000..b5cd4008 Binary files /dev/null and b/src/automil/viz/static/fonts/Manrope-800.woff2 differ diff --git a/src/automil/viz/static/img/fig1_overview.png b/src/automil/viz/static/img/fig1_overview.png new file mode 100644 index 00000000..c1907db8 Binary files /dev/null and b/src/automil/viz/static/img/fig1_overview.png differ diff --git a/src/automil/viz/static/index.html b/src/automil/viz/static/index.html index 95a86dfe..530f4d87 100644 --- a/src/automil/viz/static/index.html +++ b/src/automil/viz/static/index.html @@ -1,171 +1,62 @@ -<!DOCTYPE html> +<!doctype html> <html lang="en"> <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>autoMIL Experiment Graph - + + + + + autoMIL + + + + + -
-
-

autoMIL

- -
-
-
-
- Executed - 0 -
-
- Proposed - 0 -
-
- Best primary value - 0.000 -
-
-
- - Offline -
-
-
- -
-
-
-
-
-
Keep
-
Discard
-
Proposed
-
Running
-
Crash/OOM
-
Size = primary value
-
- - -
No experiment data available.
-
-
-
-

Leaderboard

-
-
- - - - - - - - - - - - -
RankDescriptionPrimary valueParent ΔGlobal ΔStatus
-
-
No executed experiments yet.
-
-
-
-
-

Branch Potential

-
- - - - - - - - - - - - -
RankDescriptionParent NodeTechniquesPotentialTier
-
-
No proposed experiments.
-
-
-
-
-

Technique Effectiveness

-
- - - - - - - - - - - -
TechniqueTimes TriedBest Parent ΔAvg Parent ΔVerdict
-
-
No technique data available.
-
-
-
-
- - -
- -
-
-
- - -
-
- -
-

Metrics

-
-
- -
-

Deltas

-
-
- -
-

Techniques

-
-
- -
-

Description

-

-
- -
-

Lineage

-
-
- -
-

Info

-
-
+
+ autoMIL + +
+ Recorded +
- - - - - - +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/automil/viz/static/js/core.js b/src/automil/viz/static/js/core.js new file mode 100644 index 00000000..fd159a63 --- /dev/null +++ b/src/automil/viz/static/js/core.js @@ -0,0 +1,249 @@ +/* autoMIL frontend core: DOM helpers, formatting, state, router, tooltip, theme. */ +window.AM = (function () { + 'use strict'; + + const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + function esc(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ESCAPES[c]); + } + + /* h('div.cls', {attrs}, children...) builds an element; strings become text. */ + function h(spec, attrs, ...children) { + const [tag, ...classes] = spec.split('.'); + const el = tag === 'svg' + ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') + : document.createElement(tag || 'div'); + if (classes.length) el.setAttribute('class', classes.join(' ')); + if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) { + for (const [key, value] of Object.entries(attrs)) { + if (value == null || value === false) continue; + if (key === 'class') el.setAttribute('class', ((el.getAttribute('class') || '') + ' ' + value).trim()); + else if (key === 'text') el.textContent = value; + else if (key === 'html') el.innerHTML = value; + else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value); + else if (key === 'dataset') Object.assign(el.dataset, value); + else if (key === 'style' && typeof value === 'object') Object.assign(el.style, value); + else el.setAttribute(key, value === true ? '' : value); + } + } else if (attrs != null) { + children.unshift(attrs); + } + append(el, children); + return el; + } + function append(el, children) { + for (const child of children.flat(Infinity)) { + if (child == null || child === false) continue; + el.appendChild(child instanceof Node ? child : document.createTextNode(String(child))); + } + return el; + } + function clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + return el; + } + + /* ---- formatting ---- */ + const fmt = { + num(value, digits = 4) { + if (value == null || Number.isNaN(Number(value))) return '–'; + return Number(value).toFixed(digits); + }, + delta(value, digits = 4) { + if (value == null || Number.isNaN(Number(value))) return '–'; + const n = Number(value); + return (n > 0 ? '+' : '') + n.toFixed(digits); + }, + int(value) { + if (value == null) return '–'; + return Number(value).toLocaleString(); + }, + tokens(value) { + if (value == null) return '–'; + const n = Number(value); + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return Math.round(n / 1e3) + 'k'; + return String(n); + }, + when(iso, opts) { + if (!iso) return '–'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return String(iso); + const options = Object.assign({ year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, opts || {}); + return date.toLocaleString(undefined, options); + }, + time(iso) { + if (!iso) return '–'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return String(iso); + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }, + duration(seconds) { + if (seconds == null || Number.isNaN(Number(seconds))) return '–'; + const s = Math.max(0, Math.round(Number(seconds))); + if (s < 60) return s + ' s'; + const m = Math.round(s / 60); + if (m < 60) return m + ' min'; + const hours = Math.floor(m / 60); + const rest = m % 60; + return rest ? `${hours} h ${rest} min` : `${hours} h`; + }, + between(a, b) { + if (!a || !b) return null; + return (new Date(b) - new Date(a)) / 1000; + }, + bytes(n) { + if (n == null) return '–'; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; + }, + shortId(sid) { + return sid ? String(sid).slice(0, 8) : '–'; + }, + truncate(text, n) { + const s = String(text == null ? '' : text); + return s.length > n ? s.slice(0, n - 1) + '…' : s; + }, + }; + + const STATUS_LABEL = { + keep: 'kept', discard: 'discarded', pending: 'proposed', running: 'running', crash: 'crashed', + oom: 'out of memory', timeout: 'timed out', cancelled: 'cancelled', partial: 'partial', + candidate: 'gate candidate', registered: 'registered', + }; + function statusLabel(status) { + return STATUS_LABEL[status] || status || 'unknown'; + } + function statusClass(status) { + return 'status-' + (status || 'discard'); + } + const STATUS_COLOR_VAR = { + keep: '--status-keep', candidate: '--status-gate', registered: '--status-gate', discard: '--status-discard', + pending: '--status-pending', running: '--status-running', crash: '--status-crash', oom: '--status-crash', + timeout: '--status-crash', partial: '--status-partial', cancelled: '--status-cancelled', + }; + function statusColor(status) { + const name = STATUS_COLOR_VAR[status] || '--status-discard'; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + } + function cssVar(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + } + + /* ---- graph helpers ---- */ + function lineage(nodes, id) { + const path = []; + const seen = new Set(); + let current = id; + while (current && nodes[current] && !seen.has(current)) { + seen.add(current); + path.unshift(nodes[current]); + current = nodes[current].parent_id; + } + return path; + } + function executedInOrder(graph) { + return Object.values(graph.nodes) + .filter((n) => n.type === 'executed') + .sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '') || a.id.localeCompare(b.id)); + } + + /* ---- state ---- */ + const state = { + index: null, + sources: {}, + run: null, + selection: null, + theme: null, + }; + const listeners = new Set(); + function setState(patch) { + Object.assign(state, patch); + for (const fn of listeners) fn(state); + } + function onState(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + /* ---- router (hash) ---- */ + function parseRoute(hash) { + const raw = (hash || '#/').replace(/^#/, ''); + const [pathPart, queryPart] = raw.split('?'); + const parts = pathPart.split('/').filter(Boolean); + const query = Object.fromEntries(new URLSearchParams(queryPart || '')); + if (parts.length === 0) return { name: 'home', params: {}, query }; + if (parts[0] === 'runs') return { name: 'runs', params: {}, query }; + if (parts[0] === 'remote') return { name: 'remote', params: {}, query }; + if (parts[0] === 'run' && parts[1]) { + const run = decodeURIComponent(parts[1]); + const view = parts[2] || 'tree'; + if (view === 'node' && parts[3]) return { name: 'tree', params: { run, node: parts[3] }, query }; + if (view === 'session' && parts[3]) { + return { name: 'agent', params: { run, session: parts[3], turn: parts[4] === 'turn' ? Number(parts[5]) : null }, query }; + } + if (['tree', 'timeline', 'agent', 'nodes', 'notes'].includes(view)) return { name: view, params: { run }, query }; + } + return { name: 'home', params: {}, query }; + } + function runHref(run, view, extra) { + return `#/run/${encodeURIComponent(run)}/${view}${extra || ''}`; + } + function navigate(hash) { + if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange')); + else location.hash = hash; + } + + /* ---- tooltip ---- */ + const tooltip = { + el: null, + show(html, x, y) { + if (!this.el) this.el = document.getElementById('tooltip'); + this.el.innerHTML = html; + this.el.style.display = 'block'; + const rect = this.el.getBoundingClientRect(); + const left = Math.min(x + 14, window.innerWidth - rect.width - 8); + const top = Math.min(y + 14, window.innerHeight - rect.height - 8); + this.el.style.left = left + 'px'; + this.el.style.top = top + 'px'; + }, + hide() { + if (this.el) this.el.style.display = 'none'; + }, + }; + + /* ---- theme ---- */ + function initTheme() { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => window.dispatchEvent(new CustomEvent('am:theme'))); + let saved = null; + try { saved = localStorage.getItem('am.theme'); } catch (e) { saved = null; } + if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved; + const button = document.getElementById('theme-toggle'); + if (button) { + button.addEventListener('click', () => { + const dark = document.documentElement.dataset.theme === 'dark' || + (!document.documentElement.dataset.theme && window.matchMedia('(prefers-color-scheme: dark)').matches); + const next = dark ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('am.theme', next); } catch (e) { /* private mode */ } + window.dispatchEvent(new CustomEvent('am:theme')); + }); + } + } + + function storage(key, value) { + try { + if (value === undefined) return localStorage.getItem(key); + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } catch (e) { return null; } + return value; + } + + return { + esc, h, append, clear, fmt, statusLabel, statusClass, statusColor, cssVar, lineage, executedInOrder, + state, setState, onState, parseRoute, runHref, navigate, tooltip, initTheme, storage, + views: {}, + }; +})(); diff --git a/src/automil/viz/static/js/main.js b/src/automil/viz/static/js/main.js new file mode 100644 index 00000000..228540df --- /dev/null +++ b/src/automil/viz/static/js/main.js @@ -0,0 +1,142 @@ +/* Boot: open the bundled record, restore a connected local server, route views. */ +(function (AM) { + 'use strict'; + + const { h, esc, fmt } = AM; + let cleanup = null; + let unsubscribe = null; + + function setConnection(state, label) { + const el = document.getElementById('conn'); + el.className = 'conn ' + state; + document.getElementById('conn-text').textContent = label; + } + + /* Which source serves a run id: bundled runs, or the connected local server for local:. */ + function resolveRun(runId) { + if (runId.startsWith('local:')) { + const source = AM.state.sources.local; + return { source, id: runId.slice(6), local: true }; + } + return { source: AM.state.sources.bundled, id: runId, local: false }; + } + + async function findRun(runId) { + const resolved = resolveRun(runId); + if (!resolved.source) return null; + const index = await resolved.source.index(); + const entry = (index.runs || []).find((r) => r.run_id === resolved.id); + if (!entry) return null; + return Object.assign(resolved, { entry, index, key: runId }); + } + + function renderRunbar(run, view) { + const bar = document.getElementById('runbar'); + AM.clear(bar); + if (!run) { + bar.hidden = true; + return; + } + bar.hidden = false; + const e = run.entry; + const meta = [e.task, e.encoder, e.mil_model].filter(Boolean).join(' / '); + AM.append(bar, [ + h('span.run-title', { text: e.title || e.run_id }), + h('span.run-meta', { text: meta ? `${meta} · ${e.n_executed} attempts` : `${e.n_executed} attempts` }), + run.local ? h('span.tag', { text: 'local server' }) : null, + h('span.tabs', + ...[['tree', 'Tree'], ['timeline', 'Timeline'], ['agent', 'Agent'], ['nodes', 'Nodes'], ['notes', 'Notes']].map(([name, label]) => + h('a', { href: AM.runHref(run.key, name), class: name === view ? 'active' : '', text: label }), + ), + ), + ]); + } + + function markNav(name) { + for (const link of document.querySelectorAll('#nav a[data-route]')) { + link.classList.toggle('active', link.dataset.route === name); + } + } + + async function route() { + const parsed = AM.parseRoute(location.hash); + const app = document.getElementById('app'); + if (cleanup) { try { cleanup(); } catch (err) { console.error(err); } cleanup = null; } + if (unsubscribe) { unsubscribe(); unsubscribe = null; } + AM.setState({ route: parsed }); + markNav(parsed.name); + let run = null; + if (parsed.params.run) { + try { + run = await findRun(parsed.params.run); + } catch (err) { + console.error(err); + } + if (!run) { + renderRunbar(null); + AM.clear(app).append(h('div.page', h('div.error-box', { text: `No run named ${parsed.params.run} in this record.` }), + h('p', h('a', { href: '#/runs', text: 'All runs' })))); + return; + } + renderRunbar(run, parsed.name); + const source = run.source; + setConnection(source.connection, source.kind === 'live' ? (source.connection === 'live' ? 'Live' : 'Connecting') : 'Recorded'); + unsubscribe = source.subscribe((frame) => { + if (frame.type === 'connection') { + setConnection(frame.state, frame.state === 'live' ? 'Live' : 'Offline'); + } + }); + } else { + renderRunbar(null); + const bundled = AM.state.sources.bundled; + setConnection(bundled && bundled.kind === 'live' ? bundled.connection : 'recorded', + bundled && bundled.kind === 'live' ? (bundled.connection === 'live' ? 'Live' : 'Connecting') : 'Recorded'); + if (bundled && bundled.kind === 'live') { + unsubscribe = bundled.subscribe((frame) => { + if (frame.type === 'connection') setConnection(frame.state, frame.state === 'live' ? 'Live' : 'Offline'); + }); + } + } + const view = AM.views[parsed.name] || AM.views.home; + AM.clear(app); + try { + cleanup = await view(app, { route: parsed, run }) || null; + } catch (err) { + console.error(err); + app.append(h('div.page', h('div.error-box', { text: `This view failed to load: ${err.message}` }))); + } + if (parsed.query.scroll) { + const target = document.getElementById(parsed.query.scroll); + if (target) target.scrollIntoView(); + } + } + + async function boot() { + AM.initTheme(); + let bundled = null; + try { + bundled = await AM.openBundled(); + } catch (err) { + console.error(err); + } + AM.state.sources.bundled = bundled; + const savedLocal = AM.storage('am.local'); + if (savedLocal) { + try { AM.state.sources.local = AM.openLocal(savedLocal); } catch (err) { AM.storage('am.local', null); } + } + if (bundled) { + const index = await bundled.index(); + AM.state.index = index; + const note = document.getElementById('footer-note'); + if (index.mode === 'live') note.textContent = 'This page is served by automil viz on this host.'; + else if (index.generated_at) note.textContent = `Record exported ${fmt.when(index.generated_at)}.`; + } + window.addEventListener('hashchange', route); + await route(); + } + + AM.resolveRun = resolveRun; + AM.findRun = findRun; + AM.setConnection = setConnection; + document.addEventListener('DOMContentLoaded', boot); +})(window.AM); diff --git a/src/automil/viz/static/js/markdown.js b/src/automil/viz/static/js/markdown.js new file mode 100644 index 00000000..44751aa4 --- /dev/null +++ b/src/automil/viz/static/js/markdown.js @@ -0,0 +1,41 @@ +/* Markdown to sanitised HTML. Transcript text is untrusted content: it always goes through DOMPurify. */ +(function (AM) { + 'use strict'; + + const hasMarked = typeof window.marked !== 'undefined'; + const hasPurify = typeof window.DOMPurify !== 'undefined'; + if (hasMarked) { + window.marked.setOptions({ gfm: true, breaks: false, mangle: false, headerIds: false }); + } + + function render(text) { + const source = String(text == null ? '' : text); + let html; + if (hasMarked) { + try { html = window.marked.parse(source); } catch (err) { html = '
' + AM.esc(source) + '
'; } + } else { + html = '
' + AM.esc(source) + '
'; + } + if (hasPurify) { + return window.DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + FORBID_TAGS: ['style', 'form', 'input', 'button', 'iframe', 'object', 'embed'], + FORBID_ATTR: ['style', 'onerror', 'onload'], + }); + } + return '
' + AM.esc(source) + '
'; + } + + /* A DOM node with the rendered markdown; links open in a new tab. */ + function element(text, className) { + const el = AM.h('div.prose' + (className ? '.' + className : '')); + el.innerHTML = render(text); + for (const link of el.querySelectorAll('a[href]')) { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + return el; + } + + AM.markdown = { render, element }; +})(window.AM); diff --git a/src/automil/viz/static/js/source.js b/src/automil/viz/static/js/source.js new file mode 100644 index 00000000..f3b0ee52 --- /dev/null +++ b/src/automil/viz/static/js/source.js @@ -0,0 +1,162 @@ +/* A record source: the bundled snapshot, a live server, or the inlined single-file record. */ +(function (AM) { + 'use strict'; + + class RecordSource { + /* kind: 'snapshot' | 'live' | 'inline'; base: origin or relative root without trailing slash. */ + constructor(kind, base, inline) { + this.kind = kind; + this.base = base; + this.inline = inline || null; + this.cache = new Map(); + this.listeners = new Set(); + this.events = null; + this.connection = kind === 'live' ? 'offline' : 'recorded'; + this.prefix = kind === 'inline' ? '' : `${base}/record/`; + } + + async get(rel) { + if (this.cache.has(rel)) return this.cache.get(rel); + const promise = this.fetch(rel); + this.cache.set(rel, promise); + try { + return await promise; + } catch (err) { + this.cache.delete(rel); + throw err; + } + } + + async fetch(rel) { + if (this.kind === 'inline') { + if (!(rel in this.inline)) throw new Error(`${rel} is not in this file`); + return this.inline[rel]; + } + const options = { cache: 'no-store' }; + if (this.kind === 'live' && /^https?:/.test(this.base) && !this.base.startsWith(location.origin)) { + options.mode = 'cors'; + try { options.targetAddressSpace = 'loopback'; } catch (e) { /* older browsers */ } + } + const response = await fetch(this.prefix + rel, options); + if (!response.ok) throw new Error(`${response.status} for ${rel}`); + return response.json(); + } + + invalidate(prefix) { + for (const key of Array.from(this.cache.keys())) { + if (!prefix || key.startsWith(prefix)) this.cache.delete(key); + } + } + + index() { return this.get('index.json'); } + graph(run) { return this.get(`runs/${run}/graph.json`); } + timeline(run) { return this.get(`runs/${run}/timeline.json`); } + sessions(run) { return this.get(`runs/${run}/sessions.json`); } + links(run) { return this.get(`runs/${run}/agent_links.json`); } + notes(run) { return this.get(`runs/${run}/notes.json`); } + certified(run) { return this.get(`runs/${run}/certified.json`).catch(() => null); } + node(run, id) { return this.get(`runs/${run}/nodes/${id}.json`); } + file(run, id, path) { return this.get(`runs/${run}/nodes/${id}/files/${path}.json`); } + chunk(run, sid, k) { return this.get(`runs/${run}/sessions/${sid}/turns/${k}.json`); } + fullResult(run, sid, tool) { return this.get(`runs/${run}/sessions/${sid}/results/${tool}.json`); } + agent(run, sid, agent) { return this.get(`runs/${run}/sessions/${sid}/agents/${agent}.json`); } + + /* Live only: one EventSource shared by every view; listeners get parsed frames. */ + subscribe(listener) { + this.listeners.add(listener); + if (this.kind === 'live' && !this.events) this.connect(); + return () => this.listeners.delete(listener); + } + + connect() { + const url = `${this.base}/events`; + let source; + try { + source = new EventSource(url); + } catch (err) { + this.connection = 'offline'; + this.emit({ type: 'connection', state: 'offline' }); + return; + } + this.events = source; + source.onopen = () => { + this.connection = 'live'; + this.emit({ type: 'connection', state: 'live' }); + }; + source.onmessage = (event) => { + let frame; + try { frame = JSON.parse(event.data); } catch (err) { return; } + this.applyFrame(frame); + this.emit(frame); + }; + source.onerror = () => { + this.connection = 'offline'; + this.emit({ type: 'connection', state: 'offline' }); + }; + } + + applyFrame(frame) { + const run = frame.run_id; + if (frame.type === 'graph_update') { + this.invalidate('index.json'); + this.invalidate('runs/'); + if (frame.full_graph && run) this.cache.set(`runs/${run}/graph.json`, Promise.resolve(Object.assign({ run_id: run }, frame.full_graph))); + } else if (frame.type === 'sessions_update') { + this.invalidate('index.json'); + if (run) { + this.invalidate(`runs/${run}/sessions.json`); + this.invalidate(`runs/${run}/timeline.json`); + this.invalidate(`runs/${run}/agent_links.json`); + if (frame.sessions) this.cache.set(`runs/${run}/sessions.json`, Promise.resolve(frame.sessions)); + } + } else if (frame.type === 'transcript_delta' || frame.type === 'transcript_invalidate') { + if (run) { + this.invalidate(`runs/${run}/sessions/${frame.session_id}/`); + this.invalidate(`runs/${run}/sessions.json`); + this.invalidate(`runs/${run}/agent_links.json`); + this.invalidate(`runs/${run}/timeline.json`); + this.invalidate(`runs/${run}/nodes/`); + } + } + } + + emit(frame) { + for (const fn of this.listeners) { + try { fn(frame); } catch (err) { console.error('listener failed', err); } + } + } + + close() { + if (this.events) this.events.close(); + this.events = null; + } + } + + /* The record this page was published with: inline map, else ./record next to the page. */ + async function openBundled() { + const inline = document.getElementById('automil-record'); + if (inline) { + let map; + try { map = JSON.parse(inline.textContent); } catch (err) { map = null; } + if (map && map['index.json']) return new RecordSource('inline', '', map); + } + const probe = new RecordSource('snapshot', '.'); + const index = await probe.index(); + if (index.mode === 'live') { + const live = new RecordSource('live', location.origin + location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '')); + live.cache.set('index.json', Promise.resolve(index)); + return live; + } + return probe; + } + + function openLocal(origin) { + const base = String(origin || '').replace(/\/+$/, ''); + if (!/^https?:\/\//.test(base)) throw new Error('enter the server address as http://host:port'); + return new RecordSource('live', base); + } + + AM.RecordSource = RecordSource; + AM.openBundled = openBundled; + AM.openLocal = openLocal; +})(window.AM); diff --git a/src/automil/viz/static/js/views/agent.js b/src/automil/viz/static/js/views/agent.js new file mode 100644 index 00000000..6c01ef08 --- /dev/null +++ b/src/automil/viz/static/js/views/agent.js @@ -0,0 +1,330 @@ +/* The agent's sessions: every turn, tool call, result and subagent, with links to the nodes it touched. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + const CHUNK = 100; + + function callSummary(call) { + const input = call.input || {}; + if (call.name === 'Bash') return input.description || String(input.command || '').split('\n')[0]; + if (input.file_path) return input.file_path; + if (input.pattern) return input.pattern; + if (input.description) return input.description; + if (input.prompt) return fmt.truncate(input.prompt, 100); + const keys = Object.keys(input); + return keys.length ? fmt.truncate(JSON.stringify(input), 100) : ''; + } + + function inputText(call) { + const input = call.input || {}; + if (call.name === 'Bash') return input.command || ''; + if (call.name === 'Edit' || call.name === 'Write') { + const parts = [input.file_path]; + if (input.old_string != null) parts.push('--- old', input.old_string, '+++ new', input.new_string); + if (input.content != null) parts.push(input.content); + return parts.filter((p) => p != null).join('\n'); + } + return JSON.stringify(input, null, 2); + } + + function nodeChips(run, ids) { + return (ids || []).map((id) => h('span.tag.link', { text: id, onclick: (event) => { event.stopPropagation(); AM.nodeDrawer.open(run, id); } })); + } + + function renderCall(run, sid, call, ctx) { + const result = call.result; + const status = result ? result.status : 'pending'; + const automil = call.automil; + const created = automil ? automil.created.map((c) => c.node_id) : []; + const ids = created.length ? created : (automil ? automil.node_ids : []); + const chips = ids.length ? nodeChips(run, ids.slice(0, 6)).concat(ids.length > 6 ? [h('span.tag', { text: `+${ids.length - 6}` })] : []) : []; + const body = h('div.body'); + const details = h('details.call', + h('summary', + h('span.result-dot', { class: status, title: status }), + h('span.tool', call.name), + h('span.summary-text', { title: callSummary(call) }, callSummary(call)), + chips.length ? h('span.chips', chips) : null, + ), + body, + ); + let filled = false; + details.addEventListener('toggle', async () => { + if (!details.open || filled) return; + filled = true; + body.append(h('h5', 'input'), h('pre', inputText(call))); + if (!result) { + body.append(h('h5', 'result'), h('div.muted.small', 'still running')); + return; + } + if (result.status === 'missing') { + body.append(h('h5', 'result'), h('div.muted.small', 'no result was recorded (the call was interrupted or the session ended)')); + return; + } + const head = h('h5', `result (${result.status}` + (result.bytes != null ? `, ${fmt.bytes(result.bytes)}` : '') + (result.images ? `, ${result.images} image(s) omitted` : '') + ')'); + const pre = h('pre', result.preview || ''); + body.append(head, pre); + if (result.truncated) { + const button = h('button.quiet', { text: 'Load the full result' }); + button.addEventListener('click', async () => { + button.disabled = true; + try { + const full = await run.source.fullResult(run.id, sid, call.tool_use_id); + pre.textContent = full.text + (full.truncated ? '\n…[capped at 1 MB]' : ''); + button.remove(); + } catch (err) { + button.textContent = `Not available (${err.message})`; + } + }); + body.append(button); + } + if (result.agent_id) { + body.append(await subagentBlock(run, sid, result.agent_id, ctx)); + } + }); + return details; + } + + async function subagentBlock(run, sid, agentId, ctx) { + const box = h('details.subagent', h('summary', `Subagent ${agentId}`)); + let loaded = false; + box.addEventListener('toggle', async () => { + if (!box.open || loaded) return; + loaded = true; + try { + const payload = await run.source.agent(run.id, sid, agentId); + box.querySelector('summary').textContent = `Subagent: ${payload.description || payload.agent_type || agentId} (${payload.turns.length} turns)`; + const list = h('div.turns'); + for (const turn of payload.turns) list.append(renderTurn(run, sid, turn, Object.assign({}, ctx, { nested: true }))); + box.append(list); + } catch (err) { + box.append(h('div.muted.small', `No transcript stored for this subagent (${err.message}).`)); + } + }); + return box; + } + + function renderTurn(run, sid, turn, ctx) { + const el = h('div.turn', { class: turn.kind, id: ctx.nested ? null : `turn-${turn.index}` }); + const who = { assistant: 'Agent', human: 'Operator', notification: 'Notification', system: 'System' }[turn.kind] || turn.kind; + const head = h('div.turn-head', + h('span.who', who), + h('span.idx', `#${turn.index}`), + turn.at ? h('span', fmt.time(turn.at)) : null, + turn.model ? h('span', turn.model) : null, + turn.usage ? h('span', `${fmt.tokens(turn.usage.output)} out`) : null, + ctx.nested ? null : h('a', { href: `#/run/${encodeURIComponent(run.key)}/session/${sid}/turn/${turn.index}`, text: 'link' }), + ); + el.append(head); + if (turn.kind === 'system') { + const s = turn.system || {}; + if (s.subtype === 'compact_boundary') el.append(h('div', `Context compacted: ${fmt.tokens(s.pre_tokens)} tokens became ${fmt.tokens(s.post_tokens)}.`)); + else if (s.subtype === 'compact_summary') el.append(h('details', h('summary', 'Compaction summary'), AM.markdown.element(turn.text))); + else el.append(h('div.small', fmt.truncate(turn.text, 300))); + return el; + } + if (turn.kind === 'notification') { + const note = turn.notification || {}; + el.append(h('details', h('summary', note.title || 'Task notification'), turn.text ? AM.markdown.element(turn.text) : null)); + return el; + } + if (turn.kind === 'human') { + el.append(AM.markdown.element(turn.text, 'text')); + return el; + } + if (turn.thinking && ctx.filters.thinking) { + el.append(h('details.thinking-box', h('summary', 'thinking'), h('div.thinking', turn.thinking))); + } + if (turn.text) el.append(AM.markdown.element(turn.text, 'text')); + if (turn.tool_calls && turn.tool_calls.length && ctx.filters.tools) { + el.append(h('div.calls', turn.tool_calls.map((call) => renderCall(run, sid, call, ctx)))); + } + return el; + } + + /* A turn that only calls tools (no text worth reading) folds with its neighbours. */ + function toolOnly(turn) { + if (turn.kind !== 'assistant') return false; + const text = (turn.text || '').trim(); + return (turn.tool_calls || []).length > 0 && text.length < 80 && !text.includes('\n'); + } + + function foldBlock(run, sid, group, ctx) { + const calls = group.flatMap((t) => t.tool_calls || []); + const byTool = new Map(); + for (const c of calls) byTool.set(c.name, (byTool.get(c.name) || 0) + 1); + const tools = Array.from(byTool.entries()).sort((a, b) => b[1] - a[1]).map(([n, k]) => `${n} \u00d7${k}`).join(', '); + const created = []; + for (const c of calls) for (const cr of (c.automil && c.automil.created) || []) if (!created.includes(cr.node_id)) created.push(cr.node_id); + const errors = calls.filter((c) => c.result && c.result.status === 'error').length; + const first = group[0]; + const last = group[group.length - 1]; + const span = first.at && last.at ? `${fmt.time(first.at)} to ${fmt.time(last.at)}` : ''; + const fold = h('details.fold', { id: `turn-${first.index}` }, + h('summary', + h('span.caret'), + h('span.count', `${group.length} turns, ${calls.length} tool calls`), + h('span.tools', tools), + span ? h('span.span', span) : null, + errors ? h('span.tools', `${errors} failed`) : null, + created.length ? h('span.chips', nodeChips(run, created.slice(0, 8)), created.length > 8 ? h('span.tag', { text: `+${created.length - 8}` }) : null) : null, + ), + ); + let filled = false; + fold.addEventListener('toggle', () => { + if (!fold.open || filled) return; + filled = true; + fold.append(h('div.turns', group.map((t) => renderTurn(run, sid, t, ctx)))); + }); + return fold; + } + + function passes(turn, filters) { + if (turn.kind === 'human') return filters.prompts; + if (turn.kind === 'notification') return filters.notifications; + if (turn.kind === 'system') return filters.system; + if (filters.automilOnly) return turn.tool_calls.some((c) => c.automil); + return true; + } + + function sessionItem(run, session, active) { + const duration = fmt.between(session.first_at, session.last_at); + return h('a.session-item', { class: active ? 'active' : '', href: `#/run/${encodeURIComponent(run.key)}/session/${session.session_id}` }, + h('div', h('span.sid', fmt.shortId(session.session_id)), session.live ? h('span.tag', { text: 'live', style: { marginLeft: '6px' } }) : null), + h('div.meta', `${fmt.when(session.first_at || session.opened_at)}${duration ? ', ' + fmt.duration(duration) : ''}`), + h('div.meta', `${session.n_turns} turns, ${session.n_tool_calls} tool calls, ${session.n_automil_commands} automil commands`), + session.usage ? h('div.meta', `${fmt.tokens(session.usage.output)} output tokens, ${fmt.tokens(session.usage.cache_read + session.usage.input)} read`) : null, + session.source === 'missing' ? h('div.meta', { style: { color: 'var(--coral)' } }, 'transcript not stored') : null, + ); + } + + AM.views.agent = async function (root, ctx) { + const { run } = ctx; + const source = run.source; + const sessions = await source.sessions(run.id); + const page = h('div.page.wide'); + root.append(page); + if (!sessions.sessions.length) { + page.append(h('div.page-title', h('h1', 'Agent')), h('div.empty', 'No agent session is recorded for this run.' + (sessions.journal_error ? ` The activity journal could not be read: ${sessions.journal_error}.` : ''))); + return; + } + const wanted = ctx.route.params.session; + const session = sessions.sessions.find((s) => s.session_id === wanted) || sessions.sessions[0]; + const sid = session.session_id; + const filters = { prompts: true, notifications: true, system: true, tools: true, thinking: true, fold: true, automilOnly: false }; + const turnsEl = h('div.turns'); + const loadMore = h('div.turn-more'); + const listEl = h('div.session-list', sessions.sessions.map((s) => sessionItem(run, s, s.session_id === sid))); + const filterEl = h('div.filters', + ...[['fold', 'fold runs of tool calls'], ['prompts', 'operator prompts'], ['notifications', 'notifications'], ['system', 'system lines'], ['tools', 'tool calls'], ['thinking', 'thinking'], ['automilOnly', 'only turns with automil commands']].map(([key, label]) => { + const box = h('input', { type: 'checkbox', checked: filters[key] || null }); + box.addEventListener('change', () => { filters[key] = box.checked; redraw(); }); + return h('label', box, label); + }), + ); + listEl.append(filterEl); + page.append(h('div.agent', listEl, h('div', h('div.session-head', + h('h3', `Session ${fmt.shortId(sid)}`), + h('span.muted.small.session-cwd', session.cwd ? `in ${session.cwd}` : ''), + h('span.label', { style: { marginLeft: 'auto' } }, session.ended_by ? `ended, ${session.ended_by}` : session.live ? 'live' : 'recorded'), + ), turnsEl, loadMore))); + + let turns = []; + let openTurn = null; + let loadedChunks = 0; + let nChunks = session.n_chunks || 1; + const drawn = new Set(); + function redraw() { + AM.clear(turnsEl); + drawn.clear(); + appendTurns(turns); + if (openTurn) appendOpen(openTurn); + } + function appendTurns(list) { + const ctx = { filters, run }; + let group = []; + const flush = () => { + if (!group.length) return; + if (group.length >= 2) turnsEl.append(foldBlock(run, sid, group, ctx)); + else turnsEl.append(renderTurn(run, sid, group[0], ctx)); + group = []; + }; + for (const turn of list) { + if (drawn.has(turn.index) || !passes(turn, filters)) continue; + drawn.add(turn.index); + if (filters.fold && !filters.automilOnly && toolOnly(turn) && !(turn.tool_calls || []).some((c) => c.automil && c.automil.created.length)) { + group.push(turn); + continue; + } + flush(); + turnsEl.append(renderTurn(run, sid, turn, ctx)); + } + flush(); + } + let openEl = null; + function appendOpen(turn) { + if (openEl) openEl.remove(); + openEl = renderTurn(run, sid, turn, { filters, run }); + openEl.classList.add('open-turn'); + turnsEl.append(openEl); + } + async function loadChunk(k) { + const payload = await source.chunk(run.id, sid, k); + turns = turns.concat(payload.turns.filter((t) => !turns.some((u) => u.index === t.index))); + if (payload.open_turn) openTurn = payload.open_turn; + loadedChunks = Math.max(loadedChunks, k + 1); + nChunks = Math.max(nChunks, Math.ceil(payload.n_turns / CHUNK) || 1); + appendTurns(payload.turns); + if (payload.open_turn) appendOpen(payload.open_turn); + updateMore(); + } + function updateMore() { + AM.clear(loadMore); + if (loadedChunks < nChunks) { + const button = h('button', { text: `Load turns ${loadedChunks * CHUNK}–${Math.min((loadedChunks + 1) * CHUNK, nChunks * CHUNK) - 1}` }); + button.addEventListener('click', () => loadChunk(loadedChunks)); + loadMore.append(button); + } else { + loadMore.append(h('span.muted.small', session.live ? 'Following the session live.' : `End of the session, ${turns.length} turns.`)); + } + } + const target = ctx.route.params.turn; + const firstChunk = Number.isFinite(target) ? Math.floor(target / CHUNK) : 0; + for (let k = 0; k <= firstChunk; k += 1) await loadChunk(k); + if (Number.isFinite(target)) { + const el = document.getElementById(`turn-${target}`); + if (el) { el.classList.add('highlight'); el.scrollIntoView({ block: 'start' }); } + } + const sentinel = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting) && loadedChunks < nChunks) loadChunk(loadedChunks); + }); + sentinel.observe(loadMore); + + const unsubscribe = source.subscribe(async (frame) => { + if (frame.session_id !== sid) return; + if (frame.type === 'transcript_delta') { + if (frame.from_turn !== turns.length) { + const missing = Math.floor(turns.length / CHUNK); + for (let k = missing; k < Math.ceil(frame.n_turns / CHUNK); k += 1) await loadChunk(k); + } else { + turns = turns.concat(frame.turns); + appendTurns(frame.turns); + for (const patch of frame.patches || []) { + const turn = turns.find((t) => t.index === patch.turn); + if (turn) turn.tool_calls[patch.call] = Object.assign({}, turn.tool_calls[patch.call], { result: patch.result }); + } + if (frame.patches && frame.patches.length) redraw(); + } + openTurn = frame.open_turn; + if (openTurn) appendOpen(openTurn); else if (openEl) { openEl.remove(); openEl = null; } + nChunks = Math.max(nChunks, frame.n_chunks || 1); + loadedChunks = nChunks; + updateMore(); + } else if (frame.type === 'transcript_invalidate') { + turns = []; openTurn = null; loadedChunks = 0; AM.clear(turnsEl); drawn.clear(); + await loadChunk(0); + } + }); + return () => { sentinel.disconnect(); unsubscribe(); }; + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/home.js b/src/automil/viz/static/js/views/home.js new file mode 100644 index 00000000..e8765834 --- /dev/null +++ b/src/automil/viz/static/js/views/home.js @@ -0,0 +1,249 @@ +/* Home: what autoMIL is, how a run works, what it leaves behind, the benchmark, how to start. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + + const METHODS = ['CLAM', 'nnMIL', 'ABMIL', 'DTFD']; + + async function heroVisual(source, index) { + const run = (index.runs || [])[0]; + const box = h('div.hero-visual'); + if (!run) { + box.append(h('div.empty', { style: { padding: '24px' } }, 'No run is recorded in this copy of the site yet.')); + return { box, caption: null, destroy() {} }; + } + let handle = null; + try { + const graph = await source.graph(run.run_id); + if (AM.tree3d && AM.tree3d.available()) { + const holder = h('div.gl'); + box.append(holder); + requestAnimationFrame(() => { + try { + handle = AM.tree3d.renderTree3D(holder, graph, { compact: true, rotate: true }); + } catch (err) { + AM.clear(box); + const svg = h('svg'); + box.append(svg); + AM.renderTree(svg, graph, { compact: true, interactive: false }); + } + }); + } else { + const svg = h('svg'); + box.append(svg); + requestAnimationFrame(() => AM.renderTree(svg, graph, { compact: true, interactive: false })); + } + } catch (err) { + box.append(h('div.error-box', 'The tree could not be drawn.')); + } + const caption = h('div.hero-caption', + h('span', `${run.title || run.run_id}: ${run.n_executed} attempts, best ${fmt.num(run.best_primary_value)} from a baseline of ${fmt.num(run.baseline_primary_value)}. `, + h('a', { href: AM.runHref(run.run_id, 'tree'), text: 'Open this run' })), + h('span', 'fig. 1')); + return { box, caption, destroy() { if (handle) handle.destroy(); } }; + } + + async function excerpt(source, index) { + const run = (index.runs || [])[0]; + if (!run || !run.n_sessions) return h('div.muted.small', 'No session is recorded in this copy of the site.'); + try { + const sessions = await source.sessions(run.run_id); + const session = sessions.sessions[0]; + const chunk = await source.chunk(run.run_id, session.session_id, 0); + const assistant = chunk.turns.filter((t) => t.kind === 'assistant'); + const first = assistant.find((t) => t.text.length > 240 && t.tool_calls.some((c) => c.automil && c.automil.created.length)) + || assistant.find((t) => t.text.length > 400) || assistant[0] || chunk.turns[0]; + const box = h('div.excerpt'); + box.append(h('div.turn-head', h('span.who', 'Agent'), h('span.idx', `turn #${first.index} of ${chunk.n_turns}`), h('span', fmt.when(first.at)))); + if (first.text) box.append(AM.markdown.element(fmt.truncate(first.text, 1100))); + const calls = first.tool_calls || []; + const creations = calls.filter((c) => c.automil && c.automil.created.length); + if (creations.length) { + box.append(h('div.small.muted', { style: { marginTop: '8px' } }, 'This turn created ', + ...creations.flatMap((c) => c.automil.created.map((cr) => h('span.tag', { text: cr.node_id, style: { marginRight: '4px' } }))))); + } else if (calls.length) { + box.append(h('div.small.muted', { style: { marginTop: '8px' } }, `${calls.length} tool call${calls.length > 1 ? 's' : ''} in this turn: ${calls.slice(0, 3).map((c) => c.name).join(', ')}${calls.length > 3 ? ', \u2026' : ''}`)); + } + box.append(h('p.small', { style: { marginTop: '12px', marginBottom: 0 } }, h('a', { href: `#/run/${encodeURIComponent(run.run_id)}/session/${session.session_id}/turn/${first.index}`, text: 'Read this session from the start' }))); + return box; + } catch (err) { + return h('div.muted.small', 'The transcript could not be loaded.'); + } + } + + function lane(kind, title, actor, steps) { + return h('div.lane-row', { class: kind }, + h('div', h('h3', h('span.swatch'), title), h('div.actor', actor)), + h('div.lane-steps', steps.map(([head, text], i) => h('div.lane-step', h('b', h('span.n', `0${i + 1}`), head), text))), + ); + } + + function lanesStrip() { + return h('div.lanes-strip', + lane('research', 'Research', 'the agent', + [['Read', 'the code base, the tree and its own notes'], ['Propose', 'a change under the current best node, with its kind declared'], ['Submit', 'the changed files; the agent runs nothing itself']]), + lane('execution', 'Execution', 'the orchestrator', + [['Queue', 'nodes leave in priority order, as many at once as GPU memory allows'], ['Isolate', 'a detached git worktree at the pinned commit, changed files on top'], ['Train', 'the script writes result.json; only its validation block is passed on']]), + lane('evidence', 'Evidence', 'the framework', + [['Recompute', 'the validation value from the per-fold results'], ['Compare', 'the child must beat its parent by max(δ, k · SE) on paired folds'], ['Decide', 'kept: it becomes the next parent; discarded: it stays as a record']]), + lane('test', 'Final test', 'once, at the end', + [['Set aside', 'test metrics go to a closed directory during the search'], ['Select', 'the search ends on validation evidence alone'], ['Reveal', 'automil certify reports the chosen node']]), + ); + } + + const ROWS = [ + { label: 'LUAD KRAS', dataset: 'tcga_luad', task: 'kras' }, + { label: 'PDAC immune', dataset: 'cptac_pdac', task: 'immune_class' }, + { label: 'HNSC grade', dataset: 'tcga_hnsc', task: 'grade' }, + { label: 'LUAD survival', dataset: 'tcga_luad', task: 'os' }, + { label: 'HNSC survival', dataset: 'tcga_hnsc', task: 'os' }, + ]; + const ENCODER_KEYS = ['uni_v2', 'virchow2', 'hoptimus1']; + const METHOD_KEYS = ['clam', 'nnmil', 'abmil', 'dtfd']; + + function methodKey(model) { + const m = String(model || '').toLowerCase(); + if (m.startsWith('clam')) return 'clam'; + if (m.includes('nnmil') || m.includes('simple_mil')) return 'nnmil'; + if (m.includes('abmil')) return 'abmil'; + if (m.includes('dtfd')) return 'dtfd'; + if (m.includes('titan')) return 'titan'; + return m; + } + + function cellGrid(index) { + const recorded = new Set((index.runs || []).map((r) => { + const dataset = String((r.project && r.project.name) || '').toLowerCase(); + return `${dataset}:${String(r.task || '').toLowerCase()}:${String(r.encoder || '').toLowerCase()}:${methodKey(r.mil_model)}`; + })); + const cols = METHOD_KEYS.length * ENCODER_KEYS.length + 1; + const cellW = 36; + const cellH = 26; + const left = 118; + const top = 58; + const width = left + cols * cellW + 20; + const height = top + ROWS.length * cellH + 8; + const svg = h('svg.cell-grid', { viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': 'The 65 cells: five task and cohort pairs by thirteen method configurations' }); + const ns = 'http://www.w3.org/2000/svg'; + function text(x, y, value, cls, anchor) { + const t = document.createElementNS(ns, 'text'); + t.setAttribute('x', x); t.setAttribute('y', y); + if (cls) t.setAttribute('class', cls); + if (anchor) t.setAttribute('text-anchor', anchor); + t.textContent = value; + svg.appendChild(t); + } + METHODS.forEach((m, i) => text(left + (i * ENCODER_KEYS.length + 1.5) * cellW, 18, m, 'head', 'middle')); + const short = ['U', 'V', 'H']; + METHOD_KEYS.forEach((m, i) => ENCODER_KEYS.forEach((e, j) => text(left + (i * ENCODER_KEYS.length + j + 0.5) * cellW, 40, short[j], null, 'middle'))); + text(left + (cols - 0.5) * cellW, 18, 'TITAN', 'head', 'middle'); + text(left + (cols - 0.5) * cellW, 40, 'slide', null, 'middle'); + ROWS.forEach((row, r) => { + text(left - 10, top + r * cellH + cellH * 0.68, row.label, null, 'end'); + for (let col = 0; col < cols; col += 1) { + const rect = document.createElementNS(ns, 'rect'); + rect.setAttribute('x', left + col * cellW + 3); + rect.setAttribute('y', top + r * cellH + 3); + rect.setAttribute('width', cellW - 6); + rect.setAttribute('height', cellH - 6); + rect.setAttribute('rx', 3); + const key = col === cols - 1 + ? `${row.dataset}:${row.task}:titan:titan` + : `${row.dataset}:${row.task}:${ENCODER_KEYS[col % ENCODER_KEYS.length]}:${METHOD_KEYS[Math.floor(col / ENCODER_KEYS.length)]}`; + rect.setAttribute('class', 'cell' + (recorded.has(key) ? ' recorded' : '')); + svg.appendChild(rect); + } + }); + return svg; + } + + AM.views.home = async function (root) { + const source = AM.state.sources.bundled; + const index = source ? await source.index() : { runs: [] }; + const first = (index.runs || [])[0]; + const openRun = first ? AM.runHref(first.run_id, 'tree') : '#/runs'; + const hero = await heroVisual(source, index); + + const gain = first && first.best_primary_value != null && first.baseline_primary_value != null ? first.best_primary_value - first.baseline_primary_value : null; + const sessions = first ? await source.sessions(first.run_id).catch(() => null) : null; + const calls = sessions && sessions.sessions.length ? sessions.sessions.reduce((n, s) => n + (s.n_tool_calls || 0), 0) : null; + root.append(h('div.band.hero-band', h('div.inner', h('section.hero', + h('div.hero-text', + h('div.label', '01 / a framework for agentic MIL research'), + h('h1', 'A coding agent runs the experiment loop on your existing code base.'), + h('p.lead', 'It proposes a change, submits it as an experiment, reads the validation result and keeps or discards it. Every attempt, decision and transcript is recorded and shown here.'), + h('div.actions', h('a.button.primary.large', { href: openRun, text: first ? 'Open a recorded run \u2192' : 'Runs' }), h('a.button.large', { href: '#/?scroll=get-started', text: 'Install' })), + first ? h('div.hero-stats', + h('div', h('div.value', String(first.n_executed)), h('div.what', 'attempts in the recorded run')), + h('div', h('div.value.teal', gain != null ? fmt.delta(gain, 3) : '\u2013'), h('div.what', 'over the baseline, validation')), + h('div', h('div.value', calls != null ? String(calls) : '\u2013'), h('div.what', 'tool calls recorded')), + ) : null, + ), + h('div.hero-figure', hero.box, hero.caption), + )))); + + root.append(h('div.band', { id: 'how' }, h('div.inner', + h('div.index', '02 / how a run works'), + h('h2', 'How a run works'), + h('p.lead', 'autoMIL overlays one directory onto an existing repository. Four lanes share the work: the agent proposes, the orchestrator runs, the framework judges, and the test set waits until the end.'), + lanesStrip(), + h('p.figure-link', 'The same mechanism as drawn in the preprint: ', h('a', { href: './static/img/fig1_overview.png', target: '_blank', rel: 'noopener', text: 'Figure 1' }), '.'), + ))); + + root.append(h('div.band', { id: 'record' }, h('div.inner', + h('div.index', '03 / the record'), + h('h2', 'What a run leaves behind'), + h('p.lead', 'Everything on this site is read from files in the project directory, the same files the framework works from.'), + h('div.cols-2.uneven', + h('div.record-list', + h('div.record-item', h('div.path', 'automil/graph.json'), h('div.what', 'The tree: every proposal and attempt, its parent, its validation value and the keep or discard status.')), + h('div.record-item', h('div.path', 'automil/orchestrator/archive//'), h('div.what', 'The changed files, the launched spec, the validation result and the run log of one attempt.')), + h('div.record-item', h('div.path', 'automil/sessions//'), h('div.what', 'The agent session as the runtime recorded it: every message, tool call, result and subagent.')), + h('div.record-item', h('div.path', 'automil/plan.md, learnings.md'), h('div.what', 'What the agent planned to try and what it concluded from each batch.')), + ), + await excerpt(source, index), + ), + ))); + + root.append(h('div.band', { id: 'benchmark' }, h('div.inner', + h('div.index', '04 / the benchmark'), + h('h2', 'The benchmark'), + h('p.lead', 'Does the ranking of pathology MIL methods change when every method gets the same search opportunity? Four aggregators on three encoders, plus TITAN at slide level, across five task and cohort pairs, on frozen features and splits.'), + h('div.stat-row', + h('div.stat', h('div.value', '3'), h('div.label', 'cohorts: TCGA-LUAD, CPTAC-PDAC, TCGA-HNSC')), + h('div.stat', h('div.value', '13'), h('div.label', 'method configurations per task')), + h('div.stat', h('div.value', '65'), h('div.label', 'cells, each with a native and a searched arm')), + h('div.stat', h('div.value', '30'), h('div.label', 'attempts per searched cell on three folds')), + ), + cellGrid(index), + h('p.grid-note', 'Columns per aggregator: U = UNI2-h, V = Virchow2, H = H-optimus-1. Filled cells are recorded on this site. The campaign is in progress: these are rehearsal cells and no ranking result is reported. The ten best recipes of a cell are re-run on the two remaining folds; the winner is the best five-fold validation mean, with the native baseline in the pool.'), + ))); + + root.append(h('div.band', { id: 'get-started' }, h('div.inner', + h('div.index', '05 / get started'), + h('h2', 'Get started'), + h('div.cols-2', + h('ol.steps', + h('li', 'Install the framework.', h('span.note', 'Python 3.10 or newer; uv recommended.')), + h('li', 'Overlay it onto a project whose training script writes a result.json.', h('span.note', 'automil check validates the setup.')), + h('li', 'Start the orchestrator and the dashboard, each in its own terminal.'), + h('li', 'Run the agent in a third terminal and let it work.', h('span.note', 'Claude Code with the /automil skill; Codex and OpenCode runtimes are supported too.')), + h('li', 'On a remote host, forward the dashboard port, or connect this site to it.', h('span.note', h('a', { href: '#/remote', text: 'Remote access in detail' }))), + ), + h('div.code-panel', { html: + '# install\nuv tool install git+https://github.com/leoyin1127/autoMIL.git\n\n' + + '# overlay onto your project\ncd /path/to/your/project\nautomil init\nautomil check\n\n' + + '# run (three terminals)\nautomil orchestrator start\nautomil viz start\nclaude --dangerously-skip-permissions # then /automil-setup, /automil\n\n' + + '# from your own machine\nssh -N -L 8420:127.0.0.1:8420 user@gpu-host' }), + ), + ))); + + root.append(h('div.band', { id: 'cite' }, h('div.inner', + h('div.index', '06 / authors'), + h('h2', 'Authors'), + h('p.authors', 'Shuolin Yin, Yeonwoo Seo and Jun Ma. The framework is released under the Apache-2.0 licence; the code and the issue tracker are on GitHub.'), + h('pre', '@software{automil,\n title = {autoMIL},\n author = {Yin, Shuolin and Seo, Yeonwoo and Ma, Jun},\n year = {2026},\n url = {https://github.com/leoyin1127/autoMIL}\n}'), + ))); + return () => hero.destroy(); + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/node.js b/src/automil/viz/static/js/views/node.js new file mode 100644 index 00000000..e8f356c7 --- /dev/null +++ b/src/automil/viz/static/js/views/node.js @@ -0,0 +1,179 @@ +/* The node drawer: one attempt in full, with the verdict in words and the link to the agent's turn. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + + function section(title, ...children) { + return [h('h4', title), ...children]; + } + + function metricsList(metrics) { + const entries = Object.entries(metrics || {}).filter(([k]) => k !== 'primary_value'); + if (!entries.length) return h('div.muted.small', 'no validation metrics recorded'); + return h('dl.kv', entries.map(([k, v]) => [h('dt', k), h('dd', typeof v === 'number' ? fmt.num(v) : String(v))])); + } + + function foldValues(node) { + if (Array.isArray(node.fold_primary_values)) { + return new Map(node.fold_primary_values.map((f) => [f.fold_index, f.primary_value])); + } + const folds = node.metadata && node.metadata.validation_folds; + if (Array.isArray(folds)) return new Map(folds.map((f) => [f.fold_index, f.primary_value])); + return new Map(); + } + + function foldTable(detail) { + const child = foldValues(detail.node); + const parent = detail.parent ? foldValues(detail.parent) : new Map(); + if (!child.size) return null; + const indices = Array.from(child.keys()).sort((a, b) => a - b); + return h('table.fold-table', + h('thead', h('tr', h('th', 'fold'), h('th.num', 'this node'), parent.size ? h('th.num', 'parent') : null, parent.size ? h('th.num', 'delta') : null)), + h('tbody', indices.map((i) => { + const c = child.get(i); + const p = parent.get(i); + return h('tr', h('td', String(i)), h('td.num', fmt.num(c)), + parent.size ? h('td.num', fmt.num(p)) : null, + parent.size ? h('td.num', { class: p != null && c - p > 0 ? 'delta-pos' : p != null && c - p < 0 ? 'delta-neg' : '' }, p != null ? fmt.delta(c - p) : '–') : null); + })), + ); + } + + function verdictBox(detail) { + const v = detail.verdict; + if (!v) return h('div.muted.small', `No keep or discard verdict: ${detail.verdict_unavailable || 'not judged'}.`); + const rows = []; + if (v.parent_id) { + rows.push([h('dt', 'parent'), h('dd', v.parent_id + ' at ' + fmt.num(v.parent_primary_value))]); + rows.push([h('dt', 'change'), h('dd', fmt.delta(v.delta))]); + rows.push([h('dt', 'bar to clear'), h('dd', fmt.num(v.bar) + ` (max of δ ${fmt.num(v.accept_margin)} and ${fmt.num(v.se_multiplier, 1)} × ${v.basis} SE ${v.basis_se != null ? fmt.num(v.basis_se) : 'n/a'})`)]); + if (v.guard && v.guard.verdict !== 'none') rows.push([h('dt', 'guard ' + (v.guard.metric || '')), h('dd', `${v.guard.delta != null ? fmt.delta(v.guard.delta) : 'unreported'}${v.guard.margin != null ? ' against a bar of ' + fmt.num(v.guard.margin) : ''}: ${v.guard.verdict}${v.guard.decisive ? ', decided the outcome' : ''}`)]); + } + if (!v.consistent) rows.push([h('dt', 'note'), h('dd', `stored status ${v.stored_status} differs from the recomputed decision`)]); + return h('div.verdict', { class: v.decision }, h('div', v.explanation), rows.length ? h('dl.kv', { style: { marginTop: '8px' } }, rows) : null); + } + + function timing(detail) { + const t = detail.timing || {}; + const rows = [ + ['submitted', fmt.when(t.submitted_at)], ['launched', fmt.when(t.launched_at)], ['finished', fmt.when(t.completed_at)], + ['queue wait', fmt.duration(t.queue_wait_s)], ['run time', fmt.duration(t.run_s)], ['slot', t.slot || '–'], + ]; + const r = detail.result || {}; + if (r.peak_vram_mb != null) rows.push(['peak VRAM', `${fmt.int(r.peak_vram_mb)} MB`]); + return h('dl.kv', rows.map(([k, v]) => [h('dt', k), h('dd', v)])); + } + + function filesList(run, detail) { + const overlay = detail.overlay || {}; + const items = []; + if (overlay.run_command_override) items.push(h('div.small', h('span.muted', 'run command: '), h('code', overlay.run_command_override))); + if (!overlay.files || !overlay.files.length) { + items.push(h('div.muted.small', 'no source files changed (configuration-only attempt)')); + return h('div', items); + } + const list = h('div.file-list'); + for (const file of overlay.files) { + const holder = h('div'); + const button = h('button.quiet', { text: file.path }); + let open = false; + button.addEventListener('click', async () => { + if (open) { holder.querySelector('pre') && holder.querySelector('pre').remove(); open = false; return; } + try { + const payload = await run.source.file(run.id, detail.node_id, file.path); + holder.append(h('pre', payload.text + (payload.truncated ? '\n…[file truncated]' : ''))); + open = true; + } catch (err) { + holder.append(h('div.error-box', `The file could not be loaded (${err.message}).`)); + } + }); + holder.append(button); + list.append(holder); + } + items.push(list); + if (overlay.base_commit) items.push(h('div.small.muted', 'base commit ', h('code', String(overlay.base_commit).slice(0, 12)))); + return h('div', items); + } + + function logTail(detail) { + const log = detail.run_log || {}; + if (!log.available) return h('div.muted.small', log.reason === 'not terminal' ? 'The run log is shown once the attempt has finished.' : 'No run log recorded.'); + const details = h('details', h('summary', { class: 'small' }, `last ${log.tail_lines} of ${fmt.int(log.n_lines_total)} lines` + (log.redacted_lines ? `, ${log.redacted_lines} redacted` : '')), + h('pre.log-tail', log.tail.join('\n'))); + return details; + } + + function agentLinks(run, detail) { + const links = detail.agent || []; + if (!links.length) return h('div.muted.small', 'No transcript turn names this node.'); + const labels = { propose: 'proposed', submit: 'submitted', resubmit: 'resubmitted', mention: 'mentioned' }; + return h('ul', { style: { paddingLeft: '1.1em', margin: 0 } }, links.map((l) => + h('li.small', `${labels[l.kind] || l.kind} at turn ${l.turn} `, + h('a', { href: `#/run/${encodeURIComponent(run.key)}/session/${l.session_id}/turn/${l.turn}`, text: `(${fmt.when(l.at)})` })), + )); + } + + const drawer = { + el: null, + current: null, + init() { + if (this.el) return; + this.el = document.getElementById('drawer'); + document.addEventListener('keydown', (event) => { if (event.key === 'Escape') this.close(); }); + }, + async open(run, nodeId, options) { + this.init(); + const opts = options || {}; + this.current = nodeId; + const body = h('div.drawer-body', h('div.skeleton', { style: { height: '120px' } })); + AM.clear(this.el).append( + h('div.drawer-head', h('h2', nodeId), h('span.small.muted', 'loading'), h('button.quiet.close', { text: 'Close', onclick: () => this.close() })), + body, + ); + this.el.classList.add('open'); + let detail; + try { + detail = await run.source.node(run.id, nodeId); + } catch (err) { + AM.clear(body).append(h('div.error-box', `This node could not be loaded (${err.message}).`)); + return; + } + if (this.current !== nodeId) return; + const node = detail.node; + const graph = await run.source.graph(run.id); + const parentDelta = detail.verdict && detail.verdict.delta != null ? detail.verdict.delta : null; + const metric = (graph.meta && graph.meta.scoring && graph.meta.scoring.formula) || 'primary value'; + AM.clear(this.el).append( + h('div.drawer-head', h('h2', nodeId), h('span.small', h('span.status-dot', { class: AM.statusClass(node.status) }), AM.statusLabel(node.status)), + h('button.quiet.close', { text: 'Close', onclick: () => this.close() })), + h('div.drawer-body', + node.type === 'executed' ? h('div.big-number', fmt.num(node.primary_value), h('small', metric + (parentDelta != null ? `, ${fmt.delta(parentDelta)} vs parent` : ''))) : null, + h('p', { style: { marginTop: '8px' } }, node.description || ''), + node.kind ? h('div.small.muted', [node.kind, node.tier ? `tier ${node.tier}` : null, node.techniques && node.techniques.length ? node.techniques.join(', ') : null].filter(Boolean).join(' · ')) : null, + ...section('Decision', verdictBox(detail)), + ...section('Validation metrics', metricsList(node.metrics)), + foldTable(detail) ? h('div', ...section('Per fold', foldTable(detail))) : null, + ...section('Timing', timing(detail)), + ...section('Files changed', filesList(run, detail)), + ...section('Run log', logTail(detail)), + ...section('In the transcript', agentLinks(run, detail)), + ...section('Lineage', h('div.small', AM.lineage(graph.nodes, nodeId).map((n, i, arr) => [ + h('span.tag.link', { text: n.id, onclick: () => this.open(run, n.id, opts) }), i < arr.length - 1 ? ' → ' : '', + ]))), + node.type === 'executed' ? h('div.small', { style: { marginTop: '16px' } }, h('a', { href: AM.runHref(run.key, 'tree') + `/node/${nodeId}`.replace('/tree/node', '/node'), text: 'Show in the tree' })) : null, + ), + ); + if (opts.onVerdict) { + const v = detail.verdict; + opts.onVerdict(v && v.parent_id ? { node_id: nodeId, parent_value: v.parent_primary_value, bar: v.bar } : null); + } + }, + close() { + if (!this.el) return; + this.el.classList.remove('open'); + this.current = null; + }, + }; + + AM.nodeDrawer = drawer; +})(window.AM); diff --git a/src/automil/viz/static/js/views/nodes.js b/src/automil/viz/static/js/views/nodes.js new file mode 100644 index 00000000..bd41b9b9 --- /dev/null +++ b/src/automil/viz/static/js/views/nodes.js @@ -0,0 +1,87 @@ +/* Nodes: every attempt as a sortable table, and technique effectiveness when it was tracked. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + + const COLUMNS = [ + { key: 'id', label: 'Node', get: (n) => n.id, render: (n) => h('td.mono', n.id) }, + { key: 'status', label: 'Status', get: (n) => n.status, render: (n) => h('td', { style: { whiteSpace: 'nowrap' } }, h('span.status-dot', { class: AM.statusClass(n.status) }), AM.statusLabel(n.status)) }, + { key: 'description', label: 'Description', get: (n) => n.description || '', render: (n) => h('td.desc-cell', { title: n.description || '' }, n.description || '') }, + { key: 'primary_value', label: 'Value', num: true, get: (n) => n.primary_value, render: (n) => h('td.num', n.type === 'executed' ? fmt.num(n.primary_value) : '–') }, + { key: 'parent_delta', label: 'vs parent', num: true, get: (n, g) => delta(n, g), render: (n, g) => { const d = delta(n, g); return h('td.num', { class: d > 0 ? 'delta-pos' : d < 0 ? 'delta-neg' : '' }, d == null ? '–' : fmt.delta(d)); } }, + { key: 'primary_se', label: 'SE', num: true, get: (n) => n.primary_se, render: (n) => h('td.num', n.primary_se != null ? fmt.num(n.primary_se) : '–') }, + { key: 'kind', label: 'Kind', get: (n) => n.kind || '', render: (n) => h('td', n.kind || '') }, + { key: 'elapsed_min', label: 'Minutes', num: true, get: (n) => n.elapsed_min, render: (n) => h('td.num', n.elapsed_min != null ? fmt.num(n.elapsed_min, 1) : '–') }, + { key: 'vram_gb', label: 'VRAM GB', num: true, get: (n) => n.vram_gb, render: (n) => h('td.num', n.vram_gb != null ? fmt.num(n.vram_gb, 1) : '–') }, + { key: 'created_at', label: 'Created', get: (n) => n.created_at || '', render: (n) => h('td', fmt.when(n.created_at)) }, + ]; + + function delta(n, graph) { + const parent = n.parent_id ? graph.nodes[n.parent_id] : null; + if (!parent || parent.primary_value == null || n.primary_value == null || n.type !== 'executed') return null; + return n.primary_value - parent.primary_value; + } + + function techniques(stats) { + const keys = Object.keys(stats || {}); + if (!keys.length) return null; + keys.sort((a, b) => (stats[b].times_tried || 0) - (stats[a].times_tried || 0)); + return h('section', { style: { marginTop: '40px' } }, h('h2', 'Techniques'), + h('p', 'Aggregated over every attempt that declared the technique.'), + h('div.table-wrap', h('table', h('thead', h('tr', h('th', 'Technique'), h('th.num', 'Tried'), h('th.num', 'Best gain vs parent'), h('th.num', 'Mean gain vs parent'))), + h('tbody', keys.map((k) => { + const s = stats[k]; + const best = s.best_parent_delta === -Infinity || s.best_parent_delta == null ? 0 : s.best_parent_delta; + return h('tr', h('td', k), h('td.num', String(s.times_tried || 0)), h('td.num', fmt.delta(best)), h('td.num', fmt.delta(s.avg_parent_delta))); + }))))); + } + + AM.views.nodes = async function (root, ctx) { + const { run } = ctx; + const source = run.source; + let graph = await source.graph(run.id); + let sortKey = 'created_at'; + let sortDir = 'asc'; + let statusFilter = 'all'; + const tbody = h('tbody'); + const thead = h('thead'); + const count = h('span.muted'); + const select = h('select', ['all', 'executed', 'keep', 'discard', 'pending', 'running', 'crash'].map((s) => h('option', { value: s }, s === 'all' ? 'all nodes' : s === 'executed' ? 'executed only' : AM.statusLabel(s)))); + select.addEventListener('change', () => { statusFilter = select.value; render(); }); + const page = h('div.page', + h('div.page-title', h('h1', 'Nodes'), h('p', 'Every proposal and attempt in the tree. Click a row for the full record of that node.')), + h('div.table-tools', h('label', 'Show ', select), count), + h('div.table-wrap', h('table', thead, tbody)), + h('div', { id: 'techniques' }), + ); + root.append(page); + + function render() { + let rows = Object.values(graph.nodes); + if (statusFilter === 'executed') rows = rows.filter((n) => n.type === 'executed'); + else if (statusFilter !== 'all') rows = rows.filter((n) => n.status === statusFilter); + const column = COLUMNS.find((c) => c.key === sortKey); + rows.sort((a, b) => { + const va = column.get(a, graph); + const vb = column.get(b, graph); + const cmp = va == null ? 1 : vb == null ? -1 : typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb)); + return sortDir === 'asc' ? cmp : -cmp; + }); + AM.clear(thead).append(h('tr', COLUMNS.map((c) => h('th.sortable', { class: (c.num ? 'num ' : '') + (c.key === sortKey ? sortDir : ''), onclick: () => { + if (sortKey === c.key) sortDir = sortDir === 'asc' ? 'desc' : 'asc'; else { sortKey = c.key; sortDir = c.num ? 'desc' : 'asc'; } + render(); + } }, c.label)))); + AM.append(AM.clear(tbody), rows.map((n) => h('tr.clickable', { onclick: () => AM.nodeDrawer.open(run, n.id) }, COLUMNS.map((c) => c.render(n, graph))))); + count.textContent = `${rows.length} of ${Object.keys(graph.nodes).length}`; + const tech = techniques(graph.technique_stats); + const holder = document.getElementById('techniques'); + AM.clear(holder); + if (tech) holder.append(tech); + } + render(); + const unsubscribe = source.subscribe(async (frame) => { + if (frame.type === 'graph_update') { graph = await source.graph(run.id); render(); } + }); + return () => { unsubscribe(); AM.nodeDrawer.close(); }; + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/notes.js b/src/automil/viz/static/js/views/notes.js new file mode 100644 index 00000000..8562ea92 --- /dev/null +++ b/src/automil/viz/static/js/views/notes.js @@ -0,0 +1,38 @@ +/* Notes: the plan and the learnings the agent wrote during the run. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + + AM.views.notes = async function (root, ctx) { + const { run } = ctx; + const source = run.source; + let notes = await source.notes(run.id); + const body = h('div'); + const tabs = h('div.notes-tabs'); + let current = 'plan_md'; + const page = h('div.page.narrow', + h('div.page-title', h('h1', 'Notes'), h('p', 'The agent keeps two files as it works: a plan of what it will try and why, and the learnings it draws from each batch of results.')), + tabs, body, + ); + root.append(page); + function render() { + AM.append(AM.clear(tabs), [ + h('button', { class: current === 'plan_md' ? 'active' : '', text: 'plan.md', onclick: () => { current = 'plan_md'; render(); } }), + h('button', { class: current === 'learnings_md' ? 'active' : '', text: 'learnings.md', onclick: () => { current = 'learnings_md'; render(); } }), + notes.mtime ? h('span.muted.small', { style: { marginLeft: 'auto', alignSelf: 'center' } }, `last written ${fmt.when(notes.mtime * 1000)}`) : null, + ]); + AM.clear(body); + const text = notes[current]; + body.append(text ? AM.markdown.element(text) : h('div.empty', 'This file is empty or was not written.')); + } + render(); + const unsubscribe = source.subscribe(async (frame) => { + if (frame.type === 'graph_update' || frame.type === 'transcript_delta') { + source.invalidate(`runs/${run.id}/notes.json`); + notes = await source.notes(run.id); + render(); + } + }); + return () => unsubscribe(); + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/progress.js b/src/automil/viz/static/js/views/progress.js new file mode 100644 index 00000000..14399ecb --- /dev/null +++ b/src/automil/viz/static/js/views/progress.js @@ -0,0 +1,114 @@ +/* Primary value by attempt: each executed node in submission order, the best value so far, the baseline. */ +(function (AM) { + 'use strict'; + const { esc, fmt } = AM; + const KEEP_CLASS = new Set(['keep', 'candidate', 'registered']); + const FAILED = new Set(['crash', 'oom', 'timeout', 'cancelled', 'partial']); + + function progressChart(svg, graph, options) { + const opts = Object.assign({ selected: null, onSelect: null }, options || {}); + const sel = d3.select(svg).classed('chart-svg', true); + sel.selectAll('*').remove(); + const box = svg.getBoundingClientRect(); + const width = box.width || 600; + const height = box.height || 400; + const margin = { top: 16, right: 18, bottom: 40, left: 52 }; + const innerW = Math.max(60, width - margin.left - margin.right); + const innerH = Math.max(60, height - margin.top - margin.bottom); + const g = sel.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + const ordered = AM.executedInOrder(graph); + if (!ordered.length) { + sel.append('text').attr('x', width / 2).attr('y', height / 2).attr('text-anchor', 'middle').text('no executed attempt yet'); + return { update() {}, setBand() {} }; + } + const scored = ordered.filter((n) => !FAILED.has(n.status) && n.primary_value != null); + const values = scored.map((n) => n.primary_value); + const baseline = ordered.find((n) => !n.parent_id); + const baseValue = baseline && baseline.primary_value != null ? baseline.primary_value : null; + if (baseValue != null) values.push(baseValue); + let [lo, hi] = values.length ? d3.extent(values) : [0, 1]; + if (lo === hi) { lo -= 0.01; hi += 0.01; } + const padY = (hi - lo) * 0.12; + const x = d3.scalePoint().domain(ordered.map((_, i) => i)).range([0, innerW]).padding(0.6); + const y = d3.scaleLinear().domain([lo - padY, hi + padY]).range([innerH, 0]).nice(); + const failedY = innerH + 14; + + g.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(5)).enter().append('line') + .attr('x1', 0).attr('x2', innerW).attr('y1', (d) => y(d)).attr('y2', (d) => y(d)); + g.append('g').attr('class', 'axis').call(d3.axisLeft(y).ticks(5).tickFormat(d3.format('.3f')).tickSize(0)).select('.domain').remove(); + const tickEvery = ordered.length > 40 ? 10 : ordered.length > 16 ? 5 : 1; + g.append('g').attr('class', 'axis').attr('transform', `translate(0,${innerH})`) + .call(d3.axisBottom(x).tickValues(x.domain().filter((i) => i % tickEvery === 0 || i === ordered.length - 1)).tickFormat((i) => String(i + 1)).tickSize(3)); + g.append('text').attr('class', 'label').attr('x', innerW).attr('y', innerH + 32).attr('text-anchor', 'end').text('attempt, in submission order'); + + if (baseValue != null) { + g.append('line').attr('class', 'baseline').attr('x1', 0).attr('x2', innerW).attr('y1', y(baseValue)).attr('y2', y(baseValue)); + g.append('text').attr('x', innerW).attr('y', y(baseValue) - 4).attr('text-anchor', 'end').text('baseline'); + } + + /* best kept value so far, as a step line */ + let best = -Infinity; + const steps = []; + ordered.forEach((n, i) => { + if (KEEP_CLASS.has(n.status) && n.primary_value != null && n.primary_value > best) best = n.primary_value; + if (best > -Infinity) steps.push([i, best]); + }); + if (steps.length) { + const line = d3.line().x((d) => x(d[0])).y((d) => y(d[1])).curve(d3.curveStepAfter); + g.append('path').attr('class', 'best').attr('d', line(steps)); + } + + const band = g.append('rect').attr('class', 'band').attr('width', 0).attr('height', 0); + + const points = g.selectAll('g.pt').data(ordered).enter().append('g').attr('class', 'pt'); + points.filter((n) => !FAILED.has(n.status) && n.primary_value != null).append('circle') + .attr('class', 'point') + .attr('cx', (n, i) => x(ordered.indexOf(n))) + .attr('cy', (n) => y(n.primary_value)) + .attr('r', 6) + .attr('fill', (n) => AM.statusColor(n.status)) + .classed('kept', (n) => KEEP_CLASS.has(n.status)); + points.filter((n) => FAILED.has(n.status) || n.primary_value == null).append('path') + .attr('class', 'point') + .attr('d', 'M-5,-5L5,5M-5,5L5,-5') + .attr('transform', (n) => `translate(${x(ordered.indexOf(n))},${failedY})`) + .attr('stroke', (n) => AM.statusColor(n.status)) + .attr('stroke-width', 1.5); + g.append('text').attr('x', -6).attr('y', failedY + 4).attr('text-anchor', 'end').text('failed'); + + points.style('cursor', 'pointer') + .on('click', (event, n) => { if (opts.onSelect) opts.onSelect(n.id); }) + .on('mousemove', (event, n) => { + const parent = n.parent_id ? graph.nodes[n.parent_id] : null; + const delta = parent && parent.primary_value != null && n.primary_value != null ? n.primary_value - parent.primary_value : null; + AM.tooltip.show( + `${esc(n.id)} ${esc(AM.statusLabel(n.status))}
${esc(fmt.truncate(n.description, 90))}
` + + `${esc(fmt.num(n.primary_value))}${delta != null ? ' (' + esc(fmt.delta(delta)) + ' vs parent)' : ''}`, + event.clientX, event.clientY, + ); + }) + .on('mouseleave', () => AM.tooltip.hide()); + + function update(selectedId) { + points.selectAll('.point').classed('selected', function () { + return d3.select(this.parentNode).datum().id === selectedId; + }); + if (!selectedId) band.attr('width', 0); + } + function setBand(info) { + /* info: {node_id, parent_value, bar} from the node's verdict */ + if (!info || info.parent_value == null || info.bar == null) { band.attr('width', 0); return; } + const index = ordered.findIndex((n) => n.id === info.node_id); + if (index < 0) { band.attr('width', 0); return; } + const half = Math.max(6, x.step() / 2); + const top = y(info.parent_value + info.bar); + const bottom = y(info.parent_value); + band.attr('x', x(index) - half).attr('width', half * 2).attr('y', Math.min(top, bottom)).attr('height', Math.abs(bottom - top)); + } + update(opts.selected); + return { update, setBand }; + } + + AM.progressChart = progressChart; +})(window.AM); diff --git a/src/automil/viz/static/js/views/remote.js b/src/automil/viz/static/js/views/remote.js new file mode 100644 index 00000000..8e097aed --- /dev/null +++ b/src/automil/viz/static/js/views/remote.js @@ -0,0 +1,41 @@ +/* Remote access: reaching a dashboard that runs on another machine. */ +(function (AM) { + 'use strict'; + const { h } = AM; + + AM.views.remote = async function (root) { + const page = h('div.page.narrow', + h('div.page-title', h('h1', 'Remote access'), h('p', 'The dashboard runs where the project runs. It listens on the loopback interface of that host only, so a browser elsewhere reaches it through an SSH port forward.')), + h('section.section', + h('h2', 'On the host'), + h('pre', 'cd \nuv run automil viz start'), + h('p', 'The command prints the address and, when it sees that you are logged in over SSH, the exact forward to run on your own machine.'), + ), + h('section.section', + h('h2', 'From your own machine'), + h('p', 'One host, reachable directly:'), + h('pre', 'ssh -N -L 8420:127.0.0.1:8420 user@gpu-host\n# then open http://localhost:8420'), + h('p', 'A compute node behind a login node (a cluster with a scheduler):'), + h('pre', 'ssh -N -L 8420::8420 user@login-node\n# the dashboard must bind that node\'s address: viz.host in automil/config.yaml, or --host'), + h('p', 'A jump host in between:'), + h('pre', 'ssh -N -J user@jump -L 8420:127.0.0.1:8420 user@gpu-host'), + h('p', 'VS Code and similar editors forward the port on their own when they see the address in the terminal.'), + ), + h('section.section', + h('h2', 'Two ways to view it'), + h('p', 'Open http://localhost:8420: the host serves this same site with your run as its only run.'), + h('p', 'Or stay on this site and connect it to the forwarded port from the ', h('a', { href: '#/runs', text: 'runs page' }), '. The page reads http://localhost:8420 directly; the host allows that only for this site and for pages served from localhost. Chrome asks once for permission to reach the local network. Safari does not allow a public page to read a local address; use the first way there.'), + ), + h('section.section', + h('h2', 'Sharing a run without a tunnel'), + h('pre', 'uv run automil viz export --out ./site --single-file ./run.html'), + h('p', 'The directory is the complete site with the run recorded inside it and can be served by any static file server. The single file opens from disk.'), + ), + h('section.section', + h('h2', 'Ports'), + h('p', 'The dashboard uses 8420 by default (viz.port in automil/config.yaml, or --port). Two projects on one host need two ports and two forwards.'), + ), + ); + root.append(page); + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/runs.js b/src/automil/viz/static/js/views/runs.js new file mode 100644 index 00000000..4453c121 --- /dev/null +++ b/src/automil/viz/static/js/views/runs.js @@ -0,0 +1,94 @@ +/* Runs: every recorded run, and the control that connects a local server. */ +(function (AM) { + 'use strict'; + const { h, fmt } = AM; + + function deltaCell(entry) { + if (entry.best_primary_value == null || entry.baseline_primary_value == null) return h('td.num', '–'); + const delta = entry.best_primary_value - entry.baseline_primary_value; + return h('td.num', { class: delta > 0 ? 'delta-pos' : delta < 0 ? 'delta-neg' : '' }, fmt.delta(delta)); + } + + function runsTable(index, prefix) { + const runs = index.runs || []; + if (!runs.length) return h('div.empty', 'No runs in this record yet.'); + const rows = runs.map((entry) => { + const key = prefix + entry.run_id; + return h('tr.clickable', { onclick: () => AM.navigate(AM.runHref(key, 'tree')) }, + h('td', h('a', { href: AM.runHref(key, 'tree'), text: entry.title || entry.run_id })), + h('td', [entry.task, entry.encoder, entry.mil_model].filter(Boolean).join(' / ') || '–'), + h('td.num', String(entry.n_executed)), + h('td.num', fmt.num(entry.baseline_primary_value)), + h('td.num', fmt.num(entry.best_primary_value)), + deltaCell(entry), + h('td.num', String(entry.n_sessions) + (entry.live_sessions ? ' (live)' : '')), + h('td', entry.campaign_phase || (entry.n_running ? `${entry.n_running} running` : entry.live_sessions ? 'in progress' : 'recorded')), + h('td', fmt.when(entry.ended_at, { hour: undefined, minute: undefined })), + ); + }); + return h('div.table-wrap', h('table', + h('thead', h('tr', + h('th', 'Run'), h('th', 'Task / encoder / model'), h('th.num', 'Attempts'), h('th.num', 'Baseline'), + h('th.num', 'Best'), h('th.num', 'Gain'), h('th.num', 'Sessions'), h('th', 'State'), h('th', 'Last node'))), + h('tbody', rows), + )); + } + + function connectPanel() { + const saved = AM.storage('am.local') || 'http://127.0.0.1:8420'; + const input = h('input', { type: 'url', value: saved, placeholder: 'http://127.0.0.1:8420', 'aria-label': 'Local server address' }); + const status = h('div.small.muted', { id: 'local-status' }); + const list = h('div', { id: 'local-runs' }); + const button = h('button.primary', { text: 'Connect' }); + const forget = h('button.quiet', { text: 'Disconnect' }); + + async function connect(origin, quiet) { + status.textContent = 'Connecting…'; + let source; + try { + source = AM.openLocal(origin); + const index = await source.index(); + AM.state.sources.local = source; + AM.storage('am.local', source.base); + status.textContent = `Connected to ${source.base}: ${index.runs.length} run(s).`; + AM.clear(list).append(runsTable(index, 'local:')); + } catch (err) { + AM.state.sources.local = null; + AM.clear(list); + status.textContent = quiet + ? `No server answered at ${origin}.` + : `Could not read ${origin}/record/index.json (${err.message}). Is the tunnel up? See Remote access.`; + } + } + button.addEventListener('click', () => connect(input.value.trim())); + input.addEventListener('keydown', (event) => { if (event.key === 'Enter') connect(input.value.trim()); }); + forget.addEventListener('click', () => { + AM.storage('am.local', null); + AM.state.sources.local = null; + status.textContent = 'Disconnected.'; + AM.clear(list); + }); + if (AM.storage('am.local')) connect(saved, true); + return h('section.section', { id: 'local' }, + h('h2', 'Your local run'), + h('p', 'Start the dashboard next to your project (', h('code', 'automil viz start'), ') and, when it runs on a remote host, forward its port with SSH. Then connect this page to it: the same views read your live tree and transcript. Safari blocks this; open http://localhost:8420 directly there.'), + h('div.connect', input, button, forget), + status, + list, + ); + } + + AM.views.runs = async function (root) { + const bundled = AM.state.sources.bundled; + const page = h('div.page'); + root.append(page); + page.append(h('div.page-title', h('h1', 'Runs'), h('p', 'Each run is one project: its experiment tree, the agent sessions that built it, and the files every attempt left behind.'))); + if (bundled) { + const index = await bundled.index(); + page.append(h('section', h('h2', index.mode === 'live' ? 'This host' : 'Recorded runs'), runsTable(index, ''))); + } else { + page.append(h('div.notice', 'No record is bundled with this page.')); + } + page.append(connectPanel()); + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/timeline.js b/src/automil/viz/static/js/views/timeline.js new file mode 100644 index 00000000..bd073528 --- /dev/null +++ b/src/automil/viz/static/js/views/timeline.js @@ -0,0 +1,198 @@ +/* The discovery timeline: the agent's actions, the experiment runs and the best value, on one time axis. */ +(function (AM) { + 'use strict'; + const { h, esc, fmt } = AM; + const KEEP_CLASS = new Set(['keep', 'candidate', 'registered']); + const MARK_KINDS = ['prompt', 'propose', 'submit', 'reconcile', 'rank', 'notification', 'compact']; + + function parse(iso) { + const t = iso ? new Date(iso).getTime() : NaN; + return Number.isNaN(t) ? null : t; + } + + function draw(svg, timeline, graph, run, domainOverride) { + const sel = d3.select(svg).classed('timeline-svg', true); + sel.selectAll('*').remove(); + const box = svg.getBoundingClientRect(); + const width = box.width || 900; + const margin = { top: 16, right: 20, bottom: 30, left: 120 }; + const innerW = Math.max(100, width - margin.left - margin.right); + const nodes = timeline.nodes.filter((n) => parse(n.submitted_at) || parse(n.launched_at) || parse(n.completed_at)); + nodes.sort((a, b) => (parse(a.submitted_at) || parse(a.launched_at) || 0) - (parse(b.submitted_at) || parse(b.launched_at) || 0)); + const stamps = []; + for (const n of nodes) for (const key of ['submitted_at', 'launched_at', 'completed_at']) { const t = parse(n[key]); if (t) stamps.push(t); } + for (const e of timeline.events) { const t = parse(e.at); if (t) stamps.push(t); } + for (const s of timeline.sessions) for (const key of ['opened_at', 'ended_at']) { const t = parse(s[key]); if (t) stamps.push(t); } + if (!stamps.length) { + sel.attr('height', 80).append('text').attr('x', 20).attr('y', 40).text('no timed events yet'); + return null; + } + const now = Date.now(); + const fullDomain = [Math.min(...stamps), Math.max(...stamps, timeline.sessions.some((s) => s.live) ? now : 0)]; + const domain = domainOverride || fullDomain; + const x = d3.scaleUtc().domain(domain).range([0, innerW]); + + const laneHead = 24; /* a header line per lane: the title sits here, never on a row or a tick */ + const laneAgentH = laneHead + 46; + const rowH = Math.max(8, Math.min(14, 420 / Math.max(nodes.length, 1))); + const laneRunsH = laneHead + Math.max(40, nodes.length * rowH); + const laneValueH = laneHead + 90; + const gap = 26; + const height = margin.top + laneAgentH + gap + laneRunsH + gap + laneValueH + margin.bottom; + sel.attr('height', height).attr('viewBox', `0 0 ${width} ${height}`); + const g = sel.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + const clip = sel.append('defs').append('clipPath').attr('id', 'tl-clip').append('rect').attr('width', innerW).attr('height', height); + const body = g.append('g').attr('clip-path', 'url(#tl-clip)'); + + /* lane backgrounds, behind everything */ + const backdrop = g.insert('g', ':first-child'); + backdrop.append('rect').attr('class', 'lane-bg').attr('x', -margin.left + 8).attr('y', -6).attr('width', innerW + margin.left - 8).attr('height', laneAgentH + 12).attr('rx', 4); + backdrop.append('rect').attr('class', 'lane-bg alt').attr('x', -margin.left + 8).attr('y', laneAgentH + gap - 6).attr('width', innerW + margin.left - 8).attr('height', laneRunsH + 12).attr('rx', 4); + backdrop.append('rect').attr('class', 'lane-bg').attr('x', -margin.left + 8).attr('y', laneAgentH + gap + laneRunsH + gap - 6).attr('width', innerW + margin.left - 8).attr('height', laneValueH + 12).attr('rx', 4); + + /* axis */ + g.append('g').attr('class', 'axis').attr('transform', `translate(0,${laneAgentH + gap + laneRunsH + gap + laneValueH})`) + .call(d3.axisBottom(x).ticks(Math.max(3, Math.floor(innerW / 110))).tickSize(3)); + + /* agent lane */ + let y0 = 0; + const laneTitle = (y, text) => g.append('text').attr('class', 'lane-label').attr('x', -margin.left + 16).attr('y', y + 14).attr('text-anchor', 'start').text(text); + laneTitle(y0, 'agent'); + for (const s of timeline.sessions) { + const start = parse(s.opened_at) || parse(s.first_at); + const end = parse(s.ended_at) || (s.live ? now : null); + if (!start) continue; + body.append('rect').attr('class', 'session-span').attr('x', x(start)).attr('y', y0 + laneHead).attr('width', Math.max(2, x(end || start) - x(start))).attr('height', 8) + .append('title').text(`session ${s.session_id}`); + } + const marks = timeline.events.filter((e) => MARK_KINDS.includes(e.kind) && parse(e.at)); + const markY = { prompt: laneHead + 14, propose: laneHead + 24, submit: laneHead + 24, reconcile: laneHead + 34, rank: laneHead + 34, notification: laneHead + 14, compact: laneHead + 34 }; + body.selectAll('rect.mark').data(marks).enter().append('rect').attr('class', 'mark') + .attr('x', (e) => x(parse(e.at)) - 3).attr('y', (e) => y0 + markY[e.kind]).attr('width', 6).attr('height', 9).attr('rx', 1) + .attr('fill', (e) => ({ prompt: AM.cssVar('--ink'), propose: AM.cssVar('--sand'), submit: AM.cssVar('--accent'), reconcile: AM.cssVar('--cyan-light'), rank: AM.cssVar('--cyan-light'), notification: AM.cssVar('--amber-light'), compact: AM.cssVar('--faint') }[e.kind])) + .on('mousemove', (event, e) => AM.tooltip.show(`${esc(e.kind)} ${e.node_id ? '' + esc(e.node_id) + '' : ''}
${esc(fmt.truncate(e.label, 80))}
${esc(fmt.when(e.at))}`, event.clientX, event.clientY)) + .on('mouseleave', () => AM.tooltip.hide()) + .on('click', (event, e) => { + if (e.turn != null && e.session_id) AM.navigate(`#/run/${encodeURIComponent(run.key)}/session/${e.session_id}/turn/${e.turn}`); + else if (e.node_id) AM.nodeDrawer.open(run, e.node_id); + }); + + /* runs lane */ + y0 = laneAgentH + gap; + laneTitle(y0, 'experiments'); + const rowsTop = y0 + laneHead; + const rows = body.selectAll('g.row').data(nodes).enter().append('g').attr('class', 'row').attr('transform', (n, i) => `translate(0,${rowsTop + i * rowH})`); + rows.filter((n) => parse(n.submitted_at) && (parse(n.launched_at) || parse(n.completed_at))).append('rect').attr('class', 'bar queued') + .attr('x', (n) => x(parse(n.submitted_at))).attr('y', rowH * 0.3).attr('height', rowH * 0.4) + .attr('width', (n) => Math.max(1, x(parse(n.launched_at) || parse(n.completed_at)) - x(parse(n.submitted_at)))); + rows.filter((n) => parse(n.launched_at)).append('rect').attr('class', 'bar') + .attr('x', (n) => x(parse(n.launched_at))).attr('y', rowH * 0.15).attr('height', rowH * 0.7) + .attr('width', (n) => Math.max(2, x(parse(n.completed_at) || (n.status === 'running' ? now : parse(n.launched_at))) - x(parse(n.launched_at)))) + .attr('fill', (n) => AM.statusColor(n.status)) + .attr('stroke', (n) => (n.status === 'discard' ? AM.cssVar('--status-discard-stroke') : 'none')); + rows.append('rect').attr('x', 0).attr('width', innerW).attr('height', rowH).attr('fill', 'transparent') + .on('mousemove', (event, n) => AM.tooltip.show(`${esc(n.node_id)} ${esc(AM.statusLabel(n.status))}
queued ${esc(fmt.when(n.submitted_at))}
ran ${esc(fmt.duration(fmt.between(n.launched_at, n.completed_at)))}${n.primary_value != null ? '
' + esc(fmt.num(n.primary_value)) : ''}`, event.clientX, event.clientY)) + .on('mouseleave', () => AM.tooltip.hide()) + .on('click', (event, n) => AM.nodeDrawer.open(run, n.node_id)) + .style('cursor', 'pointer'); + if (rowH >= 11) { + g.selectAll('text.rowlabel').data(nodes).enter().append('text').attr('x', -10).attr('y', (n, i) => rowsTop + i * rowH + rowH * 0.75).attr('text-anchor', 'end') + .attr('font-size', 9).text((n) => n.node_id.replace('node_', '')); + } + + /* value lane: best kept value at completion time */ + y0 = laneAgentH + gap + laneRunsH + gap; + laneTitle(y0, 'best value'); + const done = nodes.filter((n) => parse(n.completed_at) && n.primary_value != null && !['crash', 'cancelled', 'partial'].includes(n.status)) + .sort((a, b) => parse(a.completed_at) - parse(b.completed_at)); + const values = done.map((n) => n.primary_value); + /* the baseline is the root of the graph; it usually carries no run timing, so it is not a lane row */ + const baseline = Object.values(graph.nodes || {}).find((n) => !n.parent_id && n.type === 'executed') || nodes.find((n) => !n.parent_id); + if (baseline && baseline.primary_value != null) values.push(baseline.primary_value); + if (values.length) { + let [lo, hi] = d3.extent(values); + if (lo === hi) { lo -= 0.01; hi += 0.01; } + const y = d3.scaleLinear().domain([lo, hi]).range([y0 + laneValueH - 6, y0 + laneHead + 6]).nice(); + g.append('g').attr('class', 'axis').call(d3.axisLeft(y).ticks(3).tickFormat(d3.format('.3f')).tickSize(0)).select('.domain').remove(); + let best = baseline && baseline.primary_value != null ? baseline.primary_value : -Infinity; + const steps = []; + if (best > -Infinity) steps.push([domain[0], best]); + for (const n of done) { + if (KEEP_CLASS.has(n.status) && n.primary_value > best) best = n.primary_value; + if (best > -Infinity) steps.push([parse(n.completed_at), best]); + } + if (steps.length) steps.push([domain[1], steps[steps.length - 1][1]]); + body.append('path').attr('class', 'best').attr('fill', 'none').attr('stroke', AM.cssVar('--accent')).attr('stroke-width', 2) + .attr('d', d3.line().x((d) => x(d[0])).y((d) => y(d[1])).curve(d3.curveStepAfter)(steps)); + if (baseline && baseline.primary_value != null) { + g.append('text').attr('x', innerW - 4).attr('y', y(baseline.primary_value) - 5).attr('text-anchor', 'end').text('baseline'); + body.append('line').attr('x1', 0).attr('x2', innerW).attr('y1', y(baseline.primary_value)).attr('y2', y(baseline.primary_value)) + .attr('stroke', AM.cssVar('--ink')).attr('stroke-dasharray', '3 3').attr('opacity', 0.6); + } + body.selectAll('circle.pt').data(done).enter().append('circle').attr('class', 'point') + .attr('cx', (n) => x(parse(n.completed_at))).attr('cy', (n) => y(n.primary_value)).attr('r', 4) + .attr('fill', (n) => AM.statusColor(n.status)) + .attr('stroke', (n) => (KEEP_CLASS.has(n.status) ? AM.cssVar('--ink') : AM.cssVar('--status-discard-stroke'))) + .attr('stroke-width', 1.2) + .on('mousemove', (event, n) => AM.tooltip.show(`${esc(n.node_id)} ${esc(AM.statusLabel(n.status))}
${esc(fmt.num(n.primary_value))} at ${esc(fmt.when(n.completed_at))}`, event.clientX, event.clientY)) + .on('mouseleave', () => AM.tooltip.hide()) + .on('click', (event, n) => AM.nodeDrawer.open(run, n.node_id)).style('cursor', 'pointer'); + } + return { x, fullDomain, height }; + } + + AM.views.timeline = async function (root, ctx) { + const { run } = ctx; + const source = run.source; + let [timeline, graph] = await Promise.all([source.timeline(run.id), source.graph(run.id)]); + const svg = h('svg', { role: 'img', 'aria-label': 'Discovery timeline', style: { width: '100%', display: 'block' } }); + const overview = h('svg', { style: { height: '44px', width: '100%', display: 'block' }, 'aria-label': 'Time range' }); + const reset = h('button.quiet', { text: 'Whole run' }); + const page = h('div.page.wide', + h('div.page-title', h('h1', 'Timeline'), h('p', 'What the agent did, when each attempt waited and ran, and how the best validation value moved. Drag on the strip below to zoom; click a bar or a mark to open it.')), + timeline.warnings && timeline.warnings.length ? h('div.notice', timeline.warnings.join(' ')) : null, + h('div.pane', { style: { minHeight: '0' } }, h('div.pane-head', h('h3', 'Overview'), h('span.spacer'), reset), h('div.pane-body', { style: { padding: '4px 12px', flex: 'none' } }, overview)), + h('div.pane.timeline-wrap', { style: { marginTop: '24px' } }, h('div.pane-head', h('h3', 'Timeline')), h('div.pane-body', { style: { flex: 'none' } }, svg), + h('div.timeline-legend', ...['prompt', 'propose', 'submit', 'reconcile', 'notification', 'compact'].map((k) => h('span', h('span.glyph', { class: k }), k)), + h('span', h('span.status-dot.status-keep'), 'kept run'), h('span', h('span.status-dot.status-discard'), 'discarded run'), h('span', h('span.status-dot.status-crash'), 'crashed'))), + ); + root.append(page); + let domain = null; + let brush = null; + function redraw() { + const result = draw(svg, timeline, graph, run, domain); + if (!result) return; + const box = overview.getBoundingClientRect(); + const width = box.width || 900; + const ov = d3.select(overview).attr('viewBox', `0 0 ${width} 44`); + ov.selectAll('*').remove(); + const ox = d3.scaleUtc().domain(result.fullDomain).range([110, width - 20]); + ov.append('g').attr('class', 'axis').attr('transform', 'translate(0,30)').call(d3.axisBottom(ox).ticks(Math.max(3, Math.floor(width / 130))).tickSize(3)); + for (const n of timeline.nodes) { + const s = parse(n.launched_at); + const e = parse(n.completed_at) || (n.status === 'running' ? Date.now() : null); + if (!s || !e) continue; + ov.append('rect').attr('x', ox(s)).attr('y', 8).attr('width', Math.max(1, ox(e) - ox(s))).attr('height', 16).attr('fill', AM.statusColor(n.status)).attr('opacity', 0.7); + } + brush = d3.brushX().extent([[110, 4], [width - 20, 28]]).on('end', (event) => { + if (!event.selection) { domain = null; redraw(); return; } + domain = event.selection.map(ox.invert); + draw(svg, timeline, graph, run, domain); + }); + ov.append('g').attr('class', 'brush').call(brush); + if (domain) ov.select('.brush').call(brush.move, domain.map(ox)); + } + redraw(); + reset.addEventListener('click', () => { domain = null; redraw(); }); + const onResize = () => redraw(); + window.addEventListener('resize', onResize); + window.addEventListener('am:theme', onResize); + const unsubscribe = source.subscribe(async (frame) => { + if (['graph_update', 'sessions_update', 'transcript_delta'].includes(frame.type)) { + [timeline, graph] = await Promise.all([source.timeline(run.id), source.graph(run.id)]); + redraw(); + } + }); + return () => { window.removeEventListener('resize', onResize); window.removeEventListener('am:theme', onResize); unsubscribe(); }; + }; +})(window.AM); diff --git a/src/automil/viz/static/js/views/tree.js b/src/automil/viz/static/js/views/tree.js new file mode 100644 index 00000000..97aa5539 --- /dev/null +++ b/src/automil/viz/static/js/views/tree.js @@ -0,0 +1,230 @@ +/* The tree view: the 3D tree by default, the flat lineage tree on request, beside the value chart. */ +(function (AM) { + 'use strict'; + const { h, esc, fmt } = AM; + + function buildHierarchy(graph) { + const nodes = graph.nodes || {}; + const children = new Map(); + const roots = []; + for (const node of Object.values(nodes)) { + const parent = node.parent_id && nodes[node.parent_id] ? node.parent_id : null; + if (parent) { + if (!children.has(parent)) children.set(parent, []); + children.get(parent).push(node); + } else { + roots.push(node); + } + } + const order = (a, b) => (a.created_at || '').localeCompare(b.created_at || '') || a.id.localeCompare(b.id); + for (const list of children.values()) list.sort(order); + roots.sort(order); + const virtual = { id: '__root__', children: roots, virtual: true }; + return d3.hierarchy(virtual, (d) => (d.virtual ? d.children : children.get(d.id) || [])); + } + + /* The flat lineage tree in an svg; returns {update, fit, focus, transform, destroy}. */ + function renderTree(svg, graph, options) { + const opts = Object.assign({ selected: null, onSelect: null, compact: false, interactive: true }, options || {}); + const sel = d3.select(svg).classed('tree-svg', true); + sel.selectAll('*').remove(); + const rootLayer = sel.append('g'); + const layer = rootLayer.append('g'); + const nodes = graph.nodes || {}; + const spine = new Set(AM.lineage(nodes, graph.meta && graph.meta.best_node_id).map((n) => n.id)); + const hierarchy = buildHierarchy(graph); + const dx = opts.compact ? 18 : 50; + const dy = opts.compact ? 48 : 84; + d3.tree().nodeSize([dx, dy])(hierarchy); + const points = hierarchy.descendants().filter((d) => !d.data.virtual); + const links = hierarchy.links().filter((l) => !l.source.data.virtual); + const radius = opts.compact ? 4 : 8; + + layer.selectAll('path.link').data(links).enter().append('path') + .attr('class', (l) => 'link' + (spine.has(l.source.data.id) && spine.has(l.target.data.id) ? ' spine' : '')) + .attr('d', (l) => { + const midY = (l.source.y + l.target.y) / 2; + return `M${l.source.x},${l.source.y}V${midY}H${l.target.x}V${l.target.y}`; + }); + + const nodeSel = layer.selectAll('g.node').data(points, (d) => d.data.id).enter().append('g') + .attr('class', (d) => 'node' + (spine.has(d.data.id) ? ' spine' : '')) + .attr('transform', (d) => `translate(${d.x},${d.y})`); + nodeSel.append('circle') + .attr('r', (d) => (d.data.id === (graph.meta && graph.meta.best_node_id) ? radius + 3 : radius)) + .attr('fill', (d) => AM.statusColor(d.data.status)) + .attr('stroke-dasharray', (d) => (d.data.status === 'cancelled' ? '2 2' : null)); + if (!opts.compact) { + nodeSel.append('text').attr('dx', radius + 5).attr('dy', 4).text((d) => d.data.id.replace('node_', '')); + } + if (opts.interactive) { + nodeSel.style('cursor', 'pointer') + .on('click', (event, d) => { if (opts.onSelect) opts.onSelect(d.data.id); }) + .on('mousemove', (event, d) => { + const n = d.data; + AM.tooltip.show( + `${esc(n.id)} ${esc(AM.statusLabel(n.status))}
${esc(fmt.truncate(n.description, 90))}` + + (n.primary_value != null && n.type === 'executed' ? `
${esc(fmt.num(n.primary_value))}` : ''), + event.clientX, event.clientY, + ); + }) + .on('mouseleave', () => AM.tooltip.hide()); + } + + function update(selectedId) { + nodeSel.classed('selected', (d) => d.data.id === selectedId); + } + update(opts.selected); + + const box = svg.getBoundingClientRect(); + const width = box.width || 800; + const height = box.height || 500; + const xs = points.map((d) => d.x); + const ys = points.map((d) => d.y); + const pad = 48; + const minX = Math.min(...xs) - pad; + const maxX = Math.max(...xs) + pad + (opts.compact ? 0 : 44); + const minY = Math.min(...ys) - pad; + const maxY = Math.max(...ys) + pad; + const scale = Math.min(1.5, Math.min(width / (maxX - minX || 1), height / (maxY - minY || 1))); + const tx = (width - (maxX + minX) * scale) / 2; + const ty = (height - (maxY + minY) * scale) / 2; + const fitTransform = d3.zoomIdentity.translate(tx, ty).scale(scale); + let zoom = null; + if (opts.interactive) { + zoom = d3.zoom().scaleExtent([0.2, 3]).on('zoom', (event) => { + rootLayer.attr('transform', event.transform); + sel.classed('labels-off', event.transform.k < 0.7); + }); + sel.call(zoom).on('dblclick.zoom', null); + sel.call(zoom.transform, opts.transform || fitTransform); + } else { + rootLayer.attr('transform', fitTransform); + } + return { + update, + fit() { if (zoom) sel.transition().duration(300).call(zoom.transform, fitTransform); }, + transform() { return d3.zoomTransform(svg); }, + focus(id) { + const point = points.find((d) => d.data.id === id); + if (!point || !zoom) return; + const t = d3.zoomTransform(svg); + const target = d3.zoomIdentity.translate(width / 2 - point.x * t.k, height / 2 - point.y * t.k).scale(t.k); + sel.transition().duration(300).call(zoom.transform, target); + }, + destroy() { sel.selectAll('*').remove(); }, + }; + } + + function legend() { + const items = [['keep', 'kept'], ['discard', 'discarded'], ['pending', 'proposed'], ['running', 'running'], ['crash', 'crashed'], ['candidate', 'gate stage']]; + return h('div.legend', items.map(([status, label]) => h('span', h('span.status-dot', { class: AM.statusClass(status) }), label)), + h('span', h('span', { style: { display: 'inline-block', width: '18px', borderTop: '3px solid var(--accent)', verticalAlign: 'middle', marginRight: '6px' } }), 'lineage of the best node')); + } + + function preferredMode() { + const saved = AM.storage('am.tree'); + if (saved === '2d') return '2d'; + return AM.tree3d && AM.tree3d.available() ? '3d' : '2d'; + } + + AM.views.tree = async function (root, ctx) { + const { run } = ctx; + const source = run.source; + let graph = await source.graph(run.id); + let selected = ctx.route.params.node || null; + let mode = preferredMode(); + const body = h('div.pane-body'); + const chartSvg = h('svg', { role: 'img', 'aria-label': 'Primary value by attempt' }); + const fitButton = h('button.quiet', { text: 'Fit' }); + const counts = h('span.muted'); + const toggle = h('div.segmented'); + const page = h('div.page.wide', h('div.workspace', + h('div.pane', h('div.pane-head', h('h3', 'Experiment tree'), counts, h('span.spacer'), toggle, fitButton), body, legend()), + h('div.pane', h('div.pane-head', h('h3', 'Primary value by attempt'), h('span.muted', { id: 'chart-metric' })), h('div.pane-body', chartSvg)), + )); + root.append(page); + + let tree = null; + let chart = null; + function summarize(g) { + const all = Object.values(g.nodes); + const executed = all.filter((n) => n.type === 'executed'); + const kept = executed.filter((n) => ['keep', 'candidate', 'registered'].includes(n.status)).length; + counts.textContent = `${executed.length} attempts, ${kept} kept, ${all.length - executed.length} proposed`; + } + function select(id, focus) { + selected = id; + if (tree) tree.update(id); + if (chart) chart.update(id); + if (id) { + AM.nodeDrawer.open(run, id, { onVerdict: (band) => chart && chart.setBand(band) }); + if (focus && tree) tree.focus(id); + } + } + function drawTree(keepTransform) { + const transform = keepTransform && tree && mode === '2d' && tree.transform ? tree.transform() : null; + if (tree) tree.destroy(); + AM.clear(body); + if (mode === '3d' && AM.tree3d && AM.tree3d.available()) { + const holder = h('div.gl'); + body.append(holder); + try { + tree = AM.tree3d.renderTree3D(holder, graph, { selected, onSelect: (id) => select(id, false) }); + } catch (err) { + console.error('3D tree failed, falling back to the flat tree', err); + mode = '2d'; + AM.clear(body); + } + } + if (mode === '2d') { + const svg = h('svg', { role: 'img', 'aria-label': 'Experiment tree' }); + body.append(svg); + tree = renderTree(svg, graph, { selected, onSelect: (id) => select(id, false), transform }); + } + renderToggle(); + } + function renderToggle() { + AM.clear(toggle); + if (!(AM.tree3d && AM.tree3d.available())) return; + for (const [value, label] of [['3d', '3D'], ['2d', 'Lineage']]) { + toggle.append(h('button', { class: mode === value ? 'active' : '', text: label, onclick: () => { + if (mode === value) return; + mode = value; + AM.storage('am.tree', value); + drawTree(false); + } })); + } + } + function drawChart() { + chart = AM.progressChart(chartSvg, graph, { selected, onSelect: (id) => select(id, true) }); + document.getElementById('chart-metric').textContent = (graph.meta && graph.meta.scoring && graph.meta.scoring.formula) || ''; + summarize(graph); + } + drawTree(false); + drawChart(); + fitButton.addEventListener('click', () => tree && tree.fit()); + if (selected) select(selected, true); + + const onResize = () => { if (mode === '2d') drawTree(true); drawChart(); }; + const onTheme = () => { if (tree && tree.setTheme) tree.setTheme(); else drawTree(true); drawChart(); }; + window.addEventListener('resize', onResize); + window.addEventListener('am:theme', onTheme); + const unsubscribe = source.subscribe(async (frame) => { + if (frame.type === 'graph_update') { + graph = await source.graph(run.id); + drawTree(true); + drawChart(); + } + }); + return () => { + window.removeEventListener('resize', onResize); + window.removeEventListener('am:theme', onTheme); + unsubscribe(); + if (tree) tree.destroy(); + AM.nodeDrawer.close(); + }; + }; + + AM.renderTree = renderTree; +})(window.AM); diff --git a/src/automil/viz/static/js/views/tree3d.js b/src/automil/viz/static/js/views/tree3d.js new file mode 100644 index 00000000..2b74e6b7 --- /dev/null +++ b/src/automil/viz/static/js/views/tree3d.js @@ -0,0 +1,180 @@ +/* The tree in three dimensions: a top-down layout on the page's paper, no glow, the kept lineage in teal. */ +(function (AM) { + 'use strict'; + const { esc, fmt } = AM; + const KEEP_CLASS = new Set(['keep', 'candidate', 'registered']); + + function available() { + return typeof ForceGraph3D !== 'undefined' && typeof THREE !== 'undefined'; + } + + function graphData(graph) { + const nodes = graph.nodes || {}; + const spine = new Set(AM.lineage(nodes, graph.meta && graph.meta.best_node_id).map((n) => n.id)); + const list = Object.values(nodes) + .sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '') || a.id.localeCompare(b.id)) + .map((n) => ({ id: n.id, status: n.status, description: n.description, primary_value: n.primary_value, type: n.type, spine: spine.has(n.id), best: n.id === (graph.meta && graph.meta.best_node_id) })); + const links = []; + for (const n of list) { + const parent = nodes[n.id].parent_id; + if (parent && nodes[parent]) links.push({ source: parent, target: n.id, spine: spine.has(parent) && spine.has(n.id) }); + } + return { nodes: list, links }; + } + + function colorOf(status) { + return new THREE.Color(AM.statusColor(status) || '#999'); + } + + function sphere(node, opts) { + const group = new THREE.Group(); + const radius = opts.compact ? (node.best ? 5.5 : node.spine ? 4.5 : 3.8) : node.best ? 8 : node.spine ? 6.5 : 5.2; + const material = new THREE.MeshLambertMaterial({ color: colorOf(node.status) }); + const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 16), material); + if (node.status === 'discard' || node.status === 'cancelled') { + material.color = new THREE.Color(AM.cssVar('--status-discard')); + const rim = new THREE.Mesh(new THREE.SphereGeometry(radius + 0.5, 24, 16), new THREE.MeshBasicMaterial({ color: new THREE.Color(AM.cssVar('--status-discard-stroke')), transparent: true, opacity: 0.5, side: THREE.BackSide })); + group.add(rim); + } else { + const rim = new THREE.Mesh(new THREE.SphereGeometry(radius + 0.5, 24, 16), new THREE.MeshBasicMaterial({ color: new THREE.Color(AM.cssVar('--ink')), transparent: true, opacity: 0.55, side: THREE.BackSide })); + group.add(rim); + } + group.add(mesh); + if (!opts.compact && (node.spine || node.status === 'running' || node.status === 'pending') && typeof SpriteText !== 'undefined') { + const label = new SpriteText(node.id.replace('node_', ''), 4.2, AM.cssVar('--ink')); + label.fontFace = 'JetBrains Mono, Menlo, monospace'; + label.backgroundColor = false; + label.position.y = radius + 5; + group.add(label); + } + return group; + } + + /* Draws into container; returns {update(selected), fit(), focus(id), destroy(), setTheme()}. */ + function renderTree3D(container, graph, options) { + const opts = Object.assign({ selected: null, onSelect: null, compact: false, rotate: false }, options || {}); + const data = graphData(graph); + const paper = AM.cssVar('--paper'); + let selectedId = opts.selected; + const fg = ForceGraph3D({ controlType: opts.compact ? 'orbit' : 'trackball' })(container) + .width(container.clientWidth || 600) + .height(container.clientHeight || 400) + .backgroundColor(paper) + .showNavInfo(false) + .enableNodeDrag(false) + .enableNavigationControls(true) + .dagMode('td') + .dagLevelDistance(opts.compact ? 36 : 54) + .nodeThreeObject((node) => sphere(node, opts)) + .nodeThreeObjectExtend(false) + .linkColor((link) => (link.spine ? AM.cssVar('--ink') : '#b5b5b5')) + .linkWidth((link) => (link.spine ? (opts.compact ? 1.4 : 2.4) : opts.compact ? 0.6 : 1.0)) + .linkOpacity(1) + .linkDirectionalParticles(0) + .d3Force('charge', d3.forceManyBody().strength(opts.compact ? -40 : -70)) + .d3Force('link', d3.forceLink().id((n) => n.id).distance(opts.compact ? 16 : 26).strength(0.9)) + .d3AlphaDecay(0.045) + .cooldownTicks(170) + .warmupTicks(40); + if (!opts.compact) { + fg.nodeLabel((node) => `
${esc(node.id)} ${esc(AM.statusLabel(node.status))}
${esc(fmt.truncate(node.description, 90))}${node.primary_value != null && node.type === 'executed' ? '
' + esc(fmt.num(node.primary_value)) : ''}
`) + .onNodeHover((node) => { container.style.cursor = node ? 'pointer' : 'default'; }) + .onNodeClick((node) => { if (node && opts.onSelect) opts.onSelect(node.id); }); + } + fg.graphData(data); + /* start near the layout so the first frames already show a tree, not a speck */ + fg.cameraPosition({ x: 0, y: 40, z: opts.compact ? 230 : 320 }, { x: 0, y: 0, z: 0 }, 0); + if (opts.compact) { + /* the hero only turns by itself: no wheel, drag or pan, so the page keeps scrolling */ + const controls = fg.controls(); + controls.enableZoom = false; + controls.enableRotate = false; + controls.enablePan = false; + } + let settled = false; + function centroid() { + return { + x: d3.mean(data.nodes, (n) => n.x || 0) || 0, + y: d3.mean(data.nodes, (n) => n.y || 0) || 0, + z: d3.mean(data.nodes, (n) => n.z || 0) || 0, + }; + } + function tilt(ms) { + /* an elevated, slightly turned viewpoint, so the depth of the layout shows */ + const c = centroid(); + const cam = fg.camera().position; + const d = Math.hypot(cam.x - c.x, cam.y - c.y, cam.z - c.z) * 0.95; + fg.cameraPosition({ x: c.x + d * Math.sin(0.55) * 0.9, y: c.y + d * 0.42, z: c.z + d * Math.cos(0.55) * 0.9 }, c, ms); + } + let onSettled = null; + fg.onEngineStop(() => { + if (settled) return; + settled = true; + fg.zoomToFit(400, opts.compact ? 6 : 24); + setTimeout(() => { + tilt(700); + if (onSettled) setTimeout(onSettled, 800); + }, 450); + }); + + /* selection ring */ + let ring = null; + function update(id) { + selectedId = id; + if (ring) { fg.scene().remove(ring); ring = null; } + const node = data.nodes.find((n) => n.id === id); + if (!node || node.x == null) return; + ring = new THREE.Mesh(new THREE.TorusGeometry(8, 0.7, 12, 48), new THREE.MeshBasicMaterial({ color: new THREE.Color(AM.cssVar('--accent-ink')) })); + ring.position.set(node.x, node.y, node.z); + ring.lookAt(fg.camera().position); + fg.scene().add(ring); + } + setTimeout(() => update(selectedId), 900); + + /* slow rotation for the hero */ + let frame = null; + let angle = 0; + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (opts.rotate && !reduce) { + const start = () => { + const centre = centroid(); + const cam = fg.camera().position; + const distance = Math.hypot(cam.x - centre.x, cam.z - centre.z) * 0.9 || 200; + const height = cam.y; + angle = Math.atan2(cam.x - centre.x, cam.z - centre.z); + const spin = () => { + angle += 0.003; + fg.cameraPosition({ x: centre.x + distance * Math.sin(angle), y: height, z: centre.z + distance * Math.cos(angle) }, centre); + frame = requestAnimationFrame(spin); + }; + frame = requestAnimationFrame(spin); + }; + onSettled = start; + } + + const onResize = () => fg.width(container.clientWidth || 600).height(container.clientHeight || 400); + window.addEventListener('resize', onResize); + + return { + update, + fit() { fg.zoomToFit(400, 24); setTimeout(() => tilt(600), 450); }, + focus(id) { + const node = data.nodes.find((n) => n.id === id); + if (!node || node.x == null) return; + const distance = 120; + const norm = Math.hypot(node.x, node.y, node.z) || 1; + const ratio = 1 + distance / norm; + fg.cameraPosition({ x: node.x * ratio, y: node.y * ratio, z: node.z * ratio }, node, 600); + }, + setTheme() { fg.backgroundColor(AM.cssVar('--paper')); }, + destroy() { + if (frame) cancelAnimationFrame(frame); + window.removeEventListener('resize', onResize); + try { fg._destructor && fg._destructor(); } catch (err) { /* older builds */ } + AM.clear(container); + }, + }; + } + + AM.tree3d = { available, renderTree3D }; +})(window.AM); diff --git a/src/automil/viz/static/vendor/marked.min.js b/src/automil/viz/static/vendor/marked.min.js new file mode 100644 index 00000000..b4e0d73b --- /dev/null +++ b/src/automil/viz/static/vendor/marked.min.js @@ -0,0 +1,69 @@ +/** + * marked v15.0.12 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(` +`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,` +`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],` +`).split(` +`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(` +`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+` +`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=` +`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=` +`+g}!T&&!g.trim()&&(T=!0),p+=G+` +`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=` +`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` +`;return s?'
'+(n?i:R(i,!0))+`
+`:"
"+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let i=0;i${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${R(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='
    ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`${n}{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+R(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/src/automil/viz/static/vendor/purify.min.js b/src/automil/viz/static/vendor/purify.min.js new file mode 100644 index 00000000..d6d097b8 --- /dev/null +++ b/src/automil/viz/static/vendor/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.4.15 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.15/LICENSE */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).DOMPurify=e()}(this,function(){"use strict";function t(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n2?n-2:0),r=2;r1?e-1:0),o=1;o1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:S;if(o&&o(t,null),!b(e))return t;let i=e.length;for(;i--;){let o=e[i];if("string"==typeof o){const t=n(o);t!==o&&(r(e)||(e[i]=t),o=t)}t[o]=!0}return t}function M(t){for(let e=0;e/g),J=c(/\${[\w\W]*/g),Q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),tt=c(/^aria-[\-\w]+$/),et=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),nt=c(/^(?:\w+script|data):/i),ot=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),rt=c(/^html$/i),it=c(/^[a-z][.\w]*(-[.\w]+)+$/i),at=c(/<[/\w!]/g),lt=c(/<[/\w]/g),ct=c(/<\/no(script|embed|frames)/i),st=c(/\/>/i),ut=1,ft=3,pt=7,mt=8,dt=9,ht=11,yt=["style","script","xmp","iframe","noembed","noframes","plaintext","noscript"],gt=l(z({},yt)),bt=function(){const t={};return m(yt,e=>{t[e]=c(new RegExp("])","i"))}),l(t)}(),St=function(){return"undefined"==typeof window?null:window},Tt=function(t,e,n,o){return D(t,e)&&b(t[e])?z(o.base?P(o.base):{},t[e],o.transform):n},At=function(t,e,n){const o=D(t,e)?t[e]:void 0;return o&&"object"==typeof o?P(o):n()};var Et=function t(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:St();const o=e=>t(e);if(o.version="3.4.15",o.removed=[],!e||!e.document||e.document.nodeType!==dt||!e.Element)return o.isSupported=!1,o;let r=e.document;const i=r,a=i.currentScript;e.DocumentFragment;const u=e.HTMLTemplateElement,f=e.Node,p=e.Element,I=e.NodeFilter,L=e.NamedNodeMap;void 0===L&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;const M=e.DOMParser,yt=e.trustedTypes,Et=p.prototype,wt=U(Et,"cloneNode"),vt=U(Et,"remove"),Ot=U(Et,"removeAttributeNode"),Nt=U(Et,"nextSibling"),xt=U(Et,"childNodes"),_t=U(Et,"parentNode"),Dt=U(Et,"shadowRoot"),Rt=U(Et,"attributes"),kt=f&&f.prototype?U(f.prototype,"nodeType"):null,Ct=f&&f.prototype?U(f.prototype,"nodeName"):null,It=f&&f.prototype?U(f.prototype,"ownerDocument"):null,Lt=function(t){return kt?kt(t):t.nodeType},zt=function(t){return Ct?Ct(t):t.nodeName};if("function"==typeof u){const t=r.createElement("template");t.content&&t.content.ownerDocument&&(r=t.content.ownerDocument)}let Mt,Pt,Ut="",Ft=!1,Ht=0;const jt=function(){if(Ht>0)throw C('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},Bt=function(t){jt(),Ht++;try{return Mt.createHTML(t)}finally{Ht--}},Wt=function(){return Ft||(Pt=function(t,e){if("object"!=typeof t||"function"!=typeof t.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";e&&e.hasAttribute(o)&&(n=e.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return t.createPolicy(r,{createHTML:t=>t,createScriptURL:t=>t})}catch(t){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(yt,a),Ft=!0),Pt},Yt=r,Gt=Yt.implementation,qt=Yt.createNodeIterator,$t=Yt.createDocumentFragment,Xt=Yt.getElementsByTagName,Kt=i.importNode;let Vt={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof n&&"function"==typeof _t&&Gt&&void 0!==Gt.createHTMLDocument;const Zt=V,Jt=Z,Qt=J,te=Q,ee=tt,ne=nt,oe=ot,re=it;let ie=et,ae=null;const le=z({},[...F,...H,...j,...W,...G]);let ce=null;const se=z({},[...q,...$,...X,...K]);let ue=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),fe=null,pe=null;const me=Object.seal(s(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let de=!0,he=!0,ye=!1,ge=!0,be=!1,Se=!0,Te=!1,Ae=!1,Ee=null,we=null,ve=!1,Oe=!1,Ne=!1,xe=!1,_e=!0,De=!1;const Re="user-content-";let ke=!0,Ce=!1,Ie={},Le=null;const ze=z({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Me=null;const Pe=z({},["audio","video","img","source","image","track"]);let Ue=null;const Fe=z({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),He="http://www.w3.org/1998/Math/MathML",je="http://www.w3.org/2000/svg",Be="http://www.w3.org/1999/xhtml";let We=Be,Ye=!1,Ge=null;const qe=z({},[He,je,Be],T),$e=l(["mi","mo","mn","ms","mtext"]);let Xe=z({},$e);const Ke=l(["annotation-xml"]);let Ve=z({},Ke);const Ze=z({},["title","style","font","a","script"]);let Je=null;const Qe=["application/xhtml+xml","text/html"];let tn=null,en=null;const nn=r.createElement("form"),on=function(t){return t instanceof RegExp||t instanceof Function},rn=function(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(en&&en===t)return;t&&"object"==typeof t||(t={}),t=P(t),Je=-1===Qe.indexOf(t.PARSER_MEDIA_TYPE)?"text/html":t.PARSER_MEDIA_TYPE,tn="application/xhtml+xml"===Je?T:S,ae=Tt(t,"ALLOWED_TAGS",le,{transform:tn}),ce=Tt(t,"ALLOWED_ATTR",se,{transform:tn}),Ge=Tt(t,"ALLOWED_NAMESPACES",qe,{transform:T}),Ue=Tt(t,"ADD_URI_SAFE_ATTR",Fe,{transform:tn,base:Fe}),Me=Tt(t,"ADD_DATA_URI_TAGS",Pe,{transform:tn,base:Pe}),Le=Tt(t,"FORBID_CONTENTS",ze,{transform:tn}),fe=Tt(t,"FORBID_TAGS",P({}),{transform:tn}),pe=Tt(t,"FORBID_ATTR",P({}),{transform:tn}),Ie=!!D(t,"USE_PROFILES")&&(t.USE_PROFILES&&"object"==typeof t.USE_PROFILES?P(t.USE_PROFILES):t.USE_PROFILES),de=!1!==t.ALLOW_ARIA_ATTR,he=!1!==t.ALLOW_DATA_ATTR,ye=t.ALLOW_UNKNOWN_PROTOCOLS||!1,ge=!1!==t.ALLOW_SELF_CLOSE_IN_ATTR,be=t.SAFE_FOR_TEMPLATES||!1,Se=!1!==t.SAFE_FOR_XML,Te=t.WHOLE_DOCUMENT||!1,Oe=t.RETURN_DOM||!1,Ne=t.RETURN_DOM_FRAGMENT||!1,xe=t.RETURN_TRUSTED_TYPE||!1,ve=t.FORCE_BODY||!1,_e=!1!==t.SANITIZE_DOM,De=t.SANITIZE_NAMED_PROPS||!1,ke=!1!==t.KEEP_CONTENT,Ce=t.IN_PLACE||!1,ie=function(t){try{return k(t,""),!0}catch(t){return!1}}(t.ALLOWED_URI_REGEXP)?t.ALLOWED_URI_REGEXP:et,We="string"==typeof t.NAMESPACE?t.NAMESPACE:Be,Xe=At(t,"MATHML_TEXT_INTEGRATION_POINTS",()=>z({},$e)),Ve=At(t,"HTML_INTEGRATION_POINTS",()=>z({},Ke));const e=At(t,"CUSTOM_ELEMENT_HANDLING",()=>s(null));if(ue=s(null),D(e,"tagNameCheck")&&on(e.tagNameCheck)&&(ue.tagNameCheck=e.tagNameCheck),D(e,"attributeNameCheck")&&on(e.attributeNameCheck)&&(ue.attributeNameCheck=e.attributeNameCheck),D(e,"allowCustomizedBuiltInElements")&&"boolean"==typeof e.allowCustomizedBuiltInElements&&(ue.allowCustomizedBuiltInElements=e.allowCustomizedBuiltInElements),c(ue),be&&(he=!1),Ne&&(Oe=!0),Ie&&(ae=z({},G),ce=s(null),!0===Ie.html&&(z(ae,F),z(ce,q)),!0===Ie.svg&&(z(ae,H),z(ce,$),z(ce,K)),!0===Ie.svgFilters&&(z(ae,j),z(ce,$),z(ce,K)),!0===Ie.mathMl&&(z(ae,W),z(ce,X),z(ce,K))),me.tagCheck=null,me.attributeCheck=null,D(t,"ADD_TAGS")&&("function"==typeof t.ADD_TAGS?me.tagCheck=t.ADD_TAGS:b(t.ADD_TAGS)&&(ae===le&&(ae=P(ae)),z(ae,t.ADD_TAGS,tn))),D(t,"ADD_ATTR")&&("function"==typeof t.ADD_ATTR?me.attributeCheck=t.ADD_ATTR:b(t.ADD_ATTR)&&(ce===se&&(ce=P(ce)),z(ce,t.ADD_ATTR,tn))),D(t,"ADD_FORBID_CONTENTS")&&b(t.ADD_FORBID_CONTENTS)&&(Le===ze&&(Le=P(Le)),z(Le,t.ADD_FORBID_CONTENTS,tn)),ke&&(ae["#text"]=!0),Te&&z(ae,["html","head","body"]),ae.table&&(z(ae,["tbody"]),delete fe.tbody),t.TRUSTED_TYPES_POLICY){if("function"!=typeof t.TRUSTED_TYPES_POLICY.createHTML)throw C('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof t.TRUSTED_TYPES_POLICY.createScriptURL)throw C('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const e=Mt;Mt=t.TRUSTED_TYPES_POLICY;try{Ut=Bt("")}catch(t){throw Mt=e,t}}else null===t.TRUSTED_TYPES_POLICY?(Mt=void 0,Ut=""):(void 0===Mt&&(Mt=Wt()),Mt&&"string"==typeof Ut&&(Ut=Bt("")));l&&l(t),en=t},an=z({},[...H,...j,...B]),ln=z({},[...W,...Y]),cn=function(t){let e=_t(t);e&&e.tagName||(e={namespaceURI:We,tagName:"template"});const n=S(t.tagName),o=S(e.tagName);return!!Ge[t.namespaceURI]&&(t.namespaceURI===je?function(t,e,n){return e.namespaceURI===Be?"svg"===t:e.namespaceURI===He?"svg"===t&&("annotation-xml"===n||Xe[n]):Boolean(an[t])}(n,e,o):t.namespaceURI===He?function(t,e,n){return e.namespaceURI===Be?"math"===t:e.namespaceURI===je?"math"===t&&Ve[n]:Boolean(ln[t])}(n,e,o):t.namespaceURI===Be?function(t,e,n){return!(e.namespaceURI===je&&!Ve[n])&&!(e.namespaceURI===He&&!Xe[n])&&!ln[t]&&(Ze[t]||!an[t])}(n,e,o):!("application/xhtml+xml"!==Je||!Ge[t.namespaceURI]))},sn=function(t){y(o.removed,{element:t});try{_t(t).removeChild(t)}catch(e){if(vt(t),!_t(t))throw C("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},un=function(t,e,n){try{Ot(t,e)}catch(e){try{t.removeAttribute(n)}catch(t){}}},fn=function(t){dn(t);const e=xt(t);if(e){const t=[];m(e,e=>{y(t,e)}),m(t,t=>{try{vt(t)}catch(t){}})}const n=Rt(t);if(n)for(let e=n.length-1;e>=0;--e){const o=n[e],r=o&&o.name;"string"==typeof r&&un(t,o,r)}},pn=function(t,e,n){if(!n)try{n=e.getAttributeNode(t)}catch(t){n=null}y(o.removed,{attribute:n||null,from:e});try{n?Ot(e,n):e.removeAttribute(t)}catch(n){try{e.removeAttribute(t)}catch(t){}}if("is"===t)if(Oe||Ne)try{sn(e)}catch(t){}else try{e.setAttribute(t,"")}catch(t){}},mn=function(t){const e=Rt(t);if(e)for(let n=e.length-1;n>=0;--n){const o=e[n],r=o&&o.name;"string"!=typeof r||ce[tn(r)]||un(t,o,r)}},dn=function(t){const e=[t];for(;e.length>0;){const t=e.pop();Lt(t)===ut&&mn(t);const n=xt(t);if(n)for(let t=n.length-1;t>=0;--t)e.push(n[t])}},hn=function(t,e){return!!Se&&("patchsrc"===t||"for"===t&&"label"!==e&&"output"!==e)},yn=function(t){let e=null,n=null;if(ve)t=""+t;else{const e=A(t,/^[\r\n\t ]+/);n=e&&e[0]}"application/xhtml+xml"===Je&&We===Be&&(t=''+t+"");const o=Mt?Bt(t):t;if(We===Be)try{e=(new M).parseFromString(o,Je)}catch(t){}if(!e||!e.documentElement){e=Gt.createDocument(We,"template",null);try{e.documentElement.innerHTML=Ye?Ut:o}catch(t){}}const i=e.body||e.documentElement;return t&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),We===Be?Xt.call(e,Te?"html":"body")[0]:Te?e.documentElement:i},gn=function(t){const e=It?It(t):t.ownerDocument;return qt.call(e||t,t,I.SHOW_ELEMENT|I.SHOW_COMMENT|I.SHOW_TEXT|I.SHOW_PROCESSING_INSTRUCTION|I.SHOW_CDATA_SECTION,null)},bn=function(t){return t=E(t,Zt," "),t=E(t,Jt," "),t=E(t,Qt," ")},Sn=function(t){var e;t.normalize();const n=It?It(t):t.ownerDocument,o=qt.call(n||t,t,I.SHOW_TEXT|I.SHOW_COMMENT|I.SHOW_CDATA_SECTION|I.SHOW_PROCESSING_INSTRUCTION,null);let r=o.nextNode();for(;r;)r.data=bn(r.data),r=o.nextNode();const i=null===(e=t.querySelectorAll)||void 0===e?void 0:e.call(t,"template");i&&m(i,t=>{An(t.content)&&Sn(t.content)})},Tn=function(t){const e=Ct?Ct(t):null;return"string"==typeof e&&("form"===tn(e)&&("string"!=typeof t.nodeName||"string"!=typeof t.textContent||"function"!=typeof t.removeChild||t.attributes!==Rt(t)||"function"!=typeof t.removeAttribute||"function"!=typeof t.removeAttributeNode||"function"!=typeof t.getAttributeNode||"function"!=typeof t.setAttribute||"string"!=typeof t.namespaceURI||"function"!=typeof t.insertBefore||"function"!=typeof t.hasChildNodes||t.nodeType!==kt(t)||t.childNodes!==xt(t)))},An=function(t){if(!kt||"object"!=typeof t||null===t)return!1;try{return kt(t)===ht}catch(t){return!1}},En=function(t){if(!kt||"object"!=typeof t||null===t)return!1;try{return"number"==typeof kt(t)}catch(t){return!1}};function wn(t,e,n){0!==t.length&&m(t,t=>{t.call(o,e,n,en)})}const vn=function(t,e){if(t instanceof RegExp)return k(t,e);if(t instanceof Function){for(var n=arguments.length,o=new Array(n>2?n-2:0),r=2;r=0;--r){const i=t===n?wt(o[r],!0):o[r];e.insertBefore(i,Nt(t))}}return sn(t),!0}(t,n,e);return!1===o&&wn(Vt.afterSanitizeElements,t,null),o}if(Lt(t)===ut&&!cn(t))return sn(t),!0;if(("noscript"===n||"noembed"===n||"noframes"===n)&&k(ct,t.innerHTML))return sn(t),!0;if(be&&t.nodeType===ft){const e=bn(t.textContent);t.textContent!==e&&(y(o.removed,{element:t.cloneNode()}),t.textContent=e)}return wn(Vt.afterSanitizeElements,t,null),!1},_n=function(t,e,n){if(pe[e])return!1;if(hn(e,t))return!1;if(_e&&("id"===e||"name"===e)&&(n in r||n in nn))return!1;const o=ce[e]||me.attributeCheck instanceof Function&&me.attributeCheck(e,t);return!(!he||!k(te,e))||(!(!de||!k(ee,e))||(o?!!Ue[e]||(!!k(ie,E(n,oe,""))||(!("src"!==e&&"xlink:href"!==e&&"href"!==e||"script"===t||0!==w(n,"data:")||!Me[t])||(!(!ye||k(ne,E(n,oe,"")))||!n))):Rn(t)&&vn(ue.tagNameCheck,t)&&vn(ue.attributeNameCheck,e,t)||"is"===e&&ue.allowCustomizedBuiltInElements&&vn(ue.tagNameCheck,n)))},Dn=z({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),Rn=function(t){return!Dn[S(t)]&&k(re,t)},kn=function(t,e,n,o){if(Mt&&"object"==typeof yt&&"function"==typeof yt.getAttributeType&&!n)switch(yt.getAttributeType(t,e)){case"TrustedHTML":return Bt(o);case"TrustedScriptURL":return function(t){jt(),Ht++;try{return Mt.createScriptURL(t)}finally{Ht--}}(o)}return o},Cn=function(t,e,n,o){try{return n?t.setAttributeNS(n,e,o):t.setAttribute(e,o),!Tn(t)||(sn(t),!1)}catch(n){return pn(e,t),!1}},In=function(t){wn(Vt.beforeSanitizeAttributes,t,null);const e=t.attributes;if(!e||Tn(t))return;ce=On(Vt.uponSanitizeAttribute,ce,se,we);const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:ce,forceKeepAttr:void 0};let r=e.length;const i=tn(t.nodeName);for(;r--;){const a=e[r],l=a.name,c=a.namespaceURI,s=a.value,u=tn(l),f=s;let p="value"===l?f:v(f),m=!1;if(n.attrName=u,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,wn(Vt.uponSanitizeAttribute,t,n),p=n.attrValue,!De||"id"!==u&&"name"!==u||0===w(p,Re)||(pn(l,t,a),p=Re+p,m=!0),Se&&k(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,p))pn(l,t,a);else if("attributename"===u&&A(p,"href"))pn(l,t,a);else if(!n.forceKeepAttr)if(n.keepAttr)if(ge||!k(st,p))if(be&&(p=bn(p)),_n(i,u,p)){if(p=kn(i,u,c,p),p!==f){Cn(t,l,c,p)&&m&&h(o.removed)}}else pn(l,t,a);else pn(l,t,a);else pn(l,t,a)}wn(Vt.afterSanitizeAttributes,t,null)},Ln=function(t){let e=null;const n=gn(t);for(wn(Vt.beforeSanitizeShadowDOM,t,null);e=n.nextNode();)if(wn(Vt.uponSanitizeShadowNode,e,null),xn(e,t),In(e),An(e.content)&&Ln(e.content),Lt(e)===ut){const t=Dt(e);An(t)&&(zn(t),Ln(t))}wn(Vt.afterSanitizeShadowDOM,t,null)},zn=function(t){const e=[{node:t,shadow:null}];for(;e.length>0;){const t=e.pop();if(t.shadow){Ln(t.shadow);continue}const n=t.node,o=Lt(n)===ut,r=xt(n);if(r)for(let t=r.length-1;t>=0;--t)e.push({node:r[t],shadow:null});if(o){const t=Ct?Ct(n):null;if("string"==typeof t&&"template"===tn(t)){const t=n.content;An(t)&&e.push({node:t,shadow:null})}}if(o){const t=Dt(n);An(t)&&e.push({node:null,shadow:t},{node:t,shadow:null})}}};return o.sanitize=function(t){let e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,a=null,l=null;if(Ye=!t,Ye&&(t="\x3c!--\x3e"),"string"!=typeof t&&!En(t)&&"string"!=typeof(t=function(t){switch(typeof t){case"string":return t;case"number":return O(t);case"boolean":return N(t);case"bigint":return x?x(t):"0";case"symbol":return _?_(t):"Symbol()";case"undefined":default:return R(t);case"function":case"object":{if(null===t)return R(t);const e=t,n=U(e,"toString");if("function"==typeof n){const t=n(e);return"string"==typeof t?t:R(t)}return R(t)}}}(t)))throw C("dirty is not a string, aborting");if(!o.isSupported)return t;Ae?(ae=Ee,ce=we):rn(e),(Vt.uponSanitizeElement.length>0||Vt.uponSanitizeAttribute.length>0)&&(ae=P(ae)),Vt.uponSanitizeAttribute.length>0&&(ce=P(ce)),o.removed=[];const c=Ce&&"string"!=typeof t&&En(t);if(c){!function(t){if(!Se)return;const e=[t];for(;e.length>0;){const t=e.pop(),n=Lt(t);if(n===pt||n===mt&&k(lt,t.data)){try{vt(t)}catch(t){}continue}if(n===ut){const e=t,n=tn(zt(t));try{e.hasAttribute&&e.hasAttribute("patchsrc")&&e.removeAttribute("patchsrc"),e.hasAttribute&&e.hasAttribute("for")&&hn("for",n)&&e.removeAttribute("for")}catch(t){}}const o=xt(t);if(o)for(let t=o.length-1;t>=0;--t)e.push(o[t])}}(t);const e=zt(t);if("string"==typeof e){const n=tn(e);if(!ae[n]||fe[n])throw fn(t),C("root node is forbidden and cannot be sanitized in-place")}if(Tn(t))throw fn(t),C("root node is clobbered and cannot be sanitized in-place");try{zn(t)}catch(e){throw fn(t),e}}else if(En(t))n=yn("\x3c!----\x3e"),r=n.ownerDocument.importNode(t,!0),r.nodeType===ut&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r),zn(n);else{if(!Oe&&!be&&!Te&&-1===t.indexOf("<"))return Mt&&xe?Bt(t):t;if(n=yn(t),!n)return Oe?null:xe?Ut:""}n&&ve&&sn(n.firstChild);const s=c?t:n;try{const t=gn(s);for(;a=t.nextNode();)xn(a,s),In(a),An(a.content)&&Ln(a.content)}catch(e){throw c&&(fn(t),m(o.removed,t=>{t.element&&dn(t.element)})),e}if(c)return m(o.removed,t=>{t.element&&dn(t.element)}),be&&Sn(t),t;if(Oe){if(be&&Sn(n),Ne)for(l=$t.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(ce.shadowroot||ce.shadowrootmode)&&(l=Kt.call(i,l,!0)),l}let u=Te?n.outerHTML:n.innerHTML;return Te&&ae["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&k(rt,n.ownerDocument.doctype.name)&&(u="\n"+u),be&&(u=bn(u)),Mt&&xe?Bt(u):u},o.setConfig=function(){rn(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Ae=!0,Ee=ae,we=ce},o.clearConfig=function(){en=null,Ae=!1,Ee=null,we=null,Mt=Pt,Ut=""},o.isValidAttribute=function(t,e,n){en||rn({});const o=tn(t),r=tn(e);return _n(o,r,n)},o.addHook=function(t,e){"function"==typeof e&&D(Vt,t)&&y(Vt[t],e)},o.removeHook=function(t,e){if(D(Vt,t)){if(void 0!==e){const n=d(Vt[t],e);return-1===n?void 0:g(Vt[t],n,1)[0]}return h(Vt[t])}},o.removeHooks=function(t){D(Vt,t)&&(Vt[t]=[])},o.removeAllHooks=function(){Vt={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return Et}); +//# sourceMappingURL=purify.min.js.map diff --git a/src/automil/viz/subagents.py b/src/automil/viz/subagents.py new file mode 100644 index 00000000..a812eb6b --- /dev/null +++ b/src/automil/viz/subagents.py @@ -0,0 +1,110 @@ +"""Subagent transcripts from a session's sidecar directory. + +Claude Code keeps each subagent's own transcript at +``/subagents/agent-.jsonl`` with an ``agent-.meta.json`` +naming the parent ``toolUseId``. Reading them is the same as reading the main +file; attaching them links the parent turn's ``Agent`` call to the agent id. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Mapping + +from automil.viz.transcript import FullResultRef, ParsedTranscript, ReadStats, parse_transcript + +SUBAGENTS_DIRNAME = "subagents" +_PREFIX = "agent-" + + +@dataclass(frozen=True) +class SubagentTranscript: + agent_id: str + tool_use_id: str | None + agent_type: str | None + description: str | None + spawn_depth: int | None + path: Path + turns: tuple[dict[str, Any], ...] + full_refs: Mapping[str, FullResultRef] + stats: ReadStats + models: tuple[str, ...] + + +def _read_meta(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def read_subagents(sidecar_dir: Path | None) -> tuple[SubagentTranscript, ...]: + """Every subagent under ``sidecar_dir/subagents``, by agent id. + + A transcript without its meta file is still read (with unknown parent); + a meta file without its transcript yields an empty subagent, so the parent + link is not lost. + """ + if sidecar_dir is None: + return () + folder = sidecar_dir / SUBAGENTS_DIRNAME + if not folder.is_dir(): + return () + ids: set[str] = set() + for entry in folder.iterdir(): + name = entry.name + if not name.startswith(_PREFIX): + continue + if name.endswith(".meta.json"): + ids.add(name[len(_PREFIX):-len(".meta.json")]) + elif name.endswith(".jsonl"): + ids.add(name[len(_PREFIX):-len(".jsonl")]) + out = [] + for agent_id in sorted(ids): + meta = _read_meta(folder / f"{_PREFIX}{agent_id}.meta.json") + transcript = folder / f"{_PREFIX}{agent_id}.jsonl" + if transcript.is_file(): + parsed = parse_transcript(transcript, sidechain_is_main=True) + turns, refs, stats, models = parsed.turns, parsed.full_refs, parsed.stats, parsed.models + else: + turns, refs, stats, models = (), {}, ReadStats(), () + depth = meta.get("spawnDepth") + out.append( + SubagentTranscript( + agent_id=agent_id, + tool_use_id=meta.get("toolUseId") if isinstance(meta.get("toolUseId"), str) else None, + agent_type=meta.get("agentType") if isinstance(meta.get("agentType"), str) else None, + description=meta.get("description") if isinstance(meta.get("description"), str) else None, + spawn_depth=depth if isinstance(depth, int) else None, + path=transcript, + turns=turns, + full_refs=refs, + stats=stats, + models=models, + ) + ) + return tuple(out) + + +def attach_subagents(parsed: ParsedTranscript, subagents: tuple[SubagentTranscript, ...]) -> ParsedTranscript: + """Return a transcript whose ``Agent`` calls name their subagent's id.""" + by_tool: dict[str, str] = {s.tool_use_id: s.agent_id for s in subagents if s.tool_use_id} + if not by_tool: + return parsed + turns = [] + for turn in parsed.turns: + calls = list(turn["tool_calls"]) + changed = False + for index, call in enumerate(calls): + agent_id = by_tool.get(call["tool_use_id"]) + if agent_id is None: + continue + result = call["result"] or {"status": "missing"} + if result.get("agent_id") == agent_id: + continue + calls[index] = dict(call, result=dict(result, agent_id=agent_id)) + changed = True + turns.append(dict(turn, tool_calls=calls) if changed else turn) + return replace(parsed, turns=tuple(turns)) diff --git a/src/automil/viz/transcript.py b/src/automil/viz/transcript.py new file mode 100644 index 00000000..3d1c4cd2 --- /dev/null +++ b/src/automil/viz/transcript.py @@ -0,0 +1,662 @@ +"""Claude Code transcript lines become turns. + +A transcript is one JSON object per line. One model response is written as +several ``assistant`` lines sharing a ``requestId``; tool results arrive later +as ``user`` lines. :class:`TurnBuilder` folds that stream into turns and keeps +enough state (pending tool calls, byte offsets) to be fed a growing file. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping + +from automil.trajectory.redactor import redact_secrets +from automil.viz.transcript_commands import detect_automil, summarize_calls + +RESULT_PREVIEW_CHARS = 2048 +_PREVIEW_HEAD = 1536 +_PREVIEW_TAIL = 512 +RESULT_FULL_MAX_BYTES = 1 << 20 +THINKING_MAX_CHARS = 32 * 1024 +META_MAX_CHARS = 2048 + +_IGNORED_TYPES = frozenset({ + "attachment", "queue-operation", "mode", "permission-mode", "ai-title", + "custom-title", "last-prompt", "file-history-snapshot", "file-history-delta", + "atis-latch", "bridge-session", "summary", +}) +_USAGE_FIELDS = ( + ("input", "input_tokens"), + ("output", "output_tokens"), + ("cache_read", "cache_read_input_tokens"), + ("cache_create", "cache_creation_input_tokens"), +) +_TAG = { + name: re.compile(rf"<{name}>(.*?)", re.DOTALL) + for name in ("task-id", "tool-use-id", "status", "summary", "result") +} + + +@dataclass(frozen=True) +class Record: + """One decodable transcript line and where it sits in the file.""" + + payload: dict[str, Any] + offset: int + length: int + line_no: int + + +@dataclass(frozen=True) +class RecordBatch: + records: tuple[Record, ...] + garbled: int + partial_tail: bool + resume_offset: int + next_line_no: int + + +def _decode(raw: bytes) -> dict[str, Any] | None: + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def read_records(path: Path, *, start_offset: int = 0, line_no: int = 1) -> RecordBatch: + """Read complete lines from ``start_offset``; a partial last line waits. + + Lines that are not UTF-8, not JSON or not an object are skipped and + counted; blank lines are skipped silently. Never raises on content. + """ + with open(path, "rb") as fh: + fh.seek(start_offset) + data = fh.read() + records: list[Record] = [] + garbled = 0 + offset = start_offset + number = line_no + pos = 0 + while True: + newline = data.find(b"\n", pos) + if newline < 0: + break + raw = data[pos:newline] + length = newline - pos + 1 + if raw.strip(): + payload = _decode(raw) + if payload is None: + garbled += 1 + else: + records.append(Record(payload, offset, length, number)) + offset += length + pos = newline + 1 + number += 1 + return RecordBatch( + records=tuple(records), + garbled=garbled, + partial_tail=pos < len(data), + resume_offset=offset, + next_line_no=number, + ) + + +@dataclass(frozen=True) +class FullResultRef: + """Where the untruncated text of one tool result lives in the file.""" + + tool_use_id: str + offset: int + length: int + + +@dataclass(frozen=True) +class ReadStats: + lines_total: int = 0 + lines_garbled: int = 0 + lines_unknown_type: int = 0 + lines_ignored: int = 0 + lines_sidechain: int = 0 + orphan_results: int = 0 + partial_tail: bool = False + resume_offset: int = 0 + + def as_dict(self) -> dict[str, Any]: + return dict(self.__dict__) + + +@dataclass(frozen=True) +class BuildDelta: + """What one fed record changed: newly closed turns, late results, the open turn.""" + + closed: tuple[dict[str, Any], ...] = () + patches: tuple[tuple[int, int, dict[str, Any]], ...] = () + open_changed: bool = False + + +@dataclass +class _Counters: + total: int = 0 + unknown: int = 0 + ignored: int = 0 + sidechain: int = 0 + orphans: int = 0 + + +@dataclass +class _Open: + """The turn being assembled; becomes a plain dict when closed.""" + + kind: str + at: str | None + uuids: list[str] = field(default_factory=list) + request_id: str | None = None + at_end: str | None = None + model: str | None = None + texts: list[str] = field(default_factory=list) + thoughts: list[str] = field(default_factory=list) + calls: list[dict[str, Any]] = field(default_factory=list) + usage: dict[str, int] | None = None + images: int = 0 + notification: dict[str, Any] | None = None + system: dict[str, Any] | None = None + n_lines: int = 0 + + +def _result_text(block: Mapping[str, Any], payload: Mapping[str, Any]) -> tuple[str, int]: + """The textual content of a ``tool_result`` block and its image count.""" + content = block.get("content") + images = 0 + if isinstance(content, str): + text = content + elif isinstance(content, list): + parts = [] + for item in content: + if not isinstance(item, dict): + continue + kind = item.get("type") + if kind == "text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + elif kind == "image": + images += 1 + elif kind == "tool_reference": + parts.append("[tool_reference]") + text = "\n".join(parts) + else: + text = "" + if not text: + structured = payload.get("toolUseResult") + if isinstance(structured, dict) and isinstance(structured.get("stdout"), str): + text = structured["stdout"] + if isinstance(structured.get("stderr"), str) and structured["stderr"]: + text = f"{text}\n{structured['stderr']}" if text else structured["stderr"] + return text, images + + +def _preview(text: str, limit: int) -> tuple[str, bool]: + if len(text) <= limit: + return text, False + omitted = len(text) - _PREVIEW_HEAD - _PREVIEW_TAIL + return f"{text[:_PREVIEW_HEAD]}\n…[{omitted} chars omitted]…\n{text[-_PREVIEW_TAIL:]}", True + + +def _parse_notification(text: str) -> dict[str, Any]: + """The task notification's fields; the ```` tag becomes ``title``.""" + fields = {} + for name, key in (("task-id", "task_id"), ("tool-use-id", "tool_use_id"), ("status", "status"), ("summary", "title")): + match = _TAG[name].search(text) + fields[key] = match.group(1).strip() if match else None + return fields + + +def _notification_body(text: str) -> str: + match = _TAG["result"].search(text) + return match.group(1).strip() if match else text.strip() + + +class TurnBuilder: + """Fold transcript records into turns; safe to feed incrementally.""" + + def __init__( + self, + *, + preview_chars: int = RESULT_PREVIEW_CHARS, + redact: Callable[[str], str] = redact_secrets, + sidechain_is_main: bool = False, + ) -> None: + self._preview_chars = preview_chars + self._redact = redact + self._sidechain_is_main = sidechain_is_main + self._turns: list[dict[str, Any]] = [] + self._open: _Open | None = None + self._pending: dict[str, tuple[int | None, int]] = {} + self._uuid_turn: dict[str, int | None] = {} + self._counters = _Counters() + self._models: set[str] = set() + self.full_refs: dict[str, FullResultRef] = {} + self.inline: dict[str, TurnBuilder] = {} + self.session_id: str | None = None + self.cwd: str | None = None + self.git_branch: str | None = None + + # -- public state ---------------------------------------------------- + + @property + def turns(self) -> tuple[dict[str, Any], ...]: + return tuple(self._turns) + + @property + def open_turn(self) -> dict[str, Any] | None: + return self._finish(self._open, len(self._turns)) if self._open else None + + @property + def models(self) -> tuple[str, ...]: + return tuple(sorted(self._models)) + + def stats(self, *, garbled: int = 0, partial_tail: bool = False, resume_offset: int = 0) -> ReadStats: + c = self._counters + return ReadStats( + lines_total=c.total + garbled, + lines_garbled=garbled, + lines_unknown_type=c.unknown, + lines_ignored=c.ignored, + lines_sidechain=c.sidechain, + orphan_results=c.orphans, + partial_tail=partial_tail, + resume_offset=resume_offset, + ) + + # -- feeding --------------------------------------------------------- + + def feed(self, record: Record) -> BuildDelta: + payload = record.payload + self._counters.total += 1 + self._note_session(payload) + if payload.get("isSidechain") and not self._sidechain_is_main: + self._counters.sidechain += 1 + agent_id = str(payload.get("agentId") or "inline") + self.inline.setdefault(agent_id, TurnBuilder(preview_chars=self._preview_chars, redact=self._redact)) + self.inline[agent_id].feed(Record(dict(payload, isSidechain=False), record.offset, record.length, record.line_no)) + return BuildDelta() + kind = payload.get("type") + if kind == "assistant": + return self._feed_assistant(record) + if kind == "user": + return self._feed_user(record) + if kind == "system": + return self._feed_system(record) + if kind in _IGNORED_TYPES: + self._counters.ignored += 1 + else: + self._counters.unknown += 1 + return BuildDelta() + + def flush(self) -> tuple[dict[str, Any], ...]: + """Close the open turn and mark every unanswered call as missing.""" + closed = self._close_open() + missing = {"status": "missing"} + patched: list[int] = [] + for tool_use_id, (turn_index, call_index) in list(self._pending.items()): + if turn_index is None: + continue + self._turns[turn_index] = _with_result(self._turns[turn_index], call_index, missing) + patched.append(turn_index) + del self._pending[tool_use_id] + for builder in self.inline.values(): + builder.flush() + return tuple(closed) + tuple(self._turns[i] for i in sorted(set(patched)) if i >= len(self._turns) - len(closed)) + + # -- assistant ------------------------------------------------------- + + def _feed_assistant(self, record: Record) -> BuildDelta: + payload = record.payload + message = payload.get("message") if isinstance(payload.get("message"), dict) else {} + request_id = payload.get("requestId") or message.get("id") + closed: tuple[dict[str, Any], ...] = () + joins = ( + self._open is not None + and self._open.kind == "assistant" + and (request_id is None or self._open.request_id is None or self._open.request_id == request_id) + ) + if not joins: + closed = self._close_open() + self._open = _Open(kind="assistant", at=payload.get("timestamp"), request_id=request_id) + turn = self._open + assert turn is not None + turn.n_lines += 1 + turn.at_end = payload.get("timestamp") or turn.at_end + if request_id and turn.request_id is None: + turn.request_id = request_id + uuid = payload.get("uuid") + if isinstance(uuid, str): + turn.uuids.append(uuid) + self._uuid_turn[uuid] = None + model = message.get("model") + if isinstance(model, str) and model: + turn.model = model + self._models.add(model) + turn.usage = _merge_usage(turn.usage, message.get("usage")) + for block in message.get("content") or []: + if not isinstance(block, dict): + continue + kind = block.get("type") + if kind == "text" and isinstance(block.get("text"), str): + turn.texts.append(block["text"]) + elif kind == "thinking" and isinstance(block.get("thinking"), str) and block["thinking"]: + turn.thoughts.append(block["thinking"]) + elif kind == "tool_use": + self._add_call(turn, block) + return BuildDelta(closed=closed, open_changed=True) + + def _add_call(self, turn: _Open, block: Mapping[str, Any]) -> None: + tool_use_id = str(block.get("id") or f"call-{len(turn.calls)}") + name = str(block.get("name") or "?") + raw_input = block.get("input") if isinstance(block.get("input"), dict) else {} + automil = None + if name == "Bash" and isinstance(raw_input.get("command"), str): + calls = detect_automil(raw_input["command"]) + if calls: + automil = summarize_calls(calls) + turn.calls.append({ + "tool_use_id": tool_use_id, + "name": name, + "input": raw_input, + "automil": automil, + "result": None, + }) + self._pending[tool_use_id] = (None, len(turn.calls) - 1) + + # -- user ------------------------------------------------------------ + + def _feed_user(self, record: Record) -> BuildDelta: + payload = record.payload + message = payload.get("message") if isinstance(payload.get("message"), dict) else {} + content = message.get("content") + if isinstance(content, list): + results = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_result"] + if results: + patches: list[tuple[int, int, dict[str, Any]]] = [] + open_changed = False + for block in results: + outcome = self._attach(record, block) + if outcome is None: + continue + if outcome[0] is None: + open_changed = True + else: + patches.append(outcome) # type: ignore[arg-type] + return BuildDelta(patches=tuple(patches), open_changed=open_changed) + text = "\n".join(b["text"] for b in content if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str)) + images = sum(1 for b in content if isinstance(b, dict) and b.get("type") == "image") + return self._simple_turn(record, "human", text, images=images) + if not isinstance(content, str): + self._counters.ignored += 1 + return BuildDelta() + origin = payload.get("origin") if isinstance(payload.get("origin"), dict) else {} + stripped = content.lstrip() + if payload.get("isCompactSummary"): + return self._simple_turn(record, "system", content, system={"subtype": "compact_summary"}) + if payload.get("isMeta") or stripped.startswith(("", "")): + return self._simple_turn(record, "system", content[:META_MAX_CHARS], system={"subtype": "meta"}) + if origin.get("kind") == "task-notification" or stripped.startswith(""): + return self._simple_turn( + record, "notification", _notification_body(content), notification=_parse_notification(content) + ) + return self._simple_turn(record, "human", content) + + def _simple_turn( + self, + record: Record, + kind: str, + text: str, + *, + images: int = 0, + notification: dict[str, Any] | None = None, + system: dict[str, Any] | None = None, + ) -> BuildDelta: + closed = self._close_open() + turn = _Open(kind=kind, at=record.payload.get("timestamp"), notification=notification, system=system) + turn.n_lines = 1 + turn.images = images + uuid = record.payload.get("uuid") + if isinstance(uuid, str): + turn.uuids.append(uuid) + turn.texts.append(text) + self._turns.append(self._finish(turn, len(self._turns))) + return BuildDelta(closed=closed + (self._turns[-1],)) + + def _attach(self, record: Record, block: Mapping[str, Any]) -> tuple[int | None, int, dict[str, Any]] | None: + tool_use_id = block.get("tool_use_id") + location = self._pending.pop(tool_use_id, None) if isinstance(tool_use_id, str) else None + if location is None: + source = record.payload.get("sourceToolAssistantUUID") + location = self._first_unanswered(source) if isinstance(source, str) else None + if location is None: + self._counters.orphans += 1 + return None + turn_index, call_index = location + text, images = _result_text(block, record.payload) + text = self._redact(text) + preview, truncated = _preview(text, self._preview_chars) + structured = record.payload.get("toolUseResult") + structured = structured if isinstance(structured, dict) else {} + status = "error" if block.get("is_error") else "interrupted" if structured.get("interrupted") else "ok" + call_id = self._call_at(turn_index, call_index)["tool_use_id"] + if truncated: + self.full_refs[call_id] = FullResultRef(call_id, record.offset, record.length) + result = { + "status": status, + "preview": preview, + "bytes": len(text.encode("utf-8")), + "truncated": truncated, + "full_ref": f"results/{call_id}.json" if truncated else None, + "images": images, + "at": record.payload.get("timestamp"), + "agent_id": structured.get("agentId") if isinstance(structured.get("agentId"), str) else None, + } + self._set_result(turn_index, call_index, result, text) + return (turn_index, call_index, result) + + def _first_unanswered(self, source_uuid: str) -> tuple[int | None, int] | None: + if source_uuid not in self._uuid_turn: + return None + turn_index = self._uuid_turn[source_uuid] + calls = self._open.calls if turn_index is None and self._open else self._turns[turn_index]["tool_calls"] if turn_index is not None else [] + for call_index, call in enumerate(calls): + if call["result"] is None: + self._pending.pop(call["tool_use_id"], None) + return (turn_index, call_index) + return None + + def _call_at(self, turn_index: int | None, call_index: int) -> dict[str, Any]: + if turn_index is None: + assert self._open is not None + return self._open.calls[call_index] + return self._turns[turn_index]["tool_calls"][call_index] + + def _set_result(self, turn_index: int | None, call_index: int, result: dict[str, Any], text: str) -> None: + call = self._call_at(turn_index, call_index) + automil = call["automil"] + if automil is not None and result["status"] != "interrupted": + automil = summarize_calls(detect_automil(call["input"].get("command", "")), text) + updated = dict(call, result=result, automil=automil) + if turn_index is None: + assert self._open is not None + self._open.calls[call_index] = updated + else: + turn = dict(self._turns[turn_index]) + calls = list(turn["tool_calls"]) + calls[call_index] = updated + turn["tool_calls"] = calls + turn["node_ids"] = _node_ids(calls) + self._turns[turn_index] = turn + + # -- system ---------------------------------------------------------- + + def _feed_system(self, record: Record) -> BuildDelta: + payload = record.payload + if payload.get("subtype") != "compact_boundary": + self._counters.ignored += 1 + return BuildDelta() + meta = payload.get("compactMetadata") if isinstance(payload.get("compactMetadata"), dict) else {} + system = { + "subtype": "compact_boundary", + "pre_tokens": meta.get("preTokens"), + "post_tokens": meta.get("postTokens"), + } + return self._simple_turn(record, "system", "", system=system) + + # -- closing --------------------------------------------------------- + + def _close_open(self) -> tuple[dict[str, Any], ...]: + if self._open is None: + return () + index = len(self._turns) + turn = self._finish(self._open, index) + self._turns.append(turn) + for uuid in self._open.uuids: + self._uuid_turn[uuid] = index + for call_index, call in enumerate(self._open.calls): + if call["result"] is None: + self._pending[call["tool_use_id"]] = (index, call_index) + self._open = None + return (turn,) + + @staticmethod + def _finish(turn: _Open, index: int) -> dict[str, Any]: + thinking = "\n\n".join(turn.thoughts) + if len(thinking) > THINKING_MAX_CHARS: + thinking = thinking[:THINKING_MAX_CHARS] + "\n…[thinking truncated]…" + return { + "index": index, + "kind": turn.kind, + "uuid": turn.uuids[0] if turn.uuids else None, + "request_id": turn.request_id, + "at": turn.at, + "at_end": turn.at_end, + "model": turn.model, + "text": "\n\n".join(turn.texts), + "thinking": thinking or None, + "tool_calls": list(turn.calls), + "usage": turn.usage, + "images": turn.images, + "node_ids": _node_ids(turn.calls), + "notification": turn.notification, + "system": turn.system, + "n_lines": turn.n_lines, + } + + def _note_session(self, payload: Mapping[str, Any]) -> None: + if self.session_id is None: + sid = payload.get("sessionId") or payload.get("session_id") + if isinstance(sid, str): + self.session_id = sid + if self.cwd is None and isinstance(payload.get("cwd"), str): + self.cwd = payload["cwd"] + if self.git_branch is None and isinstance(payload.get("gitBranch"), str): + self.git_branch = payload["gitBranch"] + + +def _with_result(turn: dict[str, Any], call_index: int, result: dict[str, Any]) -> dict[str, Any]: + calls = list(turn["tool_calls"]) + calls[call_index] = dict(calls[call_index], result=result) + return dict(turn, tool_calls=calls) + + +def _node_ids(calls: Iterable[Mapping[str, Any]]) -> list[str]: + ids: set[str] = set() + for call in calls: + automil = call.get("automil") + if automil: + ids.update(automil.get("node_ids") or ()) + return sorted(ids) + + +def _merge_usage(current: dict[str, int] | None, raw: object) -> dict[str, int] | None: + if not isinstance(raw, dict): + return current + merged = dict(current or {}) + for name, source in _USAGE_FIELDS: + value = raw.get(source) + if isinstance(value, (int, float)) and not isinstance(value, bool): + merged[name] = max(int(value), merged.get(name, 0)) + return merged or current + + +@dataclass(frozen=True) +class ParsedTranscript: + session_id: str | None + cwd: str | None + git_branch: str | None + models: tuple[str, ...] + turns: tuple[dict[str, Any], ...] + open_turn: dict[str, Any] | None + full_refs: Mapping[str, FullResultRef] + stats: ReadStats + inline_sidechains: Mapping[str, tuple[dict[str, Any], ...]] + + +def parse_transcript( + path: Path, *, ended: bool = True, sidechain_is_main: bool = False +) -> ParsedTranscript: + """Parse a whole file. ``ended=False`` keeps the trailing turn open. + + A subagent's own file is all sidechain lines; ``sidechain_is_main`` reads + it as the main stream. + """ + builder = TurnBuilder(sidechain_is_main=sidechain_is_main) + batch = read_records(path) + for record in batch.records: + builder.feed(record) + if ended: + builder.flush() + inline = {agent_id: b.turns for agent_id, b in builder.inline.items()} + return ParsedTranscript( + session_id=builder.session_id, + cwd=builder.cwd, + git_branch=builder.git_branch, + models=builder.models, + turns=builder.turns, + open_turn=None if ended else builder.open_turn, + full_refs=dict(builder.full_refs), + stats=builder.stats(garbled=batch.garbled, partial_tail=batch.partial_tail, resume_offset=batch.resume_offset), + inline_sidechains=inline, + ) + + +def read_full_result( + path: Path, + ref: FullResultRef, + *, + max_bytes: int = RESULT_FULL_MAX_BYTES, + redact: Callable[[str], str] = redact_secrets, +) -> dict[str, Any]: + """Re-read one result line by offset; the text is redacted and capped.""" + with open(path, "rb") as fh: + fh.seek(ref.offset) + raw = fh.read(ref.length) + payload = _decode(raw.rstrip(b"\n")) + if payload is None: + return {"tool_use_id": ref.tool_use_id, "text": "", "bytes": 0, "truncated": False, "images": 0, "error": "line unreadable"} + message = payload.get("message") if isinstance(payload.get("message"), dict) else {} + content = message.get("content") if isinstance(message.get("content"), list) else [] + block = next((b for b in content if isinstance(b, dict) and b.get("tool_use_id") == ref.tool_use_id), None) + if block is None: + return {"tool_use_id": ref.tool_use_id, "text": "", "bytes": 0, "truncated": False, "images": 0, "error": "result not on line"} + text, images = _result_text(block, payload) + text = redact(text) + encoded = text.encode("utf-8") + truncated = len(encoded) > max_bytes + if truncated: + text = encoded[:max_bytes].decode("utf-8", errors="ignore") + return { + "tool_use_id": ref.tool_use_id, + "text": text, + "bytes": len(encoded), + "truncated": truncated, + "images": images, + } diff --git a/src/automil/viz/transcript_commands.py b/src/automil/viz/transcript_commands.py new file mode 100644 index 00000000..c947bb93 --- /dev/null +++ b/src/automil/viz/transcript_commands.py @@ -0,0 +1,97 @@ +"""Find ``automil`` commands inside Bash tool calls and the node ids they touch. + +The agent drives the framework through its CLI, so the transcript's Bash +calls are where proposals and submissions happen. ``propose`` and ``submit`` +print the node they created (``cli/propose.py``, ``cli/submit.py``, +``cli/resubmit.py``); every other subcommand only refers to ids given on its +command line. +""" +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass +from typing import Iterable + +NODE_ID = re.compile(r"\bnode_\d{4,}\b") + +# ``automil ...`` anywhere in a shell line, however it is launched +# (``uv run --project . automil ...``, ``python -m automil ...``). The +# ``--project`` group option may sit between the program and the subcommand. +_COMMAND = re.compile( + r"\bautomil\s+(?:--project(?:=\S+|\s+\S+)\s+)?(?P[a-z][a-z-]*)(?P[^\n;&|)]*)" +) + +_CREATED = { + "propose": re.compile(r"^Added proposal (node_\d{4,}) \[", re.MULTILINE), + "submit": re.compile(r"^Submitted (node_\d{4,}):", re.MULTILINE), + "resubmit": re.compile(r"^(node_\d{4,})\s*$", re.MULTILINE), +} + + +@dataclass(frozen=True) +class AutomilCall: + """One ``automil`` invocation found in a shell command.""" + + sub: str + argv: tuple[str, ...] + node_ids: tuple[str, ...] + + def as_dict(self) -> dict[str, object]: + return {"sub": self.sub, "argv": list(self.argv), "node_ids": list(self.node_ids)} + + +def _split(text: str) -> tuple[str, ...]: + try: + return tuple(shlex.split(text)) + except ValueError: + return tuple(text.split()) + + +def detect_automil(command: str) -> tuple[AutomilCall, ...]: + """Every ``automil`` invocation in ``command``, in order of appearance.""" + calls = [] + for match in _COMMAND.finditer(command): + argv = (match["sub"],) + _split(match["rest"]) + node_ids = tuple(sorted({m.group(0) for token in argv[1:] for m in NODE_ID.finditer(token)})) + calls.append(AutomilCall(sub=match["sub"], argv=argv, node_ids=node_ids)) + return tuple(calls) + + +def created_node_ids(subs: Iterable[str], output: str) -> tuple[tuple[str, str], ...]: + """``(node_id, sub)`` for every node the output reports as created. + + Only the subcommands present in the shell line are consulted, so a + ``rank`` listing never reads as a creation. + """ + found: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for sub in subs: + pattern = _CREATED.get(sub) + if pattern is None: + continue + for match in pattern.finditer(output): + pair = (match.group(1), sub) + if pair not in seen: + seen.add(pair) + found.append(pair) + return tuple(found) + + +def summarize_calls( + calls: Iterable[AutomilCall], output: str | None = None +) -> dict[str, object]: + """The ``automil`` field of a Bash tool call: its invocations and node ids. + + ``created`` lists the nodes the output reported creating (empty until the + result arrives); ``node_ids`` is every id the call touched. + """ + calls = tuple(calls) + created = created_node_ids([c.sub for c in calls], output) if output else () + ids = {node_id for call in calls for node_id in call.node_ids} + ids.update(node_id for node_id, _ in created) + return { + "calls": [call.as_dict() for call in calls], + "node_ids": sorted(ids), + "created": [{"node_id": node_id, "sub": sub} for node_id, sub in created], + } diff --git a/tests/cells/test_activity.py b/tests/cells/test_activity.py index 5e18f438..590737fb 100644 --- a/tests/cells/test_activity.py +++ b/tests/cells/test_activity.py @@ -386,3 +386,66 @@ def test_journal_schema_rejects_obsolete_wait_events(tmp_path): with pytest.raises(ActivityError, match="unknown journal event"): read_activity_report(tmp_path, "cell-1") + + +def test_journal_sessions_lists_open_bound_and_ended_sessions(tmp_path): + from automil.cells.activity import journal_sessions + + assert journal_sessions(tmp_path) == () + _record(tmp_path, "SessionStart", 10.0, cell_id=None, session_id="s-open") + _record(tmp_path, "SessionStart", 20.0, cell_id=None, session_id="s-done") + bind_activity_session(tmp_path, "cell-1", "s-done", "a" * 64, observed_at=21.0) + (tmp_path / ACTIVITY_SAMPLES_FILENAME).write_text(json.dumps({ + "schema_version": 1, + "sessions": {"s-done": {"active_seconds": 5.0, "observed_at": 30.0}}, + })) + _record(tmp_path, "SessionEnd", 30.0, cell_id="cell-1", session_id="s-done") + + sessions = journal_sessions(tmp_path) + assert [s.session_id for s in sessions] == ["s-open", "s-done"] + open_session, done = sessions + assert (open_session.opened_at, open_session.ended_at, open_session.cell_id) == (10.0, None, None) + assert (done.opened_at, done.ended_at, done.cell_id, done.binding_sha256) == ( + 20.0, 30.0, "cell-1", "a" * 64, + ) + assert done.ended_by == "hook" + + +def test_journal_sessions_reports_operator_close_and_a_corrupt_journal(tmp_path): + from automil.cells.activity import ACTIVITY_JOURNAL_FILENAME, journal_sessions + + _record(tmp_path, "SessionStart", 10.0, cell_id=None, session_id="s-1") + (tmp_path / ACTIVITY_SAMPLES_FILENAME).write_text(json.dumps({ + "schema_version": 1, + "sessions": {"s-1": {"active_seconds": 5.0, "observed_at": 12.0}}, + })) + from automil.cells.activity import close_dead_session + close_dead_session(tmp_path, "s-1", "runtime died", finalized_by="operator-close") + (session,) = journal_sessions(tmp_path) + assert session.ended_by == "operator-close" + + with (tmp_path / ACTIVITY_JOURNAL_FILENAME).open("a") as fh: + fh.write("not json\n") + with pytest.raises(ActivityError): + journal_sessions(tmp_path) + + +def test_replay_keeps_the_hook_fallback_marker_apart_from_an_operator_close(tmp_path): + """Both closes promote the same stored sample; the ledger must still say which ran.""" + from automil.cells.activity import close_dead_session, journal_sessions + + for session_id, marker in (("s-hook", "hook-exporter-unreachable"), ("s-op", "operator-close")): + _record(tmp_path, "SessionStart", 10.0, cell_id=None, session_id=session_id) + samples = json.loads((tmp_path / ACTIVITY_SAMPLES_FILENAME).read_text()) if (tmp_path / ACTIVITY_SAMPLES_FILENAME).exists() else {"schema_version": 1, "sessions": {}} + samples["sessions"][session_id] = {"active_seconds": 5.0, "observed_at": 12.0} + (tmp_path / ACTIVITY_SAMPLES_FILENAME).write_text(json.dumps(samples)) + close_dead_session(tmp_path, session_id, "runtime gone", finalized_by=marker) + + by_id = {s.session_id: s for s in journal_sessions(tmp_path)} + assert by_id["s-hook"].ended_by == "hook-exporter-unreachable" + assert by_id["s-op"].ended_by == "operator-close" + # the marker survives a full replay of the journal, not only the in-memory write + lines = [json.loads(line) for line in (tmp_path / ACTIVITY_JOURNAL_FILENAME).read_text().splitlines()] + ends = {e["session_id"]: e["finalized_by"] for e in lines if e["event"] == "session_end"} + assert ends == {"s-hook": "hook-exporter-unreachable", "s-op": "operator-close"} + assert read_activity_report(tmp_path, "cell-1").event_count >= 0 # replay still validates diff --git a/tests/test_activity_cli.py b/tests/test_activity_cli.py index f2dda356..0495593a 100644 --- a/tests/test_activity_cli.py +++ b/tests/test_activity_cli.py @@ -435,3 +435,75 @@ def _dead(**_kwargs): ) assert again.exit_code != 0 assert "already ended" in again.output + + +def _hook_env_project(tmp_path, monkeypatch): + """A project whose hooks can run; the exporter is unreachable (fallback path).""" + _write_config(tmp_path, _config()) + monkeypatch.chdir(tmp_path) + return tmp_path / "automil" + + +def test_session_end_stores_the_transcript_before_accounting(tmp_path, monkeypatch): + cli_runner = CliRunner() + from tests.viz.conftest import SID, write_mini_session + + automil = _hook_env_project(tmp_path, monkeypatch) + transcript = write_mini_session(tmp_path / "home" / "projects" / "-x") + automil.joinpath(".activity.jsonl").write_text( + '{"cell_id":null,"event":"session_open","observed_at":1.0,"session_id":"%s"}\n' % SID + ) + automil.joinpath(".activity.samples.json").write_text(json.dumps({ + "schema_version": 1, "sessions": {SID: {"active_seconds": 5.0, "observed_at": 2.0}}, + })) + payload = {"hook_event_name": "SessionEnd", "session_id": SID, "transcript_path": str(transcript)} + result = cli_runner.invoke(main, ["activity", "ingest"], input=json.dumps(payload)) + assert result.exit_code == 0, result.output + assert (automil / "sessions" / SID / "transcript.jsonl").read_bytes() == transcript.read_bytes() + assert (automil / "sessions" / SID / "subagents" / "agent-agent1.jsonl").is_file() + + +def test_session_end_without_transcript_path_warns_and_still_accounts(tmp_path, monkeypatch): + cli_runner = CliRunner() + from tests.viz.conftest import SID + + automil = _hook_env_project(tmp_path, monkeypatch) + automil.joinpath(".activity.jsonl").write_text( + '{"cell_id":null,"event":"session_open","observed_at":1.0,"session_id":"%s"}\n' % SID + ) + automil.joinpath(".activity.samples.json").write_text(json.dumps({ + "schema_version": 1, "sessions": {SID: {"active_seconds": 5.0, "observed_at": 2.0}}, + })) + payload = {"hook_event_name": "SessionEnd", "session_id": SID} + result = cli_runner.invoke(main, ["activity", "ingest"], input=json.dumps(payload)) + assert result.exit_code == 0, result.output + assert "session record not stored" in result.output + assert not (automil / "sessions").exists() + assert "session_end" in automil.joinpath(".activity.jsonl").read_text() + + +def test_store_sessions_copies_journaled_sessions_and_reports_missing(tmp_path, monkeypatch): + cli_runner = CliRunner() + from tests.viz.conftest import SID, write_mini_session + + automil = _hook_env_project(tmp_path, monkeypatch) + home = tmp_path / "claude" + write_mini_session(home / "projects" / "-x") + missing = "9999eeee-0000-4000-8000-000000000000" + automil.joinpath(".activity.jsonl").write_text( + '{"cell_id":null,"event":"session_open","observed_at":1.0,"session_id":"%s"}\n' + '{"cell_id":null,"event":"session_open","observed_at":2.0,"session_id":"%s"}\n' % (SID, missing) + ) + result = cli_runner.invoke( + main, ["activity", "store-sessions", "--root", str(tmp_path), "--claude-config-dir", str(home)], + ) + assert result.exit_code == 1, result.output + assert f"{SID}: stored" in result.output and f"{missing}: missing" in result.output + assert (automil / "sessions" / SID / "transcript.jsonl").is_file() + again = cli_runner.invoke( + main, ["activity", "store-sessions", "--root", str(automil), "--claude-config-dir", str(home)], + ) + assert f"{SID}: unchanged" in again.output + assert "store-sessions" not in cli_runner.invoke(main, ["--help"]).output + bad = cli_runner.invoke(main, ["activity", "store-sessions", "--root", str(tmp_path / "nowhere")]) + assert bad.exit_code != 0 and "no automil/config.yaml" in bad.output diff --git a/tests/test_session_record.py b/tests/test_session_record.py new file mode 100644 index 00000000..cdbcc55c --- /dev/null +++ b/tests/test_session_record.py @@ -0,0 +1,93 @@ +"""session_record: the runtime's transcript is copied into the project.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from automil.session_record import ( + SessionRecordError, + claude_config_dir, + locate_home_transcript, + read_stored_sessions, + store_journaled_sessions, + store_session_record, +) + +from tests.viz.conftest import SID, write_mini_session + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + """A fake ``~/.claude`` with one project slug holding the mini session.""" + config = tmp_path / "claude" + slug = config / "projects" / "-data-project" + write_mini_session(slug) + return config + + +def test_store_copies_transcript_sidecar_and_manifest(tmp_path: Path, home: Path): + automil = tmp_path / "automil" + automil.mkdir() + source = home / "projects" / "-data-project" / f"{SID}.jsonl" + + outcome = store_session_record(automil, SID, source) + assert outcome.action == "stored" + stored = automil / "sessions" / SID + assert (stored / "transcript.jsonl").read_bytes() == source.read_bytes() + assert (stored / "subagents" / "agent-agent1.jsonl").is_file() + assert (stored / "subagents" / "agent-agent1.meta.json").is_file() + manifest = json.loads((stored / "record.json").read_text()) + assert manifest["session_id"] == SID and manifest["sidecar_files"] == 2 + assert manifest["lines"] == source.read_bytes().count(b"\n") and manifest["complete"] is True + assert manifest["bytes"] == source.stat().st_size and len(manifest["sha256"]) == 64 + + # a second store with nothing new is a no-op + assert store_session_record(automil, SID, source).action == "unchanged" + + # a grown source is copied again + with source.open("ab") as fh: + fh.write(b'{"type": "mode", "mode": "normal"}\n') + again = store_session_record(automil, SID, source) + assert again.action == "stored" + assert (stored / "transcript.jsonl").read_bytes() == source.read_bytes() + + (record,) = read_stored_sessions(automil) + assert record.session_id == SID and record.sidecar == stored + assert record.manifest["lines"] == manifest["lines"] + 1 + + +def test_store_validates_ids_and_reports_a_missing_source(tmp_path: Path): + automil = tmp_path / "automil" + automil.mkdir() + with pytest.raises(SessionRecordError): + store_session_record(automil, "../etc", tmp_path / "x.jsonl") + with pytest.raises(SessionRecordError): + store_session_record(automil, SID, tmp_path / "other.jsonl") + outcome = store_session_record(automil, SID, tmp_path / f"{SID}.jsonl") + assert outcome.action == "missing" and outcome.path is None + assert read_stored_sessions(automil) == () + + +def test_locate_honours_the_config_dir_and_newest_copy(tmp_path: Path, home: Path, monkeypatch): + assert locate_home_transcript(SID, config_dir=home) == home / "projects" / "-data-project" / f"{SID}.jsonl" + assert locate_home_transcript("0000aaaa-0000-0000-0000-000000000000", config_dir=home) is None + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + assert claude_config_dir() == home + assert locate_home_transcript(SID) is not None + monkeypatch.delenv("CLAUDE_CONFIG_DIR") + assert claude_config_dir() == Path.home() / ".claude" + + +def test_store_journaled_sessions_reports_each_outcome(tmp_path: Path, home: Path): + automil = tmp_path / "automil" + automil.mkdir() + missing = "1111aaaa-0000-0000-0000-000000000000" + (automil / ".activity.jsonl").write_text( + '{"cell_id":null,"event":"session_open","observed_at":1.0,"session_id":"%s"}\n' + '{"cell_id":null,"event":"session_open","observed_at":2.0,"session_id":"%s"}\n' % (SID, missing) + ) + outcomes = store_journaled_sessions(automil, config_dir=home) + assert [(o.session_id, o.action) for o in outcomes] == [(SID, "stored"), (missing, "missing")] + assert store_journaled_sessions(tmp_path / "nowhere", config_dir=home) == () diff --git a/tests/test_substrate_freeze_integration.py b/tests/test_substrate_freeze_integration.py index b84d3015..6dd70b88 100644 --- a/tests/test_substrate_freeze_integration.py +++ b/tests/test_substrate_freeze_integration.py @@ -58,12 +58,18 @@ def _init_git_repo(path: Path) -> None: def _roster_registry_block() -> dict: - """Read the real list off a shipped roster overlay — never a copy of it.""" + """Read the real list off a shipped roster overlay — never a copy of it. + + The overlay's ``policy_smoke`` hook stays out: it runs an autobench module + in autobench's environment, which the framework suite does not install. + The smoke validator has its own tests (``test_submit_policy_smoke.py``); + this fixture carries the lists the freeze is made of. + """ repo = Path(__file__).resolve().parents[1] cfg = yaml.safe_load( (repo / "benchmarks/experiments/tcga_luad/automil/config.yaml").read_text() ) - return cfg["registry"] + return {key: value for key, value in cfg["registry"].items() if key != "policy_smoke"} def _roster_editable() -> list[str]: diff --git a/tests/test_viz_export_cli.py b/tests/test_viz_export_cli.py new file mode 100644 index 00000000..36f5c100 --- /dev/null +++ b/tests/test_viz_export_cli.py @@ -0,0 +1,42 @@ +"""``automil viz export`` writes the site for the current project.""" +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from automil.cli import main +from automil.session_record import store_session_record + +from tests.viz.conftest import SID, write_mini_session, write_project + + +def test_viz_export_writes_a_site_and_a_single_file(tmp_path: Path, monkeypatch): + automil = write_project(tmp_path / "proj") + store_session_record(automil, SID, write_mini_session(tmp_path / "home" / "projects" / "-x")) + monkeypatch.chdir(tmp_path / "proj") + out = tmp_path / "site" + result = CliRunner().invoke( + main, ["viz", "export", "--out", str(out), "--run-id", "kras-abmil", "--tz", "America/Vancouver", + "--single-file", str(tmp_path / "one.html")], + ) + assert result.exit_code == 0, result.output + assert "exported kras-abmil: 5 nodes, 1 session(s)" in result.output + index = json.loads((out / "record" / "index.json").read_text()) + assert index["mode"] == "static" and index["runs"][0]["run_id"] == "kras-abmil" + assert index["runs"][0]["clock"]["tz_name"] == "America/Vancouver" + assert (out / "index.html").is_file() and (out / "static").is_dir() + assert (tmp_path / "one.html").is_file() + + bad_zone = CliRunner().invoke(main, ["viz", "export", "--out", str(tmp_path / "x"), "--tz", "Mars/Olympus"]) + assert bad_zone.exit_code != 0 and "unknown time zone" in bad_zone.output + + foreign = tmp_path / "foreign" + foreign.mkdir() + (foreign / "keep.txt").write_text("x") + refused = CliRunner().invoke(main, ["viz", "export", "--out", str(foreign)]) + assert refused.exit_code != 0 and "--force" in refused.output + forced = CliRunner().invoke(main, ["viz", "export", "--out", str(foreign), "--force", "--no-frontend"]) + assert forced.exit_code == 0, forced.output + assert (foreign / "record" / "index.json").is_file() and not (foreign / "index.html").exists() diff --git a/tests/trajectory/test_redactor.py b/tests/trajectory/test_redactor.py index 0e3267f1..f3ed2686 100644 --- a/tests/trajectory/test_redactor.py +++ b/tests/trajectory/test_redactor.py @@ -172,3 +172,16 @@ def test_apply_size_cap_sentinel_on_pathological_bloat() -> None: # If sentinel: must have gen_ai.event.name = "truncated" if result.get("gen_ai.event.name") == "truncated": assert "_dropped_size" in result + + +def test_redact_secrets_masks_patterns_without_touching_the_project(monkeypatch) -> None: + """The pure variant never looks for a project; only the patterns apply.""" + from automil.trajectory import redactor + + def _boom() -> None: # pragma: no cover - the assertion is that it is never called + raise AssertionError("redact_secrets must not resolve a project dir") + + monkeypatch.setattr(redactor, "_held_out_ids", _boom) + text = "export HF_TOKEN=hf_abcdefghijklmnopqrstuvwxyz1234 && echo node_0007" + assert redactor.redact_secrets(text) == "export HF_TOKEN=[REDACTED] && echo node_0007" + assert redactor.redact_secrets("plain text") == "plain text" diff --git a/tests/viz/conftest.py b/tests/viz/conftest.py new file mode 100644 index 00000000..2b21e34e --- /dev/null +++ b/tests/viz/conftest.py @@ -0,0 +1,393 @@ +"""Builders for scrubbed Claude Code transcripts and small project trees. + +Every fixture is built in code from a handful of line factories so a test can +say exactly which shapes it feeds the parser. The shapes mirror the real +runtime files (line types, block layouts, sidecar naming) with no real content. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +SID = "0f0f0f0f-1111-4222-8333-444444444444" +CWD = "/data/project" + + +def _line(kind: str, ts: str, uuid: str, parent: str | None, **extra: Any) -> dict[str, Any]: + return { + "type": kind, + "uuid": uuid, + "parentUuid": parent, + "timestamp": ts, + "sessionId": SID, + "cwd": CWD, + "isSidechain": False, + "version": "2.1.228", + **extra, + } + + +def assistant( + uuid: str, + parent: str | None, + ts: str, + blocks: list[dict[str, Any]], + *, + request_id: str, + usage: dict[str, int] | None = None, + model: str = "claude-opus-5", +) -> dict[str, Any]: + return _line( + "assistant", + ts, + uuid, + parent, + requestId=request_id, + message={ + "id": f"msg_{request_id}", + "role": "assistant", + "model": model, + "content": blocks, + "usage": usage + or { + "input_tokens": 2, + "output_tokens": 50, + "cache_read_input_tokens": 1000, + "cache_creation_input_tokens": 0, + }, + }, + ) + + +def tool_use(tool_use_id: str, name: str, **input_: Any) -> dict[str, Any]: + return {"type": "tool_use", "id": tool_use_id, "name": name, "input": input_} + + +def text(value: str) -> dict[str, Any]: + return {"type": "text", "text": value} + + +def thinking(value: str) -> dict[str, Any]: + return {"type": "thinking", "thinking": value, "signature": "sig"} + + +def tool_result( + uuid: str, + parent: str, + ts: str, + tool_use_id: str, + content: Any, + *, + is_error: bool = False, + tool_use_result: Any = None, + source_assistant: str | None = None, +) -> dict[str, Any]: + block: dict[str, Any] = {"type": "tool_result", "tool_use_id": tool_use_id, "content": content} + if is_error: + block["is_error"] = True + extra: dict[str, Any] = {} + if tool_use_result is not None: + extra["toolUseResult"] = tool_use_result + if source_assistant is not None: + extra["sourceToolAssistantUUID"] = source_assistant + return _line("user", ts, uuid, parent, message={"role": "user", "content": [block]}, **extra) + + +def bash_result(stdout: str, *, stderr: str = "", interrupted: bool = False) -> dict[str, Any]: + return { + "stdout": stdout, + "stderr": stderr, + "interrupted": interrupted, + "isImage": False, + "noOutputExpected": False, + } + + +def human(uuid: str, parent: str | None, ts: str, prompt: str, **extra: Any) -> dict[str, Any]: + return _line( + "user", + ts, + uuid, + parent, + message={"role": "user", "content": prompt}, + origin={"kind": "human"}, + promptSource="typed", + **extra, + ) + + +def notification(uuid: str, parent: str, ts: str, task_id: str, tool_use_id: str, summary: str, body: str) -> dict[str, Any]: + content = ( + "\n" + f"{task_id}\n" + f"{tool_use_id}\n" + "completed\n" + f"{summary}\n" + f"{body}\n" + "" + ) + return _line( + "user", + ts, + uuid, + parent, + message={"role": "user", "content": content}, + origin={"kind": "task-notification"}, + ) + + +def queue_operation(ts: str, content: str) -> dict[str, Any]: + return {"type": "queue-operation", "operation": "enqueue", "timestamp": ts, "sessionId": SID, "content": content} + + +def system(uuid: str, parent: str | None, ts: str, subtype: str, **extra: Any) -> dict[str, Any]: + return _line("system", ts, uuid, parent, subtype=subtype, **extra) + + +def write_lines(path: Path, lines: list[Any], *, partial_tail: str | None = None) -> Path: + """Write dict lines as JSON and bytes lines verbatim; optionally a partial last line.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as fh: + for item in lines: + if isinstance(item, bytes): + fh.write(item) + else: + fh.write(json.dumps(item, ensure_ascii=False).encode("utf-8")) + fh.write(b"\n") + if partial_tail is not None: + fh.write(partial_tail.encode("utf-8")) + return path + + +T0 = "2026-09-07T00:22:30.000Z" + + +def stamp(seconds: int) -> str: + """A UTC stamp ``seconds`` after T0 (keeps fixtures readable).""" + base = 22 * 60 + 30 + seconds + return f"2026-09-07T00:{base // 60:02d}:{base % 60:02d}.000Z" + + +def mini_session_lines() -> list[Any]: + """The standard scrubbed session: one propose, one submit, one subagent, edge cases.""" + big = "x" * 5000 + return [ + {"type": "mode", "mode": "normal", "sessionId": SID}, + {"type": "permission-mode", "permissionMode": "bypassPermissions", "sessionId": SID}, + human("u1", None, stamp(0), "Session is bound. Begin the discovery loop per your policy."), + {"type": "attachment", "uuid": "att1", "parentUuid": "u1", "attachment": {"type": "deferred_tools_delta"}}, + # one response, two assistant lines sharing a requestId + assistant("a1", "u1", stamp(5), [thinking("think first"), text("I will read the state.")], request_id="req1"), + assistant("a2", "a1", stamp(6), [tool_use("t1", "Bash", command="uv run automil propose --parent node_0001 --desc 'lr down' --kind hp", description="propose")], request_id="req1"), + tool_result("r1", "a2", stamp(8), "t1", "Added proposal node_0002 [hp]: lr down", tool_use_result=bash_result("Added proposal node_0002 [hp]: lr down"), source_assistant="a2"), + assistant("a3", "r1", stamp(10), [tool_use("t2", "Bash", command="cd /data/project && uv run automil submit --node node_0002 --files train.py", description="submit")], request_id="req2"), + tool_result("r2", "a3", stamp(12), "t2", "Submitted node_0002: 1 file(s) snapshotted", tool_use_result=bash_result("Submitted node_0002: 1 file(s) snapshotted")), + # a subagent + assistant("a4", "r2", stamp(20), [tool_use("t3", "Agent", prompt="research recipes", description="Research MIL recipes", run_in_background=True)], request_id="req3"), + tool_result("r3", "a4", stamp(21), "t3", "Async agent launched successfully.", tool_use_result={"isAsync": True, "status": "async_launched", "agentId": "agent1", "description": "Research MIL recipes"}), + queue_operation(stamp(40), "agent1"), + notification("n1", "r3", stamp(41), "agent1", "t3", 'Agent "Research MIL recipes" finished', "# Report\nUse a lower lr."), + # a list-content result, an error result, an image-bearing result + assistant("a5", "n1", stamp(50), [tool_use("t4", "Read", file_path="/data/project/train.py")], request_id="req4"), + tool_result("r4", "a5", stamp(51), "t4", [{"type": "text", "text": "line 1"}, {"type": "text", "text": "line 2"}]), + assistant("a6", "r4", stamp(52), [tool_use("t5", "Bash", command="false", description="fail")], request_id="req5"), + tool_result("r5", "a6", stamp(53), "t5", "Exit code 1", is_error=True, tool_use_result=bash_result("", stderr="boom")), + assistant("a7", "r5", stamp(54), [tool_use("t6", "Read", file_path="/data/project/fig.png")], request_id="req6"), + tool_result("r6", "a7", stamp(55), "t6", [{"type": "image", "source": {"type": "base64", "data": "AAAA"}}]), + # a large result and a secret in it + assistant("a8", "r6", stamp(60), [tool_use("t7", "Bash", command="cat big.log", description="big")], request_id="req7"), + tool_result("r7", "a8", stamp(61), "t7", "HF_TOKEN=hf_abcdefghijklmnopqrstuvwxyz1234\n" + big + "\nTAIL-MARKER", tool_use_result=bash_result(big)), + # an interrupted call with no result, followed by a compaction boundary + assistant("a9", "r7", stamp(70), [tool_use("t8", "Bash", command="sleep 600", description="wait")], request_id="req8"), + system("s1", "a9", stamp(71), "compact_boundary", compactMetadata={"preTokens": 150000, "postTokens": 20000}), + {"type": "zzz-unknown", "uuid": "z1"}, + b"\xff\xfe not utf-8", + b"this is not json", + assistant("a10", "s1", stamp(80), [text("Done for now.")], request_id="req9"), + ] + + +def write_mini_session(root: Path, *, partial_tail: str | None = None) -> Path: + """Write the standard session at ``root/.jsonl`` with its sidecar dir.""" + path = write_lines(root / f"{SID}.jsonl", mini_session_lines(), partial_tail=partial_tail) + sub = root / SID / "subagents" + sub.mkdir(parents=True, exist_ok=True) + (sub / "agent-agent1.meta.json").write_text(json.dumps({ + "agentType": "general-purpose", + "description": "Research MIL recipes", + "toolUseId": "t3", + "spawnDepth": 1, + })) + write_lines(sub / "agent-agent1.jsonl", [ + dict(human("su1", None, stamp(22), "research recipes"), isSidechain=True, agentId="agent1"), + dict(assistant("sa1", "su1", stamp(30), [text("# Report\nUse a lower lr.")], request_id="sreq1"), isSidechain=True, agentId="agent1"), + ]) + return path + + +# --- project trees --------------------------------------------------------- + +BASELINE_FOLDS = [0.60, 0.57, 0.65] +KEPT_FOLDS = [0.66, 0.62, 0.70] +LOST_FOLDS = [0.61, 0.55, 0.66] + + +def _folds(values: list[float], bacc: float) -> list[dict[str, Any]]: + return [ + {"fold_index": i, "metrics": {"val_auc": v, "val_bacc": bacc}, "primary_value": v} + for i, v in enumerate(values) + ] + + +def graph_payload(*, plant: dict[str, Any] | None = None) -> dict[str, Any]: + """A five-node schema-3 graph: baseline, a discarded child, a kept child, + a crash, and a pending proposal. ``plant`` merges extra keys into every + node's metrics (the forged-violation tests use it).""" + extra = plant or {} + mean = lambda xs: sum(xs) / len(xs) # noqa: E731 + nodes = { + "node_0001": { + "id": "node_0001", "parent_id": None, "type": "executed", "status": "keep", + "description": "native baseline", "techniques": [], "primary_value": mean(BASELINE_FOLDS), + "primary_se": 0.02, "global_delta": 0.0, "parent_delta": mean(BASELINE_FOLDS), + "metrics": {"val_auc": mean(BASELINE_FOLDS), "val_bacc": 0.56, **extra}, + "vram_gb": 0.0, "elapsed_min": 0.0, "gpu": -1, "commit": None, "archive_id": "node_0001", + "config_hash": "aaaa", "potential": 0.6, "child_count": 3, "created_at": "2026-09-06T17:00:00.000000", + "bootstrapped": True, "cell_id": "c40d2b3a7a790598", + "metadata": {"cell_id": "c40d2b3a7a790598", "mil_model": "abmil", "validation_folds": _folds(BASELINE_FOLDS, 0.56)}, + }, + "node_0002": { + "id": "node_0002", "parent_id": "node_0001", "type": "executed", "status": "discard", + "description": "lr down", "techniques": ["lr"], "tier": 2, "kind": "hp", + "primary_value": mean(LOST_FOLDS), "primary_se": 0.03, "global_delta": -0.001, "parent_delta": -0.001, + "metrics": {"val_auc": mean(LOST_FOLDS), "val_bacc": 0.55, **extra}, + "fold_primary_values": [{"fold_index": i, "primary_value": v} for i, v in enumerate(LOST_FOLDS)], + "vram_gb": 0.9, "elapsed_min": 95.5, "gpu": 0, "commit": None, "archive_id": "node_0002", + "config_hash": "bbbb", "potential": 0.61, "child_count": 0, "created_at": "2026-09-06T17:30:28.759919", + "cell_id": "c40d2b3a7a790598", "metadata": {"candidate_class": "config-only"}, + }, + "node_0003": { + "id": "node_0003", "parent_id": "node_0001", "type": "executed", "status": "keep", + "description": "dropout 0.5", "techniques": ["dropout"], "tier": 2, "kind": "regularization", + "primary_value": mean(KEPT_FOLDS), "primary_se": 0.03, "global_delta": 0.053, "parent_delta": 0.053, + "metrics": {"val_auc": mean(KEPT_FOLDS), "val_bacc": 0.60, **extra}, + "fold_primary_values": [{"fold_index": i, "primary_value": v} for i, v in enumerate(KEPT_FOLDS)], + "vram_gb": 0.9, "elapsed_min": 96.0, "gpu": 1, "commit": None, "archive_id": "node_0003", + "config_hash": "cccc", "potential": 0.66, "child_count": 0, "created_at": "2026-09-06T17:30:29.899969", + "cell_id": "c40d2b3a7a790598", "metadata": {"candidate_class": "train-only-source"}, + }, + "node_0004": { + "id": "node_0004", "parent_id": "node_0001", "type": "executed", "status": "crash", + "description": "bad policy", "techniques": [], "tier": 2, "kind": "hp", + "primary_value": 0.0, "primary_se": None, "global_delta": -0.6, "parent_delta": -0.6, + "metrics": {}, "vram_gb": 0.0, "elapsed_min": 0.8, "gpu": 1, "commit": None, "archive_id": "node_0004", + "config_hash": "dddd", "potential": 0.0, "child_count": 0, "created_at": "2026-09-06T17:30:30.000000", + "cell_id": "c40d2b3a7a790598", "metadata": {}, "error": "Traceback: test_auc=0.91 leaked\nValueError: boom", + }, + "node_0005": { + "id": "node_0005", "parent_id": "node_0003", "type": "proposed", "status": "pending", + "description": "dropout 0.75", "techniques": ["dropout"], "tier": 2, "kind": "regularization", + "rationale": "", "reference": None, "expected_gain": "low", "effort": "low", "potential": 0.65, + "created_at": "2026-09-06T19:00:00.000000", "cell_id": "c40d2b3a7a790598", "metadata": {}, + }, + } + return { + "schema_version": 3, + "meta": { + "best_primary_value": mean(KEPT_FOLDS), "best_node_id": "node_0003", "total_executed": 4, + "total_proposed": 1, "next_id": 6, "baseline_primary_value": mean(BASELINE_FOLDS), + "scoring": { + "exploration_weight": 0.005, "novelty_weight": 0.003, "accept_margin": 0.015, + "se_multiplier": 1.0, "formula": "val_auc", + "guard": {"metric": "val_bacc", "margin": 0.0099}, + }, + }, + "nodes": nodes, + "technique_stats": {"dropout": {"times_tried": 2, "best_parent_delta": 0.053, "avg_parent_delta": 0.026}}, + } + + +def write_project(root: Path, *, plant: dict[str, Any] | None = None, running: tuple[str, ...] = ()) -> Path: + """Write a project tree at ``root`` and return its ``automil/`` dir.""" + automil = root / "automil" + orch = automil / "orchestrator" + for sub in ("queue", "completed", "archive", "running"): + (orch / sub).mkdir(parents=True, exist_ok=True) + (automil / "cells").mkdir(exist_ok=True) + (automil / "config.yaml").write_text( + "project:\n name: demo_project\n description: demo\ntask:\n name: kras\n" + "encoders:\n primary: hoptimus1\nrun:\n mil_model: abmil\nscoring:\n formula: val_auc\n" + ) + graph = graph_payload(plant=plant) + (automil / "graph.json").write_text(json.dumps(graph)) + (automil / "plan.md").write_text("# Plan\n\nTry dropout.\n") + (automil / "learnings.md").write_text("# Learnings\n\n- lr down does nothing.\n") + (orch / "gpu_state.json").write_text(json.dumps({ + "counter": 1, "last_updated": "2026-09-06T19:10:00.000000", + "gpus": {"0": {"running": list(running)}}, + "execution_slots": {"cuda:0": {"accelerator": "cuda", "device_index": 0, "running": list(running), "capacity": 4}}, + })) + (orch / "orchestrator.log").write_text( + "2026-09-06 17:36:40,814 [INFO] Launched node_0002 on CUDA GPU 0 (PID 1, est. 0.5GB, timeout 600min)\n" + "2026-09-06 17:36:41,000 [INFO] Launched node_0003 on CUDA GPU 1 (PID 2, est. 0.5GB, timeout 600min)\n" + "2026-09-06 17:36:42,000 [INFO] Launched node_0004 on CUDA GPU 1 (PID 3, est. 0.5GB, timeout 600min)\n" + "2026-09-06 17:37:30,000 [INFO] Completed node_0004: status=crash, primary_value=0.0, elapsed=0.8min, CUDA GPU 1\n" + "2026-09-06 19:12:58,109 [INFO] Completed node_0002: status=completed, primary_value=0.6067, elapsed=96.2min, CUDA GPU 0\n" + "2026-09-06 19:13:00,000 [INFO] Completed node_0003: status=completed, primary_value=0.66, elapsed=96.0min, CUDA GPU 1\n" + ) + extra = plant or {} + for node_id, node in graph["nodes"].items(): + if node["type"] != "executed" or node.get("bootstrapped"): + continue + archive = orch / "archive" / node_id + archive.mkdir(parents=True, exist_ok=True) + folds = {"node_0002": LOST_FOLDS, "node_0003": KEPT_FOLDS}.get(node_id, []) + result = { + "status": "completed" if node["status"] != "crash" else "crash", + "metrics": dict(node["metrics"]), + "primary_value": node["primary_value"], "primary_se": node["primary_se"], + "elapsed_seconds": node["elapsed_min"] * 60, "peak_vram_mb": int(node["vram_gb"] * 1000), + "validation_folds": _folds(folds, 0.55) if folds else [], + "held_out": {"test_auc": 0.93, "test_bacc": 0.88}, + "summary": {"test_auc": 0.93}, + } + if extra: + for fold in result["validation_folds"]: + fold["metrics"].update(extra) + # the archive root result.json is the sealed-off copy: no held_out block + (archive / "result.json").write_text(json.dumps({k: v for k, v in result.items() if k not in ("held_out", "summary")})) + certify = archive / "certify" + certify.mkdir(exist_ok=True) + (certify / "result.json").write_text(json.dumps(result)) + (certify / "certify.json").write_text(json.dumps({"held_out": result["held_out"], "summary": result["summary"]})) + (archive / "spec.json").write_text(json.dumps({ + "id": node_id, "description": node["description"], "base_commit": "1660b0d8f267d58cd2c68644ddb4847ab70c81b3", + "overlay_dir": f"archive/{node_id}", + "overlay_manifest": {"automil/variants/_policies/dropout.py": "sha256:" + "ab" * 32} if node_id == "node_0003" else {}, + "deletions": [], "framework_overlay_files": [], + "submitted_at": "2026-09-07T00:3%d:23.214441+00:00" % (int(node_id[-1]) % 10), + "metadata": {"backend": "local", "cell_id": "c40d2b3a7a790598"}, + })) + if node_id == "node_0003": + policy = archive / "automil" / "variants" / "_policies" + policy.mkdir(parents=True) + (policy / "dropout.py").write_text("DROPOUT = 0.5\n") + (archive / "run.log").write_text( + "epoch 1 val_auc=0.61\n" + ("test_auc=0.93 leaked line\n" if extra else "") + + "[selected] epoch=2\nExperiment complete\n" + ) + (orch / "completed" / f"{node_id}.json").write_text(json.dumps({ + "id": node_id, "status": result["status"], "primary_value": node["primary_value"], + "primary_se": node["primary_se"], "fold_primary_values": node.get("fold_primary_values"), + "metrics": {**node["metrics"], **({"test_auc": 0.93} if extra else {})}, + "elapsed_seconds": node["elapsed_min"] * 60, "peak_vram_mb": int(node["vram_gb"] * 1000), + "accelerator": "cuda", "gpu": node["gpu"], + "completed_at": {"node_0002": "2026-09-06T19:12:51.003299", "node_0003": "2026-09-06T19:12:59.000000", "node_0004": "2026-09-06T17:37:29.000000"}[node_id], + "budget_killed": False, + "graph_metadata": {"parent_id": node["parent_id"], "techniques": node["techniques"], "config_hash": node["config_hash"]}, + })) + (automil / ".activity.jsonl").write_text( + '{"cell_id":null,"event":"session_open","observed_at":1788740513.9171042,"session_id":"%s"}\n' % SID + ) + return automil diff --git a/tests/viz/test_app_js_metrics_reader.py b/tests/viz/test_app_js_metrics_reader.py deleted file mode 100644 index 2679e0c3..00000000 --- a/tests/viz/test_app_js_metrics_reader.py +++ /dev/null @@ -1,67 +0,0 @@ -"""DEC-04 / D-200: viz/static/app.js metric-reader migration regression test. - -The dashboard JS reads node-displayed metrics. After D-200, those metrics live -under node.metrics; this file asserts the post-migration access pattern is -present and the pre-migration pattern is absent. - -The JS file is not executed in this test; we assert via static text patterns. -End-to-end browser tests (Playwright/etc.) are out of scope for v1. -""" -from __future__ import annotations - -from pathlib import Path - -import pytest - -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent -_APP_JS = _REPO_ROOT / "src" / "automil" / "viz" / "static" / "app.js" - - -@pytest.fixture -def app_js_text() -> str: - """Return app.js source text (skip if file is absent).""" - if not _APP_JS.exists(): - pytest.skip(f"app.js not found at {_APP_JS}") - return _APP_JS.read_text() - - -def test_app_js_reads_node_metrics_post_d200(app_js_text: str): - """D-200: viz reads node.metrics, not top-level node[key].""" - assert "(node.metrics || {})[pair[0]]" in app_js_text, ( - "Expected the post-D-200 access pattern '(node.metrics || {})[pair[0]]' " - "in app.js. The viz dashboard must read consumer metrics from " - "node.metrics after Phase 8 graph.py dict-spread refactor." - ) - - -def test_app_js_pre_d200_pattern_absent(app_js_text: str): - """Regression: the pre-D-200 access pattern is removed.""" - # The exact pre-migration line. - forbidden = "var val = node[pair[0]];" - assert forbidden not in app_js_text, ( - f"Found pre-D-200 access pattern {forbidden!r} in app.js. " - "This pattern reads top-level node[key] which no longer carries " - "consumer metrics after D-200; it must be replaced by " - "'var val = (node.metrics || {})[pair[0]];'." - ) - - -def test_app_js_metric_fields_val_only(app_js_text: str): - """Val-firewall: the dashboard surfaces validation metrics only; test is - sealed (certify.json) and must never appear in the viz during search.""" - # Validation metrics ARE shown (the selection signal the tree optimizes). - for label_pair in ( - "['val_auc', 'Val AUC']", - "['val_bacc', 'Val BACC']", - ): - assert label_pair in app_js_text, ( - f"metricFields entry {label_pair!r} missing from app.js. The viz " - "must surface the validation metrics that drive keep/discard." - ) - # Test metrics must NOT be surfaced (val-firewall quarantine). - for forbidden in ("'Test AUC'", "'Test BACC'", "['test_auc'", "['test_bacc'"): - assert forbidden not in app_js_text, ( - f"Found test metric {forbidden!r} in app.js metricFields. Under the " - "val-firewall, test is sealed in certify.json and must not appear " - "in the dashboard during search." - ) diff --git a/tests/viz/test_clock.py b/tests/viz/test_clock.py new file mode 100644 index 00000000..affb1033 --- /dev/null +++ b/tests/viz/test_clock.py @@ -0,0 +1,78 @@ +"""viz.clock: put naive host-local stamps and UTC stamps on one axis.""" +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from automil.viz.clock import ( + HostClock, + host_clock, + offset_cross_check, + parse_log_asctime, + to_utc_iso, +) + + +def test_explicit_zone_is_validated_and_marked_explicit(): + clock = host_clock(tz_name="America/Vancouver", now=datetime(2026, 9, 7, tzinfo=timezone.utc)) + assert clock.source == "explicit" + assert clock.tz_name == "America/Vancouver" + assert clock.utc_offset_s == -7 * 3600 # PDT in September + with pytest.raises(ValueError): + host_clock(tz_name="Mars/Olympus") + + +def test_explicit_fixed_offset(): + clock = host_clock(utc_offset_s=3600) + assert clock == HostClock(tz_name=None, utc_offset_s=3600, source="explicit") + + +def test_host_zone_comes_from_tz_env_then_localtime_link(tmp_path: Path): + from_env = host_clock(env={"TZ": "Europe/Berlin"}, now=datetime(2026, 1, 15, tzinfo=timezone.utc)) + assert (from_env.tz_name, from_env.source, from_env.utc_offset_s) == ("Europe/Berlin", "host", 3600) + + link = tmp_path / "localtime" + link.symlink_to("/usr/share/zoneinfo/Asia/Tokyo") + from_link = host_clock(env={}, localtime_link=link, now=datetime(2026, 1, 15, tzinfo=timezone.utc)) + assert (from_link.tz_name, from_link.utc_offset_s) == ("Asia/Tokyo", 9 * 3600) + + # An unusable TZ value falls through to the link, and a missing link to the + # process offset (tz_name None, still source "host"). + fallback = host_clock(env={"TZ": "not-a-zone"}, localtime_link=tmp_path / "missing") + assert fallback.source == "host" and fallback.tz_name is None + + +def test_to_utc_iso_accepts_aware_naive_epoch_and_garbage(): + clock = host_clock(tz_name="America/Vancouver") + assert to_utc_iso("2026-09-07T00:22:29.657Z", clock) == "2026-09-07T00:22:29.657Z" + assert to_utc_iso("2026-09-07T00:36:23.214441+00:00", clock) == "2026-09-07T00:36:23.214Z" + # naive host-local (PDT, -7) in September + assert to_utc_iso("2026-09-06T17:30:28.759919", clock) == "2026-09-07T00:30:28.759Z" + # naive in January is PST (-8): the IANA zone handles the DST change + assert to_utc_iso("2026-01-06T17:30:28", clock) == "2026-01-07T01:30:28.000Z" + assert to_utc_iso(1788740513.9171042, clock) == "2026-09-07T00:21:53.917Z" + assert to_utc_iso(None, clock) is None + assert to_utc_iso("yesterday", clock) is None + assert to_utc_iso({"at": 1}, clock) is None + + +def test_to_utc_iso_uses_fixed_offset_without_a_zone_name(): + clock = HostClock(tz_name=None, utc_offset_s=-7 * 3600, source="explicit") + assert to_utc_iso("2026-09-06T17:30:28", clock) == "2026-09-07T00:30:28.000Z" + + +def test_parse_log_asctime(): + assert parse_log_asctime("2026-09-06 17:36:40,814") == datetime(2026, 9, 6, 17, 36, 40, 814000) + assert parse_log_asctime("not a stamp") is None + + +def test_offset_cross_check_rounds_to_quarter_hours_and_needs_pairs(): + pairs = [ + ("2026-09-06T17:30:28.759919", "2026-09-07T00:30:27.100Z"), + ("2026-09-06T17:30:29.899969", "2026-09-07T00:30:29.500Z"), + ] + assert offset_cross_check(pairs) == -7 * 3600 + assert offset_cross_check([]) is None + assert offset_cross_check([("garbage", "2026-09-07T00:30:27Z")]) is None diff --git a/tests/viz/test_export.py b/tests/viz/test_export.py new file mode 100644 index 00000000..9d140d5c --- /dev/null +++ b/tests/viz/test_export.py @@ -0,0 +1,114 @@ +"""viz.export: the record tree on disk, index merging, the single-file form.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from automil.session_record import store_session_record +from automil.viz.clock import host_clock +from automil.viz.export import ( + ExportError, + check_target, + export_run, + export_site, + merge_index, + single_file, +) +from automil.viz.record import RunSource + +from tests.viz.conftest import SID, write_mini_session, write_project + + +def _source(tmp_path: Path, name: str, run_id: str) -> RunSource: + automil = write_project(tmp_path / name) + transcript = write_mini_session(tmp_path / f"home-{name}" / "projects" / "-data-project") + store_session_record(automil, SID, transcript) + return RunSource(automil, host_clock(tz_name="UTC"), run_id=run_id, config_dir=tmp_path / "nohome") + + +def _fake_static(tmp_path: Path) -> Path: + static = tmp_path / "static" + (static / "css").mkdir(parents=True) + (static / "js").mkdir() + (static / "fonts").mkdir() + (static / "fonts" / "f.woff2").write_bytes(b"\x00font") + (static / "css" / "a.css").write_text("@font-face{src:url(../fonts/f.woff2)}\nbody{margin:0}\n") + (static / "js" / "main.js").write_text("console.log('');\n") + (static / "index.html").write_text( + '' + '' + ) + return static + + +def test_export_run_writes_the_record_tree_and_is_byte_stable(tmp_path: Path): + source = _source(tmp_path, "a", "run-a") + out = tmp_path / "out" + summary = export_run(source, out) + run_dir = out / "record" / "runs" / "run-a" + assert summary.nodes == 5 and summary.sessions == 1 and summary.files > 10 + for name in ("graph.json", "timeline.json", "sessions.json", "agent_links.json", "notes.json"): + assert (run_dir / name).is_file() + assert not (run_dir / "certified.json").exists() + assert (run_dir / "nodes" / "node_0003.json").is_file() + assert (run_dir / "nodes" / "node_0003" / "files" / "automil" / "variants" / "_policies" / "dropout.py.json").is_file() + assert (run_dir / "sessions" / SID / "turns" / "0.json").is_file() + assert (run_dir / "sessions" / SID / "results" / "t7.json").is_file() # the only truncated result + assert not (run_dir / "sessions" / SID / "results" / "t1.json").exists() + assert (run_dir / "sessions" / SID / "agents" / "agent1.json").is_file() + snapshot = {p: p.read_bytes() for p in run_dir.rglob("*.json")} + export_run(source, out) + assert {p: p.read_bytes() for p in run_dir.rglob("*.json")} == snapshot + + +def test_index_merges_runs_and_drops_stale_entries(tmp_path: Path): + a = _source(tmp_path, "a", "run-a") + b = _source(tmp_path, "b", "run-b") + out = tmp_path / "out" + export_run(a, out) + merge_index(out, [a]) + export_run(b, out) + index = merge_index(out, [b]) + assert [r["run_id"] for r in index["runs"]] == ["run-a", "run-b"] and index["mode"] == "static" + # a run whose files were removed disappears from the index on the next merge + import shutil + + shutil.rmtree(out / "record" / "runs" / "run-a") + index = merge_index(out, [b]) + assert [r["run_id"] for r in index["runs"]] == ["run-b"] + + +def test_check_target_refuses_foreign_non_empty_directories(tmp_path: Path): + out = tmp_path / "out" + check_target(out, force=False) # missing is fine + out.mkdir() + (out / "notes.txt").write_text("x") + with pytest.raises(ExportError): + check_target(out, force=False) + check_target(out, force=True) + (out / "record").mkdir() + (out / "record" / "index.json").write_text("{}") + check_target(out, force=False) # an existing record is a valid target + + +def test_export_site_copies_the_frontend_and_single_file_inlines_everything(tmp_path: Path): + static = _fake_static(tmp_path) + source = _source(tmp_path, "a", "run-a") + out = tmp_path / "site" + export_site([source], out, static_dir=static) + assert (out / "index.html").is_file() and (out / "static" / "js" / "main.js").is_file() + assert not (out / "static" / "index.html").exists() + assert json.loads((out / "record" / "index.json").read_text())["runs"][0]["run_id"] == "run-a" + + html_path = single_file(tmp_path / "one.html", out, static_dir=static) + html = html_path.read_text() + assert "