Skip to content

feat(status): port the four tag-derived notices from Go's spawn status (#56) - #64

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/56-status-notices
Aug 1, 2026
Merged

feat(status): port the four tag-derived notices from Go's spawn status (#56)#64
scttfrdmn merged 1 commit into
mainfrom
fix/56-status-notices

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #56.

spawn status answered "what did you configure" and nothing else. Go appends four notices after its remote spored status output (cmd/status.go:130-134); none were ported, and every one of them is pure tag reading a browser can already do.

The headline gap. spored writes spawn:dns-status + spawn:dns-error when registration fails, specifically so the failure isn't buried in the instance's journal (spawn#435) — and nothing in spawn-ts ever read them. A portal user got an FQDN that never resolves and no explanation, while the diagnostic sat in a tag DescribeInstances had already returned.

What's here

A pure src/core/notices.ts returning structured { kind, level, text, detail } rather than pre-formatted strings, so the CLI, the dashboard and the portal render one source three ways.

notice says
lifecycle-protection who enforces the deadline, when it falls, and the worst-case compute cost to it
dns registration failed, with spored's own reason
elastic-ip an EIP still billing on a stopped instance (~$3.60/mo), + the release command
spored-upgrade the running spored is older than the newest release

lifecycleProtection is the counterpart to accumulatedCost(), which reports only what's been spent. The ceiling is the number that tells a user whether to worry.

elasticIpNotice matters most on a stopped instance: stopping pauses compute billing, so the user believes they've stopped paying — and an unassociated EIP is charged precisely because nothing is using it.

Absence is never smoothed into reassurance

Per #63, each notice has an input it can't do without, and a missing input omits the notice rather than answering optimistically:

  • no spawn:dns-status → no notice. It does not mean registration succeeded; an older spored simply never wrote the tag.
  • no latestSporedVersion → no notice. It does not mean spored is current. The value is passed in rather than fetched, because Go's release check hits the GitHub API and that's the embedder's call.
  • no EIP lookup → no notice; a lookup that fails is its own warn notice naming the error.

That last one is a deliberate divergence from Go. GetInstanceElasticIP (pkg/aws/cleanup.go:219) returns nil, nil on any API error, so a missing ec2:DescribeAddresses permission is indistinguishable from a clean bill of health — in the one check whose entire job is catching an unnoticed charge.

Two honesty constraints carried over verbatim and asserted in tests: the out-of-band reaper is described as a backstop "if deployed" (it runs in the infra account and isn't authoritatively visible from the launch account), and the cost figure keeps its "compute only" label since it excludes EBS and network. It's skipped entirely when the instance has no recorded price rather than shown as $0.00.

Wiring

EC2Provider.lookupElasticIp delegates to the shared lookup with the credentials it already holds, and the CLI falls back to it when the ShellCtx supplies no override. Without that the notice would exist but never fire in the real appterminal.ts builds a bare ShellCtx. New permission: ec2:DescribeAddresses (optional; its absence is reported, not fatal).

The dashboard renders the two notices its meters don't already cover, escaped — spawn:dns-error is whatever the DNS endpoint returned, and it lands in innerHTML.

Also

Driving the built CLI (not the suite) surfaced an adjacent line: humanRemaining() already returns "expired", so status on an overdue instance read ttl: 4h — expired left (terminates).

Not ported, deliberately

The remote spored status output these notices are appended to (needs a shell on the box), and Go's fallback of parsing the spored version out of that output. compareSemver is a faithful port of libs/update's comparison, so the two tools never disagree about whether an upgrade exists.

Verification

  • 451 → 503 tests. Typecheck, npm test, npm run build, npm run build:lib all clean.
  • Each new guard was proven to catch the bug, not assert the fix. Five mutations reverting notices.ts to the absent/Go-equivalent behaviour (EIP error collapse, no past-due branch, no cost ceiling, no NaN guard, dropped DNS detail) → 11 failures across notices.test.ts and commands.test.ts. Removing the dashboard's escapeHtml → 1 more.
  • Driven end to end, not just tested: the built CLI through a live/stopped/past-due/failed-lookup sequence, and the dashboard in Chromium — where the new notice CSS was confirmed to actually land (.notice.warn red-on-tint with a border, .notice.info visibly subordinate).

…us` (#56)

`spawn status` answered "what did you configure" and nothing else. Go appends
four notices after its remote `spored status` output (cmd/status.go:130-134);
none were ported, and every one of them is pure tag reading that a browser can
already do.

The headline gap: spored writes `spawn:dns-status` + `spawn:dns-error` when
registration fails, specifically so the failure isn't buried in the instance's
journal (spawn#435) — and nothing in spawn-ts ever read them. A portal user got
an FQDN that never resolves and no explanation, while the diagnostic sat in a tag
DescribeInstances had already returned.

New pure `src/core/notices.ts` returning structured `{ kind, level, text,
detail }` rather than pre-formatted strings, so the CLI, the dashboard and the
portal render one source three ways:

  - lifecycleProtection() — who enforces the deadline, when it falls, and the
    worst-case *compute* cost to it. The counterpart to accumulatedCost(), which
    reports only what's been spent; the ceiling is the number that tells a user
    whether to worry.
  - dnsNotice() — the failure, with spored's own reason.
  - sporedUpgrade() + compareSemver() — a port of libs/update's comparison, so
    the two tools never disagree about whether an upgrade exists.
  - elasticIpNotice() — an EIP on a *stopped* instance keeps billing (~$3.60/mo)
    precisely because nothing is using it, which is exactly when the user
    believes they've stopped paying. Pure; the lookup is src/aws/eip.ts.

Absence is never smoothed into reassurance, per #63. Each notice has an input it
can't do without, and a missing input omits the notice rather than answering
optimistically: no dns-status tag does not mean "registered", and no supplied
latest version does not mean "up to date".

That principle forces one deliberate divergence from Go. GetInstanceElasticIP
(pkg/aws/cleanup.go:219) returns nil,nil on any API error, so a missing
ec2:DescribeAddresses permission is indistinguishable from a clean bill of
health — in the one check whose entire job is catching an unnoticed charge. Here
a failed lookup is its own warn notice naming the error.

Two other honesty constraints carried over verbatim and asserted in tests: the
out-of-band reaper is described as a backstop "if deployed" (it runs in the infra
account and isn't visible from the launch account), and the cost figure keeps its
"compute only" label since it excludes EBS and network.

Wiring: EC2Provider.lookupElasticIp delegates to the shared lookup with the
credentials it already holds, and the CLI falls back to it when the ShellCtx
supplies no override — without that the notice would exist but never fire in the
real app, since terminal.ts builds a bare ShellCtx. The dashboard renders the two
notices its meters don't already cover, escaped (spawn:dns-error is whatever the
DNS endpoint returned, and it lands in innerHTML).

Also fixes an adjacent line found by driving the built CLI rather than trusting
vitest: humanRemaining() already returns "expired", so `status` on an overdue
instance read "ttl: 4h — expired left (terminates)".

Not ported, and deliberately: the remote `spored status` output these notices are
appended *to* (needs a shell on the box), and Go's fallback of parsing the spored
version out of that output.

Verified: each new guard was checked against the absent/Go-equivalent behaviour
(5 mutations → 11 failures; the XSS guard → 1 more) before being kept. Driven end
to end through the built CLI and through Chromium, where the notice CSS was
confirmed to actually land. 451 → 503 tests; typecheck, build and build:lib all
clean.

Refs #56
@scttfrdmn
scttfrdmn merged commit 976fe0a into main Aug 1, 2026
1 check passed
@scttfrdmn
scttfrdmn deleted the fix/56-status-notices branch August 1, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

status: the four tag-derived notices are unported — a failed DNS registration is invisible despite spored recording why

1 participant