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,
}
}
+204
View File
@@ -0,0 +1,204 @@
package session
import (
"os"
"path/filepath"
"testing"
"time"
)
// writeLeaseAt is provided by lease_inspect_test.go in this package.
func TestSessionStatusNone(t *testing.T) {
ws := t.TempDir() // no .ctask/session.json
got := statusAt(ws, time.Now())
if got.State != SessionStateNone {
t.Errorf("State: got %q, want %q", got.State, SessionStateNone)
}
if got.Mode != "" {
t.Errorf("Mode: got %q, want empty", got.Mode)
}
if got.PID != 0 {
t.Errorf("PID: got %d, want 0", got.PID)
}
if got.Hostname != "" {
t.Errorf("Hostname: got %q, want empty", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusFreshLocal(t *testing.T) {
ws := t.TempDir()
now := time.Now().UTC().Truncate(time.Second)
writeLeaseAt(t, ws, &Lease{
SessionID: "fakehost-1234-20260514120000",
PID: 1234,
Hostname: "fakehost",
Username: "tester",
Agent: "claude",
Mode: "persistent",
StartedAt: now,
LastHeartbeatAt: now,
Terminal: "test",
})
got := statusAt(ws, now.Add(10*time.Second)) // well within StaleLeaseAfter
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Mode != "persistent" {
t.Errorf("Mode: got %q, want persistent", got.Mode)
}
if got.PID != 1234 {
t.Errorf("PID: got %d, want 1234", got.PID)
}
if got.Hostname != "fakehost" {
t.Errorf("Hostname: got %q, want fakehost", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusFreshRemote(t *testing.T) {
// SessionStatus is display-only; "remote" is just whatever hostname is
// recorded in the lease. The helper does not compare to currentHostname()
// — that comparison happens in the cmd display layer (info).
ws := t.TempDir()
now := time.Now().UTC().Truncate(time.Second)
writeLeaseAt(t, ws, &Lease{
PID: 4242,
Hostname: "some-other-host",
Mode: "direct",
StartedAt: now,
LastHeartbeatAt: now,
})
got := statusAt(ws, now.Add(10*time.Second))
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Hostname != "some-other-host" {
t.Errorf("Hostname: got %q, want some-other-host", got.Hostname)
}
if got.Mode != "direct" {
t.Errorf("Mode: got %q, want direct", got.Mode)
}
if got.PID != 4242 {
t.Errorf("PID: got %d, want 4242", got.PID)
}
}
func TestSessionStatusStale(t *testing.T) {
ws := t.TempDir()
heartbeat := time.Now().UTC().Add(-5 * time.Minute)
writeLeaseAt(t, ws, &Lease{
PID: 999,
Hostname: "oldhost",
Mode: "persistent",
StartedAt: heartbeat,
LastHeartbeatAt: heartbeat,
})
got := statusAt(ws, time.Now())
if got.State != SessionStateStale {
t.Errorf("State: got %q, want %q", got.State, SessionStateStale)
}
if got.Mode != "persistent" {
t.Errorf("Mode: got %q, want persistent", got.Mode)
}
if got.PID != 999 {
t.Errorf("PID: got %d, want 999", got.PID)
}
if got.Hostname != "oldhost" {
t.Errorf("Hostname: got %q, want oldhost", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusMalformed(t *testing.T) {
ws := t.TempDir()
if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(LeasePath(ws), []byte("{not valid json"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := statusAt(ws, time.Now())
if got.State != SessionStateStale {
t.Errorf("State: got %q, want %q", got.State, SessionStateStale)
}
if got.Mode != "" {
t.Errorf("Mode: got %q, want empty (malformed lease)", got.Mode)
}
if got.Diagnostic == "" {
t.Errorf("Diagnostic: want non-empty for malformed lease, got empty")
}
// PID/Hostname should be zero values when the lease can't be parsed —
// no partial info from a corrupt source.
if got.PID != 0 {
t.Errorf("PID: got %d, want 0 (malformed lease)", got.PID)
}
if got.Hostname != "" {
t.Errorf("Hostname: got %q, want empty (malformed lease)", got.Hostname)
}
}
func TestSessionStatusMissingMode(t *testing.T) {
// Leases written by pre-v0.5.3 ctask have no `mode` field. SessionStatus
// must default to "direct" so the display layer does not show a blank
// session mode for older workspaces.
ws := t.TempDir()
if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
now := time.Now().UTC().Truncate(time.Second)
// Write a lease JSON that omits the `mode` field entirely.
leaseJSON := []byte(`{
"session_id": "fakehost-1234-20260514120000",
"pid": 1234,
"hostname": "fakehost",
"username": "tester",
"agent": "claude",
"started_at": "` + now.Format(time.RFC3339) + `",
"last_heartbeat_at": "` + now.Format(time.RFC3339) + `",
"terminal": "test"
}`)
if err := os.WriteFile(LeasePath(ws), leaseJSON, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := statusAt(ws, now.Add(10*time.Second))
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Mode != "direct" {
t.Errorf("Mode: got %q, want direct (default for missing-mode lease)", got.Mode)
}
if got.PID != 1234 {
t.Errorf("PID: got %d, want 1234", got.PID)
}
}
func TestSessionStatusUsesProductionEntrypoint(t *testing.T) {
// Sanity check that the production entry point (which uses time.Now)
// returns SessionStateNone when the lease is missing — guards against
// a refactor that loses the SessionStatus -> statusAt forwarding.
ws := t.TempDir()
got := SessionStatus(ws)
if got.State != SessionStateNone {
t.Errorf("SessionStatus on empty workspace: got %q, want %q", got.State, SessionStateNone)
}
}