feat: unify interactive operation coordination

This commit is contained in:
proxyctl coordinator
2026-07-17 22:23:31 +08:00
parent 177bf34354
commit 7c0b4481d7
17 changed files with 985 additions and 1013 deletions
+122 -11
View File
@@ -5,7 +5,9 @@ import (
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"proxyctl/internal/app"
"proxyctl/internal/profile"
@@ -13,12 +15,17 @@ import (
)
type coordinatedOperationTarget struct {
id string
state target.State
started chan struct{}
release chan struct{}
confirmation string
failure error
id string
state target.State
started chan struct{}
release chan struct{}
detectionStarted chan struct{}
detectionRelease chan struct{}
confirmation string
failure error
mu sync.Mutex
inDetection bool
overlapObserved chan bool
}
func (t *coordinatedOperationTarget) ID() string { return t.id }
@@ -32,7 +39,28 @@ func (t *coordinatedOperationTarget) Inspect(context.Context, *profile.Profile)
return target.Inspection{State: t.state, Evidence: "fresh state"}
}
func (t *coordinatedOperationTarget) Confirmation(bool) string { return t.confirmation }
func (t *coordinatedOperationTarget) Detect(ctx context.Context, active *profile.Profile) target.Detection {
t.mu.Lock()
t.inDetection = true
t.mu.Unlock()
if t.detectionStarted != nil {
close(t.detectionStarted)
}
if t.detectionRelease != nil {
<-t.detectionRelease
}
t.mu.Lock()
t.inDetection = false
t.mu.Unlock()
return target.Detection{Availability: t.Availability(ctx), Inspection: t.Inspect(ctx, active)}
}
func (t *coordinatedOperationTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
t.mu.Lock()
overlapped := t.inDetection
t.mu.Unlock()
if t.overlapObserved != nil {
t.overlapObserved <- overlapped
}
if t.started != nil {
close(t.started)
}
@@ -66,14 +94,15 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
running := <-operation.Updates
if running.Phase != app.OperationRunning || len(running.Progress) != 1 || running.Progress[0].Phase != app.TargetProgressRunning || running.Progress[0].TargetID != "shell" || running.Status.Kind != app.StatusProgress {
runningProgress := running.Progress()
if !running.IsRunning() || len(runningProgress) != 1 || runningProgress[0].Phase != app.TargetProgressRunning || runningProgress[0].TargetID != "shell" || running.Status().Kind != app.StatusProgress {
t.Fatalf("running update = %#v", running)
}
<-adapter.started
blocked := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false})
blockedUpdate := <-blocked.Updates
if blockedUpdate.Phase != app.OperationBlocked || blockedUpdate.Status.Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status.Summary, "already in progress") {
if !blockedUpdate.IsTerminal() || blockedUpdate.Status().Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status().Summary, "already in progress") {
t.Fatalf("blocked update = %#v", blockedUpdate)
}
if _, open := <-blocked.Updates; open {
@@ -82,7 +111,8 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
close(adapter.release)
final := <-operation.Updates
if final.Phase != app.OperationSucceeded || final.Snapshot.Targets[0].State != target.Enabled || len(final.Progress) != 1 || final.Progress[0].Phase != app.TargetProgressSucceeded {
finalProgress := final.Progress()
if !final.IsSucceeded() || final.Snapshot.Targets[0].State != target.Enabled || len(finalProgress) != 1 || finalProgress[0].Phase != app.TargetProgressSucceeded {
t.Fatalf("final update = %#v", final)
}
if _, open := <-operation.Updates; open {
@@ -90,6 +120,86 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
}
}
func TestDetectionOperationSerializesWithSynchronousMutation(t *testing.T) {
adapter := &coordinatedOperationTarget{
id: "shell", state: target.Disabled,
detectionStarted: make(chan struct{}), detectionRelease: make(chan struct{}),
started: make(chan struct{}), overlapObserved: make(chan bool, 1),
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
t.Fatal(result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
detection := application.StartOperation(app.Intent{Kind: app.RefreshTargets})
<-adapter.detectionStarted
mutationAttempted := make(chan struct{})
mutationDone := make(chan app.Result, 1)
go func() {
close(mutationAttempted)
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
}()
<-mutationAttempted
close(adapter.detectionRelease)
for range detection.Updates {
}
result := <-mutationDone
if result.Err != nil || result.Snapshot.Targets[0].State != target.Enabled {
t.Fatalf("serialized mutation = %#v", result)
}
if overlapped := <-adapter.overlapObserved; overlapped {
t.Fatal("mutation overlapped Target detection")
}
}
func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing.T) {
adapter := &coordinatedOperationTarget{
id: "shell", state: target.Disabled,
started: make(chan struct{}), release: make(chan struct{}),
detectionStarted: make(chan struct{}), detectionRelease: make(chan struct{}),
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
t.Fatal(result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
mutationDone := make(chan app.Result, 1)
go func() {
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
}()
<-adapter.started // barrier: synchronous Dispatch owns dispatch serialization
returned := make(chan app.OperationStream, 1)
go func() { returned <- application.StartOperation(app.Intent{Kind: app.RefreshTargets}) }()
var stream app.OperationStream
select {
case stream = <-returned:
case <-time.After(time.Second):
t.Fatal("StartOperation blocked Bubble Tea while dispatch serialization was held")
}
close(adapter.release)
if result := <-mutationDone; result.Err != nil {
t.Fatal(result.Err)
}
<-adapter.detectionStarted
close(adapter.detectionRelease)
for range stream.Updates {
}
}
func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
adapter := &coordinatedOperationTarget{id: "docker-daemon", state: target.Disabled, confirmation: "Docker will restart and may disrupt running work."}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
@@ -105,7 +215,8 @@ func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true})
update := <-operation.Updates
if update.Phase != app.OperationAwaitingConfirmation || update.Confirmation == nil || update.Confirmation.Title == "" || update.Confirmation.CancelLabel == "" || update.Status.Kind != app.StatusConfirmation {
confirmation := update.Confirmation()
if !update.IsConfirmation() || confirmation == nil || confirmation.Title == "" || confirmation.CancelLabel == "" {
t.Fatalf("confirmation update = %#v", update)
}
if _, open := <-operation.Updates; open {
@@ -140,7 +251,7 @@ func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testi
for update := range operation.Updates {
final = update
}
if final.Phase != app.OperationFailed || final.FirstFailedTargetID != "second" || final.Snapshot.ActiveProfile != "new" {
if !final.IsFailed() || final.FirstFailedTargetID() != "second" || final.Snapshot.ActiveProfile != "new" {
t.Fatalf("final transition = %#v", final)
}
if final.Snapshot.Targets[0].State != target.Enabled || final.Snapshot.Targets[1].State != target.Drifted {