diff --git a/internal/session/lease.go b/internal/session/lease.go index 7d0257e..dc33d0f 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -7,6 +7,7 @@ import ( "os" "os/user" "path/filepath" + "strings" "time" ) @@ -155,3 +156,76 @@ func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) { } return &l, nil } + +// FormatActiveWarning renders the human-readable warning printed when a +// fresh active lease is detected on session start. +func FormatActiveWarning(l *Lease, now time.Time) string { + startedAgo := now.Sub(l.StartedAt) + lastSeenAgo := now.Sub(l.LastHeartbeatAt) + + var b strings.Builder + b.WriteString("[ctask] This workspace has an active session:\n") + fmt.Fprintf(&b, " Session: %s\n", l.SessionID) + fmt.Fprintf(&b, " Host: %s\n", l.Hostname) + fmt.Fprintf(&b, " Agent: %s\n", l.Agent) + fmt.Fprintf(&b, " Started: %s (%s ago)\n", + l.StartedAt.Local().Format("2006-01-02 15:04"), + FormatAgo(startedAgo)) + fmt.Fprintf(&b, " Last seen: %s ago\n", FormatAgoShort(lastSeenAgo)) + b.WriteString("\n") + b.WriteString(" Opening a second session may cause conflicts.\n") + b.WriteString(" Continue anyway? [y/N] ") + return b.String() +} + +// FormatStaleCleanupNotice renders the single-line notice printed when a +// stale lease has been removed during session start. +func FormatStaleCleanupNotice(l *Lease, now time.Time) string { + return fmt.Sprintf("[ctask] Cleaned up stale session from %s (started %s, last seen %s ago)\n", + l.Hostname, + l.StartedAt.Local().Format("2006-01-02 15:04"), + FormatAgo(now.Sub(l.LastHeartbeatAt))) +} + +// FormatAgo renders a coarse "2h 15m" / "3m" / "45s" for the active warning. +func FormatAgo(d time.Duration) string { + if d < 0 { + d = 0 + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + if m > 0 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%ds", int(d.Seconds())) +} + +// FormatAgoShort renders "12 seconds" / "3 minutes" / "2 hours" for the +// Last seen line, which the spec shows with a noun suffix. +func FormatAgoShort(d time.Duration) string { + if d < 0 { + d = 0 + } + s := int(d.Seconds()) + if s < 60 { + if s == 1 { + return "1 second" + } + return fmt.Sprintf("%d seconds", s) + } + m := int(d.Minutes()) + if m < 60 { + if m == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", m) + } + h := int(d.Hours()) + if h == 1 { + return "1 hour" + } + return fmt.Sprintf("%d hours", h) +} diff --git a/internal/session/lease_test.go b/internal/session/lease_test.go index e86927c..3f4dc92 100644 --- a/internal/session/lease_test.go +++ b/internal/session/lease_test.go @@ -237,3 +237,56 @@ func TestCleanupStaleLeaseCorruptRemoves(t *testing.T) { t.Error("corrupt lease file should be removed") } } + +func TestFormatActiveWarning(t *testing.T) { + now := time.Date(2026, 4, 21, 16, 45, 10, 0, time.UTC) + started := now.Add(-2*time.Hour - 15*time.Minute) + lastSeen := now.Add(-12 * time.Second) + + lease := &Lease{ + SessionID: "warren-desktop-12345-20260421143022", + Hostname: "warren-desktop", + Agent: "claude", + StartedAt: started, + LastHeartbeatAt: lastSeen, + } + + got := FormatActiveWarning(lease, now) + + for _, want := range []string{ + "[ctask] This workspace has an active session:", + "warren-desktop-12345-20260421143022", + "Host:", + "warren-desktop", + "Agent:", + "claude", + "Started:", + "2h 15m ago", + "Last seen:", + "12 seconds ago", + "Continue anyway? [y/N]", + } { + if !strings.Contains(got, want) { + t.Errorf("FormatActiveWarning missing %q in:\n%s", want, got) + } + } +} + +func TestFormatStaleCleanupNoticeRenders(t *testing.T) { + now := time.Date(2026, 4, 21, 16, 45, 10, 0, time.UTC) + lease := &Lease{ + Hostname: "other-host", + StartedAt: now.Add(-2 * time.Hour), + LastHeartbeatAt: now.Add(-65 * time.Second), + } + got := FormatStaleCleanupNotice(lease, now) + for _, want := range []string{ + "[ctask] Cleaned up stale session", + "other-host", + "started", + } { + if !strings.Contains(got, want) { + t.Errorf("FormatStaleCleanupNotice missing %q in:\n%s", want, got) + } + } +}