Skip to content

Settings → API log: the calls agents make, as a list and as lanes - #98

Merged
lvwerra merged 7 commits into
mainfrom
feat/api-log
Aug 20, 2026
Merged

Settings → API log: the calls agents make, as a list and as lanes#98
lvwerra merged 7 commits into
mainfrom
feat/api-log

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 19, 2026

Copy link
Copy Markdown
Member

Settings gains an API log tab: two views over /api/operations, built from the operator-approved mock at https://lvwerra-agent-artifacts.static.hf.space/cron-and-api-log.html (the API-log half of it; the cron half is a separate feature).

Screens and the server-side evidence: https://lvwerra-agent-artifacts.static.hf.space/api-log.html

The half that was not free

/api/operations already recorded every call that changed something. It recorded no reads at alloperations.js gated on MUTATING = POST|PUT|PATCH|DELETE. Of the last 193 entries on this Space: 173 POST, 20 PUT, 0 GET. wait is a GET, so the swimlane view's return arrows could not be drawn from the data: the picture would have shown work being handed out and nothing ever coming back.

Three changes in server/src/operations.js, all small:

  1. One allowlist. LOGGED_READS = [/^\/api\/agents\/[^/]+\/wait$/] — that route and nothing else. Deliberately not /tail: every open pane polls it, so logging it multiplies the log by the polling rate and says nothing a resolved wait does not already say.
  2. One guard. A wait is a polling loop, so only the call that resolved is an event. Entries where matched !== true are dropped — timeouts, waits the caller abandoned (no response body), and waits whose target had already gone. Without this, one watched job would write an entry per reissue for as long as it ran.
  3. Logged reads never require ?from=. wait is documented read-only and every watch loop running right now calls it without an origin; the middleware 400s mutating calls that lack one, so applying that rule to reads would have broken every running loop the moment this shipped. An unattributed wait is still recorded — it says someone finished waiting on B, which draws as a mark on B's lane rather than an arrow.

Mutating calls are untouched: no origin still means 400.

And a target field

The entry shape gains target: {id, name, cli}, resolved at write time by a resolveTarget callback that index.js supplies (the session named in the path). The id was always in the path; the name is the thing that decays — read back a month later it is the name that session has now, if it still exists at all. {id} alone is recorded when the path names something the store does not know (a 404), which is itself worth seeing.

Entries written before this have no target, so the client digs the id out of the path and matches it against the roster. That is why the map is useful against the existing backlog rather than only against new traffic.

One line of documentation

The environment skill's two wait snippets now pass ?from=$AM_ID, with a sentence saying why: it is what turns an unattributed mark into a real arrow. No code depends on it.

The two views

List (web/src/components/ApiLog.tsx). One call per line — the operator was explicit that a wrapped row halves what you can see. Every cell is nowrap/clip; the path column is the only one allowed to take the slack (max-width: 0; width: 100%); below ~640px the table scrolls sideways inside its own frame rather than crushing that column to two letters. No pills or badges: status is coloured text in its own column, the method is accent-coloured text, and a wait row's method is muted because it is the one call that came back rather than went out. Filters: origin chips, failures / prompts / files, and a path substring.

Map. One lane per agent, callers ordered above the agents they call, calls drawn between the lanes: a prompt is a solid arrow from caller to target, a resolved wait a dashed one back the other way, and a call with no distinct target is a dot on its own lane. Hovering reads out the same fields as the list row.

Two judgement calls worth reviewing:

  • x is the call's rank, not its clock position. Real traffic arrives in bursts; spacing by wall-clock collapsed a burst into one unreadable column and left the quiet hours as whitespace. The axis still names the period on screen. The mock's own arrows are evenly spaced, so I read this as matching it, but it is a decision.
  • Lane order is callers-first. Sorting lanes by traffic alone put targets above their callers, at which point "down is out, up is back" stopped being true and the legend was lying. Callers above targets makes the legend's claim visually true in the common case; where two agents call each other it cannot be, so the test asserts the semantic invariant (a wait reverses the prompt it answers) rather than "every dashed arrow points up".

Beyond the mock: a failed call draws in the danger colour in the map too, since a 404 prompt otherwise looks identical to one that landed. Say if that is unwanted.

What it cannot say, by design

Prompt text is not stored — {present, chars, sha256} — and that is deliberate. The view says who prompted whom and how long the prompt was, never what it said. Identical checksums mean identical prompts, so repeats are marked ×N in the payload column with the digest in the title; that is the only honest thing the log can say about a job that fires the same text on a schedule.

Verified

  • Against a real server, not fixtures: patched server on a temp DATA_DIR, one call of each kind. Attributed wait → logged with origin and target. Unattributed wait → logged, origin null, not 400ed. 3s timeout → not logged. tail → not logged. Roster GET → not logged. Writes → unchanged, now with a target. A wait held open for 9s recorded durationMs: 9007, which is the point of logging it at all.
  • Both views rendered at 1200px and 390px against 210 entries pulled from this Space's live log: no pane overflow at either width, and zero rows taller than one line.
  • server/test/operations.test.mjs extended (25 checks) covers all of the above at the middleware level, including that a mutating call with no origin is still refused.
  • web/test/apiLog.test.mjs is new: row height measured rather than CSS trusted, the no-badge rule (the status cell must have no border, background or radius, and must carry a colour), the wait row's shape, the ×N repeat marking, and the arrow directions.
  • web typecheck, npm test (16 suites) and npm run build; server npm test (21 suites). Both suites pick the new files up automatically — Discover test suites instead of listing them by hand #91 landed first, so there was no list to edit.

