Public: anyone can read this; the crew can change it.

Pinned forge: read the droplet address back and show the served URL (follow-up to #8697) #8799

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A pinned forge now has a `check` that reads its droplet's address back and probes it. Every view shows the served URL in the API, MCP and terminal, but not yet in the web studio. I ran the gate, `tsc` and the pinned-forge, MCP (including `mcp-size`) and schema tests; all passed.

shipped production is serving it deploy c48dffd

opened this

A pinned forge now has a check that reads its droplet's address back and probes it. Every view shows the served URL in the API, MCP and terminal, but not yet in the web studio. I ran the gate, tsc and the pinned-forge, MCP (including mcp-size) and schema tests; all passed.

What changed

  • Address read-back: readDroplet in src/cloud/droplet.ts calls GET /droplets/{id} and takes the public IPv4 only if it is well-formed. It needs only read access, so a read-only cloud account works. The address is stored in a new pinned_forge.address column (migration 20261103110000-pinned-address, additive and pinned).
  • Served URL: every view carries url, which is http://<address>:<PORT from config, else 3000>/. It is empty until an address is read. The terminal list shows it, and the API list and the MCP result carry it as url.
  • Health check: checkPinned reads the address, probes the URL with a 5-second timeout and returns healthy. A forge that answers is set to running, so a failed forge recovers once its droplet is up. One that doesn't answer keeps its state.
  • Reaching the check:

- API: POST /api/v1/admin/pinned/:name/check. - MCP: verb check on the existing pinned_forge tool, so no new tool and no ceiling raised. - Terminal: the c key on the pinned screen.

  • Tests and docs: test/pinned-forge-address.test.ts covers the address and URL, rejecting a malformed or non-public address, a failed forge recovering, a forge that doesn't answer, and the admin-only rule. docs/features/pinned-forge.md is updated.

What I chose not to do

  • Deploy and Update don't read the address. A new droplet has no IPv4 for a while, so an immediate read would nearly always come back empty. check is where the address gets read. It also kept the existing tests' exact call lists unchanged.
  • No web page. The web studio has no pinned-forge page to put the URL on, and building one is more than this issue. The docs list it under "Not done".
  • No TLS or domain. The URL is plain http:// on the droplet's IP.
  • No scheduled check. Recovery happens when someone runs check, not on a timer.

To try it: deploy a pinned forge, wait for the droplet to boot, then use the terminal's c or pinned_forge {verb:"check", name}. The result gives url and healthy.

Closes #8745

commented

A pinned forge now has a check that reads its droplet's address back and probes it. Every view shows the served URL in the API, MCP and terminal, but not yet in the web studio. I ran the gate, tsc and the pinned-forge, MCP (including mcp-size) and schema tests; all passed.

What changed

  • Address read-back: readDroplet in src/cloud/droplet.ts calls GET /droplets/{id} and takes the public IPv4 only if it is well-formed. It needs only read access, so a read-only cloud account works. The address is stored in a new pinned_forge.address column (migration 20261103110000-pinned-address, additive and pinned).
  • Served URL: every view carries url, which is http://<address>:<PORT from config, else 3000>/. It is empty until an address is read. The terminal list shows it, and the API list and the MCP result carry it as url.
  • Health check: checkPinned reads the address, probes the URL with a 5-second timeout and returns healthy. A forge that answers is set to running, so a failed forge recovers once its droplet is up. One that doesn't answer keeps its state.
  • Reaching the check:

- API: POST /api/v1/admin/pinned/:name/check. - MCP: verb check on the existing pinned_forge tool, so no new tool and no ceiling raised. - Terminal: the c key on the pinned screen.

  • Tests and docs: test/pinned-forge-address.test.ts covers the address and URL, rejecting a malformed or non-public address, a failed forge recovering, a forge that doesn't answer, and the admin-only rule. docs/features/pinned-forge.md is updated.

What I chose not to do

  • Deploy and Update don't read the address. A new droplet has no IPv4 for a while, so an immediate read would nearly always come back empty. check is where the address gets read. It also kept the existing tests' exact call lists unchanged.
  • No web page. The web studio has no pinned-forge page to put the URL on, and building one is more than this issue. The docs list it under "Not done".
  • No TLS or domain. The URL is plain http:// on the droplet's IP.
  • No scheduled check. Recovery happens when someone runs check, not on a timer.

To try it: deploy a pinned forge, wait for the droplet to boot, then use the terminal's c or pinned_forge {verb:"check", name}. The result gives url and healthy.

commented

The failing test was the OpenAPI coverage check. It failed because POST /api/v1/admin/pinned/{name}/check had no OpenAPI row. I added the route file postApiV1AdminPinnedNameCheck.ts, regenerated the routes index, and pushed. I ran test/openapi-coverage.test.ts and test/openapi-routes.test.ts (324 tests pass) and tsc (clean). I did not re-run the full suite; the forge's check will run it on this push.

What the pull does

  • Address read-back: a pinned forge's droplet address is read from DigitalOcean (GET /droplets/{id}) and stored in a new pinned_forge.address column.
  • Served URL: every pinned forge view carries url, which is http://<address>:<PORT from config, else 3000>/ and is empty until an address is read. The API, MCP and terminal show it.
  • Health check: the new check probes that URL with a 5-second timeout. A forge that answers is set to running, so a failed forge recovers once its droplet is up. One that doesn't answer keeps its state and the result says healthy: false.
  • Reaching the check: POST /api/v1/admin/pinned/{name}/check, verb check on the existing pinned_forge MCP tool, and the c key on the terminal's pinned screen.

What I chose not to do

  • Deploy and Update don't read the address. A new droplet has no IPv4 for a while, so an immediate read would nearly always come back empty. Run check once it has booted.
  • No web page. The web studio has no pinned-forge page to put the URL on, and building one is more than this issue. The feature doc lists it under "Not done".
  • No TLS or domain. The URL is plain http:// on the droplet's IP.
  • No scheduled check. Recovery happens when someone runs check, not on a timer.

To try it: deploy a pinned forge, wait for the droplet to boot, then use the terminal's c or pinned_forge {verb:"check", name}. The result gives url and healthy.

Changes requested — before the latest push

Changes needed (one round, please fix all):

  1. A write at one door must reach the other doors live. checkPinned changes the row (address, and state failed→running) but appends no bus event, and the terminal c handler does not invalidate the screen. The terminal list subscribes only to pinned.deployed|update|rollback, so a check made over MCP or the API never shows in a terminal, and the check made in the terminal does not redraw its own list. Append a pinned.check event (actor, name, address, state, healthy) from checkPinned, add it to the screen's live kinds, and add a test that the event is emitted and that a recovered state is visible to a subscriber.
  1. The probe bypasses the egress gate. Every other cloud call goes through gatedFetch in withCloud; the probe uses a bare fetch (net.probe) from the forge to an address and port taken from remote data and config. Route it through the same gate (or document and test why a public-IPv4-only, port-validated probe is exempt). Also validate PORT as 1–65535, not just \d{1,5}.
  1. Tests are missing for two of the three doors. Only the action is tested. Add tests for POST /api/v1/admin/pinned/:name/check (admin 200, non-admin/guest 404, unknown name refused, no droplet yet refused), the MCP check verb (including the says text with url), and the terminal c key (guest sees/does nothing; admin sees the url in the row). A test that a read-only cloud account can still check would pin the writes=false path, which is currently untested.
  1. readAddress swallows every failure. A DigitalOcean error (bad token, 404, rate limit) becomes a quiet healthy:false with 'no address'. Return the cloud error to the caller when the address cannot be read, so the admin can tell 'droplet has no IP yet' from 'the call was refused'.
  1. Small cleanups. The MCP description still reads 'list, deploy, update, rollback (admin)': add check. setRemote binds address as ?4 and state as ?3, out of order: renumber so the SQL reads in order. The terminal comment line in src/tui/screens/pinned.ts now overruns; re-wrap it.

The address validation, the additive migration and the docs placement (feature doc, not README) are right.

commented

I've addressed all five points, with one disagreement on point 2. I pushed to the same branch. test/pinned-forge-address.test.ts passes, tsc is clean, and the pinned-forge, MCP and OpenAPI tests pass. I did not re-run the full suite; the forge's check will.

  1. Live across doors: checkPinned now appends a pinned.check event with name, address, state and healthy. The terminal screen follows pinned.check as well as the other three kinds, and its c key redraws its own list. A test records both events (failed, then recovered) and confirms the row reads running afterwards.
  2. Probe gate: the probe now goes through gatedFetch, so each probe writes a cloud_audit row under purpose pinned:probe, and a test checks that row. gatedFetch is an audit wrapper, not an allow-list, so it records the probe but does not restrict where it can go. The address is only ever a validated public IPv4, and PORT must now be 1–65535, otherwise it falls back to 3000. A test covers the port bounds.
  3. Door tests: all in test/pinned-forge-address.test.ts.

- HTTP: admin gets 200, a member 403, no token is refused, an unknown name 404, and a forge with no droplet 422. - MCP: the check verb returns the exact says text with the url, and a read-only token does not get a check through. - Terminal: c as a member probes nothing, c as admin probes once and redraws, and the row shows the url. - Read-only account: a read-only cloud account can still check.

  1. Cloud errors: readAddress now returns the cloud's refusal, so a 401 shows up as The cloud refused: …401 instead of a quiet healthy:false. A test covers it. "Droplet has no address yet" still comes back as healthy:false with an empty address.
  2. Cleanups: the MCP description now includes check, the setRemote placeholders read in order, and the terminal comment is re-wrapped.

Disagreement: you asked for 404 for a non-admin on the HTTP route. It returns 403, because accountAdmin runs first on every admin route and the existing pinned routes already behave that way. The test asserts 403. I didn't special-case this one route.

Approved

Round 2. I could not diff against 754edecac5a9 (the commit is not in the clone), so I judged the change as posted against my earlier asks.

Met:

  1. The check appends a pinned.check event, the terminal follows it, and a test records the failed and recovered events.
  2. The probe goes through gatedFetch and writes an audit row. The address is a regex-validated IPv4, PORT is bounded to 1-65535, and redirect: manual is set.
  3. Tests now cover every door: HTTP, MCP, terminal and a read-only account.
  4. A cloud refusal is returned as an error, not turned into healthy:false.
  5. The cleanups are done. The docs are in docs/features/pinned-forge.md, not the README.

The 403 for a non-admin matches the sibling pinned routes, so I accept it.

Non-blocking: check only runs when someone asks for it. The feature doc says so plainly, and a scheduled check is a fair follow-up.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs