Commit Graph

4085 Commits

Author SHA1 Message Date
Jordan Ritter 805f521051 fix(showcase-ops): trim OPS_TRIGGER_TOKEN at boot for symmetric auth (R3-A.5)
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.
2026-04-25 20:07:46 -07:00
Jordan Ritter 6a3ff074c0 feat: log full stack alongside truncated error in orchestrator paths
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).
2026-04-25 20:07:43 -07:00
Jordan Ritter 109562d2ce fix(showcase-ops): trim expected token in bearerAuth so whitespace-padded OPS_TRIGGER_TOKEN works (R3-A.4)
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.
2026-04-25 20:07:28 -07:00
Jordan Ritter 15a097c24f fix: narrow railway-services per-service abort rethrow to AbortError only
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).
2026-04-25 20:06:24 -07:00
Jordan Ritter 94d45beefb feat: surface suspected starter/package classification typos
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.
2026-04-25 20:04:27 -07:00
Jordan Ritter 7904732c66 fix: promote ENOENT registry log to warn in production
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.
2026-04-25 20:03:28 -07:00
Jordan Ritter 7842824f6b refactor: align RailwayServiceInfo emit property order
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.
2026-04-25 20:03:02 -07:00
Jordan Ritter 38c8236aa2 fix(shell-dashboard): align status-table partial-pass tone with runs-list
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
2026-04-25 20:02:43 -07:00
Jordan Ritter f88673b349 test: cover deployedDigest extraction + resolveShape mismatch-throw
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.
2026-04-25 20:02:41 -07:00
Jordan Ritter df5fa2d499 fix(showcase-dashboard): tighten error semantics and trigger return type in ops hooks
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.
2026-04-25 20:02:02 -07:00
Jordan Ritter f2a7e73d4b fix(shell-dashboard): unify StatusTriggerButton close paths
R3-D.2: the toggle-close branch on the Trigger button forgot to reset
pickerOpen and selected, while outside-click and Escape both did. So
toggling the menu closed and reopening it showed ghost checks from a
prior session. Extract a closeMenu() helper used by all three close
paths so the invariant is structural, not duplicated.

Bonus: add aliveRef guard so async rejections from onTrigger that
resolve after unmount don't trigger setState-on-unmounted warnings.
2026-04-25 20:01:42 -07:00
Jordan Ritter 9eb20d7de1 fix(shell-dashboard): add cache: no-store to ops-api GET fetches
R3-D.1: fetchProbes and fetchProbeDetail are polled every ~10s for live
status data. Without an explicit cache directive the browser and Next.js
App Router fetch wrapper may serve stale responses, defeating the poll.
Add cache: 'no-store' to both GET call sites and cover with tests.
2026-04-25 20:01:35 -07:00
Jordan Ritter f2e18be304 test: tighten probe-config-parity coverage (yaml extension + exempt namePrefix invariant)
E1: railway-services floor scan now matches both `.yml` and `.yaml`
extensions so a future probe authored as `<name>.yaml` cannot silently
bypass the floor invariant.

E2: every entry in `RAILWAY_PROBES_EXEMPT_FROM_EXCLUDES_FLOOR` must
either carry a `namePrefix` strictly narrower than the broad
`showcase-` floor, or its rationale string must include the grep-able
phrase `all services`. Today qa.yml passes via the narrow-prefix path
(`showcase-langgraph-python`); image-drift.yml passes via the
rationale-phrase path. If qa.yml is later widened to `showcase-`
without dropping the exemption (or restating an explicit
all-services rationale), this guard fires instead of letting infra
services flap red on a stale exemption.

E3: shorten the duplicated cwd-resolution rationale at the top of the
file; the load-bearing explanation now lives once on `configsDir()`.
2026-04-25 20:00:10 -07:00
Jordan Ritter 63869ae34e fix(showcase-ops): stabilize probe-loader watch test on macOS Node 22+
The watch-callback test for probe-loader was flaking because chokidar's
native FSEvents backend on macOS does not reliably emit events for files
written into a freshly mkdtemp'd directory under Node 22+: the parent path
isn't pre-watched by the kernel, so the first add silently drops.

Add a test-only watcherOptionsOverride field on ProbeLoaderDeps so tests
can opt into chokidar's polling backend (the documented chokidar workaround
for this exact case), and switch the flaky test to use it. Also replace the
fixed setTimeout(500ms) wait with vi.waitFor polling so the assertion no
longer races chokidar's debounce on slow CI.

