Merge branch 'feat/v0.5.4-session-visibility-polish' into main

This commit is contained in:
2026-05-14 20:07:38 -04:00
13 changed files with 1678 additions and 462 deletions
+9 -1
View File
@@ -153,9 +153,17 @@ func directModeTmuxHint(opts WorkspaceEntryOptions) string {
if !shell.HasSession(tmuxPath, sessionName) {
return ""
}
return formatDirectModeTmuxHint(opts.WsMeta.Slug)
}
// formatDirectModeTmuxHint builds the hint string itself, with no tmux
// or filesystem checks. Split out so unit tests can verify that the
// command-form line uses invocationName() without needing a live tmux
// session set up against a real workspace.
func formatDirectModeTmuxHint(slug string) string {
return fmt.Sprintf(
"Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s",
invocationName(), opts.WsMeta.Slug)
invocationName(), slug)
}
func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error {
+60
View File
@@ -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: <state>
// Mode: <mode> (omitted when malformed)
// Owner: [host / ]pid N (Active; "Last owner:" when stale)
// Attach: <bin> attach <slug> (Active + persistent only)
// Note: <diagnostic> (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")
}
}
+225
View File
@@ -0,0 +1,225 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/warrenronsiek/ctask/internal/session"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// withInvocationName is provided by persistent_test.go in this package.
// 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) {
withInvocationName(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) {
withInvocationName(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) {
withInvocationName(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)
}
}
+51
View File
@@ -0,0 +1,51 @@
package cmd
import (
"strings"
"testing"
)
// v0.5.4 invocation-name audit: spec §2 codifies that command-form
// hints use invocationName() while product-identity references stay
// literal. The pre-existing tests already cover most paths individually
// (resume restore hint in resume_test.go, persistent bypass hints in
// persistent_test.go). These tests pin down the remaining piece — the
// Layer-1 active-session prompt's attach hint — and re-assert the
// resume restore-hint contract against an explicitly non-canonical
// invocation name so a regression that hard-codes "ctask" anywhere
// upstream of the hint format will fail loudly.
func TestInvocationNameInActiveSessionPrompt(t *testing.T) {
// directModeTmuxHint composes the Layer-1 prompt's attach suggestion
// from formatDirectModeTmuxHint. The format-only helper is the right
// surface to test: it isolates the rendering decision from the tmux
// presence checks (which are environment-dependent) but exercises the
// exact string the user will see.
withInvocationName(t, "my-bin")
got := formatDirectModeTmuxHint("demo-ws")
if !strings.Contains(got, "my-bin attach demo-ws") {
t.Errorf("Layer-1 attach hint should use invocation name; got:\n%s", got)
}
if strings.Contains(got, "ctask attach demo-ws") {
t.Errorf("Layer-1 attach hint must NOT hard-code 'ctask attach' when invocation name differs; got:\n%s", got)
}
}
func TestInvocationNameInRestoreHintNonCanonical(t *testing.T) {
// Complement to TestResumeArchivedShowsRestoreHint in resume_test.go,
// which pins invocationName to "ctask" — that protects against test
// binary noise but cannot detect a regression that hard-codes "ctask"
// in the format string. Pinning a non-default name flushes that out.
withInvocationName(t, "my-bin")
got := formatResumeRestoreHint("my-archived-ws")
if !strings.Contains(got, "my-bin restore my-archived-ws") {
t.Errorf("resume restore hint should use invocation name; got:\n%s", got)
}
if strings.Contains(got, "ctask restore") {
t.Errorf("resume restore hint must NOT hard-code 'ctask restore' when invocation name differs; got:\n%s", got)
}
}
+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
}
+33 -5
View File
@@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -38,8 +39,21 @@ func init() {
rootCmd.AddCommand(resumeCmd)
}
// errArchivedWorkspace is the sentinel doResume returns when the user
// asks to resume an archived workspace. doResume already prints the
// full [ctask] diagnostic + restore hint to stderr, so the cmd-layer
// wrapper flips SilenceErrors to suppress Cobra's redundant trailing
// "Error: workspace archived" line. All other errors (lookup failure,
// metadata write failure, etc.) flow through Cobra's default
// rendering unchanged.
var errArchivedWorkspace = errors.New("workspace archived")
func runResume(cmd *cobra.Command, args []string) error {
return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect)
err := doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect)
if errors.Is(err, errArchivedWorkspace) {
cmd.SilenceErrors = true
}
return err
}
// doResume is the shared resume logic used by both `resume` and `last`.
@@ -58,10 +72,8 @@ func doResume(query string, container, useShell, force bool, agentOverride strin
ws := resolveOne(roots, query, true)
if ws.Meta.Status == "archived" {
fmt.Fprintf(os.Stderr,
"[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n",
query, invocationName(), query)
return fmt.Errorf("workspace archived")
fmt.Fprint(os.Stderr, formatResumeRestoreHint(query))
return errArchivedWorkspace
}
// updated_at bump (existing v0.4 behavior).
@@ -88,3 +100,19 @@ func doResume(query string, container, useShell, force bool, agentOverride strin
CommandName: "resume",
})
}
// formatResumeRestoreHint builds the multi-line stderr block printed
// when `ctask resume <query>` resolves to an archived workspace.
// Extracted so the v0.5.4 invocation-name audit can verify the
// command-form line uses invocationName() without depending on the
// surrounding fmt.Fprintf machinery.
//
// The "[ctask]" diagnostic prefix is intentionally a literal product
// reference (spec §2: product-identity references stay literal). The
// `restore <query>` line is the command-form portion and uses
// invocationName().
func formatResumeRestoreHint(query string) string {
return fmt.Sprintf(
"[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n",
query, invocationName(), query)
}
+117
View File
@@ -0,0 +1,117 @@
package cmd
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// TestResumeArchivedHintNoDuplicateError exercises resume through Cobra
// (not just runResume directly) so the SilenceErrors path is observed
// end-to-end. After v0.5.4 the [ctask] diagnostic block must print on
// its own — Cobra's default trailing "Error: workspace archived" line
// is suppressed via the conditional SilenceErrors set inside runResume.
//
// This guards against:
// - Reverting to a generic fmt.Errorf("workspace archived") that no
// longer matches the sentinel.
// - Removing the SilenceErrors flip.
// - A future runResume refactor that returns the error before the
// conditional check runs.
func TestResumeArchivedHintNoDuplicateError(t *testing.T) {
withInvocationName(t, "ctask")
root := t.TempDir()
wsDir := filepath.Join(root, "general", "2026-04-22_archived-poll")
if err := os.MkdirAll(wsDir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
now := time.Now().UTC().Truncate(time.Second)
archived := now.Add(-time.Hour)
meta := &workspace.TaskMeta{
ID: "t", Slug: "archived-poll", Title: "archived-poll",
CreatedAt: now, UpdatedAt: archived,
ArchivedAt: &archived,
Status: "archived",
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)
}
prevRoot := os.Getenv("CTASK_ROOT")
os.Setenv("CTASK_ROOT", root)
t.Cleanup(func() {
if prevRoot == "" {
os.Unsetenv("CTASK_ROOT")
} else {
os.Setenv("CTASK_ROOT", prevRoot)
}
})
// resumeCmd is a package global; restore SilenceErrors after the test
// so other tests against the same command see the original setting.
prevSilence := resumeCmd.SilenceErrors
t.Cleanup(func() { resumeCmd.SilenceErrors = prevSilence })
// Drive a fresh Cobra parent so we don't invoke unrelated commands
// during the test. AddCommand removes from the previous parent first.
parent := &cobra.Command{Use: "ctask-test"}
parent.AddCommand(resumeCmd)
t.Cleanup(func() { rootCmd.AddCommand(resumeCmd) })
parent.SetArgs([]string{"resume", "archived-poll"})
// Cobra writes the trailing "Error: ..." line to its configured
// error output, NOT to os.Stderr by default. Capture both: the
// [ctask] diagnostic goes to os.Stderr (via fmt.Fprint), while the
// would-be Cobra trailing line would go to parent's err output.
var cobraErrBuf bytes.Buffer
parent.SetErr(&cobraErrBuf)
parent.SetOut(&bytes.Buffer{})
stderrR, stderrW, _ := os.Pipe()
prevStderr := os.Stderr
os.Stderr = stderrW
defer func() { os.Stderr = prevStderr }()
execErr := parent.Execute()
stderrW.Close()
var directStderr bytes.Buffer
directStderr.ReadFrom(stderrR)
if execErr == nil {
t.Fatal("expected error from resuming archived workspace")
}
if !errors.Is(execErr, errArchivedWorkspace) {
t.Errorf("Execute should return errArchivedWorkspace, got %v", execErr)
}
// The [ctask] diagnostic must be present (printed to os.Stderr by
// runResume's stderr write).
gotStderr := directStderr.String()
if !strings.Contains(gotStderr, "[ctask] error: workspace") {
t.Errorf("[ctask] diagnostic missing from stderr:\n%s", gotStderr)
}
if !strings.Contains(gotStderr, "ctask restore archived-poll") {
t.Errorf("restore hint missing from stderr:\n%s", gotStderr)
}
// The Cobra-default "Error: workspace archived" line must NOT appear.
// It would normally land on parent's error writer.
cobraOut := cobraErrBuf.String()
if strings.Contains(cobraOut, "Error:") {
t.Errorf("Cobra default Error: line was not suppressed:\n%s", cobraOut)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
)
var version = "0.5.3"
var version = "0.5.4"
var rootCmd = &cobra.Command{
Use: "ctask",
+642 -454
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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 {
+94
View File
@@ -0,0 +1,94 @@
package session
import (
"encoding/json"
"errors"
"os"
"time"
)
// SessionState is the display-only classification surfaced by SessionStatus.
type SessionState string
const (
// SessionStateNone: no lease file is present.
SessionStateNone SessionState = "none"
// SessionStateActive: lease file is present and fresh (heartbeat within StaleLeaseAfter).
SessionStateActive SessionState = "active"
// SessionStateStale: lease file is present but stale, or present-and-malformed.
SessionStateStale SessionState = "stale"
)
// Status is the derived display-only view of a workspace's session lease.
//
// It is intentionally narrower than InspectLease/LeaseState: callers that
// need behavioral decisions (adoption, dispatch) must keep using the
// existing primitives. Status exists for `ctask info` and `ctask list` to
// surface session visibility without touching tmux, PID liveness, or any
// lock state.
type Status struct {
State SessionState
Mode string // "direct" | "persistent" | "" when malformed
PID int
Hostname string
Diagnostic string // human-readable note for the malformed case; empty otherwise
}
// SessionStatus returns the display-only session summary for wsDir.
//
// It performs only one file read (.ctask/session.json) and never invokes
// tmux, checks PID liveness, modifies lease state, acquires locks, or
// otherwise mutates the workspace. PID liveness is intentionally deferred
// to v0.6's lazy-cleanup redesign, where it will have behavioral
// consequences; building it display-only here would mean building it
// twice.
//
// Display-only contract: do NOT call SessionStatus from lifecycle or
// adoption code. The "missing Mode defaults to direct" rule and the
// malformed-lease "stale" classification are display choices, not
// behavioral truths. Use ReadLease / IsFresh / InspectLease for
// lifecycle decisions.
func SessionStatus(wsDir string) Status {
return statusAt(wsDir, time.Now())
}
// statusAt is the test entry point with an injected clock. Production
// code goes through SessionStatus.
func statusAt(wsDir string, now time.Time) Status {
data, err := os.ReadFile(LeasePath(wsDir))
if errors.Is(err, os.ErrNotExist) {
return Status{State: SessionStateNone}
}
if err != nil {
return Status{
State: SessionStateStale,
Diagnostic: "lease exists but could not be read",
}
}
var l Lease
if jsonErr := json.Unmarshal(data, &l); jsonErr != nil {
return Status{
State: SessionStateStale,
Diagnostic: "lease exists but could not be read",
}
}
state := SessionStateStale
if IsFresh(&l, now, StaleLeaseAfter) {
state = SessionStateActive
}
// Pre-v0.5.3 leases predate the mode field; treat them as direct so
// `info` and `list` render a meaningful value rather than blank.
mode := l.Mode
if mode == "" {
mode = "direct"
}
return Status{
State: state,
Mode: mode,
PID: l.PID,
Hostname: l.Hostname,
}
}
+204
View File
@@ -0,0 +1,204 @@
package session
import (
"os"
"path/filepath"
"testing"
"time"
)
// writeLeaseAt is provided by lease_inspect_test.go in this package.
func TestSessionStatusNone(t *testing.T) {
ws := t.TempDir() // no .ctask/session.json
got := statusAt(ws, time.Now())
if got.State != SessionStateNone {
t.Errorf("State: got %q, want %q", got.State, SessionStateNone)
}
if got.Mode != "" {
t.Errorf("Mode: got %q, want empty", got.Mode)
}
if got.PID != 0 {
t.Errorf("PID: got %d, want 0", got.PID)
}
if got.Hostname != "" {
t.Errorf("Hostname: got %q, want empty", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusFreshLocal(t *testing.T) {
ws := t.TempDir()
now := time.Now().UTC().Truncate(time.Second)
writeLeaseAt(t, ws, &Lease{
SessionID: "fakehost-1234-20260514120000",
PID: 1234,
Hostname: "fakehost",
Username: "tester",
Agent: "claude",
Mode: "persistent",
StartedAt: now,
LastHeartbeatAt: now,
Terminal: "test",
})
got := statusAt(ws, now.Add(10*time.Second)) // well within StaleLeaseAfter
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Mode != "persistent" {
t.Errorf("Mode: got %q, want persistent", got.Mode)
}
if got.PID != 1234 {
t.Errorf("PID: got %d, want 1234", got.PID)
}
if got.Hostname != "fakehost" {
t.Errorf("Hostname: got %q, want fakehost", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusFreshRemote(t *testing.T) {
// SessionStatus is display-only; "remote" is just whatever hostname is
// recorded in the lease. The helper does not compare to currentHostname()
// — that comparison happens in the cmd display layer (info).
ws := t.TempDir()
now := time.Now().UTC().Truncate(time.Second)
writeLeaseAt(t, ws, &Lease{
PID: 4242,
Hostname: "some-other-host",
Mode: "direct",
StartedAt: now,
LastHeartbeatAt: now,
})
got := statusAt(ws, now.Add(10*time.Second))
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Hostname != "some-other-host" {
t.Errorf("Hostname: got %q, want some-other-host", got.Hostname)
}
if got.Mode != "direct" {
t.Errorf("Mode: got %q, want direct", got.Mode)
}
if got.PID != 4242 {
t.Errorf("PID: got %d, want 4242", got.PID)
}
}
func TestSessionStatusStale(t *testing.T) {
ws := t.TempDir()
heartbeat := time.Now().UTC().Add(-5 * time.Minute)
writeLeaseAt(t, ws, &Lease{
PID: 999,
Hostname: "oldhost",
Mode: "persistent",
StartedAt: heartbeat,
LastHeartbeatAt: heartbeat,
})
got := statusAt(ws, time.Now())
if got.State != SessionStateStale {
t.Errorf("State: got %q, want %q", got.State, SessionStateStale)
}
if got.Mode != "persistent" {
t.Errorf("Mode: got %q, want persistent", got.Mode)
}
if got.PID != 999 {
t.Errorf("PID: got %d, want 999", got.PID)
}
if got.Hostname != "oldhost" {
t.Errorf("Hostname: got %q, want oldhost", got.Hostname)
}
if got.Diagnostic != "" {
t.Errorf("Diagnostic: got %q, want empty", got.Diagnostic)
}
}
func TestSessionStatusMalformed(t *testing.T) {
ws := t.TempDir()
if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(LeasePath(ws), []byte("{not valid json"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := statusAt(ws, time.Now())
if got.State != SessionStateStale {
t.Errorf("State: got %q, want %q", got.State, SessionStateStale)
}
if got.Mode != "" {
t.Errorf("Mode: got %q, want empty (malformed lease)", got.Mode)
}
if got.Diagnostic == "" {
t.Errorf("Diagnostic: want non-empty for malformed lease, got empty")
}
// PID/Hostname should be zero values when the lease can't be parsed —
// no partial info from a corrupt source.
if got.PID != 0 {
t.Errorf("PID: got %d, want 0 (malformed lease)", got.PID)
}
if got.Hostname != "" {
t.Errorf("Hostname: got %q, want empty (malformed lease)", got.Hostname)
}
}
func TestSessionStatusMissingMode(t *testing.T) {
// Leases written by pre-v0.5.3 ctask have no `mode` field. SessionStatus
// must default to "direct" so the display layer does not show a blank
// session mode for older workspaces.
ws := t.TempDir()
if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
now := time.Now().UTC().Truncate(time.Second)
// Write a lease JSON that omits the `mode` field entirely.
leaseJSON := []byte(`{
"session_id": "fakehost-1234-20260514120000",
"pid": 1234,
"hostname": "fakehost",
"username": "tester",
"agent": "claude",
"started_at": "` + now.Format(time.RFC3339) + `",
"last_heartbeat_at": "` + now.Format(time.RFC3339) + `",
"terminal": "test"
}`)
if err := os.WriteFile(LeasePath(ws), leaseJSON, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
got := statusAt(ws, now.Add(10*time.Second))
if got.State != SessionStateActive {
t.Errorf("State: got %q, want %q", got.State, SessionStateActive)
}
if got.Mode != "direct" {
t.Errorf("Mode: got %q, want direct (default for missing-mode lease)", got.Mode)
}
if got.PID != 1234 {
t.Errorf("PID: got %d, want 1234", got.PID)
}
}
func TestSessionStatusUsesProductionEntrypoint(t *testing.T) {
// Sanity check that the production entry point (which uses time.Now)
// returns SessionStateNone when the lease is missing — guards against
// a refactor that loses the SessionStatus -> statusAt forwarding.
ws := t.TempDir()
got := SessionStatus(ws)
if got.State != SessionStateNone {
t.Errorf("SessionStatus on empty workspace: got %q, want %q", got.State, SessionStateNone)
}
}