Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7893498
feat(workspaces): persist operation journal
skevetter Sep 20, 2026
728f8a5
fix(workspaces): skip oversized journal events to bound storage
skevetter Sep 20, 2026
bf3f1e9
test(workspaces): cover oversized journal event skip
skevetter Sep 20, 2026
0e991ca
fix(workspaces): resolve import IDs before journaling
skevetter Sep 20, 2026
2417f08
fix(workspaces): journal full stop lifecycle
skevetter Sep 20, 2026
3153e4f
fix(workspaces): resolve journal workspace IDs for delete
skevetter Sep 20, 2026
e001714
fix(workspaces): journal deletes under resolved workspace IDs
skevetter Sep 20, 2026
666841e
fix(workspaces): align journal record size limit for writes and reads
skevetter Sep 20, 2026
d051c51
fix(workspaces): report delete config load failures
skevetter Sep 20, 2026
6145a87
test(workspaces): cover record read-limit skip
skevetter Sep 20, 2026
4ffece5
fix(workspaces): resolve interactive delete target once
skevetter Sep 21, 2026
5b02792
fix(workspaces): journal interactive delete under selected workspace
skevetter Sep 21, 2026
81243b4
test(workspaces): cover pre-resolved client delete
skevetter Sep 21, 2026
462e053
test(workspaces): cover interactive delete journal target
skevetter Sep 21, 2026
e08811f
test(workspaces): track fake client deletion
skevetter Sep 21, 2026
516a889
style(workspaces): reuse DefaultDockerCommand in delete test
skevetter Sep 21, 2026
ef73af2
refactor(workspaces): resolve delete targets once for reuse
skevetter Sep 21, 2026
533df6c
fix(workspaces): reuse resolved clients in named delete paths
skevetter Sep 21, 2026
0ddc8a0
test(workspaces): cover client reuse in named deletes
skevetter Sep 21, 2026
eee9fbf
style(workspaces): trim trailing blank line in journal resolver
skevetter Sep 21, 2026
1a65781
style(workspaces): trim trailing blank line in delete command
skevetter Sep 21, 2026
ac0ebc0
style(workspaces): trim trailing blank line in delete tests
skevetter Sep 21, 2026
f91a356
style(workspaces): drop trailing blank line in journal resolver
skevetter Sep 21, 2026
04450d1
style(workspaces): normalize journal resolver file ending
skevetter Sep 21, 2026
9629dca
fix(workspaces): skip journal segments removed during read
skevetter Sep 21, 2026
ff742b5
fix(workspaces): skip journal segments removed during read
skevetter Sep 21, 2026
8bff91b
test(workspaces): cover concurrent prune during journal read
skevetter Sep 21, 2026
e440079
fix(workspaces): route import parse failures through status envelope
skevetter Sep 21, 2026
29bd1b4
test(workspaces): cover import parse failure envelope
skevetter Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 119 additions & 20 deletions cmd/workspace/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,38 +87,112 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error {
if err != nil {
return err
}
var deleteErr error
var devsyConfig *config.Config
deleteErr = status.Run(
devsyConfig, err := cmd.loadConfig()
if err != nil {
return status.Run(ctx, reporter, status.Operation{Phase: status.PhaseDeletingWorkspace},
func(context.Context) error { return err })
}
if len(args) == 0 {
return cmd.deleteInteractively(ctx, reporter, devsyConfig)
}
targets := resolveDeleteTargets(ctx, devsyConfig, cmd.Owner, args)
if len(args) <= 1 {
reporter = withWorkspaceJournal(reporter, targets[journalWorkspaceKey(args)].ID)
}
deleteErr := status.Run(
ctx,
reporter,
status.Operation{Phase: status.PhaseDeletingWorkspace},
func(ctx context.Context) error {
var err error
devsyConfig, err = cmd.loadConfig()
if err != nil {
return err
}
if len(args) <= 1 {
return cmd.deleteSingle(status.WithReporter(ctx, reporter), devsyConfig, args)
return cmd.deleteSingle(
status.WithReporter(ctx, reporter),
devsyConfig,
targets[journalWorkspaceKey(args)],
args,
)
}
return cmd.deleteMultiple(status.WithReporter(ctx, reporter), devsyConfig, args)
return cmd.deleteMultiple(ctx, devsyConfig, reporter, targets, args)
},
)
if devsyConfig == nil {
return deleteErr
}

count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext)
if countErr != nil {
log.Debugf("skipping workspace count gauge: %v", countErr)
} else {
telemetry.FromContext(ctx).RecordWorkspaceGauge(count)
recordWorkspaceCount(ctx, devsyConfig)

return deleteErr
}

// deleteInteractively resolves the deletion target with a single interactive
// selection and deletes that same client, so the operation is journaled under
// the workspace that is actually deleted.
func (cmd *DeleteCmd) deleteInteractively(
ctx context.Context,
reporter status.Reporter,
devsyConfig *config.Config,
) error {
client, err := workspace.Get(ctx, workspace.GetOptions{
DevsyConfig: devsyConfig,
Owner: cmd.Owner,
})
if err != nil {
return status.Run(
ctx,
reporter,
status.Operation{Phase: status.PhaseDeletingWorkspace},
func(context.Context) error { return err },
)
}

deleteErr := cmd.deleteResolved(ctx, reporter, devsyConfig, client)
recordWorkspaceCount(ctx, devsyConfig)

return deleteErr
}

// deleteResolved deletes an already-resolved workspace client and journals
// the operation under the client's workspace ID.
func (cmd *DeleteCmd) deleteResolved(
ctx context.Context,
reporter status.Reporter,
devsyConfig *config.Config,
client client2.BaseWorkspaceClient,
) error {
reporter = withWorkspaceJournal(reporter, client.Workspace())
return status.Run(
ctx,
reporter,
status.Operation{Phase: status.PhaseDeletingWorkspace},
func(ctx context.Context) error {
_, err := cmd.deleteClient(status.WithReporter(ctx, reporter), devsyConfig, client)
return err
},
)
}

func (cmd *DeleteCmd) deleteClient(
ctx context.Context,
devsyConfig *config.Config,
client client2.BaseWorkspaceClient,
) (string, error) {
return workspace.Delete(ctx, workspace.DeleteOptions{
DevsyConfig: devsyConfig,
Client: client,
IgnoreNotFound: cmd.IgnoreNotFound,
Force: cmd.Force,
ClientDelete: cmd.DeleteOptions,
Owner: cmd.Owner,
})
}

// recordWorkspaceCount reports the remaining local workspace count as a gauge.
func recordWorkspaceCount(ctx context.Context, devsyConfig *config.Config) {
count, err := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext)
if err != nil {
log.Debugf("skipping workspace count gauge: %v", err)
return
}
telemetry.FromContext(ctx).RecordWorkspaceGauge(count)
}

