From 7893498b581475db80ab60abccb79cf9bef65055 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 15:50:06 +0000 Subject: [PATCH 01/33] feat(workspaces): persist operation journal Add a versioned, bounded NDJSON workspace operation journal with secret redaction, corruption tolerance, and restrictive filesystem permissions. Tee lifecycle status events into the journal without changing human output or making journal failures fatal. Add workspace events output and expose the last operation in workspace status JSON. --- cmd/workspace/delete.go | 17 +- cmd/workspace/events.go | 115 +++++++++ cmd/workspace/import.go | 32 ++- cmd/workspace/journal.go | 19 ++ cmd/workspace/status.go | 39 ++- cmd/workspace/status_reporter.go | 1 + cmd/workspace/stop.go | 4 +- cmd/workspace/up/journal.go | 19 ++ cmd/workspace/up/up.go | 7 +- cmd/workspace/workspace.go | 1 + e2e/tests/up/up.go | 27 +++ pkg/client/client.go | 28 ++- pkg/status/status.go | 1 + pkg/workspacejournal/journal.go | 347 +++++++++++++++++++++++++++ pkg/workspacejournal/journal_test.go | 217 +++++++++++++++++ 15 files changed, 852 insertions(+), 22 deletions(-) create mode 100644 cmd/workspace/events.go create mode 100644 cmd/workspace/journal.go create mode 100644 cmd/workspace/up/journal.go create mode 100644 pkg/workspacejournal/journal.go create mode 100644 pkg/workspacejournal/journal_test.go diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index a3f6aec56..f563a00cb 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -87,6 +87,13 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { if err != nil { return err } + if len(args) <= 1 { + workspaceID := "" + if len(args) == 1 { + workspaceID = args[0] + } + reporter = withWorkspaceJournal(reporter, workspaceID) + } var deleteErr error var devsyConfig *config.Config deleteErr = status.Run( @@ -102,7 +109,7 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { if len(args) <= 1 { return cmd.deleteSingle(status.WithReporter(ctx, reporter), devsyConfig, args) } - return cmd.deleteMultiple(status.WithReporter(ctx, reporter), devsyConfig, args) + return cmd.deleteMultiple(ctx, devsyConfig, reporter, args) }, ) if devsyConfig == nil { @@ -153,11 +160,17 @@ func (cmd *DeleteCmd) deleteSingle( func (cmd *DeleteCmd) deleteMultiple( ctx context.Context, devsyConfig *config.Config, + reporter status.Reporter, args []string, ) error { var errs []error for _, arg := range args { - name, err := cmd.deleteWorkspace(ctx, devsyConfig, []string{arg}) + targetReporter := withWorkspaceJournal(reporter, arg) + name, err := cmd.deleteWorkspace( + status.WithReporter(ctx, targetReporter), + devsyConfig, + []string{arg}, + ) if err != nil { errs = append(errs, fmt.Errorf("failed to delete workspace %s: %w", arg, err)) diff --git a/cmd/workspace/events.go b/cmd/workspace/events.go new file mode 100644 index 000000000..679592d7c --- /dev/null +++ b/cmd/workspace/events.go @@ -0,0 +1,115 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/devsy-org/devsy/cmd/completion" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/table" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/devsy-org/devsy/pkg/workspacejournal" + "github.com/spf13/cobra" +) + +type EventsCmd struct { + *flags.GlobalFlags + Limit int +} + +func NewEventsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &EventsCmd{GlobalFlags: globalFlags, Limit: workspacejournal.DefaultLimit} + eventsCmd := &cobra.Command{ + Use: "events [flags] [workspace-path|workspace-name]", + Short: "Show workspace operation events", + RunE: func(c *cobra.Command, args []string) error { return cmd.execute(c.Context(), args) }, + ValidArgsFunction: func(root *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completion.GetWorkspaceSuggestions( + root, + cmd.Context, + cmd.Provider, + args, + toComplete, + cmd.Owner, + ) + }, + } + cliflags.Add( + eventsCmd, + cliflags.Int( + &cmd.Limit, + "limit", + workspacejournal.DefaultLimit, + "Maximum number of recent events", + ), + ) + return eventsCmd +} + +func (cmd *EventsCmd) execute(ctx context.Context, args []string) error { + cfg, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + client, err := workspace2.Get( + ctx, + workspace2.GetOptions{DevsyConfig: cfg, Args: args, Owner: cmd.Owner}, + ) + if err != nil { + return err + } + dir, err := workspacejournal.DefaultDir() + if err != nil { + return err + } + events, err := workspacejournal.Read(dir, client.Workspace(), cmd.Limit) + if err != nil { + return err + } + return cmd.print(events) +} + +func (cmd *EventsCmd) print(events []workspacejournal.Event) error { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return json.NewEncoder(os.Stdout).Encode(struct { + SchemaVersion int `json:"schemaVersion"` + Events []workspacejournal.Event `json:"events"` + }{workspacejournal.SchemaVersion, events}) + } + printEvents(events) + return nil +} + +func printEvents(events []workspacejournal.Event) { + rows := make([][]string, 0, len(events)) + for _, event := range events { + message := "" + if event.Error != nil { + message = event.Error.Message + } + rows = append(rows, []string{ + event.Timestamp.Format("2006-01-02 15:04:05Z07:00"), + string(event.Phase), + string(event.State), + timeDuration(event.DurationMillis), + message, + }) + } + table.Print([]string{"Time", "Phase", "State", "Duration", "Error"}, rows) +} + +func timeDuration(milliseconds int64) string { + if milliseconds == 0 { + return "-" + } + return fmt.Sprintf("%dms", milliseconds) +} diff --git a/cmd/workspace/import.go b/cmd/workspace/import.go index 76984dd3e..4ec141170 100644 --- a/cmd/workspace/import.go +++ b/cmd/workspace/import.go @@ -16,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" snapshotpkg "github.com/devsy-org/devsy/pkg/snapshot" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -67,12 +68,8 @@ func NewImportCmd(globalFlags *flags.GlobalFlags) *cobra.Command { func (cmd *ImportCmd) Run( ctx context.Context, devsyConfig *config.Config, + exportConfig *provider.ExportConfig, ) error { - exportConfig, err := cmd.parseExportConfig() - if err != nil { - return err - } - cmd.setDefaultIDs(exportConfig) if err := cmd.checkForConflictingIDs(ctx, exportConfig, devsyConfig); err != nil { @@ -91,11 +88,32 @@ func (cmd *ImportCmd) Run( } func (cmd *ImportCmd) execute(ctx context.Context) error { - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + exportConfig, err := cmd.parseExportConfig() if err != nil { return err } - return cmd.Run(ctx, devsyConfig) + + reporter, err := newWorkspaceStatusReporter( + cmd.ResultFormat, + os.Stdout, + cmd.Verbosity > 0 || cmd.Debug, + ) + if err != nil { + return err + } + reporter = withWorkspaceJournal(reporter, cmd.WorkspaceID) + return status.Run( + ctx, + reporter, + status.Operation{Phase: status.PhaseImportingWorkspace}, + func(ctx context.Context) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + return cmd.Run(ctx, devsyConfig, exportConfig) + }, + ) } func (cmd *ImportCmd) parseExportConfig() (*provider.ExportConfig, error) { diff --git a/cmd/workspace/journal.go b/cmd/workspace/journal.go new file mode 100644 index 000000000..855333bde --- /dev/null +++ b/cmd/workspace/journal.go @@ -0,0 +1,19 @@ +package workspace + +import ( + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" + "github.com/devsy-org/devsy/pkg/workspacejournal" +) + +func withWorkspaceJournal(reporter status.Reporter, workspaceID string) status.Reporter { + journal, err := workspacejournal.OpenDefault() + if err != nil { + log.Debugf("workspace operation journal unavailable: %v", err) + return reporter + } + return status.Tee( + reporter, + status.ForPipeline(journal.Reporter(workspaceID), status.PipelineWorkspaceUp), + ) +} diff --git a/cmd/workspace/status.go b/cmd/workspace/status.go index bdf107fe9..6f60e48aa 100644 --- a/cmd/workspace/status.go +++ b/cmd/workspace/status.go @@ -17,6 +17,7 @@ import ( "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/provider" workspace2 "github.com/devsy-org/devsy/pkg/workspace" + "github.com/devsy-org/devsy/pkg/workspacejournal" "github.com/spf13/cobra" ) @@ -96,11 +97,12 @@ func (cmd *StatusCmd) Run( _, _ = fmt.Fprintln(os.Stdout, string(instanceStatus)) case output.ModeJSON: status := client2.WorkspaceStatus{ - ID: client.Workspace(), - Context: client.Context(), - Provider: client.Provider(), - State: string(instanceStatus), - Recovery: cmd.resolveRecovery(client, instanceStatus), + ID: client.Workspace(), + Context: client.Context(), + Provider: client.Provider(), + State: string(instanceStatus), + Recovery: cmd.resolveRecovery(client, instanceStatus), + LastOperation: cmd.lastOperation(client.Workspace()), } out, err := json.Marshal(&status) if err != nil { @@ -130,6 +132,33 @@ func (cmd *StatusCmd) resolveRecovery( return result.RecoveryContainer } +func (cmd *StatusCmd) lastOperation(workspaceID string) *client2.OperationSummary { + dir, err := workspacejournal.DefaultDir() + if err != nil { + return nil + } + event, err := workspacejournal.Last(dir, workspaceID) + if err != nil || event == nil { + return nil + } + summary := &client2.OperationSummary{ + Timestamp: event.Timestamp, + Pipeline: string(event.Pipeline), + Phase: string(event.Phase), + State: string(event.State), + DurationMillis: event.DurationMillis, + } + if event.Error != nil { + summary.Error = &client2.ErrorInfo{ + Code: event.Error.Code, + Message: event.Error.Message, + Hint: event.Error.Hint, + Context: event.Error.Context, + } + } + return summary +} + func (cmd *StatusCmd) execute(ctx context.Context, args []string) error { if _, err := clientimplementation.DecodeOptionsFromEnv( config.EnvFlagsStatus, &cmd.StatusOptions, diff --git a/cmd/workspace/status_reporter.go b/cmd/workspace/status_reporter.go index dea6bdeda..50a322384 100644 --- a/cmd/workspace/status_reporter.go +++ b/cmd/workspace/status_reporter.go @@ -29,6 +29,7 @@ func newWorkspaceStatusReporter( status.PhaseDeletingWorkspace: "deleting workspace", status.PhaseRebuildingWorkspace: "rebuilding workspace", status.PhaseResettingWorkspace: "resetting workspace", + status.PhaseImportingWorkspace: "importing workspace", status.PhaseReady: "ready", }, Envelope: func(e status.Event) error { diff --git a/cmd/workspace/stop.go b/cmd/workspace/stop.go index 424615df2..c77a61590 100644 --- a/cmd/workspace/stop.go +++ b/cmd/workspace/stop.go @@ -64,6 +64,7 @@ func (cmd *StopCmd) Run( if err != nil { return err } + reporter = withWorkspaceJournal(reporter, client.Workspace()) return status.Run( ctx, reporter, @@ -107,7 +108,8 @@ func (cmd *StopCmd) runArgs(ctx context.Context, args []string) error { if err != nil { return err } - return cmd.run(ctx, devsyConfig, client) + reporter = withWorkspaceJournal(reporter, client.Workspace()) + return cmd.run(status.WithReporter(ctx, reporter), devsyConfig, client) }) } diff --git a/cmd/workspace/up/journal.go b/cmd/workspace/up/journal.go new file mode 100644 index 000000000..4beacc5dc --- /dev/null +++ b/cmd/workspace/up/journal.go @@ -0,0 +1,19 @@ +package up + +import ( + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" + "github.com/devsy-org/devsy/pkg/workspacejournal" +) + +func withJournal(reporter status.Reporter, workspaceID string) status.Reporter { + journal, err := workspacejournal.OpenDefault() + if err != nil { + log.Debugf("workspace operation journal unavailable: %v", err) + return reporter + } + return status.Tee( + reporter, + status.ForPipeline(journal.Reporter(workspaceID), status.PipelineWorkspaceUp), + ) +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index c4c0539ee..a8976b122 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -261,7 +261,7 @@ func (cmd *UpCmd) Run( ) error { cmd.prepareWorkspace(client) - emitJSON, out, err := cmd.configureRun() + emitJSON, out, err := cmd.configureRun(client.Workspace()) if err != nil { return err } @@ -295,7 +295,7 @@ func (cmd *UpCmd) Run( return nil } -func (cmd *UpCmd) configureRun() (bool, io.Writer, error) { +func (cmd *UpCmd) configureRun(workspaceID string) (bool, io.Writer, error) { mode, err := output.ResolveMode(cmd.ResultFormat) if err != nil { return false, nil, err @@ -306,6 +306,9 @@ func (cmd *UpCmd) configureRun() (bool, io.Writer, error) { out, cmd.Verbosity > 0 || cmd.Debug, ) + if err == nil { + cmd.statusReporter = withJournal(cmd.statusReporter, workspaceID) + } return mode == output.ModeJSON, out, err } diff --git a/cmd/workspace/workspace.go b/cmd/workspace/workspace.go index 6d91765e4..613a4622a 100644 --- a/cmd/workspace/workspace.go +++ b/cmd/workspace/workspace.go @@ -20,6 +20,7 @@ func NewWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd.AddCommand(NewExecCmd(globalFlags)) cmd.AddCommand(NewListCmd(globalFlags)) cmd.AddCommand(NewStatusCmd(globalFlags)) + cmd.AddCommand(NewEventsCmd(globalFlags)) cmd.AddCommand(NewDescribeCmd(globalFlags)) cmd.AddCommand(NewLogsCmd(globalFlags)) cmd.AddCommand(NewBuildCmd(globalFlags)) diff --git a/e2e/tests/up/up.go b/e2e/tests/up/up.go index 9df8f4e69..dc54fdf8a 100644 --- a/e2e/tests/up/up.go +++ b/e2e/tests/up/up.go @@ -2,6 +2,7 @@ package up import ( "context" + "encoding/json" "fmt" "os" "path" @@ -11,6 +12,8 @@ import ( pkgconfig "github.com/devsy-org/devsy/pkg/config" docker "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/language" + "github.com/devsy-org/devsy/pkg/status" + "github.com/devsy-org/devsy/pkg/workspacejournal" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -167,6 +170,30 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-workspaces"), fun framework.ExpectNoError(err) projectName := workspace.ID + eventsOutput, err := f.ExecCommandOutput(ctx, []string{ + cmdWorkspace, "events", "--result-format", "json", projectName, + }) + framework.ExpectNoError(err) + var eventEnvelope struct { + SchemaVersion int `json:"schemaVersion"` + Events []workspacejournal.Event `json:"events"` + } + framework.ExpectNoError(json.Unmarshal([]byte(eventsOutput), &eventEnvelope)) + gomega.Expect(eventEnvelope.SchemaVersion).To(gomega.Equal(workspacejournal.SchemaVersion)) + gomega.Expect(eventEnvelope.Events).NotTo(gomega.BeEmpty()) + gomega.Expect(eventEnvelope.Events[len(eventEnvelope.Events)-1]).To(gomega.SatisfyAll( + gomega.HaveField("WorkspaceID", projectName), + gomega.HaveField("Pipeline", status.PipelineWorkspaceUp), + gomega.HaveField("State", status.StateSucceeded), + )) + + workspaceStatus, err := f.DevsyStatus(ctx, projectName) + framework.ExpectNoError(err) + gomega.Expect(workspaceStatus.LastOperation).NotTo(gomega.BeNil()) + gomega.Expect(workspaceStatus.LastOperation.Pipeline). + To(gomega.Equal(string(status.PipelineWorkspaceUp))) + gomega.Expect(workspaceStatus.LastOperation.State). + To(gomega.Equal(string(status.StateSucceeded))) ids, err := dockerHelper.FindContainer(ctx, []string{ fmt.Sprintf("%s=%s", pkgconfig.DevcontainerIDLabel, workspace.UID), }) diff --git a/pkg/client/client.go b/pkg/client/client.go index 76d89cddf..e9c52681b 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/devcontainer/config" @@ -243,11 +244,28 @@ const ( ) type WorkspaceStatus struct { - ID string `json:"id,omitempty"` - Context string `json:"context,omitempty"` - Provider string `json:"provider,omitempty"` - State string `json:"state,omitempty"` - Recovery bool `json:"recovery,omitempty"` + ID string `json:"id,omitempty"` + Context string `json:"context,omitempty"` + Provider string `json:"provider,omitempty"` + State string `json:"state,omitempty"` + Recovery bool `json:"recovery,omitempty"` + LastOperation *OperationSummary `json:"lastOperation,omitempty"` +} + +type OperationSummary struct { + Timestamp time.Time `json:"timestamp"` + Pipeline string `json:"pipeline"` + Phase string `json:"phase"` + State string `json:"state"` + DurationMillis int64 `json:"durationMillis,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` +} + +type ErrorInfo struct { + Code string `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Context map[string]string `json:"context,omitempty"` } type User struct { diff --git a/pkg/status/status.go b/pkg/status/status.go index 7da5256c6..2bbcf0171 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -72,6 +72,7 @@ const ( PhaseDeletingWorkspace Phase = "deleting_workspace" PhaseRebuildingWorkspace Phase = "rebuilding_workspace" PhaseResettingWorkspace Phase = "resetting_workspace" + PhaseImportingWorkspace Phase = "importing_workspace" PhaseReady Phase = "ready" PhaseFailed Phase = "failed" ) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go new file mode 100644 index 000000000..2fbd8116f --- /dev/null +++ b/pkg/workspacejournal/journal.go @@ -0,0 +1,347 @@ +package workspacejournal + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/status" +) + +const ( + SchemaVersion = 1 + DefaultMaxBytes = 5 * 1024 * 1024 + DefaultMaxSegmentBytes = 512 * 1024 + DefaultLimit = 100 + MaxLimit = 1000 +) + +type Event struct { + SchemaVersion int `json:"schemaVersion"` + Timestamp time.Time `json:"timestamp"` + WorkspaceID string `json:"workspaceId,omitempty"` + Pipeline status.Pipeline `json:"pipeline"` + OperationID string `json:"operationId,omitempty"` + ParentOperationID string `json:"parentOperationId,omitempty"` + Phase status.Phase `json:"phase"` + Step string `json:"step,omitempty"` + State status.State `json:"state"` + DurationMillis int64 `json:"durationMillis,omitempty"` + Error *status.ErrorInfo `json:"error,omitempty"` +} + +type Options struct { + Dir string + MaxBytes, MaxSegmentBytes int + Now func() time.Time + Env []string +} +type Journal struct { + mu sync.Mutex + dir string + maxBytes, maxSegmentBytes int + now func() time.Time + redactor *secrets.Redactor +} + +func DefaultDir() (string, error) { + root, err := config.GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(root, "state", "workspace-events"), nil +} + +func New(opts Options) (*Journal, error) { + if !filepath.IsAbs(opts.Dir) { + return nil, fmt.Errorf("journal directory must be absolute") + } + if opts.MaxBytes == 0 { + opts.MaxBytes = DefaultMaxBytes + } + if opts.MaxSegmentBytes == 0 { + opts.MaxSegmentBytes = DefaultMaxSegmentBytes + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Env == nil { + opts.Env = os.Environ() + } + if err := os.MkdirAll(opts.Dir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(opts.Dir, 0o700); err != nil { //nolint:gosec + return nil, err + } + return &Journal{ + dir: opts.Dir, + maxBytes: opts.MaxBytes, + maxSegmentBytes: opts.MaxSegmentBytes, + now: opts.Now, + redactor: secrets.NewEnvironmentRedactor(opts.Env), + }, nil +} + +func OpenDefault() (*Journal, error) { + dir, err := DefaultDir() + if err != nil { + return nil, err + } + return New(Options{Dir: dir}) +} + +func (j *Journal) Reporter(workspaceID string) status.Reporter { + return reporter{journal: j, workspaceID: workspaceID} +} + +type reporter struct { + journal *Journal + workspaceID string +} + +func (r reporter) Report(e status.Event) { _ = r.journal.Append(r.workspaceID, e) } +func (j *Journal) Append(workspaceID string, e status.Event) error { + j.mu.Lock() + defer j.mu.Unlock() + event := Event{ + SchemaVersion: SchemaVersion, + Timestamp: j.now().UTC(), + WorkspaceID: workspaceID, + Pipeline: e.Pipeline, + OperationID: e.OperationID, + ParentOperationID: e.ParentOperationID, + Phase: e.Phase, + Step: j.redactor.Redact(e.Step), + State: e.State, + DurationMillis: e.Duration.Milliseconds(), + Error: redactError(j.redactor, e.Error), + } + b, err := json.Marshal(event) + if err != nil { + return err + } + b = append(b, '\n') + path, err := j.activeSegment(len(b)) + if err != nil { + return err + } + // #nosec G304 -- Segment paths are generated inside the private journal directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err = os.Chmod(path, 0o600); err == nil { + _, err = f.Write(b) + } + closeErr := f.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + return j.prune() +} + +func redactError(r *secrets.Redactor, in *status.ErrorInfo) *status.ErrorInfo { + if in == nil { + return nil + } + out := &status.ErrorInfo{Code: in.Code, Message: r.Redact(in.Message), Hint: r.Redact(in.Hint)} + if in.Context != nil { + out.Context = map[string]string{} + for k, v := range in.Context { + out.Context[k] = r.Redact(v) + } + } + return out +} + +func (j *Journal) activeSegment(next int) (string, error) { + paths, err := segmentPaths(j.dir) + if err != nil { + return "", err + } + if len(paths) > 0 { + last := paths[len(paths)-1] + fi, er := os.Stat(last) + if er != nil { + return "", er + } + if fi.Size()+int64(next) <= int64(j.maxSegmentBytes) { + return last, nil + } + } + return filepath.Join(j.dir, fmt.Sprintf("events-%06d.ndjson", nextSegmentNumber(paths))), nil +} + +func nextSegmentNumber(paths []string) int { + if len(paths) == 0 { + return 1 + } + name := filepath.Base(paths[len(paths)-1]) + n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(name, "events-"), ".ndjson")) + if err != nil { + return len(paths) + 1 + } + return n + 1 +} + +func segmentPaths(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var paths []string + for _, entry := range entries { + name := entry.Name() + if entry.Type().IsRegular() && + strings.HasPrefix(name, "events-") && + strings.HasSuffix(name, ".ndjson") { + paths = append(paths, filepath.Join(dir, name)) + } + } + sort.Strings(paths) + return paths, nil +} + +func (j *Journal) prune() error { + paths, err := segmentPaths(j.dir) + if err != nil { + return err + } + var total int64 + sizes := make([]int64, len(paths)) + for i, p := range paths { + fi, er := os.Stat(p) + if er != nil { + return er + } + sizes[i] = fi.Size() + total += fi.Size() + } + for i := 0; i < len(paths)-1 && total > int64(j.maxBytes); i++ { + if err := os.Remove(paths[i]); err != nil { + return err + } + total -= sizes[i] + } + return nil +} + +func Read(dir, workspaceID string, limit int) ([]Event, error) { + paths, err := segmentPaths(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + var events []Event + for _, path := range paths { + segmentEvents, err := readSegment(path, workspaceID) + if err != nil { + return nil, err + } + events = append(events, segmentEvents...) + } + return recentEvents(events, limit), nil +} + +func readSegment(path, workspaceID string) ([]Event, error) { + // #nosec G304 -- Segment paths come from the private journal directory listing. + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var events []Event + reader := bufio.NewReaderSize(file, 64*1024) + for { + line, readErr := readRecord(reader) + if len(line) > 0 { + if event, ok := decodeEvent(line, workspaceID); ok { + events = append(events, event) + } + } + if errors.Is(readErr, io.EOF) { + return events, nil + } + if readErr != nil { + return nil, readErr + } + } +} + +// readRecord returns the next journal line. Oversized records are discarded +// through their next newline so a large write cannot abort the whole read. +func readRecord(reader *bufio.Reader) ([]byte, error) { + line, err := reader.ReadSlice('\n') + if !errors.Is(err, bufio.ErrBufferFull) { + return line, err + } + for errors.Is(err, bufio.ErrBufferFull) { + _, err = reader.ReadSlice('\n') + } + return nil, err +} + +func decodeEvent(data []byte, workspaceID string) (Event, bool) { + var event Event + if json.Unmarshal(data, &event) != nil || event.SchemaVersion != SchemaVersion { + return Event{}, false + } + if workspaceID != "" && event.WorkspaceID != workspaceID { + return Event{}, false + } + if !event.complete() { + return Event{}, false + } + return event, true +} + +func (e Event) complete() bool { + return !e.Timestamp.IsZero() && e.Pipeline != "" && e.Phase != "" && e.State != "" +} + +func recentEvents(events []Event, limit int) []Event { + if limit <= 0 { + limit = DefaultLimit + } + if limit > MaxLimit { + limit = MaxLimit + } + if len(events) > limit { + return events[len(events)-limit:] + } + return events +} + +func Last(dir, workspaceID string) (*Event, error) { + events, err := Read(dir, workspaceID, MaxLimit) + if err != nil || len(events) == 0 { + return nil, err + } + for _, event := range slices.Backward(events) { + if event.State == status.StateSucceeded || event.State == status.StateFailed { + return &event, nil + } + } + e := events[len(events)-1] + return &e, nil +} diff --git a/pkg/workspacejournal/journal_test.go b/pkg/workspacejournal/journal_test.go new file mode 100644 index 000000000..98fd07628 --- /dev/null +++ b/pkg/workspacejournal/journal_test.go @@ -0,0 +1,217 @@ +package workspacejournal + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/status" +) + +func TestJournalPersistsRedactedOperationAndPermissions(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 9, 20, 6, 0, 0, 0, time.UTC) + secret := "journal-super-secret" + journal, err := New(Options{ + Dir: dir, + Now: func() time.Time { return now }, + Env: []string{"TOKEN=" + secret}, + }) + if err != nil { + t.Fatal(err) + } + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + OperationID: "op-1", + Phase: status.PhaseReady, + Step: "using " + secret, + State: status.StateFailed, + Duration: 1500 * time.Millisecond, + Error: &status.ErrorInfo{ + Code: "boom", Message: "failed " + secret, Hint: "remove " + secret, + Context: map[string]string{"detail": secret}, + }, + }) + e := onlyEvent(t, dir, "demo") + if e.DurationMillis != 1500 || e.Error == nil || e.Error.Code != "boom" { + t.Fatalf("event=%+v", e) + } + // #nosec G304 -- The test owns its temporary directory. + data, err := os.ReadFile(filepath.Join(dir, "events-000001.ndjson")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), secret) { + t.Fatal("secret persisted") + } + info, _ := os.Stat(filepath.Join(dir, "events-000001.ndjson")) + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } +} + +func TestJournalRotatesAndPrunes(t *testing.T) { + dir := t.TempDir() + journal, err := New(Options{Dir: dir, MaxBytes: 700, MaxSegmentBytes: 220}) + if err != nil { + t.Fatal(err) + } + for range 20 { + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + Phase: status.PhaseReady, + Step: strings.Repeat("x", 50), + State: status.StateSucceeded, + }) + } + paths, err := segmentPaths(dir) + if err != nil { + t.Fatal(err) + } + if len(paths) < 2 { + t.Fatalf("segments=%d", len(paths)) + } + var total int64 + for _, p := range paths { + info, _ := os.Stat(p) + total += info.Size() + } + if total > 900 { + t.Fatalf("journal unbounded: %d", total) + } +} + +func TestReadSkipsCorruptLines(t *testing.T) { + dir := t.TempDir() + contents := "not-json\n" + + `{"schemaVersion":1,"timestamp":"2026-09-20T06:00:00Z",` + + `"workspaceId":"demo","pipeline":"workspace_up","phase":"ready","state":"succeeded"}` + + "\n{\"broken\":" + path := filepath.Join(dir, "events-000001.ndjson") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + events, err := Read(dir, "demo", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("events=%d", len(events)) + } +} + +func TestReporterNeverPropagatesWriteFailure(t *testing.T) { + journal, err := New(Options{Dir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + journal.dir = "/missing/parent/journal" + journal.Reporter("demo").Report(status.Event{ + Phase: status.PhaseReady, + State: status.StateSucceeded, + }) + if !errors.Is(journal.Append("demo", status.Event{}), os.ErrNotExist) { + t.Fatal("expected direct append failure") + } +} + +func onlyEvent(t *testing.T, dir, workspaceID string) Event { + t.Helper() + events, err := Read(dir, workspaceID, 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("events=%d", len(events)) + } + return events[0] +} + +func TestJournalRotatesFromHighestExistingSegment(t *testing.T) { + dir := t.TempDir() + full := strings.Repeat("x", 100) + for _, name := range []string{"events-000002.ndjson", "events-000003.ndjson"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(full+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + journal, err := New(Options{Dir: dir, MaxSegmentBytes: 100}) + if err != nil { + t.Fatal(err) + } + if err := journal.Append( + "demo", + status.Event{Phase: status.PhaseReady, State: status.StateSucceeded}, + ); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "events-000004.ndjson")); err != nil { + t.Fatalf("expected events-000004.ndjson: %v", err) + } + info, err := os.Stat(filepath.Join(dir, "events-000003.ndjson")) + if err != nil { + t.Fatal(err) + } + if info.Size() != int64(len(full)+1) { + t.Fatalf("existing segment grew to %d bytes", info.Size()) + } +} + +func TestReadSkipsOversizedRecords(t *testing.T) { + dir := t.TempDir() + record := `{"schemaVersion":1,"timestamp":"2026-09-20T06:00:00Z",` + + `"workspaceId":"demo","pipeline":"workspace_up","phase":"ready","state":"succeeded"}` + oversized := `{"schemaVersion":1,"timestamp":"2026-09-20T06:00:01Z",` + + `"workspaceId":"demo","pipeline":"workspace_up","phase":"ready","state":"succeeded","step":"` + + strings.Repeat("x", 128*1024) + `"}` + contents := record + "\n" + oversized + "\n" + record + "\n" + if err := os.WriteFile( + filepath.Join(dir, "events-000001.ndjson"), + []byte(contents), + 0o600, + ); err != nil { + t.Fatal(err) + } + events, err := Read(dir, "demo", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("events=%d", len(events)) + } +} + +func TestReadSkipsIncompleteRecords(t *testing.T) { + dir := t.TempDir() + record := func(fields ...string) string { + return "{" + strings.Join(fields, ",") + "}\n" + } + sv := `"schemaVersion":1` + ts := `"timestamp":"2026-09-20T06:00:00Z"` + ws := `"workspaceId":"demo"` + pl := `"pipeline":"workspace_up"` + ph := `"phase":"ready"` + st := `"state":"succeeded"` + contents := record(sv, ts, ws, pl, ph, st) + + record(sv, ws, pl, ph, st) + + record(sv, ts, ws, ph, st) + + record(sv, ts, ws, pl, st) + + record(sv, ts, ws, pl, ph) + if err := os.WriteFile( + filepath.Join(dir, "events-000001.ndjson"), + []byte(contents), + 0o600, + ); err != nil { + t.Fatal(err) + } + events, err := Read(dir, "demo", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("events=%d", len(events)) + } +} From 728f8a5a82176b70a6883d4fe123a5ffbdf93b77 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:35:45 -0600 Subject: [PATCH 02/33] fix(workspaces): skip oversized journal events to bound storage Signed-off-by: Samuel K --- pkg/workspacejournal/journal.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go index 2fbd8116f..168c7e059 100644 --- a/pkg/workspacejournal/journal.go +++ b/pkg/workspacejournal/journal.go @@ -16,6 +16,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/status" ) @@ -134,6 +135,14 @@ func (j *Journal) Append(workspaceID string, e status.Event) error { return err } b = append(b, '\n') + if len(b) > j.maxSegmentBytes { + log.Debugf( + "workspace journal: skipping oversized event (%d bytes, segment limit %d)", + len(b), + j.maxSegmentBytes, + ) + return nil + } path, err := j.activeSegment(len(b)) if err != nil { return err From bf3f1e9e2a95f7680cab8188b5a8ac825b8d5758 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:35:59 -0600 Subject: [PATCH 03/33] test(workspaces): cover oversized journal event skip Signed-off-by: Samuel K --- pkg/workspacejournal/journal_test.go | 39 +++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/workspacejournal/journal_test.go b/pkg/workspacejournal/journal_test.go index 98fd07628..606429f73 100644 --- a/pkg/workspacejournal/journal_test.go +++ b/pkg/workspacejournal/journal_test.go @@ -84,6 +84,43 @@ func TestJournalRotatesAndPrunes(t *testing.T) { } } +func TestAppendSkipsOversizedEvents(t *testing.T) { + dir := t.TempDir() + journal, err := New(Options{Dir: dir, MaxBytes: 1000, MaxSegmentBytes: 300}) + if err != nil { + t.Fatal(err) + } + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + Phase: status.PhaseReady, + Step: strings.Repeat("x", 500), + State: status.StateSucceeded, + }) + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + Phase: status.PhaseReady, + Step: "small", + State: status.StateSucceeded, + }) + events, err := Read(dir, "demo", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Step != "small" { + t.Fatalf("events=%+v", events) + } + paths, err := segmentPaths(dir) + if err != nil { + t.Fatal(err) + } + for _, p := range paths { + info, _ := os.Stat(p) + if info.Size() > 300 { + t.Fatalf("segment %s exceeds limit: %d", p, info.Size()) + } + } +} + func TestReadSkipsCorruptLines(t *testing.T) { dir := t.TempDir() contents := "not-json\n" + @@ -138,7 +175,7 @@ func TestJournalRotatesFromHighestExistingSegment(t *testing.T) { t.Fatal(err) } } - journal, err := New(Options{Dir: dir, MaxSegmentBytes: 100}) + journal, err := New(Options{Dir: dir, MaxSegmentBytes: 200}) if err != nil { t.Fatal(err) } From 0e991ca4c4d9894e19868cad9076f541719f18fb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:36:51 -0600 Subject: [PATCH 04/33] fix(workspaces): resolve import IDs before journaling Signed-off-by: Samuel K --- cmd/workspace/import.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/workspace/import.go b/cmd/workspace/import.go index 4ec141170..1747054c6 100644 --- a/cmd/workspace/import.go +++ b/cmd/workspace/import.go @@ -92,6 +92,7 @@ func (cmd *ImportCmd) execute(ctx context.Context) error { if err != nil { return err } + cmd.setDefaultIDs(exportConfig) reporter, err := newWorkspaceStatusReporter( cmd.ResultFormat, From 2417f083fa64e6ece66a73d3155720f0a9701527 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:37:25 -0600 Subject: [PATCH 05/33] fix(workspaces): journal full stop lifecycle Signed-off-by: Samuel K --- cmd/workspace/stop.go | 55 +++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/cmd/workspace/stop.go b/cmd/workspace/stop.go index c77a61590..9a1a12654 100644 --- a/cmd/workspace/stop.go +++ b/cmd/workspace/stop.go @@ -82,35 +82,40 @@ func (cmd *StopCmd) runArgs(ctx context.Context, args []string) error { if err != nil { return err } - return status.Run(status.WithReporter(ctx, reporter), reporter, - status.Operation{Phase: status.PhaseStoppingWorkspace}, func(ctx context.Context) error { - var devsyConfig *config.Config - var client client2.BaseWorkspaceClient - err := status.RunStep( - ctx, status.PhaseStoppingWorkspace, "Loading workspace", - func(ctx context.Context) error { - var err error - devsyConfig, err = config.LoadConfig(cmd.Context, cmd.Provider) - if err != nil { - return err - } - if err := clientimplementation.DecodePlatformOptionsFromEnv( - &cmd.Platform, - ); err != nil { - return fmt.Errorf("decode platform options: %w", err) - } - client, err = workspace2.Get(ctx, workspace2.GetOptions{ - DevsyConfig: devsyConfig, Args: args, Owner: cmd.Owner, - }) - return err - }, - ) + ctx = status.WithReporter(ctx, reporter) + var devsyConfig *config.Config + var client client2.BaseWorkspaceClient + err = status.RunStep( + ctx, status.PhaseStoppingWorkspace, "Loading workspace", + func(ctx context.Context) error { + var err error + devsyConfig, err = config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err } - reporter = withWorkspaceJournal(reporter, client.Workspace()) + if err := clientimplementation.DecodePlatformOptionsFromEnv( + &cmd.Platform, + ); err != nil { + return fmt.Errorf("decode platform options: %w", err) + } + client, err = workspace2.Get(ctx, workspace2.GetOptions{ + DevsyConfig: devsyConfig, Args: args, Owner: cmd.Owner, + }) + return err + }, + ) + if err != nil { + return err + } + reporter = withWorkspaceJournal(reporter, client.Workspace()) + return status.Run( + ctx, + reporter, + status.Operation{Phase: status.PhaseStoppingWorkspace}, + func(ctx context.Context) error { return cmd.run(status.WithReporter(ctx, reporter), devsyConfig, client) - }) + }, + ) } func (cmd *StopCmd) run( From 3153e4f5638298bb377febef4bf8fbc1dc8a719b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:37:39 -0600 Subject: [PATCH 06/33] fix(workspaces): resolve journal workspace IDs for delete Signed-off-by: Samuel K --- cmd/workspace/journal.go | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cmd/workspace/journal.go b/cmd/workspace/journal.go index 855333bde..a545ff7ec 100644 --- a/cmd/workspace/journal.go +++ b/cmd/workspace/journal.go @@ -1,8 +1,13 @@ package workspace import ( + "context" + + "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/status" + "github.com/devsy-org/devsy/pkg/workspace" "github.com/devsy-org/devsy/pkg/workspacejournal" ) @@ -17,3 +22,46 @@ func withWorkspaceJournal(reporter status.Reporter, workspaceID string) status.R status.ForPipeline(journal.Reporter(workspaceID), status.PipelineWorkspaceUp), ) } + +// journalWorkspaceKey returns the map key resolveJournalWorkspaceIDs uses for +// a delete target list. +func journalWorkspaceKey(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +// resolveJournalWorkspaceIDs maps each delete target to its resolved workspace +// ID so journaled events are stored under the same ID readers query with. It +// falls back to the raw argument when a target cannot be resolved. +func resolveJournalWorkspaceIDs( + ctx context.Context, + devsyConfig *config.Config, + owner platform.OwnerFilter, + args []string, +) map[string]string { + targets := args + if len(targets) == 0 { + targets = []string{""} + } + ids := make(map[string]string, len(targets)) + for _, arg := range targets { + ids[arg] = arg + callArgs := args + if len(args) > 1 { + callArgs = []string{arg} + } + client, err := workspace.Get(ctx, workspace.GetOptions{ + DevsyConfig: devsyConfig, + Args: callArgs, + Owner: owner, + }) + if err != nil { + log.Debugf("workspace operation journal: resolve workspace %q: %v", arg, err) + continue + } + ids[arg] = client.Workspace() + } + return ids +} From e0017145035dc7cd546ba679b36467cfd101883d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 11:38:12 -0600 Subject: [PATCH 07/33] fix(workspaces): journal deletes under resolved workspace IDs Signed-off-by: Samuel K --- cmd/workspace/delete.go | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index f563a00cb..63bd414ef 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -87,34 +87,25 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { if err != nil { return err } + devsyConfig, err := cmd.loadConfig() + if err != nil { + return err + } + workspaceIDs := resolveJournalWorkspaceIDs(ctx, devsyConfig, cmd.Owner, args) if len(args) <= 1 { - workspaceID := "" - if len(args) == 1 { - workspaceID = args[0] - } - reporter = withWorkspaceJournal(reporter, workspaceID) + reporter = withWorkspaceJournal(reporter, workspaceIDs[journalWorkspaceKey(args)]) } - var deleteErr error - var devsyConfig *config.Config - deleteErr = status.Run( + 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.deleteMultiple(ctx, devsyConfig, reporter, args) + return cmd.deleteMultiple(ctx, devsyConfig, reporter, workspaceIDs, args) }, ) - if devsyConfig == nil { - return deleteErr - } count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext) if countErr != nil { @@ -161,11 +152,12 @@ func (cmd *DeleteCmd) deleteMultiple( ctx context.Context, devsyConfig *config.Config, reporter status.Reporter, + workspaceIDs map[string]string, args []string, ) error { var errs []error for _, arg := range args { - targetReporter := withWorkspaceJournal(reporter, arg) + targetReporter := withWorkspaceJournal(reporter, workspaceIDs[arg]) name, err := cmd.deleteWorkspace( status.WithReporter(ctx, targetReporter), devsyConfig, From 666841ee5e46931f8fffc971235ec5c787f513c2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 13:20:45 -0600 Subject: [PATCH 08/33] fix(workspaces): align journal record size limit for writes and reads Signed-off-by: Samuel K --- pkg/workspacejournal/journal.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go index 168c7e059..dbd610afd 100644 --- a/pkg/workspacejournal/journal.go +++ b/pkg/workspacejournal/journal.go @@ -27,6 +27,9 @@ const ( DefaultMaxSegmentBytes = 512 * 1024 DefaultLimit = 100 MaxLimit = 1000 + // maxRecordBytes bounds a single event so writes never persist a + // record the reader would discard. + maxRecordBytes = 64 * 1024 ) type Event struct { @@ -135,11 +138,11 @@ func (j *Journal) Append(workspaceID string, e status.Event) error { return err } b = append(b, '\n') - if len(b) > j.maxSegmentBytes { + if limit := min(j.maxSegmentBytes, maxRecordBytes); len(b) > limit { log.Debugf( - "workspace journal: skipping oversized event (%d bytes, segment limit %d)", + "workspace journal: skipping oversized event (%d bytes, record limit %d)", len(b), - j.maxSegmentBytes, + limit, ) return nil } @@ -280,7 +283,7 @@ func readSegment(path, workspaceID string) ([]Event, error) { defer func() { _ = file.Close() }() var events []Event - reader := bufio.NewReaderSize(file, 64*1024) + reader := bufio.NewReaderSize(file, maxRecordBytes) for { line, readErr := readRecord(reader) if len(line) > 0 { From d051c518a83541f3871178b44addac4971c60fdc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 13:21:52 -0600 Subject: [PATCH 09/33] fix(workspaces): report delete config load failures Signed-off-by: Samuel K --- cmd/workspace/delete.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index 63bd414ef..25d9c7a19 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -89,7 +89,8 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { } devsyConfig, err := cmd.loadConfig() if err != nil { - return err + return status.Run(ctx, reporter, status.Operation{Phase: status.PhaseDeletingWorkspace}, + func(context.Context) error { return err }) } workspaceIDs := resolveJournalWorkspaceIDs(ctx, devsyConfig, cmd.Owner, args) if len(args) <= 1 { From 6145a872b416a8e1e9f599fdce4df5a8a977d613 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 13:24:21 -0600 Subject: [PATCH 10/33] test(workspaces): cover record read-limit skip Signed-off-by: Samuel K --- pkg/workspacejournal/journal_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/workspacejournal/journal_test.go b/pkg/workspacejournal/journal_test.go index 606429f73..35b97fb38 100644 --- a/pkg/workspacejournal/journal_test.go +++ b/pkg/workspacejournal/journal_test.go @@ -121,6 +121,27 @@ func TestAppendSkipsOversizedEvents(t *testing.T) { } } +func TestAppendSkipsRecordsBeyondReadLimit(t *testing.T) { + dir := t.TempDir() + journal, err := New(Options{Dir: dir}) + if err != nil { + t.Fatal(err) + } + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + Phase: status.PhaseReady, + Step: strings.Repeat("x", 100*1024), + State: status.StateSucceeded, + }) + paths, err := segmentPaths(dir) + if err != nil { + t.Fatal(err) + } + if len(paths) != 0 { + t.Fatalf("oversized record persisted: %v", paths) + } +} + func TestReadSkipsCorruptLines(t *testing.T) { dir := t.TempDir() contents := "not-json\n" + From 4ffece51d6d812f0111312009ef2e6510b5996cf Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:39:23 -0600 Subject: [PATCH 11/33] fix(workspaces): resolve interactive delete target once Signed-off-by: Samuel K --- pkg/workspace/delete.go | 42 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/pkg/workspace/delete.go b/pkg/workspace/delete.go index 6abbf6acf..996df11b6 100644 --- a/pkg/workspace/delete.go +++ b/pkg/workspace/delete.go @@ -19,8 +19,12 @@ import ( // DeleteOptions holds the parameters for deleting a workspace. type DeleteOptions struct { - DevsyConfig *config.Config - Args []string + DevsyConfig *config.Config + Args []string + // Client, when set, is an already-resolved workspace client. Delete then + // skips workspace resolution entirely so interactive callers select the + // deletion target exactly once. + Client client2.BaseWorkspaceClient IgnoreNotFound bool Force bool ClientDelete client2.DeleteOptions @@ -33,22 +37,24 @@ type DeleteOptions struct { // running -> stopped -> deleted lifecycle, and any detached browser tunnel // helper is reaped so its host ports do not outlive the workspace. func Delete(ctx context.Context, opts DeleteOptions) (string, error) { - var client client2.BaseWorkspaceClient - err := progress.RunStep( - ctx, - progress.PhaseDeletingWorkspace, - "Loading workspace", - func(ctx context.Context) error { - var err error - client, err = Get( - ctx, - GetOptions{DevsyConfig: opts.DevsyConfig, Args: opts.Args, Owner: opts.Owner}, - ) - return err - }, - ) - if err != nil { - return handleDeleteLoadError(ctx, opts, err) + client := opts.Client + if client == nil { + err := progress.RunStep( + ctx, + progress.PhaseDeletingWorkspace, + "Loading workspace", + func(ctx context.Context) error { + var err error + client, err = Get( + ctx, + GetOptions{DevsyConfig: opts.DevsyConfig, Args: opts.Args, Owner: opts.Owner}, + ) + return err + }, + ) + if err != nil { + return handleDeleteLoadError(ctx, opts, err) + } } defer opener.KillBrowserTunnel(client.Context(), client.Workspace()) From 5b02792fb5b4150d3df3848f91d3c8139d50f5f1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:39:56 -0600 Subject: [PATCH 12/33] fix(workspaces): journal interactive delete under selected workspace Signed-off-by: Samuel K --- cmd/workspace/delete.go | 80 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index 25d9c7a19..18e0a4b3f 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -92,6 +92,9 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { 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) + } workspaceIDs := resolveJournalWorkspaceIDs(ctx, devsyConfig, cmd.Owner, args) if len(args) <= 1 { reporter = withWorkspaceJournal(reporter, workspaceIDs[journalWorkspaceKey(args)]) @@ -108,16 +111,83 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { }, ) - 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, From 81243b49ecf08c0d853f2db73f84918bde4f4215 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:40:59 -0600 Subject: [PATCH 13/33] test(workspaces): cover pre-resolved client delete Signed-off-by: Samuel K --- pkg/workspace/delete_test.go | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/pkg/workspace/delete_test.go b/pkg/workspace/delete_test.go index 90b92e6cb..49c294f88 100644 --- a/pkg/workspace/delete_test.go +++ b/pkg/workspace/delete_test.go @@ -1,10 +1,13 @@ package workspace import ( + "context" "os" "path/filepath" "testing" + "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/provider" "github.com/stretchr/testify/require" ) @@ -70,3 +73,47 @@ func TestSweepOrphanContentDirs_MissingDirIsNoop(t *testing.T) { setupTestPathManager(t) SweepOrphanContentDirs(testDefaultContext) } + +// TestDeleteWithPreResolvedClientSkipsSelection is the regression guard for +// the double interactive selection: when the caller already resolved the +// deletion target, Delete must use that client directly. Tests run without a +// terminal, so any attempt to resolve the empty args interactively would fail. +func TestDeleteWithPreResolvedClientSkipsSelection(t *testing.T) { + setupTestPathManager(t) + + fake := &preResolvedDeleteClient{ + workspaceID: "chosen", + config: &provider.Workspace{ID: "chosen", Context: testDefaultContext}, + } + devsyConfig := &config.Config{ + DefaultContext: testDefaultContext, + Contexts: map[string]*config.ContextConfig{testDefaultContext: {}}, + } + + id, err := Delete(t.Context(), DeleteOptions{ + DevsyConfig: devsyConfig, + Client: fake, + Force: true, + }) + require.NoError(t, err) + require.Equal(t, "chosen", id) + require.True(t, fake.deleted) +} + +type preResolvedDeleteClient struct { + client.BaseWorkspaceClient + workspaceID string + config *provider.Workspace + deleted bool +} + +func (c *preResolvedDeleteClient) Workspace() string { return c.workspaceID } +func (c *preResolvedDeleteClient) WorkspaceConfig() *provider.Workspace { + return c.config +} +func (c *preResolvedDeleteClient) Context() string { return c.config.Context } +func (c *preResolvedDeleteClient) Provider() string { return "docker" } +func (c *preResolvedDeleteClient) Delete(context.Context, client.DeleteOptions) error { + c.deleted = true + return nil +} From 462e05319b86ba2cd7a1737c6a0fe7cf92b8d04d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:41:40 -0600 Subject: [PATCH 14/33] test(workspaces): cover interactive delete journal target Signed-off-by: Samuel K --- cmd/workspace/delete_test.go | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/cmd/workspace/delete_test.go b/cmd/workspace/delete_test.go index 6e2b6f672..70740fe7d 100644 --- a/cmd/workspace/delete_test.go +++ b/cmd/workspace/delete_test.go @@ -3,9 +3,16 @@ 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/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" ) @@ -82,3 +89,53 @@ 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) +} From e08811f547f8683ed9d90a3a8400a169abd59f76 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:42:17 -0600 Subject: [PATCH 15/33] test(workspaces): track fake client deletion Signed-off-by: Samuel K --- cmd/workspace/helpers_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/workspace/helpers_test.go b/cmd/workspace/helpers_test.go index 172ce1ecb..cb4f5a982 100644 --- a/cmd/workspace/helpers_test.go +++ b/cmd/workspace/helpers_test.go @@ -33,6 +33,7 @@ type fakeWorkspaceClient struct { config *provider.Workspace status client.Status statusErr error + deleted bool } func (f *fakeWorkspaceClient) Provider() string { return f.provider } @@ -44,8 +45,11 @@ func (f *fakeWorkspaceClient) RefreshOptions(context.Context, []string, bool) er func (f *fakeWorkspaceClient) Status(context.Context, client.StatusOptions) (client.Status, error) { return f.status, f.statusErr } -func (f *fakeWorkspaceClient) Stop(context.Context, client.StopOptions) error { return nil } -func (f *fakeWorkspaceClient) Delete(context.Context, client.DeleteOptions) error { return nil } +func (f *fakeWorkspaceClient) Stop(context.Context, client.StopOptions) error { return nil } +func (f *fakeWorkspaceClient) Delete(context.Context, client.DeleteOptions) error { + f.deleted = true + return nil +} func (f *fakeWorkspaceClient) Workspace() string { return f.workspace } From 516a889ceafe7f433ca87dd6fc7789bd310de3fe Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 00:14:00 -0600 Subject: [PATCH 16/33] style(workspaces): reuse DefaultDockerCommand in delete test Signed-off-by: Samuel K --- pkg/workspace/delete_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workspace/delete_test.go b/pkg/workspace/delete_test.go index 49c294f88..f47dfa2f4 100644 --- a/pkg/workspace/delete_test.go +++ b/pkg/workspace/delete_test.go @@ -112,7 +112,7 @@ func (c *preResolvedDeleteClient) WorkspaceConfig() *provider.Workspace { return c.config } func (c *preResolvedDeleteClient) Context() string { return c.config.Context } -func (c *preResolvedDeleteClient) Provider() string { return "docker" } +func (c *preResolvedDeleteClient) Provider() string { return DefaultDockerCommand } func (c *preResolvedDeleteClient) Delete(context.Context, client.DeleteOptions) error { c.deleted = true return nil From ef73af2a3a92be385808ed882ced231752a23135 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 08:57:04 -0600 Subject: [PATCH 17/33] refactor(workspaces): resolve delete targets once for reuse Signed-off-by: Samuel K --- cmd/workspace/journal.go | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cmd/workspace/journal.go b/cmd/workspace/journal.go index a545ff7ec..5b32d29b0 100644 --- a/cmd/workspace/journal.go +++ b/cmd/workspace/journal.go @@ -3,6 +3,7 @@ package workspace import ( "context" + 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" @@ -23,8 +24,8 @@ func withWorkspaceJournal(reporter status.Reporter, workspaceID string) status.R ) } -// journalWorkspaceKey returns the map key resolveJournalWorkspaceIDs uses for -// a delete target list. +// journalWorkspaceKey returns the map key resolveDeleteTargets uses for a +// delete target list. func journalWorkspaceKey(args []string) string { if len(args) == 0 { return "" @@ -32,22 +33,32 @@ func journalWorkspaceKey(args []string) string { return args[0] } -// resolveJournalWorkspaceIDs maps each delete target to its resolved workspace -// ID so journaled events are stored under the same ID readers query with. It -// falls back to the raw argument when a target cannot be resolved. -func resolveJournalWorkspaceIDs( +// resolvedDeleteTarget pairs a delete argument with the workspace client +// resolved from it. Client is nil when the argument could not be resolved; +// the deletion path then retries with the raw argument and the journal falls +// back to the argument as its workspace key. +type resolvedDeleteTarget struct { + ID string + Client client2.BaseWorkspaceClient +} + +// resolveDeleteTargets resolves each delete target exactly once so the same +// resolved client drives both deletion and journal keying. It falls back to +// the raw argument when a target cannot be resolved, such as a broken +// workspace removed with --force. +func resolveDeleteTargets( ctx context.Context, devsyConfig *config.Config, owner platform.OwnerFilter, args []string, -) map[string]string { +) map[string]resolvedDeleteTarget { targets := args if len(targets) == 0 { targets = []string{""} } - ids := make(map[string]string, len(targets)) + resolved := make(map[string]resolvedDeleteTarget, len(targets)) for _, arg := range targets { - ids[arg] = arg + resolved[arg] = resolvedDeleteTarget{ID: arg} callArgs := args if len(args) > 1 { callArgs = []string{arg} @@ -61,7 +72,7 @@ func resolveJournalWorkspaceIDs( log.Debugf("workspace operation journal: resolve workspace %q: %v", arg, err) continue } - ids[arg] = client.Workspace() + resolved[arg] = resolvedDeleteTarget{ID: client.Workspace(), Client: client} } - return ids + return resolved } From 533df6ccbcc060402595afe873a046dccb5c1eb3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 08:57:40 -0600 Subject: [PATCH 18/33] fix(workspaces): reuse resolved clients in named delete paths Signed-off-by: Samuel K --- cmd/workspace/delete.go | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index 18e0a4b3f..54a9e4ce1 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -95,9 +95,9 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { if len(args) == 0 { return cmd.deleteInteractively(ctx, reporter, devsyConfig) } - workspaceIDs := resolveJournalWorkspaceIDs(ctx, devsyConfig, cmd.Owner, args) + targets := resolveDeleteTargets(ctx, devsyConfig, cmd.Owner, args) if len(args) <= 1 { - reporter = withWorkspaceJournal(reporter, workspaceIDs[journalWorkspaceKey(args)]) + reporter = withWorkspaceJournal(reporter, targets[journalWorkspaceKey(args)].ID) } deleteErr := status.Run( ctx, @@ -105,9 +105,14 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { status.Operation{Phase: status.PhaseDeletingWorkspace}, func(ctx context.Context) error { 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(ctx, devsyConfig, reporter, workspaceIDs, args) + return cmd.deleteMultiple(ctx, devsyConfig, reporter, targets, args) }, ) @@ -207,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 } @@ -223,15 +229,17 @@ func (cmd *DeleteCmd) deleteMultiple( ctx context.Context, devsyConfig *config.Config, reporter status.Reporter, - workspaceIDs map[string]string, + targets map[string]resolvedDeleteTarget, args []string, ) error { var errs []error for _, arg := range args { - targetReporter := withWorkspaceJournal(reporter, workspaceIDs[arg]) - name, err := cmd.deleteWorkspace( + target := targets[arg] + targetReporter := withWorkspaceJournal(reporter, target.ID) + name, err := cmd.deleteTarget( status.WithReporter(ctx, targetReporter), devsyConfig, + target, []string{arg}, ) if err != nil { @@ -254,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, From 0ddc8a03aa15f72d3a83387bc446eb6acb7d885b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 08:58:14 -0600 Subject: [PATCH 19/33] test(workspaces): cover client reuse in named deletes Signed-off-by: Samuel K --- cmd/workspace/delete_test.go | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/cmd/workspace/delete_test.go b/cmd/workspace/delete_test.go index 70740fe7d..a2f2b5fbf 100644 --- a/cmd/workspace/delete_test.go +++ b/cmd/workspace/delete_test.go @@ -10,6 +10,7 @@ import ( 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" @@ -139,3 +140,93 @@ func TestDeleteCmd_DeleteResolvedJournalsUnderSelectedWorkspace(t *testing.T) { } 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) +} From eee9fbf17b5d01bd353c299cfc926272602e1c65 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 09:00:04 -0600 Subject: [PATCH 20/33] style(workspaces): trim trailing blank line in journal resolver Signed-off-by: Samuel K From 1a657814af5d4122f6836f36f37eb1095a0aef5d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 09:00:40 -0600 Subject: [PATCH 21/33] style(workspaces): trim trailing blank line in delete command Signed-off-by: Samuel K From ac0ebc06da1acce30d34b31823b3c6cf54e29aa4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 09:01:12 -0600 Subject: [PATCH 22/33] style(workspaces): trim trailing blank line in delete tests Signed-off-by: Samuel K From f91a3565d033f5cd3cba7a20ce4e7652ea163131 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 09:02:38 -0600 Subject: [PATCH 23/33] style(workspaces): drop trailing blank line in journal resolver Signed-off-by: Samuel K From 04450d1708fb41b794cbe3b2892f87fe92cb09ce Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 09:07:55 -0600 Subject: [PATCH 24/33] style(workspaces): normalize journal resolver file ending Signed-off-by: Samuel K From 9629dca9d13860d385e3689ea252adcd116a2e5b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 10:56:59 -0600 Subject: [PATCH 25/33] fix(workspaces): skip journal segments removed during read Signed-off-by: Samuel K --- pkg/workspacejournal/journal.go | 649 +++++++++++++++++++++++++++++++- 1 file changed, 648 insertions(+), 1 deletion(-) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go index dbd610afd..cd2aaee9e 100644 --- a/pkg/workspacejournal/journal.go +++ b/pkg/workspacejournal/journal.go @@ -263,15 +263,662 @@ func Read(dir, workspaceID string, limit int) ([]Event, error) { return nil, err } + events, err := readSegments(paths, workspaceID) + if err != nil { + return nil, err + } + return recentEvents(events, limit), nil +} + +func readSegments(paths []string, workspaceID string) ([]Event, error) { + var events []Event + for _, path := range paths { + segmentEvents, err := readSegment(path, workspaceID) + if errors.Is(err, os.ErrNotExist) { + // A concurrent prune removed the segment after listing. + continue + } + if err != nil { + return nil, err + } + events = append(events, segmentEvents...) + } + return events, nil +} + +func readSegment(path, workspaceID string) ([]Event, error) { + // #nosec G304 -- Segment paths come from the private journal directory listing. + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var events []Event + reader := bufio.NewReaderSize(file, maxRecordBytes) + for { + line, readErr := readRecord(reader) + if len(line) > 0 { + if event, ok := decodeEvent(line, workspaceID); ok { + events = append(events, event) + } + } + if errors.Is(readErr, io.EOF) { + return events, nil + } + if readErr != nil { + return nil, readErr + } + } +} + +// readRecord returns the next journal line. Oversized records are discarded +// through their next newline so a large write cannot abort the whole read. +func readRecord(reader *bufio.Reader) ([]byte, error) { + line, err := reader.ReadSlice('\n') + if !errors.Is(err, bufio.ErrBufferFull) { + return line, err + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/status" +) + +const ( + SchemaVersion = 1 + DefaultMaxBytes = 5 * 1024 * 1024 + DefaultMaxSegmentBytes = 512 * 1024 + DefaultLimit = 100 + MaxLimit = 1000 + // maxRecordBytes bounds a single event so writes never persist a + // record the reader would discard. + maxRecordBytes = 64 * 1024 +) + +type Event struct { + SchemaVersion int `json:"schemaVersion"` + Timestamp time.Time `json:"timestamp"` + WorkspaceID string `json:"workspaceId,omitempty"` + Pipeline status.Pipeline `json:"pipeline"` + OperationID string `json:"operationId,omitempty"` + ParentOperationID string `json:"parentOperationId,omitempty"` + Phase status.Phase `json:"phase"` + Step string `json:"step,omitempty"` + State status.State `json:"state"` + DurationMillis int64 `json:"durationMillis,omitempty"` + Error *status.ErrorInfo `json:"error,omitempty"` +} + +type Options struct { + Dir string + MaxBytes, MaxSegmentBytes int + Now func() time.Time + Env []string +} +type Journal struct { + mu sync.Mutex + dir string + maxBytes, maxSegmentBytes int + now func() time.Time + redactor *secrets.Redactor +} + +func DefaultDir() (string, error) { + root, err := config.GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(root, "state", "workspace-events"), nil +} + +func New(opts Options) (*Journal, error) { + if !filepath.IsAbs(opts.Dir) { + return nil, fmt.Errorf("journal directory must be absolute") + } + if opts.MaxBytes == 0 { + opts.MaxBytes = DefaultMaxBytes + } + if opts.MaxSegmentBytes == 0 { + opts.MaxSegmentBytes = DefaultMaxSegmentBytes + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Env == nil { + opts.Env = os.Environ() + } + if err := os.MkdirAll(opts.Dir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(opts.Dir, 0o700); err != nil { //nolint:gosec + return nil, err + } + return &Journal{ + dir: opts.Dir, + maxBytes: opts.MaxBytes, + maxSegmentBytes: opts.MaxSegmentBytes, + now: opts.Now, + redactor: secrets.NewEnvironmentRedactor(opts.Env), + }, nil +} + +func OpenDefault() (*Journal, error) { + dir, err := DefaultDir() + if err != nil { + return nil, err + } + return New(Options{Dir: dir}) +} + +func (j *Journal) Reporter(workspaceID string) status.Reporter { + return reporter{journal: j, workspaceID: workspaceID} +} + +type reporter struct { + journal *Journal + workspaceID string +} + +func (r reporter) Report(e status.Event) { _ = r.journal.Append(r.workspaceID, e) } +func (j *Journal) Append(workspaceID string, e status.Event) error { + j.mu.Lock() + defer j.mu.Unlock() + event := Event{ + SchemaVersion: SchemaVersion, + Timestamp: j.now().UTC(), + WorkspaceID: workspaceID, + Pipeline: e.Pipeline, + OperationID: e.OperationID, + ParentOperationID: e.ParentOperationID, + Phase: e.Phase, + Step: j.redactor.Redact(e.Step), + State: e.State, + DurationMillis: e.Duration.Milliseconds(), + Error: redactError(j.redactor, e.Error), + } + b, err := json.Marshal(event) + if err != nil { + return err + } + b = append(b, '\n') + if limit := min(j.maxSegmentBytes, maxRecordBytes); len(b) > limit { + log.Debugf( + "workspace journal: skipping oversized event (%d bytes, record limit %d)", + len(b), + limit, + ) + return nil + } + path, err := j.activeSegment(len(b)) + if err != nil { + return err + } + // #nosec G304 -- Segment paths are generated inside the private journal directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err = os.Chmod(path, 0o600); err == nil { + _, err = f.Write(b) + } + closeErr := f.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + return j.prune() +} + +func redactError(r *secrets.Redactor, in *status.ErrorInfo) *status.ErrorInfo { + if in == nil { + return nil + } + out := &status.ErrorInfo{Code: in.Code, Message: r.Redact(in.Message), Hint: r.Redact(in.Hint)} + if in.Context != nil { + out.Context = map[string]string{} + for k, v := range in.Context { + out.Context[k] = r.Redact(v) + } + } + return out +} + +func (j *Journal) activeSegment(next int) (string, error) { + paths, err := segmentPaths(j.dir) + if err != nil { + return "", err + } + if len(paths) > 0 { + last := paths[len(paths)-1] + fi, er := os.Stat(last) + if er != nil { + return "", er + } + if fi.Size()+int64(next) <= int64(j.maxSegmentBytes) { + return last, nil + } + } + return filepath.Join(j.dir, fmt.Sprintf("events-%06d.ndjson", nextSegmentNumber(paths))), nil +} + +func nextSegmentNumber(paths []string) int { + if len(paths) == 0 { + return 1 + } + name := filepath.Base(paths[len(paths)-1]) + n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(name, "events-"), ".ndjson")) + if err != nil { + return len(paths) + 1 + } + return n + 1 +} + +func segmentPaths(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var paths []string + for _, entry := range entries { + name := entry.Name() + if entry.Type().IsRegular() && + strings.HasPrefix(name, "events-") && + strings.HasSuffix(name, ".ndjson") { + paths = append(paths, filepath.Join(dir, name)) + } + } + sort.Strings(paths) + return paths, nil +} + +func (j *Journal) prune() error { + paths, err := segmentPaths(j.dir) + if err != nil { + return err + } + var total int64 + sizes := make([]int64, len(paths)) + for i, p := range paths { + fi, er := os.Stat(p) + if er != nil { + return er + } + sizes[i] = fi.Size() + total += fi.Size() + } + for i := 0; i < len(paths)-1 && total > int64(j.maxBytes); i++ { + if err := os.Remove(paths[i]); err != nil { + return err + } + total -= sizes[i] + } + return nil +} + +func Read(dir, workspaceID string, limit int) ([]Event, error) { + paths, err := segmentPaths(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + events, err := readSegments(paths, workspaceID) + if err != nil { + return nil, err + } + return recentEvents(events, limit), nil +} + +func readSegments(paths []string, workspaceID string) ([]Event, error) { + var events []Event + for _, path := range paths { + segmentEvents, err := readSegment(path, workspaceID) + if errors.Is(err, os.ErrNotExist) { + // A concurrent prune removed the segment after listing. + continue + } + if err != nil { + return nil, err + } + events = append(events, segmentEvents...) + } + return events, nil +} + +func readSegment(path, workspaceID string) ([]Event, error) { + // #nosec G304 -- Segment paths come from the private journal directory listing. + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var events []Event + reader := bufio.NewReaderSize(file, maxRecordBytes) + for { + line, readErr := readRecord(reader) + if len(line) > 0 { + if event, ok := decodeEvent(line, workspaceID); ok { + events = append(events, event) + } + } + if errors.Is(readErr, io.EOF) { + return events, nil + } + if readErr != nil { + return nil, readErr + } + } +} + +// readRecord returns the next journal line. Oversized records are discarded +// through their next newline so a large write cannot abort the whole read. +func readRecord(reader *bufio.Reader) ([]byte, error) { + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/status" +) + +const ( + SchemaVersion = 1 + DefaultMaxBytes = 5 * 1024 * 1024 + DefaultMaxSegmentBytes = 512 * 1024 + DefaultLimit = 100 + MaxLimit = 1000 + // maxRecordBytes bounds a single event so writes never persist a + // record the reader would discard. + maxRecordBytes = 64 * 1024 +) + +type Event struct { + SchemaVersion int `json:"schemaVersion"` + Timestamp time.Time `json:"timestamp"` + WorkspaceID string `json:"workspaceId,omitempty"` + Pipeline status.Pipeline `json:"pipeline"` + OperationID string `json:"operationId,omitempty"` + ParentOperationID string `json:"parentOperationId,omitempty"` + Phase status.Phase `json:"phase"` + Step string `json:"step,omitempty"` + State status.State `json:"state"` + DurationMillis int64 `json:"durationMillis,omitempty"` + Error *status.ErrorInfo `json:"error,omitempty"` +} + +type Options struct { + Dir string + MaxBytes, MaxSegmentBytes int + Now func() time.Time + Env []string +} +type Journal struct { + mu sync.Mutex + dir string + maxBytes, maxSegmentBytes int + now func() time.Time + redactor *secrets.Redactor +} + +func DefaultDir() (string, error) { + root, err := config.GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(root, "state", "workspace-events"), nil +} + +func New(opts Options) (*Journal, error) { + if !filepath.IsAbs(opts.Dir) { + return nil, fmt.Errorf("journal directory must be absolute") + } + if opts.MaxBytes == 0 { + opts.MaxBytes = DefaultMaxBytes + } + if opts.MaxSegmentBytes == 0 { + opts.MaxSegmentBytes = DefaultMaxSegmentBytes + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Env == nil { + opts.Env = os.Environ() + } + if err := os.MkdirAll(opts.Dir, 0o700); err != nil { + return nil, err + } + if err := os.Chmod(opts.Dir, 0o700); err != nil { //nolint:gosec + return nil, err + } + return &Journal{ + dir: opts.Dir, + maxBytes: opts.MaxBytes, + maxSegmentBytes: opts.MaxSegmentBytes, + now: opts.Now, + redactor: secrets.NewEnvironmentRedactor(opts.Env), + }, nil +} + +func OpenDefault() (*Journal, error) { + dir, err := DefaultDir() + if err != nil { + return nil, err + } + return New(Options{Dir: dir}) +} + +func (j *Journal) Reporter(workspaceID string) status.Reporter { + return reporter{journal: j, workspaceID: workspaceID} +} + +type reporter struct { + journal *Journal + workspaceID string +} + +func (r reporter) Report(e status.Event) { _ = r.journal.Append(r.workspaceID, e) } +func (j *Journal) Append(workspaceID string, e status.Event) error { + j.mu.Lock() + defer j.mu.Unlock() + event := Event{ + SchemaVersion: SchemaVersion, + Timestamp: j.now().UTC(), + WorkspaceID: workspaceID, + Pipeline: e.Pipeline, + OperationID: e.OperationID, + ParentOperationID: e.ParentOperationID, + Phase: e.Phase, + Step: j.redactor.Redact(e.Step), + State: e.State, + DurationMillis: e.Duration.Milliseconds(), + Error: redactError(j.redactor, e.Error), + } + b, err := json.Marshal(event) + if err != nil { + return err + } + b = append(b, '\n') + if limit := min(j.maxSegmentBytes, maxRecordBytes); len(b) > limit { + log.Debugf( + "workspace journal: skipping oversized event (%d bytes, record limit %d)", + len(b), + limit, + ) + return nil + } + path, err := j.activeSegment(len(b)) + if err != nil { + return err + } + // #nosec G304 -- Segment paths are generated inside the private journal directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err = os.Chmod(path, 0o600); err == nil { + _, err = f.Write(b) + } + closeErr := f.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + return j.prune() +} + +func redactError(r *secrets.Redactor, in *status.ErrorInfo) *status.ErrorInfo { + if in == nil { + return nil + } + out := &status.ErrorInfo{Code: in.Code, Message: r.Redact(in.Message), Hint: r.Redact(in.Hint)} + if in.Context != nil { + out.Context = map[string]string{} + for k, v := range in.Context { + out.Context[k] = r.Redact(v) + } + } + return out +} + +func (j *Journal) activeSegment(next int) (string, error) { + paths, err := segmentPaths(j.dir) + if err != nil { + return "", err + } + if len(paths) > 0 { + last := paths[len(paths)-1] + fi, er := os.Stat(last) + if er != nil { + return "", er + } + if fi.Size()+int64(next) <= int64(j.maxSegmentBytes) { + return last, nil + } + } + return filepath.Join(j.dir, fmt.Sprintf("events-%06d.ndjson", nextSegmentNumber(paths))), nil +} + +func nextSegmentNumber(paths []string) int { + if len(paths) == 0 { + return 1 + } + name := filepath.Base(paths[len(paths)-1]) + n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(name, "events-"), ".ndjson")) + if err != nil { + return len(paths) + 1 + } + return n + 1 +} + +func segmentPaths(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var paths []string + for _, entry := range entries { + name := entry.Name() + if entry.Type().IsRegular() && + strings.HasPrefix(name, "events-") && + strings.HasSuffix(name, ".ndjson") { + paths = append(paths, filepath.Join(dir, name)) + } + } + sort.Strings(paths) + return paths, nil +} + +func (j *Journal) prune() error { + paths, err := segmentPaths(j.dir) + if err != nil { + return err + } + var total int64 + sizes := make([]int64, len(paths)) + for i, p := range paths { + fi, er := os.Stat(p) + if er != nil { + return er + } + sizes[i] = fi.Size() + total += fi.Size() + } + for i := 0; i < len(paths)-1 && total > int64(j.maxBytes); i++ { + if err := os.Remove(paths[i]); err != nil { + return err + } + total -= sizes[i] + } + return nil +} + +func Read(dir, workspaceID string, limit int) ([]Event, error) { + paths, err := segmentPaths(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + events, err := readSegments(paths, workspaceID) + if err != nil { + return nil, err + } + return recentEvents(events, limit), nil +} + +func readSegments(paths []string, workspaceID string) ([]Event, error) { var events []Event for _, path := range paths { segmentEvents, err := readSegment(path, workspaceID) + if errors.Is(err, os.ErrNotExist) { + // A concurrent prune removed the segment after listing. + continue + } if err != nil { return nil, err } events = append(events, segmentEvents...) } - return recentEvents(events, limit), nil + return events, nil } func readSegment(path, workspaceID string) ([]Event, error) { From ff742b57d2d4b4b1767d0bb5eedadc8042f9ca0b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 10:58:45 -0600 Subject: [PATCH 26/33] fix(workspaces): skip journal segments removed during read Signed-off-by: Samuel K --- pkg/workspacejournal/journal.go | 635 -------------------------------- 1 file changed, 635 deletions(-) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go index cd2aaee9e..3237c126d 100644 --- a/pkg/workspacejournal/journal.go +++ b/pkg/workspacejournal/journal.go @@ -312,641 +312,6 @@ func readSegment(path, workspaceID string) ([]Event, error) { } } -// readRecord returns the next journal line. Oversized records are discarded -// through their next newline so a large write cannot abort the whole read. -func readRecord(reader *bufio.Reader) ([]byte, error) { - line, err := reader.ReadSlice('\n') - if !errors.Is(err, bufio.ErrBufferFull) { - return line, err - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/devsy-org/devsy/pkg/config" - "github.com/devsy-org/devsy/pkg/log" - "github.com/devsy-org/devsy/pkg/secrets" - "github.com/devsy-org/devsy/pkg/status" -) - -const ( - SchemaVersion = 1 - DefaultMaxBytes = 5 * 1024 * 1024 - DefaultMaxSegmentBytes = 512 * 1024 - DefaultLimit = 100 - MaxLimit = 1000 - // maxRecordBytes bounds a single event so writes never persist a - // record the reader would discard. - maxRecordBytes = 64 * 1024 -) - -type Event struct { - SchemaVersion int `json:"schemaVersion"` - Timestamp time.Time `json:"timestamp"` - WorkspaceID string `json:"workspaceId,omitempty"` - Pipeline status.Pipeline `json:"pipeline"` - OperationID string `json:"operationId,omitempty"` - ParentOperationID string `json:"parentOperationId,omitempty"` - Phase status.Phase `json:"phase"` - Step string `json:"step,omitempty"` - State status.State `json:"state"` - DurationMillis int64 `json:"durationMillis,omitempty"` - Error *status.ErrorInfo `json:"error,omitempty"` -} - -type Options struct { - Dir string - MaxBytes, MaxSegmentBytes int - Now func() time.Time - Env []string -} -type Journal struct { - mu sync.Mutex - dir string - maxBytes, maxSegmentBytes int - now func() time.Time - redactor *secrets.Redactor -} - -func DefaultDir() (string, error) { - root, err := config.GetConfigDir() - if err != nil { - return "", err - } - return filepath.Join(root, "state", "workspace-events"), nil -} - -func New(opts Options) (*Journal, error) { - if !filepath.IsAbs(opts.Dir) { - return nil, fmt.Errorf("journal directory must be absolute") - } - if opts.MaxBytes == 0 { - opts.MaxBytes = DefaultMaxBytes - } - if opts.MaxSegmentBytes == 0 { - opts.MaxSegmentBytes = DefaultMaxSegmentBytes - } - if opts.Now == nil { - opts.Now = time.Now - } - if opts.Env == nil { - opts.Env = os.Environ() - } - if err := os.MkdirAll(opts.Dir, 0o700); err != nil { - return nil, err - } - if err := os.Chmod(opts.Dir, 0o700); err != nil { //nolint:gosec - return nil, err - } - return &Journal{ - dir: opts.Dir, - maxBytes: opts.MaxBytes, - maxSegmentBytes: opts.MaxSegmentBytes, - now: opts.Now, - redactor: secrets.NewEnvironmentRedactor(opts.Env), - }, nil -} - -func OpenDefault() (*Journal, error) { - dir, err := DefaultDir() - if err != nil { - return nil, err - } - return New(Options{Dir: dir}) -} - -func (j *Journal) Reporter(workspaceID string) status.Reporter { - return reporter{journal: j, workspaceID: workspaceID} -} - -type reporter struct { - journal *Journal - workspaceID string -} - -func (r reporter) Report(e status.Event) { _ = r.journal.Append(r.workspaceID, e) } -func (j *Journal) Append(workspaceID string, e status.Event) error { - j.mu.Lock() - defer j.mu.Unlock() - event := Event{ - SchemaVersion: SchemaVersion, - Timestamp: j.now().UTC(), - WorkspaceID: workspaceID, - Pipeline: e.Pipeline, - OperationID: e.OperationID, - ParentOperationID: e.ParentOperationID, - Phase: e.Phase, - Step: j.redactor.Redact(e.Step), - State: e.State, - DurationMillis: e.Duration.Milliseconds(), - Error: redactError(j.redactor, e.Error), - } - b, err := json.Marshal(event) - if err != nil { - return err - } - b = append(b, '\n') - if limit := min(j.maxSegmentBytes, maxRecordBytes); len(b) > limit { - log.Debugf( - "workspace journal: skipping oversized event (%d bytes, record limit %d)", - len(b), - limit, - ) - return nil - } - path, err := j.activeSegment(len(b)) - if err != nil { - return err - } - // #nosec G304 -- Segment paths are generated inside the private journal directory. - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return err - } - if err = os.Chmod(path, 0o600); err == nil { - _, err = f.Write(b) - } - closeErr := f.Close() - if err == nil { - err = closeErr - } - if err != nil { - return err - } - return j.prune() -} - -func redactError(r *secrets.Redactor, in *status.ErrorInfo) *status.ErrorInfo { - if in == nil { - return nil - } - out := &status.ErrorInfo{Code: in.Code, Message: r.Redact(in.Message), Hint: r.Redact(in.Hint)} - if in.Context != nil { - out.Context = map[string]string{} - for k, v := range in.Context { - out.Context[k] = r.Redact(v) - } - } - return out -} - -func (j *Journal) activeSegment(next int) (string, error) { - paths, err := segmentPaths(j.dir) - if err != nil { - return "", err - } - if len(paths) > 0 { - last := paths[len(paths)-1] - fi, er := os.Stat(last) - if er != nil { - return "", er - } - if fi.Size()+int64(next) <= int64(j.maxSegmentBytes) { - return last, nil - } - } - return filepath.Join(j.dir, fmt.Sprintf("events-%06d.ndjson", nextSegmentNumber(paths))), nil -} - -func nextSegmentNumber(paths []string) int { - if len(paths) == 0 { - return 1 - } - name := filepath.Base(paths[len(paths)-1]) - n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(name, "events-"), ".ndjson")) - if err != nil { - return len(paths) + 1 - } - return n + 1 -} - -func segmentPaths(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - var paths []string - for _, entry := range entries { - name := entry.Name() - if entry.Type().IsRegular() && - strings.HasPrefix(name, "events-") && - strings.HasSuffix(name, ".ndjson") { - paths = append(paths, filepath.Join(dir, name)) - } - } - sort.Strings(paths) - return paths, nil -} - -func (j *Journal) prune() error { - paths, err := segmentPaths(j.dir) - if err != nil { - return err - } - var total int64 - sizes := make([]int64, len(paths)) - for i, p := range paths { - fi, er := os.Stat(p) - if er != nil { - return er - } - sizes[i] = fi.Size() - total += fi.Size() - } - for i := 0; i < len(paths)-1 && total > int64(j.maxBytes); i++ { - if err := os.Remove(paths[i]); err != nil { - return err - } - total -= sizes[i] - } - return nil -} - -func Read(dir, workspaceID string, limit int) ([]Event, error) { - paths, err := segmentPaths(dir) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - - events, err := readSegments(paths, workspaceID) - if err != nil { - return nil, err - } - return recentEvents(events, limit), nil -} - -func readSegments(paths []string, workspaceID string) ([]Event, error) { - var events []Event - for _, path := range paths { - segmentEvents, err := readSegment(path, workspaceID) - if errors.Is(err, os.ErrNotExist) { - // A concurrent prune removed the segment after listing. - continue - } - if err != nil { - return nil, err - } - events = append(events, segmentEvents...) - } - return events, nil -} - -func readSegment(path, workspaceID string) ([]Event, error) { - // #nosec G304 -- Segment paths come from the private journal directory listing. - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = file.Close() }() - - var events []Event - reader := bufio.NewReaderSize(file, maxRecordBytes) - for { - line, readErr := readRecord(reader) - if len(line) > 0 { - if event, ok := decodeEvent(line, workspaceID); ok { - events = append(events, event) - } - } - if errors.Is(readErr, io.EOF) { - return events, nil - } - if readErr != nil { - return nil, readErr - } - } -} - -// readRecord returns the next journal line. Oversized records are discarded -// through their next newline so a large write cannot abort the whole read. -func readRecord(reader *bufio.Reader) ([]byte, error) { - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/devsy-org/devsy/pkg/config" - "github.com/devsy-org/devsy/pkg/log" - "github.com/devsy-org/devsy/pkg/secrets" - "github.com/devsy-org/devsy/pkg/status" -) - -const ( - SchemaVersion = 1 - DefaultMaxBytes = 5 * 1024 * 1024 - DefaultMaxSegmentBytes = 512 * 1024 - DefaultLimit = 100 - MaxLimit = 1000 - // maxRecordBytes bounds a single event so writes never persist a - // record the reader would discard. - maxRecordBytes = 64 * 1024 -) - -type Event struct { - SchemaVersion int `json:"schemaVersion"` - Timestamp time.Time `json:"timestamp"` - WorkspaceID string `json:"workspaceId,omitempty"` - Pipeline status.Pipeline `json:"pipeline"` - OperationID string `json:"operationId,omitempty"` - ParentOperationID string `json:"parentOperationId,omitempty"` - Phase status.Phase `json:"phase"` - Step string `json:"step,omitempty"` - State status.State `json:"state"` - DurationMillis int64 `json:"durationMillis,omitempty"` - Error *status.ErrorInfo `json:"error,omitempty"` -} - -type Options struct { - Dir string - MaxBytes, MaxSegmentBytes int - Now func() time.Time - Env []string -} -type Journal struct { - mu sync.Mutex - dir string - maxBytes, maxSegmentBytes int - now func() time.Time - redactor *secrets.Redactor -} - -func DefaultDir() (string, error) { - root, err := config.GetConfigDir() - if err != nil { - return "", err - } - return filepath.Join(root, "state", "workspace-events"), nil -} - -func New(opts Options) (*Journal, error) { - if !filepath.IsAbs(opts.Dir) { - return nil, fmt.Errorf("journal directory must be absolute") - } - if opts.MaxBytes == 0 { - opts.MaxBytes = DefaultMaxBytes - } - if opts.MaxSegmentBytes == 0 { - opts.MaxSegmentBytes = DefaultMaxSegmentBytes - } - if opts.Now == nil { - opts.Now = time.Now - } - if opts.Env == nil { - opts.Env = os.Environ() - } - if err := os.MkdirAll(opts.Dir, 0o700); err != nil { - return nil, err - } - if err := os.Chmod(opts.Dir, 0o700); err != nil { //nolint:gosec - return nil, err - } - return &Journal{ - dir: opts.Dir, - maxBytes: opts.MaxBytes, - maxSegmentBytes: opts.MaxSegmentBytes, - now: opts.Now, - redactor: secrets.NewEnvironmentRedactor(opts.Env), - }, nil -} - -func OpenDefault() (*Journal, error) { - dir, err := DefaultDir() - if err != nil { - return nil, err - } - return New(Options{Dir: dir}) -} - -func (j *Journal) Reporter(workspaceID string) status.Reporter { - return reporter{journal: j, workspaceID: workspaceID} -} - -type reporter struct { - journal *Journal - workspaceID string -} - -func (r reporter) Report(e status.Event) { _ = r.journal.Append(r.workspaceID, e) } -func (j *Journal) Append(workspaceID string, e status.Event) error { - j.mu.Lock() - defer j.mu.Unlock() - event := Event{ - SchemaVersion: SchemaVersion, - Timestamp: j.now().UTC(), - WorkspaceID: workspaceID, - Pipeline: e.Pipeline, - OperationID: e.OperationID, - ParentOperationID: e.ParentOperationID, - Phase: e.Phase, - Step: j.redactor.Redact(e.Step), - State: e.State, - DurationMillis: e.Duration.Milliseconds(), - Error: redactError(j.redactor, e.Error), - } - b, err := json.Marshal(event) - if err != nil { - return err - } - b = append(b, '\n') - if limit := min(j.maxSegmentBytes, maxRecordBytes); len(b) > limit { - log.Debugf( - "workspace journal: skipping oversized event (%d bytes, record limit %d)", - len(b), - limit, - ) - return nil - } - path, err := j.activeSegment(len(b)) - if err != nil { - return err - } - // #nosec G304 -- Segment paths are generated inside the private journal directory. - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return err - } - if err = os.Chmod(path, 0o600); err == nil { - _, err = f.Write(b) - } - closeErr := f.Close() - if err == nil { - err = closeErr - } - if err != nil { - return err - } - return j.prune() -} - -func redactError(r *secrets.Redactor, in *status.ErrorInfo) *status.ErrorInfo { - if in == nil { - return nil - } - out := &status.ErrorInfo{Code: in.Code, Message: r.Redact(in.Message), Hint: r.Redact(in.Hint)} - if in.Context != nil { - out.Context = map[string]string{} - for k, v := range in.Context { - out.Context[k] = r.Redact(v) - } - } - return out -} - -func (j *Journal) activeSegment(next int) (string, error) { - paths, err := segmentPaths(j.dir) - if err != nil { - return "", err - } - if len(paths) > 0 { - last := paths[len(paths)-1] - fi, er := os.Stat(last) - if er != nil { - return "", er - } - if fi.Size()+int64(next) <= int64(j.maxSegmentBytes) { - return last, nil - } - } - return filepath.Join(j.dir, fmt.Sprintf("events-%06d.ndjson", nextSegmentNumber(paths))), nil -} - -func nextSegmentNumber(paths []string) int { - if len(paths) == 0 { - return 1 - } - name := filepath.Base(paths[len(paths)-1]) - n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(name, "events-"), ".ndjson")) - if err != nil { - return len(paths) + 1 - } - return n + 1 -} - -func segmentPaths(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - var paths []string - for _, entry := range entries { - name := entry.Name() - if entry.Type().IsRegular() && - strings.HasPrefix(name, "events-") && - strings.HasSuffix(name, ".ndjson") { - paths = append(paths, filepath.Join(dir, name)) - } - } - sort.Strings(paths) - return paths, nil -} - -func (j *Journal) prune() error { - paths, err := segmentPaths(j.dir) - if err != nil { - return err - } - var total int64 - sizes := make([]int64, len(paths)) - for i, p := range paths { - fi, er := os.Stat(p) - if er != nil { - return er - } - sizes[i] = fi.Size() - total += fi.Size() - } - for i := 0; i < len(paths)-1 && total > int64(j.maxBytes); i++ { - if err := os.Remove(paths[i]); err != nil { - return err - } - total -= sizes[i] - } - return nil -} - -func Read(dir, workspaceID string, limit int) ([]Event, error) { - paths, err := segmentPaths(dir) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - - events, err := readSegments(paths, workspaceID) - if err != nil { - return nil, err - } - return recentEvents(events, limit), nil -} - -func readSegments(paths []string, workspaceID string) ([]Event, error) { - var events []Event - for _, path := range paths { - segmentEvents, err := readSegment(path, workspaceID) - if errors.Is(err, os.ErrNotExist) { - // A concurrent prune removed the segment after listing. - continue - } - if err != nil { - return nil, err - } - events = append(events, segmentEvents...) - } - return events, nil -} - -func readSegment(path, workspaceID string) ([]Event, error) { - // #nosec G304 -- Segment paths come from the private journal directory listing. - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = file.Close() }() - - var events []Event - reader := bufio.NewReaderSize(file, maxRecordBytes) - for { - line, readErr := readRecord(reader) - if len(line) > 0 { - if event, ok := decodeEvent(line, workspaceID); ok { - events = append(events, event) - } - } - if errors.Is(readErr, io.EOF) { - return events, nil - } - if readErr != nil { - return nil, readErr - } - } -} - // readRecord returns the next journal line. Oversized records are discarded // through their next newline so a large write cannot abort the whole read. func readRecord(reader *bufio.Reader) ([]byte, error) { From 8bff91bd9daeb127ebd6a2171617483ff32bcd50 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 10:59:11 -0600 Subject: [PATCH 27/33] test(workspaces): cover concurrent prune during journal read Signed-off-by: Samuel K --- pkg/workspacejournal/journal_test.go | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pkg/workspacejournal/journal_test.go b/pkg/workspacejournal/journal_test.go index 35b97fb38..a9efdf891 100644 --- a/pkg/workspacejournal/journal_test.go +++ b/pkg/workspacejournal/journal_test.go @@ -273,3 +273,33 @@ func TestReadSkipsIncompleteRecords(t *testing.T) { t.Fatalf("events=%d", len(events)) } } + +func TestReadSkipsSegmentsRemovedByConcurrentPrune(t *testing.T) { + dir := t.TempDir() + journal, err := New(Options{Dir: dir}) + if err != nil { + t.Fatal(err) + } + journal.Reporter("demo").Report(status.Event{ + Pipeline: status.PipelineWorkspaceUp, + OperationID: "op-1", + Phase: status.PhaseReady, + State: status.StateSucceeded, + }) + paths, err := segmentPaths(dir) + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 { + t.Fatalf("paths=%v", paths) + } + // A prune racing Read removes a listed segment before it is opened. + missing := filepath.Join(dir, "events-999999.ndjson") + events, err := readSegments([]string{missing, paths[0]}, "demo") + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("events=%d", len(events)) + } +} From e44007950a9356f346d872a5161f1e6db3d0ccd6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 10:59:38 -0600 Subject: [PATCH 28/33] fix(workspaces): route import parse failures through status envelope Signed-off-by: Samuel K --- cmd/workspace/import.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/workspace/import.go b/cmd/workspace/import.go index 1747054c6..cd3c7f7bb 100644 --- a/cmd/workspace/import.go +++ b/cmd/workspace/import.go @@ -88,12 +88,6 @@ func (cmd *ImportCmd) Run( } func (cmd *ImportCmd) execute(ctx context.Context) error { - exportConfig, err := cmd.parseExportConfig() - if err != nil { - return err - } - cmd.setDefaultIDs(exportConfig) - reporter, err := newWorkspaceStatusReporter( cmd.ResultFormat, os.Stdout, @@ -102,12 +96,19 @@ func (cmd *ImportCmd) execute(ctx context.Context) error { if err != nil { return err } - reporter = withWorkspaceJournal(reporter, cmd.WorkspaceID) + exportConfig, parseErr := cmd.parseExportConfig() + if parseErr == nil { + cmd.setDefaultIDs(exportConfig) + reporter = withWorkspaceJournal(reporter, cmd.WorkspaceID) + } return status.Run( ctx, reporter, status.Operation{Phase: status.PhaseImportingWorkspace}, func(ctx context.Context) error { + if parseErr != nil { + return parseErr + } devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err From 29bd1b4bb35f38ba93bac1f172baf10202fbe1fb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 11:00:00 -0600 Subject: [PATCH 29/33] test(workspaces): cover import parse failure envelope Signed-off-by: Samuel K --- cmd/workspace/import_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmd/workspace/import_test.go b/cmd/workspace/import_test.go index be0ab32d6..93cddb76e 100644 --- a/cmd/workspace/import_test.go +++ b/cmd/workspace/import_test.go @@ -3,6 +3,7 @@ package workspace import ( "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/provider" snapshotpkg "github.com/devsy-org/devsy/pkg/snapshot" "github.com/stretchr/testify/assert" @@ -27,3 +28,17 @@ func TestImportWorkspace_SnapshotRefBadRef(t *testing.T) { _, _, err := snapshotpkg.RestoreComposition("invalid-ref") require.Error(t, err) } + +func TestImportCmdExecuteParseFailureKeepsStatusEnvelope(t *testing.T) { + cmd := &ImportCmd{ + GlobalFlags: &flags.GlobalFlags{ResultFormat: formatJSON}, + Data: "not json", + } + var execErr error + out := captureStdout(t, func() { + execErr = cmd.execute(t.Context()) + }) + require.Error(t, execErr) + require.Contains(t, out, `"kind":"status"`) + require.Contains(t, out, "decode workspace data") +} From 981750b690b13b356b686dc2cb2586dd81207daa Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 11:39:02 -0600 Subject: [PATCH 30/33] style(workspaces): remove narrating comments per review Signed-off-by: Samuel K --- pkg/workspacejournal/journal.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/workspacejournal/journal.go b/pkg/workspacejournal/journal.go index 3237c126d..b20cdd5e1 100644 --- a/pkg/workspacejournal/journal.go +++ b/pkg/workspacejournal/journal.go @@ -27,9 +27,7 @@ const ( DefaultMaxSegmentBytes = 512 * 1024 DefaultLimit = 100 MaxLimit = 1000 - // maxRecordBytes bounds a single event so writes never persist a - // record the reader would discard. - maxRecordBytes = 64 * 1024 + maxRecordBytes = 64 * 1024 ) type Event struct { From d284bc1864b5c93108e7fa6ba84858562fb3253c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 11:40:15 -0600 Subject: [PATCH 31/33] style(workspaces): remove narrating comments per review Signed-off-by: Samuel K --- pkg/workspace/delete.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/workspace/delete.go b/pkg/workspace/delete.go index 996df11b6..374542438 100644 --- a/pkg/workspace/delete.go +++ b/pkg/workspace/delete.go @@ -19,11 +19,8 @@ import ( // DeleteOptions holds the parameters for deleting a workspace. type DeleteOptions struct { - DevsyConfig *config.Config - Args []string - // Client, when set, is an already-resolved workspace client. Delete then - // skips workspace resolution entirely so interactive callers select the - // deletion target exactly once. + DevsyConfig *config.Config + Args []string Client client2.BaseWorkspaceClient IgnoreNotFound bool Force bool From b67b8d91f48c2b09a2b4b5cfbeb9da7dead817b7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 11:40:37 -0600 Subject: [PATCH 32/33] style(workspaces): remove narrating comments per review Signed-off-by: Samuel K --- pkg/workspace/delete_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/workspace/delete_test.go b/pkg/workspace/delete_test.go index f47dfa2f4..dd07b9e5e 100644 --- a/pkg/workspace/delete_test.go +++ b/pkg/workspace/delete_test.go @@ -74,10 +74,6 @@ func TestSweepOrphanContentDirs_MissingDirIsNoop(t *testing.T) { SweepOrphanContentDirs(testDefaultContext) } -// TestDeleteWithPreResolvedClientSkipsSelection is the regression guard for -// the double interactive selection: when the caller already resolved the -// deletion target, Delete must use that client directly. Tests run without a -// terminal, so any attempt to resolve the empty args interactively would fail. func TestDeleteWithPreResolvedClientSkipsSelection(t *testing.T) { setupTestPathManager(t) From 781c72166b78712b6850856bfd9e06f893c65613 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 11:41:36 -0600 Subject: [PATCH 33/33] style(workspaces): remove narrating comments per review Signed-off-by: Samuel K --- cmd/workspace/delete_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/cmd/workspace/delete_test.go b/cmd/workspace/delete_test.go index a2f2b5fbf..282ebb231 100644 --- a/cmd/workspace/delete_test.go +++ b/cmd/workspace/delete_test.go @@ -91,10 +91,6 @@ func TestDeleteCmd_Completion(t *testing.T) { 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}) @@ -141,10 +137,6 @@ func TestDeleteCmd_DeleteResolvedJournalsUnderSelectedWorkspace(t *testing.T) { 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}) @@ -202,9 +194,6 @@ func TestDeleteCmd_DeleteMultipleReusesResolvedClients(t *testing.T) { } } -// 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})