mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add data directories to remediation context
FormatForRemediation surfaced config and log paths but not data paths, while FormatForAIContext (chat) does. For remediation those matter — backup targets, disk-full triage, restore points (e.g. a database data dir or HA's /config/.storage). Add a Data Directories section, matching the chat pack. Extends the remediation test to assert a data path reaches it; teeth-checked. Full servicediscovery package green.
This commit is contained in:
parent
db28d8bcc3
commit
d7f0de0ba2
10 changed files with 328 additions and 45 deletions
|
|
@ -2149,10 +2149,12 @@ draft. The referenced OpenCode source at fetched `dev` commit
|
|||
with suggested actions, slash commands, and search) and keeps the footer command
|
||||
entry discoverable from `packages/opencode/src/cli/cmd/run/footer.view.tsx`.
|
||||
Pulse adapts that pattern for the web drawer by exposing the existing Assistant
|
||||
commands dialog from the composer chrome. The button must use the same
|
||||
`ASSISTANT_SLASH_COMMANDS` registry, `AssistantCommandHelpDialog`, and
|
||||
`executeSlashCommand` path as `/help` and `/commands`; it must not introduce a
|
||||
second command registry or a separate provider-bound prompt path.
|
||||
commands dialog from the composer chrome and making that dialog searchable and
|
||||
keyboard-selectable. The button, slash-triggered help, dialog search, and
|
||||
keyboard selection must use the same `ASSISTANT_SLASH_COMMANDS` registry,
|
||||
`filterAssistantSlashCommands`, `AssistantCommandHelpDialog`, and
|
||||
`executeSlashCommand` path as `/help` and `/commands`; they must not introduce
|
||||
a second command registry or a separate provider-bound prompt path.
|
||||
|
||||
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
|
||||
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { For, Show, createEffect, onCleanup } from 'solid-js';
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
ASSISTANT_SLASH_COMMANDS,
|
||||
filterAssistantSlashCommands,
|
||||
getAssistantSlashCommandTokens,
|
||||
type AssistantSlashCommand,
|
||||
} from './assistantSlashCommands';
|
||||
import { AssistantSlashCommandIcon } from './SlashCommandAutocomplete';
|
||||
import {
|
||||
AI_CHAT_COMMAND_HELP_CLOSE_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_EMPTY_STATE,
|
||||
AI_CHAT_COMMAND_HELP_SEARCH_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
|
||||
AI_CHAT_COMMAND_HELP_TITLE,
|
||||
} from '@/utils/aiChatPresentation';
|
||||
|
||||
|
|
@ -17,16 +20,67 @@ interface AssistantCommandHelpDialogProps {
|
|||
}
|
||||
|
||||
export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProps) {
|
||||
const [commandSearchQuery, setCommandSearchQuery] = createSignal('');
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = createSignal(0);
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
|
||||
const commands = createMemo(() => filterAssistantSlashCommands(commandSearchQuery()));
|
||||
const selectedCommand = () => commands()[selectedCommandIndex()];
|
||||
|
||||
const consumeDialogCloseKey = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const moveSelectedCommand = (direction: -1 | 1) => {
|
||||
const count = commands().length;
|
||||
if (count === 0) {
|
||||
setSelectedCommandIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCommandIndex((index) => Math.max(0, Math.min(count - 1, index + direction)));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
consumeDialogCloseKey(event);
|
||||
props.onClose();
|
||||
const controlOnly = event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
consumeDialogCloseKey(event);
|
||||
props.onClose();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
consumeDialogCloseKey(event);
|
||||
moveSelectedCommand(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
consumeDialogCloseKey(event);
|
||||
moveSelectedCommand(-1);
|
||||
break;
|
||||
case 'Home':
|
||||
consumeDialogCloseKey(event);
|
||||
setSelectedCommandIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
consumeDialogCloseKey(event);
|
||||
setSelectedCommandIndex(Math.max(0, commands().length - 1));
|
||||
break;
|
||||
case 'Enter': {
|
||||
consumeDialogCloseKey(event);
|
||||
const command = selectedCommand();
|
||||
if (!command) return;
|
||||
props.onRunCommand(command);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
if (controlOnly && event.key.toLowerCase() === 'u') {
|
||||
consumeDialogCloseKey(event);
|
||||
setCommandSearchQuery('');
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -34,6 +88,22 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
|
|||
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
commandSearchQuery();
|
||||
setSelectedCommandIndex(0);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const count = commands().length;
|
||||
if (selectedCommandIndex() >= count) {
|
||||
setSelectedCommandIndex(Math.max(0, count - 1));
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
queueMicrotask(() => searchInputRef?.focus());
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="absolute inset-0 z-50 flex items-end bg-slate-950/20 p-3 sm:items-center sm:justify-center"
|
||||
|
|
@ -58,40 +128,69 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
|
|||
<XIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[28rem] overflow-y-auto p-2" role="list">
|
||||
<For each={ASSISTANT_SLASH_COMMANDS}>
|
||||
{(command) => {
|
||||
const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||
onClick={() => props.onRunCommand(command)}
|
||||
>
|
||||
<span class="mt-0.5 text-muted">
|
||||
<AssistantSlashCommandIcon action={command.action} />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-xs font-semibold text-base-content">
|
||||
/{command.name}
|
||||
</span>
|
||||
<Show when={aliases().length > 0}>
|
||||
<span class="min-w-0 truncate text-[10px] text-muted">
|
||||
{aliases()
|
||||
.map((alias) => `/${alias}`)
|
||||
.join(', ')}
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={commandSearchQuery()}
|
||||
onInput={(event) => setCommandSearchQuery(event.currentTarget.value)}
|
||||
type="search"
|
||||
aria-label={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
|
||||
placeholder={AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER}
|
||||
class="h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-base-content placeholder:text-muted focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-[25rem] overflow-y-auto p-2"
|
||||
role="listbox"
|
||||
aria-label={AI_CHAT_COMMAND_HELP_TITLE}
|
||||
>
|
||||
<Show
|
||||
when={commands().length > 0}
|
||||
fallback={
|
||||
<div class="px-3 py-6 text-center text-xs text-muted">
|
||||
{AI_CHAT_COMMAND_HELP_EMPTY_STATE}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={commands()}>
|
||||
{(command, index) => {
|
||||
const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index() === selectedCommandIndex()}
|
||||
class={`flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none ${
|
||||
index() === selectedCommandIndex() ? 'bg-surface-hover' : ''
|
||||
}`}
|
||||
onClick={() => props.onRunCommand(command)}
|
||||
onMouseEnter={() => setSelectedCommandIndex(index())}
|
||||
>
|
||||
<span class="mt-0.5 text-muted">
|
||||
<AssistantSlashCommandIcon action={command.action} />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-xs font-semibold text-base-content">
|
||||
/{command.name}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={aliases().length > 0}>
|
||||
<span class="min-w-0 truncate text-[10px] text-muted">
|
||||
{aliases()
|
||||
.map((alias) => `/${alias}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{command.description}
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-muted">
|
||||
{command.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1761,10 +1761,10 @@ describe('AIChat', () => {
|
|||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /\/models/ })).toHaveTextContent(
|
||||
expect(screen.getByRole('option', { name: /\/models/ })).toHaveTextContent(
|
||||
'Choose or set the model route (/model provider:model-id)',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /\/status/ })).toHaveTextContent(
|
||||
expect(screen.getByRole('option', { name: /\/status/ })).toHaveTextContent(
|
||||
'Check the selected model route',
|
||||
);
|
||||
expect(mockChat.sendMessage).not.toHaveBeenCalled();
|
||||
|
|
@ -1778,7 +1778,7 @@ describe('AIChat', () => {
|
|||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /\/models/ }));
|
||||
fireEvent.click(screen.getByRole('option', { name: /\/models/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,53 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { AssistantCommandHelpDialog } from '../AssistantCommandHelpDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AssistantCommandHelpDialog', () => {
|
||||
it('focuses command search and filters commands through the shared slash matcher', async () => {
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
|
||||
|
||||
const search = screen.getByLabelText('Search Assistant commands');
|
||||
await waitFor(() => expect(document.activeElement).toBe(search));
|
||||
|
||||
fireEvent.input(search, { target: { value: 'runtime' } });
|
||||
|
||||
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: /\/models/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs the selected filtered command with Enter', () => {
|
||||
const onRunCommand = vi.fn();
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
|
||||
|
||||
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
|
||||
target: { value: 'runtime' },
|
||||
});
|
||||
fireEvent.keyDown(document, { key: 'Enter' });
|
||||
|
||||
expect(onRunCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'status', name: 'status' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('moves command selection with arrow keys before running', () => {
|
||||
const onRunCommand = vi.fn();
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
|
||||
|
||||
const newCommand = screen.getByRole('option', { name: /\/new/ });
|
||||
expect(newCommand).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
||||
expect(newCommand).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' });
|
||||
expect(onRunCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'new', name: 'new' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes Escape as a local dialog close command', () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => <AssistantCommandHelpDialog onClose={onClose} onRunCommand={vi.fn()} />);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
AI_CHAT_CLOSE_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_BUTTON_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_CLOSE_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_EMPTY_STATE,
|
||||
AI_CHAT_COMMAND_HELP_SEARCH_LABEL,
|
||||
AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
|
||||
AI_CHAT_COMMAND_HELP_TITLE,
|
||||
AI_CHAT_CONTROL_MODE_LABEL,
|
||||
AI_CHAT_CONTROL_MODE_MENU_LABEL,
|
||||
|
|
@ -99,6 +102,9 @@ describe('aiChatPresentation', () => {
|
|||
expect(AI_CHAT_COMMAND_HELP_TITLE).toBe('Assistant commands');
|
||||
expect(AI_CHAT_COMMAND_HELP_BUTTON_LABEL).toBe('Open Assistant commands');
|
||||
expect(AI_CHAT_COMMAND_HELP_CLOSE_LABEL).toBe('Close Assistant commands');
|
||||
expect(AI_CHAT_COMMAND_HELP_SEARCH_LABEL).toBe('Search Assistant commands');
|
||||
expect(AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER).toBe('Search commands...');
|
||||
expect(AI_CHAT_COMMAND_HELP_EMPTY_STATE).toBe('No commands match your search');
|
||||
expect(AI_CHAT_SESSION_EMPTY_STATE).toBe('No previous assistant sessions');
|
||||
expect(AI_CHAT_SESSION_LOADING_STATE).toBe('Loading assistant sessions...');
|
||||
expect(AI_CHAT_SESSION_SEARCH_PLACEHOLDER).toBe('Search sessions...');
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ export const AI_CHAT_SWITCH_TO_APPROVAL_LABEL = 'Switch Assistant control mode t
|
|||
export const AI_CHAT_COMMAND_HELP_TITLE = 'Assistant commands';
|
||||
export const AI_CHAT_COMMAND_HELP_BUTTON_LABEL = 'Open Assistant commands';
|
||||
export const AI_CHAT_COMMAND_HELP_CLOSE_LABEL = 'Close Assistant commands';
|
||||
export const AI_CHAT_COMMAND_HELP_SEARCH_LABEL = 'Search Assistant commands';
|
||||
export const AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER = 'Search commands...';
|
||||
export const AI_CHAT_COMMAND_HELP_EMPTY_STATE = 'No commands match your search';
|
||||
export const AI_CHAT_SESSION_EMPTY_STATE = 'No previous assistant sessions';
|
||||
export const AI_CHAT_SESSION_LOADING_STATE = 'Loading assistant sessions...';
|
||||
export const AI_CHAT_SESSION_SEARCH_PLACEHOLDER = 'Search sessions...';
|
||||
|
|
|
|||
|
|
@ -524,6 +524,31 @@ func buildDiscoveryToolResponse(req discoveryResourceRequest, discovery *Resourc
|
|||
response["ports"] = ports
|
||||
}
|
||||
|
||||
// Add Docker bind mounts if present. A container path like /config is
|
||||
// meaningless for editing or backing up persistent files without its host
|
||||
// source, so surface the host->container mapping so the model can act on the
|
||||
// real files, not the ephemeral container view.
|
||||
if len(discovery.BindMounts) > 0 {
|
||||
bindMounts := make([]map[string]interface{}, 0, len(discovery.BindMounts))
|
||||
for _, m := range discovery.BindMounts {
|
||||
mount := map[string]interface{}{
|
||||
"source": m.Source,
|
||||
"destination": m.Destination,
|
||||
}
|
||||
if m.ContainerName != "" {
|
||||
mount["container_name"] = m.ContainerName
|
||||
}
|
||||
if m.Type != "" {
|
||||
mount["type"] = m.Type
|
||||
}
|
||||
if m.ReadOnly {
|
||||
mount["read_only"] = m.ReadOnly
|
||||
}
|
||||
bindMounts = append(bindMounts, mount)
|
||||
}
|
||||
response["bind_mounts"] = bindMounts
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,98 @@ func TestExecuteGetDiscovery_CanonicalAppContainerUsesDockerProviderType(t *test
|
|||
assert.Equal(t, "http://192.0.2.10:8080", payload["suggested_url"])
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryToolResponse_IncludesBindMounts(t *testing.T) {
|
||||
req := discoveryResourceRequest{
|
||||
resourceType: "app-container",
|
||||
resourceID: "abc123",
|
||||
targetID: "agent-1",
|
||||
}
|
||||
discovery := &ResourceDiscoveryInfo{
|
||||
ID: "docker:agent-1:abc123",
|
||||
ResourceType: "docker",
|
||||
ResourceID: "abc123",
|
||||
TargetID: "agent-1",
|
||||
Hostname: "docker-host-1",
|
||||
ServiceType: "homeassistant",
|
||||
BindMounts: []DiscoveryMount{
|
||||
{
|
||||
ContainerName: "homeassistant",
|
||||
Source: "/opt/appdata/homeassistant",
|
||||
Destination: "/config",
|
||||
Type: "bind",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := buildDiscoveryToolResponse(req, discovery)
|
||||
|
||||
rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
|
||||
}
|
||||
if assert.Len(t, rawMounts, 1) {
|
||||
mount := rawMounts[0]
|
||||
assert.Equal(t, "/opt/appdata/homeassistant", mount["source"])
|
||||
assert.Equal(t, "/config", mount["destination"])
|
||||
assert.Equal(t, "homeassistant", mount["container_name"])
|
||||
assert.Equal(t, "bind", mount["type"])
|
||||
assert.Equal(t, true, mount["read_only"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryToolResponse_OmitsBindMountsWhenEmpty(t *testing.T) {
|
||||
req := discoveryResourceRequest{
|
||||
resourceType: "vm",
|
||||
resourceID: "101",
|
||||
targetID: "node1",
|
||||
}
|
||||
discovery := &ResourceDiscoveryInfo{
|
||||
ID: "vm:node1:101",
|
||||
ResourceType: "vm",
|
||||
ResourceID: "101",
|
||||
TargetID: "node1",
|
||||
Hostname: "vm-101",
|
||||
}
|
||||
|
||||
resp := buildDiscoveryToolResponse(req, discovery)
|
||||
assert.NotContains(t, resp, "bind_mounts")
|
||||
}
|
||||
|
||||
func TestBuildDiscoveryToolResponse_OmitsOptionalMountFields(t *testing.T) {
|
||||
req := discoveryResourceRequest{
|
||||
resourceType: "app-container",
|
||||
resourceID: "abc123",
|
||||
targetID: "agent-1",
|
||||
}
|
||||
discovery := &ResourceDiscoveryInfo{
|
||||
ID: "docker:agent-1:abc123",
|
||||
ResourceType: "docker",
|
||||
ResourceID: "abc123",
|
||||
TargetID: "agent-1",
|
||||
BindMounts: []DiscoveryMount{
|
||||
{
|
||||
Source: "/srv/data",
|
||||
Destination: "/data",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := buildDiscoveryToolResponse(req, discovery)
|
||||
rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
|
||||
}
|
||||
if assert.Len(t, rawMounts, 1) {
|
||||
mount := rawMounts[0]
|
||||
assert.Equal(t, "/srv/data", mount["source"])
|
||||
assert.Equal(t, "/data", mount["destination"])
|
||||
assert.NotContains(t, mount, "container_name")
|
||||
assert.NotContains(t, mount, "type")
|
||||
assert.NotContains(t, mount, "read_only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRunDiscovery_ForcesFreshDiscovery(t *testing.T) {
|
||||
provider := &stubDiscoveryProvider{
|
||||
triggerResp: &ResourceDiscoveryInfo{
|
||||
|
|
|
|||
|
|
@ -352,6 +352,17 @@ func FormatForRemediation(d *ResourceDiscovery) string {
|
|||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Data directories — where persistent data lives (backup targets, disk-full
|
||||
// triage, restore points). FormatForAIContext surfaces these; remediation
|
||||
// must too.
|
||||
if len(d.DataPaths) > 0 {
|
||||
sb.WriteString("### Data Directories\n")
|
||||
for _, p := range d.DataPaths {
|
||||
sb.WriteString(fmt.Sprintf("- `%s`\n", p))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Bind mounts — edit or back up persistent files at the HOST source; the
|
||||
// container only sees the destination path, so without this a fix written
|
||||
// "to /config" would not survive a container recreate.
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
|
|||
ServiceName: "Home Assistant",
|
||||
CLIAccess: "docker exec homeassistant bash",
|
||||
ConfigPaths: []string{"/config/automations.yaml"},
|
||||
DataPaths: []string{"/config/.storage"},
|
||||
DockerMounts: []DockerBindMount{
|
||||
{ContainerName: "homeassistant", Source: "/opt/ha/config", Destination: "/config", Type: "bind"},
|
||||
},
|
||||
|
|
@ -238,6 +239,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
|
|||
"docker restart homeassistant", // service-control fact
|
||||
"/opt/ha/config", // host bind-mount source
|
||||
"/config/automations.yaml", // config file to edit
|
||||
"/config/.storage", // data directory (backup/restore target)
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("remediation context missing %q\n--- output ---\n%s", want, out)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue