feat: add contextual Target actions

This commit is contained in:
proxyctl coordinator
2026-07-17 23:14:57 +08:00
parent 2ca42bd4bd
commit 2ac088b388
16 changed files with 428 additions and 194 deletions
+149 -76
View File
@@ -57,6 +57,44 @@ type TargetAction struct {
DisabledReason string
}
type targetActionPolicy struct {
kind TargetActionKind
intent IntentKind
state target.State
desired bool
primary bool
requiresProfile bool
operation string
}
var targetActionPolicies = []targetActionPolicy{
{kind: EnableTargetAction, intent: EnableTarget, state: target.Disabled, desired: true, primary: true, requiresProfile: true, operation: "enable"},
{kind: DisableTargetAction, intent: DisableTarget, state: target.Enabled, primary: true, operation: "disable"},
{kind: ApplyProfileTargetAction, intent: ApplyProfileToTarget, state: target.External, desired: true, primary: true, requiresProfile: true, operation: "apply-Profile"},
{kind: ResolveDriftTargetAction, state: target.Drifted, primary: true},
{kind: ReapplyActiveProfileAction, intent: ReapplyActiveProfile, state: target.Drifted, desired: true, requiresProfile: true, operation: "reapply-Active-Profile"},
{kind: RestoreOriginalAction, intent: RestoreOriginal, state: target.Drifted, operation: "restore-original"},
}
func targetActionPolicyForKind(kind TargetActionKind) (targetActionPolicy, bool) {
for _, policy := range targetActionPolicies {
if policy.kind == kind {
return policy, true
}
}
return targetActionPolicy{}, false
}
// Intent creates the Application intent represented by a structured Target
// action. Presentation layers use this as the sole action-to-intent boundary.
func (kind TargetActionKind) Intent(targetID string) (Intent, bool) {
policy, ok := targetActionPolicyForKind(kind)
if !ok || policy.intent == 0 {
return Intent{}, false
}
return Intent{Kind: policy.intent, TargetID: targetID}, true
}
type TargetDetectionPhase uint8
const (
@@ -193,13 +231,16 @@ func (a *Application) snapshotLocked() Snapshot {
}
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) {
primary := TargetAction{Kind: primaryTargetActionKind(state), Available: true}
primaryKind := primaryTargetActionKind(state)
primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0}
if primaryKind == 0 {
primary.DisabledReason = "Target actions are unavailable because this Target state is unknown"
}
if reason := targetActionDisabledReason(phase, available); reason != "" {
primary.Available = false
primary.DisabledReason = reason
}
needsProfile := state == target.Disabled || state == target.External
if primary.Available && needsProfile && !hasActiveProfile {
if policy, ok := targetActionPolicyForKind(primaryKind); ok && primary.Available && policy.requiresProfile && !hasActiveProfile {
primary.Available = false
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
}
@@ -207,36 +248,32 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
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
var resolution []TargetAction
for _, policy := range targetActionPolicies {
if policy.state != target.Drifted || policy.primary || policy.intent == 0 {
continue
}
action := TargetAction{Kind: policy.kind, Available: true}
if policy.requiresProfile && !hasActiveProfile {
action.Available = false
action.DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first"
}
if reason := targetActionDisabledReason(phase, available); reason != "" {
action.Available = false
action.DisabledReason = reason
}
resolution = append(resolution, action)
}
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
for _, policy := range targetActionPolicies {
if policy.primary && policy.state == state {
return policy.kind
}
}
return 0
}
func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string {
@@ -352,7 +389,7 @@ 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)}
}
a.replaceState(fresh)
if isTargetMutationIntent(intent.Kind) || intent.Kind == ActivateProfile {
if intent.Kind == ActivateProfile {
a.refreshInspections()
}
if isTargetMutationIntent(intent.Kind) {
@@ -584,6 +621,23 @@ func (a *Application) replaceInspection(id string, inspection target.Inspection)
a.inspections[id] = inspection
}
// publishTargetDetection atomically replaces all availability, inspection, and
// failure fields from one targeted check. Detect runs before this method, so no
// Application state mutex is held while adapter code executes.
func (a *Application) publishTargetDetection(id string, detection target.Detection) Snapshot {
a.stateMu.Lock()
defer a.stateMu.Unlock()
a.detectionGeneration++
a.availabilities[id] = detection.Availability
a.inspections[id] = detection.Inspection
a.detectionErr[id] = detection.Err
a.detectionPhase[id] = TargetReady
if detection.Err != nil {
a.detectionPhase[id] = TargetDetectionFailed
}
return a.snapshotLocked()
}
func (a *Application) replaceInspections(inspections map[string]target.Inspection) {
a.stateMu.Lock()
defer a.stateMu.Unlock()
@@ -672,29 +726,66 @@ func (a *Application) refreshInspections() {
}
func isTargetMutationIntent(kind IntentKind) bool {
switch kind {
case SetTargetDesired, EnableTarget, DisableTarget, ApplyProfileToTarget, ReapplyActiveProfile, RestoreOriginal:
if kind == SetTargetDesired {
return true
default:
return false
}
for _, policy := range targetActionPolicies {
if policy.intent == kind {
return true
}
}
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 requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetActionKind, bool) {
if intent.Kind == SetTargetDesired {
return legacyTargetAction(intent.Enabled, selected.State)
}
for _, policy := range targetActionPolicies {
if policy.intent == intent.Kind {
return policy.kind, true
}
}
return 0, false
}
// legacyTargetAction is the only compatibility boundary for the former
// SetTargetDesired boolean contract. All current callers use TargetActionKind.
func legacyTargetAction(enabled bool, state target.State) (TargetActionKind, bool) {
for _, policy := range targetActionPolicies {
if policy.intent != 0 && policy.state == state && policy.desired == enabled {
return policy.kind, true
}
}
return 0, false
}
func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (TargetAction, bool) {
if selected.PrimaryAction.Kind == kind {
return selected.PrimaryAction, true
}
for _, action := range selected.DriftResolutionActions {
if action.Kind == kind {
return action, true
}
}
return TargetAction{}, false
}
func validateTargetAction(intent Intent, selected TargetSnapshot) (targetActionPolicy, error) {
kind, ok := requestedTargetAction(intent, selected)
if !ok {
return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State)
}
action, ok := structuredTargetAction(selected, kind)
if !ok {
return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State)
}
if !action.Available {
return targetActionPolicy{}, errors.New(action.DisabledReason)
}
policy, _ := targetActionPolicyForKind(kind)
return policy, nil
}
func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result {
@@ -709,29 +800,28 @@ 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)}
}
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()
if enabled && active == nil {
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
// This targeted check runs inside dispatch serialization and immediately
// before action validation. Adapter calls happen without stateMu; the full
// outcome is then published under one lock before policy is derived.
detection := detectTarget(selected, active)
snapshot := a.publishTargetDetection(intent.TargetID, detection)
selectedSnapshot := snapshot.Targets[selectedIndex]
policy, err := validateTargetAction(intent, selectedSnapshot)
if err != nil {
return Result{Snapshot: snapshot, Err: err}
}
if warning := selected.Confirmation(enabled); warning != "" && !intent.Confirm {
if warning := selected.Confirmation(policy.desired); warning != "" && !intent.Confirm {
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation,
Status: OperationStatus{Summary: confirmation.Title, TargetID: intent.TargetID}}
}
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
publish(Result{Snapshot: snapshot, Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: selectedSnapshot.State}},
Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
}
result := selected.SetDesired(context.Background(), enabled, active)
result := selected.SetDesired(context.Background(), policy.desired, active)
inspection := result.Inspection
if result.Err != nil {
inspection = selected.Inspect(context.Background(), active)
@@ -742,8 +832,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu
outcome, detail = "failure", result.Err.Error()
}
if a.logger != nil {
operation := targetOperationName(intent.Kind, enabled)
_ = 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: policy.operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
}
notice := ""
progressPhase := TargetProgressSucceeded
@@ -759,22 +848,6 @@ 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}
}
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) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {