Spaces:
Running
Settings → API log: the calls agents make, as a list and as lanes
Browse filesTwo 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.
- server/src/index.js +18 -2
- server/src/operations.js +27 -4
- server/test/operations.test.mjs +51 -0
- web/src/App.tsx +1 -1
- web/src/api.ts +24 -0
- web/src/components/ApiLog.tsx +342 -0
- web/src/components/SettingsView.tsx +15 -1
- web/src/styles.css +70 -0
- web/test/apiLog.test.mjs +201 -0
|
@@ -232,8 +232,20 @@ const resolveOperationOrigin = (raw, req) => {
|
|
| 232 |
const remoteSession = store.list().find((s) => s.remote?.name === name);
|
| 233 |
return remoteSession ? { id: `remote:${name}`, type: 'remote', name, cli: remoteSession.remote?.peer?.harness || 'remote' } : null;
|
| 234 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
app.use(operationMiddleware({
|
| 236 |
resolveOrigin: resolveOperationOrigin,
|
|
|
|
| 237 |
// Test servers explicitly opt out so old endpoint-focused fixtures do not
|
| 238 |
// have to pretend to be the operator. Production never sets this switch.
|
| 239 |
allowMissing: process.env.AM_ALLOW_MISSING_ORIGIN === '1',
|
|
@@ -1360,7 +1372,7 @@ digest for one agent. Read \`state\` before you do anything:
|
|
| 1360 |
### Watch instead of asking
|
| 1361 |
\`\`\`sh
|
| 1362 |
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/tail?lines=120" | jq -r .text
|
| 1363 |
-
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/wait?timeout=300&settle=15"
|
| 1364 |
\`\`\`
|
| 1365 |
\`tail\` returns that agent's screen and scrollback — exactly what a human would
|
| 1366 |
see in its pane. \`wait\` BLOCKS until the agent has held one of \`state\`
|
|
@@ -1374,13 +1386,17 @@ see in its pane. \`wait\` BLOCKS until the agent has held one of \`state\`
|
|
| 1374 |
covers a whole job; on expiry it answers \`{matched:false,timedOut:true}\` and
|
| 1375 |
you reissue. That is the shape of the API, not a failure.
|
| 1376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
Because of both, the correct form is a background loop rather than a call:
|
| 1378 |
reissue until it matches, started the way your harness runs a command in the
|
| 1379 |
background (Claude Code: \`run_in_background\`), so you stay free meanwhile and
|
| 1380 |
are woken once, when the peer is genuinely finished.
|
| 1381 |
|
| 1382 |
\`\`\`sh
|
| 1383 |
-
( until curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/wait?timeout=300&settle=15" \\
|
| 1384 |
> /tmp/wait-$ID.json \\
|
| 1385 |
&& jq -e '.matched or .state == "gone" or has("error")' /tmp/wait-$ID.json >/dev/null
|
| 1386 |
do sleep 2; done ) >/dev/null 2>&1 &
|
|
|
|
| 232 |
const remoteSession = store.list().find((s) => s.remote?.name === name);
|
| 233 |
return remoteSession ? { id: `remote:${name}`, type: 'remote', name, cli: remoteSession.remote?.peer?.harness || 'remote' } : null;
|
| 234 |
};
|
| 235 |
+
// Who the call was aimed at. Every route that acts on one session names it in
|
| 236 |
+
// the path; resolving the NAME here, at write time, is the difference between an
|
| 237 |
+
// audit trail that still reads in a month and one full of ids whose sessions
|
| 238 |
+
// have since been renamed or deleted.
|
| 239 |
+
const TARGET_ROUTES = /^\/api\/(?:agents|sessions|trace|files)\/([^/]+)/;
|
| 240 |
+
const resolveOperationTarget = (req) => {
|
| 241 |
+
const id = (req.path.match(TARGET_ROUTES) || [])[1];
|
| 242 |
+
if (!id) return null;
|
| 243 |
+
const s = store.get(id);
|
| 244 |
+
return s ? { id: s.id, name: s.name, cli: s.cli } : { id };
|
| 245 |
+
};
|
| 246 |
app.use(operationMiddleware({
|
| 247 |
resolveOrigin: resolveOperationOrigin,
|
| 248 |
+
resolveTarget: resolveOperationTarget,
|
| 249 |
// Test servers explicitly opt out so old endpoint-focused fixtures do not
|
| 250 |
// have to pretend to be the operator. Production never sets this switch.
|
| 251 |
allowMissing: process.env.AM_ALLOW_MISSING_ORIGIN === '1',
|
|
|
|
| 1372 |
### Watch instead of asking
|
| 1373 |
\`\`\`sh
|
| 1374 |
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/tail?lines=120" | jq -r .text
|
| 1375 |
+
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/wait?timeout=300&settle=15&from=$AM_ID"
|
| 1376 |
\`\`\`
|
| 1377 |
\`tail\` returns that agent's screen and scrollback — exactly what a human would
|
| 1378 |
see in its pane. \`wait\` BLOCKS until the agent has held one of \`state\`
|
|
|
|
| 1386 |
covers a whole job; on expiry it answers \`{matched:false,timedOut:true}\` and
|
| 1387 |
you reissue. That is the shape of the API, not a failure.
|
| 1388 |
|
| 1389 |
+
\`wait\` is read-only, so \`from=$AM_ID\` is optional on it — pass it anyway. It
|
| 1390 |
+
is what lets Settings → API log draw the arrow back from the agent you waited on
|
| 1391 |
+
to you; without it the log knows the wait finished but not who was watching.
|
| 1392 |
+
|
| 1393 |
Because of both, the correct form is a background loop rather than a call:
|
| 1394 |
reissue until it matches, started the way your harness runs a command in the
|
| 1395 |
background (Claude Code: \`run_in_background\`), so you stay free meanwhile and
|
| 1396 |
are woken once, when the peer is genuinely finished.
|
| 1397 |
|
| 1398 |
\`\`\`sh
|
| 1399 |
+
( until curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/agents/$ID/wait?timeout=300&settle=15&from=$AM_ID" \\
|
| 1400 |
> /tmp/wait-$ID.json \\
|
| 1401 |
&& jq -e '.matched or .state == "gone" or has("error")' /tmp/wait-$ID.json >/dev/null
|
| 1402 |
do sleep 2; done ) >/dev/null 2>&1 &
|
|
@@ -6,6 +6,15 @@ import { DATA_DIR } from './config.js';
|
|
| 6 |
export const OPERATIONS_FILE = path.join(DATA_DIR, 'operations.jsonl');
|
| 7 |
|
| 8 |
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const MAX_TEXT = 500;
|
| 10 |
const MAX_DEPTH = 5;
|
| 11 |
const SENSITIVE_KEY = /(authorization|credential|password|secret|subscription|token|endpoint|private.?key)/i;
|
|
@@ -73,14 +82,18 @@ const cleanQuery = (query) => {
|
|
| 73 |
* unknown. It may derive an identity from a protocol route (remote agents do
|
| 74 |
* this for backwards compatibility with already-running polling loops).
|
| 75 |
*/
|
| 76 |
-
export function operationMiddleware({ resolveOrigin, allowMissing = false } = {}) {
|
| 77 |
return (req, res, next) => {
|
| 78 |
-
if (!req.path.startsWith('/api/') || !
|
| 79 |
|
| 80 |
const raw = requestOrigin(req);
|
| 81 |
let origin = resolveOrigin ? resolveOrigin(raw, req) : (raw ? { id: raw, type: 'unknown' } : null);
|
| 82 |
if (!origin && allowMissing) origin = { id: 'test', type: 'test' };
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
return res.status(400).json({
|
| 85 |
error: raw
|
| 86 |
? `unknown origin '${raw}'`
|
|
@@ -88,7 +101,7 @@ export function operationMiddleware({ resolveOrigin, allowMissing = false } = {}
|
|
| 88 |
});
|
| 89 |
}
|
| 90 |
|
| 91 |
-
req.operationOrigin = origin;
|
| 92 |
const started = Date.now();
|
| 93 |
const operationId = crypto.randomUUID();
|
| 94 |
let responseBody;
|
|
@@ -101,11 +114,21 @@ export function operationMiddleware({ resolveOrigin, allowMissing = false } = {}
|
|
| 101 |
const record = () => {
|
| 102 |
if (recorded) return;
|
| 103 |
recorded = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
append({
|
| 105 |
version: 1,
|
| 106 |
id: operationId,
|
| 107 |
at: new Date(started).toISOString(),
|
| 108 |
origin,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
method: req.method,
|
| 110 |
path: req.path,
|
| 111 |
query: cleanQuery(req.query),
|
|
|
|
| 6 |
export const OPERATIONS_FILE = path.join(DATA_DIR, 'operations.jsonl');
|
| 7 |
|
| 8 |
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
| 9 |
+
// Reads worth auditing. A GET is normally none of this log's business — it
|
| 10 |
+
// changes nothing — but `wait` is the one read that IS an event between two
|
| 11 |
+
// agents: A blocked on B until B stopped working. Without it the log records
|
| 12 |
+
// work being handed out and nothing ever coming back, which is exactly half of
|
| 13 |
+
// "who called whom". Deliberately NOT here: `tail`, which every open pane polls
|
| 14 |
+
// constantly and which says nothing a resolved wait does not already say.
|
| 15 |
+
const LOGGED_READS = [/^\/api\/agents\/[^/]+\/wait$/];
|
| 16 |
+
const shouldLog = (req) => MUTATING.has(req.method)
|
| 17 |
+
|| (req.method === 'GET' && LOGGED_READS.some((re) => re.test(req.path)));
|
| 18 |
const MAX_TEXT = 500;
|
| 19 |
const MAX_DEPTH = 5;
|
| 20 |
const SENSITIVE_KEY = /(authorization|credential|password|secret|subscription|token|endpoint|private.?key)/i;
|
|
|
|
| 82 |
* unknown. It may derive an identity from a protocol route (remote agents do
|
| 83 |
* this for backwards compatibility with already-running polling loops).
|
| 84 |
*/
|
| 85 |
+
export function operationMiddleware({ resolveOrigin, resolveTarget, allowMissing = false } = {}) {
|
| 86 |
return (req, res, next) => {
|
| 87 |
+
if (!req.path.startsWith('/api/') || !shouldLog(req)) return next();
|
| 88 |
|
| 89 |
const raw = requestOrigin(req);
|
| 90 |
let origin = resolveOrigin ? resolveOrigin(raw, req) : (raw ? { id: raw, type: 'unknown' } : null);
|
| 91 |
if (!origin && allowMissing) origin = { id: 'test', type: 'test' };
|
| 92 |
+
// A logged READ is never refused for want of an origin. `wait` is documented
|
| 93 |
+
// as read-only and every watch loop running right now calls it without
|
| 94 |
+
// `?from=`; rejecting those would break them the moment this ships. An
|
| 95 |
+
// unattributed wait still records that someone finished waiting on B.
|
| 96 |
+
if (!origin && MUTATING.has(req.method)) {
|
| 97 |
return res.status(400).json({
|
| 98 |
error: raw
|
| 99 |
? `unknown origin '${raw}'`
|
|
|
|
| 101 |
});
|
| 102 |
}
|
| 103 |
|
| 104 |
+
if (origin) req.operationOrigin = origin;
|
| 105 |
const started = Date.now();
|
| 106 |
const operationId = crypto.randomUUID();
|
| 107 |
let responseBody;
|
|
|
|
| 114 |
const record = () => {
|
| 115 |
if (recorded) return;
|
| 116 |
recorded = true;
|
| 117 |
+
// A wait is a polling loop: only the call that RESOLVED is an event. The
|
| 118 |
+
// ones that timed out say "still working", which the log already implies,
|
| 119 |
+
// and logging them would multiply the entries by however long the job ran.
|
| 120 |
+
// Same for a wait the caller abandoned (no body) or one whose target had
|
| 121 |
+
// already gone: nothing came back, so there is nothing to draw.
|
| 122 |
+
if (!MUTATING.has(req.method) && !(responseBody && responseBody.matched === true)) return;
|
| 123 |
append({
|
| 124 |
version: 1,
|
| 125 |
id: operationId,
|
| 126 |
at: new Date(started).toISOString(),
|
| 127 |
origin,
|
| 128 |
+
// Who it was done TO, resolved at write time. The id is in the path
|
| 129 |
+
// already, but a name read back later is the name the session has
|
| 130 |
+
// NOW — renamed or deleted, and the audit trail stops making sense.
|
| 131 |
+
...(resolveTarget ? (() => { const t = resolveTarget(req); return t ? { target: t } : {}; })() : {}),
|
| 132 |
method: req.method,
|
| 133 |
path: req.path,
|
| 134 |
query: cleanQuery(req.query),
|
|
@@ -20,6 +20,13 @@ const middleware = operationMiddleware({
|
|
| 20 |
resolveOrigin: (raw) => raw === 'operator'
|
| 21 |
? { id: 'operator', type: 'operator', name: 'test operator' }
|
| 22 |
: raw === 'agent-1' ? { id: raw, type: 'agent', name: 'agent one', cli: 'codex' } : null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
});
|
| 24 |
|
| 25 |
class Response extends EventEmitter {
|
|
@@ -69,6 +76,50 @@ try {
|
|
| 69 |
check('plain-text agent prompts are summarized too', rows[3]?.request?.present === true && rows[3]?.request?.chars === 26);
|
| 70 |
check('origin is separate from the recorded query', rows[3]?.origin?.id === 'agent-1' && !('from' in (rows[3]?.query || {})));
|
| 71 |
check('query parameters needed to replay the operation remain', rows[3]?.query?.cli === 'codex');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
} finally {
|
| 73 |
fs.rmSync(TMP, { recursive: true, force: true });
|
| 74 |
}
|
|
|
|
| 20 |
resolveOrigin: (raw) => raw === 'operator'
|
| 21 |
? { id: 'operator', type: 'operator', name: 'test operator' }
|
| 22 |
: raw === 'agent-1' ? { id: raw, type: 'agent', name: 'agent one', cli: 'codex' } : null,
|
| 23 |
+
// The manager resolves the session named in the path; here, anything called
|
| 24 |
+
// s-* exists and everything else does not.
|
| 25 |
+
resolveTarget: (req) => {
|
| 26 |
+
const id = (req.path.match(/^\/api\/(?:agents|sessions)\/([^/]+)/) || [])[1];
|
| 27 |
+
if (!id) return null;
|
| 28 |
+
return id.startsWith('s-') ? { id, name: `name of ${id}`, cli: 'claude' } : { id };
|
| 29 |
+
},
|
| 30 |
});
|
| 31 |
|
| 32 |
class Response extends EventEmitter {
|
|
|
|
| 76 |
check('plain-text agent prompts are summarized too', rows[3]?.request?.present === true && rows[3]?.request?.chars === 26);
|
| 77 |
check('origin is separate from the recorded query', rows[3]?.origin?.id === 'agent-1' && !('from' in (rows[3]?.query || {})));
|
| 78 |
check('query parameters needed to replay the operation remain', rows[3]?.query?.cli === 'codex');
|
| 79 |
+
|
| 80 |
+
// The one read this log cares about. A `wait` is how one agent watches
|
| 81 |
+
// another, so it is the only GET recorded — and only when it RESOLVED, since
|
| 82 |
+
// a wait that timed out is a polling artefact, not an event.
|
| 83 |
+
console.log('\nthe wait that came back');
|
| 84 |
+
const before = readOperations(50).length;
|
| 85 |
+
invoke({ method: 'GET', path: '/api/agents/s-target/wait', query: { from: 'agent-1' } },
|
| 86 |
+
(_req, res) => res.json({ id: 's-target', state: 'waiting', matched: true, waited: 143 }));
|
| 87 |
+
invoke({ method: 'GET', path: '/api/agents/s-target/wait', query: { from: 'agent-1' } },
|
| 88 |
+
(_req, res) => res.json({ id: 's-target', state: 'working', matched: false, timedOut: true }));
|
| 89 |
+
invoke({ method: 'GET', path: '/api/agents/s-target/wait' },
|
| 90 |
+
(_req, res) => res.json({ id: 's-target', state: 'idle', matched: true, waited: 4 }));
|
| 91 |
+
let tailed = false;
|
| 92 |
+
invoke({ method: 'GET', path: '/api/agents/s-target/tail' }, (_req, res) => { tailed = true; res.json({ text: '' }); });
|
| 93 |
+
let rostered = false;
|
| 94 |
+
invoke({ method: 'GET', path: '/api/agents' }, (_req, res) => { rostered = true; res.json({ agents: [] }); });
|
| 95 |
+
|
| 96 |
+
const waits = readOperations(50).filter((r) => r.method === 'GET');
|
| 97 |
+
check('a resolved wait is recorded', waits.length === 2, `${waits.length} GET rows`);
|
| 98 |
+
check('a wait that only timed out is not', !waits.some((r) => r.result?.matched === false));
|
| 99 |
+
check('tail is never logged — every open pane polls it', tailed && !waits.some((r) => r.path.endsWith('/tail')));
|
| 100 |
+
check('nor is the roster, or any other read', rostered && readOperations(50).length === before + 2);
|
| 101 |
+
|
| 102 |
+
// `wait` is documented read-only and every watch loop running today calls it
|
| 103 |
+
// without ?from=. Refusing those would break them the moment this ships.
|
| 104 |
+
const anonymous = waits.find((r) => !r.origin);
|
| 105 |
+
check('an unattributed wait is accepted, not 400ed', !!anonymous);
|
| 106 |
+
check('and still records who it was waiting ON', anonymous?.target?.id === 's-target');
|
| 107 |
+
const attributed = waits.find((r) => r.origin?.id === 'agent-1');
|
| 108 |
+
check('an attributed wait keeps both ends', attributed?.origin?.id === 'agent-1' && attributed?.target?.name === 'name of s-target');
|
| 109 |
+
check('a wait is worth its duration, not just its timestamp', typeof attributed?.durationMs === 'number');
|
| 110 |
+
|
| 111 |
+
console.log('\nwho it was done to');
|
| 112 |
+
invoke({ path: '/api/sessions/s-42/input', query: { from: 'agent-1' }, body: { text: 'go' } },
|
| 113 |
+
(_req, res) => res.json({ ok: true }));
|
| 114 |
+
invoke({ path: '/api/sessions/ghost-9/input', query: { from: 'agent-1' }, body: { text: 'go' } },
|
| 115 |
+
(_req, res) => res.status(404).json({ error: 'not found' }));
|
| 116 |
+
const [ghost, named] = readOperations(2);
|
| 117 |
+
check('the target name is resolved at write time, not read time', named?.target?.name === 'name of s-42');
|
| 118 |
+
check('a target that no longer exists still records its id', ghost?.target?.id === 'ghost-9' && !ghost?.target?.name);
|
| 119 |
+
|
| 120 |
+
console.log('\nand the guard that has to keep holding');
|
| 121 |
+
const stillRefused = invoke({ path: '/api/groups', body: { name: 'nope' } }, () => {});
|
| 122 |
+
check('a mutating call with no origin is still refused', stillRefused.statusCode === 400);
|
| 123 |
} finally {
|
| 124 |
fs.rmSync(TMP, { recursive: true, force: true });
|
| 125 |
}
|
|
@@ -50,7 +50,7 @@ function autoGrid(n: number): GridSpec {
|
|
| 50 |
return { cols: 3, rows: 3 };
|
| 51 |
}
|
| 52 |
|
| 53 |
-
type SettingsPage = 'general' | 'usage' | 'skills' | 'cron';
|
| 54 |
const ROOT_PATH = '.';
|
| 55 |
const WARM_TERMINAL_LIMIT = 12;
|
| 56 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
|
|
|
| 50 |
return { cols: 3, rows: 3 };
|
| 51 |
}
|
| 52 |
|
| 53 |
+
type SettingsPage = 'general' | 'usage' | 'skills' | 'cron' | 'apilog';
|
| 54 |
const ROOT_PATH = '.';
|
| 55 |
const WARM_TERMINAL_LIMIT = 12;
|
| 56 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
|
@@ -674,3 +674,27 @@ export const saveSkill = (name: string, content: string) =>
|
|
| 674 |
fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: content }).then(json);
|
| 675 |
export const deleteSkill = (name: string) =>
|
| 676 |
fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }).then(json);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: content }).then(json);
|
| 675 |
export const deleteSkill = (name: string) =>
|
| 676 |
fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }).then(json);
|
| 677 |
+
|
| 678 |
+
// ---- the API log (Settings → API log) ----
|
| 679 |
+
// Written by operationMiddleware: every mutating call, plus the one read that is
|
| 680 |
+
// an event between two agents — a `wait` that resolved. Payloads are summarised
|
| 681 |
+
// at write time, never stored: a prompt is {present, chars, sha256} and nothing
|
| 682 |
+
// else, so this view can say who asked whom and how long the ask was, never what
|
| 683 |
+
// it said.
|
| 684 |
+
export interface OperationSummary { present?: boolean; chars?: number; sha256?: string; bytes?: number; }
|
| 685 |
+
export interface Operation {
|
| 686 |
+
id: string;
|
| 687 |
+
at: string;
|
| 688 |
+
origin: { id: string; type: string; name?: string; cli?: string } | null;
|
| 689 |
+
target?: { id: string; name?: string; cli?: string };
|
| 690 |
+
method: string;
|
| 691 |
+
path: string;
|
| 692 |
+
query?: Record<string, unknown>;
|
| 693 |
+
request?: unknown;
|
| 694 |
+
status: number;
|
| 695 |
+
ok: boolean;
|
| 696 |
+
durationMs: number;
|
| 697 |
+
result?: unknown;
|
| 698 |
+
}
|
| 699 |
+
export const getOperations = (limit = 500): Promise<{ operations: Operation[]; generatedAt: string }> =>
|
| 700 |
+
fetch(`/api/operations?limit=${limit}`).then(json);
|
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Settings → API log: what the agents actually did to each other.
|
| 2 |
+
//
|
| 3 |
+
// Two views over /api/operations, which the manager already writes for every
|
| 4 |
+
// call that changes something (plus, since this feature, the one read that is an
|
| 5 |
+
// event between two agents — a `wait` that resolved).
|
| 6 |
+
//
|
| 7 |
+
// list — one call per LINE. Not per row: per line. A wrapped row halves how
|
| 8 |
+
// many calls fit on a screen, so every cell is nowrap/ellipsis and the
|
| 9 |
+
// path is the one column allowed to take the remaining width.
|
| 10 |
+
// map — one lane per agent, time left to right, and the calls drawn BETWEEN
|
| 11 |
+
// the lanes: a prompt is an arrow from caller to target, a resolved
|
| 12 |
+
// wait is an arrow back the other way. That return arrow is the whole
|
| 13 |
+
// reason reads are logged at all; without it the picture shows work
|
| 14 |
+
// going out and nothing ever coming back.
|
| 15 |
+
//
|
| 16 |
+
// What this cannot show, by design: the log stores {present, chars, sha256} for
|
| 17 |
+
// prompt text and never the text. So this answers who asked whom to do
|
| 18 |
+
// something, when, and how big the ask was — never what it said. Equal
|
| 19 |
+
// checksums mean identical prompts, which is what a repeating job produces, so
|
| 20 |
+
// repeats are marked rather than hidden.
|
| 21 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 22 |
+
import * as api from '../api';
|
| 23 |
+
|
| 24 |
+
type View = 'list' | 'map';
|
| 25 |
+
type Kind = 'fail' | 'prompt' | 'file';
|
| 26 |
+
|
| 27 |
+
const HHMMSS = (iso: string) => new Date(iso).toLocaleTimeString([], { hour12: false });
|
| 28 |
+
const DAY = (iso: string) => new Date(iso).toLocaleDateString([], { day: 'numeric', month: 'short' });
|
| 29 |
+
|
| 30 |
+
/** 6ms · 576ms · 18.4s · 4m 18s — always three glyphs of information, never more. */
|
| 31 |
+
export function took(ms: number): string {
|
| 32 |
+
if (!Number.isFinite(ms)) return '—';
|
| 33 |
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
| 34 |
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
| 35 |
+
const m = Math.floor(ms / 60_000);
|
| 36 |
+
return `${m}m ${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const KB = (chars: number) => (chars < 1024 ? `${chars} B` : `${(chars / 1024).toFixed(1)} KB`);
|
| 40 |
+
|
| 41 |
+
/** The summariser stores text as {present, chars, sha256} — sometimes as the
|
| 42 |
+
* whole body (a prompt), sometimes one field inside it (`input` wraps it in
|
| 43 |
+
* `text`). Find it either way; anything else has no payload worth a column. */
|
| 44 |
+
function textSummary(value: unknown): api.OperationSummary | null {
|
| 45 |
+
if (!value || typeof value !== 'object') return null;
|
| 46 |
+
const v = value as Record<string, unknown>;
|
| 47 |
+
if (typeof v.chars === 'number') return v as api.OperationSummary;
|
| 48 |
+
for (const inner of Object.values(v)) {
|
| 49 |
+
if (inner && typeof inner === 'object' && typeof (inner as api.OperationSummary).chars === 'number') {
|
| 50 |
+
return inner as api.OperationSummary;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
return null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const isFileRoute = (p: string) => p.startsWith('/api/files') || p.startsWith('/api/skills');
|
| 57 |
+
export const isWait = (op: api.Operation) => op.method === 'GET' && /\/wait$/.test(op.path);
|
| 58 |
+
const isPrompt = (op: api.Operation) => /\/(prompt|input)$/.test(op.path);
|
| 59 |
+
|
| 60 |
+
/** What the Payload column says. Text length, never text. */
|
| 61 |
+
function payloadOf(op: api.Operation): { text: string; sha?: string } {
|
| 62 |
+
if (isWait(op)) {
|
| 63 |
+
const state = (op.result as { state?: string } | undefined)?.state;
|
| 64 |
+
return { text: `resolved · ${state || 'finished'}` };
|
| 65 |
+
}
|
| 66 |
+
const bytes = (op.result as { bytes?: number } | undefined)?.bytes;
|
| 67 |
+
if (typeof bytes === 'number') return { text: `upload · ${KB(bytes)}` };
|
| 68 |
+
const sum = textSummary(op.request);
|
| 69 |
+
if (!sum || !sum.chars) return { text: '—' };
|
| 70 |
+
const what = isFileRoute(op.path) ? 'file' : isPrompt(op) ? 'prompt' : 'body';
|
| 71 |
+
return {
|
| 72 |
+
text: isFileRoute(op.path) ? `${what} · ${KB(sum.chars)}` : `${what} · ${sum.chars.toLocaleString()} chars`,
|
| 73 |
+
sha: sum.sha256,
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const statusClass = (op: api.Operation) =>
|
| 78 |
+
(op.ok ? 'ok' : op.status >= 500 ? 'bad' : 'warn');
|
| 79 |
+
|
| 80 |
+
const who = (op: api.Operation) => op.origin?.name || op.origin?.id || '—';
|
| 81 |
+
/** The id of whoever the call acted on. `target` is written into the log from
|
| 82 |
+
* this version on; entries recorded before it have the id in the path, which is
|
| 83 |
+
* worth digging out so the map is not empty on the day this ships. */
|
| 84 |
+
const TARGET_IN_PATH = /^\/api\/(?:agents|sessions|trace|files)\/([^/]+)/;
|
| 85 |
+
const targetId = (op: api.Operation) => op.target?.id || (op.path.match(TARGET_IN_PATH) || [])[1] || '';
|
| 86 |
+
const whom = (op: api.Operation, names?: Map<string, string>) => {
|
| 87 |
+
const id = targetId(op);
|
| 88 |
+
return op.target?.name || (id && names?.get(id)) || id;
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
export default function ApiLog() {
|
| 92 |
+
const [ops, setOps] = useState<api.Operation[] | null>(null);
|
| 93 |
+
// id → name for sessions that still exist, so an older entry whose target was
|
| 94 |
+
// never recorded still draws with a name rather than an id.
|
| 95 |
+
const [names, setNames] = useState<Map<string, string>>(new Map());
|
| 96 |
+
const [error, setError] = useState('');
|
| 97 |
+
const [view, setView] = useState<View>('list');
|
| 98 |
+
const [origin, setOrigin] = useState(''); // '' = everyone
|
| 99 |
+
const [kinds, setKinds] = useState<Kind[]>([]);
|
| 100 |
+
const [q, setQ] = useState('');
|
| 101 |
+
|
| 102 |
+
const load = () => api.getOperations(500)
|
| 103 |
+
.then((d) => { setOps(d.operations); setError(''); })
|
| 104 |
+
.catch(() => setError('could not read the log'));
|
| 105 |
+
useEffect(() => { load(); }, []);
|
| 106 |
+
useEffect(() => {
|
| 107 |
+
api.getTree()
|
| 108 |
+
.then((t) => setNames(new Map(t.sessions.map((s) => [s.id, s.name]))))
|
| 109 |
+
.catch(() => {});
|
| 110 |
+
}, []);
|
| 111 |
+
|
| 112 |
+
const origins = useMemo(() => {
|
| 113 |
+
const seen = new Map<string, string>();
|
| 114 |
+
for (const op of ops || []) if (op.origin) seen.set(op.origin.id, op.origin.name || op.origin.id);
|
| 115 |
+
return [...seen].sort((a, b) => a[1].localeCompare(b[1]));
|
| 116 |
+
}, [ops]);
|
| 117 |
+
|
| 118 |
+
const failures = (ops || []).filter((op) => !op.ok).length;
|
| 119 |
+
const toggle = (k: Kind) => setKinds((ks) => (ks.includes(k) ? ks.filter((x) => x !== k) : [...ks, k]));
|
| 120 |
+
|
| 121 |
+
const rows = useMemo(() => (ops || []).filter((op) => {
|
| 122 |
+
if (origin && op.origin?.id !== origin) return false;
|
| 123 |
+
if (q && !op.path.toLowerCase().includes(q.toLowerCase())) return false;
|
| 124 |
+
if (!kinds.length) return true;
|
| 125 |
+
return kinds.some((k) => (k === 'fail' ? !op.ok : k === 'prompt' ? isPrompt(op) : isFileRoute(op.path)));
|
| 126 |
+
}), [ops, origin, q, kinds]);
|
| 127 |
+
|
| 128 |
+
// Identical prompts have identical checksums. Counting them is the only thing
|
| 129 |
+
// the log can honestly say about repetition, and it is enough to spot a job
|
| 130 |
+
// that fires the same text on a schedule.
|
| 131 |
+
const repeats = useMemo(() => {
|
| 132 |
+
const n = new Map<string, number>();
|
| 133 |
+
for (const op of rows) {
|
| 134 |
+
const sha = payloadOf(op).sha;
|
| 135 |
+
if (sha) n.set(sha, (n.get(sha) || 0) + 1);
|
| 136 |
+
}
|
| 137 |
+
return n;
|
| 138 |
+
}, [rows]);
|
| 139 |
+
|
| 140 |
+
const span = ops && ops.length
|
| 141 |
+
? `${DAY(ops[ops.length - 1].at)}–${DAY(ops[0].at)}`
|
| 142 |
+
: '';
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<div className="al">
|
| 146 |
+
<div className="al-head">
|
| 147 |
+
<span className="al-count">{ops ? `${ops.length} calls` : 'loading…'}{span ? ` · ${span}` : ''}</span>
|
| 148 |
+
<div className="seg al-view">
|
| 149 |
+
<button className={view === 'list' ? 'on' : ''} onClick={() => setView('list')}>List</button>
|
| 150 |
+
<button className={view === 'map' ? 'on' : ''} onClick={() => setView('map')}>Map</button>
|
| 151 |
+
</div>
|
| 152 |
+
<button className="btn-ghost al-refresh" onClick={load}>Refresh</button>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="al-filters">
|
| 156 |
+
<button className={`al-chip${origin ? '' : ' on'}`} onClick={() => setOrigin('')}>Everyone</button>
|
| 157 |
+
{origins.map(([id, name]) => (
|
| 158 |
+
<button key={id} className={`al-chip${origin === id ? ' on' : ''}`} onClick={() => setOrigin(id)}>{name}</button>
|
| 159 |
+
))}
|
| 160 |
+
<span className="al-gap" />
|
| 161 |
+
<button className={`al-chip${kinds.includes('fail') ? ' on' : ''}`} onClick={() => toggle('fail')}>
|
| 162 |
+
Only failures ({failures})
|
| 163 |
+
</button>
|
| 164 |
+
<button className={`al-chip${kinds.includes('prompt') ? ' on' : ''}`} onClick={() => toggle('prompt')}>Prompts</button>
|
| 165 |
+
<button className={`al-chip${kinds.includes('file') ? ' on' : ''}`} onClick={() => toggle('file')}>Files</button>
|
| 166 |
+
<input className="al-find mono" placeholder="path contains…" value={q} onChange={(e) => setQ(e.target.value)} />
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{error && <div className="s-warn">{error}</div>}
|
| 170 |
+
{view === 'list'
|
| 171 |
+
? <LogTable rows={rows} repeats={repeats} names={names} />
|
| 172 |
+
: <LogMap rows={rows} names={names} />}
|
| 173 |
+
{ops && !rows.length && !error && <div className="s-muted al-empty">No calls match.</div>}
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function LogTable({ rows, repeats, names }: {
|
| 179 |
+
rows: api.Operation[]; repeats: Map<string, number>; names: Map<string, string>;
|
| 180 |
+
}) {
|
| 181 |
+
return (
|
| 182 |
+
<div className="al-tblwrap">
|
| 183 |
+
<table className="al-tbl">
|
| 184 |
+
<thead>
|
| 185 |
+
<tr>
|
| 186 |
+
<th>Time</th><th>Who</th><th className="grow">Call</th>
|
| 187 |
+
<th>Status</th><th className="num">Took</th><th>Payload</th>
|
| 188 |
+
</tr>
|
| 189 |
+
</thead>
|
| 190 |
+
<tbody>
|
| 191 |
+
{rows.map((op) => {
|
| 192 |
+
const pay = payloadOf(op);
|
| 193 |
+
const n = pay.sha ? repeats.get(pay.sha) || 0 : 0;
|
| 194 |
+
return (
|
| 195 |
+
<tr key={op.id} title={`${op.at}${whom(op, names) ? ` → ${whom(op, names)}` : ''}`}>
|
| 196 |
+
<td className="al-time">{HHMMSS(op.at)}</td>
|
| 197 |
+
<td className="al-who">{who(op)}</td>
|
| 198 |
+
<td className="grow">
|
| 199 |
+
<span className={`al-meth${isWait(op) ? ' back' : ''}`}>{op.method}</span>{' '}
|
| 200 |
+
<span className="al-path">{op.path}</span>
|
| 201 |
+
</td>
|
| 202 |
+
<td className={`al-st ${statusClass(op)}`}>{op.status}</td>
|
| 203 |
+
<td className="num">{took(op.durationMs)}</td>
|
| 204 |
+
<td className="al-pay">
|
| 205 |
+
{pay.text}
|
| 206 |
+
{n > 1 && <span className="al-rep" title={`${n} calls with this exact payload — ${pay.sha}`}> ×{n}</span>}
|
| 207 |
+
</td>
|
| 208 |
+
</tr>
|
| 209 |
+
);
|
| 210 |
+
})}
|
| 211 |
+
</tbody>
|
| 212 |
+
</table>
|
| 213 |
+
</div>
|
| 214 |
+
);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// ---- the map ----------------------------------------------------------------
|
| 218 |
+
// Geometry follows the approved mock: a labelled hairline per lane, arrows
|
| 219 |
+
// drawn between lanes, a dot on the lane the call was made from.
|
| 220 |
+
const LANE_H = 46;
|
| 221 |
+
const TOP = 26;
|
| 222 |
+
const LEFT = 108; // room for the longest lane label
|
| 223 |
+
const RIGHT = 26;
|
| 224 |
+
const MAX_LANES = 12;
|
| 225 |
+
const MAX_MARKS = 48;
|
| 226 |
+
|
| 227 |
+
function LogMap({ rows, names }: { rows: api.Operation[]; names: Map<string, string> }) {
|
| 228 |
+
const [hover, setHover] = useState<api.Operation | null>(null);
|
| 229 |
+
|
| 230 |
+
const { lanes, marks, from, to, dropped } = useMemo(() => {
|
| 231 |
+
const recent = rows.slice(0, MAX_MARKS).slice().reverse(); // oldest first, left to right
|
| 232 |
+
// Lanes: everything that CALLS above everything that is only called. Then a
|
| 233 |
+
// prompt points down the picture and a resolved wait points back up it,
|
| 234 |
+
// which is the whole claim the legend makes. Sorting by traffic alone put
|
| 235 |
+
// targets above their callers and the two directions stopped meaning
|
| 236 |
+
// anything.
|
| 237 |
+
const made = new Map<string, number>();
|
| 238 |
+
const got = new Map<string, number>();
|
| 239 |
+
const bump = (m: Map<string, number>, name: string) => name && m.set(name, (m.get(name) || 0) + 1);
|
| 240 |
+
for (const op of recent) { bump(made, who(op)); bump(got, whom(op, names)); }
|
| 241 |
+
const byCount = (m: Map<string, number>) => [...m].sort((a, b) => b[1] - a[1]).map(([n]) => n);
|
| 242 |
+
const callers = byCount(made);
|
| 243 |
+
const lanes = [...callers, ...byCount(got).filter((n) => !made.has(n))].slice(0, MAX_LANES);
|
| 244 |
+
const t0 = recent.length ? new Date(recent[0].at).getTime() : 0;
|
| 245 |
+
const t1 = recent.length ? new Date(recent[recent.length - 1].at).getTime() : 1;
|
| 246 |
+
// x is the call's RANK, not its clock position: real traffic arrives in
|
| 247 |
+
// bursts, and spacing by time collapses a burst into one unreadable column
|
| 248 |
+
// while leaving the quiet hours as empty space. The axis still says what
|
| 249 |
+
// period is on screen.
|
| 250 |
+
const marks = recent.map((op, i) => ({
|
| 251 |
+
op,
|
| 252 |
+
x: recent.length < 2 ? 0.5 : i / (recent.length - 1),
|
| 253 |
+
a: lanes.indexOf(who(op)),
|
| 254 |
+
b: lanes.indexOf(whom(op, names)),
|
| 255 |
+
})).filter((m) => m.a >= 0 || m.b >= 0);
|
| 256 |
+
void t0; void t1;
|
| 257 |
+
return {
|
| 258 |
+
lanes,
|
| 259 |
+
marks,
|
| 260 |
+
from: recent.length ? recent[0].at : '',
|
| 261 |
+
to: recent.length ? recent[recent.length - 1].at : '',
|
| 262 |
+
dropped: Math.max(0, rows.length - MAX_MARKS),
|
| 263 |
+
};
|
| 264 |
+
}, [rows, names]);
|
| 265 |
+
|
| 266 |
+
if (!lanes.length) return <div className="s-muted al-empty">Nothing to draw yet.</div>;
|
| 267 |
+
|
| 268 |
+
const width = 760;
|
| 269 |
+
const height = TOP + lanes.length * LANE_H + 34;
|
| 270 |
+
const laneY = (i: number) => TOP + i * LANE_H + 8;
|
| 271 |
+
const xOf = (t: number) => LEFT + t * (width - LEFT - RIGHT);
|
| 272 |
+
|
| 273 |
+
return (
|
| 274 |
+
<div className="al-map">
|
| 275 |
+
<div className="al-mapwrap">
|
| 276 |
+
<svg viewBox={`0 0 ${width} ${height}`} role="img"
|
| 277 |
+
aria-label="Swimlanes: one lane per agent, prompts drawn from caller to target and resolved waits back the other way.">
|
| 278 |
+
<defs>
|
| 279 |
+
<marker id="al-ar" viewBox="0 0 8 8" refX="6" refY="4" markerWidth="6" markerHeight="6" orient="auto">
|
| 280 |
+
<path d="M0 0 L8 4 L0 8 z" fill="var(--accent)" />
|
| 281 |
+
</marker>
|
| 282 |
+
<marker id="al-arb" viewBox="0 0 8 8" refX="6" refY="4" markerWidth="6" markerHeight="6" orient="auto">
|
| 283 |
+
<path d="M0 0 L8 4 L0 8 z" fill="var(--muted)" />
|
| 284 |
+
</marker>
|
| 285 |
+
</defs>
|
| 286 |
+
{lanes.map((name, i) => (
|
| 287 |
+
<g key={name}>
|
| 288 |
+
<text x="2" y={laneY(i) - 6} className="al-lane-lbl">{name}</text>
|
| 289 |
+
<line x1="2" y1={laneY(i)} x2={width - 4} y2={laneY(i)} className="al-lane" />
|
| 290 |
+
</g>
|
| 291 |
+
))}
|
| 292 |
+
{marks.map(({ op, x, a, b }) => {
|
| 293 |
+
const back = isWait(op);
|
| 294 |
+
// A wait is attention coming BACK: it is drawn from the agent that
|
| 295 |
+
// was waited on to the one that waited.
|
| 296 |
+
const src = back ? (b >= 0 ? b : a) : a;
|
| 297 |
+
const dst = back ? a : b;
|
| 298 |
+
const px = xOf(x);
|
| 299 |
+
const on = hover?.id === op.id;
|
| 300 |
+
if (src < 0 || dst < 0 || src === dst) {
|
| 301 |
+
const lane = src >= 0 ? src : dst;
|
| 302 |
+
return (
|
| 303 |
+
<circle key={op.id} cx={px} cy={laneY(lane)} r={on ? 4 : 2.6}
|
| 304 |
+
className={`al-dot${back ? ' back' : ''}${op.ok ? '' : ' bad'}${on ? ' on' : ''}`}
|
| 305 |
+
onMouseEnter={() => setHover(op)} onMouseLeave={() => setHover(null)}>
|
| 306 |
+
<title>{`${HHMMSS(op.at)} ${op.method} ${op.path}`}</title>
|
| 307 |
+
</circle>
|
| 308 |
+
);
|
| 309 |
+
}
|
| 310 |
+
const y1 = laneY(src) + (dst > src ? 5 : -5);
|
| 311 |
+
const y2 = laneY(dst) + (dst > src ? -7 : 7);
|
| 312 |
+
return (
|
| 313 |
+
<g key={op.id} onMouseEnter={() => setHover(op)} onMouseLeave={() => setHover(null)}
|
| 314 |
+
className={`al-arrowg${on ? ' on' : ''}`}>
|
| 315 |
+
<title>{`${HHMMSS(op.at)} ${op.method} ${op.path}`}</title>
|
| 316 |
+
<line x1={px} y1={y1} x2={px} y2={y2}
|
| 317 |
+
className={`al-arrow${back ? ' back' : ''}${op.ok ? '' : ' bad'}`}
|
| 318 |
+
markerEnd={`url(#${back ? 'al-arb' : 'al-ar'})`} />
|
| 319 |
+
<circle cx={px} cy={laneY(src)} r="2.6" className={`al-dot${back ? ' back' : ''}${op.ok ? '' : ' bad'}`} />
|
| 320 |
+
{/* a hit area wider than a 1.6px line, or nothing is hoverable */}
|
| 321 |
+
<line x1={px} y1={y1} x2={px} y2={y2} className="al-hit" />
|
| 322 |
+
</g>
|
| 323 |
+
);
|
| 324 |
+
})}
|
| 325 |
+
{from && <text x="2" y={height - 8} className="al-axis">{`${DAY(from)} ${HHMMSS(from).slice(0, 5)} →`}</text>}
|
| 326 |
+
{to && <text x={width - 4} y={height - 8} textAnchor="end" className="al-axis">{HHMMSS(to).slice(0, 5)}</text>}
|
| 327 |
+
</svg>
|
| 328 |
+
</div>
|
| 329 |
+
<div className="al-readout mono">
|
| 330 |
+
{hover
|
| 331 |
+
? `${HHMMSS(hover.at)} ${who(hover)}${whom(hover, names) ? ` → ${whom(hover, names)}` : ''} ${hover.method} ${hover.path} ${hover.status} ${took(hover.durationMs)} ${payloadOf(hover).text}`
|
| 332 |
+
: 'Hover a line for its time, status and payload.'}
|
| 333 |
+
</div>
|
| 334 |
+
<div className="al-legend">
|
| 335 |
+
<span><i className="al-key" /> prompt (caller → target)</span>
|
| 336 |
+
<span><i className="al-key back" /> wait resolved (target → caller)</span>
|
| 337 |
+
<span><i className="al-key dot" /> the call's own lane</span>
|
| 338 |
+
{dropped > 0 && <span className="s-muted">newest {MAX_MARKS} of {rows.length} drawn</span>}
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
);
|
| 342 |
+
}
|
|
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
|
|
| 2 |
import { isPassive, isRemote, type Cli } from '../types';
|
| 3 |
import * as api from '../api';
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
|
|
|
| 5 |
import UsagePanel from './UsagePanel';
|
| 6 |
import CronSettings from './CronSettings';
|
| 7 |
import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
|
| 8 |
import Logo from './Logo';
|
| 9 |
|
| 10 |
-
type Page = 'general' | 'usage' | 'skills' | 'cron';
|
| 11 |
const PAGES: { id: Page; label: string }[] = [
|
| 12 |
{ id: 'general', label: 'General' },
|
| 13 |
{ id: 'usage', label: 'Usage' },
|
| 14 |
{ id: 'skills', label: 'Skills' },
|
| 15 |
{ id: 'cron', label: 'Cron' },
|
|
|
|
| 16 |
];
|
| 17 |
|
| 18 |
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
|
|
@@ -777,6 +779,18 @@ export default function SettingsView({
|
|
| 777 |
</div>
|
| 778 |
)}
|
| 779 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
{page === 'skills' && (
|
| 781 |
<div className="settings-page wide">
|
| 782 |
<h2>Skills</h2>
|
|
|
|
| 2 |
import { isPassive, isRemote, type Cli } from '../types';
|
| 3 |
import * as api from '../api';
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
| 5 |
+
import ApiLog from './ApiLog';
|
| 6 |
import UsagePanel from './UsagePanel';
|
| 7 |
import CronSettings from './CronSettings';
|
| 8 |
import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
|
| 9 |
import Logo from './Logo';
|
| 10 |
|
| 11 |
+
type Page = 'general' | 'usage' | 'skills' | 'cron' | 'apilog';
|
| 12 |
const PAGES: { id: Page; label: string }[] = [
|
| 13 |
{ id: 'general', label: 'General' },
|
| 14 |
{ id: 'usage', label: 'Usage' },
|
| 15 |
{ id: 'skills', label: 'Skills' },
|
| 16 |
{ id: 'cron', label: 'Cron' },
|
| 17 |
+
{ id: 'apilog', label: 'API log' },
|
| 18 |
];
|
| 19 |
|
| 20 |
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
|
|
|
|
| 779 |
</div>
|
| 780 |
)}
|
| 781 |
|
| 782 |
+
{page === 'apilog' && (
|
| 783 |
+
<div className="settings-page wide">
|
| 784 |
+
<h2>API log</h2>
|
| 785 |
+
<p className="s-help">
|
| 786 |
+
Every call that changed something, plus the waits that resolved — who asked whom to do
|
| 787 |
+
what, and when. Prompt text is never stored, only its length and a checksum, so this
|
| 788 |
+
says who prompted whom and how long the prompt was, never what it said.
|
| 789 |
+
</p>
|
| 790 |
+
<ApiLog />
|
| 791 |
+
</div>
|
| 792 |
+
)}
|
| 793 |
+
|
| 794 |
{page === 'skills' && (
|
| 795 |
<div className="settings-page wide">
|
| 796 |
<h2>Skills</h2>
|
|
@@ -1398,6 +1398,76 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1398 |
.skiprestore { align-self: flex-start; }
|
| 1399 |
}
|
| 1400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1401 |
/* settings: skills editor */
|
| 1402 |
.settings-page.wide { max-width: 980px; }
|
| 1403 |
|
|
|
|
| 1398 |
.skiprestore { align-self: flex-start; }
|
| 1399 |
}
|
| 1400 |
|
| 1401 |
+
/* ---- settings: API log (docs: the approved cron-and-api-log mock) ---- */
|
| 1402 |
+
.al { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
|
| 1403 |
+
.al-head { display: flex; align-items: center; gap: 10px; }
|
| 1404 |
+
.al-count { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
|
| 1405 |
+
.al-view { margin-left: auto; }
|
| 1406 |
+
.al-refresh { padding: 4px 9px; font-size: 11.5px; }
|
| 1407 |
+
.al-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
| 1408 |
+
.al-gap { flex: 1 0 12px; }
|
| 1409 |
+
/* A filter is a chip: bordered, quiet until it is on, and never a coloured pill
|
| 1410 |
+
sitting in a data row — the rows themselves carry no badges at all. */
|
| 1411 |
+
.al-chip { padding: 4px 9px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); color: var(--muted); font: inherit; font-size: 11.5px; cursor: pointer; white-space: nowrap; }
|
| 1412 |
+
.al-chip:hover { border-color: var(--border-strong); color: var(--text); }
|
| 1413 |
+
.al-chip.on { border-color: var(--accent); color: var(--text); font-weight: 600; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
| 1414 |
+
.al-find { flex: 1; min-width: 130px; padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); color: var(--text); font-size: 11.5px; }
|
| 1415 |
+
.al-find:focus { outline: none; border-color: var(--accent); }
|
| 1416 |
+
.al-empty { padding: 14px 2px; }
|
| 1417 |
+
|
| 1418 |
+
/* ONE CALL PER LINE. Every cell is nowrap and clips; the path is the only
|
| 1419 |
+
column that may take the slack (max-width:0 + width:100% is what makes a
|
| 1420 |
+
table cell shrink-to-fit-the-rest rather than push the row wide). Wrapping
|
| 1421 |
+
would halve how many calls a screen holds, which is the whole point of it. */
|
| 1422 |
+
.al-tblwrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-md); }
|
| 1423 |
+
.al-tbl { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
| 1424 |
+
/* A floor, so a narrow pane scrolls the table sideways instead of crushing the
|
| 1425 |
+
Call column — which is the one column worth reading — down to two letters.
|
| 1426 |
+
The row stays one line either way; that is the rule this is protecting. */
|
| 1427 |
+
.al-tbl { min-width: 640px; }
|
| 1428 |
+
.al-tbl th { text-align: left; padding: 7px 10px; background: var(--panel-2); border-bottom: 1px solid var(--border); font-size: 10px; letter-spacing: 0.07em; text-transform: uppercase; color: var(--muted); font-weight: 600; white-space: nowrap; }
|
| 1429 |
+
.al-tbl td { padding: 5px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 1430 |
+
.al-tbl tr:last-child td { border-bottom: none; }
|
| 1431 |
+
.al-tbl td.grow, .al-tbl th.grow { max-width: 0; width: 100%; }
|
| 1432 |
+
.al-tbl td.num, .al-tbl th.num { text-align: right; }
|
| 1433 |
+
.al-tbl tbody tr:hover { background: var(--panel-2); }
|
| 1434 |
+
.al-time { color: var(--muted); }
|
| 1435 |
+
.al-who { max-width: 130px; overflow: hidden; text-overflow: ellipsis; }
|
| 1436 |
+
.al-meth { font-weight: 700; color: var(--accent); }
|
| 1437 |
+
/* the one logged read: attention coming back, not work going out */
|
| 1438 |
+
.al-meth.back { color: var(--muted); }
|
| 1439 |
+
.al-path { color: var(--text); }
|
| 1440 |
+
/* colour the text in the column, never a badge around it */
|
| 1441 |
+
.al-st { font-weight: 600; }
|
| 1442 |
+
.al-st.ok { color: var(--go); }
|
| 1443 |
+
.al-st.warn { color: #9a6212; }
|
| 1444 |
+
.al-st.bad { color: var(--danger); }
|
| 1445 |
+
.al-pay { color: var(--muted); max-width: 190px; overflow: hidden; text-overflow: ellipsis; }
|
| 1446 |
+
.al-rep { color: var(--accent); font-weight: 600; }
|
| 1447 |
+
|
| 1448 |
+
/* the map */
|
| 1449 |
+
.al-map { display: flex; flex-direction: column; gap: 8px; }
|
| 1450 |
+
.al-mapwrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); padding: 8px; }
|
| 1451 |
+
.al-map svg { display: block; width: 100%; min-width: 620px; height: auto; }
|
| 1452 |
+
.al-lane { stroke: var(--border); stroke-width: 1; }
|
| 1453 |
+
.al-lane-lbl { font-family: var(--font-mono); font-size: 9px; fill: var(--muted); }
|
| 1454 |
+
.al-axis { font-family: var(--font-mono); font-size: 8px; fill: var(--muted); }
|
| 1455 |
+
.al-arrow { stroke: var(--accent); stroke-width: 1.6; }
|
| 1456 |
+
.al-arrow.back { stroke: var(--muted); stroke-dasharray: 3 3; }
|
| 1457 |
+
/* a call that failed is worth seeing in the shape, not only in the list */
|
| 1458 |
+
.al-arrow.bad { stroke: var(--danger); }
|
| 1459 |
+
.al-dot.bad { fill: var(--danger); }
|
| 1460 |
+
.al-dot { fill: var(--accent); }
|
| 1461 |
+
.al-dot.back { fill: var(--muted); }
|
| 1462 |
+
.al-hit { stroke: transparent; stroke-width: 11; }
|
| 1463 |
+
.al-arrowg { cursor: default; }
|
| 1464 |
+
.al-arrowg.on .al-arrow { stroke-width: 2.6; }
|
| 1465 |
+
.al-readout { font-size: 11px; color: var(--muted); min-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 1466 |
+
.al-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 11.5px; color: var(--muted); }
|
| 1467 |
+
.al-legend i.al-key { display: inline-block; width: 16px; height: 0; border-top: 2px solid var(--accent); vertical-align: middle; margin-right: 5px; }
|
| 1468 |
+
.al-legend i.al-key.back { border-top-style: dashed; border-color: var(--muted); }
|
| 1469 |
+
.al-legend i.al-key.dot { width: 6px; height: 6px; border: none; border-radius: 50%; background: var(--accent); }
|
| 1470 |
+
|
| 1471 |
/* settings: skills editor */
|
| 1472 |
.settings-page.wide { max-width: 980px; }
|
| 1473 |
|
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Settings → API log, in a real browser.
|
| 2 |
+
//
|
| 3 |
+
// Two rules here came from the operator and are the kind that decay silently,
|
| 4 |
+
// so they are pinned rather than trusted:
|
| 5 |
+
//
|
| 6 |
+
// 1. ONE CALL PER LINE. A wrapped row halves how many calls fit on a screen.
|
| 7 |
+
// Every cell clips; the path is the only column allowed to take the slack,
|
| 8 |
+
// and below a certain width the table scrolls sideways instead of crushing
|
| 9 |
+
// it. Measured as row height, not as CSS.
|
| 10 |
+
// 2. NO PILLS OR BADGES in a row. Status is coloured TEXT in its column — no
|
| 11 |
+
// border, no background, no chip.
|
| 12 |
+
//
|
| 13 |
+
// And one rule the map's legend claims, which is only true if the lanes are
|
| 14 |
+
// ordered caller-above-target: a prompt goes from caller to target, a resolved
|
| 15 |
+
// wait comes back the other way.
|
| 16 |
+
//
|
| 17 |
+
// Run with: node test/apiLog.test.mjs
|
| 18 |
+
import assert from 'node:assert/strict';
|
| 19 |
+
import fs from 'node:fs';
|
| 20 |
+
import os from 'node:os';
|
| 21 |
+
import path from 'node:path';
|
| 22 |
+
import { fileURLToPath } from 'node:url';
|
| 23 |
+
import { build } from 'esbuild';
|
| 24 |
+
import { chromium } from 'playwright';
|
| 25 |
+
import { chromiumLaunchOptions } from '../../scripts/test-chromium.mjs';
|
| 26 |
+
|
| 27 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 28 |
+
const WEB = path.join(HERE, '..');
|
| 29 |
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-log-'));
|
| 30 |
+
const bundle = path.join(tmp, 'app.js');
|
| 31 |
+
|
| 32 |
+
const at = (s) => new Date(Date.UTC(2026, 7, 19, 21, 0, s)).toISOString();
|
| 33 |
+
const prompt = (i, from, to, chars, ok = true) => ({
|
| 34 |
+
id: `p${i}`, at: at(i), method: 'POST', path: `/api/agents/${to}/prompt`,
|
| 35 |
+
origin: { id: from, type: 'agent', name: from },
|
| 36 |
+
target: { id: to, name: to, cli: 'claude' },
|
| 37 |
+
request: { present: true, chars, sha256: `sha-${chars}` },
|
| 38 |
+
status: ok ? 200 : 404, ok, durationMs: 303, result: { ok },
|
| 39 |
+
});
|
| 40 |
+
const wait = (i, watcher, watched, ms) => ({
|
| 41 |
+
id: `w${i}`, at: at(i), method: 'GET', path: `/api/agents/${watched}/wait`,
|
| 42 |
+
origin: { id: watcher, type: 'agent', name: watcher },
|
| 43 |
+
target: { id: watched, name: watched, cli: 'claude' },
|
| 44 |
+
status: 200, ok: true, durationMs: ms,
|
| 45 |
+
result: { id: watched, state: 'waiting', matched: true, waited: Math.round(ms / 1000) },
|
| 46 |
+
});
|
| 47 |
+
// Two identical prompts (same checksum) — what a repeating job looks like.
|
| 48 |
+
const operations = [
|
| 49 |
+
wait(9, 'manager', 'builder', 258000),
|
| 50 |
+
prompt(8, 'manager', 'builder', 1204),
|
| 51 |
+
prompt(7, 'manager', 'ghost-1', 12, false),
|
| 52 |
+
wait(6, 'operator', 'manager', 61000),
|
| 53 |
+
prompt(5, 'operator', 'manager', 4096),
|
| 54 |
+
prompt(4, 'manager', 'builder', 1204),
|
| 55 |
+
{ id: 'f1', at: at(3), method: 'PUT', path: '/api/files/files-5/write',
|
| 56 |
+
origin: { id: 'operator', type: 'operator', name: 'operator' },
|
| 57 |
+
target: { id: 'files-5', name: 'files-5' },
|
| 58 |
+
request: { present: true, chars: 8400, sha256: 'sha-file' },
|
| 59 |
+
status: 200, ok: true, durationMs: 41, result: { ok: true } },
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const stub = path.join(tmp, 'api-stub.ts');
|
| 63 |
+
fs.writeFileSync(stub, `
|
| 64 |
+
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 65 |
+
export const getOperations = () => Promise.resolve(${JSON.stringify({ operations, generatedAt: at(9) })});
|
| 66 |
+
export const getTree = () => Promise.resolve({ sessions: [], groups: [], order: [], hidden: [] });
|
| 67 |
+
`);
|
| 68 |
+
|
| 69 |
+
await build({
|
| 70 |
+
stdin: {
|
| 71 |
+
resolveDir: WEB,
|
| 72 |
+
loader: 'tsx',
|
| 73 |
+
contents: `
|
| 74 |
+
import React from 'react';
|
| 75 |
+
import { createRoot } from 'react-dom/client';
|
| 76 |
+
import ApiLog from './src/components/ApiLog.tsx';
|
| 77 |
+
createRoot(document.getElementById('root')).render(
|
| 78 |
+
<div className="app settings"><div className="main settings-main">
|
| 79 |
+
<div className="settings-page wide"><ApiLog /></div>
|
| 80 |
+
</div></div>);
|
| 81 |
+
`,
|
| 82 |
+
},
|
| 83 |
+
outfile: bundle,
|
| 84 |
+
bundle: true,
|
| 85 |
+
format: 'iife',
|
| 86 |
+
platform: 'browser',
|
| 87 |
+
logLevel: 'error',
|
| 88 |
+
plugins: [{ name: 'stub-api', setup(b) { b.onResolve({ filter: /(^|\/)\.\.?\/api$/ }, () => ({ path: stub })); } }],
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
const css = fs.readFileSync(path.join(WEB, 'src/styles.css'), 'utf8');
|
| 92 |
+
let failed = 0;
|
| 93 |
+
const check = (what, fn) => {
|
| 94 |
+
try { fn(); console.log(` ok ${what}`); } catch (e) {
|
| 95 |
+
failed++;
|
| 96 |
+
console.log(` FAIL ${what}\n ${e.message.split('\n')[0]}`);
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const browser = await chromium.launch(chromiumLaunchOptions());
|
| 101 |
+
const open = async (width) => {
|
| 102 |
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
| 103 |
+
await page.setContent(`<style>${css}</style><div id="root"></div>`);
|
| 104 |
+
await page.addScriptTag({ path: bundle });
|
| 105 |
+
await page.waitForFunction(() => !!document.querySelector('.al-tbl tbody tr'));
|
| 106 |
+
return page;
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
for (const width of [1200, 390]) {
|
| 111 |
+
const page = await open(width);
|
| 112 |
+
const m = await page.evaluate(() => {
|
| 113 |
+
const rows = [...document.querySelectorAll('.al-tbl tbody tr')];
|
| 114 |
+
const line = parseFloat(getComputedStyle(document.querySelector('.al-tbl')).fontSize) * 2.4;
|
| 115 |
+
const st = document.querySelector('.al-st');
|
| 116 |
+
const stStyle = getComputedStyle(st);
|
| 117 |
+
const main = document.querySelector('.settings-main');
|
| 118 |
+
return {
|
| 119 |
+
rows: rows.length,
|
| 120 |
+
tallest: Math.max(...rows.map((r) => r.getBoundingClientRect().height)),
|
| 121 |
+
line,
|
| 122 |
+
clipped: rows.every((r) => [...r.children].every((c) => getComputedStyle(c).whiteSpace === 'nowrap')),
|
| 123 |
+
badge: {
|
| 124 |
+
border: stStyle.borderTopWidth,
|
| 125 |
+
background: stStyle.backgroundColor,
|
| 126 |
+
radius: stStyle.borderTopLeftRadius,
|
| 127 |
+
colour: stStyle.color,
|
| 128 |
+
},
|
| 129 |
+
paneOverflow: main.scrollWidth > main.clientWidth,
|
| 130 |
+
};
|
| 131 |
+
});
|
| 132 |
+
console.log(`the list at ${width}px`);
|
| 133 |
+
check(`every one of the ${m.rows} rows is a single line`, () => assert.ok(m.tallest < m.line, `tallest ${m.tallest}px`));
|
| 134 |
+
check('and every cell clips rather than wraps', () => assert.ok(m.clipped));
|
| 135 |
+
check('status is coloured text, not a badge', () => assert.deepEqual(
|
| 136 |
+
{ border: m.badge.border, background: m.badge.background, radius: m.badge.radius },
|
| 137 |
+
{ border: '0px', background: 'rgba(0, 0, 0, 0)', radius: '0px' },
|
| 138 |
+
));
|
| 139 |
+
check('…and it does carry a colour', () => assert.ok(!/rgba?\(0, 0, 0/.test(m.badge.colour), m.badge.colour));
|
| 140 |
+
check('the page itself never scrolls sideways', () => assert.ok(!m.paneOverflow));
|
| 141 |
+
await page.close();
|
| 142 |
+
console.log('');
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const page = await open(1200);
|
| 146 |
+
const list = await page.evaluate(() => {
|
| 147 |
+
const cells = [...document.querySelectorAll('.al-tbl tbody tr')].map((r) => [...r.children].map((c) => c.textContent.trim()));
|
| 148 |
+
return { first: cells[0], repeats: [...document.querySelectorAll('.al-rep')].map((e) => e.textContent.trim()) };
|
| 149 |
+
});
|
| 150 |
+
console.log('what a row says');
|
| 151 |
+
check('a resolved wait reads as one, with the time it blocked for',
|
| 152 |
+
() => assert.deepEqual(list.first.slice(2), ['GET /api/agents/builder/wait', '200', '4m 18s', 'resolved · waiting']));
|
| 153 |
+
check('identical prompts are marked as repeats, since the text itself is never stored',
|
| 154 |
+
() => assert.deepEqual(list.repeats, ['×2', '×2']));
|
| 155 |
+
|
| 156 |
+
await page.click('.al-view button:nth-child(2)');
|
| 157 |
+
await page.waitForSelector('.al-map svg');
|
| 158 |
+
const map = await page.evaluate(() => {
|
| 159 |
+
const labels = [...document.querySelectorAll('.al-lane-lbl')];
|
| 160 |
+
const lanes = labels.map((t) => t.textContent);
|
| 161 |
+
// Which lane an endpoint sits on: the arrow stops a few px short of the
|
| 162 |
+
// hairline, so snap to the nearest lane label.
|
| 163 |
+
const laneYs = labels.map((t) => Number(t.getAttribute('y')));
|
| 164 |
+
const laneAt = (y) => laneYs.reduce((best, ly, i) => (Math.abs(ly - y) < Math.abs(laneYs[best] - y) ? i : best), 0);
|
| 165 |
+
const arrows = [...document.querySelectorAll('.al-arrow')].map((l) => ({
|
| 166 |
+
back: l.classList.contains('back'),
|
| 167 |
+
bad: l.classList.contains('bad'),
|
| 168 |
+
down: Number(l.getAttribute('y2')) > Number(l.getAttribute('y1')),
|
| 169 |
+
src: lanes[laneAt(Number(l.getAttribute('y1')))],
|
| 170 |
+
dst: lanes[laneAt(Number(l.getAttribute('y2')))],
|
| 171 |
+
dashed: !!getComputedStyle(l).strokeDasharray && getComputedStyle(l).strokeDasharray !== 'none',
|
| 172 |
+
}));
|
| 173 |
+
return { lanes, arrows };
|
| 174 |
+
});
|
| 175 |
+
console.log('\nthe map');
|
| 176 |
+
check('callers are laid out above the agents they call',
|
| 177 |
+
() => assert.ok(map.lanes.indexOf('manager') < map.lanes.indexOf('builder'), map.lanes.join(' < ')));
|
| 178 |
+
check('a prompt is an arrow from caller down to target',
|
| 179 |
+
() => assert.ok(map.arrows.some((a) => !a.back && a.down)));
|
| 180 |
+
check('a resolved wait is an arrow back the other way, dashed',
|
| 181 |
+
() => assert.ok(map.arrows.some((a) => a.back && !a.down && a.dashed)));
|
| 182 |
+
// The claim is about direction between the two agents, not about up and down
|
| 183 |
+
// on the screen: if A calls B and B also calls A, one of the pairs has to run
|
| 184 |
+
// the other way. What must hold is that the wait reverses its own prompt.
|
| 185 |
+
check('the wait reverses the prompt it answers — manager → builder, builder → manager',
|
| 186 |
+
() => {
|
| 187 |
+
const out = map.arrows.find((a) => !a.back && a.src === 'manager' && a.dst === 'builder');
|
| 188 |
+
const back = map.arrows.find((a) => a.back && a.src === 'builder' && a.dst === 'manager');
|
| 189 |
+
assert.ok(out && back, JSON.stringify(map.arrows));
|
| 190 |
+
assert.ok(out.down && !back.down, 'and with callers on top that reads as out and back');
|
| 191 |
+
});
|
| 192 |
+
check('a failed call is visible in the shape too',
|
| 193 |
+
() => assert.ok(map.arrows.some((a) => a.bad)));
|
| 194 |
+
await page.close();
|
| 195 |
+
} finally {
|
| 196 |
+
await browser.close();
|
| 197 |
+
fs.rmSync(tmp, { recursive: true, force: true });
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
console.log(failed ? `\n${failed} failed` : '\napi-log: ok');
|
| 201 |
+
process.exit(failed ? 1 : 0);
|