From f5746df314fe31d2a9cd03c56a4117a0053a6036 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 8 May 2026 13:59:53 -0400 Subject: [PATCH] feat(v0.5.3): shared workspace-entry helper; resume + last delegate; fresh_remote prompt --- cmd/entry.go | 199 ++++++++++++++++++++++++++++++++++ cmd/entry_test.go | 88 +++++++++++++++ cmd/last.go | 10 +- cmd/persistent.go | 36 ++++++ cmd/persistent_test.go | 24 ++++ cmd/resume.go | 40 +++---- cmd/resume_persistent_test.go | 73 +++++++++++++ cmd/resume_test.go | 2 +- 8 files changed, 447 insertions(+), 25 deletions(-) create mode 100644 cmd/entry.go create mode 100644 cmd/entry_test.go create mode 100644 cmd/resume_persistent_test.go diff --git a/cmd/entry.go b/cmd/entry.go new file mode 100644 index 0000000..96c3633 --- /dev/null +++ b/cmd/entry.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// WorkspaceEntryOptions captures everything the persistent-mode dispatcher +// needs to enter a workspace. Callers (runNew, runResume, runOpen, +// runAttach) populate this struct after they have resolved or created the +// workspace, then call runWorkspaceEntry. The helper handles preflight +// (when not pre-resolved), session-name computation, lease inspection, +// dispatch decision, and the fresh_remote confirmation prompt. +type WorkspaceEntryOptions struct { + WsPath string // absolute workspace directory + WsRoot string // top-level root (used for CTASK_ROOT env var) + WsMeta *workspace.TaskMeta // workspace metadata + Agent string + Shell bool // launch interactive shell (open / new --shell) + Force bool // bypass v0.4 Layer 1/3 prompts (owner-create only) + Direct bool // user passed --direct + AlwaysPersistent bool // ctask attach: ignore CTASK_SESSION_MODE + CommandName string // for hint rendering: "new" | "resume" | "open" | "attach" + TmuxPath string // pre-resolved tmux path; if empty in persistent mode, runWorkspaceEntry resolves + NewlyCreated bool // forwarded to LaunchOpts.NewlyCreated +} + +// runWorkspaceEntry is the test seam for the persistent-mode dispatcher. +// Production code calls defaultRunWorkspaceEntry; tests override this +// variable to capture invocations or simulate the dispatch outcome. +// +// Do NOT mark tests that override this in t.Parallel() — it is a package +// global. Each test must restore via t.Cleanup. +var runWorkspaceEntry = defaultRunWorkspaceEntry + +// dispatchDecision enumerates the three persistent-mode entry paths. +type dispatchDecision int + +const ( + dispatchOwnerCreate dispatchDecision = iota + dispatchPassive + dispatchAdopted +) + +// dispatchPersistent is the pure decision function — no I/O, no globals, +// trivially testable. +func dispatchPersistent(hasTmuxSession bool, leaseState session.LeaseState) dispatchDecision { + if !hasTmuxSession { + return dispatchOwnerCreate + } + if leaseState == session.LeaseStateFreshLocal { + return dispatchPassive + } + return dispatchAdopted +} + +func defaultRunWorkspaceEntry(opts WorkspaceEntryOptions) error { + mode := config.ResolveSessionMode() + persistent := opts.AlwaysPersistent || (mode == "persistent" && !opts.Direct) + + // Direct flag with persistent env: confirm if a tmux session exists. + if !persistent && mode == "persistent" && opts.Direct { + if err := confirmDirectBypass(opts); err != nil { + return err + } + } + + if !persistent { + return invokeDirectRun(opts) + } + + tmuxPath := opts.TmuxPath + if tmuxPath == "" { + var err error + tmuxPath, err = preflightPersistentEntry(opts.CommandName) + if err != nil { + return err + } + } + + absWs, _ := filepath.Abs(opts.WsPath) + sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs) + hasSession := shell.HasSession(tmuxPath, sessionName) + leaseState := session.InspectLease(opts.WsPath) + + switch dispatchPersistent(hasSession, leaseState) { + case dispatchOwnerCreate: + return invokePersistentRun(opts, tmuxPath, sessionName) + case dispatchPassive: + return session.AttachExisting(tmuxPath, sessionName) + case dispatchAdopted: + if leaseState == session.LeaseStateFreshRemote { + if err := confirmFreshRemoteAdoption(opts.WsPath); err != nil { + return err + } + } + return invokePersistentAdoption(opts, tmuxPath, sessionName) + } + return fmt.Errorf("internal: unreachable persistent dispatch") +} + +func entryEnvVars(opts WorkspaceEntryOptions) map[string]string { + return config.EnvVars( + opts.WsMeta.Slug, opts.WsMeta.Mode, + opts.WsRoot, opts.WsPath, + opts.WsMeta.Category, workspace.EffectiveType(opts.WsMeta), + opts.WsMeta.LaunchDir, + ) +} + +func invokeDirectRun(opts WorkspaceEntryOptions) error { + return session.Run(session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + Force: opts.Force, + NewlyCreated: opts.NewlyCreated, + }) +} + +func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { + return session.Run(session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, + Force: opts.Force, + NewlyCreated: opts.NewlyCreated, + }) +} + +func invokePersistentAdoption(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { + return session.AdoptExistingPersistentSession(tmuxPath, sessionName, opts.WsPath, session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, + }) +} + +// confirmDirectBypass is invoked when the user passes --direct under +// persistent mode. If a tmux session exists for the workspace, prompt for +// confirmation. Otherwise, print a one-line warning and proceed. +func confirmDirectBypass(opts WorkspaceEntryOptions) error { + // Native Windows / no WSL: no tmux can exist; silent proceed. + if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { + return nil + } + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return nil + } + absWs, _ := filepath.Abs(opts.WsPath) + sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs) + if !shell.HasSession(tmuxPath, sessionName) { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return nil + } + fmt.Fprintf(os.Stderr, + "A persistent tmux session exists for this workspace:\n %s\n\n"+ + "Opening a direct-mode shell may create conflicting workspace activity.\n"+ + "The recommended path is:\n ctask attach %s\n\n"+ + "Continue with --direct anyway? [y/N] ", + sessionName, opts.WsMeta.Slug) + if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) { + return fmt.Errorf("canceled by user") + } + return nil +} diff --git a/cmd/entry_test.go b/cmd/entry_test.go new file mode 100644 index 0000000..e276009 --- /dev/null +++ b/cmd/entry_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// dispatchPersistent is a pure decision function — table tests are the +// right shape. +func TestDispatchPersistentOwnerWhenNoTmuxSession(t *testing.T) { + got := dispatchPersistent(false, session.LeaseStateNone) + if got != dispatchOwnerCreate { + t.Errorf("got %v, want %v", got, dispatchOwnerCreate) + } +} + +func TestDispatchPersistentPassiveWhenFreshLocal(t *testing.T) { + got := dispatchPersistent(true, session.LeaseStateFreshLocal) + if got != dispatchPassive { + t.Errorf("got %v, want %v", got, dispatchPassive) + } +} + +func TestDispatchPersistentAdoptedWhenStaleNoneOrRemote(t *testing.T) { + for _, st := range []session.LeaseState{ + session.LeaseStateStale, + session.LeaseStateNone, + session.LeaseStateFreshRemote, + } { + got := dispatchPersistent(true, st) + if got != dispatchAdopted { + t.Errorf("state %v: got %v, want %v", st, got, dispatchAdopted) + } + } +} + +// SessionName is computed by callers — sanity check determinism. +func TestEntrySessionNameStable(t *testing.T) { + abs, _ := filepath.Abs("/tmp/x") + a := session.SessionName("projects", "demo", abs) + b := session.SessionName("projects", "demo", abs) + if a != b { + t.Errorf("not stable: %q vs %q", a, b) + } +} + +// runWorkspaceEntry must be injectable so per-command tests can capture +// the WorkspaceEntryOptions each command produces. This test installs a +// stub and verifies the wiring works end-to-end. +// +// Tests in this file mutate the package-level runWorkspaceEntry. Do not +// run with t.Parallel(). +func TestRunWorkspaceEntryIsInjectable(t *testing.T) { + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + want := WorkspaceEntryOptions{ + WsPath: "/tmp/ws", + WsRoot: "/tmp", + WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: "claude"}, + Agent: "claude", + Shell: true, + CommandName: "test", + } + if err := runWorkspaceEntry(want); err != nil { + t.Fatalf("runWorkspaceEntry: %v", err) + } + if captured.CommandName != "test" { + t.Errorf("CommandName: got %q", captured.CommandName) + } + if !captured.Shell { + t.Error("Shell should be true") + } + if captured.WsMeta == nil || captured.WsMeta.Slug != "demo" { + t.Errorf("WsMeta not propagated: %+v", captured.WsMeta) + } + if captured.WsPath != "/tmp/ws" || captured.WsRoot != "/tmp" { + t.Errorf("path/root not propagated: path=%q root=%q", captured.WsPath, captured.WsRoot) + } +} diff --git a/cmd/last.go b/cmd/last.go index 8e80edd..c90c047 100644 --- a/cmd/last.go +++ b/cmd/last.go @@ -18,15 +18,17 @@ var lastCmd = &cobra.Command{ } var ( - lastShell bool - lastAgent string - lastForce bool + lastShell bool + lastAgent string + lastForce bool + lastDirect bool ) func init() { lastCmd.Flags().BoolVar(&lastShell, "shell", false, "Open shell instead of agent") lastCmd.Flags().StringVarP(&lastAgent, "agent", "a", "", "Override agent command") lastCmd.Flags().BoolVar(&lastForce, "force", false, "Skip active-session and stale-workspace warnings") + lastCmd.Flags().BoolVar(&lastDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(lastCmd) } @@ -42,5 +44,5 @@ func runLast(cmd *cobra.Command, args []string) error { os.Exit(1) } - return doResume(best.Meta.Slug, false, lastShell, lastForce, lastAgent) + return doResume(best.Meta.Slug, false, lastShell, lastForce, lastAgent, lastDirect) } diff --git a/cmd/persistent.go b/cmd/persistent.go index 7b4978f..b58d9a7 100644 --- a/cmd/persistent.go +++ b/cmd/persistent.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "runtime" + "time" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" ) @@ -84,3 +86,37 @@ func preflightPersistentEntry(commandName string) (string, error) { } return tmuxPath, nil } + +// confirmFreshRemoteAdoption prompts the user to confirm adoption of a +// workspace whose lease is fresh but from a different host. Refuses on +// non-TTY environments — silent overwrite of a fresh remote lease is +// never appropriate. +func confirmFreshRemoteAdoption(wsPath string) error { + l, _ := session.ReadLease(session.LeasePath(wsPath)) + hostStr := "unknown" + heartbeatAgo := "unknown" + if l != nil { + hostStr = l.Hostname + heartbeatAgo = session.FormatAgo(time.Since(l.LastHeartbeatAt)) + } + if !isTTYCheck() { + return fmt.Errorf( + "ctask refused to adopt persistent session: lease is fresh from another host (%s, last heartbeat %s ago); rerun in an interactive terminal to confirm", + hostStr, heartbeatAgo) + } + fmt.Fprintf(os.Stderr, + "[ctask] tmux session exists, but a fresh lease from another host is present:\n"+ + "[ctask] hostname: %s\n"+ + "[ctask] last heartbeat: %s ago\n"+ + "[ctask]\n"+ + "[ctask] This may indicate another machine is actively using this workspace\n"+ + "[ctask] (e.g., shared filesystem). Adopting will overwrite the remote lease\n"+ + "[ctask] and may create conflicting workspace activity.\n"+ + "[ctask]\n"+ + "Adopt anyway? [y/N] ", + hostStr, heartbeatAgo) + if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) { + return fmt.Errorf("[ctask] adoption refused; wait for the remote session to end or unset CTASK_SESSION_MODE") + } + return nil +} diff --git a/cmd/persistent_test.go b/cmd/persistent_test.go index dc7cd4e..fd674c6 100644 --- a/cmd/persistent_test.go +++ b/cmd/persistent_test.go @@ -6,7 +6,9 @@ import ( "runtime" "strings" "testing" + "time" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" ) @@ -128,3 +130,25 @@ func TestPreflightSuccessReturnsTmuxPath(t *testing.T) { t.Error("expected non-empty tmuxPath on success") } } + +func TestConfirmFreshRemoteAdoptionRefusesOnNonTTY(t *testing.T) { + wsDir := t.TempDir() + // Write a fresh remote lease so the prompt has data to display. + other := "remote-host-xyz" + l := &session.Lease{ + SessionID: "x", Hostname: other, + StartedAt: time.Now().UTC(), LastHeartbeatAt: time.Now().UTC(), + } + if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + withTTYCheck(t, func() bool { return false }) + err := confirmFreshRemoteAdoption(wsDir) + if err == nil { + t.Fatal("expected refusal on non-TTY") + } + if !strings.Contains(err.Error(), other) { + t.Errorf("error should name the remote host: %v", err) + } +} diff --git a/cmd/resume.go b/cmd/resume.go index 62bb63b..df92c66 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -26,6 +25,7 @@ var ( resumeShell bool resumeAgent string resumeForce bool + resumeDirect bool ) func init() { @@ -33,26 +33,28 @@ 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.Flags().BoolVar(&resumeDirect, "direct", false, "Bypass persistent session mode for this command") resumeCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(resumeCmd) } func runResume(cmd *cobra.Command, args []string) error { - return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent) + return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) } -// doResume is the shared resume logic used by both resume and last commands. -func doResume(query string, container, useShell, force bool, agentOverride string) error { +// doResume is the shared resume logic used by both `resume` and `last`. +// It preserves resume's existing archive-inclusive resolution and +// restore-hint behavior, then delegates to runWorkspaceEntry for the +// persistent-vs-direct decision and tmux dispatch. +func doResume(query string, container, useShell, force bool, agentOverride string, directFlag bool) error { if container { fmt.Println(shell.ContainerNotice()) return nil } roots := config.SearchRoots() - // 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. + // resume resolves archived-inclusive so we can give a helpful hint when + // the user resumes an archived workspace (v0.5.2 behavior — preserved). ws := resolveOne(roots, query, true) if ws.Meta.Status == "archived" { @@ -62,7 +64,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin return fmt.Errorf("workspace archived") } - // Update updated_at + // updated_at bump (existing v0.4 behavior). now := time.Now().UTC().Truncate(time.Second) ws.Meta.UpdatedAt = now metaPath := filepath.Join(ws.Path, "task.yaml") @@ -75,16 +77,14 @@ func doResume(query string, container, useShell, force bool, agentOverride strin agent = ws.Meta.Agent } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, ws.Root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) - - return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: useShell, - LaunchDir: ws.Meta.LaunchDir, - Force: force, + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + Agent: agent, + Shell: useShell, + Force: force, + Direct: directFlag, + CommandName: "resume", }) } diff --git a/cmd/resume_persistent_test.go b/cmd/resume_persistent_test.go new file mode 100644 index 0000000..c7afd97 --- /dev/null +++ b/cmd/resume_persistent_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Tests in this file mutate runWorkspaceEntry. Do not run with t.Parallel(). + +// TestRunResumeForwardsToEntryHelperWithCommandNameResume verifies that +// `doResume` constructs the correct WorkspaceEntryOptions: CommandName +// "resume", Shell mirrors --shell, Direct mirrors --direct. We stub +// runWorkspaceEntry to capture the opts and use the resume_test.go fixture +// pattern (writing task.yaml on disk) so resolveOne returns a real +// workspace. +func TestRunResumeForwardsToEntryHelperWithCommandNameResume(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-05-08_resume-fwd-demo") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "resume-fwd-demo", Title: "resume-fwd-demo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + if err := doResume("resume-fwd-demo", false, true /*shell*/, false, "" /*agent*/, true /*direct*/); err != nil { + t.Fatalf("doResume: %v", err) + } + if captured.CommandName != "resume" { + t.Errorf("CommandName: got %q, want %q", captured.CommandName, "resume") + } + if !captured.Shell { + t.Error("Shell should mirror --shell=true") + } + if !captured.Direct { + t.Error("Direct should mirror --direct=true") + } + if captured.WsMeta == nil || captured.WsMeta.Slug != "resume-fwd-demo" { + t.Errorf("WsMeta not propagated: %+v", captured.WsMeta) + } + if captured.WsPath != wsDir { + t.Errorf("WsPath: got %q, want %q", captured.WsPath, wsDir) + } +} diff --git a/cmd/resume_test.go b/cmd/resume_test.go index 4a6b2c2..126035d 100644 --- a/cmd/resume_test.go +++ b/cmd/resume_test.go @@ -33,7 +33,7 @@ func callDoResumeArchived(t *testing.T, root, query string) (stderr string, err os.Stderr = errW defer func() { os.Stderr = prevStderr }() - err = doResume(query, false, false, false, "") + err = doResume(query, false, false, false, "", false) errW.Close() var buf bytes.Buffer buf.ReadFrom(errR)