feat(v0.5.4): SessionStatus display-only helper

Add internal/session.SessionStatus(wsDir) that derives a display-only
view of the workspace session lease. Pure read of .ctask/session.json
with no tmux invocation, no PID liveness, no lock acquisition, and no
mutation of lease state.

States: none | active | stale. Mode defaults to "direct" when a
pre-v0.5.3 lease lacks the field. Malformed leases surface as stale
with a diagnostic so the present-but-broken case stays visible
instead of being silently classified as none.

Reused for ctask info and ctask list in subsequent commits. Lifecycle
code continues to use the existing primitives (ReadLease, IsFresh,
InspectLease).
This commit is contained in:
2026-05-14 19:47:24 -04:00
parent 1e9333254e
commit 7f2c43d599
2 changed files with 298 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
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 IsFresh(&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,
}
}