package session import ( "encoding/json" "errors" "os" "time" ) // SessionState is the display-only classification surfaced by SessionStatus. type SessionState string const ( // SessionStateNone: no lease file is present. SessionStateNone SessionState = "none" // SessionStateActive: lease file is present and fresh (heartbeat within StaleLeaseAfter). SessionStateActive SessionState = "active" // SessionStateStale: lease file is present but stale, or present-and-malformed. SessionStateStale SessionState = "stale" ) // Status is the derived display-only view of a workspace's session lease. // // It is intentionally narrower than InspectLease/LeaseState: callers that // need behavioral decisions (adoption, dispatch) must keep using the // existing primitives. Status exists for `ctask info` and `ctask list` to // surface session visibility without touching tmux, PID liveness, or any // lock state. type Status struct { State SessionState Mode string // "direct" | "persistent" | "" when malformed PID int Hostname string Diagnostic string // human-readable note for the malformed case; empty otherwise } // SessionStatus returns the display-only session summary for wsDir. // // It performs only one file read (.ctask/session.json) and never invokes // tmux, checks PID liveness, modifies lease state, acquires locks, or // otherwise mutates the workspace. PID liveness is intentionally deferred // to v0.6's lazy-cleanup redesign, where it will have behavioral // consequences; building it display-only here would mean building it // twice. // // Display-only contract: do NOT call SessionStatus from lifecycle or // adoption code. The "missing Mode defaults to direct" rule and the // malformed-lease "stale" classification are display choices, not // behavioral truths. Use ReadLease / IsFresh / InspectLease for // lifecycle decisions. func SessionStatus(wsDir string) Status { return statusAt(wsDir, time.Now()) } // statusAt is the test entry point with an injected clock. Production // code goes through SessionStatus. func statusAt(wsDir string, now time.Time) Status { data, err := os.ReadFile(LeasePath(wsDir)) if errors.Is(err, os.ErrNotExist) { return Status{State: SessionStateNone} } if err != nil { return Status{ State: SessionStateStale, Diagnostic: "lease exists but could not be read", } } var l Lease if jsonErr := json.Unmarshal(data, &l); jsonErr != nil { return Status{ State: SessionStateStale, Diagnostic: "lease exists but could not be read", } } state := SessionStateStale if !IsStale(&l, now, StaleLeaseAfter) { state = SessionStateActive } // Pre-v0.5.3 leases predate the mode field; treat them as direct so // `info` and `list` render a meaningful value rather than blank. mode := l.Mode if mode == "" { mode = "direct" } return Status{ State: state, Mode: mode, PID: l.PID, Hostname: l.Hostname, } }