fix(cli,aspire): generated apps report Healthy only once they can server-render - #963
Conversation
…nerated apps Records the harness run for fix/aspire-app-health-probe: research (including a verified `aspire restore` against SDK 13.4.6 confirming `ExecutableResource.withHttpHealthCheck` and its options-object signature), the plan, and the Design checkpoint with five commit slices. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves an app entry can name the HTTP path Aspire should probe, and that the scaffold has a single named default for it instead of a literal buried in a generator template. `RESOURCE_DEFAULTS.AppHealthCheckPath` sits beside `HttpEndpointName`, which the probe reuses as its endpoint name. `AppEntry.HealthCheckPath` is optional and accepts `false`, so an app that serves no health route can opt out rather than sit permanently Unhealthy. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves an Aspire resource for a generated Fresh app is no longer considered ready the instant its process spawns. Aspire treats a resource with no registered health check as ready as soon as it reaches `Running`. The Aspire helper generator registered none, so an app whose every request failed during SSR still showed green on the dashboard and satisfied `aspire wait` (#954). `generateRegisterApps` now emits `withHttpHealthCheck({ path, endpointName })` for `app` entries that expose a port, immediately after the endpoint whose base address the probe resolves against. Only the `app` type gets a probe: `tauri`, `desktop`, and `task` own no HTTP page contract, and `desktop` is not given an endpoint at all. The emitted call uses the options-object form. Aspire's published docs show `withHttpHealthCheck('/health')`, but the TypeScript SDK generated by `aspire restore` for SDK 13.4.6 declares `withHttpHealthCheck(options?: WithHttpHealthCheckOptions)` on `ExecutableResource`; the positional form would throw at AppHost start. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves the route the AppHost probes still renders through the page layer, so the probe added in the previous slice keeps exercising the SSR pipeline rather than a JSON short-circuit. Aspire's probe sends no `Accept` header, so it lands on the server-rendered branch. Narrowing that branch later — returning JSON for an unspecific Accept, say — would silently restore "Healthy while every page returns 500" without touching the generator at all. The route's own doc comment now states the contract for the next person to edit it. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proves the merge-readiness suite would catch a recurrence of #954 rather than pass straight through it. The suite started the whole AppHost, waited on every database, cache, and plugin resource and probed their HTTP health — but never waited on the generated app and never issued a single request to any app route. `behavior.ui-render`, the only app-shaped gate, renders AI payload components in-process and never touches the running server. An app that returned 500 to every request passed the suite. Two paired gates close that hole: `runtime.wait.dashboard` blocks on the app's new HTTP health probe (300s, covering Vite's cold start and first render), and `behavior.app-home` fetches the home page and requires a 2xx that is actually HTML. Asserting the status alone would not do — a 500 error page is `text/html` too. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3efbffe to
1780ffd
Compare
Closes the run's Gate phase: fmt/lint/check/test/arch/doc-lint results with counts, the fail-before output for the new generator guard, and the `aspire restore` verification that fixed the emitted SDK call shape. `scaffold.runtime` is recorded as NOT RUN rather than skipped — this host has no database containers, so the two new E2E gates are proven by registration tests only. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
[PHASE: IMPL] Five slices landed; #954 root-caused to a missing Aspire health check, not a mis-aimed one. What the root cause turned out to beThe Aspire helper generator registered no health check at all for generated resources. Slices
Two things worth a reviewer's attention
Gates
Fail-before evidence for the new generator guard (generator change stashed): Next
|
Captures the two reusable findings: Aspire's docs disagree with the generated 13.4.6 TypeScript SDK on withHttpHealthCheck's signature (and how to settle that in two minutes with `aspire restore`), and scaffold.runtime brought up the whole AppHost without ever requesting a page from the generated app. Refs #954 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🤖 Augment PR SummarySummary: This PR fixes Aspire “false healthy” reporting for generated Fresh apps by registering an HTTP health probe that exercises the app’s SSR pipeline. Changes:
Technical Notes: The probe targets the app’s own server-rendered 🤖 Was this summary useful? React with 👍 or 👎 |
| }; | ||
|
|
||
| /** Home page of the generated app, on `PORT_RANGES.APP.start`. */ | ||
| const APP_HOME_URL = 'http://127.0.0.1:8000/'; |
There was a problem hiding this comment.
packages/cli/e2e/src/application/gates/scaffold/runtime-gates.ts:21 — APP_HOME_URL hardcodes 8000 while the doc comment says it’s on PORT_RANGES.APP.start, so a future default-port change would make this gate probe the wrong URL. If the app port can vary by scaffold options, this would also become a false failure/pass depending on what’s running on 8000.
Severity: low
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| lines.push(``); | ||
| lines.push(` // HTTP health probe — a listening socket alone is not "healthy".`); | ||
| lines.push( | ||
| ` await ${id}.withHttpHealthCheck({ path: '${path}', endpointName: '${RESOURCE_DEFAULTS.HttpEndpointName}' });`, |
There was a problem hiding this comment.
packages/cli/src/kernel/templates/aspire/helpers/register/generate-register-apps.ts:248 — HealthCheckPath is interpolated into a single-quoted string literal without escaping, so a configured path containing ' would generate invalid TypeScript in the emitted helper. Since this is user-supplied config, it may be worth ensuring it can’t break codegen output.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
`behavior.app-home` shipped probing a hardcoded `http://127.0.0.1:8000/` (`PORT_RANGES.APP.start`). The scaffold publishes the app on 8010 — `PORT_RANGES.APP.start + 10`, offset on purpose so the Aspire proxy does not collide with Vite's own default of 8000. Nothing has ever listened on 8000, so all 60 attempts were refused: 60 refusals at 1s apart is the 60182ms CI failure, and a refused connection reads exactly like an app that cannot render. The app was fine throughout. `runtime.wait.dashboard` passed in the same run, and a `curl` at the app's real port returns HTTP 200 `text/html`, 130KB. The probe now takes a project root and an app name and resolves the URL from that project's `appsettings.json` — the same file the helper generator reads when it emits `withHttpEndpoint({ port })`. A literal cannot drift from the artifact it describes if there is no literal. It moved from an inline `deno eval` string to a script module so the gate's command factory stays a pure function of the run context; resolving inside the factory broke the suite-runner test that builds the real suite against a faked executor. `SCAFFOLD_APP_PORT` replaces the `PORT_RANGES.APP.start + 10` expression that was spelled out in three scaffold call sites. Generated output is unchanged. Refs #954
|
[PHASE: IMPL] [SLICE: S6]
What failed and why
The timing said so before the code did: 60182 ms is exactly 60 iterations of the probe's 1000 ms sleep plus process start, so every Confirmed against a generated project on a runtime host:
#953 / #957 are not prerequisites for this PR. Fix —
|
| Gate | Result |
|---|---|
deno task fmt:check |
PASS — 1869 files, 0 findings |
deno fmt --check --ext ts over the touched packages/cli roots (excluded from the root task) |
PASS — 94 files |
deno task lint |
PASS — 1724 files, 0 occurrences |
deno lint packages/cli/e2e … |
PASS — 107 files |
deno task check |
PASS — 2460 files, 0 failed batches |
deno task test |
PASS — 2235 passed, 0 failed, 12 ignored (3m35s) |
deno task arch:check |
PASS — exit 0, no FAIL= rows |
deno task quality:scan |
PASS — ok:true, findings [], 7 pre-existing allowances |
deno test packages/cli/e2e/tests/ |
PASS — 53 passed |
deno task e2e:cli run scaffold.runtime |
PARTIAL — environmental, see below |
scaffold.runtime reached runtime.wait.dashboard and timed out there after 300 s because two unrelated Aspire AppHosts from other projects on that host already held 127.0.0.1:8010, so this run's app proxy could never bind. Those were not this repository's processes and were left alone. The suite aborts on the first critical failure, so behavior.app-home was not reached in-suite; it was verified directly against the running generated app instead (table above). A clean-host scaffold.runtime on CI is the remaining verdict — which is the whole point of the gate this PR adds.
Drift
D-5— D-4 came true: a gate written without ever executing the suite it extends is not a gate.D-6— gate command factories must stay pure functions of the run context.D-7— every scaffolded project defaults to the same app port, so two local projects cannot run at once. Worth its own issue; not filed from this session.
|
[PHASE: IMPL] [SLICE: S6 — confirmed] Clean-host CI verdict on scaffold-runtime PASS (5m28s); every other check green. 225 ms for the home page is the pair working as designed: For completeness on the other branch: #957's |
behavior.app-home PASSED 225ms, passed=62 failed=0. Closes drift D-4 (the suite had never been executed) and D-7 (local port collision). #957 passes the same lane on re-run with no code change.
Resolves the six-file port-handling overlap with #963 (app health probe). The collision was semantic, not textual. #963 introduced SCAFFOLD_APP_PORT as a *pinned* host port so its app-home probe could reach the app, and resolved that port by reading `NetScript.Apps.<name>.Port` from appsettings.json. #952 removes host-port pinning from the pristine scaffold entirely, so that appsettings entry is now `{"Runtime":"deno","Type":"app"}` — no Port, no HostPort. The old resolver throws on exactly that input, which would have failed the scaffold-runtime gate on main the moment this branch merged. Neither PR's checks could see it: #978 ran green against a main that did not yet contain #963. Resolution: - port-ranges.ts — keep both declarations. USER_PORT_RANGE validates an explicitly requested port; SCAFFOLD_APP_PORT narrows to what it now actually is, the source-literal fallback baked into the app for standalone runs outside the AppHost. That is the exact counterpart of how this branch already treats PORT_RANGES.SERVICE, and it is no longer a host/proxy port. - plan-init.ts, render-ts-apphost.ts — take this branch: the scaffold stops writing a host port for the app. - generated-app-endpoint.ts — the probe now resolves both cases. A pinned port still comes from appsettings (HostPort, with legacy Port still honoured, so existing workspaces resolve identically); an unpinned one is read from the running AppHost via `aspire describe --format Json`, mirroring the resolver the service-health gate already uses in CI. - probe-app-home.ts, runtime-gates.ts — the gate hands the probe the AppHost path and grants --allow-run=aspire, since the allocated port exists nowhere on disk. Regression cover: a pristine scaffold resolving to "pins nothing, and that is not an error" is asserted directly, so the #952 x #963 interaction cannot silently return.
Summary
A generated Fresh app was reported
Healthyby Aspire while every request to it returned 500. TheAspire helper generator registered no health check of any kind for generated resources, and
Aspire treats a resource with no registered health check as ready the moment its process reaches
Running— so "the process started" was the entire health contract. This PR givesappresourcesan HTTP health probe against their own server-rendered
/healthroute, and extendsscaffold.runtimeso the suite actually asks the running app for a page.Scope
service—packages/cliAspirehelper generators + CLI E2E suite, plus one constant and one optional schema field in
packages/aspire.Root cause
Not a probe that was checking the wrong thing — there was no probe at all.
generateRegisterAppsemittedwithHttpEndpoint({ port, env: 'PORT' })and stopped. Aspire'sdocumented fallback is explicit: "If no health checks are registered for a resource, the AppHost
waits for the resource to be in the
Runningstate." For anaddExecutableresource,Runningmeans the process was spawned.
aspire wait <app> --non-interactivetherefore returned healthy foran app that could not render a single page.
What changed
packages/aspire—RESOURCE_DEFAULTS.AppHealthCheckPath = '/health'(beside the existingHttpEndpointName, which the probe reuses as its endpoint name), and an optionalAppEntry.HealthCheckPath(string | false).generate-register-apps.ts— emitsawait <app>.withHttpHealthCheck({ path, endpointName: 'http' })forappentries with a port,registered after the endpoint whose base address it resolves against.
routes/health.tsx.template— doc comment now states the SSR contract the probe depends on.No behaviour change.
scaffold.runtime— two new paired gates (see Regression guard).Why
/healthand not/The issue's suggested direction — "an HTTP health probe that renders a minimal SSR route" — is what
this implements. The scaffold already writes
apps/<app>/routes/health.tsx, which renders throughdefinePage()and the app shell and only short-circuits to JSON when the caller sendsAccept: application/jsonwithouttext/html. Aspire's probe sends noAcceptheader, so ittakes the SSR branch: a broken render pipeline fails the probe. Probing
/instead wouldfalse-negative on any app whose home page is auth-gated or redirects.
One correction to the upstream docs
Aspire's docs show
withHttpHealthCheck('/health'). The TypeScript SDK thataspire restoregenerates for SDK
13.4.6— the version the scaffold pins — declareswithHttpHealthCheck(options?: WithHttpHealthCheckOptions)onExecutableResource. I verified thisby running a real
aspire restoreagainst a throwaway apphost and reading the generated.aspire/modules/aspire.mts. The generator emits the options-object form; the documented positionalform would throw at AppHost start.
Regression guard
The check that was looking past the problem is
scaffold.runtime. It started the whole AppHost,waited on every database, cache, and plugin resource and probed their
/health— but never waitedon the generated app and never issued a single HTTP request to any app route. The only app-shaped
gate,
behavior.ui-render, renders AI payload components in-process and never touches the runningserver. An app that returned 500 to every request passed the suite cleanly.
Two paired gates close that:
runtime.wait.dashboard—aspire waiton the app resource, which is only meaningful now thatthe probe exists. 300s timeout, covering Vite cold start and first render.
behavior.app-home— fetches the app's home page and requires a 2xx that is actually HTML.Asserting the status alone would not do: a 500 error page is
text/htmltoo.Plus unit-level guards that fail before the fix and pass after it:
generators-background-app_test.ts— probe emitted, emitted after the endpoint, honours acustom
HealthCheckPath, omitted forHealthCheckPath: false, and never emitted fortauri/desktop/task.route-templates_test.ts— the health route still renders through the page layer, and the JSONbranch remains the guarded exception rather than the fall-through.
suite-registry_test.ts— both new gates are registered inscaffold.runtime, in order.Definition of Done
scaffold.runtime)fmt:check,lint,check,test,arch:checkall greenscaffold.runtimeexecuted end to end (needs a runtime host — not available in this session)Slices
568ffe11d@netscript/aspire—d77932249withHttpHealthCheckfor app resources —446a4781337c295966scaffold.runtimewaits on the app and fetches its home page —1780ffda7Validation
deno task fmt:checkdeno task lintdeno lint packages/cli/{templates,e2e}(cli is excluded from the root lint task)deno task checkdeno task testdeno task arch:checkFAIL=rows; pre-existing WARN/INFO onlyrun-deno-doc-lint.ts --root packages/aspiredeno task e2e:cli run scaffold.runtimescaffold.runtimewas not executed. This environment has no database containers or fullruntime, so the two new E2E gates are proven by registration tests only, not by an actual run. They
need the OpenHands / merge-readiness pass before this merges. Flagging rather than quietly skipping.
Fail-before evidence for the generator guard (generator change stashed):
What I deliberately did not change
this defect. Left open.
first-party plugin health paths are heterogeneous — workers/sagas/auth serve
/health/live,triggers serves
/health— so a single defaulted path would be wrong for several of them andwould newly gate
aspire waiton those resources against an unverified path. That needs declaredper-resource paths, which is its own change. Follow-up issue below.
tauri/desktop/taskapp types. No HTTP page contract;desktopgets no endpoint atall.
Behaviour change to be aware of
An existing project whose app has no
/healthroute will now showUnhealthyinstead of a falsegreen, and
aspire wait <app>will block on it. That is the intended correction, andHealthCheckPath: falseis the opt-out. The scaffold has always writtenroutes/health.tsx, soscaffolded projects are unaffected.
.helpers/*.mtsare regenerated bynetscript generate, soexisting projects pick the probe up on their next generate.
Follow-up worth filing
Health probes for generated services, plugins, and background processors, with each resource
declaring its own path rather than inheriting a default. Same root cause, different blast radius.
Harness
.llm/runs/fix-aspire-app-health-probe--954/drift.mdD-1): this is a single-session run, so PLAN-EVAL andIMPL-EVAL were not run as separate evaluator sessions. PR review is the reviewing pass.
Drift / Debt
D-1Single-session run; no separate evaluator sessions.D-2Aspire docs disagree with the generated SDK onwithHttpHealthCheck's signature — optionsobject wins. Re-verify if the SDK pin moves.
D-3scaffold.runtimedid not exercise the generated app at all;behavior.ui-renderdoes notcount.
D-4scaffold.runtimenot executed in this environment.