jobhunt-platform/apps/api/app/statemachine.py
hermes 17759ef564 T1: FastAPI backend core (models, state machine, approval gate) + compose test runner
- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine
  implementing the data-model transition table, approval gate with sha256 hash
  match + 24h expiry enforced at send time, EchoTransport pluggable sender,
  LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green
- apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations
  copied as directory)
- docker-compose.yml: add api-test service on compose network (host port
  publishing is broken in this sandbox; container-to-container networking used)
- Fix: replace masked placeholder password in config.py/conftest.py defaults
2026-07-30 18:10:59 +00:00

105 lines
No EOL
3 KiB
Python

"""State machine for application transitions.
Implements the transition table from docs/data-model.md.
Any transition not listed raises InvalidTransition (409 in API).
"""
from __future__ import annotations
from dataclasses import dataclass
class InvalidTransition(Exception):
"""Raised when a state transition is not allowed."""
def __init__(self, from_state: str, to_state: str, reason: str = ""):
self.from_state = from_state
self.to_state = to_state
self.reason = reason
super().__init__(
f"Invalid transition: {from_state} -> {to_state}"
+ (f": {reason}" if reason else "")
)
# All valid states
STATES = frozenset({
"discovered",
"scored",
"approved",
"rejected",
"drafting",
"sent",
"interviewing",
"offer",
"closed",
"expired",
})
# Transition table: (from, to) -> guard name (or None for no guard)
# Guard names map to guard functions below.
TRANSITIONS: dict[tuple[str, str], str | None] = {
("discovered", "scored"): "scoring_completed",
("discovered", "rejected"): None,
("scored", "approved"): None,
("scored", "rejected"): None,
("approved", "drafting"): None,
("drafting", "sent"): "confirmed_approval",
("sent", "interviewing"): None,
("interviewing", "offer"): None,
("interviewing", "closed"): None,
("offer", "closed"): None,
("scored", "expired"): None,
("approved", "expired"): None,
}
@dataclass
class TransitionContext:
"""Context passed to guard functions for validation."""
application_id: str
from_state: str
to_state: str
has_score: bool = False
has_confirmed_approval: bool = False
artifact_hash_match: bool = False
# Guard implementations
GUARDS: dict[str, callable] = {
"scoring_completed": lambda ctx: ctx.has_score,
"confirmed_approval": lambda ctx: ctx.has_confirmed_approval and ctx.artifact_hash_match,
}
def can_transition(from_state: str, to_state: str) -> bool:
"""Check if a transition is in the table (does not evaluate guards)."""
return (from_state, to_state) in TRANSITIONS
def check_transition(ctx: TransitionContext) -> None:
"""Validate a transition. Raises InvalidTransition if not allowed."""
if ctx.from_state not in STATES:
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.from_state}")
if ctx.to_state not in STATES:
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.to_state}")
key = (ctx.from_state, ctx.to_state)
if key not in TRANSITIONS:
raise InvalidTransition(
ctx.from_state,
ctx.to_state,
f"transition {ctx.from_state} -> {ctx.to_state} is not in the transition table",
)
guard_name = TRANSITIONS[key]
if guard_name is not None:
guard_fn = GUARDS[guard_name]
if not guard_fn(ctx):
raise InvalidTransition(
ctx.from_state,
ctx.to_state,
f"guard failed: {guard_name}",
)