From a050b116facb65c6752b4457820419ae347969d9 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Tue, 21 Apr 2026 17:09:41 -0400 Subject: [PATCH] feat(v0.4): add Preflight checks for Layer 1 and Layer 3 --- internal/session/run_preflight.go | 126 +++++++++++++ internal/session/run_preflight_test.go | 235 +++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 internal/session/run_preflight.go create mode 100644 internal/session/run_preflight_test.go diff --git a/internal/session/run_preflight.go b/internal/session/run_preflight.go new file mode 100644 index 0000000..049e531 --- /dev/null +++ b/internal/session/run_preflight.go @@ -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 +} diff --git a/internal/session/run_preflight_test.go b/internal/session/run_preflight_test.go new file mode 100644 index 0000000..677aa76 --- /dev/null +++ b/internal/session/run_preflight_test.go @@ -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) + } +}