Implement resource-aware Assistant context

This commit is contained in:
rcourtman
2026-06-04 16:36:04 +01:00
parent c9414e5be8
commit 3f8525a7c2
33 changed files with 2441 additions and 63 deletions
@@ -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',
);
});
});
+85
View File
@@ -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 }))}
/>
@@ -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: {
@@ -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');
});
});
@@ -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(&section.Facts, "Display name", displayName, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Canonical ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Resource type", string(resource.Type), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Status", string(resource.Status), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
addAgentContextFact(&section.Facts, "Technology", resource.Technology, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
if !resource.LastSeen.IsZero() {
addAgentContextFact(&section.Facts, "Last seen", resource.LastSeen.UTC().Format(time.RFC3339), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
}
if len(resource.Sources) > 0 {
addAgentContextFact(&section.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(&section.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(&section.Facts, "Discovery type", resource.DiscoveryTarget.ResourceType, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Discovery agent", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.AgentID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Discovery resource", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.ResourceID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextFact(&section.Facts, "Discovery hostname", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
}
addMetricsFacts(&section.Facts, resource, observedAt)
addAgentDataFacts(&section.Facts, resource, observedAt)
addProxmoxFacts(&section.Facts, resource, observedAt)
addDockerFacts(&section.Facts, resource, observedAt)
addKubernetesFacts(&section.Facts, resource, observedAt)
addStorageFacts(&section.Facts, resource, observedAt)
addTrueNASFacts(&section.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(&section.Facts, "Parent ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(*resource.ParentID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
}
addAgentContextFact(&section.Facts, "Parent name", unified.ResourcePolicyRedactedValue(resource.ParentName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
addAgentContextCountFact(&section.Facts, "Child resources", resource.ChildCount, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
relationships := unified.ResourceRelationshipsWithCanonicalParent(resource)
if len(relationships) > 0 {
addAgentContextCountFact(&section.Facts, "Relationship count", len(relationships), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
for i, rel := range relationships {
if i >= 5 {
addAgentContextFact(&section.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(&section.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(&section.Facts, "Operator state", formatOperatorStateFact(*opts.OperatorState), agentContextSourceOperatorState, agentContextTrustUserTaught, timePtrIfSet(opts.OperatorState.SetAt))
}
addAgentContextCountFact(&section.Facts, "Active findings", opts.ActiveFindingCount, agentContextSourcePatrol, agentContextTrustRuntimeObserved, &now)
addAgentContextCountFact(&section.Facts, "Pending approvals", opts.PendingApprovalCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
addAgentContextCountFact(&section.Facts, "Recent actions", opts.RecentActionCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
if len(resource.Capabilities) > 0 {
addAgentContextCountFact(&section.Facts, "Governed capabilities", len(resource.Capabilities), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
for i, capability := range resource.Capabilities {
if i >= 5 {
addAgentContextFact(&section.Facts, "Capability limit", fmt.Sprintf("%d additional capabilities omitted from context pack", len(resource.Capabilities)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
break
}
addAgentContextFact(&section.Facts, fmt.Sprintf("Capability %d", i+1), formatCapabilityFact(capability), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
}
}
if len(resource.Incidents) > 0 {
addAgentContextCountFact(&section.Facts, "Incidents", len(resource.Incidents), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
for i, incident := range resource.Incidents {
if i >= 3 {
break
}
incidentObservedAt := timePtrIfSet(incident.StartedAt)
addAgentContextFact(&section.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(&section.Facts, "Policy", line, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
}
if resource.AISafeSummary != "" && unified.ResourcePolicyUsesAISafeSummary(resource.AISafeSummary, resource.Policy) {
addAgentContextFact(&section.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(&section.Facts, "Included changes", len(changes), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, &now)
for i, change := range changes {
observedAt := timePtrIfSet(change.ObservedAt)
addAgentContextFact(&section.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, ", ")
}
+35
View File
@@ -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)
+67
View File
@@ -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":
+40
View File
@@ -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()
+52 -1
View File
@@ -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.
+282 -9
View File
@@ -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) {
+10
View File
@@ -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
+92
View File
@@ -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,
}
],
)