What could regress

  • Log volume. One entry per resolved wait, which is roughly one per prompt — the mock estimates about a third more entries, and that matches what I saw. readOperations already tails a bounded 4 MiB.
  • A watch loop that passes ?from= now attributes its waits. If an agent passes a stale id, resolveOrigin returns null and the entry records no origin rather than failing the call — the read path never rejects.
  • resolveTarget runs a store.get per logged call. In-memory map lookup, on the response path, already inside a handler that did far more work.
  • The map is capped at the newest 48 interactions and 12 lanes, and says so on screen when it drops any. Filters apply before the cap, so narrowing by origin walks further back.

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES REQUESTED

  1. Correctness — long waits break the log's chronology. server/src/operations.js:126 stores the request-start time, but the record is appended only from the response finish/close handlers at lines 142–143; readOperations() then merely reverses append order at lines 164–167. web/src/components/ApiLog.tsx:121-126 preserves that order, and the map reverses it again as “oldest first” at line 231. Repro: start a wait, complete a later prompt, then resolve the wait. The endpoint returns the GET first even though its at is earlier (GET 22:05:10.730, then POST 22:05:10.757; newest-first timestamp check is false). With the real 300-second timeout this can put a five-minute-old wait above newer calls in the list and to their right in the map; the axis can literally run from a later time to an earlier one. Recording at response time is the right place to know durationMs and to discard unmatched waits, and the approved mock only promises finished waits, so I am not asking for pending rows. But completed rows must be sorted by at before the list/map derive rank and span, with an interleaved-wait regression test.

  2. Correctness + mock match — an unattributed wait invents a caller and draws the wrong shape. web/src/components/ApiLog.tsx:80 turns a missing origin into the display string ; line 240 then adds that string as a real caller lane, and lines 296–297 draw the return arrow to it. I added the server-supported origin: null wait to the browser fixture: the lanes became manager < operator < — < builder ... and the map contained builder → —. The approved mock explicitly says a wait without from is a mark on B's lane, not an arrow, because no caller is known. This matters on the backward-compatible path the server test deliberately keeps valid. Do not add a lane for the display fallback; render this case as a dot on the target lane, and extend apiLog.test.mjs with an unattributed wait. The committed map test currently uses attributed waits only, so it misses this branch.

  3. Correctness — successful deletion loses the target metadata this PR is meant to preserve. server/src/operations.js:131 calls resolveTarget(req) during the response event. But server/src/index.js:2541 removes the session from the store before res.json() at line 2544. Reproducing that order yields target: { id: 's-1' } for a known session named deleted-agent; its name and CLI are already gone at the instant this new audit record is written. The PR says the new target snapshot prevents renamed/deleted sessions from making the audit decay, but the deletion operation itself immediately decays to a raw id, and the map cannot recover it from the current roster. Snapshot the target before the handler mutates the store (while retaining the id-only behavior for a genuinely unknown target), and make the target test mutate/remove its fake session before finishing the response rather than using a resolver that always returns a name.

What I verified as good: the GET allowlist is exactly /api/agents/:id/wait; only matched === true responses are appended; tail, roster, timeouts, abandoned waits, and gone targets are excluded; this extends the existing operationMiddleware/JSONL rather than creating a second log; prompt text remains {present, chars, sha256}. The list/browser rules also match the mock: rows stay one line at 390px and 1200px, status is colored text with no border/background/radius, and attributed prompt/wait interactions are connected arrows in opposite directions.

Checks run: server npm test (21 suites), web npm test (16 suites), web typecheck, web production build, plus targeted middleware/browser reproductions above. All committed checks pass; the anonymous-wait regression fails only when that missing fixture is added.

lvwerra pushed a commit that referenced this pull request Aug 19, 2026
…hat erased its own name

All three findings on #98 reproduced and fixed.

**A long wait no longer breaks the order.** A record carries the time its request
STARTED but is appended when the response finishes, and a wait blocks for up to
300s — so it lands in the file after calls that began later and finished sooner,
and `readOperations` merely reversed append order. The list showed a
five-minute-old wait above newer calls, and since the map derives x from rank,
its axis could run backwards. Now sorted by `at` after the reverse, so ties keep
newest-appended first exactly as before. This also fixes `?before=` paging,
which was cursoring on an order the endpoint did not actually return.

Recording at response time stays: it is the only moment `durationMs` and
`matched` are known, and the mock only promises finished waits.

**An unattributed wait no longer invents a caller.** `who()` served two masters:
the Who column, where a missing origin should read as an em dash, and the map,
where it must be nothing at all. The dash became a lane and the return arrow was
drawn into it — a caller the log never knew, on precisely the backward-compatible
path the server deliberately keeps open. Split into `originLane()` for the map;
the existing dot branch then does the right thing on its own, and the wait draws
as a mark on the lane of whoever was waited ON, which is the end that is known.

**A delete keeps the name of what it deleted.** `resolveTarget` ran inside the
response handler, but the delete route removes the session from the store BEFORE
answering — so the one operation whose name can never be recovered from the
roster was the one that recorded a bare id. Snapshotted before `next()` instead,
which also gives a rename the name it had when the call arrived. An id-only
target is still recorded when the path names something the store never knew.

Each fix has a test that fails without it: the fake store now loses its session
mid-request rather than always resolving; the chronology test starts a wait,
completes a later call, then resolves the wait; the browser fixture gains an
unattributed wait whose target nothing else touches. Restoring each bug fails
exactly those checks and nothing else — the anonymous case reproduces the
reviewer's own output, lanes `manager < operator < — < builder` and an arrow
`builder → —`.

Verified end to end against a real server as well: creating, archiving and
deleting a session leaves `{id, name: "doomed-agent", cli: "shell"}` in the log
after the session is gone from the roster.
@lvwerra

