Skip to content

fix(api): report the classified cause of a failed run on /api/v1/stats - #1301

Merged
krisarmstrong merged 1 commit into
mainfrom
fix/d-stem-5-stats-error-message
Sep 16, 2026
Merged

krisarmstrong merged 1 commit into
mainfrom
fix/d-stem-5-stats-error-message

Conversation

@krisarmstrong

Copy link
Copy Markdown
Collaborator

Summary

/api/v1/stats now carries the classified cause of a failed run, so the
Reflector page can say what went wrong instead of "The reflector stopped with
an error".

ui/src/types/api.ts:42 declared Stats.errorMessage and
ReflectorPage.tsx:242 renders stats.errorMessage || t('reflector.stoppedWithError'),
but the Go Stats struct had no such field — the four statusError sites
logged the real cause server-side only. A bind conflict, a missing capability or
a vanished interface all looked identical to the operator, who had to read the
daemon log to diagnose the Free tier's own feature.

What the wire carries. internal/api/run_error.go maps the failure onto a
closed set of five sentences — interface or port busy, permission, interface
missing, peer unreachable, and a deliberately vague fallback. The raw error is
never echoed: it names interfaces, socket paths and peer addresses, which are
the daemon host's business and not stable API. That is the same no-leak policy
respondTestExecutionError already applied to TestResultResponse.Error; the
daemon log keeps the full text for support. Matching is on substrings because
the text arrives from three layers (Go net, the cgo dataplane, module
executors) that word the same condition differently.

Server.testError is set at all four failure sites, cleared by beginTestRun
and beginRunPlan so a stale cause cannot describe a run that is not failing,
and mirrored into the snapshot by snapshotStats.

Why check-types-drift.sh missed it. It regenerates
ui/src/types/generated/*.ts from the committed schemas and diffs the result.
Stats is not in that set: ui/src/types/api.ts is hand-written and nothing
compares it to the Go struct, so a TS-only field is invisible to the gate. That
is a gap, not a bug in this change — rowed as D-STEM-13 in the plan rather
than widened into this PR.

One thing the row's site list gets wrong. runModuleTest's asynchronous
failure branch (executors.go) has no production caller: executeTest is
reached only from startReflectorRequest, whose module is always the reflector,
which returns through executeReflector before it. The bookkeeping is kept
consistent there and covered by a direct test, and the dead path is noted in
that test rather than silently propagated — also rowed (D-STEM-13) rather than
deleted here.

Linked Issue

Fixes #1251

Type of Change

  • Bug fix (a field the UI reads that the wire never carried)

Risk

Low. One additive, omitempty JSON field; no route, auth or dataplane change. The
field is empty unless a run failed, so a client that ignores it sees today's
behaviour. The only UI change is a test fixture that had invented wording the
daemon never sent.

Testing Evidence

Red first — the field did not exist:

$ go test ./internal/api/ -run TestStats
internal/api/stats_error_test.go:132:11: stats.ErrorMessage undefined (type api.Stats has no field or method ErrorMessage)
FAIL	github.com/MustardSeedNetworks/stem/internal/api [build failed]

Green, and each of the four production sites proven load-bearing by deleting
its assignment and re-running the suite (line-based deletion — an earlier
regex attempt cut the line mid-call and the resulting build failure would have
been mistaken for a caught mutant):

executors.go        assignment removed -> 1 failing test
handlers_testing.go assignment removed -> 1 failing test
run_plan.go         assignment removed -> 4 failing tests
reflector_state.go  assignment removed -> 1 failing test
snapshotStats mirror removed -> 3 failing tests

Gates (golangci-lint reports v2.13.2, the pinned CI version):

$ make lint-go
0 issues.
$ make lint-frontend
Checked 255 files in 753ms. No fixes applied.
$ make fmt-check
✓ All formatting checks passed
$ make test
[1/2] Backend (Go)   32 packages, all ok
[2/2] Frontend       561 tests passed
EXIT=0
$ npx vitest run            # re-run after the ReflectorPage fixture change
Tests  561 passed (561)
$ govulncheck ./...
No vulnerabilities found.  EXIT=0

$ for s in token-discipline file-size filename-policy output-escaping \
           request-fields route-policy schema-drift stale-tests; do …
check-token-discipline.sh      exit=0
check-file-size.sh             exit=0
check-filename-policy.sh       exit=0
check-output-escaping.sh       exit=0
check-request-fields.sh        exit=0
check-route-policy.sh          exit=0
check-schema-drift.sh          exit=0
check-stale-tests.sh           exit=0
check-banned-vocabulary.py     exit=0

The acceptance itself — the field on the wire after a forced failure, and the
TS type matching the Go struct:

$ go test ./internal/api/ -run 'TestStats|TestClassify|TestRespondTestExecutionErrorRecords|TestAutostartReflectorRecords|TestRunModuleTestFailureRecords' -count=1
ok  	github.com/MustardSeedNetworks/stem/internal/api	0.776s

Go:  ErrorMessage string `json:"errorMessage,omitempty"`
TS:  errorMessage?: string;

Security and Release Checklist

  • The wire never repeats the raw error — interfaces, socket paths and peer
    addresses stay in the daemon log (table test asserts the non-echo)
  • No new dependency, no lockfile change
  • No change to auth, CSRF, rate limiting or role gating
  • govulncheck ./... clean
  • No //nolint, no biome-ignore
  • No customer-facing AI or banned vocabulary

The UI declared Stats.errorMessage and ReflectorPage renders it as the failure
headline, but the Go struct /api/v1/stats serialises had no such field: the four
statusError sites logged the cause server-side only, so a bind conflict, a
missing capability and a vanished interface all reached the operator as the
generic "stopped with an error".

internal/api/run_error.go classifies the failure into a closed set of five
sentences and never echoes the raw error, which names interfaces, socket paths
and peer addresses -- the same no-leak policy respondTestExecutionError already
applied to TestResultResponse.Error. Server.testError is set at all four sites,
cleared when the next run begins so a stale cause cannot describe a run that is
not failing, and mirrored by snapshotStats.

check-types-drift.sh could not have caught this: it diffs the generated TS tree
against the committed schemas, and Stats lives in the hand-written api.ts, which
nothing compares to the Go struct. runModuleTest's failure branch turns out to
have no production caller. Both are rowed as D-STEM-13 rather than widened into
this change.

Fixes #1251
@krisarmstrong
krisarmstrong force-pushed the fix/d-stem-5-stats-error-message branch from f25c617 to 3e0846f Compare September 16, 2026 19:09
@krisarmstrong
krisarmstrong added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 4bc23b3 Sep 16, 2026
38 checks passed
@krisarmstrong
krisarmstrong deleted the fix/d-stem-5-stats-error-message branch September 16, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): the UI renders Stats.errorMessage but /api/v1/stats never carries it

1 participant