Commit Graph

3254 Commits

Author SHA1 Message Date
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
Jordan Ritter ec94f64886 fix(showcase-dashboard): make formatTooltip honest and signal-aware
- Drop the hardcoded '>6h' duration from the degraded tooltip; the
  threshold lives in the producer config and lying about it in copy
  misleads operators when the cadence changes.
- Replace the empty-string default branch with a loud unknown-state
  fallback gated by a `never` exhaustiveness check, mirroring rowTone /
  formatLabel.
- For red and degraded states, append a truncated (80-char) summary of
  `row.signal` so the tooltip carries triage context without ballooning.
- When the SSE connection is in error AND the cached row is red or
  degraded, append the row's last-known state so operators don't lose
  triage context to the offline banner.
2026-04-25 19:25:08 -07:00
Jordan Ritter 6f057665d6 fix(showcase-dashboard): add keyFor input guards and exhaustive state checks
- keyFor now throws when slug or featureId contain ':' or '/', preventing
  silent collisions in the live-status lookup map (e.g. `smoke:a/b` vs
  `smoke:a` + `/b`).
- rowTone() and formatLabel() switch over the State union with explicit
  `never` exhaustiveness checks so adding a new variant fails the build
  instead of silently mapping to a misleading green/gray default.
- Unknown row states now surface a distinct 'error' tone (rowTone) and a
  '?' glyph (formatLabel) rather than a silent green check.
- Tests cover the three throw paths and remain green for the existing
  per-feature key shapes.
2026-04-25 19:25:07 -07:00
Jordan Ritter 5f1b4f6b9c fix(showcase-ops): add missing aimock fixtures for byoc-json-render
Adds two feature-parity.json fixtures for the langgraph-python
byoc-json-render E2E spec prompts that the
aimock-fixture-coverage probe flagged as uncovered:

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

Responses mirror the worked examples baked into the
byoc_json_render_agent system prompt verbatim, so the fixture
output matches what the live LLM would emit (a single
@json-render/react flat-spec JSON object) and the spec
assertions on [data-testid="pie-chart"] /
[data-testid="bar-chart"] resolve deterministically when the
suite runs against the aimock sidecar.
2026-04-25 19:25:07 -07:00
Jordan Ritter d725ca308f fix(showcase-ops): stabilize probe-loader chokidar watch test
Replace the real-chokidar watch test with a vi.mock that swaps chokidar
for an in-memory EventEmitter. The original test relied on macOS
fsevents firing within 500ms of fs.writeFile, which fails ~5/5 runs
locally. Same pattern as rule-loader.test.ts, which abandoned its real-
watcher path for the identical reason. Adds a companion test asserting
the 100ms debounce collapses bursty add/change events into one reload.

Probe-loader test suite is now deterministic across 5 consecutive runs.
2026-04-25 19:25:07 -07:00
Jordan Ritter 0d0ca7f046 fix(showcase-ops): gen-ui-headless — interpolate selector via JSON.stringify, not in-page cascade
The previous `readChildCount(page, selector)` body did an in-page
re-resolution cascade across multiple selectors, which obscured which
selector actually drove the count and made the function impossible to
test for selector-fidelity. Swap to a single-selector helper
`readChildCountForSelector` whose evaluate fn embeds the selector via
`JSON.stringify(selector)` (no string interpolation, no XSS surface),
then explicitly cascade at the call site. Test asserts the selector
is interpolated literally in the evaluate fn source so the wiring
stays visible.

Findings addressed: A11.
2026-04-25 19:25:07 -07:00
Jordan Ritter 467d4eb433 fix(showcase-ops): HITL scripts — runtime page-shape guard, anchored selectors, post-stable settle
Three HITL hardenings:

1. Runtime page-shape guard (A9). The `page as HitlPage` cast was a
   silent type widening; if a fake page passed by a test lacked
   `click()`, the script would silently no-op rather than fail loudly.
   Switch to `page as unknown as HitlPage` and add a typeof check
   that throws "page is missing click() — cannot drive HITL dialog"
   if the method is absent. Applied symmetrically to both HITL
   scripts (approve-deny + text-input).

