Unverified 提交 66421906 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Automatically read logs as tool-call (#2012)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add a central log store and a new read_logs tool so the agent can fetch recent client, server, build-time, and network logs on demand, with a dyad-read-logs UI tag that shows progress and results in chat. This improves debugging by giving the agent a filtered snapshot of logs at the time of the call. - **New Features** - Central in-memory log store (per app, capped at 1000 entries). - PreviewIframe and runtime forward client, network, build-time, and server logs to the store via IPC; Supabase logs included. - New read_logs agent tool with filters: type, level, searchTerm, limit; returns a formatted snapshot and emits dyad-read-logs with results. - Added dyad-read-logs custom tag and DyadLogs component for collapsible output with pending/aborted indicators and result counts. - New IPC channels (add-log, clear-logs) whitelisted in preload, handled in main, and exposed via IpcClient.addLog and clearLogs. - E2E test for local agent validates filtered log reads. - **Bug Fixes** - Fixed missing forwarding of network-error logs from PreviewIframe to the central store. - Clear logs on app restart and deletion to prevent stale data and memory growth. <sup>Written for commit ab52f494158cea54f5e996349097abb597b1fd92. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- greptile_comment --> <h3>Greptile Summary</h3> - Implements centralized log store and `read_logs` agent tool enabling AI to fetch console logs on demand for debugging with comprehensive filtering options (time window, log type, level, source name, search terms) - Integrates log collection from client console, network events, and server stdout/stderr through IPC channels, forwarding logs to main process store with 1000-entry circular buffer per app - Adds `dyad-read-logs` UI component for collapsible log display in chat with proper state management and visual indicators for pending/aborted/completed states <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | `src/components/preview_panel/PreviewIframe.tsx` | Forwards client console and network events to central log store; network-error events missing `IpcClient.addLog()` call | | `src/pro/main/ipc/handlers/local_agent/tools/read_logs.ts` | New read_logs tool with comprehensive filtering, smart stack trace truncation, and direct log store access | | `src/ipc/handlers/app_handlers.ts` | Added server stdout/stderr to log store and registered add-log IPC handler; entry parameter uses `any` type instead of `ConsoleEntry` | | `src/lib/log_store.ts` | New central in-memory log store with circular buffer; duplicate `ConsoleEntry` type definition exists | <h3>Confidence score: 4/5</h3> - This PR requires attention before merging due to incomplete log forwarding that could impact debugging effectiveness - Score reduced due to missing network-error log forwarding in `PreviewIframe.tsx` lines 414-428, type safety issue in `app_handlers.ts` using `any` instead of proper typing, and duplicate type definitions that need consolidation - Pay close attention to `src/components/preview_panel/PreviewIframe.tsx` network-error handler and `src/ipc/handlers/app_handlers.ts` type definitions <h3>Sequence Diagram</h3> ```mermaid sequenceDiagram participant User as User participant IFrame as PreviewIframe<br/>(Client) participant IPC as IpcClient participant Main as app_handlers<br/>(Main Process) participant LogStore as LogStore participant Agent as read_logs tool participant UI as DyadReadLogs<br/>(Chat Component) Note over User,UI: Log Collection Flow User->>IFrame: "Triggers network/console event" IFrame->>IFrame: "Handles event (console-log, network-request, etc.)" IFrame->>IPC: "addLog(logEntry)" IPC->>Main: 'invoke("add-log", entry)' Main->>LogStore: "addLog(entry)" LogStore->>LogStore: "Store in Map<appId, ConsoleEntry[]>" Note over User,UI: Server Log Collection Main->>Main: "Process stdout/stderr from app" Main->>LogStore: "addLog(serverLogEntry)" Note over User,UI: Agent Tool Flow User->>Agent: "Agent executes read_logs tool" Agent->>LogStore: "getLogs(appId)" LogStore-->>Agent: "ConsoleEntry[]" Agent->>Agent: "Filter by time/type/level/search" Agent->>Agent: "Format logs for AI consumption" Agent-->>UI: '<dyad-read-logs> XML tag' UI->>UI: "Render collapsible log viewer" ``` <!-- greptile_other_comments_section --> **Context used:** - Context from `dashboard` - .cursor/rules/ipc.mdc ([source](https://app.greptile.com/review/custom-context?memory=92de190d-1eac-4167-a0e4-35db6533fe3d)) - Context from `dashboard` - AGENTS.md ([source](https://app.greptile.com/review/custom-context?memory=32c86e9e-6a00-48f2-ac32-69590e8d298c)) <!-- /greptile_comment --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds an in-memory, per-app log store and wires all major sources into it, enabling the agent to fetch filtered log snapshots and render them in chat. > > - New central store `lib/log_store.ts` with `addLog`/`getLogs`/`clearLogs` and `ConsoleEntry` moved to `ipc/ipc_types.ts` > - IPC: preload whitelists `add-log` and `clear-logs`; main handlers in `app_handlers.ts`; renderer API via `IpcClient.addLog`/`clearLogs` > - Log producers now forward to store: > - `PreviewIframe.tsx`: client console, network request/response/error, runtime errors; also keeps UI atom in sync > - `useRunApp.ts`: build-time and client-error logs; restart clears logs via IPC > - `app_handlers.ts`: server stdout/stderr appended to store > - `useSupabase.ts`: edge function logs appended to store > - New agent tool `read_logs` (`pro/main/.../tools/read_logs.ts`) with filters (`type`, `level`, `searchTerm`, `limit`), formats results, and emits `<dyad-read-logs>` with counts > - Chat UI: `DyadLogs` component and parser updates to render collapsible results (`dyad-read-logs`) > - E2E: fixture and spec validate filtered log reads in local-agent mode > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d77e209c269ad2de80e2c57ed0c8824a288244a2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 9a606404
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Read console logs with various filters",
turns: [
{
text: "Let me check the recent console logs to see what's happening in the application.",
toolCalls: [
{
name: "read_logs",
args: {
type: "all",
level: "all",
},
},
],
},
{
text: "Now let me filter for only error logs to identify any issues.",
toolCalls: [
{
name: "read_logs",
args: {
level: "error",
limit: 10,
},
},
],
},
{
text: "Let me also check client-side logs specifically.",
toolCalls: [
{
name: "read_logs",
args: {
type: "client",
},
},
],
},
{
text: "I've reviewed the console logs. The application appears to be running normally with no critical errors detected.",
},
],
};
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* E2E test for read_logs tool in local-agent mode
* Tests the ability to read and filter console logs with various parameters
* Note: read_logs has defaultConsent: "always", so no consent flow is tested
*/
testSkipIfWindows("local-agent - read logs with filters", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
// Send prompt that triggers read_logs with various filters
// The fixture tests multiple filter combinations:
// - All logs from last 5 minutes
// - Error logs from last hour
// - Client logs from last minute
await po.sendPrompt("tc=local-agent/read-logs");
await po.snapshotMessages();
});
......@@ -237,6 +237,52 @@
}
}
},
{
"type": "function",
"function": {
"name": "read_logs",
"description": "Read logs at the moment this tool is called. Includes client logs, server logs, edge function logs, and network requests. Use this to debug errors, investigate issues, or understand app behavior. IMPORTANT: Logs are a snapshot from when you call this tool - they will NOT update while you are writing code or making changes. Use filters (searchTerm, type, level) to narrow down relevant logs on the first call.",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"all",
"client",
"server",
"edge-function",
"network-requests",
"build-time"
],
"description": "Filter by log source type (default: all). Types: 'client' = browser console logs; 'server' = backend/SSR logs; 'edge-function' = edge function logs; 'network-requests' = HTTP requests and responses (outgoing calls and their responses); 'build-time' = build and bundler output."
},
"level": {
"type": "string",
"enum": [
"all",
"info",
"warn",
"error"
],
"description": "Filter by log level (default: all)"
},
"searchTerm": {
"type": "string",
"description": "Search for logs containing this text (case-insensitive)"
},
"limit": {
"type": "number",
"minimum": 1,
"maximum": 200,
"description": "Maximum number of logs to return (default: 50, max: 200)"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
......
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: auto
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/read-logs
- paragraph: Let me check the recent console logs to see what's happening in the application.
- img
- text: /LOGSReading \d+ logs?/
- img
- paragraph: Now let me filter for only error logs to identify any issues.
- img
- text: "LOGSReading 0 logs (level: error)"
- img
- paragraph: Let me also check client-side logs specifically.
- img
- text: "LOGSReading 0 logs (type: client)"
- img
- paragraph: I've reviewed the console logs. The application appears to be running normally with no critical errors detected.
- button:
- img
- img
- text: auto
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
import { atom } from "jotai";
import type { App, Version } from "@/ipc/ipc_types";
import type { App, Version, ConsoleEntry } from "@/ipc/ipc_types";
import type { UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null);
......@@ -11,20 +11,6 @@ export const previewModeAtom = atom<
>("preview");
export const selectedVersionIdAtom = atom<string | null>(null);
export interface ConsoleEntry {
level: "info" | "warn" | "error";
type:
| "server"
| "client"
| "edge-function"
| "network-requests"
| "build-time";
message: string;
timestamp: number;
sourceName?: string;
appId: number;
}
export const appConsoleEntriesAtom = atom<ConsoleEntry[]>([]);
export const appUrlAtom = atom<
| { appUrl: string; appId: number; originalUrl: string }
......
import type React from "react";
import type { ReactNode } from "react";
import { useState } from "react";
import {
ChevronsDownUp,
ChevronsUpDown,
FileText,
Loader,
CircleX,
} from "lucide-react";
import { CodeHighlight } from "./CodeHighlight";
import { CustomTagState } from "./stateTypes";
interface DyadLogsProps {
children?: ReactNode;
node?: any;
}
export const DyadLogs: React.FC<DyadLogsProps> = ({ children, node }) => {
const [isContentVisible, setIsContentVisible] = useState(false);
// State handling
const state = node?.properties?.state as CustomTagState;
const inProgress = state === "pending";
const aborted = state === "aborted";
// Get count from node properties
const logCount = node?.properties?.count || "";
const hasResults = !!logCount;
// Build description based on filters
const logType = node?.properties?.type || "all";
const logLevel = node?.properties?.level || "all";
const filters: string[] = [];
if (logType !== "all") filters.push(`type: ${logType}`);
if (logLevel !== "all") filters.push(`level: ${logLevel}`);
const filterDesc = filters.length > 0 ? ` (${filters.join(", ")})` : "";
// Build display text
const displayText = `Reading ${hasResults ? `${logCount} ` : ""}logs${filterDesc}`;
// Dynamic border styling
const borderClass = inProgress
? "border-(--primary)"
: aborted
? "border-red-500"
: "border-(--primary)/30";
return (
<div
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${borderClass}`}
onClick={() => setIsContentVisible(!isContentVisible)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText size={16} className="text-(--primary)" />
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
<span className="font-bold mr-2 outline-2 outline-(--primary)/20 bg-(--primary)/10 text-(--primary) rounded-md px-1">
LOGS
</span>
{displayText}
</span>
{inProgress && (
<div className="flex items-center text-(--primary) text-xs">
<Loader size={14} className="mr-1 animate-spin" />
<span>Reading...</span>
</div>
)}
{aborted && (
<div className="flex items-center text-red-600 text-xs">
<CircleX size={14} className="mr-1" />
<span>Did not finish</span>
</div>
)}
</div>
<div className="flex items-center">
{isContentVisible ? (
<ChevronsDownUp
size={20}
className="text-(--primary)/70 hover:text-(--primary)"
/>
) : (
<ChevronsUpDown
size={20}
className="text-(--primary)/70 hover:text-(--primary)"
/>
)}
</div>
</div>
{isContentVisible && (
<div className={`text-xs${hasResults ? " mt-2" : ""}`}>
<CodeHighlight className="language-log">{children}</CodeHighlight>
</div>
)}
</div>
);
};
......@@ -6,6 +6,7 @@ import { DyadRename } from "./DyadRename";
import { DyadDelete } from "./DyadDelete";
import { DyadAddDependency } from "./DyadAddDependency";
import { DyadExecuteSql } from "./DyadExecuteSql";
import { DyadLogs } from "./DyadLogs";
import { DyadAddIntegration } from "./DyadAddIntegration";
import { DyadEdit } from "./DyadEdit";
import { DyadSearchReplace } from "./DyadSearchReplace";
......@@ -38,6 +39,7 @@ const DYAD_CUSTOM_TAGS = [
"dyad-delete",
"dyad-add-dependency",
"dyad-execute-sql",
"dyad-read-logs",
"dyad-add-integration",
"dyad-output",
"dyad-problem-report",
......@@ -473,6 +475,23 @@ function renderCustomTag(
</DyadExecuteSql>
);
case "dyad-read-logs":
return (
<DyadLogs
node={{
properties: {
state: getState({ isStreaming, inProgress }),
time: attributes.time || "",
type: attributes.type || "",
level: attributes.level || "",
count: attributes.count || "",
},
}}
>
{content}
</DyadLogs>
);
case "dyad-add-integration":
return (
<DyadAddIntegration
......
import { appConsoleEntriesAtom, type ConsoleEntry } from "@/atoms/appAtoms";
import { appConsoleEntriesAtom } from "@/atoms/appAtoms";
import type { ConsoleEntry } from "@/ipc/ipc_types";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
......
......@@ -128,6 +128,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
isCollapsed ? "" : "rotate-90"
}`}
/>
{isCollapsed ? getTruncatedError() : error.message}
</div>
</div>
......@@ -350,18 +351,21 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
if (event.data?.type === "console-log") {
const { level, args } = event.data;
const formattedMessage = `[${level.toUpperCase()}] ${args.join(" ")}`;
const logLevel =
const logLevel: "info" | "warn" | "error" =
level === "error" ? "error" : level === "warn" ? "warn" : "info";
setConsoleEntries((prev) => [
...prev,
{
level: logLevel,
type: "client",
message: formattedMessage,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const logEntry = {
level: logLevel,
type: "client" as const,
message: formattedMessage,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
return;
}
......@@ -369,16 +373,19 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
if (event.data?.type === "network-request") {
const { method, url } = event.data;
const formattedMessage = `→ ${method} ${url}`;
setConsoleEntries((prev) => [
...prev,
{
level: "info",
type: "network-requests",
message: formattedMessage,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const logEntry = {
level: "info" as const,
type: "network-requests" as const,
message: formattedMessage,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
return;
}
......@@ -386,17 +393,21 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
if (event.data?.type === "network-response") {
const { method, url, status, duration } = event.data;
const formattedMessage = `[${status}] ${method} ${url} (${duration}ms)`;
const level = status >= 400 ? "error" : status >= 300 ? "warn" : "info";
setConsoleEntries((prev) => [
...prev,
{
level,
type: "network-requests",
message: formattedMessage,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const level: "info" | "warn" | "error" =
status >= 400 ? "error" : status >= 300 ? "warn" : "info";
const logEntry = {
level,
type: "network-requests" as const,
message: formattedMessage,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
return;
}
......@@ -405,16 +416,19 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const { method, url, status, error, duration } = event.data;
const statusCode = status && status !== 0 ? `[${status}] ` : "";
const formattedMessage = `${statusCode}${method} ${url} - ${error} (${duration}ms)`;
setConsoleEntries((prev) => [
...prev,
{
level: "error",
type: "network-requests",
message: formattedMessage,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const logEntry = {
level: "error" as const,
type: "network-requests" as const,
message: formattedMessage,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
return;
}
......@@ -552,30 +566,36 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
}\nStack trace: ${stack}`;
console.error("Iframe error:", errorMessage);
setErrorMessage({ message: errorMessage, source: "preview-app" });
setConsoleEntries((prev) => [
...prev,
{
level: "error",
type: "client",
message: `Iframe error: ${errorMessage}`,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const logEntry = {
level: "error" as const,
type: "client" as const,
message: `Iframe error: ${errorMessage}`,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
} else if (type === "build-error-report") {
console.debug(`Build error report: ${payload}`);
const errorMessage = `${payload?.message} from file ${payload?.file}.\n\nSource code:\n${payload?.frame}`;
setErrorMessage({ message: errorMessage, source: "preview-app" });
setConsoleEntries((prev) => [
...prev,
{
level: "error",
type: "client",
message: `Build error report: ${JSON.stringify(payload)}`,
timestamp: Date.now(),
appId: selectedAppId!,
},
]);
const logEntry = {
level: "error" as const,
type: "client" as const,
message: `Build error report: ${JSON.stringify(payload)}`,
appId: selectedAppId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
} else if (type === "pushState" || type === "replaceState") {
console.debug(`Navigation event: ${type}`, payload);
......
......@@ -66,20 +66,25 @@ export function useRunApp() {
}
// Add to console entries
const level =
output.type === "stderr" || output.type === "client-error"
? "error"
: "info";
setConsoleEntries((prev) => [
...prev,
{
level,
type: "build-time",
message: output.message,
timestamp: output.timestamp,
appId: output.appId,
},
]);
const logEntry = {
level:
output.type === "stderr" || output.type === "client-error"
? ("error" as const)
: ("info" as const),
type: "build-time" as const,
message: output.message,
appId: output.appId,
timestamp: output.timestamp,
};
// Only send client-error logs to central store
// Server logs (stdout/stderr) are already stored in the main process
if (output.type === "client-error") {
IpcClient.getInstance().addLog(logEntry);
}
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
// Process proxy server output
processProxyServerOutput(output);
......@@ -101,16 +106,19 @@ export function useRunApp() {
return prevAppUrlObj; // No change needed
});
setConsoleEntries((prev) => [
...prev,
{
level: "info",
type: "build-time",
message: "Trying to restart app...",
timestamp: Date.now(),
appId,
},
]);
const logEntry = {
level: "info" as const,
type: "build-time" as const,
message: "Trying to restart app...",
appId,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
const app = await ipcClient.getApp(appId);
setApp(app);
await ipcClient.runApp(appId, processAppOutput);
......@@ -180,16 +188,24 @@ export function useRunApp() {
// Clear the URL and add restart message
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null });
setConsoleEntries((prev) => [
...prev,
{
level: "info",
type: "build-time",
message: "Restarting app...",
timestamp: Date.now(),
appId,
},
]);
// Clear logs in both the backend store and UI state
await ipcClient.clearLogs(appId);
setConsoleEntries([]);
const logEntry = {
level: "info" as const,
type: "build-time" as const,
message: "Restarting app...",
appId: appId!,
timestamp: Date.now(),
};
// Send to central log store
IpcClient.getInstance().addLog(logEntry);
// Also update UI state
setConsoleEntries((prev) => [...prev, logEntry]);
const app = await ipcClient.getApp(appId);
setApp(app);
......
......@@ -151,7 +151,9 @@ export function useSupabase(options: UseSupabaseOptions = {}) {
return;
}
// Logs are already in ConsoleEntry format, just append them
logs.forEach((log) => {
IpcClient.getInstance().addLog(log);
});
setConsoleEntries((prev) => [...prev, ...logs]);
// Update the last timestamp for this project
......
......@@ -9,6 +9,7 @@ import type {
CopyAppParams,
EditAppFileReturnType,
RespondToAppInputParams,
ConsoleEntry,
ChangeAppLocationParams,
ChangeAppLocationResult,
} from "../ipc_types";
......@@ -30,6 +31,7 @@ import {
} from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
import { addLog, clearLogs } from "../../lib/log_store";
import fixPath from "fix-path";
......@@ -246,6 +248,15 @@ function listenToProcess({
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
);
// Add to central log store
addLog({
level: "info",
type: "server",
message,
timestamp: Date.now(),
appId,
});
// This is a hacky heuristic to pick up when drizzle is asking for user
// to select from one of a few choices. We automatically pick the first
// option because it's usually a good default choice. We guard this with
......@@ -292,11 +303,21 @@ function listenToProcess({
}
});
spawnedProcess.stderr?.on("data", (data) => {
spawnedProcess.stderr?.on("data", async (data) => {
const message = util.stripVTControlCharacters(data.toString());
logger.error(
`App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`,
);
// Add to central log store
addLog({
level: "error",
type: "server",
message,
timestamp: Date.now(),
appId,
});
safeSend(event.sender, "app:output", {
type: "stderr",
message,
......@@ -1166,6 +1187,9 @@ export function registerAppHandlers() {
}
}
// Clear logs for this app to prevent memory leak
clearLogs(appId);
// Delete app from database
try {
await db.delete(apps).where(eq(apps.id, appId));
......@@ -1648,6 +1672,15 @@ export function registerAppHandlers() {
},
);
// Handler for adding logs to central store from renderer
ipcMain.handle("add-log", async (_, entry: ConsoleEntry) => {
addLog(entry);
});
// Handler for clearing logs for a specific app
ipcMain.handle("clear-logs", async (_, { appId }: { appId: number }) => {
clearLogs(appId);
});
handle(
"select-app-location",
async (
......
......@@ -24,8 +24,8 @@ import {
SupabaseOrganizationInfo,
SupabaseProject,
DeleteSupabaseOrganizationParams,
ConsoleEntry,
} from "../ipc_types";
import type { ConsoleEntry } from "../../atoms/appAtoms";
const logger = log.scope("supabase_handlers");
const handle = createLoggedHandler(logger);
......
......@@ -82,8 +82,8 @@ import type {
AgentToolConsentRequestPayload,
AgentToolConsentResponseParams,
TelemetryEventPayload,
ConsoleEntry,
} from "./ipc_types";
import type { ConsoleEntry } from "../atoms/appAtoms";
import type { Template } from "../shared/templates";
import type {
AppChatContext,
......@@ -1491,4 +1491,16 @@ export class IpcClient {
): Promise<{ isDynamic: boolean; hasStaticText: boolean }> {
return this.ipcRenderer.invoke("analyze-component", params);
}
// --- Console Logs ---
public addLog(entry: ConsoleEntry): void {
// Fire and forget - send log to central store
this.ipcRenderer.invoke("add-log", entry).catch((err) => {
console.error("Failed to add log to central store:", err);
});
}
public async clearLogs(appId: number): Promise<void> {
await this.ipcRenderer.invoke("clear-logs", { appId });
}
}
......@@ -9,6 +9,20 @@ export interface AppOutput {
appId: number;
}
export interface ConsoleEntry {
level: "info" | "warn" | "error";
type:
| "server"
| "client"
| "edge-function"
| "network-requests"
| "build-time";
message: string;
timestamp: number;
sourceName?: string;
appId: number;
}
export interface SecurityFinding {
title: string;
level: "critical" | "high" | "medium" | "low";
......
/**
* Central log store for console entries
* This is the single source of truth for all logs (client, server, edge, network, build)
*/
import type { ConsoleEntry } from "../ipc/ipc_types";
// In-memory log store (per app)
const logStore = new Map<number, ConsoleEntry[]>();
// Maximum logs per app (circular buffer)
const MAX_LOGS_PER_APP = 1000;
/**
* Add a log entry to the store
*/
export function addLog(entry: ConsoleEntry): void {
const appLogs = logStore.get(entry.appId) || [];
appLogs.push(entry);
// Keep only recent logs (circular buffer)
if (appLogs.length > MAX_LOGS_PER_APP) {
appLogs.shift();
}
logStore.set(entry.appId, appLogs);
}
/**
* Get all logs for a specific app
*/
export function getLogs(appId: number): ConsoleEntry[] {
return logStore.get(appId) || [];
}
/**
* Clear all logs for a specific app
*/
export function clearLogs(appId: number): void {
logStore.delete(appId);
}
......@@ -146,6 +146,9 @@ const validInvokeChannels = [
"add-to-favorite",
"github:clone-repo-from-url",
"get-latest-security-review",
// Console logs
"add-log",
"clear-logs",
// Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because
......
......@@ -17,6 +17,7 @@ import { listFilesTool } from "./tools/list_files";
import { getDatabaseSchemaTool } from "./tools/get_database_schema";
import { setChatSummaryTool } from "./tools/set_chat_summary";
import { addIntegrationTool } from "./tools/add_integration";
import { readLogsTool } from "./tools/read_logs";
import { editFileTool } from "./tools/edit_file";
import { webSearchTool } from "./tools/web_search";
import {
......@@ -42,6 +43,7 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
getDatabaseSchemaTool,
setChatSummaryTool,
addIntegrationTool,
readLogsTool,
webSearchTool,
];
// ============================================================================
......
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlContent } from "./types";
import { db } from "@/db";
import { chats } from "@/db/schema";
import { eq } from "drizzle-orm";
import { getLogs } from "@/lib/log_store";
import type { ConsoleEntry } from "@/ipc/ipc_types";
const readLogsSchema = z.object({
type: z
.enum([
"all",
"client",
"server",
"edge-function",
"network-requests",
"build-time",
])
.optional()
.describe(
"Filter by log source type (default: all). Types: 'client' = browser console logs; 'server' = backend/SSR logs; 'edge-function' = edge function logs; 'network-requests' = HTTP requests and responses (outgoing calls and their responses); 'build-time' = build and bundler output.",
),
level: z
.enum(["all", "info", "warn", "error"])
.optional()
.describe("Filter by log level (default: all)"),
searchTerm: z
.string()
.optional()
.describe("Search for logs containing this text (case-insensitive)"),
limit: z
.number()
.min(1)
.max(200)
.optional()
.describe("Maximum number of logs to return (default: 50, max: 200)"),
});
function truncateMessage(message: string, maxLength: number = 1000): string {
if (message.length <= maxLength) {
return message;
}
// Check if it's a stack trace (lines starting with " at " indicate stack frames)
const lines = message.split("\n");
const hasStackTrace = lines.some((line) => line.startsWith(" at "));
if (hasStackTrace) {
const errorMessage = lines[0];
const stackFrames = lines
.filter((line) => line.startsWith(" at "))
.slice(0, 5);
return (
errorMessage +
"\n" +
stackFrames.join("\n") +
"\n... [stack trace truncated]"
);
}
// Regular truncation - preserve start and end
const halfLength = Math.floor((maxLength - 20) / 2);
return (
message.slice(0, halfLength) +
"\n... [truncated] ...\n" +
message.slice(-halfLength)
);
}
function formatLogsForAI(logs: ConsoleEntry[]): string {
const summary = `Found ${logs.length} log${logs.length === 1 ? "" : "s"}:\n\n`;
const formatted = logs
.map((log) => {
const timestamp = new Date(log.timestamp).toISOString();
const level = log.level.toUpperCase();
const type = log.type;
const source = log.sourceName ? ` [${log.sourceName}]` : "";
const message = truncateMessage(log.message);
return `[${timestamp}] [${level}] [${type}]${source} ${message}`;
})
.join("\n");
return summary + formatted;
}
export const readLogsTool: ToolDefinition<z.infer<typeof readLogsSchema>> = {
name: "read_logs",
description:
"Read logs at the moment this tool is called. Includes client logs, server logs, edge function logs, and network requests. Use this to debug errors, investigate issues, or understand app behavior. IMPORTANT: Logs are a snapshot from when you call this tool - they will NOT update while you are writing code or making changes. Use filters (searchTerm, type, level) to narrow down relevant logs on the first call.",
inputSchema: readLogsSchema,
defaultConsent: "always",
buildXml: (args, isComplete) => {
// When complete, return undefined so execute's onXmlComplete provides the final XML
// This prevents showing two separate components
if (isComplete) {
return undefined;
}
const filters = [];
if (args.type && args.type !== "all") filters.push(`type="${args.type}"`);
if (args.level && args.level !== "all")
filters.push(`level="${args.level}"`);
// Build a descriptive summary of what's being queried
const parts: string[] = ["Time: last 5 minutes"];
if (args.type && args.type !== "all") parts.push(`Type: ${args.type}`);
if (args.level && args.level !== "all") parts.push(`Level: ${args.level}`);
if (args.searchTerm)
parts.push(`Search: "${escapeXmlContent(args.searchTerm)}"`);
if (args.limit) parts.push(`Limit: ${args.limit}`);
const summary = parts.join(" | ");
return `<dyad-read-logs ${filters.join(" ")}>
${summary}
</dyad-read-logs>`;
},
execute: async (args, ctx: AgentContext) => {
// Get the chat to find the appId
const chat = await db.query.chats.findFirst({
where: eq(chats.id, ctx.chatId),
with: { app: true },
});
if (!chat || !chat.app) {
throw new Error("Chat or app not found.");
}
const appId = chat.app.id;
// Get logs directly from central log store (no UI coupling!)
const allLogs = getLogs(appId);
// Apply time filter (hardcoded: last 5 minutes)
const cutoff = Date.now() - 5 * 60 * 1000;
let filtered = allLogs.filter((log) => log.timestamp >= cutoff);
// Apply type filter
if (args.type && args.type !== "all") {
filtered = filtered.filter((log) => log.type === args.type);
}
// Apply level filter
if (args.level && args.level !== "all") {
filtered = filtered.filter((log) => log.level === args.level);
}
// Apply search term filter
if (args.searchTerm) {
const term = args.searchTerm.toLowerCase();
filtered = filtered.filter((log) =>
log.message.toLowerCase().includes(term),
);
}
// Sort by timestamp (oldest to newest)
filtered.sort((a, b) => a.timestamp - b.timestamp);
// Limit results (take most recent)
const limit = Math.min(args.limit ?? 50, 200);
filtered = filtered.slice(-limit);
// Format logs for display
const formattedLogs =
filtered.length === 0
? "No logs found matching the specified filters."
: formatLogsForAI(filtered);
// Build the query summary for display
const parts: string[] = ["Time: last 5 minutes"];
if (args.type && args.type !== "all") parts.push(`Type: ${args.type}`);
if (args.level && args.level !== "all") parts.push(`Level: ${args.level}`);
if (args.searchTerm)
parts.push(`Search: "${escapeXmlContent(args.searchTerm)}"`);
if (args.limit) parts.push(`Limit: ${args.limit}`);
const summary = parts.join(" | ");
// Build filter attributes for the tag
const filters = [];
if (args.type && args.type !== "all") filters.push(`type="${args.type}"`);
if (args.level && args.level !== "all")
filters.push(`level="${args.level}"`);
// Output the complete results in a single tag
ctx.onXmlComplete(
`<dyad-read-logs ${filters.join(" ")} count="${filtered.length}">\n${summary}\n\n${escapeXmlContent(formattedLogs)}\n</dyad-read-logs>`,
);
return formattedLogs;
},
};
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论