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.
This commit is contained in:
Adrià Arrufat
2026-05-22 19:58:02 +02:00
parent 0219ee66b7
commit c30ccae5fb
5 changed files with 76 additions and 0 deletions

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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();