mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
d3f5fe1c8c
commit
f86d0d79fc
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user