feat(v0.4): add Preflight checks for Layer 1 and Layer 3
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user