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:
+43
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user