diff --git a/internal/cmd/ps.go b/internal/cmd/ps.go index dc81244..f7b3885 100644 --- a/internal/cmd/ps.go +++ b/internal/cmd/ps.go @@ -47,15 +47,25 @@ func runPs(cmd *cobra.Command, args []string) error { // Create tabwriter for aligned output w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "ID\tPROJECT\tSTATUS\tSTARTED") - _, _ = fmt.Fprintln(w, "--\t-------\t------\t-------") + _, _ = fmt.Fprintln(w, "ID\tPROJECT\tSTATUS\tTIMEOUT\tEXIT REASON\tSTARTED") + _, _ = fmt.Fprintln(w, "--\t-------\t------\t-------\t-----------\t-------") for _, session := range sessions { started := session.StartedAt.Format("2006-01-02 15:04:05") - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + timeout := session.Timeout + if timeout == "" { + timeout = "-" + } + exitReason := session.ExitReason + if exitReason == "" { + exitReason = "-" + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", session.ID, session.ProjectDir, session.Status, + timeout, + exitReason, started, ) } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index c97b171..3ecc244 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sync/atomic" "time" "github.com/faize-ai/faize/internal/changeset" @@ -269,6 +270,17 @@ func runStart(cmd *cobra.Command, args []string) error { } Debug("VM started successfully") + // Timeout enforcement: stop the VM when the timeout expires + var timedOut atomic.Bool + if timeoutDuration > 0 { + timer := time.AfterFunc(timeoutDuration, func() { + timedOut.Store(true) + fmt.Printf("\nSession timeout (%s) reached. Stopping...\n", timeoutDuration) + _ = manager.Stop(sess.ID) + }) + defer timer.Stop() + } + // Take pre-snapshots of rw mounts for change tracking type mountSnapshot struct { source string @@ -312,8 +324,28 @@ func runStart(cmd *cobra.Command, args []string) error { // Attach to console — session stops when we return fmt.Println("Attaching to console... (~. to detach)") - if err := manager.Attach(sess.ID); err != nil && !errors.Is(err, vm.ErrUserDetach) { - return fmt.Errorf("console error: %w", err) + attachErr := manager.Attach(sess.ID) + if attachErr != nil && !errors.Is(attachErr, vm.ErrUserDetach) { + return fmt.Errorf("console error: %w", attachErr) + } + + // Determine exit reason and persist session metadata + exitReason := "normal" + if timedOut.Load() { + exitReason = "timeout" + } else if errors.Is(attachErr, vm.ErrUserDetach) { + exitReason = "detach" + } + now := time.Now() + sess.Timeout = startTimeout + sess.StoppedAt = &now + sess.ExitReason = exitReason + sess.Status = "stopped" + store, storeErr := session.NewStore() + if storeErr == nil { + if saveErr := store.Save(sess); saveErr != nil { + Debug("Failed to save session: %v", saveErr) + } } // Post-session change tracking diff --git a/internal/session/types.go b/internal/session/types.go index 5eb932c..59083e2 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -12,13 +12,16 @@ type VMMount struct { // Session represents a VM session with its configuration type Session struct { - ID string `json:"id"` - ProjectDir string `json:"project_dir"` - Mounts []VMMount `json:"mounts"` - Network []string `json:"network"` - CPUs int `json:"cpus"` - Memory string `json:"memory"` - Status string `json:"status"` // "created", "running", "stopped" - StartedAt time.Time `json:"started_at"` - ClaudeMode bool `json:"claude_mode"` // Whether using Claude rootfs + ID string `json:"id"` + ProjectDir string `json:"project_dir"` + Mounts []VMMount `json:"mounts"` + Network []string `json:"network"` + CPUs int `json:"cpus"` + Memory string `json:"memory"` + Status string `json:"status"` // "created", "running", "stopped" + StartedAt time.Time `json:"started_at"` + ClaudeMode bool `json:"claude_mode"` // Whether using Claude rootfs + Timeout string `json:"timeout,omitempty"` // e.g., "2h" - human-readable timeout + StoppedAt *time.Time `json:"stopped_at,omitempty"` + ExitReason string `json:"exit_reason,omitempty"` // "normal" | "timeout" | "detach" | "killed" }