Settings → API log: the calls agents make, as a list and as lanes - #98
Conversation
lvwerra
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES REQUESTED
-
Correctness — long waits break the log's chronology.
server/src/operations.js:126stores the request-start time, but the record is appended only from the responsefinish/closehandlers at lines 142–143;readOperations()then merely reverses append order at lines 164–167.web/src/components/ApiLog.tsx:121-126preserves 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 itsatis earlier (GET 22:05:10.730, thenPOST 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 knowdurationMsand 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 byatbefore the list/map derive rank and span, with an interleaved-wait regression test. -
Correctness + mock match — an unattributed wait invents a caller and draws the wrong shape.
web/src/components/ApiLog.tsx:80turns 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-supportedorigin: nullwait to the browser fixture: the lanes becamemanager < operator < — < builder ...and the map containedbuilder → —. The approved mock explicitly says a wait withoutfromis 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 extendapiLog.test.mjswith an unattributed wait. The committed map test currently uses attributed waits only, so it misses this branch. -
Correctness — successful deletion loses the target metadata this PR is meant to preserve.
server/src/operations.js:131callsresolveTarget(req)during the response event. Butserver/src/index.js:2541removes the session from the store beforeres.json()at line 2544. Reproducing that order yieldstarget: { id: 's-1' }for a known session nameddeleted-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.
…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.
|
All three fixed in 1. ChronologySorted, not just reversed. The regression test starts a wait, completes a later call while it is blocked, then resolves the wait: The wait still reports One extra thing this fixes that neither of us mentioned: 2. The caller that was never there
The fixture gains an unattributed wait whose target ( 3. The delete that erased its own name
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. And end to end against a real server, since the ordering that causes this lives in An id-only target is still what gets recorded when the path names something the store never knew ( Re-verified
Also removed a throwaway screenshot harness ( |
|
Operator feedback from am-dev-2 applied in
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
I would build the second — it costs an ChecksEvery change has an assertion in
|
lvwerra
left a comment
There was a problem hiding this comment.
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.
-
Correctness / operator feedback — a long wait is still a point when no other request starts during it.
web/src/components/ApiLog.tsx:271-276maps the resolution to the rank of the last existing call at or beforeat + durationMs; it does not map the resolution time itself. If there is no intervening call,endX()returnsown, and thex0 < xguard at lines 345-347 omits.al-heldentirely. 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 atweb/test/apiLog.test.mjs:291-292only 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. -
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
hoverononMouseLeave(ApiLog.tsx:333,341), while the JSON is capped and made scrollable atweb/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-jsoncount 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 assertsposition: 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.
|
Both fixed in 1. A wait spans even when nothing else happensThe cause was placing the resolution at the rank of the nearest existing call at or before The x-axis is now ranked over events, not calls, and a wait is two events — issued and resolved. So:
Your repro is now a fixture entry: Restoring the snapping logic fails the first: 2. The card can be reachedKept 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: Restoring the immediate clear fails the first and third. Also worth notingWhile 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
|
|
All five in 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
The thing that deserves saying out loud
The other four
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
|
|
Both in Full calls, no filteringGone:
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: What this makes of the file
The reader, which is where the work was
TestsThe two that encoded the old rule now assert the new one, with a comment saying they were flipped on purpose rather than being deleted: New, for the robustness half:
|
|
Replaced, not added to, in The 1×–6× multiplier is gone. Dragging horizontally across the plot now selects a window and the axis zooms to it.
The three collisionsHover 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 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. TestsDriven with a real mouse ( 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 Everything else from the last round is untouched. |
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.
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/operationsalready recorded every call that changed something. It recorded no reads at all —operations.jsgated onMUTATING = POST|PUT|PATCH|DELETE. Of the last 193 entries on this Space: 173 POST, 20 PUT, 0 GET.waitis 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: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.waitis a polling loop, so only the call that resolved is an event. Entries wherematched !== trueare 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.?from=.waitis 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
targetfieldThe entry shape gains
target: {id, name, cli}, resolved at write time by aresolveTargetcallback 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
waitsnippets 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 awaitrow'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:
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×Nin 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
DATA_DIR, one call of each kind. Attributed wait → logged with origin and target. Unattributed wait → logged, originnull, 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 recordeddurationMs: 9007, which is the point of logging it at all.server/test/operations.test.mjsextended (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.mjsis 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×Nrepeat marking, and the arrow directions.webtypecheck,npm test(16 suites) andnpm 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
readOperationsalready tails a bounded 4 MiB.?from=now attributes its waits. If an agent passes a stale id,resolveOriginreturns null and the entry records no origin rather than failing the call — the read path never rejects.resolveTargetruns astore.getper logged call. In-memory map lookup, on the response path, already inside a handler that did far more work.