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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user