2. Anchored selectors (A10). Both `approveOrDeny` (button selectors)
   and `pickTimeSlot` (slot selectors) previously queried the page
   globally, which could match buttons on the surrounding chat or in
   a tooltip layered above the dialog. Scope every candidate selector
   under its dialog/card root: `${dialogSelector} ${sel}`. Test
   coverage asserts the scoped form is what's actually used.

3. Post-stable settle (A17). `waitForNextAssistantMessage` previously
   returned as soon as the assistant message count grew past
   baseline, but follow-up tokens could still be streaming when the
   next assertion ran — racing the SSE tail. Add a phase-2 wait for
   `ASSISTANT_FOLLOWUP_SETTLE_MS = 1500` of count-stability after
   growth detection so the response is fully committed before we
   move on.

Findings addressed: A9 (page-shape guard), A10 (anchored selectors),
A17 (post-stable settle).
2026-04-25 19:25:07 -07:00
Jordan Ritter fe0fce3947 fix(showcase-ops): narrow assistant-message poll to exclude user articles
`readMessageCount` previously matched bare `[role="article"]` as a
fallback, which counted user-input bubbles as assistant messages on
composers that tag both. The poll could "settle" before the assistant
even started streaming because the user's just-sent message bumped
the count past the baseline.

Fix: prefer the canonical `[data-message-role="assistant"]` testid;
fall back to `[role="article"]:not([data-message-role="user"])` so we
never count user articles. Two new tests cover the tagged-assistant
path and the fallback narrowing path with a fake document patched in
via `evaluate`.

Findings addressed: A7.
2026-04-25 19:25:07 -07:00
Jordan Ritter 55868c6f09 fix(showcase-ops): split contract-axis failures into missing vs type-mismatched
The contract axis previously dumped both "field absent" and "field
present but wrong type" into one `missing_fields` array, conflating
two operationally distinct signals: a vanished field is a delete,
a typed-differently field is a shape regression — the dashboard
triagers handle them differently. Add `type_mismatched_fields` to the
report `details.contract` shape and route each failure mode to the
correct bucket. The verdict still fails on either bucket being
non-empty; only the breakdown is split. Test coverage updated with a
direct case asserting both arrays land their entries correctly.

Findings addressed: A14.
2026-04-25 19:25:07 -07:00
Jordan Ritter 088d88c92c fix(showcase-ops): D6 driver — fleet caching, rotation determinism, side-row emission on launcher error
Three independent fixes in the D6 parity driver, all touching the same
control flow:

1. Stop caching fleet-resolver failures. The pre-fix code assigned
   `cache = []` on any error, which permanently disabled D6: a
   transient ENOENT during process startup would lock the resolver to
   `[]` forever. Now we leave `cache` null on error so the next call
   retries (matching what operators get from a process restart) and
   log a warning with the registry path + error.

2. Stop appending the invocation slug to the fleet when missing.
   Per-invocation appends broke rotation determinism: different
   invocations with different slugs all produced different fleet sizes,
   and `weekNumber % len` then picked different integrations within
   the same tick. Registry drift now correctly results in "this slug
   sits out the rotation" with a `self-not-in-fleet` warning the
   operators can act on.

3. Emit per-feature side rows on launcher error. Previously, when
   Chromium failed to launch we only emitted the aggregate red — every
   feature's individual cell would render whatever state the previous
   tick left behind, including "green" from an earlier successful run.
   The launcher-error branch now writes a `d6:<slug>/<ft>` side row
   for every runnable feature with `errorClass: "launcher-error"` and
   `errorDesc` (truncated to 1200 utf8 bytes), so the dashboard's
   per-feature drilldown stays truthful.

Test coverage updated to assert all three behaviours (fleet-error
retries on next call, self-not-in-fleet warning + scoping note,
launcher-error side rows for every runnable featureType).

Findings addressed: A4 (fleet caching), A12 (self-not-in-fleet),
A13 (launcher-error side rows). Wires in the script loader from the
prior commit via the new `scriptLoader` driver dep.
2026-04-25 19:25:06 -07:00
Jordan Ritter c47705c9e0 fix(showcase-ops): export D5 script-loader & matcher; share with D6 + CLI
The script-loader was previously module-private, so the D6 parity
driver and the `d6-capture-references` CLI both ran with an empty
`D5_REGISTRY` whenever they were invoked standalone (without `e2e-deep`
running first in the same process). Symptoms: every featureType
classified as `skippedScript`, D6 silently no-op, D6 capture run
reports "no features captured" that looks like success.

