feat(v0.4): add session Lease type with JSON round-trip
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user