Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/components/agents/config/editor/review.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Pure helpers for the review step (LLD U2.5).

import type {
ChangeSafety,
ConfigChange,
ConfigErrorBody,
ConfigPreview,
InstancePreview,
} from '@/types/agent-config';
import { isPrefix } from '@/utils/agent-config/json-pointer';

export type SafetyTagKind = ChangeSafety | 'no-effect';

const RANK: Record<ChangeSafety, number> = { safe: 0, unsafe: 1, forbidden: 2 };

/**
* The safety of a diff row: the most specific change whose path equals the row pointer or is
* its prefix; failing that (e.g. a whole plugin added), the worst change under the row; no
* change at all means the row matches the instance's file again ("no effect vs file").
*/
export function safetyForRow(
path: string,
changes: ConfigChange[],
): SafetyTagKind {
let best: ConfigChange | null = null;
for (const c of changes) {
if (isPrefix(c.path, path) && (!best || c.path.length > best.path.length))
best = c;
}
if (best) {
// Several changes can share the most specific path (e.g. two new policy sources).
const same = changes.filter((c) => c.path === best!.path);
return same.reduce<ChangeSafety>(
(w, c) => (RANK[c.safety] > RANK[w] ? c.safety : w),
'safe',
);
}
const under = changes.filter((c) => isPrefix(path, c.path));
if (!under.length) return 'no-effect';
return under.reduce<ChangeSafety>(
(w, c) => (RANK[c.safety] > RANK[w] ? c.safety : w),
'safe',
);
}

/** Whether an instance's errors block a save (R48). Older APIs lack `validated` (§C). */
export function instanceErrorsBlock(inst: InstancePreview): boolean {
if (!inst.errors.length) return false;
if (inst.validated === undefined) return !inst.stale;
return inst.validated;
}

export function previewBlocks(preview: ConfigPreview | null): boolean {
if (!preview) return false;
return (
preview.overlayErrors.length > 0 ||
preview.instances.some(instanceErrorsBlock)
);
}

export function saveErrorsBlock(
body: ConfigErrorBody | null | undefined,
): boolean {
if (!body) return false;
return (
(body.overlay?.length ?? 0) > 0 ||
(body.instances ?? []).some((i) => i.errors?.length)
);
}

export function truncate(
value: unknown,
max = 120,
): { text: string; truncated: boolean } {
const text =
value === undefined
? ''
: typeof value === 'string'
? value
: JSON.stringify(value);
return text.length > max
? { text: `${text.slice(0, max)}…`, truncated: true }
: { text, truncated: false };
}
117 changes: 117 additions & 0 deletions src/composables/__tests__/axios.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { InternalAxiosRequestConfig } from 'axios';
import { useAuthenticatedInstance } from '@/composables/axios';

vi.mock('@/stores/config.ts', () => ({
Expand Down Expand Up @@ -67,3 +68,119 @@ describe('axios response conversion', () => {
});
});
});

describe('jsonBody', () => {
it('serialises without key transformation and sets the JSON content type', async () => {
const { jsonBody } = await import('@/composables/axios');
const { AxiosHeaders } = await import('axios');
const headers = new AxiosHeaders();
const data = { overlay: { policy_data: { a_b: 1 }, 'kebab-key': 2 } };
expect(jsonBody(data, headers)).toBe(JSON.stringify(data));
expect(headers.get('Content-Type')).toBe('application/json');
});
});

describe('agent config stop paths', () => {
const adapterFor =
(payload: unknown) => async (config: InternalAxiosRequestConfig) => ({
data: payload,
status: 200,
statusText: 'OK',
headers: {},
config,
});

it('keeps snake_case overlay keys verbatim (config)', async () => {
const { STOP_PATHS } = await import(
'@/composables/agent-config/useAgentConfigApi'
);
const instance = useAuthenticatedInstance();
const response = await instance.get('/x', {
camelcaseStopPaths: STOP_PATHS.config,
adapter: adapterFor({
data: {
'agent-id': 'a',
overlay: {
plugins: { 'local-ssh': { policy_data: { max_auth_tries: 3 } } },
},
},
}),
});
expect(response.data.data).toEqual({
agentId: 'a',
overlay: {
plugins: { 'local-ssh': { policy_data: { max_auth_tries: 3 } } },
},
});
});

it('keeps base/effective/remote-config verbatim inside arrays (instances list)', async () => {
const { STOP_PATHS } = await import(
'@/composables/agent-config/useAgentConfigApi'
);
const instance = useAuthenticatedInstance();
const response = await instance.get('/x', {
camelcaseStopPaths: STOP_PATHS.instances,
adapter: adapterFor({
data: [
{
'instance-id': 'i',
'remote-config': {
trusted_sources: ['a'],
overridable_config_flags: [],
},
base: { agent_evidence: { emit_on_run_completion: true } },
'report-stale': false,
},
],
meta: { 'desired-revision': 7, counts: { 'in-sync': 1 } },
}),
});
expect(response.data).toEqual({
data: [
{
instanceId: 'i',
remoteConfig: {
trusted_sources: ['a'],
overridable_config_flags: [],
},
base: { agent_evidence: { emit_on_run_completion: true } },
reportStale: false,
},
],
meta: { desiredRevision: 7, counts: { inSync: 1 } },
});
});

it('keeps preview effective and diff values verbatim', async () => {
const { STOP_PATHS } = await import(
'@/composables/agent-config/useAgentConfigApi'
);
const instance = useAuthenticatedInstance();
const response = await instance.get('/x', {
camelcaseStopPaths: STOP_PATHS.preview,
adapter: adapterFor({
data: {
instances: [
{
'instance-id': 'i',
effective: { agent_evidence: { emit_on_run_completion: true } },
'diff-vs-current': [
{ path: '/p', op: 'replace', from: { a_b: 1 }, to: { c_d: 2 } },
],
'will-apply-reason': 'mode-report',
},
],
},
}),
});
expect(response.data.data.instances[0]).toEqual({
instanceId: 'i',
effective: { agent_evidence: { emit_on_run_completion: true } },
diffVsCurrent: [
{ path: '/p', op: 'replace', from: { a_b: 1 }, to: { c_d: 2 } },
],
willApplyReason: 'mode-report',
});
});
});
98 changes: 98 additions & 0 deletions src/composables/agent-config/__tests__/instancePages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentConfigApi, InstancesPageQuery } from '../api-types';
import type { AgentInstanceSummary } from '@/types/agent-config';
import {
INSTANCE_PAGE_LIMIT,
MAX_INSTANCE_PAGES,
listAllInstances,
} from '../instancePages';
import { instancesMixed } from './fixtures';

