import { randomUUID } from 'node:crypto' import { repos, type Db } from '@evofw/db' import type { PreviewLabelAssignment, ApplyLabelAssignment, LabelAssignmentPreview } from '@evofw/shared' import { AppError } from '../plugins/error-handler.js' const fail = (code: string, message: string, status = 409): never => { throw new AppError(code, message, status) } const requireAgent = (db: Db, id: string) => repos.getAgent(db, id) ?? fail('NOT_FOUND', 'Agent not found', 404) const requireLabel = (db: Db, id: string) => repos.getAgentLabel(db, id) ?? fail('NOT_FOUND', 'Label not found', 404) const nameKey = (name: string) => name.normalize('NFKC').toLocaleLowerCase('en-US') export function createAgentLabel(db: Db, body: { name: string; color?: string | null }, now = new Date().toISOString()) { return db.transaction((tx) => { const key = nameKey(body.name) if (repos.getAgentLabelByName(tx, key)) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists') if (repos.countAgentLabels(tx) >= 1000) fail('LABEL_CAPACITY', 'At most 1000 labels may be stored', 422) const row = { id: randomUUID(), name: body.name, name_key: key, color: body.color ?? null, created_at: now } repos.insertAgentLabel(tx, row) return row }) } export function patchAgentLabel(db: Db, id: string, patch: { name?: string; color?: string | null }) { return db.transaction((tx) => { const previous = requireLabel(tx, id) const name = patch.name ?? previous.name const key = nameKey(name) const conflict = repos.getAgentLabelByName(tx, key) if (conflict && conflict.id !== id) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists') const row = { ...previous, name, name_key: key, color: patch.color === undefined ? previous.color : patch.color } repos.updateAgentLabel(tx, row) return row }) } export function putAgentLabels(db: Db, id: string, labelIds: string[]) { return db.transaction((tx) => { requireAgent(tx, id) for (const labelId of labelIds) requireLabel(tx, labelId) repos.replaceAgentLabels(tx, id, labelIds) return { agent_id: id, label_ids: repos.agentLabelIds(tx, id) } }) } export function previewLabelAssignment(db: Db, body: PreviewLabelAssignment): LabelAssignmentPreview { // Resolve the whole selection once. Never silently cap a larger label group. for (const labelId of body.selector.label_ids ?? []) requireLabel(db, labelId) const ids = body.selector.agent_ids ?? repos.agentIdsWithLabels(db, body.selector.label_ids!, 101) if (ids.length > 100) fail('GROUP_TOO_LARGE', 'An assignment may select at most 100 agents', 422) for (const setId of body.set_ids) if (!repos.getPolicySet(db, setId)) fail('NOT_FOUND', 'Policy set not found', 404) const items = ids.map((id) => { const agent = requireAgent(db, id) repos.assertAgentPolicyMutable(db, id) const previous = repos.listSetsForAgent(db, id).map((set) => set.setId) const final = body.mode === 'replace' ? [...body.set_ids] : [...previous, ...body.set_ids.filter((setId) => !previous.includes(setId))] if (final.length > 100) fail('ASSIGNMENT_TOO_LARGE', 'An agent may receive at most 100 policy sets through a group assignment', 422) return { agent_id: agent.id, generation: agent.policyGeneration, previous_set_ids: previous, set_ids: final, changed: JSON.stringify(previous) !== JSON.stringify(final) } }) return { mode: body.mode, items, total: items.length } } export function applyLabelAssignment(db: Db, body: ApplyLabelAssignment) { return db.transaction((tx) => { const preview = previewLabelAssignment(tx, body) if (preview.total === 0) fail('EMPTY_SELECTION', 'No agents match this selection') const expected = new Map(body.expected.map((row) => [row.agent_id, row.generation])) if (expected.size !== preview.total || preview.items.some((item) => expected.get(item.agent_id) !== item.generation)) fail('REVISION_CONFLICT', 'Group membership or an agent generation changed; refresh preview') // Every target/set/pin/precondition has been validated before the first write. for (const item of preview.items) if (item.changed) repos.setAgentPolicySets(tx, item.agent_id, item.set_ids) return { affected_agent_ids: preview.items.filter((item) => item.changed).map((item) => item.agent_id), items: preview.items.map((item) => ({ agent_id: item.agent_id, generation: requireAgent(tx, item.agent_id).policyGeneration, before_count: item.previous_set_ids.length, after_count: item.set_ids.length, changed: item.changed })) } }) }