lvwerra commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

All three fixed in e898f3a, each with a test that fails without it. Nothing disputed — the delete one in particular is the feature's own premise failing on the single operation it was written for, and I had a resolver in the test that could never catch it.

1. Chronology

Sorted, not just reversed. readOperations now does .reverse().sort((a, b) => …at…), so rows come back newest-first by when the request started, and ties still keep newest-appended first — which is what the plain reverse gave, so nothing else moves.

The regression test starts a wait, completes a later call while it is blocked, then resolves the wait:

with the sort:     wait at index 1, the overlapping call at 0, every row newest-first
without it:        wait at index 0, the overlapping call at 1     ← your repro

The wait still reports durationMs: 51 in that test, so recording at response time is untouched — as you said, it is the only moment durationMs and matched exist.

One extra thing this fixes that neither of us mentioned: ?before= paging cursors on row.at, so it was paging against an order the endpoint did not actually return. It does now.

2. The caller that was never there

who() was serving two masters — the Who column, where a missing origin should read as an em dash, and the map, where it must be nothing. Split into originLane(), which returns ''; the existing dot branch then handles it without further change, because a wait with no origin already resolves to "source = target lane, destination = none".

The fixture gains an unattributed wait whose target (lonely) nothing else in the fixture touches, so the mark it leaves can only have come from that entry. Three new checks: no lane, no arrow touching one, and a dot on the target's lane with no arrow to or from it. Restoring the bug reproduces your output exactly — lanes manager < operator < — < builder < files-5 < ghost-1, arrow {"src":"builder","dst":"—"} — and fails those three and only those three.

3. The delete that erased its own name

resolveTarget now runs before next(), so the snapshot is taken while the session still exists. As a side effect a rename records the name the session had when the call arrived, which is the state the entry describes.

You were right that the test could not have caught this: the resolver always answered. It now reads a fake store that the delete handler empties mid-request, the way the real route does.

target resolved in the response handler:  {"id":"s-doomed"}
snapshotted before next():                {"id":"s-doomed","name":"deleted-agent","cli":"codex"}

And end to end against a real server, since the ordering that causes this lives in index.js rather than in the middleware — create, archive, delete a shell session, then read the log after it is gone from the roster:

still in roster: False
delete entry target: {"id":"doomed-agent-e80f54","name":"doomed-agent","cli":"shell"} | status 200

An id-only target is still what gets recorded when the path names something the store never knew (never-existed), which is a separate check.

Re-verified

server npm test 21 suites, web npm test 16 suites, web typecheck and production build — all green on top of main. The map re-rendered against 210 real entries with an unattributed wait mixed in: it draws as a lone grey dot on its target's lane, and no appears anywhere.

Also removed a throwaway screenshot harness (web/test/__shot.mjs) that a git add -A had swept into the first push of this commit. It was never part of the feature; it is gone from the branch.

@lvwerra

lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Operator feedback from am-dev-2 applied in 2fbd73e, all seven notes. Screens, against the exact run described (my-claude creates poet, prompts it, waits): https://claude.ai/code/artifact/50140ce5-cdcf-490e-9765-f07540dd41cc

the note what changed
"by default the user should be excluded. this should be the only selection possible" Operator-typed origins hidden by default. The origin chips, failures/prompts/files chips and the path search are gone — one control, which names how many calls it is holding back.
"the arrows can be a bit slimmer (and only highlight slightly when hovering)" 1.6px → 1px, smaller heads; hover lifts to 1.6 rather than 2.6.
"the whole plot a bit denser" Lanes 46px → 30px, tighter top/bottom, up to 14 lanes and 60 calls on screen.
"create events are not shown" Drawn. A create names nothing in its path — the session it made is in the result — so it used to land as a dot on the caller's lane.
"it's also not clear then that the poet session didnt exist before that because the line already existed. maybe it should be a dashed and barely visible" Exactly that: a lane is dashed and faint to the left of the create that made it, solid after, with an open mark at the moment it begins. Only lanes whose creation we actually witnessed get this — a pre-existing agent stays solid rather than inventing a birthday.
"in the wait, i think it should also be visible when the wait started" A wait is now a span. Its at is when it was issued and durationMs how long it held, so the resolution is drawn where that moment falls and the stretch between is shaded on the caller's lane — the party actually blocked. Your 4m 18s wait now takes 4m 18s of the picture.
"moved the legend inside the graph (at the bottom) and then have a pretty view of the json when hovering" Legend is inside the frame on the axis line. Hovering gives the whole entry as JSON with the metadata named (from, to, status, took).

One judgement call worth flagging: the JSON card sits under the plot rather than following the cursor. A floating card covers the lanes you are reading, and gets clipped by a frame that scrolls sideways — the first version did both. Fixed position, fixed size, always in the same place. Easy to change back if you would rather it followed the mouse.

The one that needs your decision: "it would also be great if we could see the prompts sent"

Not built, because it is a policy question rather than a missing feature. The log stores {present, chars, sha256} and deliberately never the text, so I did not start writing prompts into it. Two ways to get you the words:

  1. Store the prompt text in the log. Simplest. It also changes what the log is: prompts would live in a second place on the bucket, in a file whose whole design is to hold no content — and every prompt any agent ever sent would be sitting in it, indefinitely.
  2. Link through to the target's own transcript, where the prompt already exists verbatim. The hover card gains an "open in the reader" action for the agent that was prompted. No new storage, no new copy of anything to keep private, and the reader already renders that text properly.

I would build the second — it costs an onOpenSession callback threaded from App down to the card, and the text you see is the real thing rather than a duplicate that can drift. Say which you want and it is a small follow-up either way.

Checks

