diff options
| author | t <t@tjp.lol> | 2026-06-13 23:45:59 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch) | |
| tree | 134196a82dd6fdd55b735be4c0cf38428c85038d /src/debug_log.zig | |
| parent | 71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (diff) | |
Add Codex Responses support and session debugging
Teach provider config and auth resolution about the Codex Responses
dialect, including user-facing `style = "openai_responses"` with
`dialect = "codex"`. Serialize and parse Responses traffic with
provider-specific reasoning replay, assistant phase metadata, and robust
function-call assembly keyed by `output_index` so streamed tool inputs
survive proxy quirks and empty terminal payloads.
Also persist thinking origins and message metadata across sessions, add
the Anthropic interleaved-thinking header switch, write per-session
debug logs, and improve the TUI and scripts for inspecting tool output
and session costs.
Diffstat (limited to 'src/debug_log.zig')
| -rw-r--r-- | src/debug_log.zig | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/src/debug_log.zig b/src/debug_log.zig new file mode 100644 index 0000000..0adbe63 --- /dev/null +++ b/src/debug_log.zig @@ -0,0 +1,220 @@ +//! Debug-build log redirection. +//! +//! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the +//! raw JSON of every provider API request and response. Writing that to stderr +//! makes the interactive TUI unusable (every line scribbles over the frame). +//! +//! This module installs a custom `std.Options.logFn` (wired up via +//! `std_options` in `main.zig`). In **Debug** builds it routes the entire log +//! stream to a per-session file under +//! +//! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log +//! +//! and writes *nothing* to the terminal, leaving the TUI pristine. In any +//! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated) +//! so release behavior is unchanged. +//! +//! The file is opened with `O_TRUNC`, so each session starts fresh; the file +//! holds exactly one session's logs and never grows across runs. (A *new* +//! session id per invocation already gives a fresh file; truncation guards the +//! resume case where the same id is reused.) +//! +//! The log function may be called from any thread (the `Io.Threaded` worker +//! pool as well as the main loop), so all writes are serialized behind a +//! mutex. Writes go straight to the fd via `std.c.write` — no `Io` handle is +//! needed (and none is available at a `logFn` call site), matching the raw-fd +//! approach already used by `tui_terminal.zig`. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const panto_home = @import("panto_home.zig"); + +/// True only in Debug builds; gates the file-redirect path at comptime. +const enabled = builtin.mode == .Debug; + +/// A tiny atomic spinlock serializing writes. `logFn` can be called from any +/// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out; +/// contention is rare and each critical section is a single bounded write. +var log_lock = std.atomic.Value(bool).init(false); + +fn lockLog() void { + while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) { + std.atomic.spinLoopHint(); + } +} + +fn unlockLog() void { + log_lock.store(false, .release); +} +/// The open log file descriptor, or -1 before `init` (or if init failed). +var log_fd: std.c.fd_t = -1; +/// Monotonic per-line counter. Wall-clock time needs an `Io` handle (not +/// available at a `logFn` call site), so lines are stamped with a sequence +/// number instead — enough to order events within the session. +var seq: u64 = 0; +/// Buffer backing the per-call `Writer`. Guarded by `log_mutex`. +var writer_buf: [16 * 1024]u8 = undefined; + +/// Open the per-session debug log file and arm `logFn` to write to it. No-op +/// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and +/// `logFn` silently drops messages (debug logging must never break startup). +/// +/// Safe to call once, after the session id is known. `environ_map` is used to +/// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory. +pub fn init( + allocator: Allocator, + io: Io, + environ_map: *const std.process.Environ.Map, + session_id: []const u8, +) void { + if (!enabled) return; + + var layout = panto_home.resolve(allocator, environ_map) catch return; + defer layout.deinit(); + + const debug_dir = std.fs.path.join(allocator, &.{ layout.home, "debug" }) catch return; + defer allocator.free(debug_dir); + + Io.Dir.cwd().createDirPath(io, debug_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return, + }; + + const file_name = std.fmt.allocPrint(allocator, "{s}.log", .{session_id}) catch return; + defer allocator.free(file_name); + const path = std.fs.path.joinZ(allocator, &.{ debug_dir, file_name }) catch return; + defer allocator.free(path); + + const flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }; + const fd = std.c.open(path.ptr, flags, @as(std.c.mode_t, 0o600)); + if (fd < 0) return; + + lockLog(); + defer unlockLog(); + log_fd = fd; + seq = 0; +} + +/// Custom `std.Options.logFn`. Debug builds capture everything to the session +/// file (terminal stays clean); other builds use the stderr default. +pub fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime format: []const u8, + args: anytype, +) void { + if (!enabled) { + std.log.defaultLog(level, scope, format, args); + return; + } + + lockLog(); + defer unlockLog(); + + const fd = log_fd; + // Before the file is open (early bootstrap), drop the message rather than + // letting it reach the terminal — keeping the TUI pristine is the point. + if (fd < 0) return; + + var fw: FdWriter = .{ + .fd = fd, + .interface = .{ .vtable = &fd_vtable, .buffer = &writer_buf }, + }; + const w = &fw.interface; + + seq += 1; + w.print("#{d:>6} {s}", .{ seq, level.asText() }) catch {}; + if (scope != .default) w.print("({t})", .{scope}) catch {}; + w.writeAll(": ") catch {}; + w.print(format, args) catch {}; + w.writeAll("\n") catch {}; + w.flush() catch {}; +} + +/// A minimal `std.Io.Writer` whose sink is a raw file descriptor. Writes are +/// best-effort (logging must never fault): short writes are looped, `EINTR` is +/// retried, and any other error silently drops the remaining bytes. +const FdWriter = struct { + fd: std.c.fd_t, + interface: std.Io.Writer, +}; + +const fd_vtable: std.Io.Writer.VTable = .{ .drain = drainFd }; + +fn drainFd(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + const self: *FdWriter = @alignCast(@fieldParentPtr("interface", w)); + const fd = self.fd; + + // 1. Flush any buffered bytes first (they were logically written already). + if (w.end != 0) { + writeAllFd(fd, w.buffer[0..w.end]); + w.end = 0; + } + + // 2. Write each data slice in order; the last is repeated `splat` times. + if (data.len == 0) return 0; + var consumed: usize = 0; + for (data[0 .. data.len - 1]) |slice| { + writeAllFd(fd, slice); + consumed += slice.len; + } + const last = data[data.len - 1]; + var i: usize = 0; + while (i < splat) : (i += 1) { + writeAllFd(fd, last); + consumed += last.len; + } + return consumed; +} + +fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + const n = std.c.write(fd, bytes.ptr + off, bytes.len - off); + if (n < 0) { + if (std.posix.errno(n) == .INTR) continue; + return; // best-effort: drop the rest + } + if (n == 0) return; + off += @intCast(n); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "FdWriter: buffered + splat writes reach the fd intact" { + const path = "/tmp/panto_debug_log_drain_test.txt"; + const wr_flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }; + const fd = std.c.open(path, wr_flags, @as(std.c.mode_t, 0o600)); + try testing.expect(fd >= 0); + + { + // A deliberately tiny buffer forces several drains mid-message, + // exercising the buffer-flush path and the splat repetition. + var buf: [8]u8 = undefined; + var fw: FdWriter = .{ + .fd = fd, + .interface = .{ .vtable = &fd_vtable, .buffer = &buf }, + }; + const w = &fw.interface; + try w.print("hello {d} world {s}", .{ 42, "xyz" }); + try w.splatBytesAll("ab", 3); // splat: "ababab" + try w.flush(); + } + _ = std.c.close(fd); + + const rfd = std.c.open(path, std.c.O{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0)); + try testing.expect(rfd >= 0); + defer _ = std.c.close(rfd); + var rbuf: [128]u8 = undefined; + const n = std.c.read(rfd, &rbuf, rbuf.len); + try testing.expect(n > 0); + try testing.expectEqualStrings("hello 42 world xyzababab", rbuf[0..@intCast(n)]); +} |
