fix(actions): follow the resource's advertised lifecycle verb

Proxmox guests advertise reboot while container platforms advertise
restart, and the Assistant tool schema plus the resolved-resource action
lists teach models both words. A pulse_control call with the wrong
spelling failed with CapabilityNotFound even though the equivalent
capability was advertised. Plan requests now follow the resource's own
vocabulary when the requested verb is missing but its lifecycle synonym
is advertised; the rewritten name flows into the plan, the audit record
and the executor. Non-synonym verbs still fail closed.
This commit is contained in:
rcourtman
2026-07-18 20:37:16 +01:00
parent a318c287c6
commit dc7204bf44
2 changed files with 61 additions and 0 deletions
+32
View File
@@ -224,6 +224,37 @@ func (e *ActionNotFoundError) Error() string {
return fmt.Sprintf("action %q not found", e.ActionID)
}
// lifecycleCapabilitySynonym maps interchangeable lifecycle verbs. Proxmox
// guests advertise "reboot" while container platforms advertise "restart";
// proposers (and Assistant models) use the words interchangeably.
func lifecycleCapabilitySynonym(name string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(name)) {
case "restart":
return "reboot", true
case "reboot":
return "restart", true
}
return "", false
}
// resolveAdvertisedCapabilityName follows the resource's own vocabulary when
// the requested capability is not advertised but a lifecycle synonym of it
// is. The rewritten name flows into the plan, the audit record, and the
// executor, so every later stage sees the advertised verb.
func resolveAdvertisedCapabilityName(capabilities []unified.ResourceCapability, requested string) string {
if _, found := actionplanner.FindCapability(capabilities, requested); found {
return requested
}
synonym, hasSynonym := lifecycleCapabilitySynonym(requested)
if !hasSynonym {
return requested
}
if _, found := actionplanner.FindCapability(capabilities, synonym); found {
return synonym
}
return requested
}
// CapabilityNotFoundError reports that the resource does not advertise the
// requested capability. It unwraps to actionplanner.ErrCapabilityNotFound.
type CapabilityNotFoundError struct {
@@ -371,6 +402,7 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified
if !ok || resource == nil {
return unified.ActionPlan{}, &ResourceNotFoundError{ResourceID: req.ResourceID}
}
req.CapabilityName = resolveAdvertisedCapabilityName(resource.Capabilities, req.CapabilityName)
planner := actionplanner.Planner{}
var plan unified.ActionPlan
+29
View File
@@ -1010,6 +1010,35 @@ func TestPlanPersistsPendingAuditAndLifecycle(t *testing.T) {
}
}
func TestPlanFollowsAdvertisedLifecycleSynonym(t *testing.T) {
now := time.Now().UTC()
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
// The fixture advertises only "restart"; a request for the lifecycle
// synonym "reboot" must plan the advertised verb.
req := restartRequest()
req.CapabilityName = "reboot"
plan, err := env.service.Plan(context.Background(), "default", req, testActionActor("requester", "default"))
if err != nil {
t.Fatalf("Plan with lifecycle synonym: %v", err)
}
record, ok, err := env.store.GetActionAudit(plan.ActionID)
if err != nil || !ok {
t.Fatalf("GetActionAudit: ok=%v err=%v", ok, err)
}
if record.Request.CapabilityName != "restart" {
t.Fatalf("persisted capability = %q, want the advertised verb %q", record.Request.CapabilityName, "restart")
}
// Verbs without an advertised synonym still fail closed.
unknown := restartRequest()
unknown.CapabilityName = "shutdown"
var capErr *CapabilityNotFoundError
if _, err := env.service.Plan(context.Background(), "default", unknown, testActionActor("requester", "default")); !errors.As(err, &capErr) {
t.Fatalf("non-synonym verb error = %v, want CapabilityNotFoundError", err)
}
}
func TestPlanFailsClosedOnUnknownResourceAndCapability(t *testing.T) {
now := time.Now().UTC()
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))