diff --git a/README.md b/README.md index 91300cb07..3566e7768 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,17 @@ This shows all available checkpoints in the current session. Select one to resto To see and restore sessions from earlier work: ``` -entire resume +entire resume ``` Lists all past sessions with timestamps. You can view the conversation history or restore the code from any session. +To restore and immediately start the session in one step: + +``` +entire resume --run +``` + ### 5. Disable Entire (Optional) ``` @@ -167,6 +173,15 @@ Multiple AI sessions can run on the same commit. If you start a second session w | `entire status` | Show current session and strategy info | | `entire version` | Show Entire CLI version | +### `entire resume` Flags + +| Flag | Description | +|------------------|---------------------------------------------| +| `--force`, `-f` | Resume from older checkpoint without prompt | +| `--run`, `-r` | Start the restored session immediately | + +Set `"autoRunResume": true` in `.entire/settings.json` (or `.entire/settings.local.json`) to make `--run` the default. + ### `entire enable` Flags | Flag | Description | @@ -225,6 +240,7 @@ Personal overrides, gitignored by default: |--------------------------------------|----------------------------------|------------------------------------------------------| | `enabled` | `true`, `false` | Enable/disable Entire | | `log_level` | `debug`, `info`, `warn`, `error` | Logging verbosity | +| `autoRunResume` | `true`, `false` | Auto-run restored session for `entire resume` | | `strategy` | `manual-commit`, `auto-commit` | Session capture strategy | | `strategy_options.push_sessions` | `true`, `false` | Auto-push `entire/checkpoints/v1` branch on git push | | `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time | diff --git a/cmd/entire/cli/config_test.go b/cmd/entire/cli/config_test.go index 6330fa5d4..7f15d1b20 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -229,6 +229,28 @@ func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) { } } +func TestLoadEntireSettings_LocalOverridesAutoRunResume(t *testing.T) { + setupLocalOverrideTestDir(t) + + baseSettings := `{"strategy": "manual-commit", "autoRunResume": false}` + if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { + t.Fatalf("Failed to write settings file: %v", err) + } + + localSettings := `{"autoRunResume": true}` + if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { + t.Fatalf("Failed to write local settings file: %v", err) + } + + settings, err := LoadEntireSettings() + if err != nil { + t.Fatalf("LoadEntireSettings() error = %v", err) + } + if !settings.AutoRunResume { + t.Error("AutoRunResume should be true from local override") + } +} + func TestLoadEntireSettings_LocalMergesStrategyOptions(t *testing.T) { setupLocalOverrideTestDir(t) diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 5b131692d..0c74b1731 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -6,7 +6,9 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" + "strings" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -25,6 +27,7 @@ import ( func newResumeCmd() *cobra.Command { var force bool + var autoRun bool cmd := &cobra.Command{ Use: "resume ", @@ -35,7 +38,7 @@ This command: 1. Checks out the specified branch 2. Finds the session ID from commits unique to this branch (not on main) 3. Restores the session log if it doesn't exist locally -4. Shows the command to resume the session +4. Shows the command to resume the session (or starts it with --run) If the branch doesn't exist locally but exists on origin, you'll be prompted to fetch it. @@ -48,21 +51,34 @@ most recent commit with a checkpoint. You'll be prompted to confirm resuming in if checkDisabledGuard(cmd.OutOrStdout()) { return nil } - return runResume(args[0], force) + return runResume(args[0], force, resolveAutoRun(cmd, autoRun)) }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Resume from older checkpoint without confirmation") + cmd.Flags().BoolVarP(&autoRun, "run", "r", false, "Start the restored session immediately") return cmd } -func runResume(branchName string, force bool) error { +func resolveAutoRun(cmd *cobra.Command, autoRun bool) bool { + if cmd.Flags().Changed("run") { + return autoRun + } + + s, err := LoadEntireSettings() + if err != nil { + return autoRun + } + return s.AutoRunResume +} + +func runResume(branchName string, force, autoRun bool) error { // Check if we're already on this branch currentBranch, err := GetCurrentBranch() if err == nil && currentBranch == branchName { // Already on the branch, skip checkout - return resumeFromCurrentBranch(branchName, force) + return resumeFromCurrentBranch(branchName, force, autoRun) } // Check if branch exists locally @@ -116,10 +132,10 @@ func runResume(branchName string, force bool) error { fmt.Fprintf(os.Stderr, "Switched to branch '%s'\n", branchName) } - return resumeFromCurrentBranch(branchName, force) + return resumeFromCurrentBranch(branchName, force, autoRun) } -func resumeFromCurrentBranch(branchName string, force bool) error { +func resumeFromCurrentBranch(branchName string, force, autoRun bool) error { repo, err := openRepository() if err != nil { return fmt.Errorf("not a git repository: %w", err) @@ -158,17 +174,17 @@ func resumeFromCurrentBranch(branchName string, force bool) error { metadataTree, err := strategy.GetMetadataBranchTree(repo) if err != nil { // No local metadata branch, check if remote has it - return checkRemoteMetadata(repo, checkpointID) + return checkRemoteMetadata(repo, checkpointID, force, autoRun) } // Look up metadata from sharded path metadata, err := strategy.ReadCheckpointMetadata(metadataTree, checkpointID.Path()) if err != nil { // Checkpoint exists in commit but no local metadata - check remote - return checkRemoteMetadata(repo, checkpointID) + return checkRemoteMetadata(repo, checkpointID, force, autoRun) } - return resumeSession(metadata.SessionID, checkpointID, force) + return resumeSession(metadata.SessionID, checkpointID, force, autoRun) } // branchCheckpointResult contains the result of searching for a checkpoint on a branch. @@ -324,7 +340,7 @@ func promptResumeFromOlderCheckpoint() (bool, error) { // checkRemoteMetadata checks if checkpoint metadata exists on origin/entire/checkpoints/v1 // and automatically fetches it if available. -func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID) error { +func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID, force, autoRun bool) error { // Try to get remote metadata branch tree remoteTree, err := strategy.GetRemoteMetadataBranchTree(repo) if err != nil { @@ -349,13 +365,13 @@ func checkRemoteMetadata(repo *git.Repository, checkpointID id.CheckpointID) err } // Now resume the session with the fetched metadata - return resumeSession(metadata.SessionID, checkpointID, false) + return resumeSession(metadata.SessionID, checkpointID, force, autoRun) } // resumeSession restores and displays the resume command for a specific session. // For multi-session checkpoints, restores ALL sessions and shows commands for each. // If force is false, prompts for confirmation when local logs have newer timestamps. -func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) error { +func resumeSession(sessionID string, checkpointID id.CheckpointID, force, autoRun bool) error { // Get the current agent (auto-detect or use default) ag, err := agent.Detect() if err != nil { @@ -404,7 +420,7 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e if err := restorer.RestoreLogsOnly(point, force); err != nil { // Fall back to single-session restore - return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force) + return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force, autoRun) } // Get checkpoint metadata to show all sessions @@ -413,18 +429,14 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e // Just show the primary session - graceful fallback agentSID := ag.ExtractAgentSessionID(sessionID) fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID)) - return nil //nolint:nilerr // Graceful fallback to single session + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session } metadataTree, err := strategy.GetMetadataBranchTree(repo) if err != nil { agentSID := ag.ExtractAgentSessionID(sessionID) fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID)) - return nil //nolint:nilerr // Graceful fallback to single session + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session } metadata, err := strategy.ReadCheckpointMetadata(metadataTree, checkpointID.Path()) @@ -432,9 +444,7 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e // Single session or can't read metadata - show standard single session output agentSID := ag.ExtractAgentSessionID(sessionID) fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSID)) - return nil //nolint:nilerr // Graceful fallback to single session + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSID), autoRun) //nolint:nilerr // Graceful fallback to single session } // Multi-session: show all resume commands with prompts @@ -471,17 +481,26 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e } } + if autoRun { + mostRecentSession := metadata.SessionIDs[len(metadata.SessionIDs)-1] + mostRecentAgentSID := ag.ExtractAgentSessionID(mostRecentSession) + mostRecentCmd := ag.FormatResumeCommand(mostRecentAgentSID) + fmt.Fprintf(os.Stderr, "\nStarting most recent session automatically:\n") + fmt.Fprintf(os.Stderr, " %s\n", mostRecentCmd) + return runResumeCommand(mostRecentCmd) + } + return nil } // Strategy doesn't support LogsOnlyRestorer, fall back to single session - return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force) + return resumeSingleSession(ctx, ag, sessionID, checkpointID, sessionDir, repoRoot, force, autoRun) } // resumeSingleSession restores a single session (fallback when multi-session restore fails). // Always overwrites existing session logs to ensure consistency with checkpoint state. // If force is false, prompts for confirmation when local log has newer timestamps. -func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, sessionDir, repoRoot string, force bool) error { +func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, checkpointID id.CheckpointID, sessionDir, repoRoot string, force, autoRun bool) error { agentSessionID := ag.ExtractAgentSessionID(sessionID) sessionLogPath := filepath.Join(sessionDir, agentSessionID+".jsonl") @@ -490,9 +509,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, slog.String("checkpoint_id", checkpointID.String()), ) fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID)) - return nil + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun) } logContent, _, err := checkpoint.LookupSessionLog(checkpointID) @@ -503,9 +520,7 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, slog.String("session_id", sessionID), ) fmt.Fprintf(os.Stderr, "Session '%s' found in commit trailer but session log not available\n", sessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID)) - return nil + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun) } logging.Error(ctx, "resume session failed", slog.String("checkpoint_id", checkpointID.String()), @@ -565,9 +580,35 @@ func resumeSingleSession(ctx context.Context, ag agent.Agent, sessionID string, fmt.Fprintf(os.Stderr, "Session restored to: %s\n", sessionLogPath) fmt.Fprintf(os.Stderr, "Session: %s\n", sessionID) + return showOrLaunchResumeCommand(ag.FormatResumeCommand(agentSessionID), autoRun) +} + +func showOrLaunchResumeCommand(resumeCmd string, autoRun bool) error { fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") - fmt.Fprintf(os.Stderr, " %s\n", ag.FormatResumeCommand(agentSessionID)) + fmt.Fprintf(os.Stderr, " %s\n", resumeCmd) + if !autoRun { + return nil + } + + fmt.Fprintf(os.Stderr, "\nStarting session automatically:\n") + fmt.Fprintf(os.Stderr, " %s\n", resumeCmd) + return runResumeCommand(resumeCmd) +} + +func runResumeCommand(resumeCmd string) error { + args := strings.Fields(resumeCmd) + if len(args) == 0 { + return errors.New("resume command is empty") + } + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run resume command %q: %w", resumeCmd, err) + } return nil } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index d451f0049..d0c4e87a3 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -174,7 +174,7 @@ func TestResumeFromCurrentBranch_NoCheckpoint(t *testing.T) { setupResumeTestRepo(t, tmpDir, false) // Run resumeFromCurrentBranch - should not error, just report no checkpoint found - err := resumeFromCurrentBranch("master", false) + err := resumeFromCurrentBranch("master", false, false) if err != nil { t.Errorf("resumeFromCurrentBranch() returned error for commit without checkpoint: %v", err) } @@ -230,7 +230,7 @@ func TestResumeFromCurrentBranch_WithEntireCheckpointTrailer(t *testing.T) { } // Run resumeFromCurrentBranch - err := resumeFromCurrentBranch("master", false) + err := resumeFromCurrentBranch("master", false, false) if err != nil { t.Errorf("resumeFromCurrentBranch() returned error: %v", err) } @@ -267,7 +267,7 @@ func TestRunResume_AlreadyOnBranch(t *testing.T) { } // Run resume on the branch we're already on - should skip checkout - err := runResume("feature", false) + err := runResume("feature", false, false) // Should not error (no session, but shouldn't error) if err != nil { t.Errorf("runResume() returned error when already on branch: %v", err) @@ -281,7 +281,7 @@ func TestRunResume_BranchDoesNotExist(t *testing.T) { setupResumeTestRepo(t, tmpDir, false) // Run resume on a branch that doesn't exist - err := runResume("nonexistent", false) + err := runResume("nonexistent", false, false) if err == nil { t.Error("runResume() expected error for nonexistent branch, got nil") } @@ -300,7 +300,7 @@ func TestRunResume_UncommittedChanges(t *testing.T) { } // Run resume - should fail due to uncommitted changes - err := runResume("feature", false) + err := runResume("feature", false, false) if err == nil { t.Error("runResume() expected error for uncommitted changes, got nil") } @@ -503,7 +503,7 @@ func TestCheckRemoteMetadata_MetadataExistsOnRemote(t *testing.T) { // Call checkRemoteMetadata - should find it on remote and attempt to fetch // In this test environment without a real origin remote, the fetch will fail // but it should return a SilentError (user-friendly error message already printed) - err = checkRemoteMetadata(repo, checkpointID) + err = checkRemoteMetadata(repo, checkpointID, false, false) if err == nil { t.Error("checkRemoteMetadata() should return SilentError when fetch fails") } else { @@ -528,7 +528,7 @@ func TestCheckRemoteMetadata_NoRemoteMetadataBranch(t *testing.T) { // Don't create any remote ref - simulating no remote entire/checkpoints/v1 // Call checkRemoteMetadata - should handle gracefully (no remote branch) - err := checkRemoteMetadata(repo, "nonexistent123") + err := checkRemoteMetadata(repo, "nonexistent123", false, false) if err != nil { t.Errorf("checkRemoteMetadata() returned error when no remote branch: %v", err) } @@ -563,7 +563,7 @@ func TestCheckRemoteMetadata_CheckpointNotOnRemote(t *testing.T) { } // Call checkRemoteMetadata with a DIFFERENT checkpoint ID (not on remote) - err = checkRemoteMetadata(repo, "abcd12345678") + err = checkRemoteMetadata(repo, "abcd12345678", false, false) if err != nil { t.Errorf("checkRemoteMetadata() returned error for missing checkpoint: %v", err) } @@ -624,7 +624,7 @@ func TestResumeFromCurrentBranch_FallsBackToRemote(t *testing.T) { // Run resumeFromCurrentBranch - should fall back to remote and attempt fetch // In this test environment without a real origin remote, the fetch will fail // but it should return a SilentError (user-friendly error message already printed) - err = resumeFromCurrentBranch("master", false) + err = resumeFromCurrentBranch("master", false, false) if err == nil { t.Error("resumeFromCurrentBranch() should return SilentError when fetch fails") } else { @@ -634,3 +634,95 @@ func TestResumeFromCurrentBranch_FallsBackToRemote(t *testing.T) { } } } + +func TestNewResumeCmd_HasAutoResumeFlag(t *testing.T) { + cmd := newResumeCmd() + + flag := cmd.Flags().Lookup("run") + if flag == nil { + t.Fatal("expected --run flag to be defined") + } + if flag.Shorthand != "r" { + t.Fatalf("expected --run shorthand to be -r, got -%s", flag.Shorthand) + } +} + +func TestRunResumeCommand(t *testing.T) { + t.Run("empty command", func(t *testing.T) { + err := runResumeCommand("") + if err == nil { + t.Fatal("expected error for empty command") + } + }) + + t.Run("valid command", func(t *testing.T) { + err := runResumeCommand("git --version") + if err != nil { + t.Fatalf("expected git --version to run successfully, got %v", err) + } + }) +} + +func TestResolveAutoRun(t *testing.T) { + tests := []struct { + name string + settingsJSON string + flagArg string + want bool + }{ + { + name: "default false when unset", + want: false, + }, + { + name: "reads autoRunResume from settings", + settingsJSON: `{"strategy":"manual-commit","autoRunResume":true}`, + want: true, + }, + { + name: "explicit --run=false overrides settings", + settingsJSON: `{"strategy":"manual-commit","autoRunResume":true}`, + flagArg: "--run=false", + want: false, + }, + { + name: "explicit --run overrides settings", + settingsJSON: `{"strategy":"manual-commit","autoRunResume":false}`, + flagArg: "--run", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + if tt.settingsJSON != "" { + if err := os.MkdirAll(filepath.Dir(EntireSettingsFile), 0o755); err != nil { + t.Fatalf("failed to create settings dir: %v", err) + } + if err := os.WriteFile(EntireSettingsFile, []byte(tt.settingsJSON), 0o644); err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + } + + cmd := newResumeCmd() + if tt.flagArg != "" { + if err := cmd.ParseFlags([]string{tt.flagArg}); err != nil { + t.Fatalf("failed to parse flags: %v", err) + } + } + + autoRun, err := cmd.Flags().GetBool("run") + if err != nil { + t.Fatalf("failed to read run flag: %v", err) + } + + got := resolveAutoRun(cmd, autoRun) + if got != tt.want { + t.Fatalf("resolveAutoRun() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 381c9993a..a56eff5c4 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -43,6 +43,10 @@ type EntireSettings struct { // Defaults to "info". LogLevel string `json:"log_level,omitempty"` + // AutoRunResume controls whether `entire resume` automatically runs the + // agent resume command when `--run` is not explicitly provided. + AutoRunResume bool `json:"autoRunResume,omitempty"` + // StrategyOptions contains strategy-specific configuration StrategyOptions map[string]any `json:"strategy_options,omitempty"` @@ -180,6 +184,15 @@ func mergeJSON(settings *EntireSettings, data []byte) error { } } + // Override autoRunResume if present + if autoRunResumeRaw, ok := raw["autoRunResume"]; ok { + var ar bool + if err := json.Unmarshal(autoRunResumeRaw, &ar); err != nil { + return fmt.Errorf("parsing autoRunResume field: %w", err) + } + settings.AutoRunResume = ar + } + // Merge strategy_options if present if optionsRaw, ok := raw["strategy_options"]; ok { var opts map[string]any diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index ad09bc57a..9fc3c2de6 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -58,6 +58,7 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { "enabled": true, "local_dev": false, "log_level": "debug", + "autoRunResume": true, "strategy_options": {"key": "value"}, "telemetry": true }` @@ -89,6 +90,9 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { if settings.LogLevel != "debug" { t.Errorf("expected log_level 'debug', got %q", settings.LogLevel) } + if !settings.AutoRunResume { + t.Error("expected autoRunResume to be true") + } if settings.Telemetry == nil || !*settings.Telemetry { t.Error("expected telemetry to be true") }