Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions libs/hackbot-runtime/hackbot_runtime/runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import inspect
import logging
import os
import sys
import traceback
from collections.abc import Awaitable, Callable
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 ``<session>/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.

Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions libs/hackbot-runtime/tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions services/hackbot-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions services/hackbot-ui/app/api/runs/[runId]/profile/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions services/hackbot-ui/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions services/hackbot-ui/claude-profiler.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions services/hackbot-ui/components/RunDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,6 +84,8 @@ export function RunDetail({
const [applyError, setApplyError] = useState<string | null>(null);
const [retriggering, setRetriggering] = useState(false);
const [retriggerError, setRetriggerError] = useState<string | null>(null);
const [profiling, setProfiling] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

const fetchRun = useCallback(async () => {
Expand Down Expand Up @@ -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 <div className="error-banner">{error}</div>;
}
Expand Down Expand Up @@ -233,6 +259,11 @@ export function RunDetail({
{retriggerError && (
<div className="error-banner">Retrigger failed: {retriggerError}</div>
)}
{profileError && (
<div className="error-banner">
Could not open the profile: {profileError}
</div>
)}

<div className="panel">
<h2>Run</h2>
Expand All @@ -258,6 +289,19 @@ export function RunDetail({
<a href={tracesUrl} target="_blank" rel="noreferrer">
Weave
</a>
{hasTranscripts(run.artifacts) && (
<>
{", "}
<button
type="button"
className="link"
onClick={openProfile}
disabled={profiling}
>
{profiling ? "Firefox Profiler…" : "Firefox Profiler"}
</button>
</>
)}
</dd>
</dl>
<button
Expand Down
52 changes: 52 additions & 0 deletions services/hackbot-ui/lib/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import "server-only";

import { createFirefoxProfile } from "claude-profiler";

import { getArtifactDownloadUrl, HackbotError } from "./hackbot";
import {
addSessionMarkers,
type Profile,
sessionSpans,
} from "./session-markers";
import { mergeSessions, parseJsonl, transcriptArtifacts } from "./transcripts";
import type { RunDoc } from "./types";

async function fetchArtifact(runId: string, name: string): Promise<string> {
const { url } = await getArtifactDownloadUrl(runId, name);
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) {
throw new HackbotError(`Could not download ${name} (${res.status})`, 502);
}
return res.text();
}

// Build a Firefox Profiler profile from the run's Claude Code transcripts.
export async function buildProfile(run: RunDoc): Promise<object> {
const { sessions, subagents } = transcriptArtifacts(run.artifacts);
if (sessions.length === 0) {
throw new HackbotError("Run has no transcripts", 404);
}
const [texts, agents] = await Promise.all([
Promise.all(sessions.map((name) => fetchArtifact(run.run_id, name))),
Promise.all(
subagents.map(async (s) => ({
id: s.id,
meta: s.meta
? (JSON.parse(await fetchArtifact(run.run_id, s.meta)) as object)
: {},
entries: parseJsonl(await fetchArtifact(run.run_id, s.jsonl)),
}))
),
]);
const parsed = texts.map(parseJsonl);
const profile = createFirefoxProfile(
mergeSessions(parsed),
agents
) as Profile;
addSessionMarkers(profile, sessionSpans(parsed));
// claude-profiler titles the track after the last session; the track is the
// whole run, so label it as such.
profile.threads[0].name = run.agent;
profile.threads[0].processName = run.run_id;
return profile;
}
65 changes: 65 additions & 0 deletions services/hackbot-ui/lib/profiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { injectProfile, PROFILER_ORIGIN } from "./profiler.ts";

type Listener = (event: { origin: string; data: unknown }) => void;

function fakeProfilerTab() {
const posted: { message: { name: string }; origin: string }[] = [];
const listeners = new Set<Listener>();
const source = {
addEventListener: (_type: string, fn: Listener) => listeners.add(fn),
removeEventListener: (_type: string, fn: Listener) => listeners.delete(fn),
};
const target = {
postMessage(message: { name: string }, origin: string) {
posted.push({ message, origin });
if (message.name === "ready:request") {
for (const fn of listeners) {
fn({ origin: PROFILER_ORIGIN, data: { name: "ready:response" } });
}
}
},
};
return { posted, listeners, source, target };
}

test("injects the profile once the profiler reports ready", async () => {
const tab = fakeProfilerTab();
const profile = { meta: { product: "Claude Code" } };

await injectProfile(tab.target as never, profile, tab.source as never);

assert.deepEqual(tab.posted, [
{ message: { name: "ready:request" }, origin: PROFILER_ORIGIN },
{ message: { name: "inject-profile", profile }, origin: PROFILER_ORIGIN },
]);
assert.equal(tab.listeners.size, 0);
});

test("ignores ready messages from other origins", async () => {
const tab = fakeProfilerTab();
const listeners = tab.listeners;
tab.target.postMessage = (message: { name: string }) => {
tab.posted.push({ message, origin: PROFILER_ORIGIN });
for (const fn of listeners) {
fn({ origin: "https://evil.example", data: { name: "ready:response" } });
}
};

let settled = false;
injectProfile(tab.target as never, {}, tab.source as never).then(
() => (settled = true),
() => (settled = true)
);
await new Promise((r) => setTimeout(r, 10));

assert.equal(settled, false);
assert.equal(listeners.size, 1);
for (const fn of listeners) {
fn({ origin: PROFILER_ORIGIN, data: { name: "ready:response" } });
}
await new Promise((r) => setTimeout(r, 0));
assert.equal(tab.posted.at(-1)?.message.name, "inject-profile");
});
Loading