diff --git a/harness/brain/lessons.jsonl b/harness/brain/lessons.jsonl index 22861e363..0007a8911 100644 --- a/harness/brain/lessons.jsonl +++ b/harness/brain/lessons.jsonl @@ -302,3 +302,6 @@ {"ts": "2026-05-30T14:31:35Z", "sprint_id": "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release", "lesson": "| V4_regression_aggregation | PASS | 1 JSON (44 records) | 44/44 PASS, 0 FAIL |", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release"]} {"ts": "2026-05-30T15:36:05Z", "sprint_id": "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release", "lesson": "| V4_regression_aggregation | PASS | 1 JSON (44 records) | 44/44 PASS, 0 FAIL |", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release"]} {"ts": "2026-05-30T16:41:27Z", "sprint_id": "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release", "lesson": "| V4_regression_aggregation | PASS | 1 JSON (44 records) | 44/44 PASS, 0 FAIL |", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260527-p0-solar-harness-tui-pane-recover-与-clean-pane-生命周期治理-s05-verification-release"]} +{"ts": "2026-05-31T21:46:09Z", "sprint_id": "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime", "lesson": "workflow_guard.py 的所有三分面调用均包裹在 `try/except` fail-open 逻辑中", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime"]} +{"ts": "2026-05-31T21:46:09Z", "sprint_id": "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime", "lesson": "graph_scheduler.py 的 state 写入为 best-effort,失败不影响 legacy `save_graph()` 调用", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime"]} +{"ts": "2026-05-31T21:46:09Z", "sprint_id": "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime", "lesson": "回归测试** (排除预存在失败):", "source": "auto_eval", "confidence": 0.8, "tags": ["FAIL", "eval", "sprint-20260531-请为-solar-harness-开一个新的-p0-p1-架构升级单-主题是-把-task-graph-从现网单文件主读-s03-core-runtime"]} diff --git a/harness/config/agent-actors.json b/harness/config/agent-actors.json index a551b53d3..7a87130db 100644 --- a/harness/config/agent-actors.json +++ b/harness/config/agent-actors.json @@ -1922,7 +1922,7 @@ "mini-chatgpt-deep-research": { "actor_id": "mini-chatgpt-deep-research", "host_id": "mini", - "operator_alias": "mini-claude-opus-planner", + "operator_alias": "mini-chatgpt-deep-research", "aliases": ["mini-chatgpt-deep-research"], "role": "knowledge-extractor", "display_meta": { diff --git a/harness/config/ai-influence-daily-digest.yaml b/harness/config/ai-influence-daily-digest.yaml index b3f904c9d..4d37da86b 100644 --- a/harness/config/ai-influence-daily-digest.yaml +++ b/harness/config/ai-influence-daily-digest.yaml @@ -6,8 +6,6 @@ mac_mini_only: true allowed_hostnames: - lisihaodeMac-mini-3.local - lisihaodeMac-mini-3 - - lisihaodeMini-3.local - - lisihaodeMini-3 timezone: America/Toronto schedule: diff --git a/harness/config/cmux-workspace-sample.yaml b/harness/config/cmux-workspace-sample.yaml new file mode 100644 index 000000000..fda205054 --- /dev/null +++ b/harness/config/cmux-workspace-sample.yaml @@ -0,0 +1,92 @@ +# Sample: cmux monitoring workspace (solar-harness) +# Usage: +# render-cmux-workspace config/cmux-workspace-sample.yaml +# cmux-monitor-up config/cmux-workspace-sample.yaml +# cmux-monitor-doctor config/cmux-workspace-sample.yaml +# cmux-monitor-down config/cmux-workspace-sample.yaml + +workspace_name: "solar-runtime" + +ssh_profiles: + mini: + host: "mini" + user: "lisihao" + control_master: true + +tabs: + # Tab 1: quad 2x2 default — 4 panes monitoring solar harness + - id: "solar-quad" + title: "Solar Harness (quad)" + layout: "quad" + panes: + - title: "planner" + source: "local" + tmux_target: "solar-harness:0.0" + mode: "capture" + lines: 60 + interval_sec: 2 + + - title: "builder" + source: "local" + tmux_target: "solar-harness:0.1" + mode: "capture" + lines: 60 + interval_sec: 2 + + - title: "evaluator" + source: "local" + tmux_target: "solar-harness:0.2" + mode: "capture" + lines: 60 + interval_sec: 2 + + - title: "architect" + source: "local" + tmux_target: "solar-harness:0.3" + mode: "capture" + lines: 60 + interval_sec: 2 + + # Tab 2: logs (tail mode) + - id: "solar-logs" + title: "Build Logs (tail)" + layout: "split" + panes: + - title: "harness-log" + source: "local" + tmux_target: "solar-harness:0.0" + mode: "tail" + log_path: "~/.solar/harness/.chain-watcher.log" + + - title: "sprint-log" + source: "local" + tmux_target: "solar-harness:0.1" + mode: "tail" + log_path: "~/.solar/session-state.jsonl" + + # Tab 3: remote mini (capture) — enable when remote is available + # - id: "mini-remote" + # title: "Mac Mini Remote" + # layout: "quad" + # panes: + # - title: "planner-remote" + # source: "remote" + # ssh_profile: "mini" + # tmux_target: "solar:0.0" + # mode: "capture" + # - title: "builder-remote" + # source: "remote" + # ssh_profile: "mini" + # tmux_target: "solar:0.1" + # mode: "capture" + # - title: "evaluator-remote" + # source: "remote" + # ssh_profile: "mini" + # tmux_target: "solar:0.2" + # mode: "capture" + # - title: "logs-remote" + # source: "remote" + # ssh_profile: "mini" + # tmux_target: "solar:0.2" + # mode: "tail" + # log_path: "~/.tmux-pane-logs/solar-0-2.log" diff --git a/harness/config/github-trends.yaml b/harness/config/github-trends.yaml index 2e271177e..4760326af 100644 --- a/harness/config/github-trends.yaml +++ b/harness/config/github-trends.yaml @@ -11,15 +11,6 @@ output: retention_days: 100 fetch: timeout_seconds: 20 - retry_attempts: 3 - retry_backoff_seconds: 3 - retry_statuses: - - 408 - - 429 - - 500 - - 502 - - 503 - - 504 sleep_between_requests_seconds: 8 min_source_interval_hours: 6 user_agent: Solar-GitHub-Trends/1.0 (+local research digest) diff --git a/harness/config/operator_registry.json b/harness/config/operator_registry.json index 202b7fe1d..813717017 100644 --- a/harness/config/operator_registry.json +++ b/harness/config/operator_registry.json @@ -43,6 +43,34 @@ "schedule": "on_demand", "output_dir": "reports/gemini/" }, + "chatgpt_thinking_high": { + "primary": "tools/chatgpt_browser_agent_task_operator.py", + "executors": [ + "scripts/browser_agent_chatgpt_wrapper.py", + "tools/chatgpt_report_operator.py" + ], + "fallback": [], + "schedule": "on_demand", + "output_dir": "reports/chatgpt-thinking-high/" + }, + "chatgpt_report_chapter_writer": { + "primary": "tools/chatgpt_report_operator.py", + "executors": [ + "scripts/browser_agent_chatgpt_wrapper.py" + ], + "fallback": [], + "schedule": "on_demand", + "output_dir": "reports/chatgpt-report-chapters/" + }, + "technology_diagram": { + "primary": "tools/technology_diagram_painter_operator.py", + "executors": [ + "scripts/browser_agent_technology_diagram_painter_wrapper.py" + ], + "fallback": [], + "schedule": "on_demand", + "output_dir": "reports/technology-diagrams/" + }, "youtube": { "primary": "scripts/youtube_influence_digest.py", "executors": [ diff --git a/harness/config/operator_schedules.json b/harness/config/operator_schedules.json index a0e076ac8..5b8f39c4e 100644 --- a/harness/config/operator_schedules.json +++ b/harness/config/operator_schedules.json @@ -34,6 +34,27 @@ "type": "manual", "cron": null }, + "chatgpt_thinking_high": { + "line": "chatgpt_thinking_high", + "primary": "tools/chatgpt_browser_agent_task_operator.py", + "source_schedule": "on_demand", + "type": "manual", + "cron": null + }, + "chatgpt_report_chapter_writer": { + "line": "chatgpt_report_chapter_writer", + "primary": "tools/chatgpt_report_operator.py", + "source_schedule": "on_demand", + "type": "manual", + "cron": null + }, + "technology_diagram": { + "line": "technology_diagram", + "primary": "tools/technology_diagram_painter_operator.py", + "source_schedule": "on_demand", + "type": "manual", + "cron": null + }, "youtube": { "line": "youtube", "primary": "scripts/youtube_influence_digest.py", diff --git a/harness/config/physical-operators.json b/harness/config/physical-operators.json index db42580a9..91cf69034 100644 --- a/harness/config/physical-operators.json +++ b/harness/config/physical-operators.json @@ -1398,6 +1398,88 @@ "last_error": null } }, + "mini-chatgpt-deep-research": { + "display_name": "ChatGPT Thinking High Browser Agent", + "plane": "interactive_browser", + "owner_host": "lisihao@100.122.223.55", + "pane": "solar-harness-multi-task:*", + "profile": "browser-agent", + "role": "knowledge-extractor", + "persona": "builder", + "provider": "openai", + "vendor": "OpenAI", + "backend": "command", + "model": "chatgpt-5.5", + "model_config": "ChatGPT Web UI Thinking high wrapper; project=杂项; no local fallback", + "base_url": "https://chatgpt.com", + "auth_mode": "oauth", + "key_ref": "chatgpt_browser_profile", + "quota_cycle": "webapp-account", + "quota_refresh_at": "unknown", + "quota_guard_state": "ok", + "enabled": true, + "disabled_reason": "", + "enabled_at": "2026-06-01T00:00:00Z", + "available": true, + "health_status": "ok", + "roles": [ + "knowledge-extractor" + ], + "task_classes": [ + "browser-research", + "deep-research", + "research-synthesis", + "ai-influence-report", + "trend-judgment" + ], + "strengths": [ + "chatgpt-thinking-high", + "longform-synthesis", + "high-judgment", + "browser-agent", + "report-planning" + ], + "preferred_for": [ + "deep-research-chatgpt", + "ai-influence-report", + "trend-synthesis", + "longform-report", + "strategic-insight" + ], + "avoid_for": [ + "direct-code-edit", + "bulk-extraction", + "cheap-background" + ], + "cost_tier": "high", + "latency_tier": "high", + "context_tier": "high", + "max_concurrency": 1, + "fallback_profile": "builder", + "command": "PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\" CHATGPT_MODEL=\"${CHATGPT_MODEL:-chatgpt-5.5}\" CHATGPT_REASONING_EFFORT=\"${CHATGPT_REASONING_EFFORT:-high}\" BROWSER_AGENT_CHATGPT_MODEL_MODE=\"${BROWSER_AGENT_CHATGPT_MODEL_MODE:-thinking}\" BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE=\"${BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE:-true}\" BROWSER_AGENT_CHATGPT_PROJECT_NAME=\"${BROWSER_AGENT_CHATGPT_PROJECT_NAME:-杂项}\" python3 \"$HARNESS_DIR/tools/chatgpt_browser_agent_task_operator.py\"", + "policy": { + "write_files": "artifact_dir_only", + "run_shell": "restricted_operator_command", + "network": "allowed", + "secrets_access": "secret_ref_only", + "git_commit": "denied" + }, + "flow_control": { + "success_cooldown_seconds": 180, + "rate_limit_cooldown_seconds": 3600, + "auth_cooldown_seconds": 21600, + "defer_on_cooldown": true, + "defer_on_auth": true + }, + "state": { + "availability": "enabled", + "runtime_state": "idle", + "cooldown_until": null, + "last_heartbeat_at": null, + "last_success_at": null, + "last_error": null + } + }, "mini-youtube-transcript-extractor": { "display_name": "YouTube Transcript Extractor Browser Agent", "plane": "interactive_browser", diff --git a/harness/config/tech-hotspot-radar.yaml b/harness/config/tech-hotspot-radar.yaml index 990205c78..7a43da1c3 100644 --- a/harness/config/tech-hotspot-radar.yaml +++ b/harness/config/tech-hotspot-radar.yaml @@ -36,6 +36,7 @@ fetch: sleep_between_requests_seconds: 3 min_source_interval_hours: 6 user_agent: Solar-Tech-Hotspot-Radar/1.0 (+local knowledge capture) + github_token_env: GITHUB_TOKEN # ── YouTube source ────────────────────────────────────────────────── youtube: @@ -49,7 +50,7 @@ youtube: backfill_per_channel_limit: 100 # yt-dlp playlist cap per channel keep_seen_days: 45 transcript_max_chars: 60000 - min_transcript_duration_seconds: 600 # do not spend transcript/ASR budget on videos under 10 minutes + min_transcript_duration_seconds: 600 # do not spend transcript/browser-capture budget on videos under 10 minutes phase_report_reasoner: provider: browser_agent_chatgpt model: chatgpt-5.5 @@ -79,8 +80,8 @@ youtube: notebook_name: "" timeout_seconds: 1800 asr: - enabled: true - max_per_run: 2 + enabled: false + max_per_run: 0 backend: mlx-whisper whisper_model: mlx-community/whisper-small-mlx openai_whisper_model: small diff --git a/harness/config/youtube-influence-digest.yaml b/harness/config/youtube-influence-digest.yaml index 84c176524..36af83916 100644 --- a/harness/config/youtube-influence-digest.yaml +++ b/harness/config/youtube-influence-digest.yaml @@ -28,12 +28,12 @@ fetch: max_consecutive_transcript_failures: 8 user_agent: Solar-YouTube-Influence-Digest/1.0 (+local transcript capture) asr: - enabled: true - # ASR is a fallback path. Browser-agent transcript capture is the primary path, - # so ASR backlog must not block /knowledge/progress health by default. + enabled: false + # Local/premium ASR is disabled. Browser-agent transcript capture is the only + # allowed no-caption fallback; failures must become metadata-only, not ASR jobs. blocking_for_knowledge_progress: false queue_health_policy: fallback_non_blocking - max_per_run: 1 + max_per_run: 0 queue_dir: /Users/lisihao/.solar/harness/state/youtube-influence-digest/asr-queue done_dir: /Users/lisihao/.solar/harness/state/youtube-influence-digest/asr-done audio_dir: /Users/lisihao/.solar/harness/state/youtube-influence-digest/asr-audio diff --git a/harness/docs/cmux-monitoring-workspace.md b/harness/docs/cmux-monitoring-workspace.md new file mode 100644 index 000000000..7b3203a6a --- /dev/null +++ b/harness/docs/cmux-monitoring-workspace.md @@ -0,0 +1,111 @@ +# cmux 多标签四分屏 tmux 状态监控工作台 + +## 快速开始 + +```bash +# 1. 渲染(预览命令计划) +python3 scripts/cmux/render-cmux-workspace config/cmux-workspace-sample.yaml + +# 2. 健康检查 +python3 scripts/cmux/cmux-monitor-doctor config/cmux-workspace-sample.yaml + +# 3. 启动工作台 +python3 scripts/cmux/cmux-monitor-up config/cmux-workspace-sample.yaml + +# 4. 停止工作台 +python3 scripts/cmux/cmux-monitor-down config/cmux-workspace-sample.yaml +# or: +python3 scripts/cmux/cmux-monitor-down --name solar-runtime +``` + +## 脚本说明 + +| 脚本 | 职责 | +| --- | --- | +| `render-cmux-workspace` | 解析 workspace.yaml,校验 schema,输出 tab/pane 命令计划 | +| `cmux-monitor-up` | 调用 render,构建 cmux JSON layout,启动工作台 | +| `cmux-monitor-down` | 安全关闭工作台(不影响其他 cmux 会话) | +| `cmux-monitor-doctor` | 逐项检查 cmux/tmux/ssh/target/log,输出 machine-verifiable JSON | +| `tmux-pane-view` | capture-pane 轮询,显示 timestamp+host+target,fail-open | +| `tmux-pane-log-follow` | tail -F 模式,支持 local/remote,可校验 tmux target | + +## Workspace Schema + +```yaml +workspace_name: "my-workspace" + +ssh_profiles: + mini: + host: "mini" + user: "lisihao" + control_master: true # 开启 ControlMaster + +tabs: + - id: "tab-id" + title: "Tab Title" + layout: "quad" # single | split | tri | quad + panes: + - title: "pane-name" + source: "local" # local | remote + # ssh_profile: "mini" # 仅 source=remote 时设置 + tmux_target: "session:window.pane" + mode: "capture" # capture | tail + # log_path: "~/.logs/foo.log" # 仅 mode=tail 时设置 + lines: 60 # capture 模式显示行数(可选,默认 60) + interval_sec: 1 # capture 刷新间隔(可选,默认 1) +``` + +### 布局枚举 + +| layout | panes | 布局示意 | +| --- | --- | --- | +| `single` | 1 | full screen | +| `split` | 2 | 左右平分 | +| `tri` | 3 | 上两格 / 下一格 | +| `quad` | 4 | 2x2 等分(默认四分屏) | + +### 约束 + +- 每 tab 最多 4 panes +- `mode=capture` 需要 `tmux_target` +- `mode=tail` 需要 `tmux_target` + `log_path` +- `source=remote` 需要 `ssh_profile` +- `source=local` 不允许 `ssh_profile` + +## SSH 复用配置 + +在 `~/.ssh/config` 中添加: + +```sshconfig +Host mini + HostName your-mini.local + User lisihao + ControlMaster auto + ControlPath ~/.ssh/cmux-%r@%h:%p + ControlPersist 10m + ServerAliveInterval 30 + ServerAliveCountMax 3 +``` + +## Doctor 输出格式 + +```json +{ + "ok": false, + "workspace": "solar-runtime", + "checks": [ + {"name": "cmux_exists", "ok": true}, + {"name": "ssh_mini_connect", "ok": true}, + {"name": "tmux_target_solar_0_0", "ok": false, "reason": "target_not_found"} + ] +} +``` + +`ok=false` 时 exit code 为 1,便于 CI/healthcheck 集成。 + +## Non-Goals(第一阶段) + +1. 不做复杂 TUI 完美渲染 +2. 不做可写交互控制面 +3. 不重做 remote dispatch +4. 不把 cmux 工作台做成 remote tmux pane attach 协议层 diff --git a/harness/lib/ai_influence_youtube_report/browser_agent.py b/harness/lib/ai_influence_youtube_report/browser_agent.py index ba3d9c4a8..5aacfa6a5 100644 --- a/harness/lib/ai_influence_youtube_report/browser_agent.py +++ b/harness/lib/ai_influence_youtube_report/browser_agent.py @@ -7,6 +7,10 @@ from __future__ import annotations import time +import json +import os +import subprocess +import sys from pathlib import Path from typing import Any, Protocol from uuid import uuid4 @@ -22,6 +26,88 @@ class LocalModelSubstitutionError(RuntimeError): pass +class YoutubeTranscriptExtractor: + """Production adapter for the YouTube transcript browser-agent operator. + + The report BrowserAgentClient below is for ChatGPT report planning/writing. + Transcript capture is a separate logical operator; this adapter exposes it + from the AI Influence YouTube module while reusing the existing operator + implementation instead of duplicating browser automation. + """ + + def __init__( + self, + *, + operator_script: str | Path | None = None, + python_executable: str | Path | None = None, + target_account_email: str | None = None, + ) -> None: + root = Path(__file__).resolve().parents[2] + self.operator_script = Path(operator_script or root / "tools" / "youtube_transcript_operator.py") + self.python_executable = str(python_executable or sys.executable) + self.target_account_email = target_account_email + + def extract( + self, + youtube_url: str, + *, + task_dir: str | Path, + timeout_seconds: int = 300, + max_retries: int = 1, + output_format: str = "timestamped", + headless: bool = True, + ) -> dict[str, Any]: + url = str(youtube_url or "").strip() + if not url: + raise RuntimeError("YoutubeTranscriptExtractor requires youtube_url") + if not self.operator_script.exists(): + raise RuntimeError(f"YouTube transcript operator not found: {self.operator_script}") + + run_dir = Path(task_dir).expanduser() + run_dir.mkdir(parents=True, exist_ok=True) + envelope_path = run_dir / "envelope.json" + envelope_path.write_text( + json.dumps( + { + "operator_id": "mini-youtube-transcript-extractor", + "youtube_url": url, + "timeout_seconds": max(int(timeout_seconds), 300), + "max_retries": int(max_retries), + "output_format": output_format, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + env = os.environ.copy() + env["SOLAR_OPERATOR_ENVELOPE_JSON"] = str(envelope_path) + env["TASK_DIR"] = str(run_dir) + env.setdefault("BROWSER_AGENT_HEADLESS", "true" if headless else "false") + if self.target_account_email: + env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = self.target_account_email + + proc = subprocess.run( + [self.python_executable, str(self.operator_script)], + env=env, + text=True, + capture_output=True, + timeout=max(int(timeout_seconds), 300) + 60, + ) + (run_dir / "operator.stdout.txt").write_text(proc.stdout or "", encoding="utf-8") + (run_dir / "operator.stderr.txt").write_text(proc.stderr or "", encoding="utf-8") + result_path = run_dir / "youtube-transcript-result.json" + if proc.returncode != 0 or not result_path.exists(): + combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip() + raise RuntimeError(f"browser transcript operator failed rc={proc.returncode}: {combined[-1200:]}") + payload = json.loads(result_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError("youtube-transcript-result.json must contain a JSON object") + return payload + + class BrowserAgentClient: def __init__(self, provider: BrowserAgentProvider, *, ledger_path: str | Path, sprint_id: str) -> None: self.provider = provider diff --git a/harness/lib/browser_job_runtime.py b/harness/lib/browser_job_runtime.py index e7a128521..2f1b6d8fe 100644 --- a/harness/lib/browser_job_runtime.py +++ b/harness/lib/browser_job_runtime.py @@ -448,9 +448,7 @@ def refresh_browser_profile_cache(user_data_dir: str | Path | None, profile_dire if cache_root.exists(): shutil.rmtree(cache_root, ignore_errors=True) cache_root.mkdir(parents=True, exist_ok=True) - if cache_profile.exists(): - shutil.rmtree(cache_profile, ignore_errors=True) - shutil.copytree(source_profile, cache_profile, dirs_exist_ok=True) + shutil.copytree(source_profile, cache_profile) local_state_src = source_root / "Local State" if local_state_src.exists(): shutil.copy(local_state_src, cache_root / "Local State") @@ -497,7 +495,7 @@ def _stage_browser_profile(user_data_dir: str | Path | None, profile_directory: staged_root = Path(tempfile.mkdtemp(prefix=_STAGED_PROFILE_PREFIX)) staged_profile = staged_root / profile_directory - shutil.copytree(source_profile, staged_profile, dirs_exist_ok=True) + shutil.copytree(source_profile, staged_profile) local_state_src = source_root / "Local State" if local_state_src.exists(): diff --git a/harness/lib/capabilities/gemini_deep_research/compat/__init__.py b/harness/lib/capabilities/gemini_deep_research/compat/__init__.py index 122d6b426..001c13556 100644 --- a/harness/lib/capabilities/gemini_deep_research/compat/__init__.py +++ b/harness/lib/capabilities/gemini_deep_research/compat/__init__.py @@ -3,11 +3,13 @@ from .operator_adapter import ( LI_PROFESSOR_TEMPLATE_ID, DeepResearchBrowserAdapter, + DeepResearchGeminiAdapter, ) from .status_projection import project_status __all__ = [ "LI_PROFESSOR_TEMPLATE_ID", "DeepResearchBrowserAdapter", + "DeepResearchGeminiAdapter", "project_status", ] diff --git a/harness/lib/capabilities/gemini_deep_research/compat/operator_adapter.py b/harness/lib/capabilities/gemini_deep_research/compat/operator_adapter.py index c8fc3f46d..72ba8c649 100644 --- a/harness/lib/capabilities/gemini_deep_research/compat/operator_adapter.py +++ b/harness/lib/capabilities/gemini_deep_research/compat/operator_adapter.py @@ -1,5 +1,9 @@ -"""Backward-compat adapter: bind core BrowserOperatorPort to the existing -``browser_job_runtime`` (DeepResearchBrowser logical operator). +"""Backward-compat adapters for Gemini Deep Research browser execution. + +The capability package is Gemini-specific, but it runs through the generic +``browser_job_runtime`` substrate. Keep ``DeepResearchBrowserAdapter`` for +older callers, and expose ``DeepResearchGeminiAdapter`` so the logical operator +binding can be proven without relying on comments/config convention. Does NOT modify PROTECTED_CORE. Integrates only through public functions of the existing operator runtime (submit/poll/collect_browser_job). Real Gemini @@ -82,8 +86,14 @@ def __init__( self, actor_id: str = "gemini_deep_research", mock_sequence: list[str] | None = None, + logical_operator: str = "DeepResearchBrowser", + ingress_channel: str = "gemini_deep_research", + artifact_kind: str = "gemini_deep_research", ) -> None: self.actor_id = actor_id + self.logical_operator = logical_operator + self.ingress_channel = ingress_channel + self.artifact_kind = artifact_kind # default safe mock trajectory; ignored when real calls are enabled self.mock_sequence = mock_sequence or ["planning", "running", "done"] self._rt = _import_runtime() @@ -107,7 +117,7 @@ def optimize_prompt(self, req: ResearchRequest, template_id: str) -> OptimizedPr def _build_envelope(self, prompt: OptimizedPrompt) -> dict[str, Any]: return { "task_type": "RESEARCH", - "logical_operator": "DeepResearchBrowser", + "logical_operator": self.logical_operator, "objective": ( "Run Gemini Deep Research for the optimized research prompt and " "return a classified, sourced research report." @@ -115,9 +125,9 @@ def _build_envelope(self, prompt: OptimizedPrompt) -> dict[str, Any]: "url": _GEMINI_URL, "target_url": _GEMINI_URL, "allowed_domains": ["gemini.google.com"], - "ingress_channel": "gemini_deep_research", + "ingress_channel": self.ingress_channel, "raw_request": prompt.prompt_text, - "artifact_kind": "gemini_deep_research", + "artifact_kind": self.artifact_kind, "capture_policy": {"mode": "whole_conversation", "messages_required": True}, } @@ -190,3 +200,20 @@ def _extract_report(raw: dict[str, Any]) -> tuple[str | None, list[Reference]]: if isinstance(r, dict) and r.get("category") and r.get("title") and r.get("url"): refs.append(Reference(category=r["category"], title=r["title"], url=r["url"])) return report_text, refs + + +class DeepResearchGeminiAdapter(DeepResearchBrowserAdapter): + """Gemini-specific adapter bound to the DeepResearchGemini logical operator.""" + + def __init__( + self, + actor_id: str = "gemini_deep_research", + mock_sequence: list[str] | None = None, + ) -> None: + super().__init__( + actor_id=actor_id, + mock_sequence=mock_sequence, + logical_operator="DeepResearchGemini", + ingress_channel="gemini_deep_research", + artifact_kind="gemini_deep_research", + ) diff --git a/harness/lib/cocoindex_adapter.py b/harness/lib/cocoindex_adapter.py new file mode 100644 index 000000000..f6c25c069 --- /dev/null +++ b/harness/lib/cocoindex_adapter.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""CocoIndex adapter surface for Mirage search. + +The adapter prefers a configured CocoIndex command when available. When that +command is not installed yet, it falls back to a bounded read-only code scan and +marks the source as degraded so callers can see that this is not a healthy +CocoIndex index response. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +HARNESS_DIR = Path(os.environ.get("HARNESS_DIR", str(Path.home() / ".solar" / "harness"))) +ADAPTER_TIMEOUT_S = float(os.environ.get("SOLAR_COCO_TIMEOUT_S", "3")) + + +def _hash_text(value: str) -> str: + return "sha256:" + hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest() + + +def _configured_query_cmd(query: str, limit: int) -> list[str] | None: + template = os.environ.get("SOLAR_COCO_QUERY_CMD", "").strip() + if template: + return template.format(query=query, limit=limit).split() + solar_bin = shutil.which("solar-harness") + if solar_bin: + return [solar_bin, "coco", "query", query, "--json", "--limit", str(limit)] + return None + + +def _normalize_command_hit(item: dict[str, Any], index: int) -> dict[str, Any]: + snippet = str(item.get("snippet") or item.get("text") or item.get("summary") or "")[:500] + path = str(item.get("path") or item.get("symbol") or item.get("id") or f"hit-{index}") + layer = str(item.get("layer") or item.get("capability") or "code-symbol") + if layer in {"callgraph", "code_callgraph"}: + layer = "code-callgraph" + elif layer in {"chunk", "code_chunk"}: + layer = "code-chunk" + elif layer not in {"code-symbol", "code-callgraph", "code-chunk", "retrieval-evidence"}: + layer = "code-symbol" + source_hash = str(item.get("source_hash") or _hash_text(path + "\n" + snippet)) + return { + "mount": "/cocoindex", + "path": str(item.get("uri") or f"cocoindex://{path.lstrip('/')}"), + "source_type": "cocoindex", + "layer": layer, + "snippet": snippet, + "provenance": str(item.get("provenance") or "cocoindex:query"), + "score_or_rank": float(item.get("score_or_rank", item.get("score", 0.5)) or 0.5), + "source_hash": source_hash, + "lineage": item.get("lineage") if isinstance(item.get("lineage"), list) else [path], + "degraded": bool(item.get("degraded", False)), + "degraded_reason": item.get("degraded_reason"), + } + + +def _query_external(query: str, limit: int) -> tuple[list[dict[str, Any]], str | None]: + cmd = _configured_query_cmd(query, limit) + if not cmd: + return [], "cocoindex_cli_unavailable" + try: + proc = subprocess.run(cmd, text=True, capture_output=True, timeout=ADAPTER_TIMEOUT_S) + except FileNotFoundError: + return [], "cocoindex_cli_unavailable" + except subprocess.TimeoutExpired: + return [], "cocoindex_timeout" + except Exception as exc: + return [], f"cocoindex_error:{type(exc).__name__}" + if proc.returncode != 0: + return [], f"cocoindex_rc:{proc.returncode}" + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + return [], "cocoindex_bad_json" + raw_hits = data.get("hits", data) if isinstance(data, dict) else data + if not isinstance(raw_hits, list): + return [], "cocoindex_bad_json" + return [_normalize_command_hit(item, i) for i, item in enumerate(raw_hits) if isinstance(item, dict)][:limit], None + + +def _query_variants(query: str) -> list[str]: + variants = [query.strip()] + variants.extend(t for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}", query) if t.lower() not in {"code", "the", "and"}) + seen: list[str] = [] + for item in variants: + if item and item not in seen: + seen.append(item) + return seen + + +def _local_code_scan(query: str, limit: int) -> list[dict[str, Any]]: + roots = [ + HARNESS_DIR / "lib", + HARNESS_DIR / "tools", + HARNESS_DIR / "scripts", + HARNESS_DIR / "tests", + ] + existing = [str(p) for p in roots if p.is_dir()] + if not existing: + return [] + rg = shutil.which("rg") + if not rg: + return [] + hits: list[dict[str, Any]] = [] + for variant in _query_variants(query): + cmd = [ + rg, + "--json", + "--ignore-case", + "--max-count", + str(max(1, limit)), + "--max-filesize", + "512K", + "-g", + "*.py", + "--", + variant, + *existing, + ] + try: + proc = subprocess.run(cmd, text=True, capture_output=True, timeout=ADAPTER_TIMEOUT_S) + except Exception: + continue + if proc.returncode not in (0, 1): + continue + for line in proc.stdout.splitlines(): + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "match": + continue + data = entry.get("data") or {} + file_path = str((data.get("path") or {}).get("text") or "") + line_no = data.get("line_number", 0) + text = str((data.get("lines") or {}).get("text") or "").strip() + if not file_path or not text: + continue + rel = os.path.relpath(file_path, str(HARNESS_DIR)) + source_hash = _hash_text(f"{rel}:{line_no}:{text}") + hits.append({ + "mount": "/cocoindex", + "path": f"cocoindex://code/{rel}:{line_no}", + "source_type": "cocoindex", + "layer": "code-chunk", + "snippet": text[:500], + "provenance": f"cocoindex:local-code-scan:{rel}:{line_no}", + "score_or_rank": 0.45, + "source_hash": source_hash, + "lineage": [f"repo:Solar", f"file:{rel}", f"line:{line_no}"], + "degraded": True, + "degraded_reason": "cocoindex_cli_unavailable:local_code_scan_fallback", + }) + if len(hits) >= limit: + break + if len(hits) >= limit: + break + return hits + + +def search(query: str, *, limit: int = 5, filters: dict[str, Any] | None = None) -> tuple[list[dict[str, Any]], bool, str | None]: + hits, degraded_reason = _query_external(query, limit) + if hits: + return hits, True, degraded_reason + fallback_hits = _local_code_scan(query, limit) + if fallback_hits: + return fallback_hits, True, fallback_hits[0].get("degraded_reason") + return [], degraded_reason is None, degraded_reason or "cocoindex:no_results" + + +def health_check() -> dict[str, Any]: + cmd = _configured_query_cmd("__health__", 1) + command_available = bool(cmd and shutil.which(cmd[0])) + return { + "ok": command_available, + "source_type": "cocoindex", + "command_available": command_available, + "degraded": not command_available, + "degraded_reason": None if command_available else "cocoindex_cli_unavailable", + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } diff --git a/harness/lib/compile_eval/harness.py b/harness/lib/compile_eval/harness.py index cd0b35092..39ba11582 100644 --- a/harness/lib/compile_eval/harness.py +++ b/harness/lib/compile_eval/harness.py @@ -18,11 +18,8 @@ import dataclasses import datetime import datetime as _dt -import importlib.util import json -import sys import uuid -from pathlib import Path from typing import Any, Optional from .dimensions import evaluate as _evaluate_dimensions @@ -52,81 +49,6 @@ class CompileEvalResult: hard_validators_passed: bool hard_validator_failures: list[str] hard_validator_details: dict[str, tuple[bool, str]] - compile_artifacts: dict[str, Any] = dataclasses.field(default_factory=dict) - side_info: dict[str, Any] = dataclasses.field(default_factory=dict) - - -def _load_codex_pm_router_module(): - tools_dir = Path(__file__).resolve().parents[2] / "tools" - module_path = tools_dir / "codex_pm_router.py" - if "compile_eval_codex_pm_router" in sys.modules: - return sys.modules["compile_eval_codex_pm_router"] - spec = importlib.util.spec_from_file_location("compile_eval_codex_pm_router", module_path) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - sys.modules["compile_eval_codex_pm_router"] = module - spec.loader.exec_module(module) - return module - - -def _profile_policy_params(profile: dict[str, Any], key: str) -> dict[str, Any]: - policies = profile.get("policies") if isinstance(profile.get("policies"), dict) else {} - policy = policies.get(key) if isinstance(policies, dict) else None - if not isinstance(policy, dict): - return {} - params = policy.get("params") - return params if isinstance(params, dict) else {} - - -def _normalize_compiled_artifacts(payload: dict[str, Any], candidate_profile: dict[str, Any]) -> dict[str, Any]: - requirement_ir = dict(payload.get("requirement_ir") or {}) - compiled = dict(payload.get("compiled_artifacts") or {}) - task_dag = dict(compiled.get("task_dag") or payload.get("task_graph_skeleton") or {}) - contract_files = compiled.get("contract_files") if isinstance(compiled.get("contract_files"), dict) else {} - product_contract = contract_files.get("product") if isinstance(contract_files, dict) else {} - product_contract = product_contract if isinstance(product_contract, dict) else {} - acceptance_list = list(product_contract.get("acceptance") or []) - acceptance_map = { - f"ACC-{idx + 1}": str(item) - for idx, item in enumerate(acceptance_list) - if str(item).strip() - } - contract_goal = str(product_contract.get("goal") or requirement_ir.get("normalized_goal") or "").strip() - traces = { - "planner": {"nodes": [str(node.get("id") or "") for node in (task_dag.get("nodes") or []) if str(node.get("id") or "")]}, - "builder": {"nodes": [str(node.get("id") or "") for node in (task_dag.get("nodes") or []) if str(node.get("id") or "")]}, - "evaluator": {"nodes": [str(node.get("id") or "") for node in (task_dag.get("nodes") or []) if str(node.get("id") or "")]}, - } - return { - "requirement_ir": { - "goal": requirement_ir.get("normalized_goal") or requirement_ir.get("user_intent") or "", - "success_metrics": acceptance_list, - "non_goals": list(product_contract.get("non_goals") or []), - "schema_version": requirement_ir.get("schema_version", ""), - "source_requirement_ir": requirement_ir, - }, - "contracts": { - "goal": contract_goal, - "policies": candidate_profile.get("policies", {}), - "acceptance": acceptance_map, - "manifest": compiled.get("contracts_bundle") or {}, - "product_contract": product_contract, - }, - "dag": task_dag, - "traces": traces, - "closure": compiled.get("closure_record") or {}, - "task_dag_state": compiled.get("task_dag_state") or {}, - } - - -def _expected_case_payload(case: Any) -> dict[str, Any]: - return { - "requirement_text": getattr(case, "input", ""), - "expected_ir": getattr(case, "expected_ir", {}) or {}, - "expected_contracts": getattr(case, "expected_contracts", []) or [], - "expected_dag": getattr(case, "expected_dag", {}) or {}, - "sprint_id": getattr(case, "sprint_id", "") or "", - } class CompileGEPAAdapter: @@ -163,7 +85,6 @@ def evaluate( expected: dict[str, Any], *, golden_case_id: str = "", - side_info: Optional[dict[str, Any]] = None, ) -> CompileEvalResult: """Evaluate artifacts and compute ASI score + dimension breakdown. @@ -201,8 +122,6 @@ def evaluate( hard_validators_passed=hv_result.passed, hard_validator_failures=hv_result.failures, hard_validator_details=hv_result.details, - compile_artifacts=artifacts, - side_info=side_info or {}, ) # Record trace @@ -228,34 +147,6 @@ def evaluate( return result - def compile_case( - self, - candidate_profile: dict[str, Any], - case: Any, - ) -> tuple[dict[str, Any], dict[str, Any]]: - """Compile one golden case via the deterministic requirement compiler.""" - router = _load_codex_pm_router_module() - intake_kwargs: dict[str, Any] = { - "text": getattr(case, "input", ""), - "sprint_id": getattr(case, "sprint_id", "") or "", - "compiler_profile": candidate_profile, - } - raw_payload = router.build_pm_intake(**intake_kwargs) - normalized = _normalize_compiled_artifacts(raw_payload, candidate_profile) - compile_side_info = { - "profile_id": candidate_profile.get("profile_id", ""), - "profile_version": candidate_profile.get("version", 0), - "golden_case_id": getattr(case, "sprint_id", "") or "", - "request_text": getattr(case, "input", ""), - "raw_payload_summary": { - "classification": raw_payload.get("classification"), - "canonical_request_type": raw_payload.get("canonical_request_type"), - "dag_variant": raw_payload.get("dag_variant"), - "node_count": len((raw_payload.get("compiled_artifacts") or {}).get("task_dag", {}).get("nodes", []) or []), - }, - } - return normalized, compile_side_info - def fitness_function( self, candidate_profile: dict[str, Any], @@ -280,13 +171,20 @@ def fitness_function( scores: list[float] = [] for case in golden_cases: - artifacts, compile_side_info = self.compile_case(candidate_profile, case) - expected = _expected_case_payload(case) + # Build artifacts from the golden case expected data + artifacts = { + "requirement_ir": case.expected_ir, + "contracts": case.expected_contracts[0] if case.expected_contracts else {}, + "dag": case.expected_dag, + "traces": {}, + } + expected = { + "requirement_text": case.input, + } result = self.evaluate( artifacts, expected, golden_case_id=case.sprint_id, - side_info=compile_side_info, ) scores.append(result.asi_score) diff --git a/harness/lib/compiler_profile/schema.py b/harness/lib/compiler_profile/schema.py index fca3de31e..230577b8c 100644 --- a/harness/lib/compiler_profile/schema.py +++ b/harness/lib/compiler_profile/schema.py @@ -13,12 +13,12 @@ tags: list[str] — categorisation tags created_at: str (ISO 8601) policies: dict with exactly 6 keys - intake_policy: {version: str, text?: str, params: dict} - requirement_ir_policy: {version: str, text?: str, params: dict} - contract_compiler_policy: {version: str, text?: str, params: dict} - dag_compiler_policy: {version: str, text?: str, params: dict} - evidence_policy: {version: str, text?: str, params: dict} - handoff_policy: {version: str, text?: str, params: dict} + intake_policy: {version: str, params: dict} + requirement_ir_policy: {version: str, params: dict} + contract_compiler_policy: {version: str, params: dict} + dag_compiler_policy: {version: str, params: dict} + evidence_policy: {version: str, params: dict} + handoff_policy: {version: str, params: dict} """ from __future__ import annotations @@ -115,8 +115,6 @@ def validate_profile(data: dict[str, Any]) -> tuple[bool, list[str]]: errors.append(f"'policies.{key}' missing 'version'") elif not isinstance(policy["version"], str): errors.append(f"'policies.{key}.version' must be a string") - if "text" in policy and not isinstance(policy["text"], str): - errors.append(f"'policies.{key}.text' must be a string when present") if "params" not in policy: errors.append(f"'policies.{key}' missing 'params'") elif not isinstance(policy["params"], dict): diff --git a/harness/lib/context_projection.py b/harness/lib/context_projection.py index fd9504a9a..8d0f48b21 100644 --- a/harness/lib/context_projection.py +++ b/harness/lib/context_projection.py @@ -123,6 +123,9 @@ def _retrieve_kb_hits( "layer": hit.get("layer") or "", "score": hit.get("score", 0), "relevance_score": hit.get("score", 0), + "source_hash": hit.get("source_hash") or "", + "lineage": hit.get("lineage") or [], + "degraded_reason": hit.get("degraded_reason"), }) for degraded in data.get("degraded_sources", []) or []: hits.append({ @@ -143,6 +146,42 @@ def _retrieve_kb_hits( }] +def _source_counts(kb_hits: List[Dict[str, Any]]) -> Dict[str, int]: + counts: Dict[str, int] = {} + for hit in kb_hits: + source = str(hit.get("source") or "unknown") + if source == "degraded": + continue + counts[source] = counts.get(source, 0) + 1 + return counts + + +def _degraded_sources(kb_hits: List[Dict[str, Any]]) -> List[str]: + degraded: List[str] = [] + for hit in kb_hits: + if hit.get("degraded") or hit.get("source") == "degraded": + degraded.append(str(hit.get("degraded_reason") or hit.get("title") or hit.get("source") or "unknown")) + return sorted(set(degraded)) + + +def _lineage_refs(kb_hits: List[Dict[str, Any]]) -> List[str]: + refs: List[str] = [] + for hit in kb_hits: + lineage = hit.get("lineage") or [] + if isinstance(lineage, list): + refs.extend(str(x) for x in lineage if x) + elif lineage: + refs.append(str(lineage)) + path = hit.get("path") + if path: + refs.append(str(path)) + return sorted(set(refs))[:50] + + +def _source_hash_refs(kb_hits: List[Dict[str, Any]]) -> List[str]: + return sorted({str(hit.get("source_hash")) for hit in kb_hits if hit.get("source_hash")})[:50] + + class ContextProjection: """Builds model-visible context from session events.""" @@ -174,11 +213,15 @@ def build_context( budget = budget_tokens or 8000 # default ~32K chars events = self._log.all_events() if not events: + kb_hits: List[Dict[str, Any]] = [] + if query: + kb_hits = _retrieve_kb_hits(query) return ContextView( session_id=self.session_id, policy_name=policy_name, built_at=_now_ts(), budget_tokens=budget, + kb_hits=kb_hits, ) included_ids: List[str] = [] @@ -396,6 +439,10 @@ def record_context_injected( "summarized_ranges": view.summarized_ranges, "dropped_ranges": view.dropped_ranges, "kb_hits": view.kb_hits[:8], + "context_sources": _source_counts(view.kb_hits), + "degraded_sources": _degraded_sources(view.kb_hits), + "lineage_refs": _lineage_refs(view.kb_hits), + "source_hash_refs": _source_hash_refs(view.kb_hits), "context_text": text, "redaction_policy": "default_secret_patterns", "provenance": "projection over append-only session events plus unified knowledge recall", diff --git a/harness/lib/evolution_engine.py b/harness/lib/evolution_engine.py index 3408eec37..84b7d3c72 100644 --- a/harness/lib/evolution_engine.py +++ b/harness/lib/evolution_engine.py @@ -20,6 +20,10 @@ from capability_registry import LEVEL_REVERSE, _open_db as open_capability_db # type: ignore # noqa: E402 from eval_runner import run_pack # type: ignore # noqa: E402 from failure_miner import mine as mine_failures # type: ignore # noqa: E402 +try: + from runtime_bridge import record_legacy_event # type: ignore # noqa: E402 +except Exception: + record_legacy_event = None # type: ignore LEVEL_RANK = {"dead_end": 1, "basic_usable": 2, "default_usable": 3, "closed_loop": 4} RANK_LEVEL = {v: k for k, v in LEVEL_RANK.items()} @@ -178,6 +182,19 @@ def _append_event(sprint_id: str, event: str, severity: str, payload: dict[str, sprint_events = SPRINTS_DIR / f"{sprint_id}.events.jsonl" with sprint_events.open("a", encoding="utf-8") as fh: fh.write(json.dumps(obj, ensure_ascii=False) + "\n") + if record_legacy_event is not None: + try: + # Bridge sprint-scoped legacy events into session-log v2 so + # evolution telemetry cannot drift away from runtime state. + record_legacy_event( + sprint_id, + event, + "solar-evolution-engine", + {"severity": severity, **payload}, + harness_dir=HARNESS_DIR, + ) + except Exception: + pass def _event_counts(event_names: set[str]) -> dict[str, int]: diff --git a/harness/lib/github_comparison_view.py b/harness/lib/github_comparison_view.py new file mode 100644 index 000000000..143fc38d2 --- /dev/null +++ b/harness/lib/github_comparison_view.py @@ -0,0 +1,314 @@ +"""GitHub dual-run comparison view for the /ai-influence status page. + +Loads GitHub New and Legacy operator metadata from reports/github/ and +renders a side-by-side comparison with >= 4 dimensions: + items_processed, report_quality, error_rate, duration_s + +Gracefully degrades to New-only when Legacy data is absent. + +Usage: + from lib.github_comparison_view import build_comparison, render_comparison_html + data = build_comparison(reports_dir) + html = render_comparison_html(data) + +Integration: + Called by ai_influence_status_page.py to inject the GitHub comparison + block via the {{GITHUB_COMPARISON}} template placeholder. +""" +from __future__ import annotations + +import html +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +HARNESS_ROOT = Path(os.environ.get("HARNESS_ROOT", Path.home() / ".solar" / "harness")) +DEFAULT_REPORTS_DIR = HARNESS_ROOT / "reports" + +# Sub-directories within reports/github/ for each variant +_NEW_SUBDIR = "new" +_LEGACY_SUBDIR = "legacy" + + +def esc(v: Any) -> str: + return html.escape(str(v if v is not None else "N/A"), quote=True) + + +@dataclass +class GitHubRunMetrics: + """Normalised metrics for one GitHub operator run.""" + + variant: str # "new" | "legacy" + run_status: str = "no_data" + items_processed: int | None = None + items_attempted: int | None = None + report_quality: float | None = None # 0.0–1.0 + error_rate: float | None = None # errors / attempted, 0.0–1.0 + duration_s: float | None = None + last_run: str | None = None + artifacts: dict[str, str] = field(default_factory=dict) + errors: list[dict[str, Any]] = field(default_factory=list) + raw_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class GitHubComparisonData: + """Comparison data for both GitHub variants.""" + + new_run: GitHubRunMetrics + legacy_run: GitHubRunMetrics | None # None → degrade to New-only + + +# ── metadata loading ────────────────────────────────────────────────────────── + + +def _load_metadata(path: Path) -> dict[str, Any] | None: + """Load a metadata.json; return None if absent or unreadable.""" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + +def _derive_metrics(variant: str, meta: dict[str, Any] | None) -> GitHubRunMetrics: + """Convert raw metadata dict to GitHubRunMetrics.""" + if meta is None: + return GitHubRunMetrics(variant=variant) + + run_status = meta.get("run_status", "no_data") + items_processed = meta.get("items_processed") or meta.get("processed_count") + items_attempted = meta.get("items_attempted") or meta.get("source_count") or items_processed + errors = meta.get("errors", []) + + # report_quality: 1.0 if succeeded + artifacts present, scales down otherwise + has_artifacts = bool(meta.get("artifacts")) + if run_status == "succeeded" and has_artifacts: + quality = 1.0 + elif run_status == "succeeded": + quality = 0.8 + elif run_status in ("failed", "timeout"): + quality = 0.0 + elif run_status == "running": + quality = None + else: + quality = None + + # error_rate + error_rate: float | None = None + if items_attempted and items_attempted > 0: + error_count = len(errors) if errors else meta.get("error_count", 0) + error_rate = round(error_count / items_attempted, 4) + elif run_status in ("failed", "timeout"): + error_rate = 1.0 + + duration_s = meta.get("duration_seconds") or meta.get("duration_s") + last_run = meta.get("started_at") or meta.get("last_run") + + return GitHubRunMetrics( + variant=variant, + run_status=run_status, + items_processed=items_processed, + items_attempted=items_attempted, + report_quality=quality, + error_rate=error_rate, + duration_s=duration_s, + last_run=last_run, + artifacts=meta.get("artifacts", {}), + errors=errors, + raw_metadata=meta, + ) + + +def build_comparison(reports_dir: Path | None = None) -> GitHubComparisonData: + """Build comparison data from the reports directory. + + Looks for: + reports/github/new/metadata.json → New variant + reports/github/legacy/metadata.json → Legacy variant + reports/github/metadata.json → fallback (treated as New) + + Args: + reports_dir: Base reports directory. Defaults to HARNESS_ROOT/reports. + + Returns: + GitHubComparisonData with new_run and optional legacy_run. + """ + base = reports_dir if reports_dir is not None else DEFAULT_REPORTS_DIR + github_dir = base / "github" + + # Try structured new/legacy sub-dirs first, then flat fallback + new_meta = ( + _load_metadata(github_dir / _NEW_SUBDIR / "metadata.json") + or _load_metadata(github_dir / "metadata.json") + ) + legacy_meta = _load_metadata(github_dir / _LEGACY_SUBDIR / "metadata.json") + + new_run = _derive_metrics("new", new_meta) + legacy_run = _derive_metrics("legacy", legacy_meta) if legacy_meta is not None else None + + return GitHubComparisonData(new_run=new_run, legacy_run=legacy_run) + + +# ── HTML rendering ──────────────────────────────────────────────────────────── + +_COMPARISON_DIMENSIONS = [ + ("items_processed", "Items Processed", lambda m: str(m.items_processed) if m.items_processed is not None else "—"), + ("report_quality", "Report Quality", lambda m: f"{m.report_quality:.0%}" if m.report_quality is not None else "—"), + ("error_rate", "Error Rate", lambda m: f"{m.error_rate:.1%}" if m.error_rate is not None else "—"), + ("duration_s", "Run Duration", lambda m: _fmt_duration(m.duration_s)), +] + + +def _fmt_duration(seconds: float | None) -> str: + if seconds is None: + return "—" + if seconds < 60: + return f"{seconds:.1f}s" + if seconds < 3600: + return f"{seconds / 60:.1f}m" + return f"{seconds / 3600:.1f}h" + + +def _status_class(run_status: str) -> str: + return { + "succeeded": "good", + "running": "warn", + "failed": "bad", + "timeout": "bad", + "cancelled": "muted", + }.get(run_status, "muted") + + +def _render_run_column(metrics: GitHubRunMetrics, label: str) -> str: + """Render a single run column (New or Legacy).""" + status_cls = _status_class(metrics.run_status) + last_run = esc(metrics.last_run or "Never") + + rows = "" + for dim_id, dim_label, getter in _COMPARISON_DIMENSIONS: + value = getter(metrics) + # Highlight bad error_rate + cell_cls = "" + if dim_id == "error_rate" and metrics.error_rate is not None and metrics.error_rate > 0.1: + cell_cls = ' class="bad"' + elif dim_id == "report_quality" and metrics.report_quality is not None and metrics.report_quality < 0.5: + cell_cls = ' class="bad"' + rows += f'