Architecture
HITL State Machine
Complete state machine specification with transitions, guards, and side effects.
The HITL (Human-in-the-Loop) state machine governs the full lifecycle of an interview. Each transition has a trigger (actor), a guard (precondition), and side effects (Kafka events, webhooks, state updates). The machine is implemented in the Workflow Module and is the single source of truth for valid state changes.
All States
| State | Description | Waiting For |
|---|---|---|
| RECEIVED | A2A request received, data validation running | System validation |
| INFO_NEEDED | Missing critical data — processing blocked | HITL to complete info |
| VALIDATING_SKILLS | Agno Planner validating skills via pgvector | Agno Planner Agent |
| GENERATING_PLAN | LLM generating interview plan | Agno Planner Agent |
| PENDING | Plan ready, awaiting recruiter review | Recruiter approval decision |
| APPROVED | Plan approved, candidate link generated | Candidate to join |
| REJECTED | Plan rejected by recruiter — terminal | None (terminal) |
| SCHEDULED | Interview scheduled for a future time | Scheduled time |
| IN_PROGRESS | Live interview session active | Session completion |
| COMPLETED | Session ended, assessment generating | Agno Assessor Agent |
| ASSESSMENT_PENDING | AI assessment ready, awaiting recruiter review | Recruiter verdict |
| ASSESSMENT_APPROVED | Assessment approved — terminal | None (terminal) |
| CANCELLED | Interview cancelled at any stage | None (terminal) |
State Diagram
text
RECEIVED
│
├── [data complete]──────────────────► VALIDATING_SKILLS
│ │
└── [data incomplete]──► INFO_NEEDED │
│ │ ▼
│ HITL fills GENERATING_PLAN
│ missing data │
│ │ ▼
└─────────────────┘ PENDING
│
┌────────────────┤
│ │
APPROVE REJECT ──── REJECTED (terminal)
│
APPROVED
│
▼
SCHEDULED ──► IN_PROGRESS ──► COMPLETED
│
ASSESSMENT_PENDING
│
┌───────────────┤
│ │
ASSESSMENT_APPROVED CANCELLED (terminal)
Any state ──[cancel]──► CANCELLED (terminal)Transitions Table
| Transition | From | To | Actor | Side Effect |
|---|---|---|---|---|
| VALIDATE | RECEIVED | VALIDATING_SKILLS | System | Kafka: skill.validation.requested |
| REQUEST_INFO | RECEIVED | INFO_NEEDED | System | Kafka: interview.info_needed + webhook |
| COMPLETE_INFO | INFO_NEEDED | VALIDATING_SKILLS | HITL | Kafka: interview.info_completed + webhook |
| PLAN_READY | GENERATING_PLAN | PENDING | Agno Agent | Webhook: interview.plan_generated |
| APPROVE | PENDING | APPROVED | Recruiter | Generate interview link, substitute inmailDraft placeholders, webhook: interview.approved (includes interviewLink + inmailDraft) |
| REJECT | PENDING | REJECTED | Recruiter | Webhook: interview.rejected |
| MODIFY | PENDING | GENERATING_PLAN | Recruiter | Kafka: interview.modification_requested |
| SCHEDULE | APPROVED | SCHEDULED | System/Recruiter | Calendar event (optional) |
| START | SCHEDULED | IN_PROGRESS | Candidate (WebSocket) | WebSocket session created |
| COMPLETE | IN_PROGRESS | COMPLETED | Vert.x Edge | Kafka: interview.completed |
| ASSESS | COMPLETED | ASSESSMENT_PENDING | Agno Agent | Webhook: interview.assessment_ready |
| APPROVE_ASSESSMENT | ASSESSMENT_PENDING | ASSESSMENT_APPROVED | Recruiter | Webhook: assessment.approved |
| CANCEL | Any | CANCELLED | Admin/Recruiter | Webhook: interview.cancelled |
Implementation
typescript
// WorkflowService: applyTransition
async applyTransition(
interviewId: string,
tenantId: string,
transition: WorkflowTransition,
context: TransitionContext,
): Promise<WorkflowResult> {
const interview = await this.interviewService.findById(tenantId, interviewId);
// Guard: check if transition is valid from current state
const allowed = VALID_TRANSITIONS[interview.workflowState];
if (!allowed?.includes(transition)) {
throw new BadRequestException(
`Transition ${transition} is not valid from state ${interview.workflowState}`
);
}
// Determine next state
const nextState = STATE_MACHINE[interview.workflowState][transition];
// Apply transition atomically
await this.prisma.$transaction(async (tx) => {
// Update interview state
await tx.interview.update({
where: { id: interviewId, tenantId },
data: { workflowState: nextState },
});
// Record transition in history
await tx.workflowTransition.create({
data: {
interviewId,
tenantId,
from: interview.workflowState,
to: nextState,
transition,
triggeredBy: context.userId ?? 'system',
reason: context.reason,
},
});
});
// Publish side effects outside transaction
await this.publishSideEffects(transition, interview, context);
return { previousState: interview.workflowState, newState: nextState };
}All transition logic lives in the WorkflowService. Never update
workflowState directly with a Prisma update call outside of this service — doing so bypasses guards and side effects.Was this page helpful?