mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix(x/renderer): load headless transcripts via the turn runtime
Background-task and live-note history views called the legacy runs:fetch for ids that are now turn ids (ENOENT after stage 6). New shared loader src/lib/agent-transcript.ts fetches sessions:getTurn first and falls back to runs:fetch so pre-migration histories stay readable (fallback dies with the runs runtime in stage 7); turn transcripts render through the same buildTurnConversation used by the chat views. Unit-tested (turnToTranscript mapping + failure surfacing). The chat-log download used runs:downloadLog with what is now a session id. Added sessions:downloadLog (concatenates the session's turn logs into one JSONL via the save dialog); the sidebar tries it first and falls back to runs:downloadLog for legacy background tabs. Remaining renderer runs:* callers are code-mode only (use-code-chat, runs:events feed) — the deliberate stage-7 carve-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c73531ec38
commit
49e1e99d16
7 changed files with 198 additions and 57 deletions
|
|
@ -907,6 +907,37 @@ export function setupIpcHandlers() {
|
|||
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:downloadLog': async (event, args) => {
|
||||
// Concatenate the session's turn logs into one JSONL for debugging.
|
||||
const sessions = container.resolve<ISessions>('sessions');
|
||||
const state = await sessions.getSession(args.sessionId);
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${args.sessionId}.jsonl.log`,
|
||||
filters: [
|
||||
{ name: 'Chat Log', extensions: ['log'] },
|
||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
for (const ref of state.turns) {
|
||||
const turn = await sessions.getTurn(ref.turnId);
|
||||
for (const turnEvent of turn.events) {
|
||||
lines.push(JSON.stringify(turnEvent));
|
||||
}
|
||||
}
|
||||
await fs.writeFile(result.filePath, lines.join('\n') + '\n');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'runs:downloadLog': async (event, args) => {
|
||||
const runFileName = `${args.runId}.jsonl`;
|
||||
if (path.basename(runFileName) !== runFileName) {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import {
|
|||
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
|
||||
Code2, FolderOpen, LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -17,7 +15,7 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
|
|||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
|
@ -970,10 +968,9 @@ function SetupTab({
|
|||
// Runs history tab — list + drill-down transcript view
|
||||
//
|
||||
// Source of truth: `bg-tasks/<slug>/runs.log` — a plain-text file with one
|
||||
// runId per line (newest first). The actual transcripts live at the global
|
||||
// `$WorkDir/runs/<runId>.jsonl`, so this tab fetches runIds via the bg-task
|
||||
// IPC, then loads each Run through the standard `runs:fetch`. No bg-task-
|
||||
// specific transcript path or schema needed.
|
||||
// turn id per line (newest first). Transcripts live in the turn runtime's
|
||||
// storage; this tab fetches ids via the bg-task IPC, then loads each through
|
||||
// the shared agent-transcript loader (turn-first, legacy-run fallback).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunRowSummary {
|
||||
|
|
@ -984,27 +981,6 @@ interface RunRowSummary {
|
|||
error?: string
|
||||
}
|
||||
|
||||
// Pull the bits we want to display for a row out of a full Run's event log.
|
||||
function summarizeRun(run: z.infer<typeof Run>): RunRowSummary {
|
||||
const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase }
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {
|
||||
const [rows, setRows] = useState<RunRowSummary[]>([])
|
||||
|
|
@ -1016,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask })
|
|||
setLoading(true)
|
||||
try {
|
||||
const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 })
|
||||
// Fetch each Run in parallel via the canonical IPC. Runs whose
|
||||
// jsonl no longer exists (deleted manually, never written, …) are
|
||||
// dropped silently.
|
||||
// Fetch transcripts in parallel (turn-first, legacy-run
|
||||
// fallback). Ids whose files no longer exist keep a bare row so
|
||||
// the user knows the run happened.
|
||||
const settled = await Promise.allSettled(
|
||||
runIds.map(runId => window.ipc.invoke('runs:fetch', { runId }))
|
||||
runIds.map(runId => fetchAgentRunTranscript(runId))
|
||||
)
|
||||
const next: RunRowSummary[] = []
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const r = settled[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
next.push(summarizeRun(r.value))
|
||||
if (r.status === 'fulfilled') {
|
||||
const t = r.value
|
||||
next.push({
|
||||
runId: t.id,
|
||||
...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }),
|
||||
...(t.trigger === undefined ? {} : { trigger: t.trigger }),
|
||||
...(t.summary === undefined ? {} : { summary: t.summary }),
|
||||
...(t.error === undefined ? {} : { error: t.error }),
|
||||
})
|
||||
} else {
|
||||
// Keep the row visible with just the id so the user knows it exists.
|
||||
next.push({ runId: runIds[i] })
|
||||
}
|
||||
}
|
||||
|
|
@ -1134,7 +1116,7 @@ function RunTranscriptView({
|
|||
isInFlight: boolean
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -1144,15 +1126,13 @@ function RunTranscriptView({
|
|||
setError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
// Bg-task transcripts now live at the global runs/ location —
|
||||
// same path resolution as every other run, no special handling.
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
|
|
@ -1160,8 +1140,8 @@ function RunTranscriptView({
|
|||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
const summary = run ? summarizeRun(run) : undefined
|
||||
const items: ConversationItem[] = run ? runLogToConversation(run.log) : []
|
||||
const summary = transcript ?? undefined
|
||||
const items: ConversationItem[] = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -1221,10 +1201,10 @@ function RunTranscriptView({
|
|||
Couldn't load transcript: {error}
|
||||
</div>
|
||||
)}
|
||||
{run && !loading && items.length === 0 && (
|
||||
{transcript && !loading && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loading && items.length > 0 && (
|
||||
{transcript && !loading && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -378,7 +378,14 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
// Session-first (new runtime); legacy runs fallback covers old
|
||||
// background tabs until stage 7 removes the runs runtime.
|
||||
let result: { success: boolean; error?: string }
|
||||
try {
|
||||
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
||||
} catch {
|
||||
result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import {
|
|||
ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
|
|
@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
function LastRunTab({ live }: { live: LiveNote }) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loadingRun, setLoadingRun] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
setFetchError(null)
|
||||
setLoadingRun(false)
|
||||
return
|
||||
|
|
@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
setFetchError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setFetchError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoadingRun(false)
|
||||
}
|
||||
|
|
@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
}
|
||||
|
||||
const isError = !!live.lastRunError
|
||||
const items = run ? runLogToConversation(run.log) : []
|
||||
const items = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
|
|
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
Couldn't load transcript: {fetchError}
|
||||
</div>
|
||||
)}
|
||||
{run && !loadingRun && items.length === 0 && (
|
||||
{transcript && !loadingRun && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loadingRun && items.length > 0 && (
|
||||
{transcript && !loadingRun && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { turnToTranscript } from './agent-transcript'
|
||||
import { completedTurnLog, created, requested, user } from './session-chat/test-fixtures'
|
||||
|
||||
describe('turnToTranscript', () => {
|
||||
it('maps a completed turn to id/createdAt/summary/items', () => {
|
||||
const events = completedTurnLog('turn-1', 'sess-1', 'do the thing', 'all done')
|
||||
const t = turnToTranscript('turn-1', events)
|
||||
expect(t.id).toBe('turn-1')
|
||||
expect(t.createdAt).toBeDefined()
|
||||
expect(t.summary).toBe('all done')
|
||||
expect(t.error).toBeUndefined()
|
||||
expect(t.trigger).toBeUndefined()
|
||||
expect(t.items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('surfaces turn_failed as error with no summary', () => {
|
||||
const events = [
|
||||
created('turn-2', 'sess-1', user('go')),
|
||||
requested('turn-2', 0),
|
||||
{ type: 'model_call_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', error: 'boom', usage: {} },
|
||||
]
|
||||
const t = turnToTranscript('turn-2', events)
|
||||
expect(t.error).toBe('boom')
|
||||
expect(t.summary).toBeUndefined()
|
||||
})
|
||||
})
|
||||
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { z } from 'zod'
|
||||
import type { Run } from '@x/shared/src/runs.js'
|
||||
import { reduceTurn, type TurnEvent } from '@x/shared/src/turns.js'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { buildTurnConversation } from '@/lib/session-chat/turn-view'
|
||||
|
||||
// Unified read model for a headless agent run's transcript, whether the id
|
||||
// is a turn (new runtime) or a legacy run (pre-turn-runtime files under
|
||||
// $WorkDir/runs/). Used by the background-tasks and live-note history views.
|
||||
|
||||
export interface AgentRunTranscript {
|
||||
id: string
|
||||
createdAt?: string
|
||||
// Legacy runs only (subUseCase); turns carry the trigger inside the
|
||||
// message text instead.
|
||||
trigger?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
items: ConversationItem[]
|
||||
}
|
||||
|
||||
export function turnToTranscript(
|
||||
turnId: string,
|
||||
events: Array<z.infer<typeof TurnEvent>>,
|
||||
): AgentRunTranscript {
|
||||
const state = reduceTurn(events)
|
||||
const out: AgentRunTranscript = {
|
||||
id: turnId,
|
||||
createdAt: state.definition.ts,
|
||||
items: buildTurnConversation(state),
|
||||
}
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
out.error = state.terminal.error
|
||||
}
|
||||
for (let i = state.modelCalls.length - 1; i >= 0; i--) {
|
||||
const response = state.modelCalls[i].response
|
||||
if (!response) continue
|
||||
const content = response.content
|
||||
const text =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.map((p) => (p.type === 'text' ? p.text : '')).join('')
|
||||
if (text) {
|
||||
out.summary = text
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function runToTranscript(run: z.infer<typeof Run>): AgentRunTranscript {
|
||||
const out: AgentRunTranscript = {
|
||||
id: run.id,
|
||||
createdAt: run.createdAt,
|
||||
trigger: run.subUseCase,
|
||||
items: runLogToConversation(run.log),
|
||||
}
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Turn-first with a legacy-run fallback so histories recorded before the
|
||||
// turn-runtime migration stay readable. The fallback goes away with the
|
||||
// runs runtime (stage 7).
|
||||
export async function fetchAgentRunTranscript(id: string): Promise<AgentRunTranscript> {
|
||||
try {
|
||||
const turn = await window.ipc.invoke('sessions:getTurn', { turnId: id })
|
||||
return turnToTranscript(id, turn.events)
|
||||
} catch {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
return runToTranscript(run)
|
||||
}
|
||||
}
|
||||
|
|
@ -442,6 +442,14 @@ const ipcSchemas = {
|
|||
req: z.object({ sessionId: z.string(), title: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:downloadLog': {
|
||||
// Concatenates the session's turn logs into one JSONL for debugging.
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'sessions:delete': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue