mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
Implement resource-aware Assistant context
This commit is contained in:
@@ -223,6 +223,12 @@ text unless they also hold action execution scope. Lifecycle UI and agents may
|
|||||||
use those redacted events as doorbells or status summaries, but they must fetch
|
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
|
governed detail through the approval/action surfaces and must not treat a
|
||||||
monitoring-readable event stream as command disclosure or execution authority.
|
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
|
Discovery context exposed by `/api/discovery` is similarly API-owned
|
||||||
observability context, not lifecycle command authority. Lifecycle and
|
observability context, not lifecycle command authority. Lifecycle and
|
||||||
infrastructure setup surfaces may display service versions, config paths,
|
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
|
references through the canonical unified-resource provider before querying
|
||||||
timeline changes, with raw handoff IDs used only as a compatibility fallback.
|
timeline changes, with raw handoff IDs used only as a compatibility fallback.
|
||||||
Those timeline facts remain read-only explanation context and do not grant
|
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
|
approval references from the same investigation record as
|
||||||
model-context metadata, and the API handoff builder may recover the current
|
model-context metadata, and the API handoff builder may recover the current
|
||||||
live Patrol investigation-fix approval by finding ID when the durable record
|
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
|
The Assistant drawer may also render an attached context briefing for that
|
||||||
handoff, but the briefing is runtime context visibility only: it must not
|
handoff, but the briefing is runtime context visibility only: it must not
|
||||||
mutate chat control settings, execute tools, or reveal raw command payloads.
|
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
|
Safe route-owned briefing actions may render as app links when the handoff
|
||||||
includes an `actionHref`, but those links are navigation guidance only and
|
includes an `actionHref`, but those links are navigation guidance only and
|
||||||
do not grant tool execution or approval authority.
|
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`
|
69. `frontend-modern/src/api/aiChat.ts`
|
||||||
70. `frontend-modern/src/api/patrol.ts`
|
70. `frontend-modern/src/api/patrol.ts`
|
||||||
71. `internal/api/agent_exec_token_binding.go`
|
71. `internal/api/agent_exec_token_binding.go`
|
||||||
|
72. `internal/agentcontext/`
|
||||||
|
|
||||||
## Shared Boundaries
|
## 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
|
action and verification command/note text follows the same stable-marker
|
||||||
redaction rule as action-audit readback, while stable status/error
|
redaction rule as action-audit readback, while stable status/error
|
||||||
prefixes remain verbatim.
|
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
|
The shape is intentionally narrower than the full internal types
|
||||||
so agents see a stable agent-paradigm contract, decoupled from
|
so agents see a stable agent-paradigm contract, decoupled from
|
||||||
internal type evolution.
|
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
|
`handoff_metadata` is the browser-safe identity envelope for restoring saved
|
||||||
product handoffs, currently including Patrol run kind, run ID, safe run
|
product handoffs, currently including Patrol run kind, run ID, safe run
|
||||||
type/status, a runtime-failure boolean rather than runtime failure detail, and
|
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,
|
assessment briefings must not render recommendation fields as separate title,
|
||||||
reason, route-action facts, or prompt chips; the configured model owns those
|
reason, route-action facts, or prompt chips; the configured model owns those
|
||||||
decisions from the structured handoff metadata and bounded chat context.
|
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,
|
process version once, but they must not add per-request release lookups,
|
||||||
filesystem walks, or other heavy work just to compute whether an attached
|
filesystem walks, or other heavy work just to compute whether an attached
|
||||||
agent is current.
|
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
|
The `/api/version.agentUpdateTargetVersion` projection follows that same
|
||||||
rule: it may mirror the cached deployable agent target exposed to agents,
|
rule: it may mirror the cached deployable agent target exposed to agents,
|
||||||
but it must not inspect attached agents, download artifacts, or resolve
|
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,
|
read-only evidence: it must not become a storage/recovery-owned command path,
|
||||||
secret source, restore entitlement, or frontend-only fixture separate from the
|
secret source, restore entitlement, or frontend-only fixture separate from the
|
||||||
canonical `/api/discovery` payload.
|
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
|
Storage and recovery may also consume unified-resource TrueNAS app and VM
|
||||||
metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata,
|
metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata,
|
||||||
as read-only workload, system, and storage-access context when explaining
|
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
|
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
|
owning workflow must be retired or the Overview content must be reduced to
|
||||||
aggregate signal.
|
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
|
Across platform/runtime pages, workflow tabs are evidence-gated from the
|
||||||
canonical model that owns their rows. `Overview` is the stable landing tab;
|
canonical model that owns their rows. `Overview` is the stable landing tab;
|
||||||
supporting tabs appear only when their native inventory or signal exists, and
|
supporting tabs appear only when their native inventory or signal exists, and
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('@/utils/apiClient', () => ({
|
||||||
|
apiFetchJSON: apiFetchJSONMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AgentContextAPI } from '@/api/agentContext';
|
||||||
|
|
||||||
|
describe('AgentContextAPI', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
apiFetchJSONMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches resource context with encoded canonical or source IDs', async () => {
|
||||||
|
apiFetchJSONMock.mockResolvedValue({
|
||||||
|
canonicalId: 'system-container-6adaf34f529d241a',
|
||||||
|
resourceType: 'system-container',
|
||||||
|
resourceName: 'homeassistant',
|
||||||
|
activeFindings: [],
|
||||||
|
pendingApprovals: [],
|
||||||
|
recentActions: [],
|
||||||
|
contextSections: [],
|
||||||
|
generatedAt: '2026-06-04T15:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await AgentContextAPI.getResourceContext('delly:delly:101');
|
||||||
|
|
||||||
|
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||||
|
'/api/agent/resource-context/delly%3Adelly%3A101',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { apiFetchJSON } from '@/utils/apiClient';
|
||||||
|
|
||||||
|
export interface AgentResourceContextFact {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
source?: string;
|
||||||
|
trustTier?: string;
|
||||||
|
observedAt?: string;
|
||||||
|
redacted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceContextRedaction {
|
||||||
|
field: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceContextSection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
source: string;
|
||||||
|
trustTier: string;
|
||||||
|
observedAt?: string;
|
||||||
|
generatedAt: string;
|
||||||
|
facts: AgentResourceContextFact[];
|
||||||
|
redactions?: AgentResourceContextRedaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceFindingSnapshot {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
severity: string;
|
||||||
|
category?: string;
|
||||||
|
description?: string;
|
||||||
|
impact?: string;
|
||||||
|
recommendation?: string;
|
||||||
|
confidence?: string;
|
||||||
|
regressionCount: number;
|
||||||
|
previousResolvedFixSummary?: string;
|
||||||
|
detectedAt?: string;
|
||||||
|
lastSeenAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceApprovalSummary {
|
||||||
|
id: string;
|
||||||
|
riskLevel: string;
|
||||||
|
requestedBy?: string;
|
||||||
|
requestedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
command?: string;
|
||||||
|
commandRedacted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceActionSummary {
|
||||||
|
id: string;
|
||||||
|
capabilityName: string;
|
||||||
|
state: string;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
requestedBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
command?: string;
|
||||||
|
commandRedacted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResourceContext {
|
||||||
|
canonicalId: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceName: string;
|
||||||
|
technology?: string;
|
||||||
|
activeFindings: AgentResourceFindingSnapshot[];
|
||||||
|
pendingApprovals: AgentResourceApprovalSummary[];
|
||||||
|
recentActions: AgentResourceActionSummary[];
|
||||||
|
contextSections: AgentResourceContextSection[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentContextAPI = {
|
||||||
|
getResourceContext(resourceId: string): Promise<AgentResourceContext> {
|
||||||
|
return apiFetchJSON<AgentResourceContext>(
|
||||||
|
`/api/agent/resource-context/${encodeURIComponent(resourceId)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -72,26 +72,27 @@ const {
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyChatContext = () => ({
|
const emptyChatContext = () => ({
|
||||||
targetType: undefined,
|
targetType: undefined,
|
||||||
targetId: undefined,
|
targetId: undefined,
|
||||||
findingId: undefined,
|
findingId: undefined,
|
||||||
autonomousMode: undefined,
|
autonomousMode: undefined,
|
||||||
context: undefined,
|
context: undefined,
|
||||||
briefing: undefined,
|
briefing: undefined,
|
||||||
handoffContext: undefined,
|
handoffContext: undefined,
|
||||||
handoffResources: undefined,
|
handoffResources: undefined,
|
||||||
handoffActions: undefined,
|
handoffActions: undefined,
|
||||||
|
handoffMetadata: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockAiChatStore = {
|
const mockAiChatStore = {
|
||||||
isOpenSignal: vi.fn(() => true),
|
isOpenSignal: vi.fn(() => true),
|
||||||
context: emptyChatContext() as {
|
context: emptyChatContext() as {
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
findingId?: string;
|
findingId?: string;
|
||||||
autonomousMode?: boolean;
|
autonomousMode?: boolean;
|
||||||
context?: Record<string, unknown>;
|
context?: Record<string, unknown>;
|
||||||
handoffContext?: string;
|
handoffContext?: string;
|
||||||
handoffResources?: Array<{
|
handoffResources?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -104,6 +105,13 @@ const {
|
|||||||
approvalId?: string;
|
approvalId?: string;
|
||||||
approvalStatus?: string;
|
approvalStatus?: string;
|
||||||
}>;
|
}>;
|
||||||
|
handoffMetadata?: {
|
||||||
|
kind?: string;
|
||||||
|
runId?: string;
|
||||||
|
runType?: string;
|
||||||
|
runStatus?: string;
|
||||||
|
runtimeFailure?: boolean;
|
||||||
|
};
|
||||||
briefing?: {
|
briefing?: {
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -132,6 +140,7 @@ const {
|
|||||||
handoffContext: _handoffContext,
|
handoffContext: _handoffContext,
|
||||||
handoffResources: _handoffResources,
|
handoffResources: _handoffResources,
|
||||||
handoffActions: _handoffActions,
|
handoffActions: _handoffActions,
|
||||||
|
handoffMetadata: _handoffMetadata,
|
||||||
...rest
|
...rest
|
||||||
} = mockAiChatStore.context;
|
} = mockAiChatStore.context;
|
||||||
mockAiChatStore.context = rest;
|
mockAiChatStore.context = rest;
|
||||||
@@ -398,8 +407,7 @@ describe('AIChat', () => {
|
|||||||
briefing: {
|
briefing: {
|
||||||
sourceLabel: 'Pulse Patrol',
|
sourceLabel: 'Pulse Patrol',
|
||||||
title: 'Patrol configuration failure attached',
|
title: 'Patrol configuration failure attached',
|
||||||
subject:
|
subject: 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.',
|
||||||
'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.',
|
|
||||||
statusLabel: 'HTTP 402 · model_unsupported_tools',
|
statusLabel: 'HTTP 402 · model_unsupported_tools',
|
||||||
detailLines: ['Provider: openrouter'],
|
detailLines: ['Provider: openrouter'],
|
||||||
evidence: ['Command: sensitive or command detail withheld'],
|
evidence: ['Command: sensitive or command detail withheld'],
|
||||||
@@ -418,6 +426,47 @@ describe('AIChat', () => {
|
|||||||
expect(context).toHaveTextContent('Approval required before any action.');
|
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', () => {
|
it('renders Patrol run handoff details without replacing the attached headline', () => {
|
||||||
mockAiChatStore.context = {
|
mockAiChatStore.context = {
|
||||||
targetType: 'patrol-run',
|
targetType: 'patrol-run',
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session';
|
|||||||
const DEFAULT_SESSION_KEY = '__default__';
|
const DEFAULT_SESSION_KEY = '__default__';
|
||||||
const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
||||||
const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
|
const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
|
||||||
|
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
|
||||||
|
|
||||||
interface AIChatProps {
|
interface AIChatProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -81,6 +82,10 @@ const pluralizeCount = (count: number, singular: string, plural: string) =>
|
|||||||
const shouldShowStructuredPatrolContext = (targetType: string | undefined) =>
|
const shouldShowStructuredPatrolContext = (targetType: string | undefined) =>
|
||||||
STRUCTURED_PATROL_CONTEXT_TARGETS.has(targetType ?? '');
|
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) =>
|
const isPatrolFindingSessionHandoff = (summary: ChatSessionHandoffSummary) =>
|
||||||
summary.kind === 'patrol_finding' || Boolean(summary.finding_id);
|
summary.kind === 'patrol_finding' || Boolean(summary.finding_id);
|
||||||
|
|
||||||
@@ -515,7 +520,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
|
|
||||||
const title = briefing.title?.trim() ?? '';
|
const title = briefing.title?.trim() ?? '';
|
||||||
const subject = briefing.subject?.trim() ?? '';
|
const subject = briefing.subject?.trim() ?? '';
|
||||||
if (shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) {
|
if (shouldShowStructuredBriefingContext(aiChatStore.context)) {
|
||||||
return title || subject || 'Context attached';
|
return title || subject || 'Context attached';
|
||||||
}
|
}
|
||||||
if (subject && /attached|briefing/i.test(title)) {
|
if (subject && /attached|briefing/i.test(title)) {
|
||||||
@@ -527,7 +532,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
const contextBriefingDetailLines = createMemo(() => {
|
const contextBriefingDetailLines = createMemo(() => {
|
||||||
const briefing = contextBriefing();
|
const briefing = contextBriefing();
|
||||||
if (!briefing) return [];
|
if (!briefing) return [];
|
||||||
if (!shouldShowStructuredPatrolContext(aiChatStore.context.targetType)) return [];
|
if (!shouldShowStructuredBriefingContext(aiChatStore.context)) return [];
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Show, For, Suspense } from 'solid-js';
|
import { Show, For, Suspense } from 'solid-js';
|
||||||
import type { Component } 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 type { Resource } from '@/types/resource';
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { Subtabs } from '@/components/shared/Subtabs';
|
import { Subtabs } from '@/components/shared/Subtabs';
|
||||||
@@ -102,30 +105,50 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onClose?.()}
|
onClick={() => void drawer.copyAgentContext()}
|
||||||
class="rounded-md p-1 hover:bg-surface-hover hover:text-base-content"
|
disabled={drawer.copyingAgentContext()}
|
||||||
aria-label="Close resource drawer"
|
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">
|
<CopyIcon class="h-4 w-4" />
|
||||||
<path
|
<span class="hidden sm:inline">{drawer.agentContextCopied() ? 'Copied' : 'Copy'}</span>
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Subtabs
|
<Subtabs
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
ariaLabel="Resource detail sections"
|
ariaLabel="Resource detail sections"
|
||||||
value={drawer.activeTab()}
|
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 }))}
|
tabs={drawer.tabs().map((tab) => ({ value: tab.id, label: tab.label }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+102
-1
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, waitFor, within } from '@solidjs/testing-library';
|
import { fireEvent, render, waitFor, within } from '@solidjs/testing-library';
|
||||||
|
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
import {
|
import {
|
||||||
ResourceDetailDrawer,
|
ResourceDetailDrawer,
|
||||||
getSpecializedTabAvailabilityMessage,
|
getSpecializedTabAvailabilityMessage,
|
||||||
@@ -9,6 +10,10 @@ import {
|
|||||||
|
|
||||||
const wsState = vi.hoisted(() => ({ pmg: [] as any[] }));
|
const wsState = vi.hoisted(() => ({ pmg: [] as any[] }));
|
||||||
const reconnectSpy = vi.hoisted(() => vi.fn());
|
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', () => ({
|
vi.mock('@/contexts/appRuntime', () => ({
|
||||||
useWebSocket: () => ({
|
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 {
|
class ResizeObserverMock {
|
||||||
constructor(_callback: ResizeObserverCallback) {}
|
constructor(_callback: ResizeObserverCallback) {}
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -105,7 +127,86 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
aiChatStore.setEnabled(false);
|
||||||
|
getResourceContextMock.mockReset();
|
||||||
|
copyToClipboardMock.mockReset();
|
||||||
|
notificationSuccessMock.mockReset();
|
||||||
|
notificationErrorMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
describe('ResourceDetailDrawer runtime and identity cards', () => {
|
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', () => {
|
it('renders a unified summary shell without repeating healthy source status', () => {
|
||||||
const resource = baseResource({
|
const resource = baseResource({
|
||||||
platformData: {
|
platformData: {
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||||
|
import type { Resource } from '@/types/resource';
|
||||||
|
|
||||||
|
const resource: Resource = {
|
||||||
|
id: 'app-container:homeassistant',
|
||||||
|
type: 'app-container',
|
||||||
|
name: 'homeassistant',
|
||||||
|
displayName: 'Home Assistant',
|
||||||
|
platformId: 'homeassistant',
|
||||||
|
platformType: 'docker',
|
||||||
|
sourceType: 'agent',
|
||||||
|
status: 'online',
|
||||||
|
technology: 'docker',
|
||||||
|
parentName: 'ha-lxc',
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
discoveryTarget: {
|
||||||
|
resourceType: 'app-container',
|
||||||
|
agentId: 'agent:pve-1',
|
||||||
|
resourceId: 'homeassistant',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildResourceAssistantContext', () => {
|
||||||
|
it('creates a model-only resource handoff without prefilled prompt context', () => {
|
||||||
|
const context = buildResourceAssistantContext(resource);
|
||||||
|
|
||||||
|
expect(context.targetType).toBe('resource');
|
||||||
|
expect(context.targetId).toBe('app-container:homeassistant');
|
||||||
|
expect(context.autonomousMode).toBe(false);
|
||||||
|
expect(context.handoffContext).toBeUndefined();
|
||||||
|
expect(context.handoffResources).toEqual([
|
||||||
|
{
|
||||||
|
id: 'app-container:homeassistant',
|
||||||
|
name: 'Home Assistant',
|
||||||
|
type: 'app-container',
|
||||||
|
node: 'ha-lxc',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(context.handoffMetadata).toEqual({ kind: 'resource_context' });
|
||||||
|
expect(context.briefing?.detailLines).toContain('Resource ID: app-container:homeassistant');
|
||||||
|
expect(context.briefing?.detailLines).toContain('Parent: ha-lxc');
|
||||||
|
expect(context.briefing?.detailLines).toContain('Discovery: app-container:homeassistant');
|
||||||
|
});
|
||||||
|
});
|
||||||
+41
-9
@@ -8,6 +8,7 @@ import {
|
|||||||
buildSourceSections,
|
buildSourceSections,
|
||||||
} from '@/components/Infrastructure/resourceDetailDrawerIdentityModel';
|
} from '@/components/Infrastructure/resourceDetailDrawerIdentityModel';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
|
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||||
|
|
||||||
const baseResource = (overrides: Partial<Resource> = {}): Resource => ({
|
const baseResource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||||
id: 'resource-1',
|
id: 'resource-1',
|
||||||
@@ -42,9 +43,7 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
|||||||
value: 'vm:101',
|
value: 'vm:101',
|
||||||
});
|
});
|
||||||
expect(identityView.identityIpValues).toEqual(['192.0.2.10']);
|
expect(identityView.identityIpValues).toEqual(['192.0.2.10']);
|
||||||
expect(identityView.aliasPreviewValues).toEqual(
|
expect(identityView.aliasPreviewValues).toEqual(identityView.identityAliasValues.slice(0, 4));
|
||||||
identityView.identityAliasValues.slice(0, 4),
|
|
||||||
);
|
|
||||||
expect(identityView.hasAliasOverflow).toBe(true);
|
expect(identityView.hasAliasOverflow).toBe(true);
|
||||||
expect(identityView.identityCardHasRichData).toBe(true);
|
expect(identityView.identityCardHasRichData).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -75,6 +74,39 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
|||||||
).toBeNull();
|
).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', () => {
|
it('builds canonical source sections and identity-match precedence', () => {
|
||||||
const platformData = {
|
const platformData = {
|
||||||
agent: { hostname: 'host-1' },
|
agent: { hostname: 'host-1' },
|
||||||
@@ -110,12 +142,12 @@ describe('resourceDetailDrawerIdentityModel', () => {
|
|||||||
resource,
|
resource,
|
||||||
platformData: {
|
platformData: {
|
||||||
proxmox: { nodeName: 'pve-1' },
|
proxmox: { nodeName: 'pve-1' },
|
||||||
docker: { runtime: 'docker' },
|
docker: { runtime: 'docker' },
|
||||||
vmware: { connectionName: 'Lab VC' },
|
vmware: { connectionName: 'Lab VC' },
|
||||||
},
|
},
|
||||||
sourceStatus: {
|
sourceStatus: {
|
||||||
proxmox: { status: 'online' },
|
proxmox: { status: 'online' },
|
||||||
},
|
},
|
||||||
identityMatchInfo: { matchedBy: 'hostname' },
|
identityMatchInfo: { matchedBy: 'hostname' },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { createEffect, createSignal } from 'solid-js';
|
import { createEffect, createSignal } from 'solid-js';
|
||||||
|
import { AgentContextAPI } from '@/api/agentContext';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import type { HistoryTimeRange } from '@/api/charts';
|
import type { HistoryTimeRange } from '@/api/charts';
|
||||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
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 { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||||
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
|
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||||
import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState';
|
import { useResourceDetailDrawerDockerActionsState } from './useResourceDetailDrawerDockerActionsState';
|
||||||
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
|
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
|
||||||
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
|
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
|
||||||
|
import { buildResourceAssistantContext } from '@/utils/resourceAssistantContextModel';
|
||||||
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
|
import type { ResourceDetailDrawerPresentation } from './resourceDetailDrawerPresentation';
|
||||||
|
|
||||||
type DrawerTab =
|
type DrawerTab =
|
||||||
@@ -35,6 +41,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
|||||||
);
|
);
|
||||||
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
|
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
|
||||||
const [copied, setCopied] = createSignal(false);
|
const [copied, setCopied] = createSignal(false);
|
||||||
|
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||||
|
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||||
const [showReportModal, setShowReportModal] = createSignal(false);
|
const [showReportModal, setShowReportModal] = createSignal(false);
|
||||||
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
|
const [showHistoryFilters, setShowHistoryFilters] = createSignal(false);
|
||||||
const [showAccessContext, setShowAccessContext] = createSignal(
|
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 {
|
return {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
@@ -120,6 +157,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
|||||||
setMetricsHistoryRange,
|
setMetricsHistoryRange,
|
||||||
debugEnabled,
|
debugEnabled,
|
||||||
copied,
|
copied,
|
||||||
|
copyingAgentContext,
|
||||||
|
agentContextCopied,
|
||||||
showReportModal,
|
showReportModal,
|
||||||
setShowReportModal,
|
setShowReportModal,
|
||||||
showHistoryFilters,
|
showHistoryFilters,
|
||||||
@@ -147,6 +186,9 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
|||||||
...history,
|
...history,
|
||||||
...derived,
|
...derived,
|
||||||
...dockerActions,
|
...dockerActions,
|
||||||
|
assistantAvailable,
|
||||||
|
openAssistantForResource,
|
||||||
|
copyAgentContext,
|
||||||
handleCopyJson,
|
handleCopyJson,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Suspense } from 'solid-js';
|
|||||||
import type { WorkloadGuest } from '@/types/workloads';
|
import type { WorkloadGuest } from '@/types/workloads';
|
||||||
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
||||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||||
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||||
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
|
||||||
|
|
||||||
@@ -58,6 +59,10 @@ const discoveryApiMocks = vi.hoisted(() => ({
|
|||||||
null,
|
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 () => {
|
vi.mock('@/api/discovery', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/api/discovery')>('@/api/discovery');
|
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', () => ({
|
vi.mock('@/components/shared/WebInterfaceUrlField', () => ({
|
||||||
WebInterfaceUrlField: (props: {
|
WebInterfaceUrlField: (props: {
|
||||||
metadataKind: string;
|
metadataKind: string;
|
||||||
@@ -150,6 +172,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
aiChatStore.setEnabled(false);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -157,6 +180,96 @@ afterEach(() => {
|
|||||||
// ── Tests ──────────────────────────────────────────────────────────────
|
// ── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('GuestDrawer', () => {
|
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 ──
|
// ── Tabs ──
|
||||||
|
|
||||||
describe('tab switching', () => {
|
describe('tab switching', () => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Component, Show, Suspense, createMemo } from 'solid-js';
|
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 { DiscoveryTab } from '../Discovery/DiscoveryTab';
|
||||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||||
@@ -13,7 +16,9 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||||||
activeTab,
|
activeTab,
|
||||||
agentLabel,
|
agentLabel,
|
||||||
agentTitle,
|
agentTitle,
|
||||||
|
agentContextCopied,
|
||||||
backupPresentation,
|
backupPresentation,
|
||||||
|
copyingAgentContext,
|
||||||
discoveryAgentId,
|
discoveryAgentId,
|
||||||
discoveryIdentifiedSummary,
|
discoveryIdentifiedSummary,
|
||||||
discoveryLoadingState,
|
discoveryLoadingState,
|
||||||
@@ -33,6 +38,9 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||||||
guestOsSummary,
|
guestOsSummary,
|
||||||
networkInterfaces,
|
networkInterfaces,
|
||||||
normalizedTags,
|
normalizedTags,
|
||||||
|
assistantAvailable,
|
||||||
|
copyAgentContext,
|
||||||
|
openAssistantForGuest,
|
||||||
setHistoryRange,
|
setHistoryRange,
|
||||||
showInGuestAgentInstallCue,
|
showInGuestAgentInstallCue,
|
||||||
switchTab,
|
switchTab,
|
||||||
@@ -47,12 +55,47 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section class="space-y-3" aria-labelledby={headingId()}>
|
<section class="space-y-3" aria-labelledby={headingId()}>
|
||||||
<DrawerSubjectHeading
|
<div class="flex items-start justify-between gap-3">
|
||||||
headingId={headingId()}
|
<DrawerSubjectHeading
|
||||||
title={props.guest.name}
|
headingId={headingId()}
|
||||||
statusVariant={headerIndicator().variant}
|
title={props.guest.name}
|
||||||
statusLabel={headerIndicator().label}
|
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
|
<Subtabs
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
ariaLabel="Guest drawer sections"
|
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 { createEffect, createMemo, createSignal } from 'solid-js';
|
||||||
|
|
||||||
|
import { AgentContextAPI } from '@/api/agentContext';
|
||||||
import { getDiscovery } from '@/api/discovery';
|
import { getDiscovery } from '@/api/discovery';
|
||||||
import type { HistoryTimeRange } from '@/api/charts';
|
import type { HistoryTimeRange } from '@/api/charts';
|
||||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||||
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery';
|
import type { ResourceDiscovery, ResourceType as DiscoveryResourceType } from '@/types/discovery';
|
||||||
|
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
getDiscoveryIdentifiedSummary,
|
getDiscoveryIdentifiedSummary,
|
||||||
getDiscoveryLoadingState,
|
getDiscoveryLoadingState,
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
type GuestDrawerProps,
|
type GuestDrawerProps,
|
||||||
type GuestDrawerTab,
|
type GuestDrawerTab,
|
||||||
} from './guestDrawerModel';
|
} from './guestDrawerModel';
|
||||||
|
import { buildGuestAssistantContext } from './guestAssistantContextModel';
|
||||||
import { shouldShowInGuestAgentInstallCue } from './workloadAgentReadiness';
|
import { shouldShowInGuestAgentInstallCue } from './workloadAgentReadiness';
|
||||||
|
|
||||||
interface GuestDiscoverySourceKey {
|
interface GuestDiscoverySourceKey {
|
||||||
@@ -50,6 +56,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||||
);
|
);
|
||||||
|
const [copyingAgentContext, setCopyingAgentContext] = createSignal(false);
|
||||||
|
const [agentContextCopied, setAgentContextCopied] = createSignal(false);
|
||||||
|
|
||||||
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
|
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
|
||||||
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
|
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
|
||||||
@@ -109,7 +117,10 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||||||
if (!type || !agent || !resource) return null;
|
if (!type || !agent || !resource) return null;
|
||||||
return { type, agent, resource } as GuestDiscoverySourceKey;
|
return { type, agent, resource } as GuestDiscoverySourceKey;
|
||||||
});
|
});
|
||||||
const discoveryRecord = createNonSuspendingQuery<ResourceDiscovery | null, GuestDiscoverySourceKey>({
|
const discoveryRecord = createNonSuspendingQuery<
|
||||||
|
ResourceDiscovery | null,
|
||||||
|
GuestDiscoverySourceKey
|
||||||
|
>({
|
||||||
source: discoverySourceKey,
|
source: discoverySourceKey,
|
||||||
initialValue: null,
|
initialValue: null,
|
||||||
cacheKey: (key) => `guest-drawer-discovery:${key.type}:${key.agent}:${key.resource}`,
|
cacheKey: (key) => `guest-drawer-discovery:${key.type}:${key.agent}:${key.resource}`,
|
||||||
@@ -129,6 +140,35 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||||||
setActiveTab(tab);
|
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(() => {
|
createEffect(() => {
|
||||||
if (activeTab() === 'discovery' && !hasDiscoverySupport()) {
|
if (activeTab() === 'discovery' && !hasDiscoverySupport()) {
|
||||||
setActiveTab('overview');
|
setActiveTab('overview');
|
||||||
@@ -143,6 +183,8 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||||||
agentLabel,
|
agentLabel,
|
||||||
agentTitle,
|
agentTitle,
|
||||||
backupPresentation,
|
backupPresentation,
|
||||||
|
copyingAgentContext,
|
||||||
|
agentContextCopied,
|
||||||
discoveryAgentId,
|
discoveryAgentId,
|
||||||
discoveryIdentifiedSummary,
|
discoveryIdentifiedSummary,
|
||||||
discoveryLoadingState: getDiscoveryLoadingState(),
|
discoveryLoadingState: getDiscoveryLoadingState(),
|
||||||
@@ -165,6 +207,9 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
|
|||||||
osName,
|
osName,
|
||||||
osVersion,
|
osVersion,
|
||||||
showInGuestAgentInstallCue,
|
showInGuestAgentInstallCue,
|
||||||
|
assistantAvailable,
|
||||||
|
openAssistantForGuest,
|
||||||
|
copyAgentContext,
|
||||||
switchTab,
|
switchTab,
|
||||||
setHistoryRange,
|
setHistoryRange,
|
||||||
webInterfaceTargetLabel,
|
webInterfaceTargetLabel,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatAgentResourceContextForClipboard } from '@/utils/agentContextPresentation';
|
||||||
|
|
||||||
|
describe('formatAgentResourceContextForClipboard', () => {
|
||||||
|
it('formats bounded resource context sections with provenance and redactions', () => {
|
||||||
|
const text = formatAgentResourceContextForClipboard({
|
||||||
|
canonicalId: 'app-container:homeassistant',
|
||||||
|
resourceType: 'app-container',
|
||||||
|
resourceName: 'homeassistant',
|
||||||
|
technology: 'docker',
|
||||||
|
activeFindings: [],
|
||||||
|
pendingApprovals: [],
|
||||||
|
recentActions: [],
|
||||||
|
generatedAt: '2026-05-06T14:00:00Z',
|
||||||
|
contextSections: [
|
||||||
|
{
|
||||||
|
id: 'runtime',
|
||||||
|
title: 'Runtime and Discovery',
|
||||||
|
source: 'unified-resource',
|
||||||
|
trustTier: 'runtime-observed',
|
||||||
|
observedAt: '2026-05-06T13:59:00Z',
|
||||||
|
generatedAt: '2026-05-06T14:00:00Z',
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
label: 'Ports',
|
||||||
|
value: '8123:8123/tcp',
|
||||||
|
source: 'unified-resource',
|
||||||
|
trustTier: 'discovered',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Storage path',
|
||||||
|
value: 'redacted by policy',
|
||||||
|
source: 'unified-resource',
|
||||||
|
trustTier: 'runtime-observed',
|
||||||
|
redacted: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
redactions: [
|
||||||
|
{
|
||||||
|
field: 'resource identifiers',
|
||||||
|
reason: 'canonical resource policy redacts path',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain('# Pulse resource context: homeassistant');
|
||||||
|
expect(text).toContain('Canonical ID: app-container:homeassistant');
|
||||||
|
expect(text).toContain('## Runtime and Discovery');
|
||||||
|
expect(text).toContain('- Ports: 8123:8123/tcp');
|
||||||
|
expect(text).toContain('- Storage path: redacted by policy');
|
||||||
|
expect(text).toContain('redacted=true');
|
||||||
|
expect(text).toContain('Redaction: resource identifiers');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type {
|
||||||
|
AgentResourceContext,
|
||||||
|
AgentResourceContextFact,
|
||||||
|
AgentResourceContextRedaction,
|
||||||
|
AgentResourceContextSection,
|
||||||
|
} from '@/api/agentContext';
|
||||||
|
|
||||||
|
const formatOptionalTime = (value?: string): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatProvenance = (fact: AgentResourceContextFact): string => {
|
||||||
|
const parts = [
|
||||||
|
fact.source ? `source=${fact.source}` : '',
|
||||||
|
fact.trustTier ? `trust=${fact.trustTier}` : '',
|
||||||
|
fact.observedAt ? `observed=${formatOptionalTime(fact.observedAt)}` : '',
|
||||||
|
fact.redacted ? 'redacted=true' : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFact = (fact: AgentResourceContextFact): string =>
|
||||||
|
`- ${fact.label}: ${fact.value}${formatProvenance(fact)}`;
|
||||||
|
|
||||||
|
const formatRedaction = (redaction: AgentResourceContextRedaction): string =>
|
||||||
|
`- Redaction: ${redaction.field}${redaction.reason ? ` - ${redaction.reason}` : ''}`;
|
||||||
|
|
||||||
|
const formatSectionHeader = (section: AgentResourceContextSection): string => {
|
||||||
|
const provenance = [
|
||||||
|
section.source ? `source=${section.source}` : '',
|
||||||
|
section.trustTier ? `trust=${section.trustTier}` : '',
|
||||||
|
section.observedAt ? `observed=${formatOptionalTime(section.observedAt)}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return provenance.length > 0
|
||||||
|
? `## ${section.title} (${provenance.join(', ')})`
|
||||||
|
: `## ${section.title}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatAgentResourceContextForClipboard = (context: AgentResourceContext): string => {
|
||||||
|
const lines = [
|
||||||
|
`# Pulse resource context: ${context.resourceName || context.canonicalId}`,
|
||||||
|
'',
|
||||||
|
`Generated: ${formatOptionalTime(context.generatedAt)}`,
|
||||||
|
`Canonical ID: ${context.canonicalId}`,
|
||||||
|
`Resource type: ${context.resourceType}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (context.technology) {
|
||||||
|
lines.push(`Technology: ${context.technology}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`Active findings: ${context.activeFindings.length}`,
|
||||||
|
`Pending approvals: ${context.pendingApprovals.length}`,
|
||||||
|
`Recent actions: ${context.recentActions.length}`,
|
||||||
|
'',
|
||||||
|
'Context facts below are bounded, read-only Pulse context. Redacted values were withheld by policy.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const section of context.contextSections) {
|
||||||
|
if (section.facts.length === 0 && !section.redactions?.length) continue;
|
||||||
|
lines.push('', formatSectionHeader(section));
|
||||||
|
if (section.summary) {
|
||||||
|
lines.push(section.summary);
|
||||||
|
}
|
||||||
|
for (const fact of section.facts) {
|
||||||
|
lines.push(formatFact(fact));
|
||||||
|
}
|
||||||
|
for (const redaction of section.redactions ?? []) {
|
||||||
|
lines.push(formatRedaction(redaction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n').trim()}\n`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { AIChatContext } from '@/stores/aiChat';
|
||||||
|
import type { Resource, ResourceDiscoveryTarget } from '@/types/resource';
|
||||||
|
import {
|
||||||
|
getPreferredResourceDisplayName,
|
||||||
|
getPrimaryResourceIdentity,
|
||||||
|
} from '@/utils/resourceIdentity';
|
||||||
|
|
||||||
|
const compact = (values: Array<string | undefined | null | false>): string[] =>
|
||||||
|
values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||||
|
|
||||||
|
export interface ResourceAssistantContextTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
status?: string;
|
||||||
|
technology?: string;
|
||||||
|
parentName?: string;
|
||||||
|
primaryIdentity?: string;
|
||||||
|
discoveryTarget?: ResourceDiscoveryTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildResourceAssistantContextForTarget = (
|
||||||
|
target: ResourceAssistantContextTarget,
|
||||||
|
): AIChatContext => {
|
||||||
|
const displayName = target.name || target.id;
|
||||||
|
const subjectParts = compact([target.type, target.status, target.technology]);
|
||||||
|
const detailLines = compact([
|
||||||
|
`Resource ID: ${target.id}`,
|
||||||
|
target.primaryIdentity && target.primaryIdentity !== target.id
|
||||||
|
? `Primary identity: ${target.primaryIdentity}`
|
||||||
|
: '',
|
||||||
|
target.parentName ? `Parent: ${target.parentName}` : '',
|
||||||
|
target.discoveryTarget
|
||||||
|
? `Discovery: ${target.discoveryTarget.resourceType}:${target.discoveryTarget.resourceId}`
|
||||||
|
: '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetType: 'resource',
|
||||||
|
targetId: target.id,
|
||||||
|
context: {
|
||||||
|
source: target.source,
|
||||||
|
resourceId: target.id,
|
||||||
|
resourceType: target.type,
|
||||||
|
resourceStatus: target.status,
|
||||||
|
},
|
||||||
|
briefing: {
|
||||||
|
sourceLabel: 'Pulse resource context',
|
||||||
|
title: displayName,
|
||||||
|
subject: subjectParts.join(' / '),
|
||||||
|
statusLabel: 'Read-only context attached',
|
||||||
|
detailLines,
|
||||||
|
safetyNote: 'Approval required before any action.',
|
||||||
|
},
|
||||||
|
handoffResources: [
|
||||||
|
{
|
||||||
|
id: target.id,
|
||||||
|
name: displayName,
|
||||||
|
type: target.type,
|
||||||
|
node: target.parentName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
handoffMetadata: {
|
||||||
|
kind: 'resource_context',
|
||||||
|
},
|
||||||
|
autonomousMode: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildResourceAssistantContext = (resource: Resource): AIChatContext =>
|
||||||
|
buildResourceAssistantContextForTarget({
|
||||||
|
id: resource.id,
|
||||||
|
name: getPreferredResourceDisplayName(resource),
|
||||||
|
type: resource.type,
|
||||||
|
source: 'resource-detail-drawer',
|
||||||
|
status: resource.status,
|
||||||
|
technology: resource.technology,
|
||||||
|
parentName: resource.parentName,
|
||||||
|
primaryIdentity: getPrimaryResourceIdentity(resource),
|
||||||
|
discoveryTarget: resource.discoveryTarget,
|
||||||
|
});
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
package agentcontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact is a bounded, typed fact in a resource context pack. Values come
|
||||||
|
// from Pulse-owned runtime state only; raw command output, config files,
|
||||||
|
// environment variables, and secret-bearing metadata do not belong here.
|
||||||
|
type Fact struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
TrustTier string `json:"trustTier,omitempty"`
|
||||||
|
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||||
|
Redacted bool `json:"redacted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redaction records why a context section withheld a field. It gives
|
||||||
|
// agents and UI inspectors a visible safety boundary without leaking
|
||||||
|
// the underlying value.
|
||||||
|
type Redaction struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section groups related facts with one provenance/freshness envelope.
|
||||||
|
// Additive sections make a context pack useful to Assistant and external
|
||||||
|
// MCP clients without breaking older clients that parse top-level fields.
|
||||||
|
type Section struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
TrustTier string `json:"trustTier"`
|
||||||
|
ObservedAt *time.Time `json:"observedAt,omitempty"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
Facts []Fact `json:"facts"`
|
||||||
|
Redactions []Redaction `json:"redactions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorState is the context-pack projection of operator intent. It is
|
||||||
|
// intentionally narrower than the persistence/API shape so this package
|
||||||
|
// stays independent of HTTP and storage owners.
|
||||||
|
type OperatorState struct {
|
||||||
|
IntentionallyOffline bool
|
||||||
|
NeverAutoRemediate bool
|
||||||
|
MaintenanceWindowActive bool
|
||||||
|
MaintenanceStartAt *time.Time
|
||||||
|
MaintenanceEndAt *time.Time
|
||||||
|
Criticality string
|
||||||
|
NotePresent bool
|
||||||
|
SetAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildOptions supplies operational counts owned outside the unified
|
||||||
|
// resource model.
|
||||||
|
type BuildOptions struct {
|
||||||
|
GeneratedAt time.Time
|
||||||
|
OperatorState *OperatorState
|
||||||
|
ActiveFindingCount int
|
||||||
|
PendingApprovalCount int
|
||||||
|
RecentActionCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentContextTrustPulseAuthored = "pulse-authored"
|
||||||
|
agentContextTrustRuntimeObserved = "runtime-observed"
|
||||||
|
agentContextTrustDiscovered = "discovered"
|
||||||
|
agentContextTrustUserTaught = "user-taught"
|
||||||
|
|
||||||
|
agentContextSourceUnifiedResource = "unified-resource"
|
||||||
|
agentContextSourceResourceStore = "resource-store"
|
||||||
|
agentContextSourceOperatorState = "operator-state"
|
||||||
|
agentContextSourcePatrol = "patrol"
|
||||||
|
agentContextSourceActionAudit = "action-audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildResourceContextSections returns the canonical resource context
|
||||||
|
// pack shared by Assistant, the agent HTTP endpoint, and MCP adapters.
|
||||||
|
func BuildResourceContextSections(resource unified.Resource, store unified.ResourceStore, opts BuildOptions) []Section {
|
||||||
|
now := opts.GeneratedAt
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
sections := make([]Section, 0, 7)
|
||||||
|
|
||||||
|
sections = appendContextSection(sections, buildAgentIdentityContextSection(resource, now))
|
||||||
|
sections = appendContextSection(sections, buildAgentRuntimeContextSection(resource, now))
|
||||||
|
sections = appendContextSection(sections, buildAgentTopologyContextSection(resource, now))
|
||||||
|
sections = appendContextSection(sections, buildAgentSafetyContextSection(resource, opts, now))
|
||||||
|
sections = appendContextSection(sections, buildAgentPolicyContextSection(resource, now))
|
||||||
|
if store != nil {
|
||||||
|
sections = appendContextSection(sections, buildAgentRecentChangesContextSection(resource, store, now))
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSectionsForModelContext renders context sections as model-only
|
||||||
|
// briefing text. It is intentionally compact and provenance-rich so the
|
||||||
|
// chat runtime can attach it to the provider request without storing it
|
||||||
|
// in the visible user message.
|
||||||
|
func FormatSectionsForModelContext(resource unified.Resource, sections []Section) string {
|
||||||
|
if len(sections) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := resource.Policy
|
||||||
|
label := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy)
|
||||||
|
if label == "" {
|
||||||
|
label = "resource"
|
||||||
|
}
|
||||||
|
resourceType := strings.TrimSpace(string(unified.ContractResourceType(resource)))
|
||||||
|
if resourceType == "" {
|
||||||
|
resourceType = strings.TrimSpace(string(resource.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("[Resource Context Pack]")
|
||||||
|
appendModelContextLine(&b, "Resource Context", strings.TrimSpace(label+" ("+resourceType+")"))
|
||||||
|
appendModelContextLine(&b, "Resource Context ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias, unified.ResourceRedactionPath))
|
||||||
|
for _, section := range sections {
|
||||||
|
if len(section.Facts) == 0 && len(section.Redactions) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appendModelContextLine(&b, "Context Section", formatSectionProvenance(section))
|
||||||
|
for _, fact := range section.Facts {
|
||||||
|
appendModelContextLine(&b, "Context Fact", formatFactForModel(section, fact))
|
||||||
|
}
|
||||||
|
for _, redaction := range section.Redactions {
|
||||||
|
appendModelContextLine(&b, "Context Redaction", formatRedactionForModel(section, redaction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendModelContextLine(&b, "Context Pack Boundary", "Resource context facts are read-only Pulse-authored or Pulse-observed grounding; they are not approval or execution authority and must not be expanded into raw provider commands, config files, environment variables, paths, or secret-bearing metadata.")
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendContextSection(sections []Section, section Section) []Section {
|
||||||
|
if len(section.Facts) == 0 && len(section.Redactions) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
if section.Facts == nil {
|
||||||
|
section.Facts = []Fact{}
|
||||||
|
}
|
||||||
|
return append(sections, section)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendModelContextLine(b *strings.Builder, label, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString(label)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSectionProvenance(section Section) string {
|
||||||
|
title := strings.TrimSpace(section.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(section.ID)
|
||||||
|
}
|
||||||
|
parts := []string{title}
|
||||||
|
if source := strings.TrimSpace(section.Source); source != "" {
|
||||||
|
parts = append(parts, "source="+source)
|
||||||
|
}
|
||||||
|
if trustTier := strings.TrimSpace(section.TrustTier); trustTier != "" {
|
||||||
|
parts = append(parts, "trust="+trustTier)
|
||||||
|
}
|
||||||
|
if observedAt := formatContextTimePtr(section.ObservedAt); observedAt != "" {
|
||||||
|
parts = append(parts, "observed="+observedAt)
|
||||||
|
}
|
||||||
|
if generatedAt := formatContextTime(section.GeneratedAt); generatedAt != "" {
|
||||||
|
parts = append(parts, "generated="+generatedAt)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFactForModel(section Section, fact Fact) string {
|
||||||
|
label := strings.TrimSpace(fact.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = "Fact"
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(fact.Value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := []string{strings.TrimSpace(section.Title) + " / " + label + ": " + value}
|
||||||
|
provenance := make([]string, 0, 4)
|
||||||
|
if source := strings.TrimSpace(fact.Source); source != "" {
|
||||||
|
provenance = append(provenance, "source="+source)
|
||||||
|
}
|
||||||
|
if trustTier := strings.TrimSpace(fact.TrustTier); trustTier != "" {
|
||||||
|
provenance = append(provenance, "trust="+trustTier)
|
||||||
|
}
|
||||||
|
if observedAt := formatContextTimePtr(fact.ObservedAt); observedAt != "" {
|
||||||
|
provenance = append(provenance, "observed="+observedAt)
|
||||||
|
}
|
||||||
|
if fact.Redacted {
|
||||||
|
provenance = append(provenance, "redacted=true")
|
||||||
|
}
|
||||||
|
if len(provenance) > 0 {
|
||||||
|
parts = append(parts, "["+strings.Join(provenance, ", ")+"]")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRedactionForModel(section Section, redaction Redaction) string {
|
||||||
|
field := strings.TrimSpace(redaction.Field)
|
||||||
|
if field == "" {
|
||||||
|
field = "field"
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(redaction.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
return strings.TrimSpace(section.Title) + " / " + field
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(section.Title) + " / " + field + ": " + reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatContextTimePtr(value *time.Time) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatContextTime(*value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatContextTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgentContextSection(id, title, source, trustTier string, generatedAt time.Time, observedAt *time.Time) Section {
|
||||||
|
return Section{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Source: source,
|
||||||
|
TrustTier: trustTier,
|
||||||
|
ObservedAt: observedAt,
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Facts: []Fact{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentContextFact(label, value, source, trustTier string, observedAt *time.Time) Fact {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
fact := Fact{
|
||||||
|
Label: strings.TrimSpace(label),
|
||||||
|
Value: value,
|
||||||
|
Source: source,
|
||||||
|
TrustTier: trustTier,
|
||||||
|
ObservedAt: observedAt,
|
||||||
|
}
|
||||||
|
if value == unified.ResourcePolicyRedactedLabel {
|
||||||
|
fact.Redacted = true
|
||||||
|
}
|
||||||
|
return fact
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAgentContextFact(facts *[]Fact, label, value, source, trustTier string, observedAt *time.Time) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*facts = append(*facts, agentContextFact(label, value, source, trustTier, observedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAgentContextCountFact(facts *[]Fact, label string, value int, source, trustTier string, observedAt *time.Time) {
|
||||||
|
if value <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, label, fmt.Sprintf("%d", value), source, trustTier, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentIdentityContextSection(resource unified.Resource, now time.Time) Section {
|
||||||
|
observedAt := agentResourceObservedAt(resource)
|
||||||
|
section := newAgentContextSection("identity", "Identity", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||||
|
policy := resource.Policy
|
||||||
|
|
||||||
|
displayName := unified.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, policy)
|
||||||
|
addAgentContextFact(§ion.Facts, "Display name", displayName, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Canonical ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(resource.ID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Resource type", string(resource.Type), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Status", string(resource.Status), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Technology", resource.Technology, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
if !resource.LastSeen.IsZero() {
|
||||||
|
addAgentContextFact(§ion.Facts, "Last seen", resource.LastSeen.UTC().Format(time.RFC3339), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
if len(resource.Sources) > 0 {
|
||||||
|
addAgentContextFact(§ion.Facts, "Sources", joinDataSources(resource.Sources), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
for source, status := range resource.SourceStatus {
|
||||||
|
label := fmt.Sprintf("%s source", source)
|
||||||
|
value := strings.TrimSpace(status.Status)
|
||||||
|
if !status.LastSeen.IsZero() {
|
||||||
|
value = strings.TrimSpace(value + " lastSeen=" + status.LastSeen.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
addAgentContextFact(§ion.Facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unified.ResourcePolicySummaryLines(resource.Policy)) > 0 {
|
||||||
|
section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentRuntimeContextSection(resource unified.Resource, now time.Time) Section {
|
||||||
|
observedAt := agentResourceObservedAt(resource)
|
||||||
|
section := newAgentContextSection("runtime", "Runtime and Discovery", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, now, observedAt)
|
||||||
|
policy := resource.Policy
|
||||||
|
|
||||||
|
if resource.DiscoveryTarget != nil {
|
||||||
|
addAgentContextFact(§ion.Facts, "Discovery type", resource.DiscoveryTarget.ResourceType, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Discovery agent", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.AgentID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Discovery resource", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.ResourceID, policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, "Discovery hostname", unified.ResourcePolicyRedactedValue(resource.DiscoveryTarget.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
addMetricsFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addAgentDataFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addProxmoxFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addDockerFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addKubernetesFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addStorageFacts(§ion.Facts, resource, observedAt)
|
||||||
|
addTrueNASFacts(§ion.Facts, resource, observedAt)
|
||||||
|
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentTopologyContextSection(resource unified.Resource, now time.Time) Section {
|
||||||
|
observedAt := agentResourceObservedAt(resource)
|
||||||
|
section := newAgentContextSection("topology", "Topology", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||||
|
policy := resource.Policy
|
||||||
|
|
||||||
|
if resource.ParentID != nil {
|
||||||
|
addAgentContextFact(§ion.Facts, "Parent ID", unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(*resource.ParentID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
addAgentContextFact(§ion.Facts, "Parent name", unified.ResourcePolicyRedactedValue(resource.ParentName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Child resources", resource.ChildCount, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
|
||||||
|
relationships := unified.ResourceRelationshipsWithCanonicalParent(resource)
|
||||||
|
if len(relationships) > 0 {
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Relationship count", len(relationships), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
for i, rel := range relationships {
|
||||||
|
if i >= 5 {
|
||||||
|
addAgentContextFact(§ion.Facts, "Relationship limit", fmt.Sprintf("%d additional relationships omitted from context pack", len(relationships)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := formatAgentRelationshipFact(rel, policy)
|
||||||
|
relObservedAt := timePtrIfSet(rel.LastSeenAt)
|
||||||
|
if relObservedAt == nil {
|
||||||
|
relObservedAt = timePtrIfSet(rel.ObservedAt)
|
||||||
|
}
|
||||||
|
addAgentContextFact(§ion.Facts, fmt.Sprintf("Relationship %d", i+1), value, agentContextSourceUnifiedResource, agentContextTrustDiscovered, relObservedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentSafetyContextSection(resource unified.Resource, opts BuildOptions, now time.Time) Section {
|
||||||
|
observedAt := agentResourceObservedAt(resource)
|
||||||
|
section := newAgentContextSection("safety", "Safety and Operations", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||||
|
|
||||||
|
if opts.OperatorState != nil {
|
||||||
|
addAgentContextFact(§ion.Facts, "Operator state", formatOperatorStateFact(*opts.OperatorState), agentContextSourceOperatorState, agentContextTrustUserTaught, timePtrIfSet(opts.OperatorState.SetAt))
|
||||||
|
}
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Active findings", opts.ActiveFindingCount, agentContextSourcePatrol, agentContextTrustRuntimeObserved, &now)
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Pending approvals", opts.PendingApprovalCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Recent actions", opts.RecentActionCount, agentContextSourceActionAudit, agentContextTrustRuntimeObserved, &now)
|
||||||
|
if len(resource.Capabilities) > 0 {
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Governed capabilities", len(resource.Capabilities), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
for i, capability := range resource.Capabilities {
|
||||||
|
if i >= 5 {
|
||||||
|
addAgentContextFact(§ion.Facts, "Capability limit", fmt.Sprintf("%d additional capabilities omitted from context pack", len(resource.Capabilities)-i), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
addAgentContextFact(§ion.Facts, fmt.Sprintf("Capability %d", i+1), formatCapabilityFact(capability), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(resource.Incidents) > 0 {
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Incidents", len(resource.Incidents), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
for i, incident := range resource.Incidents {
|
||||||
|
if i >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
incidentObservedAt := timePtrIfSet(incident.StartedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, fmt.Sprintf("Incident %d", i+1), unified.ResourcePolicyRedactedText(incident.Summary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, incidentObservedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentPolicyContextSection(resource unified.Resource, now time.Time) Section {
|
||||||
|
observedAt := agentResourceObservedAt(resource)
|
||||||
|
section := newAgentContextSection("policy", "Data Policy", agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, now, observedAt)
|
||||||
|
for _, line := range unified.ResourcePolicySummaryLines(resource.Policy) {
|
||||||
|
addAgentContextFact(§ion.Facts, "Policy", line, agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
if resource.AISafeSummary != "" && unified.ResourcePolicyUsesAISafeSummary(resource.AISafeSummary, resource.Policy) {
|
||||||
|
addAgentContextFact(§ion.Facts, "AI-safe summary", strings.TrimSpace(resource.AISafeSummary), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
section.Redactions = append(section.Redactions, agentResourcePolicyRedactions(resource.Policy)...)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgentRecentChangesContextSection(resource unified.Resource, store unified.ResourceStore, now time.Time) Section {
|
||||||
|
section := newAgentContextSection("recent_changes", "Recent Changes", agentContextSourceResourceStore, agentContextTrustRuntimeObserved, now, &now)
|
||||||
|
changes, err := store.GetRecentChanges(unified.CanonicalResourceID(resource.ID), now.Add(-7*24*time.Hour), 5)
|
||||||
|
if err != nil || len(changes) == 0 {
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
addAgentContextCountFact(§ion.Facts, "Included changes", len(changes), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, &now)
|
||||||
|
for i, change := range changes {
|
||||||
|
observedAt := timePtrIfSet(change.ObservedAt)
|
||||||
|
addAgentContextFact(§ion.Facts, fmt.Sprintf("Change %d", i+1), formatAgentResourceChangeFact(change, resource), agentContextSourceResourceStore, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMetricsFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Metrics == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addMetricFact(facts, "CPU", resource.Metrics.CPU, observedAt)
|
||||||
|
addMetricFact(facts, "Memory", resource.Metrics.Memory, observedAt)
|
||||||
|
addMetricFact(facts, "Disk", resource.Metrics.Disk, observedAt)
|
||||||
|
addMetricFact(facts, "Network in", resource.Metrics.NetIn, observedAt)
|
||||||
|
addMetricFact(facts, "Network out", resource.Metrics.NetOut, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMetricFact(facts *[]Fact, label string, metric *unified.MetricValue, observedAt *time.Time) {
|
||||||
|
if metric == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value := ""
|
||||||
|
switch {
|
||||||
|
case metric.Percent > 0:
|
||||||
|
value = fmt.Sprintf("%.1f%%", metric.Percent)
|
||||||
|
case metric.Value != 0:
|
||||||
|
value = fmt.Sprintf("%.1f %s", metric.Value, strings.TrimSpace(metric.Unit))
|
||||||
|
case metric.Used != nil && metric.Total != nil && *metric.Total > 0:
|
||||||
|
value = fmt.Sprintf("%d/%d", *metric.Used, *metric.Total)
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, label, value, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAgentDataFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Agent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy := resource.Policy
|
||||||
|
addAgentContextFact(facts, "Agent hostname", unified.ResourcePolicyRedactedValue(resource.Agent.Hostname, policy, unified.ResourceRedactionHostname), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Agent platform", resource.Agent.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Agent OS", strings.TrimSpace(resource.Agent.OSName+" "+resource.Agent.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Agent version", resource.Agent.AgentVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Commands enabled", fmt.Sprintf("%t", resource.Agent.CommandsEnabled), agentContextSourceUnifiedResource, agentContextTrustPulseAuthored, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addProxmoxFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Proxmox == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy := resource.Policy
|
||||||
|
addAgentContextFact(facts, "Proxmox node", unified.ResourcePolicyRedactedValue(resource.Proxmox.NodeName, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
if resource.Proxmox.VMID > 0 {
|
||||||
|
addAgentContextFact(facts, "Proxmox VMID", unified.ResourcePolicyRedactedValue(fmt.Sprintf("%d", resource.Proxmox.VMID), policy, unified.ResourceRedactionPlatformID), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "Container type", resource.Proxmox.ContainerType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "OS", strings.TrimSpace(resource.Proxmox.OSName+" "+resource.Proxmox.OSVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
if resource.Proxmox.HasDocker {
|
||||||
|
addAgentContextFact(facts, "Nested Docker", "detected", agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSetValue(resource.Proxmox.DockerCheckedAt))
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "Proxmox lock", resource.Proxmox.Lock, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDockerFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Docker == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy := resource.Policy
|
||||||
|
addAgentContextFact(facts, "Docker runtime", strings.TrimSpace(resource.Docker.Runtime+" "+resource.Docker.RuntimeVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Docker version", resource.Docker.DockerVersion, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Containers", resource.Docker.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Images", resource.Docker.ImageCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Container state", resource.Docker.ContainerState, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Container health", resource.Docker.Health, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
if resource.Docker.RestartCount > 0 {
|
||||||
|
addAgentContextFact(facts, "Restart count", fmt.Sprintf("%d", resource.Docker.RestartCount), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "Image", unified.ResourcePolicyRedactedValue(resource.Docker.Image, policy, unified.ResourceRedactionHostname, unified.ResourceRedactionAlias), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Ports", formatDockerPorts(resource.Docker.Ports), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Networks", len(resource.Docker.Networks), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Mounts", len(resource.Docker.Mounts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
addAgentContextFact(facts, "Service stack", resource.Docker.Stack, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
if resource.Docker.DesiredTasks > 0 || resource.Docker.RunningTasks > 0 {
|
||||||
|
addAgentContextFact(facts, "Service tasks", fmt.Sprintf("%d running / %d desired", resource.Docker.RunningTasks, resource.Docker.DesiredTasks), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "Endpoint ports", formatDockerServicePorts(resource.Docker.EndpointPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
if resource.Docker.UpdateStatus != nil {
|
||||||
|
addAgentContextFact(facts, "Container image update", fmt.Sprintf("available=%t", resource.Docker.UpdateStatus.UpdateAvailable), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, timePtrIfSet(resource.Docker.UpdateStatus.LastChecked))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addKubernetesFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Kubernetes == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "Namespace", resource.Kubernetes.Namespace, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Kubernetes status", resource.Kubernetes.Phase, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Service type", resource.Kubernetes.ServiceType, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Service ports", formatKubernetesServicePorts(resource.Kubernetes.ServicePorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Pod containers", len(resource.Kubernetes.PodContainers), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addStorageFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.Storage == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy := resource.Policy
|
||||||
|
addAgentContextFact(facts, "Storage type", resource.Storage.Type, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Storage platform", resource.Storage.Platform, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Storage path", unified.ResourcePolicyRedactedValue(resource.Storage.Path, policy, unified.ResourceRedactionPath), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Protection", resource.Storage.Protection, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "Risk summary", unified.ResourcePolicyRedactedText(resource.Storage.RiskSummary, resource), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "Consumers", resource.Storage.ConsumerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTrueNASFacts(facts *[]Fact, resource unified.Resource, observedAt *time.Time) {
|
||||||
|
if resource.TrueNAS == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addAgentContextFact(facts, "TrueNAS version", resource.TrueNAS.Version, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "TrueNAS services", len(resource.TrueNAS.Services), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
if resource.TrueNAS.App != nil {
|
||||||
|
addAgentContextFact(facts, "TrueNAS app state", resource.TrueNAS.App.State, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextFact(facts, "TrueNAS app version", strings.TrimSpace(resource.TrueNAS.App.Version+" "+resource.TrueNAS.App.HumanVersion), agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "TrueNAS app containers", resource.TrueNAS.App.ContainerCount, agentContextSourceUnifiedResource, agentContextTrustRuntimeObserved, observedAt)
|
||||||
|
addAgentContextCountFact(facts, "TrueNAS app ports", len(resource.TrueNAS.App.UsedPorts), agentContextSourceUnifiedResource, agentContextTrustDiscovered, observedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAgentRelationshipFact(rel unified.ResourceRelationship, policy *unified.ResourcePolicy) string {
|
||||||
|
source := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.SourceID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias)
|
||||||
|
target := unified.ResourcePolicyRedactedValue(unified.CanonicalResourceID(rel.TargetID), policy, unified.ResourceRedactionPlatformID, unified.ResourceRedactionAlias)
|
||||||
|
state := "historical"
|
||||||
|
if rel.Active {
|
||||||
|
state = "active"
|
||||||
|
}
|
||||||
|
parts := []string{fmt.Sprintf("%s %s -> %s", rel.Type, source, target), state}
|
||||||
|
if rel.Discoverer != "" {
|
||||||
|
parts = append(parts, "discoverer="+rel.Discoverer)
|
||||||
|
}
|
||||||
|
if rel.Confidence > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("confidence=%.0f%%", rel.Confidence*100))
|
||||||
|
}
|
||||||
|
if len(rel.Metadata) > 0 {
|
||||||
|
parts = append(parts, "metadata present")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOperatorStateFact(state OperatorState) string {
|
||||||
|
parts := []string{}
|
||||||
|
if state.IntentionallyOffline {
|
||||||
|
parts = append(parts, "intentionally offline")
|
||||||
|
}
|
||||||
|
if state.NeverAutoRemediate {
|
||||||
|
parts = append(parts, "never auto-remediate")
|
||||||
|
}
|
||||||
|
if state.MaintenanceWindowActive {
|
||||||
|
parts = append(parts, "maintenance active")
|
||||||
|
} else if state.MaintenanceStartAt != nil || state.MaintenanceEndAt != nil {
|
||||||
|
parts = append(parts, "maintenance scheduled")
|
||||||
|
}
|
||||||
|
if state.Criticality != "" {
|
||||||
|
parts = append(parts, "criticality="+state.Criticality)
|
||||||
|
}
|
||||||
|
if state.NotePresent {
|
||||||
|
parts = append(parts, "note present")
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "operator state recorded"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCapabilityFact(capability unified.ResourceCapability) string {
|
||||||
|
parts := []string{capability.Name}
|
||||||
|
if capability.MinimumApprovalLevel != "" {
|
||||||
|
parts = append(parts, "approval="+string(capability.MinimumApprovalLevel))
|
||||||
|
}
|
||||||
|
if capability.Platform != "" {
|
||||||
|
parts = append(parts, "platform="+capability.Platform)
|
||||||
|
}
|
||||||
|
sensitiveParams := 0
|
||||||
|
for _, param := range capability.Params {
|
||||||
|
if param.IsSensitive {
|
||||||
|
sensitiveParams++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sensitiveParams > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d sensitive params hidden", sensitiveParams))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAgentResourceChangeFact(change unified.ResourceChange, resource unified.Resource) string {
|
||||||
|
parts := []string{string(change.Kind)}
|
||||||
|
if change.From != "" || change.To != "" {
|
||||||
|
transition := strings.TrimSpace(change.From + " -> " + change.To)
|
||||||
|
parts = append(parts, unified.ResourcePolicyRedactedText(transition, resource))
|
||||||
|
}
|
||||||
|
if change.Reason != "" {
|
||||||
|
parts = append(parts, "reason="+unified.ResourcePolicyRedactedText(change.Reason, resource))
|
||||||
|
}
|
||||||
|
if change.SourceType != "" {
|
||||||
|
parts = append(parts, "source="+string(change.SourceType))
|
||||||
|
}
|
||||||
|
if change.SourceAdapter != "" {
|
||||||
|
parts = append(parts, "adapter="+string(change.SourceAdapter))
|
||||||
|
}
|
||||||
|
if change.Confidence != "" {
|
||||||
|
parts = append(parts, "confidence="+string(change.Confidence))
|
||||||
|
}
|
||||||
|
if len(change.RelatedResources) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d related resources", len(change.RelatedResources)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentResourceObservedAt(resource unified.Resource) *time.Time {
|
||||||
|
if !resource.UpdatedAt.IsZero() {
|
||||||
|
return timePtrIfSet(resource.UpdatedAt)
|
||||||
|
}
|
||||||
|
return timePtrIfSet(resource.LastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtrIfSet(t time.Time) *time.Time {
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
utc := t.UTC()
|
||||||
|
return &utc
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtrIfSetValue(t *time.Time) *time.Time {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timePtrIfSet(*t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinDataSources(sources []unified.DataSource) string {
|
||||||
|
parts := make([]string, 0, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
if strings.TrimSpace(string(source)) != "" {
|
||||||
|
parts = append(parts, string(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentResourcePolicyRedactions(policy *unified.ResourcePolicy) []Redaction {
|
||||||
|
labels := unified.ResourcePolicyRedactionLabels(policy)
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Redaction{
|
||||||
|
{
|
||||||
|
Field: "resource identifiers",
|
||||||
|
Reason: "canonical resource policy redacts " + strings.Join(labels, ", "),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDockerPorts(ports []unified.DockerPortMeta) string {
|
||||||
|
if len(ports) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, min(len(ports), 5))
|
||||||
|
for i, port := range ports {
|
||||||
|
if i >= 5 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if port.PublicPort > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublicPort, port.PrivatePort, port.Protocol))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%d/%s", port.PrivatePort, port.Protocol))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDockerServicePorts(ports []unified.DockerServicePortMeta) string {
|
||||||
|
if len(ports) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, min(len(ports), 5))
|
||||||
|
for i, port := range ports {
|
||||||
|
if i >= 5 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if port.PublishedPort > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d:%d/%s", port.PublishedPort, port.TargetPort, port.Protocol))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%d/%s", port.TargetPort, port.Protocol))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatKubernetesServicePorts(ports []unified.K8sServicePort) string {
|
||||||
|
if len(ports) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, min(len(ports), 5))
|
||||||
|
for i, port := range ports {
|
||||||
|
if i >= 5 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d more", len(ports)-i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%d:%s/%s", port.Port, port.TargetPort, port.Protocol))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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/agentexec"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
|
"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()
|
s.mu.RLock()
|
||||||
handoffResourceProvider := s.unifiedResourceProvider
|
handoffResourceProvider := s.unifiedResourceProvider
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
handoffContext = mergeHandoffResourceContextPack(handoffContext, handoffResources, handoffResourceProvider, s.actionAuditStore, time.Now())
|
||||||
handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)
|
handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)
|
||||||
handoffContext = mergeHandoffResourceStateContext(handoffContext, handoffResources, handoffResourceProvider)
|
handoffContext = mergeHandoffResourceStateContext(handoffContext, handoffResources, handoffResourceProvider)
|
||||||
handoffContext = mergeHandoffResourceRelationshipContext(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 {
|
func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
|
||||||
resources := canonicalHandoffResources(handoffResources, provider)
|
resources := canonicalHandoffResources(handoffResources, provider)
|
||||||
if len(resources) == 0 {
|
if len(resources) == 0 {
|
||||||
|
|||||||
@@ -670,6 +670,9 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
|
|||||||
if strings.Contains(stored[0].Content, "Resource State Context") {
|
if strings.Contains(stored[0].Content, "Resource State Context") {
|
||||||
t.Fatalf("stored user message should not include resource state context: %q", stored[0].Content)
|
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")
|
storedFindingID, err := store.GetModelHandoffFindingID("sess-handoff")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetModelHandoffFindingID failed: %v", err)
|
t.Fatalf("GetModelHandoffFindingID failed: %v", err)
|
||||||
@@ -700,6 +703,12 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
|
|||||||
if !strings.Contains(modelUserContent, "[Resource State Context]") {
|
if !strings.Contains(modelUserContent, "[Resource State Context]") {
|
||||||
t.Fatalf("model user content missing resource state context: %q", modelUserContent)
|
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") {
|
if !strings.Contains(modelUserContent, "Resource State Status: warning") {
|
||||||
t.Fatalf("model user content missing current resource status: %q", modelUserContent)
|
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) {
|
func TestService_ExecuteStream_HandoffResourcePolicyContextIsModelOnly(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
store, err := NewSessionStore(tmpDir)
|
store, err := NewSessionStore(tmpDir)
|
||||||
|
|||||||
@@ -2733,10 +2733,74 @@ func canonicalHandoffUnifiedResource(provider UnifiedResourceProvider, resourceI
|
|||||||
return resource, normalizedType, true
|
return resource, normalizedType, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resource, ok := findCanonicalHandoffResourceByAnyType(provider, refs...); ok {
|
||||||
|
return resource, string(unifiedresources.ContractResourceType(resource)), true
|
||||||
|
}
|
||||||
|
|
||||||
return unifiedresources.Resource{}, normalizedType, false
|
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
|
// CanonicalHandoffResourceRegistration resolves a product-originated resource
|
||||||
// handoff into the same governed registration shape used by explicit 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
|
// 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...)
|
candidates = append(candidates, resource.Tags...)
|
||||||
|
|
||||||
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
|
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)
|
vmid := strconv.Itoa(resource.Proxmox.VMID)
|
||||||
switch strings.TrimSpace(kind) {
|
switch strings.TrimSpace(kind) {
|
||||||
case "vm":
|
case "vm":
|
||||||
|
|||||||
@@ -242,6 +242,46 @@ func (p *registryUnifiedQueryProvider) GetByType(t unifiedresources.ResourceType
|
|||||||
return p.ListByType(t)
|
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 {
|
func newTrueNASUnifiedQueryProvider(t *testing.T) *registryUnifiedQueryProvider {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcontext"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||||
)
|
)
|
||||||
@@ -154,6 +155,23 @@ type AgentResourceOperatorState struct {
|
|||||||
MaintenanceWindowActive bool `json:"maintenanceWindowActive"`
|
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
|
// AgentResourceContext is the bundled, agent-consumable situated
|
||||||
// picture of a resource. One read returns:
|
// picture of a resource. One read returns:
|
||||||
// - core identity (canonical id, type, name)
|
// - core identity (canonical id, type, name)
|
||||||
@@ -161,6 +179,9 @@ type AgentResourceOperatorState struct {
|
|||||||
// - active findings (lightweight snapshot of the seven-question schema)
|
// - active findings (lightweight snapshot of the seven-question schema)
|
||||||
// - recent action attempts (including refused dispatches with their
|
// - recent action attempts (including refused dispatches with their
|
||||||
// stable error tokens)
|
// 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
|
// The endpoint trades coverage for shape: an agent gets enough to
|
||||||
// reason about the next move without chaining several calls. Deeper
|
// reason about the next move without chaining several calls. Deeper
|
||||||
@@ -175,6 +196,7 @@ type AgentResourceContext struct {
|
|||||||
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
|
ActiveFindings []AgentResourceFindingSnapshot `json:"activeFindings"`
|
||||||
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
|
PendingApprovals []AgentResourceApprovalSummary `json:"pendingApprovals"`
|
||||||
RecentActions []AgentResourceActionSummary `json:"recentActions"`
|
RecentActions []AgentResourceActionSummary `json:"recentActions"`
|
||||||
|
ContextSections []AgentResourceContextSection `json:"contextSections"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
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)
|
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resource, ok := registry.Get(resourceID)
|
resource, resourceID, ok := registry.GetByReference(resourceID)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeJSONError(w, http.StatusNotFound, "resource_not_found",
|
writeJSONError(w, http.StatusNotFound, "resource_not_found",
|
||||||
"No resource is registered with this canonical id.")
|
"No resource is registered with this canonical id.")
|
||||||
@@ -308,6 +330,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
|||||||
ActiveFindings: []AgentResourceFindingSnapshot{},
|
ActiveFindings: []AgentResourceFindingSnapshot{},
|
||||||
PendingApprovals: []AgentResourceApprovalSummary{},
|
PendingApprovals: []AgentResourceApprovalSummary{},
|
||||||
RecentActions: []AgentResourceActionSummary{},
|
RecentActions: []AgentResourceActionSummary{},
|
||||||
|
ContextSections: []AgentResourceContextSection{},
|
||||||
GeneratedAt: time.Now().UTC(),
|
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)
|
redactAgentResourceContextCommandsForRequest(&bundle, r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(bundle)
|
_ = 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
|
// extractAgentResourceContextID parses the canonical resource ID out of
|
||||||
// the URL path. Mirrors the trim/canonicalize pattern used elsewhere
|
// the URL path. Mirrors the trim/canonicalize pattern used elsewhere
|
||||||
// for nested resource routes.
|
// for nested resource routes.
|
||||||
|
|||||||
@@ -82,19 +82,13 @@ func (p *countingApprovalsProvider) PendingApprovalCountsByResource(orgID string
|
|||||||
// the agent context handler with a findings-provider stub. The seed
|
// the agent context handler with a findings-provider stub. The seed
|
||||||
// provider bypasses the snapshot → unified adapter, letting us stage
|
// provider bypasses the snapshot → unified adapter, letting us stage
|
||||||
// exactly the resource the test expects to see in the bundle.
|
// 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()
|
t.Helper()
|
||||||
cfg := &config.Config{DataPath: t.TempDir()}
|
cfg := &config.Config{DataPath: t.TempDir()}
|
||||||
resources := NewResourceHandlers(cfg)
|
resources := NewResourceHandlers(cfg)
|
||||||
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
||||||
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
||||||
resources: []unified.Resource{
|
resources: []unified.Resource{resource},
|
||||||
{
|
|
||||||
ID: resourceID,
|
|
||||||
Type: "vm",
|
|
||||||
Name: "db-01",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
provider := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}}
|
provider := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}}
|
||||||
h := NewAgentContextHandler(resources)
|
h := NewAgentContextHandler(resources)
|
||||||
@@ -102,6 +96,33 @@ func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContext
|
|||||||
return h, provider
|
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) {
|
func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *testing.T) {
|
||||||
h, findings := agentContextFixtureHandlers(t, "vm:101")
|
h, findings := agentContextFixtureHandlers(t, "vm:101")
|
||||||
|
|
||||||
@@ -200,6 +221,258 @@ func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *t
|
|||||||
if bundle.GeneratedAt.IsZero() {
|
if bundle.GeneratedAt.IsZero() {
|
||||||
t.Error("GeneratedAt must be populated")
|
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) {
|
func TestHandleAgentResourceContext_OperatorStateAbsentWhenNoEntry(t *testing.T) {
|
||||||
|
|||||||
@@ -14434,12 +14434,22 @@ func TestContract_AgentResourceContextEndpointSurfacesStableShape(t *testing.T)
|
|||||||
if !strings.Contains(src, "PendingApprovals: []AgentResourceApprovalSummary{}") {
|
if !strings.Contains(src, "PendingApprovals: []AgentResourceApprovalSummary{}") {
|
||||||
t.Error("pendingApprovals must default to an empty slice, not nil — agents iterate without nil-checks")
|
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
|
// Pending approvals must reuse the AgentResourceApprovalSummary
|
||||||
// shape so the bundle and approval.pending SSE events stay in
|
// shape so the bundle and approval.pending SSE events stay in
|
||||||
// the same vocabulary.
|
// the same vocabulary.
|
||||||
if !strings.Contains(src, "PendingApprovals []AgentResourceApprovalSummary `json:\"pendingApprovals\"`") {
|
if !strings.Contains(src, "PendingApprovals []AgentResourceApprovalSummary `json:\"pendingApprovals\"`") {
|
||||||
t.Error("AgentResourceContext must carry PendingApprovals as a stable []AgentResourceApprovalSummary field")
|
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
|
// TestContract_AgentResourceContextWiresApprovalsProvider pins the
|
||||||
|
|||||||
@@ -1058,6 +1058,98 @@ func (rr *ResourceRegistry) Get(id string) (*Resource, bool) {
|
|||||||
return &clone, true
|
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.
|
// SourceTargets returns the source-specific IDs that map to the provided resource ID.
|
||||||
func (rr *ResourceRegistry) SourceTargets(resourceID string) []SourceTarget {
|
func (rr *ResourceRegistry) SourceTargets(resourceID string) []SourceTarget {
|
||||||
rr.mu.RLock()
|
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) {
|
func TestResourceRegistry_ListByType(t *testing.T) {
|
||||||
rr := NewRegistry(nil)
|
rr := NewRegistry(nil)
|
||||||
|
|
||||||
|
|||||||
@@ -2860,8 +2860,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"heading": "## Shared Boundaries",
|
"heading": "## Shared Boundaries",
|
||||||
"path": "internal/api/access_control_handlers.go",
|
"path": "internal/api/access_control_handlers.go",
|
||||||
"line": 364,
|
"line": 365,
|
||||||
"heading_line": 113,
|
"heading_line": 114,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user