b75b82e676
LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are replaced by *agent.Resolved, carrying Command, Args, and Env. The five entry commands (new, resume, last, open, attach) each construct an AgentSpec from the workspace metadata, apply --agent as a one-shot agent.command override (Open Q 1 — keeps muscle memory for users passing executable paths), call agent.Resolve, and pass the result through. resolveEntryAgent centralises the resume/last/open/attach path. shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env is merged into the env map at the session.Run launch switch, AFTER ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision). mergeAgentEnv is the centralised merge. Lease, manifest, write lock, heartbeat, summary, and provisional cleanup are unchanged. The Agent string fields on Lease, SessionSummary, and SessionInfo continue to record the launched command for diagnostics.
255 lines
9.5 KiB
Go
255 lines
9.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
|
|
"github.com/warrenronsiek/ctask/internal/agent"
|
|
"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
|
|
ResolvedAgent *agent.Resolved // launch-ready agent (command + args + env)
|
|
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 {
|
|
// v0.6: if the resolver downgraded a configured persistent mode to
|
|
// direct via the native-Windows platform override, surface the fact
|
|
// to the user once before any launch work happens. attach
|
|
// (AlwaysPersistent=true) is excluded — it has no direct-mode
|
|
// fallback and refuses on native Windows via preflightPersistentEntry.
|
|
emitPlatformOverrideWarningIfNeeded(opts.AlwaysPersistent)
|
|
|
|
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")
|
|
}
|
|
|
|
// resolveEntryAgent builds the launch-ready agent for an entry command
|
|
// (resume / last / open / attach). It starts from the workspace's
|
|
// AgentSpec, applies an optional one-shot agent.command override (the
|
|
// --agent flag — a command override, NOT a type selector, per v0.6
|
|
// Open Question 1), then resolves against the user-level default_agent.
|
|
func resolveEntryAgent(spec workspace.AgentSpec, commandOverride string) (*agent.Resolved, error) {
|
|
if commandOverride != "" {
|
|
spec.Command = commandOverride
|
|
}
|
|
return agent.Resolve(spec, config.LoadResolver().DefaultAgent().Value)
|
|
}
|
|
|
|
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),
|
|
ResolvedAgent: opts.ResolvedAgent,
|
|
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,
|
|
ActiveLeaseHint: directModeTmuxHint(opts),
|
|
})
|
|
}
|
|
|
|
// directModeTmuxHint returns a Layer-1 prompt suggestion when ctask is
|
|
// about to enter direct mode on a workspace that already has a live tmux
|
|
// session — pointing the user at `ctask attach <slug>` as the reattach
|
|
// path. Returns "" when no hint is appropriate (no tmux on PATH, no
|
|
// session for this workspace, or native Windows without WSL).
|
|
//
|
|
// This is a best-effort UX nudge: the lookup is silent on error so a
|
|
// missing/broken tmux never blocks the direct-mode path.
|
|
func directModeTmuxHint(opts WorkspaceEntryOptions) string {
|
|
if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" {
|
|
return ""
|
|
}
|
|
tmuxPath, err := exec.LookPath("tmux")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
absWs, _ := filepath.Abs(opts.WsPath)
|
|
sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs)
|
|
if !shell.HasSession(tmuxPath, sessionName) {
|
|
return ""
|
|
}
|
|
return formatDirectModeTmuxHint(opts.WsMeta.Slug)
|
|
}
|
|
|
|
// formatDirectModeTmuxHint builds the hint string itself, with no tmux
|
|
// or filesystem checks. Split out so unit tests can verify that the
|
|
// command-form line uses invocationName() without needing a live tmux
|
|
// session set up against a real workspace.
|
|
func formatDirectModeTmuxHint(slug string) string {
|
|
return fmt.Sprintf(
|
|
"Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s",
|
|
invocationName(), slug)
|
|
}
|
|
|
|
func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error {
|
|
return session.Run(session.LaunchOpts{
|
|
WsDir: opts.WsPath,
|
|
EnvVars: entryEnvVars(opts),
|
|
ResolvedAgent: opts.ResolvedAgent,
|
|
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),
|
|
ResolvedAgent: opts.ResolvedAgent,
|
|
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 %s attach %s\n\n"+
|
|
"Continue with --direct anyway? [y/N] ",
|
|
sessionName, invocationName(), opts.WsMeta.Slug)
|
|
if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) {
|
|
return fmt.Errorf("canceled by user")
|
|
}
|
|
return nil
|
|
}
|