feat: unify interactive operation coordination
This commit is contained in:
+81
-66
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user