Every change has an assertion in web/test/apiLog.test.mjs, including the two straight from the operator's words: a create reaches the lane it created and that lane is faint before it, and a wait draws a span from where it started. Also pinned: one filter control and no search box, the operator hidden by default with the count, the failure count still visible as a fact, and the card being static rather than floating.

web typecheck, npm test (16 suites), production build; server npm test (21 suites). All green, on top of main.

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES REQUESTED — the earlier three bugs remain fixed, but the new wait-span implementation drops the duration in sparse traffic. That should block merge.

  1. Correctness / operator feedback — a long wait is still a point when no other request starts during it. web/src/components/ApiLog.tsx:271-276 maps the resolution to the rank of the last existing call at or before at + durationMs; it does not map the resolution time itself. If there is no intervening call, endX() returns own, and the x0 < x guard at lines 345-347 omits .al-held entirely. Browser repro: wait starts 21:01:40, durationMs: 300000, next request starts 21:11:40. The five-minute wait rendered with no span. The same happens whenever the wait is the newest call (own === 1). The committed assertion at web/test/apiLog.test.mjs:291-292 only asks whether some wait has a span; the fixture's other wait has intervening marks, so it cannot catch this case. Please map the end time between neighboring ranks (and/or include wait resolution in the plot domain), clamp it to the frame, and pin the sparse 300s case. This is the exact condition the operator's “visible when the wait started” change has to survive.

  2. Does it match the operator's design feedback — the “whole entry” card cannot be scrolled or selected. I agree with fixing the card under the plot; that position keeps the lanes visible and avoids sideways-scroll clipping. The interaction does not yet support that decision, though: every mark clears hover on onMouseLeave (ApiLog.tsx:333,341), while the JSON is capped and made scrollable at web/src/styles.css:1482. On the create entry in my browser probe, the JSON had a 210px client height and a 411px scroll height. Moving the pointer from the mark to the fixed card immediately replaced it with the idle card (.al-json count went from 1 to 0), so the lower half cannot be read, scrolled, or copied. Keep the fixed position, but retain the selected entry while the card is being interacted with (or make selection persistent by another explicit interaction). The current browser test only asserts position: static, so it misses the unusable overflow path.

Reconfirmed as good at 2fbd73e: operator rows are the exact rows held back by the one control and its count; the removed chips/search do not hide any other non-operator rows; successful creates read their lane from the result and failed/shape-mismatched creates do not invent a birth; birth styling is derived only from witnessed creates in the plotted window, so pre-existing/off-window agents stay solid; 14 lanes is a hard cap and rendered with 30px viewBox separation (no overlap at desktop or the 620px mobile plot floor); the legend remains inside the frame. Chronology is sorted by at, an unattributed wait creates no lane, and delete target metadata is snapshotted before store mutation. Prompt text remains summarized rather than stored.

Checks: server npm test (21 suites), web npm test (16 suites), web typecheck and production build all pass. Added browser probes reproduce both findings; the 300s probe fails specifically because its wait has no .al-held.

@lvwerra

lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Both fixed in d237eb3. Nothing disputed — finding 1 is the operator's own feature not surviving its normal case, and my assertion was written loosely enough to pass while it was broken.

1. A wait spans even when nothing else happens

The cause was placing the resolution at the rank of the nearest existing call at or before at + durationMs. With no intervening call that resolved to the wait's own rank, and x0 < x dropped the span; same whenever the wait was the newest entry (own === 1).

The x-axis is now ranked over events, not calls, and a wait is two events — issued and resolved. So:

  • every wait that blocked spans at least one step, whatever else was or wasn't happening;
  • the axis covers the last resolution rather than the last request, so a wait still open at the right edge runs to the frame edge instead of collapsing;
  • a wait with durationMs: 0 contributes no end event and draws no span, which is correct — nothing to show.

Your repro is now a fixture entry: at 600s, durationMs: 300000, one prompt before it and nothing at all after. The assertion is specific in two ways it wasn't before — one span per wait that blocked, and that particular wait's span reaching the plot's right edge:

ok  every one of the 4 waits that blocked draws a span
ok  including the five-minute one that blocked with nothing else going on

Restoring the snapping logic fails the first: spans: 212 — one span where four are due, exactly the shape you described.

2. The card can be reached

Kept fixed below the plot. What was missing was any way for the pointer to get there: a short grace period now carries it from the mark into the card, and the card holds the selection while it is under the pointer. Clicking a call pins it, which survives the pointer going anywhere at all — the border picks up the accent, the footer hint disappears, and a ✕ releases it.

Two new checks, and they measure the thing rather than the CSS:

ok  the entry survives the pointer moving onto the card
ok  and the JSON can actually be scrolled when it overflows   (sets scrollTop, reads it back)
ok  clicking a call pins the entry, and it says so

Restoring the immediate clear fails the first and third.

Also worth noting

While fixing the fixture I found my own list assertion had been reading "the newest row" positionally, which broke the moment I appended a newer entry. It now looks the row up by its path, and there is a separate check that the newest row really is the newest — the thing that assertion was quietly standing in for.

Checks

web typecheck, npm test (16 suites), production build; server npm test (21 suites). All green on top of main. Prompt text still summarised, not stored — that question is untouched and still the operator's.

@lvwerra

lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

All five in c86cb99. Screens: https://claude.ai/code/artifact/85658068-9fb4-46a3-8718-5e00d75e42b0 — same run as before, with the prompts now stored so the card can show what was actually said.

Nothing here fought anything already built except one thing I kept rather than replaced, called out below.

"why arent the prompts not stored? or just not shown? we should change that"

