Skip to content

Commit 95239ca

Browse files
committed
Rebuild the run page around one client, with live status per URL
The run page now says what a run is and how many cells can start at each revision (18 and 31 today, not 49 at every revision), then leads with the config picker, whose block covers the starter set only. Below it come the one URL per revision for scenarios that need no sign-in, the cells that need a URL of their own, and the auth cells, metadata-default first and the rest folded by group. Every URL carries a live line: its state as a glyph and a word, the time of the last request and the client's name, judged by buildReport() from the stored log so every process agrees with the report. It refreshes with the report's 10-second script, paused while the tab is hidden. The page ends with how many cells were reached and how many need a look. Every client's full block, copy all, the mcpServers map of every cell and the run's matrix move to a bulk page at /s/<run-id>/bulk.
1 parent 33040bc commit 95239ca

9 files changed

Lines changed: 1405 additions & 249 deletions

File tree

‎src/hosted/README.md‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ name (`auth/metadata-var2/tenant1` → scenario `auth/metadata-var2`, suffix
6868
Before the matrix it says, in plain sentences: what this is (a server that
6969
tests a client you point at it by hand; a cell is one scenario at one
7070
revision with its own MCP URL); how to test a client in five minutes (a new
71-
run at `/s`, the run page's composite URLs and per-client config blocks,
72-
the cell steps, the report and its frozen, Markdown and plain-text copies);
71+
run at `/s`, the run page's composite URLs, `auth/metadata-default` and
72+
client picker, the cell steps, the report and its frozen, Markdown and
73+
plain-text copies);
7374
what the score means (the frozen requirement set's scored cells per
7475
revision, "X of the M scored cells you can run here pass · N are scored for
7576
the revision", one line per state, and that a
@@ -82,8 +83,41 @@ issue tracker are. Every page ends with the server's build (short commit,
8283
run report stores it as `server` (`build`, `deployedAt`), so a frozen copy
8384
names the build that froze it. The Val Town deploy stamps the staged
8485
`src/hosted/build-stamp.ts`; the local `hosted` command reads the checkout's
85-
commit from git; anything else reads "unknown". The run page's two sections it names carry anchors,
86-
`#composites` and `#client-config`.
86+
commit from git; anything else reads "unknown". The run page's sections carry anchors:
87+
`#client-config`, `#composites`, `#own-url`, `#auth` and `#results`.
88+
89+
**The run page** (`/s/<run-id>`; `/s/<run-id>/<rev>` for one revision) is
90+
written for the author of one client setting it up. It says what a run is
91+
and how many cells can start at each revision ("At 2025-11-25, 18 cells can
92+
start here, and at 2026-07-28, 31 can."), then:
93+
94+
1. **Pick your client**: a picker (`src/hosted/config-picker.ts`) whose
95+
default is "Just the URL", with VS Code, Codex, Goose, Copilot CLI and
96+
`mcpServers` JSON behind it. The choice is kept in the browser's
97+
localStorage (`c9e-client`), read and written inside try/catch, so a
98+
blocked store leaves the URL. Its block covers the starter set only: the
99+
ready-made composites and `auth/metadata-default` at each revision.
100+
2. One URL per revision for the scenarios that need no sign-in (the
101+
ready-made composite), with the scenarios behind it folded.
102+
3. The cells that need a URL of their own (no composite can carry them), each
103+
with the reason.
104+
4. The auth cells: `auth/metadata-default` at each revision, then the rest
105+
folded by group (metadata discovery, scopes, registration and token
106+
endpoint authentication, issuer checks, other auth).
107+
5. How many cells the client has reached and how many need a look, with
108+
"Open the results".
109+
110+
Every URL has a live line: `✓ reached`, `✗ fails`, `■ stopped`, `◔ waiting`,
111+
`◑ in progress`, `◐ stopped short` or `○ not reached yet`, with the time of
112+
the last request and the client's name. A cell the client reached reads
113+
"reached" until it fails or stops; only the report says pass. A composite's
114+
line is its worst child's, with how many of its scenarios were reached. The
115+
lines are judged exactly as the report judges the cell
116+
(`src/hosted/run-status.ts` over `buildReport()`), from the stored log when
117+
there is a store, and the page refreshes them with the report's 10-second
118+
script, paused while the tab is hidden. The bulk page (`/s/<run-id>/bulk`)
119+
has every client's full block with "copy all", the `mcpServers` map of every
120+
cell, and the run's matrix with each cell's steps, links and copy buttons.
87121

88122
**How long results last.** The landing page states these from the values
89123
the deployment runs with, not from the defaults, so it cannot disagree with
@@ -173,10 +207,10 @@ each with its own copy button and a line on adding it to a file that already
173207
has servers. Names are `c9e-<revision>-<scenario>` with
174208
anything but letters, digits, `*`and`-`made`-`
175209
(`c9e-2025-11-25-auth-metadata-default`); the run page's ready-made
176-
composites are `c9e-<revision>-composite`. The run page gives each client a
210+
composites are `c9e-<revision>-composite`. The run page's picker gives the
177211
starter block (the ready-made composites and `auth/metadata-default`per
178-
revision) and, folded, the full one, which covers every startable cell of the
179-
run once: the composites, each cell no composite carries (such as`request-metadata`and`http-invalid-tool-headers`), and every auth cell, switched off where
212+
revision); the bulk page gives each client the full one, which covers every
213+
startable cell of the run once: the composites, each cell no composite carries (such as`request-metadata`and`http-invalid-tool-headers`), and every auth cell, switched off where
180214
the format can say so (`enabled: false`for Goose,`enabled = false` for
181215
Codex), since each auth cell starts a sign-in when the client connects
182216
(`src/hosted/client-config.ts`). An `auth/\*`cell, which has no generic-client steps, shows plain ones for a hand-driven

‎src/hosted/client-config.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from './client-config';
1010
import { DEFAULT_COMPOSITES, parseComposite } from './composite';
1111
import { createHostedApp, type RunConfig } from './server';
12-
import { runClientEntries } from './html';
12+
import { runClientEntries } from './run-page';
1313
import { MCP_PATH } from './matrix';
1414

1515
const entries: ServerEntry[] = [

‎src/hosted/composite.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,9 @@ describe('composite cells on the hosted server', () => {
380380
const html = await (
381381
await fetch(`${base}/s/comp5`, { headers: { accept: 'text/html' } })
382382
).text();
383-
expect(html).toContain('One URL for several scenarios');
383+
expect(html).toContain(
384+
'Add one URL per revision for the scenarios that need no sign-in'
385+
);
384386
expect(html).toContain(
385387
`${base}/s/comp5/${STATELESS}/tools_call+http-standard-headers+http-custom-headers+json-schema-ref-no-deref+sep-2322-client-request-state/mcp`
386388
);

‎src/hosted/html.test.ts‎

Lines changed: 84 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { scoreText } from './markdown';
1919
import { cellId, DEFAULT_CELL_TTL_MS } from './session';
2020
import { describeStep } from '../steps';
2121
import { CLIENTS } from './client-config';
22+
import { renderBulkPage, renderRunPage } from './run-page';
23+
import type { RunLive } from './run-status';
2224
import { identityCheck, identityOf } from './identity';
2325
import { hostedScenarios } from './catalog';
2426

@@ -69,6 +71,14 @@ function configFor(runId: string, scope: Partial<RunConfig> = {}): RunConfig {
6971
/** A single process without a store, at the default idle TTL. */
7072
const IN_MEMORY = { idleMs: DEFAULT_CELL_TTL_MS };
7173

74+
/** A run nothing has reached yet, for the run page. */
75+
const NO_LIVE: RunLive = {
76+
cells: new Map(),
77+
reached: 0,
78+
needsLook: 0,
79+
now: 0
80+
};
81+
7282
/** The page's text with tags dropped and whitespace folded. */
7383
const text = (html: string) =>
7484
html
@@ -99,23 +109,21 @@ describe('landing page for a newcomer', () => {
99109

100110
it('gives the five-minute path: new run, composite, per-client config, steps, results', () => {
101111
expect(html).toContain('<a href="/s">Start a new run</a>');
102-
expect(said).toContain('One URL for several scenarios');
103-
expect(said).toContain('Each auth scenario needs a URL of its own.');
112+
expect(said).toContain('one URL per revision');
113+
expect(said).toContain('Each auth scenario needs a URL of its own');
104114
expect(said).toContain(
105-
'VS Code, Codex, Goose and any client that reads mcpServers JSON'
115+
'VS Code, Codex, Goose, Copilot CLI and any client that reads mcpServers JSON'
106116
);
107117
expect(said).toContain('/results/<run-id>');
108118
expect(said).toContain('Freeze a copy to link to');
109119
expect(said).toContain('Markdown or plain text');
110-
// The section names match the run page's headings, which carry anchors.
111-
const run = renderConfig('http://x', matrix, configFor('run0'));
112-
expect(run).toContain(
113-
'<h2 id=composites>One URL for several scenarios</h2>'
114-
);
115-
expect(run).toContain('<h2 id=client-config>Paste into your client</h2>');
116-
expect(said).toContain('Paste into your client');
120+
// The section it names matches the run page's heading, with an anchor,
121+
// and the picker offers every client the landing page names.
122+
const run = renderRunPage('http://x', matrix, configFor('run0'), NO_LIVE);
123+
expect(run).toContain('<h2 id=client-config>Pick your client</h2>');
124+
expect(said).toContain('Pick your client');
117125
for (const client of CLIENTS)
118-
expect(run).toContain(`<b>${client.label}</b>`);
126+
expect(run).toContain(`>${client.label}</option>`);
119127
});
120128

121129
it('explains the score from the matrix and every state in one line', () => {
@@ -237,9 +245,9 @@ describe('hosted HTML', () => {
237245
expect(html).toContain('excluded &lt;here&gt;');
238246
});
239247

240-
it('run page links every startable cell and embeds the config for the copy buttons', () => {
248+
it('bulk page links every startable cell and embeds the config for the copy buttons', () => {
241249
const config = configFor('run1');
242-
const html = renderConfig('http://x', matrix, config);
250+
const html = renderBulkPage('http://x', matrix, config);
243251
expect(html).toContain('<a href="/s/run1/2026-07-28/tools_call">open</a>');
244252
expect(html).toContain(
245253
'<a href="/results/run1/2026-07-28/tools_call">results</a>'
@@ -256,14 +264,12 @@ describe('hosted HTML', () => {
256264

257265
it('run page and landing count startable cells per revision, not "at every revision"', () => {
258266
const config = configFor('run6');
259-
const per = matrix.revisions
260-
.map(
261-
(r) => `${config.cells.filter((c) => c.revision === r).length} at ${r}`
262-
)
263-
.join(', ');
264-
const html = renderConfig('http://x', matrix, config);
265-
expect(html).toContain(
266-
`<p>${config.cells.length} startable cells (${per}).`
267+
const [older, newer] = matrix.revisions.map(
268+
(r) => config.cells.filter((c) => c.revision === r).length
269+
);
270+
const html = renderRunPage('http://x', matrix, config, NO_LIVE);
271+
expect(text(html)).toContain(
272+
`At ${matrix.revisions[0]}, ${older} cells can start here, and at ${matrix.revisions[1]}, ${newer} can.`
267273
);
268274
expect(html).not.toContain('at every revision');
269275

@@ -275,27 +281,25 @@ describe('hosted HTML', () => {
275281
`${cells.length} startable cells (${landingPer}) here.`
276282
);
277283

278-
const column = renderConfig(
284+
const column = renderRunPage(
279285
'http://x',
280286
matrix,
281-
configFor('run7', { revision: '2026-07-28' })
282-
);
283-
expect(column).toMatch(
284-
/<p>\d+ startable cells? at revision <code>2026-07-28<\/code>\./
287+
configFor('run7', { revision: '2026-07-28' }),
288+
NO_LIVE
285289
);
290+
expect(text(column)).toMatch(/At 2026-07-28, \d+ cells? can start here\./);
286291
});
287292

288-
it('column page filters to one revision', () => {
289-
const html = renderConfig(
293+
it('column page lists one revision', () => {
294+
const html = renderRunPage(
290295
'http://x',
291296
matrix,
292-
configFor('run2', { revision: '2025-11-25' })
293-
);
294-
expect(html).toContain(
295-
'<th><a href="/s/run2/2025-11-25">2025-11-25</a></th>'
297+
configFor('run2', { revision: '2025-11-25' }),
298+
NO_LIVE
296299
);
297-
expect(html).not.toContain('2026-07-28</a></th>');
298-
expect(html).toContain('href="/s/run2/2025-11-25/initialize"');
300+
expect(html).toContain('/s/run2/2025-11-25/initialize+tools_call/mcp');
301+
expect(html).toContain('href="/s/run2/2025-11-25/elicitation-sep1034');
302+
expect(html).not.toContain('/2026-07-28/');
299303
});
300304

301305
it('cell page shows the endpoint, env and steps with a copy button', () => {
@@ -370,12 +374,16 @@ describe('hosted HTML', () => {
370374
});
371375

372376
it('offers the bare MCP URL to copy on every cell, cell page and composite', () => {
373-
const run = renderConfig('http://x', matrix, configFor('run6'));
374-
expect(run).toContain(
377+
const bulk = renderBulkPage('http://x', matrix, configFor('run6'));
378+
expect(bulk).toContain(
375379
'<button class=copy data-copy-text="http://x/s/run6/2026-07-28/tools_call/mcp">copy URL</button>'
376380
);
381+
const run = renderRunPage('http://x', matrix, configFor('run6'), NO_LIVE);
377382
expect(run).toMatch(
378-
/data-copy-text="http:\/\/x\/s\/run6\/2026-07-28\/tools_call\+[^"]*\/mcp">copy URL/
383+
/data-copy-text="http:\/\/x\/s\/run6\/2026-07-28\/tools_call\+[^"]*\/mcp" title="[^"]*">copy URL/
384+
);
385+
expect(run).toContain(
386+
'data-copy-text="http://x/s/run6/2026-07-28/request-metadata/mcp"'
379387
);
380388
const cell = renderConfig(
381389
'http://x',
@@ -439,7 +447,7 @@ describe('hosted HTML', () => {
439447
);
440448
});
441449

442-
it('offers one block per client on the run page: the ready-made composites and every auth cell', () => {
450+
it('offers one block per client: the starter on the run page, every cell on the bulk page', () => {
443451
const withAuth = buildMatrix({ auxOrigins: { as: 'http://as' } });
444452
const cells = withAuth
445453
.cells()
@@ -455,25 +463,36 @@ describe('hosted HTML', () => {
455463
MCP_CONFORMANCE_PROTOCOL_VERSION: c.revision
456464
}
457465
}));
458-
const run = renderConfig('http://x', withAuth, {
466+
const config: RunConfig = {
459467
runId: 'run9',
460468
resultsUrl: 'http://x/results/run9',
461469
mcpServers: {},
462470
cells
463-
});
464-
// Per client a starter block and the full one; the full one is longer.
465-
const blocksWith = (marker: string) =>
466-
[...run.matchAll(/data-copy-text="([^"]*)"/g)]
471+
};
472+
const run = renderRunPage('http://x', withAuth, config, NO_LIVE);
473+
const bulk = renderBulkPage('http://x', withAuth, config);
474+
const blockWith = (page: string, marker: string) => {
475+
const found = [...page.matchAll(/data-copy-text="([^"]*)"/g)]
467476
.map((m) => m[1])
468-
.filter((t) => t.includes(marker))
469-
.sort((a, b) => a.length - b.length);
470-
const [gooseStarter, goose] = blocksWith('type: streamable_http');
471-
const [codexStarter, codex] = blocksWith('[mcp_servers.');
477+
.filter((t) => t.includes(marker));
478+
expect(found, marker).toHaveLength(1);
479+
return found[0];
480+
};
481+
const [gooseStarter, codexStarter] = [
482+
blockWith(run, 'type: streamable_http'),
483+
blockWith(run, '[mcp_servers.')
484+
];
485+
const [goose, codex] = [
486+
blockWith(bulk, 'type: streamable_http'),
487+
blockWith(bulk, '[mcp_servers.')
488+
];
472489
// Pasted under the file's own `extensions:` key: no key of its own.
473490
expect(goose).not.toMatch(/^extensions:/m);
474-
expect(run).toContain(
475-
'Paste under extensions: in ~/.config/goose/config.yaml.'
476-
);
491+
for (const page of [run, bulk]) {
492+
expect(page).toContain(
493+
'Paste under extensions: in ~/.config/goose/config.yaml.'
494+
);
495+
}
477496
// Auth cells arrive switched off, the composites on.
478497
expect(goose).toContain(
479498
' c9e-2026-07-28-auth-resource-mismatch:\n enabled: false'
@@ -491,7 +510,8 @@ describe('hosted HTML', () => {
491510
expect(starter).not.toContain('enabled = false');
492511
}
493512
// Copilot CLI has its own block (its entries need `tools`).
494-
expect(run).toContain('<b>Copilot CLI</b>');
513+
expect(bulk).toContain('<b>Copilot CLI</b>');
514+
expect(run).toContain('<option value="copilot">Copilot CLI</option>');
495515
const auth = cells.filter((c) => c.scenario.startsWith('auth/'));
496516
expect(auth.length).toBeGreaterThan(10);
497517
for (const name of [
@@ -504,6 +524,7 @@ describe('hosted HTML', () => {
504524
// Only those: a cell a composite covers is not listed again.
505525
expect(goose).not.toContain('c9e-2026-07-28-tools_call:');
506526
expect(run).toContain('data-copy-text="{\n &quot;servers&quot;');
527+
expect(bulk).toContain('data-copy-text="{\n &quot;servers&quot;');
507528
});
508529

509530
it('renders the markdown in scenario descriptions instead of showing it raw', () => {
@@ -563,9 +584,18 @@ describe('hosted HTML', () => {
563584

564585
it('escapes request-derived values', () => {
565586
const evil = '"><img src=x onerror=alert(1)>';
566-
const html = renderConfig('http://x', matrix, configFor(evil));
567-
expect(html).not.toContain('<img');
568-
expect(html).toContain('&quot;&gt;&lt;img');
587+
for (const html of [
588+
renderRunPage('http://x', matrix, configFor(evil), NO_LIVE),
589+
renderBulkPage('http://x', matrix, configFor(evil)),
590+
renderConfig(
591+
'http://x',
592+
matrix,
593+
configFor(evil, { revision: '2026-07-28', scenario: 'tools_call' })
594+
)
595+
]) {
596+
expect(html).not.toContain('<img');
597+
expect(html).toContain('&quot;&gt;&lt;img');
598+
}
569599
expect(
570600
renderMatrixTable(matrix, { origin: 'http://x', runId: evil })
571601
).not.toContain('<img');

0 commit comments

Comments
 (0)