diff --git a/cmd/info.go b/cmd/info.go index 27bb2d4..cc2579d 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" ) var infoCmd = &cobra.Command{ @@ -44,6 +45,8 @@ func runInfo(cmd *cobra.Command, args []string) error { fmt.Printf("Updated: %s\n", m.UpdatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Printf("Path: %s\n", ws.Path) + printSessionBlock(ws.Path, m.Slug) + if m.LaunchDir != "" { // Per spec amendment: stat the expected path directly instead of // inferring existence from ResolveLaunch's fallback behavior. info @@ -81,3 +84,60 @@ func runInfo(cmd *cobra.Command, args []string) error { return nil } + +// printSessionBlock renders the v0.5.4 Session block for `ctask info`. +// +// Layout (values align at column 14 across the block): +// +// Session: +// Mode: (omitted when malformed) +// Owner: [host / ]pid N (Active; "Last owner:" when stale) +// Attach: attach (Active + persistent only) +// Note: (stale or malformed only) +// +// The hostname is omitted from the Owner/Last-owner line when it matches +// the local machine, matching the spec's "omit when local" rule. +// +// All command-form text uses invocationName() so the hint reflects how +// the user actually invoked the binary (./ctask vs ctask.exe vs ctask). +// SessionStatus itself stays neutral and never builds a command string. +func printSessionBlock(wsPath, slug string) { + s := session.SessionStatus(wsPath) + fmt.Println() + + switch s.State { + case session.SessionStateNone: + fmt.Println("Session: none") + return + case session.SessionStateStale: + // Malformed lease: SessionStatus reports state=stale, mode empty, + // diagnostic set. Render only the Note so we don't pretend to + // know the mode/owner when the file couldn't be parsed. + if s.Diagnostic != "" { + fmt.Println("Session: stale") + fmt.Printf(" Note: %s\n", s.Diagnostic) + return + } + } + + fmt.Printf("Session: %s\n", s.State) + fmt.Printf(" Mode: %s\n", s.Mode) + + ownerValue := fmt.Sprintf("pid %d", s.PID) + if s.Hostname != "" && s.Hostname != session.CurrentHostname() { + ownerValue = s.Hostname + " / " + ownerValue + } + if s.State == session.SessionStateActive { + fmt.Printf(" Owner: %s\n", ownerValue) + } else { + fmt.Printf(" Last owner: %s\n", ownerValue) + } + + if s.State == session.SessionStateActive && s.Mode == "persistent" { + fmt.Printf(" Attach: %s attach %s\n", invocationName(), slug) + } + + if s.State == session.SessionStateStale { + fmt.Println(" Note: lease expired; workspace may be available") + } +} diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go new file mode 100644 index 0000000..ea487a7 --- /dev/null +++ b/cmd/info_session_test.go @@ -0,0 +1,234 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// withInvocationNameInfo pins invocationName() to "ctask" for the duration +// of the test so attach-hint substring assertions are stable across hosts. +// Mirrors the helper in persistent_test.go but kept local to avoid +// cross-test coupling. +func withInvocationNameInfo(t *testing.T, name string) { + t.Helper() + prev := invocationNameOverride + invocationNameOverride = name + t.Cleanup(func() { invocationNameOverride = prev }) +} + +// makeInfoSessionWorkspace writes a workspace under root with the given +// slug and returns the workspace directory path. The workspace metadata +// is minimal — info only needs a parsable task.yaml. +func makeInfoSessionWorkspace(t *testing.T, root, slug string) string { + t.Helper() + dirName := "2026-05-14_" + slug + wsDir := filepath.Join(root, "general", dirName) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestInfoNoSession(t *testing.T) { + root := t.TempDir() + makeInfoSessionWorkspace(t, root, "no-sess") + + out, err := runInfoCapture(t, root, "no-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: none") { + t.Errorf("expected 'Session: none' in output:\n%s", out) + } + for _, mustNot := range []string{" Mode:", " Owner:", " Attach:", " Note:"} { + if strings.Contains(out, mustNot) { + t.Errorf("none-state info should not contain %q:\n%s", mustNot, out) + } + } +} + +func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-persist") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 12345, + Hostname: session.CurrentHostname(), + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-persist") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: active", + "Mode: persistent", + "Owner: pid 12345", // local host -> hostname omitted + "Attach: ctask attach active-persist", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } +} + +func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "active-direct") + + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 8888, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "active-direct") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: active") { + t.Errorf("expected 'Session: active' in output:\n%s", out) + } + if !strings.Contains(out, "Mode: direct") { + t.Errorf("expected 'Mode: direct' in output:\n%s", out) + } + if strings.Contains(out, "Attach:") { + t.Errorf("direct active session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsRemoteHostnameInOwnerLine(t *testing.T) { + withInvocationNameInfo(t, "ctask") + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "remote-active") + + other := "some-other-host-not-this-one" + if other == session.CurrentHostname() { + other = "different-" + other + } + now := time.Now().UTC().Truncate(time.Second) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 7777, + Hostname: other, + Mode: "persistent", + StartedAt: now, + LastHeartbeatAt: now, + }) + + out, err := runInfoCapture(t, root, "remote-active") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + wantOwner := "Owner: " + other + " / pid 7777" + if !strings.Contains(out, wantOwner) { + t.Errorf("expected %q in output:\n%s", wantOwner, out) + } +} + +func TestInfoShowsStaleSessionWithLastOwner(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "stale-sess") + + heartbeat := time.Now().UTC().Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, wsDir, &session.Lease{ + PID: 4242, + Hostname: session.CurrentHostname(), + Mode: "direct", + StartedAt: heartbeat, + LastHeartbeatAt: heartbeat, + }) + + out, err := runInfoCapture(t, root, "stale-sess") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + for _, want := range []string{ + "Session: stale", + "Mode: direct", + "Last owner: pid 4242", + "Note: lease expired; workspace may be available", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output:\n%s", want, out) + } + } + if strings.Contains(out, "Attach:") { + t.Errorf("stale session must NOT show Attach hint:\n%s", out) + } +} + +func TestInfoShowsMalformedLeaseAsStaleWithDiagnostic(t *testing.T) { + root := t.TempDir() + wsDir := makeInfoSessionWorkspace(t, root, "broken-lease") + + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), []byte("{not json"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + out, err := runInfoCapture(t, root, "broken-lease") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Session: stale") { + t.Errorf("malformed-lease info should show 'Session: stale':\n%s", out) + } + if !strings.Contains(out, "Note: lease exists but could not be read") { + t.Errorf("malformed-lease info should surface the diagnostic:\n%s", out) + } + // Mode/Owner/Attach are deliberately suppressed for the malformed case + // — we don't have a parsed lease to read those values from. The + // indented " " prefix scopes the assertion to the session block so + // we don't false-positive on the workspace-metadata "Mode: local" + // header line. + for _, mustNot := range []string{" Mode:", " Owner:", " Last owner:", " Attach:"} { + if strings.Contains(out, mustNot) { + t.Errorf("malformed-lease info should not contain %q:\n%s", mustNot, out) + } + } +} + +// writeLeaseAtForCmdTest writes a Lease as JSON to wsDir's lease path. +// Local to the cmd package — internal/session has its own writeLeaseAt +// that we cannot reach across packages. +func writeLeaseAtForCmdTest(t *testing.T, wsDir string, l *session.Lease) { + t.Helper() + if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(session.LeasePath(wsDir), data, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} diff --git a/internal/session/lease.go b/internal/session/lease.go index 4da4e83..b12efa2 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -81,6 +81,12 @@ func currentHostname() string { return h } +// CurrentHostname is the exported form of currentHostname for callers in +// cmd/ that need to compare a lease's recorded hostname against the local +// machine (e.g., info's Owner-line "omit when local" rule). Keeps a +// single source of truth for the unknown-fallback semantics. +func CurrentHostname() string { return currentHostname() } + // currentTerminal is a best-effort terminal identifier based on common env vars. // Returns "unknown" if none are set. func currentTerminal() string {