mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
Implement resource-aware Assistant context
This commit is contained in:
@@ -223,6 +223,12 @@ text unless they also hold action execution scope. Lifecycle UI and agents may
|
||||
use those redacted events as doorbells or status summaries, but they must fetch
|
||||
governed detail through the approval/action surfaces and must not treat a
|
||||
monitoring-readable event stream as command disclosure or execution authority.
|
||||
The resource-context endpoint's additive context sections are the same
|
||||
read-only boundary: lifecycle consumers, MCP adapters, and external agents may
|
||||
use their bounded facts, provenance, freshness, and explicit redaction metadata
|
||||
to understand a resource, but those sections must not be expanded into
|
||||
provider-local config, raw discovery command output, environment values,
|
||||
secret-like metadata, install-token scope, or command authority.
|
||||
Discovery context exposed by `/api/discovery` is similarly API-owned
|
||||
observability context, not lifecycle command authority. Lifecycle and
|
||||
infrastructure setup surfaces may display service versions, config paths,
|
||||
|
||||
@@ -552,7 +552,14 @@ runtime cost control, and shared AI transport surfaces.
|
||||
references through the canonical unified-resource provider before querying
|
||||
timeline changes, with raw handoff IDs used only as a compatibility fallback.
|
||||
Those timeline facts remain read-only explanation context and do not grant
|
||||
action authority. The runtime may also persist structured pending-action and
|
||||
action authority. Assistant runtime must also treat the shared
|
||||
`internal/agentcontext` resource context pack as the canonical rich
|
||||
resource-grounding substrate for product-originated resource handoffs:
|
||||
runtime/discovery, topology, safety, policy, and recent-change facts are
|
||||
hydrated as model-only context from canonical unified resources and the
|
||||
resource store, carry provenance/freshness/redaction metadata, and must not
|
||||
include raw command output, provider config, environment values, mount paths,
|
||||
label maps, or secret-bearing metadata. The runtime may also persist structured pending-action and
|
||||
approval references from the same investigation record as
|
||||
model-context metadata, and the API handoff builder may recover the current
|
||||
live Patrol investigation-fix approval by finding ID when the durable record
|
||||
@@ -658,6 +665,11 @@ runtime cost control, and shared AI transport surfaces.
|
||||
The Assistant drawer may also render an attached context briefing for that
|
||||
handoff, but the briefing is runtime context visibility only: it must not
|
||||
mutate chat control settings, execute tools, or reveal raw command payloads.
|
||||
Resource-drawer Assistant entries use that same briefing path with
|
||||
`handoff_metadata.kind=resource_context`, a structured `handoff_resources`
|
||||
reference, and `autonomous_mode:false`; they must not prefill or submit a
|
||||
browser-authored prompt, and any rich resource facts must be hydrated by the
|
||||
backend context-pack path rather than reconstructed in the browser.
|
||||
Safe route-owned briefing actions may render as app links when the handoff
|
||||
includes an `actionHref`, but those links are navigation guidance only and
|
||||
do not grant tool execution or approval authority.
|
||||
|
||||
@@ -109,6 +109,7 @@ product API routes free of maintainer commercial analytics.
|
||||
69. `frontend-modern/src/api/aiChat.ts`
|
||||
70. `frontend-modern/src/api/patrol.ts`
|
||||
71. `internal/api/agent_exec_token_binding.go`
|
||||
72. `internal/agentcontext/`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -2009,6 +2010,15 @@ view and the doorbell speak the same vocabulary on probe outcomes;
|
||||
action and verification command/note text follows the same stable-marker
|
||||
redaction rule as action-audit readback, while stable status/error
|
||||
prefixes remain verbatim.
|
||||
The same response also carries additive `contextSections`, built by the
|
||||
shared `internal/agentcontext` package, with stable fact/redaction objects,
|
||||
source, trust-tier, observed-at, and generated-at metadata. Those sections are
|
||||
the canonical rich resource context pack for Assistant, MCP/tool clients, and
|
||||
copy/export consumers: they may expose bounded Pulse-authored or
|
||||
Pulse-observed runtime/discovery, topology, safety, policy, and recent-change
|
||||
facts, but must not expose raw command output, provider config, environment
|
||||
values, unbounded metadata maps, bind-mount paths, label values, or secret-like
|
||||
capability parameters.
|
||||
The shape is intentionally narrower than the full internal types
|
||||
so agents see a stable agent-paradigm contract, decoupled from
|
||||
internal type evolution.
|
||||
@@ -2952,7 +2962,11 @@ references used to seed canonical approval and action-audit refresh.
|
||||
`handoff_metadata` is the browser-safe identity envelope for restoring saved
|
||||
product handoffs, currently including Patrol run kind, run ID, safe run
|
||||
type/status, a runtime-failure boolean rather than runtime failure detail, and
|
||||
bounded resource/action counts when available. Frontend-visible Patrol
|
||||
bounded resource/action counts when available. Resource drawer handoffs use
|
||||
`handoff_metadata.kind=resource_context` with a structured `handoff_resources`
|
||||
reference and no browser-authored prompt or model text; the backend chat
|
||||
runtime must hydrate the shared resource context pack from canonical resources
|
||||
instead of trusting the browser to serialize rich context. Frontend-visible Patrol
|
||||
assessment briefings must not render recommendation fields as separate title,
|
||||
reason, route-action facts, or prompt chips; the configured model owns those
|
||||
decisions from the structured handoff metadata and bounded chat context.
|
||||
|
||||
@@ -176,6 +176,12 @@ regression protection.
|
||||
process version once, but they must not add per-request release lookups,
|
||||
filesystem walks, or other heavy work just to compute whether an attached
|
||||
agent is current.
|
||||
Workload drawer Assistant and context-copy entries are also click-triggered
|
||||
boundaries. Rendering `GuestDrawer` and `useGuestDrawerState` may derive
|
||||
canonical workload identity and Discovery coordinates from already-loaded row
|
||||
data, but it must not fetch the agent resource-context bundle or hydrate rich
|
||||
Assistant context while a row is merely rendered, expanded, filtered, sorted,
|
||||
or windowed.
|
||||
The `/api/version.agentUpdateTargetVersion` projection follows that same
|
||||
rule: it may mirror the cached deployable agent target exposed to agents,
|
||||
but it must not inspect attached agents, download artifacts, or resolve
|
||||
|
||||
@@ -199,6 +199,12 @@ services, including mock-mode config/data/log path examples. That context is
|
||||
read-only evidence: it must not become a storage/recovery-owned command path,
|
||||
secret source, restore entitlement, or frontend-only fixture separate from the
|
||||
canonical `/api/discovery` payload.
|
||||
The agent resource-context endpoint follows the same adjacent-evidence rule for
|
||||
storage/recovery consumers: bounded context sections, provenance, redactions,
|
||||
and recent action counts may help explain a workload or protected service, but
|
||||
they must not create restore authority, backup visibility, storage-local
|
||||
approval policy, or a bypass around the API-owned action and recovery
|
||||
contracts.
|
||||
Storage and recovery may also consume unified-resource TrueNAS app and VM
|
||||
metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata,
|
||||
as read-only workload, system, and storage-access context when explaining
|
||||
|
||||
@@ -179,6 +179,14 @@ workload, service, storage, configuration, policy, and event object rows belong
|
||||
in their workflow tabs. If a future Overview repeats a detailed table, the
|
||||
owning workflow must be retired or the Overview content must be reduced to
|
||||
aggregate signal.
|
||||
Product-originated resource references may arrive as registered unified
|
||||
resource IDs, source-specific IDs, or canonical identity aliases. The
|
||||
unified-resource registry owns resolving those references through
|
||||
`GetByReference`, returning the registered resource ID for downstream store,
|
||||
action, finding, and context-pack lookups. Resource drawer and workload drawer
|
||||
Assistant handoffs may pass stable source IDs such as Proxmox
|
||||
`instance:node:vmid`, but they must not expose generated registry IDs as the
|
||||
browser-side contract or rebuild alias matching in frontend code.
|
||||
Across platform/runtime pages, workflow tabs are evidence-gated from the
|
||||
canonical model that owns their rows. `Overview` is the stable landing tab;
|
||||
supporting tabs appear only when their native inventory or signal exists, and
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: apiFetchJSONMock,
|
||||
}));
|
||||
|
||||
import { AgentContextAPI } from '@/api/agentContext';
|
||||
|
||||
describe('AgentContextAPI', () => {
|
||||
afterEach(() => {
|
||||
apiFetchJSONMock.mockReset();
|
||||
});
|
||||
|
||||
it('fetches resource context with encoded canonical or source IDs', async () => {
|
||||
apiFetchJSONMock.mockResolvedValue({
|
||||
canonicalId: 'system-container-6adaf34f529d241a',
|
||||
resourceType: 'system-container',
|
||||
resourceName: 'homeassistant',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
contextSections: [],
|
||||
generatedAt: '2026-06-04T15:00:00Z',
|
||||
});
|
||||
|
||||
await AgentContextAPI.getResourceContext('delly:delly:101');
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/agent/resource-context/delly%3Adelly%3A101',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
export interface AgentResourceContextFact {
|
||||
label: string;
|
||||
value: string;
|
||||
source?: string;
|
||||
trustTier?: string;
|
||||
observedAt?: string;
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentResourceContextRedaction {
|
||||
field: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AgentResourceContextSection {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
source: string;
|
||||
trustTier: string;
|
||||
observedAt?: string;
|
||||
generatedAt: string;
|
||||
facts: AgentResourceContextFact[];
|
||||
redactions?: AgentResourceContextRedaction[];
|
||||
}
|
||||
|
||||
export interface AgentResourceFindingSnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
impact?: string;
|
||||
recommendation?: string;
|
||||
confidence?: string;
|
||||
regressionCount: number;
|
||||
previousResolvedFixSummary?: string;
|
||||
detectedAt?: string;
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentResourceApprovalSummary {
|
||||
id: string;
|
||||
riskLevel: string;
|
||||
requestedBy?: string;
|
||||
requestedAt: string;
|
||||
expiresAt: string;
|
||||
command?: string;
|
||||
commandRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentResourceActionSummary {
|
||||
id: string;
|
||||
capabilityName: string;
|
||||
state: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
requestedBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
command?: string;
|
||||
commandRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentResourceContext {
|
||||
canonicalId: string;
|
||||
resourceType: string;
|
||||
resourceName: string;
|
||||
technology?: string;
|
||||
activeFindings: AgentResourceFindingSnapshot[];
|
||||
pendingApprovals: AgentResourceApprovalSummary[];
|
||||
recentActions: AgentResourceActionSummary[];
|
||||
contextSections: AgentResourceContextSection[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export const AgentContextAPI = {
|
||||
getResourceContext(resourceId: string): Promise<AgentResourceContext> {
|
||||
return apiFetchJSON<AgentResourceContext>(
|
||||
`/api/agent/resource-context/${encodeURIComponent(resourceId)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -72,26 +72,27 @@ const {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const emptyChatContext = () => ({
|
||||
targetType: undefined,
|
||||
targetId: undefined,
|
||||
findingId: undefined,
|
||||
autonomousMode: undefined,
|
||||
context: undefined,
|
||||
const emptyChatContext = () => ({
|
||||
targetType: undefined,
|
||||
targetId: undefined,
|
||||
findingId: undefined,
|
||||
autonomousMode: undefined,
|
||||
context: undefined,
|
||||
briefing: undefined,
|
||||
handoffContext: undefined,
|
||||
handoffResources: undefined,
|
||||
handoffActions: undefined,
|
||||
handoffMetadata: undefined,
|
||||
});
|
||||
|
||||
const mockAiChatStore = {
|
||||
isOpenSignal: vi.fn(() => true),
|
||||
context: emptyChatContext() as {
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
findingId?: string;
|
||||
autonomousMode?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
const mockAiChatStore = {
|
||||
isOpenSignal: vi.fn(() => true),
|
||||
context: emptyChatContext() as {
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
findingId?: string;
|
||||
autonomousMode?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
handoffContext?: string;
|
||||
handoffResources?: Array<{
|
||||
id: string;
|
||||
@@ -104,6 +105,13 @@ const {
|
||||
approvalId?: string;
|
||||
approvalStatus?: string;
|
||||
}>;
|
||||
handoffMetadata?: {
|
||||
kind?: string;
|
||||
runId?: string;
|
||||
runType?: string;
|
||||
runStatus?: string;
|
||||
runtimeFailure?: boolean;
|
||||
};
|
||||
briefing?: {
|
||||
sourceLabel: string;
|
||||
title: string;
|
||||
@@ -132,6 +140,7 @@ const {
|
||||
handoffContext: _handoffContext,
|
||||
handoffResources: _handoffResources,
|
||||
handoffActions: _handoffActions,
|
||||
handoffMetadata: _handoffMetadata,
|
||||
...rest
|
||||
} = mockAiChatStore.context;
|
||||
mockAiChatStore.context = rest;
|
||||
@@ -398,8 +407,7 @@ describe('AIChat', () => {
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol configuration failure attached',
|
||||
subject:
|
||||
'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.',
|
||||
subject: 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.',
|
||||
statusLabel: 'HTTP 402 · model_unsupported_tools',
|
||||
detailLines: ['Provider: openrouter'],
|
||||
evidence: ['Command: sensitive or command detail withheld'],
|
||||
@@ -418,6 +426,47 @@ describe('AIChat', () => {
|
||||
expect(context).toHaveTextContent('Approval required before any action.');
|
||||
});
|
||||
|
||||
it('renders resource context handoff details without prompt text injection', () => {
|
||||
mockAiChatStore.context = {
|
||||
targetType: 'resource',
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'app-container:homeassistant',
|
||||
name: 'Home Assistant',
|
||||
type: 'app-container',
|
||||
node: 'ha-lxc',
|
||||
},
|
||||
],
|
||||
handoffMetadata: {
|
||||
kind: 'resource_context',
|
||||
},
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse resource context',
|
||||
title: 'Home Assistant',
|
||||
subject: 'app-container / online / docker',
|
||||
statusLabel: 'Read-only context attached',
|
||||
detailLines: [
|
||||
'Resource ID: app-container:homeassistant',
|
||||
'Parent: ha-lxc',
|
||||
'Discovery: app-container:homeassistant',
|
||||
],
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
};
|
||||
|
||||
renderChat();
|
||||
|
||||
const context = screen.getByLabelText('Assistant context');
|
||||
expect(context).toHaveTextContent('Pulse resource context');
|
||||
expect(context).toHaveTextContent('Home Assistant');
|
||||
expect(context).toHaveTextContent('app-container / online / docker');
|
||||
expect(context).toHaveTextContent('Resource ID: app-container:homeassistant');
|
||||
expect(context).toHaveTextContent('Parent: ha-lxc');
|
||||
expect(context).toHaveTextContent('Approval required before any action.');
|
||||
expect(context).not.toHaveTextContent('[Resource Context Pack]');
|
||||
});
|
||||
|
||||
it('renders Patrol run handoff details without replacing the attached headline', () => {
|
||||
mockAiChatStore.context = {
|
||||
targetType: 'patrol-run',
|
||||
|
||||
@@ -67,6 +67,7 @@ const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session';
|
||||
const DEFAULT_SESSION_KEY = '__default__';
|
||||
const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
||||
const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
|
||||
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
|
||||
|
||||
interface AIChatProps {
|
||||
onClose: () => void;
|
||||
@@ -81,6 +82,10 @@ const pluralizeCount = (count: number, singular: string, plural: string) =>
|
||||
const shouldShowStructuredPatrolContext = (targetType: string | undefined) =>
|
||||
STRUCTURED_PATROL_CONTEXT_TARGETS.has(targetType ?? '');
|
||||
|
||||
const shouldShowStructuredBriefingContext = (context: AIChatContext) =>
|
||||
shouldShowStructuredPatrolContext(context.targetType) ||
|
||||
STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS.has(context.handoffMetadata?.kind ?? '');
|
||||
|
||||
const isPatrolFindingSessionHandoff = (summary: ChatSessionHandoffSummary) =>
|
||||
summary.kind === 'patrol_finding' || Boolean(summary.finding_id);
|
||||
|
||||
@@ -515,7 +520,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
|
||||
const title = briefing.title?.trim() ?? '';
|
||||
const subject = briefing.subject?.trim() ?? '';
|
||||
if (shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) {
|
||||
if (shouldShowStructuredBriefingContext(aiChatStore.context)) {
|
||||
return title || subject || 'Context attached';
|
||||
}
|
||||
if (subject && /attached|briefing/i.test(title)) {
|
||||
@@ -527,7 +532,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const contextBriefingDetailLines = createMemo(() => {
|
||||
const briefing = contextBriefing();
|
||||
if (!briefing) return [];
|
||||
if (!shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) return [];
|
||||
if (!shouldShowStructuredBriefingContext(aiChatStore.context)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Show, For, Suspense } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { Subtabs } from '@/components/shared/Subtabs';
|
||||
@@ -102,30 +105,50 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.onClose}>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Show when={drawer.assistantAvailable()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => drawer.openAssistantForResource()}
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
title={`Ask Pulse Assistant about ${drawer.displayName()}`}
|
||||
aria-label={`Ask Pulse Assistant about ${drawer.displayName()}`}
|
||||
>
|
||||
<MessageSquareIcon class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Ask</span>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onClose?.()}
|
||||
class="rounded-md p-1 hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close resource drawer"
|
||||
onClick={() => void drawer.copyAgentContext()}
|
||||
disabled={drawer.copyingAgentContext()}
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
|
||||
title={`Copy Pulse context for ${drawer.displayName()}`}
|
||||
aria-label={`Copy Pulse context for ${drawer.displayName()}`}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<CopyIcon class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{drawer.agentContextCopied() ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onClose?.()}
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Close resource drawer"
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Resource detail sections"
|
||||
value={drawer.activeTab()}
|
||||
onChange={(value) => drawer.setActiveTab(value as Parameters<typeof drawer.setActiveTab>[0])}
|
||||
onChange={(value) =>
|
||||
drawer.setActiveTab(value as Parameters<typeof drawer.setActiveTab>[0])
|
||||
}
|
||||
tabs={drawer.tabs().map((tab) => ({ value: tab.id, label: tab.label }))}
|
||||
/>
|
||||
|
||||
|
||||
+102
-1
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, waitFor, within } from '@solidjs/testing-library';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
getSpecializedTabAvailabilityMessage,
|
||||
@@ -9,6 +10,10 @@ import {
|
||||
|
||||
const wsState = vi.hoisted(() => ({ pmg: [] as any[] }));
|
||||
const reconnectSpy = vi.hoisted(() => vi.fn());
|
||||
const getResourceContextMock = vi.hoisted(() => vi.fn());
|
||||
const copyToClipboardMock = vi.hoisted(() => vi.fn());
|
||||
const notificationSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const notificationErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
@@ -80,6 +85,23 @@ vi.mock('@/api/ai', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentContext', () => ({
|
||||
AgentContextAPI: {
|
||||
getResourceContext: getResourceContextMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: notificationSuccessMock,
|
||||
error: notificationErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
class ResizeObserverMock {
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
observe() {}
|
||||
@@ -105,7 +127,86 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
aiChatStore.setEnabled(false);
|
||||
getResourceContextMock.mockReset();
|
||||
copyToClipboardMock.mockReset();
|
||||
notificationSuccessMock.mockReset();
|
||||
notificationErrorMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
it('opens Pulse Assistant with a resource-scoped handoff from the drawer header', () => {
|
||||
aiChatStore.setEnabled(true);
|
||||
const openSpy = vi.spyOn(aiChatStore, 'open');
|
||||
const resource = baseResource({
|
||||
type: 'app-container',
|
||||
displayName: 'Home Assistant',
|
||||
technology: 'docker',
|
||||
parentName: 'ha-lxc',
|
||||
discoveryTarget: {
|
||||
resourceType: 'app-container',
|
||||
agentId: 'agent:pve-1',
|
||||
resourceId: 'homeassistant',
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole } = render(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Ask Pulse Assistant about Home Assistant' }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'resource',
|
||||
targetId: 'resource-1',
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'resource-1',
|
||||
name: 'Home Assistant',
|
||||
type: 'app-container',
|
||||
node: 'ha-lxc',
|
||||
},
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
}),
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('copies the canonical agent resource context from the drawer header', async () => {
|
||||
getResourceContextMock.mockResolvedValue({
|
||||
canonicalId: 'resource-1',
|
||||
resourceType: 'agent',
|
||||
resourceName: 'host-1',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
contextSections: [
|
||||
{
|
||||
id: 'identity',
|
||||
title: 'Identity',
|
||||
source: 'unified-resource',
|
||||
trustTier: 'pulse-authored',
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
facts: [{ label: 'Display name', value: 'host-1' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
|
||||
const { getByRole } = render(() => <ResourceDetailDrawer resource={baseResource({})} />);
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Copy Pulse context for host-1' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResourceContextMock).toHaveBeenCalledWith('resource-1');
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('host-1'));
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a unified summary shell without repeating healthy source status', () => {
|
||||
const resource = baseResource({
|
||||
platformData: {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const resource: Resource = {
|
||||
id: 'app-container:homeassistant',
|
||||
type: 'app-container',
|
||||
name: 'homeassistant',
|
||||
displayName: 'Home Assistant',
|
||||
platformId: 'homeassistant',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
technology: 'docker',
|
||||
parentName: 'ha-lxc',
|
||||
lastSeen: Date.now(),
|
||||
discoveryTarget: {
|
||||
resourceType: 'app-container',
|
||||
agentId: 'agent:pve-1',
|
||||
resourceId: 'homeassistant',
|
||||
},
|
||||
};
|
||||
|
||||
describe('buildResourceAssistantContext', () => {
|
||||
it('creates a model-only resource handoff without prefilled prompt context', () => {
|
||||
const context = buildResourceAssistantContext(resource);
|
||||
|
||||
expect(context.targetType).toBe('resource');
|
||||
expect(context.targetId).toBe('app-container:homeassistant');
|
||||
expect(context.autonomousMode).toBe(false);
|
||||
expect(context.handoffContext).toBeUndefined();
|
||||
expect(context.handoffResources).toEqual([
|
||||
{
|
||||
id: 'app-container:homeassistant',
|
||||
name: 'Home Assistant',
|
||||
type: 'app-container',
|
||||
node: 'ha-lxc',
|
||||
},
|
||||
]);
|
||||
expect(context.handoffMetadata).toEqual({ kind: 'resource_context' });
|
||||
expect(context.briefing?.detailLines).toContain('Resource ID: app-container:homeassistant');
|
||||
expect(context.briefing?.detailLines).toContain('Parent: ha-lxc');
|
||||
expect(context.briefing?.detailLines).toContain('Discovery: app-container:homeassistant');
|
||||
});
|
||||
});
|
||||
+41
-9
@@ -8,6 +8,7 @@ import {
|
||||
buildSourceSections,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerIdentityModel';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||
|
||||
const baseResource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
id: 'resource-1',
|
||||
@@ -42,9 +43,7 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
||||
value: 'vm:101',
|
||||
});
|
||||
expect(identityView.identityIpValues).toEqual(['192.0.2.10']);
|
||||
expect(identityView.aliasPreviewValues).toEqual(
|
||||
identityView.identityAliasValues.slice(0, 4),
|
||||
);
|
||||
expect(identityView.aliasPreviewValues).toEqual(identityView.identityAliasValues.slice(0, 4));
|
||||
expect(identityView.hasAliasOverflow).toBe(true);
|
||||
expect(identityView.identityCardHasRichData).toBe(true);
|
||||
});
|
||||
@@ -75,6 +74,39 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds resource Assistant handoffs from canonical resource identity without prompt text', () => {
|
||||
const context = buildResourceAssistantContext(
|
||||
baseResource({
|
||||
id: 'system-container-6adaf34f529d241a',
|
||||
type: 'system-container',
|
||||
name: 'homeassistant',
|
||||
displayName: 'homeassistant',
|
||||
technology: 'lxc',
|
||||
parentName: 'delly',
|
||||
discoveryTarget: {
|
||||
resourceType: 'system-container',
|
||||
agentId: 'delly',
|
||||
resourceId: '101',
|
||||
hostname: 'homeassistant',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context.targetType).toBe('resource');
|
||||
expect(context.targetId).toBe('system-container-6adaf34f529d241a');
|
||||
expect(context.handoffContext).toBeUndefined();
|
||||
expect(context.handoffMetadata).toEqual({ kind: 'resource_context' });
|
||||
expect(context.handoffResources).toEqual([
|
||||
{
|
||||
id: 'system-container-6adaf34f529d241a',
|
||||
name: 'homeassistant',
|
||||
type: 'system-container',
|
||||
node: 'delly',
|
||||
},
|
||||
]);
|
||||
expect(context.briefing?.detailLines).toContain('Discovery: system-container:101');
|
||||
});
|
||||
|
||||
it('builds canonical source sections and identity-match precedence', () => {
|
||||
const platformData = {
|
||||
agent: { hostname: 'host-1' },
|
||||
@@ -110,12 +142,12 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
||||
resource,
|
||||
platformData: {
|
||||
proxmox: { nodeName: 'pve-1' },
|
||||
docker: { runtime: 'docker' },
|
||||
vmware: { connectionName: 'Lab VC' },
|
||||
},
|
||||
sourceStatus: {
|
||||
proxmox: { status: 'online' },
|
||||
},
|
||||
docker: { runtime: 'docker' },
|
||||
vmware: { connectionName: 'Lab VC' },
|
||||
},
|
||||
sourceStatus: {
|
||||
proxmox: { status: 'online' },
|
||||
},
|
||||
identityMatchInfo: { matchedBy: 'hostname' },
|
||||
}),
|
||||
).toEqual({
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { createEffect, createSignal } from 'solid-js';
|
||||
import { AgentContextAPI } from '@/api/agentContext';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||
import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState';
|
||||
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
|
||||
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
|
||||
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
|
||||
|
||||
type DrawerTab =
|
||||
@@ -35,6 +41,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
);
|
||||
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||
const [showReportModal, setShowReportModal] = createSignal(false);
|
||||
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
|
||||
const [showAccessContext, setShowAccessContext] = createSignal(
|
||||
@@ -113,6 +121,35 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
}
|
||||
};
|
||||
|
||||
const assistantAvailable = () => aiChatStore.enabled === true;
|
||||
|
||||
const openAssistantForResource = () => {
|
||||
if (!assistantAvailable()) return;
|
||||
aiChatStore.open(buildResourceAssistantContext(resource));
|
||||
};
|
||||
|
||||
const copyAgentContext = async () => {
|
||||
if (copyingAgentContext()) return;
|
||||
setCopyingAgentContext(true);
|
||||
setAgentContextCopied(false);
|
||||
|
||||
try {
|
||||
const context = await AgentContextAPI.getResourceContext(resource.id);
|
||||
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
|
||||
if (!copiedContext) {
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
setAgentContextCopied(true);
|
||||
notificationStore.success('Resource context copied.');
|
||||
setTimeout(() => setAgentContextCopied(false), 2000);
|
||||
} catch {
|
||||
notificationStore.error('Unable to copy resource context.');
|
||||
setAgentContextCopied(false);
|
||||
} finally {
|
||||
setCopyingAgentContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
@@ -120,6 +157,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
setMetricsHistoryRange,
|
||||
debugEnabled,
|
||||
copied,
|
||||
copyingAgentContext,
|
||||
agentContextCopied,
|
||||
showReportModal,
|
||||
setShowReportModal,
|
||||
showHistoryFilters,
|
||||
@@ -147,6 +186,9 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
...history,
|
||||
...derived,
|
||||
...dockerActions,
|
||||
assistantAvailable,
|
||||
openAssistantForResource,
|
||||
copyAgentContext,
|
||||
handleCopyJson,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
||||
|
||||
@@ -58,6 +59,10 @@ const discoveryApiMocks = vi.hoisted(() => ({
|
||||
null,
|
||||
),
|
||||
}));
|
||||
const getResourceContextMock = vi.hoisted(() => vi.fn());
|
||||
const copyToClipboardMock = vi.hoisted(() => vi.fn());
|
||||
const notificationSuccessMock = vi.hoisted(() => vi.fn());
|
||||
const notificationErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/api/discovery', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/discovery')>('@/api/discovery');
|
||||
@@ -67,6 +72,23 @@ vi.mock('@/api/discovery', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/agentContext', () => ({
|
||||
AgentContextAPI: {
|
||||
getResourceContext: getResourceContextMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: notificationSuccessMock,
|
||||
error: notificationErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/WebInterfaceUrlField', () => ({
|
||||
WebInterfaceUrlField: (props: {
|
||||
metadataKind: string;
|
||||
@@ -150,6 +172,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
aiChatStore.setEnabled(false);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -157,6 +180,96 @@ afterEach(() => {
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GuestDrawer', () => {
|
||||
describe('Assistant context actions', () => {
|
||||
it('opens Pulse Assistant with a canonical Proxmox LXC resource handoff', () => {
|
||||
aiChatStore.setEnabled(true);
|
||||
const openSpy = vi.spyOn(aiChatStore, 'open');
|
||||
const guest = makeGuest({
|
||||
id: 'legacy-row-id',
|
||||
instance: 'pve-main',
|
||||
node: 'pve-a',
|
||||
vmid: 101,
|
||||
name: 'homeassistant',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
platformType: 'proxmox',
|
||||
status: 'running',
|
||||
});
|
||||
|
||||
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Ask Pulse Assistant about homeassistant' }),
|
||||
);
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetType: 'resource',
|
||||
targetId: 'pve-main:pve-a:101',
|
||||
autonomousMode: false,
|
||||
handoffResources: [
|
||||
{
|
||||
id: 'pve-main:pve-a:101',
|
||||
name: 'homeassistant',
|
||||
type: 'system-container',
|
||||
node: 'pve-a',
|
||||
},
|
||||
],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
}),
|
||||
);
|
||||
expect(openSpy.mock.calls[0]?.[0]?.handoffContext).toBeUndefined();
|
||||
expect(openSpy.mock.calls[0]?.[0]?.briefing?.detailLines).toContain(
|
||||
'Discovery: system-container:101',
|
||||
);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('copies the safe agent context bundle using canonical guest identity', async () => {
|
||||
getResourceContextMock.mockResolvedValue({
|
||||
canonicalId: 'pve-main:pve-a:101',
|
||||
resourceType: 'system-container',
|
||||
resourceName: 'homeassistant',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
contextSections: [
|
||||
{
|
||||
id: 'runtime',
|
||||
title: 'Runtime',
|
||||
source: 'discovery',
|
||||
trustTier: 'pulse-observed',
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
facts: [{ label: 'Service', value: 'Home Assistant' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
const guest = makeGuest({
|
||||
id: 'legacy-row-id',
|
||||
instance: 'pve-main',
|
||||
node: 'pve-a',
|
||||
vmid: 101,
|
||||
name: 'homeassistant',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
});
|
||||
|
||||
render(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy Pulse context for homeassistant' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResourceContextMock).toHaveBeenCalledWith('pve-main:pve-a:101');
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Pulse resource context: homeassistant'),
|
||||
);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Resource context copied.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tabs ──
|
||||
|
||||
describe('tab switching', () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Component, Show, Suspense, createMemo } from 'solid-js';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||
@@ -13,7 +16,9 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
activeTab,
|
||||
agentLabel,
|
||||
agentTitle,
|
||||
agentContextCopied,
|
||||
backupPresentation,
|
||||
copyingAgentContext,
|
||||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryLoadingState,
|
||||
@@ -33,6 +38,9 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
guestOsSummary,
|
||||
networkInterfaces,
|
||||
normalizedTags,
|
||||
assistantAvailable,
|
||||
copyAgentContext,
|
||||
openAssistantForGuest,
|
||||
setHistoryRange,
|
||||
showInGuestAgentInstallCue,
|
||||
switchTab,
|
||||
@@ -47,12 +55,47 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
|
||||
return (
|
||||
<section class="space-y-3" aria-labelledby={headingId()}>
|
||||
<DrawerSubjectHeading
|
||||
headingId={headingId()}
|
||||
title={props.guest.name}
|
||||
statusVariant={headerIndicator().variant}
|
||||
statusLabel={headerIndicator().label}
|
||||
/>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<DrawerSubjectHeading
|
||||
headingId={headingId()}
|
||||
title={props.guest.name}
|
||||
statusVariant={headerIndicator().variant}
|
||||
statusLabel={headerIndicator().label}
|
||||
/>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<Show when={assistantAvailable()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openAssistantForGuest()}
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
title={`Ask Pulse Assistant about ${props.guest.name}`}
|
||||
aria-label={`Ask Pulse Assistant about ${props.guest.name}`}
|
||||
>
|
||||
<MessageSquareIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">Ask</span>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyAgentContext()}
|
||||
disabled={copyingAgentContext()}
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded border border-border bg-surface px-2 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
|
||||
title={`Copy Pulse context for ${props.guest.name}`}
|
||||
aria-label={`Copy Pulse context for ${props.guest.name}`}
|
||||
>
|
||||
<CopyIcon class="h-4 w-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">{agentContextCopied() ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onClose()}
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Close guest drawer"
|
||||
>
|
||||
<XIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Guest drawer sections"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AIChatContext } from '@/stores/aiChat';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { buildResourceAssistantContextForTarget } from '@/utils/resourceAssistantContextModel';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
resolveDiscoveryTargetForWorkload,
|
||||
resolveWorkloadType,
|
||||
} from '@/utils/workloads';
|
||||
|
||||
const firstTrimmed = (values: Array<string | null | undefined>): string | undefined => {
|
||||
for (const value of values) {
|
||||
const trimmed = (value || '').trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getGuestAssistantTechnology = (guest: WorkloadGuest): string | undefined => {
|
||||
const workloadType = resolveWorkloadType(guest);
|
||||
if (workloadType === 'app-container') {
|
||||
return firstTrimmed([guest.containerRuntime, guest.platformType, guest.type]);
|
||||
}
|
||||
if (workloadType === 'pod') {
|
||||
return firstTrimmed([guest.platformType, 'kubernetes']);
|
||||
}
|
||||
return firstTrimmed([guest.platformType, guest.type]);
|
||||
};
|
||||
|
||||
export const buildGuestAssistantContext = (guest: WorkloadGuest): AIChatContext => {
|
||||
const canonicalId = getCanonicalWorkloadId(guest);
|
||||
const workloadType = resolveWorkloadType(guest);
|
||||
const discoveryTarget = resolveDiscoveryTargetForWorkload(guest);
|
||||
|
||||
return buildResourceAssistantContextForTarget({
|
||||
id: canonicalId,
|
||||
name: guest.name || canonicalId,
|
||||
type: workloadType,
|
||||
source: 'guest-drawer',
|
||||
status: guest.status,
|
||||
technology: getGuestAssistantTechnology(guest),
|
||||
parentName: guest.node,
|
||||
primaryIdentity: guest.id,
|
||||
discoveryTarget,
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
|
||||
import { AgentContextAPI } from '@/api/agentContext';
|
||||
import { getDiscovery } from '@/api/discovery';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery';
|
||||
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import {
|
||||
getDiscoveryIdentifiedSummary,
|
||||
getDiscoveryLoadingState,
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
type GuestDrawerProps,
|
||||
type GuestDrawerTab,
|
||||
} from './guestDrawerModel';
|
||||
import { buildGuestAssistantContext } from './guestAssistantContextModel';
|
||||
import { shouldShowInGuestAgentInstallCue } from './workloadAgentReadiness';
|
||||
|
||||
interface GuestDiscoverySourceKey {
|
||||
@@ -50,6 +56,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||
|
||||
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
|
||||
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
|
||||
@@ -109,7 +117,10 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
if (!type || !agent || !resource) return null;
|
||||
return { type, agent, resource } as GuestDiscoverySourceKey;
|
||||
});
|
||||
const discoveryRecord = createNonSuspendingQuery<ResourceDiscovery | null, GuestDiscoverySourceKey>({
|
||||
const discoveryRecord = createNonSuspendingQuery<
|
||||
ResourceDiscovery | null,
|
||||
GuestDiscoverySourceKey
|
||||
>({
|
||||
source: discoverySourceKey,
|
||||
initialValue: null,
|
||||
cacheKey: (key) => `guest-drawer-discovery:${key.type}:${key.agent}:${key.resource}`,
|
||||
@@ -129,6 +140,35 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const assistantAvailable = () => aiChatStore.enabled === true;
|
||||
|
||||
const openAssistantForGuest = () => {
|
||||
if (!assistantAvailable()) return;
|
||||
aiChatStore.open(buildGuestAssistantContext(props.guest));
|
||||
};
|
||||
|
||||
const copyAgentContext = async () => {
|
||||
if (copyingAgentContext()) return;
|
||||
setCopyingAgentContext(true);
|
||||
setAgentContextCopied(false);
|
||||
|
||||
try {
|
||||
const context = await AgentContextAPI.getResourceContext(guestId());
|
||||
const copiedContext = await copyToClipboard(formatAgentResourceContextForClipboard(context));
|
||||
if (!copiedContext) {
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
setAgentContextCopied(true);
|
||||
notificationStore.success('Resource context copied.');
|
||||
setTimeout(() => setAgentContextCopied(false), 2000);
|
||||
} catch {
|
||||
notificationStore.error('Unable to copy resource context.');
|
||||
setAgentContextCopied(false);
|
||||
} finally {
|
||||
setCopyingAgentContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (activeTab() === 'discovery' && !hasDiscoverySupport()) {
|
||||
setActiveTab('overview');
|
||||
@@ -143,6 +183,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
agentLabel,
|
||||
agentTitle,
|
||||
backupPresentation,
|
||||
copyingAgentContext,
|
||||
agentContextCopied,
|
||||
discoveryAgentId,
|
||||
discoveryIdentifiedSummary,
|
||||
discoveryLoadingState: getDiscoveryLoadingState(),
|
||||
@@ -165,6 +207,9 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
osName,
|
||||
osVersion,
|
||||
showInGuestAgentInstallCue,
|
||||
assistantAvailable,
|
||||
openAssistantForGuest,
|
||||
copyAgentContext,
|
||||
switchTab,
|
||||
setHistoryRange,
|
||||
webInterfaceTargetLabel,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||
|
||||
describe('formatAgentResourceContextForClipboard', () => {
|
||||
it('formats bounded resource context sections with provenance and redactions', () => {
|
||||
const text = formatAgentResourceContextForClipboard({
|
||||
canonicalId: 'app-container:homeassistant',
|
||||
resourceType: 'app-container',
|
||||
resourceName: 'homeassistant',
|
||||
technology: 'docker',
|
||||
activeFindings: [],
|
||||
pendingApprovals: [],
|
||||
recentActions: [],
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
contextSections: [
|
||||
{
|
||||
id: 'runtime',
|
||||
title: 'Runtime and Discovery',
|
||||
source: 'unified-resource',
|
||||
trustTier: 'runtime-observed',
|
||||
observedAt: '2026-05-06T13:59:00Z',
|
||||
generatedAt: '2026-05-06T14:00:00Z',
|
||||
facts: [
|
||||
{
|
||||
label: 'Ports',
|
||||
value: '8123:8123/tcp',
|
||||
source: 'unified-resource',
|
||||
trustTier: 'discovered',
|
||||
},
|
||||
{
|
||||
label: 'Storage path',
|
||||
value: 'redacted by policy',
|
||||
source: 'unified-resource',
|
||||
trustTier: 'runtime-observed',
|
||||
redacted: true,
|
||||
},
|
||||
],
|
||||
redactions: [
|
||||
{
|
||||
field: 'resource identifiers',
|
||||
reason: 'canonical resource policy redacts path',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text).toContain('# Pulse resource context: homeassistant');
|
||||
expect(text).toContain('Canonical ID: app-container:homeassistant');
|
||||
expect(text).toContain('## Runtime and Discovery');
|
||||
expect(text).toContain('- Ports: 8123:8123/tcp');
|
||||
expect(text).toContain('- Storage path: redacted by policy');
|
||||
expect(text).toContain('redacted=true');
|
||||
expect(text).toContain('Redaction: resource identifiers');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
AgentResourceContext,
|
||||
AgentResourceContextFact,
|
||||
AgentResourceContextRedaction,
|
||||
AgentResourceContextSection,
|
||||
} from '@/api/agentContext';
|
||||
|
||||
const formatOptionalTime = (value?: string): string => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const formatProvenance = (fact: AgentResourceContextFact): string => {
|
||||
const parts = [
|
||||
fact.source ? `source=${fact.source}` : '',
|
||||
fact.trustTier ? `trust=${fact.trustTier}` : '',
|
||||
fact.observedAt ? `observed=${formatOptionalTime(fact.observedAt)}` : '',
|
||||
fact.redacted ? 'redacted=true' : '',
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
||||
};
|
||||
|
||||
const formatFact = (fact: AgentResourceContextFact): string =>
|
||||
`- ${fact.label}: ${fact.value}${formatProvenance(fact)}`;
|
||||
|
||||
const formatRedaction = (redaction: AgentResourceContextRedaction): string =>
|
||||
`- Redaction: ${redaction.field}${redaction.reason ? ` - ${redaction.reason}` : ''}`;
|
||||
|
||||
const formatSectionHeader = (section: AgentResourceContextSection): string => {
|
||||
const provenance = [
|
||||
section.source ? `source=${section.source}` : '',
|
||||
section.trustTier ? `trust=${section.trustTier}` : '',
|
||||
section.observedAt ? `observed=${formatOptionalTime(section.observedAt)}` : '',
|
||||
].filter(Boolean);
|
||||
return provenance.length > 0
|
||||
? `## ${section.title} (${provenance.join(', ')})`
|
||||
: `## ${section.title}`;
|
||||
};
|
||||
|
||||
export const formatAgentResourceContextForClipboard = (context: AgentResourceContext): string => {
|
||||
const lines = [
|
||||
`# Pulse resource context: ${context.resourceName || context.canonicalId}`,
|
||||
'',
|
||||
`Generated: ${formatOptionalTime(context.generatedAt)}`,
|
||||
`Canonical ID: ${context.canonicalId}`,
|
||||
`Resource type: ${context.resourceType}`,
|
||||
];
|
||||
|
||||
if (context.technology) {
|
||||
lines.push(`Technology: ${context.technology}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Active findings: ${context.activeFindings.length}`,
|
||||
`Pending approvals: ${context.pendingApprovals.length}`,
|
||||
`Recent actions: ${context.recentActions.length}`,
|
||||
'',
|
||||
'Context facts below are bounded, read-only Pulse context. Redacted values were withheld by policy.',
|
||||
);
|
||||
|
||||
for (const section of context.contextSections) {
|
||||
if (section.facts.length === 0 && !section.redactions?.length) continue;
|
||||
lines.push('', formatSectionHeader(section));
|
||||
if (section.summary) {
|
||||
lines.push(section.summary);
|
||||
}
|
||||
for (const fact of section.facts) {
|
||||
lines.push(formatFact(fact));
|
||||
}
|
||||
for (const redaction of section.redactions ?? []) {
|
||||
lines.push(formatRedaction(redaction));
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n').trim()}\n`;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AIChatContext } from '@/stores/aiChat';
|
||||
import type { Resource, ResourceDiscoveryTarget } from '@/types/resource';
|
||||
import {
|
||||
getPreferredResourceDisplayName,
|
||||
getPrimaryResourceIdentity,
|
||||
} from '@/utils/resourceIdentity';
|
||||
|
||||
const compact = (values: Array<string | undefined | null | false>): string[] =>
|
||||
values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
export interface ResourceAssistantContextTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
source: string;
|
||||
status?: string;
|
||||
technology?: string;
|
||||
parentName?: string;
|
||||
primaryIdentity?: string;
|
||||
discoveryTarget?: ResourceDiscoveryTarget | null;
|
||||
}
|
||||
|
||||
export const buildResourceAssistantContextForTarget = (
|
||||
target: ResourceAssistantContextTarget,
|
||||
): AIChatContext => {
|
||||
const displayName = target.name || target.id;
|
||||
const subjectParts = compact([target.type, target.status, target.technology]);
|
||||
const detailLines = compact([
|
||||
`Resource ID: ${target.id}`,
|
||||
target.primaryIdentity && target.primaryIdentity !== target.id
|
||||
? `Primary identity: ${target.primaryIdentity}`
|
||||
: '',
|
||||
target.parentName ? `Parent: ${target.parentName}` : '',
|
||||
target.discoveryTarget
|
||||
? `Discovery: ${target.discoveryTarget.resourceType}:${target.discoveryTarget.resourceId}`
|
||||
: '',
|
||||
]);
|
||||
|
||||
return {
|
||||
targetType: 'resource',
|
||||
targetId: target.id,
|
||||
context: {
|
||||
source: target.source,
|
||||
resourceId: target.id,
|
||||
resourceType: target.type,
|
||||
resourceStatus: target.status,
|
||||
},
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse resource context',
|
||||
title: displayName,
|
||||
subject: subjectParts.join(' / '),
|
||||
statusLabel: 'Read-only context attached',
|
||||
detailLines,
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
handoffResources: [
|
||||
{
|
||||
id: target.id,
|
||||
name: displayName,
|
||||
type: target.type,
|
||||
node: target.parentName,
|
||||
},
|
||||
],
|
||||
handoffMetadata: {
|
||||
kind: 'resource_context',
|
||||
},
|
||||
autonomousMode: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildResourceAssistantContext = (resource: Resource): AIChatContext =>
|
||||
buildResourceAssistantContextForTarget({
|
||||
id: resource.id,
|
||||
name: getPreferredResourceDisplayName(resource),
|
||||
type: resource.type,
|
||||
source: 'resource-detail-drawer',
|
||||
status: resource.status,
|
||||
technology: resource.technology,
|
||||
parentName: resource.parentName,
|
||||
primaryIdentity: getPrimaryResourceIdentity(resource),
|
||||
discoveryTarget: resource.discoveryTarget,
|
||||
});
|
||||
@@ -0,0 +1,738 @@
|
||||
package agentcontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// Fact is a bounded, typed fact in a resource context pack. Values come
|
||||
// from Pulse-owned runtime state only; raw command output, config files,
|
||||
// environment variables, and secret-bearing metadata do not belong here.
|
||||
type Fact struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source,omitempty"`
|
||||
TrustTier string `json:"trustTier,omitempty"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
Redacted bool `json:"redacted,omitempty"`
|
||||
}
|
||||
|
||||
// Redaction records why a context section withheld a field. It gives
|
||||
// agents and UI inspectors a visible safety boundary without leaking
|
||||
// the underlying value.
|
||||
type Redaction struct {
|
||||
Field string `json:"field"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Section groups related facts with one provenance/freshness envelope.
|
||||
// Additive sections make a context pack useful to Assistant and external
|
||||
// MCP clients without breaking older clients that parse top-level fields.
|
||||
type Section struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Source string `json:"source"`
|
||||
TrustTier string `json:"trustTier"`
|
||||
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Facts []Fact `json:"facts"`
|
||||
Redactions []Redaction `json:"redactions,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorState is the context-pack projection of operator intent. It is
|
||||
// intentionally narrower than the persistence/API shape so this package
|
||||
// stays independent of HTTP and storage owners.
|
||||
type OperatorState struct {
|
||||
IntentionallyOffline bool
|
||||
NeverAutoRemediate bool
|
||||
MaintenanceWindowActive bool
|
||||
MaintenanceStartAt *time.Time
|
||||
MaintenanceEndAt *time.Time
|
||||
Criticality string
|
||||
NotePresent bool
|
||||
SetAt time.Time
|
||||
}
|
||||
|
||||
// BuildOptions supplies operational counts owned outside the unified
|
||||
// resource model.
|
||||
type BuildOptions struct {
|
||||
GeneratedAt time.Time
|
||||
OperatorState *OperatorState
|
||||
ActiveFindingCount int
|
||||
PendingApprovalCount int
|
||||
RecentActionCount int
|
||||
}
|
||||
|
||||
const (
|
||||
agentContextTrustPulseAuthored = "pulse-authored"
|
||||
agentContextTrustRuntimeObserved = "runtime-observed"
|
||||
agentContextTrustDiscovered = "discovered"
|
||||
agentContextTrustUserTaught = "user-taught"
|
||||
|
||||
agentContextSourceUnifiedResource = "unified-resource"
|
||||
agentContextSourceResourceStore = "resource-store"
|
||||
agentContextSourceOperatorState = "operator-state"
|
||||
agentContextSourcePatrol = "patrol"
|
||||
agentContextSourceActionAudit = "action-audit"
|
||||
)
|
||||
|
||||
// BuildResourceContextSections returns the canonical resource context
|
||||
// pack shared by Assistant, the agent HTTP endpoint, and MCP adapters.
|
||||
func BuildResourceContextSections(resource unified.Resource, store unified.ResourceStore, opts BuildOptions) []Section {
|
||||
now := opts.GeneratedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
sections := make([]Section, 0, 7)
|
||||
|
||||
sections = appendContextSection(sections, buildAgentIdentityContextSection(resource, now))
|
||||
sections = appendContextSection(sections, buildAgentRuntimeContextSection(resource, now))
|
||||
sections = appendContextSection(sections, buildAgentTopologyContextSection(resource, now))
|
||||
sections = appendContextSection(sections, buildAgentSafetyContextSection(resource, opts, now))
|
||||
sections = appendContextSection(sections, buildAgentPolicyContextSection(resource, now))
|
||||
if store != nil {
|
||||
sections = appendContextSection(sections, buildAgentRecentChangesContextSection(resource, store, now))
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
// FormatSectionsForModelContext renders context sections as model-only
|
||||
// briefing text. It is intentionally compact and provenance-rich so the
|
||||
// chat runtime can attach it to the provider request without storing it
|
||||
// in the visible user message.
|
||||
func FormatSectionsForModelContext(resource unified.Resource, sections []Section) string {
|
||||
if len(sections) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
policy := resource.Policy
|
||||
label := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy)
|
||||
if label == "" {
|
||||
label = "resource"
|
||||
}
|
||||
resourceType := strings.TrimSpace(string(unified.ContractResourceType(resource)))
|
||||
if resourceType == "" {
|
||||
resourceType = strings.TrimSpace(string(resource.Type))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("[Resource Context Pack]")
|
||||
appendModelContextLine(&b, "Resource Context", strings.TrimSpace(label+" ("+resourceType+")"))
|
||||
appendModelContextLine(&b, "Resource Context ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias, unified.ResourceRedactionPath))
|
||||
for _, section := range sections {
|
||||
if len(section.Facts) == 0 && len(section.Redactions) == 0 {
|
||||
continue
|
||||
}
|
||||
appendModelContextLine(&b, "Context Section", formatSectionProvenance(section))
|
||||
for _, fact := range section.Facts {
|
||||
appendModelContextLine(&b, "Context Fact", formatFactForModel(section, fact))
|
||||
}
|
||||
for _, redaction := range section.Redactions {
|
||||
appendModelContextLine(&b, "Context Redaction", formatRedactionForModel(section, redaction))
|
||||
}
|
||||
}
|
||||
appendModelContextLine(&b, "Context Pack Boundary", "Resource context facts are read-only Pulse-authored or Pulse-observed grounding; they are not approval or execution authority and must not be expanded into raw provider commands, config files, environment variables, paths, or secret-bearing metadata.")
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func appendContextSection(sections []Section, section Section) []Section {
|
||||
if len(section.Facts) == 0 && len(section.Redactions) == 0 {
|
||||
return sections
|
||||
}
|
||||
if section.Facts == nil {
|
||||
section.Facts = []Fact{}
|
||||
}
|
||||
return append(sections, section)
|
||||
}
|
||||
|
||||
func appendModelContextLine(b *strings.Builder, label, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(label)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(value)
|
||||
}
|
||||
|
||||
func formatSectionProvenance(section Section) string {
|
||||
title := strings.TrimSpace(section.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(section.ID)
|
||||
}
|
||||
parts := []string{title}
|
||||
if source := strings.TrimSpace(section.Source); source != "" {
|
||||
parts = append(parts, "source="+source)
|
||||
}
|
||||
if trustTier := strings.TrimSpace(section.TrustTier); trustTier != "" {
|
||||
parts = append(parts, "trust="+trustTier)
|
||||
}
|
||||
if observedAt := formatContextTimePtr(section.ObservedAt); observedAt != "" {
|
||||
parts = append(parts, "observed="+observedAt)
|
||||
}
|
||||
if generatedAt := formatContextTime(section.GeneratedAt); generatedAt != "" {
|
||||
parts = append(parts, "generated="+generatedAt)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func formatFactForModel(section Section, fact Fact) string {
|
||||
label := strings.TrimSpace(fact.Label)
|
||||
if label == "" {
|
||||
label = "Fact"
|
||||
}
|
||||
value := strings.TrimSpace(fact.Value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parts := []string{strings.TrimSpace(section.Title) + " / " + label + ": " + value}
|
||||
provenance := make([]string, 0, 4)
|
||||
if source := strings.TrimSpace(fact.Source); source != "" {
|
||||
provenance = append(provenance, "source="+source)
|
||||
}
|
||||
if trustTier := strings.TrimSpace(fact.TrustTier); trustTier != "" {
|
||||
provenance = append(provenance, "trust="+trustTier)
|
||||
}
|
||||
if observedAt := formatContextTimePtr(fact.ObservedAt); observedAt != "" {
|
||||
provenance = append(provenance, "observed="+observedAt)
|
||||
}
|
||||
if fact.Redacted {
|
||||
provenance = append(provenance, "redacted=true")
|
||||
}
|
||||
if len(provenance) > 0 {
|
||||
parts = append(parts, "["+strings.Join(provenance, ", ")+"]")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func formatRedactionForModel(section Section, redaction Redaction) string {
|
||||
field := strings.TrimSpace(redaction.Field)
|
||||
if field == "" {
|
||||
field = "field"
|
||||
}
|
||||
reason := strings.TrimSpace(redaction.Reason)
|
||||
if reason == "" {
|
||||
return strings.TrimSpace(section.Title) + " / " + field
|
||||
}
|
||||
return strings.TrimSpace(section.Title) + " / " + field + ": " + reason
|
||||
}
|
||||
|
||||
func formatContextTimePtr(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return formatContextTime(*value)
|
||||
}
|
||||
|
||||
func formatContextTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func newAgentContextSection(id, title, source, trustTier string, generatedAt time.Time, observedAt *time.Time) Section {
|
||||
return Section{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Source: source,
|
||||
TrustTier: trustTier,
|
||||
ObservedAt: observedAt,
|
||||
GeneratedAt: generatedAt,
|
||||
Facts: []Fact{},
|
||||
}
|
||||
}
|
||||
|
||||
func agentContextFact(label, value, source, trustTier string, observedAt *time.Time) Fact {
|
||||
value = strings.TrimSpace(value)
|
||||
fact := Fact{
|
||||
Label: strings.TrimSpace(label),
|
||||
Value: value,
|
||||
Source: source,
|
||||
TrustTier: trustTier,
|
||||
ObservedAt: observedAt,
|
||||
}
|
||||
if value == unified.ResourcePolicyRedactedLabel {
|
||||
fact.Redacted = true
|
||||
}
|
||||
return fact
|
||||
}
|
||||
|
||||
func addAgentContextFact(facts *[]Fact, label, value, source, trustTier string, observedAt *time.Time) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
*facts = append(*facts, agentContextFact(label, value, source, trustTier, observedAt))
|
||||
}
|
||||
|
||||
func addAgentContextCountFact(facts *[]Fact, label string, value int, source, trustTier string, observedAt *time.Time) {
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
addAgentContextFact(facts, label, fmt.Sprintf("%d", value), source, trustTier, observedAt)
|
||||
}
|
||||
|
||||
func buildAgentIdentityContextSection(resource unified.Resource, now time.Time) Section {
|
||||
observedAt := agentResourceObservedAt(resource)
|
||||
section := newAgentContextSection("identity", "Identity", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||
policy := resource.Policy
|
||||
|
||||
displayName := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy)
|
||||
addAgentContextFact(§ion.Facts, "Display name", displayName, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Canonical ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Resource type", string(resource.Type), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Status", string(resource.Status), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Technology", resource.Technology, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
if !resource.LastSeen.IsZero() {
|
||||
addAgentContextFact(§ion.Facts, "Last seen", resource.LastSeen.UTC().Format(time.RFC3339), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
if len(resource.Sources) > 0 {
|
||||
addAgentContextFact(§ion.Facts, "Sources", joinDataSources(resource.Sources), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
for source, status := range resource.SourceStatus {
|
||||
label := fmt.Sprintf("%s source", source)
|
||||
value := strings.TrimSpace(status.Status)
|
||||
if !status.LastSeen.IsZero() {
|
||||
value = strings.TrimSpace(value + " lastSeen=" + status.LastSeen.UTC().Format(time.RFC3339))
|
||||
}
|
||||
addAgentContextFact(§ion.Facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
|
||||
if len(unified.ResourcePolicySummaryLines(resource.Policy)) > 0 {
|
||||
section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...)
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
func buildAgentRuntimeContextSection(resource unified.Resource, now time.Time) Section {
|
||||
observedAt := agentResourceObservedAt(resource)
|
||||
section := newAgentContextSection("runtime", "Runtime and Discovery", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, now, observedAt)
|
||||
policy := resource.Policy
|
||||
|
||||
if resource.DiscoveryTarget != nil {
|
||||
addAgentContextFact(§ion.Facts, "Discovery type", resource.DiscoveryTarget.ResourceType, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Discovery agent", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.AgentID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Discovery resource", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.ResourceID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextFact(§ion.Facts, "Discovery hostname", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
|
||||
addMetricsFacts(§ion.Facts, resource, observedAt)
|
||||
addAgentDataFacts(§ion.Facts, resource, observedAt)
|
||||
addProxmoxFacts(§ion.Facts, resource, observedAt)
|
||||
addDockerFacts(§ion.Facts, resource, observedAt)
|
||||
addKubernetesFacts(§ion.Facts, resource, observedAt)
|
||||
addStorageFacts(§ion.Facts, resource, observedAt)
|
||||
addTrueNASFacts(§ion.Facts, resource, observedAt)
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
func buildAgentTopologyContextSection(resource unified.Resource, now time.Time) Section {
|
||||
observedAt := agentResourceObservedAt(resource)
|
||||
section := newAgentContextSection("topology", "Topology", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||
policy := resource.Policy
|
||||
|
||||
if resource.ParentID != nil {
|
||||
addAgentContextFact(§ion.Facts, "Parent ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(*resource.ParentID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
addAgentContextFact(§ion.Facts, "Parent name", unified.ResourcePolicyRedactedValue(resource.ParentName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
addAgentContextCountFact(§ion.Facts, "Child resources", resource.ChildCount, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
|
||||
relationships := unified.ResourceRelationshipsWithCanonicalParent(resource)
|
||||
if len(relationships) > 0 {
|
||||
addAgentContextCountFact(§ion.Facts, "Relationship count", len(relationships), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
for i, rel := range relationships {
|
||||
if i >= 5 {
|
||||
addAgentContextFact(§ion.Facts, "Relationship limit", fmt.Sprintf("%d additional relationships omitted from context pack", len(relationships)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
break
|
||||
}
|
||||
value := formatAgentRelationshipFact(rel, policy)
|
||||
relObservedAt := timePtrIfSet(rel.LastSeenAt)
|
||||
if relObservedAt == nil {
|
||||
relObservedAt = timePtrIfSet(rel.ObservedAt)
|
||||
}
|
||||
addAgentContextFact(§ion.Facts, fmt.Sprintf("Relationship %d", i+1), value, agentContextSourceUnifiedResource, agentContextTrustDiscovered, relObservedAt)
|
||||
}
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
func buildAgentSafetyContextSection(resource unified.Resource, opts BuildOptions, now time.Time) Section {
|
||||
observedAt := agentResourceObservedAt(resource)
|
||||
section := newAgentContextSection("safety", "Safety and Operations", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||
|
||||
if opts.OperatorState != nil {
|
||||
addAgentContextFact(§ion.Facts, "Operator state", formatOperatorStateFact(*opts.OperatorState), agentContextSourceOperatorState, agentContextTrustUserTaught, timePtrIfSet(opts.OperatorState.SetAt))
|
||||
}
|
||||
addAgentContextCountFact(§ion.Facts, "Active findings", opts.ActiveFindingCount, agentContextSourcePatrol, agentContextTrustRuntimeObserved, &now)
|
||||
addAgentContextCountFact(§ion.Facts, "Pending approvals", opts.PendingApprovalCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
|
||||
addAgentContextCountFact(§ion.Facts, "Recent actions", opts.RecentActionCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
|
||||
if len(resource.Capabilities) > 0 {
|
||||
addAgentContextCountFact(§ion.Facts, "Governed capabilities", len(resource.Capabilities), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
for i, capability := range resource.Capabilities {
|
||||
if i >= 5 {
|
||||
addAgentContextFact(§ion.Facts, "Capability limit", fmt.Sprintf("%d additional capabilities omitted from context pack", len(resource.Capabilities)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
break
|
||||
}
|
||||
addAgentContextFact(§ion.Facts, fmt.Sprintf("Capability %d", i+1), formatCapabilityFact(capability), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
}
|
||||
if len(resource.Incidents) > 0 {
|
||||
addAgentContextCountFact(§ion.Facts, "Incidents", len(resource.Incidents), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
for i, incident := range resource.Incidents {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
incidentObservedAt := timePtrIfSet(incident.StartedAt)
|
||||
addAgentContextFact(§ion.Facts, fmt.Sprintf("Incident %d", i+1), unified.ResourcePolicyRedactedText(incident.Summary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, incidentObservedAt)
|
||||
}
|
||||
}
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
func buildAgentPolicyContextSection(resource unified.Resource, now time.Time) Section {
|
||||
observedAt := agentResourceObservedAt(resource)
|
||||
section := newAgentContextSection("policy", "Data Policy", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||
for _, line := range unified.ResourcePolicySummaryLines(resource.Policy) {
|
||||
addAgentContextFact(§ion.Facts, "Policy", line, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
if resource.AISafeSummary != "" && unified.ResourcePolicyUsesAISafeSummary(resource.AISafeSummary, resource.Policy) {
|
||||
addAgentContextFact(§ion.Facts, "AI-safe summary", strings.TrimSpace(resource.AISafeSummary), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...)
|
||||
return section
|
||||
}
|
||||
|
||||
func buildAgentRecentChangesContextSection(resource unified.Resource, store unified.ResourceStore, now time.Time) Section {
|
||||
section := newAgentContextSection("recent_changes", "Recent Changes", agentContextSourceResourceStore, agentContextTrustRuntimeObserved, now, &now)
|
||||
changes, err := store.GetRecentChanges(unified.CanonicalResourceID(resource.ID), now.Add(-7*24*time.Hour), 5)
|
||||
if err != nil || len(changes) == 0 {
|
||||
return section
|
||||
}
|
||||
addAgentContextCountFact(§ion.Facts, "Included changes", len(changes), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, &now)
|
||||
for i, change := range changes {
|
||||
observedAt := timePtrIfSet(change.ObservedAt)
|
||||
addAgentContextFact(§ion.Facts, fmt.Sprintf("Change %d", i+1), formatAgentResourceChangeFact(change, resource), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
func addMetricsFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Metrics == nil {
|
||||
return
|
||||
}
|
||||
addMetricFact(facts, "CPU", resource.Metrics.CPU, observedAt)
|
||||
addMetricFact(facts, "Memory", resource.Metrics.Memory, observedAt)
|
||||
addMetricFact(facts, "Disk", resource.Metrics.Disk, observedAt)
|
||||
addMetricFact(facts, "Network in", resource.Metrics.NetIn, observedAt)
|
||||
addMetricFact(facts, "Network out", resource.Metrics.NetOut, observedAt)
|
||||
}
|
||||
|
||||
func addMetricFact(facts *[]Fact, label string, metric *unified.MetricValue, observedAt *time.Time) {
|
||||
if metric == nil {
|
||||
return
|
||||
}
|
||||
value := ""
|
||||
switch {
|
||||
case metric.Percent > 0:
|
||||
value = fmt.Sprintf("%.1f%%", metric.Percent)
|
||||
case metric.Value != 0:
|
||||
value = fmt.Sprintf("%.1f %s", metric.Value, strings.TrimSpace(metric.Unit))
|
||||
case metric.Used != nil && metric.Total != nil && *metric.Total > 0:
|
||||
value = fmt.Sprintf("%d/%d", *metric.Used, *metric.Total)
|
||||
}
|
||||
addAgentContextFact(facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
|
||||
func addAgentDataFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Agent == nil {
|
||||
return
|
||||
}
|
||||
policy := resource.Policy
|
||||
addAgentContextFact(facts, "Agent hostname", unified.ResourcePolicyRedactedValue(resource.Agent.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Agent platform", resource.Agent.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Agent OS", strings.TrimSpace(resource.Agent.OSName+" "+resource.Agent.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Agent version", resource.Agent.AgentVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Commands enabled", fmt.Sprintf("%t", resource.Agent.CommandsEnabled), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||
}
|
||||
|
||||
func addProxmoxFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Proxmox == nil {
|
||||
return
|
||||
}
|
||||
policy := resource.Policy
|
||||
addAgentContextFact(facts, "Proxmox node", unified.ResourcePolicyRedactedValue(resource.Proxmox.NodeName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
if resource.Proxmox.VMID > 0 {
|
||||
addAgentContextFact(facts, "Proxmox VMID", unified.ResourcePolicyRedactedValue(fmt.Sprintf("%d", resource.Proxmox.VMID), policy, unified.ResourceRedactionPlatformID), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
addAgentContextFact(facts, "Container type", resource.Proxmox.ContainerType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "OS", strings.TrimSpace(resource.Proxmox.OSName+" "+resource.Proxmox.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
if resource.Proxmox.HasDocker {
|
||||
addAgentContextFact(facts, "Nested Docker", "detected", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSetValue(resource.Proxmox.DockerCheckedAt))
|
||||
}
|
||||
addAgentContextFact(facts, "Proxmox lock", resource.Proxmox.Lock, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
|
||||
func addDockerFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Docker == nil {
|
||||
return
|
||||
}
|
||||
policy := resource.Policy
|
||||
addAgentContextFact(facts, "Docker runtime", strings.TrimSpace(resource.Docker.Runtime+" "+resource.Docker.RuntimeVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Docker version", resource.Docker.DockerVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "Containers", resource.Docker.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "Images", resource.Docker.ImageCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Container state", resource.Docker.ContainerState, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Container health", resource.Docker.Health, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
if resource.Docker.RestartCount > 0 {
|
||||
addAgentContextFact(facts, "Restart count", fmt.Sprintf("%d", resource.Docker.RestartCount), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
addAgentContextFact(facts, "Image", unified.ResourcePolicyRedactedValue(resource.Docker.Image, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Ports", formatDockerPorts(resource.Docker.Ports), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
addAgentContextCountFact(facts, "Networks", len(resource.Docker.Networks), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
addAgentContextCountFact(facts, "Mounts", len(resource.Docker.Mounts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
addAgentContextFact(facts, "Service stack", resource.Docker.Stack, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
if resource.Docker.DesiredTasks > 0 || resource.Docker.RunningTasks > 0 {
|
||||
addAgentContextFact(facts, "Service tasks", fmt.Sprintf("%d running / %d desired", resource.Docker.RunningTasks, resource.Docker.DesiredTasks), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
addAgentContextFact(facts, "Endpoint ports", formatDockerServicePorts(resource.Docker.EndpointPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
if resource.Docker.UpdateStatus != nil {
|
||||
addAgentContextFact(facts, "Container image update", fmt.Sprintf("available=%t", resource.Docker.UpdateStatus.UpdateAvailable), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSet(resource.Docker.UpdateStatus.LastChecked))
|
||||
}
|
||||
}
|
||||
|
||||
func addKubernetesFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Kubernetes == nil {
|
||||
return
|
||||
}
|
||||
addAgentContextFact(facts, "Namespace", resource.Kubernetes.Namespace, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Kubernetes status", resource.Kubernetes.Phase, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Service type", resource.Kubernetes.ServiceType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Service ports", formatKubernetesServicePorts(resource.Kubernetes.ServicePorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
addAgentContextCountFact(facts, "Pod containers", len(resource.Kubernetes.PodContainers), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
}
|
||||
|
||||
func addStorageFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.Storage == nil {
|
||||
return
|
||||
}
|
||||
policy := resource.Policy
|
||||
addAgentContextFact(facts, "Storage type", resource.Storage.Type, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Storage platform", resource.Storage.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Storage path", unified.ResourcePolicyRedactedValue(resource.Storage.Path, policy, unified.ResourceRedactionPath), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Protection", resource.Storage.Protection, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "Risk summary", unified.ResourcePolicyRedactedText(resource.Storage.RiskSummary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "Consumers", resource.Storage.ConsumerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
}
|
||||
|
||||
func addTrueNASFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||
if resource.TrueNAS == nil {
|
||||
return
|
||||
}
|
||||
addAgentContextFact(facts, "TrueNAS version", resource.TrueNAS.Version, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "TrueNAS services", len(resource.TrueNAS.Services), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
if resource.TrueNAS.App != nil {
|
||||
addAgentContextFact(facts, "TrueNAS app state", resource.TrueNAS.App.State, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextFact(facts, "TrueNAS app version", strings.TrimSpace(resource.TrueNAS.App.Version+" "+resource.TrueNAS.App.HumanVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "TrueNAS app containers", resource.TrueNAS.App.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||
addAgentContextCountFact(facts, "TrueNAS app ports", len(resource.TrueNAS.App.UsedPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func formatAgentRelationshipFact(rel unified.ResourceRelationship, policy *unified.ResourcePolicy) string {
|
||||
source := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.SourceID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias)
|
||||
target := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.TargetID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias)
|
||||
state := "historical"
|
||||
if rel.Active {
|
||||
state = "active"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("%s %s -> %s", rel.Type, source, target), state}
|
||||
if rel.Discoverer != "" {
|
||||
parts = append(parts, "discoverer="+rel.Discoverer)
|
||||
}
|
||||
if rel.Confidence > 0 {
|
||||
parts = append(parts, fmt.Sprintf("confidence=%.0f%%", rel.Confidence*100))
|
||||
}
|
||||
if len(rel.Metadata) > 0 {
|
||||
parts = append(parts, "metadata present")
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func formatOperatorStateFact(state OperatorState) string {
|
||||
parts := []string{}
|
||||
if state.IntentionallyOffline {
|
||||
parts = append(parts, "intentionally offline")
|
||||
}
|
||||
if state.NeverAutoRemediate {
|
||||
parts = append(parts, "never auto-remediate")
|
||||
}
|
||||
if state.MaintenanceWindowActive {
|
||||
parts = append(parts, "maintenance active")
|
||||
} else if state.MaintenanceStartAt != nil || state.MaintenanceEndAt != nil {
|
||||
parts = append(parts, "maintenance scheduled")
|
||||
}
|
||||
if state.Criticality != "" {
|
||||
parts = append(parts, "criticality="+state.Criticality)
|
||||
}
|
||||
if state.NotePresent {
|
||||
parts = append(parts, "note present")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "operator state recorded"
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func formatCapabilityFact(capability unified.ResourceCapability) string {
|
||||
parts := []string{capability.Name}
|
||||
if capability.MinimumApprovalLevel != "" {
|
||||
parts = append(parts, "approval="+string(capability.MinimumApprovalLevel))
|
||||
}
|
||||
if capability.Platform != "" {
|
||||
parts = append(parts, "platform="+capability.Platform)
|
||||
}
|
||||
sensitiveParams := 0
|
||||
for _, param := range capability.Params {
|
||||
if param.IsSensitive {
|
||||
sensitiveParams++
|
||||
}
|
||||
}
|
||||
if sensitiveParams > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d sensitive params hidden", sensitiveParams))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func formatAgentResourceChangeFact(change unified.ResourceChange, resource unified.Resource) string {
|
||||
parts := []string{string(change.Kind)}
|
||||
if change.From != "" || change.To != "" {
|
||||
transition := strings.TrimSpace(change.From + " -> " + change.To)
|
||||
parts = append(parts, unified.ResourcePolicyRedactedText(transition, resource))
|
||||
}
|
||||
if change.Reason != "" {
|
||||
parts = append(parts, "reason="+unified.ResourcePolicyRedactedText(change.Reason, resource))
|
||||
}
|
||||
if change.SourceType != "" {
|
||||
parts = append(parts, "source="+string(change.SourceType))
|
||||
}
|
||||
if change.SourceAdapter != "" {
|
||||
parts = append(parts, "adapter="+string(change.SourceAdapter))
|
||||
}
|
||||
if change.Confidence != "" {
|
||||
parts = append(parts, "confidence="+string(change.Confidence))
|
||||
}
|
||||
if len(change.RelatedResources) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d related resources", len(change.RelatedResources)))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func agentResourceObservedAt(resource unified.Resource) *time.Time {
|
||||
if !resource.UpdatedAt.IsZero() {
|
||||
return timePtrIfSet(resource.UpdatedAt)
|
||||
}
|
||||
return timePtrIfSet(resource.LastSeen)
|
||||
}
|
||||
|
||||
func timePtrIfSet(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
utc := t.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
func timePtrIfSetValue(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return timePtrIfSet(*t)
|
||||
}
|
||||
|
||||
func joinDataSources(sources []unified.DataSource) string {
|
||||
parts := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
if strings.TrimSpace(string(source)) != "" {
|
||||
parts = append(parts, string(source))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func agentResourcePolicyRedactions(policy *unified.ResourcePolicy) []Redaction {
|
||||
labels := unified.ResourcePolicyRedactionLabels(policy)
|
||||
if len(labels) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []Redaction{
|
||||
{
|
||||
Field: "resource identifiers",
|
||||
Reason: "canonical resource policy redacts " + strings.Join(labels, ", "),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatDockerPorts(ports []unified.DockerPortMeta) string {
|
||||
if len(ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, min(len(ports), 5))
|
||||
for i, port := range ports {
|
||||
if i >= 5 {
|
||||
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||
break
|
||||
}
|
||||
if port.PublicPort > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublicPort, port.PrivatePort, port.Protocol))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d/%s", port.PrivatePort, port.Protocol))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatDockerServicePorts(ports []unified.DockerServicePortMeta) string {
|
||||
if len(ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, min(len(ports), 5))
|
||||
for i, port := range ports {
|
||||
if i >= 5 {
|
||||
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||
break
|
||||
}
|
||||
if port.PublishedPort > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublishedPort, port.TargetPort, port.Protocol))
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d/%s", port.TargetPort, port.Protocol))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatKubernetesServicePorts(ports []unified.K8sServicePort) string {
|
||||
if len(ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, min(len(ports), 5))
|
||||
for i, port := range ports {
|
||||
if i >= 5 {
|
||||
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||
break
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d:%s/%s", port.Port, port.TargetPort, port.Protocol))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
|
||||
@@ -592,6 +593,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
|
||||
s.mu.RLock()
|
||||
handoffResourceProvider := s.unifiedResourceProvider
|
||||
s.mu.RUnlock()
|
||||
handoffContext = mergeHandoffResourceContextPack(handoffContext, handoffResources, handoffResourceProvider, s.actionAuditStore, time.Now())
|
||||
handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)
|
||||
handoffContext = mergeHandoffResourceStateContext(handoffContext, handoffResources, handoffResourceProvider)
|
||||
handoffContext = mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider)
|
||||
@@ -1009,6 +1011,39 @@ func mergeHandoffResourcePolicyContext(handoffContext string, handoffResources [
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHandoffResourceContextPack(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string {
|
||||
contextPack := buildHandoffResourceContextPack(handoffResources, provider, store, now)
|
||||
switch {
|
||||
case strings.TrimSpace(handoffContext) == "":
|
||||
return contextPack
|
||||
case contextPack == "":
|
||||
return strings.TrimSpace(handoffContext)
|
||||
default:
|
||||
return strings.TrimSpace(handoffContext) + "\n\n" + contextPack
|
||||
}
|
||||
}
|
||||
|
||||
func buildHandoffResourceContextPack(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider, store unifiedresources.ResourceStore, now time.Time) string {
|
||||
resources := canonicalHandoffResources(handoffResources, provider)
|
||||
if len(resources) == 0 {
|
||||
return ""
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
blocks := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
sections := agentcontext.BuildResourceContextSections(resource, store, agentcontext.BuildOptions{
|
||||
GeneratedAt: now.UTC(),
|
||||
})
|
||||
if contextText := agentcontext.FormatSectionsForModelContext(resource, sections); contextText != "" {
|
||||
blocks = append(blocks, contextText)
|
||||
}
|
||||
}
|
||||
return strings.Join(blocks, "\n\n")
|
||||
}
|
||||
|
||||
func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
|
||||
resources := canonicalHandoffResources(handoffResources, provider)
|
||||
if len(resources) == 0 {
|
||||
|
||||
@@ -670,6 +670,9 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
|
||||
if strings.Contains(stored[0].Content, "Resource State Context") {
|
||||
t.Fatalf("stored user message should not include resource state context: %q", stored[0].Content)
|
||||
}
|
||||
if strings.Contains(stored[0].Content, "Resource Context Pack") {
|
||||
t.Fatalf("stored user message should not include resource context pack: %q", stored[0].Content)
|
||||
}
|
||||
storedFindingID, err := store.GetModelHandoffFindingID("sess-handoff")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelHandoffFindingID failed: %v", err)
|
||||
@@ -700,6 +703,12 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
|
||||
if !strings.Contains(modelUserContent, "[Resource State Context]") {
|
||||
t.Fatalf("model user content missing resource state context: %q", modelUserContent)
|
||||
}
|
||||
if !strings.Contains(modelUserContent, "[Resource Context Pack]") {
|
||||
t.Fatalf("model user content missing shared resource context pack: %q", modelUserContent)
|
||||
}
|
||||
if !strings.Contains(modelUserContent, "Context Pack Boundary: Resource context facts are read-only") {
|
||||
t.Fatalf("model user content missing shared context-pack boundary: %q", modelUserContent)
|
||||
}
|
||||
if !strings.Contains(modelUserContent, "Resource State Status: warning") {
|
||||
t.Fatalf("model user content missing current resource status: %q", modelUserContent)
|
||||
}
|
||||
@@ -762,6 +771,102 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHandoffResourceContextPackUsesSharedDiscoverySections(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
parentID := "agent:pve-1"
|
||||
provider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
|
||||
unifiedresources.ResourceTypeAppContainer: {{
|
||||
ID: "app-container:homeassistant",
|
||||
Type: unifiedresources.ResourceTypeAppContainer,
|
||||
Name: "homeassistant",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
Technology: "docker",
|
||||
UpdatedAt: now.Add(-1 * time.Minute),
|
||||
ParentID: &parentID,
|
||||
ParentName: "ha-lxc",
|
||||
DiscoveryTarget: &unifiedresources.DiscoveryTarget{
|
||||
ResourceType: "app-container",
|
||||
AgentID: "agent:pve-1",
|
||||
ResourceID: "homeassistant",
|
||||
Hostname: "homeassistant.local",
|
||||
},
|
||||
Docker: &unifiedresources.DockerData{
|
||||
Runtime: "docker",
|
||||
RuntimeVersion: "27.0",
|
||||
ContainerState: "running",
|
||||
Health: "healthy",
|
||||
Ports: []unifiedresources.DockerPortMeta{
|
||||
{PrivatePort: 8123, PublicPort: 8123, Protocol: "tcp"},
|
||||
},
|
||||
Mounts: []unifiedresources.DockerMountMeta{
|
||||
{Source: "/srv/homeassistant/secret-config", Destination: "/config", RW: true},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"com.example.env": "TOKEN=should-not-leak",
|
||||
},
|
||||
},
|
||||
Capabilities: []unifiedresources.ResourceCapability{{
|
||||
Name: "restart_container",
|
||||
MinimumApprovalLevel: unifiedresources.ApprovalAdmin,
|
||||
Platform: "docker",
|
||||
Params: []unifiedresources.CapabilityParam{{
|
||||
Name: "token",
|
||||
IsSensitive: true,
|
||||
DefaultValue: "secret-token",
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}}
|
||||
store := unifiedresources.NewMemoryStore()
|
||||
if err := store.RecordChange(unifiedresources.ResourceChange{
|
||||
ID: "change-ha-restart",
|
||||
ResourceID: "app-container:homeassistant",
|
||||
ObservedAt: now.Add(-30 * time.Second),
|
||||
Kind: unifiedresources.ChangeRestart,
|
||||
From: "running",
|
||||
To: "restarted",
|
||||
SourceType: unifiedresources.SourcePulseDiff,
|
||||
SourceAdapter: unifiedresources.AdapterDocker,
|
||||
Confidence: unifiedresources.ConfidenceHigh,
|
||||
Reason: "container restarted after update",
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordChange failed: %v", err)
|
||||
}
|
||||
|
||||
contextText := buildHandoffResourceContextPack([]HandoffResource{{
|
||||
ID: "app-container:homeassistant",
|
||||
Name: "homeassistant",
|
||||
Type: "app-container",
|
||||
Node: "ha-lxc",
|
||||
}}, provider, store, now)
|
||||
|
||||
for _, expected := range []string{
|
||||
"[Resource Context Pack]",
|
||||
"Resource Context: homeassistant (app-container)",
|
||||
"Context Section: Runtime and Discovery",
|
||||
"Context Fact: Runtime and Discovery / Ports: 8123:8123/tcp",
|
||||
"Context Fact: Runtime and Discovery / Mounts: 1",
|
||||
"Context Fact: Safety and Operations / Capability 1: restart_container; approval=admin; platform=docker; 1 sensitive params hidden",
|
||||
"Context Fact: Recent Changes / Change 1: restart; running -> restarted; reason=container restarted after update",
|
||||
"Context Pack Boundary: Resource context facts are read-only",
|
||||
} {
|
||||
if !strings.Contains(contextText, expected) {
|
||||
t.Fatalf("resource context pack missing %q: %q", expected, contextText)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range []string{
|
||||
"/srv/homeassistant/secret-config",
|
||||
"/config",
|
||||
"TOKEN=should-not-leak",
|
||||
"secret-token",
|
||||
} {
|
||||
if strings.Contains(contextText, forbidden) {
|
||||
t.Fatalf("resource context pack leaked raw unsafe detail %q: %q", forbidden, contextText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ExecuteStream_HandoffResourcePolicyContextIsModelOnly(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewSessionStore(tmpDir)
|
||||
|
||||
@@ -2733,10 +2733,74 @@ func canonicalHandoffUnifiedResource(provider UnifiedResourceProvider, resourceI
|
||||
return resource, normalizedType, true
|
||||
}
|
||||
}
|
||||
if resource, ok := findCanonicalHandoffResourceByAnyType(provider, refs...); ok {
|
||||
return resource, string(unifiedresources.ContractResourceType(resource)), true
|
||||
}
|
||||
|
||||
return unifiedresources.Resource{}, normalizedType, false
|
||||
}
|
||||
|
||||
var canonicalHandoffFallbackResourceTypes = []unifiedresources.ResourceType{
|
||||
unifiedresources.ResourceTypeAgent,
|
||||
unifiedresources.ResourceTypeVM,
|
||||
unifiedresources.ResourceTypeSystemContainer,
|
||||
unifiedresources.ResourceTypeAppContainer,
|
||||
unifiedresources.ResourceTypeDockerService,
|
||||
unifiedresources.ResourceTypeDockerImage,
|
||||
unifiedresources.ResourceTypeDockerVolume,
|
||||
unifiedresources.ResourceTypeDockerNetwork,
|
||||
unifiedresources.ResourceTypeDockerTask,
|
||||
unifiedresources.ResourceTypeDockerSwarmNode,
|
||||
unifiedresources.ResourceTypeDockerSecret,
|
||||
unifiedresources.ResourceTypeDockerConfig,
|
||||
unifiedresources.ResourceTypeK8sCluster,
|
||||
unifiedresources.ResourceTypeK8sNode,
|
||||
unifiedresources.ResourceTypePod,
|
||||
unifiedresources.ResourceTypeK8sDeployment,
|
||||
unifiedresources.ResourceTypeK8sReplicaSet,
|
||||
unifiedresources.ResourceTypeK8sNamespace,
|
||||
unifiedresources.ResourceTypeK8sService,
|
||||
unifiedresources.ResourceTypeK8sStatefulSet,
|
||||
unifiedresources.ResourceTypeK8sDaemonSet,
|
||||
unifiedresources.ResourceTypeK8sJob,
|
||||
unifiedresources.ResourceTypeK8sCronJob,
|
||||
unifiedresources.ResourceTypeK8sIngress,
|
||||
unifiedresources.ResourceTypeK8sEndpointSlice,
|
||||
unifiedresources.ResourceTypeK8sNetworkPolicy,
|
||||
unifiedresources.ResourceTypeK8sPV,
|
||||
unifiedresources.ResourceTypeK8sPVC,
|
||||
unifiedresources.ResourceTypeK8sStorageClass,
|
||||
unifiedresources.ResourceTypeK8sConfigMap,
|
||||
unifiedresources.ResourceTypeK8sSecret,
|
||||
unifiedresources.ResourceTypeK8sServiceAccount,
|
||||
unifiedresources.ResourceTypeK8sRole,
|
||||
unifiedresources.ResourceTypeK8sClusterRole,
|
||||
unifiedresources.ResourceTypeK8sRoleBinding,
|
||||
unifiedresources.ResourceTypeK8sClusterRoleBinding,
|
||||
unifiedresources.ResourceTypeK8sResourceQuota,
|
||||
unifiedresources.ResourceTypeK8sLimitRange,
|
||||
unifiedresources.ResourceTypeK8sPDB,
|
||||
unifiedresources.ResourceTypeK8sHPA,
|
||||
unifiedresources.ResourceTypeK8sEvent,
|
||||
unifiedresources.ResourceTypeStorage,
|
||||
unifiedresources.ResourceTypeNetwork,
|
||||
unifiedresources.ResourceTypePBS,
|
||||
unifiedresources.ResourceTypePMG,
|
||||
unifiedresources.ResourceTypeCeph,
|
||||
unifiedresources.ResourceTypePhysicalDisk,
|
||||
unifiedresources.ResourceTypeNetworkShare,
|
||||
unifiedresources.ResourceTypeNetworkEndpoint,
|
||||
}
|
||||
|
||||
func findCanonicalHandoffResourceByAnyType(provider UnifiedResourceProvider, refs ...string) (unifiedresources.Resource, bool) {
|
||||
for _, resourceType := range canonicalHandoffFallbackResourceTypes {
|
||||
if resource, ok := findCanonicalResourceByReference(provider.GetByType(resourceType), refs...); ok {
|
||||
return resource, true
|
||||
}
|
||||
}
|
||||
return unifiedresources.Resource{}, false
|
||||
}
|
||||
|
||||
// CanonicalHandoffResourceRegistration resolves a product-originated resource
|
||||
// handoff into the same governed registration shape used by explicit resource
|
||||
// reads. It intentionally fails closed when the resource cannot be found in the
|
||||
@@ -2877,6 +2941,9 @@ func canonicalGuestSearchCandidates(kind string, resource unifiedresources.Resou
|
||||
candidates = append(candidates, resource.Tags...)
|
||||
|
||||
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
|
||||
if sourceID := strings.TrimSpace(resource.Proxmox.SourceID); sourceID != "" {
|
||||
candidates = append(candidates, sourceID)
|
||||
}
|
||||
vmid := strconv.Itoa(resource.Proxmox.VMID)
|
||||
switch strings.TrimSpace(kind) {
|
||||
case "vm":
|
||||
|
||||
@@ -242,6 +242,46 @@ func (p *registryUnifiedQueryProvider) GetByType(t unifiedresources.ResourceType
|
||||
return p.ListByType(t)
|
||||
}
|
||||
|
||||
func TestCanonicalHandoffUnifiedResourceFallsBackAcrossResourceTypes(t *testing.T) {
|
||||
provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{
|
||||
ID: "k8s-cluster:prod",
|
||||
Type: unifiedresources.ResourceTypeK8sCluster,
|
||||
Name: "prod-cluster",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
}}}
|
||||
|
||||
resource, ok := CanonicalHandoffUnifiedResource(provider, "k8s-cluster:prod", "prod-cluster", "k8s-cluster", "")
|
||||
if !ok {
|
||||
t.Fatal("expected k8s cluster handoff to resolve by canonical resource id")
|
||||
}
|
||||
if resource.ID != "k8s-cluster:prod" || resource.Type != unifiedresources.ResourceTypeK8sCluster {
|
||||
t.Fatalf("resolved resource = %+v, want k8s-cluster:prod", resource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalHandoffUnifiedResourceResolvesProxmoxSourceID(t *testing.T) {
|
||||
provider := &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{{
|
||||
ID: "system-container-6adaf34f529d241a",
|
||||
Type: unifiedresources.ResourceTypeSystemContainer,
|
||||
Name: "homeassistant",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
Proxmox: &unifiedresources.ProxmoxData{
|
||||
SourceID: "delly:delly:101",
|
||||
NodeName: "delly",
|
||||
Instance: "delly",
|
||||
VMID: 101,
|
||||
},
|
||||
}}}
|
||||
|
||||
resource, ok := CanonicalHandoffUnifiedResource(provider, "delly:delly:101", "", "system-container", "delly")
|
||||
if !ok {
|
||||
t.Fatal("expected Proxmox source ID handoff to resolve")
|
||||
}
|
||||
if resource.ID != "system-container-6adaf34f529d241a" {
|
||||
t.Fatalf("resolved resource id = %q, want system-container-6adaf34f529d241a", resource.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newTrueNASUnifiedQueryProvider(t *testing.T) *registryUnifiedQueryProvider {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
@@ -154,6 +155,23 @@ type AgentResourceOperatorState struct {
|
||||
MaintenanceWindowActive bool `json:"maintenanceWindowActive"`
|
||||
}
|
||||
|
||||
// AgentResourceContextFact is a bounded, typed fact in the richer
|
||||
// agent-consumable context pack. Values come from Pulse-owned runtime
|
||||
// state only; raw command output, config files, environment variables,
|
||||
// and secret-bearing metadata do not belong here.
|
||||
type AgentResourceContextFact = agentcontext.Fact
|
||||
|
||||
// AgentResourceContextRedaction records why a context section withheld
|
||||
// a field. It gives agents and UI inspectors a visible safety boundary
|
||||
// without leaking the underlying value.
|
||||
type AgentResourceContextRedaction = agentcontext.Redaction
|
||||
|
||||
// AgentResourceContextSection groups related facts with one
|
||||
// provenance/freshness envelope. Additive sections make the context
|
||||
// pack useful to Assistant and external MCP clients without breaking
|
||||
// older clients that parse the original top-level fields.
|
||||
type AgentResourceContextSection = agentcontext.Section
|
||||
|
||||
// AgentResourceContext is the bundled, agent-consumable situated
|
||||
// picture of a resource. One read returns:
|
||||
// - core identity (canonical id, type, name)
|
||||
@@ -161,6 +179,9 @@ type AgentResourceOperatorState struct {
|
||||
// - active findings (lightweight snapshot of the seven-question schema)
|
||||
// - recent action attempts (including refused dispatches with their
|
||||
// stable error tokens)
|
||||
// - additive context sections with provenance, freshness, and
|
||||
// redaction metadata for richer Assistant and external-agent
|
||||
// grounding
|
||||
//
|
||||
// The endpoint trades coverage for shape: an agent gets enough to
|
||||
// reason about the next move without chaining several calls. Deeper
|
||||
@@ -175,6 +196,7 @@ type AgentResourceContext struct {
|
||||
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
|
||||
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
|
||||
RecentActions []AgentResourceActionSummary `json:"recentActions"`
|
||||
ContextSections []AgentResourceContextSection `json:"contextSections"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
}
|
||||
|
||||
@@ -287,7 +309,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resource, ok := registry.Get(resourceID)
|
||||
resource, resourceID, ok := registry.GetByReference(resourceID)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, "resource_not_found",
|
||||
"No resource is registered with this canonical id.")
|
||||
@@ -308,6 +330,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
||||
ActiveFindings: []AgentResourceFindingSnapshot{},
|
||||
PendingApprovals: []AgentResourceApprovalSummary{},
|
||||
RecentActions: []AgentResourceActionSummary{},
|
||||
ContextSections: []AgentResourceContextSection{},
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
@@ -353,12 +376,40 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
||||
}
|
||||
}
|
||||
|
||||
bundle.ContextSections = buildAgentResourceContextSections(*resource, store, bundle)
|
||||
|
||||
redactAgentResourceContextCommandsForRequest(&bundle, r)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(bundle)
|
||||
}
|
||||
|
||||
func buildAgentResourceContextSections(resource unified.Resource, store unified.ResourceStore, bundle AgentResourceContext) []AgentResourceContextSection {
|
||||
return agentcontext.BuildResourceContextSections(resource, store, agentcontext.BuildOptions{
|
||||
GeneratedAt: bundle.GeneratedAt,
|
||||
OperatorState: agentResourceOperatorStateForContext(bundle.OperatorState),
|
||||
ActiveFindingCount: len(bundle.ActiveFindings),
|
||||
PendingApprovalCount: len(bundle.PendingApprovals),
|
||||
RecentActionCount: len(bundle.RecentActions),
|
||||
})
|
||||
}
|
||||
|
||||
func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *agentcontext.OperatorState {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return &agentcontext.OperatorState{
|
||||
IntentionallyOffline: state.IntentionallyOffline,
|
||||
NeverAutoRemediate: state.NeverAutoRemediate,
|
||||
MaintenanceWindowActive: state.MaintenanceWindowActive,
|
||||
MaintenanceStartAt: state.MaintenanceStartAt,
|
||||
MaintenanceEndAt: state.MaintenanceEndAt,
|
||||
Criticality: state.Criticality,
|
||||
NotePresent: strings.TrimSpace(state.Note) != "",
|
||||
SetAt: state.SetAt,
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgentResourceContextID parses the canonical resource ID out of
|
||||
// the URL path. Mirrors the trim/canonicalize pattern used elsewhere
|
||||
// for nested resource routes.
|
||||
|
||||
@@ -82,19 +82,13 @@ func (p *countingApprovalsProvider) PendingApprovalCountsByResource(orgID string
|
||||
// the agent context handler with a findings-provider stub. The seed
|
||||
// provider bypasses the snapshot → unified adapter, letting us stage
|
||||
// exactly the resource the test expects to see in the bundle.
|
||||
func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContextHandler, *staticFindingsProvider) {
|
||||
func agentContextFixtureHandlersForResource(t *testing.T, resource unified.Resource) (*AgentContextHandler, *staticFindingsProvider) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
resources := NewResourceHandlers(cfg)
|
||||
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
||||
resources: []unified.Resource{
|
||||
{
|
||||
ID: resourceID,
|
||||
Type: "vm",
|
||||
Name: "db-01",
|
||||
},
|
||||
},
|
||||
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
||||
resources: []unified.Resource{resource},
|
||||
})
|
||||
provider := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}}
|
||||
h := NewAgentContextHandler(resources)
|
||||
@@ -102,6 +96,33 @@ func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContext
|
||||
return h, provider
|
||||
}
|
||||
|
||||
func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContextHandler, *staticFindingsProvider) {
|
||||
t.Helper()
|
||||
return agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: resourceID,
|
||||
Type: "vm",
|
||||
Name: "db-01",
|
||||
})
|
||||
}
|
||||
|
||||
func findAgentContextSection(sections []AgentResourceContextSection, id string) (AgentResourceContextSection, bool) {
|
||||
for _, section := range sections {
|
||||
if section.ID == id {
|
||||
return section, true
|
||||
}
|
||||
}
|
||||
return AgentResourceContextSection{}, false
|
||||
}
|
||||
|
||||
func findAgentContextFact(section AgentResourceContextSection, label string) (AgentResourceContextFact, bool) {
|
||||
for _, fact := range section.Facts {
|
||||
if fact.Label == label {
|
||||
return fact, true
|
||||
}
|
||||
}
|
||||
return AgentResourceContextFact{}, false
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *testing.T) {
|
||||
h, findings := agentContextFixtureHandlers(t, "vm:101")
|
||||
|
||||
@@ -200,6 +221,258 @@ func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *t
|
||||
if bundle.GeneratedAt.IsZero() {
|
||||
t.Error("GeneratedAt must be populated")
|
||||
}
|
||||
if len(bundle.ContextSections) == 0 {
|
||||
t.Fatal("contextSections must be populated for resource-aware agents")
|
||||
}
|
||||
if section, ok := findAgentContextSection(bundle.ContextSections, "identity"); !ok {
|
||||
t.Fatal("identity context section missing")
|
||||
} else if section.Source == "" || section.TrustTier == "" || section.GeneratedAt.IsZero() {
|
||||
t.Fatalf("identity section must carry provenance and freshness metadata: %+v", section)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_ResolvesSourceIDReference(t *testing.T) {
|
||||
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: "system-container-6adaf34f529d241a",
|
||||
Type: unified.ResourceTypeSystemContainer,
|
||||
Technology: "lxc",
|
||||
Name: "homeassistant",
|
||||
Status: unified.StatusOnline,
|
||||
Sources: []unified.DataSource{unified.SourceProxmox},
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
SourceID: "delly:delly:101",
|
||||
NodeName: "delly",
|
||||
Instance: "delly",
|
||||
VMID: 101,
|
||||
ContainerType: "lxc",
|
||||
},
|
||||
DiscoveryTarget: &unified.DiscoveryTarget{
|
||||
ResourceType: "system-container",
|
||||
AgentID: "delly",
|
||||
ResourceID: "101",
|
||||
Hostname: "homeassistant",
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/delly:delly:101", nil)
|
||||
h.HandleResourceContext(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var bundle AgentResourceContext
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
||||
t.Fatalf("unmarshal bundle: %v", err)
|
||||
}
|
||||
if bundle.CanonicalID != "system-container-6adaf34f529d241a" {
|
||||
t.Fatalf("canonical id = %q, want registered unified resource id", bundle.CanonicalID)
|
||||
}
|
||||
if bundle.ResourceName != "homeassistant" || bundle.ResourceType != "system-container" {
|
||||
t.Fatalf("bundle resource = %s/%s, want homeassistant/system-container", bundle.ResourceName, bundle.ResourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_ContextSectionsIncludeRuntimeTopologyAndRecentChanges(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
parentID := "agent:pve1"
|
||||
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: "app-container:homeassistant",
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Name: "homeassistant",
|
||||
Status: unified.StatusOnline,
|
||||
Technology: "docker",
|
||||
LastSeen: now.Add(-time.Minute),
|
||||
UpdatedAt: now,
|
||||
ParentID: &parentID,
|
||||
ParentName: "ha-lxc",
|
||||
DiscoveryTarget: &unified.DiscoveryTarget{
|
||||
ResourceType: "app-container",
|
||||
AgentID: "agent:pve1",
|
||||
ResourceID: "homeassistant",
|
||||
Hostname: "homeassistant.local",
|
||||
},
|
||||
Docker: &unified.DockerData{
|
||||
Runtime: "docker",
|
||||
RuntimeVersion: "27.0",
|
||||
ContainerState: "running",
|
||||
Health: "healthy",
|
||||
RestartCount: 2,
|
||||
Image: "ghcr.io/home-assistant/home-assistant:stable",
|
||||
Ports: []unified.DockerPortMeta{
|
||||
{PrivatePort: 8123, PublicPort: 8123, Protocol: "tcp"},
|
||||
},
|
||||
Mounts: []unified.DockerMountMeta{
|
||||
{Source: "/srv/homeassistant/secret-config", Destination: "/config", RW: true},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"com.example.env": "TOKEN=should-not-leak",
|
||||
},
|
||||
},
|
||||
Relationships: []unified.ResourceRelationship{
|
||||
{
|
||||
SourceID: "app-container:homeassistant",
|
||||
TargetID: "agent:pve1",
|
||||
Type: unified.RelRunsOn,
|
||||
Confidence: 0.95,
|
||||
Active: true,
|
||||
Discoverer: "docker_adapter",
|
||||
ObservedAt: now.Add(-2 * time.Minute),
|
||||
LastSeenAt: now.Add(-time.Minute),
|
||||
Metadata: map[string]any{"raw": "metadata is intentionally summarized only"},
|
||||
},
|
||||
},
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
{
|
||||
Name: "restart_container",
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
Platform: "docker",
|
||||
Params: []unified.CapabilityParam{
|
||||
{Name: "token", IsSensitive: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
store, err := h.resources.getStore("default")
|
||||
if err != nil {
|
||||
t.Fatalf("getStore: %v", err)
|
||||
}
|
||||
if err := store.RecordChange(unified.ResourceChange{
|
||||
ID: "change-ha-restart",
|
||||
ResourceID: "app-container:homeassistant",
|
||||
ObservedAt: now.Add(-30 * time.Second),
|
||||
Kind: unified.ChangeRestart,
|
||||
From: "running",
|
||||
To: "restarted",
|
||||
SourceType: unified.SourcePulseDiff,
|
||||
SourceAdapter: unified.AdapterDocker,
|
||||
Confidence: unified.ConfidenceHigh,
|
||||
Reason: "container restarted after update",
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordChange: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/app-container:homeassistant", nil)
|
||||
h.HandleResourceContext(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var bundle AgentResourceContext
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
||||
t.Fatalf("unmarshal bundle: %v", err)
|
||||
}
|
||||
|
||||
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
||||
if !ok {
|
||||
t.Fatalf("runtime section missing from context pack: %+v", bundle.ContextSections)
|
||||
}
|
||||
if fact, ok := findAgentContextFact(runtimeSection, "Ports"); !ok || !strings.Contains(fact.Value, "8123:8123/tcp") {
|
||||
t.Fatalf("runtime ports fact missing or wrong: %+v", fact)
|
||||
}
|
||||
if fact, ok := findAgentContextFact(runtimeSection, "Mounts"); !ok || fact.Value != "1" {
|
||||
t.Fatalf("runtime must include mount count without path values; got %+v", fact)
|
||||
}
|
||||
|
||||
topologySection, ok := findAgentContextSection(bundle.ContextSections, "topology")
|
||||
if !ok {
|
||||
t.Fatal("topology section missing from context pack")
|
||||
}
|
||||
if fact, ok := findAgentContextFact(topologySection, "Relationship 1"); !ok || !strings.Contains(fact.Value, "runs_on") || !strings.Contains(fact.Value, "metadata present") {
|
||||
t.Fatalf("relationship fact missing bounded topology summary: %+v", fact)
|
||||
}
|
||||
|
||||
safetySection, ok := findAgentContextSection(bundle.ContextSections, "safety")
|
||||
if !ok {
|
||||
t.Fatal("safety section missing from context pack")
|
||||
}
|
||||
if fact, ok := findAgentContextFact(safetySection, "Capability 1"); !ok || !strings.Contains(fact.Value, "1 sensitive params hidden") {
|
||||
t.Fatalf("capability fact must hide sensitive params: %+v", fact)
|
||||
}
|
||||
|
||||
changesSection, ok := findAgentContextSection(bundle.ContextSections, "recent_changes")
|
||||
if !ok {
|
||||
t.Fatal("recent_changes section missing from context pack")
|
||||
}
|
||||
if fact, ok := findAgentContextFact(changesSection, "Change 1"); !ok || !strings.Contains(fact.Value, "container restarted after update") {
|
||||
t.Fatalf("recent change fact missing canonical timeline context: %+v", fact)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, forbidden := range []string{"/srv/homeassistant/secret-config", "TOKEN=should-not-leak", "metadata is intentionally summarized only"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("context pack leaked raw unsafe detail %q in body: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_ContextSectionsApplyPolicyRedactions(t *testing.T) {
|
||||
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
||||
ID: "storage:dataset-1",
|
||||
Type: unified.ResourceTypeStorage,
|
||||
Name: "dataset-1",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Policy: &unified.ResourcePolicy{
|
||||
Sensitivity: unified.ResourceSensitivitySensitive,
|
||||
Routing: unified.ResourceRoutingPolicy{
|
||||
Scope: unified.ResourceRoutingScopeLocalOnly,
|
||||
Redact: []unified.ResourceRedactionHint{
|
||||
unified.ResourceRedactionHostname,
|
||||
unified.ResourceRedactionPath,
|
||||
unified.ResourceRedactionPlatformID,
|
||||
},
|
||||
},
|
||||
},
|
||||
AISafeSummary: "storage resource; status online; local-only context",
|
||||
DiscoveryTarget: &unified.DiscoveryTarget{
|
||||
ResourceType: "storage",
|
||||
AgentID: "agent-secret-id",
|
||||
ResourceID: "storage-secret-id",
|
||||
Hostname: "secret-storage.local",
|
||||
},
|
||||
Storage: &unified.StorageMeta{
|
||||
Type: "zfs",
|
||||
Path: "/mnt/secret-dataset",
|
||||
RiskSummary: "secret-storage.local has elevated writes",
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/storage:dataset-1", nil)
|
||||
h.HandleResourceContext(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var bundle AgentResourceContext
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
||||
t.Fatalf("unmarshal bundle: %v", err)
|
||||
}
|
||||
|
||||
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
||||
if !ok {
|
||||
t.Fatal("runtime section missing")
|
||||
}
|
||||
for _, label := range []string{"Discovery hostname", "Storage path"} {
|
||||
fact, ok := findAgentContextFact(runtimeSection, label)
|
||||
if !ok {
|
||||
t.Fatalf("%s fact missing", label)
|
||||
}
|
||||
if fact.Value != unified.ResourcePolicyRedactedLabel || !fact.Redacted {
|
||||
t.Fatalf("%s must be explicitly redacted by policy; got %+v", label, fact)
|
||||
}
|
||||
}
|
||||
policySection, ok := findAgentContextSection(bundle.ContextSections, "policy")
|
||||
if !ok || len(policySection.Redactions) == 0 {
|
||||
t.Fatalf("policy section must explain redactions; got %+v", policySection)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, forbidden := range []string{"secret-storage.local", "/mnt/secret-dataset"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("context pack leaked policy-redacted value %q in body: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgentResourceContext_OperatorStateAbsentWhenNoEntry(t *testing.T) {
|
||||
|
||||
@@ -14434,12 +14434,22 @@ func TestContract_AgentResourceContextEndpointSurfacesStableShape(t *testing.T)
|
||||
if !strings.Contains(src, "PendingApprovals: []AgentResourceApprovalSummary{}") {
|
||||
t.Error("pendingApprovals must default to an empty slice, not nil — agents iterate without nil-checks")
|
||||
}
|
||||
if !strings.Contains(src, "ContextSections: []AgentResourceContextSection{}") {
|
||||
t.Error("contextSections must default to an empty slice, not nil — resource-aware agents iterate without nil-checks")
|
||||
}
|
||||
// Pending approvals must reuse the AgentResourceApprovalSummary
|
||||
// shape so the bundle and approval.pending SSE events stay in
|
||||
// the same vocabulary.
|
||||
if !strings.Contains(src, "PendingApprovals []AgentResourceApprovalSummary `json:\"pendingApprovals\"`") {
|
||||
t.Error("AgentResourceContext must carry PendingApprovals as a stable []AgentResourceApprovalSummary field")
|
||||
}
|
||||
if !strings.Contains(src, "ContextSections []AgentResourceContextSection `json:\"contextSections\"`") {
|
||||
t.Error("AgentResourceContext must carry ContextSections as a stable []AgentResourceContextSection field")
|
||||
}
|
||||
if !strings.Contains(src, "type AgentResourceContextRedaction = agentcontext.Redaction") ||
|
||||
!strings.Contains(src, "type AgentResourceContextFact = agentcontext.Fact") {
|
||||
t.Error("AgentResourceContext context sections must expose the shared typed fact and redaction metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_AgentResourceContextWiresApprovalsProvider pins the
|
||||
|
||||
@@ -1058,6 +1058,98 @@ func (rr *ResourceRegistry) Get(id string) (*Resource, bool) {
|
||||
return &clone, true
|
||||
}
|
||||
|
||||
// GetByReference resolves a resource by its canonical resource ID, a unique
|
||||
// source-specific ID, or a canonical identity alias. It returns the registered
|
||||
// resource ID alongside the cloned resource so callers can keep downstream
|
||||
// store lookups on the canonical registry identity.
|
||||
func (rr *ResourceRegistry) GetByReference(ref string) (*Resource, string, bool) {
|
||||
rr.mu.RLock()
|
||||
defer rr.mu.RUnlock()
|
||||
|
||||
ref = CanonicalResourceID(ref)
|
||||
if ref == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
if r := rr.resources[ref]; r != nil {
|
||||
clone := cloneResource(r)
|
||||
return &clone, ref, true
|
||||
}
|
||||
|
||||
for _, resolvedID := range []string{
|
||||
rr.uniqueSourceResourceIDLocked(ref),
|
||||
rr.uniqueCanonicalIdentityResourceIDLocked(ref),
|
||||
} {
|
||||
if resolvedID == "" {
|
||||
continue
|
||||
}
|
||||
if r := rr.resources[resolvedID]; r != nil {
|
||||
clone := cloneResource(r)
|
||||
return &clone, resolvedID, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) uniqueSourceResourceIDLocked(sourceID string) string {
|
||||
sourceID = normalizeSourceID(sourceID)
|
||||
if sourceID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
matches := map[string]struct{}{}
|
||||
for _, mapping := range rr.bySource {
|
||||
if resourceID := mapping[sourceID]; resourceID != "" {
|
||||
matches[resourceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return uniqueResourceIDMatch(matches)
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) uniqueCanonicalIdentityResourceIDLocked(ref string) string {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
matches := map[string]struct{}{}
|
||||
for resourceID, resource := range rr.resources {
|
||||
if resourceMatchesCanonicalIdentityReference(resource, ref) {
|
||||
matches[resourceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return uniqueResourceIDMatch(matches)
|
||||
}
|
||||
|
||||
func resourceMatchesCanonicalIdentityReference(resource *Resource, ref string) bool {
|
||||
if resource == nil || resource.Canonical == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
canonical := resource.Canonical
|
||||
candidates := append([]string{
|
||||
canonical.PrimaryID,
|
||||
canonical.PlatformID,
|
||||
}, canonical.Aliases...)
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), ref) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueResourceIDMatch(matches map[string]struct{}) string {
|
||||
if len(matches) != 1 {
|
||||
return ""
|
||||
}
|
||||
for resourceID := range matches {
|
||||
return resourceID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SourceTargets returns the source-specific IDs that map to the provided resource ID.
|
||||
func (rr *ResourceRegistry) SourceTargets(resourceID string) []SourceTarget {
|
||||
rr.mu.RLock()
|
||||
|
||||
@@ -66,6 +66,43 @@ func TestMemoryStore_RecordActionAuditAppliesRedaction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_GetByReferenceResolvesSourceIDAndCanonicalAlias(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
rr.IngestRecords(SourceProxmox, []IngestRecord{{
|
||||
SourceID: "delly:delly:101",
|
||||
Resource: Resource{
|
||||
Type: ResourceTypeSystemContainer,
|
||||
Name: "homeassistant",
|
||||
},
|
||||
}})
|
||||
rr.IngestResources([]Resource{{
|
||||
ID: "agent-host-1",
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Agent: &AgentData{
|
||||
AgentID: "machine-1",
|
||||
Hostname: "delly.local",
|
||||
},
|
||||
}})
|
||||
|
||||
resource, resolvedID, ok := rr.GetByReference("delly:delly:101")
|
||||
if !ok {
|
||||
t.Fatal("expected Proxmox source ID to resolve")
|
||||
}
|
||||
wantID := SourceSpecificID(ResourceTypeSystemContainer, SourceProxmox, "delly:delly:101")
|
||||
if resolvedID != wantID || resource.ID != wantID {
|
||||
t.Fatalf("source reference resolved to id=%q resource=%q, want %q", resolvedID, resource.ID, wantID)
|
||||
}
|
||||
|
||||
resource, resolvedID, ok = rr.GetByReference("machine-1")
|
||||
if !ok {
|
||||
t.Fatal("expected canonical alias to resolve")
|
||||
}
|
||||
if resolvedID != "agent-host-1" || resource.ID != "agent-host-1" {
|
||||
t.Fatalf("alias reference resolved to id=%q resource=%q, want agent-host-1", resolvedID, resource.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRegistry_ListByType(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
|
||||
|
||||
@@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 364,
|
||||
"heading_line": 113,
|
||||
"line": 365,
|
||||
"heading_line": 114,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user