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
+81 -66
View File
@@ -53,15 +53,6 @@ type TargetSnapshot struct {
Evidence string
}
// TargetDetectionUpdate carries the complete authoritative Application state
// after one Target changed. TargetID identifies that change without duplicating
// availability, inspection, or failure fields already present in Snapshot.
type TargetDetectionUpdate struct {
Snapshot Snapshot
TargetID string
Complete bool
}
type Snapshot struct {
Profiles []profile.Profile
ActiveProfile string
@@ -70,9 +61,6 @@ type Snapshot struct {
type TargetProgress struct {
TargetID string
// Applying is retained for synchronous DispatchProgress callers. New
// interactive callers use the structured Phase field.
Applying bool
Phase TargetProgressPhase
State target.State
Evidence string
@@ -80,15 +68,12 @@ type TargetProgress struct {
}
type Result struct {
Snapshot Snapshot
Notice string
// Confirmation remains for existing synchronous callers.
Confirmation string
Snapshot Snapshot
Notice string
ConfirmationRequest *Confirmation
Status OperationStatus
Progress []TargetProgress
FirstFailedTargetID string
Complete bool
Err error
}
@@ -97,20 +82,6 @@ type Module interface {
Dispatch(Intent) Result
}
// ProgressiveModule is implemented by Applications that can expose each
// coordinated Target update while an Active Profile transition is running.
// The final Result has Complete set and closes the stream.
type ProgressiveModule interface {
DispatchProgress(Intent) <-chan Result
}
// ProgressiveDetectionModule exposes a useful all-Checking snapshot
// synchronously, followed by one structured Target result at a time. Startup
// and explicit refresh use this same presentation-independent contract.
type ProgressiveDetectionModule interface {
BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate)
}
type Application struct {
store profile.Store
state profile.State
@@ -196,7 +167,13 @@ type targetDetectionOutcome struct {
detection target.Detection
}
func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUpdate) {
type targetDetectionUpdate struct {
snapshot Snapshot
targetID string
complete bool
}
func (a *Application) beginTargetDetection() (Snapshot, <-chan targetDetectionUpdate) {
a.stateMu.Lock()
a.detectionGeneration++
generation := a.detectionGeneration
@@ -208,7 +185,7 @@ func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUp
checking := a.snapshotLocked()
a.stateMu.Unlock()
updates := make(chan TargetDetectionUpdate, len(a.targets)+1)
updates := make(chan targetDetectionUpdate, len(a.targets)+1)
outcomes := make(chan targetDetectionOutcome, len(a.targets))
for i, adapter := range a.targets {
adapter, id := adapter, a.targetIDs[i]
@@ -233,7 +210,7 @@ func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUp
a.inspections[outcome.targetID] = outcome.detection.Inspection
a.detectionPhase[outcome.targetID] = phase
a.detectionErr[outcome.targetID] = outcome.detection.Err
update := TargetDetectionUpdate{Snapshot: a.snapshotLocked(), TargetID: outcome.targetID}
update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID}
// The buffered send cannot wait on a consumer and happens before a
// newer Begin call can make this generation stale.
updates <- update
@@ -241,7 +218,7 @@ func (a *Application) BeginTargetDetection() (Snapshot, <-chan TargetDetectionUp
}
a.stateMu.Lock()
if generation == a.detectionGeneration {
updates <- TargetDetectionUpdate{Snapshot: a.snapshotLocked(), Complete: true}
updates <- targetDetectionUpdate{snapshot: a.snapshotLocked(), complete: true}
}
a.stateMu.Unlock()
}()
@@ -262,17 +239,6 @@ func (a *Application) Dispatch(intent Intent) Result {
return a.dispatch(intent, nil)
}
func (a *Application) DispatchProgress(intent Intent) <-chan Result {
results := make(chan Result, 2*len(a.targets)+1)
go func() {
defer close(results)
final := a.dispatch(intent, func(update Result) { results <- update })
final.Complete = true
results <- final
}()
return results
}
func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
// Serialize local mutation workflows even when tests intentionally omit the
// cross-process MutationLock.
@@ -302,8 +268,7 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
return a.setTargetDesired(intent, publish)
}
if intent.Kind == RefreshTargets {
a.refreshTargets()
return Result{Snapshot: a.Snapshot(), Notice: "Refreshed Target availability and state"}
return a.refreshTargets(publish)
}
if intent.Kind == ActivateProfile {
return a.activateProfile(intent, publish)
@@ -371,8 +336,9 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
Title: "Confirm Active Profile change", Summary: "Changing the Active Profile has high-impact Target effects.",
Details: warnings, ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
}
return Result{Snapshot: a.Snapshot(), Confirmation: message, ConfirmationRequest: confirmation,
Status: OperationStatus{Kind: StatusConfirmation, Summary: confirmation.Title, Detail: confirmation.Summary}}
_ = message
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
Status: OperationStatus{Summary: confirmation.Title}}
}
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
@@ -410,8 +376,8 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
name := a.targetDescriptions[targetIndex].Name
updated[id] = true
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: id, Applying: true, Phase: TargetProgressRunning, State: a.inspection(id).State}}, Notice: "Updating " + name + " Target",
Status: OperationStatus{Kind: StatusProgress, Summary: "Updating " + name + " Target", TargetID: id}})
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: id, Phase: TargetProgressRunning, State: a.inspection(id).State}}, Notice: "Updating " + name + " Target",
Status: OperationStatus{Summary: "Updating " + name + " Target", TargetID: id}})
}
operation := adapter.SetDesired(context.Background(), true, active)
inspection := operation.Inspection
@@ -430,9 +396,9 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
item := TargetProgress{TargetID: id, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: safeErr}
progress = append(progress, item)
if publish != nil {
status := OperationStatus{Kind: StatusSuccess, Summary: name + " Target update completed", TargetID: id}
status := OperationStatus{Summary: name + " Target update completed", TargetID: id}
if safeErr != nil {
status = OperationStatus{Kind: StatusError, Summary: name + " Target update failed", Detail: safeErr.Error(), TargetID: id}
status = OperationStatus{Summary: name + " Target update failed", TargetID: id}
}
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: name + " Target update completed", Status: status, Err: safeErr})
}
@@ -458,11 +424,11 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
}
a.mergeInspections(remaining)
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name),
Status: OperationStatus{Kind: StatusSuccess, Summary: fmt.Sprintf("Activated Profile %q", intent.Name)}}
Status: OperationStatus{Summary: fmt.Sprintf("Activated Profile %q", intent.Name)}}
if len(failures) > 0 {
result.Err = errors.Join(failures...)
result.Notice = fmt.Sprintf("Activated Profile %q with %d Target update failure(s); successful updates were kept and no global rollback was attempted", intent.Name, len(failures))
result.Status = OperationStatus{Kind: StatusError, Summary: fmt.Sprintf("Activated Profile %q with %d Target update failure(s)", intent.Name, len(failures)), Detail: result.Err.Error()}
result.Status = OperationStatus{Summary: fmt.Sprintf("Activated Profile %q with %d Target update failure(s)", intent.Name, len(failures))}
for _, item := range progress {
if item.Err != nil {
result.FirstFailedTargetID = item.TargetID
@@ -548,10 +514,61 @@ func (a *Application) mergeInspections(inspections map[string]target.Inspection)
}
}
func (a *Application) refreshTargets() {
_, updates := a.BeginTargetDetection()
for range updates {
func targetByID(snapshot Snapshot, id string) (TargetSnapshot, string) {
for _, item := range snapshot.Targets {
if item.ID == id {
name := item.Description.Name
if name == "" {
name = id
}
return item, name
}
}
return TargetSnapshot{ID: id}, id
}
func (a *Application) refreshTargets(publish func(Result)) Result {
checking, updates := a.beginTargetDetection()
if publish != nil {
progress := make([]TargetProgress, 0, len(checking.Targets))
for _, item := range checking.Targets {
progress = append(progress, TargetProgress{TargetID: item.ID, Phase: TargetProgressRunning, State: item.State})
}
publish(Result{Snapshot: checking, Status: OperationStatus{Summary: "Checking Target availability and state"}, Progress: progress})
}
var failures []error
firstFailed := ""
final := checking
for update := range updates {
final = update.snapshot
if update.complete {
continue
}
item, name := targetByID(update.snapshot, update.targetID)
progress := TargetProgress{TargetID: update.targetID, Phase: TargetProgressSucceeded, State: item.State, Evidence: item.Evidence}
status := OperationStatus{Summary: "Checked " + name + " Target", TargetID: update.targetID}
if item.DetectionPhase == TargetDetectionFailed {
progress.Phase = TargetProgressFailed
progress.Err = item.DetectionErr
status.Summary = "Could not check " + name + " Target"
if item.DetectionErr != nil {
failures = append(failures, fmt.Errorf("%s Target: %w", name, item.DetectionErr))
}
if firstFailed == "" {
firstFailed = update.targetID
}
}
if publish != nil {
publish(Result{Snapshot: update.snapshot, Status: status, Progress: []TargetProgress{progress}, Err: progress.Err})
}
}
result := Result{Snapshot: final, Notice: "Target availability and state checks completed", Status: OperationStatus{Summary: "Target availability and state checks completed"}}
if len(failures) > 0 {
result.Err = errors.Join(failures...)
result.FirstFailedTargetID = firstFailed
result.Status = OperationStatus{Summary: "Some Target checks failed", TargetID: firstFailed}
}
return result
}
func (a *Application) refreshInspections() {
@@ -582,12 +599,12 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm {
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
return Result{Snapshot: a.Snapshot(), Confirmation: warning, ConfirmationRequest: confirmation,
Status: OperationStatus{Kind: StatusConfirmation, Summary: confirmation.Title, Detail: confirmation.Summary, TargetID: intent.TargetID}}
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
Status: OperationStatus{Summary: confirmation.Title, TargetID: intent.TargetID}}
}
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Applying: true, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
Status: OperationStatus{Kind: StatusProgress, Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
}
result := selected.SetDesired(context.Background(), intent.Enabled, active)
inspection := result.Inspection
@@ -608,15 +625,13 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
}
notice := ""
progressPhase := TargetProgressSucceeded
status := OperationStatus{Kind: StatusSuccess, TargetID: intent.TargetID}
status := OperationStatus{TargetID: intent.TargetID}
if result.Err == nil {
notice = fmt.Sprintf("%s Target is now %s", a.targetDescriptions[selectedIndex].Name, inspection.State)
status.Summary = notice
} else {
progressPhase = TargetProgressFailed
status.Kind = StatusError
status.Summary = a.targetDescriptions[selectedIndex].Name + " Target update failed"
status.Detail = result.Err.Error()
}
progress := TargetProgress{TargetID: intent.TargetID, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: result.Err}
return Result{Snapshot: a.Snapshot(), Notice: notice, Status: status, Progress: []TargetProgress{progress}, FirstFailedTargetID: map[bool]string{true: intent.TargetID}[result.Err != nil], Err: result.Err}