diff --git a/internal/session/status.go b/internal/session/status.go new file mode 100644 index 0000000..689bdc3 --- /dev/null +++ b/internal/session/status.go @@ -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, + } +} diff --git a/internal/session/status_test.go b/internal/session/status_test.go new file mode 100644 index 0000000..d4d9270 --- /dev/null +++ b/internal/session/status_test.go @@ -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) + } +}