diff --git a/src/components/agents/config/__tests__/helpers.ts b/src/components/agents/config/__tests__/helpers.ts new file mode 100644 index 00000000..b17c761c --- /dev/null +++ b/src/components/agents/config/__tests__/helpers.ts @@ -0,0 +1,175 @@ +import PrimeVue from 'primevue/config'; + +// PrimeVue overlays (Select, Dialog) query matchMedia, which jsdom lacks. +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} +import ToastService from 'primevue/toastservice'; +import { RouterLinkStub } from '@vue/test-utils'; +import ConfirmationService from 'primevue/confirmationservice'; +import { createPinia, setActivePinia, type Pinia } from 'pinia'; +import { usePermissionsStore } from '@/stores/permissions'; + +/** A pinia whose permission store is hydrated with `permissions`. */ +export function piniaWith(permissions: Record): Pinia { + const pinia = createPinia(); + setActivePinia(pinia); + const store = usePermissionsStore(); + store.permissions = permissions; + store.loaded = true; + return pinia; +} + +export function globalWith( + pinia: Pinia, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extraStubs: Record = {}, +) { + return { + plugins: [pinia, PrimeVue, ToastService, ConfirmationService], + directives: { + tooltip: { mounted: () => undefined, updated: () => undefined }, + }, + stubs: { + // CodeMirror: specs that render editors mock '@/components/code-editor' with + // ./codeEditorMock (sync stand-ins); only CodeEditor.spec mounts the real editor. + RouterLink: RouterLinkStub, + ...extraStubs, + }, + }; +} + +export const ADMIN = { + admin: ['manage'], + agent: ['read', 'configure'], +}; +export const READER = { agent: ['read'] }; + +// ---- Workspace harness: a real useAgentConfig + useConfigWorkspace over a fake API ---- +import { computed, defineComponent, h, watch, type Component } from 'vue'; +import { vi } from 'vitest'; +import type { + AgentConfigApi, + InstancesPageQuery, +} from '@/composables/agent-config/api-types'; +import type { + AgentInstanceSummary, + InstanceCounts, +} from '@/types/agent-config'; +import { useAgentConfig } from '@/composables/agent-config/useAgentConfig'; +import { + useConfigWorkspace, + type ConfigWorkspace, +} from '@/composables/agent-config/useConfigWorkspace'; +import { + configRev7, + detailFor, + instanceDetailA, + instanceIds, + instancesMixed, +} from '@/composables/agent-config/__tests__/fixtures'; + +/** + * A listInstances that serves `items` as the paginated API does: `limit` (default 25) a page, + * page fields in meta, and counts over every item. + */ +export function pagedListInstances(items: AgentInstanceSummary[]) { + const counts: InstanceCounts = { + total: items.length, + fresh: items.filter((i) => !i.stale).length, + stale: items.filter((i) => i.stale).length, + inSync: items.filter((i) => i.syncStatus === 'in-sync').length, + outOfSync: items.filter((i) => i.syncStatus === 'out-of-sync').length, + pending: items.filter((i) => i.status === 'pending').length, + rejected: items.filter((i) => i.status === 'rejected').length, + failed: items.filter((i) => i.status === 'failed').length, + unknown: items.filter((i) => i.status === 'unknown').length, + }; + return vi.fn(async (_agentId: string, q: InstancesPageQuery = {}) => { + const page = q.page ?? 1; + const limit = q.limit ?? 25; + return { + items: items.slice((page - 1) * limit, page * limit), + meta: { + desiredRevision: instancesMixed.meta.desiredRevision, + counts, + page, + limit, + total: items.length, + totalPages: Math.max(1, Math.ceil(items.length / limit)), + }, + }; + }); +} + +/** A fake API backed by the fixtures; override any method. */ +export function fakeApi(over: Partial = {}): AgentConfigApi { + return { + getConfig: vi.fn().mockResolvedValue(configRev7), + putConfig: vi.fn(), + preview: vi.fn(), + listRevisions: vi + .fn() + .mockResolvedValue({ items: [], total: 0, totalPages: 1 }), + getRevision: vi.fn().mockResolvedValue(configRev7), + revert: vi.fn(), + listInstances: pagedListInstances(instancesMixed.items), + getInstance: vi.fn().mockImplementation(async (_a: string, id: string) => { + if (id === instanceIds.a) return instanceDetailA; + const s = instancesMixed.items.find((i) => i.instanceId === id)!; + return detailFor(s, configRev7.overlay ?? {}); + }), + ...over, + }; +} + +/** + * A host component that loads the agent config and provides a workspace to `inner` + * (rendered with `props` once the configuration is ready). `out.ws` exposes the workspace. + * Like the Configuration tab, it loads every instance's detail for editors. + */ +export function workspaceHost( + api: AgentConfigApi, + inner: Component, + props: () => Record = () => ({}), + out: { ws?: ConfigWorkspace } = {}, + agentId = 'agent-1', +) { + return defineComponent({ + name: 'WorkspaceHost', + setup() { + const state = useAgentConfig( + computed(() => agentId), + api, + ); + const ws = useConfigWorkspace(agentId, api, state); + out.ws = ws; + // As the Configuration tab does: editors load every reporting instance's file. + watch( + () => + state.status.value === 'ready' && + !state.instanceLoading.value && + ws.canConfigure.value, + (go) => { + if (go && !ws.detailsLoaded.value && !ws.detailsLoading.value) + ws.loadDetails(); + }, + { immediate: true }, + ); + state.load(); + return () => + state.status.value === 'ready' + ? h(inner, props()) + : h('div', 'loading'); + }, + }); +} diff --git a/src/composables/agent-config/__tests__/useAgentConfig.spec.ts b/src/composables/agent-config/__tests__/useAgentConfig.spec.ts new file mode 100644 index 00000000..383ace5c --- /dev/null +++ b/src/composables/agent-config/__tests__/useAgentConfig.spec.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { usePermissionsStore } from '@/stores/permissions'; +import { AxiosError, AxiosHeaders } from 'axios'; +import type { AgentConfigApi } from '../api-types'; +import { createHttpAgentConfigApi } from '../useAgentConfigApi'; +import type { AgentInstanceDetail } from '@/types/agent-config'; +import { + configRev6, + configRev7, + detailFor, + instanceIds, + instancesMixed, +} from './fixtures'; +import { useAgentConfig } from '../useAgentConfig'; +import { MAX_INSTANCE_PAGES } from '../instancePages'; + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeApi(over: Partial = {}): AgentConfigApi { + return { + getConfig: vi.fn().mockResolvedValue(configRev7), + putConfig: vi.fn(), + preview: vi.fn(), + listRevisions: vi.fn(), + getRevision: vi.fn().mockResolvedValue(configRev6), + revert: vi.fn(), + listInstances: vi.fn().mockResolvedValue(instancesMixed), + getInstance: vi + .fn() + .mockImplementation(async (_a: string, id: string) => + detailFor(instancesMixed.items.find((i) => i.instanceId === id)!, {}), + ), + ...over, + }; +} + +describe('useAgentConfig', () => { + let store: ReturnType; + beforeEach(() => { + setActivePinia(createPinia()); + store = usePermissionsStore(); + }); + + it('waits for permission hydration and never fetches without agent:read (D-21)', async () => { + const hydrated = deferred(); + store.hydrate = vi.fn(async () => { + await hydrated.promise; + store.permissions = { agent: [] }; + store.loaded = true; + return store.permissions; + }) as unknown as typeof store.hydrate; + const api = makeApi(); + const state = useAgentConfig(ref('agent-1'), api); + const loading = state.load(); + await Promise.resolve(); + // Optimistic can() would allow it; the composable waits instead. + expect(api.getConfig).not.toHaveBeenCalled(); + hydrated.resolve(); + await loading; + expect(api.getConfig).not.toHaveBeenCalled(); + expect(state.status.value).toBe('error'); + }); + + it('publishes an instance detail together with its applied overlay; stale selections are dropped', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + const slowB = deferred(); + const api = makeApi({ + getInstance: vi + .fn() + .mockImplementation(async (_a: string, id: string) => { + if (id === instanceIds.b) return slowB.promise; + return detailFor( + instancesMixed.items.find((i) => i.instanceId === id)!, + {}, + ); + }), + }); + const state = useAgentConfig(ref('agent-1'), api); + await state.load(); + expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.a); + + const toB = state.selectInstance(instanceIds.b); + expect(state.selectedInstanceCurrent.value).toBe(false); + const toF = state.selectInstance(instanceIds.f); + await toF; + expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.f); + // ip-f runs r6: its overlay comes with it. + expect(state.appliedOverlay.value).toEqual(configRev6.overlay); + slowB.resolve(detailFor(instancesMixed.items[1], {})); + await toB; + // The late ip-b response must not replace the newer selection. + expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.f); + expect(state.selectedInstanceCurrent.value).toBe(true); + }); + + it('flags the provenance fallback when the applied revision cannot be loaded', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + const api = makeApi({ + getRevision: vi.fn().mockRejectedValue(new Error('gone')), + }); + const state = useAgentConfig(ref('agent-1'), api); + await state.load(); + await state.selectInstance(instanceIds.f); + expect(state.appliedOverlayFallback.value).toBe(true); + expect(state.appliedOverlay.value).toEqual(configRev7.overlay); + }); + + it('reports an older API as unsupported even when listInstances 404s first', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + // An older API has neither route: both 404 with no {errors:{body}}. + const notFound = () => { + const err = new AxiosError('Not Found', 'ERR_BAD_REQUEST'); + err.response = { + status: 404, + data: { message: 'Not Found' }, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return err; + }; + const cfg = deferred(); + const get = vi.fn((url: string) => + url.endsWith('/instances') ? Promise.reject(notFound()) : cfg.promise, + ); + const api = createHttpAgentConfigApi({ get } as never); + const state = useAgentConfig(ref('agent-1'), api); + await state.load(); + expect(get).toHaveBeenCalledTimes(2); + expect(state.status.value).toBe('unsupported'); + cfg.reject(notFound()); + }); + + /** `n` copies of ip-a served 25 per page, as the paginated API does. */ + function pagedApi(n: number) { + const rows = Array.from({ length: n }, (_, i) => ({ + ...instancesMixed.items[0], + instanceId: `i${i + 1}`, + })); + return makeApi({ + listInstances: vi.fn( + async (_a: string, q: { page?: number; limit?: number } = {}) => { + const page = q.page ?? 1; + const limit = q.limit ?? 25; + return { + items: rows.slice((page - 1) * limit, page * limit), + meta: { + desiredRevision: 7, + counts: { ...instancesMixed.meta.counts, total: n, inSync: n }, + page, + limit, + total: n, + totalPages: Math.ceil(n / limit), + }, + }; + }, + ), + }); + } + + it('loads every page of instances; fleet numbers come from meta.counts', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + const api = pagedApi(60); + const state = useAgentConfig(ref('agent-1'), api); + await state.load(); + expect(api.listInstances).toHaveBeenCalledTimes(3); + expect(state.instances.value).toHaveLength(60); + expect(state.instancesPartial.value).toBe(false); + expect(state.instanceTotal.value).toBe(60); + expect(state.syncSummary.value.inSync).toBe(60); + }); + + it('past the page cap the list is partial; totals still cover the fleet', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + const api = pagedApi(130); + const state = useAgentConfig(ref('agent-1'), api); + await state.load(); + expect(api.listInstances).toHaveBeenCalledTimes(MAX_INSTANCE_PAGES); + expect(state.instances.value).toHaveLength(100); + expect(state.instancesPartial.value).toBe(true); + expect(state.instanceTotal.value).toBe(130); + expect(state.syncSummary.value.total).toBe(130); + expect(state.syncSummary.value.partial).toBe(true); + }); + + it('ignores the failure of a superseded load', async () => { + store.permissions = { agent: ['read'] }; + store.loaded = true; + const first = deferred(); + const getConfig = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValue(configRev7); + const state = useAgentConfig(ref('agent-1'), makeApi({ getConfig })); + const a = state.load(); + await Promise.resolve(); + await state.refresh(); + expect(state.status.value).toBe('ready'); + first.reject(new Error('late failure')); + await a; + expect(state.status.value).toBe('ready'); + }); +}); diff --git a/src/composables/agent-config/__tests__/useConfigWorkspace.spec.ts b/src/composables/agent-config/__tests__/useConfigWorkspace.spec.ts new file mode 100644 index 00000000..f5681a34 --- /dev/null +++ b/src/composables/agent-config/__tests__/useConfigWorkspace.spec.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; +import type { AgentConfigApi } from '../api-types'; +import type { AgentInstanceDetail } from '@/types/agent-config'; +import { resetAgentDrafts } from '../draftRegistry'; +import type { ConfigWorkspace } from '../useConfigWorkspace'; +import { clone } from '@/utils/agent-config/merge-patch'; +import { getAt } from '@/utils/agent-config/json-pointer'; +import { + ADMIN, + fakeApi, + globalWith, + pagedListInstances, + piniaWith, + workspaceHost, +} from '@/components/agents/config/__tests__/helpers'; +import { + configRev7, + detailFor, + instanceIds, + instancesMixed, + overlayRev7, +} from './fixtures'; + +enableAutoUnmount(afterEach); + +const Inner = defineComponent({ render: () => h('div') }); + +async function mountWorkspace(api: AgentConfigApi) { + const out: { ws?: ConfigWorkspace } = {}; + mount( + workspaceHost(api, Inner, () => ({}), out), + { + global: globalWith(piniaWith(ADMIN)), + }, + ); + await flushPromises(); + return out.ws!; +} + +const PTR = '/plugins/local-ssh/policy_data/max_auth_tries'; +const SCOPE = '/plugins/local-ssh/policy_data'; + +describe('useConfigWorkspace: edits wait for every instance file', () => { + beforeEach(() => resetAgentDrafts()); + + // ip-a's file has max_auth_tries 4, ip-b's has 6, the saved overlay sets 3. Setting 4 must + // pin 4 in the overlay (ip-b's file differs); dropping the entry would give ip-b 6. + function apiWithB(b: () => Promise) { + const fallback = fakeApi().getInstance; + return fakeApi({ + getInstance: vi.fn(async (agentId: string, id: string) => + id === instanceIds.b ? b() : fallback(agentId, id), + ), + }); + } + const detailB = () => { + const s = instancesMixed.items.find((i) => i.instanceId === instanceIds.b)!; + const d = clone(detailFor(s, overlayRev7)); + d.base!.plugins!['local-ssh']!.policy_data = { max_auth_tries: 6 }; + return d; + }; + + it('ip-b still loading: no edits until it loads, then setting ip-a’s file value keeps it pinned', async () => { + let resolveB!: (d: AgentInstanceDetail) => void; + const ws = await mountWorkspace( + apiWithB(() => new Promise((r) => (resolveB = r))), + ); + expect(ws.detailsLoaded.value).toBe(false); // ip-b still in flight + expect(ws.canEditPointer(PTR)).toBe(false); + expect(ws.basesBlockedReason.value).toMatch(/Loading/); + expect(ws.failedBaseIds.value).toEqual([]); + + resolveB(detailB()); + await flushPromises(); + expect(ws.detailsLoaded.value).toBe(true); + expect(ws.basesBlockedReason.value).toBe(''); + expect(ws.canEditPointer(PTR)).toBe(true); + ws.draft.setValue(PTR, 4, SCOPE); + expect(getAt(ws.draft.overlay.value, PTR)).toBe(4); + }); + + it('ip-b failed to load: no edits, the failure is named, and a retry enables them', async () => { + let fail = true; + const ws = await mountWorkspace( + apiWithB(async () => { + if (fail) throw new Error('502'); + return detailB(); + }), + ); + expect(ws.detailsLoaded.value).toBe(true); + expect(ws.failedBaseIds.value).toEqual([instanceIds.b]); + expect(ws.canEditPointer(PTR)).toBe(false); + expect(ws.basesBlockedReason.value).toContain('ip-b'); + + fail = false; + await ws.loadDetails(); + await flushPromises(); + expect(ws.failedBaseIds.value).toEqual([]); + expect(ws.canEditPointer(PTR)).toBe(true); + ws.draft.setValue(PTR, 4, SCOPE); + expect(getAt(ws.draft.overlay.value, PTR)).toBe(4); + }); + + it('control: with ip-b loaded, the value is pinned', async () => { + const ws = await mountWorkspace(apiWithB(async () => detailB())); + expect(ws.canEditPointer(PTR)).toBe(true); + ws.draft.setValue(PTR, 4, SCOPE); + expect(getAt(ws.draft.overlay.value, PTR)).toBe(4); + }); + + it('a single-instance agent edits as soon as its instance is loaded', async () => { + const only = instancesMixed.items[0]; + const getInstance = vi.fn(async () => detailFor(only, overlayRev7)); + const ws = await mountWorkspace( + fakeApi({ + getConfig: vi.fn().mockResolvedValue(configRev7), + listInstances: pagedListInstances([only]), + getInstance, + }), + ); + expect(ws.basesBlockedReason.value).toBe(''); + expect(ws.canEditPointer(PTR)).toBe(true); + // The background load reuses the selected instance's detail: one request. + expect(getInstance).toHaveBeenCalledTimes(1); + // One page of instances: one list request, and the summary covers the fleet. + expect(ws.api.listInstances).toHaveBeenCalledTimes(1); + expect(ws.state.syncSummary.value.partial).toBe(false); + }); + + it('an instance list cut at the page cap blocks edits, naming the cap', async () => { + // 130 instances (6 pages of 25): only the first 4 pages are loaded. + const rows = Array.from({ length: 130 }, (_, i) => ({ + ...instancesMixed.items[0], + instanceId: `i${i + 1}`, + hostname: `ip-${i + 1}`, + })); + const ws = await mountWorkspace( + fakeApi({ + listInstances: pagedListInstances(rows), + getInstance: vi.fn(async (_a: string, id: string) => ({ + ...detailFor(instancesMixed.items[0], overlayRev7), + instanceId: id, + })), + }), + ); + expect(ws.state.instancesPartial.value).toBe(true); + // Every loaded instance's file is there, yet the rest of the fleet is unknown. + expect(ws.failedBaseIds.value).toEqual([]); + expect(ws.canEditPointer(PTR)).toBe(false); + expect(ws.basesBlockedReason.value).toBe( + "Only 100 of 130 instances are loaded (at most 100): editing needs every instance's file", + ); + }); +}); diff --git a/src/composables/agent-config/useAgentConfig.ts b/src/composables/agent-config/useAgentConfig.ts new file mode 100644 index 00000000..5d3c9eb2 --- /dev/null +++ b/src/composables/agent-config/useAgentConfig.ts @@ -0,0 +1,296 @@ +// State of the agent Configuration tab (LLD U1.3): the desired revision, the reporting +// instances, the selected instance's detail and the overlay of the revision it runs. +// No polling: the tab exposes a Refresh button and is mounted only while visible. + +import { computed, ref, shallowRef, type Ref } from 'vue'; +import type { + AgentConfigRevision, + AgentInstanceDetail, + AgentInstanceSummary, + InstancesMeta, + OverlayDoc, +} from '@/types/agent-config'; +import { usePermissionsStore } from '@/stores/permissions'; +import { ACTIONS, RESOURCES } from '@/constants/permissions'; +import { + deriveInstanceState, + summarizeSync, +} from '@/utils/agent-config/instance-status'; +import { isAgentConfigApiError, type AgentConfigApi } from './api-types'; +import { listAllInstances } from './instancePages'; + +export type AgentConfigStatus = + | 'idle' + | 'loading' + | 'ready' + | 'unsupported' + | 'error'; + +/** Max concurrent instance-detail requests when the editor needs every base (U2.2). */ +const DETAIL_CONCURRENCY = 6; + +export function useAgentConfig(agentId: Ref, api: AgentConfigApi) { + const permissions = usePermissionsStore(); + + const config = shallowRef(null); + const instances = shallowRef([]); + const meta = shallowRef(null); + /** The loaded rows are not every instance (paginated list past MAX_INSTANCE_PAGES). */ + const instancesPartial = ref(false); + const selectedInstanceId = ref(null); + const selectedInstance = shallowRef(null); + const appliedOverlay = shallowRef(null); + const instanceLoading = ref(false); + const instanceError = ref(null); + /** The applied revision's overlay could not be loaded; provenance uses the desired one. */ + const appliedOverlayFallback = ref(false); + const status = ref('idle'); + const error = ref(null); + + const revisionCache = new Map(); + const detailCache = new Map(); + let loadSeq = 0; + let selectSeq = 0; + + const desiredRevision = computed( + () => config.value?.revision ?? meta.value?.desiredRevision ?? 0, + ); + const instanceStates = computed(() => + instances.value.map((i) => deriveInstanceState(i, desiredRevision.value)), + ); + /** Every instance of the agent (the API's fleet-wide count), loaded or not. */ + const instanceTotal = computed( + () => meta.value?.counts?.total ?? instances.value.length, + ); + const syncSummary = computed(() => + summarizeSync(instances.value, instanceStates.value, meta.value?.counts), + ); + /** The loaded detail belongs to the selected id (false while switching instances). */ + const selectedInstanceCurrent = computed( + () => + !!selectedInstance.value && + selectedInstance.value.instanceId === selectedInstanceId.value, + ); + const selectedState = computed( + () => + instanceStates.value.find( + (s) => s.instanceId === selectedInstanceId.value, + ) ?? null, + ); + + async function ensurePermissions(): Promise { + // stores/permissions can() is optimistic before hydration; wait so an unauthorised + // user never fires requests that race the hydration (D-21). + if (!permissions.loaded) await permissions.hydrate(); + return permissions.can(RESOURCES.AGENT, ACTIONS.READ); + } + + function messageOf(e: unknown, fallback: string): string { + if (isAgentConfigApiError(e)) return e.message || fallback; + if (e instanceof Error && e.message) return e.message; + return fallback; + } + + /** Overlay of a revision, cached per revision number. */ + async function getRevisionCached(rev: number): Promise { + const cached = revisionCache.get(rev); + if (cached) return cached; + const loaded = await api.getRevision(agentId.value, rev); + revisionCache.set(rev, loaded); + return loaded; + } + + async function resolveAppliedOverlay( + inst: AgentInstanceSummary, + ): Promise { + const applied = inst.appliedRevision; + if (applied === null || applied === 0) return {}; + if (config.value && applied === config.value.revision) + return config.value.overlay ?? {}; + const rev = await getRevisionCached(applied); + return rev.overlay ?? {}; + } + + function defaultInstanceId(list: AgentInstanceSummary[]): string | null { + const fresh = list.find((i) => !i.stale && i.reportedAt != null); + return (fresh ?? list[0])?.instanceId ?? null; + } + + async function selectInstance(id: string): Promise { + const seq = ++selectSeq; + selectedInstanceId.value = id; + instanceLoading.value = true; + instanceError.value = null; + try { + const detail = await getInstanceDetail(id, true); + if (seq !== selectSeq) return; + let overlay: OverlayDoc = {}; + let fallback = false; + try { + overlay = await resolveAppliedOverlay(detail); + } catch { + // Provenance degrades to the desired overlay when the applied one can't be loaded. + overlay = config.value?.overlay ?? {}; + fallback = true; + } + if (seq !== selectSeq) return; + // Publish the detail and the overlay of the revision it runs together, so the views + // never pair one instance's config with another's provenance. + selectedInstance.value = detail; + appliedOverlay.value = overlay; + appliedOverlayFallback.value = fallback; + } catch (e) { + if (seq !== selectSeq) return; + selectedInstance.value = null; + appliedOverlay.value = null; + instanceError.value = messageOf(e, 'Failed to load the instance.'); + } finally { + if (seq === selectSeq) instanceLoading.value = false; + } + } + + async function getInstanceDetail( + id: string, + fresh = false, + ): Promise { + if (!fresh) { + const cached = detailCache.get(id); + if (cached) return cached; + } + const detail = await api.getInstance(agentId.value, id); + detailCache.set(id, detail); + return detail; + } + + /** + * Details of every instance that has reported (the editor's review diff needs every base), + * at most DETAIL_CONCURRENCY in flight. Failures are skipped (the panel falls back to the + * change list for that instance). + */ + async function loadAllInstanceDetails(): Promise< + Map + > { + const ids = instances.value + .filter((i) => i.reportedAt != null) + .map((i) => i.instanceId); + return fetchDetails(ids, true); + } + + /** Details of `ids`, at most DETAIL_CONCURRENCY in flight; failures skipped or rethrown. */ + async function fetchDetails( + ids: string[], + skipFailures: boolean, + ): Promise> { + const out = new Map(); + let next = 0; + const worker = async () => { + while (next < ids.length) { + const id = ids[next++]; + try { + out.set(id, await getInstanceDetail(id)); + } catch (e) { + if (!skipFailures) throw e; + } + } + }; + await Promise.all( + Array.from({ length: Math.min(DETAIL_CONCURRENCY, ids.length) }, worker), + ); + return out; + } + + async function fetchAll(keepSelection: boolean): Promise { + const seq = ++loadSeq; + if (!(await ensurePermissions())) { + status.value = 'error'; + error.value = "You don't have permission to view agent configuration."; + return; + } + const [cfg, list] = await Promise.all([ + api.getConfig(agentId.value), + listAllInstances(api, agentId.value), + ]); + if (seq !== loadSeq) return; + config.value = cfg; + if (cfg.overlay) revisionCache.set(cfg.revision, cfg); + instances.value = list.items; + meta.value = list.meta; + instancesPartial.value = list.partial; + detailCache.clear(); + status.value = 'ready'; + const keep = + keepSelection && + selectedInstanceId.value && + list.items.some((i) => i.instanceId === selectedInstanceId.value) + ? selectedInstanceId.value + : null; + const id = keep ?? defaultInstanceId(list.items); + if (id) { + await selectInstance(id); + } else { + selectedInstanceId.value = null; + selectedInstance.value = null; + appliedOverlay.value = null; + } + } + + async function load(): Promise { + status.value = 'loading'; + error.value = null; + const seq = loadSeq + 1; + try { + await fetchAll(false); + } catch (e) { + // A newer load/refresh already superseded this one. + if (seq !== loadSeq) return; + if (isAgentConfigApiError(e) && e.kind === 'unsupported') { + status.value = 'unsupported'; + } else { + status.value = 'error'; + error.value = messageOf(e, 'Failed to load the agent configuration.'); + } + } + } + + /** After save/revert: reload and keep the selected instance. */ + async function refresh(): Promise { + const seq = loadSeq + 1; + try { + await fetchAll(true); + } catch (e) { + if (seq !== loadSeq) return; + error.value = messageOf(e, 'Failed to refresh the agent configuration.'); + if (isAgentConfigApiError(e) && e.kind === 'unsupported') + status.value = 'unsupported'; + else status.value = 'error'; + } + } + + return { + config, + instances, + meta, + instancesPartial, + instanceTotal, + instanceStates, + selectedInstanceId, + selectedInstance, + selectedInstanceCurrent, + selectedState, + appliedOverlay, + appliedOverlayFallback, + desiredRevision, + syncSummary, + status, + error, + instanceLoading, + instanceError, + load, + selectInstance, + refresh, + getRevisionCached, + loadAllInstanceDetails, + }; +} + +export type AgentConfigState = ReturnType; diff --git a/src/composables/agent-config/useConfigWorkspace.ts b/src/composables/agent-config/useConfigWorkspace.ts new file mode 100644 index 00000000..8e88fabb --- /dev/null +++ b/src/composables/agent-config/useConfigWorkspace.ts @@ -0,0 +1,372 @@ +// The editing workspace of one agent (R69): the shared pending-changes draft, the instance +// bases it is shown against, the live preview and the permission rules, provided to the +// Effective view (inline pencils), the raw YAML dialog, the pending-changes bar and the review +// dialog. Created by the Configuration tab; the draft is per agent (draftRegistry). + +import { + computed, + inject, + provide, + ref, + shallowRef, + toValue, + watch, + type InjectionKey, + type MaybeRefOrGetter, + type Ref, +} from 'vue'; +import type { + AgentConfigRevision, + AgentInstanceDetail, + ConfigDoc, + ConfigPreview, + OverlayDoc, + SaveResult, +} from '@/types/agent-config'; +import { usePermissions } from '@/composables/usePermissions'; +import { instanceErrorsBlock } from '@/components/agents/config/editor/review'; +import { clone, deepEqual } from '@/utils/agent-config/merge-patch'; +import { useUserStore } from '@/stores/auth'; +import { hasBlocking, type ClientIssue } from '@/utils/agent-config/validation'; +import { + addPluginAccess, + fieldAccess, + type AccessContext, + type FieldAccess, +} from '@/utils/agent-config/field-access'; +import type { AgentConfigApi } from './api-types'; +import type { AgentConfigState } from './useAgentConfig'; +import { agentDraftState, syncDraftState } from './draftRegistry'; +import { INSTANCE_PAGE_LIMIT, MAX_INSTANCE_PAGES } from './instancePages'; +import { useOverlayDraft, type DraftState } from './useOverlayDraft'; +import { usePreview } from './usePreview'; +import { + EDITOR_CONTEXT_KEY, + OVERLAY_DRAFT_KEY, + type EditorContext, +} from './editorContext'; + +/** A writable ref that reads and writes whichever ref `target` currently returns. */ +function proxyRef(target: () => Ref): Ref { + return computed({ + get: () => target().value, + set: (v) => { + target().value = v; + }, + }); +} + +const EMPTY_REVISION: AgentConfigRevision = { + agentId: '', + revision: 0, + overlay: {}, + overlaySize: 2, + comment: null, + createdBy: null, + createdAt: null, + revertOf: null, +}; + +export function useConfigWorkspace( + /** The agent (a string, ref or getter): API calls and the draft follow its current value. */ + agentId: MaybeRefOrGetter, + api: AgentConfigApi, + state: AgentConfigState, +) { + const { can, RESOURCES, ACTIONS } = usePermissions(); + const canConfigure = computed(() => can(RESOURCES.AGENT, ACTIONS.CONFIGURE)); + + // ---- Instance details (bases + reports): every reported instance, loaded on demand ---- + const loadedDetails = shallowRef(new Map()); + const detailsLoading = ref(false); + const detailsLoaded = ref(false); + let detailsSeq = 0; + async function loadDetails(): Promise { + const seq = ++detailsSeq; + detailsLoading.value = true; + try { + const m = await state.loadAllInstanceDetails(); + if (seq !== detailsSeq) return; + loadedDetails.value = m; + detailsLoaded.value = true; + } finally { + if (seq === detailsSeq) detailsLoading.value = false; + } + } + // A refresh re-reads the instance list: the loaded details may be stale. + watch(state.instances, () => { + if (detailsLoaded.value || detailsLoading.value) loadDetails(); + }); + + /** Loaded details plus the selected instance's (fresh) detail. */ + const instanceDetails = computed(() => { + const m = new Map(loadedDetails.value); + const sel = state.selectedInstance.value; + if (sel && state.selectedInstanceCurrent.value) m.set(sel.instanceId, sel); + return m; + }); + const placeholderInstanceId = computed(() => { + const sel = state.selectedInstanceId.value; + if (sel && instanceDetails.value.get(sel)?.base) return sel; + return ( + Array.from(instanceDetails.value.values()).find((d) => d.base) + ?.instanceId ?? sel + ); + }); + const placeholderDetail = computed(() => + placeholderInstanceId.value + ? (instanceDetails.value.get(placeholderInstanceId.value) ?? null) + : null, + ); + const placeholderBase = computed( + () => placeholderDetail.value?.base ?? null, + ); + const bases = computed(() => + Array.from(instanceDetails.value.values()) + .map((d) => d.base) + .filter((b): b is ConfigDoc => !!b), + ); + + // Base-dependent edits (null or omit a key, "back to the file value") are only right against + // EVERY reporting instance's file, so they wait until each one is loaded, a failed load + // blocks them until a retry (loadDetails) succeeds, and an instance list cut at the page cap + // blocks them outright. A single-instance agent's only file is the selected instance's, so it + // never waits on the background load. + const missingBaseIds = computed(() => + state.instances.value + .filter( + (i) => i.reportedAt != null && !instanceDetails.value.has(i.instanceId), + ) + .map((i) => i.instanceId), + ); + /** Reporting instances whose detail the last loadDetails could not load. */ + const failedBaseIds = computed(() => + detailsLoaded.value && !detailsLoading.value ? missingBaseIds.value : [], + ); + /** '' = every reporting instance's file is loaded (edits allowed); else why not. */ + const basesBlockedReason = computed(() => { + // Past the page cap the instances (and so their files) are not all known: field access + // and the draft would be computed over part of the fleet. + if (state.instancesPartial.value) { + return `Only ${state.instances.value.length} of ${state.instanceTotal.value} instances are loaded (at most ${MAX_INSTANCE_PAGES * INSTANCE_PAGE_LIMIT}): editing needs every instance's file`; + } + if (!missingBaseIds.value.length) return ''; + if (!failedBaseIds.value.length) return "Loading the instances' files…"; + const hosts = failedBaseIds.value.map((id) => { + const s = state.instances.value.find((i) => i.instanceId === id); + return s?.hostname || id.slice(0, 8); + }); + return `Could not load the file of ${hosts.join(', ')}: editing waits until every reporting instance's file is loaded`; + }); + + // ---- The draft (shared per agent) ---- + // Scoped to the signed-in user (see draftRegistry). A different agent id swaps the state + // the draft refs point at (one DraftState per agent); only a config load syncs it, so a + // new agent's draft is never initialised from the previous agent's still-loaded config. + const userKey = useUserStore().user?.id ?? ''; + const agentIdRef = computed(() => toValue(agentId)); + const registryState = computed(() => + agentDraftState(agentIdRef.value, userKey), + ); + const draftState: DraftState = { + baseRevision: proxyRef(() => registryState.value.baseRevision), + original: proxyRef(() => registryState.value.original), + overlay: proxyRef(() => registryState.value.overlay), + comment: proxyRef(() => registryState.value.comment), + }; + watch( + state.config, + (cfg) => { + if (cfg) syncDraftState(draftState, cfg); + }, + { immediate: true }, + ); + const ready = computed(() => draftState.baseRevision.value >= 0); + + const draft = useOverlayDraft(draftState, placeholderBase, { + bases, + extraIssues: () => previewIssues.value, + }); + + // ---- Live preview (debounced; only for a dirty draft without client-only blockers) ---- + // R89: the preview is the UI's validation. Its errors gate Review & save. + const clientBlocked = computed(() => hasBlocking(draft.clientIssues.value)); + const canPreview = computed( + () => + canConfigure.value && + ready.value && + draft.isDirty.value && + !clientBlocked.value && + !detailsLoading.value, + ); + const preview = usePreview(agentIdRef, draft.overlay, api, canPreview); + /** The last preview, while it still describes the draft. */ + const currentPreview = computed(() => + preview.isCurrent() ? preview.lastPreview.value : null, + ); + /** The preview's overlay and per-instance problems, as issues at their pointers (R59). */ + const previewIssues = computed(() => { + const p = currentPreview.value; + if (!p) return []; + const out: ClientIssue[] = p.overlayErrors.map((e) => ({ + ptr: e.path, + message: e.message, + blocking: true, + })); + const many = p.instances.length > 1; + for (const inst of p.instances) { + const on = many && inst.hostname ? ` (on ${inst.hostname})` : ''; + // R48: only validated instances block; older APIs lack `validated` (fresh ones block). + const blocks = instanceErrorsBlock(inst); + for (const e of inst.errors) + out.push({ ptr: e.path, message: e.message + on, blocking: blocks }); + for (const e of inst.warnings ?? []) + out.push({ + ptr: e.path, + message: `${e.message} (already in the agent's file)${on}`, + blocking: false, + }); + } + return out; + }); + const blockingCount = computed( + () => draft.issues.value.filter((i) => i.blocking).length, + ); + + // ---- R71: per-field access over the reporting instances (field-access.ts) ---- + // Instance bases tell which plugins and sources each host's file has (a plugin the draft + // adds applies only where its source is accepted); the draft overlay supplies that source. + const accessContext = computed(() => ({ + instances: state.instances.value, + bases: new Map( + Array.from(instanceDetails.value, ([id, d]) => [id, d.base] as const), + ), + overlay: draft.overlay.value, + })); + /** Whether the reporting instances would apply a change at `ptr` (three states). */ + function accessAt(ptr: string): FieldAccess { + return fieldAccess(ptr, accessContext.value); + } + /** Whether they would install a new plugin (with `source`, once known). */ + function addPluginAccessFor(source?: string): FieldAccess { + return addPluginAccess(accessContext.value, source); + } + + const config = computed(() => state.config.value ?? EMPTY_REVISION); + const ctx: EditorContext = { + agentId: agentIdRef, + config, + instances: state.instances, + instanceDetails, + placeholderInstanceId, + placeholderBase, + bases, + lastPreview: preview.lastPreview, + currentPreview, + accessAt, + }; + + /** + * Whether this user may edit the field at `ptr` (R40): never a forbidden key, nor a field + * no reporting instance would apply (R71 read-only), nor before every reporting instance's + * file is loaded (`basesBlockedReason`). + */ + function canEditPointer(ptr: string): boolean { + if (!canConfigure.value || basesBlockedReason.value) return false; + const state = accessAt(ptr).state; + return state !== 'forbidden' && state !== 'readonly'; + } + + /** '' = the user may save this draft; else why not (R40). */ + const saveDisabledReason = computed(() => + canConfigure.value + ? '' + : "You don't have permission to change this configuration", + ); + + /** '' = Review & save is enabled; else why not (R89: never ahead of a pending preview). */ + const reviewDisabledReason = computed(() => { + if (blockingCount.value) return 'Fix the problems first'; + if (preview.pending.value) return 'Checking the pending changes…'; + return saveDisabledReason.value; + }); + + const reviewOpen = ref(false); + function openReview(): void { + if (!draft.isDirty.value) return; + reviewOpen.value = true; + } + + /** + * After a save: the saved overlay becomes the base, then everything reloads. `sent` is the + * overlay the save sent: when the draft has moved on since, only the base moves and the + * newer edits stay pending. + */ + async function onSaved(result: SaveResult, sent?: OverlayDoc): Promise { + const saved = { + ...result.revision, + overlay: result.revision.overlay ?? sent ?? draft.overlay.value, + }; + if (!sent || deepEqual(draft.overlay.value, sent)) { + syncDraftState(draftState, saved, true); + } else { + draftState.baseRevision.value = saved.revision; + draftState.original.value = clone(saved.overlay); + draftState.comment.value = ''; + } + await state.refresh(); + } + + /** Drops the pending changes, and catches up with a newer desired revision. */ + function discard(): void { + draft.discard(); + if (state.config.value) syncDraftState(draftState, state.config.value); + } + + const workspace = { + agentId: agentIdRef, + api, + state, + ready, + canConfigure, + draft, + instanceDetails, + detailsLoading, + detailsLoaded, + loadDetails, + failedBaseIds, + basesBlockedReason, + placeholderInstanceId, + placeholderBase, + bases, + preview, + clientBlocked, + blockingCount, + ctx, + accessAt, + addPluginAccess: addPluginAccessFor, + canEditPointer, + saveDisabledReason, + reviewDisabledReason, + reviewOpen, + openReview, + onSaved, + discard, + }; + + provide(WORKSPACE_KEY, workspace); + provide(OVERLAY_DRAFT_KEY, draft); + provide(EDITOR_CONTEXT_KEY, ctx); + return workspace; +} + +export type ConfigWorkspace = ReturnType; + +export const WORKSPACE_KEY: InjectionKey = Symbol( + 'agent-config-workspace', +); + +/** The provided workspace, or null in read-only contexts (no editing UI). */ +export function useWorkspace(): ConfigWorkspace | null { + return inject(WORKSPACE_KEY, null); +}