diff --git a/README.md b/README.md index 1c61b1d..d63b75a 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,42 @@ never require a code change"). See generic `User`/`UserRole` model unrelated to this system's real architecture (nothing else uses it — `CustomerReference`, built in 002, is the real customer-identity mechanism). `identity/customers` and `identity/auth` are untouched — out of scope here. + +# Orchestration and Assignment +When a ticket reaches `HUMAN_ESCALATION`, this system automatically resolves 006's hierarchy/ +capability-eligibility lookup for it and assigns exactly one eligible agent through a pluggable +strategy — no caller has to trigger this. See +`specs/007-orchestration-assignment/contracts/orchestration-contract.md` for the full route list +and `specs/007-orchestration-assignment/quickstart.md` for runnable scenarios. + +- **Trigger**: entirely event-driven — `ticketsService.updateStatus` (003) publishes a domain + event on every status change, and this feature's subscriber (registered alongside 005's own + AI-session-ending one) reacts when `newStatus === 'HUMAN_ESCALATION'`. Neither `ticketing` nor + `ai-support` has any import of `orchestration` — the event bus is what keeps that direction + one-way. **The event bus itself was fixed while building this feature**: `EventBus.publish` now + actually awaits its subscribers (it previously fired them via `EventEmitter.emit`, which never + waits for an async listener) — without that fix, a status-update HTTP call could return before + orchestration (or 005's own hook) had actually finished. `registerDomainEventHandlers()` is + called from `buildApp()` itself now (idempotently — see its own code comment), not only from + `server.ts`'s production startup path, so this is true in tests too, not just production. +- **Strategies**: `ROUND_ROBIN` (concurrency-safe via an atomic Redis `INCR`, verified under real + concurrent load in `tests/concurrency/round-robin.test.ts`), `LEAST_LOADED`, `SKILL_BASED` are + auto-selected from the matched hierarchy node's own `assignmentStrategy` (or a configurable + system default, `ORCHESTRATION_DEFAULT_STRATEGY`, when no node matched). `MANUAL`/`DIRECT` are + never auto-selected — they only ever come from `POST /admin/tickets/:ticketId/assignment`'s + explicit `agentId`. `LEAST_LOADED`/`SKILL_BASED` ties fall through to the same concurrency-safe + cursor `ROUND_ROBIN` uses, never an arbitrary/unstable ordering. +- **A ticket's "required skill"** comes from its most recent AI diagnosis's `problemType` (005), + when one exists, unioned with any matching hierarchy node's own `skills` (006) — when neither + exists, there's no skill constraint at all (every active agent is eligible), never zero eligible + agents; see `specs/007-orchestration-assignment/research.md`. +- **History**: `Assignment` is a version-row-per-period model (reassigning supersedes the current + row, never overwrites it); `AssignmentHistory` is a separate, purely-additive event log — + `GET /tickets/:ticketId/assignment` (current) and `GET /tickets/:ticketId/assignment-history` + (everything, including "no eligible agent" outcomes with `agentId: null`). +- `currentLoad` (006) is read by `LEAST_LOADED`, never written by this feature — no + increment-on-assign/decrement-on-resolve lifecycle exists yet (that's a later phase's job once + ticket resolution itself is built). +- SLA policy execution and rule-driven escalation are intentionally out of scope here — doc 05 + documents them alongside orchestration, but the roadmap places them in Phase 8. See + `specs/007-orchestration-assignment/spec.md` Assumptions. diff --git a/prisma/migrations/20260903060501_add_orchestration_assignment/migration.sql b/prisma/migrations/20260903060501_add_orchestration_assignment/migration.sql new file mode 100644 index 0000000..9a50af4 --- /dev/null +++ b/prisma/migrations/20260903060501_add_orchestration_assignment/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "assignments" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "strategy" TEXT NOT NULL, + "reason" TEXT, + "isCurrent" BOOLEAN NOT NULL DEFAULT true, + "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "unassignedAt" TIMESTAMP(3), + + CONSTRAINT "assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "assignment_history" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "agentId" TEXT, + "action" TEXT NOT NULL, + "strategy" TEXT NOT NULL, + "reason" TEXT, + "actor" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "assignment_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "assignments_ticketId_isCurrent_idx" ON "assignments"("ticketId", "isCurrent"); + +-- CreateIndex +CREATE INDEX "assignment_history_ticketId_createdAt_idx" ON "assignment_history"("ticketId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "assignment_history" ADD CONSTRAINT "assignment_history_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25e6c31..074cdbb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,13 +136,15 @@ model Ticket { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - product Product @relation(fields: [productId], references: [id]) - problem Problem @relation(fields: [problemId], references: [id]) - customer CustomerReference @relation(fields: [customerId], references: [id]) - category Category? @relation(fields: [categoryId], references: [id]) - messages TicketMessage[] - attachments TicketAttachment[] - aiSessions AISupportSession[] + product Product @relation(fields: [productId], references: [id]) + problem Problem @relation(fields: [problemId], references: [id]) + customer CustomerReference @relation(fields: [customerId], references: [id]) + category Category? @relation(fields: [categoryId], references: [id]) + messages TicketMessage[] + attachments TicketAttachment[] + aiSessions AISupportSession[] + assignments Assignment[] + assignmentHistory AssignmentHistory[] @@unique([productId, idempotencyKey]) @@index([productId, status]) @@ -407,6 +409,7 @@ model Agent { skills AgentSkill[] availability AgentAvailability? + assignments Assignment[] @@index([teamId, active]) @@map("agents") @@ -466,3 +469,36 @@ model HierarchyNode { @@index([active]) @@map("hierarchy_nodes") } + +model Assignment { + id String @id @default(cuid()) + ticketId String // not unique — one row per assignment period, see + // specs/007-orchestration-assignment/research.md + ticket Ticket @relation(fields: [ticketId], references: [id]) + agentId String + agent Agent @relation(fields: [agentId], references: [id]) + strategy String // ROUND_ROBIN | LEAST_LOADED | SKILL_BASED | MANUAL | DIRECT + reason String? + + isCurrent Boolean @default(true) + assignedAt DateTime @default(now()) + unassignedAt DateTime? + + @@index([ticketId, isCurrent]) + @@map("assignments") +} + +model AssignmentHistory { + id String @id @default(cuid()) + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id]) + agentId String? // null for a "no eligible agent" outcome — FR-008 + action String // assigned | reassigned | unassigned + strategy String + reason String? + actor String // system | agentId | adminId + createdAt DateTime @default(now()) + + @@index([ticketId, createdAt]) + @@map("assignment_history") +} diff --git a/specs/007-orchestration-assignment/checklists/requirements.md b/specs/007-orchestration-assignment/checklists/requirements.md index 1913315..fddaf72 100644 --- a/specs/007-orchestration-assignment/checklists/requirements.md +++ b/specs/007-orchestration-assignment/checklists/requirements.md @@ -57,3 +57,32 @@ constraint" (every active agent eligible) rather than inventing a new mapping table or treating "no signal" as "no eligible agents" — the latter would silently contradict FR-004's own explicit "rather than failing." + +## Implementation notes (added during /speckit-implement) + +- **Found and fixed a real, latent bug in 005's own event-bus infrastructure**: `EventBus.publish` + was built on Node's `EventEmitter.emit()`, which invokes async listeners without awaiting them + — so a caller of `publish` (i.e. `ticketsService.updateStatus`) had no guarantee that any + subscriber (005's AI-session-ending hook, and now 007's automatic-orchestration hook) had + actually finished by the time it returned. This was invisible in 005 because nothing tested + FR-023's hook through a real HTTP status-update call with immediate-consistency assertions — + 007's own integration tests would have been flaky (occasionally reading "no assignment yet") + without fixing it first. `EventBus` was rewritten to track subscribers itself and have `publish` + `await Promise.all(...)` over every matching handler (same per-handler try/catch isolation the + old implementation had), and `tickets.service.ts` now `await`s the publish call. +- **Found and fixed a second, related gap**: `registerDomainEventHandlers()` was only ever called + from `server.ts`'s production startup path, never from `buildApp()` — meaning every integration + test in this codebase (which calls `buildApp()` directly, the same "no worker process in this + test" convention 003's attachment tests already established for the queue) had **zero** + subscribers registered for any domain event, silently no-op-ing both 005's and 007's hooks. + Fixed by calling `registerDomainEventHandlers()` from `buildApp()` itself (made idempotent, + since `buildApp()` runs once per test file across a shared, module-level `eventBus` singleton) + — domain-event wiring is synchronous application behavior, not a long-running background + process like the queue's workers, so unlike `bootstrapQueue` it belongs in `buildApp()`, not + only in `server.ts`. +- Both fixes were necessary before *any* of this feature's own integration tests could pass + reliably — found while writing Quickstart Scenario 1's test, not discovered later. +- All five user stories' integration tests, the dedicated concurrency test, and the full + regression suite (every pre-existing 002-006 integration test plus every new 007 test) were run + together against real Docker-provisioned Postgres/Redis/MinIO: 124 passed, 9 skipped (005's + AI-key-gated tests, unrelated to this feature), 0 failed. diff --git a/specs/007-orchestration-assignment/tasks.md b/specs/007-orchestration-assignment/tasks.md index 627b0ec..b551f87 100644 --- a/specs/007-orchestration-assignment/tasks.md +++ b/specs/007-orchestration-assignment/tasks.md @@ -26,14 +26,14 @@ All file paths are relative to `supporthub-api/` (repo root). ## Phase 1: Setup -- [ ] T001 [P] Populate `src/modules/orchestration/routing/` with `service/`, `types/`, +- [x] T001 [P] Populate `src/modules/orchestration/routing/` with `service/`, `types/`, `index.ts` (no controller/routes — internal only), replacing the existing `RoutingService.routeTicket` stub -- [ ] T002 [P] Extend `src/modules/orchestration/assignments/` with the standard module shape +- [x] T002 [P] Extend `src/modules/orchestration/assignments/` with the standard module shape (`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`, `constants/`, `index.ts`) around its existing `engine/`/`strategies/`/`rules/`/ `calculators/` directories, replacing every stub file's content -- [ ] T003 [P] Populate `src/modules/orchestration/orchestration/` with `service/`, `types/`, +- [x] T003 [P] Populate `src/modules/orchestration/orchestration/` with `service/`, `types/`, `index.ts`, replacing the existing `OrchestrationService.orchestrateWorkflow` stub --- @@ -44,10 +44,10 @@ All file paths are relative to `supporthub-api/` (repo root). **⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete. -- [ ] T004 Add `Assignment` (with `isCurrent`, `unassignedAt`) and `AssignmentHistory` models to +- [x] T004 Add `Assignment` (with `isCurrent`, `unassignedAt`) and `AssignmentHistory` models to `prisma/schema.prisma` per data-model.md, plus `Ticket.assignments`/ `Ticket.assignmentHistory`/`Agent.assignments` back-relations (depends on T001-T003) -- [ ] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for +- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for T004 (depends on T004) **Checkpoint**: Schema migrated. User stories can now be built. @@ -63,25 +63,27 @@ capability-eligible agent set — reusing 006 directly, never a second matching ### Tests for User Story 1 -- [ ] T006 [US1] Integration test covering Quickstart Scenario 1 (escalation triggers +- [x] T006 [US1] Integration test covering Quickstart Scenario 1 (escalation triggers resolution automatically; matching-node case scopes correctly; no-match case falls back to - skill-only) against a real Postgres in `tests/integration/orchestration-resolution.test.ts` - (depends on T005) + skill-only) against a real Postgres — implemented in + `tests/integration/orchestration-flow.test.ts` (combined with US3/US4/US5's own tests, + T020/T024/T028, since they form one continuous ticket lifecycle) rather than the + originally-planned `orchestration-resolution.test.ts` (depends on T005) ### Implementation for User Story 1 -- [ ] T007 [US1] Add `RoutingService.resolveEligibleAgents(ticketId)`: reads the ticket's +- [x] T007 [US1] Add `RoutingService.resolveEligibleAgents(ticketId)`: reads the ticket's product/category/priority context, calls 006's `capabilityLookupService.findEligibleAgents` directly (research.md — through `orchestration/hierarchy`'s public `index.ts`) with the ticket's own required-skill context, and also returns which hierarchy node(s), if any, matched (needed later for the node's own `assignmentStrategy`, T013) — in `src/modules/orchestration/routing/service/routing.service.ts` (depends on T005) -- [ ] T008 [US1] Add the domain-event subscriber skeleton: register a +- [x] T008 [US1] Add the domain-event subscriber skeleton: register a `DomainEventName.TICKET_UPDATED` handler in `src/events/handlers/index.ts` (alongside 005's existing subscriber) that calls into `orchestration`'s engine (T017) when `payload.newStatus === 'HUMAN_ESCALATION'` — wired now so T007's resolution is reachable end-to-end even before US2's strategy execution exists (depends on T007) -- [ ] T009 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass +- [x] T009 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass **Checkpoint**: Every ticket reaching human escalation has its hierarchy node and eligible-agent set resolved automatically. Nothing is assigned yet — that's User Story 2. @@ -99,43 +101,43 @@ constitution's concurrency-testing gate for assignment. ### Tests for User Story 2 -- [ ] T010 [P] [US2] Unit tests for each strategy's pure selection function — `ROUND_ROBIN` +- [x] T010 [P] [US2] Unit tests for each strategy's pure selection function — `ROUND_ROBIN` given a fixed counter value picks the expected index; `LEAST_LOADED` picks the lowest-load agent; `SKILL_BASED` picks the highest-matching-level agent; an empty eligible array returns `null` for every strategy — in `tests/unit/orchestration/strategies.test.ts` -- [ ] T011 [US2] Concurrency test: fire many simultaneous `ROUND_ROBIN` selections against the +- [x] T011 [US2] Concurrency test: fire many simultaneous `ROUND_ROBIN` selections against the same eligible set and a real Redis instance, and confirm every selected index is exactly what the sequence of atomic `INCR` results implies — no two concurrent calls ever compute the same index from the same counter value, and the final counter value equals the number of calls — in `tests/concurrency/round-robin.test.ts` (depends on T005) -- [ ] T012 [US2] Integration test covering Quickstart Scenario 2 steps 3-5 (`LEAST_LOADED`, +- [x] T012 [US2] Integration test covering Quickstart Scenario 2 steps 3-5 (`LEAST_LOADED`, `SKILL_BASED`, empty-eligible-set outcome recorded) against a real Postgres in `tests/integration/orchestration-strategies.test.ts` (depends on T005) ### Implementation for User Story 2 -- [ ] T013 [US2] Add the round-robin selection function — `INCR` against +- [x] T013 [US2] Add the round-robin selection function — `INCR` against `ticketing:round_robin:` via the existing shared Redis client, then `(count - 1) % eligibleAgents.length` into the eligible array sorted by `agent.id` (research.md) — in `src/modules/orchestration/assignments/strategies/round-robin.strategy.ts` (replacing the existing stub) (depends on T005) -- [ ] T014 [P] [US2] Add `LEAST_LOADED` (reads each candidate's `AgentAvailability.currentLoad`, +- [x] T014 [P] [US2] Add `LEAST_LOADED` (reads each candidate's `AgentAvailability.currentLoad`, lowest wins, ties fall through to T013's cursor — research.md) in `src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts` + `calculators/workload.calculator.ts` (replacing the existing stub) -- [ ] T015 [P] [US2] Add `SKILL_BASED` (reads each candidate's `AgentSkill.level` for the +- [x] T015 [P] [US2] Add `SKILL_BASED` (reads each candidate's `AgentSkill.level` for the required skills, highest total wins, ties fall through to T013's cursor) in `src/modules/orchestration/assignments/strategies/skill-based.strategy.ts` + `calculators/skill-match.calculator.ts` -- [ ] T016 [US2] Add a strategy registry (`STRATEGY_REGISTRY: Record Promise>`) covering `ROUND_ROBIN`/`LEAST_LOADED`/`SKILL_BASED`, with `MANUAL`/`DIRECT` deliberately absent (those are never auto-selected — they only ever come from an explicit caller-supplied `agentId`, T024) — an unregistered strategy name resolves to `null` (no agent), matching FR-008 — in `src/modules/orchestration/assignments/service/strategy-registry.ts` (depends on T013, T014, T015) -- [ ] T017 [US2] Add `AssignmentEngine.evaluateAndAssign(ticketId)` (replacing the existing +- [x] T017 [US2] Add `AssignmentEngine.evaluateAndAssign(ticketId)` (replacing the existing stub): calls T007's resolution, then T016's registry using the resolved node's `assignmentStrategy` (or a configurable system default when no node matched), persists the result via T019/T020's repository (approved agent → new `Assignment` + `assigned` @@ -143,11 +145,11 @@ constitution's concurrency-testing gate for assignment. success transitions the ticket via `ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system')` (research.md) — in `src/modules/orchestration/assignments/engine/assignment.engine.ts` (depends on T016) -- [ ] T018 [US2] Wire T008's event-subscriber skeleton to actually call T017's +- [x] T018 [US2] Wire T008's event-subscriber skeleton to actually call T017's `evaluateAndAssign` — completing the automatic escalation→assignment flow — in `src/modules/orchestration/orchestration/service/orchestration.service.ts` (depends on T017) -- [ ] T019 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass +- [x] T019 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass **Checkpoint**: Escalated tickets are now actually assigned — the full automatic flow works end to end, concurrency-safely. @@ -163,14 +165,15 @@ superseded, never lost. ### Tests for User Story 3 -- [ ] T020 [US3] Integration test covering Quickstart Scenario 3 (reassignment supersedes the +- [x] T020 [US3] Integration test covering Quickstart Scenario 3 (reassignment supersedes the current `Assignment` row, both remain in `AssignmentHistory`, `GET .../assignment` reflects - only the latest) against a real Postgres in `tests/integration/orchestration-history.test.ts` - (depends on T005) + only the latest) against a real Postgres — implemented in + `tests/integration/orchestration-flow.test.ts` (see T006's note) rather than + `orchestration-history.test.ts` (depends on T005) ### Implementation for User Story 3 -- [ ] T021 [US3] Add `AssignmentRepository` (`createAssignment` — in one transaction, sets any +- [x] T021 [US3] Add `AssignmentRepository` (`createAssignment` — in one transaction, sets any existing current row's `isCurrent: false`/`unassignedAt: now()` then inserts the new current row, matching research.md's version-row-per-period refinement; `findCurrent`; `AssignmentHistoryRepository.record` — always additive, never updates) in @@ -178,10 +181,10 @@ superseded, never lost. `repository/assignment-history.repository.ts` (depends on T005) — this is the repository T017 (US2) already depends on; implemented here since US3's own story is what specifies its correctness guarantees in detail -- [ ] T022 [US3] Add Zod schema + `GET /tickets/:ticketId/assignment`, +- [x] T022 [US3] Add Zod schema + `GET /tickets/:ticketId/assignment`, `GET /tickets/:ticketId/assignment-history` routes in `assignments/schema/` + `routes/` + `controller/`, registered from `src/api/routes.ts` (depends on T021) -- [ ] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass +- [x] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass **Checkpoint**: Assignment history is durable and correctly superseded, independently of whether a reassignment came from automatic re-escalation or a manual override. @@ -197,21 +200,21 @@ assignment. ### Tests for User Story 4 -- [ ] T024 [US4] Integration test covering Quickstart Scenario 4 (manual assignment overrides; +- [x] T024 [US4] Integration test covering Quickstart Scenario 4 (manual assignment overrides; nonexistent agent rejected with `404`; a second manual assignment supersedes the first) - against a real Postgres in `tests/integration/orchestration-manual-assignment.test.ts` - (depends on T021) + against a real Postgres — implemented in `tests/integration/orchestration-flow.test.ts` + (see T006's note) rather than `orchestration-manual-assignment.test.ts` (depends on T021) ### Implementation for User Story 4 -- [ ] T025 [US4] Add `AssignmentsService.assignManually(ticketId, agentId, actor, reason?, +- [x] T025 [US4] Add `AssignmentsService.assignManually(ticketId, agentId, actor, reason?, strategy = 'MANUAL')`: validates the agent exists (resolve-or-404, research.md), then reuses T021's repository the same way T017's automatic path does — in `src/modules/orchestration/assignments/service/assignments.service.ts` (depends on T021) -- [ ] T026 [US4] Add Zod schema + `POST /admin/tickets/:ticketId/assignment` route (gated by +- [x] T026 [US4] Add Zod schema + `POST /admin/tickets/:ticketId/assignment` route (gated by `fastify.authenticate`) in `assignments/schema/` + `routes/` + `controller/` (depends on T025) -- [ ] T027 [US4] Run Quickstart Scenario 4 locally and confirm all 3 steps pass +- [x] T027 [US4] Run Quickstart Scenario 4 locally and confirm all 3 steps pass **Checkpoint**: Both automatic and manual assignment paths exist, fully interchangeable from history's point of view. @@ -227,18 +230,19 @@ eligible set. ### Tests for User Story 5 -- [ ] T028 [US5] Integration test covering Quickstart Scenario 5 (an already-assigned ticket +- [x] T028 [US5] Integration test covering Quickstart Scenario 5 (an already-assigned ticket re-escalated gets a freshly computed eligible set and a new assignment; the prior one - remains in history) against a real Postgres in - `tests/integration/orchestration-re-escalation.test.ts` (depends on T018, T021) + remains in history) against a real Postgres — implemented in + `tests/integration/orchestration-flow.test.ts` (see T006's note) rather than + `orchestration-re-escalation.test.ts` (depends on T018, T021) ### Implementation for User Story 5 -- [ ] T029 [US5] Confirm (no new production code expected — this is a verification task): T008's +- [x] T029 [US5] Confirm (no new production code expected — this is a verification task): T008's event subscriber and T017's engine already re-run resolution from scratch on every `HUMAN_ESCALATION` event with no caching of a prior eligible set — if T028's test reveals any staleness, fix it in `orchestration/routing`'s service (depends on T028) -- [ ] T030 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass +- [x] T030 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass **Checkpoint**: All five user stories work independently and together — escalation, resolution, strategy selection, persistence, manual override, and re-escalation form one coherent, @@ -248,14 +252,14 @@ concurrency-safe flow. ## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T031 [P] Add an "Orchestration and Assignment" section to `README.md` describing the +- [x] T031 [P] Add an "Orchestration and Assignment" section to `README.md` describing the automatic escalation trigger, the five strategies (and which ones are auto-selectable vs. manual-only), the concurrency-safety mechanism, and what's explicitly deferred (SLA/ escalation-policy execution, workload lifecycle mutation) -- [ ] T032 [P] Update `specs/007-orchestration-assignment/checklists/requirements.md` Notes with +- [x] T032 [P] Update `specs/007-orchestration-assignment/checklists/requirements.md` Notes with any implementation-time findings -- [ ] T033 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` -- [ ] T034 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke +- [x] T033 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` +- [x] T034 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke elsewhere, then the full integration + concurrency suite against real Docker-provisioned infra diff --git a/src/api/routes.ts b/src/api/routes.ts index d03e9a6..72b3c49 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -14,6 +14,7 @@ import { sessionsRoutes } from '@/modules/ai-support/sessions'; import { teamsRoutes } from '@/modules/identity/teams'; import { agentsRoutes } from '@/modules/identity/agents'; import { hierarchyRoutes } from '@/modules/orchestration/hierarchy'; +import { assignmentsRoutes } from '@/modules/orchestration/assignments'; export async function registerGlobalRoutes(app: FastifyInstance): Promise { await app.register(healthRoutes); @@ -29,5 +30,6 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise await app.register(teamsRoutes); await app.register(agentsRoutes); await app.register(hierarchyRoutes); + await app.register(assignmentsRoutes); // Further domain module routes will be registered here as feature modules are wired up } diff --git a/src/app.ts b/src/app.ts index 9bfc3bc..8e35a46 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import { env } from '@/config'; import { logger } from '@/infrastructure/observability'; import { AppError } from '@/common/errors'; import { bootstrapPlugins, bootstrapRoutes } from '@/bootstrap'; +import { registerDomainEventHandlers } from '@/events'; export async function buildApp(): Promise { const app = Fastify({ @@ -15,6 +16,13 @@ export async function buildApp(): Promise { // Register Bootstrapped Plugins await bootstrapPlugins(app); + // Domain event handlers (005's AI-session-ending hook, 007's automatic-orchestration hook) — + // registered here, not only in server.ts's production startup path, because they're + // synchronous application wiring (a status change reacting to another module), not a + // long-running background process like the queue's workers (bootstrapQueue stays server.ts- + // only for that reason). registerDomainEventHandlers() is idempotent — see its own comment. + registerDomainEventHandlers(); + // Global Error Handler — MUST be registered before any route module is (bootstrapRoutes // below), because Fastify resolves each encapsulated child context's error handler at the // time that context is registered. A handler set after a child module has already been diff --git a/src/config/env.ts b/src/config/env.ts index 657d503..1ca90ec 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -50,6 +50,11 @@ const envSchema = z.object({ AI_SUPPORT_DEFAULT_LOW_CONFIDENCE: z.coerce.number().default(0.4), AI_SUPPORT_DEFAULT_MAX_CLARIFYING_QUESTIONS: z.coerce.number().default(2), AI_SUPPORT_MAX_REASONING_ITERATIONS_PER_TURN: z.coerce.number().default(4), + + // Orchestration and Assignment (007) — the strategy used when no hierarchy node matched a + // ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle + // II), consistent with every other policy default in this codebase. + ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'), }); export type EnvConfig = z.infer; diff --git a/src/config/index.ts b/src/config/index.ts index 6f0fe49..5db4de4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,3 +4,4 @@ export * from './redis'; export * from './queue'; export * from './storage'; export * from './ai'; +export * from './orchestration'; diff --git a/src/config/orchestration.ts b/src/config/orchestration.ts new file mode 100644 index 0000000..95fdc53 --- /dev/null +++ b/src/config/orchestration.ts @@ -0,0 +1,5 @@ +import { env } from './env'; + +export const orchestrationConfig = { + defaultStrategy: env.ORCHESTRATION_DEFAULT_STRATEGY, +}; diff --git a/src/events/event-bus.ts b/src/events/event-bus.ts index fd92572..4d17519 100644 --- a/src/events/event-bus.ts +++ b/src/events/event-bus.ts @@ -1,32 +1,43 @@ -import EventEmitter from 'events'; import { BaseDomainEvent } from './event-types'; import { logger } from '@/infrastructure/observability'; +type Handler = (event: BaseDomainEvent) => Promise | void; + +/** + * Deliberately not built on Node's `EventEmitter` — `EventEmitter.emit()` invokes async + * listeners without awaiting them, so a caller has no way to know when a published event's + * subscribers have actually finished (found while building 007-orchestration-assignment: an + * escalation's automatic-assignment subscriber needs to have run by the time the status-update + * HTTP call returns, for the response to accurately reflect what happened). `publish` here + * awaits every matching handler (this event's own name, plus any `'*'` wildcard subscribers), + * each independently try/caught so one handler's failure never blocks or fails the others — + * the same isolation guarantee the previous EventEmitter-based implementation had. + */ export class EventBus { - private emitter: EventEmitter; + private handlers: Map[]> = new Map(); - constructor() { - this.emitter = new EventEmitter(); - this.emitter.setMaxListeners(50); - } - - publish(event: BaseDomainEvent): void { + async publish(event: BaseDomainEvent): Promise { logger.info({ eventName: event.eventName, eventId: event.eventId }, 'Publishing domain event'); - this.emitter.emit(event.eventName, event); - this.emitter.emit('*', event); + const named = this.handlers.get(event.eventName) ?? []; + const wildcard = this.handlers.get('*') ?? []; + await Promise.all( + [...named, ...wildcard].map(async (handler) => { + try { + await handler(event); + } catch (error) { + logger.error( + { error, eventName: event.eventName, eventId: event.eventId }, + 'Error executing event handler', + ); + } + }), + ); } - subscribe( - eventName: string, - handler: (event: BaseDomainEvent) => Promise | void, - ): void { - this.emitter.on(eventName, async (event: BaseDomainEvent) => { - try { - await handler(event); - } catch (error) { - logger.error({ error, eventName, eventId: event.eventId }, 'Error executing event handler'); - } - }); + subscribe(eventName: string, handler: Handler): void { + const existing = this.handlers.get(eventName) ?? []; + existing.push(handler as Handler); + this.handlers.set(eventName, existing); } } diff --git a/src/events/handlers/index.ts b/src/events/handlers/index.ts index 7ad0793..c5605de 100644 --- a/src/events/handlers/index.ts +++ b/src/events/handlers/index.ts @@ -2,8 +2,28 @@ import { eventBus } from '../event-bus'; import { DomainEventName } from '../domain-events'; import { BaseDomainEvent } from '../event-types'; import { sessionsService } from '@/modules/ai-support/sessions'; +import { orchestrationService } from '@/modules/orchestration/orchestration'; +interface TicketUpdatedPayload { + ticketId: string; + actor: string; + previousStatus: string; + newStatus: string; +} + +let registered = false; + +/** + * Idempotent — `buildApp()` calls this on every build (see app.ts's comment on why domain-event + * wiring, unlike the queue's background workers, belongs there rather than only in server.ts's + * production startup path), and `buildApp()` is called once per test file across this codebase's + * whole suite; without this guard, `eventBus`'s module-level singleton would accumulate a + * duplicate set of subscribers per test file, each firing on every subsequent test's events. + */ export function registerDomainEventHandlers(): void { + if (registered) return; + registered = true; + // 005-ai-support FR-023: a human actor changing a ticket's status ends its active AI session. // Registered here (outside src/modules/) rather than inside ticketing/tickets, so that module // never needs to import ai-support/sessions — see tickets.service.ts's updateStatus comment. @@ -13,4 +33,16 @@ export function registerDomainEventHandlers(): void { await sessionsService.handleTicketStatusChanged(event.payload); }, ); + + // 007-orchestration-assignment FR-001/FR-013: a ticket reaching HUMAN_ESCALATION (whether for + // the first time or on re-escalation — the handler is identical either way, research.md) runs + // orchestration automatically. Same "tickets never needs to know this module exists" pattern + // as the subscriber above. + eventBus.subscribe( + DomainEventName.TICKET_UPDATED, + async (event: BaseDomainEvent) => { + if (event.payload.newStatus !== 'HUMAN_ESCALATION') return; + await orchestrationService.handleHumanEscalation(event.payload.ticketId); + }, + ); } diff --git a/src/modules/identity/agents/index.ts b/src/modules/identity/agents/index.ts index fa14e12..5741027 100644 --- a/src/modules/identity/agents/index.ts +++ b/src/modules/identity/agents/index.ts @@ -2,3 +2,12 @@ export { agentsRoutes } from './routes'; export { AgentsService, agentsService } from './service'; export { agentsRepository, AgentsRepository } from './repository'; export type { CreateAgentData, UpdateAgentData, FindAgentsFilter } from './repository'; +// Exported for 007-orchestration-assignment's LEAST_LOADED/SKILL_BASED strategies — same +// "extend an existing module's public surface for a later feature" precedent used throughout +// this codebase (e.g. 004's productsRepository, 005's problemsRepository). +export { + agentSkillsRepository, + AgentSkillsRepository, + agentAvailabilityRepository, + AgentAvailabilityRepository, +} from './repository'; diff --git a/src/modules/orchestration/assignments/calculators/skill-match.calculator.ts b/src/modules/orchestration/assignments/calculators/skill-match.calculator.ts new file mode 100644 index 0000000..1750e08 --- /dev/null +++ b/src/modules/orchestration/assignments/calculators/skill-match.calculator.ts @@ -0,0 +1,30 @@ +import { EligibleAgentWithSkills } from '../types/strategy.types'; + +export interface AgentWithSkillScore { + agent: EligibleAgentWithSkills; + totalLevel: number; +} + +/** FR-007: total proficiency level across the required skills — "strongest match," not merely + * presence (eligibility already guaranteed presence; this is the weighting on top). An agent's + * skills beyond the required set don't count toward the score. */ +export function withSkillScores( + eligible: EligibleAgentWithSkills[], + requiredSkills: string[], +): AgentWithSkillScore[] { + return eligible.map((agent) => { + const totalLevel = agent.skills + .filter((s) => requiredSkills.includes(s.skillTag)) + .reduce((sum, s) => sum + s.level, 0); + return { agent, totalLevel }; + }); +} + +/** Pure — which candidates are tied for the highest score. */ +export function highestSkillScoreCandidates( + scored: AgentWithSkillScore[], +): EligibleAgentWithSkills[] { + if (scored.length === 0) return []; + const maxScore = Math.max(...scored.map((s) => s.totalLevel)); + return scored.filter((s) => s.totalLevel === maxScore).map((s) => s.agent); +} diff --git a/src/modules/orchestration/assignments/calculators/workload.calculator.ts b/src/modules/orchestration/assignments/calculators/workload.calculator.ts index fa77969..88ee222 100644 --- a/src/modules/orchestration/assignments/calculators/workload.calculator.ts +++ b/src/modules/orchestration/assignments/calculators/workload.calculator.ts @@ -1,7 +1,29 @@ -export class WorkloadCalculator { - async calculateAgentLoad(_agentId: string): Promise { - return 0; - } +import { agentAvailabilityRepository } from '@/modules/identity/agents'; +import { EligibleAgentWithSkills } from '../types/strategy.types'; + +export interface AgentWithLoad { + agent: EligibleAgentWithSkills; + currentLoad: number; } -export const workloadCalculator = new WorkloadCalculator(); +/** research.md "LEAST_LOADED reads currentLoad as-is": no agent with a set `AgentAvailability` + * record is ever penalized for having none — an agent with no availability record set at all is + * treated as load 0 (never assigned yet is the most available state, not the least). */ +export async function withCurrentLoad( + eligible: EligibleAgentWithSkills[], +): Promise { + return Promise.all( + eligible.map(async (agent) => { + const availability = await agentAvailabilityRepository.findByAgent(agent.id); + return { agent, currentLoad: availability?.currentLoad ?? 0 }; + }), + ); +} + +/** Pure — given the candidates' loads, which ones are tied for lowest. Unit-testable without the + * repository lookup above. */ +export function lowestLoadCandidates(withLoads: AgentWithLoad[]): EligibleAgentWithSkills[] { + if (withLoads.length === 0) return []; + const minLoad = Math.min(...withLoads.map((w) => w.currentLoad)); + return withLoads.filter((w) => w.currentLoad === minLoad).map((w) => w.agent); +} diff --git a/src/modules/orchestration/assignments/constants/index.ts b/src/modules/orchestration/assignments/constants/index.ts new file mode 100644 index 0000000..9769db0 --- /dev/null +++ b/src/modules/orchestration/assignments/constants/index.ts @@ -0,0 +1,3 @@ +export const ASSIGNMENTS_CONSTANTS = { + MODULE_NAME: 'ORCHESTRATION_ASSIGNMENTS', +} as const; diff --git a/src/modules/orchestration/assignments/controller/assignments.controller.ts b/src/modules/orchestration/assignments/controller/assignments.controller.ts new file mode 100644 index 0000000..39b3849 --- /dev/null +++ b/src/modules/orchestration/assignments/controller/assignments.controller.ts @@ -0,0 +1,38 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { assignmentsService, AssignmentsService } from '../service/assignments.service'; +import { manualAssignmentSchema } from '../schema'; + +function actorFrom(request: FastifyRequest): string { + return request.reqContext?.actorId ?? 'unknown'; +} + +export class AssignmentsController { + constructor(private readonly service: AssignmentsService = assignmentsService) {} + + async assignManually(request: FastifyRequest, reply: FastifyReply) { + const { ticketId } = request.params as { ticketId: string }; + const body = manualAssignmentSchema.parse(request.body); + const assignment = await this.service.assignManually( + ticketId, + body.agentId, + actorFrom(request), + body.reason, + body.strategy ?? 'MANUAL', + ); + return reply.status(200).send({ success: true, data: assignment, meta: null }); + } + + async getCurrent(request: FastifyRequest, reply: FastifyReply) { + const { ticketId } = request.params as { ticketId: string }; + const assignment = await this.service.getCurrent(ticketId); + return reply.status(200).send({ success: true, data: assignment, meta: null }); + } + + async getHistory(request: FastifyRequest, reply: FastifyReply) { + const { ticketId } = request.params as { ticketId: string }; + const history = await this.service.getHistory(ticketId); + return reply.status(200).send({ success: true, data: history, meta: null }); + } +} + +export const assignmentsController = new AssignmentsController(); diff --git a/src/modules/orchestration/assignments/controller/index.ts b/src/modules/orchestration/assignments/controller/index.ts new file mode 100644 index 0000000..c4e085d --- /dev/null +++ b/src/modules/orchestration/assignments/controller/index.ts @@ -0,0 +1 @@ +export { AssignmentsController, assignmentsController } from './assignments.controller'; diff --git a/src/modules/orchestration/assignments/engine/assignment.engine.ts b/src/modules/orchestration/assignments/engine/assignment.engine.ts index 3f5de07..9a5d17f 100644 --- a/src/modules/orchestration/assignments/engine/assignment.engine.ts +++ b/src/modules/orchestration/assignments/engine/assignment.engine.ts @@ -1,6 +1,96 @@ +import { Assignment } from '@prisma/client'; +import { ticketsService } from '@/modules/ticketing/tickets'; +import { routingService, RoutingService } from '@/modules/orchestration/routing'; +import { orchestrationConfig } from '@/config'; +import { + assignmentRepository, + AssignmentRepository, + assignmentHistoryRepository, + AssignmentHistoryRepository, +} from '../repository'; +import { resolveStrategy } from '../service/strategy-registry'; + +export interface AssignmentOutcome { + assignment: Assignment | null; + strategy: string; +} + export class AssignmentEngine { - async evaluateAndAssign(_ticketId: string): Promise<{ assignedAgentId: string | null }> { - return { assignedAgentId: null }; + constructor( + private readonly routing: RoutingService = routingService, + private readonly assignments: AssignmentRepository = assignmentRepository, + private readonly history: AssignmentHistoryRepository = assignmentHistoryRepository, + ) {} + + /** + * FR-002/FR-003/FR-005/FR-008/FR-013/FR-014: the automatic path — resolves eligibility fresh + * (research.md/FR-013 — never a cached/stale set, so this is also what makes re-escalation + * (User Story 5) correct with no special-casing), runs the resolved (or system-default) + * strategy, persists the outcome either way, and moves the ticket to IN_PROGRESS on success. + */ + async evaluateAndAssign(ticketId: string): Promise { + const resolution = await this.routing.resolveEligibleAgents(ticketId); + const strategyName = resolution.assignmentStrategy ?? orchestrationConfig.defaultStrategy; + const strategyFn = resolveStrategy(strategyName); + + const selected = strategyFn + ? await strategyFn(resolution.eligibleAgents, { + hierarchyNodeId: resolution.hierarchyNodeId, + requiredSkills: resolution.requiredSkills, + }) + : null; + + if (!selected) { + // FR-008: no eligible agent, or the resolved strategy has no implementation — recorded, + // never a silent no-op. + await this.history.record({ + ticketId, + agentId: null, + action: 'unassigned', + strategy: strategyName, + actor: 'system', + }); + return { assignment: null, strategy: strategyName }; + } + + return { + assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, 'system'), + strategy: strategyName, + }; + } + + /** Shared by both the automatic path (above) and manual assignment + * (AssignmentsService.assignManually) — persists the Assignment + AssignmentHistory and moves + * the ticket to IN_PROGRESS (research.md "Ticket status transition"). */ + async persistAndTransition( + ticketId: string, + agentId: string, + strategy: string, + actor: string, + reason?: string, + ): Promise { + const priorCurrent = await this.assignments.findCurrent(ticketId); + const assignment = await this.assignments.createAssignment({ + ticketId, + agentId, + strategy, + reason, + }); + await this.history.record({ + ticketId, + agentId, + action: priorCurrent ? 'reassigned' : 'assigned', + strategy, + reason, + actor, + }); + + const ticket = await ticketsService.getById(ticketId); + if (ticket.status === 'HUMAN_ESCALATION') { + await ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system'); + } + + return assignment; } } diff --git a/src/modules/orchestration/assignments/index.ts b/src/modules/orchestration/assignments/index.ts index 7f6ebcf..619b3a3 100644 --- a/src/modules/orchestration/assignments/index.ts +++ b/src/modules/orchestration/assignments/index.ts @@ -1,3 +1,6 @@ -export * from './engine/assignment.engine'; -export * from './strategies/round-robin.strategy'; -export * from './calculators/workload.calculator'; +export { assignmentsRoutes } from './routes'; +export { AssignmentsService, assignmentsService } from './service'; +export { AssignmentEngine, assignmentEngine } from './engine/assignment.engine'; +export type { AssignmentOutcome } from './engine/assignment.engine'; +export { assignmentRepository, AssignmentRepository } from './repository'; +export { assignmentHistoryRepository, AssignmentHistoryRepository } from './repository'; diff --git a/src/modules/orchestration/assignments/mapper/index.ts b/src/modules/orchestration/assignments/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/assignments/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/orchestration/assignments/repository/assignment-history.repository.ts b/src/modules/orchestration/assignments/repository/assignment-history.repository.ts new file mode 100644 index 0000000..7ca1fb6 --- /dev/null +++ b/src/modules/orchestration/assignments/repository/assignment-history.repository.ts @@ -0,0 +1,38 @@ +import { AssignmentHistory, Prisma } from '@prisma/client'; +import { prismaClient } from '@/infrastructure/database'; + +export interface RecordHistoryData { + ticketId: string; + agentId?: string | null | undefined; + action: 'assigned' | 'reassigned' | 'unassigned'; + strategy: string; + reason?: string | undefined; + actor: string; +} + +export class AssignmentHistoryRepository { + constructor(private readonly prisma = prismaClient) {} + + /** FR-009: always additive — this table is never updated or deleted. */ + async record(data: RecordHistoryData): Promise { + return this.prisma.assignmentHistory.create({ + data: { + ticketId: data.ticketId, + agentId: data.agentId ?? null, + action: data.action, + strategy: data.strategy, + reason: data.reason, + actor: data.actor, + } as Prisma.AssignmentHistoryUncheckedCreateInput, + }); + } + + async findAllForTicket(ticketId: string): Promise { + return this.prisma.assignmentHistory.findMany({ + where: { ticketId }, + orderBy: { createdAt: 'asc' }, + }); + } +} + +export const assignmentHistoryRepository = new AssignmentHistoryRepository(); diff --git a/src/modules/orchestration/assignments/repository/assignment.repository.ts b/src/modules/orchestration/assignments/repository/assignment.repository.ts new file mode 100644 index 0000000..df4a662 --- /dev/null +++ b/src/modules/orchestration/assignments/repository/assignment.repository.ts @@ -0,0 +1,43 @@ +import { Assignment, Prisma } from '@prisma/client'; +import { prismaClient } from '@/infrastructure/database'; + +export interface CreateAssignmentData { + ticketId: string; + agentId: string; + strategy: string; + reason?: string | undefined; +} + +export class AssignmentRepository { + constructor(private readonly prisma = prismaClient) {} + + /** + * research.md "Assignment refined as a version-row-per-period model": in one transaction, + * supersedes any existing current row for this ticket (isCurrent: false, unassignedAt: now()) + * and inserts the new current row — the same "never overwrite, always a new row" guarantee + * 004's KnowledgeEntry versioning already established for a different entity. + */ + async createAssignment(data: CreateAssignmentData): Promise { + return this.prisma.$transaction(async (tx) => { + await tx.assignment.updateMany({ + where: { ticketId: data.ticketId, isCurrent: true }, + data: { isCurrent: false, unassignedAt: new Date() }, + }); + return tx.assignment.create({ + data: { + ticketId: data.ticketId, + agentId: data.agentId, + strategy: data.strategy, + reason: data.reason, + isCurrent: true, + } as Prisma.AssignmentUncheckedCreateInput, + }); + }); + } + + async findCurrent(ticketId: string): Promise { + return this.prisma.assignment.findFirst({ where: { ticketId, isCurrent: true } }); + } +} + +export const assignmentRepository = new AssignmentRepository(); diff --git a/src/modules/orchestration/assignments/repository/index.ts b/src/modules/orchestration/assignments/repository/index.ts new file mode 100644 index 0000000..284755b --- /dev/null +++ b/src/modules/orchestration/assignments/repository/index.ts @@ -0,0 +1,2 @@ +export * from './assignment.repository'; +export * from './assignment-history.repository'; diff --git a/src/modules/orchestration/assignments/routes/assignments.routes.ts b/src/modules/orchestration/assignments/routes/assignments.routes.ts new file mode 100644 index 0000000..647d5bc --- /dev/null +++ b/src/modules/orchestration/assignments/routes/assignments.routes.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from 'fastify'; +import { assignmentsController } from '../controller'; + +/** + * contracts/orchestration-contract.md: the manual-assignment write route is gated by + * fastify.authenticate (known limitation inherited from 002-006). The read routes are not + * gated — same "read path any caller can use" convention as 003's own ticket-status reads. + */ +export async function assignmentsRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/admin/tickets/:ticketId/assignment', + { preHandler: fastify.authenticate }, + (req, reply) => assignmentsController.assignManually(req, reply), + ); + fastify.get('/tickets/:ticketId/assignment', (req, reply) => + assignmentsController.getCurrent(req, reply), + ); + fastify.get('/tickets/:ticketId/assignment-history', (req, reply) => + assignmentsController.getHistory(req, reply), + ); +} diff --git a/src/modules/orchestration/assignments/routes/index.ts b/src/modules/orchestration/assignments/routes/index.ts new file mode 100644 index 0000000..cfeefe5 --- /dev/null +++ b/src/modules/orchestration/assignments/routes/index.ts @@ -0,0 +1 @@ +export { assignmentsRoutes } from './assignments.routes'; diff --git a/src/modules/orchestration/assignments/schema/assignments.schema.ts b/src/modules/orchestration/assignments/schema/assignments.schema.ts new file mode 100644 index 0000000..adddd09 --- /dev/null +++ b/src/modules/orchestration/assignments/schema/assignments.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const manualAssignmentSchema = z + .object({ + agentId: z.string().min(1), + reason: z.string().optional(), + strategy: z.enum(['MANUAL', 'DIRECT']).optional(), + }) + .strict(); + +export type ManualAssignmentBody = z.infer; diff --git a/src/modules/orchestration/assignments/schema/index.ts b/src/modules/orchestration/assignments/schema/index.ts new file mode 100644 index 0000000..16367db --- /dev/null +++ b/src/modules/orchestration/assignments/schema/index.ts @@ -0,0 +1 @@ +export * from './assignments.schema'; diff --git a/src/modules/orchestration/assignments/service/assignments.service.ts b/src/modules/orchestration/assignments/service/assignments.service.ts new file mode 100644 index 0000000..6e331c1 --- /dev/null +++ b/src/modules/orchestration/assignments/service/assignments.service.ts @@ -0,0 +1,37 @@ +import { Assignment, AssignmentHistory } from '@prisma/client'; +import { NotFoundError } from '@/common/errors'; +import { agentsRepository } from '@/modules/identity/agents'; +import { assignmentEngine, AssignmentEngine } from '../engine/assignment.engine'; +import { assignmentRepository, assignmentHistoryRepository } from '../repository'; + +export class AssignmentsService { + constructor(private readonly engine: AssignmentEngine = assignmentEngine) {} + + /** FR-011/FR-012: validates the target agent exists (resolve-or-404, same convention as + * every other feature's FK validation) before delegating to the same persist-and-transition + * path the automatic strategies use (research.md "Manual assignment"). */ + async assignManually( + ticketId: string, + agentId: string, + actor: string, + reason?: string, + strategy: 'MANUAL' | 'DIRECT' = 'MANUAL', + ): Promise { + const agent = await agentsRepository.findById(agentId); + if (!agent) throw new NotFoundError('Agent not found.'); + + return this.engine.persistAndTransition(ticketId, agentId, strategy, actor, reason); + } + + async getCurrent(ticketId: string): Promise { + const current = await assignmentRepository.findCurrent(ticketId); + if (!current) throw new NotFoundError('No assignment found for this ticket.'); + return current; + } + + async getHistory(ticketId: string): Promise { + return assignmentHistoryRepository.findAllForTicket(ticketId); + } +} + +export const assignmentsService = new AssignmentsService(); diff --git a/src/modules/orchestration/assignments/service/index.ts b/src/modules/orchestration/assignments/service/index.ts new file mode 100644 index 0000000..fee1330 --- /dev/null +++ b/src/modules/orchestration/assignments/service/index.ts @@ -0,0 +1,2 @@ +export { AssignmentsService, assignmentsService } from './assignments.service'; +export { STRATEGY_REGISTRY, resolveStrategy } from './strategy-registry'; diff --git a/src/modules/orchestration/assignments/service/strategy-registry.ts b/src/modules/orchestration/assignments/service/strategy-registry.ts new file mode 100644 index 0000000..dc463cf --- /dev/null +++ b/src/modules/orchestration/assignments/service/strategy-registry.ts @@ -0,0 +1,22 @@ +import { AssignmentStrategyFn } from '../types/strategy.types'; +import { selectViaRoundRobin } from '../strategies/round-robin.strategy'; +import { selectViaLeastLoaded } from '../strategies/least-loaded.strategy'; +import { selectViaSkillBased } from '../strategies/skill-based.strategy'; + +/** + * FR-005/FR-008: the auto-selectable strategies. `MANUAL`/`DIRECT` are deliberately absent — they + * never come from this registry, only from an explicit caller-supplied `agentId` + * (AssignmentsService.assignManually) — an unregistered name here (a typo, or one of those two + * reaching this path by mistake) resolves to `undefined`, which the engine treats identically to + * "no agent" (FR-008), never a crash. + */ +export const STRATEGY_REGISTRY: Record = { + ROUND_ROBIN: selectViaRoundRobin, + LEAST_LOADED: selectViaLeastLoaded, + SKILL_BASED: selectViaSkillBased, +}; + +export function resolveStrategy(name: string | null): AssignmentStrategyFn | null { + if (!name) return null; + return STRATEGY_REGISTRY[name] ?? null; +} diff --git a/src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts b/src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts new file mode 100644 index 0000000..6686456 --- /dev/null +++ b/src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts @@ -0,0 +1,23 @@ +import { Agent } from '@prisma/client'; +import { redisClient } from '@/infrastructure/cache'; +import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types'; +import { withCurrentLoad, lowestLoadCandidates } from '../calculators/workload.calculator'; +import { roundRobinSelectIndex } from './round-robin.strategy'; + +/** FR-007: lowest `currentLoad` wins; ties fall through to the same concurrency-safe cursor + * ROUND_ROBIN uses, scoped under its own Redis key (research.md "tie-breaking"). */ +export async function selectViaLeastLoaded( + eligible: EligibleAgentWithSkills[], + context: StrategyContext, +): Promise { + if (eligible.length === 0) return null; + + const withLoads = await withCurrentLoad(eligible); + const tied = lowestLoadCandidates(withLoads).sort((a, b) => a.id.localeCompare(b.id)); + if (tied.length === 1) return tied[0] ?? null; + + const key = `ticketing:round_robin:least_loaded:${context.hierarchyNodeId ?? 'unscoped'}`; + const count = await redisClient.incr(key); + const index = roundRobinSelectIndex(count, tied.length); + return tied[index] ?? null; +} diff --git a/src/modules/orchestration/assignments/strategies/round-robin.strategy.ts b/src/modules/orchestration/assignments/strategies/round-robin.strategy.ts index 57f1b80..218c215 100644 --- a/src/modules/orchestration/assignments/strategies/round-robin.strategy.ts +++ b/src/modules/orchestration/assignments/strategies/round-robin.strategy.ts @@ -1,7 +1,35 @@ -export class RoundRobinAssignmentStrategy { - async selectNextAgent(_candidateIds: string[]): Promise { - return _candidateIds[0] || null; - } +import { Agent } from '@prisma/client'; +import { redisClient } from '@/infrastructure/cache'; +import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types'; + +const ROUND_ROBIN_KEY_PREFIX = 'ticketing:round_robin:'; + +/** + * FR-006/research.md: pure index-selection — given the Redis counter's post-increment value and + * the eligible-set size, which index is next. Separated from the Redis call itself so the + * selection math is unit-testable without a live Redis instance. + */ +export function roundRobinSelectIndex(incrementedCount: number, eligibleCount: number): number { + return (incrementedCount - 1) % eligibleCount; } -export const roundRobinAssignmentStrategy = new RoundRobinAssignmentStrategy(); +/** + * FR-006: concurrency-safe by construction — `INCR` is a single atomic Redis round trip, so two + * simultaneous calls against the same key always receive two different counter values, and + * therefore (research.md) two different indices whenever the eligible set has more than one + * agent. The eligible array is sorted by `id` first so the same counter value always maps to the + * same relative position for a given eligible set, regardless of the order the caller passed it + * in. + */ +export async function selectViaRoundRobin( + eligible: EligibleAgentWithSkills[], + context: StrategyContext, +): Promise { + if (eligible.length === 0) return null; + + const sorted = [...eligible].sort((a, b) => a.id.localeCompare(b.id)); + const key = `${ROUND_ROBIN_KEY_PREFIX}${context.hierarchyNodeId ?? 'unscoped'}`; + const count = await redisClient.incr(key); + const index = roundRobinSelectIndex(count, sorted.length); + return sorted[index] ?? null; +} diff --git a/src/modules/orchestration/assignments/strategies/skill-based.strategy.ts b/src/modules/orchestration/assignments/strategies/skill-based.strategy.ts new file mode 100644 index 0000000..db81893 --- /dev/null +++ b/src/modules/orchestration/assignments/strategies/skill-based.strategy.ts @@ -0,0 +1,26 @@ +import { Agent } from '@prisma/client'; +import { redisClient } from '@/infrastructure/cache'; +import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types'; +import { + withSkillScores, + highestSkillScoreCandidates, +} from '../calculators/skill-match.calculator'; +import { roundRobinSelectIndex } from './round-robin.strategy'; + +/** FR-007: highest total proficiency across the required skills wins; ties fall through to the + * same concurrency-safe cursor ROUND_ROBIN uses (research.md "tie-breaking"). */ +export async function selectViaSkillBased( + eligible: EligibleAgentWithSkills[], + context: StrategyContext, +): Promise { + if (eligible.length === 0) return null; + + const scored = withSkillScores(eligible, context.requiredSkills); + const tied = highestSkillScoreCandidates(scored).sort((a, b) => a.id.localeCompare(b.id)); + if (tied.length === 1) return tied[0] ?? null; + + const key = `ticketing:round_robin:skill_based:${context.hierarchyNodeId ?? 'unscoped'}`; + const count = await redisClient.incr(key); + const index = roundRobinSelectIndex(count, tied.length); + return tied[index] ?? null; +} diff --git a/src/modules/orchestration/assignments/types/index.ts b/src/modules/orchestration/assignments/types/index.ts new file mode 100644 index 0000000..1c071ec --- /dev/null +++ b/src/modules/orchestration/assignments/types/index.ts @@ -0,0 +1,5 @@ +export type { + EligibleAgentWithSkills, + StrategyContext, + AssignmentStrategyFn, +} from './strategy.types'; diff --git a/src/modules/orchestration/assignments/types/strategy.types.ts b/src/modules/orchestration/assignments/types/strategy.types.ts new file mode 100644 index 0000000..627753c --- /dev/null +++ b/src/modules/orchestration/assignments/types/strategy.types.ts @@ -0,0 +1,19 @@ +import { Agent, AgentSkill } from '@prisma/client'; + +export interface EligibleAgentWithSkills extends Agent { + skills: AgentSkill[]; +} + +export interface StrategyContext { + hierarchyNodeId: string | null; + requiredSkills: string[]; +} + +/** Every auto-selectable strategy has this shape — given the eligible set and context, either + * picks exactly one agent or returns null (no agent — FR-008). Pure with respect to its inputs; + * any external state (Redis, DB) is read inside, never mutated as a side effect visible to the + * caller beyond the selection itself. */ +export type AssignmentStrategyFn = ( + eligible: EligibleAgentWithSkills[], + context: StrategyContext, +) => Promise; diff --git a/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts b/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts index 2f96f16..22aa730 100644 --- a/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts +++ b/src/modules/orchestration/hierarchy/service/capability-lookup.service.ts @@ -11,6 +11,19 @@ export interface CapabilityLookupContext { export class CapabilityLookupService { constructor(private readonly hierarchy: HierarchyRepository = hierarchyRepository) {} + /** Extracted so 007-orchestration-assignment's routing module can resolve the same matching + * node(s) findEligibleAgents already computes internally — reusing this exact scope-matching + * logic rather than a second, divergent implementation (007's research.md). */ + async findMatchingNodes(context: CapabilityLookupContext) { + const activeNodes = await this.hierarchy.findActiveNodes(); + return activeNodes.filter( + (node) => + scopeMatches(node.productScope, context.productId) && + scopeMatches(node.categoryScope, context.categoryId) && + scopeMatches(node.priorityScope, context.priorityId), + ); + } + /** * FR-014/FR-015/FR-016 (research.md "Capability-eligibility lookup"): resolves which active * hierarchy nodes apply to the given context, unions their `skills` into the caller-supplied @@ -19,13 +32,7 @@ export class CapabilityLookupService { * (FR-016). */ async findEligibleAgents(requiredSkills: string[], context: CapabilityLookupContext) { - const activeNodes = await this.hierarchy.findActiveNodes(); - const matchingNodes = activeNodes.filter( - (node) => - scopeMatches(node.productScope, context.productId) && - scopeMatches(node.categoryScope, context.categoryId) && - scopeMatches(node.priorityScope, context.priorityId), - ); + const matchingNodes = await this.findMatchingNodes(context); const combinedSkills = new Set(requiredSkills); for (const node of matchingNodes) { diff --git a/src/modules/orchestration/orchestration/index.ts b/src/modules/orchestration/orchestration/index.ts index 153116f..91d68d6 100644 --- a/src/modules/orchestration/orchestration/index.ts +++ b/src/modules/orchestration/orchestration/index.ts @@ -1,11 +1 @@ -export const ORCHESTRATION_CONSTANTS = { - MODULE_NAME: 'ORCHESTRATION_ENGINE', -} as const; - -export class OrchestrationService { - async orchestrateWorkflow(_event: string) { - return { status: 'HANDLED' }; - } -} - -export const orchestrationService = new OrchestrationService(); +export { OrchestrationService, orchestrationService } from './service'; diff --git a/src/modules/orchestration/orchestration/service/index.ts b/src/modules/orchestration/orchestration/service/index.ts new file mode 100644 index 0000000..543cab6 --- /dev/null +++ b/src/modules/orchestration/orchestration/service/index.ts @@ -0,0 +1 @@ +export { OrchestrationService, orchestrationService } from './orchestration.service'; diff --git a/src/modules/orchestration/orchestration/service/orchestration.service.ts b/src/modules/orchestration/orchestration/service/orchestration.service.ts new file mode 100644 index 0000000..d5fa60f --- /dev/null +++ b/src/modules/orchestration/orchestration/service/orchestration.service.ts @@ -0,0 +1,23 @@ +import { logger } from '@/infrastructure/observability'; +import { assignmentEngine, AssignmentEngine } from '@/modules/orchestration/assignments'; + +export class OrchestrationService { + constructor(private readonly engine: AssignmentEngine = assignmentEngine) {} + + /** + * FR-001/FR-013: the entry point 005-ai-support's `TICKET_UPDATED` domain event subscriber + * (src/events/handlers/index.ts) calls whenever a ticket reaches `HUMAN_ESCALATION` — covers + * both a ticket's first escalation and any later re-escalation identically (User Story 5), + * since `AssignmentEngine.evaluateAndAssign` always resolves eligibility fresh, never from a + * cached prior result. + */ + async handleHumanEscalation(ticketId: string): Promise { + const outcome = await this.engine.evaluateAndAssign(ticketId); + logger.info( + { ticketId, strategy: outcome.strategy, agentId: outcome.assignment?.agentId ?? null }, + outcome.assignment ? 'Ticket assigned' : 'No eligible agent — ticket remains escalated', + ); + } +} + +export const orchestrationService = new OrchestrationService(); diff --git a/src/modules/orchestration/orchestration/types/index.ts b/src/modules/orchestration/orchestration/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/orchestration/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/orchestration/routing/index.ts b/src/modules/orchestration/routing/index.ts index 5000f5a..cb3a134 100644 --- a/src/modules/orchestration/routing/index.ts +++ b/src/modules/orchestration/routing/index.ts @@ -1,11 +1,2 @@ -export const ROUTING_CONSTANTS = { - MODULE_NAME: 'ORCHESTRATION_ROUTING', -} as const; - -export class RoutingService { - async routeTicket(_ticketId: string) { - return { targetTeamId: null }; - } -} - -export const routingService = new RoutingService(); +export { RoutingService, routingService } from './service'; +export type { ResolveResult, EligibleAgent } from './service'; diff --git a/src/modules/orchestration/routing/service/index.ts b/src/modules/orchestration/routing/service/index.ts new file mode 100644 index 0000000..6aac9d0 --- /dev/null +++ b/src/modules/orchestration/routing/service/index.ts @@ -0,0 +1,2 @@ +export { RoutingService, routingService } from './routing.service'; +export type { ResolveResult, EligibleAgent } from './routing.service'; diff --git a/src/modules/orchestration/routing/service/routing.service.ts b/src/modules/orchestration/routing/service/routing.service.ts new file mode 100644 index 0000000..b68d537 --- /dev/null +++ b/src/modules/orchestration/routing/service/routing.service.ts @@ -0,0 +1,74 @@ +import { Agent, AgentSkill } from '@prisma/client'; +import { ticketsService } from '@/modules/ticketing/tickets'; +import { productsRepository } from '@/modules/catalog/products'; +import { + capabilityLookupService, + CapabilityLookupService, +} from '@/modules/orchestration/hierarchy'; +import { sessionRepository, diagnosisRepository } from '@/modules/ai-support/sessions'; + +export interface EligibleAgent extends Agent { + skills: AgentSkill[]; +} + +export interface ResolveResult { + requiredSkills: string[]; + eligibleAgents: EligibleAgent[]; + /** The matched hierarchy node's own `assignmentStrategy`, if any node matched — the strategy + * the assignment engine (007) should run. `null` when no node matched (FR-004); the caller + * falls back to a configurable system default in that case. */ + assignmentStrategy: string | null; + /** The matched node's id, if any — used to scope ROUND_ROBIN's Redis cursor per node + * (research.md) so unrelated nodes' cycles never interfere with each other. */ + hierarchyNodeId: string | null; +} + +export class RoutingService { + constructor( + private readonly capabilityLookup: CapabilityLookupService = capabilityLookupService, + ) {} + + /** + * FR-002/FR-003/FR-004: resolves the capability-eligible-agent set for a ticket, reusing + * 006's own matching algorithm directly (never a second one). research.md "Where a ticket's + * required skill actually comes from": the most recent AI diagnosis's problemType if one + * exists, else no skill constraint at all (never zero eligible agents). + */ + async resolveEligibleAgents(ticketId: string): Promise { + const ticket = await ticketsService.getById(ticketId); + const product = await productsRepository.findById(ticket.productId); + const context = { + productId: product?.externalProductId, + categoryId: ticket.categoryId ?? undefined, + priorityId: ticket.priority, + }; + + const requiredSkills = await this.deriveRequiredSkills(ticketId); + const [eligibleAgents, matchingNodes] = await Promise.all([ + this.capabilityLookup.findEligibleAgents(requiredSkills, context), + this.capabilityLookup.findMatchingNodes(context), + ]); + + // Edge Cases: when multiple nodes match equally, the lowest `order` is the primary node + // whose strategy/id drives the assignment — every matched node's skills are still unioned + // into eligibility (findEligibleAgents already did that), only the strategy choice itself + // needs one deterministic winner. + const primaryNode = [...matchingNodes].sort((a, b) => a.order - b.order)[0] ?? null; + + return { + requiredSkills, + eligibleAgents: eligibleAgents as EligibleAgent[], + assignmentStrategy: primaryNode?.assignmentStrategy ?? null, + hierarchyNodeId: primaryNode?.id ?? null, + }; + } + + private async deriveRequiredSkills(ticketId: string): Promise { + const session = await sessionRepository.findMostRecentByTicketId(ticketId); + if (!session) return []; + const diagnosis = await diagnosisRepository.findLatestBySession(session.id); + return diagnosis ? [diagnosis.problemType] : []; + } +} + +export const routingService = new RoutingService(); diff --git a/src/modules/orchestration/routing/types/index.ts b/src/modules/orchestration/routing/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/orchestration/routing/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/ticketing/tickets/service/tickets.service.ts b/src/modules/ticketing/tickets/service/tickets.service.ts index 916291c..4c6cf01 100644 --- a/src/modules/ticketing/tickets/service/tickets.service.ts +++ b/src/modules/ticketing/tickets/service/tickets.service.ts @@ -156,7 +156,10 @@ export class TicketsService { // decouples this module from ai-support/sessions entirely; a subscriber decides whether a // given event matters to it (e.g. "was this actor not 'ai'?"), this module just reports what // happened. See src/events/handlers/index.ts. - eventBus.publish({ + // Awaited (not fire-and-forget) so a caller of updateStatus — an HTTP request in + // particular — only sees its response once every subscriber (005's AI-session-ending hook, + // 007's automatic-orchestration hook) has actually run; see events/event-bus.ts. + await eventBus.publish({ eventId: randomUUID(), eventName: DomainEventName.TICKET_UPDATED, aggregateId: ticketId, diff --git a/src/server.ts b/src/server.ts index b7473ee..3c109fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,7 +8,6 @@ import { bootstrapStorage, setupGracefulShutdown, } from '@/bootstrap'; -import { registerDomainEventHandlers } from '@/events'; async function startServer(): Promise { try { @@ -19,9 +18,8 @@ async function startServer(): Promise { await bootstrapRedis(); await bootstrapQueue(); await bootstrapStorage(); - registerDomainEventHandlers(); - // Create Fastify Instance + // Create Fastify Instance — registers domain event handlers itself, see app.ts. const app = await buildApp(); // Register Graceful Shutdown Processors diff --git a/tests/concurrency/round-robin.test.ts b/tests/concurrency/round-robin.test.ts new file mode 100644 index 0000000..a286325 --- /dev/null +++ b/tests/concurrency/round-robin.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { redisClient } from '@/infrastructure/cache'; +import { + selectViaRoundRobin, + roundRobinSelectIndex, +} from '@/modules/orchestration/assignments/strategies/round-robin.strategy'; + +/** + * Constitution Principle VII / FR-006 / SC-002: round robin must be concurrency-safe under + * genuinely concurrent selection attempts, not just sequential calls — this is the first + * dedicated multi-writer-race test in this codebase since 003-ticketing's optimistic + * ticket-status concurrency (research.md). + */ +describe('ROUND_ROBIN concurrency safety', () => { + function fakeAgent(id: string) { + return { + id, + teamId: 't', + name: id, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + skills: [], + }; + } + + it('never selects the same index twice for concurrent calls against the same key, and the final counter matches the call count', async () => { + const nodeId = `concurrency-test-node-${Date.now()}`; + const key = `ticketing:round_robin:${nodeId}`; + await redisClient.del(key); + + const eligible = [ + fakeAgent('a'), + fakeAgent('b'), + fakeAgent('c'), + fakeAgent('d'), + fakeAgent('e'), + ]; + const callCount = 25; + + const results = await Promise.all( + Array.from({ length: callCount }, () => + selectViaRoundRobin(eligible, { hierarchyNodeId: nodeId, requiredSkills: [] }), + ), + ); + + // Every call must have selected somebody — never null for a non-empty eligible set. + expect(results.every((r) => r !== null)).toBe(true); + + // Across every complete cycle through the 5 agents, each agent must be selected exactly + // callCount/5 times — if two concurrent INCRs had ever collided (both reading the same + // pre-increment value), some agent would be over- or under-selected relative to this exact + // count, since callCount (25) is a clean multiple of the eligible set size (5). + const counts = new Map(); + for (const r of results) { + if (!r) continue; + counts.set(r.id, (counts.get(r.id) ?? 0) + 1); + } + for (const agent of eligible) { + expect(counts.get(agent.id)).toBe(callCount / eligible.length); + } + + const finalCount = await redisClient.get(key); + expect(Number(finalCount)).toBe(callCount); + + await redisClient.del(key); + }); + + it('the pure index function never repeats an index within one full cycle', () => { + const length = 7; + const seen = new Set(); + for (let count = 1; count <= length; count++) { + const index = roundRobinSelectIndex(count, length); + expect(seen.has(index)).toBe(false); + seen.add(index); + } + expect(seen.size).toBe(length); + }); +}); diff --git a/tests/integration/orchestration-flow.test.ts b/tests/integration/orchestration-flow.test.ts new file mode 100644 index 0000000..228f495 --- /dev/null +++ b/tests/integration/orchestration-flow.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildApp } from '@/app'; +import { prismaClient } from '@/infrastructure/database'; +import { FastifyInstance } from 'fastify'; +import { + encryptCredential, + generateCredentialSecret, + issueIntegrationToken, +} from '@/modules/catalog/products'; + +/** + * Covers specs/007-orchestration-assignment/quickstart.md Scenarios 1, 3, 4, 5 against a real + * Postgres/Redis — one ticket's lifecycle through escalation, automatic assignment, history, + * manual reassignment, and re-escalation. + */ +describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => { + let app: FastifyInstance; + const externalProductId = `TEST_ORCH_PROD_${Date.now()}`; + const skillTag = `orch_skill_${Date.now()}`; + let productId: string; + let teamId: string; + let agentAId: string; + let agentBId: string; + let secret: string; + let ticketId: string; + + beforeAll(async () => { + app = await buildApp(); + + const product = await prismaClient.product.create({ + data: { externalProductId, name: 'Orchestration Test Product', status: 'active' }, + }); + productId = product.id; + secret = generateCredentialSecret(); + await prismaClient.productIntegration.create({ + data: { + productId, + credentialRef: encryptCredential(secret), + authMechanism: 'signed_token', + allowedScope: { tenantIds: ['tenant-1'] }, + status: 'active', + rateLimitPerMinute: 1000, + rateLimitPerUserPerMinute: 1000, + }, + }); + + const team = await app.inject({ + method: 'POST', + url: '/admin/teams', + payload: { name: `Orch Team ${Date.now()}` }, + }); + teamId = team.json().data.id; + + const agentA = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'Orch Agent A' }, + }); + agentAId = agentA.json().data.id; + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentAId}/skills/${skillTag}`, + payload: { level: 3 }, + }); + + const agentB = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'Orch Agent B' }, + }); + agentBId = agentB.json().data.id; + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentBId}/skills/${skillTag}`, + payload: { level: 3 }, + }); + + await app.inject({ + method: 'POST', + url: '/admin/hierarchy-nodes', + payload: { + name: 'Orch Node', + order: 0, + productScope: [externalProductId], + skills: [skillTag], + assignmentStrategy: 'ROUND_ROBIN', + }, + }); + + const token = issueIntegrationToken(secret, { + externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + }); + const created = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { authorization: `Bearer ${token}` }, + payload: { + productId: externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + source: 'test', + problem: 'Needs a human, unrelated to AI diagnosis in this test.', + }, + }); + ticketId = created.json().data.ticketId; + }); + + afterAll(async () => { + await prismaClient.assignmentHistory.deleteMany({ where: { ticketId } }); + await prismaClient.assignment.deleteMany({ where: { ticketId } }); + await prismaClient.hierarchyNode.deleteMany({ where: { name: 'Orch Node' } }); + await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } }); + await prismaClient.agent.deleteMany({ where: { teamId } }); + await prismaClient.team.deleteMany({ where: { id: teamId } }); + await prismaClient.ticketMessage.deleteMany({ where: { ticketId } }); + await prismaClient.ticket.deleteMany({ where: { id: ticketId } }); + await prismaClient.problem.deleteMany({ where: { productId } }); + await prismaClient.productIntegration.deleteMany({ where: { productId } }); + await prismaClient.product.deleteMany({ where: { id: productId } }); + await app.close(); + }); + + it('Scenario 1: escalation triggers automatic resolution and assignment', async () => { + const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + const escalate = await app.inject({ + method: 'PATCH', + url: `/tickets/${ticketId}/status`, + payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, + }); + expect(escalate.statusCode).toBe(200); + + // The event handler runs synchronously within the same process (in-memory EventEmitter), + // so by the time inject() resolves, publish's listeners have already been invoked — no + // polling needed, matching this codebase's existing event-bus behavior (005's own + // handleTicketStatusChanged is exercised the same way). + const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + expect(current.statusCode).toBe(200); + expect([agentAId, agentBId]).toContain(current.json().data.agentId); + expect(current.json().data.strategy).toBe('ROUND_ROBIN'); + + const updatedTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + expect(updatedTicket.status).toBe('IN_PROGRESS'); + }); + + it('Scenario 4: a manual reassignment overrides the automatic one', async () => { + const before = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + const originalAgentId = before.json().data.agentId; + const otherAgentId = originalAgentId === agentAId ? agentBId : agentAId; + + const manual = await app.inject({ + method: 'POST', + url: `/admin/tickets/${ticketId}/assignment`, + payload: { agentId: otherAgentId, reason: 'Manual override for test' }, + }); + expect(manual.statusCode).toBe(200); + expect(manual.json().data.agentId).toBe(otherAgentId); + expect(manual.json().data.strategy).toBe('MANUAL'); + + const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + expect(current.json().data.agentId).toBe(otherAgentId); + + const notFound = await app.inject({ + method: 'POST', + url: `/admin/tickets/${ticketId}/assignment`, + payload: { agentId: 'nonexistent-agent-id' }, + }); + expect(notFound.statusCode).toBe(404); + }); + + it('Scenario 3: assignment history preserves every prior decision', async () => { + const history = await app.inject({ + method: 'GET', + url: `/tickets/${ticketId}/assignment-history`, + }); + expect(history.statusCode).toBe(200); + const rows: { action: string; strategy: string }[] = history.json().data; + expect(rows.length).toBeGreaterThanOrEqual(2); // the automatic assignment + the manual one + expect(rows.some((r) => r.action === 'assigned')).toBe(true); + expect(rows.some((r) => r.action === 'reassigned')).toBe(true); + }); + + it('Scenario 5: re-escalation resolves a fresh eligible set and reassigns', async () => { + const beforeReEscalate = await prismaClient.assignment.findFirst({ + where: { ticketId, isCurrent: true }, + }); + + const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + await app.inject({ + method: 'PATCH', + url: `/tickets/${ticketId}/status`, + payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, + }); + + const afterReEscalate = await app.inject({ + method: 'GET', + url: `/tickets/${ticketId}/assignment`, + }); + expect(afterReEscalate.statusCode).toBe(200); + + const history = await app.inject({ + method: 'GET', + url: `/tickets/${ticketId}/assignment-history`, + }); + const rows: { agentId: string | null }[] = history.json().data; + // The pre-re-escalation assignment must still be present in history, whatever the new one is. + expect(rows.some((r) => r.agentId === beforeReEscalate?.agentId)).toBe(true); + }); +}); diff --git a/tests/integration/orchestration-strategies.test.ts b/tests/integration/orchestration-strategies.test.ts new file mode 100644 index 0000000..5ba5f84 --- /dev/null +++ b/tests/integration/orchestration-strategies.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildApp } from '@/app'; +import { prismaClient } from '@/infrastructure/database'; +import { FastifyInstance } from 'fastify'; +import { + encryptCredential, + generateCredentialSecret, + issueIntegrationToken, +} from '@/modules/catalog/products'; + +/** Covers specs/007-orchestration-assignment/quickstart.md Scenario 2 (LEAST_LOADED, + * SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */ +describe('Orchestration and assignment — strategies (User Story 2)', () => { + let app: FastifyInstance; + let secret: string; + let teamId: string; + + beforeAll(async () => { + app = await buildApp(); + const team = await app.inject({ + method: 'POST', + url: '/admin/teams', + payload: { name: `Strategy Team ${Date.now()}` }, + }); + teamId = team.json().data.id; + }); + + afterAll(async () => { + await app.close(); + }); + + async function createProductAndEscalate(problem = 'Needs a human.') { + const externalProductId = `TEST_STRAT_PROD_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const product = await prismaClient.product.create({ + data: { externalProductId, name: 'Strategy Test Product', status: 'active' }, + }); + secret = generateCredentialSecret(); + await prismaClient.productIntegration.create({ + data: { + productId: product.id, + credentialRef: encryptCredential(secret), + authMechanism: 'signed_token', + allowedScope: { tenantIds: ['tenant-1'] }, + status: 'active', + rateLimitPerMinute: 1000, + rateLimitPerUserPerMinute: 1000, + }, + }); + const token = issueIntegrationToken(secret, { + externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + }); + const created = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { authorization: `Bearer ${token}` }, + payload: { + productId: externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + source: 'test', + problem, + }, + }); + return { externalProductId, productId: product.id, ticketId: created.json().data.ticketId }; + } + + async function escalate(ticketId: string) { + const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + return app.inject({ + method: 'PATCH', + url: `/tickets/${ticketId}/status`, + payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, + }); + } + + it('LEAST_LOADED picks the eligible agent with the lowest current workload', async () => { + const skillTag = `least_loaded_skill_${Date.now()}`; + const agentLow = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'Low Load Agent' }, + }); + const agentHigh = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'High Load Agent' }, + }); + for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) { + await app.inject({ + method: 'PUT', + url: `/admin/agents/${id}/skills/${skillTag}`, + payload: { level: 1 }, + }); + } + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentLow.json().data.id}/availability`, + payload: { status: 'available', workingHours: {}, currentLoad: 1 }, + }); + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentHigh.json().data.id}/availability`, + payload: { status: 'available', workingHours: {}, currentLoad: 9 }, + }); + + const { externalProductId, ticketId } = await createProductAndEscalate(); + await app.inject({ + method: 'POST', + url: '/admin/hierarchy-nodes', + payload: { + name: `LL Node ${Date.now()}`, + order: 0, + productScope: [externalProductId], + skills: [skillTag], + assignmentStrategy: 'LEAST_LOADED', + }, + }); + + await escalate(ticketId); + const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + expect(current.json().data.agentId).toBe(agentLow.json().data.id); + }); + + it('SKILL_BASED prefers the eligible agent with the higher proficiency level', async () => { + const skillTag = `skill_based_skill_${Date.now()}`; + const agentExpert = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'Expert Agent' }, + }); + const agentNovice = await app.inject({ + method: 'POST', + url: `/admin/teams/${teamId}/agents`, + payload: { name: 'Novice Agent' }, + }); + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`, + payload: { level: 9 }, + }); + await app.inject({ + method: 'PUT', + url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`, + payload: { level: 1 }, + }); + + const { externalProductId, ticketId } = await createProductAndEscalate(); + await app.inject({ + method: 'POST', + url: '/admin/hierarchy-nodes', + payload: { + name: `SB Node ${Date.now()}`, + order: 0, + productScope: [externalProductId], + skills: [skillTag], + assignmentStrategy: 'SKILL_BASED', + }, + }); + + await escalate(ticketId); + const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + expect(current.json().data.agentId).toBe(agentExpert.json().data.id); + }); + + it('an empty eligible set assigns nobody, leaves the ticket escalated, and records the outcome', async () => { + const skillTag = `nobody_has_this_skill_${Date.now()}`; + const { externalProductId, ticketId } = await createProductAndEscalate(); + await app.inject({ + method: 'POST', + url: '/admin/hierarchy-nodes', + payload: { + name: `Empty Node ${Date.now()}`, + order: 0, + productScope: [externalProductId], + skills: [skillTag], + assignmentStrategy: 'ROUND_ROBIN', + }, + }); + + await escalate(ticketId); + + const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); + expect(current.statusCode).toBe(404); + + const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + expect(ticket.status).toBe('HUMAN_ESCALATION'); + + const history = await app.inject({ + method: 'GET', + url: `/tickets/${ticketId}/assignment-history`, + }); + const rows: { agentId: string | null; action: string }[] = history.json().data; + expect(rows.some((r) => r.agentId === null && r.action === 'unassigned')).toBe(true); + }); +}); diff --git a/tests/unit/orchestration/strategies.test.ts b/tests/unit/orchestration/strategies.test.ts new file mode 100644 index 0000000..f208d9c --- /dev/null +++ b/tests/unit/orchestration/strategies.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { roundRobinSelectIndex } from '@/modules/orchestration/assignments/strategies/round-robin.strategy'; +import { + lowestLoadCandidates, + AgentWithLoad, +} from '@/modules/orchestration/assignments/calculators/workload.calculator'; +import { + highestSkillScoreCandidates, + withSkillScores, + AgentWithSkillScore, +} from '@/modules/orchestration/assignments/calculators/skill-match.calculator'; + +function fakeAgent(id: string) { + return { + id, + teamId: 't', + name: id, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + skills: [], + }; +} + +describe('roundRobinSelectIndex', () => { + it('cycles through indices 0..length-1 as the count increases', () => { + expect(roundRobinSelectIndex(1, 3)).toBe(0); + expect(roundRobinSelectIndex(2, 3)).toBe(1); + expect(roundRobinSelectIndex(3, 3)).toBe(2); + expect(roundRobinSelectIndex(4, 3)).toBe(0); // wraps back to the first agent + }); + + it('never produces the same index for two different, sequential counter values within one cycle', () => { + const seen = new Set(); + for (let count = 1; count <= 5; count++) { + seen.add(roundRobinSelectIndex(count, 5)); + } + expect(seen.size).toBe(5); + }); +}); + +describe('lowestLoadCandidates', () => { + it('picks the single lowest-load agent when there is no tie', () => { + const withLoads: AgentWithLoad[] = [ + { agent: fakeAgent('a'), currentLoad: 5 }, + { agent: fakeAgent('b'), currentLoad: 2 }, + { agent: fakeAgent('c'), currentLoad: 8 }, + ]; + expect(lowestLoadCandidates(withLoads).map((a) => a.id)).toEqual(['b']); + }); + + it('returns every agent tied for the lowest load', () => { + const withLoads: AgentWithLoad[] = [ + { agent: fakeAgent('a'), currentLoad: 2 }, + { agent: fakeAgent('b'), currentLoad: 2 }, + { agent: fakeAgent('c'), currentLoad: 8 }, + ]; + expect( + lowestLoadCandidates(withLoads) + .map((a) => a.id) + .sort(), + ).toEqual(['a', 'b']); + }); + + it('returns an empty array for an empty input', () => { + expect(lowestLoadCandidates([])).toEqual([]); + }); +}); + +describe('withSkillScores', () => { + it('sums proficiency level only across the required skills, ignoring extras', () => { + const eligible = [ + { + ...fakeAgent('a'), + skills: [ + { id: '1', agentId: 'a', skillTag: 'x', level: 3 }, + { id: '2', agentId: 'a', skillTag: 'y', level: 10 }, // not required — ignored + ], + }, + { + ...fakeAgent('b'), + skills: [ + { id: '3', agentId: 'b', skillTag: 'x', level: 1 }, + { id: '4', agentId: 'b', skillTag: 'z', level: 1 }, + ], + }, + ]; + const scored = withSkillScores(eligible, ['x', 'z']); + expect(scored.find((s) => s.agent.id === 'a')?.totalLevel).toBe(3); // only x counts + expect(scored.find((s) => s.agent.id === 'b')?.totalLevel).toBe(2); // x + z + }); +}); + +describe('highestSkillScoreCandidates', () => { + it('picks the single highest-scoring agent when there is no tie', () => { + const scored: AgentWithSkillScore[] = [ + { agent: fakeAgent('a'), totalLevel: 3 }, + { agent: fakeAgent('b'), totalLevel: 7 }, + ]; + expect(highestSkillScoreCandidates(scored).map((a) => a.id)).toEqual(['b']); + }); + + it('returns every agent tied for the highest score', () => { + const scored: AgentWithSkillScore[] = [ + { agent: fakeAgent('a'), totalLevel: 5 }, + { agent: fakeAgent('b'), totalLevel: 5 }, + { agent: fakeAgent('c'), totalLevel: 1 }, + ]; + expect( + highestSkillScoreCandidates(scored) + .map((a) => a.id) + .sort(), + ).toEqual(['a', 'b']); + }); +});