diff --git a/libs/hackbot-runtime/hackbot_runtime/runtime.py b/libs/hackbot-runtime/hackbot_runtime/runtime.py index 30af26eea0..7dec6d22b2 100644 --- a/libs/hackbot-runtime/hackbot_runtime/runtime.py +++ b/libs/hackbot-runtime/hackbot_runtime/runtime.py @@ -1,6 +1,7 @@ import asyncio import inspect import logging +import os import sys import traceback from collections.abc import Awaitable, Callable @@ -32,6 +33,11 @@ _CONFIG_NAME = "hackbot.toml" _SUMMARY_NAME = "summary.json" _AGENT_LOG_KEY = "logs/agent.log" +_TRANSCRIPTS_PREFIX = "transcripts/" +_TRANSCRIPT_CONTENT_TYPES = { + ".jsonl": "application/x-ndjson", + ".json": "application/json", +} def _configure_auth() -> None: @@ -135,6 +141,35 @@ def _publish_log(ctx: HackbotContext) -> None: ctx.publish_file(_AGENT_LOG_KEY, ctx.log_path, "text/plain; charset=utf-8") +def _claude_projects_dir() -> Path: + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + return (Path(config_dir) if config_dir else Path.home() / ".claude") / "projects" + + +def _publish_transcripts(ctx: HackbotContext) -> None: + """Publish the Claude Code session transcripts written during the run. + + The CLI the SDK spawns keeps every session under ``~/.claude/projects``, with + subagent transcripts beside it in ``/subagents/``. The container is + fresh per execution, so all of them belong to this run. + """ + projects = _claude_projects_dir() + if not projects.is_dir(): + return + for session_file in sorted(projects.glob("*/*.jsonl")): + session = session_file.stem + _publish_transcript(ctx, f"{_TRANSCRIPTS_PREFIX}{session}.jsonl", session_file) + subagents = session_file.with_suffix("") / "subagents" + for path in sorted(subagents.glob("*")) if subagents.is_dir() else (): + if path.is_file(): + key = f"{_TRANSCRIPTS_PREFIX}{session}/subagents/{path.name}" + _publish_transcript(ctx, key, path) + + +def _publish_transcript(ctx: HackbotContext, key: str, path: Path) -> None: + ctx.publish_file(key, path, _TRANSCRIPT_CONTENT_TYPES.get(path.suffix)) + + def _finish(ctx: HackbotContext, outcome: object) -> int: """Write summary.json from the agent's outcome and return the exit code. @@ -163,6 +198,11 @@ def _finish(ctx: HackbotContext, outcome: object) -> int: except Exception: log.exception("Failed to publish agent log") + try: + _publish_transcripts(ctx) + except Exception: + log.exception("Failed to publish Claude Code transcripts") + try: ctx.publish_changes() except Exception: diff --git a/libs/hackbot-runtime/tests/test_runtime.py b/libs/hackbot-runtime/tests/test_runtime.py index 4a638a79a0..8ab742cc21 100644 --- a/libs/hackbot-runtime/tests/test_runtime.py +++ b/libs/hackbot-runtime/tests/test_runtime.py @@ -135,6 +135,40 @@ def test_finish_skips_log_when_none_written(tmp_path): assert not (tmp_path / "artifacts" / "local-test" / "logs" / "agent.log").exists() +def test_finish_publishes_claude_transcripts(tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) + project = tmp_path / "claude" / "projects" / "-tmp-repo" + subagents = project / "sess-1" / "subagents" + subagents.mkdir(parents=True) + (project / "sess-1.jsonl").write_text('{"type":"user"}\n') + (project / "sess-2.jsonl").write_text('{"type":"assistant"}\n') + (subagents / "agent-abc.jsonl").write_text('{"type":"user"}\n') + (subagents / "agent-abc.meta.json").write_text('{"agentType":"Explore"}') + ctx = _ctx(tmp_path) + + _finish(ctx, HackbotAgentResult(num_turns=1)) + + transcripts = tmp_path / "artifacts" / "local-test" / "transcripts" + assert sorted( + str(p.relative_to(transcripts)) for p in transcripts.rglob("*") if p.is_file() + ) == [ + "sess-1.jsonl", + "sess-1/subagents/agent-abc.jsonl", + "sess-1/subagents/agent-abc.meta.json", + "sess-2.jsonl", + ] + assert (transcripts / "sess-2.jsonl").read_text() == '{"type":"assistant"}\n' + + +def test_finish_skips_transcripts_without_projects_dir(tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) + ctx = _ctx(tmp_path) + + _finish(ctx, HackbotAgentResult(num_turns=1)) + + assert not (tmp_path / "artifacts" / "local-test" / "transcripts").exists() + + def test_runs_are_namespaced_by_run_id(tmp_path): ctx_a = _ctx(tmp_path, run_id="run-a") ctx_b = _ctx(tmp_path, run_id="run-b") diff --git a/services/hackbot-ui/README.md b/services/hackbot-ui/README.md index bb06a0c912..0a696ebddb 100644 --- a/services/hackbot-ui/README.md +++ b/services/hackbot-ui/README.md @@ -53,6 +53,17 @@ to `@mozilla.com` accounts: | Download artifact | `GET /runs/{run_id}/artifacts/{path}` † | | (available) | `GET /agents` | +## Firefox Profiler + +hackbot-runtime publishes the Claude Code session transcripts of a run as +`transcripts/*.jsonl` artifacts. When a run has them, the Traces row of its page +offers "Firefox Profiler" next to Weave: `GET /api/runs/{run_id}/profile` +(`lib/profile.ts`) downloads the transcripts and converts them with +[claude-profiler](https://github.com/fqueze/claude-profiler), and the page hands +the result to a `profiler.firefox.com/from-post-message/` window +(`lib/profiler.ts`). Nothing is exposed outside the SSO session; use the +profiler's own Upload button for a shareable link. + ## Local development 1. Install dependencies: diff --git a/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts b/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts new file mode 100644 index 0000000000..9536d7c05d --- /dev/null +++ b/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { apiErrorResponse } from "@/lib/api-errors"; +import { getRun } from "@/lib/hackbot"; +import { buildProfile } from "@/lib/profile"; +import { getAuthedEmail } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +// GET /api/runs/:runId/profile — the run's Claude Code transcripts as a Firefox +// Profiler profile, built on demand from the transcripts/ artifacts. +export async function GET( + _req: Request, + { params }: { params: Promise<{ runId: string }> } +) { + if (!(await getAuthedEmail())) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { runId } = await params; + try { + const profile = await buildProfile(await getRun(runId)); + // Transcripts are only listed once the run is finalized, so the profile is + // stable from then on. + return NextResponse.json(profile, { + headers: { "Cache-Control": "private, max-age=3600" }, + }); + } catch (err) { + return apiErrorResponse(err); + } +} diff --git a/services/hackbot-ui/app/globals.css b/services/hackbot-ui/app/globals.css index cca871a241..716287eaee 100644 --- a/services/hackbot-ui/app/globals.css +++ b/services/hackbot-ui/app/globals.css @@ -176,6 +176,17 @@ button.secondary { border: 1px solid var(--border); color: var(--muted); } +button.link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); +} +button.link:hover:not(:disabled) { + background: none; + text-decoration: underline; +} .badge { display: inline-block; diff --git a/services/hackbot-ui/claude-profiler.d.ts b/services/hackbot-ui/claude-profiler.d.ts new file mode 100644 index 0000000000..72e05278df --- /dev/null +++ b/services/hackbot-ui/claude-profiler.d.ts @@ -0,0 +1,11 @@ +declare module "claude-profiler" { + export interface Subagent { + id: string; + meta: object; + entries: unknown[]; + } + export function createFirefoxProfile( + entries: unknown[], + subagents: Subagent[] + ): object; +} diff --git a/services/hackbot-ui/components/RunDetail.tsx b/services/hackbot-ui/components/RunDetail.tsx index c6c7e865c4..9d232b500a 100644 --- a/services/hackbot-ui/components/RunDetail.tsx +++ b/services/hackbot-ui/components/RunDetail.tsx @@ -4,7 +4,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { injectProfile, PROFILER_INJECT_URL } from "@/lib/profiler"; import { updateRunStatus } from "@/lib/store"; +import { hasTranscripts } from "@/lib/transcripts"; import { isFailed, isTerminal, @@ -82,6 +84,8 @@ export function RunDetail({ const [applyError, setApplyError] = useState(null); const [retriggering, setRetriggering] = useState(false); const [retriggerError, setRetriggerError] = useState(null); + const [profiling, setProfiling] = useState(false); + const [profileError, setProfileError] = useState(null); const timer = useRef | null>(null); const fetchRun = useCallback(async () => { @@ -172,6 +176,28 @@ export function RunDetail({ } }, [runId, router]); + const openProfile = useCallback(async () => { + setProfiling(true); + setProfileError(null); + // Opened from the click itself so popup blockers treat it as user-initiated; + // the profile is handed over once it is built. + const popup = window.open(PROFILER_INJECT_URL, "_blank"); + try { + if (!popup) throw new Error("the browser blocked the profiler window"); + const res = await fetch(`/api/runs/${runId}/profile`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error ?? `Request failed (${res.status})`); + } + await injectProfile(popup, await res.json()); + } catch (err) { + popup?.close(); + setProfileError((err as Error).message); + } finally { + setProfiling(false); + } + }, [runId]); + if (!run && error) { return
{error}
; } @@ -233,6 +259,11 @@ export function RunDetail({ {retriggerError && (
Retrigger failed: {retriggerError}
)} + {profileError && ( +
+ Could not open the profile: {profileError} +
+ )}

Run

@@ -258,6 +289,19 @@ export function RunDetail({ Weave + {hasTranscripts(run.artifacts) && ( + <> + {", "} + + + )}