They were not stored — the log deliberately kept {present, chars, sha256}. Now it stores the text, and the card shows it as its own block above the JSON (a paragraph escaped onto one JSON line is unreadable). Three limits I put in deliberately, all reviewable:

  • Only the routes that carry a prompt are unwrapped: spawn, /prompt, /input, remote messages. A file write goes through the same summariser and its body is still a checksum — "store the prompts" is not "store every byte that crossed the API", and /api/files/:id/write bodies are the biggest thing through there.
  • Credentials are still redacted, prompt route or not.
  • A prompt over 64 KB is cut, with truncated: true, while chars and sha256 still describe the whole thing. Not a size policy — a bound so one pasted binary cannot make a line unreadable.
  • sha256 stays beside the text, as you asked: identical checksums are how a repeat shows up, and the ×N marker in the list still works.

The thing that deserves saying out loud

operations.jsonl is on the bucket, append-only, with no rotation, and from this change on it contains the text of every prompt any agent sends. Yesterday it held no content at all; today it holds all of it. You've said size is not a concern so I have not built rotation — but the file is a different object than it was, and if it is ever shared or copied that matters. The test that asserted "prompt content is not copied into the audit log" now asserts the opposite, deliberately, so nobody re-adds the old rule by accident.

The other four

  • Checkbox. "show user calls", unchecked, on the same line as List/Map/Refresh, with the count of what it is holding back. The notice is gone.
  • Real time. A second mode — even | real time. Ranked spacing stays the default and stays supported: it is what makes a sparse trace readable, and the wait span depends on ranking over events. Both work.
  • Zoom. 1× to 6×, widening the drawing inside the frame that already scrolled sideways. Horizontal only, so the 14-lane cap and the 30px lane spacing are untouched and nothing can overlap vertically at any level. Clicking the level resets to 1×.
  • The card does not scroll. As tall as its content; the page scrolls. That also settles the reviewer's earlier finding — a 210px window over 411px of JSON was the reason the second half could not be read.

One thing I kept rather than removed: clicking a mark still pins the entry, and the pointer can still travel from a mark into the card. With no scrolling that is no longer needed for reading, but it is still what lets you select and copy a prompt without it vanishing. Say the word if you would rather hover-only.

Checks

server npm test (21 suites, including new coverage that a prompt is stored, a nested prompt is stored, a file body is not, an over-size prompt is cut and says so, and a credential beside a prompt is still redacted), web npm test (16 suites, with the checkbox, the prompt block, both layout modes and zoom pinned), typecheck and production build. All green on top of main.

@lvwerra

lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Both in 5514eb8. Screens are now on the Space, not claude.ai: https://lvwerra-agent-artifacts.static.hf.space/api-log.html — the PR body's link is updated too, and the artifacts index has an entry. Nothing gets published anywhere else from here on.

Full calls, no filtering

Gone:

  • the route allowlist — every body is stored, whatever route it came in on;
  • the 64 KB cut and the truncated flag;
  • the item caps (slice(0, 100)) that were quietly trimming long arrays and wide objects.

Kept, and only this: credential redaction. It is not compression, and writing secrets into a file on the bucket is not something you asked for.

Also kept, as you said: sha256 and chars beside anything long. The 500-character threshold still decides the shape — a bare string, or a string with its checksum attached — and nothing is lost either way. MAX_DEPTH stays (raised to 20) purely as a cycle backstop: a parsed body cannot contain a cycle, but JSON.stringify throwing there would lose the whole entry rather than trim it.

What this makes of the file

operations.jsonl is on the bucket, append-only, unrotated, and now takes bodies whole. No cap and no rotation, as instructed. Two specifics worth having on the record:

  • File writes are the big ones. PUT /api/files/:id/write accepts up to 8 MB of text and all of it now lands in the log. Skill writes take 5 MB.
  • Attachment bytes do not land there. That route streams the request into storage rather than parsing a body, so req.body is undefined for it — the entry records the attachment's metadata from the response, not the image. So the "pasted screenshot is megabytes on one line" case does not actually arise through this path; file writes are what will make the big lines.

The reader, which is where the work was

  • readOperations could have read as empty. It took a fixed 4 MiB tail and dropped the first (partial) line — so once a single entry was bigger than the window, the tail held no complete line at all. It now grows the window ×4 up to 256 MiB until it holds whole records. Pinned with a 6 MB entry: the rows behind it, the giant one itself, and the one after it are all still readable.
  • The card paints a bounded slice. 20,000 characters, with "showing the first 20,000 of 392,000 characters" above it. A <pre> holding 400,000 characters locks the tab. Nothing is cut on disk — this is what the viewer draws.
  • The list stays one line per call: the payload column reads chars and switches to KB past 100k, so an enormous entry is a size rather than an attempt to show it.
  • The web client asks for 200 entries, not 500 — a page of them is measured in megabytes now.

Tests

The two that encoded the old rule now assert the new one, with a comment saying they were flipped on purpose rather than being deleted:

ok  a file write body is kept too — no route allowlist any more
ok  nothing is truncated, however big — kept 71680 of 71680
ok  with the checksum of the whole thing beside it

New, for the robustness half:

ok  the entries behind it are still readable — 15 rows      (6 MB entry in the file)
ok  including the giant one itself
ok  and the one after it
ok  a 400 KB body is a size in the list, not an attempt to show it
ok  a 400 KB body is not painted whole, and the card says how much it is holding back

server npm test 21 suites, web npm test 16 suites, typecheck and production build — all green on top of main. The settings blurb and the component header no longer claim prompt text is never stored, since that is no longer true.

@lvwerra

lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Replaced, not added to, in 83cedb8. Screens updated in place: https://lvwerra-agent-artifacts.static.hf.space/api-log.html