func (cmd *DeleteCmd) loadConfig() (*config.Config, error) {
_, err := clientimplementation.DecodeOptionsFromEnv(
config.EnvFlagsDelete,
Expand All @@ -138,9 +212,10 @@ func (cmd *DeleteCmd) loadConfig() (*config.Config, error) {
func (cmd *DeleteCmd) deleteSingle(
ctx context.Context,
devsyConfig *config.Config,
target resolvedDeleteTarget,
args []string,
) error {
name, err := cmd.deleteWorkspace(ctx, devsyConfig, args)
name, err := cmd.deleteTarget(ctx, devsyConfig, target, args)
if err != nil {
return err
}
Expand All @@ -153,11 +228,20 @@ func (cmd *DeleteCmd) deleteSingle(
func (cmd *DeleteCmd) deleteMultiple(
ctx context.Context,
devsyConfig *config.Config,
reporter status.Reporter,
targets map[string]resolvedDeleteTarget,
args []string,
) error {
var errs []error
for _, arg := range args {
name, err := cmd.deleteWorkspace(ctx, devsyConfig, []string{arg})
target := targets[arg]
targetReporter := withWorkspaceJournal(reporter, target.ID)
name, err := cmd.deleteTarget(
status.WithReporter(ctx, targetReporter),
devsyConfig,
target,
[]string{arg},
)
if err != nil {
errs = append(errs, fmt.Errorf("failed to delete workspace %s: %w", arg, err))

Expand All @@ -178,6 +262,21 @@ func (cmd *DeleteCmd) deleteMultiple(
return nil
}

// deleteTarget deletes one workspace. When the target carries a pre-resolved
// client, deletion reuses it so the deleted workspace and the journal key
// cannot diverge; otherwise it resolves the raw arguments.
func (cmd *DeleteCmd) deleteTarget(
ctx context.Context,
devsyConfig *config.Config,
target resolvedDeleteTarget,
args []string,
) (string, error) {
if target.Client != nil {
return cmd.deleteClient(ctx, devsyConfig, target.Client)
}
return cmd.deleteWorkspace(ctx, devsyConfig, args)
}

func (cmd *DeleteCmd) deleteWorkspace(
ctx context.Context,
devsyConfig *config.Config,
Expand Down
148 changes: 148 additions & 0 deletions cmd/workspace/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ package workspace
import (
"bytes"
"fmt"
"os"
"testing"

"github.com/devsy-org/devsy/cmd/flags"
client2 "github.com/devsy-org/devsy/pkg/client"
"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/platform"
"github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/status"
"github.com/devsy-org/devsy/pkg/workspacejournal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -82,3 +90,143 @@ func TestDeleteCmd_Completion(t *testing.T) {
assert.Contains(t, completionOutput, fmt.Sprintf("%q", aliasRm))
assert.Contains(t, completionOutput, `"delete"`)
}

// TestDeleteCmd_DeleteResolvedJournalsUnderSelectedWorkspace is the regression
// guard for journaling an interactive delete under a workspace other than the
// one deleted: the resolved client drives both the deletion and the journal
// key, so every recorded event must carry its workspace ID.
func TestDeleteCmd_DeleteResolvedJournalsUnderSelectedWorkspace(t *testing.T) {
log.Init(log.Config{Verbosity: 0})

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
config.ResetPathManager()
t.Cleanup(config.ResetPathManager)

fake := &fakeWorkspaceClient{
workspace: "chosen",
context: testContext,
provider: testProvider,
config: &provider.Workspace{ID: "chosen", Context: testContext},
}
cmd := &DeleteCmd{
GlobalFlags: &flags.GlobalFlags{ResultFormat: formatPlain},
DeleteOptions: client2.DeleteOptions{Force: true},
}
devsyConfig := &config.Config{
DefaultContext: testContext,
Contexts: map[string]*config.ContextConfig{testContext: {}},
}
reporter, err := newWorkspaceStatusReporter(formatPlain, os.Stdout, false)
require.NoError(t, err)

captureStdout(t, func() {
require.NoError(t, cmd.deleteResolved(t.Context(), reporter, devsyConfig, fake))
})
require.True(t, fake.deleted)

dir, err := workspacejournal.DefaultDir()
require.NoError(t, err)
events, err := workspacejournal.Read(dir, "chosen", workspacejournal.DefaultLimit)
require.NoError(t, err)
require.NotEmpty(t, events)

hasDeletePhase := false
for _, event := range events {
if event.Phase == status.PhaseDeletingWorkspace {
hasDeletePhase = true
}
}
assert.True(t, hasDeletePhase, "expected a delete-phase journal event, got %+v", events)
}

// TestDeleteCmd_DeleteMultipleReusesResolvedClients guards the multi-target
// delete invariant: each pre-resolved client is deleted directly and every
// operation is journaled under that client's workspace ID, never the raw
// argument.
func TestDeleteCmd_DeleteMultipleReusesResolvedClients(t *testing.T) {
log.Init(log.Config{Verbosity: 0})

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
config.ResetPathManager()
t.Cleanup(config.ResetPathManager)

idA, idB := "chosen-a", "chosen-b"
argA, argB := "arg-a", "arg-b"
newFake := func(id string) *fakeWorkspaceClient {
return &fakeWorkspaceClient{
workspace: id,
context: testContext,
provider: testProvider,
config: &provider.Workspace{ID: id, Context: testContext},
}
}
fakeA := newFake(idA)
fakeB := newFake(idB)
cmd := &DeleteCmd{
GlobalFlags: &flags.GlobalFlags{ResultFormat: formatPlain},
DeleteOptions: client2.DeleteOptions{Force: true},
}
devsyConfig := &config.Config{
DefaultContext: testContext,
Contexts: map[string]*config.ContextConfig{testContext: {}},
}
reporter, err := newWorkspaceStatusReporter(formatPlain, os.Stdout, false)
require.NoError(t, err)

targets := map[string]resolvedDeleteTarget{
argA: {ID: idA, Client: fakeA},
argB: {ID: idB, Client: fakeB},
}
captureStdout(t, func() {
require.NoError(t, cmd.deleteMultiple(
t.Context(),
devsyConfig,
reporter,
targets,
[]string{argA, argB},
))
})
require.True(t, fakeA.deleted)
require.True(t, fakeB.deleted)

dir, err := workspacejournal.DefaultDir()
require.NoError(t, err)
for _, id := range []string{idA, idB} {
events, err := workspacejournal.Read(dir, id, workspacejournal.DefaultLimit)
require.NoError(t, err)
assert.NotEmpty(t, events, "expected journaled events under %s", id)
}
}

// TestResolveDeleteTargets_FallsBackToRawArgument covers targets that cannot
// be resolved, such as a broken workspace removed with --force: the journal
// key falls back to the raw argument and no client is reused.
func TestResolveDeleteTargets_FallsBackToRawArgument(t *testing.T) {
log.Init(log.Config{Verbosity: 0})

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
config.ResetPathManager()
t.Cleanup(config.ResetPathManager)

missingArg := "missing"
devsyConfig := &config.Config{
DefaultContext: testContext,
Contexts: map[string]*config.ContextConfig{testContext: {}},
}
targets := resolveDeleteTargets(
t.Context(),
devsyConfig,
platform.OwnerFilter(""),
[]string{missingArg},
)
target, ok := targets[missingArg]
require.True(t, ok)
assert.Equal(t, missingArg, target.ID)
assert.Nil(t, target.Client)
}
Loading
Loading