Wheelhouse docs

Staging: the working set#

Every editor in Wheelhouse stages. It posts the operations it wants to POST /api/stage, they join a queue held by the agent, and the editor is finished — nothing has reached the router. That queue is the working set: an ordered list of set and delete operations that will be sent to the router as one array, in one commit, when somebody presses Commit. Until then it can be read, added to, trimmed one operation at a time, or thrown away.

Why staging exists at all#

The router's own HTTP API has no staged-diff mode. A call to its /configure endpoint applies and commits immediately — that is recorded, with the date it was verified, in docs/api-cookbook.md. So the staging area is Wheelhouse's: the agent accumulates the change set in memory and submits the whole of it as one request, which is one commit (agent/staging.go, ToConfigurePayload).

That is the difference between "the UI applied nine settings" and "the router performed one commit". Only the second can be shown as a diff, refused as a unit, armed with commit-confirm, and undone with one rollback.

The queue belongs to the agent, not to your tab#

There is exactly one working set per agent process — agent/main.go creates it once with staging = newStagingArea() — and everything that stages, stages into it:

  • every browser session signed in to that router;
  • the reconcile loop, when it is running in its default stage mode;
  • wheelhouse-agent apply --agent-url … from a shell or a cron job.

The browser adopts whatever the agent already holds when the page loads, and re-reads it whenever the tab comes back into view (ui/src/lib/staging.tsx). This is not a nicety. An index sent to POST /api/stage/remove names a position in the agent's queue, so a tab that had only appended to its own local copy could un-stage somebody else's operation. For the same reason, every mutating staging call returns the entire queue rather than an acknowledgement.

The working set is held in memory and is not written to disk. Restarting the agent empties it. Nothing is lost from the router by that — nothing had been applied — but work you staged and did not commit is gone.

What may enter it#

Two verbs, and nothing else. set and delete are the only operations the Commit Bar can render honestly, so anything else is refused rather than carried:

  • POST /api/stage answers 400 naming the offending index for an operation whose verb is not set or delete, one with no path, or one with an empty element in its path (agent/main.go, handleStage).
  • StagingArea.Add drops any other verb even if it reaches that far, and ToConfigurePayload filters again on the way out (agent/staging.go).

An empty path element is caught here because the alternative is a commit that fails on the router with a message naming neither the field nor the request.

Staging is a write: it needs the operator role and a usable licence. Both gates are on the route (agent/main.go, routes()).

A worked example#

bash
R=https://<router>:8443
T=wh_...                                  # an API token with the operator role

curl -sk -X POST "$R/api/stage" -H "Authorization: Bearer $T" \
  -d '[{"op":"set","path":["interfaces","ethernet","eth1","description","primary uplink"]},
       {"op":"set","path":["interfaces","ethernet","eth1","address","203.0.113.2/24"]}]'
json
{"staged": 2,
 "ops": [{"op":"set","path":["interfaces","ethernet","eth1","description","primary uplink"]},
         {"op":"set","path":["interfaces","ethernet","eth1","address","203.0.113.2/24"]}],
 "commands": ["set interfaces ethernet eth1 description 'primary uplink'",
              "set interfaces ethernet eth1 address 203.0.113.2/24"]}

Read it back at any time, from any session:

bash
curl -sk -H "Authorization: Bearer $T" "$R/api/staged"
# {"count":2,"commands":[…],"ops":[…]}

Change your mind about the second one, then commit the rest:

bash
curl -sk -X POST "$R/api/stage/remove" -H "Authorization: Bearer $T" -d '{"index":1}'
curl -sk -X POST "$R/api/commit" -H "Authorization: Bearer $T" -d '{"confirm_minutes":2}'

The ops array rides along with commands on every one of those answers so that a client never has to re-parse a rendered string back into a path. It cannot be done safely: a value containing a space does not survive a join-then-split round trip.

What staging does not do#

  • It does not validate. The router decides whether a command is acceptable, and it decides at commit. Staging checks the shape of an operation, not its meaning.
  • It does not deduplicate. Staging the same operation twice queues it twice. Only the reconcile paths use AddIfAbsent, which exists because two clicks of Stage on the Reconcile page, or a loop pass racing a click, used to queue every operation twice — and the second delete of the same node then failed the whole commit (agent/staging.go).
  • It does not reorder. Operations are sent in the order they were staged.
  • It is not a transaction against other writers. Someone committing from an SSH session while your set is staged changes the configuration your commands will land on.

See also#

Checked against#

agent/staging.go · agent/main.go · ui/src/lib/staging.tsx · ui/src/components/CommitBar.tsx · docs/api-cookbook.md · docs/ui.md

Updated 2026-09-02 concepts staging commit