The 1×–6× multiplier is gone. Dragging horizontally across the plot now selects a window and the axis zooms to it.

  • A way back out, two of them: a reset zoom button that only exists while zoomed, and double-click anywhere on the plot.
  • It says what you have. The range follows the pointer while you drag (10:31:54 → 10:34:19 above the selection), and once zoomed the bar reads reset zoom · 10:31:54 → 10:34:19 · 4 of 8 calls with the axis ends naming the slice instead of the whole run.
  • Both modes. The window is stored in domain units rather than pixels or timestamps, so a brush is a range of events in ranked mode and a range of time in real time. The readout is a clock in both, because the axis can name the time at any point of itself — so I did not need to restrict it to real time.

The three collisions

Hover and click still belong to the marks. The pointer handlers sit on the SVG, so a drag that starts on top of a mark still drags — but two guards keep it from also being read as an inspection: no card preview while a drag is in progress, and the click that arrives after a drag is swallowed once. Pinning by click still works; there is a test for each.

Sideways scroll. Without the multiplier the drawing fits its frame again, so this only arises below the 620px floor on a phone. Brush coordinates go through the SVG's own bounding box, which already accounts for the frame's scroll offset, and the whole drawing is brushable rather than only the visible slice — dragging to the edge of a scrolled frame still selects the domain point under the cursor.

Touch is left alone, deliberately. Brushing ignores pointerType === 'touch' and nothing sets touch-action, so a horizontal drag still scrolls the frame and a vertical one still scrolls the page. Mouse and pen only. If you want it on a phone later it wants a different gesture (two-finger, or a pinch) rather than stealing the scroll.

Also: marks and lane births are clipped to the window, so a lane born before it is solid across it, one born after is faint across it, and a wait that started outside but resolved inside still draws its span up to the edge.

Tests

Driven with a real mouse (page.mouse.down/move/up) rather than synthetic events, because pointer capture and the trailing click are exactly what had to behave:

ok  the selection is drawn while dragging, with the range on it
ok  releasing keeps that window, and says which one it is
ok  there are fewer calls in view than before
ok  the axis names the slice, not the whole run
ok  and a drag that started on a mark did not also select it
ok  there is a way back out
ok  reset puts everything back
ok  double-click clears it too
ok  a touch drag does not brush, and the page keeps its own scrolling
ok  and the map renders without React complaining

That last one earned its place: while writing these, two entries in my own fixture shared an id, React duplicated their DOM nodes, and the symptom read exactly like a zoom bug — a mark count that changed after a reset. The suite now fails on any React warning, so the next id collision is named rather than debugged. (Production ids are crypto.randomUUID(), so this was only ever a fixture problem.)

Everything else from the last round is untouched. web typecheck, npm test (16 suites), build; server npm test (21 suites) — green on top of main. One incidental note: test/archive.test.mjs failed once on its fixed port 7894 mid-run and passed alone and on the re-run, which is the port-collision flake #91 documents rather than anything here.

Agent Manager added 7 commits August 20, 2026 15:37
Two views over /api/operations, per the approved mock.

**List.** One call per LINE, not per row — a wrapped row halves how many calls
fit on a screen, so every cell clips and the path is the only column allowed to
take the slack; below ~640px the table scrolls sideways rather than crushing it
to two letters. No pills anywhere: status is coloured text in its own column.
Filters are origin, failures/prompts/files, and a path substring.

**Map.** One lane per agent, callers above the agents they call, and the calls
drawn between the lanes: a prompt is an arrow from caller to target, a resolved
wait is a dashed arrow back the other way. x is the call's RANK, not its clock
position — real traffic arrives in bursts, and spacing by time collapses a burst
into one unreadable column and leaves the quiet hours as whitespace. The axis
still names the period on screen.

**The part that was not free.** Only writes were logged, so of the last 193
calls, 173 were POST, 20 PUT and none were GET. `wait` is a GET, so every arrow
coming back was missing from the data and the lanes would have shown work going
out and nothing ever returning. Three changes, all in operations.js:

- one allowlist — `/api/agents/:id/wait` is logged, and deliberately nothing
  else. Not `tail`: every open pane polls it, so logging it multiplies the log
  by the polling rate and says nothing the resolved wait does not.
- one guard — a wait is a polling loop, so only the call that RESOLVED is an
  event. Timeouts, abandoned waits and gone targets are dropped, which is what
  keeps this from growing the log by however long a job ran.
- logged reads never require `?from=`. `wait` is documented read-only and every
  watch loop running right now calls it without one; refusing those would break
  them the moment this ships. An unattributed wait still records who was waited
  ON, and draws as a mark on that agent's lane rather than an arrow.

Adding `?from=$AM_ID` to the skill's wait examples is what turns those marks
into arrows, so this also updates the environment skill's two wait snippets and
says why in one line.

**And a target field.** The entry shape gained `target: {id, name, cli}`,
resolved at write time from the session named in the path. The id was always in
the path, but a name read back later is the name that session has NOW —
renamed or deleted and the audit trail stops making sense. Older entries have no
target, so the view digs the id out of the path and matches it against the
roster; the map is useful on the existing backlog rather than only on new
traffic.

Prompt text stays unstored — {present, chars, sha256}, as before. So the view
says who prompted whom and how long the prompt was, never what it said, and
marks equal checksums as repeats, which is the only honest thing it can say
about a job that fires the same text on a schedule.

Verified against the running Space's real log and a local server: an attributed
wait, an unattributed one, a timeout, a tail and a roster poll — logged, logged,
dropped, dropped, dropped — and a resolved wait recorded with durationMs 9007.
Rendered both views at 1200 and 390: no pane overflow, and zero rows taller than
one line. New browser test pins that, the no-badge rule, and the arrow
directions; the server test covers the middleware.
…hat erased its own name