Production callers leave watcherOptionsOverride undefined, so prod still
gets FSEvents/inotify with no perf change.

Verified stable across 7 consecutive runs of the watch test.
2026-04-25 19:59:38 -07:00
Jordan Ritter 6a03c3e11d fix: add aimock fixtures for byoc-json-render chart prompts
The aimock-fixture-coverage probe scans byoc-json-render.spec.ts and was
flagging two prompts without matching feature-parity.json fixtures:

  - "Break down revenue by category as a pie chart"
  - "Show me monthly expenses as a bar chart"

Existing pie/bar fixtures used substrings ("revenue distribution by
category", "expenses by category") that don't appear in these prompts,
so the substring matcher returned no hits.

Added two new fixtures with substrings that DO appear in the prompts
("revenue by category as a pie chart", "monthly expenses as a bar
chart") so the coverage probe stays green and aimock returns
deterministic chart data when the BYOC json-render specs run against
the sidecar.
2026-04-25 19:56:41 -07:00
Jordan Ritter 4861ee043f fix(showcase-dashboard): correct misleading 'last pass' tooltip copy for degraded state
The degraded-state tooltip read "stale — last pass @ <observed_at>", but
observed_at is the most recent producer tick (when the row was last
*seen* in any state) — for a degraded row that is when degradation was
last observed, NOT when it last went green. Operators reading "last
pass @ ..." would mis-triage by treating that timestamp as the last
green tick.

Change copy to "stale — last seen @ <observed_at>" and add a covering
test asserting the new wording (and the absence of "last pass").
2026-04-25 19:54:07 -07:00
Jordan Ritter 17edaccee1 merge: origin auto-format 2026-04-25 19:50:54 -07:00
Jordan Ritter 9409b4ca91 chore(showcase-ops): oxfmt format pass 2026-04-25 19:50:35 -07:00
Jordan Ritter 62cdcccbe6 docs(showcase-ops): point d6-reference README at the CLI script
R11: The 'How to invoke' example referenced a nonexistent
defaultLaunchBrowser symbol. Reorganized to lead with the CLI wrapper
(scripts/d6-capture-references.ts) which already wires the default
deps; the programmatic example below is annotated as test/internal-
only with a comment pointing at the script for the canonical wiring.
2026-04-25 19:48:15 -07:00
Jordan Ritter fb3a9d901b fix(showcase-ops): e2e-parity outer-turn label, fleet TTL, rotation row refresh
R7 (e2e-parity half): Same per-turn slicing pattern as reference-
capture — runFeatureCapture's inner runConversation always reports
failure_turn === 1. Translated to outer 1-based index in the
'conversation-error' errorDesc so dashboards show which turn actually
failed. Added covering test asserting turn 3 (not turn 1) is reported
when the third turn fails.

R8: createDefaultFleetResolver cached successful reads forever, so
operators editing registry.json needed a process restart for D6
rotation to see the new fleet membership. Added mtime-based
invalidation plus a 60s wall-clock TTL safety rail. Resolver factory
now accepts injected now/stat for deterministic testing. Added two
covering tests: mtime-change invalidation and TTL elapsed even when
mtime is unchanged.

R10: When selectedThisTick === false (rotation skips a slug), the
driver returned aggregate green but emitted no per-feature side rows,
leaving stale red on the dashboard for the entire rotation cycle (up
to a week). Now emits a green per-feature side row with a 'not
selected this tick' note for every requested-known feature so cells
refresh while clearly signalling we didn't actually verify parity.
Updated the existing rotation-skip test to assert one side row per
requested feature and tightened slug expectations.
2026-04-25 19:48:09 -07:00
Jordan Ritter 3980bb1b61 merge: r2-fix-D R2 CR fixes 2026-04-25 19:48:01 -07:00
Jordan Ritter 64a27190fc merge: r2-fix-C R2 CR fixes 2026-04-25 19:48:01 -07:00
Jordan Ritter af3b253c33 merge: r2-fix-B R2 CR fixes 2026-04-25 19:48:01 -07:00
Jordan Ritter b3804f0efa fix(showcase-ops): prefer structured tool_call/tool_use names over generic name
R9: pickToolCallName checked the bare 'name' field before the
structured 'tool_call.name' / 'tool_use.name' fields. Some transports
include a top-level 'name' that's a step/event label alongside a
structured tool descriptor — that ordering harvested the label and
poisoned the parity-compare engine's tool-call list.

Reordered to: toolCallName -> tool_call.name -> tool_use.name -> name.
Added 3 covering tests asserting structured fields win over a top-level
step-label and the canonical toolCallName still wins over everything.
2026-04-25 19:47:56 -07:00
Jordan Ritter 05db1aa4c5 fix(showcase-ops): translate per-turn slice failure_turn to outer index
R7 (reference-capture half): The capture helper runs each turn as
runConversation(page, [turn]) so the SSE interceptor only sees that
turn's request. The inner runConversation always reports
failure_turn === 1 (its slice-local index), which would mislabel
every per-turn capture failure as 'turn 1' regardless of which
outer turn actually failed. Translated to outer 1-based turn index
when constructing the failure reason. Updated the existing covering
test to assert turn 2 (not turn 1) when the second turn fails.
2026-04-25 19:47:49 -07:00
Jordan Ritter c4812616ba fix(showcase-ops): centralize assistant-message selectors across probes
R6: Four sibling files duplicated the assistant-message selector
cascade with subtle drift; some used a bare '[role="article"]'
fallback that picked up user-input bubbles into the transcript and
caused turn-1 user-message substrings to spuriously match the user's
own input.

Hoisted ASSISTANT_MESSAGE_PRIMARY_SELECTOR (canonical testid) and
ASSISTANT_MESSAGE_FALLBACK_SELECTOR ([role="article"] excluding
data-message-role="user") to conversation-runner.ts. Updated
_hitl-shared, _gen-ui-shared, d5-agentic-chat, and d5-shared-state to
import the shared constants and apply them via a string-templated
new Function so closures don't have to cross the page.evaluate
boundary while DOM-lib remains excluded from the helper module.
2026-04-25 19:47:42 -07:00
Jordan Ritter 23726c2562 fix(showcase-ops): drop double-prefix in HITL time-slot selector cascade
R5: TIME_PICKER_SLOT_SELECTORS entries themselves embedded the
'[data-testid="time-picker-card"] ...' prefix, and the helper then
composed cardSelector + entry — producing nonsense like
'[data-testid="time-picker-card"] [data-testid="time-picker-card"] button >> nth=0'
that never matched. Simplified the cascade to its bare slot variants
('[data-testid="time-picker-slot"]', 'button >> nth=0') and rely on
the helper's prefixing for card anchoring.

Added _hitl-shared.test.ts with 3 covering tests: no doubled card
prefix anywhere in probed selectors, click composes card + slot, and
fallthrough cascade still anchors under the resolved card selector.
2026-04-25 19:47:28 -07:00
Jordan Ritter 78bb962b22 fix(showcase-ops): extract findPackageRoot helper for D6 capture script
R4: The CLI script's package-root resolution was inlined and not unit
tested. Hoisted to src/probes/helpers/package-root.ts with dependency-
injected exists predicate so it picks up vitest's src/**/*.test.ts
glob. Added 5 covering tests (source-tree, dist-tree, startDir match,
missing throws, deepest stop).
2026-04-25 19:47:20 -07:00
Jordan Ritter 169de65da5 fix(showcase-ops): parity-compare zero-chunk guard and multiset class superset
R1: classesEqual was a strict length-equality check that rejected
captured-superset cases. Replaced with capturedClassesIncludeReference
(multiset superset) so a captured DOM with extra classes still passes
while a missing reference class fails the comparison.

R2-R3: When captured.total_chunks === 0 but reference.total_chunks > 0
the timing math degenerates and the stream axis silently passed. Added
an early-return guard with a clear failure reason so the parity verdict
reflects reality.
2026-04-25 19:47:13 -07:00
Jordan Ritter 321eb79077 fix(showcase-ops): harden boot/teardown lifecycle (R2-B.1, R2-B.2)
R2-B.1 — diffCronSchedules now awaits scheduler.unregister and emits
orchestrator.cron-unregister-failed on rejection; orphan rule stays
visible in scheduler.list() (mirrors CR-A2.2 probe-path fix). Watch
callback uses .then/.catch to surface reload failures via the bus.

R2-B.2 — boot wraps serve() in try/catch; on throw, awaits
scheduler.stop() and resets schedulerRunning before rethrowing, so a
late bind failure (e.g. EADDRINUSE) cannot leave cron jobs ticking
without an owner.

Tests: 2 new red-green specs added under orchestrator.test.ts
covering both rejection paths (vi.spyOn(logger.error) + vi.doMock for
the @hono/node-server serve() throw).
2026-04-25 19:46:57 -07:00
Jordan Ritter 35f30bc9a7 fix(showcase-ops): fail loud when OPS_TRIGGER_TOKEN is set but empty
R2-B.3: distinguish unset from set-but-empty. An operator who mistypes
`OPS_TRIGGER_TOKEN=` ships an empty value AND sees the silent-skip
log instead of a clear error. Trim and reject whitespace-only too.

- unset       → boot succeeds, info log (intentional disable)
- set-empty   → boot throws (misconfig — refuse to mount probes router)
- set-value   → boot succeeds, router mounted

Adds 4 covering tests under "OPS_TRIGGER_TOKEN empty-string handling".
2026-04-25 19:41:57 -07:00
Jordan Ritter fcfafdcafe fix(pocketbase/migrations): make probe_runs migration robust to partial applies
R2-A.11: two robustness improvements to 1777165230_create_probe_runs.js:

- CREATE INDEX statements now use IF NOT EXISTS so a partial-apply
  doesn't trip the next migration run on the index DDL step. The
  collection-presence gate above already covers saveCollection
  idempotency; this covers the per-index path in case PB ever runs
  index DDL separately from the schema commit.

- Document why the up-migration's findCollectionByNameOrId catch must
  remain broad: PB JSVM does not expose typed error discrimination
  (no ErrCollectionNotFound), so we cannot narrow without a runtime
  feature change. The down-migration's catch is already narrowed
  because it operates on a resolved-or-skip path.
2026-04-25 19:32:47 -07:00
Jordan Ritter 02ee62d46e fix(probes/run-tracker): drop terminal-state fields when start() is re-called
R2-A.10: ProbeRunTracker.start() spread the existing entry, so a
defensive call on a slug that previously transitioned through complete
or fail carried finishedAt / result / error into the new running entry.
The snapshot then surfaced a "running" service with terminal-state
metadata, which is incoherent.

Replace the spread with an explicit reset to the running shape so a
re-started slug starts fresh.

Tests: assert finishedAt/result/error are undefined after start() on a
previously completed or failed service.
2026-04-25 19:32:38 -07:00
Jordan Ritter 2cdd420766 fix(probes/run-history): guard finish() against missing PocketBase row
R2-A.7: when pb.getOne returns null (row deleted, lookup race, etc.)
the previous code fell through with NaN duration and called pb.update
on a non-existent id, either throwing on the underlying client or
silently writing junk. Add an explicit null check that logs a warning
and returns early, so finish() is a no-op when the row is gone instead
of producing a stale-row error.

Test: asserts pb.update is not called when getOne returns null.
2026-04-25 19:32:30 -07:00
Jordan Ritter a109330683 fix(http/probes): harden trigger endpoint and degrade gracefully on history fetch
Five hardening fixes on the /api/probes routes:

R2-A.4: validate filter.slugs is a string[] before forwarding to the
scheduler. The invoker constructs new Set(filterSlugs) on the value, so
a string sneaks through as per-character set membership. Reject malformed
shapes with 400 invalid_filter at the route boundary.

R2-A.5: stamp the rate-limit window AFTER body read + JSON parse +
filter validation, not before. A 400 from validation must not consume
the operator's 5-minute hold. New sequence: auth -> 404 -> body read ->
parse -> filter shape -> rate-limit check -> stamp -> trigger.

R2-A.6: rollbackRateLimit now compare-and-swaps on its own t value
before restoring the prior stamp. Without this, two racing triggers
where A errors after B successfully stamped would have A's rollback
delete B's stamp.

R2-A.8: cap POST body at 16 KiB. Reject oversized payloads with 413
both via Content-Length pre-check and post-read length check (chunked
clients can lie about Content-Length).

R2-A.9: GET /api/probes/:id wraps writer.recent in try/catch so a
transient PB outage degrades to runs:[] + runsError: history_unavailable
with a 200, instead of 500-ing the whole probe-detail page.

Tests: added 13 new tests across five describe blocks covering each
fix, including a concurrent-trigger CAS rollback scenario and chunked
body-size enforcement.
2026-04-25 19:32:23 -07:00
Jordan Ritter 8c1330406a fix(probes/invoker): preserve RunSummary invariant and absorb late race rejections
Three correctness fixes in the probe invoker:

R2-A.1: when discovery enumerate fails, the RunSummary previously returned
total=0 with failed=1, violating total === passed + failed. Treat the
discovery itself as the single failed unit so the invariant holds.
Also stop polluting tracker.snapshot().services with a fake entry keyed
by the probe id (cfg.id is not a service slug); the failure is conveyed
structurally via discoveryFailed: true.

R2-A.2: Promise.race against the driver promise left the driver's tail
rejection unhandled when the timeoutPromise won. Under
--unhandled-rejections=throw this can crash. Attach a no-op .catch() to
the driver promise before the race so late rejections are absorbed; the
race outcome is unchanged.

R2-A.3: same fix for the discovery enumerate Promise.race path.

Tests: updated CR-A1.5 invariant assertion plus added two unhandled-
rejection guard tests for driver and enumerate late-reject paths.
2026-04-25 19:32:10 -07:00
Jordan Ritter 5b915dd844 chore(shell-dashboard): humanizeCron HH:MM and rename completed→finished
Bucket-(b) bonus polish:
- humanizeCron now renders "M H * * *" with a non-zero minute as
  "Daily at HH:MM" instead of falling through to the raw expression.
- humanizeCron rejects */0 step values for both the every-Nh and every-Nm
  shapes — that's not a valid cron step.
- StatusRunningPanel: rename `completed` to `finished`. The variable
  counts services in either completed OR failed states, so "completed"
  read as misleadingly success-only. The data-completed attribute name
  stays for backwards compatibility with existing tests.
2026-04-25 19:31:39 -07:00
Jordan Ritter 5fe2058c79 fix(shell-dashboard): badge partial-pass runs amber instead of green
A run with failed=0 but passed<total represents some services that were
skipped/unknown — rendering this as green "completed" misleads operators
into thinking all targets passed (R2-D.4). Now badged amber with a
"partial (N skipped)" label so the partial-coverage case is visible.
2026-04-25 19:31:32 -07:00
Jordan Ritter 29a67b78e0 fix(shell-dashboard): surface trigger errors and reconcile selection
Three related fixes to StatusTriggerButton:
- R2-D.2: render onTrigger errors inline via data-testid="status-trigger-error"
  instead of discarding the value through a write-only setter.
- R2-D.3: when serviceSlugs prop changes, intersect selected with the new
  list so stale slugs cannot be POSTed; also clear selection on
  outside-click and Escape close so reopening doesn't show ghost checks.
- R2-D.5: drop unreachable serviceSlugs.length === 0 ternary inside the
  picker — the outer guard already requires length > 0.
2026-04-25 19:31:25 -07:00
Jordan Ritter 46aa14c35f fix(shell-dashboard): wire page.tsx to useProbes + useTriggerProbe
Replace the no-op handleTrigger stub and empty-list placeholder with real
hooks. useProbes feeds the schedule grid via 10s polling; useTriggerProbe
POSTs /trigger with the operator token. If NEXT_PUBLIC_OPS_TRIGGER_TOKEN
is unset, the trigger throws a clear "token required" error so operators
see fail-loud feedback instead of a silent no-op (R2-D.1).
2026-04-25 19:31:18 -07:00
Jordan Ritter 5cd22270a3 fix(shell-dashboard): keep useTriggerProbe POST alive on unmount, swallow supersession AbortError
POST to /trigger is non-idempotent — the server may have already received
the request and queued the run by the time the component unmounts.
Aborting the controller client-side hides the result, but does NOT
unfire the server-side action. Stop aborting on unmount; rely on
aliveRef to gate any setState after teardown.

Back-to-back trigger() calls intentionally abort the prior controller.
The first call's promise was rejecting with AbortError, surfacing as a
user-facing error on the Status tab. Detect AbortError (and the
cancelled-controller case) in the catch block and resolve silently
instead of setError + rethrow. The owning-controller guard on the
finally block prevents a superseded call from flipping pending off
while a newer call is still in flight.

Bonus consistency for useProbes: setLoading(true) on dep change so
consumers can render a "refreshing" indicator across baseUrl swaps.

Tests:
- unmount during in-flight trigger does NOT abort the signal
- post-unmount resolution does not call setState (no act warnings)
- back-to-back triggers: first promise resolves silently, error stays null
2026-04-25 19:29:20 -07:00
Jordan Ritter 0243aa39bd fix(shell-dashboard): preserve AbortError name through ops-api error wrapping
ensureOk and parseJson previously caught body-read failures and rewrapped
them in a generic Error, losing the AbortError name. Hooks filter abort
noise via err.name === "AbortError", so the wrap defeated cancellation
filtering and surfaced "AbortError" as a user-facing failure on the
Status tab.

Re-throw AbortError as-is from both helpers; non-Abort body errors keep
the descriptive wrap path. Also tighten ProbeKind to use (string & {})
so known-kind autocomplete is preserved alongside forward-compat strings.

Add covering tests:
- response.text() rejects with AbortError → ensureOk re-throws as-is
- response.json() rejects with AbortError → parseJson re-throws as-is
- non-Abort body errors still wrap with descriptive message
- triggerProbe with already-aborted signal surfaces AbortError unwrapped
2026-04-25 19:29:08 -07:00
Jordan Ritter 15af4c4779 test(showcase-dashboard): tighten matrix tests, cover starter cells and regressions filter
Backstop the matrix fixes and address weak/no-op assertions:

  - Tighten depth-chip count assertions from '>= N' to exact 'N'
    where the fixture pins the count.
  - Replace the no-op filter=wired test with a fixture containing a
    feature whose every cell is unshipped, then assert that row IS
    visible under filter=all and IS HIDDEN under filter=wired.
  - Extend the collapse test with a re-expand click that asserts
    chip count returns to baseline (regression guard for one-way
    toggle state).
  - Cover filter=regressions empty-state.
  - Cover starter cells (feature === null) in both matrices: the
    cell index must skip them without polluting the grid or leaking
    a 'null' string into rendered output.
  - Pin parity-matrix integration-header ordering across all five
    parity tiers (reference, at_parity, partial, minimal, not_wired).
  - Cover D2 cap for starter cells in deriveDepth (chat/tools cannot
    lift past D2 because D3 is not evaluable without a feature id).
  - Replace the locally-declared CatalogCell shape in depth-utils.test
    with the source type so tests cannot drift from production.
  - Add afterEach(vi.unstubAllGlobals) to both test files so the
    localStorage stub does not leak across files in the same worker.
2026-04-25 19:25:09 -07:00
Jordan Ritter f6742c9871 fix(showcase-dashboard): drop depth typecasts and harden matrix cell index
Three related cleanups in cell-matrix.tsx and parity-matrix.tsx:

  1. Drop the 'as 0 | 1 | 2 | 3 | 4' typecasts on depth.achieved. The
     newly tightened DepthResult.achieved literal-union type makes
     them redundant.

  2. Skip starter cells (feature === null) when building the
     cell-by-key index. The previous code interpolated null into the
     key string, producing '<slug>/null' entries that orphaned
     starter rows in the index.

  3. Render an explicit empty-state in CellMatrix when filter is
     'regressions'. The placeholder logic returned false for every
     row, silently hiding the entire matrix. Until regression
     detection lands (DepthResult.isRegression always false), the
     matrix now surfaces a 'Regression detection not yet implemented'
     message via data-empty-reason='regressions-not-implemented'.
2026-04-25 19:25:09 -07:00
Jordan Ritter 56e8f41861 fix(showcase-dashboard): tighten DepthResult.achieved to 0|1|2|3|4 literal union
deriveDepth's return type carried the loose 'number' on achieved, forcing
every consumer to cast to '0 | 1 | 2 | 3 | 4' to satisfy DepthChip's
prop type. Expose AchievedDepth alias and narrow the source so the
casts at use sites (cell-matrix.tsx, parity-matrix.tsx) become
unnecessary.
2026-04-25 19:25:08 -07:00
Jordan Ritter efba71b224 fix(showcase-dashboard): CellStatus drops smoke + hides D5/D6 for testing features
Per-cell status row corrections + a focused unit-test file covering CP1,
CP2, CP5, CP7, CP8 behaviors:

- CP3: stop consuming `cell.smoke` in CellStatus. resolveCell still
  computes the field for backwards-compat with other call-sites, but
  per Phase 3 Decision #7 the smoke L1 signal is integration-scoped and
  lives in the per-integration strip — there's no per-feature smoke
  badge in the cell row. (The CellState type narrowing for the `smoke`
  field is owned by the live-status.ts fix in a sibling worktree; this
  patch only removes the consumer.)
- CP8: hide the D5/D6 LiveBadges when feature.kind === "testing".
  The e2e-deep / e2e-parity producers only emit rows for primary
  features per spec; testing features would otherwise render a
  perpetual gray "?" that adds noise without information. Mirrors the
  docs-row visibility rule (already gated on isTesting).
- CP9: document the missing per-cell drilldown URL convention for D5/D6
  with a TODO comment. No drilldown route exists today; once one does,
  wire it through keyFor here.

Tests cover red-green for CP1 (mouseleave handler wired), CP2 (first /
error / green_to_red transition formatting), CP5 (opt-out vs absent
tooltip copy), and CP8 (D5/D6 hidden for testing features). The
existing docs-link.test.tsx still passes.
2026-04-25 19:25:08 -07:00
Jordan Ritter 2900998889 fix(showcase-dashboard): LiveBadge tooltip-open lifecycle and transition copy
- CP1: wrap Badge in a span that resets `tooltipOpen` on mouseleave/blur
  so the lazy-fetch flag can flip back to false. Without this, the flag
  stayed true forever after the first hover, defeating the §5.6
  optimization and (once the hook upgrades to a subscription) leaking it.
- CP2: discriminate `first` and `error` transitions in the tooltip copy
  so operators can tell "first observation (initial: <state>)" apart
  from "producer error (error → <state>)" instead of seeing the same
  fallback string. Logic moved into a named `formatTransitionLine`
  helper for testability.
2026-04-25 19:25:08 -07:00
Jordan Ritter 72275e8b2f fix(showcase-dashboard): tighten DocsRow/DocsLink — exhaustiveness, honest tooltips, error linkability
Four fixes to the per-cell docs row:

- CP4: extract the `unselected` shell-routing fallback into
  SHELL_UNSELECTED_PATH so the coupling is documented and easy to find.
- CP5: split the `missing` tooltip into "framework opt-out" vs "no docs
  URL declared" so operators can distinguish a framework that declined
  docs from a registry entry that never declared one.
- CP6: add `default: never` exhaustiveness guard to DocsLink's switch so
  any new DocState variant is a compile error until handled.
- CP7: render `<a>` (not `<span>`) for `state === "error"` when an href
  is present so operators can manually verify a flaky probe — `notfound`
  stays a span because the URL is positively known broken.
2026-04-25 19:25:08 -07:00
Jordan Ritter 3a826168c8 fix(showcase-dashboard): preserve array identity on no-op upsert and warn on merge collisions
LS6: `mergeRowsToMap` now logs a `console.warn` when the same key is
seen twice across the merged groups. Each dimension is supposed to own
a disjoint slice of the keyspace, so a collision is an upstream bug.
Return semantics are unchanged (last-wins remains correct under
eventual consistency), but the warn surfaces the regression in dev mode.

LS7: `upsertByKey` returns the SAME array reference when the incoming
row is observably identical (key + state + observed_at +
transitioned_at). Producers re-emit unchanged rows on every poll tick;
allocating a fresh array for each one defeats React's reference-equality
short-circuit and re-renders every memoised consumer downstream.

Tests cover no-op identity, mutation paths (state change,
observed_at change), the disjoint-merge happy path, and the collision
warn.
2026-04-25 19:25:08 -07:00
Jordan Ritter ae0bf40724 fix(showcase-dashboard): close stale-green hole and align smoke key shape
LS1: `allGreen` now requires BOTH `healthRow` and `e2eRow` to be present
and green. Previously a missing e2e row was treated as green-eligible,
which let a brand-new cell read green before any e2e probe had ticked —
the same flavour of stale-green lie as the connection=error case.

LS11: smoke lookup now uses the integration-scoped key (`smoke:<slug>`)
to match the producer. Pre-fix, resolveCell looked up
`smoke:<slug>/<featureId>` which always missed, leaving every smoke badge
gray. Tests update fixtures accordingly and add a regression guard so a
stray feature-keyed smoke row cannot bleed into the resolver.
2026-04-25 19:25:08 -07:00