Repository navigation
feat(instance): add an Operations console for sending any operations-API request - #1882
Conversation
There was a problem hiding this comment.
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.
…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>
aaca36e to
8f6b690
Compare
…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>
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 anddprint checkall 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-offsql) 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.💡 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 samegetInstanceClientclient 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:
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 mutatingget_….skipGatewayRetryflag opts the request out of the shared client's 502/503/504 replay.restartis tracked withmarkRestartinguntil 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.logoutis refused: it would end the session the rest of Studio is still using.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
fetch, which bypasses the instance client's restart gate, Bearer refresh, 401 recovery and proxy routing.🔧 Changes
The page
src/features/instance/operations/index.tsx:OperationsIndexgates onuseInstanceManagePermission. It renders nothing while the instance user is still loading, so a super user is never bounced, and sends a non-super user to Databases.sendholds a synchronous in-flight ref. It tracks arestartwithmarkRestartingand releases it on a 4xx, otherwise once the target answers again.refetchType: 'none'for any non-read that reached Harper, and skips state updates after unmount.submitrefuseslogoutand confirms everything that is not a built-in read.ResultViewshows the status badge and body, the too-large notice, or the "may or may not have run" failure.src/features/instance/operations/routes.tsis the lazyoperationsroute, the samecreateXRouteTree(instanceLayoutRoute)shape as Logs and Status.src/features/instance/routes.tsmounts it in local, cluster and instance mode, keepinggetParentRouteandaddChildrenin lockstep.src/features/instance/InstanceNavBar.tsx: theOperationsentry, behindcanManage.The request
src/features/instance/operations/sendOperation.ts:responseType: 'text'andskipGatewayRetry, and passesskipRestartGatethrough for a tracked restart.MAX_RESPONSE_BYTES, then formats the body with the explorer'sformatResponseBody.src/features/instance/operations/parseOperationRequest.tsrequires a single JSON object with a stringoperation, and returns the text untouched.src/features/instance/operations/operationKinds.ts:isBuiltInReadis the read naming rule plusgetOperationInfofrom the role editor's catalog.Shared pieces this touches
src/integrations/api/isReadOperation.tsis the restart gate's read regex, moved unchanged so both callers share one rule.src/lib/restart/restartGate.tsnow calls it with unchanged behavior, and itsoperationOfcomment now notes the console's string bodies.src/integrations/api/retryGatewayErrors.ts: askipGatewayRetryrequest flag, checked before__retryCountis touched. It is off by default, so no existing call changes.src/integrations/api/instance/status/restartInstance.ts:waitUntilAnsweringAfterRestartis extracted fromrestartInstance, whose behavior is unchanged, so the console waits the same way the Restart button does.src/features/instance/databases/modals/recordEditorJson.ts:tryParseJsonis split out oftryParseRecordJson, which is unchanged. Also fixed:withoutEngineLocationnow strips V8'safter JSON at position N (line L column C)too. The record editors were printing that location twice (seen in the browser here asLine 1, column 57: … at position 56 (line 1 column 57)).src/features/instance/applications/components/Chat/tools/getUserContext/execute.ts: the chat agent now says the user is in the operations console, instead of describing the last focused file.DESIGN.md: a new "Operations console" section with the rules and where each is enforced, and a pointer from the restart section toisReadOperation.✅ Verification
End-to-end against a real Harper (4.7.40 in a throwaway loopback container, generated test credentials):
getInstanceClientinterceptor chain and jsdom's XHR:describe_all,create_databaseandcreate_tableanswered 200. An unknown operation answered 400Operation 'not_a_real_operation' not foundand a missing table 404, both rendered as Harper's body. A 6,000-record insert answered 200. Asearch_by_valuereturning ~6 MB came backtoo-largefrom real progress events, and the same search withlimit: 2came back normally.pnpm dev:local, signed in with the test user). Monaco loaded with no console errors and no DI errors.describe_allsent immediately. A typo producedLine 1, column 57: …inline, which cleared on edit.create_databaseopened the confirmation with the exact body, sent once on confirm, and answered 200. Cancel ondrop_databasesent nothing, and the dialog kept its content through the close animation.describe_tableon a missing table rendered the 404 body inline. Noconsole.errorcame 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.
src/features/instance/operations/index.test.tsx:get_…confirms.logoutis refused. A double click inside one act sends once.operationis refused, and the message clears on edit.console.erroris never called. An unanswered write "may or may not have run". A write abandoned on unmount still marks data stale.src/features/instance/operations/sendOperation.test.tsruns the realgetInstanceClient(restart gate, replication check and gateway retry installed) with a custom adapter:markRestarting), gate bypass, caller gone before send, abandoned mid-flight, and connection replaced.src/features/instance/operations/parseOperationRequest.test.ts,src/features/instance/operations/operationKinds.test.tsandsrc/integrations/api/isReadOperation.test.tscover the validation, the built-in-read rule, and the shared read rule's anchoring.src/integrations/api/retryGatewayErrors.test.ts: the flag stops a 502/503/504 replay without touching__retryCount.src/features/instance/databases/modals/recordEditorJson.test.ts: the "after JSON" wording loses its duplicated position and keeps the location.src/router/__tests__/orgUndefinedRepro.test.tschecks that cluster and instance/operationsdeep links keeporganizationId/clusterId/instanceId.src/features/instance/InstanceNavBar.test.tsxchecks the entry is shown to a super user and hidden from anyone else.Gates (real exit codes, Node 24.21.0, through the husky pre-commit hook): full
vitest runpassed 401 files, 3,760 tests, exit 0.tsc -bexit 0,oxlintexit 0,dprint checkexit 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/logoutbypassing 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:stringifySafelyreturns the trimmed string for a JSON string body, and the test asserts the trimmed body.restart_serviceis an outage too":useRestartInstanceClickmarks onlyrestart, becauserestart_servicekeeps answering.holdsRequests(…)is true, i.e. only while the entity is tracked as restarting." restart ", with spaces, skips tracking": Harper resolves operations by exactMap.get(json.operation)(serverUtilities.ts), so that name is an unknown operation, a 400, not a restart.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 withno-receipt(the 1Password SSH agent refused to sign the review CLI's fetch), and the Harper domain adjudicator failedauth(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