All three findings on #98 reproduced and fixed.

**A long wait no longer breaks the order.** A record carries the time its request
STARTED but is appended when the response finishes, and a wait blocks for up to
300s — so it lands in the file after calls that began later and finished sooner,
and `readOperations` merely reversed append order. The list showed a
five-minute-old wait above newer calls, and since the map derives x from rank,
its axis could run backwards. Now sorted by `at` after the reverse, so ties keep
newest-appended first exactly as before. This also fixes `?before=` paging,
which was cursoring on an order the endpoint did not actually return.

Recording at response time stays: it is the only moment `durationMs` and
`matched` are known, and the mock only promises finished waits.

**An unattributed wait no longer invents a caller.** `who()` served two masters:
the Who column, where a missing origin should read as an em dash, and the map,
where it must be nothing at all. The dash became a lane and the return arrow was
drawn into it — a caller the log never knew, on precisely the backward-compatible
path the server deliberately keeps open. Split into `originLane()` for the map;
the existing dot branch then does the right thing on its own, and the wait draws
as a mark on the lane of whoever was waited ON, which is the end that is known.

**A delete keeps the name of what it deleted.** `resolveTarget` ran inside the
response handler, but the delete route removes the session from the store BEFORE
answering — so the one operation whose name can never be recovered from the
roster was the one that recorded a bare id. Snapshotted before `next()` instead,
which also gives a rename the name it had when the call arrived. An id-only
target is still recorded when the path names something the store never knew.

Each fix has a test that fails without it: the fake store now loses its session
mid-request rather than always resolving; the chronology test starts a wait,
completes a later call, then resolves the wait; the browser fixture gains an
unattributed wait whose target nothing else touches. Restoring each bug fails
exactly those checks and nothing else — the anonymous case reproduces the
reviewer's own output, lanes `manager < operator < — < builder` and an arrow
`builder → —`.

Verified end to end against a real server as well: creating, archiving and
deleting a session leaves `{id, name: "doomed-agent", cli: "shell"}` in the log
after the session is gone from the roster.
From using it on am-dev-2, with the screenshot they sent as the reference.

**Your own calls are hidden by default, and that is the only filter.** The
operator's clicks were most of the log and none of the story — this view is for
what the agents did to each other. The origin chips, the failures/prompts/files
chips and the path search are gone; one control turns your own calls back on and
says how many are hidden. The failure count survives as a fact in the header,
where it was more useful than as a filter anyway.

**Slimmer and denser.** Arrows 1.6px → 1px with smaller heads, hover lifts them
to 1.6 instead of 2.6, lanes 46px → 30px, and the legend moved inside the frame
next to the time axis. At a real pane width the old plot was mostly white space.

**Creates are drawn, and a lane admits when it did not exist.** A create names
nothing in its path — the session it made is in the RESULT — so it used to draw
as a dot on the caller's lane while the agent it brought into being appeared to
have been there all along. Now the arrow reaches the new lane and ends in an
open mark, and the lane itself is faint and dashed to the left of that moment.
That was the operator's example: `my-claude` created `poet`, prompted it, waited
for it, and only the middle step was visible.

**A wait is a span, not a point.** `at` is when it started and `durationMs` how
long it held, so the resolution is drawn where that moment falls in the sequence
and the stretch between is shaded on the CALLER's lane — the party that was
blocked. Four minutes of waiting now takes four minutes of the picture.

**Hovering shows the whole entry as JSON** — the call, who to whom, status and
duration named as fields — under the plot rather than floating over it. A card
that follows the cursor covers the lanes you are trying to read, and clips
against a frame that scrolls; this one is always the same size in the same place.

Not done, because it needs a decision rather than an implementation: seeing the
prompts themselves. Raised on the PR with a proposal.

Every change has a check in apiLog.test.mjs, including the two that came
straight from their words: a create reaches the lane it created and that lane is
faint before it, and a wait draws a span from where it started.
Both findings reproduced and fixed.

**The span no longer depends on other traffic.** The first version placed a
wait's resolution at the rank of the nearest EXISTING call at or before it, so a
wait that blocked while nothing else happened resolved onto its own rank and the
`x0 < x` guard dropped the span entirely — a five-minute wait drawn as a point,
which is the ordinary shape of waiting rather than an edge case. The same held
whenever the wait was the newest call.

The x-axis is now ranked over EVENTS instead of calls, and a wait is two events:
issued, and resolved. Every wait that blocked therefore spans at least one step,
the axis covers the last resolution rather than the last request, and a wait that
is still the newest thing on screen runs to the right edge instead of collapsing.

**The card can be read.** It stays fixed below the plot — that part was right —
but every mark cleared the selection on mouseleave while the JSON is capped and
scrollable, so the pointer could never arrive: the lower half of a long entry
could not be read, scrolled or copied. A short grace period now carries the
pointer from the mark into the card, and the card holds the selection while it is
under the pointer. Clicking a call pins it outright, which survives the pointer
going anywhere at all; the card says so, and offers a way to release it.

The old assertion asked whether SOME wait had a span, which passed while the bug
was live because another wait in the fixture had calls inside it. It now asserts
one span per wait that blocked, and the fixture gained the reviewer's case: five
minutes of blocking with nothing in between and nothing after. Restoring the
snapping logic fails it (1 span where 4 are due); restoring the immediate clear
fails the two new card checks.
…a checkbox

Last round of operator feedback from am-dev-2.

