Add API-first action planning endpoint

This commit is contained in:
rcourtman
2026-05-03 23:51:54 +01:00
parent 3e497ecb70
commit b11f57ed62
12 changed files with 1387 additions and 2 deletions
+55
View File
@@ -137,6 +137,61 @@ Report an incorrect merge (creates exclusions).
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
```
### Unified Action Planning
`POST /api/actions/plan`
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
Request:
```json
{
"requestId": "agent-run-123",
"resourceId": "vm:42",
"capabilityName": "restart",
"params": { "mode": "graceful" },
"reason": "Recover after confirmed outage",
"requestedBy": "agent:oncall-helper"
}
```
Response:
```json
{
"actionId": "act_...",
"requestId": "agent-run-123",
"allowed": true,
"requiresApproval": true,
"approvalPolicy": "admin",
"predictedBlastRadius": ["vm:42", "node-1"],
"rollbackAvailable": false,
"message": "Plan created for restart on web-42. Execution requires admin approval and is not performed by this endpoint.",
"plannedAt": "2026-05-03T10:00:00Z",
"expiresAt": "2026-05-03T10:05:00Z",
"resourceVersion": "resource:sha256:...",
"policyVersion": "policy:sha256:...",
"planHash": "sha256:...",
"preflight": {
"target": "vm:42",
"currentState": "web-42 is warning",
"intendedChange": "Restart the VM",
"dryRunAvailable": false,
"dryRunSummary": "No provider-supported dry run is advertised for this capability.",
"safetyChecks": [
"Resource was resolved from the unified resource registry.",
"Capability is advertised by the resource contract.",
"This endpoint plans only; it does not approve or execute the action.",
"Execution requires admin approval."
],
"verificationSteps": [
"Refresh the resource and confirm the expected state after execution.",
"Review /api/audit/actions/{actionId}/events for lifecycle evidence."
],
"generatedAt": "2026-05-03T10:00:00Z"
}
}
```
### Resource Metadata
User notes, tags, and custom URLs for resources.
@@ -121,6 +121,13 @@ management, and fleet control surfaces.
22. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
23. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Agent lifecycle and fleet-operation surfaces may consume
`POST /api/actions/plan` for resource capability planning, but the action plan
contract remains API-owned through `internal/api/actions.go` and
`internal/actionplanner/planner.go`. Agent lifecycle work must not define a
parallel approval policy, blast-radius model, stale-plan hash, or execution
contract for those resource actions.
The node setup modal boundary must keep guided setup and manual credential
submission separate. For new PVE/PBS setup, Agent Install and Direct Connection
setup-script modes are command-driven auto-registration paths; Token ID/Value
@@ -29,6 +29,8 @@ product API routes free of maintainer commercial analytics.
2. `internal/api/resources.go`
3. `internal/api/alerts.go`
4. `internal/api/activity_audit_handlers.go`
5. `internal/api/actions.go`
6. `internal/actionplanner/planner.go`
5. `frontend-modern/src/types/api.ts`
6. `frontend-modern/src/types/actionAudit.ts`
7. `frontend-modern/src/api/actionAudit.ts`
@@ -272,6 +274,14 @@ the canonical monitored-system blocked payload.
resource API JSON, and exercised with backend contract tests plus the
canonical `useUnifiedResources` frontend hook proof whenever it changes.
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
Plan-only unified action planning is part of that same API-first action
contract: `POST /api/actions/plan` must route through
`internal/api/actions.go`, `internal/actionplanner/planner.go`, and
`internal/api/contract_test.go` together, returning deterministic
`ActionPlan` identity, approval policy, blast radius, resource/policy
versions, plan hash, and preflight checks without approving or executing the
capability. MCP, CLI, and UI consumers may adapt this payload, but they must
not become the source of truth for action planning semantics.
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection.
`/api/resources/{id}/timeline` and `/api/resources/{id}/facets` must keep
resource timelines relationship-aware by opting into the canonical
@@ -74,6 +74,11 @@ Storage/recovery may also consume org-scoped session identity from the shared
API boundary, but durable user IDs remain the authorization principal. Contact
email may support display or legacy lookup only; storage and recovery surfaces
must not create their own email-keyed membership or entitlement interpretation.
Storage/recovery remediation or restore-adjacent workflows may consume
`POST /api/actions/plan` only as the API-owned resource capability planning
contract. This subsystem must not create a storage-local approval policy,
stale-plan hash, blast-radius model, or execution protocol outside
`internal/api/actions.go` and `internal/actionplanner/planner.go`.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
+807
View File
@@ -0,0 +1,807 @@
package actionplanner
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"regexp"
"sort"
"strings"
"time"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const DefaultPlanTTL = 5 * time.Minute
var ErrCapabilityNotFound = errors.New("resource capability is not advertised")
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
if e == nil {
return ""
}
if e.Field == "" {
return e.Message
}
return e.Field + ": " + e.Message
}
func AsValidationError(err error) (*ValidationError, bool) {
var target *ValidationError
if errors.As(err, &target) {
return target, true
}
return nil, false
}
type Planner struct {
Now func() time.Time
TTL time.Duration
}
func (p Planner) Plan(req unified.ActionRequest, resource unified.Resource) (unified.ActionPlan, error) {
req = normalizeRequest(req)
if err := validateRequest(req); err != nil {
return unified.ActionPlan{}, err
}
resourceID := unified.CanonicalResourceID(resource.ID)
if resourceID == "" {
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "resource has no canonical id"}
}
if req.ResourceID != resourceID {
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "request resource does not match planned resource"}
}
capability, ok := findCapability(resource.Capabilities, req.CapabilityName)
if !ok {
return unified.ActionPlan{}, ErrCapabilityNotFound
}
if err := validateParams(req.Params, capability.Params); err != nil {
return unified.ActionPlan{}, err
}
plannedAt := p.now()
ttl := p.ttl()
policy := normalizeApprovalPolicy(capability.MinimumApprovalLevel)
requiresApproval := policy == unified.ApprovalAdmin || policy == unified.ApprovalMultiFactor
resourceVersion := ResourceVersion(resource)
policyVersion := PolicyVersion(capability)
actionID := actionID(req, resourceVersion, policyVersion)
plan := unified.ActionPlan{
ActionID: actionID,
RequestID: req.RequestID,
Allowed: true,
RequiresApproval: requiresApproval,
ApprovalPolicy: policy,
PredictedBlastRadius: predictedBlastRadius(resource),
RollbackAvailable: false,
Message: planMessage(resource, capability, policy),
PlannedAt: plannedAt,
ExpiresAt: plannedAt.Add(ttl),
ResourceVersion: resourceVersion,
PolicyVersion: policyVersion,
Preflight: buildPreflight(resource, capability, req, actionID, policy, plannedAt),
}
plan.PlanHash = planHash(req, plan)
plan.Preflight = unified.NormalizeActionPreflight(plan.Preflight, req, plan)
return plan, nil
}
func ResourceVersion(resource unified.Resource) string {
payload := struct {
ID string `json:"id"`
Type unified.ResourceType `json:"type"`
Technology string `json:"technology,omitempty"`
Name string `json:"name"`
Status unified.ResourceStatus `json:"status"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
Sources []unified.DataSource `json:"sources,omitempty"`
Identity normalizedIdentity `json:"identity,omitempty"`
ParentID string `json:"parentId,omitempty"`
Capabilities []normalizedCapabilityForResourceHash `json:"capabilities,omitempty"`
Relationships []normalizedRelationship `json:"relationships,omitempty"`
RecentChanges []normalizedChange `json:"recentChanges,omitempty"`
IncidentCode string `json:"incidentCode,omitempty"`
IncidentStatus storageIncidentHashFields `json:"incidentStatus,omitempty"`
SourceStatus map[unified.DataSource]sourceStatusHash `json:"sourceStatus,omitempty"`
}{
ID: unified.CanonicalResourceID(resource.ID),
Type: unified.CanonicalResourceType(resource.Type),
Technology: strings.TrimSpace(resource.Technology),
Name: strings.TrimSpace(resource.Name),
Status: resource.Status,
AISafeSummary: strings.TrimSpace(resource.AISafeSummary),
Sources: normalizeDataSources(resource.Sources),
Identity: normalizeIdentity(resource.Identity),
Capabilities: normalizeCapabilitiesForResourceHash(resource.Capabilities),
Relationships: normalizeRelationships(unified.ResourceRelationshipsWithCanonicalParent(resource)),
RecentChanges: normalizeChanges(resource.RecentChanges),
IncidentCode: strings.TrimSpace(resource.IncidentCode),
IncidentStatus: storageIncidentHashFields{
Severity: string(resource.IncidentSeverity),
Summary: strings.TrimSpace(resource.IncidentSummary),
Category: strings.TrimSpace(resource.IncidentCategory),
Priority: resource.IncidentPriority,
},
SourceStatus: normalizeSourceStatus(resource.SourceStatus),
}
if resource.ParentID != nil {
payload.ParentID = unified.CanonicalResourceID(*resource.ParentID)
}
return "resource:sha256:" + hashJSON(payload, 12)
}
func PolicyVersion(capability unified.ResourceCapability) string {
payload := struct {
Name string `json:"name"`
Type unified.CapabilityType `json:"type"`
Description string `json:"description"`
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
Platform string `json:"platform,omitempty"`
InternalHandler string `json:"internalHandler,omitempty"`
Params []unified.CapabilityParam `json:"params,omitempty"`
NormalizedPolicy unified.ActionApprovalLevel `json:"normalizedPolicy"`
ParamNames []string `json:"paramNames,omitempty"`
}{
Name: strings.TrimSpace(capability.Name),
Type: capability.Type,
Description: strings.TrimSpace(capability.Description),
MinimumApprovalLevel: capability.MinimumApprovalLevel,
Platform: strings.TrimSpace(capability.Platform),
InternalHandler: strings.TrimSpace(capability.InternalHandler),
Params: normalizeCapabilityParams(capability.Params),
NormalizedPolicy: normalizeApprovalPolicy(capability.MinimumApprovalLevel),
ParamNames: sortedCapabilityParamNames(capability.Params),
}
return "policy:sha256:" + hashJSON(payload, 12)
}
func (p Planner) now() time.Time {
if p.Now != nil {
return p.Now().UTC()
}
return time.Now().UTC()
}
func (p Planner) ttl() time.Duration {
if p.TTL > 0 {
return p.TTL
}
return DefaultPlanTTL
}
func normalizeRequest(req unified.ActionRequest) unified.ActionRequest {
req.RequestID = strings.TrimSpace(req.RequestID)
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
req.CapabilityName = strings.TrimSpace(req.CapabilityName)
req.Reason = strings.TrimSpace(req.Reason)
req.RequestedBy = strings.TrimSpace(req.RequestedBy)
if req.Params == nil {
req.Params = map[string]any{}
}
return req
}
func validateRequest(req unified.ActionRequest) error {
if req.RequestID == "" {
return &ValidationError{Field: "requestId", Message: "request id is required"}
}
if req.ResourceID == "" {
return &ValidationError{Field: "resourceId", Message: "resource id is required"}
}
if req.CapabilityName == "" {
return &ValidationError{Field: "capabilityName", Message: "capability name is required"}
}
if req.Reason == "" {
return &ValidationError{Field: "reason", Message: "reason is required"}
}
if req.RequestedBy == "" {
return &ValidationError{Field: "requestedBy", Message: "requester is required"}
}
return nil
}
func findCapability(capabilities []unified.ResourceCapability, name string) (unified.ResourceCapability, bool) {
name = strings.TrimSpace(name)
for _, capability := range capabilities {
if strings.TrimSpace(capability.Name) == name {
return capability, true
}
}
return unified.ResourceCapability{}, false
}
func validateParams(params map[string]any, specs []unified.CapabilityParam) error {
specByName := make(map[string]unified.CapabilityParam, len(specs))
for _, spec := range specs {
name := strings.TrimSpace(spec.Name)
if name == "" {
return &ValidationError{Field: "params", Message: "capability declares an unnamed parameter"}
}
if _, exists := specByName[name]; exists {
return &ValidationError{Field: "params." + name, Message: "capability declares duplicate parameter"}
}
spec.Name = name
specByName[name] = spec
}
for name := range params {
trimmed := strings.TrimSpace(name)
if trimmed == "" || trimmed != name {
return &ValidationError{Field: "params", Message: "parameter names must be non-empty and trimmed"}
}
if _, ok := specByName[name]; !ok {
return &ValidationError{Field: "params." + name, Message: "parameter is not declared by this capability"}
}
}
for _, spec := range specByName {
value, exists := params[spec.Name]
if !exists || isEmptyParamValue(value) {
if spec.Required {
return &ValidationError{Field: "params." + spec.Name, Message: "required parameter is missing"}
}
continue
}
if err := validateParamValue(value, spec); err != nil {
return err
}
}
return nil
}
func isEmptyParamValue(value any) bool {
if value == nil {
return true
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
func validateParamValue(value any, spec unified.CapabilityParam) error {
if err := validateParamType(value, spec); err != nil {
return err
}
if len(spec.Enum) > 0 {
valueString := enumString(value)
for _, candidate := range spec.Enum {
if valueString == strings.TrimSpace(candidate) {
return nil
}
}
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value is outside the allowed enum"}
}
if spec.Pattern != "" {
valueString, ok := value.(string)
if !ok {
return &ValidationError{Field: "params." + spec.Name, Message: "pattern validation requires a string value"}
}
matched, err := regexp.MatchString(spec.Pattern, valueString)
if err != nil {
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares an invalid pattern"}
}
if !matched {
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value does not match the required pattern"}
}
}
return nil
}
func validateParamType(value any, spec unified.CapabilityParam) error {
typ := strings.ToLower(strings.TrimSpace(spec.Type))
switch typ {
case "", "any":
return nil
case "string":
if _, ok := value.(string); ok {
return nil
}
case "bool", "boolean":
if _, ok := value.(bool); ok {
return nil
}
case "int", "integer":
if isInteger(value) {
return nil
}
case "number", "float", "float64":
if isNumber(value) {
return nil
}
case "object", "map":
if isMap(value) {
return nil
}
case "array", "list":
if isSlice(value) {
return nil
}
default:
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares unsupported parameter type " + typ}
}
return &ValidationError{Field: "params." + spec.Name, Message: "parameter must be " + typ}
}
func isInteger(value any) bool {
switch v := value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
case float32:
return math.Trunc(float64(v)) == float64(v)
case float64:
return math.Trunc(v) == v
case json.Number:
_, err := v.Int64()
return err == nil
default:
return false
}
}
func isNumber(value any) bool {
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
return true
default:
return false
}
}
func isMap(value any) bool {
if _, ok := value.(map[string]any); ok {
return true
}
rv := reflect.ValueOf(value)
return rv.IsValid() && rv.Kind() == reflect.Map
}
func isSlice(value any) bool {
rv := reflect.ValueOf(value)
return rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array)
}
func enumString(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case json.Number:
return v.String()
default:
return fmt.Sprint(value)
}
}
func normalizeApprovalPolicy(level unified.ActionApprovalLevel) unified.ActionApprovalLevel {
switch level {
case unified.ApprovalNone, unified.ApprovalDryRun, unified.ApprovalAdmin, unified.ApprovalMultiFactor:
return level
default:
return unified.ApprovalAdmin
}
}
func actionID(req unified.ActionRequest, resourceVersion string, policyVersion string) string {
payload := struct {
RequestID string `json:"requestId"`
ResourceID string `json:"resourceId"`
CapabilityName string `json:"capabilityName"`
Params map[string]any `json:"params"`
Reason string `json:"reason"`
RequestedBy string `json:"requestedBy"`
ResourceVersion string `json:"resourceVersion"`
PolicyVersion string `json:"policyVersion"`
}{
RequestID: req.RequestID,
ResourceID: req.ResourceID,
CapabilityName: req.CapabilityName,
Params: req.Params,
Reason: req.Reason,
RequestedBy: req.RequestedBy,
ResourceVersion: resourceVersion,
PolicyVersion: policyVersion,
}
return "act_" + hashJSON(payload, 16)
}
func planHash(req unified.ActionRequest, plan unified.ActionPlan) string {
payload := struct {
ActionID string `json:"actionId"`
Request unified.ActionRequest `json:"request"`
Allowed bool `json:"allowed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovalPolicy unified.ActionApprovalLevel `json:"approvalPolicy"`
PredictedBlastRadius []string `json:"predictedBlastRadius"`
RollbackAvailable bool `json:"rollbackAvailable"`
ResourceVersion string `json:"resourceVersion"`
PolicyVersion string `json:"policyVersion"`
}{
ActionID: plan.ActionID,
Request: req,
Allowed: plan.Allowed,
RequiresApproval: plan.RequiresApproval,
ApprovalPolicy: plan.ApprovalPolicy,
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
RollbackAvailable: plan.RollbackAvailable,
ResourceVersion: plan.ResourceVersion,
PolicyVersion: plan.PolicyVersion,
}
return "sha256:" + hashJSON(payload, 32)
}
func predictedBlastRadius(resource unified.Resource) []string {
seen := map[string]struct{}{}
add := func(id string) {
id = unified.CanonicalResourceID(id)
if id == "" {
return
}
seen[id] = struct{}{}
}
resourceID := unified.CanonicalResourceID(resource.ID)
add(resourceID)
for _, relationship := range unified.ResourceRelationshipsWithCanonicalParent(resource) {
if !relationship.Active {
continue
}
add(relationship.SourceID)
add(relationship.TargetID)
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
if resourceID != "" {
for i, id := range out {
if id == resourceID {
copy(out[1:i+1], out[0:i])
out[0] = resourceID
break
}
}
}
return out
}
func planMessage(resource unified.Resource, capability unified.ResourceCapability, policy unified.ActionApprovalLevel) string {
target := displayResourceName(resource)
action := strings.TrimSpace(capability.Name)
if target == "" {
target = unified.CanonicalResourceID(resource.ID)
}
switch policy {
case unified.ApprovalDryRun:
return fmt.Sprintf("Plan created for %s on %s. Policy allows planning/dry-run only; execution is not performed by this endpoint.", action, target)
case unified.ApprovalNone:
return fmt.Sprintf("Plan created for %s on %s. Execution is not performed by this endpoint.", action, target)
default:
return fmt.Sprintf("Plan created for %s on %s. Execution requires %s approval and is not performed by this endpoint.", action, target, policy)
}
}
func buildPreflight(resource unified.Resource, capability unified.ResourceCapability, req unified.ActionRequest, actionID string, policy unified.ActionApprovalLevel, plannedAt time.Time) *unified.ActionPreflight {
resourceID := unified.CanonicalResourceID(resource.ID)
action := strings.TrimSpace(capability.Name)
description := strings.TrimSpace(capability.Description)
intendedChange := description
if intendedChange == "" {
intendedChange = fmt.Sprintf("Run %s on %s", action, resourceID)
}
safetyChecks := []string{
"Resource was resolved from the unified resource registry.",
"Capability is advertised by the resource contract.",
"This endpoint plans only; it does not approve or execute the action.",
}
switch policy {
case unified.ApprovalDryRun:
safetyChecks = append(safetyChecks, "Capability policy is dry-run-only.")
case unified.ApprovalNone:
safetyChecks = append(safetyChecks, "Capability policy allows execution without additional approval.")
default:
safetyChecks = append(safetyChecks, fmt.Sprintf("Execution requires %s approval.", policy))
}
return &unified.ActionPreflight{
Target: resourceID,
CurrentState: currentState(resource),
IntendedChange: intendedChange,
DryRunAvailable: false,
DryRunSummary: "No provider-supported dry run is advertised for this capability.",
SafetyChecks: safetyChecks,
VerificationSteps: []string{
"Refresh the resource and confirm the expected state after execution.",
"Review /api/audit/actions/" + actionID + "/events for lifecycle evidence.",
},
GeneratedAt: plannedAt,
}
}
func currentState(resource unified.Resource) string {
name := displayResourceName(resource)
status := strings.TrimSpace(string(resource.Status))
if name == "" {
name = unified.CanonicalResourceID(resource.ID)
}
if status == "" {
return name
}
return fmt.Sprintf("%s is %s", name, status)
}
func displayResourceName(resource unified.Resource) string {
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.DisplayName) != "" {
return strings.TrimSpace(resource.Canonical.DisplayName)
}
if strings.TrimSpace(resource.Name) != "" {
return strings.TrimSpace(resource.Name)
}
return unified.CanonicalResourceID(resource.ID)
}
type normalizedIdentity struct {
MachineID string `json:"machineId,omitempty"`
DMIUUID string `json:"dmiUuid,omitempty"`
Hostnames []string `json:"hostnames,omitempty"`
IPAddresses []string `json:"ipAddresses,omitempty"`
MACAddresses []string `json:"macAddresses,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
}
type normalizedCapabilityForResourceHash struct {
Name string `json:"name"`
Type unified.CapabilityType `json:"type"`
Description string `json:"description"`
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
Platform string `json:"platform,omitempty"`
Params []unified.CapabilityParam `json:"params,omitempty"`
}
type normalizedRelationship struct {
SourceID string `json:"sourceId"`
TargetID string `json:"targetId"`
Type unified.RelationshipType `json:"type"`
Confidence float64 `json:"confidence"`
Active bool `json:"active"`
Discoverer string `json:"discoverer"`
ObservedAt time.Time `json:"observedAt,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type normalizedChange struct {
ID string `json:"id"`
ResourceID string `json:"resourceId"`
Kind unified.ChangeKind `json:"kind"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
SourceType unified.ChangeSourceType `json:"sourceType"`
SourceAdapter unified.ChangeSourceAdapter `json:"sourceAdapter"`
Confidence unified.ChangeConfidence `json:"confidence"`
RelatedResources []string `json:"relatedResources,omitempty"`
}
type storageIncidentHashFields struct {
Severity string `json:"severity,omitempty"`
Summary string `json:"summary,omitempty"`
Category string `json:"category,omitempty"`
Priority int `json:"priority,omitempty"`
}
type sourceStatusHash struct {
Status string `json:"status"`
LastSeen time.Time `json:"lastSeen,omitempty"`
Error string `json:"error,omitempty"`
}
func normalizeDataSources(sources []unified.DataSource) []unified.DataSource {
out := make([]unified.DataSource, 0, len(sources))
seen := map[unified.DataSource]struct{}{}
for _, source := range sources {
normalized := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func normalizeIdentity(identity unified.ResourceIdentity) normalizedIdentity {
return normalizedIdentity{
MachineID: strings.TrimSpace(identity.MachineID),
DMIUUID: strings.TrimSpace(identity.DMIUUID),
Hostnames: sortedTrimmedStrings(identity.Hostnames),
IPAddresses: sortedTrimmedStrings(identity.IPAddresses),
MACAddresses: sortedTrimmedStrings(identity.MACAddresses),
ClusterName: strings.TrimSpace(identity.ClusterName),
}
}
func normalizeCapabilitiesForResourceHash(capabilities []unified.ResourceCapability) []normalizedCapabilityForResourceHash {
out := make([]normalizedCapabilityForResourceHash, 0, len(capabilities))
for _, capability := range capabilities {
out = append(out, normalizedCapabilityForResourceHash{
Name: strings.TrimSpace(capability.Name),
Type: capability.Type,
Description: strings.TrimSpace(capability.Description),
MinimumApprovalLevel: capability.MinimumApprovalLevel,
Platform: strings.TrimSpace(capability.Platform),
Params: normalizeCapabilityParams(capability.Params),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name == out[j].Name {
return out[i].Platform < out[j].Platform
}
return out[i].Name < out[j].Name
})
return out
}
func normalizeCapabilityParams(params []unified.CapabilityParam) []unified.CapabilityParam {
out := make([]unified.CapabilityParam, 0, len(params))
for _, param := range params {
param.Name = strings.TrimSpace(param.Name)
param.Type = strings.ToLower(strings.TrimSpace(param.Type))
param.Pattern = strings.TrimSpace(param.Pattern)
param.Description = strings.TrimSpace(param.Description)
param.Enum = sortedTrimmedStrings(param.Enum)
out = append(out, param)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func sortedCapabilityParamNames(params []unified.CapabilityParam) []string {
names := make([]string, 0, len(params))
for _, param := range params {
if name := strings.TrimSpace(param.Name); name != "" {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
func normalizeRelationships(relationships []unified.ResourceRelationship) []normalizedRelationship {
out := make([]normalizedRelationship, 0, len(relationships))
for _, relationship := range relationships {
out = append(out, normalizedRelationship{
SourceID: unified.CanonicalResourceID(relationship.SourceID),
TargetID: unified.CanonicalResourceID(relationship.TargetID),
Type: relationship.Type,
Confidence: relationship.Confidence,
Active: relationship.Active,
Discoverer: strings.TrimSpace(relationship.Discoverer),
ObservedAt: relationship.ObservedAt.UTC(),
LastSeenAt: relationship.LastSeenAt.UTC(),
Metadata: relationship.Metadata,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].SourceID != out[j].SourceID {
return out[i].SourceID < out[j].SourceID
}
if out[i].TargetID != out[j].TargetID {
return out[i].TargetID < out[j].TargetID
}
return out[i].Type < out[j].Type
})
return out
}
func normalizeChanges(changes []unified.ResourceChange) []normalizedChange {
out := make([]normalizedChange, 0, len(changes))
for _, change := range changes {
out = append(out, normalizedChange{
ID: strings.TrimSpace(change.ID),
ResourceID: unified.CanonicalResourceID(change.ResourceID),
Kind: change.Kind,
From: strings.TrimSpace(change.From),
To: strings.TrimSpace(change.To),
SourceType: change.SourceType,
SourceAdapter: change.SourceAdapter,
Confidence: change.Confidence,
RelatedResources: sortedCanonicalResourceIDs(change.RelatedResources),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].ResourceID != out[j].ResourceID {
return out[i].ResourceID < out[j].ResourceID
}
if out[i].ID != out[j].ID {
return out[i].ID < out[j].ID
}
return out[i].Kind < out[j].Kind
})
return out
}
func normalizeSourceStatus(status map[unified.DataSource]unified.SourceStatus) map[unified.DataSource]sourceStatusHash {
if len(status) == 0 {
return nil
}
out := make(map[unified.DataSource]sourceStatusHash, len(status))
for source, sourceStatus := range status {
normalizedSource := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
if normalizedSource == "" {
continue
}
out[normalizedSource] = sourceStatusHash{
Status: strings.TrimSpace(sourceStatus.Status),
LastSeen: sourceStatus.LastSeen.UTC(),
Error: strings.TrimSpace(sourceStatus.Error),
}
}
return out
}
func sortedCanonicalResourceIDs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
id = unified.CanonicalResourceID(id)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Strings(out)
return out
}
func sortedTrimmedStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
func hashJSON(value any, bytes int) string {
payload, err := json.Marshal(value)
if err != nil {
payload = []byte(fmt.Sprintf("%#v", value))
}
sum := sha256.Sum256(payload)
if bytes <= 0 || bytes > len(sum) {
bytes = len(sum)
}
return hex.EncodeToString(sum[:bytes])
}
+165
View File
@@ -0,0 +1,165 @@
package actionplanner
import (
"errors"
"testing"
"time"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestPlannerBuildsDeterministicGovernedPlan(t *testing.T) {
now := time.Date(2026, 5, 3, 9, 30, 0, 0, time.UTC)
parentID := "agent:node-1"
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusWarning,
LastSeen: now.Add(-time.Minute),
UpdatedAt: now.Add(-30 * time.Second),
ParentID: &parentID,
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: "proxmox.vm.restart",
Params: []unified.CapabilityParam{
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
},
},
},
Relationships: []unified.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "service:web",
Type: unified.RelDependsOn,
Active: true,
},
},
}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: " vm:42 ",
CapabilityName: "restart",
Params: map[string]any{"mode": "graceful"},
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
}
planner := Planner{Now: func() time.Time { return now }}
plan, err := planner.Plan(req, resource)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
second, err := planner.Plan(req, resource)
if err != nil {
t.Fatalf("Plan() second error = %v", err)
}
if plan.ActionID == "" || plan.ActionID != second.ActionID {
t.Fatalf("action id is not deterministic: first=%q second=%q", plan.ActionID, second.ActionID)
}
if plan.PlanHash == "" || plan.PlanHash != second.PlanHash {
t.Fatalf("plan hash is not deterministic: first=%q second=%q", plan.PlanHash, second.PlanHash)
}
if !plan.Allowed {
t.Fatalf("Allowed = false, want true")
}
if !plan.RequiresApproval {
t.Fatalf("RequiresApproval = false, want true")
}
if plan.ApprovalPolicy != unified.ApprovalAdmin {
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
}
if !plan.PlannedAt.Equal(now) {
t.Fatalf("PlannedAt = %s, want %s", plan.PlannedAt, now)
}
if !plan.ExpiresAt.Equal(now.Add(DefaultPlanTTL)) {
t.Fatalf("ExpiresAt = %s, want %s", plan.ExpiresAt, now.Add(DefaultPlanTTL))
}
if len(plan.PredictedBlastRadius) != 3 ||
plan.PredictedBlastRadius[0] != "vm:42" ||
plan.PredictedBlastRadius[1] != "agent:node-1" ||
plan.PredictedBlastRadius[2] != "service:web" {
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
}
if plan.Preflight == nil {
t.Fatalf("Preflight is nil")
}
if plan.Preflight.Target != "vm:42" {
t.Fatalf("Preflight.Target = %q, want vm:42", plan.Preflight.Target)
}
if plan.Preflight.DryRunAvailable {
t.Fatalf("DryRunAvailable = true, want false without provider dry-run contract")
}
}
func TestPlannerRejectsUndeclaredParams(t *testing.T) {
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Capabilities: []unified.ResourceCapability{
{Name: "restart", Type: unified.CapabilityTypeCommon, MinimumApprovalLevel: unified.ApprovalAdmin},
},
}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: "vm:42",
CapabilityName: "restart",
Params: map[string]any{"force": true},
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
}
_, err := Planner{}.Plan(req, resource)
validationErr, ok := AsValidationError(err)
if !ok {
t.Fatalf("Plan() error = %v, want validation error", err)
}
if validationErr.Field != "params.force" {
t.Fatalf("validation field = %q, want params.force", validationErr.Field)
}
}
func TestResourceVersionIgnoresObservationOnlyTimestamps(t *testing.T) {
base := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusOnline,
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
}
refreshed := base
refreshed.LastSeen = base.LastSeen.Add(time.Minute)
refreshed.UpdatedAt = base.UpdatedAt.Add(time.Minute)
if got, want := ResourceVersion(refreshed), ResourceVersion(base); got != want {
t.Fatalf("ResourceVersion changed for observation-only timestamp drift: got %q want %q", got, want)
}
changed := base
changed.Status = unified.StatusWarning
if got, unchanged := ResourceVersion(changed), ResourceVersion(base); got == unchanged {
t.Fatalf("ResourceVersion did not change for status drift: %q", got)
}
}
func TestPlannerReturnsCapabilityNotFound(t *testing.T) {
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: "vm:42",
CapabilityName: "restart",
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
}
_, err := Planner{}.Plan(req, resource)
if !errors.Is(err, ErrCapabilityNotFound) {
t.Fatalf("Plan() error = %v, want ErrCapabilityNotFound", err)
}
}
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const maxActionPlanRequestBytes = 1 << 20
func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req unified.ActionRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
"body": "request body must be a valid ActionRequest JSON object",
})
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
"body": "request body must contain one JSON object",
})
return
}
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
if req.ResourceID == "" {
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
"resourceId": "resource id is required",
})
return
}
orgID := GetOrgID(r.Context())
registry, err := h.buildRegistry(orgID)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"), nil)
return
}
resource, ok := registry.Get(req.ResourceID)
if !ok || resource == nil {
writeErrorResponse(w, http.StatusNotFound, "resource_not_found", "Resource not found", map[string]string{
"resourceId": req.ResourceID,
})
return
}
plan, err := (actionplanner.Planner{}).Plan(req, *resource)
if err != nil {
if validationErr, ok := actionplanner.AsValidationError(err); ok {
details := map[string]string{}
if validationErr.Field != "" {
details[validationErr.Field] = validationErr.Message
}
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", details)
return
}
if errors.Is(err, actionplanner.ErrCapabilityNotFound) {
writeErrorResponse(w, http.StatusNotFound, "capability_not_found", "Capability not found on resource", map[string]string{
"resourceId": req.ResourceID,
"capabilityName": req.CapabilityName,
})
return
}
writeErrorResponse(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"), nil)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plan); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "action_plan_encode_failed", "Failed to encode action plan", nil)
}
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceProxmox},
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: "proxmox.vm.restart",
Params: []unified.CapabilityParam{
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
},
},
},
Relationships: []unified.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "node-1",
Type: unified.RelRunsOn,
Active: true,
},
},
},
},
})
body := bytes.NewBufferString(`{
"requestId":"agent-run-123",
"resourceId":"vm:42",
"capabilityName":"restart",
"params":{"mode":"graceful"},
"reason":"Recover after confirmed outage",
"requestedBy":"agent:oncall-helper"
}`)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body)
h.HandlePlanAction(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "InternalHandler") || strings.Contains(rec.Body.String(), "proxmox.vm.restart") {
t.Fatalf("response leaked internal execution handler: %s", rec.Body.String())
}
var plan unified.ActionPlan
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
t.Fatalf("decode response: %v", err)
}
if !plan.Allowed {
t.Fatalf("Allowed = false, want true")
}
if !plan.RequiresApproval {
t.Fatalf("RequiresApproval = false, want true")
}
if plan.ApprovalPolicy != unified.ApprovalAdmin {
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
}
if plan.ActionID == "" || !strings.HasPrefix(plan.PlanHash, "sha256:") {
t.Fatalf("missing action identity/hash: actionID=%q planHash=%q", plan.ActionID, plan.PlanHash)
}
if plan.Preflight == nil || plan.Preflight.Target != "vm:42" {
t.Fatalf("Preflight = %#v, want target vm:42", plan.Preflight)
}
if len(plan.PredictedBlastRadius) != 2 || plan.PredictedBlastRadius[0] != "vm:42" || plan.PredictedBlastRadius[1] != "node-1" {
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
}
}
func TestHandlePlanActionRejectsMissingCapability(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{ID: "vm:42", Type: unified.ResourceTypeVM, Name: "web-42", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now},
},
})
body := bytes.NewBufferString(`{
"requestId":"agent-run-123",
"resourceId":"vm:42",
"capabilityName":"restart",
"reason":"Recover after confirmed outage",
"requestedBy":"agent:oncall-helper"
}`)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body)
h.HandlePlanAction(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"code":"capability_not_found"`) {
t.Fatalf("unexpected response body: %s", rec.Body.String())
}
}
+124
View File
@@ -21,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
@@ -72,6 +73,39 @@ func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T)
}
}
func TestContract_HostedMagicLinkStablePrincipalProof(t *testing.T) {
source, err := os.ReadFile(filepath.Clean("magic_link_handlers.go"))
if err != nil {
t.Fatalf("read magic_link_handlers.go: %v", err)
}
text := string(source)
for _, required := range []string{
"resolveMagicLinkPrincipal",
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, principal.UserID)",
"TrackUserSession(principal.UserID, sessionToken)",
} {
if !strings.Contains(text, required) {
t.Fatalf("magic_link_handlers.go must contain %q", required)
}
}
for _, forbidden := range []string{
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, token.Email)",
"TrackUserSession(token.Email, sessionToken)",
} {
if strings.Contains(text, forbidden) {
t.Fatalf("magic_link_handlers.go must not contain legacy email-principal pattern %q", forbidden)
}
}
invariantDoc, err := os.ReadFile(filepath.Clean("../../docs/release-control/v6/internal/IDENTITY_INVARIANTS.md"))
if err != nil {
t.Fatalf("read identity invariant contract: %v", err)
}
if !strings.Contains(string(invariantDoc), "Email is contact metadata") {
t.Fatal("identity invariant contract must define email as contact metadata")
}
}
func TestContractAISettingsClampsPaidRuntimeControlsToEntitlements(t *testing.T) {
t.Parallel()
@@ -11183,6 +11217,96 @@ func TestContract_ResourceFacetsDeriveCanonicalParentRelationship(t *testing.T)
assertJSONSnapshot(t, got, want)
}
func TestContract_ActionPlanJSONSnapshot(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
resource := unifiedresources.Resource{
ID: "vm:42",
Type: unifiedresources.ResourceTypeVM,
Name: "web-42",
Status: unifiedresources.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
Capabilities: []unifiedresources.ResourceCapability{
{
Name: "restart",
Type: unifiedresources.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unifiedresources.ApprovalAdmin,
InternalHandler: "proxmox.vm.restart",
Params: []unifiedresources.CapabilityParam{
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
},
},
},
Relationships: []unifiedresources.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "node-1",
Type: unifiedresources.RelRunsOn,
Confidence: 1,
Active: true,
Discoverer: "proxmox_adapter",
ObservedAt: now,
LastSeenAt: now,
},
},
}
req := unifiedresources.ActionRequest{
RequestID: "agent-run-123",
ResourceID: "vm:42",
CapabilityName: "restart",
Params: map[string]any{"mode": "graceful"},
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
}
plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(req, resource)
if err != nil {
t.Fatalf("plan action: %v", err)
}
got, err := json.Marshal(plan)
if err != nil {
t.Fatalf("marshal action plan: %v", err)
}
const want = `{
"actionId":"act_371d0bcde73818752f83bb759e1fa523",
"requestId":"agent-run-123",
"allowed":true,
"requiresApproval":true,
"approvalPolicy":"admin",
"predictedBlastRadius":["vm:42","node-1"],
"rollbackAvailable":false,
"message":"Plan created for restart on web-42. Execution requires admin approval and is not performed by this endpoint.",
"plannedAt":"2026-05-03T10:00:00Z",
"expiresAt":"2026-05-03T10:05:00Z",
"resourceVersion":"resource:sha256:3a87c71cf83bd017736bcb25",
"policyVersion":"policy:sha256:0bce3cd2df181ace685598eb",
"planHash":"sha256:38e8a016794bae597cd6129e65506556048bcac88d6a5a1b59e1337a2acc5a05",
"preflight":{
"target":"vm:42",
"currentState":"web-42 is warning",
"intendedChange":"Restart the VM",
"dryRunAvailable":false,
"dryRunSummary":"No provider-supported dry run is advertised for this capability.",
"safetyChecks":[
"Resource was resolved from the unified resource registry.",
"Capability is advertised by the resource contract.",
"This endpoint plans only; it does not approve or execute the action.",
"Execution requires admin approval."
],
"verificationSteps":[
"Refresh the resource and confirm the expected state after execution.",
"Review /api/audit/actions/act_371d0bcde73818752f83bb759e1fa523/events for lifecycle evidence."
],
"generatedAt":"2026-05-03T10:00:00Z"
}
}`
assertJSONSnapshot(t, got, want)
}
func TestContract_ResourceTimelineEndpointsIncludeRelatedChanges(t *testing.T) {
now := time.Date(2026, 4, 25, 22, 15, 0, 0, time.UTC)
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
+1
View File
@@ -406,6 +406,7 @@ var allRouteAllowlist = []string{
"/api/resources/",
"/api/resources/{id}/facets",
"/api/resources/{id}/timeline",
"POST /api/actions/plan",
"/api/guests/metadata",
"/api/guests/metadata/",
"/api/docker/metadata",
+1
View File
@@ -37,6 +37,7 @@ func (r *Router) registerMonitoringResourceRoutes(
r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets)))
r.mux.HandleFunc("/api/resources/{id}/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceTimeline)))
r.mux.HandleFunc("/api/resources/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleResourceRoutes)))
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.resourceHandlers.HandlePlanAction)))
// Guest metadata routes
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
@@ -3604,8 +3604,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 156,
"heading_line": 91,
"line": 158,
"heading_line": 93,
}
],
)