Changes:

  * Lift the filename matcher to an exported `D5_SCRIPT_FILE_MATCHER`
    constant. The new regex explicitly rejects co-located `*.test.{js,ts}`
    files (which would re-import the script under test and trigger
    double-registration throws) and `*.d.ts` declaration files. Covered
    by 4 new tests asserting accept/reject behaviour.
  * Export `defaultScriptLoader` so callers can reuse one canonical
    implementation. The D6 CLI now invokes it at startup and fails
    fast if the registry is still empty afterwards (with a clear
    "check that scripts/d5-*.ts call registerD5Script" hint).
  * Replace the `feature as D5FeatureType` cast in the CLI with the
    `isD5FeatureType` type guard from this commit's parent.

Findings addressed: A1 (matcher hardening), A2 (loader export +
fail-fast in CLI).
2026-04-25 19:25:06 -07:00
Jordan Ritter 9d8c9dec66 fix(showcase-ops): make D5 registry registration atomic + add type guard
Two changes to the D5 registry:

1. Atomic registration. The pre-fix loop validated and inserted in a
   single pass, so a script with `featureTypes: ["a", "b"]` whose `b`
   collided would still set `a` before throwing — leaving the registry
   in a partial state. Split into a pre-pass that validates every
   featureType BEFORE inserting any. Covered by a new test that
   asserts neither key is registered when the second collides.

2. Add `isD5FeatureType` runtime type guard backed by a closed-set
   readonly mirror of the type union (kept honest by `satisfies`).
   Replaces ad-hoc `value as D5FeatureType` casts that would let an
   unknown CLI/env-var string slip past the type system. Covered by
   tests for accept/reject/non-string/narrowing.

Findings addressed: A6 (atomic registration), A8 (type guard).
2026-04-25 19:25:06 -07:00
Jordan Ritter 489e365a30 fix(showcase-ops): canonicalize cron expressions in probe configs
The `*/60 * * * *` form is non-canonical: the divisor equals the field
range (0-59), and some cron parsers reject it as invalid stepping.
Switch to `0 * * * *` ("every hour, on the hour"), which every parser
accepts. Also clarify in the e2e-parity comment header that the
`schedule` field IS the source of truth — the orchestrator reads it
directly via `cfg.schedule`.

Findings addressed: A3.
2026-04-25 19:25:06 -07:00
github-actions[bot] 938d734c34 style: auto-fix formatting 2026-04-26 02:15:30 +00:00
Jordan Ritter 07e29ee343 Merge R4-F3: driver timeout threading + yaml/parity hardening 2026-04-25 19:15:06 -07:00
Jordan Ritter 62c07210b1 Merge R4-F2: railway-services discovery hardening 2026-04-25 19:15:06 -07:00
Jordan Ritter c743bebb6a merge: pull origin auto-format commit
# Conflicts:
#	showcase/shell-dashboard/src/components/status-duration-sparkline.tsx
2026-04-25 19:14:14 -07:00
Jordan Ritter ac63cbce7c test: monotonic dispatch ordering + env-threading + abort-fanout coverage
Three test improvements landed together because they share the same test
file and target the same R4 CR findings:

  G/I — replace Date.now()+sleep timing in shortest-service-first
        integration test with a monotonic ordinal counter. Removes the
        millisecond-tie flake risk on fast hosts and drops ~7s of test
        wall-clock for a strictly-ordered semantic assertion.

  A   — pin the resolution order for the driver-internal cap:
        E2E_DEMOS_TIMEOUT_MS env > deps.timeoutMs > built-in default.
        Two new tests: env override wins, invalid env value falls back.

  L   — verify driver fires the internal cap MID fan-out and side-emits
        errorClass: 'abort' rows for every remaining demo (not just an
        aggregate). Per-demo dot semantics depend on this — silent skip
        would leave dashboard cells gray rather than red.
