diff --git a/cmd/list.go b/cmd/list.go index cc348d3..e888ac2 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -100,8 +101,9 @@ func runList(cmd *cobra.Command, args []string) error { date = dirName[:10] } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ws.Meta.Status, + sessionColumn(ws.Path, ws.Meta.Status), workspace.EffectiveType(ws.Meta), ws.Meta.Mode, ws.Meta.Category, @@ -113,3 +115,43 @@ func runList(cmd *cobra.Command, args []string) error { return nil } + +// noSessionDisplay is the placeholder shown in the SESSION column when +// no session lease is present (or for archived workspaces, which are +// always rendered as no-session for display simplicity per v0.5.4 spec). +// Em dash matches the spec's example output. +const noSessionDisplay = "—" // — + +// sessionColumn returns the SESSION column value for ctask list. +// +// Archived workspaces always render as the em-dash placeholder — the +// spec calls this a display simplification, not a lifecycle invariant. +// Archive guards against active sessions, but a crash or manual file +// manipulation could theoretically leave a lease behind; ctask info +// will surface that diagnostic, ctask list will not. +// +// Active workspaces map SessionStatus to a single token: +// - state=none -> em dash +// - state=stale -> "stale" +// - state=active -> the lease's mode ("direct" or "persistent") +// +// SessionStatus reads only the lease file — no tmux invocation, no PID +// liveness — so this adds at most one short file read per workspace. +func sessionColumn(wsPath, wsStatus string) string { + if wsStatus == "archived" { + return noSessionDisplay + } + s := session.SessionStatus(wsPath) + switch s.State { + case session.SessionStateNone: + return noSessionDisplay + case session.SessionStateStale: + return "stale" + case session.SessionStateActive: + if s.Mode == "" { + return "active" + } + return s.Mode + } + return noSessionDisplay +} diff --git a/cmd/list_session_test.go b/cmd/list_session_test.go new file mode 100644 index 0000000..1ef27a3 --- /dev/null +++ b/cmd/list_session_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// makeListSessionWorkspace writes a workspace beneath root and returns +// the workspace directory. Mirrors makeInfoSessionWorkspace but allows +// the caller to control category, status, and slug for list-fixture +// scenarios. +func makeListSessionWorkspace(t *testing.T, root, category, dirName, slug, status string) string { + t.Helper() + wsDir := filepath.Join(root, category, 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: status, Category: category, Type: "task", + Mode: "local", Agent: "claude", + } + if status == "archived" { + meta.ArchivedAt = &now + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +func TestListSessionColumnShowsModeAndStaleAndDash(t *testing.T) { + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + directWS := makeListSessionWorkspace(t, root, "general", "2026-05-13_direct-ws", "direct-ws", "active") + staleWS := makeListSessionWorkspace(t, root, "general", "2026-05-12_stale-ws", "stale-ws", "active") + makeListSessionWorkspace(t, root, "general", "2026-05-11_idle-ws", "idle-ws", "active") + + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + writeLeaseAtForCmdTest(t, directWS, &session.Lease{ + PID: 2, Hostname: host, Mode: "direct", + StartedAt: now, LastHeartbeatAt: now, + }) + heartbeat := now.Add(-10 * time.Minute) + writeLeaseAtForCmdTest(t, staleWS, &session.Lease{ + PID: 3, Hostname: host, Mode: "direct", + StartedAt: heartbeat, LastHeartbeatAt: heartbeat, + }) + + out, _, err := runListCapture(t, root, false, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + + type expectation struct { + slug string + session string + } + for _, exp := range []expectation{ + {"persist-ws", "persistent"}, + {"direct-ws", "direct"}, + {"stale-ws", "stale"}, + {"idle-ws", noSessionDisplay}, + } { + line := findLineContaining(out, exp.slug) + if line == "" { + t.Errorf("output missing line for slug %q:\n%s", exp.slug, out) + continue + } + if !columnTokenPresent(line, exp.session) { + t.Errorf("slug %q line should contain session token %q, got line %q", exp.slug, exp.session, line) + } + } +} + +func TestListSessionColumnArchivedAlwaysDash(t *testing.T) { + // Archived workspaces show the em-dash placeholder in the SESSION + // column even if a lease file exists. ctask info still surfaces the + // raw lease state for diagnostic purposes; list keeps the simpler + // view. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + + archivedWS := makeListSessionWorkspace(t, root, "general", "2026-05-10_archived-ws", "archived-ws", "archived") + writeLeaseAtForCmdTest(t, archivedWS, &session.Lease{ + PID: 99, Hostname: session.CurrentHostname(), Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + + out, _, err := runListCapture(t, root, true, false, false) + if err != nil { + t.Fatalf("runList --all: %v", err) + } + + line := findLineContaining(out, "archived-ws") + if line == "" { + t.Fatalf("output missing line for archived-ws:\n%s", out) + } + if !columnTokenPresent(line, noSessionDisplay) { + t.Errorf("archived-ws line should contain em-dash session token, got %q", line) + } + if columnTokenPresent(line, "persistent") { + t.Errorf("archived-ws line must NOT show 'persistent' even with a lease file present, got %q", line) + } +} + +func TestListNamesUnchangedHasNoSessionColumn(t *testing.T) { + // Spec invariant: ctask list --names is machine-readable, one + // basename per line, no session column. Adding the SESSION column + // to the formatted view must not leak into --names output. + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + host := session.CurrentHostname() + + persistWS := makeListSessionWorkspace(t, root, "general", "2026-05-14_persist-ws", "persist-ws", "active") + writeLeaseAtForCmdTest(t, persistWS, &session.Lease{ + PID: 1, Hostname: host, Mode: "persistent", + StartedAt: now, LastHeartbeatAt: now, + }) + makeListSessionWorkspace(t, root, "general", "2026-05-13_idle-ws", "idle-ws", "active") + + out, err := runListNamesCapture(t, root, false) + if err != nil { + t.Fatalf("runList --names: %v", err) + } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + for _, line := range lines { + if line == "" { + continue + } + // Each line must be a bare basename, no whitespace, no session token. + if strings.ContainsAny(line, " \t") { + t.Errorf("--names output line must not contain whitespace, got %q", line) + } + for _, tok := range []string{"persistent", "direct", "stale", noSessionDisplay} { + if strings.Contains(line, tok) { + t.Errorf("--names output line %q must not contain session token %q", line, tok) + } + } + } + + // And the basenames we expect must still be there. + for _, want := range []string{"2026-05-14_persist-ws", "2026-05-13_idle-ws"} { + found := false + for _, line := range lines { + if line == want { + found = true + break + } + } + if !found { + t.Errorf("--names output missing %q:\n%s", want, out) + } + } +} + +// findLineContaining returns the first line of out that contains substr, +// or "" if no such line exists. +func findLineContaining(out, substr string) string { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, substr) { + return line + } + } + return "" +} + +// columnTokenPresent reports whether token appears in line as a +// whitespace-separated field. This avoids substring false positives like +// "stale" matching inside another token. +func columnTokenPresent(line, token string) bool { + for _, f := range strings.Fields(line) { + if f == token { + return true + } + } + return false +}