From 305a9d7c23561a8060aa0422de9f0a79b66c25d7 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Tue, 21 Apr 2026 17:02:23 -0400 Subject: [PATCH] feat(v0.4): add session Lease type with JSON round-trip --- internal/session/lease.go | 91 +++++++++++++++++++++++++++++ internal/session/lease_test.go | 102 +++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 internal/session/lease.go create mode 100644 internal/session/lease_test.go diff --git a/internal/session/lease.go b/internal/session/lease.go new file mode 100644 index 0000000..f2fa40f --- /dev/null +++ b/internal/session/lease.go @@ -0,0 +1,91 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + "os/user" + "path/filepath" + "time" +) + +// Lease is the on-disk format of .ctask/session.json. It identifies the ctask +// process holding this workspace open and records a heartbeat timestamp so +// that crashed sessions can be detected and cleaned up by a later session. +type Lease struct { + SessionID string `json:"session_id"` + PID int `json:"pid"` + Hostname string `json:"hostname"` + Username string `json:"username"` + Agent string `json:"agent"` + Mode string `json:"mode"` + StartedAt time.Time `json:"started_at"` + LastHeartbeatAt time.Time `json:"last_heartbeat_at"` + Terminal string `json:"terminal"` +} + +// LeasePath returns the absolute path of the lease file for the given workspace. +func LeasePath(wsDir string) string { + return filepath.Join(wsDir, ".ctask", "session.json") +} + +// WriteLease marshals a lease to JSON and writes it to path. +// The .ctask/ directory is created if needed. +func WriteLease(path string, l *Lease) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ReadLease reads and parses a lease file. A missing file returns the +// stdlib os.ErrNotExist (via os.ReadFile), which callers should detect +// with errors.Is. +func ReadLease(path string) (*Lease, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var l Lease + if err := json.Unmarshal(data, &l); err != nil { + return nil, fmt.Errorf("parsing lease: %w", err) + } + return &l, nil +} + +// currentUsername returns the current OS username, falling back to "unknown" +// on error. Never panics. +func currentUsername() string { + u, err := user.Current() + if err != nil || u == nil { + return "unknown" + } + if u.Username == "" { + return "unknown" + } + return u.Username +} + +// currentHostname returns the current hostname, falling back to "unknown". +func currentHostname() string { + h, err := os.Hostname() + if err != nil || h == "" { + return "unknown" + } + return h +} + +// currentTerminal is a best-effort terminal identifier based on common env vars. +// Returns "unknown" if none are set. +func currentTerminal() string { + for _, k := range []string{"TERM_PROGRAM", "WT_SESSION", "TERM"} { + if v := os.Getenv(k); v != "" { + return v + } + } + return "unknown" +} diff --git a/internal/session/lease_test.go b/internal/session/lease_test.go new file mode 100644 index 0000000..8394ac2 --- /dev/null +++ b/internal/session/lease_test.go @@ -0,0 +1,102 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLeaseRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + + now := time.Date(2026, 4, 21, 14, 30, 22, 0, time.UTC) + want := &Lease{ + SessionID: "warren-desktop-12345-20260421143022", + PID: 12345, + Hostname: "warren-desktop", + Username: "warren", + Agent: "claude", + Mode: "local", + StartedAt: now, + LastHeartbeatAt: now.Add(30 * time.Second), + Terminal: "Windows Terminal", + } + + if err := WriteLease(path, want); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + got, err := ReadLease(path) + if err != nil { + t.Fatalf("ReadLease: %v", err) + } + + if got.SessionID != want.SessionID { + t.Errorf("SessionID: got %q, want %q", got.SessionID, want.SessionID) + } + if got.PID != want.PID { + t.Errorf("PID: got %d, want %d", got.PID, want.PID) + } + if !got.StartedAt.Equal(want.StartedAt) { + t.Errorf("StartedAt: got %v, want %v", got.StartedAt, want.StartedAt) + } + if !got.LastHeartbeatAt.Equal(want.LastHeartbeatAt) { + t.Errorf("LastHeartbeatAt: got %v, want %v", got.LastHeartbeatAt, want.LastHeartbeatAt) + } +} + +func TestLeaseJSONFields(t *testing.T) { + now := time.Date(2026, 4, 21, 14, 30, 22, 0, time.UTC) + lease := &Lease{ + SessionID: "h-1-x", + PID: 1, + Hostname: "h", + Username: "u", + Agent: "claude", + Mode: "local", + StartedAt: now, + LastHeartbeatAt: now, + Terminal: "t", + } + data, err := json.Marshal(lease) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + s := string(data) + for _, want := range []string{ + `"session_id"`, `"pid"`, `"hostname"`, `"username"`, + `"agent"`, `"mode"`, `"started_at"`, `"last_heartbeat_at"`, `"terminal"`, + } { + if !strings.Contains(s, want) { + t.Errorf("JSON missing field %s: %s", want, s) + } + } +} + +func TestReadLeaseCorruptReturnsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + if err := os.WriteFile(path, []byte("{not json"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := ReadLease(path); err == nil { + t.Error("expected error reading corrupt lease") + } +} + +func TestReadLeaseMissingFileReturnsErrNotExist(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + _, err := ReadLease(path) + if err == nil { + t.Fatal("expected error for missing lease") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected os.ErrNotExist, got %v", err) + } +}