**Prompts are kept, and shown.** The log summarised every payload to
{present, chars, sha256}; the operator asked why, and asked for it changed. It
now stores the text — but only on the routes that carry a prompt (spawn, prompt,
input, remote messages). A file write goes through the same summariser and its
body is still a checksum: "store the prompts" is not "store every byte that
crossed the API". Credentials are still redacted whatever route they arrive on,
the checksum stays beside the text because equal checksums are how a repeated
prompt is spotted, and a prompt over 64 KB is cut with `truncated: true` while
chars and sha256 still describe the whole thing.

**This makes operations.jsonl a different object than it was.** It is on the
bucket, it is append-only, it has no rotation, and it now contains the text of
every prompt any agent sends. The operator has said size is not a concern so
there is no rotation here, but the file's nature changed and that belongs in the
open rather than in a diff. The test that used to assert "prompt content is not
copied into the audit log" now asserts the opposite, deliberately.

**The prompt shows as itself** in the card, above the JSON — escaped into one
JSON line a paragraph is unreadable — with its length and checksum still in the
entry.

**Real time is a second mode, not a replacement.** Ranked spacing is what makes
a sparse trace readable and the wait span depends on ranking over events, so
both exist and ranked stays the default. By the clock, three prompts in one
second land on top of each other, which is exactly why:

**Zoom.** 1× to 6×, widening the drawing inside the frame that already scrolls
sideways. Horizontal only, so the 14-lane cap and the 30px lane spacing are
untouched and nothing can overlap vertically at any level.

**The control is a plain checkbox** — "show user calls", unchecked, on the same
line as the buttons — instead of a notice announcing what it had hidden.

**The card no longer scrolls.** It is as tall as its content and the page
scrolls, which also settles the earlier review finding: a 210px window over
411px of JSON was the reason the second half could not be read.
"just store all the full api calls. why this arbitrary compression."

Gone: the route allowlist, the 64 KB cut and the `truncated` flag, the
item and entry caps on arrays and objects. Every body is now stored as it
arrived, on every route.

Kept: credential redaction, which is not compression — it is not writing secrets
into a file that lives on the bucket. And `chars`/`sha256` beside anything long,
because equal checksums are how a repeated prompt or a scheduled job shows up in
the list, and `chars` is what the compact column reads without touching the body.
The 500-character threshold still decides SHAPE — a bare string, or a string with
its checksum attached — and loses nothing either way. `MAX_DEPTH` stays at 20 as
a cycle backstop rather than a limit: a request body cannot contain a cycle, but
JSON.stringify throwing there would lose the whole entry.

**What this makes of the file, stated rather than designed around.**
`operations.jsonl` is on the bucket, append-only, unrotated, and now takes bodies
in full. File writes are the big ones — that route accepts up to 8 MB of text —
so single lines of megabytes are expected. Attachment BYTES do not land here:
that route streams the request rather than parsing a body, so `req.body` is
undefined for it. No cap and no rotation, per the operator.

**The reader had to change to survive that**, which is robustness, not
compression:

- `readOperations` grew a fixed 4 MiB tail that could contain no complete line at
  all once one entry was bigger than the window — the log would have read as
  empty. It now grows the window (×4, ceiling 256 MiB) until it holds whole
  records. Pinned with a 6 MB entry and reads behind it.
- the card paints at most 20,000 characters of a body and says how many it is
  holding back; a <pre> with 400,000 characters in it locks the tab.
- the list column reads `chars` and switches to KB past 100k, so a row stays one
  line whatever it describes.
- the web client asks for 200 entries rather than 500: a page of them is measured
  in megabytes now.

The two tests that asserted the allowlist and the truncation now assert the
opposite, with a note saying so, rather than being deleted. The settings blurb
and the component's own header no longer claim prompt text is never stored.
"when i said zoom, i meant more zooming into specific regions of the x-axis…
drag horizontally a window to zoom in."

The 1×–6× multiplier is gone — it widened the whole drawing, which is not the
same thing. Dragging horizontally across the plot now selects a window and the
x-axis zooms to it: the brush every charting library has.

- **A way back out**, two of them: a `reset zoom` button that only exists while
  zoomed, and double-click anywhere on the plot.
- **It says what you have.** The range follows the pointer while dragging, and
  once zoomed the bar reads `10:31:54 → 10:34:19 · 4 of 8 calls` with the axis
  ends naming the slice rather than the whole run.
- **Both modes.** The window is held in domain units, so brushing means a range
  of events in ranked mode and a range of time in real time; the readout is a
  clock in both, because the axis knows the time at any point of itself.

The three collisions, handled rather than discovered:

- **Hover and click still belong to the marks.** Pointer handlers sit on the SVG
  and the marks keep theirs, with two guards: no card preview while a drag is in
  progress, and the click that follows a drag is swallowed once — so a drag that
  begins on top of a mark drags, and does not also select it.
- **Sideways scroll.** Without the multiplier the drawing fits its frame again,
  so this only arises below the 620px floor on a phone. Brush coordinates go
  through the SVG's own box, which already accounts for the scroll, and the whole
  drawing is brushable rather than only the visible slice.
- **Touch is left alone.** Brushing ignores `pointerType === 'touch'` and nothing
  sets `touch-action`, so a horizontal drag still scrolls the frame and a vertical
  one still scrolls the page. Mouse and pen only, deliberately.

Marks and lane births are clipped to the window: a lane born before the window
is solid across it, one born after is faint across it, and a wait that started
outside but resolved inside still draws its span up to the edge.

Driven in the tests with a real mouse rather than synthetic events, because
pointer capture and the trailing click are exactly what had to behave. One of
those tests also found a duplicate-key bug in my own fixture — two entries
shared an id, React duplicated their DOM nodes, and it read like a zoom bug — so
the suite now fails on a React warning as well.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant