/** * Work Inbox — the `agent_disposition` source. * * ## Why this source exists * * `dispositionService` raises a proposal for a human when the auto-approve rule * declines to act. That work belongs to nobody in particular: the module cannot * pick a tenant user without inventing routing policy that belongs in the * workflow definition, and it cannot name a role because `assignedToRoles` holds * tenant role NAMES that a rename would orphan silently. Under §6.4 — * "you see a task iff it is yours" — an item with no owner is an item nobody * sees, so without a visibility class of its own the whole queue would go dark. * * The class is `administrativeQueueFeature`: an ACL feature that admits a * principal to a source's unassigned rows, declaring "this kind is an * administrative queue, not personal work". Here it is * `agent_orchestrator.proposals.view`, which the seeded `admin`, `employee`, * `operator` and `engineer` roles already hold — so the population that can see * disposition work is exactly the population that could act on it before, while * a workflows-only user with no orchestrator grant stops seeing it. That is the * intended narrowing. * * ## What it lists, and why not the `UserTask` rows * * The `AgentProposal` rows awaiting human disposition — NOT the shadow * `UserTask` rows `dispositionService` also writes. Three reasons: * * 1. **Disposing the proposal is what unblocks the run.** A parked * `INVOKE_AGENT` resumes from the dispose path, never from the task row, so * the proposal is the work item and the task row is a pointer at it. * 2. **Listing the task rows here would double-list them.** They are ordinary * `user_tasks`, already served by core's `user_task` source, and core cannot * exclude them without learning an enterprise-owned discriminator — exactly * the coupling the provider contract exists to avoid. A tenant `admin` holds * both grants and would see every disposition item twice. * 3. **`dispositionService.ts` stays untouched.** The auto-approve boundary it * guards is not moved, so its Ask-First gate does not fire. * * The shadow `UserTask` rows therefore remain visible to `workflows.tasks.view_all` * holders through core's source, which is the correct reading of what they are: * administrative residue. Nothing closes them today — that is defect A7, filed * separately and deliberately not fixed here. */ import type { EntityManager, FilterQuery } from '@mikro-orm/core' import type { WorkInboxAction, WorkInboxQuery, WorkInboxRow, WorkInboxScope, WorkInboxSourceContext, WorkInboxSourceProvider, WorkInboxSourceResult, } from '@open-mercato/core/modules/workflows/lib/work-inbox/provider' import { deriveWorkInboxOverdue, normalizeWorkInboxPriority, } from '@open-mercato/core/modules/workflows/lib/work-inbox/provider' import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate' import type { TranslateWithFallbackFn } from '@open-mercato/shared/lib/i18n/translate' import { AgentProposal } from '../../data/entities' import { ensureAgentsLoaded, getAgentEntry } from '../sdk/defineAgent' export const AGENT_ORCHESTRATOR_MODULE_ID = 'agent_orchestrator' export const AGENT_DISPOSITION_INBOX_KIND = 'agent_disposition' /** * The queue feature. Concrete rather than a wildcard, and matched with the * platform's wildcard-aware matcher wherever it is checked, so `admin`'s * `agent_orchestrator.*` satisfies it. */ export const AGENT_DISPOSITION_QUEUE_FEATURE = 'agent_orchestrator.proposals.view' /** * One action, and it is a navigation rather than a POST: disposing needs the * operator to read the payload and choose approve / edit / reject, which is the * Caseload's job. A one-shot endpoint here would be a second, unreviewed way to * dispose. */ export const AGENT_DISPOSITION_ACTIONS: WorkInboxAction[] = [] /** Proposals a human still has to answer for. */ const PENDING_DISPOSITION = 'pending' /** * The status EVERY row this source projects carries. * * It is a constant rather than a column read: the query already narrows to * `disposition: 'pending'`, so an undisposed proposal is pending work by * construction. Named because the inbox's status filter has to be answered * against it — see `agentDispositionSourceMatchesQuery`. */ export const AGENT_DISPOSITION_ROW_STATUS = 'PENDING' /** * Rows produced by an eval replay are a record of what the agent proposed, not * work for an operator — the same `source: 'runtime'` floor the proposals list * route applies, and for the same reason: an allowlist keeps the queue closed by * default, so a future source has to opt in rather than silently appear in * somebody's inbox. */ const RUNTIME_SOURCE = 'runtime' /** * Whether this source can contribute to a NARROWED query at all. * * Its projection is a constant in every row-level dimension the inbox filters * on: each row is `PENDING`, unprioritized, undated (so never overdue) and * ownerless (no assignee, no claimant, no role queue, no entity binding). None * of that is expressible as a column predicate on `agent_proposals`, so the * filters have to be answered against the constant here — and answering them by * ignoring them is the defect this exists to prevent: with the inbox asking for * `status=COMPLETED`, an ignored filter returned the whole disposition queue * alongside the completed tasks, and every other status returned it alone. * * `myWork` is deliberately NOT answered here. An administrative-queue item * belongs to nobody on purpose, and the inbox opens with `myWork` on, so * treating it as a row-level filter would hide the very queue this source * exists to raise. Admission to these rows is the queue feature's job * (`administrativeQueueFeature`), not the assignment filter's. */ export function agentDispositionSourceMatchesQuery(query: WorkInboxQuery): boolean { if ( query.statuses && query.statuses.length > 0 && !query.statuses.includes(AGENT_DISPOSITION_ROW_STATUS) ) { return false } if (query.priorities && query.priorities.length > 0) return false if (query.roles && query.roles.length > 0) return false if (query.entityTypes && query.entityTypes.length > 0) return false if (query.assignedTo) return false if (query.overdueOnly) return false return true } export function buildAgentDispositionWhere( query: WorkInboxQuery, scope: WorkInboxScope, ): FilterQuery { const where: Record = { tenantId: scope.tenantId, disposition: PENDING_DISPOSITION, source: RUNTIME_SOURCE, deletedAt: null, } if (scope.organizationIds) { where.organizationId = { $in: scope.organizationIds } } if (query.workflowInstanceId) { where.workflowInstanceId = query.workflowInstanceId } return where as FilterQuery } /** i18n key for the row title, so the key lives next to the row that uses it. */ export const AGENT_DISPOSITION_TITLE_KEY = 'agent_orchestrator.workInbox.disposition.title' /** English source text — the value every locale file carries under the key. */ export const AGENT_DISPOSITION_TITLE_FALLBACK = 'Agent proposal to review: {agent}' /** * Everything the row projection needs that is NOT on the proposal row. * * `WorkInboxRow.title` is a rendered string on the wire — core's inbox has no * translation-key channel for it — so the string has to be resolved here, in * the request that serves the page. Passing the translator in (rather than * calling `resolveTranslations()` inside the projection) keeps * `toAgentDispositionRow` a pure function of its inputs, which is what makes * the "never English chrome" invariant testable. * * `agentLabels` turns the registry id into the name the operator sees on every * other screen; an id with no registry entry falls back to the id itself, * because inventing a prettier name for an agent that is no longer registered * would be a lie. */ export type AgentDispositionRowPresentation = { translate: TranslateWithFallbackFn agentLabels?: ReadonlyMap } /** * A proposal has no assignee, no claimant, no queue and no due date — every one * of those is `null` on purpose rather than invented, because a fabricated * assignee is what the whole administrative-queue class exists to avoid. The * source's `administrativeQueueFeature` is what makes the row visible. */ export function toAgentDispositionRow( proposal: AgentProposal, now: Date, presentation: AgentDispositionRowPresentation, ): WorkInboxRow { const createdAt = proposal.createdAt.toISOString() const agent = presentation.agentLabels?.get(proposal.agentId) || proposal.agentId return { id: proposal.id, kind: AGENT_DISPOSITION_INBOX_KIND, moduleId: AGENT_ORCHESTRATOR_MODULE_ID, title: presentation.translate(AGENT_DISPOSITION_TITLE_KEY, AGENT_DISPOSITION_TITLE_FALLBACK, { agent, }), description: null, status: AGENT_DISPOSITION_ROW_STATUS, priority: normalizeWorkInboxPriority(null), dueDate: null, overdue: deriveWorkInboxOverdue(null, AGENT_DISPOSITION_ROW_STATUS, now), createdAt, updatedAt: proposal.updatedAt.toISOString(), assignedTo: null, assignedToRoles: null, claimedBy: null, entityTypes: [], entityBindings: [], detailHref: `/backend/caseload/${proposal.id}`, actions: AGENT_DISPOSITION_ACTIONS, // `proposalId` is what the existing "Review proposal" row action reads, and // the API spreads `details` UNDER the common fields so it survives onto the // wire without shadowing anything. details: { proposalId: proposal.id, agentId: proposal.agentId, runId: proposal.runId, workflowInstanceId: proposal.workflowInstanceId ?? null, stepId: proposal.stepId ?? null, confidence: proposal.confidence ?? null, }, } } /** * The request's translator, or an English-only one. * * `resolveTranslations` is imported lazily because it pulls `server-only`, and * this file is reachable from module registration; it also throws outside a * Next request scope (a CLI invocation, a unit test), where degrading to the * fallback text is the honest answer rather than failing the queue. */ async function resolveWorkInboxTranslator(): Promise { try { const { resolveTranslations } = await import('@open-mercato/shared/lib/i18n/server') return (await resolveTranslations()).translate } catch { return createFallbackTranslator({}) } } /** * Registry labels for the agents on THIS page only — the projection never needs * the whole registry, and a missing entry is left to fall back to the id. */ async function resolveAgentLabels(agentIds: string[]): Promise> { const labels = new Map() if (agentIds.length === 0) return labels try { await ensureAgentsLoaded() } catch { return labels } for (const agentId of new Set(agentIds)) { const label = getAgentEntry(agentId)?.label if (label) labels.set(agentId, label) } return labels } async function resolveAgentDispositionPresentation( agentIds: string[], ): Promise { const [translate, agentLabels] = await Promise.all([ resolveWorkInboxTranslator(), resolveAgentLabels(agentIds), ]) return { translate, agentLabels } } async function listAgentDispositionWorkItems( query: WorkInboxQuery, context: WorkInboxSourceContext, ): Promise { // A narrowing the constant projection cannot satisfy drops the source before // it costs a query — and, crucially, before it contributes to `total`: a row // counted in the pagination total and then absent from the page is the same // "short page, lying total" failure the inbox guards against everywhere else. if (!agentDispositionSourceMatchesQuery(query)) { return { rows: [], total: 0 } } // The source is dropped entirely for a caller without the queue feature // (`selectWorkInboxSources`), so reaching here already means admitted. Tenant // and organization scoping is still applied on every query — the queue feature // says which KIND of work you may see, never whose. const em = context.resolve('em') const [proposals, total] = await em.findAndCount( AgentProposal, buildAgentDispositionWhere(query, context.scope), { orderBy: [{ createdAt: 'DESC' }], limit: query.offset + query.limit, offset: 0, }, ) const presentation = await resolveAgentDispositionPresentation( proposals.map((proposal) => proposal.agentId), ) return { rows: proposals.map((proposal) => toAgentDispositionRow(proposal, query.now, presentation)), total, } } export const agentDispositionWorkInboxSource: WorkInboxSourceProvider = { kind: AGENT_DISPOSITION_INBOX_KIND, moduleId: AGENT_ORCHESTRATOR_MODULE_ID, administrativeQueueFeature: AGENT_DISPOSITION_QUEUE_FEATURE, actions: AGENT_DISPOSITION_ACTIONS, list: listAgentDispositionWorkItems, }