From 21a3073375febcaac36da517307e2076a07ca789 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 6 Oct 2026 14:42:53 +0300 Subject: [PATCH 01/14] feat(access): manage who an agent serves from its Access tab (abilityai/trinity-enterprise#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assignments (primary, approver, collaborator, viewer) had no UI: setting an agent's primary human or its approvers took an API call. Agent Detail › Access now has a "People this agent serves" section above the operator list, over the existing ent#500 endpoints. An instance admin sees the primary apart from the stakeholders, with the seat it serves, each row's proactive-brief consent and role-check state; adds a person picked by name, changes a kind in one action on the row, removes a row, and replaces the primary as one flow that demotes the old primary first and puts it back if the new one cannot be written. Every refused write shows the backend's own reason. Non-admins see the same section read-only. The section is absent without the assignments module. Co-Authored-By: Claude Opus 5.5 --- src/frontend/src/components/AccessPanel.vue | 5 + .../components/AgentAssignmentsSection.vue | 331 ++++++++++++++++++ src/frontend/src/stores/assignments.js | 116 ++++++ src/frontend/src/utils/assignments.js | 120 +++++++ .../agentAssignmentsSection.mount.spec.js | 245 +++++++++++++ 5 files changed, 817 insertions(+) create mode 100644 src/frontend/src/components/AgentAssignmentsSection.vue create mode 100644 src/frontend/src/stores/assignments.js create mode 100644 src/frontend/src/utils/assignments.js create mode 100644 src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js diff --git a/src/frontend/src/components/AccessPanel.vue b/src/frontend/src/components/AccessPanel.vue index 2183364f50..1954372cbb 100644 --- a/src/frontend/src/components/AccessPanel.vue +++ b/src/frontend/src/components/AccessPanel.vue @@ -10,6 +10,10 @@

+ + +
import { ref, watch, onMounted } from 'vue' import { useAgentsStore } from '../stores/agents' +import AgentAssignmentsSection from './AgentAssignmentsSection.vue' const props = defineProps({ agentName: { type: String, required: true }, diff --git a/src/frontend/src/components/AgentAssignmentsSection.vue b/src/frontend/src/components/AgentAssignmentsSection.vue new file mode 100644 index 0000000000..82d8f97cbe --- /dev/null +++ b/src/frontend/src/components/AgentAssignmentsSection.vue @@ -0,0 +1,331 @@ + + + diff --git a/src/frontend/src/stores/assignments.js b/src/frontend/src/stores/assignments.js new file mode 100644 index 0000000000..e1337f4b69 --- /dev/null +++ b/src/frontend/src/stores/assignments.js @@ -0,0 +1,116 @@ +/** + * Agent assignments — who an agent serves (trinity-enterprise#810). + * + * The domain store over the ent#500 endpoints under `/api/enterprise/assignments` + * (enterprise-gated, writes admin-only). One roster per agent, keyed by name, + * so switching agents never shows the previous one's people. + * + * Writes re-read the roster rather than patching it locally: the server decides + * the view (display names, consent, drift), and a reload is one cheap GET. + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import api from '../api' +import { replacePrimarySteps, writeError } from '../utils/assignments' + +const base = (agent) => `/api/enterprise/assignments/agents/${encodeURIComponent(agent)}` + +export const useAssignmentsStore = defineStore('assignments', () => { + // agentName -> { roster, hasLoaded, error, lastLoadedAt } + const byAgent = ref({}) + // The instance users an admin can pick from (GET /api/users is admin-only). + const users = ref([]) + const usersLoaded = ref(false) + const usersError = ref(null) + + function entry(agent) { + if (!byAgent.value[agent]) { + byAgent.value[agent] = { roster: null, hasLoaded: false, error: null, lastLoadedAt: null } + } + return byAgent.value[agent] + } + + // Read without creating — safe inside a computed. + const EMPTY = Object.freeze({ roster: null, hasLoaded: false, error: null, lastLoadedAt: null }) + function peek(agent) { + return byAgent.value[agent] || EMPTY + } + + async function load(agent) { + const e = entry(agent) + try { + const { data } = await api.get(base(agent)) + e.roster = data + e.hasLoaded = true + e.error = null + e.lastLoadedAt = Date.now() + } catch (err) { + // A failed REFRESH keeps the roster on screen; the section says it is stale. + e.error = writeError(err, "Couldn't load who this agent serves.") + } + return e + } + + async function loadUsers() { + try { + const { data } = await api.get('/api/users') + users.value = (Array.isArray(data) ? data : []).filter((u) => !u.suspended_at) + usersLoaded.value = true + usersError.value = null + } catch (err) { + usersError.value = writeError(err, "Couldn't load the instance's users.") + } + } + + async function run(agent, step) { + if (step.op === 'create') return api.post(base(agent), step.body) + if (step.op === 'update') return api.patch(`${base(agent)}/${encodeURIComponent(step.id)}`, step.body) + return api.delete(`${base(agent)}/${encodeURIComponent(step.id)}`) + } + + async function add(agent, body) { + await run(agent, { op: 'create', body }) + await load(agent) + } + + async function setKind(agent, id, kind) { + await run(agent, { op: 'update', id, body: { kind } }) + await load(agent) + } + + async function remove(agent, id) { + await run(agent, { op: 'delete', id }) + await load(agent) + } + + /** + * Replace the primary as one flow (see `replacePrimarySteps`). If the new + * primary cannot be written, step one is undone so the agent keeps the + * primary it had, and the server's reason is rethrown for the form. + */ + async function replacePrimary(agent, opts) { + const roster = entry(agent).roster + const { steps, rollback } = replacePrimarySteps(roster?.assignments || [], opts) + let done = 0 + try { + for (const step of steps) { + await run(agent, step) + done += 1 + } + } catch (err) { + if (done > 0) { + for (const step of rollback) { + try { await run(agent, step) } catch { /* reported below by the reload */ } + } + } + await load(agent) + throw err + } + await load(agent) + } + + return { + byAgent, users, usersLoaded, usersError, + entry, peek, load, loadUsers, add, setKind, remove, replacePrimary, + } +}) diff --git a/src/frontend/src/utils/assignments.js b/src/frontend/src/utils/assignments.js new file mode 100644 index 0000000000..8f23881c8b --- /dev/null +++ b/src/frontend/src/utils/assignments.js @@ -0,0 +1,120 @@ +/** + * Agent assignments — who an agent serves (trinity-enterprise#810, over the + * ent#500 endpoints). Pure: the store runs the steps these return, the section + * renders what these decide, and the tests reach both without a mount. + * + * An assignment row is `{ id, user_id, display_name, role_id, kind, ... }`. + * A person may hold several kinds on one agent (one row per kind); an agent + * has at most ONE primary row — the backend enforces it with a partial unique + * index, so the replace flow below never asks for a second. + */ + +export const KINDS = ['primary', 'approver', 'collaborator', 'viewer'] + +export const KIND_LABELS = { + primary: 'Primary', + approver: 'Approver', + collaborator: 'Collaborator', + viewer: 'Viewer', +} + +/** The non-primary kinds, in the order the pickers offer them. */ +export const STAKEHOLDER_KINDS = KINDS.filter((k) => k !== 'primary') + +export function primaryOf(assignments) { + return (assignments || []).find((a) => a.kind === 'primary') || null +} + +/** Every row but the primary, grouped person by person, in kind order. */ +export function stakeholdersOf(assignments) { + return (assignments || []) + .filter((a) => a.kind !== 'primary') + .slice() + .sort((a, b) => (a.display_name || '').localeCompare(b.display_name || '') + || KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)) +} + +/** + * The section header's seat line. This cut knows one form: the agent serves + * the seat its primary holds (the role id on the primary's row). "Holds the + * seat" needs seat-holder storage that does not exist yet (ent#810 follow-up). + * + * @returns {{ form: 'serves', roleId: string, person: string } | { form: 'none' }} + */ +export function seatLine({ assignments = null, primary = null } = {}) { + const row = primaryOf(assignments) + if (row && row.role_id) return { form: 'serves', roleId: row.role_id, person: row.display_name } + if (!assignments && primary) return { form: 'none', person: primary } + return { form: 'none' } +} + +/** + * The steps that replace the primary without ever asking for two at once. + * + * The old primary row leaves FIRST — re-kinded to `oldBecomes`, or deleted when + * the old person is to be removed or already holds that kind (a second row of + * one kind for one person is refused by the backend) — and only then is the + * new primary row created. The new primary keeps any other kinds they hold; + * it is a new row, not a re-kinding of an existing one. + * + * `rollback` undoes step one, for the store to run if step two fails, so a + * failed replacement leaves the agent with the primary it had. + * + * @param {object[]} assignments the current rows + * @param {object} opts + * @param {number} opts.newUserId + * @param {string} opts.oldBecomes one of STAKEHOLDER_KINDS, or 'remove' + * @param {string} opts.roleId the seat the new primary holds + */ +export function replacePrimarySteps(assignments, { newUserId, oldBecomes, roleId }) { + const old = primaryOf(assignments) + const create = { op: 'create', body: { user_id: newUserId, role_id: roleId, kind: 'primary' } } + if (!old) return { steps: [create], rollback: [] } + if (old.user_id === newUserId) return { steps: [], rollback: [] } + + const oldHoldsKind = (assignments || []).some( + (a) => a.user_id === old.user_id && a.kind === oldBecomes) + if (oldBecomes !== 'remove' && !oldHoldsKind) { + return { + steps: [{ op: 'update', id: old.id, body: { kind: oldBecomes } }, create], + rollback: [{ op: 'update', id: old.id, body: { kind: 'primary' } }], + } + } + return { + steps: [{ op: 'delete', id: old.id }, create], + rollback: [{ op: 'create', body: { user_id: old.user_id, role_id: old.role_id, kind: 'primary' } }], + } +} + +/** + * Proactive-brief consent as the row can state it. The backend returns one + * boolean today, so "not asked" and "declined" are one state; the label says + * only what is known. + */ +export function consentLabel(row) { + return row?.proactive_consent ? 'Proactive briefs on' : 'Proactive briefs off' +} + +/** + * The role check on a row. Only `changed` is a warning; `unknown` is stated + * with its reason rather than hidden, because "no check has run" and "the + * role is fine" must not look alike. + * + * @returns {{ tone: 'warning'|'muted'|'ok', text: string }} + */ +export function driftNote(row) { + const state = row?.drift_state || 'unknown' + if (state === 'changed') { + return { tone: 'warning', text: 'The role file changed since this was assigned — review it' } + } + if (state === 'current') return { tone: 'ok', text: 'Role file unchanged' } + return { tone: 'muted', text: 'Role file not checked yet' } +} + +/** The backend's own words for a failed write, never a generic line first. */ +export function writeError(err, fallback) { + const detail = err?.response?.data?.detail + if (typeof detail === 'string' && detail.trim()) return detail.trim() + if (Array.isArray(detail) && detail[0]?.msg) return detail[0].msg + return fallback +} diff --git a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js new file mode 100644 index 0000000000..21dff0733d --- /dev/null +++ b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/** + * trinity-enterprise#810 — Agent Detail › Access: "People this agent serves". + * + * The admin UI over the ent#500 assignment endpoints. Mounted (#2918): every + * write is a predicate on what the admin sees and clicks, so the tests drive + * the rendered section against a stubbed API and read the requests it sends. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() })) +vi.mock('@/api', () => ({ default: api })) + +import AgentAssignmentsSection from '@/components/AgentAssignmentsSection.vue' +import { useAuthStore } from '@/stores/auth' +import { useEnterpriseStore } from '@/stores/enterprise' +import { replacePrimarySteps, seatLine, driftNote, writeError } from '@/utils/assignments' + +const AGENT = 'scout' +const ANN = { id: 'a1', user_id: 1, display_name: 'Ann Lee', role_id: 'cfo', kind: 'primary', drift_state: 'unknown', proactive_consent: true } +const BOB = { id: 'a2', user_id: 2, display_name: 'Bob Ray', role_id: 'cfo', kind: 'approver', drift_state: 'changed', proactive_consent: false } +const USERS = [ + { id: 1, username: 'ann', name: 'Ann Lee' }, + { id: 2, username: 'bob', name: 'Bob Ray' }, + { id: 3, username: 'cat', name: 'Cat Moe' }, + { id: 4, username: 'dan', name: 'Dan Off', suspended_at: '2026-01-01T00:00:00Z' }, +] + +let roster +function serve() { + api.get.mockImplementation(async (url) => { + if (url === '/api/users') return { data: USERS } + return { data: roster } + }) +} + +function render({ admin = true, entitled = true } = {}) { + const auth = useAuthStore() + auth.user = { role: admin ? 'admin' : 'user' } + auth.profileVerified = true + auth.isAuthenticated = true + const ent = useEnterpriseStore() + ent.enterpriseFeatures = entitled ? ['assignments'] : [] + ent.featureFlagsLoaded = true + return mount(AgentAssignmentsSection, { props: { agentName: AGENT }, attachTo: document.body }) +} + +const conflict = (detail) => Object.assign(new Error('409'), { response: { status: 409, data: { detail } } }) +const q = (w, id) => w.find(`[data-testid="${id}"]`) + +beforeEach(() => { + setActivePinia(createPinia()) + for (const fn of Object.values(api)) fn.mockReset() + roster = { agent_name: AGENT, assignments: [ANN, BOB], mine: null, primary: 'Ann Lee' } + serve() + api.post.mockResolvedValue({ data: {} }) + api.patch.mockResolvedValue({ data: {} }) + api.delete.mockResolvedValue({ data: {} }) +}) + +describe('gating', () => { + it('is absent, not broken, without the assignments module', async () => { + const w = render({ entitled: false }) + await flushPromises() + expect(q(w, 'agent-assignments').exists()).toBe(false) + expect(api.get).not.toHaveBeenCalled() + }) +}) + +describe('what an admin sees', () => { + it('shows the primary apart from the stakeholders, with the seat it serves', async () => { + const w = render() + await flushPromises() + expect(q(w, 'assignments-seat').text()).toBe('Serves the seat of its primary: cfo (Ann Lee)') + expect(q(w, 'assignment-primary').text()).toContain('Ann Lee') + expect(q(w, 'assignment-primary').text()).toContain('Primary') + const rows = w.findAll('[data-testid="assignment-row"]') + expect(rows).toHaveLength(1) + expect(rows[0].text()).toContain('Bob Ray') + }) + + it('shows consent and a changed role file as a warning on its row', async () => { + const w = render() + await flushPromises() + expect(q(w, 'assignment-primary').find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs on') + const bob = w.find('[data-testid="assignment-row"]') + expect(bob.find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs off') + expect(bob.find('[data-testid="assignment-drift"]').text()).toContain('role file changed') + }) + + it('warns when there is no primary, and names the next action when nobody is assigned', async () => { + roster = { agent_name: AGENT, assignments: [], mine: null, primary: null } + const w = render() + await flushPromises() + expect(q(w, 'assignments-no-primary').text()).toContain('nowhere to go') + expect(q(w, 'assignments-empty').text()).toContain('Add the person this agent coaches first.') + expect(q(w, 'assignments-seat').text()).toBe('No seat yet') + }) +}) + +describe('writes', () => { + it('adds a person picked by name, with the seat filled in, and reloads', async () => { + const w = render() + await flushPromises() + const options = q(w, 'add-user').findAll('option').map((o) => o.text()) + expect(options).toContain('Cat Moe') + expect(options).not.toContain('Dan Off') // suspended + expect(q(w, 'add-role').element.value).toBe('cfo') // the agent's seat + await q(w, 'add-user').setValue('3') + await q(w, 'add-kind').setValue('viewer') + await q(w, 'assignment-add-form').trigger('submit') + await flushPromises() + expect(api.post).toHaveBeenCalledWith(`/api/enterprise/assignments/agents/${AGENT}`, { user_id: 3, role_id: 'cfo', kind: 'viewer' }) + expect(api.get.mock.calls.filter(([u]) => u.startsWith('/api/enterprise/assignments')).length).toBe(2) + }) + + it('surfaces the backend error by name when a write is refused', async () => { + api.post.mockRejectedValueOnce(conflict('That assignment already exists, or this agent already has a primary human.')) + const w = render() + await flushPromises() + await q(w, 'add-user').setValue('2') + await q(w, 'assignment-add-form').trigger('submit') + await flushPromises() + expect(w.text()).toContain('That assignment already exists, or this agent already has a primary human.') + }) + + it('changes a kind with one action on the row', async () => { + const w = render() + await flushPromises() + await w.find('[data-testid="assignment-row"] [data-testid="assignment-kind"]').setValue('viewer') + await flushPromises() + expect(api.patch).toHaveBeenCalledWith(`/api/enterprise/assignments/agents/${AGENT}/a2`, { kind: 'viewer' }) + }) + + it('removes a stakeholder', async () => { + const w = render() + await flushPromises() + await w.find('[data-testid="assignment-row"] [data-testid="assignment-remove"]').trigger('click') + await flushPromises() + expect(api.delete).toHaveBeenCalledWith(`/api/enterprise/assignments/agents/${AGENT}/a2`) + }) +}) + +describe('replacing the primary', () => { + async function replaceWith(w, userId, oldBecomes) { + await q(w, 'assignment-replace').trigger('click') + await q(w, 'replace-user').setValue(String(userId)) + await q(w, 'replace-old-becomes').setValue(oldBecomes) + await q(w, 'assignment-replace-form').trigger('submit') + await flushPromises() + } + + it('demotes the old primary before writing the new one', async () => { + const order = [] + api.patch.mockImplementation(async (url, body) => { order.push(['patch', url, body]); return { data: {} } }) + api.post.mockImplementation(async (url, body) => { order.push(['post', url, body]); return { data: {} } }) + const w = render() + await flushPromises() + await replaceWith(w, 3, 'viewer') + expect(order).toEqual([ + ['patch', `/api/enterprise/assignments/agents/${AGENT}/a1`, { kind: 'viewer' }], + ['post', `/api/enterprise/assignments/agents/${AGENT}`, { user_id: 3, role_id: 'cfo', kind: 'primary' }], + ]) + expect(q(w, 'assignment-replace-form').exists()).toBe(false) + }) + + it('puts the old primary back when the new one cannot be written', async () => { + api.post.mockRejectedValueOnce(conflict('User not found')) + const w = render() + await flushPromises() + await replaceWith(w, 3, 'viewer') + expect(api.patch.mock.calls).toEqual([ + [`/api/enterprise/assignments/agents/${AGENT}/a1`, { kind: 'viewer' }], + [`/api/enterprise/assignments/agents/${AGENT}/a1`, { kind: 'primary' }], + ]) + expect(q(w, 'assignment-replace-form').text()).toContain('User not found') + }) +}) + +describe('who cannot change it', () => { + it('shows a non-admin the same section read-only, controls hidden', async () => { + const w = render({ admin: false }) + await flushPromises() + expect(q(w, 'assignment-primary').text()).toContain('Ann Lee') + expect(q(w, 'assignment-add-form').exists()).toBe(false) + expect(q(w, 'assignment-kind').exists()).toBe(false) + expect(q(w, 'assignment-remove').exists()).toBe(false) + expect(q(w, 'assignment-replace').exists()).toBe(false) + expect(q(w, 'assignments-readonly-note').text()).toBe('Only an instance admin can change who this agent serves.') + expect(api.get).not.toHaveBeenCalledWith('/api/users') + }) + + it('shows a shared user only what the backend gives them', async () => { + roster = { agent_name: AGENT, assignments: null, mine: { ...BOB }, primary: 'Ann Lee' } + const w = render({ admin: false }) + await flushPromises() + expect(q(w, 'assignments-limited').text()).toContain('Primary: Ann Lee') + expect(q(w, 'assignments-limited').text()).toContain('You are an approver on this agent.') + }) + + it('names a failed first load and retries', async () => { + api.get.mockRejectedValueOnce(conflict('Agent not found')) + const w = render() + await flushPromises() + expect(w.text()).toContain('Agent not found') + }) +}) + +describe('helpers', () => { + const rows = [ANN, BOB] + + it('replaces with no current primary by creating one', () => { + expect(replacePrimarySteps([], { newUserId: 3, oldBecomes: 'viewer', roleId: 'cfo' })).toEqual({ + steps: [{ op: 'create', body: { user_id: 3, role_id: 'cfo', kind: 'primary' } }], rollback: [], + }) + }) + + it('deletes the old primary row when the old person already holds that kind', () => { + const both = [ANN, { ...BOB, id: 'a3', user_id: 1, kind: 'approver' }] + const { steps, rollback } = replacePrimarySteps(both, { newUserId: 3, oldBecomes: 'approver', roleId: 'cfo' }) + expect(steps[0]).toEqual({ op: 'delete', id: 'a1' }) + expect(rollback).toEqual([{ op: 'create', body: { user_id: 1, role_id: 'cfo', kind: 'primary' } }]) + }) + + it('deletes and can restore when the old primary is removed', () => { + const { steps } = replacePrimarySteps(rows, { newUserId: 3, oldBecomes: 'remove', roleId: 'cfo' }) + expect(steps.map((s) => s.op)).toEqual(['delete', 'create']) + }) + + it('does nothing when the new primary is the current one', () => { + expect(replacePrimarySteps(rows, { newUserId: 1, oldBecomes: 'viewer', roleId: 'cfo' }).steps).toEqual([]) + }) + + it('states an unchecked role file rather than hiding it', () => { + expect(driftNote({ drift_state: 'unknown' })).toEqual({ tone: 'muted', text: 'Role file not checked yet' }) + expect(driftNote({ drift_state: 'current' }).tone).toBe('ok') + }) + + it('reads the seat from the primary row only', () => { + expect(seatLine({ assignments: [BOB] })).toEqual({ form: 'none' }) + expect(writeError({ response: { data: { detail: [{ msg: 'field required' }] } } }, 'x')).toBe('field required') + }) +}) From c1246046a540ff67db59c9fcfc7972aa54a8481c Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 6 Oct 2026 15:21:43 +0300 Subject: [PATCH 02/14] =?UTF-8?q?feat(access):=20seats=20on=20the=20assign?= =?UTF-8?q?ments=20section=20=E2=80=94=20an=20agent=20holding=20one,=20opt?= =?UTF-8?q?ional=20and=20clearable=20roles=20(abilityai/trinity-enterprise?= =?UTF-8?q?#811)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public half of ent#811, on top of the ent#810 section. - services/assignment_provider: the optional provider method seat_for and resolve_seat(agent), which answers which seat an agent holds itself or serves through its primary with no triggering user. Always a dict, never a raise: no provider, a provider without seat_for, no answer, a raise or a malformed shape all read as "no seat" with a named reason, the failure direction the readiness gate needs. - The section's header has both forms: "Holds the seat: " (the agent itself, which wins) or "Serves the seat of its primary". An admin can record, change or clear the seat the agent holds; a refusal names the agent that already holds it. - The seat on a row is optional when adding or replacing the primary, and a row's seat can be added, changed or cleared (explicit null). - With no primary yet, the add form starts on Primary. Co-Authored-By: Claude Opus 5.5 --- src/backend/services/assignment_provider.py | 62 ++++++++ .../components/AgentAssignmentsSection.vue | 134 ++++++++++++++++-- src/frontend/src/stores/assignments.js | 18 +++ src/frontend/src/utils/assignments.js | 25 +++- .../agentAssignmentsSection.mount.spec.js | 84 +++++++++++ tests/unit/test_ent811_resolve_seat.py | 102 +++++++++++++ 6 files changed, 408 insertions(+), 17 deletions(-) create mode 100644 tests/unit/test_ent811_resolve_seat.py diff --git a/src/backend/services/assignment_provider.py b/src/backend/services/assignment_provider.py index a5ce0ed296..3a3adba75e 100644 --- a/src/backend/services/assignment_provider.py +++ b/src/backend/services/assignment_provider.py @@ -84,6 +84,14 @@ def assignment_for( # the value addresses a person, it is never rendered into a prompt. # def people_for(self, agent_name: str, role: str) -> Optional[dict]: ... + # OPTIONAL (trinity-enterprise#811): which seat ``agent_name`` HOLDS itself or + # SERVES through its primary, asked with no triggering user — the readiness + # gate, the role card and the objective join ask on their own. Shaped as + # ``{"case": "holds"|"serves"|"none", "role_id": str|None, "seats": [str]}``; + # seat ids only, never a person. Read through ``getattr`` by + # :func:`resolve_seat`, which answers "no seat" for every degraded case. + # def seat_for(self, agent_name: str) -> Optional[dict]: ... + _provider: Optional[AssignmentProvider] = None @@ -238,3 +246,57 @@ def resolve_assignment( ) return None return _validated(answer, agent_name) + + +#: The answer :func:`resolve_seat` gives when nothing better is known. +SEAT_CASES = ("holds", "serves", "none") + + +def _no_seat(reason: str) -> Dict[str, Any]: + return {"case": "none", "role_id": None, "seats": [], "reason": reason} + + +def resolve_seat(agent_name: Optional[str]) -> Dict[str, Any]: + """Which seat ``agent_name`` holds or serves (trinity-enterprise#811). + + Always a dict, never ``None`` and never a raise: the callers (the readiness + gate above all) must treat ambiguity as "no seat", and a caller that had to + tell ``None`` from a dict would be one more place to get that wrong. + + ``{"case", "role_id", "seats", "reason"}``. ``reason`` is ``"provider"`` when + a registered provider answered, else why the answer is "no seat": + ``no_agent`` / ``no_provider`` / ``unsupported`` (a provider without + ``seat_for``) / ``no_answer`` / ``error`` / ``malformed``. + """ + if not agent_name: + return _no_seat("no_agent") + provider = _provider + if provider is None: + return _no_seat("no_provider") + seat_for = getattr(provider, "seat_for", None) + if seat_for is None: + return _no_seat("unsupported") + try: + answer = seat_for(agent_name) + except Exception: # noqa: BLE001 — a provider bug must never break a gate + logger.warning("[assignment_provider] provider.seat_for failed for %s; " + "answering no seat", agent_name, exc_info=True) + return _no_seat("error") + if answer is None: + return _no_seat("no_answer") + if not isinstance(answer, Mapping): + return _no_seat("malformed") + case = answer.get("case") + role_id = answer.get("role_id") + seats = answer.get("seats") + if (case not in SEAT_CASES + or (role_id is not None and not isinstance(role_id, str)) + or (case != "none" and not role_id) + or not isinstance(seats, (list, tuple)) + or not all(isinstance(v, str) for v in seats)): + logger.warning("[assignment_provider] provider.seat_for returned a malformed " + "answer for %s; answering no seat", agent_name) + return _no_seat("malformed") + if case == "none": + role_id = None + return {"case": case, "role_id": role_id, "seats": list(seats), "reason": "provider"} diff --git a/src/frontend/src/components/AgentAssignmentsSection.vue b/src/frontend/src/components/AgentAssignmentsSection.vue index 82d8f97cbe..51a23af4a4 100644 --- a/src/frontend/src/components/AgentAssignmentsSection.vue +++ b/src/frontend/src/components/AgentAssignmentsSection.vue @@ -6,7 +6,10 @@

People this agent serves

-