diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 349790f..7bcc3a9 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -1,7 +1,7 @@ import type { Command } from 'commander' -import { existsSync, readFileSync } from 'node:fs' -import { extname } from 'node:path' -import { gzipSync } from 'node:zlib' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { extname, join } from 'node:path' +import { gunzipSync, gzipSync } from 'node:zlib' import { parse as parseYaml } from 'yaml' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' @@ -39,6 +39,7 @@ import type { ReplayAgentSessionRequest, SessionSearchResult, SessionSearchScope, + SessionTranscript, StartAgentSessionRequest, SyncAgentSessionRequest, } from '../lib/types' @@ -332,6 +333,76 @@ export function registerSession(program: Command): void { }) }) + session + .command('transcript ') + .description( + "Download a session's raw transcript files (GET /v1/sessions/{id}/transcripts)", + ) + .option('-o, --output ', 'write to a file instead of stdout') + .option('--process ', 'pick a specific process (retries have several)') + .option('--all', "download every process's transcript") + .option('-d, --dir ', 'directory for --all downloads', '.') + .option('--json', 'print the metadata response (incl. download URLs), download nothing') + .option('--gzip', 'keep the .jsonl.gz bytes as-is (skip gunzip)') + .action( + async ( + sessionId: string, + opts: { + output?: string + process?: string + all?: boolean + dir: string + json?: boolean + gzip?: boolean + }, + ) => { + await runAction(async () => { + if (opts.all && (opts.process || opts.output)) { + throw new Error('--all cannot be combined with --process or -o') + } + const res = await new ApiClient().getSessionTranscripts(sessionId) + if (opts.json) { + printJson(res) + return + } + if (res.transcripts.length === 0) { + console.log('No transcripts stored for this session.') + return + } + const ext = opts.gzip ? 'jsonl.gz' : 'jsonl' + if (opts.all) { + mkdirSync(opts.dir, { recursive: true }) + for (const t of res.transcripts) { + const path = join(opts.dir, `${t.process_id}.${ext}`) + writeFileSync(path, await fetchTranscript(t, { gzip: opts.gzip })) + console.log(path) + } + return + } + // Default: the latest process (the list is in process-creation + // order, so retries supersede the attempts before them). + let transcript = res.transcripts[res.transcripts.length - 1]! + if (opts.process) { + const picked = res.transcripts.find((t) => t.process_id === opts.process) + if (!picked) { + throw new Error( + `no transcript for process '${opts.process}' — available: ` + + res.transcripts.map((t) => t.process_id).join(', '), + ) + } + transcript = picked + } + const data = await fetchTranscript(transcript, { gzip: opts.gzip }) + if (opts.output) { + writeFileSync(opts.output, data) + console.log(opts.output) + } else { + process.stdout.write(data) + } + }) + }, + ) + session .command('get ') .description('Get a single agent session (GET /v1/sessions/{id})') @@ -809,6 +880,39 @@ export function formatSearchResult( // connect`); re-exported here for existing importers and tests. export { formatStepLine, stepText } +// Pull a transcript from its presigned S3 URL (bare fetch — the signature in +// the URL is the credential) and gunzip unless the caller wants the raw +// .jsonl.gz bytes. A warning for a failed final write goes to stderr so the +// default stdout stream stays pure JSONL. +export async function fetchTranscript( + transcript: SessionTranscript, + opts: { gzip?: boolean }, +): Promise { + if (transcript.write_status === 'failed') { + console.error( + `warning: ${transcript.process_id}'s final transcript write failed — ` + + 'the tail past the last periodic flush may be missing', + ) + } + const res = await fetch(transcript.download_url) + if (!res.ok) { + if (res.status === 404) { + throw new Error( + `transcript for ${transcript.process_id} is gone from storage — ` + + 'it was likely deleted by your log retention setting', + ) + } + throw new Error( + `download failed: ${res.status} ${res.statusText}` + + (res.status === 403 + ? ' (the presigned URL likely expired — re-run the command for a fresh one)' + : ''), + ) + } + const raw = Buffer.from(await res.arrayBuffer()) + return opts.gzip ? raw : gunzipSync(raw) +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/lib/api.ts b/src/lib/api.ts index df060c8..bea0f2e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -34,6 +34,7 @@ import type { ListLinearTeamsResponse, ListSentryOrganizationsResponse, ListSessionStepsResponse, + ListSessionTranscriptsResponse, ListSlackChannelsResponse, ListSlackMembersResponse, ReplayAgentSessionRequest, @@ -203,6 +204,16 @@ export class ApiClient { return res.steps } + // The session's raw transcripts — one .jsonl.gz per process — each with a + // short-lived presigned download URL. Fetch the URL immediately; the JSON + // API never carries the bytes. + getSessionTranscripts(sessionId: string): Promise { + return this.request( + 'GET', + `/v1/sessions/${encodeURIComponent(sessionId)}/transcripts`, + ) + } + syncAgentSession(req: SyncAgentSessionRequest): Promise { return this.request('POST', '/v1/sessions/sync', req) } diff --git a/src/lib/types.ts b/src/lib/types.ts index 41606a4..893c26f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -294,6 +294,28 @@ export interface ListSessionStepsResponse { steps: AgentStep[] } +// One process's raw transcript from GET /v1/sessions/{id}/transcripts: the +// pointer metadata plus a short-lived presigned S3 GET for the .jsonl.gz +// object itself (expires after expires_in seconds — fetch it immediately). +export interface SessionTranscript { + process_id: string + // "claude_stream_json" (cloud) or "claude_transcript" (synced laptop). + format: string + event_count: number | null + bytes: number | null + written_at: string | null + // null = only periodic flushes so far (session still running); "failed" = + // the final write failed, so the tail past the last flush may be missing. + write_status: 'ok' | 'failed' | null + download_url: string + expires_in: number +} + +export interface ListSessionTranscriptsResponse { + session_id: string + transcripts: SessionTranscript[] +} + // GET /v1/sessions/{id}/ide (`agent session ide`): the live sandbox's // code-server tunnel URL. Unguessable, customer-scoped at discovery, and dead // once the sandbox is torn down — fetch it fresh on every open, never store it. diff --git a/test/api.test.ts b/test/api.test.ts index 9deb0f7..50ed9c2 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -192,6 +192,20 @@ describe('ApiClient sandbox variables', () => { }) }) +describe('getSessionTranscripts', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('hits the transcripts path and returns the response', async () => { + const body = { session_id: 'session_1', transcripts: [] } + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').getSessionTranscripts('session_1') + expect(out).toEqual(body) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sessions/session_1/transcripts') + }) +}) + describe('replayAgentSession', () => { afterEach(() => vi.unstubAllGlobals()) diff --git a/test/session.test.ts b/test/session.test.ts index e049f9f..1dedf16 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,10 +1,16 @@ import { mkdtempSync, writeFileSync } from 'node:fs' +import { gzipSync } from 'node:zlib' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { applyConfigOverride, readConfigFile, watchSession } from '../src/commands/session' +import { + applyConfigOverride, + fetchTranscript, + readConfigFile, + watchSession, +} from '../src/commands/session' import type { ApiClient } from '../src/lib/api' -import type { AgentSession, AgentSessionStatus } from '../src/lib/types' +import type { AgentSession, AgentSessionStatus, SessionTranscript } from '../src/lib/types' function session(status: AgentSessionStatus): AgentSession { return { @@ -159,3 +165,55 @@ describe('applyConfigOverride', () => { ) }) }) + +describe('fetchTranscript', () => { + const transcript = (overrides: Partial = {}): SessionTranscript => ({ + process_id: 'proc_1', + format: 'claude_stream_json', + event_count: 2, + bytes: 100, + written_at: '2026-07-07T00:00:00+00:00', + write_status: 'ok', + download_url: 'https://s3.example.com/signed', + expires_in: 60, + ...overrides, + }) + const gzipped = gzipSync(Buffer.from('{"a":1}\n{"b":2}\n')) + + afterEach(() => vi.unstubAllGlobals()) + + it('gunzips by default', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })), + ) + const out = await fetchTranscript(transcript(), {}) + expect(out.toString()).toBe('{"a":1}\n{"b":2}\n') + }) + + it('keeps the raw bytes with gzip: true', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })), + ) + const out = await fetchTranscript(transcript(), { gzip: true }) + expect(Buffer.compare(out, gzipped)).toBe(0) + }) + + it('maps a storage 404 to the retention explanation', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 }))) + await expect(fetchTranscript(transcript(), {})).rejects.toThrow(/log retention/) + }) + + it('warns on stderr for a failed final write but still downloads', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })), + ) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const out = await fetchTranscript(transcript({ write_status: 'failed' }), {}) + expect(out.toString()).toContain('{"a":1}') + expect(errSpy.mock.calls[0][0]).toMatch(/final transcript write failed/) + errSpy.mockRestore() + }) +})