Merge branch 'feat/v0.5.4-session-visibility-polish' into main
This commit is contained in:
+9
-1
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
+33
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user