pi-agent: Implement contextual Target actions

This commit is contained in:
proxyctl coordinator
2026-07-17 22:37:38 +08:00
parent 7c0b4481d7
commit 2ca42bd4bd
13 changed files with 793 additions and 120 deletions
+149 -11
View File
@@ -19,8 +19,13 @@ const (
UpdateProfile UpdateProfile
DeleteProfile DeleteProfile
ActivateProfile ActivateProfile
SetTargetDesired SetTargetDesired // retained for synchronous compatibility; interactive callers use the explicit intents below.
RefreshTargets RefreshTargets
EnableTarget
DisableTarget
ApplyProfileToTarget
ReapplyActiveProfile
RestoreOriginal
) )
type Intent struct { type Intent struct {
@@ -32,6 +37,26 @@ type Intent struct {
Enabled bool Enabled bool
} }
// TargetActionKind identifies a Target action independently of display labels.
type TargetActionKind uint8
const (
EnableTargetAction TargetActionKind = iota + 1
DisableTargetAction
ApplyProfileTargetAction
ResolveDriftTargetAction
ReapplyActiveProfileAction
RestoreOriginalAction
)
// TargetAction exposes whether an intent is currently available and, when it
// is not, the reason presentation layers should show instead of ignoring it.
type TargetAction struct {
Kind TargetActionKind
Available bool
DisabledReason string
}
type TargetDetectionPhase uint8 type TargetDetectionPhase uint8
const ( const (
@@ -51,6 +76,8 @@ type TargetSnapshot struct {
AvailabilityEvidence string AvailabilityEvidence string
State target.State State target.State
Evidence string Evidence string
PrimaryAction TargetAction
DriftResolutionActions []TargetAction
} }
type Snapshot struct { type Snapshot struct {
@@ -154,14 +181,78 @@ func (a *Application) snapshotLocked() Snapshot {
for i, id := range a.targetIDs { for i, id := range a.targetIDs {
availability := a.availabilities[id] availability := a.availabilities[id]
inspection := a.inspections[id] inspection := a.inspections[id]
phase := a.detectionPhase[id]
primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "")
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
ID: id, Description: a.targetDescriptions[i], DetectionPhase: a.detectionPhase[id], DetectionErr: a.detectionErr[id], ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence, Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
PrimaryAction: primary, DriftResolutionActions: resolution,
}) })
} }
return snapshot return snapshot
} }
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) {
primary := TargetAction{Kind: primaryTargetActionKind(state), Available: true}
if reason := targetActionDisabledReason(phase, available); reason != "" {
primary.Available = false
primary.DisabledReason = reason
}
needsProfile := state == target.Disabled || state == target.External
if primary.Available && needsProfile && !hasActiveProfile {
primary.Available = false
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
}
if state != target.Drifted {
return primary, nil
}
resolution := []TargetAction{
{Kind: ReapplyActiveProfileAction, Available: true},
{Kind: RestoreOriginalAction, Available: true},
}
if !hasActiveProfile {
resolution[0].Available = false
resolution[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first"
}
if reason := targetActionDisabledReason(phase, available); reason != "" {
for i := range resolution {
resolution[i].Available = false
resolution[i].DisabledReason = reason
}
}
return primary, resolution
}
func primaryTargetActionKind(state target.State) TargetActionKind {
switch state {
case target.Disabled:
return EnableTargetAction
case target.Enabled:
return DisableTargetAction
case target.External:
return ApplyProfileTargetAction
case target.Drifted:
return ResolveDriftTargetAction
default:
return 0
}
}
func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string {
switch phase {
case TargetChecking:
return "Target actions are unavailable while this Target is still being checked"
case TargetDetectionFailed:
return "Target actions are unavailable because this Target check failed; refresh Targets to retry"
default:
if !available {
return "Target actions are unavailable because this Target is unavailable"
}
return ""
}
}
type targetDetectionOutcome struct { type targetDetectionOutcome struct {
targetID string targetID string
detection target.Detection detection target.Detection
@@ -261,10 +352,10 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)} return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)}
} }
a.replaceState(fresh) a.replaceState(fresh)
if intent.Kind == SetTargetDesired || intent.Kind == ActivateProfile { if isTargetMutationIntent(intent.Kind) || intent.Kind == ActivateProfile {
a.refreshInspections() a.refreshInspections()
} }
if intent.Kind == SetTargetDesired { if isTargetMutationIntent(intent.Kind) {
return a.setTargetDesired(intent, publish) return a.setTargetDesired(intent, publish)
} }
if intent.Kind == RefreshTargets { if intent.Kind == RefreshTargets {
@@ -580,6 +671,32 @@ func (a *Application) refreshInspections() {
a.replaceInspections(inspections) a.replaceInspections(inspections)
} }
func isTargetMutationIntent(kind IntentKind) bool {
switch kind {
case SetTargetDesired, EnableTarget, DisableTarget, ApplyProfileToTarget, ReapplyActiveProfile, RestoreOriginal:
return true
default:
return false
}
}
func contextualIntentSemantics(kind IntentKind) (expected target.State, enabled bool, ok bool) {
switch kind {
case EnableTarget:
return target.Disabled, true, true
case DisableTarget:
return target.Enabled, false, true
case ApplyProfileToTarget:
return target.External, true, true
case ReapplyActiveProfile:
return target.Drifted, true, true
case RestoreOriginal:
return target.Drifted, false, true
default:
return "", false, false
}
}
func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result { func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result {
selectedIndex := -1 selectedIndex := -1
for i, id := range a.targetIDs { for i, id := range a.targetIDs {
@@ -592,11 +709,19 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)} return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)}
} }
selected := a.targets[selectedIndex] selected := a.targets[selectedIndex]
enabled := intent.Enabled
if expected, contextualEnabled, contextual := contextualIntentSemantics(intent.Kind); contextual {
actual := a.inspection(intent.TargetID).State
if actual != expected {
return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q action is no longer available because its state is %s", intent.TargetID, actual)}
}
enabled = contextualEnabled
}
active := a.activeProfile() active := a.activeProfile()
if intent.Enabled && active == nil { if enabled && active == nil {
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")} return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
} }
if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm { if warning := selected.Confirmation(enabled); warning != "" && !intent.Confirm {
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning, confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true} ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation, return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
@@ -606,7 +731,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}}, 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}}) Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
} }
result := selected.SetDesired(context.Background(), intent.Enabled, active) result := selected.SetDesired(context.Background(), enabled, active)
inspection := result.Inspection inspection := result.Inspection
if result.Err != nil { if result.Err != nil {
inspection = selected.Inspect(context.Background(), active) inspection = selected.Inspect(context.Background(), active)
@@ -617,10 +742,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
outcome, detail = "failure", result.Err.Error() outcome, detail = "failure", result.Err.Error()
} }
if a.logger != nil { if a.logger != nil {
operation := "disable" operation := targetOperationName(intent.Kind, enabled)
if intent.Enabled {
operation = "enable"
}
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)}) _ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
} }
notice := "" notice := ""
@@ -637,6 +759,22 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
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} 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}
} }
func targetOperationName(kind IntentKind, enabled bool) string {
switch kind {
case ApplyProfileToTarget:
return "apply-Profile"
case ReapplyActiveProfile:
return "reapply-Active-Profile"
case RestoreOriginal:
return "restore-original"
default:
if enabled {
return "enable"
}
return "disable"
}
}
func create(state profile.State, input profile.Profile) (profile.State, string, error) { func create(state profile.State, input profile.Profile) (profile.State, string, error) {
p, err := profile.ValidateAndNormalize(input) p, err := profile.ValidateAndNormalize(input)
if err != nil { if err != nil {
+233
View File
@@ -0,0 +1,233 @@
package app_test
import (
"errors"
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
func TestApplicationSnapshotStructuresContextualTargetActions(t *testing.T) {
states := []struct {
id string
state target.State
action app.TargetActionKind
}{
{id: "disabled", state: target.Disabled, action: app.EnableTargetAction},
{id: "enabled", state: target.Enabled, action: app.DisableTargetAction},
{id: "external", state: target.External, action: app.ApplyProfileTargetAction},
{id: "drifted", state: target.Drifted, action: app.ResolveDriftTargetAction},
}
adapters := make([]target.Target, 0, len(states))
for _, item := range states {
adapters = append(adapters, targetWithID{fakeTarget: &fakeTarget{available: true, state: item.state}, id: item.id})
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters)
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
snapshot := application.Snapshot()
for i, want := range states {
got := snapshot.Targets[i]
if got.PrimaryAction.Kind != want.action || !got.PrimaryAction.Available || got.PrimaryAction.DisabledReason != "" {
t.Errorf("%s primary action = %#v, want available %v", want.state, got.PrimaryAction, want.action)
}
}
drift := snapshot.Targets[3]
if len(drift.DriftResolutionActions) != 2 || drift.DriftResolutionActions[0].Kind != app.ReapplyActiveProfileAction || drift.DriftResolutionActions[1].Kind != app.RestoreOriginalAction {
t.Fatalf("Drift Resolution actions = %#v", drift.DriftResolutionActions)
}
for _, action := range drift.DriftResolutionActions {
if !action.Available || action.DisabledReason != "" {
t.Errorf("Drift Resolution action = %#v, want available", action)
}
}
}
func TestApplicationDispatchesExplicitContextualTargetIntents(t *testing.T) {
tests := []struct {
name string
state target.State
intent app.IntentKind
enabled bool
}{
{name: "Enable", state: target.Disabled, intent: app.EnableTarget, enabled: true},
{name: "Disable", state: target.Enabled, intent: app.DisableTarget, enabled: false},
{name: "Apply Profile", state: target.External, intent: app.ApplyProfileToTarget, enabled: true},
{name: "Reapply Active Profile", state: target.Drifted, intent: app.ReapplyActiveProfile, enabled: true},
{name: "Restore Original", state: target.Drifted, intent: app.RestoreOriginal, enabled: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Disabled}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.state = test.state
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()})
if result.Err != nil || len(adapter.calls) != 1 || adapter.calls[0] != test.enabled {
t.Fatalf("result = %#v, SetDesired calls = %#v", result, adapter.calls)
}
wantState := target.Disabled
if test.enabled {
wantState = target.Enabled
}
if result.Snapshot.Targets[0].State != wantState {
t.Fatalf("final state = %s, want %s", result.Snapshot.Targets[0].State, wantState)
}
})
}
}
func TestApplicationRejectsContextualIntentWhenTargetStateChanged(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.External}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "External") || len(adapter.calls) != 0 {
t.Fatalf("stale Enable intent = %#v, calls = %#v", result, adapter.calls)
}
}
func TestApplicationReapplyPublishesTruthfulDriftWhenAdapterCannotConverge(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Disabled}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.state = target.Drifted
adapter.setState = target.Drifted
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.ReapplyActiveProfile, TargetID: adapter.ID()})
if result.Err != nil || result.Snapshot.Targets[0].State != target.Drifted || result.Progress[0].State != target.Drifted {
t.Fatalf("truthful non-converged reapply = %#v", result)
}
}
func TestApplicationRejectsStructuredUnavailableActionWithoutAdapterMutation(t *testing.T) {
adapter := &fakeTarget{available: false, state: target.External}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
adapter.calls = nil
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
if action := application.Snapshot().Targets[0].PrimaryAction; action.Available || !strings.Contains(action.DisabledReason, "unavailable") {
t.Fatalf("unavailable action = %#v", action)
}
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "unavailable") || len(adapter.calls) != 0 {
t.Fatalf("unavailable action result = %#v, calls=%#v", result, adapter.calls)
}
}
func TestApplicationRejectsProfileDependentIntentWithoutActiveProfile(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.External}
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.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), "no Active Profile") || len(adapter.calls) != 0 {
t.Fatalf("missing Active Profile result = %#v, calls=%#v", result, adapter.calls)
}
}
func TestApplicationReapplyAndRestoreFailuresPublishFreshDriftedState(t *testing.T) {
for _, test := range []struct {
name string
intent app.IntentKind
err string
}{
{name: "reapply", intent: app.ReapplyActiveProfile, err: "write failed"},
{name: "restore ownership loss", intent: app.RestoreOriginal, err: "restoration evidence is insufficient; refusing to overwrite external changes"},
} {
t.Run(test.name, func(t *testing.T) {
adapter := &fakeTarget{available: true, state: target.Drifted, setErr: errors.New(test.err)}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
createAndActivate(t, application)
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()})
if result.Err == nil || !strings.Contains(result.Err.Error(), test.err) || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "actual state" {
t.Fatalf("failure = %#v", result)
}
if len(adapter.calls) != 1 || adapter.calls[0] != (test.intent == app.ReapplyActiveProfile) {
t.Fatalf("SetDesired calls = %#v", adapter.calls)
}
})
}
}
func TestApplicationSnapshotDisablesProfileDependentActionsWithoutActiveProfile(t *testing.T) {
adapters := []target.Target{
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Disabled}, id: "disabled"},
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.External}, id: "external"},
targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Drifted}, id: "drifted"},
}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters)
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil {
t.Fatal(result.Err)
}
snapshot := application.Snapshot()
for _, index := range []int{0, 1} {
action := snapshot.Targets[index].PrimaryAction
if action.Available || !strings.Contains(action.DisabledReason, "no Active Profile") {
t.Errorf("profile-dependent primary action = %#v", action)
}
}
drift := snapshot.Targets[2]
if !drift.PrimaryAction.Available {
t.Fatalf("Restore Original should keep Resolve available: %#v", drift.PrimaryAction)
}
if got := drift.DriftResolutionActions[0]; got.Available || !strings.Contains(got.DisabledReason, "no Active Profile") {
t.Fatalf("Reapply action without Active Profile = %#v", got)
}
if got := drift.DriftResolutionActions[1]; !got.Available {
t.Fatalf("Restore Original action without Active Profile = %#v", got)
}
}
+6 -3
View File
@@ -16,6 +16,7 @@ type fakeTarget struct {
available bool available bool
state target.State state target.State
setErr error setErr error
setState target.State
confirmation string confirmation string
calls []bool calls []bool
} }
@@ -34,7 +35,9 @@ func (f *fakeTarget) Confirmation(bool) string { return f.confirmation }
func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult { func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
f.calls = append(f.calls, enabled) f.calls = append(f.calls, enabled)
if f.setErr == nil { if f.setErr == nil {
if enabled { if f.setState != "" {
f.state = f.setState
} else if enabled {
f.state = target.Enabled f.state = target.Enabled
} else { } else {
f.state = target.Disabled f.state = target.Disabled
@@ -84,11 +87,11 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) {
} }
createAndActivate(t, application) createAndActivate(t, application)
warning := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true}) warning := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon"})
if warning.ConfirmationRequest == nil || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled { if warning.ConfirmationRequest == nil || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled {
t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls) t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls)
} }
confirmed := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true, Confirm: true}) confirmed := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon", Confirm: true})
if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled { if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled {
t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls) t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls)
} }
+135
View File
@@ -0,0 +1,135 @@
package tui
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/target"
)
func completeTargetSnapshot(state target.State, primary app.TargetAction) app.Snapshot {
return app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: state, PrimaryAction: primary,
}}}
}
func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T) {
tests := []struct {
name string
state target.State
action app.TargetActionKind
intent app.IntentKind
label string
}{
{name: "Disabled", state: target.Disabled, action: app.EnableTargetAction, intent: app.EnableTarget, label: "Enable"},
{name: "Enabled", state: target.Enabled, action: app.DisableTargetAction, intent: app.DisableTarget, label: "Disable"},
{name: "External", state: target.External, action: app.ApplyProfileTargetAction, intent: app.ApplyProfileToTarget, label: "Apply Profile"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
initial := completeTargetSnapshot(test.state, app.TargetAction{Kind: test.action, Available: true})
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View()
if !strings.Contains(view, "Action: "+test.label) || !strings.Contains(view, "Space: "+test.label) {
t.Fatalf("contextual action not rendered:\n%s", view)
}
if test.state == target.External && (!strings.Contains(view, "proxyctl will begin managing this configuration") || !strings.Contains(view, "while preserving restorable state")) {
t.Fatalf("Apply Profile ownership explanation missing:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent {
t.Fatalf("intent = %#v, command=%v", module.intents, command != nil)
}
})
}
}
func TestCompleteModelOffersBothExplicitDriftResolutionIntents(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{
{Kind: app.ReapplyActiveProfileAction, Available: true},
{Kind: app.RestoreOriginalAction, Available: true},
}
for _, test := range []struct {
key string
intent app.IntentKind
}{
{key: "r", intent: app.ReapplyActiveProfile},
{key: "o", intent: app.RestoreOriginal},
} {
t.Run(test.key, func(t *testing.T) {
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
view := model.View()
for _, want := range []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"} {
if !strings.Contains(view, want) {
t.Fatalf("missing %q:\n%s", want, view)
}
}
updated, command := model.Update(keyMessage(test.key))
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent {
t.Fatalf("resolution intent = %#v, command=%v", module.intents, command != nil)
}
})
}
}
func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *testing.T) {
initial := completeTargetSnapshot(target.Disabled, app.TargetAction{
Kind: app.EnableTargetAction, DisabledReason: "No Active Profile is selected; create or select one before enabling",
})
module := &targetModule{snapshot: initial}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View()
if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable disabled") {
t.Fatalf("structured disabled reason missing:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "No Active Profile is selected") {
t.Fatalf("disabled action mutated: intents=%#v\n%s", module.intents, model.View())
}
}
func TestCompleteModelRestoreOwnershipLossFailsTruthfully(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{
{Kind: app.ReapplyActiveProfileAction, Available: true},
{Kind: app.RestoreOriginalAction, Available: true},
}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("restoration evidence is insufficient; external changes were not overwritten")}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
updated, command := model.Update(keyMessage("o"))
model = updated.(Model)
if command == nil {
t.Fatal("Restore Original did not start")
}
updated, _ = model.Update(command())
model = updated.(Model)
if len(module.intents) != 1 || module.intents[0].Kind != app.RestoreOriginal || !strings.Contains(model.View(), "Current error") || !strings.Contains(model.View(), "Drifted") {
t.Fatalf("ownership-loss result: intents=%#v\n%s", module.intents, model.View())
}
}
func TestCompleteModelCancelsDriftResolutionWithoutMutation(t *testing.T) {
initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true})
initial.Targets[0].DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}
module := &targetModule{snapshot: initial}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, " ")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || strings.Contains(model.View(), "r: Reapply Active Profile") {
t.Fatalf("safe-default cancellation mutated: intents=%#v\n%s", module.intents, model.View())
}
}
+19
View File
@@ -5,6 +5,25 @@ import (
"proxyctl/internal/target" "proxyctl/internal/target"
) )
func targetActionLabel(kind app.TargetActionKind) string {
switch kind {
case app.EnableTargetAction:
return "Enable"
case app.DisableTargetAction:
return "Disable"
case app.ApplyProfileTargetAction:
return "Apply Profile"
case app.ResolveDriftTargetAction:
return "Resolve"
case app.ReapplyActiveProfileAction:
return "Reapply Active Profile"
case app.RestoreOriginalAction:
return "Restore Original"
default:
return "Unavailable"
}
}
type dashboardPane uint8 type dashboardPane uint8
const ( const (
+12
View File
@@ -142,6 +142,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
var details strings.Builder var details strings.Builder
details.WriteString(p.theme.section.Render(item.Description.Name)) details.WriteString(p.theme.section.Render(item.Description.Name))
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence)
if item.PrimaryAction.Kind != 0 {
fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind))
if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" {
fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason)
}
switch item.PrimaryAction.Kind {
case app.ApplyProfileTargetAction:
details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.")
case app.ResolveDriftTargetAction:
details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.")
}
}
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr)
} }
+4 -4
View File
@@ -51,11 +51,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, "down") model = updateKey(model, "down")
model = resize(model, 80, 24) model = resize(model, 80, 24)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view) t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
} }
model = resize(model, 120, 36) model = resize(model, 120, 36)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view) t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
} }
@@ -199,7 +199,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
"State: ! Drifted", "State: ! Drifted",
"Docker daemon selected evidence.", "Docker daemon selected evidence.",
"Status: Refresh completed; selected Target remains actionable.", "Status: Refresh completed; selected Target remains actionable.",
"Space: toggle", "Space: Resolve",
"p: manage Proxy Profiles", "p: manage Proxy Profiles",
) )
@@ -209,7 +209,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
"> ! Drifted Docker daemon", "> ! Drifted Docker daemon",
"State: ! Drifted", "State: ! Drifted",
"Docker daemon selected evidence.", "Docker daemon selected evidence.",
"Space: toggle", "Space: Resolve",
) )
} }
+81 -15
View File
@@ -10,7 +10,6 @@ import (
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/probe" "proxyctl/internal/probe"
"proxyctl/internal/profile" "proxyctl/internal/profile"
"proxyctl/internal/target"
) )
type page int type page int
@@ -42,6 +41,7 @@ type Model struct {
dashboardFocus dashboardPane dashboardFocus dashboardPane
showUnavailable bool showUnavailable bool
detailsOffset int detailsOffset int
driftResolutionTargetID string
form formState form formState
confirm bool confirm bool
status string status string
@@ -237,27 +237,54 @@ type mutationAvailability struct {
compactReason string compactReason string
} }
// targetMutationAvailability is the sole policy used by both input handling // targetMutationAvailability consumes the Application's structured action
// and rendering, so the displayed disabled reason cannot drift from behavior. // policy. It adds only Model-owned serialization state and never derives an
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, activeProfile string, operationBlocked bool) mutationAvailability { // action from Target state or display text.
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability {
switch { switch {
case operationBlocked: case operationBlocked:
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"} return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
case !selectedOK: case !selectedOK:
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"} return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
case selected.DetectionPhase == app.TargetChecking: case !selected.PrimaryAction.Available:
return mutationAvailability{reason: "Target mutation is disabled while this Target is still being checked", compactReason: "checking Target"} reason := selected.PrimaryAction.DisabledReason
case selected.DetectionPhase == app.TargetDetectionFailed: if reason == "" {
return mutationAvailability{reason: "Target mutation is disabled because this Target check failed; refresh Targets to retry", compactReason: "Target check failed"} reason = "This Target action is unavailable"
case !selected.Available: }
return mutationAvailability{reason: "Target mutation is disabled because this Target is unavailable", compactReason: "Target unavailable"} return mutationAvailability{reason: reason, compactReason: "unavailable"}
case (selected.State == target.Disabled || selected.State == target.External) && activeProfile == "":
return mutationAvailability{reason: "Target mutation is disabled because there is no Active Profile; open Profile management first", compactReason: "no Active Profile"}
default: default:
return mutationAvailability{available: true} return mutationAvailability{available: true}
} }
} }
func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) {
for _, action := range selected.DriftResolutionActions {
if action.Kind == kind {
return action, true
}
}
return app.TargetAction{Kind: kind}, false
}
func targetIntent(kind app.TargetActionKind, targetID string) (app.Intent, bool) {
intent := app.Intent{TargetID: targetID}
switch kind {
case app.EnableTargetAction:
intent.Kind = app.EnableTarget
case app.DisableTargetAction:
intent.Kind = app.DisableTarget
case app.ApplyProfileTargetAction:
intent.Kind = app.ApplyProfileToTarget
case app.ReapplyActiveProfileAction:
intent.Kind = app.ReapplyActiveProfile
case app.RestoreOriginalAction:
intent.Kind = app.RestoreOriginal
default:
return app.Intent{}, false
}
return intent, true
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.operation.confirmation != nil { if m.operation.confirmation != nil {
switch key.String() { switch key.String() {
@@ -273,6 +300,37 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.driftResolutionTargetID != "" {
switch key.String() {
case "enter", "esc", "n":
m.driftResolutionTargetID = ""
return m, nil
case "r", "o":
kind := app.ReapplyActiveProfileAction
if key.String() == "o" {
kind = app.RestoreOriginalAction
}
selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID)
if !ok {
m.driftResolutionTargetID = ""
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"}
return m, nil
}
action, ok := driftResolutionAction(selected, kind)
if !ok || !action.Available {
reason := action.DisabledReason
if reason == "" {
reason = "This Drift Resolution action is unavailable"
}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason}
return m, nil
}
intent, _ := targetIntent(kind, selected.ID)
m.driftResolutionTargetID = ""
return m, m.startOperation(intent)
}
return m, nil
}
if key.String() == "e" && m.operation.currentError != nil { if key.String() == "e" && m.operation.currentError != nil {
m.operation.errorExpanded = !m.operation.errorExpanded m.operation.errorExpanded = !m.operation.errorExpanded
m.operation.errorOffset = 0 m.operation.errorOffset = 0
@@ -356,13 +414,20 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case " ": case " ":
selected, ok := catalog.selected(m.selectedTargetID) selected, ok := catalog.selected(m.selectedTargetID)
availability := targetMutationAvailability(selected, ok, m.snapshot.ActiveProfile, m.operation.active || m.operation.quitRequested) availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested)
if !availability.available { if !availability.available {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason} m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
break break
} }
desired := selected.State == target.Disabled || selected.State == target.External if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction {
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired} m.driftResolutionTargetID = selected.ID
break
}
intent, ok := targetIntent(selected.PrimaryAction.Kind, selected.ID)
if !ok {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
break
}
return m, m.startOperation(intent) return m, m.startOperation(intent)
} }
@@ -562,6 +627,7 @@ func (m Model) View() string {
dashboardFocus: m.dashboardFocus, dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable, showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset, detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
form: m.form, form: m.form,
confirm: m.confirm, confirm: m.confirm,
confirmation: m.operation.confirmation, confirmation: m.operation.confirmation,
+36 -2
View File
@@ -11,6 +11,7 @@ import (
"proxyctl/internal/app" "proxyctl/internal/app"
"proxyctl/internal/probe" "proxyctl/internal/probe"
"proxyctl/internal/profile" "proxyctl/internal/profile"
"proxyctl/internal/target"
) )
type recordingModule struct { type recordingModule struct {
@@ -18,10 +19,43 @@ type recordingModule struct {
intents []app.Intent intents []app.Intent
} }
func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot } func withTestTargetActions(snapshot app.Snapshot) app.Snapshot {
for i := range snapshot.Targets {
item := &snapshot.Targets[i]
if item.PrimaryAction.Kind != 0 {
continue
}
switch item.State {
case target.Disabled:
item.PrimaryAction.Kind = app.EnableTargetAction
case target.Enabled:
item.PrimaryAction.Kind = app.DisableTargetAction
case target.External:
item.PrimaryAction.Kind = app.ApplyProfileTargetAction
case target.Drifted:
item.PrimaryAction.Kind = app.ResolveDriftTargetAction
item.DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: snapshot.ActiveProfile != ""}, {Kind: app.RestoreOriginalAction, Available: true}}
}
item.PrimaryAction.Available = item.Available && item.DetectionPhase == app.TargetReady
if item.PrimaryAction.Available && (item.State == target.Disabled || item.State == target.External) && snapshot.ActiveProfile == "" {
item.PrimaryAction.Available = false
item.PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first"
}
if item.DetectionPhase == app.TargetChecking {
item.PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked"
} else if item.DetectionPhase == app.TargetDetectionFailed {
item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target check failed; refresh Targets to retry"
} else if !item.Available {
item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target is unavailable"
}
}
return snapshot
}
func (m *recordingModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *recordingModule) Dispatch(intent app.Intent) app.Result { func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent) m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot, Notice: "done"} return app.Result{Snapshot: withTestTargetActions(m.snapshot), Notice: "done"}
} }
func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream { func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream {
result := m.Dispatch(intent) result := m.Dispatch(intent)
+8 -4
View File
@@ -18,7 +18,7 @@ type interactiveScriptModule struct {
intents []app.Intent intents []app.Intent
} }
func (m *interactiveScriptModule) Snapshot() app.Snapshot { return m.snapshot } func (m *interactiveScriptModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result { func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent) m.intents = append(m.intents, intent)
return app.Result{Snapshot: m.snapshot} return app.Result{Snapshot: m.snapshot}
@@ -27,18 +27,18 @@ func (m *interactiveScriptModule) StartOperation(intent app.Intent) app.Operatio
m.intents = append(m.intents, intent) m.intents = append(m.intents, intent)
stream := m.streams[0] stream := m.streams[0]
m.streams = m.streams[1:] m.streams = m.streams[1:]
return app.OperationStream{Snapshot: m.snapshot, Updates: stream} return app.OperationStream{Snapshot: withTestTargetActions(m.snapshot), Updates: stream}
} }
func operationSnapshot() app.Snapshot { func operationSnapshot() app.Snapshot {
return app.Snapshot{ return withTestTargetActions(app.Snapshot{
ActiveProfile: "office", ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}}, Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}},
Targets: []app.TargetSnapshot{ Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled}, {ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled}, {ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled},
}, },
} })
} }
func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) { func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
@@ -194,6 +194,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
module := &interactiveScriptModule{snapshot: checking} module := &interactiveScriptModule{snapshot: checking}
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
model.snapshot.Targets[0].DetectionPhase = app.TargetChecking model.snapshot.Targets[0].DetectionPhase = app.TargetChecking
model.snapshot.Targets[0].PrimaryAction.Available = false
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked"
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model) model = updated.(Model)
@@ -202,6 +204,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
} }
model.snapshot.Targets[0].DetectionPhase = app.TargetReady model.snapshot.Targets[0].DetectionPhase = app.TargetReady
model.snapshot.Targets[0].PrimaryAction.Available = false
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first"
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model) model = updated.(Model)
if command != nil || !strings.Contains(model.View(), "disabled because there is no Active Profile") { if command != nil || !strings.Contains(model.View(), "disabled because there is no Active Profile") {
+32 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"proxyctl/internal/app"
"proxyctl/internal/profile" "proxyctl/internal/profile"
) )
@@ -41,6 +42,13 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
operationProgress: model.operationProgress, operationProgress: model.operationProgress,
}, },
} }
if model.driftResolutionTargetID != "" && model.confirmation == nil {
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
page.dashboard = nil
page.primary, page.context = p.driftResolutionView(selected)
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/Esc: Cancel"
}
}
if model.confirmation != nil { if model.confirmation != nil {
var details strings.Builder var details strings.Builder
details.WriteString(p.theme.section.Render(model.confirmation.Title)) details.WriteString(p.theme.section.Render(model.confirmation.Title))
@@ -57,6 +65,26 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
return page return page
} }
func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (string, string) {
var choices strings.Builder
fmt.Fprintf(&choices, "%s\nChoose how to resolve %s without an automatic recovery direction.", p.theme.section.Render("Drift Resolution — "+selected.Description.Name), selected.Description.Name)
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
action, ok := driftResolutionAction(selected, kind)
label := targetActionLabel(kind)
if !ok || !action.Available {
reason := action.DisabledReason
if reason == "" {
reason = "unavailable"
}
fmt.Fprintf(&choices, "\n\n%s — unavailable: %s", label, reason)
continue
}
fmt.Fprintf(&choices, "\n\n%s", label)
}
context := "Reapply Active Profile writes the Active Profile again. Restore Original asks the Target Adapter to use its existing ownership and restoration safeguards; insufficient evidence fails safely without overwriting external changes."
return choices.String(), context
}
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
if model.exitDeferred { if model.exitDeferred {
return "Exit pending; browsing remains safe while the current mutation completes" return "Exit pending; browsing remains safe while the current mutation completes"
@@ -74,11 +102,12 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
} }
actions := "↑/k ↓/j: select Tab: focus details " + visibility actions := "↑/k ↓/j: select Tab: focus details " + visibility
selected, ok := catalog.selected(model.selectedTargetID) selected, ok := catalog.selected(model.selectedTargetID)
availability := targetMutationAvailability(selected, ok, model.snapshot.ActiveProfile, model.operationActive || model.exitDeferred) availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available { if availability.available {
actions = "Space: toggle " + actions actions = "Space: " + label + " " + actions
} else { } else {
actions = "Space: disabled (" + availability.compactReason + ") " + actions actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
} }
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit" footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit"
if model.operationActive { if model.operationActive {
+1
View File
@@ -62,6 +62,7 @@ type presentationState struct {
dashboardFocus dashboardPane dashboardFocus dashboardPane
showUnavailable bool showUnavailable bool
detailsOffset int detailsOffset int
driftResolutionTargetID string
form formState form formState
confirm bool confirm bool
confirmation *app.Confirmation confirmation *app.Confirmation
+13 -14
View File
@@ -18,9 +18,10 @@ type targetModule struct {
intents []app.Intent intents []app.Intent
} }
func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot } func (m *targetModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
func (m *targetModule) Dispatch(intent app.Intent) app.Result { func (m *targetModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent) m.intents = append(m.intents, intent)
m.result.Snapshot = withTestTargetActions(m.result.Snapshot)
m.snapshot = m.result.Snapshot m.snapshot = m.result.Snapshot
return m.result return m.result
} }
@@ -49,7 +50,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"} module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"}
model := NewModel(module) model := NewModel(module)
view := model.View() view := model.View()
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") { if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target panel:\n%s", view) t.Fatalf("Target panel:\n%s", view)
} }
@@ -61,7 +62,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
message := command() message := command()
updated, _ = model.Update(message) updated, _ = model.Update(message)
model = updated.(Model) model = updated.(Model)
if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") { if len(module.intents) != 1 || module.intents[0].Kind != app.ApplyProfileToTarget || !strings.Contains(model.View(), "Enabled") {
t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View()) t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View())
} }
} }
@@ -109,7 +110,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
}}} }}}
model := NewModel(module) model := NewModel(module)
view := model.View() view := model.View()
if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") { if strings.Contains(view, "APT") || !strings.Contains(view, "Action: Enable") || !strings.Contains(view, "integration installed") {
t.Fatalf("main Target panel:\n%s", view) t.Fatalf("main Target panel:\n%s", view)
} }
model = updateKey(model, "?") model = updateKey(model, "?")
@@ -346,17 +347,15 @@ func TestTargetPanelRendersEveryTargetState(t *testing.T) {
} }
} }
func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) { func TestDriftedTargetWithoutActiveProfileRequiresExplicitResolutionDirection(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}} initial := withTestTargetActions(app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}})
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}} initial.Targets[0].DriftResolutionActions[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile"
module := &targetModule{snapshot: initial}
model := NewModel(module) model := NewModel(module)
_, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
if command == nil { model = updated.(Model)
t.Fatal("Drifted Target did not start disable") if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "Drift Resolution") || !strings.Contains(model.View(), "Reapply Active Profile is unavailable") {
} t.Fatalf("Drifted Target chose a direction implicitly: intents=%#v\n%s", module.intents, model.View())
_ = command()
if len(module.intents) != 1 || module.intents[0].Enabled {
t.Fatalf("Drifted Target intent = %#v", module.intents)
} }
} }