Summary
POST /v1/nodes creates fleet nodes. Nothing deletes them. nodeRoutes exposes DELETE /v1/nodes/:name/agents/:agentName and DELETE /v1/nodes/:node/providers/:name, but there is no DELETE /v1/nodes/:name — so a node row, once created, is permanent for the life of the workspace.
Because POST /v1/nodes is enroll-or-rotate keyed on name and is available to any workspace-key holder, node rows accumulate as a normal consequence of ordinary use: every differently-named CI run, probe, short-lived sandbox, or renamed host leaves a row behind that no API call can remove.
The number is the argument
A production workspace roster currently returns 1225 nodes. The overwhelming majority are status: "offline" with active_agents: 0 — direct-<snowflake> rows and one-off named nodes from runs that ended long ago.
That is not a cosmetic problem:
GET /v1/nodes returns the full roster, so every consumer pays for the dead rows on every read.
- Operators reading the roster have to distinguish "nodes that exist" from "nodes that matter" by eye, with no supported way to reduce the set.
- Anything that reasons over the roster inherits the noise.
Concrete instance
While verifying whether a workspace key can enroll a node directly — the answer is yes, and it is the basis of a fix for an unrelated dispatch outage — I created one deliberately throwaway node:
name factory-selfenroll-probe-20260821-5952a573
tags ["probe", "factory-selfenroll-feasibility"]
version factory-cloud-selfenroll-probe
status offline, active_agents 0
It is inert and it will remain in that roster permanently, because there is no call I can make to remove it. Naming it clearly was the only mitigation available. This is a small, honest example of the leak: the write path is one HTTP call and the cleanup path does not exist.
Prior art in this repo
relaycast#336 — "No way to delete a workspace, and CI creates one on every Package Validation run" — is the same shape one level up, and was accepted. The node-level version has the same driver: an automated caller creates a resource on a normal code path and has no way to clean up after itself.
Suggested shape
DELETE /v1/nodes/:name, requireWorkspaceKey, consistent with the existing POST:
- Refuse (409) while the node is live, so deletion cannot race an active session.
isNodeLive already provides the predicate.
- Refuse (409) while agents are still bound, or require an explicit cascade — the per-agent
DELETE already exists for the caller to unwind first.
- Return 404 for an unknown name, matching the other name-scoped routes.
An offline node with no bound agents is the common case and is safe to remove.
If deletion is deliberately not wanted, a supported alternative would still solve the reading problem: roster filtering (GET /v1/nodes?status=online) or a documented retention policy for offline nodes with no bindings. Any of the three is better than the current state, where the only way a node leaves the roster is that nobody looks at it.
Verified against
origin/main at the time of filing: packages/engine/src/routes/node.ts (route table), packages/engine/src/engine/node.ts (createNodeToken, publicNode), packages/engine/src/engine/placement.ts (isNodeLive, NODE_LIVENESS_TTL_MS).
Summary
POST /v1/nodescreates fleet nodes. Nothing deletes them.nodeRoutesexposesDELETE /v1/nodes/:name/agents/:agentNameandDELETE /v1/nodes/:node/providers/:name, but there is noDELETE /v1/nodes/:name— so a node row, once created, is permanent for the life of the workspace.Because
POST /v1/nodesis enroll-or-rotate keyed on name and is available to any workspace-key holder, node rows accumulate as a normal consequence of ordinary use: every differently-named CI run, probe, short-lived sandbox, or renamed host leaves a row behind that no API call can remove.The number is the argument
A production workspace roster currently returns 1225 nodes. The overwhelming majority are
status: "offline"withactive_agents: 0—direct-<snowflake>rows and one-off named nodes from runs that ended long ago.That is not a cosmetic problem:
GET /v1/nodesreturns the full roster, so every consumer pays for the dead rows on every read.Concrete instance
While verifying whether a workspace key can enroll a node directly — the answer is yes, and it is the basis of a fix for an unrelated dispatch outage — I created one deliberately throwaway node:
It is inert and it will remain in that roster permanently, because there is no call I can make to remove it. Naming it clearly was the only mitigation available. This is a small, honest example of the leak: the write path is one HTTP call and the cleanup path does not exist.
Prior art in this repo
relaycast#336 — "No way to delete a workspace, and CI creates one on every Package Validation run" — is the same shape one level up, and was accepted. The node-level version has the same driver: an automated caller creates a resource on a normal code path and has no way to clean up after itself.
Suggested shape
DELETE /v1/nodes/:name,requireWorkspaceKey, consistent with the existingPOST:isNodeLivealready provides the predicate.DELETEalready exists for the caller to unwind first.An offline node with no bound agents is the common case and is safe to remove.
If deletion is deliberately not wanted, a supported alternative would still solve the reading problem: roster filtering (
GET /v1/nodes?status=online) or a documented retention policy for offline nodes with no bindings. Any of the three is better than the current state, where the only way a node leaves the roster is that nobody looks at it.Verified against
origin/mainat the time of filing:packages/engine/src/routes/node.ts(route table),packages/engine/src/engine/node.ts(createNodeToken,publicNode),packages/engine/src/engine/placement.ts(isNodeLive,NODE_LIVENESS_TTL_MS).