From 4fdd153bc4bf085fdc7b5f45cc944a87a34f6ecc Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 17:57:31 -0400 Subject: [PATCH] feat(v0.4.1): warn when archiving workspace with active session 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) --- cmd/archive.go | 52 +++++++++++++++ cmd/archive_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 cmd/archive_test.go diff --git a/cmd/archive.go b/cmd/archive.go index a7a382f..3dc3f68 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -2,11 +2,13 @@ package cmd import ( "fmt" + "os" "path/filepath" "time" "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -26,6 +28,26 @@ func runArchive(cmd *cobra.Command, args []string) error { roots := config.SearchRoots() ws := resolveOne(roots, args[0], false) + // Active-session check: a fresh lease means a session is actively writing + // to this workspace. On TTY stdin we prompt. On non-TTY stdin we refuse — + // archiving under an active session silently is the most surprising + // failure mode, so the safe default is to exit non-zero. + if lease, err := session.ReadLease(session.LeasePath(ws.Path)); err == nil && lease != nil { + if session.IsFresh(lease, time.Now(), session.StaleLeaseAfter) { + fmt.Print(formatArchiveActiveWarning(lease, time.Now())) + + if !isStdinTerminal() { + fmt.Println() + return fmt.Errorf("refusing to archive workspace with active session (stdin is not a terminal)") + } + + if !session.ConfirmYN(os.Stdin, os.Stdout, " Archive anyway? [y/N] ", false) { + fmt.Println(" Cancelled.") + return nil + } + } + } + now := time.Now().UTC().Truncate(time.Second) ws.Meta.Status = "archived" ws.Meta.ArchivedAt = &now @@ -41,3 +63,33 @@ func runArchive(cmd *cobra.Command, args []string) error { return nil } + +// formatArchiveActiveWarning renders the warning block shown when archive +// detects a fresh lease. +func formatArchiveActiveWarning(l *session.Lease, now time.Time) string { + startedAgo := now.Sub(l.StartedAt) + return fmt.Sprintf( + "[ctask] Warning: this workspace has an active session:\n"+ + " Host: %s\n"+ + " Agent: %s\n"+ + " Started: %s (%s ago)\n\n"+ + " Archiving will hide it from list and resume.\n"+ + " The active session will continue but may behave unexpectedly.\n\n", + l.Hostname, + l.Agent, + l.StartedAt.Local().Format("2006-01-02 15:04"), + session.FormatAgo(startedAgo), + ) +} + +// isStdinTerminal reports whether os.Stdin is attached to a terminal. +// Uses os.Stdin.Stat + ModeCharDevice to avoid adding a golang.org/x/term +// dependency. Pipes and redirected files lack the ModeCharDevice bit on +// every supported platform. +func isStdinTerminal() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/cmd/archive_test.go b/cmd/archive_test.go new file mode 100644 index 0000000..c997b96 --- /dev/null +++ b/cmd/archive_test.go @@ -0,0 +1,150 @@ +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) + } +}