mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
Recover live Patrol approvals for Assistant handoffs
This commit is contained in:
@@ -134,7 +134,10 @@ lifecycle evidence, and retry/idempotency handling must not create duplicate
|
||||
lifecycle events. Approval or rejection decisions for those plans must flow
|
||||
through `POST /api/actions/{id}/decision`, which records API-owned audit and
|
||||
lifecycle evidence only; lifecycle surfaces must not treat approval as
|
||||
implicit command execution or define a parallel execution handoff. When a
|
||||
implicit command execution or define a parallel execution handoff. Assistant
|
||||
handoffs that recover a live Patrol approval by finding ID are still AI/runtime
|
||||
review context only; agent lifecycle surfaces must not treat that recovered
|
||||
approval reference as an agent command grant or host-execution shortcut. When a
|
||||
planned resource capability is actually executed from an agent-lifecycle
|
||||
surface, that handoff must route through `POST /api/actions/{id}/execute` so
|
||||
the API-owned action audit records `executing` before dispatch and the
|
||||
|
||||
@@ -241,13 +241,15 @@ runtime cost control, and shared AI transport surfaces.
|
||||
Those timeline facts remain read-only explanation context and do not grant
|
||||
action authority. The runtime may also persist structured pending-action and
|
||||
approval references from the same investigation record as
|
||||
model-context metadata, but those references are review context only: they
|
||||
must not include raw command text, must not grant approval or execution
|
||||
authority, and must route any operator decision back through the governed
|
||||
approval/remediation flow. When
|
||||
those references include an approval ID, Assistant runtime may refresh a
|
||||
current status snapshot from the canonical approval store on each turn, but
|
||||
it must enforce org scoping and still omit the approval command payload.
|
||||
model-context metadata, and the API handoff builder may recover the current
|
||||
live Patrol investigation-fix approval by finding ID when the durable record
|
||||
does not yet carry the latest approval ID. Those references are review
|
||||
context only: they must not include raw command text, must not grant
|
||||
approval or execution authority, and must route any operator decision back
|
||||
through the governed approval/remediation flow. When those references include
|
||||
an approval ID, Assistant runtime may refresh a current status snapshot from
|
||||
the canonical approval store on each turn, but it must enforce org scoping
|
||||
and still omit the approval command payload.
|
||||
When those references resolve to a governed action plan or action audit,
|
||||
Assistant runtime must hydrate the canonical action ID, lifecycle state,
|
||||
requester, capability, approval policy, plan expiry, preflight/dry-run
|
||||
|
||||
@@ -316,11 +316,15 @@ the canonical monitored-system blocked payload.
|
||||
Context]`, structured handoff resources, related root-cause/correlation
|
||||
finding context, and structured handoff actions model-only, with the
|
||||
briefing summarizing operator next steps, latest lifecycle event, and
|
||||
governed action posture without raw command text. Related finding context
|
||||
must resolve from the current unified finding store, stay bounded and
|
||||
deduplicated, include current recency and latest lifecycle facts, and seed
|
||||
only structured handoff resources for canonical policy, state, topology,
|
||||
and timeline hydration. Chat execution owns
|
||||
governed action posture without raw command text. Structured handoff action
|
||||
references may use the current live Patrol investigation-fix approval for
|
||||
the finding when that approval is newer than the approval ID on the durable
|
||||
record, but the payload may carry only IDs, status/risk/target metadata, and
|
||||
fix/action references, never the approval command payload. Related finding
|
||||
context must resolve from the current unified finding store, stay bounded
|
||||
and deduplicated, include current recency and latest lifecycle facts, and
|
||||
seed only structured handoff resources for canonical policy, state,
|
||||
topology, and timeline hydration. Chat execution owns
|
||||
resource-policy sanitization of the assembled model-only handoff before
|
||||
prompt injection, so API payload builders may pass structured product
|
||||
context without turning raw resource identity into user-authored text or
|
||||
@@ -830,12 +834,14 @@ the canonical monitored-system blocked payload.
|
||||
explanation data and must not become saved user text or action authority. The
|
||||
backend may also carry structured
|
||||
pending-action and approval references from the investigation record into chat
|
||||
execution, but those references must omit raw proposed-fix commands, remain
|
||||
model-only review context, and leave approval/execution authority with the
|
||||
governed approval and remediation APIs. Chat execution may refresh approval
|
||||
status snapshots for those references from the canonical approval store, but
|
||||
that snapshot is read-only, org-scoped, and must not expose or infer the raw
|
||||
command. When the reference resolves to a governed action plan or action
|
||||
execution, and may recover the current live Patrol investigation-fix approval
|
||||
for the finding when the durable record has no current approval ID, but those
|
||||
references must omit raw proposed-fix commands, remain model-only review
|
||||
context, and leave approval/execution authority with the governed approval
|
||||
and remediation APIs. Chat execution may refresh approval status snapshots for
|
||||
those references from the canonical approval store, but that snapshot is
|
||||
read-only, org-scoped, and must not expose or infer the raw command. When the
|
||||
reference resolves to a governed action plan or action
|
||||
audit, chat execution must hydrate the canonical action ID, lifecycle state,
|
||||
requester, capability, approval policy, plan expiry, preflight/dry-run
|
||||
summary, and terminal success/failure state from the action-audit store so
|
||||
|
||||
@@ -107,13 +107,16 @@ Patrol-specific presentation helpers.
|
||||
only structured action references such as approval ID, fix ID, risk, target,
|
||||
and resource identity; approval and execution authority stays with the
|
||||
governed approval/remediation surfaces. Assistant may refresh the referenced
|
||||
approval's current status for review, but Patrol presentation must still keep
|
||||
command payloads inside governed approval/remediation context rather than
|
||||
rendering them as handoff copy. Assistant may also hydrate the referenced
|
||||
action plan or action audit so the handoff explains current action lifecycle
|
||||
state, requester, capability, approval policy, plan expiry, preflight/dry-run
|
||||
posture, and terminal success/failure without treating approval as execution
|
||||
authority or exposing raw command/execution payloads. Assistant may also
|
||||
approval's current status for review, and the backend handoff builder may
|
||||
recover a live pending Patrol investigation-fix approval by finding ID when
|
||||
the durable record lacks the current approval reference, but Patrol
|
||||
presentation must still keep command payloads inside governed
|
||||
approval/remediation context rather than rendering them as handoff copy.
|
||||
Assistant may also hydrate the referenced action plan or action audit so the
|
||||
handoff explains current action lifecycle state, requester, capability,
|
||||
approval policy, plan expiry, preflight/dry-run posture, and terminal
|
||||
success/failure without treating approval as execution authority or exposing
|
||||
raw command/execution payloads. Assistant may also
|
||||
enrich that same handoff with refreshed unified finding and
|
||||
investigation-record state, canonical resource-policy guidance, current
|
||||
canonical resource-state and capability context, canonical
|
||||
|
||||
@@ -424,7 +424,9 @@ bypass the API fail-closed execution gate.
|
||||
finding no longer resolves is adjacent AI/runtime invalidation, not a
|
||||
recovery freshness or restore-support decision. Structured action or
|
||||
approval references carried by that handoff are also adjacent AI/runtime
|
||||
review metadata only. Unified finding lifecycle facts, latest lifecycle
|
||||
review metadata only, including when Assistant recovers the current live
|
||||
Patrol approval by finding ID before building model-only action context.
|
||||
Unified finding lifecycle facts, latest lifecycle
|
||||
event briefing lines, and detailed lifecycle context carried by the same
|
||||
handoff remain Patrol/AI review metadata and must not become backup recency,
|
||||
restore support, or storage-local lifecycle state. Primary finding
|
||||
|
||||
+74
-13
@@ -1646,29 +1646,50 @@ func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding, lookup unifi
|
||||
return resources
|
||||
}
|
||||
|
||||
func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding) []chat.HandoffAction {
|
||||
if f == nil || f.InvestigationRecord == nil {
|
||||
func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding, orgID string) []chat.HandoffAction {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rec := f.InvestigationRecord
|
||||
if strings.TrimSpace(rec.ApprovalID) == "" && rec.ProposedFix == nil {
|
||||
liveApproval := livePatrolApprovalForFinding(f.ID, orgID)
|
||||
if rec == nil && liveApproval == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
action := chat.HandoffAction{
|
||||
FindingID: f.ID,
|
||||
RecordID: rec.ID,
|
||||
ApprovalID: rec.ApprovalID,
|
||||
TargetResourceID: rec.Subject.ResourceID,
|
||||
TargetResourceName: rec.Subject.ResourceName,
|
||||
TargetResourceType: rec.Subject.ResourceType,
|
||||
TargetNode: rec.Subject.Node,
|
||||
TargetResourceID: f.ResourceID,
|
||||
TargetResourceName: f.ResourceName,
|
||||
TargetResourceType: f.ResourceType,
|
||||
TargetNode: f.Node,
|
||||
}
|
||||
if rec.ProposedFix != nil {
|
||||
if rec != nil {
|
||||
action.RecordID = rec.ID
|
||||
action.ApprovalID = rec.ApprovalID
|
||||
action.TargetResourceID = firstNonEmptyString(rec.Subject.ResourceID, action.TargetResourceID)
|
||||
action.TargetResourceName = firstNonEmptyString(rec.Subject.ResourceName, action.TargetResourceName)
|
||||
action.TargetResourceType = firstNonEmptyString(rec.Subject.ResourceType, action.TargetResourceType)
|
||||
action.TargetNode = firstNonEmptyString(rec.Subject.Node, action.TargetNode)
|
||||
}
|
||||
if liveApproval != nil {
|
||||
action.ApprovalID = strings.TrimSpace(liveApproval.ID)
|
||||
if risk := strings.TrimSpace(string(liveApproval.RiskLevel)); risk != "" {
|
||||
action.RiskLevel = risk
|
||||
}
|
||||
if action.TargetResourceName == "" {
|
||||
action.TargetResourceName = strings.TrimSpace(liveApproval.TargetName)
|
||||
}
|
||||
if action.TargetResourceType == "" && !strings.EqualFold(strings.TrimSpace(liveApproval.TargetID), strings.TrimSpace(f.ID)) {
|
||||
action.TargetResourceType = strings.TrimSpace(liveApproval.TargetType)
|
||||
}
|
||||
}
|
||||
if rec != nil && rec.ProposedFix != nil {
|
||||
action.FixID = rec.ProposedFix.ID
|
||||
action.Description = rec.ProposedFix.Description
|
||||
action.RiskLevel = rec.ProposedFix.RiskLevel
|
||||
if strings.TrimSpace(action.RiskLevel) == "" {
|
||||
action.RiskLevel = rec.ProposedFix.RiskLevel
|
||||
}
|
||||
action.Destructive = rec.ProposedFix.Destructive
|
||||
action.TargetHost = rec.ProposedFix.TargetHost
|
||||
}
|
||||
@@ -1681,6 +1702,45 @@ func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding) []chat.Handoff
|
||||
return []chat.HandoffAction{action}
|
||||
}
|
||||
|
||||
func livePatrolApprovalForFinding(findingID, orgID string) *approval.ApprovalRequest {
|
||||
findingID = strings.TrimSpace(findingID)
|
||||
if findingID == "" {
|
||||
return nil
|
||||
}
|
||||
store := approval.GetStore()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var selected *approval.ApprovalRequest
|
||||
for _, req := range store.GetPendingApprovalsForOrg(orgID) {
|
||||
if req == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(req.ToolID), "investigation_fix") {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(req.TargetID) != findingID {
|
||||
continue
|
||||
}
|
||||
if selected == nil ||
|
||||
req.RequestedAt.After(selected.RequestedAt) ||
|
||||
(req.RequestedAt.Equal(selected.RequestedAt) && strings.TrimSpace(req.ID) > strings.TrimSpace(selected.ID)) {
|
||||
selected = req
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if normalized := strings.TrimSpace(value); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatChatResource(name, resourceType string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
resourceType = strings.TrimSpace(resourceType)
|
||||
@@ -1949,13 +2009,14 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if findingID != "" {
|
||||
findingResolved := false
|
||||
store := h.GetUnifiedStoreForOrg(GetOrgID(ctx))
|
||||
orgID := GetOrgID(ctx)
|
||||
store := h.GetUnifiedStoreForOrg(orgID)
|
||||
if store != nil {
|
||||
if f := store.Get(findingID); f != nil {
|
||||
findingResolved = true
|
||||
handoffContext = buildUnifiedFindingChatContext(f, store)
|
||||
handoffResources = buildUnifiedFindingHandoffResources(f, store)
|
||||
handoffActions = buildUnifiedFindingHandoffActions(f)
|
||||
handoffActions = buildUnifiedFindingHandoffActions(f, orgID)
|
||||
}
|
||||
}
|
||||
if !findingResolved {
|
||||
|
||||
@@ -818,6 +818,114 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestHandleChat_RecoversLivePatrolApprovalForFindingHandoffAction(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
h := newTestAIHandler(cfg, nil, nil)
|
||||
mockSvc := new(MockAIService)
|
||||
h.defaultService = mockSvc
|
||||
|
||||
prevStore := approval.GetStore()
|
||||
approvalStore, err := approval.NewStore(approval.StoreConfig{
|
||||
DataDir: t.TempDir(),
|
||||
DefaultTimeout: 10 * time.Minute,
|
||||
MaxApprovals: 10,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
approval.SetStore(approvalStore)
|
||||
t.Cleanup(func() {
|
||||
approval.SetStore(prevStore)
|
||||
})
|
||||
|
||||
assert.NoError(t, approvalStore.CreateApproval(&approval.ApprovalRequest{
|
||||
ID: "approval-live",
|
||||
ToolID: "investigation_fix",
|
||||
Command: "systemctl restart workload.service",
|
||||
TargetType: "vm",
|
||||
TargetID: "finding-123",
|
||||
TargetName: "web-server",
|
||||
Context: "Restart the workload service after backup saturation clears.",
|
||||
RiskLevel: approval.RiskHigh,
|
||||
}))
|
||||
|
||||
detectedAt := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
store := unified.NewUnifiedStore(unified.DefaultAlertToFindingConfig())
|
||||
store.AddFromAI(&unified.UnifiedFinding{
|
||||
ID: "finding-123",
|
||||
Source: unified.SourceAIPatrol,
|
||||
Severity: unified.SeverityCritical,
|
||||
Category: unified.CategoryPerformance,
|
||||
ResourceID: "vm-100",
|
||||
ResourceName: "web-server",
|
||||
ResourceType: "vm",
|
||||
Node: "pve-1",
|
||||
Title: "High CPU usage",
|
||||
Description: "CPU stayed above 95%.",
|
||||
InvestigationStatus: "completed",
|
||||
InvestigationOutcome: "fix_queued",
|
||||
LoopState: "awaiting_approval",
|
||||
InvestigationRecord: &aicontracts.InvestigationRecord{
|
||||
ID: "investigation-123",
|
||||
FindingID: "finding-123",
|
||||
Subject: aicontracts.InvestigationRecordSubject{
|
||||
ResourceID: "vm-100",
|
||||
ResourceName: "web-server",
|
||||
ResourceType: "vm",
|
||||
Node: "pve-1",
|
||||
},
|
||||
Trigger: aicontracts.InvestigationRecordTrigger{
|
||||
Title: "High CPU usage",
|
||||
DetectedAt: detectedAt,
|
||||
},
|
||||
Status: aicontracts.InvestigationStatusCompleted,
|
||||
Outcome: aicontracts.OutcomeFixQueued,
|
||||
Confidence: aicontracts.InvestigationRecordConfidenceHigh,
|
||||
Conclusion: "Backup job saturated CPU.",
|
||||
ProposedFix: &aicontracts.InvestigationRecordFix{
|
||||
ID: "fix-123",
|
||||
Description: "Restart the workload service",
|
||||
Commands: []string{"systemctl restart workload.service"},
|
||||
RiskLevel: "medium",
|
||||
TargetHost: "pve-1",
|
||||
Destructive: true,
|
||||
},
|
||||
StartedAt: detectedAt,
|
||||
},
|
||||
})
|
||||
h.SetUnifiedStore(store)
|
||||
|
||||
mockSvc.On("IsRunning").Return(true)
|
||||
mockSvc.
|
||||
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(nil).
|
||||
Run(func(args mock.Arguments) {
|
||||
reqArg := args.Get(1).(chat.ExecuteRequest)
|
||||
assert.Equal(t, []chat.HandoffAction{{
|
||||
FindingID: "finding-123",
|
||||
RecordID: "investigation-123",
|
||||
ApprovalID: "approval-live",
|
||||
FixID: "fix-123",
|
||||
Description: "Restart the workload service",
|
||||
RiskLevel: "high",
|
||||
Destructive: true,
|
||||
TargetHost: "pve-1",
|
||||
TargetResourceID: "vm-100",
|
||||
TargetResourceName: "web-server",
|
||||
TargetResourceType: "vm",
|
||||
TargetNode: "pve-1",
|
||||
}}, reqArg.HandoffActions)
|
||||
assert.NotContains(t, reqArg.HandoffContext, "systemctl restart workload.service")
|
||||
assert.NotContains(t, fmt.Sprintf("%#v", reqArg.HandoffActions), "systemctl restart workload.service")
|
||||
})
|
||||
|
||||
body := `{"prompt":"What approval is waiting?","finding_id":"finding-123"}`
|
||||
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleChat(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestHandleChat_RefreshesStoredFindingContextForFollowUp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
h := newTestAIHandler(cfg, nil, nil)
|
||||
|
||||
@@ -144,7 +144,8 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
|
||||
`svc.ClearModelHandoffContext(ctx, sessionID)`,
|
||||
"handoffContext = buildUnifiedFindingChatContext(f, store)",
|
||||
"handoffResources = buildUnifiedFindingHandoffResources(f, store)",
|
||||
"handoffActions = buildUnifiedFindingHandoffActions(f)",
|
||||
"handoffActions = buildUnifiedFindingHandoffActions(f, orgID)",
|
||||
"livePatrolApprovalForFinding(f.ID, orgID)",
|
||||
`appendChatContextLine(&b, "Finding Status", unifiedFindingChatStatus(f, time.Now()))`,
|
||||
`appendChatContextLine(&b, "Finding Detected At", f.DetectedAt.Format(time.RFC3339))`,
|
||||
`appendChatContextLine(&b, "Finding Last Seen At", f.LastSeenAt.Format(time.RFC3339))`,
|
||||
|
||||
Reference in New Issue
Block a user