fix: harden operations and configuration recovery

This commit is contained in:
proxyctl coordinator
2026-08-30 11:13:45 +08:00
parent e34bf7d989
commit 8928986830
27 changed files with 542 additions and 125 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ func TestCompleteModelDialogResizeAndHelpPreserveMeaningfulFocus(t *testing.T) {
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile {
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile {
t.Fatalf("resize and Help changed confirmation focus: intents=%#v", module.intents)
}
}
+20 -21
View File
@@ -145,13 +145,24 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.operation.unexpectedClose()
return m, m.operation.deferredQuit()
}
completedIntent := m.operation.intent
m.snapshot = progress.update.Snapshot
m.retainTargetSelection()
m.retainProfileSelection(m.selectedProfileName, m.selected)
decoded := progress.update.Decode()
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
m.selectedTargetID = firstFailed
m.detailsOffset = 0
}
if decoded.IsTerminal() && isProfileMutation(completedIntent.Kind) {
if decoded.Kind == app.OperationSucceeded {
m.page = profilesPage
m.form = formState{}
m.status = decoded.Status.Summary
} else if decoded.Err != nil {
m.status = "Error: " + decoded.Err.Error()
}
}
if m.operation.confirmation != nil {
m.page = mainPage
if m.help.open {
@@ -399,7 +410,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case confirmationAccepted:
name := m.profileDelete.name
m.profileDelete = nil
m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "")
return m, m.startOperation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true})
case confirmationCancelled:
m.profileDelete = nil
}
@@ -519,13 +530,8 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
normalized := m.form.validation.Normalized
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
result := m.applyProfileMutation(intent, normalized.Name)
if result.Err != nil {
return m, nil
}
m.page = profilesPage
m.form = formState{}
return m, nil
m.selectedProfileName = normalized.Name
return m, m.startOperation(intent)
case tea.KeyCtrlD:
address, token, started := m.form.startProbe()
if !started {
@@ -556,20 +562,13 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
return m, m.startOperation(intent)
}
func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) app.Result {
formerName, formerIndex := m.selectedProfileName, m.selected
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if preferredName == "" {
preferredName = formerName
func isProfileMutation(kind app.IntentKind) bool {
switch kind {
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
return true
default:
return false
}
m.retainProfileSelection(preferredName, formerIndex)
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
} else {
m.status = result.Notice
}
return result
}
func (m Model) selectedProfile() (profile.Profile, bool) {
+14 -1
View File
@@ -149,7 +149,20 @@ func updateKey(model Model, key string) Model {
msg = tea.KeyMsg{Type: keyType}
}
updated, _ := model.Update(msg)
return updated.(Model)
model = updated.(Model)
// In-memory test modules publish terminal operation updates synchronously.
// Drain only updates already available; real in-flight operation tests remain
// under their explicit channel control.
for model.operation.active && isProfileMutation(model.operation.intent.Kind) {
select {
case update, open := <-model.operation.updates:
updated, _ = model.Update(operationProgress{updates: model.operation.updates, update: update, closed: !open})
model = updated.(Model)
default:
return model
}
}
return model
}
func updateRunes(model Model, value string) Model {
+20
View File
@@ -22,6 +22,7 @@ type operationCoordinator struct {
active bool
intent app.Intent
updates <-chan app.OperationUpdate
cancel func()
progress map[string]app.TargetProgressPhase
status app.OperationStatus
currentError *operationError
@@ -85,6 +86,7 @@ func (o *operationCoordinator) start(application app.InteractiveModule, intent a
o.active = true
o.intent = intent
o.updates = stream.Updates
o.cancel = stream.Cancel
o.progress = make(map[string]app.TargetProgressPhase)
o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner))
if intent.TargetID != "" {
@@ -100,6 +102,8 @@ func operationStartingSummary(kind app.IntentKind) string {
return "Checking Target availability and state…"
case app.ActivateProfile:
return "Changing Active Profile…"
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
return "Saving Proxy Profile changes…"
default:
return "Starting Target mutation…"
}
@@ -171,6 +175,8 @@ func relatedResolution(failed, success app.Intent) bool {
return success.Kind == app.RefreshTargets
case failed.Kind == app.ActivateProfile:
return success.Kind == app.ActivateProfile && failed.Name == success.Name
case isProfileMutation(failed.Kind):
return isProfileMutation(success.Kind) && failed.Name == success.Name
case isTargetMutation(failed.Kind):
return isTargetMutation(success.Kind) && failed.TargetID != "" && failed.TargetID == success.TargetID
default:
@@ -188,9 +194,19 @@ func isTargetMutation(kind app.IntentKind) bool {
}
func (o *operationCoordinator) finish() {
// start seeds speculative Running progress before the stream publishes its
// first update. A terminal update may contain no replacement progress (most
// notably a confirmation request), so never let that transient phase outlive
// the operation that created it.
for targetID, phase := range o.progress {
if phase == app.TargetProgressRunning {
delete(o.progress, targetID)
}
}
o.active = false
o.intent = app.Intent{}
o.updates = nil
o.cancel = nil
}
func (o *operationCoordinator) unexpectedClose() {
@@ -225,6 +241,7 @@ func (o *operationCoordinator) confirmedIntent() (app.Intent, bool) {
}
intent := *o.confirmationIntent
intent.Confirm = true
intent.ConfirmationToken = o.confirmation.Token
o.confirmation = nil
o.confirmationDialog = confirmationDialog{}
o.confirmationIntent = nil
@@ -245,6 +262,9 @@ func (o operationCoordinator) deferredQuit() tea.Cmd {
func (o *operationCoordinator) requestQuit() tea.Cmd {
o.quitRequested = true
if o.active {
if o.cancel != nil {
o.cancel()
}
return nil
}
return tea.Quit
-2
View File
@@ -199,8 +199,6 @@ func (f *formState) toggleMode() tea.Cmd {
return f.rebuildInputs(0)
}
func (f formState) fieldCount() int { return len(f.inputs) }
func (f formState) currentField() profile.Field {
if f.field < 0 || f.field >= len(f.inputs) {
return ""
+2 -2
View File
@@ -146,8 +146,8 @@ func TestCompleteModelProfileDeletionDialogIsSafeByDefaultAndSupportsExplicitSel
model = updateKey(model, "right")
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
t.Fatalf("focused Delete did not dispatch the confirmed mutation: intents=%#v", module.intents)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
t.Fatalf("focused Delete did not start the confirmed mutation: intents=%#v", module.intents)
}
assertUsableFrame(t, model.View(), 80, 24, "> remote", "Proxy: http://remote.example:8080")
}
+2 -2
View File
@@ -76,8 +76,8 @@ func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T)
}
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
model = updated.(Model)
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") {
t.Fatalf("cancel mutated = intents %#v\n%s", module.intents, model.View())
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") || strings.Contains(model.View(), "Applying") {
t.Fatalf("cancel mutated or left stale progress = intents %#v\n%s", module.intents, model.View())
}
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})