feat: add get_app_runtime_logs tool to global chat (#9502)

* feat: add get_app_runtime_logs tool to global chat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only handle raw app backend messages from the runner's own iframe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: add list_app_runs tool to global chat for raw app backend runs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: simplify raw app chat tool results

* nits

* nits

* chore: bump ui builder artifact

* fix: resolve pending runtime log requests on cleanup

* fix: harden raw app runtime log requests

* nits

* fix: show raw app tool results in status

* fix: cap raw app runtime log results

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-12 00:19:13 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d3f5fe1c8c
commit f86d0d79fc
9 changed files with 549 additions and 47 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "fe13d03",
"sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c"
"version": "ad76918",
"sha256": "4c940570936a391c217a054b74c691587d7f27d012ccddbf5e3b3d25e7c1fe4a"
}
@@ -167,6 +167,8 @@ import {
prepareGlobalUserMessage,
setDeployedInSessionHandler,
setGetPreviewStatusHandler,
setGetRuntimeLogsHandler,
setListAppRunsHandler,
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
@@ -1992,33 +1994,134 @@ describe('prepareGlobalSystemMessage', () => {
expect(result).toBe('The preview is currently open showing script "u/me/foo".')
})
})
describe('get_app_runtime_logs', () => {
afterEach(() => {
setGetRuntimeLogsHandler(undefined)
})
it('returns the session-only error when no handler is registered', async () => {
setGetRuntimeLogsHandler(undefined)
const result = await callGlobalTool('get_app_runtime_logs', {})
expect(result).toContain(
'Error: get_app_runtime_logs is only available inside an AI session.'
)
expect(result).toContain('open the raw app preview')
})
it('dispatches to the registered handler with the session id and default limit of 10', async () => {
const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
const handler = vi.fn(async () => ({
aiResult: 'logs output. Next step: inspect the browser error.',
uiMessage: 'Read 1 runtime log',
toolResult: '[{"level":"log","message":"log message","ts":1718000000000}]'
}))
setGetRuntimeLogsHandler(handler)
const result = await callGlobalTool('get_app_runtime_logs', {}, callbacks, {
sessionId: 'sess-logs'
})
expect(result).toBe('logs output. Next step: inspect the browser error.')
expect(handler).toHaveBeenCalledWith({ sessionId: 'sess-logs', limit: 10 })
expect(callbacks.setToolStatus).toHaveBeenLastCalledWith('test-get_app_runtime_logs', {
content: 'Read 1 runtime log',
result: '[{"level":"log","message":"log message","ts":1718000000000}]'
})
})
it('passes an explicit limit through to the handler', async () => {
const handler = vi.fn(async () => ({
aiResult: 'logs output',
uiMessage: 'Read runtime logs',
toolResult: '[{"level":"log","message":"log message","ts":1718000000000}]'
}))
setGetRuntimeLogsHandler(handler)
await callGlobalTool('get_app_runtime_logs', { limit: 3 }, toolCallbacks, {
sessionId: 'sess-logs'
})
expect(handler).toHaveBeenCalledWith({ sessionId: 'sess-logs', limit: 3 })
})
})
describe('list_app_runs', () => {
afterEach(() => {
setListAppRunsHandler(undefined)
})
it('returns the session-only error when no handler is registered', async () => {
setListAppRunsHandler(undefined)
const result = await callGlobalTool('list_app_runs', {})
expect(result).toContain('Error: list_app_runs is only available inside an AI session.')
expect(result).toContain('open the raw app preview')
})
it('dispatches to the registered handler with the session id and default limit of 20', async () => {
const callbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn() }
const handler = vi.fn(() => ({
aiResult: 'runs output. Next step: call get_job_logs.',
uiMessage: 'Listed 1 app run',
toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
}))
setListAppRunsHandler(handler)
const result = await callGlobalTool('list_app_runs', {}, callbacks, {
sessionId: 'sess-runs'
})
expect(result).toBe('runs output. Next step: call get_job_logs.')
expect(handler).toHaveBeenCalledWith({ sessionId: 'sess-runs', limit: 20 })
expect(callbacks.setToolStatus).toHaveBeenLastCalledWith('test-list_app_runs', {
content: 'Listed 1 app run',
result:
'[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
})
})
it('passes an explicit limit through to the handler', async () => {
const handler = vi.fn(() => ({
aiResult: 'runs output',
uiMessage: 'Listed app runs',
toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]'
}))
setListAppRunsHandler(handler)
await callGlobalTool('list_app_runs', { limit: 5 }, toolCallbacks, {
sessionId: 'sess-runs'
})
expect(handler).toHaveBeenCalledWith({ sessionId: 'sess-runs', limit: 5 })
})
})
})
describe('session-only preview tools gating', () => {
const toolNames = (sessionPreview: boolean) =>
globalToolsFor({ sessionPreview }).map((t) => t.def.function.name)
it('excludes open_preview / get_preview_status outside a session', () => {
it('excludes open_preview / get_preview_status / get_app_runtime_logs / list_app_runs outside a session', () => {
const names = toolNames(false)
expect(names).not.toContain('open_preview')
expect(names).not.toContain('get_preview_status')
expect(names).not.toContain('get_app_runtime_logs')
expect(names).not.toContain('list_app_runs')
// other tools are still present
expect(names).toContain('write_script')
})
it('includes open_preview / get_preview_status inside a session', () => {
it('includes open_preview / get_preview_status / get_app_runtime_logs / list_app_runs inside a session', () => {
const names = toolNames(true)
expect(names).toContain('open_preview')
expect(names).toContain('get_preview_status')
expect(names).toContain('get_app_runtime_logs')
expect(names).toContain('list_app_runs')
// session set is the full globalTools
expect(names.length).toBe(globalTools.length)
})
it('mentions open_preview in the system prompt only when preview tools are enabled', () => {
it('mentions open_preview / get_app_runtime_logs / list_app_runs in the system prompt only when preview tools are enabled', () => {
const off = prepareGlobalSystemMessage(undefined, { previewTools: false }).content as string
const on = prepareGlobalSystemMessage(undefined, { previewTools: true }).content as string
expect(off).not.toContain('open_preview')
expect(off).not.toContain('get_app_runtime_logs')
expect(off).not.toContain('list_app_runs')
expect(on).toContain('open_preview')
expect(on).toContain('get_app_runtime_logs')
expect(on).toContain('list_app_runs')
})
})
@@ -583,6 +583,32 @@ const openPreviewSchema = z.object({
const getPreviewStatusSchema = z.object({})
type SessionToolResult = {
aiResult: string
uiMessage: string
toolResult: string
}
const getRuntimeLogsSchema = z.object({
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('How many of the most recent runtime log lines to return. Defaults to 10.')
})
const listAppRunsSchema = z.object({
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('How many of the most recent backend runs to return, newest first. Defaults to 20.')
})
const FRAMEWORK_KEYS = [
'react19',
'react18',
@@ -635,7 +661,9 @@ Rules:
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit.
- Keep context targeted.${previewTools
? `
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.`
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.`
: ''
}
@@ -2024,6 +2052,43 @@ export const globalTools: Tool<{}>[] = [
),
fn: async (ctx) => getSessionPreviewStatus(sessionIdFromCtx(ctx))
},
{
def: createToolDef(
getRuntimeLogsSchema,
'get_app_runtime_logs',
'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session.'
),
showDetails: true,
autoCollapseDetails: false,
fn: async (ctx) => {
const parsed = getRuntimeLogsSchema.parse(ctx.args)
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Reading app runtime logs...' })
const result = await getSessionRuntimeLogs(parsed.limit ?? 10, sessionIdFromCtx(ctx))
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: result.uiMessage,
result: result.toolResult,
})
return result.aiResult
}
},
{
def: createToolDef(
listAppRunsSchema,
'list_app_runs',
"List the backend runnable executions (jobs) the raw app preview currently open in this AI session has triggered, newest first."
),
showDetails: true,
fn: async (ctx) => {
const parsed = listAppRunsSchema.parse(ctx.args)
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Listing app runs...' })
const result = await getSessionAppRuns(parsed.limit ?? 20, sessionIdFromCtx(ctx))
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
content: result.uiMessage,
result: result.toolResult
})
return result.aiResult
}
},
// Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy)
...getDatatableTools()
]
@@ -2031,7 +2096,12 @@ export const globalTools: Tool<{}>[] = [
// Tools that only make sense inside an AI session (they drive the session's
// side-panel preview). The regular global side-panel chat shouldn't even be
// offered them — see `globalToolsFor`.
export const SESSION_PREVIEW_TOOL_NAMES = new Set(['open_preview', 'get_preview_status'])
export const SESSION_PREVIEW_TOOL_NAMES = new Set([
'open_preview',
'get_preview_status',
'get_app_runtime_logs',
'list_app_runs'
])
/**
* The global tool set for a given chat: the full `globalTools` for a session
@@ -2125,6 +2195,58 @@ function getSessionPreviewStatus(sessionId: string | undefined): string {
return getPreviewStatusHandler(sessionId)
}
export type GetRuntimeLogsHandler = (req: {
sessionId: string | undefined
limit: number
}) => Promise<SessionToolResult>
let getRuntimeLogsHandler: GetRuntimeLogsHandler | undefined
export function setGetRuntimeLogsHandler(handler: GetRuntimeLogsHandler | undefined): void {
getRuntimeLogsHandler = handler
}
function getSessionRuntimeLogs(
limit: number,
sessionId: string | undefined
): Promise<SessionToolResult> {
if (!getRuntimeLogsHandler) {
return Promise.resolve({
aiResult:
'Error: get_app_runtime_logs is only available inside an AI session. Tell the user runtime logs can only be read from a session preview, or switch to a session and open the raw app preview.',
uiMessage: 'Runtime logs unavailable',
toolResult: 'Runtime logs unavailable'
})
}
return getRuntimeLogsHandler({ sessionId, limit })
}
export type ListAppRunsHandler = (req: {
sessionId: string | undefined
limit: number
}) => SessionToolResult
let listAppRunsHandler: ListAppRunsHandler | undefined
export function setListAppRunsHandler(handler: ListAppRunsHandler | undefined): void {
listAppRunsHandler = handler
}
function getSessionAppRuns(
limit: number,
sessionId: string | undefined
): Promise<SessionToolResult> {
if (!listAppRunsHandler) {
return Promise.resolve({
aiResult:
'Error: list_app_runs is only available inside an AI session. Tell the user app runs can only be read from a session preview, or switch to a session and open the raw app preview.',
uiMessage: 'App runs unavailable',
toolResult: 'App runs unavailable'
})
}
return Promise.resolve(listAppRunsHandler({ sessionId, limit }))
}
// Registered by the session runtime to reload the open preview after a chat
// deploy. Undefined outside a session.
export type DeployedInSessionHandler = (req: {
@@ -28,6 +28,8 @@
}: Props = $props()
let listener = async (event) => {
if (!iframe || event.source !== iframe.contentWindow) return
const data = event.data
function respond(o: object) {
@@ -15,7 +15,15 @@
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import { workspaceStore } from '$lib/stores'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { genWmillTs, type Runnable } from './utils'
import {
genWmillTs,
normalizeRawAppRuntimeLogs,
type Runnable,
type RawAppRuntimeLogEntry,
type RawAppRuntimeLogRequester,
type RawAppRunSummary,
type RawAppRunsProvider
} from './utils'
import DarkModeObserver from '../DarkModeObserver.svelte'
import RawAppSidebar from './RawAppSidebar.svelte'
import type { Modules } from './RawAppModules.svelte'
@@ -43,6 +51,7 @@
type RawAppData,
DEFAULT_DATA
} from './dataTableRefUtils'
import { randomUUID } from '$lib/utils/uuid'
interface Props {
files?: Record<string, string>
@@ -89,6 +98,8 @@
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
onRuntimeLogRequester?: (requester: RawAppRuntimeLogRequester | undefined) => void
onRunsProvider?: (provider: RawAppRunsProvider | undefined) => void
}
let {
@@ -108,7 +119,9 @@
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined,
defaultSplitWithPreview = true
defaultSplitWithPreview = true,
onRuntimeLogRequester = undefined,
onRunsProvider = undefined
}: Props = $props()
export const version: number | undefined = undefined
@@ -987,6 +1000,11 @@
return
}
if (fromPreview && e.data.type === 'runtimeLogsResponse') {
resolvePendingRuntimeLogRequest(e.data.requestId, normalizeRawAppRuntimeLogs(e.data.logs))
return
}
// Inspector events come exclusively from the preview iframe.
if (fromPreview && e.data.type === 'inspectorSelect') {
inspectorElement = e.data.element as InspectorElementInfo
@@ -1073,6 +1091,66 @@
})
}
const RUNTIME_LOGS_TIMEOUT_MS = 2000
type PendingRuntimeLogRequest = {
resolve: (entries: RawAppRuntimeLogEntry[] | undefined) => void
timer: ReturnType<typeof setTimeout>
}
const pendingRuntimeLogReqs = new Map<string, PendingRuntimeLogRequest>()
function resolvePendingRuntimeLogRequest(
requestId: string,
entries: RawAppRuntimeLogEntry[] | undefined
) {
const pending = pendingRuntimeLogReqs.get(requestId)
if (!pending) return
clearTimeout(pending.timer)
pendingRuntimeLogReqs.delete(requestId)
pending.resolve(entries)
}
const requestRuntimeLogs: RawAppRuntimeLogRequester = (limit) => {
const win = previewIframe?.contentWindow
if (!win || !previewIframeLoaded) return Promise.resolve(undefined)
const requestId = randomUUID()
return new Promise<RawAppRuntimeLogEntry[] | undefined>((resolve) => {
const timer = setTimeout(() => {
resolvePendingRuntimeLogRequest(requestId, undefined)
}, RUNTIME_LOGS_TIMEOUT_MS)
pendingRuntimeLogReqs.set(requestId, { resolve, timer })
win.postMessage({ type: 'getRuntimeLogs', requestId, limit }, '*')
})
}
const getRuns: RawAppRunsProvider = () => {
const out: RawAppRunSummary[] = []
for (const id of jobs) {
const j = jobsById[id]
if (!j) continue
const run: RawAppRunSummary = {
job_id: j.job ?? id,
component: j.component,
status: j.result !== undefined || j.duration_ms !== undefined ? 'completed' : 'running'
}
if (j.created_at !== undefined) run.created_at = j.created_at
if (j.started_at !== undefined) run.started_at = j.started_at
if (j.duration_ms !== undefined) run.duration_ms = j.duration_ms
out.push(run)
}
return out.reverse()
}
onMount(() => {
onRuntimeLogRequester?.(requestRuntimeLogs)
onRunsProvider?.(getRuns)
return () => {
onRuntimeLogRequester?.(undefined)
onRunsProvider?.(undefined)
for (const requestId of Array.from(pendingRuntimeLogReqs.keys()))
resolvePendingRuntimeLogRequest(requestId, undefined)
}
})
let darkMode: boolean = $state(false)
// Host's computed `text-xs` size in px. Windmill bumps :root to 18px at
// ≥1760px viewports, so this re-evaluates on resize via the listener below.
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { genWmillTs, type Runnable } from './utils'
import {
formatRuntimeLogsForChat,
genWmillTs,
normalizeRawAppRuntimeLogs,
type Runnable
} from './utils'
const flowSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -38,8 +43,21 @@ describe('genWmillTs', () => {
const dts = genWmillTs(runnables)
expect(dts).toContain(
'myflow: (args: { string_input: string }) => Promise<any>;'
)
expect(dts).toContain('myflow: (args: { string_input: string }) => Promise<any>;')
})
})
describe('normalizeRawAppRuntimeLogs', () => {
it('keeps only well-formed runtime log entries', () => {
const entries = normalizeRawAppRuntimeLogs([
{ level: 'log', message: 'ready', ts: 1718000000000 },
{ level: 'trace', message: 'unsupported level', ts: 1718000000000 },
{ level: 'error', message: 'bad date', ts: Number.MAX_VALUE },
{ level: 'warn', message: 123, ts: 1718000000000 },
'not an entry'
])
expect(entries).toEqual([{ level: 'log', message: 'ready', ts: 1718000000000 }])
expect(formatRuntimeLogsForChat(entries)).toBe('[06:13:20.000] LOG: ready')
})
})
@@ -15,6 +15,70 @@ export type RawApp = {
files: string[]
}
export type RawAppRuntimeLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
export type RawAppRuntimeLogEntry = {
level: RawAppRuntimeLogLevel
message: string
ts: number
}
export type RawAppRuntimeLogRequester = (
limit: number
) => Promise<RawAppRuntimeLogEntry[] | undefined>
const RAW_APP_RUNTIME_LOG_LEVELS = new Set<RawAppRuntimeLogLevel>([
'log',
'info',
'warn',
'error',
'debug'
])
function isRawAppRuntimeLogLevel(level: unknown): level is RawAppRuntimeLogLevel {
return typeof level === 'string' && RAW_APP_RUNTIME_LOG_LEVELS.has(level as RawAppRuntimeLogLevel)
}
function isValidRuntimeLogTimestamp(ts: unknown): ts is number {
return typeof ts === 'number' && Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime())
}
export function normalizeRawAppRuntimeLogs(logs: unknown): RawAppRuntimeLogEntry[] {
if (!Array.isArray(logs)) return []
return logs.flatMap((entry) => {
if (!entry || typeof entry !== 'object') return []
const { level, message, ts } = entry as Record<string, unknown>
if (
!isRawAppRuntimeLogLevel(level) ||
typeof message !== 'string' ||
!isValidRuntimeLogTimestamp(ts)
)
return []
return [{ level, message, ts }]
})
}
export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): string {
const lines = entries.map((e) => {
const date = new Date(e.ts)
const time = Number.isNaN(date.getTime()) ? '--:--:--' : date.toISOString().slice(11, 23)
return `[${time}] ${e.level.toUpperCase()}: ${e.message}`
})
return lines.join('\n')
}
export type RawAppRunSummary = {
job_id: string
component: string
status: 'running' | 'completed'
created_at?: number
started_at?: number
duration_ms?: number
}
export type RawAppRunsProvider = () => RawAppRunSummary[] | undefined
export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
return JSON.stringify(runs, null, 2)
}
export function htmlContent(
workspace: string,
secret: string | undefined,
@@ -5,6 +5,10 @@
import type { SessionRuntime } from './sessionRuntime.svelte'
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import type {
RawAppRuntimeLogRequester,
RawAppRunsProvider
} from '$lib/components/raw_apps/utils'
let {
runtime,
@@ -28,6 +32,14 @@
diffDrawer?.closeDrawer()
await runtime.loadRawApp(workspaceId, path)
}
function registerRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined) {
runtime.setRuntimeLogRequester(requester)
}
function registerRunsProvider(provider: RawAppRunsProvider | undefined) {
runtime.setAppRunsProvider(provider)
}
</script>
{#if runtime.savedRawApp.val}
@@ -74,6 +86,8 @@
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
defaultSplitWithPreview={false}
onRuntimeLogRequester={registerRuntimeLogRequester}
onRunsProvider={registerRunsProvider}
/>
{/if}
{/snippet}
@@ -33,8 +33,18 @@ import { applyDraftToRuntimeRawApp, runtimeRawAppToDraft, type RawAppDraft } fro
import {
setDeployedInSessionHandler,
setGetPreviewStatusHandler,
setGetRuntimeLogsHandler,
setListAppRunsHandler,
setOpenPreviewHandler
} from '$lib/components/copilot/chat/global/core'
import {
formatRuntimeLogsForChat,
formatAppRunsForChat,
type RawAppRuntimeLogEntry,
type RawAppRuntimeLogRequester,
type RawAppRunSummary,
type RawAppRunsProvider
} from '$lib/components/raw_apps/utils'
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
@@ -74,34 +84,38 @@ export interface SessionRuntime {
// Raw App (HTML-based) target state
readonly rawApp: {
val:
| {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
custom_path?: string
}
| undefined
| {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
policy: any
summary: string
path: string
custom_path?: string
}
| undefined
}
readonly savedRawApp: {
val:
| {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
| {
value: {
files: Record<string, { code: string }>
runnables: Record<string, HiddenRunnable>
}
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
}
loadRawApp(workspace: string, path: string, force?: boolean): Promise<void>
setRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined): void
requestRuntimeLogs(limit: number): Promise<RawAppRuntimeLogEntry[] | undefined>
setAppRunsProvider(provider: RawAppRunsProvider | undefined): void
getAppRuns(): RawAppRunSummary[] | undefined
// Discard the local draft + refresh the fork diff + force-reload the editor,
// so the preview matches the deployed version. Used by editor onDeploy + the
// chat deploy handler.
@@ -270,6 +284,8 @@ function createRuntime(session: Session): SessionRuntime {
const rawApp: { val: SessionRuntime['rawApp']['val'] } = $state({ val: undefined })
const savedRawApp: { val: SessionRuntime['savedRawApp']['val'] } = $state({ val: undefined })
const rawAppSlot: LoadSlot = $state({ loadedPath: undefined, loading: false, notFound: false })
let runtimeLogRequester: RawAppRuntimeLogRequester | undefined = undefined
let appRunsProvider: RawAppRunsProvider | undefined = undefined
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
let loadingForkComparison = $state(false)
@@ -404,18 +420,18 @@ function createRuntime(session: Session): SessionRuntime {
// against.
const baseline: NewScript = savedScript.val
? (structuredClone(
$state.snapshot(
(savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript)
)
) as NewScript)
$state.snapshot(
(savedScript.val.draft as NewScript | undefined) ?? (savedScript.val as NewScript)
)
) as NewScript)
: {
path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
schema: emptySchema(),
language: (aiDraft.language ?? 'bun') as any
}
if (savedScript.val?.hash) {
baseline.parent_hash = savedScript.val.hash
}
@@ -556,6 +572,19 @@ function createRuntime(session: Session): SessionRuntime {
else void this.loadRawApp(workspace, path, true)
},
setRuntimeLogRequester(requester) {
runtimeLogRequester = requester
},
async requestRuntimeLogs(limit) {
return runtimeLogRequester ? runtimeLogRequester(limit) : undefined
},
setAppRunsProvider(provider) {
appRunsProvider = provider
},
getAppRuns() {
return appRunsProvider ? appRunsProvider() : undefined
},
forkComparison,
get loadingForkComparison() {
return loadingForkComparison
@@ -736,6 +765,78 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
runtime.syncPreviewWithDeployed(session.workspace_id, kind, path)
})
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
const runtime = sessionId ? runtimes.get(sessionId) : undefined
if (!runtime) {
return {
aiResult:
'Error: get_app_runtime_logs is only available inside an AI session. Tell the user runtime logs can only be read from a session preview, or switch to a session and open the raw app preview.',
uiMessage: 'Runtime logs unavailable',
toolResult: 'Runtime logs unavailable'
}
}
const entries = await runtime.requestRuntimeLogs(limit)
if (entries === undefined) {
return {
aiResult:
'No runtime logs are available because no raw app preview is running for this session. Next step: call open_preview with kind="raw_app" and the app path, wait for it to load, then call get_app_runtime_logs again. Runtime logs are read live from the running preview and are not persisted.',
uiMessage: 'Runtime logs unavailable',
toolResult: 'Runtime logs unavailable'
}
}
if (entries.length === 0) {
return {
aiResult:
'The raw app preview is running, but it has not emitted console logs, uncaught errors, or unhandled rejections yet. If the user reported a failure, reproduce the interaction in the preview, then call get_app_runtime_logs again. For backend.<id>() failures, call list_app_runs and then get_job_logs for the relevant job_id.',
uiMessage: 'No runtime logs',
toolResult: 'No runtime logs'
}
}
const limited = entries.slice(-limit)
return {
aiResult: formatRuntimeLogsForChat(limited),
uiMessage: `Read runtime logs`,
toolResult: formatRuntimeLogsForChat(limited)
}
})
setListAppRunsHandler(({ sessionId: callerSessionId, limit }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
const runtime = sessionId ? runtimes.get(sessionId) : undefined
if (!runtime) {
return {
aiResult:
'Error: list_app_runs is only available inside an AI session. Tell the user app runs can only be read from a session preview.',
uiMessage: 'App runs unavailable',
toolResult: 'App runs unavailable'
}
}
const runs = runtime.getAppRuns()
if (runs === undefined) {
return {
aiResult:
'No raw app preview is open for this session, so no backend runs can be listed. Next step: call open_preview with kind="raw_app" and the app path, let the preview load, then call list_app_runs again.',
uiMessage: 'App runs unavailable',
toolResult: 'App runs unavailable'
}
}
if (runs.length === 0) {
return {
aiResult:
'No backend runnable executions are tracked for this raw app preview yet.',
uiMessage: 'No app runs',
toolResult: 'No app runs'
}
}
const limited = limit > 0 ? runs.slice(0, limit) : runs
return {
aiResult: formatAppRunsForChat(limited),
uiMessage: `Fetched app runs`,
toolResult: formatAppRunsForChat(limited)
}
})
export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus {
const m = runtime.manager
if (m.loading) return 'streaming'