feat(v0.4): add Preflight checks for Layer 1 and Layer 3

This commit is contained in:
2026-04-21 17:09:41 -04:00
parent 77513aa5f8
commit a050b116fa
2 changed files with 361 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
package session
import (
"errors"
"fmt"
"io"
"os"
"time"
)
// PreflightOpts configures the Layer-3 + Layer-1 preflight checks that run
// before session.Run establishes its own lease.
type PreflightOpts struct {
WsDir string
Force bool
In io.Reader
Out io.Writer
}
// PreflightResult reports whether the session should proceed and whether an
// existing fresh lease is being coexisted-with (in which case Run must NOT
// write its own lease).
type PreflightResult struct {
Proceed bool
ActiveLeaseFound bool
}
// Preflight is a convenience wrapper around PreflightFull for callers that
// only need the proceed/cancel decision.
func Preflight(opts PreflightOpts) (bool, error) {
res, err := PreflightFull(opts)
if err != nil {
return false, err
}
return res.Proceed, nil
}
// PreflightFull runs Layer 3 (stale-workspace) then Layer 1 (active-session).
// Both warnings are suppressed when Force is true.
//
// Missing lease/summary files are the normal first-session case — silent.
// Corrupt files are removed with an explicit warning. Real I/O errors are
// surfaced via the returned error.
func PreflightFull(opts PreflightOpts) (PreflightResult, error) {
// --- Layer 3: stale-workspace detection (external modifications) ---
if !opts.Force {
proceed, err := runStaleWorkspaceCheck(opts)
if err != nil {
return PreflightResult{}, err
}
if !proceed {
return PreflightResult{Proceed: false}, nil
}
}
// --- Layer 1: active-session lease check ---
activeLeaseFound, proceed, err := runActiveLeaseCheck(opts)
if err != nil {
return PreflightResult{}, err
}
if !proceed {
return PreflightResult{Proceed: false}, nil
}
return PreflightResult{Proceed: true, ActiveLeaseFound: activeLeaseFound}, nil
}
// runStaleWorkspaceCheck returns (proceed, err).
func runStaleWorkspaceCheck(opts PreflightOpts) (bool, error) {
summary, err := ReadSummary(SummaryPath(opts.WsDir))
if err != nil {
return false, fmt.Errorf("reading last-session summary: %w", err)
}
if summary == nil {
return true, nil
}
diff, err := DetectExternalChanges(opts.WsDir)
if err != nil {
return false, fmt.Errorf("detecting external changes: %w", err)
}
if !HasChanges(diff) {
return true, nil
}
fmt.Fprint(opts.Out, FormatStaleWarning(summary, diff))
return ConfirmYN(opts.In, opts.Out, "", true), nil
}
// runActiveLeaseCheck returns (activeLeaseFound, proceed, err).
func runActiveLeaseCheck(opts PreflightOpts) (bool, bool, error) {
leasePath := LeasePath(opts.WsDir)
existing, err := ReadLease(leasePath)
switch {
case err == nil && existing != nil:
if IsFresh(existing, time.Now(), StaleLeaseAfter) {
if opts.Force {
return true, true, nil
}
fmt.Fprint(opts.Out, FormatActiveWarning(existing, time.Now()))
if !ConfirmYN(opts.In, opts.Out, "", false) {
return false, false, nil
}
return true, true, nil
}
removed, rmErr := CleanupStaleLease(leasePath, StaleLeaseAfter)
if rmErr != nil {
fmt.Fprintf(opts.Out, "[ctask] Warning: could not remove stale lease: %v\n", rmErr)
} else if removed != nil {
fmt.Fprint(opts.Out, FormatStaleCleanupNotice(removed, time.Now()))
}
return false, true, nil
case errors.Is(err, os.ErrNotExist):
return false, true, nil
case err != nil:
fmt.Fprintf(opts.Out, "[ctask] Warning: unparseable session lease at %s (%v); removing\n", leasePath, err)
if rmErr := os.Remove(leasePath); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
fmt.Fprintf(opts.Out, "[ctask] Warning: failed to remove corrupt lease: %v\n", rmErr)
}
return false, true, nil
}
return false, true, nil
}
+235
View File
@@ -0,0 +1,235 @@
package session
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPreflightNoPriorSession(t *testing.T) {
wsDir := t.TempDir()
var out bytes.Buffer
in := strings.NewReader("")
ok, err := Preflight(PreflightOpts{
WsDir: wsDir,
Force: false,
In: in,
Out: &out,
})
if err != nil {
t.Fatalf("Preflight: %v", err)
}
if !ok {
t.Error("Preflight should proceed on empty workspace")
}
if out.Len() != 0 {
t.Errorf("expected silent preflight, got:\n%s", out.String())
}
}
func TestPreflightStaleWorkspaceYes(t *testing.T) {
wsDir := t.TempDir()
oldTime := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
summary := &SessionSummary{
EndedAt: oldTime,
Hostname: "other-host",
Agent: "claude",
EndManifest: []FileEntry{{Path: "notes.md", Size: 5, Mtime: oldTime}},
}
WriteSummary(SummaryPath(wsDir), summary)
os.WriteFile(filepath.Join(wsDir, "notes.md"), []byte("changed content"), 0644)
var out bytes.Buffer
in := strings.NewReader("\n")
ok, err := Preflight(PreflightOpts{WsDir: wsDir, Force: false, In: in, Out: &out})
if err != nil {
t.Fatalf("Preflight: %v", err)
}
if !ok {
t.Error("default-yes on stale warning should proceed")
}
if !strings.Contains(out.String(), "Workspace modified since last session ended") {
t.Errorf("expected stale warning, got:\n%s", out.String())
}
}
func TestPreflightStaleWorkspaceNo(t *testing.T) {
wsDir := t.TempDir()
oldTime := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
summary := &SessionSummary{
EndedAt: oldTime,
Hostname: "other-host",
Agent: "claude",
EndManifest: []FileEntry{{Path: "notes.md", Size: 5, Mtime: oldTime}},
}
WriteSummary(SummaryPath(wsDir), summary)
os.WriteFile(filepath.Join(wsDir, "notes.md"), []byte("changed content"), 0644)
var out bytes.Buffer
in := strings.NewReader("n\n")
ok, err := Preflight(PreflightOpts{WsDir: wsDir, Force: false, In: in, Out: &out})
if err != nil {
t.Fatalf("Preflight: %v", err)
}
if ok {
t.Error(`"n" on stale warning should NOT proceed`)
}
}
func TestPreflightForceSuppressesStaleWarning(t *testing.T) {
wsDir := t.TempDir()
oldTime := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
summary := &SessionSummary{
EndedAt: oldTime,
EndManifest: []FileEntry{{Path: "notes.md", Size: 5, Mtime: oldTime}},
}
WriteSummary(SummaryPath(wsDir), summary)
os.WriteFile(filepath.Join(wsDir, "notes.md"), []byte("changed content"), 0644)
var out bytes.Buffer
in := strings.NewReader("")
ok, err := Preflight(PreflightOpts{WsDir: wsDir, Force: true, In: in, Out: &out})
if err != nil {
t.Fatalf("Preflight: %v", err)
}
if !ok {
t.Error("Force should bypass the stale warning and proceed")
}
if strings.Contains(out.String(), "Workspace modified since last session ended") {
t.Errorf("Force should suppress stale warning, got:\n%s", out.String())
}
}
func TestPreflightActiveLeaseWarnAccept(t *testing.T) {
wsDir := t.TempDir()
os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755)
now := time.Now().UTC().Truncate(time.Second)
fresh := &Lease{
SessionID: "other-host-99-20260421140000",
Hostname: "other-host",
Agent: "claude",
Mode: "local",
StartedAt: now.Add(-10 * time.Minute),
LastHeartbeatAt: now.Add(-5 * time.Second),
}
WriteLease(LeasePath(wsDir), fresh)
var out bytes.Buffer
in := strings.NewReader("y\n")
res, err := PreflightFull(PreflightOpts{WsDir: wsDir, Force: false, In: in, Out: &out})
if err != nil {
t.Fatalf("PreflightFull: %v", err)
}
if !res.Proceed {
t.Error(`"y" on active lease should proceed`)
}
if !res.ActiveLeaseFound {
t.Error("ActiveLeaseFound should be true when coexisting with an active lease")
}
if !strings.Contains(out.String(), "active session") {
t.Errorf("expected active-session warning, got:\n%s", out.String())
}
if _, err := os.Stat(LeasePath(wsDir)); err != nil {
t.Errorf("existing lease should remain intact after user confirms: %v", err)
}
}
func TestPreflightForceActiveLeaseCoexists(t *testing.T) {
wsDir := t.TempDir()
os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755)
now := time.Now().UTC().Truncate(time.Second)
fresh := &Lease{
SessionID: "other-host-99-20260421140000",
Hostname: "other-host",
StartedAt: now,
LastHeartbeatAt: now,
}
WriteLease(LeasePath(wsDir), fresh)
var out bytes.Buffer
in := strings.NewReader("")
res, err := PreflightFull(PreflightOpts{WsDir: wsDir, Force: true, In: in, Out: &out})
if err != nil {
t.Fatalf("PreflightFull: %v", err)
}
if !res.Proceed {
t.Error("Force must always proceed")
}
if !res.ActiveLeaseFound {
t.Error("Force with fresh lease should still mark ActiveLeaseFound")
}
if strings.Contains(out.String(), "active session") {
t.Errorf("Force should suppress active-session warning, got:\n%s", out.String())
}
if _, err := os.Stat(LeasePath(wsDir)); err != nil {
t.Errorf("existing lease should remain intact under --force: %v", err)
}
}
func TestPreflightStaleLeaseAutoCleans(t *testing.T) {
wsDir := t.TempDir()
os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755)
old := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
stale := &Lease{
SessionID: "other-host-99-20260421100000",
Hostname: "other-host",
StartedAt: old,
LastHeartbeatAt: old,
}
WriteLease(LeasePath(wsDir), stale)
var out bytes.Buffer
in := strings.NewReader("")
ok, err := Preflight(PreflightOpts{WsDir: wsDir, Force: false, In: in, Out: &out})
if err != nil {
t.Fatalf("Preflight: %v", err)
}
if !ok {
t.Error("stale-lease cleanup should proceed automatically")
}
if !strings.Contains(out.String(), "Cleaned up stale session") {
t.Errorf("expected stale-cleanup notice, got:\n%s", out.String())
}
if _, err := os.Stat(LeasePath(wsDir)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("stale lease should be removed, got err=%v", err)
}
}
func TestPreflightCorruptLeaseRemoved(t *testing.T) {
wsDir := t.TempDir()
os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755)
if err := os.WriteFile(LeasePath(wsDir), []byte("{not json"), 0644); err != nil {
t.Fatalf("plant corrupt lease: %v", err)
}
var out bytes.Buffer
in := strings.NewReader("")
ok, err := Preflight(PreflightOpts{WsDir: wsDir, Force: false, In: in, Out: &out})
if err != nil {
t.Fatalf("Preflight should not error on corrupt lease: %v", err)
}
if !ok {
t.Error("corrupt lease should be cleaned up and preflight should proceed")
}
if !strings.Contains(out.String(), "unparseable session lease") {
t.Errorf("expected corrupt-lease warning, got:\n%s", out.String())
}
if _, err := os.Stat(LeasePath(wsDir)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("corrupt lease should be removed, got err=%v", err)
}
}