/** A fleet of `n` instances served `limit` per page, as the API does. */
function fleet(n: number) {
const rows: AgentInstanceSummary[] = Array.from({ length: n }, (_, i) => ({
...instancesMixed.items[0],
instanceId: `i${i + 1}`,
hostname: `ip-${i + 1}`,
}));
const listInstances = vi.fn(
async (_agentId: string, q: InstancesPageQuery = {}) => {
const page = q.page ?? 1;
const limit = q.limit ?? INSTANCE_PAGE_LIMIT;
return {
items: rows.slice((page - 1) * limit, page * limit),
meta: {
desiredRevision: 7,
counts: { ...instancesMixed.meta.counts, total: n },
page,
limit,
total: n,
totalPages: Math.max(1, Math.ceil(n / limit)),
},
};
},
);
return { api: { listInstances } as unknown as AgentConfigApi, listInstances };
}

describe('listAllInstances', () => {
it('one page: exactly one request', async () => {
const { api, listInstances } = fleet(1);
const all = await listAllInstances(api, 'a1');
expect(listInstances).toHaveBeenCalledTimes(1);
expect(listInstances).toHaveBeenCalledWith('a1', { page: 1, limit: 25 });
expect(all.items.map((i) => i.instanceId)).toEqual(['i1']);
expect(all.partial).toBe(false);
});

it('reads the pages in order until the last one', async () => {
const { api, listInstances } = fleet(60);
const all = await listAllInstances(api, 'a1');
expect(listInstances.mock.calls.map((c) => c[1])).toEqual([
{ page: 1, limit: 25 },
{ page: 2, limit: 25 },
{ page: 3, limit: 25 },
]);
expect(all.items).toHaveLength(60);
expect(all.meta.totalPages).toBe(3);
expect(all.partial).toBe(false);
});

it(`stops at the cap (${MAX_INSTANCE_PAGES} pages) and says the result is partial`, async () => {
const { api, listInstances } = fleet(130);
const all = await listAllInstances(api, 'a1');
expect(listInstances).toHaveBeenCalledTimes(MAX_INSTANCE_PAGES);
expect(all.items).toHaveLength(MAX_INSTANCE_PAGES * INSTANCE_PAGE_LIMIT);
expect(all.meta.counts.total).toBe(130);
expect(all.partial).toBe(true);
});

it('keeps an instance once when pages shift, and then counts the list as partial', async () => {
const { api, listInstances } = fleet(30);
const page1 = await listInstances('a1', { page: 1 });
listInstances.mockClear();
// Page 2 repeats the last row of page 1 (an instance reported meanwhile): i26 is missed.
listInstances.mockResolvedValueOnce(page1).mockResolvedValueOnce({
...page1,
items: [page1.items[24]],
meta: { ...page1.meta, page: 2 },
});
const all = await listAllInstances(api, 'a1');
expect(all.items).toHaveLength(25);
expect(all.partial).toBe(true);
});

it('an unpaginated API (no totalPages) is one page', async () => {
const listInstances = vi.fn().mockResolvedValue({
items: instancesMixed.items,
meta: { desiredRevision: 7, counts: instancesMixed.meta.counts },
});
const all = await listAllInstances(
{ listInstances } as unknown as AgentConfigApi,
'a1',
);
expect(listInstances).toHaveBeenCalledTimes(1);
expect(all.items).toHaveLength(7);
expect(all.partial).toBe(false);
});
});
Loading
Loading