feat: add JSON format to agd log and rich PR summary#11
feat: add JSON format to agd log and rich PR summary#11BlueHotDog wants to merge 6 commits intomainfrom
Conversation
Add JSON output format to the log command for programmatic consumption. The GitHub Action workflow now parses JSON and renders a rich PR comment with stats table, collapsible sessions, and full conversation traces.
The .agd store is local (gitignored by design), so the CI workflow could never read traces. Replace with a Makefile target that runs locally: agd log --format json | render-summary.py | gh api. - Remove .github/workflows/agd-trace.yml - Add scripts/render-summary.py (JSON-to-markdown renderer) - Add 'make pr-summary' target (detects branch, finds PR, posts comment)
AGD SummaryBranch:
Session: ses-pr-summary (2 actions) — agents: plan, builduserassistantassistantSession: ses-rich (2 actions) — agents: coderuserassistanttool call: tooltool result (call_id: assistantFiles changed
|
The per-action observed/produced view showed the same messages repeatedly. Now we collect all unique messages across a session (deduped by hash) in chronological order and render as a single conversation thread.
- Add observed/produced workspace trees to JSON action output - Renderer now computes file diffs across actions (added/modified/removed) - Tool calls and tool results render with name, call_id, and content - Created richer test session with tool calls + workspace state
- Fix jsonWriteEscaped missing 0x08 (backspace) and 0x0C (form feed) control characters, which produced invalid JSON per RFC 8259 - Fix total_sessions overcounting in branch mode when agent/tag filters exclude entire sessions - Add unique_observed_messages and unique_produced_messages stats for deduplicated message counts across actions - Update render-summary.py to show unique counts as primary, with raw counts shown only when they differ
| tag_set.put(action.tag, {}) catch {}; | ||
| agent_set.put(action.agent_id, {}) catch {}; | ||
| session_set.put(action.session_id, {}) catch {}; | ||
|
|
||
| entries.append(allocator, .{ .hash = h, .raw = raw }) catch { | ||
| allocator.free(raw); | ||
| continue; | ||
| }; |
There was a problem hiding this comment.
🔴 Use-after-free: StringHashMap keys dangle when entries.append fails in printLogJson
In printLogJson, string slices from a deserialized action (pointing into raw) are stored as keys in tag_set, agent_set, and session_set at lines 1589-1591 before raw is appended to entries. If the subsequent entries.append at line 1593 fails (OOM), the catch block frees raw at line 1594 — but the hashmap keys are now dangling pointers into freed memory.
Root Cause and Impact
The insertion order is: (1) put keys into hashmaps, (2) append raw to entries. If step 2 fails:
tag_set.put(action.tag, {}) catch {}; // stores slice into raw
agent_set.put(action.agent_id, {}) catch {}; // stores slice into raw
session_set.put(action.session_id, {}) catch {}; // stores slice into raw
entries.append(allocator, .{ .hash = h, .raw = raw }) catch {
allocator.free(raw); // ← frees backing memory for hashmap keys above
continue;
};Zig's StringHashMap stores the []const u8 key slice directly (no copy). After allocator.free(raw), the key pointers in the hashmaps are dangling. These are later dereferenced when iterating the hashmaps at src/main.zig:1608-1621 (jsonWriteString(key.*)) to render unique_tags and unique_agents, and also during subsequent put calls which compare against existing keys.
Impact: Undefined behavior (potential crash or memory corruption) in the OOM error-handling path.
Prompt for agents
In src/main.zig, function printLogJson, lines 1589-1596: Move the entries.append call BEFORE the tag_set.put / agent_set.put / session_set.put calls. This ensures that if entries.append fails and raw is freed, no dangling pointers have been stored in the hashmaps yet. The reordered block should be:
entries.append(allocator, .{ .hash = h, .raw = raw }) catch {
allocator.free(raw);
continue;
};
total_actions += 1;
total_observed += action.observed_messages.len;
... (existing stats counting code) ...
tag_set.put(action.tag, {}) catch {};
agent_set.put(action.agent_id, {}) catch {};
session_set.put(action.session_id, {}) catch {};
This way, raw is guaranteed to be owned by entries before any slices into it are stored elsewhere.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for (entries.items, 0..) |entry, idx| { | ||
| const action = agd.objects.Action.deserialize(entry.raw, allocator) catch continue; | ||
| defer action.deinit(allocator); | ||
|
|
||
| if (idx > 0) jsonWriteAll(","); | ||
| printActionJson(store, entry.hash, &action, allocator); | ||
| } |
There was a problem hiding this comment.
🟡 Invalid JSON output: comma placement uses loop index instead of write counter in printLogJson render pass
In the render loop of printLogJson, the comma separator between JSON array elements is gated on idx > 0 (the loop index over entries.items). If any entry's deserialization fails via catch continue at line 1628, subsequent entries will still use their array index for the comma check, producing malformed JSON.
Detailed Explanation
At src/main.zig:1627-1633:
for (entries.items, 0..) |entry, idx| {
const action = agd.objects.Action.deserialize(entry.raw, allocator) catch continue;
defer action.deinit(allocator);
if (idx > 0) jsonWriteAll(",");
printActionJson(store, entry.hash, &action, allocator);
}If entry 0 fails deserialization (e.g., OOM during array allocation in Action.deserialize) and entry 1 succeeds, idx is 1 so if (idx > 0) writes a leading comma, producing [,{...}] — invalid JSON.
Note: entries were already deserialized successfully in the stats pass, so this only triggers under OOM conditions during the second deserialization. Nevertheless, the code explicitly handles the catch continue path, making this a real error-handling bug.
Impact: Malformed JSON output that would break downstream consumers like render-summary.py.
| for (entries.items, 0..) |entry, idx| { | |
| const action = agd.objects.Action.deserialize(entry.raw, allocator) catch continue; | |
| defer action.deinit(allocator); | |
| if (idx > 0) jsonWriteAll(","); | |
| printActionJson(store, entry.hash, &action, allocator); | |
| } | |
| var first_action = true; | |
| for (entries.items) |entry| { | |
| const action = agd.objects.Action.deserialize(entry.raw, allocator) catch continue; | |
| defer action.deinit(allocator); | |
| if (!first_action) jsonWriteAll(","); | |
| first_action = false; | |
| printActionJson(store, entry.hash, &action, allocator); | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
--format jsonoutput option toagd logcommand for programmatic consumptionWhat it looks like
The PR comment will render:
<details>block showing session ID, action count, and participating agentsNote on CI testing
The
.agdstore is gitignored (by design — seeworkflow.md). The CI workflow will build agd and attempt to read traces, but since the store isn't in the repo, the trace step will produce empty output and skip posting a comment. The rendering pipeline has been verified locally.