From b923ae8892d2d7b94f9d1e8d75b04b75a5cb2252 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 7 May 2026 19:47:24 -0400 Subject: [PATCH] feat(v0.5.2): direct lookup includes archived; resume hint for archived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the v0.5.2 lookup policy to the existing commands and wire ValidArgsFunction hooks across the workspace-accepting surface. info: drop the --all/-a flag entirely. Direct lookup is now archived-inclusive by default — the user typed a name, so we find the workspace and surface its status in the output. The Status line already distinguishes active vs archived clearly. resume: when targeting an archived workspace, fail with a useful hint instead of letting the resolver report "not found": [ctask] error: workspace "X" is archived To restore it: ctask restore X This is implemented by resolving archived-inclusive and rejecting status==archived before any session-launch logic runs. Genuine not-found and ambiguous-match behavior are unchanged. Adds ValidArgsFunction hooks to archive (active), delete (active), open (active), info (any), resume (active). Restore/notes/path already have hooks from the previous commit. Shell completion now covers the full direct-lookup surface. --- cmd/archive.go | 1 + cmd/delete.go | 4 +++ cmd/info.go | 10 +++--- cmd/info_launch_test.go | 39 +++++++++++++++++--- cmd/open.go | 2 ++ cmd/resume.go | 15 +++++++- cmd/resume_test.go | 80 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 cmd/resume_test.go diff --git a/cmd/archive.go b/cmd/archive.go index 3dc3f68..f358623 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -21,6 +21,7 @@ var archiveCmd = &cobra.Command{ } func init() { + archiveCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(archiveCmd) } diff --git a/cmd/delete.go b/cmd/delete.go index 3be75ad..adca539 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -28,6 +28,10 @@ var ( func init() { deleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "Skip confirmation prompt") deleteCmd.Flags().BoolVarP(&deleteAll, "all", "a", false, "Include archived workspaces in query resolution") + // v0.5.2: completion offers active candidates only. Users with `--all` + // can still type the archived name explicitly; flag-aware completion is + // deferred until there's a real need. + deleteCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(deleteCmd) } diff --git a/cmd/info.go b/cmd/info.go index 1d30079..27bb2d4 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -17,16 +17,18 @@ var infoCmd = &cobra.Command{ RunE: runInfo, } -var infoAll bool - func init() { - infoCmd.Flags().BoolVarP(&infoAll, "all", "a", false, "Include archived workspaces in query resolution") + infoCmd.ValidArgsFunction = completeWorkspaces(completionAny) rootCmd.AddCommand(infoCmd) } func runInfo(cmd *cobra.Command, args []string) error { + // v0.5.2: info is a read-only direct lookup. Always include archived + // workspaces — if the user types a name, they want to find it whether + // or not it's archived. The status field in the output makes the state + // obvious. roots := config.SearchRoots() - ws := resolveOne(roots, args[0], infoAll) + ws := resolveOne(roots, args[0], true) m := ws.Meta fmt.Printf("Task: %s\n", m.Slug) diff --git a/cmd/info_launch_test.go b/cmd/info_launch_test.go index f22b166..118585e 100644 --- a/cmd/info_launch_test.go +++ b/cmd/info_launch_test.go @@ -109,10 +109,7 @@ func TestInfoFormatsTimestampsInLocalZone(t *testing.T) { } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) - // infoAll must be true because the workspace is archived. - prevAll := infoAll - infoAll = true - defer func() { infoAll = prevAll }() + // v0.5.2: info is archived-inclusive by default — no flag setup needed. out, err := runInfoCapture(t, root, "tz-test") if err != nil { @@ -138,6 +135,40 @@ func TestInfoFormatsTimestampsInLocalZone(t *testing.T) { } } +func TestInfoFindsArchivedWorkspaceWithoutFlag(t *testing.T) { + // v0.5.2: info defaults to archived-inclusive lookup. Direct lookup of + // an archived workspace must succeed without any flag, and the output + // must clearly show Status: archived. + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_archived-ws") + os.MkdirAll(wsDir, 0755) + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(time.Hour) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "archived-ws", Title: "archived-ws", + CreatedAt: now, + UpdatedAt: now, + ArchivedAt: &archived, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) + + out, err := runInfoCapture(t, root, "archived-ws") + if err != nil { + t.Fatalf("info should find archived workspace by default: %v", err) + } + if !strings.Contains(out, "Status: archived") { + t.Errorf("expected 'Status: archived' in output:\n%s", out) + } + if !strings.Contains(out, "Archived:") { + t.Errorf("expected 'Archived:' line in output:\n%s", out) + } +} + func TestInfoShowsDirExistsNoWhenLaunchDirMissing(t *testing.T) { root := t.TempDir() wsDir := filepath.Join(root, "projects", "2026-04-22_renamed") diff --git a/cmd/open.go b/cmd/open.go index c33b106..1df23b6 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -27,6 +27,8 @@ var ( func init() { openCmd.Flags().BoolVarP(&openAll, "all", "a", false, "Include archived workspaces in query resolution") openCmd.Flags().BoolVar(&openForce, "force", false, "Skip active-session and stale-workspace warnings") + // v0.5.2: completion offers active candidates only (see delete for rationale). + openCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(openCmd) } diff --git a/cmd/resume.go b/cmd/resume.go index 00ee788..62bb63b 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "path/filepath" "time" @@ -32,6 +33,7 @@ func init() { resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent") resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command") resumeCmd.Flags().BoolVar(&resumeForce, "force", false, "Skip active-session and stale-workspace warnings") + resumeCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(resumeCmd) } @@ -47,7 +49,18 @@ func doResume(query string, container, useShell, force bool, agentOverride strin } roots := config.SearchRoots() - ws := resolveOne(roots, query, false) + // v0.5.2: resolve archived-inclusive so we can give a helpful hint when + // the user resumes an archived workspace. resolveOne still handles + // not-found and ambiguity exactly as before — this only changes which + // workspaces are reachable, not how lookup failures are reported. + ws := resolveOne(roots, query, true) + + if ws.Meta.Status == "archived" { + fmt.Fprintf(os.Stderr, + "[ctask] error: workspace %q is archived\n\nTo restore it:\n ctask restore %s\n", + query, query) + return fmt.Errorf("workspace archived") + } // Update updated_at now := time.Now().UTC().Truncate(time.Second) diff --git a/cmd/resume_test.go b/cmd/resume_test.go new file mode 100644 index 0000000..4a6b2c2 --- /dev/null +++ b/cmd/resume_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// callDoResumeArchived is a focused harness: it sets CTASK_ROOT, captures +// stderr, and runs doResume. The test fixtures only exercise the early +// archived-status branch; we never reach session.Run because the archived +// branch returns first. +func callDoResumeArchived(t *testing.T, root, query string) (stderr string, err error) { + t.Helper() + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }() + + errR, errW, _ := os.Pipe() + prevStderr := os.Stderr + os.Stderr = errW + defer func() { os.Stderr = prevStderr }() + + err = doResume(query, false, false, false, "") + errW.Close() + var buf bytes.Buffer + buf.ReadFrom(errR) + return buf.String(), err +} + +func TestResumeArchivedWorkspaceShowsRestoreHint(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_resume-archived") + os.MkdirAll(wsDir, 0755) + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(-time.Hour) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "resume-archived", Title: "resume-archived", + CreatedAt: now, UpdatedAt: archived, + ArchivedAt: &archived, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) + + stderr, err := callDoResumeArchived(t, root, "resume-archived") + if err == nil { + t.Fatal("expected error resuming archived workspace") + } + if !strings.Contains(stderr, `workspace "resume-archived" is archived`) { + t.Errorf("stderr should explain archived state: %q", stderr) + } + if !strings.Contains(stderr, "ctask restore resume-archived") { + t.Errorf("stderr should contain restore hint: %q", stderr) + } + + // Workspace metadata must be unchanged (no UpdatedAt bump). + got, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml")) + if got.Status != "archived" { + t.Errorf("workspace mutated despite refusal: status=%s", got.Status) + } + if !got.UpdatedAt.Equal(archived) { + t.Errorf("UpdatedAt advanced despite refusal: was %v, now %v", archived, got.UpdatedAt) + } +}