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
+32 -38
View File
@@ -14,52 +14,41 @@ import (
type progressiveDetectionModule struct {
snapshot app.Snapshot
streams []chan app.TargetDetectionUpdate
streams []chan app.OperationUpdate
begins int
}
func (m *progressiveDetectionModule) Snapshot() app.Snapshot {
return cloneDetectionSnapshot(m.snapshot)
snapshot := cloneDetectionSnapshot(m.snapshot)
if m.begins == 0 {
for i := range snapshot.Targets {
snapshot.Targets[i].DetectionPhase = app.TargetChecking
}
}
return snapshot
}
func (m *progressiveDetectionModule) Dispatch(app.Intent) app.Result {
return app.Result{Snapshot: m.snapshot}
}
func (m *progressiveDetectionModule) BeginTargetDetection() (app.Snapshot, <-chan app.TargetDetectionUpdate) {
func (m *progressiveDetectionModule) StartOperation(intent app.Intent) app.OperationStream {
m.begins++
for i := range m.snapshot.Targets {
m.snapshot.Targets[i].DetectionPhase = app.TargetChecking
m.snapshot.Targets[i].DetectionErr = nil
}
return cloneDetectionSnapshot(m.snapshot), m.streams[m.begins-1]
}
func (m *progressiveDetectionModule) StartOperation(intent app.Intent) app.OperationStream {
initial, detection := m.BeginTargetDetection()
updates := make(chan app.OperationUpdate, len(initial.Targets)+1)
go func() {
defer close(updates)
for update := range detection {
phase := app.OperationRunning
status := app.OperationStatus{Kind: app.StatusProgress, Summary: "Checking Targets"}
if update.Complete {
phase = app.OperationSucceeded
status = app.OperationStatus{Kind: app.StatusSuccess, Summary: "Target checks completed"}
}
updates <- app.OperationUpdate{Snapshot: update.Snapshot, Phase: phase, Status: status}
}
}()
return app.OperationStream{Snapshot: initial, Updates: updates}
return app.OperationStream{Snapshot: cloneDetectionSnapshot(m.snapshot), Updates: m.streams[m.begins-1]}
}
func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *testing.T) {
startup := make(chan app.TargetDetectionUpdate, 4)
refresh := make(chan app.TargetDetectionUpdate, 4)
startup := make(chan app.OperationUpdate, 4)
refresh := make(chan app.OperationUpdate, 4)
module := &progressiveDetectionModule{
snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT description"}},
{ID: "docker", Description: target.Description{Name: "Docker", Summary: "Docker description"}},
}},
streams: []chan app.TargetDetectionUpdate{startup, refresh},
streams: []chan app.OperationUpdate{startup, refresh},
}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
assertUsableFrame(t, model.View(), 120, 36,
@@ -90,39 +79,40 @@ func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *
failure := errors.New("apt-config inspection failed")
startup <- module.detectionUpdate("shell", app.TargetDetectionFailed, false, "", "", failure)
model, command = applyDetectionCommand(t, model, command)
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> … Checking APT", "● Enabled Docker")
assertUsableFrame(t, model.View(), 120, 36, "! Failed Shell", "> … Checking APT", "● Enabled Docker")
startup <- module.detectionUpdate("apt", app.TargetReady, false, target.Disabled, "apt-config is unavailable", nil)
model, command = applyDetectionCommand(t, model, command)
startup <- app.TargetDetectionUpdate{Snapshot: cloneDetectionSnapshot(module.snapshot), Complete: true}
startup <- app.FailedOperationUpdate(cloneDetectionSnapshot(module.snapshot), app.OperationStatus{Summary: "Some Target checks failed", TargetID: "shell"}, nil, "shell", failure)
model, command = applyDetectionCommand(t, model, command)
if command != nil || model.selectedTargetID != "apt" {
t.Fatalf("startup completion command=%v selection=%q", command != nil, model.selectedTargetID)
if command != nil || model.selectedTargetID != "shell" {
t.Fatalf("failed startup did not focus the first failed Target: command=%v selection=%q", command != nil, model.selectedTargetID)
}
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> ○ Unavailable APT", "● Enabled Docker", "Evidence: apt-config is unavailable")
assertUsableFrame(t, model.View(), 120, 36, "> ! Failed Shell", "● Enabled Docker")
model = updateKey(model, "down")
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
if command == nil || module.begins != 2 {
t.Fatalf("refresh did not reuse progressive detection: command=%v begins=%d", command != nil, module.begins)
}
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> … Checking APT", "… Checking Docker")
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "… Checking APT", "> … Checking Docker")
refresh <- module.detectionUpdate("apt", app.TargetReady, true, target.Enabled, "apt enabled", nil)
model, command = applyDetectionCommand(t, model, command)
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> ● Enabled APT", "… Checking Docker")
if model.selectedTargetID != "apt" {
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "● Enabled APT", "> … Checking Docker")
if model.selectedTargetID != "docker" {
t.Fatalf("refresh completion moved selection to %q", model.selectedTargetID)
}
}
func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClosesStale(t *testing.T) {
startup := make(chan app.TargetDetectionUpdate)
startup := make(chan app.OperationUpdate)
module := &progressiveDetectionModule{
snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"},
}}},
streams: []chan app.TargetDetectionUpdate{startup},
streams: []chan app.OperationUpdate{startup},
}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
assertUsableFrame(t, model.View(), 80, 24, "> … Checking Shell")
@@ -132,13 +122,13 @@ func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClos
_ = module.detectionUpdate("shell", app.TargetReady, true, target.Enabled, "shell enabled", nil)
close(startup)
model, next := applyDetectionCommand(t, model, model.Init())
if next != nil || model.refreshing {
t.Fatalf("stale closure left detection active: next=%v refreshing=%v", next != nil, model.refreshing)
if next != nil || model.operation.active {
t.Fatalf("stale closure left detection active: next=%v active=%v", next != nil, model.operation.active)
}
assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "Evidence: shell enabled")
}
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.TargetDetectionUpdate {
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate {
for i := range m.snapshot.Targets {
if m.snapshot.Targets[i].ID != id {
continue
@@ -151,7 +141,11 @@ func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.Target
m.snapshot.Targets[i].Evidence = evidence
break
}
return app.TargetDetectionUpdate{Snapshot: cloneDetectionSnapshot(m.snapshot), TargetID: id}
progressPhase := app.TargetProgressSucceeded
if err != nil {
progressPhase = app.TargetProgressFailed
}
return app.RunningOperationUpdate(cloneDetectionSnapshot(m.snapshot), app.OperationStatus{Summary: "Checked Target", TargetID: id}, []app.TargetProgress{{TargetID: id, Phase: progressPhase, State: state, Evidence: evidence, Err: err}})
}
func cloneDetectionSnapshot(snapshot app.Snapshot) app.Snapshot {