personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Clean up agents app files (moved to concurrent lode branch)

-4012
-2
apps/agent/__init__.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc
-172
apps/agent/call.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """CLI commands for the agent identity system. 5 - 6 - Auto-discovered by ``think.call`` and mounted as ``sol call agent ...``. 7 - """ 8 - 9 - import json 10 - import os 11 - import subprocess 12 - from datetime import datetime, timezone 13 - from pathlib import Path 14 - 15 - import typer 16 - 17 - app = typer.Typer(help="Agent identity — name and status.") 18 - 19 - 20 - def _get_agent_config() -> dict: 21 - """Read agent config from journal config.""" 22 - from think.utils import get_config 23 - 24 - return get_config().get( 25 - "agent", 26 - { 27 - "name": "sol", 28 - "name_status": "default", 29 - "named_date": None, 30 - "proposal_count": 0, 31 - }, 32 - ) 33 - 34 - 35 - def _update_agent_config(updates: dict) -> dict: 36 - """Update agent config in journal.json and return the full agent block.""" 37 - from think.utils import get_config, get_journal 38 - 39 - config = get_config() 40 - agent = config.get( 41 - "agent", 42 - { 43 - "name": "sol", 44 - "name_status": "default", 45 - "named_date": None, 46 - "proposal_count": 0, 47 - }, 48 - ) 49 - agent.update(updates) 50 - config["agent"] = agent 51 - 52 - config_path = Path(get_journal()) / "config" / "journal.json" 53 - config_path.parent.mkdir(parents=True, exist_ok=True) 54 - with open(config_path, "w", encoding="utf-8") as f: 55 - json.dump(config, f, indent=2) 56 - f.write("\n") 57 - os.chmod(config_path, 0o600) 58 - 59 - return agent 60 - 61 - 62 - @app.command("name") 63 - def name() -> None: 64 - """Show the current agent name and status.""" 65 - agent = _get_agent_config() 66 - typer.echo(json.dumps(agent, indent=2)) 67 - 68 - 69 - @app.command("set-name") 70 - def set_name( 71 - name: str = typer.Argument(..., help="New agent name."), 72 - status: str = typer.Option( 73 - "chosen", 74 - "--status", 75 - "-s", 76 - help="Name status (chosen, self-named, deferred, default).", 77 - ), 78 - ) -> None: 79 - """Set the agent name.""" 80 - agent = _update_agent_config( 81 - { 82 - "name": name, 83 - "name_status": status, 84 - "named_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), 85 - } 86 - ) 87 - typer.echo(json.dumps(agent, indent=2)) 88 - # Update sol/self.md with new name 89 - from think.awareness import update_self_md_opening, update_self_md_section 90 - 91 - named_date = agent.get("named_date", "") 92 - update_self_md_opening( 93 - f"I am {name}. this is a new journal — we're just getting started." 94 - ) 95 - if named_date: 96 - update_self_md_section("my name", f"{name} (named {named_date})") 97 - else: 98 - update_self_md_section("my name", name) 99 - project_root = Path(__file__).resolve().parent.parent.parent 100 - subprocess.run( 101 - ["make", "skills"], cwd=project_root, check=False, capture_output=True 102 - ) 103 - 104 - 105 - @app.command("reset") 106 - def reset() -> None: 107 - """Reset the agent name to default.""" 108 - agent = _update_agent_config( 109 - { 110 - "name": "sol", 111 - "name_status": "default", 112 - "named_date": None, 113 - } 114 - ) 115 - typer.echo(json.dumps(agent, indent=2)) 116 - project_root = Path(__file__).resolve().parent.parent.parent 117 - subprocess.run( 118 - ["make", "skills"], cwd=project_root, check=False, capture_output=True 119 - ) 120 - 121 - 122 - @app.command("thickness") 123 - def thickness() -> None: 124 - """Show journal thickness signals for naming readiness.""" 125 - from think.awareness import compute_thickness 126 - 127 - typer.echo(json.dumps(compute_thickness(), indent=2)) 128 - 129 - 130 - @app.command("set-owner") 131 - def set_owner( 132 - name: str = typer.Argument(..., help="Owner name."), 133 - bio: str = typer.Option(None, "--bio", "-b", help="Short owner bio."), 134 - ) -> None: 135 - """Set the journal owner's name (and optional bio).""" 136 - from think.awareness import update_self_md_section 137 - from think.utils import get_config, get_journal 138 - 139 - config = get_config() 140 - identity = config.get("identity", {}) 141 - identity["name"] = name 142 - if bio is not None: 143 - identity["bio"] = bio 144 - config["identity"] = identity 145 - 146 - config_path = Path(get_journal()) / "config" / "journal.json" 147 - config_path.parent.mkdir(parents=True, exist_ok=True) 148 - with open(config_path, "w", encoding="utf-8") as f: 149 - json.dump(config, f, indent=2) 150 - f.write("\n") 151 - os.chmod(config_path, 0o600) 152 - 153 - # Update sol/self.md 154 - owner_content = name 155 - if bio: 156 - owner_content += f"\n{bio}" 157 - update_self_md_section("who I'm here for", owner_content) 158 - 159 - typer.echo(json.dumps({"name": name, "bio": bio or ""}, indent=2)) 160 - project_root = Path(__file__).resolve().parent.parent.parent 161 - subprocess.run( 162 - ["make", "skills"], cwd=project_root, check=False, capture_output=True 163 - ) 164 - 165 - 166 - @app.command("sol-init") 167 - def sol_init() -> None: 168 - """Initialize the sol directory with self.md and agency.md.""" 169 - from think.awareness import ensure_sol_directory 170 - 171 - sol_dir = ensure_sol_directory() 172 - typer.echo(json.dumps({"sol_dir": str(sol_dir), "status": "ok"}, indent=2))
-5
apps/agents/app.json
··· 1 - { 2 - "icon": "🤖", 3 - "label": "Agents", 4 - "date_nav": true 5 - }
-206
apps/agents/maint/000_migrate_agent_layout.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Migrate agent output files to the new agents/ directory layout. 5 - 6 - Changes applied: 7 - - Segment agent outputs: YYYYMMDD/HHMMSS_LEN/*.{md,json} -> YYYYMMDD/HHMMSS_LEN/agents/** 8 - - Daily faceted outputs: YYYYMMDD/agents/{topic}_{facet}.{ext} -> YYYYMMDD/agents/{facet}/{topic}.{ext} 9 - 10 - Use --dry-run to preview without writing changes. 11 - """ 12 - 13 - from __future__ import annotations 14 - 15 - import argparse 16 - import shutil 17 - from pathlib import Path 18 - 19 - from think.utils import day_dirs, get_journal, iter_segments, setup_cli 20 - 21 - KNOWN_SEGMENT_AGENT_JSON = frozenset( 22 - {"facets.json", "speakers.json", "activity_state.json"} 23 - ) 24 - 25 - 26 - class MigrationSummary: 27 - """Mutable counters for migration operations.""" 28 - 29 - def __init__(self) -> None: 30 - self.moved = 0 31 - self.cleaned = 0 32 - self.skipped = 0 33 - self.errors = 0 34 - 35 - 36 - def _move_file( 37 - src: Path, dst: Path, *, dry_run: bool, summary: MigrationSummary 38 - ) -> None: 39 - """Move one file, cleaning up identical duplicates when dest already exists.""" 40 - if dst.exists(): 41 - # Dest already exists — clean up src if content is identical. 42 - if src.read_bytes() == dst.read_bytes(): 43 - if dry_run: 44 - print(f"[DRY-RUN] clean {src} (identical to {dst})") 45 - else: 46 - src.unlink() 47 - summary.cleaned += 1 48 - else: 49 - summary.skipped += 1 50 - return 51 - 52 - if dry_run: 53 - print(f"[DRY-RUN] move {src} -> {dst}") 54 - summary.moved += 1 55 - return 56 - 57 - try: 58 - dst.parent.mkdir(parents=True, exist_ok=True) 59 - shutil.move(str(src), str(dst)) 60 - summary.moved += 1 61 - except Exception as exc: 62 - summary.errors += 1 63 - print(f"[ERROR] move failed: {src} -> {dst}: {exc}") 64 - 65 - 66 - def _migrate_segment_outputs( 67 - segment_dir: Path, 68 - *, 69 - facet_names: set[str], 70 - dry_run: bool, 71 - summary: MigrationSummary, 72 - ) -> None: 73 - """Move segment-level agent outputs into segment/agents/ layout.""" 74 - agents_dir = segment_dir / "agents" 75 - 76 - # Move segment markdown outputs from segment root to segment/agents/ 77 - for md_file in sorted(segment_dir.glob("*.md")): 78 - _move_file(md_file, agents_dir / md_file.name, dry_run=dry_run, summary=summary) 79 - 80 - # Move known segment JSON outputs from segment root to segment/agents/ 81 - for json_file in sorted(segment_dir.glob("*.json")): 82 - name = json_file.name 83 - 84 - if name in KNOWN_SEGMENT_AGENT_JSON: 85 - _move_file( 86 - json_file, 87 - agents_dir / name, 88 - dry_run=dry_run, 89 - summary=summary, 90 - ) 91 - continue 92 - 93 - if name.startswith("activity_state_") and name.endswith(".json"): 94 - facet = name[len("activity_state_") : -len(".json")] 95 - if facet in facet_names: 96 - _move_file( 97 - json_file, 98 - agents_dir / facet / "activity_state.json", 99 - dry_run=dry_run, 100 - summary=summary, 101 - ) 102 - continue 103 - 104 - summary.skipped += 1 105 - 106 - 107 - def _migrate_daily_faceted_outputs( 108 - day_dir: Path, 109 - *, 110 - facet_names: set[str], 111 - dry_run: bool, 112 - summary: MigrationSummary, 113 - ) -> None: 114 - """Move daily faceted files from suffix naming to facet subdirectory naming.""" 115 - agents_dir = day_dir / "agents" 116 - if not agents_dir.is_dir(): 117 - return 118 - 119 - # Match longest facet names first to avoid partial matches. 120 - ordered_facets = sorted(facet_names, key=len, reverse=True) 121 - 122 - for file_path in sorted(agents_dir.iterdir()): 123 - if not file_path.is_file() or file_path.suffix not in (".md", ".json"): 124 - continue 125 - 126 - stem = file_path.stem 127 - matched_facet = None 128 - matched_topic = None 129 - 130 - for facet in ordered_facets: 131 - suffix = f"_{facet}" 132 - if stem.endswith(suffix): 133 - topic = stem[: -len(suffix)] 134 - if topic: 135 - matched_facet = facet 136 - matched_topic = topic 137 - break 138 - 139 - if matched_facet is None or matched_topic is None: 140 - summary.skipped += 1 141 - continue 142 - 143 - dest = agents_dir / matched_facet / f"{matched_topic}{file_path.suffix}" 144 - _move_file(file_path, dest, dry_run=dry_run, summary=summary) 145 - 146 - 147 - def migrate_agent_layout(*, dry_run: bool) -> MigrationSummary: 148 - """Run filesystem migration for all day directories in the active journal.""" 149 - summary = MigrationSummary() 150 - journal_path = Path(get_journal()) 151 - 152 - facets_dir = journal_path / "facets" 153 - facet_names = ( 154 - {entry.name for entry in facets_dir.iterdir() if entry.is_dir()} 155 - if facets_dir.is_dir() 156 - else set() 157 - ) 158 - 159 - for day_name, day_abs in sorted(day_dirs().items()): 160 - day_dir = Path(day_abs) 161 - if not day_dir.is_dir(): 162 - continue 163 - 164 - # Segment directories (across all streams) 165 - for _stream, _seg_key, seg_path in iter_segments(day_name): 166 - _migrate_segment_outputs( 167 - seg_path, 168 - facet_names=facet_names, 169 - dry_run=dry_run, 170 - summary=summary, 171 - ) 172 - 173 - # Daily agents/ directory 174 - _migrate_daily_faceted_outputs( 175 - day_dir, 176 - facet_names=facet_names, 177 - dry_run=dry_run, 178 - summary=summary, 179 - ) 180 - 181 - print("Migration complete") 182 - print(f" moved: {summary.moved}") 183 - print(f" cleaned: {summary.cleaned}") 184 - print(f" skipped: {summary.skipped}") 185 - print(f" errors: {summary.errors}") 186 - 187 - return summary 188 - 189 - 190 - def main() -> None: 191 - parser = argparse.ArgumentParser(description="Migrate agent output layout.") 192 - parser.add_argument( 193 - "--dry-run", 194 - action="store_true", 195 - help="Preview moves without writing files.", 196 - ) 197 - args = setup_cli(parser) 198 - 199 - if args.dry_run: 200 - print("[DRY-RUN] No files will be modified.") 201 - 202 - migrate_agent_layout(dry_run=args.dry_run) 203 - 204 - 205 - if __name__ == "__main__": 206 - main()
-255
apps/agents/maint/001_migrate_agent_run_logs.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Migrate agent run logs from flat to per-agent subdirectory layout. 5 - 6 - Changes applied: 7 - - agents/<id>.jsonl -> agents/<name>/<id>.jsonl 8 - - agents/<name>.jsonl symlinks -> agents/<name>.log symlinks 9 - - Build day index files (agents/<day>.jsonl) from migrated data 10 - 11 - Use --dry-run to preview without writing changes. 12 - """ 13 - 14 - from __future__ import annotations 15 - 16 - import argparse 17 - import json 18 - from datetime import datetime 19 - from pathlib import Path 20 - 21 - from think.utils import get_journal, setup_cli 22 - 23 - 24 - class MigrationSummary: 25 - """Mutable counters for migration operations.""" 26 - 27 - def __init__(self) -> None: 28 - self.moved = 0 29 - self.symlinks_removed = 0 30 - self.symlinks_created = 0 31 - self.day_index_entries = 0 32 - self.skipped = 0 33 - self.errors = 0 34 - 35 - 36 - def _read_first_line(path: Path) -> dict | None: 37 - """Read and parse the first JSON line from a file.""" 38 - try: 39 - with open(path, "r") as f: 40 - line = f.readline().strip() 41 - if line: 42 - return json.loads(line) 43 - except (json.JSONDecodeError, IOError): 44 - pass 45 - return None 46 - 47 - 48 - def _read_last_event(path: Path, event_types: set[str]) -> dict | None: 49 - """Read last few lines to find a specific event type.""" 50 - try: 51 - with open(path, "r") as f: 52 - lines = f.readlines() 53 - for line in reversed(lines[-10:]): 54 - line = line.strip() 55 - if not line: 56 - continue 57 - try: 58 - event = json.loads(line) 59 - if event.get("event") in event_types: 60 - return event 61 - except json.JSONDecodeError: 62 - continue 63 - except IOError: 64 - pass 65 - return None 66 - 67 - 68 - def _agent_id_to_day(agent_id: str) -> str: 69 - """Convert agent ID (ms timestamp) to YYYYMMDD string.""" 70 - try: 71 - ts = int(agent_id) / 1000 72 - return datetime.fromtimestamp(ts).strftime("%Y%m%d") 73 - except (ValueError, OSError): 74 - return "" 75 - 76 - 77 - def migrate(agents_dir: Path, dry_run: bool = False) -> MigrationSummary: 78 - """Migrate flat agent files to per-agent subdirectories.""" 79 - summary = MigrationSummary() 80 - 81 - if not agents_dir.exists(): 82 - return summary 83 - 84 - # Phase 1: Remove old symlinks (*.jsonl that are symlinks) 85 - for path in sorted(agents_dir.iterdir()): 86 - if path.is_symlink() and path.suffix == ".jsonl": 87 - print(f" Remove symlink: {path.name}") 88 - if not dry_run: 89 - path.unlink() 90 - summary.symlinks_removed += 1 91 - 92 - # Phase 2: Move flat agent files into subdirectories 93 - # Collect all non-symlink .jsonl files at the root level 94 - # (exclude day index files which are 8-digit dates) 95 - latest_per_name: dict[str, tuple[int, str]] = {} # name -> (agent_id_num, agent_id) 96 - day_entries: dict[str, list[dict]] = {} # day -> [summary_dicts] 97 - 98 - for path in sorted(agents_dir.iterdir()): 99 - if path.is_symlink(): 100 - continue 101 - if not path.is_file(): 102 - continue 103 - if path.suffix != ".jsonl": 104 - continue 105 - # Skip day index files (8-digit filenames) 106 - if len(path.stem) == 8 and path.stem.isdigit(): 107 - continue 108 - 109 - # Determine agent_id and whether active 110 - stem = path.stem 111 - is_active = stem.endswith("_active") 112 - agent_id = stem.replace("_active", "") 113 - 114 - # Read first line to get agent name 115 - first_line = _read_first_line(path) 116 - if not first_line: 117 - print(f" Skip (unreadable): {path.name}") 118 - summary.skipped += 1 119 - continue 120 - 121 - name = first_line.get("name", "unified") 122 - safe_name = name.replace(":", "--") 123 - 124 - # Move to subdirectory 125 - subdir = agents_dir / safe_name 126 - new_path = subdir / path.name 127 - 128 - if new_path.exists(): 129 - print(f" Skip (already exists): {safe_name}/{path.name}") 130 - summary.skipped += 1 131 - continue 132 - 133 - print(f" Move: {path.name} -> {safe_name}/{path.name}") 134 - if not dry_run: 135 - subdir.mkdir(parents=True, exist_ok=True) 136 - path.rename(new_path) 137 - summary.moved += 1 138 - 139 - # Track latest completed agent per name for symlinks 140 - if not is_active: 141 - try: 142 - agent_id_num = int(agent_id) 143 - except ValueError: 144 - summary.skipped += 1 145 - continue 146 - 147 - if name not in latest_per_name or agent_id_num > latest_per_name[name][0]: 148 - latest_per_name[name] = (agent_id_num, agent_id) 149 - 150 - # Build day index entry 151 - start_ts = first_line.get("ts", 0) 152 - day = first_line.get("day") or _agent_id_to_day(agent_id) 153 - if day: 154 - end_event = _read_last_event( 155 - new_path if not dry_run else path, 156 - {"finish", "error"}, 157 - ) 158 - runtime_seconds = None 159 - status = "completed" 160 - if end_event: 161 - if end_event.get("event") == "error": 162 - status = "error" 163 - end_ts = end_event.get("ts", 0) 164 - if end_ts and start_ts: 165 - runtime_seconds = round((end_ts - start_ts) / 1000.0, 1) 166 - 167 - entry = { 168 - "agent_id": agent_id, 169 - "name": name, 170 - "day": day, 171 - "facet": first_line.get("facet"), 172 - "ts": start_ts, 173 - "status": status, 174 - "runtime_seconds": runtime_seconds, 175 - "provider": first_line.get("provider"), 176 - "model": first_line.get("model"), 177 - } 178 - day_entries.setdefault(day, []).append(entry) 179 - summary.day_index_entries += 1 180 - 181 - # Phase 3: Create new .log symlinks 182 - for name, (_agent_id_num, agent_id) in latest_per_name.items(): 183 - safe_name = name.replace(":", "--") 184 - link_path = agents_dir / f"{safe_name}.log" 185 - target = f"{safe_name}/{agent_id}.jsonl" 186 - print(f" Symlink: {safe_name}.log -> {target}") 187 - if not dry_run: 188 - from think.runner import _atomic_symlink 189 - 190 - _atomic_symlink(link_path, target) 191 - summary.symlinks_created += 1 192 - 193 - # Phase 4: Write day index files 194 - for day, entries in sorted(day_entries.items()): 195 - day_index_path = agents_dir / f"{day}.jsonl" 196 - 197 - # Append to existing day index (idempotent: skip entries already present) 198 - existing_ids: set[str] = set() 199 - if day_index_path.exists() and not dry_run: 200 - try: 201 - with open(day_index_path, "r") as f: 202 - for line in f: 203 - line = line.strip() 204 - if line: 205 - try: 206 - existing = json.loads(line) 207 - existing_id = existing.get("agent_id") 208 - if isinstance(existing_id, str): 209 - existing_ids.add(existing_id) 210 - except json.JSONDecodeError: 211 - continue 212 - except IOError: 213 - pass 214 - 215 - new_entries = [e for e in entries if e["agent_id"] not in existing_ids] 216 - if new_entries: 217 - print(f" Day index: {day}.jsonl ({len(new_entries)} entries)") 218 - if not dry_run: 219 - with open(day_index_path, "a") as f: 220 - for entry in new_entries: 221 - f.write(json.dumps(entry) + "\n") 222 - 223 - return summary 224 - 225 - 226 - def main() -> None: 227 - """CLI entrypoint.""" 228 - parser = argparse.ArgumentParser( 229 - description="Migrate agent run logs to per-agent subdirectories" 230 - ) 231 - parser.add_argument( 232 - "--dry-run", action="store_true", help="Preview changes without writing" 233 - ) 234 - args = setup_cli(parser) 235 - 236 - journal_path = Path(get_journal()) 237 - agents_dir = journal_path / "agents" 238 - 239 - if args.dry_run: 240 - print("[DRY-RUN] No files will be modified.") 241 - 242 - print(f"Migrating agent run logs in: {agents_dir}") 243 - summary = migrate(agents_dir, dry_run=args.dry_run) 244 - 245 - print("Migration complete") 246 - print(f" moved: {summary.moved}") 247 - print(f" symlinks_removed: {summary.symlinks_removed}") 248 - print(f" symlinks_created: {summary.symlinks_created}") 249 - print(f" day_index_entries:{summary.day_index_entries}") 250 - print(f" skipped: {summary.skipped}") 251 - print(f" errors: {summary.errors}") 252 - 253 - 254 - if __name__ == "__main__": 255 - main()
-2
apps/agents/maint/__init__.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc
-642
apps/agents/routes.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Agents app - browse historical agent runs by day and facet.""" 5 - 6 - from __future__ import annotations 7 - 8 - import json 9 - import re 10 - from datetime import date, datetime 11 - from functools import lru_cache 12 - from pathlib import Path 13 - from typing import Any 14 - 15 - from flask import Blueprint, jsonify, redirect, render_template, request, url_for 16 - 17 - from convey import state 18 - from convey.utils import DATE_RE, format_date 19 - from think.facets import get_facets 20 - from think.models import calc_agent_cost 21 - from think.talent import get_output_path, get_talent_configs 22 - from think.utils import updated_days 23 - 24 - agents_bp = Blueprint( 25 - "app:agents", 26 - __name__, 27 - url_prefix="/app/agents", 28 - ) 29 - 30 - 31 - def _resolve_output_path( 32 - request_event: dict[str, Any], journal_root: str 33 - ) -> Path | None: 34 - """Resolve output file path from an agent request event. 35 - 36 - Uses explicit output_path if present, otherwise derives from 37 - request fields via get_output_path. 38 - 39 - Returns absolute Path or None if not resolvable. 40 - """ 41 - # Prefer explicit output_path (set for activity agents, custom paths) 42 - if request_event.get("output_path"): 43 - return Path(request_event["output_path"]) 44 - 45 - # Derive from request fields 46 - req_day = request_event.get("day") 47 - if not req_day: 48 - return None 49 - day_dir = Path(journal_root) / req_day 50 - req_segment = request_event.get("segment") 51 - req_facet = request_event.get("facet") 52 - req_name = request_event.get("name", "unified") 53 - req_env = request_event.get("env") or {} 54 - req_stream = req_env.get("SOL_STREAM") if req_env else None 55 - return get_output_path( 56 - day_dir, 57 - req_name, 58 - segment=req_segment, 59 - output_format=request_event.get("output"), 60 - facet=req_facet, 61 - stream=req_stream, 62 - ) 63 - 64 - 65 - def _get_facet_filter() -> str | None: 66 - """Get facet filter from query param or cookie. 67 - 68 - Returns None for all-facet mode. 69 - """ 70 - facet = request.args.get("facet") 71 - if facet is None: 72 - facet = request.cookies.get("selectedFacet") or None 73 - return facet 74 - 75 - 76 - def _agent_id_to_day(agent_id: str) -> str: 77 - """Convert agent_id (millisecond timestamp) to YYYYMMDD day string.""" 78 - try: 79 - ts = int(agent_id) / 1000 80 - return datetime.fromtimestamp(ts).strftime("%Y%m%d") 81 - except (ValueError, OSError): 82 - return "" 83 - 84 - 85 - def _parse_agent_events( 86 - lines: list[str], *, collect_events: bool = False 87 - ) -> dict[str, Any]: 88 - """Parse agent event lines and extract counts and cost data. 89 - 90 - Args: 91 - lines: List of JSONL lines 92 - collect_events: If True, include parsed event dicts as "events" key 93 - 94 - Returns: 95 - Dict with: thinking_count, tool_count, model, provider, usage, finish_ts, 96 - error_ts, error_message, and optionally events 97 - """ 98 - result: dict[str, Any] = { 99 - "thinking_count": 0, 100 - "tool_count": 0, 101 - "model": None, 102 - "provider": None, 103 - "usage": None, 104 - "finish_ts": None, 105 - "error_ts": None, 106 - "error_message": None, 107 - } 108 - events: list[dict] = [] if collect_events else None 109 - 110 - for line in lines: 111 - line = line.strip() 112 - if not line: 113 - continue 114 - try: 115 - event = json.loads(line) 116 - if events is not None: 117 - # Strip bulky provider-native data not used by the frontend 118 - event.pop("raw", None) 119 - events.append(event) 120 - event_type = event.get("event") 121 - if event_type == "thinking": 122 - result["thinking_count"] += 1 123 - elif event_type == "tool_start": 124 - result["tool_count"] += 1 125 - elif event_type == "start": 126 - result["model"] = event.get("model") 127 - result["provider"] = event.get("provider") 128 - elif event_type == "finish": 129 - result["finish_ts"] = event.get("ts", 0) 130 - result["usage"] = event.get("usage") 131 - elif event_type == "error": 132 - result["error_ts"] = event.get("ts", 0) 133 - msg = event.get("error", "") 134 - if msg: 135 - result["error_message"] = msg[:200] 136 - except json.JSONDecodeError: 137 - continue 138 - 139 - if events is not None: 140 - result["events"] = events 141 - 142 - return result 143 - 144 - 145 - def _parse_agent_file(agent_file: Path) -> dict[str, Any] | None: 146 - """Parse agent JSONL file and extract metadata. 147 - 148 - Returns dict with: id, name, start, status, prompt, facet, failed, 149 - runtime_seconds, thinking_count, tool_count, cost, model, provider, 150 - error_message. 151 - Returns None if file cannot be parsed. 152 - """ 153 - from think.cortex_client import get_agent_end_state 154 - 155 - try: 156 - with open(agent_file, "r") as f: 157 - lines = f.readlines() 158 - 159 - if not lines: 160 - return None 161 - 162 - first_line = lines[0].strip() 163 - if not first_line: 164 - return None 165 - 166 - request_event = json.loads(first_line) 167 - if request_event.get("event") != "request": 168 - return None 169 - 170 - # Extract agent ID from filename 171 - is_active = "_active.jsonl" in agent_file.name 172 - agent_id = agent_file.stem.replace("_active", "") 173 - 174 - # Parse events using shared helper 175 - event_data = _parse_agent_events(lines[1:]) 176 - 177 - agent_info: dict[str, Any] = { 178 - "id": agent_id, 179 - "name": request_event.get("name", "unified"), 180 - "start": request_event.get("ts", 0), 181 - "status": "running" if is_active else "completed", 182 - "prompt": request_event.get("prompt", ""), 183 - "facet": request_event.get("facet"), 184 - "failed": False, 185 - "runtime_seconds": None, 186 - "thinking_count": event_data["thinking_count"], 187 - "tool_count": event_data["tool_count"], 188 - "cost": None, 189 - "model": event_data["model"], 190 - "provider": request_event.get("provider") or event_data.get("provider"), 191 - "error_message": event_data["error_message"], 192 - } 193 - 194 - # Check for output file (generators only) 195 - output_file = None 196 - req_output = request_event.get("output") 197 - if req_output: 198 - out_path = _resolve_output_path(request_event, state.journal_root) 199 - if out_path and out_path.exists(): 200 - req_day = request_event.get("day") 201 - day_dir = Path(state.journal_root) / req_day if req_day else None 202 - if day_dir and out_path.is_relative_to(day_dir): 203 - output_file = str(out_path.relative_to(day_dir)) 204 - else: 205 - output_file = str(out_path.relative_to(state.journal_root)) 206 - agent_info["output_file"] = output_file 207 - 208 - # For completed agents, determine end state and calculate cost 209 - if not is_active: 210 - end_state = get_agent_end_state(agent_id) 211 - agent_info["failed"] = end_state in ("error", "unknown") 212 - 213 - # Calculate runtime from finish or error timestamp 214 - end_ts = event_data["finish_ts"] or event_data["error_ts"] 215 - if end_ts and agent_info["start"]: 216 - agent_info["runtime_seconds"] = (end_ts - agent_info["start"]) / 1000.0 217 - 218 - # Calculate cost 219 - agent_info["cost"] = calc_agent_cost( 220 - event_data["model"], event_data["usage"] 221 - ) 222 - 223 - return agent_info 224 - except (json.JSONDecodeError, IOError): 225 - return None 226 - 227 - 228 - def _get_agent_day(agent_file: Path) -> str: 229 - """Get the logical day for an agent from its request event. 230 - 231 - Prefers the ``day`` field from the request event (the day being processed) 232 - over the agent_id timestamp (when the agent actually ran). This ensures 233 - overnight dream agents appear under the day they processed. 234 - """ 235 - agent_id = agent_file.stem.replace("_active", "") 236 - try: 237 - with open(agent_file, "r") as f: 238 - first_line = f.readline().strip() 239 - if first_line: 240 - request_event = json.loads(first_line) 241 - req_day = request_event.get("day") 242 - if req_day: 243 - return req_day 244 - except (json.JSONDecodeError, IOError): 245 - pass 246 - return _agent_id_to_day(agent_id) 247 - 248 - 249 - def _get_agents_for_day(day: str, facet_filter: str | None = None) -> list[dict]: 250 - """Get all agent runs for a specific day. 251 - 252 - Uses the day index file for fast lookup instead of scanning all agent files. 253 - 254 - Args: 255 - day: YYYYMMDD day string 256 - facet_filter: Optional facet to filter by (None = all facets) 257 - 258 - Returns: 259 - List of agent info dicts sorted by start time (newest first) 260 - """ 261 - agents_dir = Path(state.journal_root) / "agents" 262 - if not agents_dir.exists(): 263 - return [] 264 - 265 - agents = [] 266 - 267 - # Read day index for completed agents 268 - day_index_path = agents_dir / f"{day}.jsonl" 269 - if day_index_path.exists(): 270 - try: 271 - with open(day_index_path, "r") as f: 272 - for line in f: 273 - line = line.strip() 274 - if not line: 275 - continue 276 - try: 277 - entry = json.loads(line) 278 - except json.JSONDecodeError: 279 - continue 280 - 281 - # Filter by facet if specified 282 - if facet_filter is not None and entry.get("facet") != facet_filter: 283 - continue 284 - 285 - # Locate the actual file for full parsing 286 - agent_id = entry.get("agent_id", "") 287 - name = entry.get("name", "unified") 288 - safe_name = name.replace(":", "--") 289 - agent_file = agents_dir / safe_name / f"{agent_id}.jsonl" 290 - if not agent_file.exists(): 291 - continue 292 - 293 - agent_info = _parse_agent_file(agent_file) 294 - if agent_info: 295 - agents.append(agent_info) 296 - except IOError: 297 - pass 298 - 299 - # Also check for running agents (only have _active files, no day index entry yet) 300 - for agent_file in agents_dir.glob("*/*_active.jsonl"): 301 - if "_pending" in agent_file.name: 302 - continue 303 - if _get_agent_day(agent_file) != day: 304 - continue 305 - 306 - agent_info = _parse_agent_file(agent_file) 307 - if not agent_info: 308 - continue 309 - 310 - if facet_filter is not None and agent_info.get("facet") != facet_filter: 311 - continue 312 - 313 - agents.append(agent_info) 314 - 315 - # Sort by start time (newest first) 316 - agents.sort(key=lambda x: x["start"], reverse=True) 317 - return agents 318 - 319 - 320 - @lru_cache(maxsize=1) 321 - def _build_agents_meta() -> dict[str, dict[str, Any]]: 322 - """Build agent metadata dict from all talent configs. 323 - 324 - Returns dict mapping agent name to metadata with capability fields 325 - for frontend display. Cached for process lifetime since talent configs 326 - are static. 327 - """ 328 - configs = get_talent_configs(include_disabled=True) 329 - agents: dict[str, dict[str, Any]] = {} 330 - 331 - for name, config in configs.items(): 332 - agents[name] = { 333 - "title": config.get("title", name), 334 - "description": config.get("description"), 335 - "color": config.get("color", "#6c757d"), 336 - "source": config.get("source", "system"), 337 - "app": config.get("app"), 338 - "schedule": config.get("schedule"), 339 - "type": config.get("type"), 340 - "output_format": config.get("output"), 341 - "multi_facet": bool(config.get("multi_facet")), 342 - } 343 - 344 - return agents 345 - 346 - 347 - # ============================================================================= 348 - # Page Routes 349 - # ============================================================================= 350 - 351 - 352 - @agents_bp.route("/") 353 - def index() -> Any: 354 - """Redirect to today's agent history.""" 355 - today = date.today().strftime("%Y%m%d") 356 - return redirect(url_for("app:agents.agents_day", day=today)) 357 - 358 - 359 - @agents_bp.route("/<day>") 360 - def agents_day(day: str) -> str: 361 - """Render agent history viewer for a specific day.""" 362 - if not DATE_RE.fullmatch(day): 363 - return "", 404 364 - 365 - title = format_date(day) 366 - 367 - return render_template("app.html", title=title) 368 - 369 - 370 - # ============================================================================= 371 - # API Routes 372 - # ============================================================================= 373 - 374 - 375 - @agents_bp.route("/api/agents/<day>") 376 - def api_agents_day(day: str) -> Any: 377 - """Get agent runs and metadata for a specific day. 378 - 379 - Returns flat data for frontend grouping/rendering. 380 - 381 - Query params: 382 - facet: Optional facet filter (from cookie if not specified) 383 - 384 - Returns: 385 - { 386 - "runs": [run objects...], 387 - "agents": {name: metadata...}, 388 - "facets": {name: {title, color}...} 389 - } 390 - """ 391 - if not DATE_RE.fullmatch(day): 392 - return jsonify({"error": "Invalid day format"}), 400 393 - 394 - facet_filter = _get_facet_filter() 395 - 396 - runs = _get_agents_for_day(day, facet_filter) 397 - agents = _build_agents_meta() 398 - facets = { 399 - name: {"title": f.get("title", name), "color": f.get("color")} 400 - for name, f in get_facets().items() 401 - } 402 - 403 - return jsonify( 404 - { 405 - "runs": runs, 406 - "agents": agents, 407 - "facets": facets, 408 - } 409 - ) 410 - 411 - 412 - @agents_bp.route("/api/run/<agent_id>") 413 - def api_agent_run(agent_id: str) -> Any: 414 - """Return full agent run detail with metadata and parsed events.""" 415 - # Locate the agent JSONL file 416 - journal_path = Path(state.journal_root) 417 - agents_dir = journal_path / "agents" 418 - # Search subdirectories for the agent file 419 - agent_file = None 420 - for match in agents_dir.glob(f"*/{agent_id}.jsonl"): 421 - agent_file = match 422 - break 423 - 424 - if not agent_file: 425 - # Check if the agent is still running 426 - for match in agents_dir.glob(f"*/{agent_id}_active.jsonl"): 427 - return jsonify({"error": "Agent run is still in progress"}), 202 428 - return jsonify({"error": f"Agent run {agent_id} not found"}), 404 429 - 430 - try: 431 - from think.cortex_client import get_agent_end_state 432 - 433 - with open(agent_file, "r", encoding="utf-8") as f: 434 - lines = f.readlines() 435 - 436 - if not lines: 437 - return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 438 - 439 - first_line = lines[0].strip() 440 - if not first_line: 441 - return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 442 - 443 - request_event = json.loads(first_line) 444 - if request_event.get("event") != "request": 445 - return jsonify({"error": f"Agent run {agent_id} is malformed"}), 500 446 - 447 - event_data = _parse_agent_events(lines[1:], collect_events=True) 448 - 449 - output_file = None 450 - req_output = request_event.get("output") 451 - if req_output: 452 - out_path = _resolve_output_path(request_event, state.journal_root) 453 - if out_path and out_path.exists(): 454 - req_day = request_event.get("day") 455 - day_dir = Path(state.journal_root) / req_day if req_day else None 456 - if day_dir and out_path.is_relative_to(day_dir): 457 - output_file = str(out_path.relative_to(day_dir)) 458 - else: 459 - output_file = str(out_path.relative_to(state.journal_root)) 460 - 461 - start_ts = request_event.get("ts", 0) 462 - runtime_seconds = None 463 - end_ts = event_data["finish_ts"] or event_data["error_ts"] 464 - if end_ts and start_ts: 465 - runtime_seconds = (end_ts - start_ts) / 1000.0 466 - 467 - end_state = get_agent_end_state(agent_id) 468 - 469 - run: dict[str, Any] = { 470 - "id": agent_id, 471 - "name": request_event.get("name", "unified"), 472 - "start": start_ts, 473 - "status": "completed", 474 - "prompt": request_event.get("prompt", ""), 475 - "facet": request_event.get("facet"), 476 - "failed": end_state in ("error", "unknown"), 477 - "runtime_seconds": runtime_seconds, 478 - "thinking_count": event_data["thinking_count"], 479 - "tool_count": event_data["tool_count"], 480 - "cost": calc_agent_cost(event_data["model"], event_data["usage"]), 481 - "model": event_data["model"], 482 - "provider": request_event.get("provider") or event_data.get("provider"), 483 - "error_message": event_data["error_message"], 484 - "output_file": output_file, 485 - "events": event_data.get("events", []), 486 - } 487 - run["day"] = request_event.get("day") or _agent_id_to_day(agent_id) 488 - return jsonify(run) 489 - except Exception as e: 490 - return jsonify({"error": str(e)}), 500 491 - 492 - 493 - @agents_bp.route("/api/output/<day>/<path:filename>") 494 - def api_output_file(day: str, filename: str) -> Any: 495 - """Serve output file content for the run detail output tab. 496 - 497 - Returns JSON with content, format, and filename. 498 - Path is validated to stay within the journal directory. 499 - 500 - Supports two path styles: 501 - - Day-relative: ``agents/flow.md`` → resolved under ``{day}/`` 502 - - Journal-relative: ``facets/work/activities/...`` → resolved under journal root 503 - """ 504 - if not DATE_RE.fullmatch(day): 505 - return jsonify(error="Invalid day format"), 400 506 - 507 - journal_root = Path(state.journal_root).resolve() 508 - 509 - # Journal-relative paths (e.g., activity output under facets/) 510 - if filename.startswith("facets/"): 511 - file_path = (journal_root / filename).resolve() 512 - else: 513 - file_path = (journal_root / day / filename).resolve() 514 - 515 - # Security: ensure path is within the journal directory 516 - try: 517 - file_path.relative_to(journal_root) 518 - except ValueError: 519 - return jsonify(error="Invalid path"), 403 520 - 521 - if not file_path.is_file(): 522 - return jsonify(error="File not found"), 404 523 - 524 - ext = file_path.suffix.lower() 525 - fmt = "json" if ext == ".json" else "md" 526 - 527 - try: 528 - content = file_path.read_text(encoding="utf-8") 529 - except IOError: 530 - return jsonify(error="Could not read file"), 500 531 - 532 - return jsonify(content=content, format=fmt, filename=file_path.name) 533 - 534 - 535 - @agents_bp.route("/api/preview/<path:name>") 536 - def api_preview_prompt(name: str) -> Any: 537 - """Return the complete rendered prompt for an agent. 538 - 539 - Returns: 540 - { 541 - "name": str, 542 - "title": str, 543 - "full_prompt": str, 544 - "multi_facet": bool 545 - } 546 - """ 547 - try: 548 - from think.talent import get_agent 549 - 550 - config = get_agent(name) 551 - 552 - system_instruction = config.get("system_instruction", "") 553 - extra_context = config.get("extra_context", "") 554 - user_instruction = config.get("user_instruction", "") 555 - # Compose full prompt with labeled sections 556 - labeled = [] 557 - if system_instruction: 558 - labeled.append(f"## System Instruction\n\n{system_instruction}") 559 - if extra_context: 560 - labeled.append(f"## Context\n\n{extra_context}") 561 - if user_instruction: 562 - labeled.append(f"## Instructions\n\n{user_instruction}") 563 - full_prompt = "\n\n".join(labeled) 564 - 565 - return jsonify( 566 - { 567 - "name": name, 568 - "title": config.get("title", name), 569 - "full_prompt": full_prompt, 570 - "multi_facet": config.get("multi_facet", False), 571 - } 572 - ) 573 - except FileNotFoundError: 574 - return jsonify({"error": f"Agent '{name}' not found"}), 404 575 - except Exception as e: 576 - return jsonify({"error": str(e)}), 500 577 - 578 - 579 - @agents_bp.route("/api/stats/<month>") 580 - def api_stats(month: str) -> Any: 581 - """Return agent run counts per day per facet for a month. 582 - 583 - Args: 584 - month: YYYYMM format month string 585 - 586 - Returns: 587 - JSON dict mapping day (YYYYMMDD) to {facet: count, ...} 588 - For unfaceted runs, uses "_none" as the key. 589 - """ 590 - if not re.fullmatch(r"\d{6}", month): 591 - return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 592 - 593 - agents_dir = Path(state.journal_root) / "agents" 594 - if not agents_dir.exists(): 595 - return jsonify({}) 596 - 597 - stats: dict[str, dict[str, int]] = {} 598 - 599 - # Read day index files for the month 600 - for day_index_file in agents_dir.glob(f"{month}*.jsonl"): 601 - day = day_index_file.stem 602 - if not re.fullmatch(r"\d{8}", day): 603 - continue 604 - 605 - try: 606 - with open(day_index_file, "r") as f: 607 - for line in f: 608 - line = line.strip() 609 - if not line: 610 - continue 611 - try: 612 - entry = json.loads(line) 613 - except json.JSONDecodeError: 614 - continue 615 - 616 - facet = entry.get("facet") or "_none" 617 - if day not in stats: 618 - stats[day] = {} 619 - stats[day][facet] = stats[day].get(facet, 0) + 1 620 - except IOError: 621 - continue 622 - 623 - return jsonify(stats) 624 - 625 - 626 - @agents_bp.route("/api/badge-count") 627 - def api_badge_count() -> Any: 628 - """Get count of failed agent runs for today (for app icon badge).""" 629 - today = date.today().strftime("%Y%m%d") 630 - agents = _get_agents_for_day(today, facet_filter=None) 631 - failed_count = sum(1 for a in agents if a.get("failed")) 632 - return jsonify({"count": failed_count}) 633 - 634 - 635 - @agents_bp.route("/api/updated-days") 636 - def api_updated_days() -> Any: 637 - """Return journal days with pending reprocessing.""" 638 - today = date.today().strftime("%Y%m%d") 639 - try: 640 - return jsonify(updated_days(exclude={today})) 641 - except Exception: 642 - return jsonify([])
-2419
apps/agents/workspace.html
··· 1 - {# Agents day view - historical agent runs browser #} 2 - 3 - <style> 4 - /* ============================================================================ 5 - Layout & Navigation 6 - ============================================================================ */ 7 - .agents-content { 8 - max-width: 1200px; 9 - margin: 0 auto; 10 - padding: 1.5rem 2rem 2rem; 11 - border-top: 1px solid var(--facet-border, #e5e0db); 12 - } 13 - 14 - .view-header { 15 - display: flex; 16 - align-items: center; 17 - gap: 1rem; 18 - margin-bottom: 1.5rem; 19 - padding-bottom: 0.75rem; 20 - border-bottom: 1px solid var(--facet-border, #e5e0db); 21 - } 22 - 23 - .back-btn { 24 - background: none; 25 - border: 1px solid #ddd; 26 - border-radius: 6px; 27 - padding: 0.5rem 0.75rem; 28 - cursor: pointer; 29 - font-size: 1rem; 30 - display: flex; 31 - align-items: center; 32 - gap: 0.5rem; 33 - color: #666; 34 - transition: background 0.2s, border-color 0.2s, color 0.2s; 35 - } 36 - 37 - .back-btn:hover { 38 - background: #f5f5f5; 39 - border-color: #ccc; 40 - color: #333; 41 - } 42 - 43 - .back-btn:active { 44 - background: #e0e0e0; 45 - } 46 - 47 - .back-btn:focus-visible { 48 - outline: 2px solid var(--facet-color, #b06a1a); 49 - outline-offset: 2px; 50 - } 51 - 52 - .view-title { 53 - margin: 0; 54 - font-size: 1.5rem; 55 - font-weight: 600; 56 - color: #333; 57 - } 58 - 59 - /* ============================================================================ 60 - Day Summary Bar 61 - ============================================================================ */ 62 - .day-summary { 63 - display: flex; 64 - gap: 1.5rem; 65 - padding: 0.5rem 0 1rem; 66 - font-size: 0.85rem; 67 - color: #666; 68 - } 69 - 70 - .day-summary .summary-item { 71 - display: flex; 72 - align-items: center; 73 - gap: 0.3rem; 74 - } 75 - 76 - .day-summary .summary-failed { 77 - color: #c62828; 78 - } 79 - 80 - /* ============================================================================ 81 - Agent Card Grid 82 - ============================================================================ */ 83 - .agent-section { 84 - margin-bottom: 2rem; 85 - } 86 - 87 - .section-title { 88 - font-size: 0.85rem; 89 - font-weight: 600; 90 - color: #666; 91 - text-transform: uppercase; 92 - letter-spacing: 0.05em; 93 - margin-bottom: 0.75rem; 94 - padding-bottom: 0.25rem; 95 - border-bottom: 1px solid var(--facet-border, #e5e0db); 96 - } 97 - 98 - .agent-cards { 99 - display: grid; 100 - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); 101 - gap: 1rem; 102 - } 103 - 104 - .agent-card { 105 - background: white; 106 - border: 2px solid var(--facet-border, #e5e0db); 107 - border-left: 3px solid #6c757d; 108 - border-radius: 8px; 109 - padding: 1rem; 110 - cursor: pointer; 111 - transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease; 112 - } 113 - 114 - .agent-card:hover { 115 - border-top-color: #007bff; 116 - border-right-color: #007bff; 117 - border-bottom-color: #007bff; 118 - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.15); 119 - } 120 - 121 - .agent-card:active { 122 - transform: scale(0.98); 123 - } 124 - 125 - .agent-card-header { 126 - display: flex; 127 - align-items: flex-start; 128 - justify-content: space-between; 129 - margin-bottom: 0.25rem; 130 - } 131 - 132 - .agent-card-title { 133 - font-weight: 600; 134 - color: #333; 135 - font-size: 1rem; 136 - line-height: 1.3; 137 - margin: 0; 138 - } 139 - 140 - .agent-card-badges { 141 - display: flex; 142 - gap: 0.4rem; 143 - flex-shrink: 0; 144 - } 145 - 146 - .badge { 147 - display: inline-flex; 148 - align-items: center; 149 - justify-content: center; 150 - padding: 0.15rem 0.5rem; 151 - border-radius: 12px; 152 - font-size: 0.75rem; 153 - font-weight: 500; 154 - } 155 - 156 - .badge-success { 157 - background: #e8f5e9; 158 - color: #2e7d32; 159 - } 160 - 161 - .badge-failed { 162 - background: #ffebee; 163 - color: #c62828; 164 - } 165 - 166 - .agent-card-description { 167 - font-size: 0.85rem; 168 - color: #6b7280; 169 - margin: 0 0 0.5rem; 170 - display: -webkit-box; 171 - -webkit-line-clamp: 2; 172 - -webkit-box-orient: vertical; 173 - overflow: hidden; 174 - } 175 - 176 - .agent-card-caps { 177 - display: flex; 178 - gap: 0.4rem; 179 - margin-bottom: 0.5rem; 180 - flex-wrap: wrap; 181 - } 182 - 183 - .cap-badge { 184 - display: inline-flex; 185 - align-items: center; 186 - padding: 0.15rem 0.5rem; 187 - border-radius: 3px; 188 - font-size: 0.7rem; 189 - font-weight: 500; 190 - background: #f0f0f0; 191 - color: #555; 192 - text-transform: uppercase; 193 - letter-spacing: 0.04em; 194 - } 195 - 196 - .agent-card-activity { 197 - display: flex; 198 - justify-content: space-between; 199 - align-items: center; 200 - gap: 0.75rem; 201 - font-size: 0.8rem; 202 - color: #666; 203 - } 204 - 205 - .activity-counts { 206 - display: flex; 207 - gap: 0.75rem; 208 - } 209 - 210 - .facet-dots { 211 - display: flex; 212 - gap: 0.4rem; 213 - flex-shrink: 0; 214 - } 215 - 216 - .activity-item { 217 - display: flex; 218 - align-items: center; 219 - gap: 0.25rem; 220 - } 221 - 222 - .facet-dot { 223 - width: 10px; 224 - height: 10px; 225 - border-radius: 50%; 226 - display: inline-flex; 227 - align-items: center; 228 - justify-content: center; 229 - overflow: hidden; 230 - border: 1px solid rgba(0,0,0,0.1); 231 - } 232 - 233 - /* ============================================================================ 234 - Run List View 235 - ============================================================================ */ 236 - .run-list-header { 237 - display: flex; 238 - align-items: center; 239 - gap: 1rem; 240 - margin-bottom: 1rem; 241 - } 242 - 243 - .preview-btn { 244 - background: #f8f9fa; 245 - border: 1px solid #ddd; 246 - border-radius: 6px; 247 - padding: 0.4rem 0.75rem; 248 - cursor: pointer; 249 - font-size: 0.85rem; 250 - color: #666; 251 - transition: background 0.2s, border-color 0.2s; 252 - margin-left: auto; 253 - } 254 - 255 - .preview-btn:hover { 256 - background: #e9ecef; 257 - border-color: #ccc; 258 - } 259 - 260 - .preview-btn:active { 261 - background: #dee2e6; 262 - } 263 - 264 - .preview-btn:focus-visible { 265 - outline: 2px solid var(--facet-color, #b06a1a); 266 - outline-offset: 2px; 267 - } 268 - 269 - .runs-table { 270 - width: 100%; 271 - border-collapse: collapse; 272 - } 273 - 274 - .runs-table th, 275 - .runs-table td { 276 - padding: 0.75rem 0.5rem; 277 - text-align: left; 278 - border-bottom: 1px solid var(--facet-border, #e5e0db); 279 - } 280 - 281 - .runs-table th { 282 - font-weight: 600; 283 - color: #666; 284 - font-size: 0.85rem; 285 - } 286 - 287 - .runs-table tr { 288 - cursor: pointer; 289 - transition: background 0.15s; 290 - } 291 - 292 - .runs-table tbody tr:hover { 293 - background: rgba(0, 0, 0, 0.03); 294 - } 295 - 296 - .runs-table tbody tr:active { 297 - background: rgba(0, 0, 0, 0.06); 298 - } 299 - 300 - .col-status { width: 50px; text-align: center; } 301 - .col-time { width: 100px; } 302 - .col-model { width: 140px; } 303 - .col-provider { width: 90px; } 304 - .col-runtime { width: 80px; text-align: right; } 305 - .col-activity { width: 50px; text-align: center; } 306 - .col-facet { width: 120px; } 307 - .col-output { width: 120px; } 308 - .col-prompt { } 309 - 310 - .status-icon { 311 - font-size: 1.1rem; 312 - } 313 - 314 - .status-ok { color: #4caf50; } 315 - .status-failed { color: #f44336; } 316 - .status-running { color: #ff9800; } 317 - 318 - .facet-tag { 319 - display: inline-flex; 320 - align-items: center; 321 - gap: 0.3rem; 322 - padding: 0.2rem 0.5rem; 323 - border-radius: 4px; 324 - font-size: 0.85rem; 325 - background: #f5f5f5; 326 - } 327 - 328 - .prompt-snippet { 329 - max-width: 400px; 330 - overflow: hidden; 331 - text-overflow: ellipsis; 332 - white-space: nowrap; 333 - color: #666; 334 - font-size: 0.85rem; 335 - } 336 - 337 - .model-name { 338 - font-size: 0.85rem; 339 - color: #5f6368; 340 - } 341 - 342 - .provider-name { 343 - font-size: 0.8rem; 344 - color: #888; 345 - } 346 - 347 - .error-preview { 348 - font-size: 0.8rem; 349 - color: #c62828; 350 - margin-top: 0.2rem; 351 - overflow: hidden; 352 - text-overflow: ellipsis; 353 - white-space: nowrap; 354 - max-width: 400px; 355 - } 356 - 357 - /* ============================================================================ 358 - Run Detail View 359 - ============================================================================ */ 360 - .run-detail-breadcrumb { 361 - display: flex; 362 - align-items: center; 363 - gap: 0.45rem; 364 - margin-bottom: 1rem; 365 - font-size: 0.9rem; 366 - font-weight: 500; 367 - } 368 - 369 - .crumb-link { 370 - color: #1a73e8; 371 - text-decoration: none; 372 - cursor: pointer; 373 - } 374 - 375 - .crumb-link:hover { 376 - text-decoration: underline; 377 - } 378 - 379 - .crumb-link:focus-visible { 380 - outline: 2px solid var(--facet-color, #b06a1a); 381 - outline-offset: 2px; 382 - border-radius: 2px; 383 - } 384 - 385 - .crumb-sep { 386 - color: #999; 387 - } 388 - 389 - .crumb-current { 390 - color: #5f6368; 391 - } 392 - 393 - .run-detail-header-card { 394 - background: #fafafa; 395 - border: 1px solid var(--facet-border, #e5e0db); 396 - border-radius: 8px; 397 - padding: 1rem; 398 - margin-bottom: 1rem; 399 - } 400 - 401 - .run-detail-title-row { 402 - display: flex; 403 - align-items: center; 404 - gap: 0.75rem; 405 - flex-wrap: wrap; 406 - margin-bottom: 0.4rem; 407 - } 408 - 409 - .run-detail-title { 410 - margin: 0; 411 - font-size: 1.2rem; 412 - font-weight: 600; 413 - color: #333; 414 - } 415 - 416 - .run-status-badge { 417 - display: inline-flex; 418 - align-items: center; 419 - padding: 0.2rem 0.6rem; 420 - border-radius: 999px; 421 - font-size: 0.75rem; 422 - font-weight: 600; 423 - } 424 - 425 - .run-status-badge.status-completed { 426 - background: #e8f5e9; 427 - color: #2e7d32; 428 - } 429 - 430 - .run-status-badge.status-failed { 431 - background: #ffebee; 432 - color: #c62828; 433 - } 434 - 435 - .run-status-badge.status-running { 436 - background: #e3f2fd; 437 - color: #1565c0; 438 - } 439 - 440 - .run-detail-subtitle { 441 - color: #6b7280; 442 - font-size: 0.9rem; 443 - margin-bottom: 0.85rem; 444 - } 445 - 446 - .run-detail-prompt { 447 - color: #6b7280; 448 - font-size: 0.85rem; 449 - margin-bottom: 0.85rem; 450 - padding: 0.5rem 0.7rem; 451 - background: #f8f9fa; 452 - border-radius: 6px; 453 - border-left: 3px solid #d1d5db; 454 - } 455 - 456 - .run-meta-grid { 457 - display: grid; 458 - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); 459 - gap: 0.75rem; 460 - } 461 - 462 - .run-meta-item { 463 - display: flex; 464 - flex-direction: column; 465 - gap: 0.2rem; 466 - background: #fff; 467 - border: 1px solid var(--facet-border, #e5e0db); 468 - border-radius: 6px; 469 - padding: 0.6rem 0.7rem; 470 - } 471 - 472 - .meta-label { 473 - font-size: 0.75rem; 474 - color: #6b7280; 475 - } 476 - 477 - .meta-value { 478 - font-size: 0.9rem; 479 - color: #2f3437; 480 - } 481 - 482 - .rd-tabs { 483 - gap: 8px; 484 - padding: 8px 0; 485 - border-bottom: 1px solid var(--facet-border, #e5e0db); 486 - flex-shrink: 0; 487 - display: flex; 488 - } 489 - 490 - .rd-tab { 491 - padding: 6px 14px; 492 - border: 1px solid var(--facet-border, #e5e0db); 493 - border-radius: 6px; 494 - font-size: 13px; 495 - cursor: pointer; 496 - background: #fff; 497 - color: #374151; 498 - transition: background 0.15s, border-color 0.15s, color 0.15s; 499 - } 500 - 501 - .rd-tab:hover { 502 - background: #f9fafb; 503 - } 504 - 505 - .rd-tab.active { 506 - background: var(--facet-color, #b06a1a); 507 - border-color: var(--facet-color, #b06a1a); 508 - color: #fff; 509 - } 510 - 511 - .rd-tab:not(.active):active { 512 - background: #e5e7eb; 513 - } 514 - 515 - .rd-tab:focus-visible { 516 - outline: 2px solid var(--facet-color, #b06a1a); 517 - outline-offset: 2px; 518 - } 519 - 520 - .rd-panel { 521 - padding-top: 0.85rem; 522 - } 523 - 524 - .rd-tab-pane { 525 - display: none; 526 - } 527 - 528 - .rd-tab-pane.active { 529 - display: block; 530 - } 531 - 532 - .run-output-content { 533 - line-height: 1.6; 534 - font-size: 0.9rem; 535 - } 536 - 537 - .run-output-content pre { 538 - background: #f5f5f5; 539 - padding: 1rem; 540 - border-radius: 4px; 541 - overflow-x: auto; 542 - white-space: pre-wrap; 543 - word-wrap: break-word; 544 - font-size: 0.85rem; 545 - line-height: 1.5; 546 - margin: 0; 547 - } 548 - 549 - .run-output-content .rendered-markdown h1, 550 - .run-output-content .rendered-markdown h2, 551 - .run-output-content .rendered-markdown h3 { 552 - margin-top: 1rem; 553 - margin-bottom: 0.5rem; 554 - } 555 - 556 - .run-output-content .rendered-markdown ul, 557 - .run-output-content .rendered-markdown ol { 558 - padding-left: 1.5rem; 559 - } 560 - 561 - .run-output-content .rendered-markdown p { 562 - margin-bottom: 0.5rem; 563 - } 564 - 565 - .run-loading { 566 - text-align: center; 567 - padding: 2rem; 568 - color: #666; 569 - } 570 - 571 - /* Event timeline */ 572 - .event-timeline { 573 - display: flex; 574 - flex-direction: column; 575 - gap: 0.5rem; 576 - } 577 - 578 - .event-block { 579 - border-left: 3px solid #e0e0e0; 580 - padding: 0.5rem 1rem; 581 - font-size: 0.875rem; 582 - line-height: 1.5; 583 - } 584 - 585 - .event-block .event-time { 586 - font-size: 0.75rem; 587 - color: #999; 588 - margin-right: 0.5rem; 589 - font-family: monospace; 590 - } 591 - 592 - /* Thinking events */ 593 - .event-thinking { 594 - border-left-color: #7c4dff; 595 - background: #f5f0ff; 596 - } 597 - 598 - .event-thinking .event-label { 599 - font-weight: 600; 600 - color: #7c4dff; 601 - font-size: 0.75rem; 602 - } 603 - 604 - .event-thinking .thinking-text { 605 - margin-top: 0.3rem; 606 - color: #555; 607 - white-space: pre-wrap; 608 - } 609 - 610 - /* Tool events */ 611 - .event-tool { 612 - border-left-color: #0288d1; 613 - background: #f0f7ff; 614 - } 615 - 616 - .event-tool .event-label { 617 - font-weight: 600; 618 - color: #0288d1; 619 - font-size: 0.75rem; 620 - } 621 - 622 - .event-tool .tool-name { 623 - font-weight: 600; 624 - color: #01579b; 625 - } 626 - 627 - .event-tool .tool-toggle { 628 - background: none; 629 - border: none; 630 - cursor: pointer; 631 - font-size: 0.75rem; 632 - font-weight: 500; 633 - color: #0288d1; 634 - padding: 0.1rem 0.3rem; 635 - border-radius: 3px; 636 - margin-left: 0.5rem; 637 - } 638 - 639 - .event-tool .tool-toggle:hover { 640 - background: #e1f0ff; 641 - } 642 - 643 - .event-tool .tool-toggle:active { 644 - background: #c8e1f7; 645 - } 646 - 647 - .event-tool .tool-toggle:focus-visible { 648 - outline: 2px solid var(--facet-color, #b06a1a); 649 - outline-offset: 1px; 650 - } 651 - 652 - .event-tool .tool-details { 653 - margin-top: 0.4rem; 654 - } 655 - 656 - .event-tool .tool-details pre { 657 - background: #e8f0f8; 658 - padding: 0.5rem; 659 - border-radius: 4px; 660 - overflow-x: auto; 661 - font-size: 0.8rem; 662 - margin: 0.3rem 0; 663 - max-height: 300px; 664 - overflow-y: auto; 665 - } 666 - 667 - .event-tool .tool-section-label { 668 - font-size: 0.75rem; 669 - font-weight: 600; 670 - color: #666; 671 - margin-top: 0.4rem; 672 - } 673 - 674 - .event-tool .tool-incomplete { 675 - font-style: italic; 676 - color: #999; 677 - font-size: 0.82rem; 678 - } 679 - 680 - .event-tool .tool-incomplete-badge { 681 - font-size: 0.72rem; 682 - color: #e65100; 683 - font-style: italic; 684 - margin-left: 0.4rem; 685 - } 686 - 687 - /* Error events */ 688 - .event-error { 689 - border-left-color: #d32f2f; 690 - background: #fff5f5; 691 - } 692 - 693 - .event-error .event-label { 694 - font-weight: 600; 695 - color: #d32f2f; 696 - font-size: 0.75rem; 697 - } 698 - 699 - .event-error .error-message { 700 - font-weight: 500; 701 - color: #c62828; 702 - } 703 - 704 - .event-error .error-trace { 705 - margin-top: 0.3rem; 706 - } 707 - 708 - .event-error .error-trace pre { 709 - background: #fee; 710 - padding: 0.5rem; 711 - border-radius: 4px; 712 - overflow-x: auto; 713 - font-size: 0.8rem; 714 - max-height: 200px; 715 - overflow-y: auto; 716 - } 717 - 718 - /* Info events */ 719 - .event-info { 720 - border-left-color: #bbb; 721 - color: #888; 722 - font-size: 0.82rem; 723 - font-style: italic; 724 - } 725 - 726 - /* Finish event */ 727 - .event-finish { 728 - border-left-color: #388e3c; 729 - background: #f0faf0; 730 - } 731 - 732 - .event-finish .event-label { 733 - font-weight: 600; 734 - color: #388e3c; 735 - font-size: 0.75rem; 736 - } 737 - 738 - .event-finish .finish-result { 739 - margin-top: 0.3rem; 740 - line-height: 1.6; 741 - } 742 - 743 - .event-finish .finish-empty { 744 - color: #9e9e9e; 745 - font-style: italic; 746 - font-size: 0.85rem; 747 - } 748 - 749 - .event-finish .finish-result pre { 750 - background: #e8f5e9; 751 - padding: 0.5rem; 752 - border-radius: 4px; 753 - overflow-x: auto; 754 - font-size: 0.8rem; 755 - } 756 - 757 - .event-finish .finish-result code { 758 - background: #e0f0e0; 759 - padding: 0.1rem 0.3rem; 760 - border-radius: 3px; 761 - font-size: 0.85em; 762 - } 763 - 764 - .event-finish .finish-result pre code { 765 - background: none; 766 - padding: 0; 767 - } 768 - 769 - /* Flow events (agent_updated, continue) */ 770 - .event-flow { 771 - border-left-color: #f9a825; 772 - background: #fffde7; 773 - font-size: 0.82rem; 774 - color: #666; 775 - } 776 - 777 - .event-flow .flow-label { 778 - font-weight: 500; 779 - color: #f57f17; 780 - } 781 - 782 - /* ============================================================================ 783 - Preview Modal 784 - ============================================================================ */ 785 - .modal-backdrop { 786 - display: none; 787 - position: fixed; 788 - top: 0; 789 - left: 0; 790 - right: 0; 791 - bottom: 0; 792 - background: rgba(0,0,0,0.5); 793 - z-index: var(--z-modals, 200); 794 - } 795 - 796 - .modal-backdrop.show { 797 - display: flex; 798 - align-items: center; 799 - justify-content: center; 800 - cursor: pointer; 801 - } 802 - 803 - .modal-content { 804 - background: white; 805 - border-radius: 8px; 806 - width: 90%; 807 - max-width: 800px; 808 - max-height: 80vh; 809 - display: flex; 810 - flex-direction: column; 811 - } 812 - 813 - .modal-header { 814 - display: flex; 815 - align-items: center; 816 - justify-content: space-between; 817 - padding: 1rem; 818 - border-bottom: 1px solid var(--facet-border, #e5e0db); 819 - } 820 - 821 - .modal-title { 822 - margin: 0; 823 - font-size: 1.1rem; 824 - font-weight: 600; 825 - } 826 - 827 - .modal-close { 828 - background: none; 829 - border: none; 830 - font-size: 1.5rem; 831 - cursor: pointer; 832 - color: #666; 833 - padding: 0; 834 - line-height: 1; 835 - } 836 - 837 - .modal-close:hover { 838 - color: #333; 839 - } 840 - 841 - .modal-close:active { 842 - color: #111; 843 - } 844 - 845 - .modal-close:focus-visible { 846 - outline: 2px solid var(--facet-color, #b06a1a); 847 - outline-offset: 2px; 848 - border-radius: 4px; 849 - } 850 - 851 - .modal-body { 852 - padding: 1rem; 853 - overflow-y: auto; 854 - flex: 1; 855 - } 856 - 857 - .modal-body pre { 858 - background: #f5f5f5; 859 - padding: 1rem; 860 - border-radius: 4px; 861 - overflow-x: auto; 862 - white-space: pre-wrap; 863 - word-wrap: break-word; 864 - font-size: 0.85rem; 865 - line-height: 1.5; 866 - margin: 0; 867 - } 868 - 869 - /* ============================================================================ 870 - Empty States 871 - ============================================================================ */ 872 - .empty-state { 873 - text-align: center; 874 - color: #666; 875 - padding: calc(30vh - 2rem) 1rem 2rem; 876 - min-height: calc(100vh - 300px); 877 - box-sizing: border-box; 878 - } 879 - 880 - .empty-state-icon { 881 - font-size: 2.5rem; 882 - margin: 0 auto 0.75rem; 883 - opacity: 0.5; 884 - width: 72px; 885 - height: 72px; 886 - line-height: 72px; 887 - background: rgba(0, 0, 0, 0.025); 888 - border-radius: 50%; 889 - } 890 - 891 - .empty-state-text { 892 - font-size: 1.1rem; 893 - margin-bottom: 0.75rem; 894 - } 895 - 896 - .empty-state-hint { 897 - font-size: 0.85rem; 898 - color: #aaa; 899 - } 900 - 901 - /* ============================================================================ 902 - Loading 903 - ============================================================================ */ 904 - .loading { 905 - text-align: center; 906 - padding: 3rem; 907 - color: #666; 908 - } 909 - 910 - .spinner { 911 - display: inline-block; 912 - width: 32px; 913 - height: 32px; 914 - border: 3px solid #e0e0e0; 915 - border-top-color: #007bff; 916 - border-radius: 50%; 917 - animation: spin 1s linear infinite; 918 - margin-bottom: 1rem; 919 - } 920 - 921 - .updated-banner { 922 - background: #fff8e1; 923 - border: 1px solid #ffe082; 924 - border-radius: 6px; 925 - padding: 0.75rem 1rem; 926 - margin-bottom: 1rem; 927 - font-size: 0.9rem; 928 - color: #5d4037; 929 - } 930 - 931 - .updated-banner-title { 932 - font-weight: 600; 933 - margin-bottom: 0.25rem; 934 - } 935 - 936 - .updated-banner a { 937 - color: #e65100; 938 - text-decoration: none; 939 - font-weight: 500; 940 - } 941 - 942 - .updated-banner a:hover { 943 - text-decoration: underline; 944 - } 945 - 946 - @keyframes spin { 947 - to { transform: rotate(360deg); } 948 - } 949 - 950 - @media (prefers-reduced-motion: reduce) { 951 - .spinner { 952 - animation: none; 953 - border-top-color: #007bff; 954 - opacity: 0.7; 955 - } 956 - } 957 - 958 - .agent-card:focus-visible { 959 - outline: 2px solid var(--facet-color, #b06a1a); 960 - outline-offset: 2px; 961 - } 962 - 963 - .runs-table tbody tr:focus-visible { 964 - outline: 2px solid var(--facet-color, #b06a1a); 965 - outline-offset: -2px; 966 - } 967 - 968 - /* Responsive: tablet */ 969 - @media (max-width: 768px) { 970 - .col-model, .col-provider, .col-output, .col-prompt { 971 - display: none; 972 - } 973 - .runs-table { 974 - font-size: 0.85rem; 975 - } 976 - #list-view { 977 - overflow-x: auto; 978 - } 979 - } 980 - 981 - /* Responsive: mobile */ 982 - @media (max-width: 480px) { 983 - .col-runtime, .col-facet { 984 - display: none; 985 - } 986 - .runs-table { 987 - font-size: 0.8rem; 988 - } 989 - } 990 - 991 - .sr-only { 992 - position: absolute; 993 - width: 1px; 994 - height: 1px; 995 - padding: 0; 996 - margin: -1px; 997 - overflow: hidden; 998 - clip: rect(0, 0, 0, 0); 999 - white-space: nowrap; 1000 - border: 0; 1001 - } 1002 - </style> 1003 - 1004 - <div class="agents-content"> 1005 - <h2 class="sr-only">Agent Runs</h2> 1006 - <!-- Loading state --> 1007 - <div id="loading-view" class="loading" role="status" aria-live="polite"> 1008 - <div class="spinner" aria-hidden="true"></div> 1009 - <div>Loading agents...</div> 1010 - </div> 1011 - 1012 - <div id="agents-status" class="sr-only" role="status" aria-live="polite"></div> 1013 - 1014 - <!-- Card Grid View --> 1015 - <div id="grid-view" style="display: none;"> 1016 - <div id="updated-banner" class="updated-banner" style="display: none;"></div> 1017 - <div id="day-summary" class="day-summary" style="display: none;"></div> 1018 - <div id="agent-groups"></div> 1019 - <div id="empty-state" class="empty-state" style="display: none;"> 1020 - <div class="empty-state-icon">🤖</div> 1021 - <div class="empty-state-text">No agent runs on this day</div> 1022 - <div class="empty-state-hint">Agent runs will appear here when they complete</div> 1023 - </div> 1024 - </div> 1025 - 1026 - <!-- Run List View --> 1027 - <div id="list-view" style="display: none;"> 1028 - <div class="view-header"> 1029 - <button class="back-btn" onclick="showGridView()"> 1030 - <span>←</span> Back 1031 - </button> 1032 - <h2 class="view-title" id="list-view-title"></h2> 1033 - <button class="preview-btn" id="preview-btn" onclick="showPreview()"> 1034 - View Prompt 1035 - </button> 1036 - </div> 1037 - <div class="run-list-header"> 1038 - <span id="list-view-count"></span> 1039 - </div> 1040 - <table class="runs-table"> 1041 - <thead> 1042 - <tr> 1043 - <th class="col-status"></th> 1044 - <th class="col-time">time</th> 1045 - <th class="col-model">model</th> 1046 - <th class="col-provider">provider</th> 1047 - <th class="col-runtime">runtime</th> 1048 - <th class="col-activity" title="Thinking events">💭</th> 1049 - <th class="col-activity" title="Tool calls">🔧</th> 1050 - <th class="col-activity" title="Cost">💰</th> 1051 - <th class="col-facet">facet</th> 1052 - <th class="col-output">output</th> 1053 - <th class="col-prompt">prompt</th> 1054 - </tr> 1055 - </thead> 1056 - <tbody id="runs-tbody"></tbody> 1057 - </table> 1058 - </div> 1059 - 1060 - <!-- Run Detail View --> 1061 - <div id="run-detail-view" style="display: none;"> 1062 - <div class="run-detail-breadcrumb"> 1063 - <a class="crumb-link" id="crumb-agents" href="#" onclick="event.preventDefault(); showGridView()">Agents</a> 1064 - <span class="crumb-sep">/</span> 1065 - <a class="crumb-link" id="crumb-agent" href="#">Agent Name</a> 1066 - <span class="crumb-sep">/</span> 1067 - <span class="crumb-current" id="crumb-run">Run 09:41</span> 1068 - </div> 1069 - <div id="run-detail-header-mount"></div> 1070 - <div id="run-detail-tabs" class="rd-tabs" role="tablist" aria-label="run detail tabs"> 1071 - <button type="button" class="rd-tab active" data-tab="log" id="tab-log" role="tab" aria-selected="true" aria-controls="run-detail-log-pane" tabindex="0">Run Log</button> 1072 - <button type="button" class="rd-tab" data-tab="output" id="tab-output" role="tab" aria-selected="false" aria-controls="run-detail-output-pane" tabindex="-1" style="display: none;">Output</button> 1073 - </div> 1074 - <div id="run-detail-panel" class="rd-panel"> 1075 - <div id="run-detail-log-pane" class="rd-tab-pane active" role="tabpanel" aria-labelledby="tab-log"></div> 1076 - <div id="run-detail-output-pane" class="rd-tab-pane" role="tabpanel" aria-labelledby="tab-output"></div> 1077 - </div> 1078 - </div> 1079 - </div> 1080 - 1081 - <script src="{{ vendor_lib('marked') }}"></script> 1082 - 1083 - <!-- Preview Modal --> 1084 - <div id="preview-modal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="preview-modal-title"> 1085 - <div class="modal-content"> 1086 - <div class="modal-header"> 1087 - <h3 class="modal-title" id="preview-modal-title">Agent Prompt</h3> 1088 - <button class="modal-close" onclick="hidePreview()">&times;</button> 1089 - </div> 1090 - <div class="modal-body"> 1091 - <pre id="preview-modal-content"></pre> 1092 - </div> 1093 - </div> 1094 - </div> 1095 - 1096 - <script> 1097 - (function() { 1098 - // State 1099 - let currentDay = null; 1100 - let currentName = null; 1101 - let currentRunId = null; 1102 - let cachedRunDetail = null; 1103 - let runOutputLoaded = false; 1104 - let runOutputLoading = false; 1105 - let allRuns = []; 1106 - let agentsMeta = {}; 1107 - let facetsMeta = {}; 1108 - let previousPreviewFocus = null; 1109 - let previewFocusTrapHandler = null; 1110 - 1111 - // Get current day from URL path 1112 - function getDayFromUrl() { 1113 - const path = window.location.pathname; 1114 - const match = path.match(/\/app\/agents\/(\d{8})$/); 1115 - return match ? match[1] : null; 1116 - } 1117 - 1118 - // Format timestamp to time string 1119 - function formatTime(ts) { 1120 - if (!ts) return '\u2014'; 1121 - const d = new Date(ts); 1122 - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 1123 - } 1124 - 1125 - // Format runtime 1126 - function formatRuntime(seconds) { 1127 - if (seconds === null || seconds === undefined) return '\u2014'; 1128 - if (seconds < 60) return `${Math.round(seconds)}s`; 1129 - const mins = Math.floor(seconds / 60); 1130 - const secs = Math.round(seconds % 60); 1131 - return `${mins}m ${secs}s`; 1132 - } 1133 - 1134 - // Truncate text for display 1135 - function truncate(text, maxLen = 80) { 1136 - if (!text) return '\u2014'; 1137 - const clean = text.replace(/\s+/g, ' ').trim(); 1138 - if (clean.length <= maxLen) return clean; 1139 - return clean.substring(0, maxLen) + '...'; 1140 - } 1141 - 1142 - // Format cost in USD with exact value in title 1143 - function formatCost(costUSD) { 1144 - if (costUSD === null || costUSD === undefined) { 1145 - return { text: '\u2014', title: '' }; 1146 - } 1147 - const exactUSD = '$' + costUSD.toFixed(4); 1148 - if (costUSD > 0 && costUSD < 0.01) { 1149 - return { text: '<$0.01', title: exactUSD }; 1150 - } 1151 - return { text: '$' + costUSD.toFixed(2), title: exactUSD }; 1152 - } 1153 - 1154 - // Shorten model name for display 1155 - function formatModel(model) { 1156 - if (!model) return '\u2014'; 1157 - // Strip common provider prefixes and date suffixes 1158 - return model 1159 - .replace(/^(models\/|accounts\/[^/]+\/models\/)/, '') 1160 - .replace(/-\d{8}$/, ''); 1161 - } 1162 - 1163 - function formatAgentDisplayName(name) { 1164 - if (!name) return '\u2014'; 1165 - const meta = agentsMeta[name]; 1166 - if (meta && meta.title) return meta.title; 1167 - return name.charAt(0).toUpperCase() + name.slice(1); 1168 - } 1169 - 1170 - // ========================================================================= 1171 - // Grouping & Aggregation (moved from server) 1172 - // ========================================================================= 1173 - 1174 - function groupRunsByAgent(runs) { 1175 - const groups = {}; 1176 - 1177 - for (const run of runs) { 1178 - const name = run.name; 1179 - if (!groups[name]) { 1180 - const meta = agentsMeta[name] || {}; 1181 - groups[name] = { 1182 - name: name, 1183 - title: meta.title || name, 1184 - description: meta.description || null, 1185 - color: meta.color || '#6c757d', 1186 - source: meta.source || 'system', 1187 - app: meta.app || null, 1188 - schedule: meta.schedule || null, 1189 - type: meta.type || null, 1190 - multi_facet: meta.multi_facet || false, 1191 - run_count: 0, 1192 - failed_count: 0, 1193 - thinking_count: 0, 1194 - tool_count: 0, 1195 - total_cost: 0.0, 1196 - facets: new Set(), 1197 - }; 1198 - } 1199 - 1200 - const g = groups[name]; 1201 - g.run_count++; 1202 - if (run.failed) g.failed_count++; 1203 - g.thinking_count += (run.thinking_count || 0); 1204 - g.tool_count += (run.tool_count || 0); 1205 - if (run.cost != null) g.total_cost += run.cost; 1206 - if (run.facet) g.facets.add(run.facet); 1207 - } 1208 - 1209 - return groups; 1210 - } 1211 - 1212 - function organizeGroups(groups) { 1213 - const systemGroups = []; 1214 - const appGroups = {}; 1215 - 1216 - for (const group of Object.values(groups)) { 1217 - if (group.source === 'system') { 1218 - systemGroups.push(group); 1219 - } else { 1220 - const appName = group.app || 'unknown'; 1221 - if (!appGroups[appName]) appGroups[appName] = []; 1222 - appGroups[appName].push(group); 1223 - } 1224 - } 1225 - 1226 - // Sort alphabetically by title 1227 - const sortByTitle = (a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()); 1228 - systemGroups.sort(sortByTitle); 1229 - for (const appName of Object.keys(appGroups)) { 1230 - appGroups[appName].sort(sortByTitle); 1231 - } 1232 - 1233 - return { system: systemGroups, apps: appGroups }; 1234 - } 1235 - 1236 - // ========================================================================= 1237 - // Data Loading 1238 - // ========================================================================= 1239 - 1240 - function hideAllViews() { 1241 - document.getElementById('loading-view').style.display = 'none'; 1242 - document.getElementById('grid-view').style.display = 'none'; 1243 - document.getElementById('list-view').style.display = 'none'; 1244 - document.getElementById('run-detail-view').style.display = 'none'; 1245 - } 1246 - 1247 - function loadUpdatedBanner() { 1248 - const banner = document.getElementById('updated-banner'); 1249 - const now = new Date(); 1250 - const today = String(now.getFullYear()) + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0'); 1251 - if (currentDay !== today || window.selectedFacet) { 1252 - banner.style.display = 'none'; 1253 - return; 1254 - } 1255 - fetch('api/updated-days') 1256 - .then(r => r.json()) 1257 - .then(days => { 1258 - if (!days.length) { 1259 - banner.style.display = 'none'; 1260 - return; 1261 - } 1262 - const links = days.map(d => { 1263 - const label = d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6); 1264 - return `<a href="${d}">${label}</a>`; 1265 - }).join(', '); 1266 - banner.innerHTML = `<div class="updated-banner-title">Days with pending reprocessing</div>${links}`; 1267 - banner.style.display = 'block'; 1268 - }) 1269 - .catch(() => { banner.style.display = 'none'; }); 1270 - } 1271 - 1272 - async function loadAgents() { 1273 - currentDay = getDayFromUrl(); 1274 - if (!currentDay) return; 1275 - 1276 - hideAllViews(); 1277 - document.getElementById('loading-view').style.display = 'block'; 1278 - document.getElementById('agents-status').textContent = ''; 1279 - 1280 - try { 1281 - const response = await fetch(`api/agents/${currentDay}`); 1282 - const data = await response.json(); 1283 - 1284 - allRuns = data.runs || []; 1285 - agentsMeta = data.agents || {}; 1286 - facetsMeta = data.facets || {}; 1287 - 1288 - renderGridView(); 1289 - document.getElementById('agents-status').textContent = 1290 - allRuns.length + ' agent run' + (allRuns.length !== 1 ? 's' : '') + ' loaded'; 1291 - 1292 - // Restore hash-based view after data load 1293 - if (location.hash) { 1294 - handleHashChange(); 1295 - } 1296 - } catch (error) { 1297 - console.error('Error loading agents:', error); 1298 - document.getElementById('loading-view').innerHTML = 1299 - '<div class="empty-state" aria-live="polite">' + 1300 - '<div class="empty-state-icon">⚠️</div>' + 1301 - '<div class="empty-state-text">Unable to load agents</div>' + 1302 - '<div class="empty-state-hint">The server may be temporarily unavailable. Check your connection and try again.</div>' + 1303 - '<button class="back-btn" onclick="loadAgents()" style="margin-top: 1rem;">Try again</button>' + 1304 - '</div>'; 1305 - } 1306 - } 1307 - 1308 - // ========================================================================= 1309 - // Grid View 1310 - // ========================================================================= 1311 - 1312 - function renderGridView() { 1313 - hideAllViews(); 1314 - document.getElementById('grid-view').style.display = 'block'; 1315 - 1316 - const container = document.getElementById('agent-groups'); 1317 - container.innerHTML = ''; 1318 - 1319 - if (allRuns.length === 0) { 1320 - document.getElementById('day-summary').style.display = 'none'; 1321 - document.getElementById('empty-state').style.display = 'block'; 1322 - return; 1323 - } 1324 - 1325 - document.getElementById('empty-state').style.display = 'none'; 1326 - 1327 - // Group and organize 1328 - const groups = groupRunsByAgent(allRuns); 1329 - const organized = organizeGroups(groups); 1330 - 1331 - // Render summary bar 1332 - renderDaySummary(allRuns); 1333 - 1334 - // Render system agents 1335 - if (organized.system.length > 0) { 1336 - container.appendChild(renderSection('system agents', organized.system)); 1337 - } 1338 - 1339 - // Render app agent sections 1340 - const appNames = Object.keys(organized.apps).sort(); 1341 - for (const appName of appNames) { 1342 - const title = appName; 1343 - container.appendChild(renderSection(title, organized.apps[appName])); 1344 - } 1345 - } 1346 - 1347 - function renderDaySummary(runs) { 1348 - const summary = document.getElementById('day-summary'); 1349 - const totalRuns = runs.length; 1350 - const failedRuns = runs.filter(r => r.failed).length; 1351 - let totalCost = 0; 1352 - for (const r of runs) { 1353 - if (r.cost != null) totalCost += r.cost; 1354 - } 1355 - 1356 - const parts = []; 1357 - parts.push(`<span class="summary-item">${totalRuns} run${totalRuns !== 1 ? 's' : ''}</span>`); 1358 - if (failedRuns > 0) { 1359 - parts.push(`<span class="summary-item summary-failed">${failedRuns} failed</span>`); 1360 - } 1361 - if (totalCost > 0) { 1362 - const costInfo = formatCost(totalCost); 1363 - parts.push(`<span class="summary-item" title="${costInfo.title}">💰 ${costInfo.text}</span>`); 1364 - } 1365 - 1366 - summary.innerHTML = parts.join(''); 1367 - summary.style.display = totalRuns > 0 ? 'flex' : 'none'; 1368 - } 1369 - 1370 - // Render a section with cards 1371 - function renderSection(title, agents) { 1372 - const section = document.createElement('div'); 1373 - section.className = 'agent-section'; 1374 - 1375 - const titleEl = document.createElement('h3'); 1376 - titleEl.className = 'section-title'; 1377 - titleEl.textContent = title; 1378 - section.appendChild(titleEl); 1379 - 1380 - const grid = document.createElement('div'); 1381 - grid.className = 'agent-cards'; 1382 - 1383 - for (const agent of agents) { 1384 - grid.appendChild(renderCard(agent)); 1385 - } 1386 - 1387 - section.appendChild(grid); 1388 - return section; 1389 - } 1390 - 1391 - // Render an agent card 1392 - function renderCard(agent) { 1393 - const card = document.createElement('div'); 1394 - card.className = 'agent-card'; 1395 - card.style.borderLeftColor = agent.color; 1396 - card.onclick = () => showRunList(agent.name); 1397 - card.setAttribute('role', 'button'); 1398 - card.setAttribute('tabindex', '0'); 1399 - card.setAttribute('aria-label', agent.title); 1400 - card.addEventListener('keydown', e => { 1401 - if (e.key !== 'Enter' && e.key !== ' ') return; 1402 - e.preventDefault(); 1403 - showRunList(agent.name); 1404 - }); 1405 - 1406 - // Header with title and badges 1407 - const header = document.createElement('div'); 1408 - header.className = 'agent-card-header'; 1409 - 1410 - const title = document.createElement('h4'); 1411 - title.className = 'agent-card-title'; 1412 - title.textContent = agent.title; 1413 - header.appendChild(title); 1414 - 1415 - const badges = document.createElement('div'); 1416 - badges.className = 'agent-card-badges'; 1417 - 1418 - const successCount = agent.run_count - agent.failed_count; 1419 - if (successCount > 0) { 1420 - const successBadge = document.createElement('span'); 1421 - successBadge.className = 'badge badge-success'; 1422 - successBadge.textContent = '✓ ' + successCount; 1423 - badges.appendChild(successBadge); 1424 - } 1425 - 1426 - if (agent.failed_count > 0) { 1427 - const failBadge = document.createElement('span'); 1428 - failBadge.className = 'badge badge-failed'; 1429 - failBadge.textContent = '✗ ' + agent.failed_count; 1430 - badges.appendChild(failBadge); 1431 - } 1432 - 1433 - header.appendChild(badges); 1434 - card.appendChild(header); 1435 - 1436 - // Description 1437 - if (agent.description) { 1438 - const desc = document.createElement('p'); 1439 - desc.className = 'agent-card-description'; 1440 - desc.textContent = agent.description; 1441 - desc.title = agent.description; 1442 - card.appendChild(desc); 1443 - } 1444 - 1445 - // Capability badges 1446 - const caps = []; 1447 - if (agent.schedule) caps.push(agent.schedule); 1448 - if (agent.type === 'cogitate') caps.push('tools'); 1449 - if (agent.type === 'generate') caps.push('output'); 1450 - if (agent.multi_facet) caps.push('per-facet'); 1451 - 1452 - if (caps.length > 0) { 1453 - const capsRow = document.createElement('div'); 1454 - capsRow.className = 'agent-card-caps'; 1455 - for (const cap of caps) { 1456 - const pill = document.createElement('span'); 1457 - pill.className = 'cap-badge'; 1458 - pill.textContent = cap; 1459 - capsRow.appendChild(pill); 1460 - } 1461 - card.appendChild(capsRow); 1462 - } 1463 - 1464 - // Activity row: counts on left, facet dots on right 1465 - const hasActivity = agent.thinking_count > 0 || agent.tool_count > 0 || agent.total_cost > 0; 1466 - const facetNames = Array.from(agent.facets); 1467 - const hasFacetDots = facetNames.length > 0; 1468 - 1469 - if (hasActivity || hasFacetDots) { 1470 - const activity = document.createElement('div'); 1471 - activity.className = 'agent-card-activity'; 1472 - 1473 - const counts = document.createElement('div'); 1474 - counts.className = 'activity-counts'; 1475 - 1476 - if (agent.thinking_count > 0) { 1477 - const thinking = document.createElement('span'); 1478 - thinking.className = 'activity-item'; 1479 - thinking.innerHTML = `💭 ${agent.thinking_count}`; 1480 - thinking.title = `${agent.thinking_count} thinking events`; 1481 - counts.appendChild(thinking); 1482 - } 1483 - 1484 - if (agent.tool_count > 0) { 1485 - const tools = document.createElement('span'); 1486 - tools.className = 'activity-item'; 1487 - tools.innerHTML = `🔧 ${agent.tool_count}`; 1488 - tools.title = `${agent.tool_count} tool calls`; 1489 - counts.appendChild(tools); 1490 - } 1491 - 1492 - if (agent.total_cost > 0) { 1493 - const costInfo = formatCost(agent.total_cost); 1494 - const cost = document.createElement('span'); 1495 - cost.className = 'activity-item'; 1496 - cost.innerHTML = `💰 ${costInfo.text}`; 1497 - cost.title = costInfo.title; 1498 - counts.appendChild(cost); 1499 - } 1500 - 1501 - activity.appendChild(counts); 1502 - 1503 - if (hasFacetDots) { 1504 - const dots = document.createElement('div'); 1505 - dots.className = 'facet-dots'; 1506 - 1507 - for (const facetName of facetNames) { 1508 - const facet = facetsMeta[facetName]; 1509 - const dot = document.createElement('span'); 1510 - dot.className = 'facet-dot'; 1511 - dot.style.backgroundColor = (facet && facet.color) || '#999'; 1512 - const label = (facet && facet.title) || facetName; 1513 - dot.setAttribute('aria-label', label); 1514 - dot.style.fontSize = '7px'; 1515 - dot.style.color = 'white'; 1516 - dot.style.fontWeight = 'bold'; 1517 - dot.style.lineHeight = '10px'; 1518 - dot.style.textAlign = 'center'; 1519 - dot.appendChild(document.createTextNode(label.charAt(0).toUpperCase())); 1520 - dots.appendChild(dot); 1521 - } 1522 - 1523 - activity.appendChild(dots); 1524 - } 1525 - 1526 - card.appendChild(activity); 1527 - } 1528 - 1529 - return card; 1530 - } 1531 - 1532 - // ========================================================================= 1533 - // Run List View 1534 - // ========================================================================= 1535 - 1536 - function showRunList(name) { 1537 - currentName = name; 1538 - currentRunId = null; 1539 - cachedRunDetail = null; 1540 - runOutputLoaded = false; 1541 - runOutputLoading = false; 1542 - 1543 - // Update hash without re-triggering if already correct 1544 - const encodedName = encodeURIComponent(name); 1545 - if (location.hash.slice(1) !== encodedName) { 1546 - location.hash = encodedName; 1547 - return; // hashchange will call us back 1548 - } 1549 - 1550 - hideAllViews(); 1551 - document.getElementById('list-view').style.display = 'block'; 1552 - 1553 - // Filter runs client-side 1554 - const runs = allRuns.filter(r => r.name === name); 1555 - const meta = agentsMeta[name] || {}; 1556 - const title = meta.title || name; 1557 - const failedCount = runs.filter(r => r.failed).length; 1558 - 1559 - document.getElementById('list-view-title').textContent = title; 1560 - document.getElementById('list-view-count').textContent = 1561 - `${runs.length} run${runs.length !== 1 ? 's' : ''}` + 1562 - (failedCount > 0 ? ` (${failedCount} failed)` : ''); 1563 - 1564 - renderRunList(runs); 1565 - } 1566 - 1567 - // Render the run list table 1568 - function renderRunList(runs) { 1569 - const tbody = document.getElementById('runs-tbody'); 1570 - tbody.innerHTML = ''; 1571 - 1572 - if (runs.length === 0) { 1573 - tbody.innerHTML = '<tr><td colspan="11" class="run-loading">No runs found</td></tr>'; 1574 - return; 1575 - } 1576 - 1577 - for (const run of runs) { 1578 - const row = document.createElement('tr'); 1579 - row.dataset.runId = run.id; 1580 - row.onclick = () => showRunDetail(run.id, run.name); 1581 - row.setAttribute('tabindex', '0'); 1582 - row.addEventListener('keydown', e => { 1583 - if (e.key === 'Enter') { 1584 - showRunDetail(run.id, run.name); 1585 - } else if (e.key === 'ArrowDown') { 1586 - e.preventDefault(); 1587 - const next = row.nextElementSibling; 1588 - if (next) next.focus(); 1589 - } else if (e.key === 'ArrowUp') { 1590 - e.preventDefault(); 1591 - const prev = row.previousElementSibling; 1592 - if (prev) prev.focus(); 1593 - } else if (e.key === 'Home') { 1594 - e.preventDefault(); 1595 - const tbody = row.closest('tbody'); 1596 - if (tbody && tbody.firstElementChild) tbody.firstElementChild.focus(); 1597 - } else if (e.key === 'End') { 1598 - e.preventDefault(); 1599 - const tbody = row.closest('tbody'); 1600 - if (tbody && tbody.lastElementChild) tbody.lastElementChild.focus(); 1601 - } 1602 - }); 1603 - 1604 - // Status 1605 - const statusCell = document.createElement('td'); 1606 - statusCell.className = 'col-status'; 1607 - const statusIcon = document.createElement('span'); 1608 - statusIcon.className = 'status-icon'; 1609 - if (run.status === 'running') { 1610 - statusIcon.className += ' status-running'; 1611 - statusIcon.textContent = '\u23f3'; 1612 - statusIcon.title = 'Running'; 1613 - } else if (run.failed) { 1614 - statusIcon.className += ' status-failed'; 1615 - statusIcon.textContent = '\u2717'; 1616 - statusIcon.title = 'Failed'; 1617 - } else { 1618 - statusIcon.className += ' status-ok'; 1619 - statusIcon.textContent = '\u2713'; 1620 - statusIcon.title = 'Completed'; 1621 - } 1622 - statusCell.appendChild(statusIcon); 1623 - row.appendChild(statusCell); 1624 - 1625 - // Time 1626 - const timeCell = document.createElement('td'); 1627 - timeCell.className = 'col-time'; 1628 - timeCell.textContent = formatTime(run.start); 1629 - row.appendChild(timeCell); 1630 - 1631 - // Model 1632 - const modelCell = document.createElement('td'); 1633 - modelCell.className = 'col-model'; 1634 - const modelSpan = document.createElement('span'); 1635 - modelSpan.className = 'model-name'; 1636 - modelSpan.textContent = formatModel(run.model); 1637 - modelSpan.title = run.model || ''; 1638 - modelCell.appendChild(modelSpan); 1639 - row.appendChild(modelCell); 1640 - 1641 - // Provider 1642 - const providerCell = document.createElement('td'); 1643 - providerCell.className = 'col-provider'; 1644 - const providerSpan = document.createElement('span'); 1645 - providerSpan.className = 'provider-name'; 1646 - providerSpan.textContent = run.provider || '\u2014'; 1647 - providerCell.appendChild(providerSpan); 1648 - row.appendChild(providerCell); 1649 - 1650 - // Runtime 1651 - const runtimeCell = document.createElement('td'); 1652 - runtimeCell.className = 'col-runtime'; 1653 - runtimeCell.textContent = formatRuntime(run.runtime_seconds); 1654 - row.appendChild(runtimeCell); 1655 - 1656 - // Thinking count 1657 - const thinkingCell = document.createElement('td'); 1658 - thinkingCell.className = 'col-activity'; 1659 - thinkingCell.textContent = run.thinking_count || 0; 1660 - row.appendChild(thinkingCell); 1661 - 1662 - // Tool count 1663 - const toolCell = document.createElement('td'); 1664 - toolCell.className = 'col-activity'; 1665 - toolCell.textContent = run.tool_count || 0; 1666 - row.appendChild(toolCell); 1667 - 1668 - // Cost 1669 - const costCell = document.createElement('td'); 1670 - costCell.className = 'col-activity'; 1671 - const costInfo = formatCost(run.cost); 1672 - costCell.textContent = costInfo.text; 1673 - if (costInfo.title) costCell.title = costInfo.title; 1674 - row.appendChild(costCell); 1675 - 1676 - // Facet 1677 - const facetCell = document.createElement('td'); 1678 - facetCell.className = 'col-facet'; 1679 - if (run.facet) { 1680 - const facet = facetsMeta[run.facet]; 1681 - const tag = document.createElement('span'); 1682 - tag.className = 'facet-tag'; 1683 - if (facet && facet.color) { 1684 - const dot = document.createElement('span'); 1685 - dot.className = 'facet-dot'; 1686 - dot.style.backgroundColor = facet.color; 1687 - dot.setAttribute('aria-label', facet.title || run.facet); 1688 - tag.appendChild(dot); 1689 - } 1690 - tag.appendChild(document.createTextNode((facet && facet.title) || run.facet)); 1691 - facetCell.appendChild(tag); 1692 - } else { 1693 - facetCell.textContent = '\u2014'; 1694 - } 1695 - row.appendChild(facetCell); 1696 - 1697 - // Output file 1698 - const outputCell = document.createElement('td'); 1699 - outputCell.className = 'col-output'; 1700 - if (run.output_file) { 1701 - outputCell.textContent = run.output_file.split('/').pop(); 1702 - outputCell.title = run.output_file; 1703 - } else { 1704 - outputCell.textContent = '\u2014'; 1705 - } 1706 - row.appendChild(outputCell); 1707 - 1708 - // Prompt + error preview 1709 - const promptCell = document.createElement('td'); 1710 - promptCell.className = 'col-prompt'; 1711 - const promptSpan = document.createElement('span'); 1712 - promptSpan.className = 'prompt-snippet'; 1713 - promptSpan.textContent = truncate(run.prompt); 1714 - promptSpan.title = run.prompt || ''; 1715 - promptCell.appendChild(promptSpan); 1716 - 1717 - if (run.failed && run.error_message) { 1718 - const errorDiv = document.createElement('div'); 1719 - errorDiv.className = 'error-preview'; 1720 - errorDiv.textContent = run.error_message; 1721 - errorDiv.title = run.error_message; 1722 - promptCell.appendChild(errorDiv); 1723 - } 1724 - 1725 - row.appendChild(promptCell); 1726 - 1727 - tbody.appendChild(row); 1728 - } 1729 - } 1730 - 1731 - async function showRunDetail(runId, agentName) { 1732 - currentName = agentName; 1733 - currentRunId = runId; 1734 - cachedRunDetail = null; 1735 - runOutputLoaded = false; 1736 - runOutputLoading = false; 1737 - 1738 - const targetHash = `${encodeURIComponent(agentName)}/${encodeURIComponent(runId)}`; 1739 - if (location.hash.slice(1) !== targetHash) { 1740 - location.hash = targetHash; 1741 - return; 1742 - } 1743 - 1744 - hideAllViews(); 1745 - document.getElementById('run-detail-view').style.display = 'block'; 1746 - 1747 - const crumbAgent = document.getElementById('crumb-agent'); 1748 - crumbAgent.textContent = formatAgentDisplayName(agentName); 1749 - crumbAgent.onclick = (e) => { 1750 - e.preventDefault() 1751 - showRunList(agentName) 1752 - }; 1753 - document.getElementById('crumb-run').textContent = 'Run ...'; 1754 - 1755 - document.getElementById('run-detail-header-mount').innerHTML = 1756 - '<div class="run-loading"><div class="spinner"></div><div>Loading run details...</div></div>'; 1757 - document.getElementById('run-detail-log-pane').innerHTML = 1758 - '<div class="run-loading"><div class="spinner"></div><div>Loading run log...</div></div>'; 1759 - document.getElementById('run-detail-output-pane').innerHTML = ''; 1760 - document.getElementById('tab-output').style.display = 'none'; 1761 - activateRunDetailTab('log'); 1762 - 1763 - try { 1764 - const response = await fetch(`api/run/${encodeURIComponent(runId)}`); 1765 - const data = await response.json(); 1766 - 1767 - if (currentRunId !== runId) return; 1768 - 1769 - if (response.status === 202) { 1770 - renderRunDetailMessage(data.error || 'Run is still in progress'); 1771 - return; 1772 - } 1773 - 1774 - if (data.error) { 1775 - renderRunDetailMessage(`Unable to load run details — ${data.error}. Go back and select the run again.`); 1776 - return; 1777 - } 1778 - 1779 - renderRunDetail(data, agentName); 1780 - } catch (error) { 1781 - if (currentRunId !== runId) return; 1782 - renderRunDetailMessage('Unable to load run details. The server may be unreachable — check your connection and try again.'); 1783 - } 1784 - } 1785 - 1786 - function renderRunDetailMessage(message) { 1787 - document.getElementById('run-detail-header-mount').innerHTML = ''; 1788 - const logPane = document.getElementById('run-detail-log-pane'); 1789 - logPane.innerHTML = ''; 1790 - const msg = document.createElement('div'); 1791 - msg.className = 'run-loading'; 1792 - msg.setAttribute('aria-live', 'polite'); 1793 - msg.textContent = message; 1794 - logPane.appendChild(msg); 1795 - document.getElementById('run-detail-output-pane').innerHTML = ''; 1796 - document.getElementById('tab-output').style.display = 'none'; 1797 - activateRunDetailTab('log'); 1798 - } 1799 - 1800 - function renderRunDetail(data, agentName) { 1801 - cachedRunDetail = data; 1802 - runOutputLoaded = false; 1803 - runOutputLoading = false; 1804 - 1805 - const displayName = formatAgentDisplayName(agentName); 1806 - const crumbAgent = document.getElementById('crumb-agent'); 1807 - crumbAgent.textContent = displayName; 1808 - crumbAgent.onclick = (e) => { 1809 - e.preventDefault() 1810 - showRunList(agentName) 1811 - }; 1812 - document.getElementById('crumb-run').textContent = `Run ${formatTime(data.start)}`; 1813 - 1814 - const headerMount = document.getElementById('run-detail-header-mount'); 1815 - headerMount.innerHTML = ''; 1816 - headerMount.appendChild(renderRunDetailHeader(data)); 1817 - 1818 - const logPane = document.getElementById('run-detail-log-pane'); 1819 - logPane.innerHTML = ''; 1820 - const events = Array.isArray(data.events) ? data.events : []; 1821 - if (events.length > 0) { 1822 - logPane.appendChild(renderEventTimeline(events)); 1823 - } else { 1824 - const empty = document.createElement('div'); 1825 - empty.className = 'run-loading'; 1826 - empty.textContent = 'No log events recorded for this run'; 1827 - logPane.appendChild(empty); 1828 - } 1829 - 1830 - const outputTab = document.getElementById('tab-output'); 1831 - outputTab.style.display = data.output_file ? 'inline-flex' : 'none'; 1832 - document.getElementById('run-detail-output-pane').innerHTML = ''; 1833 - activateRunDetailTab('log'); 1834 - } 1835 - 1836 - function renderRunDetailHeader(data) { 1837 - const card = document.createElement('div'); 1838 - card.className = 'run-detail-header-card'; 1839 - 1840 - const titleRow = document.createElement('div'); 1841 - titleRow.className = 'run-detail-title-row'; 1842 - 1843 - const status = document.createElement('span'); 1844 - status.className = 'run-status-badge'; 1845 - if (data.status === 'running') { 1846 - status.classList.add('status-running'); 1847 - status.textContent = 'running'; 1848 - } else if (data.failed) { 1849 - status.classList.add('status-failed'); 1850 - status.textContent = 'failed'; 1851 - } else { 1852 - status.classList.add('status-completed'); 1853 - status.textContent = 'completed'; 1854 - } 1855 - titleRow.appendChild(status); 1856 - 1857 - const title = document.createElement('h2'); 1858 - title.className = 'run-detail-title'; 1859 - title.textContent = `Run ${formatTime(data.start)}`; 1860 - titleRow.appendChild(title); 1861 - 1862 - if (data.facet) { 1863 - const facet = facetsMeta[data.facet]; 1864 - const tag = document.createElement('span'); 1865 - tag.className = 'facet-tag'; 1866 - if (facet && facet.color) { 1867 - const dot = document.createElement('span'); 1868 - dot.className = 'facet-dot'; 1869 - dot.style.backgroundColor = facet.color; 1870 - dot.setAttribute('aria-label', (facet && facet.title) || data.facet); 1871 - tag.appendChild(dot); 1872 - } 1873 - tag.appendChild(document.createTextNode((facet && facet.title) || data.facet)); 1874 - titleRow.appendChild(tag); 1875 - } 1876 - 1877 - card.appendChild(titleRow); 1878 - 1879 - const subtitle = document.createElement('div'); 1880 - subtitle.className = 'run-detail-subtitle'; 1881 - subtitle.textContent = formatAgentDisplayName(data.name || currentName); 1882 - card.appendChild(subtitle); 1883 - 1884 - if (data.prompt) { 1885 - const prompt = document.createElement('div'); 1886 - prompt.className = 'run-detail-prompt'; 1887 - prompt.textContent = data.prompt; 1888 - card.appendChild(prompt); 1889 - } 1890 - 1891 - const grid = document.createElement('div'); 1892 - grid.className = 'run-meta-grid'; 1893 - 1894 - function addMetaItem(label, value, titleText = '') { 1895 - const item = document.createElement('div'); 1896 - item.className = 'run-meta-item'; 1897 - const labelEl = document.createElement('span'); 1898 - labelEl.className = 'meta-label'; 1899 - labelEl.textContent = label; 1900 - const valueEl = document.createElement('span'); 1901 - valueEl.className = 'meta-value'; 1902 - valueEl.textContent = value; 1903 - if (titleText) valueEl.title = titleText; 1904 - item.appendChild(labelEl); 1905 - item.appendChild(valueEl); 1906 - grid.appendChild(item); 1907 - } 1908 - 1909 - addMetaItem('started', formatTimestamp(data.start) || '\u2014'); 1910 - addMetaItem('runtime', formatRuntime(data.runtime_seconds)); 1911 - addMetaItem('model', formatModel(data.model), data.model || ''); 1912 - addMetaItem('provider', data.provider || '\u2014'); 1913 - addMetaItem('thinking', String(data.thinking_count || 0)); 1914 - addMetaItem('tools', String(data.tool_count || 0)); 1915 - const costInfo = formatCost(data.cost); 1916 - addMetaItem('cost', costInfo.text, costInfo.title); 1917 - 1918 - card.appendChild(grid); 1919 - return card; 1920 - } 1921 - 1922 - function activateRunDetailTab(tabId) { 1923 - const tabs = document.querySelectorAll('#run-detail-tabs .rd-tab'); 1924 - for (const tab of tabs) { 1925 - tab.classList.toggle('active', tab.dataset.tab === tabId); 1926 - tab.setAttribute('aria-selected', tab.dataset.tab === tabId ? 'true' : 'false'); 1927 - tab.setAttribute('tabindex', tab.dataset.tab === tabId ? '0' : '-1'); 1928 - } 1929 - 1930 - document.getElementById('run-detail-log-pane').classList.toggle('active', tabId === 'log'); 1931 - document.getElementById('run-detail-output-pane').classList.toggle('active', tabId === 'output'); 1932 - 1933 - if (tabId === 'output') { 1934 - loadRunOutput(); 1935 - } 1936 - } 1937 - 1938 - async function loadRunOutput() { 1939 - if ( 1940 - !cachedRunDetail || 1941 - !cachedRunDetail.day || 1942 - !cachedRunDetail.output_file || 1943 - runOutputLoaded || 1944 - runOutputLoading 1945 - ) { 1946 - return; 1947 - } 1948 - 1949 - runOutputLoading = true; 1950 - const pane = document.getElementById('run-detail-output-pane'); 1951 - pane.innerHTML = '<div class="run-loading"><div class="spinner"></div><div>Loading output...</div></div>'; 1952 - 1953 - const encodedFilename = cachedRunDetail.output_file 1954 - .split('/') 1955 - .map(part => encodeURIComponent(part)) 1956 - .join('/'); 1957 - 1958 - try { 1959 - const response = await fetch(`api/output/${cachedRunDetail.day}/${encodedFilename}`); 1960 - const data = await response.json(); 1961 - 1962 - if (data.error) { 1963 - pane.innerHTML = `<div class="run-loading" aria-live="polite">Unable to load output — ${escapeHtml(data.error)}. Try switching tabs or reloading the page.</div>`; 1964 - return; 1965 - } 1966 - 1967 - const container = document.createElement('div'); 1968 - container.className = 'run-output-content'; 1969 - 1970 - if (data.format === 'md') { 1971 - const markdown = document.createElement('div'); 1972 - markdown.className = 'rendered-markdown'; 1973 - markdown.innerHTML = sanitizeHtml(marked.parse(data.content, { breaks: true, gfm: true })); 1974 - container.appendChild(markdown); 1975 - } else { 1976 - const pre = document.createElement('pre'); 1977 - if (data.format === 'json') { 1978 - try { 1979 - pre.textContent = JSON.stringify(JSON.parse(data.content), null, 2); 1980 - } catch (e) { 1981 - pre.textContent = data.content; 1982 - } 1983 - } else { 1984 - pre.textContent = data.content; 1985 - } 1986 - container.appendChild(pre); 1987 - } 1988 - 1989 - pane.innerHTML = ''; 1990 - pane.appendChild(container); 1991 - runOutputLoaded = true; 1992 - } catch (error) { 1993 - pane.innerHTML = '<div class="run-loading" aria-live="polite">Unable to load output file. The server may be unreachable — check your connection and try again.</div>'; 1994 - } finally { 1995 - runOutputLoading = false; 1996 - } 1997 - } 1998 - 1999 - // Format a millisecond timestamp to HH:MM:SS 2000 - function formatTimestamp(ts) { 2001 - if (!ts) return ''; 2002 - const d = new Date(ts); 2003 - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 2004 - } 2005 - 2006 - // Escape HTML to prevent XSS 2007 - function escapeHtml(text) { 2008 - const div = document.createElement('div'); 2009 - div.textContent = text; 2010 - return div.innerHTML; 2011 - } 2012 - 2013 - // Sanitize HTML to allow only safe tags and attributes 2014 - function sanitizeHtml(html) { 2015 - const doc = new DOMParser().parseFromString(html, 'text/html'); 2016 - const allowedTags = new Set([ 2017 - 'p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'a', 2018 - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'br', 'hr', 2019 - 'table', 'thead', 'tbody', 'tr', 'th', 'td' 2020 - ]); 2021 - const allowedAttributes = { 2022 - a: new Set(['href']) 2023 - }; 2024 - 2025 - Array.from(doc.body.querySelectorAll('*')).forEach(function(el) { 2026 - const tagName = el.tagName.toLowerCase(); 2027 - if (!allowedTags.has(tagName)) { 2028 - el.replaceWith(doc.createTextNode(el.textContent || '')); 2029 - return; 2030 - } 2031 - 2032 - Array.from(el.attributes).forEach(function(attr) { 2033 - if (!allowedAttributes[tagName] || !allowedAttributes[tagName].has(attr.name)) { 2034 - el.removeAttribute(attr.name); 2035 - } 2036 - }); 2037 - 2038 - if (tagName === 'a') { 2039 - const href = el.getAttribute('href'); 2040 - if (!href || !/^(https?:\/\/|mailto:)/.test(href)) { 2041 - el.removeAttribute('href'); 2042 - } 2043 - el.setAttribute('target', '_blank'); 2044 - el.setAttribute('rel', 'noopener noreferrer'); 2045 - } 2046 - }); 2047 - 2048 - return doc.body.innerHTML; 2049 - } 2050 - 2051 - // Render agent events as a rich interactive timeline 2052 - function renderEventTimeline(events) { 2053 - const timeline = document.createElement('div'); 2054 - timeline.className = 'event-timeline'; 2055 - 2056 - // First pass: index tool_end events by call_id for pairing 2057 - const toolEnds = {}; 2058 - for (const event of events) { 2059 - if (event.event === 'tool_end' && event.call_id) { 2060 - toolEnds[event.call_id] = event; 2061 - } 2062 - } 2063 - 2064 - // Second pass: render in chronological order 2065 - for (const event of events) { 2066 - const type = event.event; 2067 - if (!type) continue; 2068 - 2069 - // Skip request/start events (metadata, shown in header already) 2070 - if (type === 'request' || type === 'start') continue; 2071 - 2072 - // Skip tool_end with call_id (rendered with its tool_start) 2073 - if (type === 'tool_end' && event.call_id && toolEnds[event.call_id]) continue; 2074 - 2075 - if (type === 'thinking') { 2076 - timeline.appendChild(renderThinkingEvent(event)); 2077 - } else if (type === 'tool_start') { 2078 - const endEvent = event.call_id ? toolEnds[event.call_id] : null; 2079 - timeline.appendChild(renderToolEvent(event, endEvent || null)); 2080 - } else if (type === 'tool_end') { 2081 - // Orphaned tool_end (no call_id or unmatched) 2082 - timeline.appendChild(renderToolEvent(null, event)); 2083 - } else if (type === 'error') { 2084 - timeline.appendChild(renderErrorEvent(event)); 2085 - } else if (type === 'info') { 2086 - timeline.appendChild(renderInfoEvent(event)); 2087 - } else if (type === 'finish') { 2088 - timeline.appendChild(renderFinishEvent(event)); 2089 - } else if (type === 'agent_updated') { 2090 - timeline.appendChild(renderFlowEvent('Switched to agent: ' + (event.agent || 'unknown'), event.ts)); 2091 - } else if (type === 'continue') { 2092 - timeline.appendChild(renderFlowEvent('Continued in agent: ' + (event.to || 'unknown'), event.ts)); 2093 - } 2094 - } 2095 - 2096 - return timeline; 2097 - } 2098 - 2099 - function renderThinkingEvent(event) { 2100 - const block = document.createElement('div'); 2101 - block.className = 'event-block event-thinking'; 2102 - 2103 - const text = event.summary || event.content || ''; 2104 - block.innerHTML = 2105 - '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2106 - '<span class="event-label">thinking</span>' + 2107 - (text ? '<div class="thinking-text">' + escapeHtml(text) + '</div>' : ''); 2108 - 2109 - return block; 2110 - } 2111 - 2112 - function renderToolEvent(startEvent, endEvent) { 2113 - const block = document.createElement('div'); 2114 - block.className = 'event-block event-tool'; 2115 - 2116 - const toolName = (startEvent && startEvent.tool) || (endEvent && endEvent.tool) || 'unknown'; 2117 - const ts = (startEvent && startEvent.ts) || (endEvent && endEvent.ts) || 0; 2118 - const args = (startEvent && startEvent.args) || (endEvent && endEvent.args) || null; 2119 - const result = endEvent && endEvent.result; 2120 - const hasDetails = args || result; 2121 - const incomplete = startEvent && !endEvent; 2122 - 2123 - // Header line with tool name and toggle 2124 - const headerLine = document.createElement('div'); 2125 - headerLine.innerHTML = 2126 - '<span class="event-time">' + escapeHtml(formatTimestamp(ts)) + '</span>' + 2127 - '<span class="event-label">tool</span> ' + 2128 - '<span class="tool-name">' + escapeHtml(toolName) + '</span>' + 2129 - (incomplete ? '<span class="tool-incomplete-badge">did not complete</span>' : ''); 2130 - 2131 - if (hasDetails) { 2132 - const toggle = document.createElement('button'); 2133 - toggle.className = 'tool-toggle'; 2134 - toggle.textContent = 'show'; 2135 - toggle.onclick = function(e) { 2136 - e.stopPropagation(); 2137 - const details = block.querySelector('.tool-details'); 2138 - if (details.style.display === 'none') { 2139 - details.style.display = 'block'; 2140 - toggle.textContent = 'hide'; 2141 - } else { 2142 - details.style.display = 'none'; 2143 - toggle.textContent = 'show'; 2144 - } 2145 - }; 2146 - headerLine.appendChild(toggle); 2147 - } 2148 - 2149 - block.appendChild(headerLine); 2150 - 2151 - // Collapsible details section 2152 - if (hasDetails || incomplete) { 2153 - const details = document.createElement('div'); 2154 - details.className = 'tool-details'; 2155 - details.style.display = 'none'; 2156 - 2157 - if (args && Object.keys(args).length > 0) { 2158 - const argsLabel = document.createElement('div'); 2159 - argsLabel.className = 'tool-section-label'; 2160 - argsLabel.textContent = 'args'; 2161 - details.appendChild(argsLabel); 2162 - const argsPre = document.createElement('pre'); 2163 - argsPre.textContent = JSON.stringify(args, null, 2); 2164 - details.appendChild(argsPre); 2165 - } 2166 - 2167 - if (result) { 2168 - const resultLabel = document.createElement('div'); 2169 - resultLabel.className = 'tool-section-label'; 2170 - resultLabel.textContent = 'result'; 2171 - details.appendChild(resultLabel); 2172 - const resultPre = document.createElement('pre'); 2173 - resultPre.textContent = result; 2174 - details.appendChild(resultPre); 2175 - } 2176 - 2177 - if (incomplete) { 2178 - const note = document.createElement('div'); 2179 - note.className = 'tool-incomplete'; 2180 - note.textContent = 'Tool call did not complete'; 2181 - details.appendChild(note); 2182 - } 2183 - 2184 - block.appendChild(details); 2185 - } 2186 - 2187 - return block; 2188 - } 2189 - 2190 - function renderErrorEvent(event) { 2191 - const block = document.createElement('div'); 2192 - block.className = 'event-block event-error'; 2193 - 2194 - let html = 2195 - '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2196 - '<span class="event-label">error</span> ' + 2197 - '<span class="error-message">' + escapeHtml(event.error || 'Unknown error') + '</span>'; 2198 - 2199 - if (event.trace) { 2200 - html += '<div class="error-trace"><pre>' + escapeHtml(event.trace) + '</pre></div>'; 2201 - } 2202 - 2203 - block.innerHTML = html; 2204 - return block; 2205 - } 2206 - 2207 - function renderInfoEvent(event) { 2208 - const block = document.createElement('div'); 2209 - block.className = 'event-block event-info'; 2210 - block.innerHTML = 2211 - '<span class="event-time">' + escapeHtml(formatTimestamp(event.ts)) + '</span>' + 2212 - escapeHtml(event.message || ''); 2213 - return block; 2214 - } 2215 - 2216 - function renderFinishEvent(event) { 2217 - const block = document.createElement('div'); 2218 - block.className = 'event-block event-finish'; 2219 - 2220 - const timeSpan = document.createElement('span'); 2221 - timeSpan.className = 'event-time'; 2222 - timeSpan.textContent = formatTimestamp(event.ts); 2223 - block.appendChild(timeSpan); 2224 - 2225 - const label = document.createElement('span'); 2226 - label.className = 'event-label'; 2227 - label.textContent = 'result'; 2228 - block.appendChild(label); 2229 - 2230 - if (event.result && event.result.trim()) { 2231 - const resultDiv = document.createElement('div'); 2232 - resultDiv.className = 'finish-result'; 2233 - resultDiv.innerHTML = sanitizeHtml(marked.parse(event.result, { breaks: true, gfm: true })); 2234 - block.appendChild(resultDiv); 2235 - } else { 2236 - const empty = document.createElement('div'); 2237 - empty.className = 'finish-empty'; 2238 - empty.textContent = 'No output'; 2239 - block.appendChild(empty); 2240 - } 2241 - 2242 - return block; 2243 - } 2244 - 2245 - function renderFlowEvent(text, ts) { 2246 - const block = document.createElement('div'); 2247 - block.className = 'event-block event-flow'; 2248 - block.innerHTML = 2249 - '<span class="event-time">' + escapeHtml(formatTimestamp(ts)) + '</span>' + 2250 - '<span class="flow-label">' + escapeHtml(text) + '</span>'; 2251 - return block; 2252 - } 2253 - 2254 - // ========================================================================= 2255 - // Navigation & Preview Modal 2256 - // ========================================================================= 2257 - 2258 - window.loadAgents = loadAgents; 2259 - 2260 - window.showGridView = function() { 2261 - currentName = null; 2262 - currentRunId = null; 2263 - cachedRunDetail = null; 2264 - runOutputLoaded = false; 2265 - runOutputLoading = false; 2266 - if (location.hash) { 2267 - location.hash = ''; 2268 - return; 2269 - } 2270 - handleHashChange(); 2271 - }; 2272 - 2273 - // Hash-based routing 2274 - window.addEventListener('hashchange', handleHashChange); 2275 - 2276 - function handleHashChange() { 2277 - const hash = location.hash.startsWith('#') ? location.hash.slice(1) : location.hash; 2278 - if (!hash) { 2279 - renderGridView(); 2280 - return; 2281 - } 2282 - 2283 - try { 2284 - const slashIndex = hash.indexOf('/'); 2285 - if (slashIndex === -1) { 2286 - showRunList(decodeURIComponent(hash)); 2287 - return; 2288 - } 2289 - 2290 - const namePart = hash.slice(0, slashIndex); 2291 - const runPart = hash.slice(slashIndex + 1); 2292 - if (!namePart || !runPart) { 2293 - renderGridView(); 2294 - return; 2295 - } 2296 - 2297 - showRunDetail(decodeURIComponent(runPart), decodeURIComponent(namePart)); 2298 - } catch (_error) { 2299 - renderGridView(); 2300 - } 2301 - } 2302 - 2303 - document.getElementById('run-detail-tabs').addEventListener('click', function(e) { 2304 - const tab = e.target.closest('.rd-tab'); 2305 - if (!tab) return; 2306 - activateRunDetailTab(tab.dataset.tab); 2307 - }); 2308 - 2309 - document.getElementById('run-detail-tabs').addEventListener('keydown', function(e) { 2310 - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; 2311 - e.preventDefault(); 2312 - const visibleTabs = Array.from(this.querySelectorAll('.rd-tab')).filter(t => t.offsetParent !== null); 2313 - if (visibleTabs.length === 0) return; 2314 - const currentIndex = visibleTabs.indexOf(document.activeElement); 2315 - if (currentIndex === -1) return; 2316 - let newIndex; 2317 - switch (e.key) { 2318 - case 'ArrowRight': newIndex = (currentIndex + 1) % visibleTabs.length; break; 2319 - case 'ArrowLeft': newIndex = (currentIndex - 1 + visibleTabs.length) % visibleTabs.length; break; 2320 - case 'Home': newIndex = 0; break; 2321 - case 'End': newIndex = visibleTabs.length - 1; break; 2322 - } 2323 - visibleTabs[newIndex].focus(); 2324 - activateRunDetailTab(visibleTabs[newIndex].dataset.tab); 2325 - }); 2326 - 2327 - window.showPreview = async function() { 2328 - if (!currentName) return; 2329 - 2330 - previousPreviewFocus = document.activeElement; 2331 - 2332 - const modal = document.getElementById('preview-modal'); 2333 - const content = document.getElementById('preview-modal-content'); 2334 - const title = document.getElementById('preview-modal-title'); 2335 - 2336 - content.textContent = 'Loading...'; 2337 - modal.classList.add('show'); 2338 - 2339 - // Focus the close button 2340 - const closeBtn = modal.querySelector('.modal-close'); 2341 - if (closeBtn) closeBtn.focus(); 2342 - 2343 - // Install focus trap (mirrors conversation panel pattern from app.html:230-246) 2344 - previewFocusTrapHandler = function(e) { 2345 - if (e.key !== 'Tab') return; 2346 - var focusable = Array.from( 2347 - modal.querySelectorAll('button, [href], input, select, textarea, [tabindex="0"]') 2348 - ).filter(function(el) { return el.offsetParent !== null && el.tabIndex >= 0 && !el.disabled; }); 2349 - if (focusable.length === 0) return; 2350 - var first = focusable[0]; 2351 - var last = focusable[focusable.length - 1]; 2352 - if (e.shiftKey && document.activeElement === first) { 2353 - e.preventDefault(); 2354 - last.focus(); 2355 - } else if (!e.shiftKey && document.activeElement === last) { 2356 - e.preventDefault(); 2357 - first.focus(); 2358 - } 2359 - }; 2360 - document.addEventListener('keydown', previewFocusTrapHandler); 2361 - 2362 - try { 2363 - const response = await fetch(`api/preview/${encodeURIComponent(currentName)}`); 2364 - const data = await response.json(); 2365 - 2366 - if (data.error) { 2367 - content.textContent = `Error: ${data.error}`; 2368 - } else { 2369 - title.textContent = `${data.title} - Prompt`; 2370 - content.textContent = data.full_prompt; 2371 - } 2372 - } catch (error) { 2373 - content.textContent = 'Error loading prompt'; 2374 - } 2375 - }; 2376 - 2377 - window.hidePreview = function() { 2378 - document.getElementById('preview-modal').classList.remove('show'); 2379 - 2380 - // Remove focus trap 2381 - if (previewFocusTrapHandler) { 2382 - document.removeEventListener('keydown', previewFocusTrapHandler); 2383 - previewFocusTrapHandler = null; 2384 - } 2385 - 2386 - // Restore focus (mirrors conversation panel pattern from app.html:273-278) 2387 - if (previousPreviewFocus && previousPreviewFocus.isConnected) { 2388 - previousPreviewFocus.focus(); 2389 - } else { 2390 - document.getElementById('preview-btn')?.focus(); 2391 - } 2392 - previousPreviewFocus = null; 2393 - }; 2394 - 2395 - // Close modal on backdrop click 2396 - document.getElementById('preview-modal').addEventListener('click', function(e) { 2397 - if (e.target === this) { 2398 - hidePreview(); 2399 - } 2400 - }); 2401 - 2402 - // Close modal on Escape 2403 - document.addEventListener('keydown', function(e) { 2404 - if (e.key === 'Escape' && document.getElementById('preview-modal').classList.contains('show')) { 2405 - hidePreview(); 2406 - } 2407 - }); 2408 - 2409 - // Listen for facet changes 2410 - window.addEventListener('facet.switch', () => { 2411 - loadUpdatedBanner(); 2412 - loadAgents(); 2413 - }); 2414 - 2415 - // Initial load 2416 - loadUpdatedBanner(); 2417 - loadAgents(); 2418 - })(); 2419 - </script>
-309
tests/test_app_agents.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Tests for app agent discovery, loading, and route helpers.""" 5 - 6 - import json 7 - import os 8 - from pathlib import Path 9 - 10 - import pytest 11 - 12 - from apps.agents.routes import _resolve_output_path 13 - from think.talent import _resolve_agent_path, get_agent, get_talent_configs 14 - 15 - 16 - @pytest.fixture 17 - def fixture_journal(): 18 - """Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for testing.""" 19 - os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = "tests/fixtures/journal" 20 - yield 21 - 22 - 23 - @pytest.fixture 24 - def app_with_agent(tmp_path, monkeypatch): 25 - """Create a temporary app with an agent for testing. 26 - 27 - Creates apps/testapp/talent/myhelper.md with frontmatter in a temp directory, 28 - then monkeypatches the apps directory path. 29 - """ 30 - # Create app structure 31 - app_dir = tmp_path / "apps" / "testapp" 32 - talent_dir = app_dir / "talent" 33 - talent_dir.mkdir(parents=True) 34 - 35 - # Create workspace.html (required for app discovery, though not used here) 36 - (app_dir / "workspace.html").write_text("<h1>Test App</h1>") 37 - 38 - # Create agent file with frontmatter 39 - metadata = { 40 - "type": "cogitate", 41 - "title": "My Test Helper", 42 - "provider": "openai", 43 - "tools": "journal", 44 - "schedule": "daily", 45 - "priority": 42, 46 - } 47 - json_str = json.dumps(metadata, indent=2) 48 - (talent_dir / "myhelper.md").write_text( 49 - f"{{\n{json_str[1:-1]}\n}}\n\nYou are a test helper agent.\n\n## Purpose\nHelp with testing." 50 - ) 51 - 52 - # Create another agent without metadata (defaults only) 53 - (talent_dir / "simple.md").write_text("A simple test agent with no metadata.") 54 - 55 - # Monkeypatch the parent directory so apps discovery finds our temp apps 56 - monkeypatch.setattr( 57 - "think.utils.Path.__file__", 58 - str(tmp_path / "think" / "utils.py"), 59 - ) 60 - 61 - # Actually we need to patch where get_agents looks for apps 62 - # It uses Path(__file__).parent.parent / "apps" 63 - # Let's patch it differently - create a mock apps dir structure 64 - yield { 65 - "tmp_path": tmp_path, 66 - "app_dir": app_dir, 67 - "talent_dir": talent_dir, 68 - } 69 - 70 - 71 - def test_resolve_agent_path_system_agent(): 72 - """Test _resolve_agent_path returns correct path for system agents.""" 73 - agent_dir, agent_name = _resolve_agent_path("unified") 74 - 75 - assert agent_name == "chat" 76 - assert agent_dir.name == "talent" 77 - 78 - 79 - def test_resolve_agent_path_app_agent(): 80 - """Test _resolve_agent_path returns correct path for app agents.""" 81 - agent_dir, agent_name = _resolve_agent_path("support:support") 82 - 83 - assert agent_name == "support" 84 - assert agent_dir.name == "talent" 85 - assert agent_dir.parent.name == "support" 86 - assert "apps" in str(agent_dir) 87 - 88 - 89 - def test_resolve_agent_path_app_agent_with_underscores(): 90 - """Test _resolve_agent_path handles app names with underscores.""" 91 - agent_dir, agent_name = _resolve_agent_path("my_app:my_agent") 92 - 93 - assert agent_name == "my_agent" 94 - assert agent_dir.parent.name == "my_app" 95 - 96 - 97 - def test_get_agent_system_agent(fixture_journal): 98 - """Test get_agent loads system agents correctly.""" 99 - config = get_agent("unified") 100 - 101 - assert config["name"] == "unified" 102 - assert "user_instruction" in config 103 - assert len(config["user_instruction"]) > 0 104 - 105 - 106 - def test_get_agent_nonexistent_raises(): 107 - """Test get_agent raises FileNotFoundError for nonexistent agents.""" 108 - with pytest.raises(FileNotFoundError) as exc_info: 109 - get_agent("nonexistent_agent_xyz") 110 - 111 - assert "nonexistent_agent_xyz" in str(exc_info.value) 112 - 113 - 114 - def test_get_agent_nonexistent_app_agent_raises(): 115 - """Test get_agent raises FileNotFoundError for nonexistent app agents.""" 116 - with pytest.raises(FileNotFoundError) as exc_info: 117 - get_agent("fakeapp:fakeagent") 118 - 119 - assert "fakeapp:fakeagent" in str(exc_info.value) 120 - 121 - 122 - def test_get_talent_configs_includes_system_agents(fixture_journal): 123 - """Test get_talent_configs returns system agents with metadata.""" 124 - agents = get_talent_configs(type="cogitate") 125 - 126 - # Should include known system agents with frontmatter metadata 127 - assert "chat" in agents 128 - assert agents["chat"]["source"] == "system" 129 - assert "title" in agents["chat"] 130 - assert "path" in agents["chat"] 131 - 132 - 133 - def test_get_talent_configs_system_agents_have_metadata(fixture_journal): 134 - """Test system agents have proper metadata fields.""" 135 - agents = get_talent_configs(type="cogitate") 136 - 137 - # Check a known system agent 138 - chat = agents.get("chat") 139 - assert chat is not None 140 - assert chat["source"] == "system" 141 - assert "title" in chat 142 - assert "color" in chat 143 - 144 - 145 - def test_get_talent_configs_excludes_private_apps( 146 - fixture_journal, tmp_path, monkeypatch 147 - ): 148 - """Test get_talent_configs skips apps starting with underscore.""" 149 - # Create a private app with an agent 150 - private_app = tmp_path / "_private_app" / "agents" 151 - private_app.mkdir(parents=True) 152 - (private_app / "secret.md").write_text("Secret agent") 153 - 154 - # This is tricky to test without modifying the actual apps directory 155 - # The current implementation filters by app_path.name.startswith("_") 156 - # We verify this by checking the code behavior with get_talent_configs() 157 - 158 - agents = get_talent_configs(type="cogitate") 159 - 160 - # No agents should have keys starting with "_" 161 - for key in agents: 162 - assert not key.startswith("_"), f"Private app agent found: {key}" 163 - 164 - 165 - def test_app_agent_namespace_format(fixture_journal): 166 - """Test app agent keys follow {app}:{agent} format.""" 167 - agents = get_talent_configs(type="cogitate") 168 - 169 - for key, config in agents.items(): 170 - if config.get("source") == "app": 171 - # App agents must have colon in key 172 - assert ":" in key, f"App agent key missing namespace: {key}" 173 - app_name, agent_name = key.split(":", 1) 174 - assert config.get("app") == app_name 175 - 176 - 177 - # --- _resolve_output_path tests --- 178 - 179 - 180 - class TestResolveOutputPath: 181 - """Tests for _resolve_output_path route helper.""" 182 - 183 - def test_explicit_output_path_returned_directly(self): 184 - """When output_path is set, return it as-is without derivation.""" 185 - event = { 186 - "output_path": "/journal/facets/work/activities/20260214/coding_100/summary.md" 187 - } 188 - result = _resolve_output_path(event, "/journal") 189 - assert result == Path( 190 - "/journal/facets/work/activities/20260214/coding_100/summary.md" 191 - ) 192 - 193 - def test_derives_path_from_request_fields(self, fixture_journal): 194 - """Without output_path, derives from day/name/segment fields.""" 195 - event = { 196 - "day": "20260214", 197 - "name": "unified", 198 - "segment": "100", 199 - "facet": "health", 200 - } 201 - result = _resolve_output_path(event, "tests/fixtures/journal") 202 - assert result is not None 203 - assert "20260214" in str(result) 204 - assert result.suffix in (".md", ".json") 205 - 206 - def test_returns_none_without_day_or_output_path(self): 207 - """Returns None when neither output_path nor day is present.""" 208 - event = {"name": "unified"} 209 - result = _resolve_output_path(event, "/journal") 210 - assert result is None 211 - 212 - def test_empty_output_path_falls_through(self, fixture_journal): 213 - """Empty string output_path falls through to derivation.""" 214 - event = {"output_path": "", "day": "20260214", "name": "unified"} 215 - result = _resolve_output_path(event, "tests/fixtures/journal") 216 - # Empty string is falsy, so falls through to derivation 217 - assert result is not None 218 - 219 - def test_uses_env_stream_name(self, fixture_journal): 220 - """SOL_STREAM from env is passed through to get_output_path.""" 221 - event = { 222 - "day": "20260214", 223 - "name": "unified", 224 - "env": {"SOL_STREAM": "mystream"}, 225 - } 226 - result = _resolve_output_path(event, "tests/fixtures/journal") 227 - assert result is not None 228 - 229 - def test_explicit_path_ignores_other_fields(self): 230 - """When output_path is set, day/name/segment are ignored.""" 231 - event = { 232 - "output_path": "/custom/path/output.md", 233 - "day": "20260214", 234 - "name": "unified", 235 - "segment": "100", 236 - } 237 - result = _resolve_output_path(event, "/journal") 238 - assert result == Path("/custom/path/output.md") 239 - 240 - 241 - # --- api_output_file endpoint tests --- 242 - 243 - 244 - @pytest.fixture 245 - def agents_client(tmp_path): 246 - """Create a Flask test client with agents blueprint and tmp journal.""" 247 - from flask import Flask 248 - 249 - from apps.agents.routes import agents_bp 250 - from convey import state 251 - 252 - app = Flask(__name__) 253 - app.register_blueprint(agents_bp) 254 - 255 - # Point state at our tmp journal 256 - state.journal_root = str(tmp_path) 257 - 258 - # Create test files 259 - day_dir = tmp_path / "20260214" 260 - day_dir.mkdir() 261 - (day_dir / "agents" / "flow.md").parent.mkdir(parents=True) 262 - (day_dir / "agents" / "flow.md").write_text("# Day agent output") 263 - 264 - facet_dir = tmp_path / "facets" / "work" / "activities" / "20260214" / "coding_100" 265 - facet_dir.mkdir(parents=True) 266 - (facet_dir / "summary.md").write_text("# Activity summary") 267 - 268 - yield app.test_client() 269 - 270 - 271 - class TestApiOutputFile: 272 - """Tests for api_output_file endpoint.""" 273 - 274 - def test_serves_day_relative_file(self, agents_client): 275 - """Day-relative paths resolve under {journal}/{day}/.""" 276 - resp = agents_client.get("/app/agents/api/output/20260214/agents/flow.md") 277 - assert resp.status_code == 200 278 - data = resp.get_json() 279 - assert data["content"] == "# Day agent output" 280 - assert data["format"] == "md" 281 - assert data["filename"] == "flow.md" 282 - 283 - def test_serves_facet_scoped_activity_file(self, agents_client): 284 - """Paths starting with facets/ resolve from journal root.""" 285 - resp = agents_client.get( 286 - "/app/agents/api/output/20260214/" 287 - "facets/work/activities/20260214/coding_100/summary.md" 288 - ) 289 - assert resp.status_code == 200 290 - data = resp.get_json() 291 - assert data["content"] == "# Activity summary" 292 - assert data["format"] == "md" 293 - 294 - def test_rejects_invalid_day_format(self, agents_client): 295 - """Non-YYYYMMDD day returns 400.""" 296 - resp = agents_client.get("/app/agents/api/output/bad-day/agents/flow.md") 297 - assert resp.status_code == 400 298 - 299 - def test_rejects_path_traversal(self, agents_client): 300 - """Path traversal attempts return 403.""" 301 - resp = agents_client.get("/app/agents/api/output/20260214/../../etc/passwd") 302 - assert resp.status_code in (403, 404) 303 - 304 - def test_missing_file_returns_404(self, agents_client): 305 - """Non-existent file returns 404.""" 306 - resp = agents_client.get( 307 - "/app/agents/api/output/20260214/agents/nonexistent.md" 308 - ) 309 - assert resp.status_code == 404