diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 2863ec67ab..aa09b8730f 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "fe13d03", - "sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c" + "version": "ad76918", + "sha256": "4c940570936a391c217a054b74c691587d7f27d012ccddbf5e3b3d25e7c1fe4a" } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 49e9a2df9f..d6eb6803e4 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -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') }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 66bc7dcbdd..7c3040e284 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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. 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 + +let getRuntimeLogsHandler: GetRuntimeLogsHandler | undefined + +export function setGetRuntimeLogsHandler(handler: GetRuntimeLogsHandler | undefined): void { + getRuntimeLogsHandler = handler +} + +function getSessionRuntimeLogs( + limit: number, + sessionId: string | undefined +): Promise { + 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 { + 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: { diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 552773578a..73abdf6c34 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -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) { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 63f7a42453..b62e047e84 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -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 @@ -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 + } + const pendingRuntimeLogReqs = new Map() + + 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((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. diff --git a/frontend/src/lib/components/raw_apps/utils.test.ts b/frontend/src/lib/components/raw_apps/utils.test.ts index e6b1fe5e36..b8ca6d207d 100644 --- a/frontend/src/lib/components/raw_apps/utils.test.ts +++ b/frontend/src/lib/components/raw_apps/utils.test.ts @@ -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;' - ) + expect(dts).toContain('myflow: (args: { string_input: string }) => Promise;') + }) +}) + +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') }) }) diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 928a7b8d81..53d84fd97c 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -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 + +const RAW_APP_RUNTIME_LOG_LEVELS = new Set([ + '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 + 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, diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 683d32a31f..8b2d435daa 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -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) + } {#if runtime.savedRawApp.val} @@ -74,6 +86,8 @@ defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" defaultSplitWithPreview={false} + onRuntimeLogRequester={registerRuntimeLogRequester} + onRunsProvider={registerRunsProvider} /> {/if} {/snippet} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 8bfe6dda30..5509c1dc25 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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 - runnables: Record - data: RawAppData - policy: any - summary: string - path: string - custom_path?: string - } - | undefined + | { + files: Record + runnables: Record + data: RawAppData + policy: any + summary: string + path: string + custom_path?: string + } + | undefined } readonly savedRawApp: { val: - | { - value: { - files: Record - runnables: Record - } - draft?: any - path: string - summary: string - policy: any - draft_only?: boolean - custom_path?: string - } - | undefined + | { + value: { + files: Record + runnables: Record + } + draft?: any + path: string + summary: string + policy: any + draft_only?: boolean + custom_path?: string + } + | undefined } loadRawApp(workspace: string, path: string, force?: boolean): Promise + setRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined): void + requestRuntimeLogs(limit: number): Promise + 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.() 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'