2026-04-25 19:14:07 -07:00
Jordan Ritter 6e5427922a fix(shell-dashboard): null-guard lastRun.summary in status-table
After B1 tightened ProbeLastRun.summary to ProbeRunSummary | null, three
sites in status-table.tsx accessed .summary fields without null-guards
(TS2533 errors at lines 121 and 165).

- lastRunTone: when summary is null but state === "completed", return
  "gray" (matches runs-list "unknown" semantics — finished but no count
  info means we can't claim coverage). state === "failed" still returns
  "red" regardless of summary.
- Result cell: when summary is null, render "—" (matches existing
  fallbacks for unknown values); otherwise render passed/total as before.

Adds two covering tests for the null-summary completed (gray + "—") and
null-summary failed (red + "—") cases.
2026-04-25 19:12:41 -07:00
Jordan Ritter 8863dc09cc merge: cr-fix-C-page (drop as-unknown double cast) 2026-04-25 19:09:54 -07:00
Jordan Ritter 08aad36af8 fix(showcase-ops): scope scheduler runId counter per instance
Move runIdCounter and nextRunId from module scope into the createScheduler
closure so each Scheduler gets its own counter starting at 1. Previously two
schedulers in the same process (test fixtures, future multi-tenant cases)
shared a single counter and observed leaked, monotonically-increasing IDs
across instances — a real test-isolation defect even though production runs
a single scheduler today.

Adds a covering test that creates two Schedulers, triggers each, and asserts
both first-run IDs end with counter "1" rather than a shared sequence.
2026-04-25 19:09:25 -07:00
Jordan Ritter 8452cfab83 test: hoist cleanups declaration + fix tmp dir leak on setup throw
Three related fixes to the e2e-demos driver test file:

1. Hoist `const cleanups: Array<() => void> = []` + `afterEach(...)`
   from the BOTTOM of the `describe("e2e-demos driver", ...)` block to
   the TOP. Vitest's hoisting kept this working at runtime, but the
   source-ordering smell was fragile — a future test that pushes
   BEFORE the array initializer line would hit a ReferenceError. Top
   placement eliminates the footgun.

2. Move `cleanups.push(() => fs.rmSync(tmp, ...))` to IMMEDIATELY
   after `fs.mkdtempSync(...)` — before any subsequent `writeFileSync`
   that could throw. Previously the push happened AFTER writeFileSync,
   so a disk-full / permission throw would orphan the tmp dir for the
   rest of the test process's lifetime.

3. Align an inline writer mock that returned `{}` with the surrounding
   `mkWriter` helper which returns `undefined`. ProbeResultWriter's
   `write` is typed `Promise<unknown>` so both work, but the
   inconsistency was a needless code smell.
2026-04-25 19:08:32 -07:00
Jordan Ritter a9476bf404 test: tighten railway-services test stubs and abort propagation coverage
- makeCtx now provides a fresh AbortController().signal by default
  rather than leaving abortSignal undefined; pass abortSignal:undefined
  explicitly to opt into the legacy behaviour. Catches regressions
  that drop signal forwarding.
- Add abort-propagation test that fires the controller mid-flight and
  asserts the next gql() call sees aborted state. The legacy 'threads
  ctx.abortSignal' test asserts identity equality but never aborts —
  a regression forwarding a different never-aborted signal would
  still pass.
- Add concurrent-noise isolation test that stages REGISTRY_JSON_PATH
  to a tmp file so 5 parallel enumerate() calls don't fall through to
  /app/data/registry.json and emit log noise into CI.
- Add starter slug contract test asserting that
  showcase-starter-ag2 → starter-ag2 misses the registry (which uses
  bare integration slugs like ag2) and produces demos: [] — matches
  the e2e-demos driver's shape-gated fan-out (it doesn't dispatch
  for starters anyway).
- Document the same starter-slug contract on
  deriveSlugFromServiceName().
2026-04-25 19:07:27 -07:00
Jordan Ritter 8b3f53aefb fix(shell-dashboard): replace double cast with single CatalogData cast
CR-C-page.1: `catalog as unknown as CatalogData` violated the project
policy against double casts. catalog.json is generated by pretest/
predev/prebuild from generate-registry.ts and conforms to CatalogData
by construction, so a single `as CatalogData` cast is safe and clearer.

