package session import ( "encoding/json" "errors" "fmt" "os" "os/user" "path/filepath" "strings" "time" ) // Lease is the on-disk format of .ctask/session.json. It identifies the ctask // process holding this workspace open and records a heartbeat timestamp so // that crashed sessions can be detected and cleaned up by a later session. type Lease struct { SessionID string `json:"session_id"` PID int `json:"pid"` Hostname string `json:"hostname"` Username string `json:"username"` Agent string `json:"agent"` Mode string `json:"mode"` StartedAt time.Time `json:"started_at"` LastHeartbeatAt time.Time `json:"last_heartbeat_at"` Terminal string `json:"terminal"` } // LeasePath returns the absolute path of the lease file for the given workspace. func LeasePath(wsDir string) string { return filepath.Join(wsDir, ".ctask", "session.json") } // WriteLease marshals a lease to JSON and writes it to path. // The .ctask/ directory is created if needed. func WriteLease(path string, l *Lease) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(l, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } // ReadLease reads and parses a lease file. A missing file returns the // stdlib os.ErrNotExist (via os.ReadFile), which callers should detect // with errors.Is. func ReadLease(path string) (*Lease, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var l Lease if err := json.Unmarshal(data, &l); err != nil { return nil, fmt.Errorf("parsing lease: %w", err) } return &l, nil } // currentUsername returns the current OS username, falling back to "unknown" // on error. Never panics. func currentUsername() string { u, err := user.Current() if err != nil || u == nil { return "unknown" } if u.Username == "" { return "unknown" } return u.Username } // currentHostname returns the current hostname, falling back to "unknown". func currentHostname() string { h, err := os.Hostname() if err != nil || h == "" { return "unknown" } return h } // currentTerminal is a best-effort terminal identifier based on common env vars. // Returns "unknown" if none are set. func currentTerminal() string { for _, k := range []string{"TERM_PROGRAM", "WT_SESSION", "TERM"} { if v := os.Getenv(k); v != "" { return v } } return "unknown" } // NewSessionID returns a session identifier in the format // "--" using the given time in UTC. func NewSessionID(hostname string, pid int, t time.Time) string { return fmt.Sprintf("%s-%d-%s", hostname, pid, t.UTC().Format("20060102150405")) } // NewLease constructs a fresh lease for the current process. startedAt is used // for both StartedAt and LastHeartbeatAt. func NewLease(startedAt time.Time, agent, mode string) *Lease { hostname := currentHostname() pid := os.Getpid() return &Lease{ SessionID: NewSessionID(hostname, pid, startedAt), PID: pid, Hostname: hostname, Username: currentUsername(), Agent: agent, Mode: mode, StartedAt: startedAt.UTC().Truncate(time.Second), LastHeartbeatAt: startedAt.UTC().Truncate(time.Second), Terminal: currentTerminal(), } } // IsFresh returns true if the lease's last heartbeat is within threshold // of now. A lease with a zero LastHeartbeatAt is treated as stale. func IsFresh(l *Lease, now time.Time, threshold time.Duration) bool { if l == nil || l.LastHeartbeatAt.IsZero() { return false } return now.Sub(l.LastHeartbeatAt) <= threshold } // CleanupStaleLease inspects the lease at path: // - missing file: no-op, returns (nil, nil) // - corrupt / unparseable: removes the file, returns (nil, nil) // - stale (heartbeat older than staleAfter): removes the file, returns the parsed lease // - fresh: no-op, returns (nil, nil) // // An I/O error during removal is returned to the caller along with the // parsed lease (if any), so the caller can log it explicitly. func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var l Lease if jsonErr := json.Unmarshal(data, &l); jsonErr != nil { if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { return nil, rmErr } return nil, nil } if IsFresh(&l, time.Now(), staleAfter) { return nil, nil } if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { return &l, rmErr } return &l, nil } // FormatActiveWarning renders the human-readable warning printed when a // fresh active lease is detected on session start. func FormatActiveWarning(l *Lease, now time.Time) string { startedAgo := now.Sub(l.StartedAt) lastSeenAgo := now.Sub(l.LastHeartbeatAt) var b strings.Builder b.WriteString("[ctask] This workspace has an active session:\n") fmt.Fprintf(&b, " Session: %s\n", l.SessionID) fmt.Fprintf(&b, " Host: %s\n", l.Hostname) fmt.Fprintf(&b, " Agent: %s\n", l.Agent) fmt.Fprintf(&b, " Started: %s (%s ago)\n", l.StartedAt.Local().Format("2006-01-02 15:04"), FormatAgo(startedAgo)) fmt.Fprintf(&b, " Last seen: %s ago\n", FormatAgoShort(lastSeenAgo)) b.WriteString("\n") b.WriteString(" Opening a second session may cause conflicts.\n") b.WriteString(" Continue anyway? [y/N] ") return b.String() } // FormatStaleCleanupNotice renders the single-line notice printed when a // stale lease has been removed during session start. func FormatStaleCleanupNotice(l *Lease, now time.Time) string { return fmt.Sprintf("[ctask] Cleaned up stale session from %s (started %s, last seen %s ago)\n", l.Hostname, l.StartedAt.Local().Format("2006-01-02 15:04"), FormatAgo(now.Sub(l.LastHeartbeatAt))) } // FormatAgo renders a coarse "2h 15m" / "3m" / "45s" for the active warning. func FormatAgo(d time.Duration) string { if d < 0 { d = 0 } h := int(d.Hours()) m := int(d.Minutes()) % 60 if h > 0 { return fmt.Sprintf("%dh %dm", h, m) } if m > 0 { return fmt.Sprintf("%dm", m) } return fmt.Sprintf("%ds", int(d.Seconds())) } // FormatAgoShort renders "12 seconds" / "3 minutes" / "2 hours" for the // Last seen line, which the spec shows with a noun suffix. func FormatAgoShort(d time.Duration) string { if d < 0 { d = 0 } s := int(d.Seconds()) if s < 60 { if s == 1 { return "1 second" } return fmt.Sprintf("%d seconds", s) } m := int(d.Minutes()) if m < 60 { if m == 1 { return "1 minute" } return fmt.Sprintf("%d minutes", m) } h := int(d.Hours()) if h == 1 { return "1 hour" } return fmt.Sprintf("%d hours", h) }