From dc7204bf440e1275e24bd7aeb29bce6d0b15b43e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 18 Jul 2026 20:37:16 +0100 Subject: [PATCH] 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. --- internal/actionlifecycle/service.go | 32 ++++++++++++++++++++++++ internal/actionlifecycle/service_test.go | 29 +++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/internal/actionlifecycle/service.go b/internal/actionlifecycle/service.go index 73b3445d6..8587025c4 100644 --- a/internal/actionlifecycle/service.go +++ b/internal/actionlifecycle/service.go @@ -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 diff --git a/internal/actionlifecycle/service_test.go b/internal/actionlifecycle/service_test.go index 7e3a30bd7..9878c09ef 100644 --- a/internal/actionlifecycle/service_test.go +++ b/internal/actionlifecycle/service_test.go @@ -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))