Typecheck: no new errors (pre-existing __tests__ + status-table.tsx
errors are unrelated and unchanged). All 220 vitest tests pass.
2026-04-25 19:07:22 -07:00
Jordan Ritter d32004c53e test: expand probe-config-parity to all railway-services probes + namePrefix
The previous parity test only checked nameExcludes across the
canonical trio (smoke, e2e-smoke, e2e-demos). Two gaps closed:

1. namePrefix parity is also load-bearing — a typo on one would
   silently scope it to a different service set. Mechanise it in
   the same test so a drift in either field fails CI together.

2. Other railway-services-backed YAMLs (image-drift, qa, ...) need
   the same infra-exclude floor or must be explicitly exempted.
   Glob all YAMLs, find the railway-services-source ones, and
   assert their nameExcludes is a SUPERSET of the smoke set. Add
   RAILWAY_PROBES_EXEMPT_FROM_EXCLUDES_FLOOR for the two probes
   that legitimately don't carry the floor (image-drift checks
   ALL services including infra; qa is scoped to one service via
   precise namePrefix).

Also add a vacuous-parity guard (smoke set must be non-empty) so
three empty lists can't accidentally pass.
2026-04-25 19:06:08 -07:00
Jordan Ritter 71a5b31ce7 docs: align e2e-demos.yml demo-count math and timeout_ms claims
Pick a single canonical framing for the demo-count math: 17 framework
packages averaging ~32 demos each with the largest service carrying
38, total ~544 typical page loads per tick. The earlier "~32 demos
each" + "38-demo service" were not internally consistent.

Also update the timeout_ms block to describe the new orchestrator-
threaded driver-internal cap (E2E_DEMOS_TIMEOUT_MS) so the YAML's
documented behaviour ("driver iterates remaining demos and side-
emits abort rows when the cap fires at 1200000ms") is now truthful
under the singleton-driver registration model.

nameExcludes already use the unquoted style — no quote changes
needed (the parity test is what guards drift now).
2026-04-25 19:05:59 -07:00
Jordan Ritter 31f41c9b83 fix: thread cfg.timeout_ms into e2e-demos driver via env var
The driver was registered as a singleton at orchestrator boot with
no per-probe deps, so its internal hard-cap defaulted to 5 min while
the YAML's timeout_ms documented 20 min — the driver would abort
mid-fan-out 15 min before the invoker's outer race fired,
contradicting the YAML's "driver iterates remaining demos and
side-emits abort rows when the cap fires at 1200000ms" claim.

The driver now reads ctx.env.E2E_DEMOS_TIMEOUT_MS per-run() in front
of the deps default, and the orchestrator projects each probe's
cfg.timeout_ms onto process.env.E2E_DEMOS_TIMEOUT_MS at probe-load
time. Singleton registration stays intact; the env-thread carries
the YAML value into the driver without rebuilding it.

Invalid env values (non-numeric / zero) fall through to deps default
so a typo can't silently disable the cap.
2026-04-25 19:05:51 -07:00
Jordan Ritter d8bac807af refactor: drop dead env reassignment + redundant path.resolve
- Remove path.resolve() wrap on the absolute fallback registry path —
  path.resolve() is a no-op for absolute paths.
- Drop the unused 'node:path' import.
- Drop the redundant 'env = {}' reassignment inside the variables
  schema-success branch; the variable is already initialised to {}
  at declaration. Schema-failure path now relies on the initialiser
  with an explicit comment explaining the contract.
2026-04-25 19:05:18 -07:00
Jordan Ritter 06703b1519 merge: cr-fix-C-server CR fixes 2026-04-25 19:05:05 -07:00
Jordan Ritter d24a9d9a5f merge: cr-fix-B2 CR fixes 2026-04-25 19:05:05 -07:00
Jordan Ritter 3440eb857e merge: cr-fix-B1 CR fixes 2026-04-25 19:05:05 -07:00
Jordan Ritter 94aa58c15b merge: cr-fix-A2 CR fixes 2026-04-25 19:05:04 -07:00