Skip to content

Commit 2c84aaa

Browse files
Gkrumbach07claude
andcommitted
fix: address self-review findings on sandbox stop/start + draft approve
- Fix false-positive success toasts: list-page Stop/Start now report success/failure from the mutation's actual outcome, not immediately on click. Detail-page Stop/Start gained the error feedback they were missing entirely. - Extract canStopSandbox/canStartSandbox so the list row and detail page page can't disagree on which phases allow which action. - Add STOPPED/STOPPING/STARTING colors to StatusDot (PhaseLabel already had them). - Drop the delete-confirmation copy claiming deletion is "the only way to stop a running sandbox" — no longer true now that Stop exists. - ApproveDraftChunk: accept an optional client-supplied reviewToken (the frontend already has it from GetDraftPolicy) and only fall back to the extra GetDraft round-trip when the client didn't send one. - Add handler tests for StopSandbox/StartSandbox (previously untested; the SDK mock's Stop/Start literally panicked) and for both ApproveDraftChunk token paths. - Correct .claude/rules/openshell-api.md and CLAUDE.md: backend/proto/ and backend/internal/gateway/ were removed by the SDK migration (#43) and are stale/unused — the vendored Go SDK is the source of truth now, not a local proto copy. Also documented ApproveDraftChunk's new review_token under the optimistic-concurrency hard fact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Gage Krumbach <gkrumbac@redhat.com>
1 parent 8ac8cd8 commit 2c84aaa

13 files changed

Lines changed: 261 additions & 50 deletions

File tree

‎.claude/rules/openshell-api.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
---
2-
description: OpenShell gRPC API reference for dashboard development — proto is source of truth
3-
globs: "backend/internal/gateway/**,backend/proto/**,frontend/src/api/**,frontend/src/types/**"
2+
description: OpenShell gRPC API reference for dashboard development — the vendored Go SDK is source of truth
3+
globs: "backend/internal/sdkclient/**,backend/internal/api/**,frontend/src/api/**,frontend/src/types/**"
44
alwaysApply: false
55
---
66

77
# OpenShell API Reference
88

9-
**THE PROTO FILES IN `backend/proto/` ARE THE SOURCE OF TRUTH.** Before writing any gateway wrapper method, REST handler, or TypeScript type, read the actual RPC and message definitions in the proto. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint.
9+
**THE VENDORED GO SDK IS THE SOURCE OF TRUTH.** Since the SDK migration (PR #43), the BFF calls `github.com/NVIDIA/OpenShell/sdk/go` directly — there is no local `backend/proto/` copy or `backend/gen/` anymore (both were removed; `backend/proto/*.proto` predates the migration and is stale/unused — do not edit or regenerate from it). Before writing any handler or TypeScript type, read the actual RPC/message definitions in `$(go env GOMODCACHE)/github.com/!n!v!i!d!i!a/!open!shell/sdk/go@<version>/openshell/v1/` (hand-written client wrappers with doc comments; `types/*.go` for message shapes) or via `go doc`. Never invent RPCs, fields, or lifecycle states. If a UI idea has no backing RPC, flag it — do not fabricate an endpoint. Track upstream via `go get github.com/NVIDIA/OpenShell/sdk/go@latest`.
1010

1111
## Services
1212

13-
| Service | Proto | What we use |
14-
|---------|-------|-------------|
15-
| `openshell.v1.OpenShell` (68 RPCs) | openshell.proto | Sandbox (incl. stop/start), workspace, member, provider, profile, policy, draft policy, logs, SSH, services |
16-
| `openshell.inference.v1.Inference` (4 RPCs) | inference.proto | Inference route CRUD |
13+
| Service | SDK package | What we use |
14+
|---------|-------------|-------------|
15+
| `openshell.v1.OpenShell` | `openshell/v1` (`Client.Sandboxes()`, `.Workspaces()`, `.Providers()`, `.Policy()`, ...) | Sandbox (incl. stop/start), workspace, member, provider, profile, policy, draft policy, logs, SSH, services |
16+
| `openshell.inference.v1.Inference` | `openshell/v1` (`Client.Inference()`) | Inference route CRUD |
1717

1818
Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/operator only.
1919

@@ -29,7 +29,7 @@ Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/
2929
8. **Provider model** (`datamodel.v1.Provider`): metadata, type (profile slug like "claude"/"gitlab"), credentials (map, `secret` option — strip before returning to browser), config (map), credential_expires_at_ms, profile_workspace. No endpoint-URL/status/model fields. Valid types come from `ListProviderProfiles`.
3030
9. **No list-images API.** Sandbox images are free-text OCI refs; community images by convention `ghcr.io/nvidia/openshell-community/sandboxes/<name>`.
3131
10. **`GetGatewayInfo` returns only** status, gateway_version, compute_drivers[]. No uptime/db/TLS/auth-mode fields.
32-
11. **Optimistic concurrency:** `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateConfig`, `UpdateProviderProfiles` accept `expected_resource_version` — pass the ObjectMeta.resource_version from the last read.
32+
11. **Optimistic concurrency:** `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateConfig`, `UpdateProviderProfiles` accept `expected_resource_version` — pass the ObjectMeta.resource_version from the last read. `ApproveDraftChunk` similarly takes a `review_token` (from the chunk's last `GetDraft` read) pinning the exact evaluated candidate; the BFF falls back to resolving it server-side if the client doesn't send one.
3333
12. **Workspace scoping:** most requests carry a `workspace` field (empty = "default"); list RPCs offer `all_workspaces`. Workspaces themselves are top-level.
3434
13. **Secret fields** are annotated `[(openshell.options.v1.secret) = true]` in proto — grep for `secret` when adding a wrapper and never serialize those fields to the frontend.
3535

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Each rule has a corresponding Architecture Decision Record in [`docs/adrs/`](doc
5353

5454
## OpenShell API reference
5555

56-
The gateway exposes 72 gRPC RPCs across 2 services (68 in `OpenShell`, 4 in `Inference`). Proto files are in `backend/proto/`.
56+
The gateway exposes RPCs across 2 services (`OpenShell`, `Inference`), consumed via the vendored `github.com/NVIDIA/OpenShell/sdk/go` (see `.claude/rules/openshell-api.md`) — not a local proto copy.
5757

5858
## Personas
5959

‎backend/internal/api/drafts_handler.go‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,38 @@ func (app *App) GetDraftPolicy(w http.ResponseWriter, r *http.Request) {
4545
writeJSON(w, http.StatusOK, models.FromSDKDraftPolicy(draft))
4646
}
4747

48+
// ApproveDraftChunkRequest optionally carries the chunk's review token, which
49+
// the frontend already has from its last GetDraftPolicy fetch. Absent for
50+
// older clients or the approve-from-notification path.
51+
type ApproveDraftChunkRequest struct {
52+
ReviewToken string `json:"reviewToken,omitempty"`
53+
}
54+
4855
// ApproveDraftChunk merges one proposed rule into the active policy.
4956
func (app *App) ApproveDraftChunk(w http.ResponseWriter, r *http.Request) {
5057
workspace := chi.URLParam(r, "workspace")
5158
name := chi.URLParam(r, "name")
5259
chunkID := chi.URLParam(r, "chunk")
5360

54-
// The gateway binds each approval to a review_token that pins the exact
55-
// evaluated candidate (optimistic concurrency). Resolve the current token
56-
// for this chunk from the draft before approving; older gateways return an
57-
// empty token, which the RPC accepts.
58-
reviewToken, err := app.resolveDraftReviewToken(r.Context(), workspace, name, chunkID)
59-
if err != nil {
60-
writeSDKError(w, err)
61+
var body ApproveDraftChunkRequest
62+
if r.ContentLength > 0 && !decodeBody(w, r, &body) {
6163
return
6264
}
6365

66+
// The gateway binds each approval to a review_token that pins the exact
67+
// evaluated candidate (optimistic concurrency). Use the token the client
68+
// already has; only fall back to a GetDraft round-trip when it didn't send
69+
// one. Older gateways return an empty token, which the RPC accepts.
70+
reviewToken := body.ReviewToken
71+
if reviewToken == "" {
72+
var err error
73+
reviewToken, err = app.resolveDraftReviewToken(r.Context(), workspace, name, chunkID)
74+
if err != nil {
75+
writeSDKError(w, err)
76+
return
77+
}
78+
}
79+
6480
result, err := app.sdk.Policy().ApproveDraftChunk(r.Context(), workspace, name, chunkID, reviewToken)
6581
if err != nil {
6682
writeSDKError(w, err)

‎backend/internal/api/mock_sdk_test.go‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ type mockSDKSandboxes struct {
4646
detachFn func(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*openshell.DetachProviderResult, error)
4747
listProvidersFn func(ctx context.Context, workspace, sandboxName string) ([]*openshell.Provider, error)
4848
getLogsFn func(ctx context.Context, workspace, name string, opts ...openshell.LogOption) (*openshell.LogResult, error)
49+
stopFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
50+
startFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
4951
}
5052

5153
func (m *mockSDKSandboxes) List(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
@@ -112,12 +114,18 @@ func (m *mockSDKSandboxes) GetLogs(ctx context.Context, workspace, name string,
112114
return &openshell.LogResult{}, nil
113115
}
114116

115-
func (m *mockSDKSandboxes) Start(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
116-
panic("not implemented")
117+
func (m *mockSDKSandboxes) Start(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) {
118+
if m.startFn != nil {
119+
return m.startFn(ctx, workspace, name)
120+
}
121+
return &openshell.Sandbox{Name: name}, nil
117122
}
118123

119-
func (m *mockSDKSandboxes) Stop(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
120-
panic("not implemented")
124+
func (m *mockSDKSandboxes) Stop(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) {
125+
if m.stopFn != nil {
126+
return m.stopFn(ctx, workspace, name)
127+
}
128+
return &openshell.Sandbox{Name: name}, nil
121129
}
122130

123131
func (m *mockSDKSandboxes) WaitStopped(_ context.Context, _, _ string, _ ...openshell.WaitOptions) (*openshell.Sandbox, error) {

‎backend/internal/api/policies_handler_test.go‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,63 @@ func TestGetDraftPolicy(t *testing.T) {
210210
}
211211

212212
func TestApproveDraftChunk(t *testing.T) {
213-
app := newTestAppWithSDK(&mockSDK{})
214-
r := chi.NewRouter()
215-
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
216-
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve", nil)
217-
w := httptest.NewRecorder()
218-
r.ServeHTTP(w, req)
219-
if w.Code != http.StatusOK {
220-
t.Fatalf("status = %d", w.Code)
221-
}
213+
t.Run("uses client-supplied review token without resolving the draft", func(t *testing.T) {
214+
sdk := &mockSDK{}
215+
sdk.policy.getDraftFn = func(_ context.Context, _, _ string, _ ...openshell.GetDraftOption) (*openshell.DraftPolicy, error) {
216+
t.Fatal("GetDraft should not be called when the client supplies a reviewToken")
217+
return nil, nil
218+
}
219+
var gotToken string
220+
sdk.policy.approveFn = func(_ context.Context, _, _, _, reviewToken string) (*openshell.ApproveResult, error) {
221+
gotToken = reviewToken
222+
return &openshell.ApproveResult{PolicyVersion: 2}, nil
223+
}
224+
app := newTestAppWithSDK(sdk)
225+
r := chi.NewRouter()
226+
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
227+
req := httptest.NewRequest(
228+
http.MethodPost,
229+
"/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve",
230+
strings.NewReader(`{"reviewToken":"client-token"}`),
231+
)
232+
req.Header.Set("Content-Type", "application/json")
233+
w := httptest.NewRecorder()
234+
r.ServeHTTP(w, req)
235+
if w.Code != http.StatusOK {
236+
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
237+
}
238+
if gotToken != "client-token" {
239+
t.Errorf("reviewToken passed to ApproveDraftChunk = %q, want %q", gotToken, "client-token")
240+
}
241+
})
242+
243+
t.Run("resolves the review token from the draft when the client sends none", func(t *testing.T) {
244+
sdk := &mockSDK{}
245+
sdk.policy.getDraftFn = func(_ context.Context, _, _ string, _ ...openshell.GetDraftOption) (*openshell.DraftPolicy, error) {
246+
return &openshell.DraftPolicy{
247+
Chunks: []openshell.PolicyChunk{
248+
{ID: "c1", ReviewToken: "resolved-token"},
249+
},
250+
}, nil
251+
}
252+
var gotToken string
253+
sdk.policy.approveFn = func(_ context.Context, _, _, _, reviewToken string) (*openshell.ApproveResult, error) {
254+
gotToken = reviewToken
255+
return &openshell.ApproveResult{PolicyVersion: 2}, nil
256+
}
257+
app := newTestAppWithSDK(sdk)
258+
r := chi.NewRouter()
259+
r.Post("/workspaces/{workspace}/sandboxes/{name}/drafts/{chunk}/approve", app.ApproveDraftChunk)
260+
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/drafts/c1/approve", nil)
261+
w := httptest.NewRecorder()
262+
r.ServeHTTP(w, req)
263+
if w.Code != http.StatusOK {
264+
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
265+
}
266+
if gotToken != "resolved-token" {
267+
t.Errorf("reviewToken passed to ApproveDraftChunk = %q, want %q", gotToken, "resolved-token")
268+
}
269+
})
222270
}
223271

224272
func TestRejectDraftChunk(t *testing.T) {

‎backend/internal/api/sandboxes_handler_test.go‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,93 @@ func TestGetSandbox(t *testing.T) {
235235
}
236236
}
237237

238+
func TestStopSandbox(t *testing.T) {
239+
tests := []struct {
240+
stopFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
241+
name string
242+
wantStatus int
243+
}{
244+
{
245+
name: "success",
246+
stopFn: func(_ context.Context, _, name string) (*openshell.Sandbox, error) {
247+
return &openshell.Sandbox{ID: "id-1", Name: name, Status: openshell.SandboxStatus{Phase: openshell.SandboxStopping}}, nil
248+
},
249+
wantStatus: http.StatusOK,
250+
},
251+
{
252+
name: "not found",
253+
stopFn: func(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
254+
return nil, &openshell.StatusError{Code: openshell.ErrorNotFound, Message: "sandbox not found"}
255+
},
256+
wantStatus: http.StatusNotFound,
257+
},
258+
{
259+
name: "already stopped conflicts",
260+
stopFn: func(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
261+
return nil, &openshell.StatusError{Code: openshell.ErrorConflict, Message: "sandbox is not running"}
262+
},
263+
wantStatus: http.StatusConflict,
264+
},
265+
}
266+
for _, tc := range tests {
267+
t.Run(tc.name, func(t *testing.T) {
268+
sdk := &mockSDK{}
269+
sdk.sandboxes.stopFn = tc.stopFn
270+
app := newTestAppWithSDK(sdk)
271+
r := chi.NewRouter()
272+
r.Post("/workspaces/{workspace}/sandboxes/{name}/stop", app.StopSandbox)
273+
274+
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/stop", nil)
275+
w := httptest.NewRecorder()
276+
r.ServeHTTP(w, req)
277+
278+
if w.Code != tc.wantStatus {
279+
t.Errorf("status = %d, want %d; body: %s", w.Code, tc.wantStatus, w.Body.String())
280+
}
281+
})
282+
}
283+
}
284+
285+
func TestStartSandbox(t *testing.T) {
286+
tests := []struct {
287+
startFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
288+
name string
289+
wantStatus int
290+
}{
291+
{
292+
name: "success",
293+
startFn: func(_ context.Context, _, name string) (*openshell.Sandbox, error) {
294+
return &openshell.Sandbox{ID: "id-1", Name: name, Status: openshell.SandboxStatus{Phase: openshell.SandboxStarting}}, nil
295+
},
296+
wantStatus: http.StatusOK,
297+
},
298+
{
299+
name: "not found",
300+
startFn: func(_ context.Context, _, _ string) (*openshell.Sandbox, error) {
301+
return nil, &openshell.StatusError{Code: openshell.ErrorNotFound, Message: "sandbox not found"}
302+
},
303+
wantStatus: http.StatusNotFound,
304+
},
305+
}
306+
for _, tc := range tests {
307+
t.Run(tc.name, func(t *testing.T) {
308+
sdk := &mockSDK{}
309+
sdk.sandboxes.startFn = tc.startFn
310+
app := newTestAppWithSDK(sdk)
311+
r := chi.NewRouter()
312+
r.Post("/workspaces/{workspace}/sandboxes/{name}/start", app.StartSandbox)
313+
314+
req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/start", nil)
315+
w := httptest.NewRecorder()
316+
r.ServeHTTP(w, req)
317+
318+
if w.Code != tc.wantStatus {
319+
t.Errorf("status = %d, want %d; body: %s", w.Code, tc.wantStatus, w.Body.String())
320+
}
321+
})
322+
}
323+
}
324+
238325
func TestDeleteSandbox(t *testing.T) {
239326
tests := []struct {
240327
deleteFn func(ctx context.Context, workspace, name string) error

‎frontend/src/api/policy.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ export const approveDraftChunk = (
6363
workspace: string,
6464
name: string,
6565
chunkId: string,
66+
reviewToken?: string,
6667
): Promise<PolicyUpdateResult> =>
6768
post<PolicyUpdateResult>(
6869
`${sandboxBase(workspace, name)}/drafts/${encodeURIComponent(chunkId)}/approve`,
69-
{},
70+
reviewToken ? { reviewToken } : {},
7071
);
7172

7273
export const rejectDraftChunk = (
@@ -190,8 +191,11 @@ const useDraftMutation = <TArgs, TResult>(
190191
};
191192

192193
export const useApproveDraftChunk = (workspace: string, name: string) =>
193-
useDraftMutation(workspace, name, (chunkId: string) =>
194-
approveDraftChunk(workspace, name, chunkId),
194+
useDraftMutation(
195+
workspace,
196+
name,
197+
(args: { chunkId: string; reviewToken?: string }) =>
198+
approveDraftChunk(workspace, name, args.chunkId, args.reviewToken),
195199
);
196200

197201
export const useRejectDraftChunk = (workspace: string, name: string) =>

‎frontend/src/components/SandboxDraftsTab/DraftChunkRow.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ const DraftChunkRow: React.FC<DraftChunkRowProps> = ({
114114
<Button
115115
variant="primary"
116116
size="sm"
117-
onClick={() => approve.mutate(chunk.id)}
117+
onClick={() =>
118+
approve.mutate({
119+
chunkId: chunk.id,
120+
reviewToken: chunk.reviewToken,
121+
})
122+
}
118123
isDisabled={approve.isPending}
119124
data-testid={`approve-chunk-${chunk.id}`}
120125
>

‎frontend/src/components/StatusDot.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ const STATUS_DOT_COLORS: Partial<Record<SandboxPhase, string>> = {
44
READY: 'var(--pf-t--global--color--status--success--default)',
55
ERROR: 'var(--pf-t--global--color--status--danger--default)',
66
PROVISIONING: 'var(--pf-t--global--color--status--info--default)',
7+
STARTING: 'var(--pf-t--global--color--status--info--default)',
8+
STOPPING: 'var(--pf-t--global--color--status--info--default)',
79
DELETING: 'var(--pf-t--global--color--status--warning--default)',
10+
STOPPED: 'var(--pf-t--global--icon--color--disabled)',
811
};
912

1013
export const getStatusDotColor = (phase: SandboxPhase): string =>

‎frontend/src/components/sandbox/SandboxTableRow.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import LabelsList from '../LabelsList';
1616
import { getPolicySummary } from './SandboxEgressSummary';
1717
import StatusDot from '../StatusDot';
1818
import { formatAge } from '../../utils/formatters';
19+
import { canStartSandbox, canStopSandbox } from '../../utils/sandboxLifecycle';
1920
import type { Sandbox, SandboxPolicyView } from '../../types';
2021

2122
type PolicySummary = ReturnType<typeof getPolicySummary>;
@@ -67,10 +68,10 @@ const SandboxTableRow: React.FC<SandboxTableRowProps> = ({
6768
const actionItems = [
6869
...(onOpenTerminal ? [{ title: 'Terminal', onClick: onOpenTerminal }] : []),
6970
{ title: 'Logs', onClick: onViewLogs },
70-
...(onStop && sandbox.status.phase === 'READY'
71+
...(onStop && canStopSandbox(sandbox.status.phase)
7172
? [{ title: 'Stop', onClick: onStop }]
7273
: []),
73-
...(onStart && sandbox.status.phase === 'STOPPED'
74+
...(onStart && canStartSandbox(sandbox.status.phase)
7475
? [{ title: 'Start', onClick: onStart }]
7576
: []),
7677
{ title: 'Delete', onClick: onDelete },

0 commit comments

Comments
 (0)