Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 235 additions & 93 deletions cli/cmd/fellowship/main.go

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions cli/internal/dashboard/fellowship.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"

"github.com/justinjdev/fellowship/cli/internal/datadir"
"github.com/justinjdev/fellowship/cli/internal/filelock"
"github.com/justinjdev/fellowship/cli/internal/errand"
"github.com/justinjdev/fellowship/cli/internal/state"
)
Expand Down Expand Up @@ -200,10 +200,10 @@ func WithStateLock(path string, fn func(s *FellowshipState) error) error {
}
defer lockFile.Close()

if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil {
if err := filelock.Lock(lockFile.Fd()); err != nil {
return fmt.Errorf("acquiring lock: %w", err)
}
defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
defer filelock.Unlock(lockFile.Fd())

s, err := LoadFellowshipState(path)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions cli/internal/filelock/filelock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !windows

package filelock

import "syscall"

// Lock acquires an exclusive lock on the given file descriptor.
func Lock(fd uintptr) error {
return syscall.Flock(int(fd), syscall.LOCK_EX)
}

// Unlock releases the lock on the given file descriptor.
func Unlock(fd uintptr) error {
return syscall.Flock(int(fd), syscall.LOCK_UN)
}
50 changes: 50 additions & 0 deletions cli/internal/filelock/filelock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build windows

package filelock

import (
"fmt"
"syscall"
"unsafe"
)

var (
modkernel32 = syscall.NewLazyDLL("kernel32.dll")
procLockFileEx = modkernel32.NewProc("LockFileEx")
procUnlockFileEx = modkernel32.NewProc("UnlockFileEx")
)

const lockfileExclusiveLock = 0x00000002

// Lock acquires an exclusive lock on the given file descriptor.
func Lock(fd uintptr) error {
h := syscall.Handle(fd)
ol := new(syscall.Overlapped)
r1, _, err := procLockFileEx.Call(
uintptr(h),
lockfileExclusiveLock,
0,
1, 0,
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
return fmt.Errorf("LockFileEx: %w", err)
}
return nil
}

// Unlock releases the lock on the given file descriptor.
func Unlock(fd uintptr) error {
h := syscall.Handle(fd)
ol := new(syscall.Overlapped)
r1, _, err := procUnlockFileEx.Call(
uintptr(h),
0,
1, 0,
uintptr(unsafe.Pointer(ol)),
)
if r1 == 0 {
return fmt.Errorf("UnlockFileEx: %w", err)
}
return nil
}
2 changes: 2 additions & 0 deletions cli/internal/herald/herald.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const (
PhaseTransition TidingType = "phase_transition"
LembasCompleted TidingType = "lembas_completed"
MetadataUpdated TidingType = "metadata_updated"
QuestHeld TidingType = "quest_held"
QuestUnheld TidingType = "quest_unheld"
)

// Tiding represents a single quest event.
Expand Down
12 changes: 12 additions & 0 deletions cli/internal/hooks/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ type HookResult struct {
}

func GateGuard(s *state.State, input *HookInput) HookResult {
if s.Held {
msg := "Quest is held — paused by the lead."
if s.HeldReason != nil {
msg += " Reason: " + *s.HeldReason
}
msg += " Wait for the lead to unhold before taking any action."
return HookResult{
Block: true,
Message: msg,
}
}

if s.GatePending {
return HookResult{
Block: true,
Expand Down
35 changes: 35 additions & 0 deletions cli/internal/hooks/guard_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hooks

import (
"strings"
"testing"

"github.com/justinjdev/fellowship/cli/internal/state"
Expand Down Expand Up @@ -84,3 +85,37 @@ func TestGateGuard_PendingBlocksEvenDuringLatePhase(t *testing.T) {
t.Error("gate_pending should block even during Implement")
}
}

func TestGateGuard_BlocksWhenHeld(t *testing.T) {
s := &state.State{Phase: "Implement", Held: true}
input := &HookInput{ToolInput: ToolInput{Command: "ls"}}
result := GateGuard(s, input)
if !result.Block {
t.Error("should block when quest is held")
}
}

func TestGateGuard_BlocksWhenHeldWithReason(t *testing.T) {
reason := "file conflict with quest-auth"
s := &state.State{Phase: "Implement", Held: true, HeldReason: &reason}
input := &HookInput{ToolInput: ToolInput{Command: "ls"}}
result := GateGuard(s, input)
if !result.Block {
t.Error("should block when quest is held")
}
if !strings.Contains(result.Message, reason) {
t.Errorf("message should include held reason, got: %s", result.Message)
}
}

func TestGateGuard_HeldTakesPriorityOverGatePending(t *testing.T) {
s := &state.State{Phase: "Implement", Held: true, GatePending: true}
input := &HookInput{ToolInput: ToolInput{Command: "ls"}}
result := GateGuard(s, input)
if !result.Block {
t.Error("should block")
}
if !strings.Contains(result.Message, "held") {
t.Errorf("held should take priority over gate_pending, got: %s", result.Message)
}
}
38 changes: 38 additions & 0 deletions cli/internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import (
"strings"

"github.com/justinjdev/fellowship/cli/internal/datadir"
"github.com/justinjdev/fellowship/cli/internal/filelock"
)

// ErrNoSave can be returned from a WithLock callback to skip saving
// the state file while still releasing the lock without error.
var ErrNoSave = fmt.Errorf("no save needed")

type State struct {
Version int `json:"version"`
QuestName string `json:"quest_name"`
Expand All @@ -23,6 +28,8 @@ type State struct {
LembasCompleted bool `json:"lembas_completed"`
MetadataUpdated bool `json:"metadata_updated"`
AutoApproveGates []string `json:"auto_approve_gates"`
Held bool `json:"held"`
HeldReason *string `json:"held_reason"`
}

var phaseOrder = []string{"Onboard", "Research", "Plan", "Implement", "Review", "Complete"}
Expand Down Expand Up @@ -75,6 +82,37 @@ func Save(path string, s *State) error {
return nil
}

// WithLock acquires an exclusive file lock, loads the state, calls fn to
// mutate it, and saves the result. The entire load→mutate→save is atomic with
// respect to other processes using the same lock.
func WithLock(path string, fn func(s *State) error) error {
lockPath := path + ".lock"
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return fmt.Errorf("opening lock file: %w", err)
}
defer lockFile.Close()

if err := filelock.Lock(lockFile.Fd()); err != nil {
return fmt.Errorf("acquiring lock: %w", err)
}
defer filelock.Unlock(lockFile.Fd())

s, err := Load(path)
if err != nil {
return err
}

if err := fn(s); err != nil {
if err == ErrNoSave {
return nil
}
return err
}

return Save(path, s)
}

func FindStateFile(fromDir string) (string, error) {
root, err := gitRoot(fromDir)
if err != nil {
Expand Down
Loading
Loading