feat(v0.5.4): list SESSION column

Add a SESSION column to ctask list output, inserted to the right of
STATUS per spec. Values: "direct", "persistent", "stale", or em dash
for no session. Populated by SessionStatus, so each workspace adds at
most one short lease-file read — negligible for typical workspace
counts.

Archived workspaces always render as the em dash regardless of any
lease file present. The spec calls this a display simplification, not
a lifecycle invariant: ctask info still surfaces the raw session
state on archived workspaces because info is the diagnostic command.

ctask list --names is unchanged: one basename per line, no header,
no SESSION column. Verified by a regression test that asserts every
emitted line is bare-basename whitespace-free and contains none of
the SESSION tokens.
This commit is contained in:
2026-05-14 19:52:52 -04:00
parent e0e9cd764e
commit 0c8076aba9
2 changed files with 236 additions and 1 deletions
+43 -1
View File
@@ -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
}
+193
View File
@@ -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
}