Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
683aa05
feat(workspaces): persist operation journal
skevetter Sep 20, 2026
e667bac
fix(workspaces): skip oversized journal events to bound storage
skevetter Sep 20, 2026
8613395
test(workspaces): cover oversized journal event skip
skevetter Sep 20, 2026
335a55b
fix(workspaces): resolve import IDs before journaling
skevetter Sep 20, 2026
5759f5b
fix(workspaces): journal full stop lifecycle
skevetter Sep 20, 2026
0c5218f
fix(workspaces): resolve journal workspace IDs for delete
skevetter Sep 20, 2026
aecf728
fix(workspaces): journal deletes under resolved workspace IDs
skevetter Sep 20, 2026
b14c0cc
fix(workspaces): align journal record size limit for writes and reads
skevetter Sep 20, 2026
e8237d3
fix(workspaces): report delete config load failures
skevetter Sep 20, 2026
d03b8c2
test(workspaces): cover record read-limit skip
skevetter Sep 20, 2026
5155b44
fix(workspaces): resolve interactive delete target once
skevetter Sep 21, 2026
737885b
fix(workspaces): journal interactive delete under selected workspace
skevetter Sep 21, 2026
115d9e4
test(workspaces): cover pre-resolved client delete
skevetter Sep 21, 2026
5d49aa3
test(workspaces): cover interactive delete journal target
skevetter Sep 21, 2026
63cfa8e
test(workspaces): track fake client deletion
skevetter Sep 21, 2026
4a033f6
style(workspaces): reuse DefaultDockerCommand in delete test
skevetter Sep 21, 2026
56aeaa5
refactor(workspaces): resolve delete targets once for reuse
skevetter Sep 21, 2026
b3c872e
fix(workspaces): reuse resolved clients in named delete paths
skevetter Sep 21, 2026
4fa8d32
test(workspaces): cover client reuse in named deletes
skevetter Sep 21, 2026
8bf66c6
style(workspaces): trim trailing blank line in journal resolver
skevetter Sep 21, 2026
3ba6eb3
style(workspaces): trim trailing blank line in delete command
skevetter Sep 21, 2026
c838b81
style(workspaces): trim trailing blank line in delete tests
skevetter Sep 21, 2026
1f608ef
style(workspaces): drop trailing blank line in journal resolver
skevetter Sep 21, 2026
4fd5dcd
style(workspaces): normalize journal resolver file ending
skevetter Sep 21, 2026
82d4896
fix(workspaces): skip journal segments removed during read
skevetter Sep 21, 2026
a643318
fix(workspaces): skip journal segments removed during read
skevetter Sep 21, 2026
f3f22f5
test(workspaces): cover concurrent prune during journal read
skevetter Sep 21, 2026
ee4bc6b
fix(workspaces): route import parse failures through status envelope
skevetter Sep 21, 2026
646f275
test(workspaces): cover import parse failure envelope
skevetter Sep 21, 2026
9effb14
style(workspaces): remove narrating comments per review
skevetter Sep 21, 2026
6a40f8e
style(workspaces): remove narrating comments per review
skevetter Sep 21, 2026
ec45de0
style(workspaces): remove narrating comments per review
skevetter Sep 21, 2026
55554fb
style(workspaces): remove narrating comments per review
skevetter Sep 21, 2026
cf27754
fix(workspaces): serialize journal appends across processes
skevetter Sep 21, 2026
3788971
test(workspaces): cover concurrent journal append and prune
skevetter Sep 21, 2026
28bdfb9
fix(workspace-journal): validate bounded records
skevetter Sep 23, 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
137 changes: 137 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,132 @@ func TestDeleteCmd_Completion(t *testing.T) {
assert.Contains(t, completionOutput, fmt.Sprintf("%q", aliasRm))
assert.Contains(t, completionOutput, `"delete"`)
}

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)
}

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)
}
}

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