Skip to content

Commit de21ec8

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): download saved session transcripts
Download saved transcripts from the session inspector when using built-in storage. Downloads preserve the original stored contents, including messages and runtime state, and use a `.jsonl` extension for indexed transcripts. Mono-RevId: 2bd347196eb5993c27d986ebb7abf460020b670e
1 parent 3dcafbd commit de21ec8

10 files changed

Lines changed: 666 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
Download a session’s original saved transcript file from the session inspector.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// @vitest-environment jsdom
2+
import { createServer, type Server, type ServerResponse } from "node:http";
3+
import { once } from "node:events";
4+
import { act, createElement } from "react";
5+
import { createRoot, type Root } from "react-dom/client";
6+
import { afterEach, expect, test } from "vitest";
7+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
8+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
9+
import { TranscriptDownloadButton } from "./TranscriptDownloadButton";
10+
11+
let server: Server | undefined;
12+
let root: Root | undefined;
13+
let container: HTMLDivElement;
14+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15+
16+
afterEach(async () => {
17+
if (root) await act(async () => root?.unmount());
18+
root = undefined;
19+
container?.remove();
20+
if (server) {
21+
server.closeAllConnections();
22+
await new Promise<void>((resolve, reject) =>
23+
server!.close((error) => (error ? reject(error) : resolve()))
24+
);
25+
server = undefined;
26+
}
27+
});
28+
29+
async function serve(handler: (response: ServerResponse) => void) {
30+
server = createServer((_request, response) => handler(response));
31+
server.listen(0, "127.0.0.1");
32+
await once(server, "listening");
33+
const address = server.address();
34+
if (!address || typeof address === "string") throw new Error("Expected TCP listener");
35+
return `http://127.0.0.1:${address.port}/resources/transcript-download`;
36+
}
37+
38+
async function render(resourcePath: string, initiallyAvailable: boolean) {
39+
container = document.createElement("div");
40+
document.body.append(container);
41+
root = createRoot(container);
42+
await act(async () =>
43+
root!.render(
44+
createElement(
45+
OperatingSystemContextProvider,
46+
{ platform: "mac" },
47+
createElement(
48+
ShortcutsProvider,
49+
null,
50+
createElement(
51+
"div",
52+
null,
53+
createElement("h1", null, "Session overview"),
54+
createElement(TranscriptDownloadButton, { resourcePath, initiallyAvailable })
55+
)
56+
)
57+
)
58+
)
59+
);
60+
}
61+
62+
async function settleUntil(condition: () => boolean) {
63+
const deadline = Date.now() + 2000;
64+
while (!condition() && Date.now() < deadline) {
65+
await act(async () => {
66+
await new Promise((resolve) => setTimeout(resolve, 10));
67+
});
68+
}
69+
expect(condition()).toBe(true);
70+
}
71+
72+
test("a seeded transcript uses a native attachment link without fetching the object", async () => {
73+
let requests = 0;
74+
const url = await serve((response) => {
75+
requests++;
76+
response.end();
77+
});
78+
await render(url, true);
79+
const link = container.querySelector<HTMLAnchorElement>('a[aria-label="Download transcript"]');
80+
expect(link?.href).toBe(url);
81+
expect(link?.hasAttribute("download")).toBe(true);
82+
expect(requests).toBe(0);
83+
});
84+
85+
test("an HTML proxy error shows availability guidance and Retry recovers", async () => {
86+
let attempts = 0;
87+
const url = await serve((response) => {
88+
if (++attempts === 1) {
89+
response.writeHead(502, { "Content-Type": "text/html" });
90+
response.end("<html>Bad gateway</html>");
91+
} else {
92+
response.writeHead(200, { "Content-Type": "application/json" });
93+
response.end(JSON.stringify({ available: true }));
94+
}
95+
});
96+
await render(url, false);
97+
await settleUntil(() => !!container.querySelector('[role="alert"]'));
98+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
99+
"Could not check transcript availability."
100+
);
101+
expect(container.textContent).not.toContain("Unexpected token");
102+
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
103+
await settleUntil(() => !!container.querySelector("a[download]"));
104+
expect(attempts).toBe(2);
105+
});
106+
107+
test("a pending availability request does not block the overview and a missing object stays hidden", async () => {
108+
let pending: ServerResponse | undefined;
109+
const url = await serve((response) => {
110+
pending = response;
111+
});
112+
await render(url, false);
113+
await settleUntil(() => !!pending);
114+
expect(container.querySelector("h1")?.textContent).toBe("Session overview");
115+
expect(container.querySelector("a")).toBeNull();
116+
await act(async () => {
117+
pending!.writeHead(200, { "Content-Type": "application/json" });
118+
pending!.end(JSON.stringify({ available: false }));
119+
await new Promise((resolve) => setTimeout(resolve, 20));
120+
});
121+
expect(container.textContent).toBe("Session overview");
122+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ArrowDownTrayIcon } from "@heroicons/react/20/solid";
2+
import { useEffect, useState } from "react";
3+
import { Button, LinkButton } from "~/components/primitives/Buttons";
4+
import * as Property from "~/components/primitives/PropertyTable";
5+
6+
export function TranscriptDownloadButton({
7+
resourcePath,
8+
initiallyAvailable,
9+
}: {
10+
resourcePath: string;
11+
initiallyAvailable: boolean;
12+
}) {
13+
const [checkedAvailability, setAvailability] = useState<
14+
"loading" | "available" | "missing" | "error"
15+
>("loading");
16+
const availability = initiallyAvailable ? "available" : checkedAvailability;
17+
const [retry, setRetry] = useState(0);
18+
19+
useEffect(() => {
20+
if (initiallyAvailable) return;
21+
const controller = new AbortController();
22+
fetch(`${resourcePath}?check=1`, { signal: controller.signal })
23+
.then(async (response) => {
24+
// A proxy may return HTML; all failed checks use the same actionable message.
25+
if (response.redirected || !response.ok) throw new Error("Availability check failed");
26+
const body: unknown = await response.json();
27+
if (
28+
!body ||
29+
typeof body !== "object" ||
30+
!("available" in body) ||
31+
typeof body.available !== "boolean"
32+
) {
33+
throw new Error("Invalid availability response");
34+
}
35+
if (!controller.signal.aborted) setAvailability(body.available ? "available" : "missing");
36+
})
37+
.catch(() => {
38+
if (!controller.signal.aborted) setAvailability("error");
39+
});
40+
return () => controller.abort();
41+
}, [resourcePath, initiallyAvailable, retry]);
42+
43+
if (availability === "loading" || availability === "missing") return null;
44+
45+
return (
46+
<Property.Item>
47+
<Property.Label>Transcript</Property.Label>
48+
<Property.Value>
49+
{availability === "available" ? (
50+
<LinkButton
51+
to={resourcePath}
52+
download
53+
variant="secondary/small"
54+
LeadingIcon={ArrowDownTrayIcon}
55+
aria-label="Download transcript"
56+
>
57+
Download
58+
</LinkButton>
59+
) : (
60+
<div className="flex flex-col items-start gap-1">
61+
<span role="alert" className="text-xs">
62+
Could not check transcript availability.
63+
</span>
64+
<Button
65+
variant="secondary/small"
66+
onClick={() => {
67+
setAvailability("loading");
68+
setRetry((value) => value + 1);
69+
}}
70+
>
71+
Retry
72+
</Button>
73+
</div>
74+
)}
75+
</Property.Value>
76+
</Property.Item>
77+
);
78+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
descriptionForTaskRunStatus,
4646
TaskRunStatusCombo,
4747
} from "~/components/runs/v3/TaskRunStatus";
48+
import { TranscriptDownloadButton } from "~/components/sessions/v1/TranscriptDownloadButton";
4849
import { CloseSessionDialog } from "~/components/sessions/v1/CloseSessionDialog";
4950
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
5051
import { $replica } from "~/db.server";
@@ -946,6 +947,11 @@ function OverviewTab({
946947
)}
947948
</Property.Value>
948949
</Property.Item>
950+
<TranscriptDownloadButton
951+
key={session.friendlyId}
952+
initiallyAvailable={!!session.agentView.transcriptSeed}
953+
resourcePath={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodeURIComponent(session.friendlyId)}/transcript-download`}
954+
/>
949955
{session.currentRun ? (
950956
<Property.Item>
951957
<Property.Label>Current run</Property.Label>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { $replica } from "~/db.server";
4+
import { findProjectBySlug } from "~/models/project.server";
5+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
6+
import { logger } from "~/services/logger.server";
7+
import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server";
8+
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
9+
import {
10+
downloadTranscript,
11+
isTranscriptNotFound,
12+
} from "~/services/realtime/transcriptDownload.server";
13+
import { requireUserId } from "~/services/session.server";
14+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
15+
import {
16+
objectExistsInObjectStore,
17+
downloadObjectResponseFromObjectStore,
18+
} from "~/v3/objectStore.server";
19+
20+
const ParamsSchema = EnvironmentParamSchema.extend({ sessionParam: z.string() });
21+
const headers = { "Cache-Control": "private, no-store" };
22+
23+
// Cookie-auth only: saved runtime state must never be exposed to public session tokens.
24+
export async function loader({ request, params }: LoaderFunctionArgs) {
25+
const userId = await requireUserId(request);
26+
const { organizationSlug, projectParam, envParam, sessionParam } = ParamsSchema.parse(params);
27+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
28+
if (!project) return json({ error: "Project not found" }, { status: 404, headers });
29+
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
30+
if (!environment) return json({ error: "Environment not found" }, { status: 404, headers });
31+
const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam);
32+
if (!session) return json({ error: "Session not found" }, { status: 404, headers });
33+
34+
try {
35+
const storagePath = chatSnapshotStorageKey(session);
36+
const packet = { dataType: "application/store", data: storagePath };
37+
const location = { projectRef: project.externalRef, envSlug: environment.slug };
38+
if (new URL(request.url).searchParams.get("check") === "1") {
39+
return json({ available: await objectExistsInObjectStore(packet, location) }, { headers });
40+
}
41+
const object = await downloadObjectResponseFromObjectStore(packet, location);
42+
return downloadTranscript(object, storagePath, (error) => {
43+
logger.error("Session transcript download stream failed", {
44+
projectId: project.id,
45+
environmentId: environment.id,
46+
sessionId: session.friendlyId,
47+
error,
48+
});
49+
});
50+
} catch (error) {
51+
if (isTranscriptNotFound(error)) {
52+
return json({ error: "No saved transcript is available." }, { status: 404, headers });
53+
}
54+
logger.error("Failed to access session transcript", {
55+
projectId: project.id,
56+
environmentId: environment.id,
57+
sessionId: session.friendlyId,
58+
error,
59+
});
60+
return json(
61+
{
62+
error: "Could not download the saved transcript. Please try again.",
63+
},
64+
{ status: 502, headers }
65+
);
66+
}
67+
}

0 commit comments

Comments
 (0)