Skip to content

feat(instance): add an Operations console for sending any operations-API request - #1882

Merged
dawsontoth merged 20 commits into
stagefrom
claude/1283-freeform-operations
Oct 13, 2026
Merged

dawsontoth merged 20 commits into
stagefrom
claude/1283-freeform-operations

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

A raccoon engineer sending a message to the great tree through a console

Folded in

This PR now carries the whole stack, rebased in a straight line on stage (20 commits, no merge commits), so one Rebase and merge lands all four:

Each of those PRs keeps its own verification and cross-model review record. On the top commit, the full suite (522 files), tsc -b, oxlint and dprint check all pass.

Decided in review:

⊙ Problem

Studio has no way to send an arbitrary operation to an instance. The APIs tab is an explorer for the REST port's OpenAPI document (/api/openapi/rest, built from exported resources), and Harper publishes no spec for the operations API. So anything Studio has no page for (system_information, get_analytics, a component's own operation, a one-off sql) means leaving Studio for curl, with credentials copied out of it. #1283 asks for a JSON box that sends to the instance as the signed-in user.

❓ Your call: is the requirement right at this size? I built the minimal slice: one request at a time, no history, no autocomplete, no Cmd+Enter. The alternative is a fuller console (operation-name autocomplete from the role editor's catalog, request history, cURL export). Everything here is additive, so any of that can follow without reworking this.

💡 Solution

A new Operations tab on every instance and cluster, for super users. It holds a Monaco JSON editor (prefilled with {"operation": "describe_all"}) and a Send button, with the status, duration and pretty-printed response (or Harper's error) shown inline underneath. The request goes through the same getInstanceClient client every other page uses, so direct, basic-auth, local and central-manager-proxied connections all work unchanged.

What makes a raw console safe to put in front of a super user:

  • Only Harper's own reads go straight through. Everything else opens a confirmation showing the exact body. A read is the restart gate's naming rule (get_/search_/describe_/list_/read_…), and the name must also appear in Studio's catalog of Harper's operations, because a component can register a mutating get_….
  • The typed text is sent as-is, at most once. Re-serializing the parse would round integers past 2^53, a primary key among them. A new skipGatewayRetry flag opts the request out of the shared client's 502/503/504 replay.
  • Studio is told about the side effects it has to know about. A console restart is tracked with markRestarting until the target answers again, as DESIGN.md requires of any flow that takes an instance down, so the page's polls ride out the outage. logout is refused: it would end the session the rest of Studio is still using.
  • Nothing leaves the page. Failures render inline and never go through console.error (RUM reports it) or the global React Query handlers. The buffer is not persisted, because bodies carry passwords and secrets. The console remounts per instance, so a confirmation written for one can never be sent to another. Anything that reached Harper, an error answer included, marks entity queries stale without refetching them.

⚖️ Alternatives

❓ Your call: which requests should confirm? I chose to confirm everything except Harper's built-in reads. The brief asked for a small list of destructive names (drop_*, delete_*, set_configuration, restart…), but the planning reviewer showed that a denylist misses sql DROP/DELETE, alter_role, approve_agent_action and any component operation. The reviewer's own preference was to confirm every request, reads included; I rejected that because reads are most of what a console sends, and confirming describe_all teaches people to click through. Changing it is a one-line predicate change.

❓ Your call: who sees the tab? I gated it on super_user (useInstanceManagePermission), like APIs, Status, Logs, Config and Applications. A non-super user who deep-links in is sent to Databases. Harper authorizes every operation itself (verifyPerms), so this gate is about visibility, not security. Opening it to every signed-in instance user is the alternative and would work, since Harper would refuse what their role can't do. It is a one-line change.

❓ Your call: the 5 MB response limit is progress-bounded, not exact. The cross-model reviewer rated it a blocker because XHR only reports progress about every 50 ms, so a fast response can arrive in full before the abort fires. I overruled that. The only browser adapter that enforces maxContentLength is axios's fetch adapter, and it unconditionally adds a User-Agent request header (axios/lib/adapters/fetch.js). Harper's operations-API CORS allowlist is Content-Type, Authorization, Accept (harper server/operationsServer.ts), so in a browser that sends that header every direct-connect preflight would fail. Display is still truncated at the API explorer's 512,000 characters, so the DOM never receives an unbounded body.

  • Extend the APIs explorer instead: rejected. It is driven by the REST OpenAPI spec and sends with its own fetch, which bypasses the instance client's restart gate, Bearer refresh, 401 recovery and proxy routing.
  • A typed form per operation: rejected. Harper has no operation-schema discovery (Surface role operation-level grants (permission.operations) in the roles/users UI #1627), so ~110 operations' fields would be hand-maintained across 4.x/5.x.

🔧 Changes

The page

The request

Shared pieces this touches

✅ Verification

End-to-end against a real Harper (4.7.40 in a throwaway loopback container, generated test credentials):

  • Through the real getInstanceClient interceptor chain and jsdom's XHR: describe_all, create_database and create_table answered 200. An unknown operation answered 400 Operation 'not_a_real_operation' not found and a missing table 404, both rendered as Harper's body. A 6,000-record insert answered 200. A search_by_value returning ~6 MB came back too-large from real progress events, and the same search with limit: 2 came back normally.
  • Browser: the page ran in local-studio mode against that container (pnpm dev:local, signed in with the test user). Monaco loaded with no console errors and no DI errors. describe_all sent immediately. A typo produced Line 1, column 57: … inline, which cleared on edit. create_database opened the confirmation with the exact body, sent once on confirm, and answered 200. Cancel on drop_database sent nothing, and the dialog kept its content through the close animation. describe_table on a missing table rendered the 404 body inline. No console.error came from the page; the one console line is the browser's own resource-404 log. Not exercised end-to-end: Harper 5.x and the central-manager proxy (no stage session in this environment).

Unit and component tests: mutation-checked, meaning each guard was reverted locally and its test went red.

Gates (real exit codes, Node 24.21.0, through the husky pre-commit hook): full vitest run passed 401 files, 3,760 tests, exit 0. tsc -b exit 0, oxlint exit 0, dprint check exit 0, and commitlint passed.

Cross-model review: four rounds, codex (gpt-5.6-sol, graded) and gemini (3.1-pro-high) on each; the last was a full round on the squashed head. Planning review came first; it returned better-alternative-exists, and I adopted most of it: raw-text send, no gateway replay, stale-only invalidation, memoized client, and confirming far more. The read-allowlist part I overruled, as in Alternatives. Implementation rounds found and fixed: the console not remounting per entity, read-looking component operations skipping confirmation, restart/logout bypassing Studio's coordinators, in-flight cancellation reported as unsent, state updates after unmount, 5xx treated as not applied, and 4xx skipping stale-marking. I rejected, on evidence:

  • Gemini's "the body is sent untrimmed": axios's stringifySafely returns the trimmed string for a JSON string body, and the test asserts the trimmed body.
  • Gemini's "restart_service is an outage too": useRestartInstanceClick marks only restart, because restart_service keeps answering.
  • Gemini's "the restart gate parses every console body": its interceptor runs only while holdsRequests(…) is true, i.e. only while the entity is tracked as restarting.
  • Gemini's "an operation of " restart ", with spaces, skips tracking": Harper resolves operations by exact Map.get(json.operation) (serverUtilities.ts), so that name is an unknown operation, a 400, not a restart.
  • Gemini's "the restart gate holds a confirmed component get_… write": it can't duplicate. The in-flight guard blocks a second send, and leaving the page aborts the held request through the gate's signal handling.

The XHR cap remains open as a reviewer blocker; it is the third ❓ above. I left one nit unaddressed: codex called tryParseJson's doc comment narration. Coverage gap: the Cursor legs failed every round with no-receipt (the 1Password SSH agent refused to sign the review CLI's fetch), and the Harper domain adjudicator failed auth (expired Claude OAuth), so no round was adjudicated.

Also found: #1879 (filed, P3): every instance client replays a 502/503/504 for every operation, so a write whose answer a gateway lost runs twice. The console opts out here; the rest of Studio still replays.

Closes #1283
Closes #1879
Closes #1890
Closes #1895

🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: none found
Complexity: medium

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-muse(no-receipt),cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi; rounds=4; full=1 @ aaca36e

Review-Attention: study ~9m (raised: degraded review) @ aaca36e

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Operations Console feature under the 'Operations' tab for instances and clusters, allowing super users to send freeform operations-API requests. The console includes features such as request validation, confirmation prompts for mutating operations, inline error rendering, and specialized handling for restart operations. Key changes include the addition of the operations route, navigation bar integration, and extensive unit tests. Under the hood, read operation detection was refactored into a shared utility, and Axios was updated to support skipping gateway retries for non-idempotent requests. There are no review comments to address, and I have no additional feedback to provide.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 77.37% 13288 / 17173
🔵 Statements 77.33% 14107 / 18242
🔵 Functions 72.24% 3499 / 4843
🔵 Branches 72.26% 10426 / 14428
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/describeAuthFailure.ts 100% 100% 100% 100%
src/features/cluster/domains/Management.tsx 91.08% 87.69% 90% 92.78% 100, 127, 264-275, 283, 289-290
src/features/clusters/components/ClusterCard.tsx 54.02% 82.97% 25.92% 54.76% 107-119, 123-124, 127, 130-163, 267-296, 382, 457-496
src/features/instance/InstanceNavBar.tsx 100% 96.15% 100% 100%
src/features/instance/routes.ts 100% 100% 100% 100%
src/features/instance/applications/components/Chat/tools/describeToolError.ts 100% 50% 100% 100%
src/features/instance/applications/components/Chat/tools/createApp/execute.ts 75% 50% 50% 75% 21-23, 75-91
src/features/instance/applications/components/Chat/tools/deleteTableRecords/execute.ts 0% 100% 0% 0% 12-29
src/features/instance/applications/components/Chat/tools/dropComponentFile/execute.ts 0% 100% 0% 0% 11-24
src/features/instance/applications/components/Chat/tools/getUserContext/execute.ts 0% 0% 0% 0% 11-88
src/features/instance/applications/components/Chat/tools/insertTableRecords/execute.ts 0% 100% 0% 0% 12-29
src/features/instance/applications/components/Chat/tools/restartHTTPService/execute.ts 77.77% 100% 100% 77.77% 42-52
src/features/instance/applications/components/Chat/tools/setComponentFile/execute.ts 0% 100% 0% 0% 13-32
src/features/instance/applications/components/Chat/tools/updateTableRecords/execute.ts 0% 100% 0% 0% 12-29
src/features/instance/applications/components/NewApplication/useImportApplication.ts 91.78% 82.6% 100% 91.54% 112-116, 118-122, 137-140, 174-175
src/features/instance/applications/hooks/useRenameFiles.tsx 56.66% 35% 40% 55.93% 27, 36, 76, 88, 97-100, 111, 117-169
src/features/instance/applications/modals/RedeployApplicationModal.tsx 75.22% 51.94% 91.66% 74.77% 103, 126-141, 162-169, 173, 179, 197, 211-213, 225-234, 246, 334-344
src/features/instance/applications/modals/RenameFileModal.tsx 37.03% 14.28% 50% 36.53% 40, 47-48, 82-143, 148-149
src/features/instance/applications/modals/reportDeployHealth.ts 84.61% 73.58% 100% 84% 63-67, 72-91
src/features/instance/config/deployments/components/ActivateStagedRelease.tsx 100% 81.25% 100% 100%
src/features/instance/config/secrets/SecretGrantsEditor.tsx 0% 0% 0% 0% 30-78
src/features/instance/databases/components/DatabaseTableView.tsx 72.33% 73.84% 64.91% 71.95% 269, 402-405, 450-459, 636-650, 695-711, 715-723, 732-740, 746-774, 785, 809, 835-850, 857-861, 864-891, 907, 911, 941-942, 1083-1093
src/features/instance/databases/modals/recordEditorJson.ts 70.58% 76% 88.88% 69.69% 70-88
src/features/instance/operations/index.tsx 98.71% 88.15% 100% 98.7% 96
src/features/instance/operations/operationKinds.ts 100% 100% 100% 100%
src/features/instance/operations/parseOperationRequest.ts 100% 100% 100% 100%
src/features/instance/operations/routes.ts 50% 100% 50% 50% 8-9
src/features/instance/operations/sendOperation.ts 91.66% 80.64% 100% 91.17% 102-105
src/features/instance/secrets/SecretModals.tsx 88.88% 85.96% 92.3% 88.88% 130-132, 287-288, 308-309
src/features/organization/roles/modals/AddOrganizationRoleModal.tsx 65.62% 69.23% 50% 65.62% 73, 82, 92-98, 152, 170-198
src/features/organization/roles/modals/EditOrganizationRoleModal.tsx 45.61% 56.86% 33.33% 46.42% 43, 63-67, 102-118, 144, 151-187, 280-310
src/features/organization/settings/index.tsx 72.6% 66.66% 64.7% 74.64% 85-86, 126, 127, 139-143, 150-151, 156-157, 162-165, 204, 268-374, 409-410, 484-485
src/features/organization/users/modals/EditUserModal.tsx 0% 0% 0% 0% 36-172
src/hooks/useApplyLicensesClick.tsx 93.02% 84.84% 50% 92.85% 122, 133, 154
src/hooks/useRestartClusterClick.tsx 93.15% 96.15% 63.63% 92.85% 38, 84, 206, 217, 240
src/hooks/useRestartInstanceClick.ts 89.65% 73.07% 50% 89.65% 43, 84, 98
src/hooks/useRollingConfigUpdate.tsx 95.65% 98.61% 71.42% 95.5% 118, 260, 271, 295
src/integrations/api/isReadOperation.ts 100% 100% 100% 100%
src/integrations/api/retryGatewayErrors.ts 100% 100% 100% 100%
src/integrations/api/unansweredWrite.ts 100% 91.66% 100% 100%
src/integrations/api/instance/applications/packageComponent.ts 66.66% 100% 100% 66.66% 34
src/integrations/api/instance/secrets/secrets.ts 91.3% 89.47% 85% 90.9% 139-146
src/integrations/api/instance/status/restartInstance.ts 100% 100% 100% 100%
src/integrations/api/instance/status/restartUnconfirmed.ts 100% 96.29% 100% 100%
src/integrations/api/sse/resolveInstanceConnection.ts 100% 91.3% 100% 100%
src/lib/restart/restartGate.ts 97.56% 90.9% 100% 97.29% 94
src/react-query/queryClient.ts 98.18% 100% 85.71% 98.18% 121
Generated in workflow #2324 for commit 6cd6fbc by the Vitest Coverage Report Action

dawsontoth and others added 9 commits October 12, 2026 20:51
…ests

A super-user-only Operations tab on every instance and cluster: a Monaco
JSON editor (the worker-free JSON language the record editors use) whose
body is validated client-side, sent through the entity's existing
getInstanceClient client, and answered inline with the status, duration
and pretty-printed response or error.

- The typed text is the request body; it is parsed only to validate it,
  so integers past 2^53 reach Harper unchanged.
- It is sent at most once: a new skipGatewayRetry request flag opts it
  out of the shared 502/503/504 replay, which could re-apply a write
  (the rest of Studio's exposure is #1879).
- Only Harper's own reads skip confirmation: the restart gate's naming
  rule (isReadOperation, extracted so both share it) and membership in
  the role editor's operations catalog, since a component can register
  a mutating get_ operation. Everything else confirms with the exact
  body.
- A console `restart` is tracked with markRestarting until the target
  answers again (waitUntilAnsweringAfterRestart, extracted from
  restartInstance); `logout` is refused, as it would end the session
  the rest of Studio is using.
- Failures never reach console.error or the global React Query
  handlers. Anything that reached Harper marks entity queries stale
  without refetching; a request abandoned mid-flight is reported as
  possibly applied. The console remounts per entity, and the download
  is abandoned once progress passes 5 MB.

Also: tryParseJson is split out of tryParseRecordJson for reuse, and the
engine-position stripper now handles V8's "after JSON at position"
wording, which the record editors showed twice too.

Closes #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sent twice

Every instance client replayed a 502/503/504 up to three times whatever
the request, and none of those statuses means the request went
unapplied: a proxy answers for an upstream that may already have it, and
Harper itself answers 503 after handing an operation to a worker that
exits or overruns. So an insert could store its records twice, a drop
could succeed and then report "does not exist", and a deploy or restart
could run again.

The retry now replays only what the restart gate already treats as a
read — a GET, or a body whose operation is a read by Harper's naming.
That predicate moves out of the gate into isReadOperation.ts as
isReadRequest, so the two share one rule. A write is sent once and its
first 5xx is final. skipGatewayRetry stays for the operations console,
whose component operations can carry a read-looking name.

Closes #1879

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isReadRequest took the operation name from any request's body, so a DELETE, PUT or PATCH whose body happened to carry a read-named operation field counted as a read: the restart gate would hold it and the gateway retry would replay it. Harper takes an operation only from POST / (server/operationsServer.ts) and central manager's proxy forwards one only from a POST body (HDBInstance.js post), so on any other method that field is data and the request is a write. A missing or non-string method stays a write too; axios always sets a lowercase method before interceptors run (lib/core/Axios.js), so no real request reaches the predicate without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cross-model round flagged the JSDoc's first sentence as restating the predicate's two branches. Keep only what the code cannot say: what the answer is used for, the fail-closed default, and why only a POST body names an operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not that it failed

A write answered 502/503/504, or not at all (a timeout, ERR_NETWORK), may
already have been applied, but Studio showed it as an ordinary failure: the
gateway's body or "Request failed with status code 504", and callers' own
"Failed to restart …" / "Failed to import …" on top. Retrying by hand
repeated the side effect.

isUnansweredWrite (new) recognizes it from the request (isReadRequest) and
the status, never the body. describeError checks it first and returns
"Outcome unknown" copy, so the global mutation toast and every describeError
caller get it. Callers that add their own failure toast to a mutation ask
shouldToastOwnFailure first; rename and the chat tools, which have no
mutation behind them, show the same copy; the cluster restart, rolling
config and license loops count these apart from failures. secrets.ts no
longer re-wraps such an error, so the predicate can still see it.
package_component is classified as the read it is, and skips the gateway
replay because each attempt rebuilds the archive.

Closes #1890

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e TXT record

Validating pending domains posts one request per domain and, on any failure,
said "Validation failed! Please make sure the TXT record has been put in
place." A validation central manager answered 502/503/504, or never
answered, may have succeeded, so it is now counted apart: the toast says the
outcome is unknown and the list is refetched to show what changed.

Also from review: isUnansweredWrite checks the status before it parses a
serialized body, so only an ambiguous failure pays for the parse; trims
comments that only restated their identifiers; guards a test teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…answers again

A restart answered 502/503/504, or not at all, may have run: central
manager's proxy answers 504 after forwarding it, and Harper can go down
mid-reply. The Restart button still released its restart mark at once on
that failure, so the tracker's refetches hit an instance going down and
their connectivity errors toasted beside "Outcome unknown". The cluster
loop also released the member and restarted the next one while it might
still be down, which defeats the one-at-a-time restart.

Both now hold the mark until waitUntilAnsweringAfterRestart sees the
target again, as the operations console already does: the button in the
background, after showing the warning; the loop before it moves on.

From the adjudicated cross-model review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sh after an unanswered deploy

The license loop's "Outcome unknown" toast dropped the licenses that were
refused outright, so a run with one of each left the refused one
unaccounted for. It now adds "N other licenses failed to apply", as the
domain validation toast does.

A redeploy or import that got no answer is told to check whether it took
effect, but nothing refreshed what the user would check. The redeploy
modal now refetches the components list on that failure, live progress or
not, and the import refetches the instance and reloads the file tree.

Also trims the isUnansweredWrite JSDoc to the part the code doesn't say.

From the adjudicated cross-model review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h gives up

The note read as though the mark held indefinitely and the cluster loop never
moved past a member that never answers. Both release when
waitUntilAnsweringAfterRestart gives up, as they do after an accepted restart.

From the adjudicated cross-model review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1283-freeform-operations branch from aaca36e to 8f6b690 Compare October 13, 2026 00:52
dawsontoth and others added 11 commits October 12, 2026 21:23
…ailed

The rebase onto stage brought in a write surface this PR's caller audit
predates: activating a staged release (#1876's chain). Its mutation opts
out of the global toast, like import and redeploy since #1871, so an
activation that timed out or got a 504 showed "Failed to activate <app>"
with axios's own text and no warning that it may have gone through.

deployFailureTitle (new, beside the deploy reporters) is the one place a
deploy's failure title is decided: "Couldn't confirm <app> was activated"
for a write with no verdict, "Failed to activate <app>" otherwise. The
import form and the redeploy modal, which the rebase had given their own
copies of that rule, now use it too. reportDeployFailure shows the
unknown-outcome copy, and the activation's progress panel says so as
well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eat their neighbours

Review round after the rebase (codex nit, kept by the Harper domain
adjudicator): DeployVerb had no consumer outside reportDeployHealth.ts,
since every caller passes a literal. The redeploy modal's second refresh
comment repeated the first, and the skipGatewayRetry type doc listed
package_component, whose own call site already says why it opts out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at it failed

restartInstance sent the restart and then watched for the target with
user_info, and both steps rejected the same promise, so a target slow to
answer again read as a failed restart everywhere: "Failed to restart
instance.", "Only N of M instances restarted", and success: false to the
chat agent. Each of those invites a second restart of a target that
already went down once.

Harper's answer to the restart request is its only verdict: `restart`
answers before it tears down (bin/restart.ts schedules the teardown 50ms
out), and `restart_service` once its job has started (executeJob). So a
probe failure after a 2xx now rejects with RestartUnconfirmedError
(cause: the last probe error), shown as "Not back yet ... check its
status before you restart it again".

- The watch keeps going past the usual recovery time (a second probe
  schedule, no second settle) and calls onSlowToReturn once in between,
  so the Restart button and both loops say "still waiting" instead of
  giving up. Every restart mark's TTL comes from the same constants, and
  the probes skip the gateway replay, so no mark lapses mid-watch.
- The cluster restart and rolling config update stop at a member that
  never comes back instead of restarting the next one, which could leave
  no member serving, and report the members they left alone.
- createApp keeps success: true (the app exists) and restartHTTPService
  returns copy without the "Error:" prefix telling the agent not to
  restart again. Both now replace their progress toast on any failure,
  which used to keep spinning.

Closes #1895

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…members too

Review of the previous commit:

- A Cancel clicked while the loop waited on a member that never came back
  replaced the "Not back yet, check before you restart it again" toast
  with "Cancelled", hiding the one warning that matters. The uncertain
  outcome now wins; Cancelled still shows when nothing is uncertain.
- The uncertain-outcome toast counted the members that refused only
  implicitly. It now says "N instances failed to restart", from one
  helper both loops share (describeUncertainRollingOutcome).
- onSlowToReturn is queued rather than called inline, so a callback that
  throws cannot cut the watch short or become its cause.

Not adopted: counting a 401/403 probe as the target being back. Through
central manager's proxy a 403 is CM refusing the caller before it
contacts the instance (notAllowed() in HDBInstance.js), so it says
nothing about the target; recorded in DESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat it never answered

Review round 2:

- A probe can fail with an answer (a 401/403, a Harper 500), so "hasn't
  answered since" was not always true. Every surface now says Studio
  couldn't confirm the target came back, which is what a failed watch
  proves. The watch itself still counts only a successful user_info as
  back: through central manager's proxy a 403 is CM refusing the caller
  (notAllowed() in HDBInstance.js) before it contacts the instance.
- A throwing onSlowToReturn is caught and logged instead of escaping as
  an uncaught microtask exception; the watch carries on either way.
- RollingOutcome is no longer exported; it has no consumer outside its
  module.

Not adopted: dismissing the button's loading toast for an unconfirmed
restart. It already is: the catch dismisses it before deciding whether to
add its own toast. A test now pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever answered

Review round 3: DESIGN.md and restartInstance's doc comment still said the
target "never answers", which contradicts the rule that only a successful
user_info counts as back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efused config-update members

Mutation checks after the rebase onto the new restart loops found three
guards no test held: the restart request's explicit 60s timeout (the
client default is also 60s, so it only shows with that default cleared),
RESTART_MARK_TTL_MS on the Restart button and both cluster-loop marks
(restoring the old five minutes stayed green, since sleep is mocked), and
the rolling config update's failed count.

- restartInstance: the request's bound, every probe's timeout and every
  wait add up to less than RESTART_MARK_TTL_MS.
- The Restart button and the cluster loop still hold their marks six
  minutes in, past the old TTL, while the watch runs.
- A config update names a member that refused alongside the one not back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fig fan-out

After the rebase onto #1861's rolling config update, the stop rule also
decides the fan-out: a canary that is not back never proved it can run
the new configuration, so it is never fanned out from, and a stop after
the fan-out keeps the note that the members left alone may already have
the configuration saved. Both are pinned in useRollingConfigUpdate.test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay what Cancel left

Review round 1 after the port (codex major, kept by the Harper domain
adjudicator): the cluster loop waits on a member whose restart got no
verdict, but when that watch also ran out it discarded the rejection and
restarted the next member. On a two-member cluster, a 504 after the
restart ran plus a member that stays down left no member serving, which
is exactly what the stop rule exists to prevent. The loop now stops
there too, and the report says Studio couldn't confirm that member came
back either, instead of claiming it accepted the restart.

The adjudicator's nit: a Cancel after a no-verdict member now yields the
"Outcome unknown" report, which never said Cancel skipped the rest. Both
loops now name the members Cancel left alone in that report.

Also from the round: drop the test comments that only narrated their
setup, and trim restartInstance's JSDoc to its contract (DESIGN.md
carries the reasoning).

Not taken: Cancel during set_configuration still restarts that member;
that check is #1867's, unchanged here. The watch outliving the progress
toasts' durations is not a problem: sonner never starts an auto-close
timer for a loading toast (sonner 2.0.8, dist/index.mjs:644).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onor Cancel mid-write

Review round 2 after the port (codex major twice; the Harper domain
adjudicator kept both as minor and said the base stack could take them):

- An unanswered restart_service moved on to the next member after 3s.
  If the restart ran onto a configuration that broke the member, the
  next member got the same configuration. The config update now waits on
  that member, as the cluster restart does, and stops there if the watch
  gives up too. An unanswered set_configuration does not wait: nothing
  restarted that member.
- A Cancel clicked while a member's set_configuration was in flight still
  restarted it. The loop now stops before that restart, and the Cancelled
  report says the configuration may be pending there, as after a fan-out.
- After a Cancel, the "Waiting On Instance" toast that onSlowToReturn
  brings back offered a second Cancel, as if the first had not landed.
  Both loops now show it without the action and say no other instance
  will be restarted or updated.

Also drops the test helper comments the round flagged as narration.

Rejected on evidence: gemini's "axiosRetry resends the restart POST"
(the POST is sent once; axiosRetry wraps only the probes, and #1891's
replay never repeats a write), and "a Cancel aborts the watch into
RestartUnconfirmedError" (Cancel only sets a flag; no signal reaches the
watch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l was clicked

Review round 3 (codex minor, kept by the Harper domain adjudicator):
round 2 took the Cancel action off the slow-return toast, but the
no-verdict follow-up toast round 2 added to the config update, the
cluster loop's one, and both loops' "carry on momentarily" toasts still
came back with a Cancel button after Cancel had closed the toast, and
promised to carry on for a watch of up to about seven minutes.

One helper per loop now builds every progress toast after the first: the
usual Cancel action and copy, or, once Cancel was clicked, neither, and a
note that no other instance will be restarted or updated. Also drops a
comment that only restated its flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review October 13, 2026 02:49
@dawsontoth
dawsontoth requested a review from a team as a code owner October 13, 2026 02:49
@dawsontoth
dawsontoth merged commit 88390c6 into stage Oct 13, 2026
11 checks passed
@dawsontoth
dawsontoth deleted the claude/1283-freeform-operations branch October 13, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant