Bobcat.Console, the test-run viewer — design notes
A deployable web console (dotnet bobcat) that shows live progress for every Bobcat test suite running on the box. Primary purpose: visualizing AI-agent-driven test runs — much of Critter Stack development is gated on testing time, and this makes that time observable.
Decisions of record (2026-07-31), amended by the Bobcat/Stoat split (2026-08-09) and by the rename to Bobcat.Console (2026-08-21, issue #100).
The name (2026-08-21)
The project is Bobcat.Console (src/Bobcat.Console, Bobcat.Console.Tests, Bobcat.Console.Specs, Bobcat.Console.FrontEnd; namespaces Bobcat.Console.*; NuGet package id Bobcat.Console, tool command still bobcat). It was Bobcat.Monitor from 2026-07-31. Bobcat.Viewer was the other candidate and lost because nobody calls it a viewer out loud — "console" is the word already in the tool's description, in this document, and in every handoff.
Two things stay as they were, on purpose, and this file's name is the first of them:
docs/monitor-design.mdkeeps its filename. It documents the monitor protocol — what a run publishes, over which routes, with which env vars — at least as much as the viewer that receives it, and issues, handoffs, and the CLAUDE.md seams all link to it by this name.- The publisher side in core keeps the monitor vocabulary:
Bobcat.Monitoring,BobcatRunner.PublishToMonitor,MonitorPublisher,BOBCAT_MONITOR*,BOBCAT_RUN_ID,BOBCAT_RUN_TAG, theMonitor:*config keys, the/api/*routes, the wire shapes and the duplicatedMonitorEvents.csrecords. There "monitor" means the thing a run publishes to, and every one of those is a user-facing or wire contract. Renaming them would be a breaking change that #100 explicitly scoped out.
Mentions of Bobcat.Monitor in dated history below were rewritten to the new name wholesale; read them as the same project.
The split (2026-08-09)
This document once described two futures for the tool. Both are now settled, and neither happened the way it was written:
- The AI agent coordination surface moved out to Stoat, its own BSL repository. Bobcat stays MIT and stays about testing. Stoat observes this viewer's runs over HTTP (
GET /api/runs) exactly as it observes GitHub and nuget.org — it holds no reference to anyBobcat.*assembly, and none of them reference it. - The rename is dead. The plan was for this tool to become "Bobcat" and the library to be renamed. The split makes that unnecessary: Bobcat keeps its name and its meaning as the testing framework, and the coordination half got a new name instead. Issue #87 closes resolved-by-decision.
What the split changed here, concretely:
ToolCommandNameisbobcat, notbobcat-monitor— "monitor" turned ambiguous the moment there were two consoles, and this is the only global tool Bobcat ships. Note thatrun/listare not subcommands of it: those belong toBobcatRunnerinside a consumer's own test executable, because they need that project's compiled fixtures.BOBCAT_PLAN_NODEbecameBOBCAT_RUN_TAG, an opaque correlation tag Bobcat stamps and never interprets. Coordination vocabulary does not belong in the MIT repo, and a general tag is more useful anyway (a ticket id, a build number, an external tool's node id).GET /api/runsis now a public wire contract, not just the dashboard's list model. It carries the tag, outcome counts, and scenario progress, and takes a?tag=filter — an external consumer correlating its own work to a suite has no other way in. Reads go through the registry's lockedReadAllso live ingestion can never hand a caller a torn scenario collection.- NDJSON stays. The old plan demoted it to an export format once a shared event store landed. That rationale was "one store, not two" for runs plus coordination; with coordination gone there legitimately are two tools, and the run archive is fine as it is (issue #90).
Stack — CritterWatch's, on purpose
Vue 3 + Pinia + Element Plus + @microsoft/signalr frontend, ASP.NET + Wolverine + Wolverine.SignalR backend, mirroring ~/code/critterwatch:
- One
HubConnectionowned byuseSignalR.ts, retry-forever backoff, rAF-batched flush intorelayToStore.ts, which switches on snake_case envelope types and fans out to Pinia stores. Stores never touch SignalR. The rAF flush has a plain-timer backstop because rAF never fires in a hidden tab — without it a backgrounded (or headless) dashboard queues events until refocus, which also breaks any headless e2e against the UI (found live 2026-07-31). - Color tokens in
src/styles/variables.css— see "Palette" below. Everything else on this list is still CritterWatch's; the palette is the one place the two consoles deliberately parted company. - Backend flow (built 2026-07-31): the
[WolverinePost]ingestion endpoint folds into the registry, then queues events intoSignalRBatchAccumulator— CritterWatch's 100ms accumulator, lifted but simplified: ingestion is the only producer, so the endpoint feeds it directly (no per-type relay handlers, no static-instance hack). Each flush publishes oneBatchedWebSocketPayload : WebSocketMessage(so the existing publish rule routes it and Wolverine's WebSocketMessage naming yieldsbatched_web_socket_payloadwith no attribute; no loop risk because no publish rule feeds the accumulator).relayToStoreunwraps the{type, data}items recursively; wire names are pinned to the STJ discriminators bySignalRBatchingTests. - TS mirrors of the contracts are generated (issue #85, built 2026-08-21): CritterWatch's NJsonSchema
GenerateCommandpattern, cut down.TypeScriptContractsinBobcat.ConsolereflectsMonitorEvents.csthrough the same STJ settings the wire uses and emitssrc/messages/monitor-events.tswholesale (interfacesextends MonitorEvent, aMonitorEventTypeunion, the batching envelope);relayToStore.tsis patched, not owned — a missingcaseis inserted above the*CASE ABOVE*marker in the store'shandle<Type>convention and the import block is merged, while hand-written cases stay verbatim. Regenerate withdotnet run --project src/Bobcat.Console -- generate(--checkverifies only).TypeScriptContractTestsfailsdotnet testwhen either committed file differs from what the records generate, so drift is a red build. Two deliberate rules: a record constructor parameter with a default (RunStarted.Tag) mirrors as an optional member, because "additive" means an old publisher's JSON has no such member at all; and the Bobcat-side publisher mirrors (src/Bobcat/Monitoring/MonitorEvents.cs) are NOT generated or unified — that duplication is a decision of record kept honest byContractRoundTripTests. - No Aspire. The Vite dev server proxies
/api(ws included) to the host's fixed dev port 5525. Bobcat will eventually need an Aspire resource recipe as a testing feature; that is unrelated to this tool's dev workflow.
Packaging (built 2026-07-31): dotnet tool (ToolCommandName: bobcat, launched as dotnet bobcat) with the Vite build embedded as resources — CritterWatch's EmbedFrontend + EmbeddedFileProvider pattern (Hosting/EmbeddedSpa.cs, minus its sub-path mounting; this tool owns the root). Two rules of record: IsPackable is gated on EmbedFrontend, so the tool nupkg cannot exist hollow (a solution-level dotnet pack simply skips the project — publish.yml packs it explicitly); and the EmbeddedResource items are created INSIDE the BuildFrontend target, because a static glob evaluates before the Vite build runs and silently embeds nothing on a clean build.
Palette — the docs site's, not CritterWatch's (2026-09-02)
The console wears "Ember on Ink", the palette of https://bobcat.jasperfx.net: rust and ember accents on paper, ink for text. docs/.vitepress/theme/style.css is the source of truth — src/styles/variables.css copies its --bc-* ramp verbatim and derives every --bm-* and --el-* value from those, so the two files can be diffed rather than compared by eye.
This reverses the earlier decision (2026-07-31) to mirror CritterWatch's JasperFx orange so the two consoles read as siblings. The reasoning that changed: the docs site and the console are the two surfaces a Bobcat user meets, and they were the pair that did not match. CritterWatch is a separate, paid product; looking like it is not a goal worth the console not looking like Bobcat. Nothing else about the CritterWatch lineage moved — the stack, the SignalR batching, the contract generation and the Event Modeling canvas are all still lifted from it.
Three consequences worth knowing:
- The test-state grammar keeps its meaning and changes its pigments. Running is still command blue, passed read-model green, failed failure red, retrying/flaky event orange — now
--bc-sky-deep,--bc-pass,--bc-failand a darkened--bc-ember. Retrying is the one value that is not a straight lift:--bc-ember(#f0a23b) is a dark-mode accent in the docs and misses 4.5:1 as text on paper, so the token darkens it and keeps the ember hue only in the row tint. - Element Plus's neutrals are overridden too, not just its primary ramp. Element's stock greys are cool; against a warm paper background they read as a bug rather than a choice.
--el-text-color-*,--el-bg-color*,--el-border-color*and--el-fill-color*are all restated from the palette. - The Event Model canvas is untouched. Its blue/orange/green is the Event Modeling grammar owned by
@jasperfx/event-model-vueand shared with CritterWatch — "renders identically in both viewers" is the point of that package, and re-tinting it here would break it.
Not done, and not implied by this: the console still has no dark mode. Only the docs palette's light mode is expressed. The --bc-ink-* values are carried in variables.css anyway so a dark theme has them to hand. Typography was also left alone — the docs' Space Grotesk / JetBrains Mono would mean a webfont fetch from a tool that is often run offline, which is its own decision.
Branding (issue #179, 2026-08-31)
The console had no favicon and no product mark at all — a browser tab of "localhost" beside CritterWatch's critter badge. Fixed, in the same family CritterWatch uses:
src/Bobcat.Console.FrontEnd/public/holdsfavicon.png(the docs site's ownbobcat-favicon-64.png),bobcat-mark-128.png(the framed lynx avatar, downscaled) andjasperfx-logo-128.png. Copied into the SPA rather than referenced across projects: Vite only serves what is under its own root, and the csproj embedsdist/verbatim, so a cross-project path would work innpm run devand 404 in the packaged tool.- A title bar, which the console did not have before. The product on the left (mark + word mark), the company on the right (the JasperFx gear, linking to jasperfx.net). The sidebar's plain-text brand moved here rather than being duplicated — and moving it is what gives the company mark a right-hand edge to sit against, which a 220px rail does not have.
- The #166 scroll invariant survives the extra row: the title bar is a fixed-height flex child and the inner
el-containertakesflex: 1; min-height: 0, soel-mainstays the one scroller. Without themin-heightthe flex child refuses to shrink andel-mainis pushed off the bottom of the viewport.
Transport: HTTP, fire-and-forget, never slows a run
Publishers (BobcatRunner, the supervisor, worker processes) POST batches of events to /api/ingest. HTTP over raw TCP because the emitting client must be dependency-free (HttpClient + STJ only — no Wolverine in Bobcat), and events arrive at tens/second, not thousands. The invariant that outranks all others: a test run is never slowed or failed by the monitor. Probe GET /api/ping once at startup with a tight timeout → publisher goes no-op for the run if nothing answers; bounded channel, drop on backpressure; discovery via BOBCAT_MONITOR_URL (default http://localhost:5525).
Event model
src/Bobcat.Console/Contracts/MonitorEvents.cs — polymorphic MonitorEvent records. The STJ type discriminator and the Wolverine message type name are pinned to the same snake_case string, so ingestion JSON and the SignalR envelope agree by construction. Identity: RunId (minted per run) + scenario uid "{Feature}/{Scenario}" — the string BobcatRunner, RetryBudget, SpecNodeMapping, and WorkPlan already share. RunStarted carries the root repository path + branch, the dashboard's grouping key for parallel suites on one box. RunHeartbeat exists so a crashed/orphaned run renders as such instead of "running" forever.
The publisher client lives in Bobcat as Bobcat.Monitoring (issue #65): mirror records of these contracts, DECIDED to stay deliberately unshared — Bobcat must not depend on the monitor's Wolverine stack, and the wire shape (not an assembly) is the contract. The round-trip tests in Bobcat.Console.Tests are what keep the two sides honest.
Bobcat-side seams (issue #65 — built)
CompositeObserver+BobcatRunner.AddObserver— observers fan in additively, so the monitor publisher rides alongside the MTPPublishingObserver.WithObserverkeeps replace semantics. An observer throwing never fails the run or starves other observers.MonitorPublishingObserver+MonitorPublisher(src/Bobcat/Monitoring/) — maps observer callbacks (plus the newRunStarted/RunFinishedrun bracket onIExecutionObserver) onto the wire events; fire-and-forget HTTP with a bounded drop-on-backpressure channel; probes/api/pingonce and no-ops when absent.BOBCAT_MONITOR_URLoverrides the target,BOBCAT_MONITOR=0is the kill switch. Publishing is opt-in (BobcatRunner.PublishToMonitor) and turned on only by the real entry points —BobcatRunner.Runand the MTP host's execution path (never discovery) — so unit tests driving the runner never probe.BOBCAT_RUN_IDseeds the run identity so a supervisor can group its workers' streams without supervisor changes.Supervised-run grouping (built 2026-07-31, once supervisor work reopened): the supervisor is the run's monitor-facing OWNER.
Supervisor.PublishToMonitor(opt-in, same policy and probe as the runner's) posts the run bracket itself viaSupervisorRunPublisher— RunStarted with modesupervisedand the true post-filter test total (which no single worker knows), heartbeats, and a RunFinished whose counts includeIndeterminate(never folded into Failed — same split as exit 2 vs 1). Every worker launch — discovery included — inheritsBOBCAT_RUN_ID+BOBCAT_RUN_OWNERviaWorkerLaunchContext.Environment, the LOWEST layer of the env stack (factory shared env andEnvironmentForboth override it).BOBCAT_RUN_OWNERis deliberately a second variable: a worker seeing it suppresses its own bracket (else the first worker to finish would mark the shared run finished with partial counts), whileBOBCAT_RUN_IDalone still just pins identity for a standalone run that keeps its bracket. A cancelled/crashed supervisor posts no RunFinished — heartbeats stop and orphan detection tells the truth.ISupervisorObserver(built 2026-08-02, issue #84) — the supervisor's live narration:AttemptRecorded(every attempt, passes included, with the policy verdict that followed it),RetryScheduled,LaneStarted/LaneFinished,ResourceRecycled,WorkerFaulted. Every member is a default no-op so a consumer implements only what it wants, and an observer that throws is logged and stepped over — a dashboard must not be able to fail a test run.SupervisorRunPublisheris one, registered automatically whenSupervisor.PublishToMonitoris on.- Retry topology is on the wire: a supervised retry now posts
RetryScheduledwith the disposition and reason, announced after the budget and the resolve step have had their say — a retry that was requested and refused never reaches a watcher as though it were about to happen. - The attempt number is the load-bearing part. A worker counts from one:
MonitorPublishingObserver's tracking belongs to aBobcatRunner, and the MTP host builds a fresh runner per run request, so a retry in a brand-new process and a retry in a reused one both announce attempt 1. The supervisor holds the only true count, soRetryScheduled.NextAttemptpins the number the nextScenarioStartedfolds as — inRunProjectionand in the Pinia store identically. Taken as a floor, never an assignment: an attempt number never goes backwards, because hydration routinely replays a start for an attempt already watched.ScenarioFinished.Attemptsgets the same floor. Before this, a supervised retry overwrote its own previous attempt and CTRF'sretryAttempts[]worked for in-process retries only. - Lane topology, recycles and worker faults are on the wire (built 2026-08-21, the rest of #84):
LaneStarted(lane + the uids it was handed),LaneFinished(outcomes reported,Crashed),ResourceRecycled, andWorkerFaulted(lane or null for a one-test process, the report's sentence, exit code and last standard error as separate fields), each stamped with the supervisor's clock.SupervisorRunPublisherposts them from the observer callbacks;ISupervisorObservergained a structuredWorkerFaulted(WorkerFault)whose default forwards to the originalWorkerFaulted(string), so an observer written against either keeps working. A lane starts again for a same-process retry (back to the lane the test ran in, carrying only the retried uids) — the store counts that as a second pass of the same lane; isolated and recycled retries are one-test processes and never announce a lane, so a foreign-framework worker's lane events are the only live signal it has. Folded in the Pinia runs-store aslanes(lane order, with "running now" = the lane's uids joined to live scenario state),recyclesandfaultson the run; rendered byLaneStripon the card andSupervisorTopologyon the detail. Replay-safe by the supervisor's timestamps: a lane start no newer than the pass we are on, a finish older than that pass, or a recycle/fault already seen is the archive being re-announced over live state, not a new fact. - Folded server-side too (built 2026-08-21, the last piece of #84):
RunProjectioncarriesLanes/Recycles/WorkerFaults(+RunningIn(lane), the lane's uids joined to live scenario state) under exactly the store's rules —SupervisorTopologyProjectionTestsis a case-for-case port ofruns-store-topology.test.ts, so the two folds cannot drift silently, and it includes the same replay-over-live-state no-op. Read by MCPrun_status(lanes,recycles,workerFaults, always present — empty arrays for an in-process run, so an agent never has to guess whether the field is missing), byGET /api/runs/{id}(RunDetail.Lanes/Recycles/WorkerFaults, additive init properties), and by the CTRF export in the results-levelextra(lanes,recycles,workerFaults, omitted for an in-process run so that export is byte-identical to before; CTRF has no vocabulary for worker processes and the schema would reject an invented top-level field). One consequence for the scenario fold: a supervised retry's first attempt reported its own terminal outcome, so a genuinely new attempt'sScenarioStartednow clearsOutcome— the retried scenario reads as running again, which is what a lane's "running now" andrun_statusneed. Only a new attempt clears it (attempt numbers are a floor), so a replayed start never un-finishes one. A crashed lane's scenario that never reported an outcome keeps reading as running — the fold infers nothing for it; the supervisor'sRunFinishedis what counts it Indeterminate. - The observability cluster is on the wire too (built 2026-08-24, issues #145/#146/#148/#149): three additive events posted by
SupervisorRunPublisherfrom the cluster's observer callbacks.worker_started(purpose, lane or null, pid) — a discovery worker is deliberately never announced, because it launches before the run bracket opens andrun_startedstays the stream's first event; its pid folds onto the lane, so lane→pid correlation needs no/procguessing.test_stalled(uid, display name, in-flight ms, lane, pid) — once per attempt, the name a capped CI job's log cannot produce.run_progress(elapsed, done/total, in-flight count, the longest-running test, and peak worker RSS when memory sampling is on — null otherwise, unmeasured is never zero) — posted only when the supervisor's opt-inHeartbeatIntervalis set, distinct fromrun_heartbeatwhich stays a bare liveness ping; for a foreign-framework worker this is the run's only live progress. Folded on both sides under the same rules (stallsreplay-guarded by uid+timestamp;progresslatest-wins, ordered by the supervisor's elapsed clock so a replayed older heartbeat never rolls it back; a replacement worker's own start moves the lane's pid) — mirrored case-for-case betweenSupervisorTopologyProjectionTestsandruns-store-topology.test.tslike the rest of the topology. Read back byGET /api/runs/{id}(RunDetail.Stalls/Progress,LaneResult.ProcessId, all additive) and MCPrun_status(stallsalways present,progressnullable,processIdper lane); rendered bySupervisorTopology(progress line, pid column, stalled list). MtpWorkerClient.handleNotificationreceives live per-testtesting/testUpdates/testsupdates; since #99 it relays them (see item 5) instead of reading only the outcome. A supervised run already gets step-level visibility because each worker IS an MTP host runningBobcatRunner, and its own publisher streams steps directly to the monitor.
- Retry topology is on the wire: a supervised retry now posts
Step-level progress for a scenario in flight (built 2026-08-21, issue #99). Four additive pieces, engine to viewer:
- Step n of N with elapsed.
IExecutionObserver.ScenarioStarted(feature, scenario, totalSteps)is a new default member the runner calls (the plan is built before the scenario is announced, so the count is a fact); the two-argument form is what it forwards to, so existing observers are untouched. On the wireScenarioStarted.TotalSteps,StepStarted.StepNumber/TotalSteps/ScenarioElapsedMs,StepFinished.ScenarioElapsedMs— all optional trailing members, null from an older publisher. "Expected" per step is deliberately not here: it needs the cross-run duration ledger (#44 layer 2 / #56 layer 3). - Row progress for
[TableGrammar]. The generated envelope callsctx.ReportProgress(StepUpdate.ForRow(k, M))before each row;StepUpdategainedRow/TotalRows. Row ticks carry no message on purpose, so the Spectre console (which prints every message) stays quiet while renderers with a live counter move. - One wire event,
step_progress, for both row ticks and the[WaitFor]poll loop's interim message (#32/#34'sStepProgressfinally has a wire form):StepId,Message,Row,TotalRows,ElapsedMssince the step started. Coalesced by the publisher —MonitorPublishingObserverposts at most one per 100 ms per step, always the first update and always the last row — because 200 rows in a few milliseconds would otherwise be 200 events into a channel that drops on backpressure, crowding out theStepFinishedthat matters more. Consumers upsert per step; only the latest matters, and a finished step ignores late (hydration-replayed) progress. - The tap.
IWorkerClient.OnTestUpdate(handler)(default no-op) andISupervisorObserver.TestUpdated(WorkerLaunchContext, WorkerTestUpdate)— every node change a worker streams, in-progress included, stamped with the lane and purpose it came from. Discovery is not tapped ("discovered" is not progress). Supervisor-side only for now; see Not built yet for why it has no wire event. - Viewer:
ScenarioProgress.vueon the run detail — step n/N bar, current step text, row k/M bar, waiting-for message with elapsed. Store fieldsScenarioState.totalSteps,StepState.stepNumber/scenarioElapsedMs/progress.
- Step n of N with elapsed.
Run evidence: touched types on
scenario_finished(built 2026-08-24, issue #107). Slice↔spec binding is by identity plus run evidence, never hand-typed — the runtime half of the #106 descriptor pairing.ScenarioFinishedgained two optional trailing members:TouchedTypes(a list ofTouchedType(Name, FullName, AssemblyName)— deliberately JasperFxTypeDescriptor's three fields, mirrored not referenced, because the contract files stay dependency-free copies;FullNameis the join key against a design-timeSpecificationDescriptor.ResolvedTypes,Uidthe identity both sides key on) andAt(the finish stamp a consumer ages evidence by). Evidence is observed, never asserted:IStepContext.RecordTouchedType(Type)(default no-op) accumulates ontoExecutionResults.TouchedTypesin first-touch order, deduplicated, andBobcat.CritterStack's typed steps record at the point a type actually crossed the scenario — the aggregate arranged, the command dispatched (a validation rejection still received it), the events the stream actually gained, the messages the tracked session actually sent, the read model actually loaded — never what aThenmerely names, so a sad path records its rejected command and no event type. Nothing recorded travels as null, not an empty list (absence of evidence, not evidence of nothing), and the folds assign rather than append so hydration replay cannot double the ledger. Read back per scenario byGET /api/runs/{id}(ScenarioResult.TouchedTypes/FinishedAt, additive) and folded into the Pinia store (ScenarioState.touchedTypes/finishedAt); CTRF/JUnit exports are untouched — they project explicit shapes and CTRF's schema has no vocabulary for this.Foreign per-test progress:
test_started/test_finished(built 2026-09-01, issue #195). A supervised run of a non-Bobcat suite registered on the dashboard with the right total and then never moved —scenariosFinishedstayed 0 for the whole run, observed live at 0/1627 for five minutes, which is nearly indistinguishable from a wedged run. The bracket was right; the gap was that per-scenario events come from each worker's ownMonitorPublishingObserver, and a plain xUnit worker has none. The supervisor already had the facts (ISupervisorObserver.TestUpdated, item 5's tap) and simply was not forwarding them, so this is forwarding, not new machinery.- A separate pair, not
scenario_started/scenario_finished. Those carry spec identity —{Feature}/{Scenario}, the string a design-timeSpecificationDescriptorjoins on — and feeding them an xUnit method uid would widen that meaning for every consumer of the join.TestStarted/TestFinishedcarry the worker's test id and say so; for a Bobcat worker the two strings coincide anyway. Nothing about spec semantics is implied: this is a progress bar, not #110's projection of foreign specs into the Bobcat model. - The worker's own stream always wins. The supervisor forwards for every worker, Bobcat ones included, because it cannot know which of them publishes without a marker only new workers would carry.
ScenarioProjection.WorkerPublished(and the store'sScenarioState.workerPublished) is set by anyscenario_*/step_startedfor a uid, and both new handlers stand down for it. The guard is a property of the scenario, so it holds in either arrival order — a forwarded verdict that lands first is overwritten by the worker's own, one that lands second is ignored — and one test is one card either way. Two extra events per test against a batching, backpressure-dropping publisher was the cheaper side of that trade. - The framework's word travels verbatim.
Stateis Passed / Failed / Error / Skipped / Timeout / Cancelled, never re-labelled by the publisher: two enums meaning the same thing is how a vocabulary drifts.ForeignTestOutcome.From(and its Pinia mirror) does the mapping in one documented place per side — Skipped counts as a clean pass because the supervisor's ownWorkerOutcome.Succeededdoes, so the progress bar and the terminalrun_finishedcounts cannot disagree about the same test; an unrecognised state is a failure rather than a drop, since not counting a finished test stalls the whole bar. The raw word survives onScenarioResult.Statefor anything that wants the distinction. - Indeterminate never reaches the wire. Silence is not a verdict, and a padded outcome is not a live one — a test a crashed worker never answered for publishes no
test_finishedat all, so a crashed run cannot read as a complete one. DurationMsis measured between the two updates on the supervisor's own clock, and is null when it never saw the start — unmeasured is never zero.Laneis null for a one-test isolated or recycled process, the same rule asworker_faulted. Discovery is never tapped.- Free consequence: a supervised xUnit run now has per-test rows in
GET /api/runs/{id}and therefore a CTRF/JUnit eject, without a Bobcat reference anywhere in the suite.
- A separate pair, not
Event Model page + /api/event-model (issue #108, built 2026-08-24)
The design-time Event Modeling viewer with spec drill-down — free, MIT, in this repo by the 2026-08-20 decision of record; CritterWatch is the production, paid surface. Both render the same JasperFx EventModelDescriptor through one shared component, which is what makes "the same descriptor renders identically in both viewers" true by construction rather than by convention:
@jasperfx/event-model-vue(src/Bobcat.EventModel.FrontEnd/, landed with #143) renders a descriptor with a pure synchronous layout — position is a function of the descriptor alone, pinned on exact coordinates by its own Vitest gate (event-model-frontend.yml). #108's page work added theslice-clickemit (the slice header is the drill-down handle; the slice overlay itself stays pointer-inert so cards keep their clicks) and dropped the vestigial@vue-flow/corepeer dependency — nothing in the package ever imported it, and npm 7+ would have installed it into every consumer.- The SPA consumes the package as a
file:dependency, and the package'sdist/is gitignored — so bothconsole-frontend.ymland the csprojBuildFrontend(EmbedFrontend) target build the package before the SPA, and the workflow's path filter includes the package so a package change re-gates the SPA. PUT /api/event-model/GET /api/event-modelis a public wire contract likeGET /api/runs, persisted beside the run archives (EventModelStore). A push names its SOURCE and replaces only that source's contribution;GETserves the merge (issue #268, CritterWatch#1212) —PUT /api/event-model/{source}for one producer, the barePUTfor the sourcedefault, which is what keeps an existing console working across the upgrade. One model has two producers compiled into different assemblies: Wolverine'sevent-modelexport runs against the host and carries slices with noSpecifications, while a spec assembly's generatedIEventModelDefinitionSource(#106) carries the spec identities run evidence joins on and is invisible to the host. Latest-wins erased one of them every time, which is why every slice on a real console read "no specification bound". The store round-trips every document through the typed descriptor, so a bad push 400s at the push (not as a blank canvas later), the stored copy is normalized to the shape the renderer's TS mirror types (camelCase members, PascalCase enum values — enum reads are case-insensitive so camelCase producers normalize), and the computedelements/edgesare always present however sparse the pushed roles were.- The spec half's producer is the runner (issue #294,
SpecEventModelPublisher). When a run attaches to a console, it PUTs its spec assemblies' descriptors under a source named for the assembly (dots and anything else a file name will not take become-, because the source becomesevent-model.{source}.jsonand the store refuses the rest). Under the same invariant as the event pump — probe first, bounded, never retried, never surfaced to the run. - Both halves must name the same model, and the runner checks.
GETmerges only the sources carrying the current name, so a spec half namingBankAccountES.Testsat a console servingBankAccountESwould hide the other half rather than join it. On a disagreement the runner publishes nothing and prints the one-line fix:[assembly: EventModelName("…")](#172). The host half stays a separate step —event-model --url, wrapped bybobcat watch-event-model— because a runner cannot export the host's chains without referencing Wolverine. - Consequence pinned in the csproj:
Bobcat.ConsolereferencesJasperFx.Eventsdirectly, because at 2.54.0 the descriptor lives there (it moves to JasperFx only in 2.55.0, jasperfx#693) and CPM pins only direct references — without it the transitive JasperFx.Events resolves to the pre-#687 sketch, which compiles and then silently dropspattern/specifications/elementson the round trip. TheDispositionKindtrap again.
- The spec half's producer is the runner (issue #294,
- The page (
/event-model,EventModelPage.vue): renders the descriptor, colours slices from run evidence —outcomesForfolds a selected run's scenarios onto the descriptor's spec identities (verdict → passed/failed; declared-but-unreached is stated asnotRun, never omitted, because that is the drift colour), newest run by default with a picker. Clicking a slice header (or any card — ownership is an element-id lookup) opens the drawer: each bound spec with its verdict tag, the scenario's step results, and its touched types (#107), withundeclaredTouchesflagging evidence the model does not declare — the "spec touching undeclared types" yellow. - Card sizing is decided in the package, not here (issue #180, 0.5.0). Cards were absolutely sized at 180px with
overflow: hidden, so a long command name — and worse, a route trigger label — was cut off mid-glyph. The order is now wrap (<wbr>at camel humps and after/ . _ - :), then widen the column to fit its own labels in two lines up to amaxCardWidthcap, then clamp to three lines with the full text on the tooltip. Widths are estimated from the label text, never measured, because layout must stay a pure function of the descriptor. One rule worth knowing: aHotspotlabel is excluded from the width vote — the producer makes the hotspot's text the label (jasperfx#704), and letting a sentence size a column of type names widened every column on the real Stoat model to fit the finding rather than the model. Full reasoning in the package README. - The edges are drawn, and routed in the package (issue #181, 0.6.0).
Edgesis computed upstream from the typed roles on every read so no renderer invents its own opinion about what connects to what — and the canvas laid them out and then drew nothing. Now one pointer-inert SVG layer behind the cards: straight along a lane, an orthogonal elbow through the middle of the lane gap across lanes. The polyline is computed inlayout.tsbeside the coordinates, because a route is as much a rendering claim as a position and "identical in both viewers" has to cover it. - The 2026-08-31 review batch, all in the shared package (0.7.0): zoom/pan for a canvas that is 106 slices wide (#182 — a CSS transform on a wrapper, never a scale factor threaded into the pure layout), a bound-specification badge per slice carrying the run verdict where evidence exists (#183), a trigger-kind glyph with the route on its tooltip plus a verb badge on route cards (#184), and a source disagreement that renders as a structured finding — role, kept claim, struck-through dropped claim — rather than as the clipped sentence that got read as a malformed events list (#178).
- Navigation, not magnification (issue #296, 0.9.0). Zoom stops and a filter bar both landed, and the measured 106-slice model was still ~10,000px wide at the 25% floor. Four features, all in the shared package on the same transform wrapper, none of them touching
layoutEventModel: focus fits a slice's neighbourhood — the slice plus every slice one cross-slicelinkaway — and dims the rest, with aFleet › Reporting › Slice079breadcrumb whose crumbs step out and an Esc that restores the zoom and scroll the reader had; level of detail is adata-lodattribute set from the scale that CSS switches on (detail≥ 0.7,compact0.4–0.7,overviewbelow), so 700 cards do not re-render when someone nudges the wheel and the two consoles cannot disagree about what "less" means; a minimap of the same graph as bare rects; and continuous cursor-anchored wheel zoom, with the nine stops kept as the button ladder.linksis computed upstream (jasperfx#823) and absent from every descriptor this repo's pinned JasperFx 2.67.1 can produce, so the degraded path — neighbourhood = the slice alone — is the one that runs today, and it is the one the specs exercise. Nothing is derived client-side.- The page owns where a viewport is kept and mirrors
viewport-changeinto the route query (z/x/y/focus/sel) withreplace, so a link to part of a big model pastes into a PR and Back does not walk every notch of a zoom. The encoding stays in the package (viewportToQuery/viewportFromQuery) so a Bobcat link and a CritterWatch link agree. - Three things only the real 106-slice canvas found, all fixed: a uniformly-scaled minimap of a 100:1 canvas measured 222 × 3.6px, so its axes scale independently and it is not drawn at all below ~2,500px of canvas; the viewport has no height of its own, so
min(vw/w, vh/h)reduced to the zoom the reader already had and focus "fitted" 46% to 48%; and the toolbar Focus button is unreachable once a selection opens the modal drill-down drawer over it, which is why each slice header carries its own ⌖.
- Cause and effect is drawn (issue #295, 0.11.0).
EventModelDescriptor.linkshas been computed upstream since JasperFx.Events 2.69 and the canvas drew nothing with it. Links now route through the lane gaps: out of the source, along a track inside the band, into the target — so a link never crosses a card. Tracks are first-fit over x-intervals per band, and every link leaving one element shares one trunk, so four consumers of an event are four branches off one line.- The chevron is the half that scales. An element at the far end of a link carries
◂ OpenAccountin its corner and clicking it jumps to the origin. Event Modeling repeats a sticky where it is consumed rather than connecting back, the descriptor already repeats, and a slice name stays readable at a zoom where a 3,000px arrow does not. - Faint at rest, lit by selection, with a toolbar ⇢ all / selected / none whose
noneis the canvas exactly as it was before. One glyph per kind — solid, dotted, dashed — and no labels. - ⚠️ Two honest limits, both stated in the package README: a link between non-adjacent lanes has one vertical leg that may pass the rows between them, and hover-driven highlighting plus the off-screen
⇢ Nbadge are not in this release.
- The chevron is the half that scales. An element at the far end of a link carries
- A stream is a row (issue #299, 0.10.0). Two slices that write
Accountnow put their events on the same horizontal line inside the Event Stream lane, and that they share a stream is visible with no arrow at all — which is the point, and is decision 2 of the canvas design: a shared aggregate is not a cause→effect link, and fanning every event of an aggregate out to every slice on it is noise rather than a statement. The lane becomes one row per aggregate in the model'saggregatesorder, captioned in the gutter under the lane's own caption, with alternate rows tinted so the bands still separate atoverview, where the captions are too small to draw.- Three rules, and the first is why no existing canvas moved. Fewer than two aggregates in view means one flat row — a row is a comparison, and with one stream there is nothing to compare. Rows are computed over the slices actually drawn, so filtering a 106-slice model down to one aggregate collapses the lane back rather than leaving empty rows behind. An event sits on the aggregate whose
appliedEventsnames it, falling back to its slice's first aggregate, because a producer that cannot resolve an apply set statically emits none. - A published message is on no stream, and neither is an event of a slice that writes no aggregate. They share one trailing unlabelled row rather than getting a row each: the design left the messages row "above/below", and two rows both captioned by their absence say less than one row that means "in this lane, on no stream".
- It costs nothing measurable — a 106-slice model across four streams lays out in 0.60ms against 0.56ms flat, both sub-millisecond and in one synchronous pass, and
LayoutOptions.streamRows: falsekeeps the old lane exactly. The layout mirrorsAggregateDescriptorproperly to do it:aggregateshad been typed in this package as the slices' Aggregate cards, which is not what the model document carries, and nothing had ever read the member to notice.
- Three rules, and the first is why no existing canvas moved. Fewer than two aggregates in view means one flat row — a row is a comparison, and with one stream there is nothing to compare. Rows are computed over the slices actually drawn, so filtering a 106-slice model down to one aggregate collapses the lane back rather than leaving empty rows behind. An event sits on the aggregate whose
- Proven end to end:
EventModel.featureinBobcat.Console.Specsdrives the wire (404-before-publish, normalized read-back, slice↔spec binding);EventModelStoreTestspins the normalization; the page and store folds are Vitest-covered; and the flow was verified in the running app — descriptor PUT, run ingested with touched types, canvas coloured, drawer drilled.
Ejecting results: CTRF primary, JUnit XML fallback
Researched 2026-07-31 (GitHubActionsTestLogger, MTP-native reports, CTRF, JUnit):
- CTRF (ctrf.io) is the primary export. It is the only CI format with first-class
retries,retryAttempts[],flaky, andsteps[]— Bobcat's attempt history,PassedOnRetryledger, and Gherkin steps map onto schema-blessed fields. Richer data (Disposition reasons, recovery hints, worker/lane ids) rides the spec'sextraobject, allowed at every level. Microsoft standardized on CTRF + JUnit + TRX for MTP 2.3's first-party report extensions, and xunit.v3 ships--report-ctrfworking today on the MTP 1.9.1 pin (verified against the builtBobcat.Testshost), so the format is aligned with where the platform is going and usable now.ctrf-io/github-test-reportercovers GitHub PR reporting over it. - JUnit XML is the lossy compatibility floor: native ingestion in GitLab, Jenkins, Azure DevOps, CircleCI. Accept the lossiness.
- Not: TRX (only AzDO wants it, and AzDO eats JUnit); a bespoke JSON (CTRF
extraremoves the justification); GitHubActionsTestLogger as a base (VSTest logger at the wrong altitude; its MTP mode needs MTP 2.x +xunit.v3.mtp-v2, excluded by the 1.9.1 pin). - Persist each run's raw ingested event stream as NDJSON — export and replay-for-debugging both fall out of it. "Eject" in the UI = export (CTRF/JUnit/NDJSON) + remove from dashboard.
MCP (built 2026-07-31)
src/Bobcat.Console/Mcp/MonitorTools.cs, mounted at /api/mcp — streamable HTTP, stateless, via ModelContextProtocol.AspNetCore in the CritterWatch *.Mcp shape (static [McpServerTool] methods returning camelCase JSON). Six tools: list_runs, run_status (live steps for the executing scenario only), failing_tests, flaky_ledger (spans all known runs — the box's chronic-flakiness view), export_run (CTRF/JUnit as a tool result), and await_run_completion — the agent killer feature: block until the suite settles instead of polling, returning finished/orphaned/timeout with the final summary. All tool reads go through the registry's locked Read/ReadAll so live ingestion can never hand a tool a torn scenario collection (exports were moved onto the same locked reads).
Live-verified: with several publishers active on the box, a no-runId await honestly latched the one in-flight run — which happened to be another session's supervisor worker. Agents on a busy box should pass the runId from list_runs.
Testing
Vitest is the whole UI test story: store/dispatcher logic tested by feeding recorded event sequences at the Pinia stores (src/stores/__tests__, src/messages/__tests__), happy-dom for component mounts, CI gate in .github/workflows/console-frontend.yml (path-filtered: node 22, npm ci → vue-tsc -b → vitest run).
End-to-end is src/Bobcat.Console.Specs/ (issue #86, built 2026-08-21): the viewer's own Program booted in-process over Alba's TestServer by a MonitorHost resource, and Bobcat's Gherkin runner driving it as an MTP host that dotnet test collects. Four features — Live Runs, Retries, Ejection, Exports — ingest events over POST /api/ingest exactly as a publisher does and assert against GET /api/runs, GET /api/runs/{id} (added for this; same wire-contract status as the list), and the export endpoints. That replaces a Playwright layer, deliberately: the whole wire is verifiable without a browser, and the SignalR leg is pinned separately by relayToStore.test.ts. Archives go to a temp Monitor:DataPath per run, never ~/.bobcat; MonitorHost.Restart() is how the hydration rules are exercised. The spec host publishes its own progress like any other (a dotnet bobcat on 5525 sees the suite run while it tests a second, in-memory viewer — no loop is possible, the instance under test has no address); CI sets BOBCAT_MONITOR=0. What the framework was missing to write it is recorded on issue #62.
Retention
Two knobs that bound two different things, and the split is the design. The board is not the archive: a run evicted from the dashboard still has its NDJSON on disk, and only the age policy ever deletes a file. That is what makes an automatic eviction reasonable to ship at all, and it is the same promise the manual Eject button has always made.
Archive age (built 2026-07-31)
The archive directory ages instead of growing forever. The NDJSON file's mtime is the aging clock — every ingested event (heartbeats included) appends, so a file untouched for the whole retention period has had a dead publisher exactly that long. A stale live archive is ejected exactly like a manual eject (off the dashboard, into ejected/); a stale ejected archive is deleted. Nothing is ever deleted straight out of the live folder, and a manual eject keeps its data for the rest of the retention window. One knob: Monitor:RetentionDays config → BOBCAT_MONITOR_RETENTION_DAYS env var → 14 days; zero or negative disables aging entirely. Swept at boot (before rehydration, so a long-dead archive is never loaded just to be swept) and hourly by ArchiveRetentionService.
Board size (built 2026-09-01, issue #198)
Nothing evicted runs by count, so a board grew until someone cleared it by hand: 46 runs across four repositories and worktrees, several days old, accumulated purely by using the tool — a live dashboard retaining like an archive. MonitorRunRegistry.SweepRetainedRuns keeps the most recent N and ejects the rest. One knob: Monitor:RetentionRuns config → BOBCAT_MONITOR_RETENTION_RUNS env var → 10; zero or negative disables it. Swept inline when a run_finished lands (the only moment the finished pool can grow), at boot after rehydration (so a restart does not restore what the policy already evicted), and on the hourly service tick for the case with no such moment — an orphan that only became evictable because a restart declared it one.
The three questions the issue asked to settle before coding, settled:
- N is per job — repository plus suite — not per box. A shared console is genuinely multi-repo, and a global cap lets the busiest repository evict every card the quiet ones had, which is the opposite of what someone watching their own gate wants. Per job is also the familiar shape ("the last N builds of this job") and the identity the card is already named by. The same suite in two worktrees is two jobs, deliberately: that is how a comparison between them stays possible.
- A live run is never evicted, and never counts against the cap. Gate runs here are 20–50 minutes and must not vanish because the suite ran ten more times. An orphan is evictable precisely because its publisher is gone. Being at capacity is a statement about history, not about how many suites may run at once.
- Eviction is ejection. Same code path as
Remove, archive intoejected/, subject to the age policy from there. An automatic policy that deleted archives would be a materially different and much riskier feature.
Nothing joining on run ids across time is disturbed: CritterWatch consumes GET /api/runs?tag= for spec evidence promptly, and the tag query is correlation, not history.
Bulk eject (built 2026-09-01, issue #197)
Ejecting was one run at a time, so the only way back to a readable board was one click per card. DELETE /api/runs takes the whole set, narrowed by ?olderThan=<instant> (strictly before, so the run you anchored on survives its own "eject all older") and ?exceptRunId=, which compose. It returns {count, runIds} — the ids, not just the count, so the UI drops exactly what the server agreed to take rather than what it predicted.
The verbs are the browser tab menu's, because that is the mental model people already have for this exact problem: Eject all / Eject all older / Eject all but this. "Older" rather than "to the right" because this board is time-ordered in a way tab position is not — which is also why #196 had to land first: a bulk control whose cards show no age is a button whose effect the user cannot predict.
Two rules worth stating out loud, both in the UI text as well as here:
- Eject is not delete. The confirm names the count and says the archives are kept on disk. A control that reads as "delete 43 test runs" does not get used; the same control labelled as clearing a board does. It is never the default-focused button (
autofocus: false). - A live run is never taken, whatever the filter matched — not out of caution but because it does not work: the publisher's next event recreates the entry, so ejecting a live run buys a card that reappears and a count that lied. The confirm says how many are staying.
Run card timestamps (built 2026-09-01, issue #196)
The card rendered suite, repository, branch, mode, counts and runId — and no time at all, on a board where age is the single most useful thing a card carries. Purely a display gap: startedAt and finishedAt were already on RunSummary and on the wire.
- Relative by default (
4m ago,2d ago), absolute in thetitle. Relative is what answers "is this mine, from just now?" at a glance. - Anchored on the finish for a finished run and the start for a live one, and the label says which — a card reading "6m ago" means two different things before and after it finishes, and the reader cannot tell them apart. A live one reads "started 6m ago".
- Duration for finished runs, from the same two stamps. Null while running, because a duration derived from one stamp plus the current clock is the run's age, not its length.
- A stamp from the near future reads "just now": that is clock skew between a publisher and the browser, not a scheduled run, and a negative age is not the honest rendering of it.
- The board is now sorted newest-first (
runsNewestFirst), anchored onstartedAtso a card does not jump when its run finishes. Insertion order was no order at all once several repositories' runs shared one board.
Hydration (built 2026-07-31)
Both directions are archive replays — one fold, two transports, nothing to keep in sync:
- Monitor restart: the registry replays every non-ejected NDJSON archive back into projections on boot. A rehydrated run with no terminal
RunFinishedis orphaned (its publisher is gone; rendering it "running" forever would lie) — any later event un-orphans it. Eject moves the archive toejected/(never deletes) precisely so an eject survives restart. Torn tail lines (monitor killed mid-write) are skipped, not fatal. - Browser load/reconnect:
useSignalRcallshydrateFromServer()after connect and on every reconnect —GET /api/runs, then each run's NDJSON export replayed throughrelayToStore, i.e. the store's own live-event fold. Store handlers upsert (stepId guard) so replay over already-arrived live events cannot duplicate; local runs the server no longer lists are pruned.
Observed live: a supervisor test suite running in another checkout streamed dozens of one-scenario SampleWorker runs, each its own dashboard card — each worker minted its own RunId. Fixed 2026-07-31 by supervised-run grouping (see the Bobcat-side seams section): verified live, the same SampleWorker suite across 4 worker processes is now exactly one card — one run_started (supervised, total 7), all worker scenario/step streams, one run_finished. Note the grouping only applies when the run is driven through a Supervisor with PublishToMonitor on — workers launched by a supervisor that doesn't publish keep the old one-card-per-worker behavior on purpose (a grouped run with no bracket owner would render as an unnamed orphan).
CTRF retryAttempts (built 2026-07-31)
RunProjection keeps every retried-away attempt's step history (ScenarioProjection. PriorAttempts, snapshotted when RetryScheduled arrives — with the policy's disposition and reason — or on the next attempt's start as fallback). The CTRF export renders the FULL attempt list including the final attempt, matching the spec's own with-retries example; attempt objects admit no extra members, so step detail and disposition/reason ride each attempt's extra. Exports are validated against the official ctrf-io/ctrf schema — which also caught that suite must be an ARRAY (hierarchy), fixed at the same time. Null-valued fields are omitted (CTRF's typed fields don't admit null).
The console's own port and lifetime (issue #200, built 2026-09-07)
A bobcat run was found alive 20h52m after the Claude session that started it had ended — its cwd a since-abandoned scratchpad for a different repository — wedged on 127.0.0.1:5000. The next repository's gate then failed 15 tests across two suites with AddressInUseException, and the red read as a product regression until lsof -iTCP:5000 named the squatter. Two independent defects, both fixed here.
The port was never applied server-side. 5525 is the console's address everywhere a client looks — MonitorPublisher.DefaultUrl, EventModelWatchPlan.DefaultConsoleUrl, the Vite dev proxy, this document — but the only server-side declaration was launchSettings.json, a dotnet run file the packaged tool never sees. So bobcat run fell to Kestrel's bare :5000: unreachable by every publisher (they all probe 5525, so the console silently saw nothing) and squatting on the port every other ASP.NET default host on the box wants. Program.cs now applies EventModelWatchPlan.DefaultConsoleUrl when nothing else configured a URL. A default, so ASPNETCORE_URLS still wins — and it has to be one, because the command line cannot reach this: RunJasperFxCommands wraps an already-built WebApplication in a PreBuiltHostBuilder, and NetCoreInput.ApplyHostBuilderInput returns early for one, which makes --config:urls=… silently inert. The two constants are pinned to each other by ConsoleUrlAgreementTests, on opposite sides of the layering rule; a publisher probing an address the server does not bind is precisely how an orphan goes unnoticed for a day.
Nothing in the process could ever have ended it. JasperFx's run blocks on an untimed ManualResetEventSlim whose only realistic release is a Console.CancelKeyPress that a process detached from a dead terminal never receives. IdleShutdownService gives it one: after a stretch with nothing connected and nothing publishing, it logs loudly and calls StopApplication().
- Idleness rather than parent death. A parent-death watchdog is the obvious answer and is not portable — .NET has no cross-platform "who is my parent" — and watching for stdin to close kills a legitimately backgrounded console the moment it starts. Idleness is a statement about the process's purpose: a viewer with no browser attached and no run publishing to it is doing nothing for anybody, and is only holding its port against whoever wants it next.
- In flight is half of "idle".
ConsoleActivityis fed by one middleware at the top of the pipeline and counts requests in flight as well as the last one seen. A browser with the dashboard open holds a SignalR connection, which is one request that never completes — so a watched console never even starts the window, however long it sits between runs. That is the case an idle ceiling must not break, and it cannot. - On by default, unlike the retry and stall knobs. Those are opt-in because they preserve a behaviour someone may be relying on; this one preserves nothing. Two hours, reset by any request at all:
Monitor:IdleMinutes→BOBCAT_MONITOR_IDLE_MINUTES→ 2h, zero or negative runs until stopped — the same order as the retention knobs.
And the diagnosis is in the failure message now. PortHolder (in core Bobcat.Runtime, not here) turns a resource's bind collision into the name of the process holding the port, appended to the SpecCatastrophicException TestSuite.StartAll already raises: "Port 5000 is held by pid 12345 (bobcat) — that process, not this suite, is what has to go." It matches Kestrel's AddressInUseException by type name, because core must not reference ASP.NET, and the wrapped SocketException properly; both halves are required, so a message that merely mentions a port is not mistaken for a collision. Report, never act: naming the holder is the whole feature, because killing somebody else's process for being in our way is not a decision a test harness gets to make, and the holder is as likely to be a development server somebody is using as an orphan.
A repeated step is its own row (issue #322, built 2026-09-16)
StepId is a step template id — the step method's name — and was being used as a within-scenario identity on both sides of the wire. It is not one, and the two step shapes the grammar most recently encouraged both repeat: Given {event} occurred once per arranged event (#259) and And no events for {aggregate} "…" once per re-pointed stream (#311, #320). So the viewer under-reported exactly the arrangements the grammar recommends.
Two failures, one cause:
- The projection left later occurrences running.
StepFinishedresolved its step withFirstOrDefault(s => s.StepId == e.StepId), so the first occurrence absorbed every finish. On a real 37-scenario suite: 33 of 182 steps stuck atrunning, across 21 scenarios, every one of them aCleanPass. Nothing contradicted anything a reader was looking at, because a scenario's own outcome is computed elsewhere — which is why it went unnoticed. - The store collapsed them.
handleStepStartedupserted bystepId, so repeats became one row carrying the last occurrence's text and the first one's duration, welded together with nothing saying so.
Resolved without a protocol change, because the wire already carried enough:
- Starts key on
stepNumber, which the publisher increments per scenario. That is occurrence-correct and stable across a replay — and hydration idempotency was the reason the old code keyed onstepIdat all, so it had to survive. - Finishes and progress pair with the first occurrence still running.
StepFinishedcarries nostepNumber, but steps are appended in order and finish in order, so "first still running" is exact. Both the projection and the store use that same rule, and both keep a fallback so a late or replayed event lands somewhere rather than vanishing.
The alternative was adding an ordinal to StepFinished and StepProgress. Not needed, and a wire change is a compatibility question where this is not.
Step results are the natural place for cells — label / expected / actual / comparison / verdict — so a table step's failure renders as a marked-up table instead of the sentence it is flattened into today. That is issue #324 and shares this record; it was left out of this change deliberately rather than bundled in.
Not built yet
- Gherkin-runner dogfood e2e against this UI (#86).
- Elapsed-vs-expected per step. Step progress (#99, Bobcat-side seams item 5) carries elapsed; "expected" needs a duration history across runs, which is the same committed ledger #44 layer 2 and #56 layer 3 want — one store, not three.
Supervisor-side test updates on the wire.Built 2026-09-01 — see Bobcat-side seams item 7. The condition this bullet named ("it earns a wire event when a non-Bobcat worker is driven under the viewer") arrived exactly as written.- Telling a connected browser that a run was ejected. Neither the manual eject, the bulk eject (#197) nor the retention sweep (#198) puts anything on the SignalR stream, so another open dashboard keeps its stale cards until it next hydrates. The acting browser drops its own cards, and
hydrateFromServer'spruneToreconciles on load and reconnect, so nothing is wrong — just late. Arun_ejectedrelay message is the fix when someone is bothered by it.