feat(workspace): per-workspace data_persistence choice (internal#734 PR-2) #2014

Merged
hongming merged 3 commits from feat/workspace-data-persistence into main 2026-05-30 18:57:56 +00:00
22 changed files with 254 additions and 40 deletions
@@ -29,8 +29,15 @@ type FormState = {
displayMode: string;
displayProtocol: string;
resolution: string;
dataPersistence: string; // "" (auto) | "persist" | "ephemeral" — internal#734
};
// internal#734: per-workspace durable-data choice. "" = auto (desktop-control
// keeps data, others follow the org default). Human labels for the selector.
const DATA_PERSISTENCE_OPTIONS = ["", "persist", "ephemeral"];
const dataPersistenceLabel = (v: string): string =>
v === "persist" ? "Always keep (persist)" : v === "ephemeral" ? "Don't keep (ephemeral)" : "Auto";
export function ContainerConfigTab({ workspaceId, data }: Props) {
const runtime = data.runtime;
const instanceType = data.compute?.instance_type;
@@ -39,9 +46,10 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
const displayProtocol = data.compute?.display?.protocol;
const displayWidth = data.compute?.display?.width;
const displayHeight = data.compute?.display?.height;
const dataPersistence = data.compute?.data_persistence;
const initial = useMemo(
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight }),
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight],
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence }),
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight, dataPersistence],
);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
@@ -84,6 +92,8 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
display: form.displayEnabled
? { mode: form.displayMode, protocol: form.displayProtocol, width, height }
: { mode: "none" },
// internal#734: omit when "auto" so the wire/default behavior is unchanged.
...(form.dataPersistence ? { data_persistence: form.dataPersistence } : {}),
};
const resp = await api.patch<{ needs_restart?: boolean }>(`/workspaces/${workspaceId}`, {
@@ -176,6 +186,18 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
onChange={(resolution) => setForm((s) => ({ ...s, resolution }))}
/>
)}
<SelectField
id="data-persistence"
label="Saved data (cookies, downloads, memory)"
value={form.dataPersistence}
options={DATA_PERSISTENCE_OPTIONS}
optionLabel={dataPersistenceLabel}
onChange={(dataPersistence) => setForm((s) => ({ ...s, dataPersistence }))}
/>
<p className="-mt-1 text-[10px] leading-snug text-ink-soft">
Whether this workspace&apos;s data survives a restart/recreate. Auto keeps it for
browser (desktop) workspaces; Ephemeral never keeps it (privacy).
</p>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
@@ -231,6 +253,7 @@ function formFromData(data: {
displayProtocol?: string;
displayWidth?: number;
displayHeight?: number;
dataPersistence?: string;
}): FormState {
const width = data.displayWidth ?? 1920;
const height = data.displayHeight ?? 1080;
@@ -243,6 +266,7 @@ function formFromData(data: {
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
displayProtocol: data.displayProtocol || "novnc",
resolution,
dataPersistence: data.dataPersistence || "",
};
}
+19 -1
View File
@@ -29,6 +29,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
const [peers, setPeers] = useState<PeerData[]>([]);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [eraseData, setEraseData] = useState(false); // internal#734: erase saved data on delete
const [peersError, setPeersError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -93,7 +94,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
const handleDelete = async () => {
setDeleteError(null);
try {
await api.del(`/workspaces/${workspaceId}?confirm=true`, {
// internal#734: erase_data=true asks the server to prune this workspace's
// durable data volume (cookies / downloads / memory). Default off keeps it
// for the orphan-sweeper grace.
await api.del(`/workspaces/${workspaceId}?confirm=true${eraseData ? "&erase_data=true" : ""}`, {
headers: { "X-Confirm-Name": name },
});
// Mirror the server-side cascade — drop the row + every
@@ -323,6 +327,19 @@ export function DetailsTab({ workspaceId, data }: Props) {
<h3 id="delete-confirm-title" className="text-xs font-medium text-bad">
Confirm deletion
</h3>
<label className="flex items-start gap-2 text-[11px] text-ink-mid">
<input
type="checkbox"
aria-label="Also erase saved data"
checked={eraseData}
onChange={(e) => setEraseData(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 accent-red-600"
/>
<span>
Also erase saved data (cookies, downloads, agent memory). Cannot be undone.
Unchecked keeps it recoverable briefly.
</span>
</label>
<div className="flex gap-2">
<button
type="button"
@@ -339,6 +356,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
onClick={() => {
setConfirmDelete(false);
setDeleteError(null);
setEraseData(false);
// Return focus to the trigger so keyboard users aren't stranded
deleteButtonRef.current?.focus();
}}
@@ -297,6 +297,25 @@ describe("DetailsTab — delete workflow", () => {
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
// internal#734: checking "also erase saved data" adds &erase_data=true so the
// server prunes the data volume. Default (unchecked) must NOT send it.
it("checking erase-saved-data sends erase_data=true on delete", async () => {
mockApi.del.mockResolvedValue(undefined);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
fireEvent.click(screen.getByRole("checkbox", { name: /erase saved data/i }));
const confirmBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Confirm Delete",
) as HTMLButtonElement;
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
await flush();
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true&erase_data=true", {
headers: { "X-Confirm-Name": "Test Workspace" },
});
});
it("cancelling delete returns to view mode", async () => {
mockApi.del.mockResolvedValue(undefined);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
+3
View File
@@ -368,6 +368,9 @@ export interface WorkspaceCompute {
width?: number;
height?: number;
};
// internal#734: per-workspace durable-data choice. "persist" | "ephemeral" |
// undefined (auto). Controls whether the data volume survives recreate.
data_persistence?: string;
}
let socket: ReconnectingSocket | null = null;
@@ -16,9 +16,9 @@ import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
@@ -2117,6 +2117,10 @@ func (f *fakeCPProv) Stop(_ context.Context, _ string) error {
f.stopCalls++
return nil
}
func (f *fakeCPProv) StopAndPrune(_ context.Context, _ string) error {
f.stopCalls++
return nil
}
func (f *fakeCPProv) GetConsoleOutput(_ context.Context, _ string) (string, error) {
return "", nil
}
+3 -1
View File
@@ -875,7 +875,9 @@ func (h *OrgHandler) Import(c *gin.Context) {
rows.Close()
for _, oid := range orphanIDs {
descendantIDs, stopErrs, err := h.workspace.CascadeDelete(ctx, oid)
// erase=false: a reconcile is not a user-requested erase —
// never prune data volumes on the import-reconcile path (internal#734).
descendantIDs, stopErrs, err := h.workspace.CascadeDelete(ctx, oid, false)
if err != nil {
log.Printf("Org import reconcile: CascadeDelete(%s) failed: %v", oid, err)
reconcileErrs = append(reconcileErrs, fmt.Sprintf("delete %s: %v", oid, err))
@@ -65,6 +65,14 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
return err
}
// internal#734: the durable-data choice. CP re-validates the same enum at
// its provision edge (IsValidDataPersistence → 400); validating here too
// gives the user a clear workspace-server error before the CP round-trip.
switch compute.DataPersistence {
case "", "persist", "ephemeral":
default:
return fmt.Errorf("unsupported compute.data_persistence (want persist|ephemeral)")
}
return nil
}
@@ -11,8 +11,8 @@ import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
@@ -36,6 +36,23 @@ func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
}
}
// internal#734: data_persistence enum. "" (auto), "persist", "ephemeral" are
// the only accepted values; anything else is a clear 400 before the CP call.
func TestValidateWorkspaceCompute_DataPersistence(t *testing.T) {
for _, ok := range []string{"", "persist", "ephemeral"} {
c := models.WorkspaceCompute{DataPersistence: ok}
if err := validateWorkspaceCompute(c); err != nil {
t.Errorf("data_persistence=%q must be accepted: %v", ok, err)
}
}
for _, bad := range []string{"persistent", "off", "none", "Ephemeral", "true"} {
c := models.WorkspaceCompute{DataPersistence: bad}
if err := validateWorkspaceCompute(c); err == nil {
t.Errorf("data_persistence=%q must be rejected", bad)
}
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
for _, rootGB := range []int{29, 501} {
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
@@ -399,7 +399,13 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
// disable, broadcast). The HTTP-specific bits — direct-children 409
// gate above, ?purge=true hard-delete below, response shaping —
// stay in this handler.
descendantIDs, stopErrs, err := h.CascadeDelete(ctx, id)
// internal#734: the user can ask to erase saved data (browser profile /
// cookies / downloads / agent memory) on delete. Opt-in — default keeps the
// data on its volume for the orphan-sweeper grace. Only a genuine
// permanent-delete reaches here (restart/reconcile use other paths), so this
// is the one place prune may be requested.
erase := c.Query("erase_data") == "true"
descendantIDs, stopErrs, err := h.CascadeDelete(ctx, id, erase)
if err != nil {
// Audit 2026-05-09 (Core-Security): raw `err.Error()` here was
// exposed to HTTP clients verbatim, including wrapped lib/pq
@@ -515,7 +521,13 @@ func destructiveDeleteCounts(ctx context.Context, id string) (childCount int, sc
// Caller is responsible for the children-confirmation gate (the HTTP handler
// returns 409 when children exist + ?confirm=true is missing); this helper
// always cascades.
func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]string, []error, error) {
// CascadeDelete tears down a workspace and its descendants (stop compute,
// remove volumes, revoke tokens, disable schedules, broadcast). erase=true
// (internal#734) means the user asked to erase saved data, so the CP compute
// teardown prunes each workspace's durable data volume; the HTTP delete passes
// the user's choice, the org-import reconcile passes false (a reconcile is not
// a user-erase).
func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string, erase bool) ([]string, []error, error) {
if err := validateWorkspaceID(id); err != nil {
return nil, nil, err
}
@@ -579,7 +591,7 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
// pending EC2 is queryable and handed off to the CP-orphan-sweeper —
// rather than the bare one-shot StopWorkspaceAuto that produced the
// silent-leak class (task #15 / workspace-ec2-leak).
if err := h.stopWorkspaceForDelete(cleanupCtx, wsID); err != nil {
if err := h.stopWorkspaceForDelete(cleanupCtx, wsID, erase); err != nil {
log.Printf("CascadeDelete %s stop failed: %v — leaving cleanup for orphan sweeper", wsID, err)
stopErrs = append(stopErrs, fmt.Errorf("stop %s: %w", wsID, err))
return
@@ -521,7 +521,7 @@ func TestValidateWorkspaceDir_Empty(t *testing.T) {
func TestCascadeDelete_InvalidUUID(t *testing.T) {
h := &WorkspaceHandler{}
descendants, stopErrs, err := h.CascadeDelete(context.Background(), "not-a-uuid")
descendants, stopErrs, err := h.CascadeDelete(context.Background(), "not-a-uuid", false)
if err == nil {
t.Error("expected error for invalid UUID")
}
@@ -542,7 +542,7 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
WithArgs(wsID).
WillReturnError(sql.ErrConnDone)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID, false)
if err == nil {
t.Error("CascadeDelete returned nil error; want descendant query error")
}
@@ -569,7 +569,7 @@ func TestCascadeDelete_DescendantRowsError(t *testing.T) {
WithArgs(wsID).
WillReturnRows(rows)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID, false)
if err == nil {
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
}
@@ -45,7 +45,7 @@ func TestStopWorkspaceForDelete_CPRetriesTransientThenSucceeds(t *testing.T) {
}}
h := &WorkspaceHandler{cpProv: stub}
err := h.stopWorkspaceForDelete(context.Background(), "ws-del-1")
err := h.stopWorkspaceForDelete(context.Background(), "ws-del-1", false)
if err != nil {
t.Fatalf("expected nil error on eventual success, got %v", err)
}
@@ -73,7 +73,7 @@ func TestStopWorkspaceForDelete_CPExhaustsEmitsDurableEventAndReturnsError(t *te
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
err := h.stopWorkspaceForDelete(context.Background(), "ws-doomed")
err := h.stopWorkspaceForDelete(context.Background(), "ws-doomed", false)
if err == nil {
t.Fatal("expected terminal error on retry exhaustion, got nil")
}
@@ -96,7 +96,7 @@ func TestStopWorkspaceForDelete_CPExhaustsEmitsDurableEventAndReturnsError(t *te
func TestStopWorkspaceForDelete_NoBackendIsNoOp(t *testing.T) {
h := &WorkspaceHandler{} // cpProv nil, provisioner nil
if err := h.stopWorkspaceForDelete(context.Background(), "ws-x"); err != nil {
if err := h.stopWorkspaceForDelete(context.Background(), "ws-x", false); err != nil {
t.Errorf("expected nil no-op with no backend, got %v", err)
}
}
@@ -235,9 +235,13 @@ func (h *WorkspaceHandler) StopWorkspaceAuto(ctx context.Context, workspaceID st
// container won't heal on retry (matches RestartWorkspaceAuto's Docker
// rationale); the orphan-container sweeper (registry/orphan_sweeper.go) is
// the Docker-side backstop.
func (h *WorkspaceHandler) stopWorkspaceForDelete(ctx context.Context, workspaceID string) error {
// stopWorkspaceForDelete terminates a workspace's compute on the delete path.
// erase=true (internal#734) means the user asked to erase saved data, so the CP
// teardown prunes the durable data volume. The local-docker path always removes
// its volume via CascadeDelete's RemoveVolume, so erase is a CP-only concern.
func (h *WorkspaceHandler) stopWorkspaceForDelete(ctx context.Context, workspaceID string, erase bool) error {
if h.cpProv != nil {
if err := h.cpStopWithRetryErr(ctx, workspaceID, "Delete"); err != nil {
if err := h.cpStopWithRetryErr(ctx, workspaceID, "Delete", erase); err != nil {
h.emitDeleteTerminateRetryExhausted(ctx, workspaceID, err)
return err
}
@@ -330,6 +330,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
DataPersistence: payload.Compute.DataPersistence,
Display: provisioner.WorkspaceDisplayConfig{
Mode: payload.Compute.Display.Mode,
Width: payload.Compute.Display.Width,
@@ -42,6 +42,7 @@ type trackingCPProv struct {
mu sync.Mutex
started []string
stopped []string
pruned []string // internal#734: workspaces stopped via StopAndPrune
startErr error
stopErr error
}
@@ -61,6 +62,13 @@ func (r *trackingCPProv) Stop(_ context.Context, workspaceID string) error {
r.mu.Unlock()
return r.stopErr
}
func (r *trackingCPProv) StopAndPrune(_ context.Context, workspaceID string) error {
r.mu.Lock()
r.stopped = append(r.stopped, workspaceID)
r.pruned = append(r.pruned, workspaceID)
r.mu.Unlock()
return r.stopErr
}
func (r *trackingCPProv) GetConsoleOutput(_ context.Context, _ string) (string, error) {
return "", nil
}
@@ -10,9 +10,9 @@ import (
"sync/atomic"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
"github.com/DATA-DOG/go-sqlmock"
)
// Issue #2486 reproduction harness: 7 simultaneous claude-code provisions
@@ -71,6 +71,9 @@ func (r *recordingCPProv) Start(_ context.Context, cfg provisioner.WorkspaceConf
func (r *recordingCPProv) Stop(_ context.Context, _ string) error {
panic("recordingCPProv.Stop not expected in concurrent-repro test")
}
func (r *recordingCPProv) StopAndPrune(_ context.Context, _ string) error {
panic("recordingCPProv.StopAndPrune not expected in concurrent-repro test")
}
func (r *recordingCPProv) GetConsoleOutput(_ context.Context, _ string) (string, error) {
panic("recordingCPProv.GetConsoleOutput not expected in concurrent-repro test")
@@ -1399,6 +1399,9 @@ func (s *stubFailingCPProv) Start(_ context.Context, _ provisioner.WorkspaceConf
func (s *stubFailingCPProv) Stop(_ context.Context, _ string) error {
panic("stubFailingCPProv.Stop not expected on the provisionWorkspaceCP failure path")
}
func (s *stubFailingCPProv) StopAndPrune(_ context.Context, _ string) error {
panic("stubFailingCPProv.StopAndPrune not expected on the provisionWorkspaceCP failure path")
}
func (s *stubFailingCPProv) GetConsoleOutput(_ context.Context, _ string) (string, error) {
panic("stubFailingCPProv.GetConsoleOutput not expected on the provisionWorkspaceCP failure path")
@@ -726,7 +726,7 @@ func (h *WorkspaceHandler) cpStopWithRetry(ctx context.Context, workspaceID, sou
// terminal error. The delete path needs the error (it must keep the
// row recoverable for the orphan-sweeper + emit a durable event), so
// the actual retry loop lives in cpStopWithRetryErr below.
_ = h.cpStopWithRetryErr(ctx, workspaceID, source)
_ = h.cpStopWithRetryErr(ctx, workspaceID, source, false) // restart/hibernate never prunes
}
// cpStopWithRetryErr is the shared bounded-retry core for cpProv.Stop.
@@ -743,14 +743,24 @@ func (h *WorkspaceHandler) cpStopWithRetry(ctx context.Context, workspaceID, sou
// - all attempts fail → returns the LAST attempt's error and emits the
// stable `LEAK-SUSPECT cpProv.Stop ...` log line so the CP-side orphan
// reconciler can correlate by workspace_id.
func (h *WorkspaceHandler) cpStopWithRetryErr(ctx context.Context, workspaceID, source string) error {
//
// cpStopWithRetryErr terminates the workspace's CP-managed compute with bounded
// retry. prune=true (internal#734) additionally requests CP erase the durable
// data volume — set ONLY by the permanent-delete-with-erase path, NEVER by
// restart/hibernate (those pass false), so a recreate can never prune.
func (h *WorkspaceHandler) cpStopWithRetryErr(ctx context.Context, workspaceID, source string, prune bool) error {
if h.cpProv == nil {
return nil
}
var lastErr error
delay := cpStopRetryBaseDelay
for attempt := 1; attempt <= cpStopRetryAttempts; attempt++ {
err := h.cpProv.Stop(ctx, workspaceID)
var err error
if prune {
err = h.cpProv.StopAndPrune(ctx, workspaceID)
} else {
err = h.cpProv.Stop(ctx, workspaceID)
}
if err == nil {
if attempt > 1 {
log.Printf("%s: cpProv.Stop(%s) succeeded on attempt %d", source, workspaceID, attempt)
@@ -72,6 +72,13 @@ func (s *scriptedCPStop) Stop(ctx context.Context, _ string) error {
}
return nil
}
// StopAndPrune delegates to Stop so the retry/error scripting is identical —
// the prune flag only changes the URL the real provisioner builds, not the
// retry behavior these tests exercise.
func (s *scriptedCPStop) StopAndPrune(ctx context.Context, id string) error {
return s.Stop(ctx, id)
}
func (s *scriptedCPStop) Start(_ context.Context, _ provisioner.WorkspaceConfig) (string, error) {
return "", nil
}
@@ -168,6 +168,12 @@ type WorkspaceCompute struct {
InstanceType string `json:"instance_type,omitempty"`
Volume WorkspaceComputeVolume `json:"volume,omitempty"`
Display WorkspaceComputeDisplay `json:"display,omitempty"`
// DataPersistence is the per-workspace durable-data choice (internal#734):
// "persist" keeps the workspace's data volume (browser profile / cookies /
// downloads / agent memory) across recreate; "ephemeral" uses no durable
// disk (wiped each recreate — privacy); "" = auto (desktop-control persists,
// others follow the org flag). Forwarded verbatim to CP's data_persistence.
DataPersistence string `json:"data_persistence,omitempty"`
}
type CreateWorkspacePayload struct {
@@ -32,6 +32,9 @@ import (
type CPProvisionerAPI interface {
Start(ctx context.Context, cfg WorkspaceConfig) (string, error)
Stop(ctx context.Context, workspaceID string) error
// StopAndPrune is Stop + "erase the durable data volume" (internal#734),
// for the permanent-delete-with-erase flow ONLY. Restart/recreate use Stop.
StopAndPrune(ctx context.Context, workspaceID string) error
GetConsoleOutput(ctx context.Context, workspaceID string) (string, error)
// IsRunning reports whether the workspace's compute (EC2 instance) is
// currently in the running state. Surfaced on the interface (rather than
@@ -152,15 +155,19 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
}
type cpProvisionRequest struct {
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
Display WorkspaceDisplayConfig `json:"display,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
Runtime string `json:"runtime"`
Tier int `json:"tier"`
InstanceType string `json:"instance_type,omitempty"`
DiskGB int32 `json:"disk_gb,omitempty"`
// DataPersistence is the per-workspace durable-data choice (internal#734);
// CP validates the enum at its provision edge and resolves the data volume
// from it. Empty = auto (omitted on the wire).
DataPersistence string `json:"data_persistence,omitempty"`
Display WorkspaceDisplayConfig `json:"display,omitempty"`
PlatformURL string `json:"platform_url"`
Env map[string]string `json:"env"`
// ConfigFiles are template + generated config files to write into the
// EC2 instance's /configs directory. OFFSEC-010: collected by
// collectCPConfigFiles which rejects symlinks and non-regular files
@@ -211,16 +218,17 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
}
req := cpProvisionRequest{
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
InstanceType: cfg.InstanceType,
DiskGB: cfg.DiskGB,
Display: cfg.Display,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
OrgID: p.orgID,
WorkspaceID: cfg.WorkspaceID,
Runtime: cfg.Runtime,
Tier: cfg.Tier,
InstanceType: cfg.InstanceType,
DiskGB: cfg.DiskGB,
DataPersistence: cfg.DataPersistence,
Display: cfg.Display,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
}
body, err := json.Marshal(req)
@@ -398,6 +406,20 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
// blocking the next provision with InvalidGroup.Duplicate — a full
// "Save & Restart" crash on SaaS.
func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
return p.stopInternal(ctx, workspaceID, false)
}
// StopAndPrune terminates the workspace's compute AND requests that its durable
// data volume (browser profile / cookies / downloads / agent memory) be erased
// (internal#734). Used ONLY by the permanent-delete flow when the user chose to
// erase saved data — NEVER by restart/recreate (which call Stop), so a recreate
// can never trigger a prune. CP enforces this defensively too (the prune is a
// short-grace mark-then-sweep gated on the workspace being genuinely gone).
func (p *CPProvisioner) StopAndPrune(ctx context.Context, workspaceID string) error {
return p.stopInternal(ctx, workspaceID, true)
}
func (p *CPProvisioner) stopInternal(ctx context.Context, workspaceID string, prune bool) error {
if p == nil {
return ErrNoBackend
}
@@ -420,6 +442,10 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
return ErrNoBackend
}
url := fmt.Sprintf("%s/cp/workspaces/%s?instance_id=%s", p.baseURL, workspaceID, instanceID)
if prune {
// internal#734: ask CP to erase the data volume on this delete.
url += "&prune=true"
}
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
if err != nil {
return fmt.Errorf("cp provisioner: stop: build request: %w", err)
@@ -1096,3 +1096,41 @@ func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
t.Errorf("expected symlink-related error, got: %v", err)
}
}
// internal#734 (F1): the prune signal must reach CP ONLY via StopAndPrune.
// Stop must NEVER append prune=true (restart/recreate use Stop, and a prune on
// a recreate = data loss). Captures the DELETE URL and asserts the contract.
func TestStopVsStopAndPrune_PruneQueryParam(t *testing.T) {
primeInstanceIDLookup(t, map[string]string{"ws-keep": "i-keep", "ws-erase": "i-erase"})
var gotURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"status":"terminated"}`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
// Stop → no prune.
if err := p.Stop(context.Background(), "ws-keep"); err != nil {
t.Fatalf("Stop: %v", err)
}
if strings.Contains(gotURL, "prune=true") {
t.Errorf("Stop must NOT send prune=true (recreate-safety); url=%s", gotURL)
}
if !strings.Contains(gotURL, "instance_id=i-keep") {
t.Errorf("Stop url missing instance_id; url=%s", gotURL)
}
// StopAndPrune → prune=true.
if err := p.StopAndPrune(context.Background(), "ws-erase"); err != nil {
t.Fatalf("StopAndPrune: %v", err)
}
if !strings.Contains(gotURL, "prune=true") {
t.Errorf("StopAndPrune MUST send prune=true; url=%s", gotURL)
}
if !strings.Contains(gotURL, "instance_id=i-erase") {
t.Errorf("StopAndPrune url missing instance_id; url=%s", gotURL)
}
}
@@ -99,6 +99,7 @@ type WorkspaceConfig struct {
Runtime string // "claude-code" (default), "codex", "hermes", "openclaw", etc.
InstanceType string // Optional CP EC2 instance type override (SaaS only)
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
DataPersistence string // internal#734: "persist"|"ephemeral"|"" — durable-data choice forwarded to CP (SaaS only)
Display WorkspaceDisplayConfig
EnvVars map[string]string // Additional env vars (API keys, etc.)
PlatformURL string