From c30ccae5fb36d47c798b4512fcecee512e4ca5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 22 May 2026 19:58:02 +0200 Subject: [PATCH] terminal: improve log routing and navigation error reporting - Route logs through a terminal sink to avoid trampling the spinner. - Track and format the last navigation error in browser tools. --- src/agent/Agent.zig | 3 +++ src/agent/Terminal.zig | 26 ++++++++++++++++++++++++++ src/browser/Frame.zig | 6 ++++++ src/browser/tools.zig | 25 +++++++++++++++++++++++++ src/log.zig | 16 ++++++++++++++++ 5 files changed, 76 insertions(+) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index b56309bd..01f1dce8 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -256,6 +256,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent errdefer self.node_registry.deinit(); errdefer self.terminal.deinit(); errdefer self.message_arena.deinit(); + self.terminal.installLogSink(); + errdefer self.terminal.uninstallLogSink(); try self.browser.init(app, .{}, null); errdefer self.browser.deinit(); @@ -297,6 +299,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent } pub fn deinit(self: *Agent) void { + self.terminal.uninstallLogSink(); if (self.recorder) |*r| r.deinit(); self.terminal.deinit(); self.message_arena.deinit(); diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 8563c1b3..6e312133 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -610,6 +610,32 @@ pub fn freeLine(line: []const u8) void { c.ic_free(@ptrCast(@constCast(line.ptr))); } +// Free-function `lp.log.sink` can't capture self; the agent sets this +// before installing the sink and clears it on teardown. +var active_for_log: ?*Terminal = null; + +pub fn installLogSink(self: *Terminal) void { + active_for_log = self; + lp.log.sink = logSink; +} + +pub fn uninstallLogSink(self: *Terminal) void { + _ = self; + lp.log.sink = null; + active_for_log = null; +} + +fn logSink(bytes: []const u8) void { + if (active_for_log) |t| { + // REPL already surfaces the clean `● ...` outcome line + if (t.isRepl()) return; + if (t.spinner.emitAbove(bytes)) return; + } + std.debug.lockStdErr(); + defer std.debug.unlockStdErr(); + _ = std.posix.write(std.posix.STDERR_FILENO, bytes) catch {}; +} + pub fn interactiveTty() bool { return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO); } diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f22513a0..46001ca4 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -198,6 +198,10 @@ _load_state: LoadState = .waiting, _parse_state: ParseState = .pre, +/// `frameErrorCallback` swallows the failure into a placeholder page; +/// callers that need to detect it read this. +_last_navigate_error: ?anyerror = null, + _notified_network_idle: IdleNotification = .init, _notified_network_almost_idle: IdleNotification = .init, @@ -522,6 +526,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo lp.assert(self._load_state == .waiting, "frame.renavigate", .{}); const session = self._session; self._load_state = .parsing; + self._last_navigate_error = null; const req_id = self._session.browser.http_client.nextReqId(); log.info(.frame, "navigate", .{ @@ -1261,6 +1266,7 @@ fn frameDoneCallback(ctx: *anyopaque) !void { fn frameErrorCallback(ctx: *anyopaque, err: anyerror) void { var self: *Frame = @ptrCast(@alignCast(ctx)); + self._last_navigate_error = err; log.err(.frame, "navigate failed", .{ .err = err, .type = self._type, .url = self.url }); // A pending root navigation that failed before commit: discard the diff --git a/src/browser/tools.zig b/src/browser/tools.zig index f9f057be..22dd20c9 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -511,6 +511,22 @@ pub fn call( return .{ .text = msg, .is_error = true }; const substituted = try substituteStringArgs(arena, tool, arguments); + return dispatch(arena, session, registry, tool, substituted) catch |err| { + if (err == error.NavigationFailed) { + if (formatNavigationError(arena, session)) |text| + return .{ .text = text, .is_error = true }; + } + return err; + }; +} + +fn dispatch( + arena: std.mem.Allocator, + session: *lp.Session, + registry: *CDPNode.Registry, + tool: Tool, + substituted: ?std.json.Value, +) ToolError!ToolResult { return switch (tool) { .goto => .{ .text = try execGoto(arena, session, registry, substituted) }, .search => .{ .text = try execSearch(arena, session, registry, substituted) }, @@ -539,6 +555,12 @@ pub fn call( }; } +fn formatNavigationError(arena: std.mem.Allocator, session: *lp.Session) ?[]const u8 { + const frame = session.currentFrame() orelse return null; + const err = frame._last_navigate_error orelse return null; + return std.fmt.allocPrint(arena, "navigation failed: {s}", .{@errorName(err)}) catch null; +} + /// Run JavaScript against the current page. The script need not be /// 0-terminated; a copy is made internally. pub fn evalScript( @@ -1198,6 +1220,9 @@ fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const .ms = timeout orelse 10000, .until = waitUntil orelse .done, }) catch |err| return if (err == error.Cancelled) ToolError.Cancelled else ToolError.NavigationFailed; + + const frame = session.currentFrame() orelse return ToolError.NavigationFailed; + if (frame._last_navigate_error != null) return ToolError.NavigationFailed; } fn resolveNodeAndPage(session: *lp.Session, registry: *CDPNode.Registry, node_id: CDPNode.Id) ToolError!NodeAndPage { diff --git a/src/log.zig b/src/log.zig index 65dc1772..6d629f4d 100644 --- a/src/log.zig +++ b/src/log.zig @@ -52,6 +52,11 @@ const Opts = struct { pub var opts = Opts{}; +/// Optional sink for formatted log lines. The agent's REPL terminal sets +/// this so log output can be routed through `Spinner.emitAbove` instead +/// of trampling the spinner line on stderr. +pub var sink: ?*const fn (bytes: []const u8) void = null; + // synchronizes access to last_log var last_log_lock: Thread.Mutex = .{}; @@ -116,6 +121,17 @@ pub fn log(comptime scope: Scope, level: Level, comptime msg: []const u8, data: return; } + if (sink) |s| { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + logTo(scope, level, msg, data, &w) catch |log_err| { + std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); + return; + }; + s(w.buffered()); + return; + } + std.debug.lockStdErr(); defer std.debug.unlockStdErr();