Small, collapsible disclosure panel above StatsBar on the Cells tab with
plain-English definitions of the D0–D6 chip layers. Default collapsed;
open/closed state persists across navigations via
localStorage["chip-explainer-open"]. Footer link points to the Notion
plain-English page for deeper detail.
The Cells tab is consulted dozens of times a day by operators who already
know what the chips mean; the explainer is for first-time viewers and
rare refreshers, so the trigger is a small text button and the default
is collapsed to keep the layout clean.
The chat regression on crewai-crews surfaced as AG-UI clients receiving
RUN_STARTED and then the SSE stream aborting (RUN_ERROR: INCOMPLETE_STREAM,
no TEXT_MESSAGE_* / RUN_FINISHED ever emitted). Production logs from the
Railway crewai-crews service confirmed the abort was triggered by:
RuntimeError: Unexpected message received: http.request
raised from `starlette/middleware/base.py:55` mid-stream.
Root cause: ForwardedPropsMiddleware (introduced as part of the agent-config
demo plumbing) was a BaseHTTPMiddleware subclass that called
`await request.body()` to inspect / mutate the JSON body, then reinstalled
`request._receive` with a one-shot replay. Stacked under HealthMiddleware
(also a BaseHTTPMiddleware), that body-replay raced with Starlette's inner
anyio TaskGroup `wrapped_receive`. The middleware fired its bailout check
(`_has_agent_config_props`) only AFTER `request.body()` had already consumed
the receive queue — so the bug fired on every POST, regardless of whether
forwardedProps was present.
Fix: drop ForwardedPropsMiddleware and replace it with a raw ASGI middleware
(`ForwardedPropsASGIMiddleware`). The raw form owns the receive stream from
the outset and replays buffered bytes via a single fresh receive — no
`_receive` reinstallation, no BaseHTTPMiddleware machinery to race against.
The forwardedProps -> state.inputs splice logic is unchanged (extracted into
a pure dict-in / dict-out `_splice_forwarded_props` helper) so the
agent-config demo's tone / expertise / responseLength contract is preserved.
TDD: tests/python/test_forwarded_props.py reproduces the bug at the ASGI
layer (no LLM, no crewai required) and pins five contracts:
- streaming POST through the middleware stack completes without RuntimeError
- the racy `class ForwardedPropsMiddleware(BaseHTTPMiddleware` is gone
- end-to-end import of agent_server + POST to / streams cleanly
- /health continues to short-circuit
- non-agent-config POSTs pass through unchanged
Pre-fix RED: 3 failures, including the exact RuntimeError from production
logs surfaced via Starlette TestClient. Post-fix GREEN: 99 / 99 pass.
R4-A.2: ProbeInvokerDeps.schedulerId is now required (no `?? cfg.id` fallback).
The earlier optional+default path silently re-introduced the R3-A.2 bug for any
caller that forgot to supply the prefixed scheduler id — tracker registration
became a silent no-op against the live scheduler. Required-at-typecheck
surfaces missing-prefix mistakes immediately. All 30 test invoker
constructions updated to pass schedulerId: cfg.id.
R4-A.7: outer fan-out catch now synthesizes a failed-tile + bumps `failed`
+ flips runState to "failed" instead of just logging. Pre-fix, if an
invariant inside the inner pool ever broke, the run would persist as
state="completed" with failed:0 while a real defect occurred — fake-green.
Post-fix, the synthetic tile is keyed `<probeId>:__internal_invariant__`,
written through the writer like any other failure, and the RunSummary
preserves total === passed + failed.
Adds covering tests for both: the typecheck contract via @ts-expect-error
on a fenced call, and the runtime fail-loud behavior via a fake scheduler
whose tracker.start throws (the only path that escapes Promise.all into
the outer catch).
- R4-A.1: response `scope` is now null when no filter was provided in the
request body, vs the actual array (possibly empty) when filter.slugs was
sent. Operators can distinguish "no filter" from "empty scope" in audit
logs.
- R4-A.4: post-read body cap now compares Buffer.byteLength(raw, "utf8")
against TRIGGER_BODY_LIMIT_BYTES instead of raw.length. UTF-16 char
counts let multibyte payloads (emoji = 4 bytes / 2 code units) slip
past a byte-denominated cap.
- R4-A.5: replaced the post-read length check with Hono's bodyLimit
middleware. Chunked uploads that omit (or lie about) Content-Length
now abort the body read at limit+small via a streaming reader, rather
than buffering the full body before checking. BodyLimitError surfaces
as 413 on the read; the post-read byte check stays as defense in depth.
- R4-A.6: rate-limit rollback now fires on ALL non-success paths from
scheduler.trigger, not just InflightConflictError. A transient 5xx no
longer locks the operator out of the 5-min window. Added structured
console.error("probes.trigger-unexpected", ...) before the rethrow so
500s are diagnosable from logs alone.
Tests: 6 new red-green tests covering scope null vs [], UTF-8 byte cap,
streaming early bail, and generic-error rollback. Updated the existing
"queued envelope" test to assert scope: null (was scope: []).
R4-B.1: useProbes effect now calls setData(null) on dep change to mirror
useProbeDetail's CR-B1.4 behavior. Without this, the dashboard renders
stale list data from the previous baseUrl until the new fetch resolves.
R4-B.6: useTriggerProbe checks controller.signal.aborted after the await
resolves. If a back-to-back trigger aborted the controller after the
fetch already returned, honor the R3-C.1 supersession contract and
resolve to null instead of returning the stale TriggerResponse.
serve() from @hono/node-server returns the http.Server synchronously, but
the bind happens via server.listen() which emits 'error' asynchronously
for conditions like EADDRINUSE. The R2-B.2 try/catch only caught
synchronous throws, so a real bind failure resolved boot() successfully
with an orphaned scheduler still ticking.
Race 'listening' vs 'error' on the returned server. On 'error', flip
schedulerRunning false, fire scheduler.stop() as a separate cleanup
chain (so the rejection isn't gated on stop), and reject boot() with
the original error. Guard the 'already listening' edge case so a sync
bind that beats listener attachment doesn't hang.
Adds covering test that mocks serve() to return an EventEmitter-like
stub which emits 'error' on next tick — boot() must reject and
scheduler.stop() must be called.
Two related test-isolation fixes for `src/orchestrator.test.ts`:
1. Bump global `hookTimeout` from 10s default to 60s. Several integration
tests boot a real Hono server, chokidar watcher, scheduler, and PB
client in beforeEach and tear them down in afterEach. On macOS + CI
runners the teardown can spike well past the default 10s ceiling
(chokidar.close on a deleted tempdir + http.Server.close + scheduler
settle), producing cascading "Hook timed out in 10000ms" failures on
/health-wiring, OPS_TRIGGER_TOKEN, and probe-unregister blocks. The
R21-a SIGHUP-reload test deletes its configDir mid-test and chokidar's
watcher.close() on a vanished path can hang for ~30s. 60s absorbs the
variance without masking real hangs (a stuck stop() blocks for minutes,
not seconds).
2. R21-a SIGHUP test: replace the fixed `setTimeout(100)` wait with a
polling loop that checks for the synthetic `<sighup>` emission with a
15s ceiling. Empirically the SIGHUP reload chain
(loader.load → loadDefaults readFile ENOENT → fs.readdir ENOENT →
reloadRules .catch → bus.emit) settles 2-5s later on macOS under load
— the prior 100ms budget produced a deterministic false-fail even
though the production path was correct.
Result: full showcase-ops suite — 941/941 tests pass; orchestrator.test.ts
run twice consecutively without flake. Typecheck clean.
Test names and section banners carry CR-round bookkeeping markers
((A1), (A6), (A11), (E1), (E2), F1.B/C/D/E/F) that leaked into the
suite during the R4 fix cycle. Those markers are useful while the
round is open but become noise once the work lands — operators
running `vitest -t` against the master suite shouldn't have to
remember which bucket a fix came from.
Strip all CR-bucket prefixes from `it()` names, describe-block names,
and section-divider comments. Test contents and assertions are
unchanged; this is a pure rename pass for long-term hygiene.
If `probeLoader.watch(...)` threw synchronously (e.g. an underlying
fs.watch ENOENT path), `unwatchProbes` was never bound, so a
subsequent `stop()` invocation hit `ReferenceError: unwatchProbes is
not defined` and orphaned the engine + scheduler.
Initialize `unwatchProbes` as a no-op BEFORE the watch() call, then
overwrite if registration succeeds. A failed init is logged via
`logErrorWithStack` rather than crashing the boot path.
R5-G4 D7.
Pre-fix, if both SHARED_SECRET and SHARED_SECRET_PREV were unset/empty,
webhookSecrets resolved to `[]` and webhook auth on the deploy.result
endpoint was silently disabled — anyone who knew the URL could POST.
Mirror the POCKETBASE_URL fail-loud pattern: in production, missing
webhook secret is a deploy bug that must surface immediately, so throw
FATAL-CONFIG. In dev/test emit an info-level log so developers see the
bypass without crashing local boot.
R5-G4 D5.
Comment-only cleanups (no behaviour change):
A3: rephrase the logErrorWithStack JSDoc so the "every time" wording
no longer contradicts the implementation (string-thrown values have
no stack to preserve, so the full-stack debug emission is conditional).
A4: collapse two duplicate sort-gate paragraphs into one, mention
"head-of-line blocking under bounded concurrency" so the reasoning
for why the sort exists is in one place rather than scattered.
A5: replace the dismissive "listener-cleanup is not a real concern"
finally-block comment with an honest acknowledgement that the listener-
leak window is bounded by GC of the AbortController after resolveInputs
returns. Documents the tradeoff rather than pretending it doesn't
exist.
A10 (schema.ts): drop the "the 845/847 unit-suite passed" reference
that pinned the comment to one specific test count. Replace with a
generic statement that the loader unit tests use stubs not real YAMLs,
so the gap only surfaces in integration.
The orchestrator's inline `gql` in `createRailwayAdapter` diverged from
the discovery source's `makeGql` on two axes:
1. Non-JSON response bodies (HTML edge-proxy 5xx pages) surfaced as a
raw `SyntaxError` from `res.json()` instead of a schema-class
classification — operators reading the log stream couldn't tell
schema drift from a bad gateway.
2. Any non-empty `errors[]` threw even when `data` was present;
`makeGql` returns the partial payload and logs the errors. Two
callers running the same query saw different exception shapes
depending on whether they hit the inline gql or the discovery one.
Fix: export `makeGql` and `RAILWAY_GRAPHQL_ENDPOINT` from
`probes/discovery/railway-services.ts` and route `createRailwayAdapter`
through it, so error taxonomy (Auth / Backend / Schema / Transport
class hierarchy) is uniform across both consumers.
While here, drop the in-adapter `cachedServices` cache (D6). The
aimock-wiring probe always calls `listServices` first, and per-tick
refetch costs one round-trip but ensures renamed/added services
surface on the next cron tick without orchestrator restart.
`createRailwayAdapter` is also exported now and accepts an optional
`{ fetchImpl }` dep so unit tests can drive arbitrary HTTP behaviour.
R5-G4 D2/D3/D6.
Discovery returning zero records (everything filtered out, registry
empty, etc.) was silent — no signal to operators that the tick existed
but produced zero writes. Add a `probe.no-inputs` info log carrying
`probeId` + `kind` so log-aggregation can correlate "no signal"
against "discovery returned empty". Behaviour is unchanged — zero
inputs still mean zero writes — this is observability only.
Also drop the dead `if (!item) break;` defensive guard in the worker
loop. The loop precondition `cursor < inputs.length` guarantees
`inputs[idx]` is defined; the guard was a TypeScript-strictness
artifact, not a real invariant. Replace with `inputs[idx]!` plus a
comment documenting the loop-precondition invariant.
TDD red-green test: discovery source returns `[]`, asserts zero
writes plus exactly one structured `probe.no-inputs` info log.
Reorganises the cost-budget paragraph in config/probes/e2e-demos.yml
to cleanly distinguish:
- per-service average (~32 demos)
- the largest single service (~38 demos, wall-clock dominator)
- aggregate page loads per tick (~544)
and explicitly calls out the per-demo wall-clock bound enforced by
runDemo()'s single deadline across goto + fallback selectors. The
20-min outer cap and the worst-case ~19 min math now follow from
that bound directly, matching the driver's actual behaviour.
Driver throws can carry multi-MB Playwright stack traces or browser
console dumps. Without truncation those propagate untouched into PB
rows and Slack alerts, blowing past render budgets and making the
dashboard unreadable.
Add a 1200-char `SYNTHETIC_ERROR_MSG_BUDGET` (matching the budget the
e2e drivers themselves self-truncate to) and pipe the synthetic
errorDesc through `truncateUtf8` from `render/filters.js`. Same UTF-8-
safe truncation the renderer uses, so the synthetic path matches what
drivers produce.
TDD red-green test throws a 50KB error and asserts errorDesc <= 1200.
Adds 10 new tests in src/probes/drivers/e2e-demos.test.ts covering the
behaviors hardened in the driver:
- registry-read-failed at info on ENOENT (downgraded from warn)
- registry-parse-failed at warn on corrupt JSON
- registry-shape-invalid at warn when root is not a plain object
- non-abort error classified as driver-error even if abort races
- abort mid-selector-loop short-circuits without walking all 5 selectors
- empty-string route emits red config-invalid side row
- slow writer does not block abort fan-out (fire-and-forget side emit)
- writer-missing warn deduped to at-most-once per run
- per-demo wall-clock bounded to pageTimeoutMs across goto + selectors
- synthetic resolver-error side row when custom resolver throws
Also introduces mkSpyLogger() / mkCtxWithLogger() helpers and an
imported Logger type to keep the new structured-logging assertions
type-safe.
When the railway-services discovery source can't load registry.json (or
every record otherwise has zero demos), the existing sort still runs
its tie-break on `key` ascending — silently re-ordering discovery's
natural emission order without operator signal. Skip the sort entirely
in that case and emit a structured `probe.e2e-demos.sort-no-demos`
warn with the probe id, input count, and a hint pointing operators at
the most likely cause (registry.json missing/corrupt).
TDD red-green test exercises three records in non-alphabetic order
(zulu/alpha/mike) and asserts dispatch preserves enumeration order
plus exactly one structured warn fires.
Pre-fix `diffProbeSchedules` wrote `process.env.E2E_DEMOS_TIMEOUT_MS = String(cfg.timeout_ms)`
directly. Three problems with that approach:
1. Stale across YAML reloads — when a reload dropped timeout_ms, the
env var stayed set and leaked into every subsequent probe tick.
2. Last-write-wins silently across multiple e2e_demos configs.
3. Mutating shared global state from a diff function broke parallel
test isolation.
The fix: a pure `envForCfg(cfg, baseEnv)` overlay function. The
orchestrator now snapshots process.env once at the top of the diff
loop, and each probe gets a per-cfg overlay handed to its invoker.
process.env is never mutated.
R5-G4 D1.
Two related observability fixes:
A2: stamp `errName: "TimeoutError"` on the synthetic signal returned
by both timeout-path branches (setTimeout callback and the post-race
`if (timedOut)` branch). Operators triaging "timeout vs. driver-error"
no longer have to parse free-form errorDesc — the discriminator is on
the signal directly.
A8: tighten the object-shape gate in resolveInputs to reject `null`
explicitly (previous `record &&` truthiness check let null records
fall through silently). Else-branch now emits a single
`probe.discovery-record-non-object` warn for every non-object record
(array/null/number/string/boolean) with a `recordKind` discriminator.
Previously only arrays warned; null and primitives slipped through
silently.
TDD red-green tests for both: timeout signal carries
errName='TimeoutError'; non-object discovery records (null, number,
string, boolean) each emit a structured warn with the right kind.
Several CR R5 G3 fixes to drivers/e2e-demos.ts, all in the same file
because the changes touch overlapping regions:
C1: drop redundant path.resolve("/app/data/registry.json") wrap. No-op
for an absolute path; mirror railway-services.ts's plain string fallback
so the constant stays greppable across the two readers.
C3: replace empty catch {} in defaultDemosResolver with structured
read/parse/shape error logging. Mirrors loadDemosMap in
railway-services.ts: ENOENT → info (steady-state in dev), other read
errors → warn, JSON parse errors → warn, non-object root → warn. A
corrupt or missing registry now emits a distinct log key per failure
mode instead of silently flipping every e2e_demos cell green forever.
C4: document the per-run cache lifetime. The factory is invoked inside
run() so the cache survives within one tick (avoids 17 redundant reads)
but is discarded across ticks (so a registry update mid-day takes
effect on the next tick without driver restart). Hoisting to module
scope would make the registry stale forever.
C5: classify run errors via err.name === "AbortError" instead of
reading abortSignal.aborted after the catch settles. The prior approach
let a concurrent abort flip the flag mid-throw and misclassify a real
TypeError / TargetClosedError as "abort".
C6: log page/context teardown failures at debug instead of swallowing
silently. Stable log keys (page-close-failed, context-close-failed) so
operators can distinguish the two cases.
C7: check abortSignal.aborted at the top of every selector-loop
iteration so a cap that fires mid-loop bails promptly rather than
walking all 5 selectors at pageTimeoutMs each.
C8: treat empty-string route as a config mistake. Surface a red row
with errorClass="config-invalid" and errorDesc="route is empty
string" instead of falling into the informational-cell green path.
C9: void sideEmit on the abort branch instead of awaiting. A slow /
hung writer was defeating the prompt-shutdown contract for remaining
demos.
C10: dedup writer-missing warn via a per-run closure. N demos with no
writer wired used to produce N warns; now exactly one per run.
C11: bound per-demo wall-clock to pageTimeoutMs across goto + all
selector waits via a shared deadline. Worst case was 6*pageTimeoutMs
per demo; now strictly bounded.
C12: emit a synthetic e2e:<slug>/__resolver red row with
errorClass="resolver-error" when a custom resolver throws. Prior
behaviour fell through to demos: [] which masquerades as a green
aggregate; the synthetic row surfaces the configuration mistake on
the dashboard distinctly from an empty registry.
C2: align the abort-branch comment with the actual continue (not
break) semantics — every remaining demo gets a red side row.
A driver rejection BEFORE the timeout fires reaches both the detached
`.catch` observer and the outer `try/catch` which logs probe.run-failed.
Without this guard we double-log on every normal rejection. Add an
`if (!timedOut) return;` so only the true late-rejection path (timeout
won the race, driver rejected afterwards) emits the debug log.
Adds a TDD red-green test that asserts exactly one probe.run-failed log
and zero probe.driver-late-rejection logs on a normal driver throw.
The docstring for `selectorCascade` says "Race a list of selectors and
return the first one to resolve", but the implementation iterated
selectors sequentially with `await page.waitForSelector` — on a full
miss with 4 selectors at 3s each, total wait was 12s instead of 3s.
Convert to `Promise.any` over concurrent `page.waitForSelector` probes;
all selectors are dispatched in the same tick and whichever resolves
first wins. On full miss, throw with a consolidated reason listing
each tried selector's failure detail (preserves the existing label-
based error contract for callers).
Add a covering test that asserts the helper dispatches all
waitForSelector calls within ~50ms of each other (would fail at >50ms
spread under the previous sequential implementation, which would
serialize 50ms-rejecting probes). Plus a fast-vs-slow probe test
that asserts the FAST resolution wins regardless of cascade order
(the race semantic the docstring promises).
The existing `pickTimeSlot` and `approveOrDeny` callers continue to
work unchanged — `Promise.any` returns the SAME selector value the
sequential loop returned (the first to resolve), and the caller-side
selector composition (`${cardSelector} ${slotEntry}`) is unaffected.
The fenced ts block had `launchBrowser: /* see scripts/d6-capture-references.ts for the wiring */,`
which is invalid TS — a comment cannot be a property value. Replace the
broken example with a `yourLauncher` placeholder and a prose pointer to
the canonical wiring in scripts/d6-capture-references.ts.
Also drop the duplicate exit-code paragraph that lived under the
programmatic helper section — exit codes are a CLI concern only, and
the CLI section already states them on line 59.
R3-A.1: filter.slugs path now retains every input with a preError set so
discovery-time key_template failures still surface as synthetic-error
results when a `--slugs foo` manual trigger runs. Pre-fix the resolver's
synthetic preError keys (e.g. `<probeId>:invalid-key-template:N`) never
matched operator-supplied slugs and were silently dropped, hiding the
exact misconfigurations operators were investigating.
R3-A.2: invoker now reads `deps.schedulerId` (defaulting to `cfg.id` for
test fixtures) and uses it for `scheduler.getEntry` and
`setEntryTracker`. Orchestrator passes the prefixed `probe:<cfg.id>` it
registers under. Pre-fix the invoker passed the bare `cfg.id` while the
orchestrator registered with the `probe:` prefix, so `getEntry` returned
undefined and `setEntryTracker` was a silent no-op against the live
scheduler — `/api/probes` never surfaced inflight tracker data for
cron-tick runs and the cleanup setEntryTracker(null) was a no-op too.
R3-A.3: emit a structured `probe.run-row-orphan-risk` warn alongside the
existing `probe.run-writer-start-failed` error when runWriter.start
throws. The PB-side create may have succeeded before the response was
lost, leaving an orphan `running` row with no caller holding the id.
The warn is the observability surface operators grep for; the run
continues normally because probe_runs is best-effort observability.
Defense-in-depth pair to R3-A.4. Orchestrator now trims the token at
boot so the value handed to the bearer-auth middleware is the same one
the comparison runs against. The fail-loud empty-string guard already
handled the all-whitespace case at the env-var level; this hunk only
normalises non-empty padded values so a " abc " env var boots cleanly
and accepts "Bearer abc" without forcing operators to know about the
auth layer's internal trimming.
Replace `String(err)` in `logger.error` call sites across orchestrator.ts
with the shared `logErrorWithStack` helper from probe-invoker.ts. The
helper attaches `errName`, the message, a 5-line truncated stack at
error level, and emits the full stack in a parallel `<msg>.full-stack`
debug log. Pre-fix, error logs lost the stack entirely — operators
post-mortem-reading the orchestrator log saw only the message and had
no way to correlate errors back to the throwing call site.
Export `logErrorWithStack` from probe-invoker.ts so it can be reused
without introducing module-level duplication. Bus emit payloads and
warn-level logs that carry an `err` field keep `String(err)` (their
contracts and audience are different — bus payloads are stringified
for downstream consumers, warn logs are non-fatal).
Pre-fix the middleware trimmed the presented Bearer token but compared
against the verbatim env value. A copy-pasted OPS_TRIGGER_TOKEN with
surrounding whitespace booted clean but silently 401'd every well-formed
request. Trim BOTH sides at construction so the comparison is symmetric;
an all-whitespace expected token collapses to empty and trips the
existing fail-loud MissingAuthTokenError guard.
The per-service variables-loop catch used to rethrow on
`ctx.abortSignal?.aborted === true` OR a real AbortError from
fetch. The first half of that disjunction over-rethrew unrelated
mid-loop failures: once an external tick-timer fires the
controller, every subsequent iteration sees signal.aborted === true,
so a plain Railway 500 on the next service iteration also escaped
the catch and killed the whole tick — operators saw a discovery
abort with no useful breadcrumb on which service actually 500'd.
Narrow the rethrow to a real AbortError only. We walk up to two
`cause` links because gql() wraps fetch rejections in
DiscoverySourceTransportError; the underlying AbortError lives on
err.cause. Walking the chain catches that case without
re-tripping on unrelated transport errors that happened to fire
while the signal was aborted. AuthError rethrow remains unchanged.
While here, propagate B2 partial-data correlation: gql() now
returns { data, partialData } so downstream Zod-failure logs can
tag `cause: "partial-data"` when the same request already fired
the partial-errors warn. Operators reading the log stream now see
one correlated pair instead of two unrelated keys for the same
root cause. Project-level schema-failure throw includes
"(cause: partial-data)" in the message; the per-service
variables-schema warn carries cause: "partial-data" as a meta
field.
Regression test: 3-service project, abort fires between service-1
and service-2, service-2's variables call returns 500. Asserts the
500 records as env: {} + a single variables-failed warn instead
of escaping the loop, and that signal.aborted === true at the
end (confidence the abort actually fired).
The widened package-name regex in classifyShape() accepts
multi-segment names like `showcase-langgraph-python` without
warning — but it can't distinguish a legitimate multi-segment
package name from a 1-edit-distance typo of `showcase-starter-*`.
`showcase-strater-ag2` (a/r transposition) currently classifies
as package silently, so a typo-renamed Railway service flips
deployment archetype on the next tick without any audit signal.
Add a Levenshtein-distance-1 nudge (with adjacent-transposition
detection — `strater` is distance-2 by pure Levenshtein but
distance-1 with transpositions): when classifying as package, if
the first segment after `showcase-` is exactly one edit away from
`starter`, emit a structured warn
(`discovery.railway-services.classify-typo-suspected`) with the
service name and a suggested correction. Classification is NOT
changed — that would break the documented "anything-not-starter is
package" fallback. The warn is the audit trail; operators decide
whether to act.
Tests cover: (a) the typo case fires the warn with name +
suggested, (b) legitimate multi-segment package names do not
fire, (c) well-formed starter names do not fire.
ENOENT on registry.json is steady-state for non-demos consumers
(image-drift, smoke, aimock-wiring) — they don't mount the registry
volume — so in dev/test it correctly logs at info to avoid pulsing
the alert stream every tick. In production however a missing
registry is genuinely an operational concern: the volume mount may
have failed, or the image was built without the registry payload
copied in. Promote ENOENT back to warn only when
NODE_ENV === "production" so production operators see the missing
registry as a pageable signal while local/CI runs stay quiet.
Other read errors (EACCES, EIO, AbortError) already logged at warn
regardless of environment and continue to do so — those signal an
active fault, not steady-state.
Reorders the out.push({...}) properties to match the
RailwayServiceInfo interface declaration order (name, imageRef,
publicUrl, env, shape, deployedDigest, demos). The previous order
interleaved deployedDigest between imageRef and publicUrl, which
matched neither the interface nor any logical grouping. Pure
ergonomic refactor — no behaviour change. Reviewers can now grep
the interface and the emit site side by side without re-shuffling
fields.
R3-B.1: status-table lastRunTone() returned green for partial-pass runs
(failed=0, passed<total), reintroducing the misread that R2-D.4 fixed in
status-runs-list. Schedule table is the most-viewed surface, so this is
the highest-impact regression. Mirror runs-list semantics: amber tone +
"P/T (S skipped)" result text. Also explicit "P/T pass (F fail)" for
failed runs so the column reads consistently with the tone.
R3-B.2: StatusDetailPanel durations filter used \`typeof d === "number"\`
which admits NaN/Infinity (typeof NaN === "number"). The sparkline then
computed Math.min(...durations) = NaN and emitted "NaN,NaN" coords in
the polyline. Switch to Number.isFinite() so non-finite durations are
dropped.
Tests added (red-green TDD):
- status-table: amber tone + "skipped" text for partial pass; explicit
"pass (N fail)" text for failed; "N/N pass" for fully-passed
- status-detail-panel: NaN/Infinity durations don't appear in sparkline
Adds explicit unit coverage for two paths the suite was previously
silent on:
B5 — deployedDigest extraction. The fixture builder used to discard
latestDeployment entirely, leaving the production code path that
pulls imageDigest from latestDeployment.meta unit-test uncovered.
The fixture now optionally accepts a latestDeployment block; new
tests assert: happy path string digest flows through; null
latestDeployment yields ''; missing meta yields ''; non-string
imageDigest is rejected defensively.
B6 — resolveShape mismatch-throw path. The classifier-vs-explicit
disagreement throw is the load-bearing assertion preventing silent
shape drift at driver boundaries; previously it had zero tests.
Two new tests cover both directions (starter classifier + package
input, package classifier + starter input) and pin the error
message to include both values for operator triage.
R3-C.1: useTriggerProbe.trigger now returns Promise<TriggerResponse | null>.
The supersession path resolves to `null` (typed) instead of the previous
`undefined as unknown as TriggerResponse` cast, which lied about the
return shape and would crash any caller that read fields off the result.
R3-C.2: useProbeDetail clears `error` on id change so the detail panel no
longer renders a stale error message under a freshly-selected probe header.
R3-C.3: useProbes clears `error` on dep change (e.g. baseUrl swap) so a
prior dep tuple's failure does not persist under the new one.
R3-C.4: useTriggerProbe clears `error` at the start of each trigger call
so a successful retry leaves no sticky error from the prior failure.
Bonus: parseJson now attaches the original parse error as `cause` on the
wrapper so debuggers can walk back to the SyntaxError name/stack without
re-parsing the wrapped message.
Tests: red-green tests added for each behavior; full file suite 49/49.