4fdd153bc4
Archive now inspects .ctask/session.json before mutating task.yaml. A fresh lease (heartbeat within 60s) triggers a warning. Interactive stdin gets a y/N prompt (default N). Non-interactive stdin refuses with a non-zero exit, which is safer than silently hiding an actively writing workspace. Stale or missing leases pass through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
4.2 KiB
Go
151 lines
4.2 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/warrenronsiek/ctask/internal/session"
|
|
"github.com/warrenronsiek/ctask/internal/workspace"
|
|
)
|
|
|
|
// This file swaps process-global os.Stdin, os.Stdout, and env vars. Do not
|
|
// call t.Parallel() in this file.
|
|
|
|
func makeArchiveWs(t *testing.T, root, category, dirName string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(root, category, dirName)
|
|
os.MkdirAll(dir, 0755)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
slug := dirName[11:]
|
|
meta := &workspace.TaskMeta{
|
|
ID: "test",
|
|
Slug: slug,
|
|
Title: slug,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Status: "active",
|
|
Category: category,
|
|
Type: "task",
|
|
Mode: "local",
|
|
Agent: "claude",
|
|
WorkspacePath: dir,
|
|
}
|
|
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
|
return dir
|
|
}
|
|
|
|
// writeTestLease drops a lease file at wsDir/.ctask/session.json with the
|
|
// given heartbeat age relative to now.
|
|
func writeTestLease(t *testing.T, wsDir string, heartbeatAge time.Duration) {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
l := &session.Lease{
|
|
SessionID: "test-session",
|
|
PID: 1234,
|
|
Hostname: "test-host",
|
|
Username: "tester",
|
|
Agent: "claude",
|
|
Mode: "local",
|
|
StartedAt: now.Add(-heartbeatAge - time.Minute),
|
|
LastHeartbeatAt: now.Add(-heartbeatAge),
|
|
Terminal: "test",
|
|
}
|
|
if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil {
|
|
t.Fatalf("WriteLease: %v", err)
|
|
}
|
|
}
|
|
|
|
// callArchive invokes runArchive with a captured (non-TTY) stdin and stdout.
|
|
func callArchive(t *testing.T, root, query, stdinInput string) (string, error) {
|
|
t.Helper()
|
|
|
|
prevRoot := os.Getenv("CTASK_ROOT")
|
|
os.Setenv("CTASK_ROOT", root)
|
|
defer func() {
|
|
if prevRoot == "" {
|
|
os.Unsetenv("CTASK_ROOT")
|
|
} else {
|
|
os.Setenv("CTASK_ROOT", prevRoot)
|
|
}
|
|
}()
|
|
|
|
stdinR, stdinW, _ := os.Pipe()
|
|
go func() {
|
|
stdinW.WriteString(stdinInput)
|
|
stdinW.Close()
|
|
}()
|
|
prevStdin := os.Stdin
|
|
os.Stdin = stdinR
|
|
defer func() { os.Stdin = prevStdin }()
|
|
|
|
stdoutR, stdoutW, _ := os.Pipe()
|
|
prevStdout := os.Stdout
|
|
os.Stdout = stdoutW
|
|
defer func() { os.Stdout = prevStdout }()
|
|
|
|
err := runArchive(archiveCmd, []string{query})
|
|
|
|
stdoutW.Close()
|
|
var buf bytes.Buffer
|
|
buf.ReadFrom(stdoutR)
|
|
return buf.String(), err
|
|
}
|
|
|
|
func TestArchiveWithNoActiveSession(t *testing.T) {
|
|
root := t.TempDir()
|
|
makeArchiveWs(t, root, "general", "2026-04-22_no-session")
|
|
|
|
out, err := callArchive(t, root, "no-session", "")
|
|
if err != nil {
|
|
t.Fatalf("archive should succeed with no lease, got error: %v", err)
|
|
}
|
|
if !strings.Contains(out, "archived") {
|
|
t.Errorf("expected archived confirmation in output, got: %s", out)
|
|
}
|
|
if strings.Contains(out, "active session") {
|
|
t.Errorf("should not warn when no lease exists: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestArchiveWithStaleLeaseProceedsSilently(t *testing.T) {
|
|
root := t.TempDir()
|
|
wsDir := makeArchiveWs(t, root, "general", "2026-04-22_stale-session")
|
|
writeTestLease(t, wsDir, 5*time.Minute) // older than 60s => stale
|
|
|
|
out, err := callArchive(t, root, "stale-session", "")
|
|
if err != nil {
|
|
t.Fatalf("archive should succeed with stale lease, got: %v", err)
|
|
}
|
|
if strings.Contains(out, "active session") {
|
|
t.Errorf("stale lease should not trigger active-session warning: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestArchiveWithFreshLeaseNonTTYRefuses(t *testing.T) {
|
|
// Per spec amendment: non-TTY stdin + fresh lease => refuse with error.
|
|
root := t.TempDir()
|
|
wsDir := makeArchiveWs(t, root, "general", "2026-04-22_fresh-session")
|
|
writeTestLease(t, wsDir, 5*time.Second) // well under 60s => fresh
|
|
|
|
out, err := callArchive(t, root, "fresh-session", "")
|
|
if err == nil {
|
|
t.Fatal("expected error refusing to archive active session on non-TTY stdin")
|
|
}
|
|
if !strings.Contains(out, "active session") {
|
|
t.Errorf("expected active-session warning in output, got: %s", out)
|
|
}
|
|
|
|
// Workspace must still be active (not archived).
|
|
ws, qerr := workspace.ResolveQuery([]string{root}, "fresh-session", true)
|
|
if qerr != nil || len(ws) == 0 {
|
|
t.Fatalf("ResolveQuery: %v, len=%d", qerr, len(ws))
|
|
}
|
|
if ws[0].Meta.Status == "archived" {
|
|
t.Errorf("workspace was archived despite refusal: status=%s", ws[0].Meta.Status)
|
|
}
|
|
}
|