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
+157 -19
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 (
@@ -43,14 +68,16 @@ const (
) )
type TargetSnapshot struct { type TargetSnapshot struct {
ID string ID string
Description target.Description Description target.Description
DetectionPhase TargetDetectionPhase DetectionPhase TargetDetectionPhase
DetectionErr error DetectionErr error
Available bool Available bool
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",
) )
} }
+117 -51
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
@@ -31,22 +30,23 @@ type formState struct {
} }
type Model struct { type Model struct {
application app.InteractiveModule application app.InteractiveModule
presentation terminalPresentation presentation terminalPresentation
snapshot app.Snapshot snapshot app.Snapshot
page page page page
width int width int
height int height int
selected int selected int
selectedTargetID string selectedTargetID string
dashboardFocus dashboardPane dashboardFocus dashboardPane
showUnavailable bool showUnavailable bool
detailsOffset int detailsOffset int
form formState driftResolutionTargetID string
confirm bool form formState
status string confirm bool
operation operationCoordinator status string
detect func(context.Context, string) (probe.Capabilities, error) operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
} }
// probeDone carries one finished Protocol Probe back to the form. address is // probeDone carries one finished Protocol Probe back to the form. address is
@@ -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)
} }
@@ -553,26 +618,27 @@ func (m *Model) selectName(name string) {
func (m Model) View() string { func (m Model) View() string {
return m.presentation.Render(presentationState{ return m.presentation.Render(presentationState{
snapshot: m.snapshot, snapshot: m.snapshot,
page: m.page, page: m.page,
width: m.width, width: m.width,
height: m.height, height: m.height,
selected: m.selected, selected: m.selected,
selectedTargetID: m.selectedTargetID, selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus, dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable, showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset, detailsOffset: m.detailsOffset,
form: m.form, driftResolutionTargetID: m.driftResolutionTargetID,
confirm: m.confirm, form: m.form,
confirmation: m.operation.confirmation, confirm: m.confirm,
status: m.status, confirmation: m.operation.confirmation,
operationStatus: m.operation.status, status: m.status,
currentError: m.operation.currentError, operationStatus: m.operation.status,
errorExpanded: m.operation.errorExpanded, currentError: m.operation.currentError,
errorOffset: m.operation.errorOffset, errorExpanded: m.operation.errorExpanded,
exitDeferred: m.operation.quitRequested && m.operationInFlight(), errorOffset: m.operation.errorOffset,
operationActive: m.operation.active, exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationProgress: m.operation.progress, operationActive: m.operation.active,
operationProgress: m.operation.progress,
}) })
} }
+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 {
+21 -20
View File
@@ -53,26 +53,27 @@ func defaultModelOptions() modelOptions {
} }
type presentationState struct { type presentationState struct {
snapshot app.Snapshot snapshot app.Snapshot
page page page page
width int width int
height int height int
selected int selected int
selectedTargetID string selectedTargetID string
dashboardFocus dashboardPane dashboardFocus dashboardPane
showUnavailable bool showUnavailable bool
detailsOffset int detailsOffset int
form formState driftResolutionTargetID string
confirm bool form formState
confirmation *app.Confirmation confirm bool
status string confirmation *app.Confirmation
operationStatus app.OperationStatus status string
currentError *operationError operationStatus app.OperationStatus
errorExpanded bool currentError *operationError
errorOffset int errorExpanded bool
exitDeferred bool errorOffset int
operationActive bool exitDeferred bool
operationProgress map[string]app.TargetProgressPhase operationActive bool
operationProgress map[string]app.TargetProgressPhase
} }
// terminalPresentation is the terminal-presentation Module. Application // terminalPresentation is the terminal-presentation Module. Application
+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)
} }
} }