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 }