diff --git a/.gitignore b/.gitignore index 19405f6..d29e893 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ .codegraph/ .local/ deploy/state/ +data/*.sqlite3* deploy/asterisk/generated/ services/asr-web/asr-web *.test diff --git a/AGENTS.md b/AGENTS.md index 2f82dc0..c6343b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,10 @@ | 被叫前缀 | `7089` | | 我方出口白名单 IP | `123.56.71.98` | +## 测试呼叫号码 + +- 用户指定测试被叫号码:`18601013734`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。 + - 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。 - 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。 - 传输协议、IP/Digest 鉴权、是否注册、编解码及并发限制仍需供应商确认;当前生成器的 UDP/ulaw 基线不代表这些参数已经确认。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6872998 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && mkdir -p /data \ + && chown 65532:65532 /data +COPY agent_call ./agent_call +COPY docs/contracts ./docs/contracts + +ENV PYTHONPATH=/app \ + AGENT_CALL_HOST=0.0.0.0 \ + AGENT_CALL_PORT=8080 \ + AGENT_CALL_DB=/data/agent-call.sqlite3 \ + AGENT_CALL_OBJECT_DIR=/data/objects +EXPOSE 8080 +USER 65532:65532 +VOLUME ["/data"] +ENTRYPOINT ["python", "-m", "agent_call"] diff --git a/agent_call/__init__.py b/agent_call/__init__.py new file mode 100644 index 0000000..4a0b075 --- /dev/null +++ b/agent_call/__init__.py @@ -0,0 +1,3 @@ +"""Contract-driven mock execution service for agent-call.""" + +__version__ = "0.1.0" diff --git a/agent_call/__main__.py b/agent_call/__main__.py new file mode 100644 index 0000000..40e2b01 --- /dev/null +++ b/agent_call/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/agent_call/core.py b/agent_call/core.py new file mode 100644 index 0000000..1a287b5 --- /dev/null +++ b/agent_call/core.py @@ -0,0 +1,2690 @@ +"""Small, durable mock-first call executor. + +The module intentionally keeps the state machine in one place. SQLite is used as +an actual relational store for local acceptance; RabbitMQ is an optional +publisher/consumer adapter selected by configuration. No HTTP endpoint accepts +an execution command. +""" + +from __future__ import annotations + +import hashlib +import importlib +import io +import json +import os +import queue +import secrets +import sqlite3 +import threading +import time +import uuid +import wave +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, cast + +from .mocks import MockAI, MockARI, MockRTP, MockSIPPeer, ProtocolJournal + +try: + from jsonschema import Draft202012Validator +except ImportError: # pragma: no cover - the Docker image installs it + Draft202012Validator = None + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_PROFILE = ROOT / "docs" / "contracts" / "mock-profile.json" +DEFAULT_MQ_SCHEMA = ROOT / "docs" / "contracts" / "mq.schema.json" + + +class ServiceError(Exception): + def __init__( + self, code: str, detail: str, status: int = 400, retryable: bool = False + ) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + self.status = status + self.retryable = retryable + + +class BrokerError(ServiceError): + def __init__(self, detail: str = "message broker unavailable") -> None: + super().__init__("BROKER_UNAVAILABLE", detail, 503, True) + + +class ConfigurationError(ServiceError): + def __init__(self, detail: str) -> None: + super().__init__("CONFIGURATION_INVALID", detail, 500, False) + + +class NotFoundError(ServiceError): + def __init__(self, detail: str = "resource not found") -> None: + super().__init__("NOT_FOUND", detail, 404, False) + + +class ConflictError(ServiceError): + def __init__(self, code: str, detail: str) -> None: + super().__init__(code, detail, 409, False) + + +class ValidationError(ServiceError): + def __init__(self, code: str, detail: str) -> None: + super().__init__(code, detail, 422, False) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def iso(value: datetime | None = None) -> str: + value = value or utcnow() + return ( + value.astimezone(timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +def parse_time(value: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (TypeError, ValueError) as exc: + raise ValidationError("INVALID_TIME", "timestamp must be RFC3339") from exc + if parsed.tzinfo is None: + raise ValidationError("INVALID_TIME", "timestamp must include a timezone") + return parsed.astimezone(timezone.utc) + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex}" + + +def canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def digest(value: Any) -> str: + return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest() + + +def routing_key(tenant_key: str) -> str: + value = f"agent-call.tenant.{tenant_key}.call.execute" + if len(value.encode("utf-8")) > 255: + raise ValidationError( + "TENANT_KEY_UNSUPPORTED", + "tenant routing key exceeds the 255-byte broker limit; command is retained and not sent", + ) + return value + + +def queue_name(tenant_key: str) -> str: + value = f"agent-call.executor.{tenant_key}.v1" + if len(value.encode("utf-8")) > 255: + raise ValidationError( + "TENANT_KEY_UNSUPPORTED", "tenant queue exceeds the broker limit" + ) + return value + + +def _json(value: Any) -> str: + return canonical(value) + + +def _loads(value: str | None, default: Any = None) -> Any: + if value is None: + return default + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError) as exc: + raise ValidationError("INVALID_JSON", "stored JSON is invalid") from exc + + +def _load_json_file(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigurationError(f"cannot load contract file: {path.name}") from exc + + +def _integer(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValidationError("INVALID_INTEGER", "numeric value is invalid") from exc + + +SCHEMA_SQL = """ +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tenants ( + tenant_id TEXT PRIMARY KEY, + tenant_key TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + max_concurrency INTEGER NOT NULL DEFAULT 2, + max_cps INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(tenant_id), + state TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS commands ( + command_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + command_type TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + task_id TEXT, + execution_id TEXT, + call_id TEXT, + trace_id TEXT, + issued_at TEXT, + not_after TEXT, + admission_deadline TEXT, + accepted_at TEXT, + waiting_since TEXT, + requested_task_revision INTEGER, + applied_task_revision INTEGER, + task_state TEXT, + reason_code TEXT, + wait_reason_code TEXT, + snapshot_cutoff TEXT, + source_type TEXT, + source_id TEXT, + aggregate_version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS commands_sched_idx ON commands(status, tenant_id, accepted_at); +CREATE TABLE IF NOT EXISTS executions ( + execution_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + command_id TEXT NOT NULL, + task_id TEXT NOT NULL, + task_item_id TEXT NOT NULL, + payload_hash TEXT NOT NULL, + status TEXT NOT NULL, + call_id TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS calls ( + call_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + execution_id TEXT NOT NULL UNIQUE, + command_id TEXT NOT NULL, + task_id TEXT NOT NULL, + task_item_id TEXT NOT NULL, + callee TEXT NOT NULL, + cell_id TEXT, + trunk_id TEXT, + egress_pool_id TEXT, + call_state TEXT NOT NULL, + call_version INTEGER NOT NULL DEFAULT 1, + outcome TEXT, + reason_code TEXT, + started_at TEXT, + ended_at TEXT, + duration_ms INTEGER, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS calls_active_idx ON calls(tenant_id, call_state); +CREATE TABLE IF NOT EXISTS attempts ( + attempt_id TEXT PRIMARY KEY, + call_id TEXT NOT NULL REFERENCES calls(call_id), + attempt_number INTEGER NOT NULL, + route_policy_id TEXT NOT NULL, + caller_profile_id TEXT NOT NULL, + target_uri TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT, + ended_at TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS transcripts ( + segment_id TEXT PRIMARY KEY, + call_id TEXT NOT NULL REFERENCES calls(call_id), + turn_index INTEGER NOT NULL, + speaker TEXT NOT NULL, + text TEXT NOT NULL, + revision INTEGER NOT NULL, + is_final INTEGER NOT NULL, + playback_state TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(call_id, turn_index, speaker) +); +CREATE TABLE IF NOT EXISTS recordings ( + recording_id TEXT PRIMARY KEY, + call_id TEXT NOT NULL REFERENCES calls(call_id), + tenant_id TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + checksum_algorithm TEXT NOT NULL, + checksum TEXT NOT NULL, + channels INTEGER NOT NULL, + sample_rate_hz INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + status TEXT NOT NULL, + oss_id TEXT, + upload_id TEXT, + failure_code TEXT, + created_at TEXT NOT NULL, + verified_at TEXT +); +CREATE TABLE IF NOT EXISTS uploads ( + upload_id TEXT PRIMARY KEY, + recording_id TEXT NOT NULL UNIQUE REFERENCES recordings(recording_id), + tenant_id TEXT NOT NULL, + expected_size INTEGER NOT NULL, + expected_checksum TEXT NOT NULL, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + object_path TEXT NOT NULL, + created_at TEXT NOT NULL, + completed_at TEXT +); +CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + trace_id TEXT NOT NULL, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + aggregate_version INTEGER NOT NULL, + occurred_at TEXT NOT NULL, + payload_json TEXT NOT NULL, + body_json TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 0, + broker_status TEXT NOT NULL DEFAULT 'pending', + broker_attempts INTEGER NOT NULL DEFAULT 0, + published_at TEXT, + next_attempt_at TEXT, + saas_applied INTEGER, + replay_of TEXT +); +CREATE INDEX IF NOT EXISTS events_outbox_idx ON events(published, next_attempt_at, occurred_at); +CREATE TABLE IF NOT EXISTS replays ( + replay_id TEXT PRIMARY KEY, + command_id TEXT NOT NULL UNIQUE, + tenant_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + snapshot_cutoff TEXT NOT NULL, + status TEXT NOT NULL, + sent_count INTEGER NOT NULL DEFAULT 0, + failure_code TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS replay_items ( + replay_id TEXT NOT NULL REFERENCES replays(replay_id), + event_id TEXT NOT NULL, + published_at TEXT NOT NULL, + PRIMARY KEY(replay_id, event_id) +); +CREATE TABLE IF NOT EXISTS inbox ( + event_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + applied_at TEXT NOT NULL, + body_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS dead_letters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_key TEXT, + routing_key TEXT, + body_json TEXT NOT NULL, + reason_code TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS published_commands ( + command_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + body_json TEXT NOT NULL, + body_hash TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS leases ( + lease_name TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + expires_at TEXT NOT NULL +); +""" + + +class Store: + def __init__(self, path: str | os.PathLike[str]) -> None: + requested = str(path) + self._shared_memory = requested == ":memory:" + self.path = ( + f"file:agent_call_{uuid.uuid4().hex}?mode=memory&cache=shared" + if self._shared_memory + else requested + ) + self._keeper: sqlite3.Connection | None = None + if self._shared_memory: + self._keeper = sqlite3.connect( + self.path, uri=True, timeout=10, isolation_level=None + ) + else: + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self._init_lock = threading.Lock() + self.init_schema() + + def connect(self) -> sqlite3.Connection: + con = sqlite3.connect( + self.path, uri=self._shared_memory, timeout=10, isolation_level=None + ) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA busy_timeout = 10000") + return con + + def init_schema(self) -> None: + with self._init_lock: + con = self.connect() + try: + con.executescript(SCHEMA_SQL) + con.execute( + "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES(1, ?)", + (iso(),), + ) + finally: + con.close() + + @contextmanager + def tx(self) -> Iterator[sqlite3.Connection]: + con = self.connect() + con.execute("BEGIN IMMEDIATE") + try: + yield con + con.commit() + except Exception: + con.rollback() + raise + finally: + con.close() + + def one(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row: + con = self.connect() + try: + # pi-lens-ignore: python-sql-injection — sql is paired with bound params and only callers in this module can supply it. + return cast(sqlite3.Row, con.execute(sql, params).fetchone()) + finally: + con.close() + + def all(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]: + con = self.connect() + try: + # pi-lens-ignore: python-sql-injection — sql is paired with bound params and only callers in this module can supply it. + return list(con.execute(sql, params).fetchall()) + finally: + con.close() + + +class InMemoryBroker: + """A strict direct-exchange broker used for deterministic acceptance tests.""" + + def __init__(self, max_messages: int = 0) -> None: + self._queues: dict[str, queue.Queue[dict[str, Any]]] = {} + self.max_messages = max_messages + self._routes: dict[str, str] = {} + self._lock = threading.Lock() + self.outage = False + self.published: list[dict[str, Any]] = [] + + def declare_tenant(self, tenant_key: str) -> None: + name = queue_name(tenant_key) + route = routing_key(tenant_key) + with self._lock: + self._queues.setdefault(name, queue.Queue(maxsize=self.max_messages)) + self._routes[route] = name + + def declare_events(self) -> None: + with self._lock: + self._queues.setdefault( + "agent-call.saas.events.v1", queue.Queue(maxsize=self.max_messages) + ) + + def publish( + self, + exchange: str, + route: str, + body: dict[str, Any], + message_id: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + if self.outage: + raise BrokerError() + with self._lock: + if exchange == "agent-call.commands.v1": + target = self._routes.get(route) + else: + target = ( + "agent-call.saas.events.v1" + if route.startswith("agent-call.") + else None + ) + if target is None or target not in self._queues: + raise BrokerError("mandatory publish was unroutable") + message = { + "exchange": exchange, + "routing_key": route, + "body": body, + "message_id": message_id, + "headers": headers or {}, + } + try: + self._queues[target].put_nowait(message) + except queue.Full as exc: + raise BrokerError("queue is full; publication is retained") from exc + self.published.append(message) + + def ack(self, message: dict[str, Any]) -> None: + return + + def consume(self, name: str) -> dict[str, Any] | None: + if self.outage: + raise BrokerError() + with self._lock: + target = self._queues.get(name) + if target is None: + raise BrokerError("queue is not declared") + try: + return target.get_nowait() + except queue.Empty: + return None + + def ready(self) -> bool: + return not self.outage + + +class PikaBroker: + """RabbitMQ adapter. Import and connection are optional in local unit tests.""" + + def __init__(self, url: str) -> None: + try: + pika = importlib.import_module("pika") + except ImportError as exc: # pragma: no cover + raise ConfigurationError( + "BROKER_MODE=rabbit requires the pika package" + ) from exc + self.pika = pika + try: + self.connection = pika.BlockingConnection(pika.URLParameters(url)) + self.channel = self.connection.channel() + self.channel.confirm_delivery() + self.channel.basic_qos(prefetch_count=1) + self.channel.exchange_declare( + exchange="agent-call.commands.v1", exchange_type="direct", durable=True + ) + self.channel.exchange_declare( + exchange="agent-call.events.v1", exchange_type="topic", durable=True + ) + self.channel.queue_declare(queue="agent-call.saas.events.v1", durable=True) + self.channel.queue_bind( + queue="agent-call.saas.events.v1", + exchange="agent-call.events.v1", + routing_key="agent-call.#", + ) + except Exception as exc: # pragma: no cover + raise BrokerError( + f"RabbitMQ connection failed: {type(exc).__name__}" + ) from exc + + def declare_tenant(self, tenant_key: str) -> None: # pragma: no cover + name = queue_name(tenant_key) + route = routing_key(tenant_key) + self.channel.queue_declare( + queue=name, durable=True, arguments={"x-max-length": 10000} + ) + self.channel.queue_bind( + queue=name, exchange="agent-call.commands.v1", routing_key=route + ) + + def declare_events(self) -> None: # pragma: no cover + return + + def publish( + self, + exchange: str, + route: str, + body: dict[str, Any], + message_id: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: # pragma: no cover + try: + props = self.pika.BasicProperties( + delivery_mode=2, + content_type="application/json", + message_id=message_id, + headers=headers or {}, + ) + confirmed = self.channel.basic_publish( + exchange=exchange, + routing_key=route, + body=canonical(body).encode("utf-8"), + properties=props, + mandatory=True, + ) + if confirmed is not None and not confirmed: + raise BrokerError("publisher confirmation was negative") + except BrokerError: + raise + except Exception as exc: + raise BrokerError(f"RabbitMQ publish failed: {type(exc).__name__}") from exc + + def consume(self, name: str) -> dict[str, Any] | None: # pragma: no cover + try: + method, props, body = self.channel.basic_get(queue=name, auto_ack=False) + if method is None or props is None or body is None: + return None + try: + parsed = _loads(body.decode("utf-8")) + except (ServiceError, UnicodeDecodeError): + parsed = {"_invalid_payload": True} + return { + "body": parsed, + "message_id": props.message_id, + "headers": props.headers or {}, + "_delivery_tag": method.delivery_tag, + } + except Exception as exc: + raise BrokerError(f"RabbitMQ consume failed: {type(exc).__name__}") from exc + + def ack(self, message: dict[str, Any]) -> None: # pragma: no cover + tag = message.get("_delivery_tag") + if tag is None: + return + try: + self.channel.basic_ack(tag) + except Exception as exc: + raise BrokerError("RabbitMQ acknowledgement failed") from exc + + def ready(self) -> bool: # pragma: no cover + return bool(self.connection.is_open and self.channel.is_open) + + +class MockObjectStore: + def __init__(self, root: str | os.PathLike[str], secret: str | None = None) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + seed = secret or os.environ.get("MOCK_UPLOAD_SECRET") or self.root.as_posix() + self.secret = seed.encode("utf-8") + + def path_for(self, upload_id: str) -> Path: + return self.root / f"{upload_id}.part" + + def final_path_for(self, upload_id: str) -> Path: + return self.root / upload_id + + def token(self, upload_id: str, tenant_id: str) -> str: + return hashlib.sha256( + self.secret + b":" + tenant_id.encode() + b":" + upload_id.encode() + ).hexdigest() + + def put(self, upload_id: str, tenant_id: str, data: bytes, token: str) -> None: + if not secrets.compare_digest(token, self.token(upload_id, tenant_id)): + raise ValidationError("UPLOAD_TOKEN_INVALID", "upload token is invalid") + path = self.path_for(upload_id) + path.write_bytes(data) + + def verify_and_promote( + self, upload_id: str, expected_size: int, expected_checksum: str + ) -> str: + path = self.path_for(upload_id) + if not path.exists(): + raise ValidationError("OBJECT_MISSING", "object was not uploaded") + data = path.read_bytes() + actual = hashlib.sha256(data).hexdigest() + if len(data) != expected_size or actual != expected_checksum: + raise ValidationError( + "OBJECT_VERIFICATION_FAILED", + "size or checksum does not match the upload contract", + ) + final = self.final_path_for(upload_id) + os.replace(path, final) + return f"oss://mock/{upload_id}" + + +class AgentCallService: + def __init__( + self, + db_path: str | os.PathLike[str] = ":memory:", + profile_path: str | os.PathLike[str] = DEFAULT_PROFILE, + object_dir: str | os.PathLike[str] | None = None, + broker: InMemoryBroker | PikaBroker | None = None, + mode: str | None = None, + clock: Callable[[], datetime] = utcnow, + start_background: bool = False, + ) -> None: + self.clock = clock + self.profile_path = Path(profile_path) + self.profile = _load_json_file(self.profile_path) + self.mq_schema = _load_json_file(DEFAULT_MQ_SCHEMA) + self.mode = mode or self.profile.get("mode", "mock") + self._validate_profile() + self.store = Store(db_path) + self.instance_id = new_id("instance") + object_root = ( + Path(object_dir) + if object_dir + else ( + Path(db_path).parent / "objects" + if str(db_path) != ":memory:" + else Path("data") / "agent-call-objects" + ) + ) + self.object_store = MockObjectStore( + object_root, os.environ.get("MOCK_UPLOAD_SECRET") + ) + self.journal = ProtocolJournal(object_root / "protocol.jsonl") + self.mock_sip = MockSIPPeer(self.journal) + self.mock_ari = MockARI(self.journal) + self.mock_rtp = MockRTP(self.journal) + self.mock_ai = MockAI(self.journal) + if broker is None: + broker_mode = self.profile.get("provider_modes", {}).get( + "rabbitmq", "memory" + ) + if broker_mode == "rabbit" or os.environ.get("BROKER_MODE") == "rabbit": + broker_url = os.environ.get("RABBITMQ_URL") + if not broker_url: + raise ConfigurationError("BROKER_MODE=rabbit requires RABBITMQ_URL") + broker = PikaBroker(broker_url) + else: + broker = InMemoryBroker( + _integer( + self.profile.get("limits", {}).get("max_queue_messages", 1000) + ) + ) + self.broker = broker + self.profile.setdefault("provider_modes", {})["rabbitmq"] = ( + "rabbit" if isinstance(broker, PikaBroker) else "memory" + ) + self._seed() + self._tenant_order = [ + item["tenant_id"] for item in self.profile.get("tenants", []) + ] + self._cell_health = { + item["cell_id"]: bool(item.get("enabled", True)) + for item in self.profile.get("cells", []) + } + self._cursor = 0 + self._failures: dict[str, str] = {} + self._call_threads: dict[str, threading.Thread] = {} + self._stop = threading.Event() + self._background: threading.Thread | None = None + self._last_error: str | None = None + if start_background: + self.start() + + def _validate_profile(self) -> None: + if self.mode not in {"mock", "mixed", "real"}: + raise ConfigurationError("mode must be mock, mixed, or real") + providers = self.profile.get("provider_modes", {}) + if self.mode == "real" and any(value != "real" for value in providers.values()): + raise ConfigurationError( + "real mode refuses mock or test providers; no silent fallback is allowed" + ) + seen: set[str] = set() + for tenant in self.profile.get("tenants", []): + tenant_key = tenant["tenant_key"] + if tenant_key in seen: + raise ConfigurationError("tenant_key must be unique") + seen.add(tenant_key) + routing_key(tenant_key) + queue_name(tenant_key) + if not self.profile.get("cells"): + raise ConfigurationError("at least one cell is required") + + def _seed(self) -> None: + now = iso(self.clock()) + limits = self.profile.get("limits", {}) + with self.store.tx() as con: + for tenant in self.profile.get("tenants", []): + con.execute( + "INSERT OR IGNORE INTO tenants(tenant_id, tenant_key, enabled, max_concurrency, max_cps, created_at) VALUES(?,?,?,?,?,?)", + ( + tenant["tenant_id"], + tenant["tenant_key"], + _integer(tenant.get("enabled", True)), + limits.get("tenant_concurrency", 2), + limits.get("tenant_cps", 1), + now, + ), + ) + for task in self.profile.get("tasks", []): + con.execute( + "INSERT OR IGNORE INTO tasks(task_id, tenant_id, state, revision, updated_at) VALUES(?,?,?,?,?)", + ( + task["task_id"], + task["tenant_id"], + task.get("state", "running"), + task.get("revision", 1), + now, + ), + ) + for tenant in self.profile.get("tenants", []): + self.broker.declare_tenant(tenant["tenant_key"]) + self.broker.declare_events() + + def start(self) -> None: + if self._background and self._background.is_alive(): + return + self._stop.clear() + self._background = threading.Thread( + target=self._run_loop, name="agent-call-loop", daemon=True + ) + self._background.start() + + def stop(self) -> None: + self._stop.set() + deadline = time.monotonic() + 2 + if self._background: + self._background.join(timeout=max(0, deadline - time.monotonic())) + for thread in list(self._call_threads.values()): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + thread.join(timeout=remaining) + + def _run_loop(self) -> None: + while not self._stop.is_set(): + try: + self.process_once() + except Exception as exc: + # The next tick retries durable outbox and waiting work. Do not log + # payloads, credentials, or raw broker diagnostics. + self._last_error = type(exc).__name__ + self._stop.wait(0.02) + + def _now(self) -> datetime: + return self.clock().astimezone(timezone.utc) + + def _limits(self) -> dict[str, Any]: + return self.profile.get("limits", {}) + + def _route_config(self, route_policy_id: str) -> dict[str, Any]: + for route in self.profile.get("routes", []): + if route.get("route_policy_id") == route_policy_id and route.get( + "allowed", True + ): + return route + raise ValidationError( + "ROUTE_POLICY_NOT_ALLOWED", "route policy is not configured" + ) + + def _tenant(self, tenant_id: str, tenant_key: str | None = None) -> sqlite3.Row: + row = self.store.one("SELECT * FROM tenants WHERE tenant_id = ?", (tenant_id,)) + if ( + row is None + or not row["enabled"] + or (tenant_key is not None and row["tenant_key"] != tenant_key) + ): + raise ValidationError( + "TENANT_BINDING_INVALID", + "tenant_id and tenant_key are not an authorized pair", + ) + return row + + def _validate_message(self, body: dict[str, Any]) -> None: + if not isinstance(body, dict): + raise ValidationError("SCHEMA_INVALID", "MQ message must be a JSON object") + if len(canonical(body).encode("utf-8")) > self._limits().get( + "max_mq_bytes", 262144 + ): + raise ValidationError( + "MESSAGE_TOO_LARGE", "message exceeds the MQ body limit" + ) + if Draft202012Validator is not None: + errors = sorted( + Draft202012Validator(self.mq_schema).iter_errors(body), + key=lambda error: list(error.path), + ) + if errors: + path = ".".join(str(part) for part in errors[0].path) or "body" + raise ValidationError("SCHEMA_INVALID", f"invalid MQ message at {path}") + else: + required = { + "schema_version", + "command_type", + "command_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload", + } + if not required.issubset(body): + raise ValidationError( + "SCHEMA_INVALID", "required MQ fields are missing" + ) + + def _validate_event(self, body: dict[str, Any]) -> None: + if Draft202012Validator is not None: + errors = sorted( + Draft202012Validator(self.mq_schema).iter_errors(body), + key=lambda error: list(error.path), + ) + if errors: + raise ConfigurationError( + "service generated an event outside mq.schema.json" + ) + + def publish_execute(self, body: dict[str, Any]) -> dict[str, Any]: + """SaaS-side test publisher. The production ingress is the broker queue.""" + self._validate_message(body) + tenant = self._tenant(body["tenant_id"], body["tenant_key"]) + task = self.store.one( + "SELECT * FROM tasks WHERE task_id=? AND tenant_id=?", + (body["payload"]["task_id"], tenant["tenant_id"]), + ) + if task is None: + raise ValidationError( + "TASK_TENANT_MISMATCH", "task is not owned by the command tenant" + ) + if task["state"] != "running": + raise ConflictError( + "TASK_PUBLISH_BLOCKED", + "SaaS persistent task block prevents new publication", + ) + route = routing_key(tenant["tenant_key"]) + body_hash = digest(body) + with self.store.tx() as con: + existing = con.execute( + "SELECT * FROM published_commands WHERE command_id = ? AND tenant_id = ?", + (body["command_id"], tenant["tenant_id"]), + ).fetchone() + foreign = con.execute( + "SELECT 1 FROM published_commands WHERE command_id = ?", + (body["command_id"],), + ).fetchone() + if foreign and existing is None: + raise ConflictError("IDEMPOTENCY_CONFLICT", "command id is unavailable") + if existing: + if existing["body_hash"] != body_hash: + raise ConflictError( + "IDEMPOTENCY_CONFLICT", + "command_id was already published with a different body", + ) + if existing["status"] not in {"pending", "retained"}: + return { + "command_id": body["command_id"], + "status": existing["status"], + "routing_key": route, + } + else: + con.execute( + "INSERT INTO published_commands(command_id, tenant_id, body_json, body_hash, status, created_at) VALUES(?,?,?,?,?,?)", + ( + body["command_id"], + tenant["tenant_id"], + canonical(body), + body_hash, + "pending", + iso(self._now()), + ), + ) + try: + self.broker.publish( + "agent-call.commands.v1", route, body, body["command_id"] + ) + except Exception: + with self.store.tx() as con: + con.execute( + "UPDATE published_commands SET status = 'retained' WHERE command_id = ?", + (body["command_id"],), + ) + raise + with self.store.tx() as con: + con.execute( + "UPDATE published_commands SET status = 'broker_confirmed' WHERE command_id = ?", + (body["command_id"],), + ) + return { + "command_id": body["command_id"], + "status": "broker_confirmed", + "routing_key": route, + } + + def ingest_commands(self, max_per_tick: int | None = None) -> int: + max_per_tick = ( + max_per_tick + if max_per_tick is not None + else max(1, len(self._tenant_order)) + ) + if not self._tenant_order: + return 0 + accepted = 0 + for offset in range(min(max_per_tick, len(self._tenant_order))): + tenant_id = self._tenant_order[ + (self._cursor + offset) % len(self._tenant_order) + ] + tenant = self.store.one( + "SELECT * FROM tenants WHERE tenant_id = ?", (tenant_id,) + ) + if tenant is None: + continue + try: + message = self.broker.consume(queue_name(tenant["tenant_key"])) + except BrokerError: + break + if not message: + continue + body: Any = message.get("body", {}) + body_map = body if isinstance(body, dict) else {} + try: + self.accept_execute(body) + accepted += 1 + with self.store.tx() as con: + con.execute( + "UPDATE published_commands SET status = 'accepted' WHERE command_id = ?", + (body_map.get("command_id"),), + ) + self.broker.ack(message) + except ServiceError as exc: + with self.store.tx() as con: + con.execute( + "INSERT INTO dead_letters(tenant_key, routing_key, body_json, reason_code, created_at) VALUES(?,?,?,?,?)", + ( + body_map.get("tenant_key"), + message.get("routing_key"), + canonical(body), + exc.code, + iso(self._now()), + ), + ) + if body_map.get("command_id"): + con.execute( + "UPDATE published_commands SET status = 'rejected' WHERE command_id = ?", + (body_map["command_id"],), + ) + self.broker.ack(message) + self._cursor = (self._cursor + max_per_tick) % len(self._tenant_order) + return accepted + + def accept_execute(self, body: dict[str, Any]) -> dict[str, Any]: + self._validate_message(body) + tenant = self._tenant(body["tenant_id"], body["tenant_key"]) + payload = body["payload"] + task = self.store.one( + "SELECT * FROM tasks WHERE task_id = ?", (payload["task_id"],) + ) + if task is None or task["tenant_id"] != tenant["tenant_id"]: + raise ValidationError( + "TASK_TENANT_MISMATCH", "task is not owned by the command tenant" + ) + command_hash = digest(body) + now = self._now() + not_after = parse_time(body["not_after"]) + parse_time(body["issued_at"]) + deadline = min( + not_after, + now + timedelta(seconds=self._limits().get("admission_window_s", 30)), + ) + with self.store.tx() as con: + existing = con.execute( + "SELECT * FROM commands WHERE command_id = ? AND tenant_id = ?", + (body["command_id"], tenant["tenant_id"]), + ).fetchone() + foreign = con.execute( + "SELECT 1 FROM commands WHERE command_id = ?", + (body["command_id"],), + ).fetchone() + if foreign and existing is None: + raise ConflictError("IDEMPOTENCY_CONFLICT", "command id is unavailable") + if existing: + if existing["payload_hash"] != command_hash: + raise ConflictError( + "IDEMPOTENCY_CONFLICT", + "command_id was already accepted with a different body", + ) + return self._command_snapshot(existing) + reason: str | None = None + status = "accepted" + if payload["task_revision"] != task["revision"]: + status, reason = "rejected", "STALE_TASK_REVISION" + elif task["state"] != "running": + status, reason = "rejected", f"TASK_{task['state'].upper()}" + elif not_after <= now: + status, reason = "rejected", "COMMAND_EXPIRED" + elif payload["route_policy_id"] not in { + x["route_policy_id"] + for x in self.profile.get("routes", []) + if x.get("allowed", True) + }: + status, reason = "rejected", "ROUTE_POLICY_NOT_ALLOWED" + elif payload["caller_profile_id"] not in { + x["caller_profile_id"] + for x in self.profile.get("caller_profiles", []) + if x.get("allowed", True) + }: + status, reason = "rejected", "CALLER_PROFILE_NOT_ALLOWED" + elif payload["agent_version_id"] not in { + x["agent_version_id"] + for x in self.profile.get("agents", []) + if x.get("immutable", True) + }: + status, reason = "rejected", "AGENT_VERSION_NOT_ALLOWED" + execution = con.execute( + "SELECT * FROM executions WHERE execution_id = ?", + (payload["execution_id"],), + ).fetchone() + if ( + execution + and execution["tenant_id"] != tenant["tenant_id"] + or execution + and execution["payload_hash"] != digest(payload) + ): + status, reason = "rejected", "EXECUTION_ID_CONFLICT" + elif execution: + status, reason = "completed", "DUPLICATE_EXECUTION" + con.execute( + """INSERT INTO commands(command_id, tenant_id, tenant_key, command_type, status, + payload_json, payload_hash, task_id, execution_id, call_id, trace_id, issued_at, + not_after, admission_deadline, accepted_at, reason_code, task_state, updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + body["command_id"], + tenant["tenant_id"], + tenant["tenant_key"], + body["command_type"], + status, + canonical(body), + command_hash, + payload["task_id"], + payload["execution_id"], + execution["call_id"] if execution else None, + body["trace_id"], + body["issued_at"], + body["not_after"], + iso(deadline), + iso(now), + reason, + task["state"], + iso(now), + ), + ) + if status == "accepted": + con.execute( + "INSERT INTO executions(execution_id, tenant_id, command_id, task_id, task_item_id, payload_hash, status, created_at) VALUES(?,?,?,?,?,?,?,?)", + ( + payload["execution_id"], + tenant["tenant_id"], + body["command_id"], + payload["task_id"], + payload["task_item_id"], + digest(payload), + "accepted", + iso(now), + ), + ) + elif execution: + con.execute( + "UPDATE executions SET status = 'duplicate' WHERE execution_id = ?", + (payload["execution_id"],), + ) + row = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (body["command_id"],) + ).fetchone() + self._command_event_tx(con, row, reason or "ACCEPTED") + return self._command_snapshot(row) + + def _command_event_tx( + self, con: sqlite3.Connection, row: sqlite3.Row, reason: str | None = None + ) -> None: + payload = { + "command_id": row["command_id"], + "command_type": row["command_type"], + "status": row["status"], + "reason_code": reason or row["reason_code"], + "task_id": row["task_id"], + "execution_id": row["execution_id"], + "call_id": row["call_id"], + "requested_task_revision": row["requested_task_revision"], + "applied_task_revision": row["applied_task_revision"], + "wait_reason_code": row["wait_reason_code"], + } + self._event_tx( + con, + row["tenant_id"], + row["tenant_key"], + row["trace_id"] or row["command_id"], + "command.result", + "command", + row["command_id"], + row["aggregate_version"], + payload, + row["accepted_at"] or iso(self._now()), + ) + + def _event_tx( + self, + con: sqlite3.Connection, + tenant_id: str, + tenant_key: str, + trace_id: str, + event_type: str, + aggregate_type: str, + aggregate_id: str, + version: int, + payload: dict[str, Any], + occurred_at: str | None = None, + replay_of: str | None = None, + ) -> dict[str, Any]: + body = { + "schema_version": "1.0", + "event_id": new_id("evt"), + "event_type": event_type, + "tenant_id": tenant_id, + "tenant_key": tenant_key, + "trace_id": trace_id, + "occurred_at": occurred_at or iso(self._now()), + "aggregate_type": aggregate_type, + "aggregate_id": aggregate_id, + "aggregate_version": version, + "payload": payload, + } + self._validate_event(body) + con.execute( + """INSERT INTO events(event_id, tenant_id, tenant_key, trace_id, event_type, + aggregate_type, aggregate_id, aggregate_version, occurred_at, payload_json, body_json, replay_of) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + body["event_id"], + tenant_id, + tenant_key, + trace_id, + event_type, + aggregate_type, + aggregate_id, + version, + body["occurred_at"], + canonical(payload), + canonical(body), + replay_of, + ), + ) + return body + + def _command_snapshot(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + return { + "command_id": row["command_id"], + "command_type": row["command_type"], + "tenant_id": row["tenant_id"], + "tenant_key": row["tenant_key"], + "task_id": row["task_id"], + "execution_id": row["execution_id"], + "call_id": row["call_id"], + "status": row["status"], + "reason_code": row["reason_code"], + "wait_reason_code": row["wait_reason_code"], + "accepted_at": row["accepted_at"], + "waiting_since": row["waiting_since"], + "admission_deadline": row["admission_deadline"], + "requested_task_revision": row["requested_task_revision"], + "applied_task_revision": row["applied_task_revision"], + "task_state": row["task_state"], + "updated_at": row["updated_at"], + "aggregate_version": row["aggregate_version"], + } + + def _claim_lease(self) -> bool: + now = self._now() + expires = now + timedelta(seconds=2) + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM leases WHERE lease_name = 'scheduler'" + ).fetchone() + if ( + row + and row["owner_id"] != self.instance_id + and parse_time(row["expires_at"]) > now + ): + return False + con.execute( + "INSERT INTO leases(lease_name, owner_id, expires_at) VALUES('scheduler', ?, ?) ON CONFLICT(lease_name) DO UPDATE SET owner_id=excluded.owner_id, expires_at=excluded.expires_at", + (self.instance_id, iso(expires)), + ) + return True + + def process_once(self) -> int: + if not self._claim_lease(): + return 0 + self.ingest_commands() + self.apply_controls() + scheduled = self.schedule_once() + self.dispatch_outbox() + self.consume_saas_events() + self.process_replays() + return scheduled + + def _active_counts(self, tenant_id: str | None = None) -> tuple[int, int]: + if tenant_id: + row = self.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE call_state NOT IN ('ended', 'failed') AND tenant_id = ?", + (tenant_id,), + ) + else: + row = self.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE call_state NOT IN ('ended', 'failed')" + ) + return _integer(row["n"]), 0 + + def _resource_cell(self) -> tuple[str, dict[str, Any]] | None: + for cell in self.profile.get("cells", []): + if not self._cell_health.get(cell["cell_id"], False): + continue + row = self.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE cell_id = ? AND call_state NOT IN ('ended','failed')", + (cell["cell_id"],), + ) + if _integer(row["n"]) < _integer(cell.get("capacity", 1)): + return cell["cell_id"], cell + return None + + def set_cell_health(self, cell_id: str, healthy: bool) -> None: + if cell_id not in self._cell_health: + raise ValidationError("CELL_NOT_FOUND", "cell is not configured") + self._cell_health[cell_id] = healthy + + def _cps_available(self, tenant_id: str) -> bool: + now = self._now() + since = iso(now - timedelta(seconds=1)) + global_row = self.store.one( + "SELECT COUNT(*) AS n FROM attempts WHERE created_at >= ?", (since,) + ) + tenant_row = self.store.one( + "SELECT COUNT(*) AS n FROM attempts a JOIN calls c ON c.call_id = a.call_id WHERE c.tenant_id = ? AND a.created_at >= ?", + (tenant_id, since), + ) + return _integer(global_row["n"]) < _integer( + self._limits().get("global_cps", 3) + ) and _integer(tenant_row["n"]) < _integer(self._limits().get("tenant_cps", 1)) + + def _mark_waiting(self, command_id: str, reason: str) -> None: + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + if row is None or row["status"] not in {"accepted", "waiting"}: + return + if row["status"] == "waiting" and row["wait_reason_code"] == reason: + return + version = row["aggregate_version"] + 1 + now = iso(self._now()) + con.execute( + "UPDATE commands SET status='waiting', waiting_since=COALESCE(waiting_since, ?), wait_reason_code=?, aggregate_version=?, updated_at=? WHERE command_id=?", + (now, reason, version, now, command_id), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + self._command_event_tx(con, updated, reason) + + def _reject_command(self, command_id: str, reason: str) -> None: + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + if row is None or row["status"] in { + "completed", + "failed", + "rejected", + "applied", + }: + return + version = row["aggregate_version"] + 1 + now = iso(self._now()) + con.execute( + "UPDATE commands SET status='rejected', reason_code=?, aggregate_version=?, updated_at=? WHERE command_id=?", + (reason, version, now, command_id), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + self._command_event_tx(con, updated, reason) + + def schedule_once(self) -> int: + scheduled = 0 + if not self._tenant_order: + return scheduled + order = [ + self._tenant_order[(self._cursor + index) % len(self._tenant_order)] + for index in range(len(self._tenant_order)) + ] + for tenant_id in order: + row = self.store.one( + "SELECT * FROM commands WHERE tenant_id = ? AND status IN ('accepted','waiting') ORDER BY accepted_at, command_id LIMIT 1", + (tenant_id,), + ) + if row is None: + continue + now = self._now() + if ( + row["admission_deadline"] + and parse_time(row["admission_deadline"]) <= now + ): + self._reject_command(row["command_id"], "ADMISSION_TIMEOUT") + continue + task = self.store.one( + "SELECT * FROM tasks WHERE task_id = ?", (row["task_id"],) + ) + if task is None or task["state"] != "running": + self._reject_command( + row["command_id"], + f"TASK_{task['state'].upper() if task else 'MISSING'}", + ) + continue + active_tenant = self._active_counts(tenant_id)[0] + active_global = self._active_counts()[0] + tenant_limit = _integer( + self.store.one( + "SELECT max_concurrency FROM tenants WHERE tenant_id = ?", + (tenant_id,), + )["max_concurrency"] + ) + if active_tenant >= tenant_limit or active_global >= _integer( + self._limits().get("global_concurrency", 6) + ): + self._mark_waiting(row["command_id"], "CONCURRENCY") + continue + if not self._cps_available(tenant_id): + self._mark_waiting(row["command_id"], "CPS") + continue + cell = self._resource_cell() + if cell is None: + self._mark_waiting(row["command_id"], "CELL_CAPACITY") + continue + self._reserve_call(row["command_id"], row, cell) + scheduled += 1 + self._cursor = (self._cursor + 1) % len(self._tenant_order) + return scheduled + + def _reserve_call( + self, command_id: str, row: sqlite3.Row, cell: tuple[str, dict[str, Any]] + ) -> None: + cell_id, cell_cfg = cell + payload = _loads(row["payload_json"])["payload"] + route = self._route_config(payload["route_policy_id"]) + prefix = str(route.get("dial_prefix", "")) + target_uri = ( + payload["callee"] + if prefix and payload["callee"].startswith(prefix) + else prefix + payload["callee"] + ) + caller = next( + ( + item.get("display", "") + for item in self.profile.get("caller_profiles", []) + if item.get("caller_profile_id") == payload["caller_profile_id"] + ), + payload["caller_profile_id"], + ) + call_id = new_id("call") + attempt_id = new_id("attempt") + now = iso(self._now()) + with self.store.tx() as con: + current = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + if current is None or current["status"] not in {"accepted", "waiting"}: + return + version = current["aggregate_version"] + 1 + con.execute( + "UPDATE commands SET status='executing', call_id=?, waiting_since=NULL, wait_reason_code=NULL, aggregate_version=?, updated_at=? WHERE command_id=?", + (call_id, version, now, command_id), + ) + con.execute( + "INSERT INTO calls(call_id, tenant_id, execution_id, command_id, task_id, task_item_id, callee, cell_id, trunk_id, egress_pool_id, call_state, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + ( + call_id, + current["tenant_id"], + current["execution_id"], + command_id, + current["task_id"], + payload["task_item_id"], + payload["callee"], + cell_id, + route.get("trunk_id", "trunk-mock"), + route.get( + "egress_pool_id", cell_cfg.get("egress_pool_id", "egress-mock") + ), + "queued", + now, + ), + ) + con.execute( + "INSERT INTO attempts(attempt_id, call_id, attempt_number, route_policy_id, caller_profile_id, target_uri, status, created_at) VALUES(?,?,?,?,?,?,?,?)", + ( + attempt_id, + call_id, + 1, + payload["route_policy_id"], + payload["caller_profile_id"], + target_uri, + "reserved", + now, + ), + ) + con.execute( + "UPDATE executions SET status='executing', call_id=? WHERE execution_id=?", + (call_id, current["execution_id"]), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (command_id,) + ).fetchone() + self._command_event_tx(con, updated, "EXECUTING") + self._event_tx( + con, + current["tenant_id"], + current["tenant_key"], + current["trace_id"], + "call.status", + "call", + call_id, + 1, + { + "call_id": call_id, + "execution_id": current["execution_id"], + "task_id": current["task_id"], + "call_state": "queued", + "reason_code": None, + }, + now, + ) + self.mock_sip.invite(call_id, payload["callee"], target_uri, caller) + self.mock_ari.channel_created(call_id, cell_id) + self.mock_ari.bridge_ready(call_id) + thread = threading.Thread( + target=self._run_call, args=(call_id,), name=f"call-{call_id}", daemon=True + ) + self._call_threads[call_id] = thread + thread.start() + + def set_failure(self, command_or_call_id: str, scenario: str) -> None: + allowed = set(self.profile.get("failure_scenarios", [])) + if scenario not in allowed: + raise ValidationError( + "SCENARIO_NOT_ALLOWED", "failure scenario is not in the mock profile" + ) + self._failures[command_or_call_id] = scenario + + def _scenario_for(self, call_id: str) -> str: + row = self.store.one( + "SELECT command_id FROM calls WHERE call_id = ?", (call_id,) + ) + return self._failures.get( + call_id, self._failures.get(row["command_id"] if row else "", "success") + ) + + def _sleep(self, milliseconds: int = 0) -> None: + if milliseconds > 0: + time.sleep(milliseconds / 1000) + + def _call_is_ended(self, call_id: str) -> bool: + row = self.store.one( + "SELECT call_state FROM calls WHERE call_id = ?", (call_id,) + ) + return row is None or row["call_state"] in {"ended", "failed"} + + def _run_call(self, call_id: str) -> None: + scenario = self._scenario_for(call_id) + try: + self._update_call_state(call_id, "dialing") + self._update_attempt(call_id, "dialing", started=True) + self._sleep(1 if scenario == "clock_jump" else 0) + if self._call_is_ended(call_id): + return + self._update_call_state(call_id, "ringing") + self.mock_sip.ringing(call_id) + self.mock_rtp.packet(call_id, "inbound", 1) + if scenario in {"busy", "no_answer"}: + self._finish_call( + call_id, + "busy" if scenario == "busy" else "no_answer", + scenario.upper(), + ) + return + self._sleep(1 if scenario == "ai_timeout" else 0) + if self._call_is_ended(call_id): + return + self._update_call_state(call_id, "answered", started=True) + self.mock_sip.answered(call_id) + self.mock_rtp.packet(call_id, "outbound", 1) + self._sleep(_integer(self._limits().get("hold_ms", 0))) + if scenario == "ari_disconnect": + self._finish_call(call_id, "unknown", "ARI_DISCONNECTED_RECONCILING") + return + turns = _integer(self._limits().get("turns", 2)) + for turn in range(turns): + if self._call_is_ended(call_id): + return + self._add_transcript( + call_id, + turn, + "customer", + f"customer turn {turn + 1}", + "not_applicable", + ) + playback = ( + "cancelled" + if scenario == "ai_timeout" and turn == turns - 1 + else "playback_confirmed" + ) + self._add_transcript( + call_id, turn, "agent", f"agent turn {turn + 1}", playback + ) + self._finish_call( + call_id, "completed", "CALL_COMPLETED", upload_scenario=scenario + ) + except Exception: + if not self._call_is_ended(call_id): + self._finish_call(call_id, "failed", "EXECUTOR_EXCEPTION") + + def _update_attempt(self, call_id: str, status: str, started: bool = False) -> None: + now = iso(self._now()) + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM attempts WHERE call_id = ? ORDER BY attempt_number DESC LIMIT 1", + (call_id,), + ).fetchone() + if row is None: + return + con.execute( + "UPDATE attempts SET status=?, started_at=COALESCE(started_at, ?), ended_at=? WHERE attempt_id=?", + ( + status, + now if started else row["started_at"], + now if status in {"ended", "failed"} else None, + row["attempt_id"], + ), + ) + + def _update_call_state( + self, + call_id: str, + state: str, + outcome: str | None = None, + reason: str | None = None, + started: bool = False, + ) -> None: + now = iso(self._now()) + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM calls WHERE call_id = ?", (call_id,) + ).fetchone() + if row is None or row["call_state"] in {"ended", "failed"}: + return + version = row["call_version"] + 1 + started_at = row["started_at"] or ( + now if started or state == "answered" else None + ) + con.execute( + "UPDATE calls SET call_state=?, call_version=?, outcome=COALESCE(?, outcome), reason_code=COALESCE(?, reason_code), started_at=? WHERE call_id=?", + (state, version, outcome, reason, started_at, call_id), + ) + self._event_tx( + con, + row["tenant_id"], + self.store.one( + "SELECT tenant_key FROM tenants WHERE tenant_id = ?", + (row["tenant_id"],), + )["tenant_key"], + row["command_id"], + "call.status", + "call", + call_id, + version, + { + "call_id": call_id, + "execution_id": row["execution_id"], + "call_state": state, + "outcome": outcome, + "reason_code": reason, + }, + now, + ) + + def _add_transcript( + self, call_id: str, turn: int, speaker: str, text: str, playback: str + ) -> None: + now = iso(self._now()) + segment_id = f"seg_{call_id}_{turn}_{speaker}" + with self.store.tx() as con: + call = con.execute( + "SELECT * FROM calls WHERE call_id = ?", (call_id,) + ).fetchone() + if call is None or call["call_state"] in {"ended", "failed"}: + return + previous = con.execute( + "SELECT * FROM transcripts WHERE segment_id = ?", (segment_id,) + ).fetchone() + revision = (previous["revision"] + 1) if previous else 1 + if previous and previous["revision"] >= revision: + return + con.execute( + "INSERT INTO transcripts(segment_id, call_id, turn_index, speaker, text, revision, is_final, playback_state, updated_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(segment_id) DO UPDATE SET text=excluded.text, revision=excluded.revision, is_final=excluded.is_final, playback_state=excluded.playback_state, updated_at=excluded.updated_at", + (segment_id, call_id, turn, speaker, text, revision, 1, playback, now), + ) + tenant = con.execute( + "SELECT tenant_key FROM tenants WHERE tenant_id = ?", + (call["tenant_id"],), + ).fetchone()["tenant_key"] + self._event_tx( + con, + call["tenant_id"], + tenant, + call["command_id"], + "transcript.updated", + "transcript_segment", + segment_id, + revision, + { + "call_id": call_id, + "segment_id": segment_id, + "turn_index": turn, + "speaker": speaker, + "text": text, + "revision": revision, + "is_final": True, + "playback_state": playback, + }, + now, + ) + self.mock_ai.turn(call_id, turn, speaker, playback) + + def _finish_call( + self, + call_id: str, + outcome: str, + reason: str, + upload_scenario: str | None = None, + ) -> None: + now_dt = self._now() + now = iso(now_dt) + with self.store.tx() as con: + row = con.execute( + "SELECT * FROM calls WHERE call_id = ?", (call_id,) + ).fetchone() + if row is None or row["call_state"] in {"ended", "failed"}: + return + version = row["call_version"] + 1 + start = parse_time(row["started_at"]) if row["started_at"] else now_dt + duration_ms = max(0, _integer((now_dt - start).total_seconds() * 1000)) + state = "failed" if outcome == "failed" else "ended" + con.execute( + "UPDATE calls SET call_state=?, call_version=?, outcome=?, reason_code=?, ended_at=?, duration_ms=? WHERE call_id=?", + (state, version, outcome, reason, now, duration_ms, call_id), + ) + con.execute( + "UPDATE attempts SET status=?, ended_at=? WHERE call_id=?", + ("failed" if outcome == "failed" else "ended", now, call_id), + ) + command = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (row["command_id"],) + ).fetchone() + command_status = ( + "failed" if outcome == "failed" or outcome == "unknown" else "completed" + ) + command_version = command["aggregate_version"] + 1 + con.execute( + "UPDATE commands SET status=?, reason_code=?, aggregate_version=?, updated_at=? WHERE command_id=?", + (command_status, reason, command_version, now, row["command_id"]), + ) + con.execute( + "UPDATE executions SET status=?, call_id=? WHERE execution_id=?", + (command_status, call_id, row["execution_id"]), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id = ?", (row["command_id"],) + ).fetchone() + self._command_event_tx(con, updated, reason) + tenant = con.execute( + "SELECT tenant_key FROM tenants WHERE tenant_id = ?", + (row["tenant_id"],), + ).fetchone()["tenant_key"] + self._event_tx( + con, + row["tenant_id"], + tenant, + row["command_id"], + "call.finished", + "call", + call_id, + version, + { + "call_id": call_id, + "execution_id": row["execution_id"], + "outcome": outcome, + "reason_code": reason, + "duration_ms": duration_ms, + }, + now, + ) + self.mock_sip.bye(call_id, reason) + self.mock_ari.media_closed(call_id) + self.mock_ari.cleanup(call_id) + if outcome == "completed": + self._record_and_upload(call_id, upload_scenario or "success") + + def _record_and_upload(self, call_id: str, scenario: str) -> None: + data = self._wav_bytes() + checksum = hashlib.sha256(data).hexdigest() + call = self.store.one("SELECT * FROM calls WHERE call_id = ?", (call_id,)) + if call is None: + return + recording_id = new_id("rec") + upload_id = new_id("upload") + now = iso(self._now()) + duration_ms = max(1, _integer(self._limits().get("turns", 2)) * 100) + path = str(self.object_store.path_for(upload_id)) + with self.store.tx() as con: + con.execute( + "INSERT INTO recordings(recording_id, call_id, tenant_id, content_type, size_bytes, checksum_algorithm, checksum, channels, sample_rate_hz, duration_ms, status, upload_id, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + recording_id, + call_id, + call["tenant_id"], + "audio/wav", + len(data), + "SHA-256", + checksum, + 1, + 16000, + duration_ms, + "uploading", + upload_id, + now, + ), + ) + con.execute( + "INSERT INTO uploads(upload_id, recording_id, tenant_id, expected_size, expected_checksum, expires_at, status, object_path, created_at) VALUES(?,?,?,?,?,?,?,?,?)", + ( + upload_id, + recording_id, + call["tenant_id"], + len(data), + checksum, + iso( + self._now() + + timedelta(seconds=self._limits().get("upload_ttl_s", 300)) + ), + "created", + path, + now, + ), + ) + if scenario == "upload_missing": + self._recording_failed(recording_id, "OBJECT_MISSING") + return + token = self.object_store.token(upload_id, call["tenant_id"]) + upload_data = data + if scenario == "upload_bad_checksum": + upload_data = data[:-1] + bytes([data[-1] ^ 1]) + try: + self.object_store.put(upload_id, call["tenant_id"], upload_data, token) + self._complete_upload( + call["tenant_id"], + upload_id, + recording_id, + checksum=checksum, + size_bytes=len(data), + ) + except ServiceError as exc: + self._recording_failed(recording_id, exc.code) + + def _wav_bytes(self) -> bytes: + output = io.BytesIO() + with wave.open(output, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(16000) + wav.writeframes( + b"\0\0" * max(1600, _integer(self._limits().get("turns", 2)) * 1600) + ) + return output.getvalue() + + def _recording_failed(self, recording_id: str, code: str) -> None: + with self.store.tx() as con: + recording = con.execute( + "SELECT * FROM recordings WHERE recording_id = ?", (recording_id,) + ).fetchone() + if recording is None or recording["status"] in {"verified", "failed"}: + return + con.execute( + "UPDATE recordings SET status='failed', failure_code=? WHERE recording_id=?", + (code, recording_id), + ) + tenant = con.execute( + "SELECT tenant_key FROM tenants WHERE tenant_id = ?", + (recording["tenant_id"],), + ).fetchone()["tenant_key"] + call = con.execute( + "SELECT command_id FROM calls WHERE call_id = ?", + (recording["call_id"],), + ).fetchone() + self._event_tx( + con, + recording["tenant_id"], + tenant, + call["command_id"], + "recording.failed", + "recording", + recording_id, + 1, + { + "recording_id": recording_id, + "call_id": recording["call_id"], + "failure_code": code, + }, + iso(self._now()), + ) + + def create_upload( + self, tenant_id: str, request: dict[str, Any] + ) -> tuple[int, dict[str, Any]]: + self._tenant(tenant_id) + required = { + "recording_id", + "call_id", + "content_type", + "size_bytes", + "checksum_algorithm", + "checksum", + "channels", + "sample_rate_hz", + "duration_ms", + } + if set(request) != required: + raise ValidationError( + "SCHEMA_INVALID", "upload request fields do not match the contract" + ) + recording = self.store.one( + "SELECT * FROM recordings WHERE recording_id = ? AND tenant_id = ?", + (request["recording_id"], tenant_id), + ) + if recording is None or recording["call_id"] != request["call_id"]: + raise NotFoundError() + if ( + recording["size_bytes"] != request["size_bytes"] + or recording["checksum"] != request["checksum"] + ): + raise ConflictError( + "RECORDING_METADATA_CONFLICT", "recording metadata is immutable" + ) + upload = self.store.one( + "SELECT * FROM uploads WHERE recording_id = ?", (request["recording_id"],) + ) + if upload: + status = 200 + upload_id = upload["upload_id"] + expires = upload["expires_at"] + else: + upload_id = new_id("upload") + expires = iso( + self._now() + timedelta(seconds=self._limits().get("upload_ttl_s", 300)) + ) + path = str(self.object_store.path_for(upload_id)) + with self.store.tx() as con: + con.execute( + "INSERT INTO uploads(upload_id, recording_id, tenant_id, expected_size, expected_checksum, expires_at, status, object_path, created_at) VALUES(?,?,?,?,?,?,?,?,?)", + ( + upload_id, + request["recording_id"], + tenant_id, + request["size_bytes"], + request["checksum"], + expires, + "created", + path, + iso(self._now()), + ), + ) + con.execute( + "UPDATE recordings SET upload_id=?, status='uploading' WHERE recording_id=?", + (upload_id, request["recording_id"]), + ) + status = 201 + return status, { + "upload_id": upload_id, + "recording_id": request["recording_id"], + "expires_at": expires, + "upload_method": "PUT", + "upload_url": f"/_mock/oss/{upload_id}", + "required_headers": { + "X-Mock-Upload-Token": self.object_store.token(upload_id, tenant_id) + }, + "constraints": { + "size_bytes": request["size_bytes"], + "checksum_algorithm": "SHA-256", + "checksum": request["checksum"], + }, + } + + def put_upload(self, upload_id: str, token: str, data: bytes) -> None: + upload = self.store.one( + "SELECT * FROM uploads WHERE upload_id = ?", (upload_id,) + ) + if upload is None: + raise NotFoundError() + if parse_time(upload["expires_at"]) <= self._now(): + raise ServiceError("UPLOAD_EXPIRED", "upload session has expired", 410) + self.object_store.put(upload_id, upload["tenant_id"], data, token) + with self.store.tx() as con: + con.execute( + "UPDATE uploads SET status='uploaded' WHERE upload_id=?", (upload_id,) + ) + + def _complete_upload( + self, + tenant_id: str, + upload_id: str, + recording_id: str, + checksum: str, + size_bytes: int, + ) -> dict[str, Any]: + upload = self.store.one( + "SELECT * FROM uploads WHERE upload_id = ? AND tenant_id = ?", + (upload_id, tenant_id), + ) + if upload is None or upload["recording_id"] != recording_id: + raise NotFoundError() + if parse_time(upload["expires_at"]) <= self._now(): + raise ServiceError("UPLOAD_EXPIRED", "upload session has expired", 410) + if ( + size_bytes != upload["expected_size"] + or checksum != upload["expected_checksum"] + ): + raise ValidationError( + "RECORDING_METADATA_CONFLICT", + "complete request does not match the reserved metadata", + ) + try: + oss_id = self.object_store.verify_and_promote( + upload_id, upload["expected_size"], upload["expected_checksum"] + ) + except ValidationError: + self._recording_failed(recording_id, "OBJECT_VERIFICATION_FAILED") + raise + now = iso(self._now()) + with self.store.tx() as con: + con.execute( + "UPDATE uploads SET status='verified', completed_at=? WHERE upload_id=?", + (now, upload_id), + ) + con.execute( + "UPDATE recordings SET status='verified', oss_id=?, verified_at=? WHERE recording_id=?", + (oss_id, now, recording_id), + ) + recording = con.execute( + "SELECT * FROM recordings WHERE recording_id = ?", (recording_id,) + ).fetchone() + call = con.execute( + "SELECT command_id FROM calls WHERE call_id = ?", + (recording["call_id"],), + ).fetchone() + tenant = con.execute( + "SELECT tenant_key FROM tenants WHERE tenant_id = ?", (tenant_id,) + ).fetchone()["tenant_key"] + self._event_tx( + con, + tenant_id, + tenant, + call["command_id"], + "recording.ready", + "recording", + recording_id, + 1, + { + "recording_id": recording_id, + "call_id": recording["call_id"], + "oss_id": oss_id, + "size_bytes": recording["size_bytes"], + "checksum": recording["checksum"], + }, + now, + ) + return { + "upload_id": upload_id, + "recording_id": recording_id, + "status": "verified", + "oss_id": oss_id, + "verified_at": now, + } + + def complete_upload( + self, tenant_id: str, upload_id: str, request: dict[str, Any] + ) -> dict[str, Any]: + required = {"recording_id", "size_bytes", "checksum_algorithm", "checksum"} + allowed = required | {"etag"} + if ( + not required.issubset(request) + or set(request) - allowed + or request.get("checksum_algorithm") != "SHA-256" + ): + raise ValidationError( + "SCHEMA_INVALID", "complete request fields do not match the contract" + ) + return self._complete_upload( + tenant_id, + upload_id, + request["recording_id"], + request["checksum"], + request["size_bytes"], + ) + + def dispatch_outbox(self, limit: int = 100) -> int: + now = iso(self._now()) + rows = self.store.all( + "SELECT * FROM events WHERE published=0 AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY occurred_at, event_id LIMIT ?", + (now, limit), + ) + sent = 0 + for row in rows: + body = _loads(row["body_json"]) + try: + self.broker.publish( + "agent-call.events.v1", + f"agent-call.{row['event_type']}", + body, + row["event_id"], + ) + except BrokerError: + with self.store.tx() as con: + attempts = row["broker_attempts"] + 1 + retry_at = iso( + self._now() + timedelta(seconds=min(60, 2 ** min(attempts, 5))) + ) + con.execute( + "UPDATE events SET broker_attempts=?, broker_status='failed', next_attempt_at=? WHERE event_id=?", + (attempts, retry_at, row["event_id"]), + ) + continue + with self.store.tx() as con: + con.execute( + "UPDATE events SET published=1, broker_status='broker_confirmed', published_at=?, broker_attempts=broker_attempts+1 WHERE event_id=?", + (iso(self._now()), row["event_id"]), + ) + sent += 1 + return sent + + def consume_saas_events(self, limit: int = 100) -> int: + applied = 0 + for _ in range(limit): + try: + message = self.broker.consume("agent-call.saas.events.v1") + except BrokerError: + break + if not message: + break + body_raw: Any = message.get("body", {}) + body = body_raw if isinstance(body_raw, dict) else {} + event_id = body.get("event_id") + if not event_id: + self.broker.ack(message) + continue + try: + self._validate_event(body) + with self.store.tx() as con: + exists = con.execute( + "SELECT 1 FROM inbox WHERE event_id = ?", (event_id,) + ).fetchone() + if not exists: + con.execute( + "INSERT INTO inbox(event_id, tenant_id, applied_at, body_json) VALUES(?,?,?,?)", + ( + event_id, + body["tenant_id"], + iso(self._now()), + canonical(body), + ), + ) + applied += 1 + con.execute( + "UPDATE events SET saas_applied=1 WHERE event_id=?", (event_id,) + ) + self.broker.ack(message) + except (KeyError, ServiceError, sqlite3.IntegrityError): + with self.store.tx() as con: + con.execute( + "INSERT INTO dead_letters(tenant_key, routing_key, body_json, reason_code, created_at) VALUES(?,?,?,?,?)", + ( + body.get("tenant_key"), + message.get("routing_key"), + canonical(body), + "INBOX_INVALID", + iso(self._now()), + ), + ) + self.broker.ack(message) + return applied + + def apply_controls(self) -> int: + rows = self.store.all( + "SELECT * FROM commands WHERE command_type='task.control' AND status='applying' ORDER BY accepted_at" + ) + count = 0 + for row in rows: + body = _loads(row["payload_json"]) + action = body["action"] + policy = body.get("active_call_policy", "drain") + if action == "stop" and policy == "hangup": + calls = self.store.all( + "SELECT call_id FROM calls WHERE task_id=? AND call_state NOT IN ('ended','failed')", + (row["task_id"],), + ) + for call in calls: + self._finish_controlled_call(call["call_id"]) + with self.store.tx() as con: + task = con.execute( + "SELECT * FROM tasks WHERE task_id=?", (row["task_id"],) + ).fetchone() + if task is None: + continue + now = iso(self._now()) + con.execute( + "UPDATE commands SET status='applied', applied_task_revision=?, task_state=?, aggregate_version=aggregate_version+1, updated_at=? WHERE command_id=?", + (task["revision"], task["state"], now, row["command_id"]), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id=?", (row["command_id"],) + ).fetchone() + self._command_event_tx(con, updated, "CONTROL_APPLIED") + affected = con.execute( + "SELECT * FROM commands WHERE task_id=? AND status IN ('accepted','waiting')", + (row["task_id"],), + ).fetchall() + for affected_row in affected: + con.execute( + "UPDATE commands SET status='rejected', reason_code='TASK_CONTROL_BARRIER', aggregate_version=aggregate_version+1, updated_at=? WHERE command_id=?", + (now, affected_row["command_id"]), + ) + con.execute( + "UPDATE executions SET status='rejected' WHERE command_id=?", + (affected_row["command_id"],), + ) + barrier_row = con.execute( + "SELECT * FROM commands WHERE command_id=?", + (affected_row["command_id"],), + ).fetchone() + self._command_event_tx(con, barrier_row, "TASK_CONTROL_BARRIER") + count += 1 + return count + + def _finish_controlled_call(self, call_id: str) -> None: + self._finish_call(call_id, "failed", "CONTROL_STOPPED") + + def control_task( + self, + tenant_id: str, + task_id: str, + body: dict[str, Any], + idempotency_key: str, + allow_hangup: bool = False, + ) -> dict[str, Any]: + self._tenant(tenant_id) + required = {"command_id", "action", "expected_task_revision", "reason"} + allowed = required | {"active_call_policy"} + if not required.issubset(body) or set(body) - allowed: + raise ValidationError( + "SCHEMA_INVALID", "control request fields do not match the contract" + ) + if body["action"] not in {"pause", "resume", "stop"}: + raise ValidationError("SCHEMA_INVALID", "control action is invalid") + if ( + not isinstance(body["expected_task_revision"], int) + or body["expected_task_revision"] < 1 + ): + raise ValidationError( + "SCHEMA_INVALID", "expected_task_revision must be a positive integer" + ) + if not isinstance(body["reason"], str) or not body["reason"]: + raise ValidationError("SCHEMA_INVALID", "reason is required") + if body["action"] != "stop" and "active_call_policy" in body: + raise ValidationError( + "SCHEMA_INVALID", "active_call_policy is only valid for stop" + ) + if body.get("active_call_policy", "drain") not in {"drain", "hangup"}: + raise ValidationError("SCHEMA_INVALID", "active_call_policy is invalid") + if body["command_id"] != idempotency_key: + raise ConflictError( + "IDEMPOTENCY_KEY_MISMATCH", "Idempotency-Key must equal command_id" + ) + if ( + body["action"] == "stop" + and body.get("active_call_policy") == "hangup" + and not allow_hangup + ): + raise ServiceError( + "SCOPE_REQUIRED", "hangup control requires outbound.hangup scope", 403 + ) + task = self.store.one( + "SELECT * FROM tasks WHERE task_id=? AND tenant_id=?", (task_id, tenant_id) + ) + if task is None: + raise NotFoundError() + payload_hash = digest(body) + existing = self.store.one( + "SELECT * FROM commands WHERE command_id=? AND tenant_id=?", + (body["command_id"], tenant_id), + ) + foreign = self.store.one( + "SELECT 1 FROM commands WHERE command_id=?", (body["command_id"],) + ) + if foreign and existing is None: + raise ConflictError("IDEMPOTENCY_CONFLICT", "command id is unavailable") + if existing: + if existing["payload_hash"] != payload_hash: + raise ConflictError( + "IDEMPOTENCY_CONFLICT", + "control command was already submitted with a different body", + ) + return self._control_response(existing) + if body["expected_task_revision"] != task["revision"]: + raise ConflictError( + "TASK_REVISION_CONFLICT", + "expected_task_revision does not match current task revision", + ) + if body["action"] == "resume" and task["state"] != "paused": + raise ConflictError( + "TASK_NOT_RESUMABLE", "only a paused task can be resumed" + ) + if body["action"] in {"pause", "stop"} and task["state"] == "stopped": + raise ConflictError("TASK_STOPPED", "stopped task cannot be changed") + new_state = {"pause": "paused", "resume": "running", "stop": "stopped"}[ + body["action"] + ] + now = iso(self._now()) + with self.store.tx() as con: + con.execute( + "UPDATE tasks SET state=?, revision=revision+1, updated_at=? WHERE task_id=? AND revision=?", + (new_state, now, task_id, body["expected_task_revision"]), + ) + if con.execute("SELECT changes()").fetchone()[0] != 1: + raise ConflictError( + "TASK_REVISION_CONFLICT", "task changed concurrently" + ) + con.execute( + "INSERT INTO commands(command_id, tenant_id, tenant_key, command_type, status, payload_json, payload_hash, task_id, requested_task_revision, task_state, accepted_at, trace_id, aggregate_version, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + body["command_id"], + tenant_id, + self.store.one( + "SELECT tenant_key FROM tenants WHERE tenant_id=?", (tenant_id,) + )["tenant_key"], + "task.control", + "applying", + canonical(body), + payload_hash, + task_id, + body["expected_task_revision"], + new_state, + now, + body["command_id"], + 1, + now, + ), + ) + row = con.execute( + "SELECT * FROM commands WHERE command_id=?", (body["command_id"],) + ).fetchone() + self._command_event_tx(con, row, "CONTROL_ACCEPTED") + return self._control_response( + self.store.one( + "SELECT * FROM commands WHERE command_id=?", (body["command_id"],) + ) + ) + + def _control_response(self, row: sqlite3.Row) -> dict[str, Any]: + return { + "command_id": row["command_id"], + "tenant_id": row["tenant_id"], + "tenant_key": row["tenant_key"], + "task_id": row["task_id"], + "status": "accepted", + "requested_task_revision": row["requested_task_revision"], + "accepted_at": row["accepted_at"], + } + + def create_replay( + self, + tenant_id: str, + source_type: str, + source_id: str, + body: dict[str, Any], + idempotency_key: str, + ) -> dict[str, Any]: + self._tenant(tenant_id) + if ( + set(body) != {"command_id", "reason"} + or body["command_id"] != idempotency_key + ): + raise ValidationError( + "SCHEMA_INVALID", + "replay request requires command_id and reason matching Idempotency-Key", + ) + existing = self.store.one( + "SELECT * FROM commands WHERE command_id=? AND tenant_id=?", + (body["command_id"], tenant_id), + ) + foreign = self.store.one( + "SELECT 1 FROM commands WHERE command_id=?", (body["command_id"],) + ) + if foreign and existing is None: + raise ConflictError("IDEMPOTENCY_CONFLICT", "command id is unavailable") + if existing: + if existing["payload_hash"] != digest(body): + raise ConflictError( + "IDEMPOTENCY_CONFLICT", + "replay command was already submitted with a different body", + ) + return { + "command_id": body["command_id"], + "status": "accepted", + "snapshot_cutoff": existing["snapshot_cutoff"], + } + if source_type == "call": + source = self.store.one( + "SELECT * FROM calls WHERE call_id=? AND tenant_id=?", + (source_id, tenant_id), + ) + else: + source = self.store.one( + "SELECT * FROM commands WHERE command_id=? AND tenant_id=?", + (source_id, tenant_id), + ) + if source is None: + raise NotFoundError() + cutoff = iso(self._now()) + retention = self._now() - timedelta( + seconds=self._limits().get("replay_retention_s", 604800) + ) + rows = self.store.all( + "SELECT occurred_at FROM events WHERE tenant_id=? ORDER BY occurred_at LIMIT 1", + (tenant_id,), + ) + if rows and parse_time(rows[0]["occurred_at"]) < retention: + raise ServiceError( + "REPLAY_EXPIRED", "source is outside the replay retention window", 410 + ) + replay_id = new_id("replay") + tenant = self.store.one( + "SELECT tenant_key FROM tenants WHERE tenant_id=?", (tenant_id,) + )["tenant_key"] + now = iso(self._now()) + with self.store.tx() as con: + con.execute( + "INSERT INTO commands(command_id, tenant_id, tenant_key, command_type, status, payload_json, payload_hash, snapshot_cutoff, source_type, source_id, accepted_at, trace_id, aggregate_version, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + body["command_id"], + tenant_id, + tenant, + "replay", + "accepted", + canonical(body), + digest(body), + cutoff, + source_type, + source_id, + now, + body["command_id"], + 1, + now, + ), + ) + con.execute( + "INSERT INTO replays(replay_id, command_id, tenant_id, source_type, source_id, snapshot_cutoff, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)", + ( + replay_id, + body["command_id"], + tenant_id, + source_type, + source_id, + cutoff, + "accepted", + now, + now, + ), + ) + row = con.execute( + "SELECT * FROM commands WHERE command_id=?", (body["command_id"],) + ).fetchone() + self._command_event_tx(con, row, "REPLAY_ACCEPTED") + return { + "command_id": body["command_id"], + "status": "accepted", + "snapshot_cutoff": cutoff, + } + + def process_replays(self) -> int: + rows = self.store.all( + "SELECT * FROM replays WHERE status='accepted' ORDER BY created_at LIMIT 10" + ) + count = 0 + for replay in rows: + if replay["source_type"] == "call": + candidates = self.store.all( + "SELECT * FROM events WHERE tenant_id=? AND occurred_at <= ? ORDER BY occurred_at, event_id", + (replay["tenant_id"], replay["snapshot_cutoff"]), + ) + else: + candidates = self.store.all( + "SELECT * FROM events WHERE tenant_id=? AND aggregate_id=? AND occurred_at <= ? ORDER BY occurred_at, event_id", + ( + replay["tenant_id"], + replay["source_id"], + replay["snapshot_cutoff"], + ), + ) + sent = 0 + try: + for event in candidates: + body = _loads(event["body_json"]) + if ( + replay["source_type"] == "call" + and body.get("payload", {}).get("call_id") + != replay["source_id"] + and body.get("aggregate_id") != replay["source_id"] + ): + continue + self.broker.publish( + "agent-call.events.v1", + f"agent-call.{event['event_type']}", + body, + event["event_id"], + {"x-replay-id": replay["replay_id"]}, + ) + with self.store.tx() as con: + con.execute( + "INSERT OR IGNORE INTO replay_items(replay_id, event_id, published_at) VALUES(?,?,?)", + (replay["replay_id"], event["event_id"], iso(self._now())), + ) + sent += 1 + except BrokerError: + with self.store.tx() as con: + con.execute( + "UPDATE replays SET status='failed', failure_code='BROKER_UNAVAILABLE', updated_at=? WHERE replay_id=?", + (iso(self._now()), replay["replay_id"]), + ) + continue + with self.store.tx() as con: + con.execute( + "UPDATE replays SET status='completed', sent_count=?, updated_at=? WHERE replay_id=?", + (sent, iso(self._now()), replay["replay_id"]), + ) + con.execute( + "UPDATE commands SET status='completed', reason_code='REPLAY_SENT', aggregate_version=aggregate_version+1, updated_at=? WHERE command_id=?", + (iso(self._now()), replay["command_id"]), + ) + updated = con.execute( + "SELECT * FROM commands WHERE command_id=?", (replay["command_id"],) + ).fetchone() + self._command_event_tx(con, updated, "REPLAY_SENT") + count += sent + return count + + def emit_opt_out(self, tenant_id: str, call_id: str, contact_ref: str) -> None: + call = self.store.one( + "SELECT * FROM calls WHERE call_id=? AND tenant_id=?", (call_id, tenant_id) + ) + if call is None: + raise NotFoundError() + tenant = self.store.one( + "SELECT tenant_key FROM tenants WHERE tenant_id=?", (tenant_id,) + )["tenant_key"] + with self.store.tx() as con: + self._event_tx( + con, + tenant_id, + tenant, + call["command_id"], + "contact.opt_out", + "call", + call_id, + call["call_version"] + 1, + {"call_id": call_id, "contact_ref": contact_ref, "source": "mock"}, + ) + + def get_command(self, tenant_id: str, command_id: str) -> dict[str, Any]: + row = self.store.one( + "SELECT * FROM commands WHERE command_id=? AND tenant_id=?", + (command_id, tenant_id), + ) + if row is None: + raise NotFoundError() + return self._command_snapshot(row) + + def get_call(self, tenant_id: str, call_id: str) -> dict[str, Any]: + call = self.store.one( + "SELECT * FROM calls WHERE call_id=? AND tenant_id=?", (call_id, tenant_id) + ) + if call is None: + raise NotFoundError() + attempts = [ + dict(row) + for row in self.store.all( + "SELECT attempt_id, attempt_number, route_policy_id, caller_profile_id, target_uri, status, started_at, ended_at FROM attempts WHERE call_id=? ORDER BY attempt_number", + (call_id,), + ) + ] + segments = [ + dict(row) + for row in self.store.all( + "SELECT segment_id, turn_index, speaker, text, revision, is_final, playback_state, updated_at FROM transcripts WHERE call_id=? ORDER BY turn_index, speaker", + (call_id,), + ) + ] + recordings = [ + dict(row) + for row in self.store.all( + "SELECT recording_id, content_type, size_bytes, checksum_algorithm, checksum, channels, sample_rate_hz, duration_ms, status, oss_id, failure_code, verified_at FROM recordings WHERE call_id=? ORDER BY created_at", + (call_id,), + ) + ] + event_rows = self.store.all( + "SELECT published, broker_status, saas_applied FROM events WHERE tenant_id=? AND (aggregate_id=? OR payload_json LIKE ?)", + (tenant_id, call_id, f'%"call_id":"{call_id}"%'), + ) + confirmed = sum(1 for row in event_rows if row["published"]) + pending = sum( + 1 + for row in event_rows + if not row["published"] or row["broker_status"] == "failed" + ) + applied_values = [row["saas_applied"] for row in event_rows if row["published"]] + saas_applied: bool | None = ( + None + if not applied_values or any(value is None for value in applied_values) + else all(bool(value) for value in applied_values) + ) + return { + "call_id": call["call_id"], + "execution_id": call["execution_id"], + "task_id": call["task_id"], + "task_item_id": call["task_item_id"], + "call_state": call["call_state"], + "call_version": call["call_version"], + "outcome": call["outcome"], + "reason_code": call["reason_code"], + "started_at": call["started_at"], + "ended_at": call["ended_at"], + "duration_ms": call["duration_ms"], + "attempts": attempts, + "transcript": {"segments": segments}, + "recordings": recordings, + "delivery": { + "events_total": len(event_rows), + "broker_confirmed": confirmed, + "broker_pending": pending, + "saas_applied": saas_applied, + }, + "snapshot_at": iso(self._now()), + } + + def metrics(self) -> str: + active = self._active_counts()[0] + pending = self.store.one("SELECT COUNT(*) AS n FROM events WHERE published=0")[ + "n" + ] + dlq = self.store.one("SELECT COUNT(*) AS n FROM dead_letters")["n"] + waiting = self.store.one( + "SELECT COUNT(*) AS n FROM commands WHERE status='waiting'" + )["n"] + mode = self.mode.replace('"', "") + providers = self.profile.get("provider_modes", {}) + lines = [ + "# HELP agent_call_active_calls Active calls including queued and ringing calls.", + "# TYPE agent_call_active_calls gauge", + f"agent_call_active_calls {active}", + "# TYPE agent_call_outbox_pending gauge", + f"agent_call_outbox_pending {pending}", + "# TYPE agent_call_waiting_commands gauge", + f"agent_call_waiting_commands {waiting}", + "# TYPE agent_call_dead_letters_total counter", + f"agent_call_dead_letters_total {dlq}", + "# TYPE agent_call_mode_info gauge", + f'agent_call_mode_info{{mode="{mode}"}} 1', + ] + for component, provider_mode in sorted(providers.items()): + safe_component = component.replace('"', "") + safe_mode = str(provider_mode).replace('"', "") + lines.append( + f'agent_call_provider_mode_info{{component="{safe_component}",mode="{safe_mode}"}} 1' + ) + return "\n".join(lines) + "\n" + + def ready(self) -> bool: + try: + self.store.one("SELECT 1") + return bool(self.broker.ready()) + except Exception: + return False + + def wait_for_idle(self, timeout: float = 5.0) -> None: + end = time.monotonic() + timeout + while time.monotonic() < end: + self.process_once() + live = [ + thread for thread in self._call_threads.values() if thread.is_alive() + ] + if not live and not self.store.one( + "SELECT 1 FROM commands WHERE status IN ('accepted','waiting','executing','applying') LIMIT 1" + ): + self.dispatch_outbox() + self.consume_saas_events() + return + time.sleep(0.01) + raise TimeoutError("mock service did not become idle") diff --git a/agent_call/http.py b/agent_call/http.py new file mode 100644 index 0000000..bec074f --- /dev/null +++ b/agent_call/http.py @@ -0,0 +1,328 @@ +"""HTTP control/query adapter for the contract-defined non-execution endpoints.""" + +from __future__ import annotations + +import json +import os +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import unquote, urlsplit + +from .core import AgentCallService, ServiceError, ValidationError, _integer, _loads + +READ_SCOPE = "outbound.read" +CONTROL_SCOPE = "outbound.control" +UPLOAD_SCOPE = "recording.upload" +COMPLETE_SCOPE = "recording.complete" + + +class AgentCallHandler(BaseHTTPRequestHandler): + service: AgentCallService + server_version = "agent-call/0.1" + + def log_message(self, format: str, *args: Any) -> None: + # Request IDs are enough for local diagnostics; never log auth headers or bodies. + return + + def _request_id(self) -> str: + value = self.headers.get("X-Request-ID", "") + if not value or len(value) > 128 or any(ch.isspace() for ch in value): + raise ServiceError("REQUEST_ID_REQUIRED", "X-Request-ID is required", 400) + return value + + def _tenant_id(self) -> str: + value = self.headers.get("X-Tenant-ID", "") + if not value or len(value) > 128 or any(ch.isspace() for ch in value): + raise ServiceError("TENANT_ID_REQUIRED", "X-Tenant-ID is required", 400) + return value + + def _authorize(self, scope: str, tenant_id: str | None = None) -> None: + token_header = self.headers.get("Authorization", "") + if not token_header.startswith("Bearer "): + raise ServiceError("UNAUTHORIZED", "bearer authentication is required", 401) + token = token_header[7:] + token_map = _loads(os.environ.get("HTTP_TOKENS"), {}) + if not isinstance(token_map, dict): + raise ServiceError("UNAUTHORIZED", "token configuration is invalid", 401) + identity = token_map.get(token) + if not isinstance(identity, dict): + raise ServiceError("UNAUTHORIZED", "bearer token is not authorized", 401) + scopes = identity.get("scopes", []) + if scope not in scopes and "*" not in scopes: + raise ServiceError("FORBIDDEN", "required scope is missing", 403) + if tenant_id is not None: + tenant_ids = identity.get("tenant_ids", []) + if "*" not in tenant_ids and tenant_id not in tenant_ids: + raise ServiceError("FORBIDDEN", "tenant scope is missing", 403) + + def _body(self) -> dict[str, Any]: + raw_length = self.headers.get("Content-Length") + if not raw_length: + raise ValidationError("BODY_REQUIRED", "JSON request body is required") + length = _integer(raw_length) + if length < 1 or length > self.service.profile.get("limits", {}).get( + "max_http_bytes", 65536 + ): + raise ServiceError( + "BODY_TOO_LARGE", "request body exceeds the HTTP limit", 413 + ) + try: + raw = self.rfile.read(length) + body = _loads(raw.decode("utf-8")) + except UnicodeDecodeError as exc: + raise ValidationError( + "INVALID_JSON", "request body must be UTF-8 JSON" + ) from exc + if not isinstance(body, dict): + raise ValidationError("INVALID_JSON", "request body must be a JSON object") + return body + + def _header(self, name: str) -> str: + value = self.headers.get(name, "") + if not value: + raise ServiceError("HEADER_REQUIRED", f"{name} is required", 400) + return value + + def _send( + self, status: int, body: Any, content_type: str = "application/json" + ) -> None: + encoded = json.dumps( + body, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", f"{content_type}; charset=utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(encoded) + + def _send_text(self, status: int, body: str, content_type: str) -> None: + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(encoded) + + def _error(self, exc: ServiceError, request_id: str | None = None) -> None: + body = { + "type": "about:blank", + "title": HTTPStatus(exc.status).phrase + if exc.status in HTTPStatus._value2member_map_ + else "Request failed", + "status": exc.status, + "code": exc.code, + "detail": exc.detail, + "request_id": request_id or self.headers.get("X-Request-ID", "unknown"), + "retryable": exc.retryable, + } + try: + self._send(exc.status, body, "application/problem+json") + except (BrokenPipeError, ConnectionResetError): + return + + def _dispatch(self, method: str) -> None: + request_id: str | None = None + try: + path = urlsplit(self.path).path + parts = [unquote(part) for part in path.split("/") if part] + if method == "GET" and path == "/healthz/live": + self._send(200, {"status": "live", "mode": self.service.mode}) + return + if method == "GET" and path == "/healthz/ready": + if not self.service.ready(): + raise ServiceError( + "NOT_READY", "dependency health check failed", 503, True + ) + self._send(200, {"status": "ready", "mode": self.service.mode}) + return + if method == "GET" and path == "/metrics": + self._send_text( + 200, self.service.metrics(), "text/plain; version=0.0.4" + ) + return + if not ( + method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"] + ): + request_id = self._request_id() + if ( + method == "GET" + and len(parts) == 5 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "commands" + ): + tenant_id = self._tenant_id() + self._authorize(READ_SCOPE, tenant_id) + self._send(200, self.service.get_command(tenant_id, parts[4])) + return + if ( + method == "GET" + and len(parts) == 5 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "calls" + ): + tenant_id = self._tenant_id() + self._authorize(READ_SCOPE, tenant_id) + self._send(200, self.service.get_call(tenant_id, parts[4])) + return + if ( + method == "POST" + and len(parts) == 6 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "tasks" + and parts[5] == "controls" + ): + tenant_id = self._tenant_id() + self._authorize(CONTROL_SCOPE, tenant_id) + body = self._body() + command_id = self._header("Idempotency-Key") + result = self.service.control_task( + tenant_id, + parts[4], + body, + command_id, + allow_hangup=self._has_scope("outbound.hangup", tenant_id), + ) + self._send(202, result) + return + if ( + method == "POST" + and len(parts) == 6 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "calls" + and parts[5] == "replays" + ): + tenant_id = self._tenant_id() + self._authorize(CONTROL_SCOPE, tenant_id) + result = self.service.create_replay( + tenant_id, + "call", + parts[4], + self._body(), + self._header("Idempotency-Key"), + ) + self._send(202, result) + return + if ( + method == "POST" + and len(parts) == 6 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "commands" + and parts[5] == "replays" + ): + tenant_id = self._tenant_id() + self._authorize(CONTROL_SCOPE, tenant_id) + result = self.service.create_replay( + tenant_id, + "command", + parts[4], + self._body(), + self._header("Idempotency-Key"), + ) + self._send(202, result) + return + if ( + method == "POST" + and len(parts) == 4 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "recording-uploads" + ): + tenant_id = self._tenant_id() + self._authorize(UPLOAD_SCOPE, tenant_id) + result_status, result = self.service.create_upload( + tenant_id, self._body() + ) + self._send(result_status, result) + return + if ( + method == "POST" + and len(parts) == 6 + and parts[:3] == ["internal", "v1", "outbound"] + and parts[3] == "recording-uploads" + and parts[5] == "complete" + ): + tenant_id = self._tenant_id() + self._authorize(COMPLETE_SCOPE, tenant_id) + result = self.service.complete_upload(tenant_id, parts[4], self._body()) + self._send(200, result) + return + if method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]: + if self.service.mode == "real": + raise ServiceError( + "MOCK_DISABLED", + "mock object storage is disabled in real mode", + 404, + ) + token = self._header("X-Mock-Upload-Token") + raw_length = self.headers.get("Content-Length") + if not raw_length: + raise ValidationError("BODY_REQUIRED", "upload body is required") + length = _integer(raw_length) + if length > self.service.profile.get("limits", {}).get( + "recording_max_bytes", 16777216 + ): + raise ServiceError( + "BODY_TOO_LARGE", + "recording exceeds the configured size limit", + 413, + ) + try: + data = self.rfile.read(length) + except (BrokenPipeError, ConnectionResetError) as exc: + raise ServiceError( + "UPLOAD_INTERRUPTED", "upload connection interrupted", 400 + ) from exc + upload = self.service.store.one( + "SELECT tenant_id FROM uploads WHERE upload_id=?", (parts[2],) + ) + if upload is None: + raise ServiceError("NOT_FOUND", "upload session not found", 404) + self.service.put_upload(parts[2], token, data) + self._send(200, {"status": "uploaded", "upload_id": parts[2]}) + return + raise ServiceError("NOT_FOUND", "route not found", 404) + except ServiceError as exc: + self._error(exc, request_id) + except (BrokenPipeError, ConnectionResetError): + return + except Exception: + self._error( + ServiceError("INTERNAL_ERROR", "request could not be completed", 500), + request_id, + ) + + def _has_scope(self, scope: str, tenant_id: str) -> bool: + token_header = self.headers.get("Authorization", "") + if not token_header.startswith("Bearer "): + return False + token_map = _loads(os.environ.get("HTTP_TOKENS"), {}) + identity = ( + token_map.get(token_header[7:]) if isinstance(token_map, dict) else None + ) + if not isinstance(identity, dict): + return False + tenant_ids = identity.get("tenant_ids", []) + return ("*" in tenant_ids or tenant_id in tenant_ids) and ( + scope in identity.get("scopes", []) or "*" in identity.get("scopes", []) + ) + + def do_GET(self) -> None: + self._dispatch("GET") + + def do_POST(self) -> None: + self._dispatch("POST") + + def do_PUT(self) -> None: + self._dispatch("PUT") + + +def make_server( + service: AgentCallService, host: str = "127.0.0.1", port: int = 8080 +) -> ThreadingHTTPServer: + class BoundHandler(AgentCallHandler): + pass + + BoundHandler.service = service + return ThreadingHTTPServer((host, port), BoundHandler) diff --git a/agent_call/main.py b/agent_call/main.py new file mode 100644 index 0000000..09d9c1f --- /dev/null +++ b/agent_call/main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from .core import AgentCallService, ConfigurationError, _integer +from .http import make_server + + +def build_service() -> AgentCallService: + profile = Path( + os.environ.get("AGENT_CALL_PROFILE", "docs/contracts/mock-profile.json") + ) + db_path = os.environ.get("AGENT_CALL_DB", "data/agent-call.sqlite3") + object_dir = os.environ.get("AGENT_CALL_OBJECT_DIR") + provider_mode = os.environ.get("AGENT_CALL_MODE") + return AgentCallService( + db_path=db_path, + profile_path=profile, + object_dir=object_dir, + mode=provider_mode, + start_background=True, + ) + + +def main() -> None: + service = build_service() + host = os.environ.get("AGENT_CALL_HOST", "127.0.0.1") + port = _integer(os.environ.get("AGENT_CALL_PORT", "8080")) + server = make_server(service, host, port) + try: + server.serve_forever() + except KeyboardInterrupt: + return + finally: + server.shutdown() + server.server_close() + service.stop() + + +if __name__ == "__main__": + try: + main() + except ConfigurationError as exc: + raise SystemExit(f"configuration error: {exc.detail}") from exc diff --git a/agent_call/mocks.py b/agent_call/mocks.py new file mode 100644 index 0000000..c4dc035 --- /dev/null +++ b/agent_call/mocks.py @@ -0,0 +1,105 @@ +"""Protocol-shaped deterministic mocks used by the mock profile. + +They record protocol facts instead of pretending that an HTTP callback is a SIP, +ARI, RTP, AI-stream, or OSS integration. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any + + +class ProtocolJournal: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + + def append(self, protocol: str, event: str, **facts: Any) -> None: + entry = {"protocol": protocol, "event": event, **facts} + try: + encoded = json.dumps( + entry, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + with self._lock, self.path.open("a", encoding="utf-8") as stream: + stream.write(encoded + "\n") + except (OSError, TypeError, ValueError) as exc: + raise RuntimeError("protocol journal write failed") from exc + + def read(self) -> list[dict[str, Any]]: + try: + lines = self.path.read_text(encoding="utf-8").splitlines() + return [json.loads(line) for line in lines if line] + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("protocol journal read failed") from exc + + +class MockSIPPeer: + def __init__(self, journal: ProtocolJournal) -> None: + self.journal = journal + + def invite(self, call_id: str, callee: str, target_uri: str, caller: str) -> None: + self.journal.append( + "sip", + "invite", + call_id=call_id, + callee=callee, + target_uri=target_uri, + caller=caller, + ) + + def ringing(self, call_id: str) -> None: + self.journal.append("sip", "ringing", call_id=call_id) + + def answered(self, call_id: str) -> None: + self.journal.append("sip", "answered", call_id=call_id) + + def bye(self, call_id: str, reason: str) -> None: + self.journal.append("sip", "bye", call_id=call_id, reason=reason) + + +class MockARI: + def __init__(self, journal: ProtocolJournal) -> None: + self.journal = journal + + def channel_created(self, call_id: str, cell_id: str) -> None: + self.journal.append("ari", "channel_created", call_id=call_id, cell_id=cell_id) + + def bridge_ready(self, call_id: str) -> None: + self.journal.append("ari", "bridge_ready", call_id=call_id) + + def media_closed(self, call_id: str) -> None: + self.journal.append("ari", "media_closed", call_id=call_id) + + def cleanup(self, call_id: str) -> None: + self.journal.append("ari", "cleanup", call_id=call_id) + + +class MockRTP: + def __init__(self, journal: ProtocolJournal) -> None: + self.journal = journal + + def packet(self, call_id: str, direction: str, sequence: int) -> None: + self.journal.append( + "rtp", "packet", call_id=call_id, direction=direction, sequence=sequence + ) + + +class MockAI: + def __init__(self, journal: ProtocolJournal) -> None: + self.journal = journal + + def turn( + self, call_id: str, turn_index: int, speaker: str, playback_state: str + ) -> None: + self.journal.append( + "ai", + "turn", + call_id=call_id, + turn_index=turn_index, + speaker=speaker, + playback_state=playback_state, + ) diff --git a/compose.yaml b/compose.yaml index 6db3252..6fb1876 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,55 @@ --- name: agent-call services: + rabbitmq: + image: >- + rabbitmq:4.1-management-alpine@sha256:eb6736723c5d0831ab12d29e7a2b8ee1082f744370c2a784b50b84b3b4d6d030 + restart: unless-stopped + init: true + environment: + RABBITMQ_DEFAULT_USER: >- + ${RABBITMQ_DEFAULT_USER:?Set RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: >- + ${RABBITMQ_DEFAULT_PASS:?Set RABBITMQ_DEFAULT_PASS} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - rabbitmq_data:/var/lib/rabbitmq + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + + agent-call: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + init: true + depends_on: + rabbitmq: + condition: service_healthy + ports: + - "127.0.0.1:${AGENT_CALL_PORT:-18080}:8080" + environment: + AGENT_CALL_MODE: "mock" + BROKER_MODE: "rabbit" + RABBITMQ_URL: "${RABBITMQ_URL:?Set RABBITMQ_URL}" + HTTP_TOKENS: "${HTTP_TOKENS:?Set HTTP_TOKENS}" + volumes: + - agent_call_data:/data + read_only: false + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 256 + mem_limit: 512m + cpus: 2 + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + asr-web: build: context: ./services/asr-web @@ -33,3 +82,7 @@ services: options: max-size: "10m" max-file: "3" + +volumes: + rabbitmq_data: + agent_call_data: diff --git a/deploy/alerts.yml b/deploy/alerts.yml new file mode 100644 index 0000000..8bf2a48 --- /dev/null +++ b/deploy/alerts.yml @@ -0,0 +1,21 @@ +groups: + - name: agent-call + rules: + - alert: AgentCallOutboxStalled + expr: agent_call_outbox_pending > 0 + for: 2m + labels: {severity: warning} + annotations: + summary: agent-call outbox has pending events + - alert: AgentCallDeadLetters + expr: increase(agent_call_dead_letters_total[5m]) > 0 + for: 1m + labels: {severity: critical} + annotations: + summary: agent-call rejected or malformed messages entered the DLQ + - alert: AgentCallNotMockSafe + expr: agent_call_mode_info{mode="mock"} != 1 + for: 1m + labels: {severity: critical} + annotations: + summary: provider mode changed; verify production admission gates diff --git a/deploy/aliyun.example.json b/deploy/aliyun.example.json index ac383b3..e209681 100644 --- a/deploy/aliyun.example.json +++ b/deploy/aliyun.example.json @@ -11,5 +11,6 @@ "key_pair_name": "", "spot_price_limit": null, "system_disk_category": "cloud_essd", + "system_disk_performance_level": "PL1", "system_disk_gib": 40 } diff --git a/deploy/aliyun_host.py b/deploy/aliyun_host.py index 18a893f..7bba478 100644 --- a/deploy/aliyun_host.py +++ b/deploy/aliyun_host.py @@ -2,6 +2,7 @@ """Read-only by default. Reuse/prepare the fixed Beijing host via the aliyun CLI.""" import argparse +import base64 import fcntl import hashlib import json @@ -230,7 +231,7 @@ def create_params(cfg): raise CloudError( "system_disk_gib must be within the approved 40–200 GiB safety bound" ) - return { + params = { "ImageId": cfg["image_id"], "InstanceType": cfg["instance_type"], "VSwitchId": cfg["vswitch_id"], @@ -244,9 +245,25 @@ def create_params(cfg): "SpotPriceLimit": price, "SystemDisk.Category": cfg.get("system_disk_category", "cloud_essd"), "SystemDisk.Size": disk, + "SystemDisk.PerformanceLevel": cfg.get("system_disk_performance_level", "PL1"), "Tag.1.Key": "project", "Tag.1.Value": cfg["project_tag"], } + user_data_file = cfg.get("user_data_file") + if user_data_file is not None: + if not isinstance(user_data_file, str) or not user_data_file.strip(): + raise CloudError("user_data_file must be a non-empty local file path") + path = Path(user_data_file) + if path.is_symlink() or not path.is_file(): + raise CloudError("user_data_file must be an existing non-symlink file") + try: + content = path.read_bytes() + except OSError as exc: + raise CloudError("user_data_file cannot be read") from exc + if len(content) > 16 * 1024: + raise CloudError("user_data_file exceeds the ECS 16 KiB limit") + params["UserData"] = base64.b64encode(content).decode("ascii") + return params def save_state(path, data): diff --git a/deploy/backup_sqlite.py b/deploy/backup_sqlite.py new file mode 100644 index 0000000..73316d8 --- /dev/null +++ b/deploy/backup_sqlite.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse +import sqlite3 +from pathlib import Path + + +def copy_database(source: str, destination: str) -> None: + source_path = Path(source) + destination_path = Path(destination) + if not source_path.is_file(): + raise FileNotFoundError(source) + destination_path.parent.mkdir(parents=True, exist_ok=True) + source_db = sqlite3.connect(source_path) + destination_db = sqlite3.connect(destination_path) + try: + source_db.backup(destination_db) + destination_db.commit() + finally: + destination_db.close() + source_db.close() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="offline SQLite backup/restore for agent-call" + ) + parser.add_argument("action", choices=("backup", "restore")) + parser.add_argument("source") + parser.add_argument("destination") + args = parser.parse_args() + copy_database(args.source, args.destination) + print(f"{args.action} complete") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/check_mock_config.py b/deploy/check_mock_config.py new file mode 100644 index 0000000..1558ac5 --- /dev/null +++ b/deploy/check_mock_config.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +_core = importlib.import_module("agent_call.core") +queue_name = _core.queue_name +routing_key = _core.routing_key + + +def check(profile_path: str | Path) -> list[str]: + path = Path(profile_path) + try: + profile = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"profile load failed: {type(exc).__name__}"] + problems: list[str] = [] + if profile.get("mode") != "mock": + problems.append("mock profile must declare mode=mock") + for component, provider in profile.get("provider_modes", {}).items(): + if component in {"llm", "tts", "asr", "sip", "oss"} and provider == "real": + problems.append(f"mock profile enables real provider: {component}") + for tenant in profile.get("tenants", []): + try: + route = routing_key(tenant["tenant_key"]) + queue_name(tenant["tenant_key"]) + except Exception as exc: + problems.append(f"tenant route invalid: {type(exc).__name__}") + continue + if len(route.encode("utf-8")) > 255: + problems.append("tenant route exceeds broker limit") + if not profile.get("cells"): + problems.append("no execution cells configured") + return problems + + +def main() -> int: + problems = check( + sys.argv[1] if len(sys.argv) > 1 else "docs/contracts/mock-profile.json" + ) + if problems: + for problem in problems: + print(f"ERROR {problem}") + return 1 + print("mock profile clean") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/grafana-agent-call.json b/deploy/grafana-agent-call.json new file mode 100644 index 0000000..189e81a --- /dev/null +++ b/deploy/grafana-agent-call.json @@ -0,0 +1,10 @@ +{ + "title": "agent-call overview", + "schemaVersion": 39, + "panels": [ + {"type": "stat", "title": "Active calls", "targets": [{"expr": "agent_call_active_calls"}]}, + {"type": "stat", "title": "Waiting commands", "targets": [{"expr": "agent_call_waiting_commands"}]}, + {"type": "stat", "title": "Outbox pending", "targets": [{"expr": "agent_call_outbox_pending"}]}, + {"type": "stat", "title": "Dead letters", "targets": [{"expr": "agent_call_dead_letters_total"}], "transparent": false} + ] +} diff --git a/deploy/migrate.py b/deploy/migrate.py new file mode 100644 index 0000000..762167b --- /dev/null +++ b/deploy/migrate.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import argparse +import importlib +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +_core = importlib.import_module("agent_call.core") +Store = _core.Store + + +def migrate(path: str) -> int: + store = Store(path) + row = store.one("SELECT MAX(version) AS version FROM schema_migrations") + print(f"schema_version={row['version']}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="apply idempotent agent-call SQLite migrations" + ) + parser.add_argument("database") + args = parser.parse_args() + return migrate(args.database) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/mock.env.example b/deploy/mock.env.example new file mode 100644 index 0000000..d6244b6 --- /dev/null +++ b/deploy/mock.env.example @@ -0,0 +1,9 @@ +# Synthetic local values only. Never use this file for production credentials. +RABBITMQ_DEFAULT_USER=agent_call_mock +RABBITMQ_DEFAULT_PASS=replace-before-start +RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f +# JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments. +HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}} +AGENT_CALL_MODE=mock +BROKER_MODE=rabbit +AGENT_CALL_PORT=18080 diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml new file mode 100644 index 0000000..e0e447c --- /dev/null +++ b/deploy/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: agent-call + metrics_path: /metrics + static_configs: + - targets: ["agent-call:8080"] diff --git a/deploy/render_asterisk.py b/deploy/render_asterisk.py index b8ad7a4..300c23a 100644 --- a/deploy/render_asterisk.py +++ b/deploy/render_asterisk.py @@ -49,6 +49,21 @@ def endpoint(name, data, env): if not isinstance(registration, bool) or registration and mode != "digest": raise ValueError("registration requires explicit digest authentication") text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=ulaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n" + from_user = data.get("from_user") + if from_user is not None: + if not isinstance(from_user, str) or not re.fullmatch( + r"[A-Za-z0-9_.+\-]+", from_user + ): + raise ValueError(f"{name}.from_user must be a plain caller identifier") + text += f"from_user={from_user}\n" + display_identity = data.get("caller_id") + if display_identity is not None: + display_identity = scalar(display_identity, name + ".caller_id") + if not re.fullmatch( + r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", display_identity + ): + raise ValueError(f"{name}.caller_id contains unsupported characters") + text += f"callerid={display_identity}\n" auth = "" if mode == "digest": user = scalar(data.get("username"), name + ".username") @@ -56,7 +71,9 @@ def endpoint(name, data, env): raise ValueError("SIP username must be a plain user identifier") password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD" password = scalar(env.get(password_key), password_key, secret=True) - text += f"outbound_auth={name}-auth\nfrom_user={user}\n" + text += f"outbound_auth={name}-auth\n" + if from_user is None: + text += f"from_user={user}\n" auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n" if registration: auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n" @@ -89,22 +106,40 @@ def render(cfg, env): password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True) if len(password) < 32: raise ValueError("ARI_PASSWORD must have at least 32 characters") - primary = endpoint("provider-primary", cfg.get("primary", {}), env) - backup = endpoint("provider-backup", cfg.get("backup", {}), env) - if (cfg["primary"]["host"], cfg["primary"].get("port", 5060)) == ( - cfg["backup"]["host"], - cfg["backup"].get("port", 5060), - ): - raise ValueError( - "primary and backup targets must be distinct; shared failure domains still require validation" - ) + primary_data = cfg.get("primary", {}) + primary = endpoint("provider-primary", primary_data, env) + backup_data = cfg.get("backup") + if backup_data is not None and not isinstance(backup_data, dict): + raise ValueError("backup must be an object when configured") + sections = [primary] + if backup_data: + backup = endpoint("provider-backup", backup_data, env) + if (primary_data["host"], primary_data.get("port", 5060)) == ( + backup_data["host"], + backup_data.get("port", 5060), + ): + raise ValueError( + "primary and backup targets must be distinct; shared failure domains still require validation" + ) + sections.append(backup) transport = f"[global]\ntype=global\nuser_agent=agent-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n" + caller = primary_data.get("caller_id") or primary_data.get("from_user") + extensions = "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n" + if caller: + caller = scalar(caller, "primary.caller_id") + extensions += ( + "\n[outbound]\n" + "exten => _X.,1,NoOp(agent-call outbound)\n" + f" same => n,Set(CALLERID(all)={caller})\n" + " same => n,Dial(PJSIP/${EXTEN}@provider-primary,60)\n" + " same => n,Hangup()\n" + ) return { "http.conf": f"[general]\nenabled=yes\nbindaddr={bind}\nbindport=8088\n", "ari.conf": f"[general]\nenabled=yes\npretty=no\n\n[outbound]\ntype=user\nread_only=no\npassword={password}\n", - "pjsip.conf": transport + primary + "\n" + backup, + "pjsip.conf": transport + "\n".join(sections), "rtp.conf": "[general]\nrtpstart=10000\nrtpend=10800\nstrictrtp=yes\n", - "extensions.conf": "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n", + "extensions.conf": extensions, } diff --git a/docs/contracts/examples/README.md b/docs/contracts/examples/README.md new file mode 100644 index 0000000..02f9c1a --- /dev/null +++ b/docs/contracts/examples/README.md @@ -0,0 +1,5 @@ +# Contract fixtures + +`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes. + +All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration. diff --git a/docs/contracts/examples/call.execute.json b/docs/contracts/examples/call.execute.json new file mode 100644 index 0000000..23f9fdc --- /dev/null +++ b/docs/contracts/examples/call.execute.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": "cmd_demo_001", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-11T08:00:00Z", + "not_after": "2099-09-11T08:05:00Z", + "payload": { + "execution_id": "exec_demo_001", + "task_id": "task-demo", + "task_item_id": "item_demo", + "task_revision": 1, + "callee": "18601013734", + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": "agent_v1", + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000 + } +} diff --git a/docs/contracts/executor.openapi.yaml b/docs/contracts/executor.openapi.yaml new file mode 100644 index 0000000..6bbcf14 --- /dev/null +++ b/docs/contracts/executor.openapi.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Executor Control API + version: 1.0.0 + description: >- + Internal control, query, replay, and recording hand-off API. Call execution + enters through RabbitMQ, not HTTP. +servers: + - url: https://executor.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/tasks/{task_id}/controls: + post: + operationId: controlTask + summary: Persist a pause, resume, or stop barrier + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlRequest' + responses: + '202': + description: Reliably persisted, not yet necessarily applied + headers: + Location: + schema: {type: string} + content: + application/json: + schema: {$ref: '#/components/schemas/ControlAccepted'} + '409': {$ref: '#/components/responses/Conflict'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/commands/{command_id}: + get: + operationId: getCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CommandId' + responses: + '200': + description: Command snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Command'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}: + get: + operationId: getCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CallId' + responses: + '200': + description: Call snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Call'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}/replays: + post: + operationId: replayCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CallId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} + /internal/v1/outbound/commands/{source_command_id}/replays: + post: + operationId: replayCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/SourceCommandId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + TaskId: + name: task_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CommandId: + name: command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + SourceCommandId: + name: source_command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CallId: + name: call_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\\s/\\\\]+$' + ControlRequest: + type: object + additionalProperties: false + required: [command_id, action, expected_task_revision, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + action: {type: string, enum: [pause, resume, stop]} + expected_task_revision: {type: integer, minimum: 1} + active_call_policy: {type: string, enum: [drain, hangup]} + reason: {type: string, minLength: 1, maxLength: 512} + ControlAccepted: + type: object + required: + - command_id + - tenant_id + - tenant_key + - task_id + - status + - requested_task_revision + - accepted_at + properties: + command_id: {$ref: '#/components/schemas/Id'} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + requested_task_revision: {type: integer} + accepted_at: {type: string, format: date-time} + ReplayRequest: + type: object + additionalProperties: false + required: [command_id, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + reason: {type: string, minLength: 1, maxLength: 512} + ReplayAccepted: + type: object + required: [command_id, status, snapshot_cutoff] + properties: + command_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + snapshot_cutoff: {type: string, format: date-time} + Command: + type: object + required: + - command_id + - command_type + - tenant_id + - tenant_key + - status + - aggregate_version + properties: + command_id: {$ref: '#/components/schemas/Id'} + command_type: {type: string} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {type: [string, 'null']} + execution_id: {type: [string, 'null']} + call_id: {type: [string, 'null']} + status: {type: string} + reason_code: {type: [string, 'null']} + wait_reason_code: {type: [string, 'null']} + accepted_at: {type: [string, 'null'], format: date-time} + waiting_since: {type: [string, 'null'], format: date-time} + admission_deadline: {type: [string, 'null'], format: date-time} + requested_task_revision: {type: [integer, 'null']} + applied_task_revision: {type: [integer, 'null']} + task_state: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + aggregate_version: {type: integer, minimum: 1} + Call: + type: object + required: + - call_id + - execution_id + - call_state + - call_version + - attempts + - transcript + - recordings + - delivery + - snapshot_at + properties: + call_id: {type: string} + execution_id: {type: string} + task_id: {type: string} + task_item_id: {type: string} + call_state: {type: string} + call_version: {type: integer} + reason_code: {type: [string, 'null']} + outcome: {type: [string, 'null']} + started_at: {type: [string, 'null'], format: date-time} + ended_at: {type: [string, 'null'], format: date-time} + duration_ms: {type: [integer, 'null']} + attempts: {type: array, items: {type: object}} + transcript: {type: object} + recordings: {type: array, items: {type: object}} + delivery: {type: object} + snapshot_at: {type: string, format: date-time} + responses: + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + NotFound: + description: Not found without cross-tenant enumeration + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Conflict: + description: Idempotency or revision conflict + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + ReplayExpired: + description: Retention window expired + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Problem: + type: object + additionalProperties: false + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string, format: uri-reference} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} diff --git a/docs/contracts/mock-profile.json b/docs/contracts/mock-profile.json new file mode 100644 index 0000000..15c93ae --- /dev/null +++ b/docs/contracts/mock-profile.json @@ -0,0 +1,54 @@ +{ + "profile_version": "1.0.0", + "mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"}, + "limits": { + "admission_window_s": 30, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "max_mq_bytes": 262144, + "max_queue_messages": 1000, + "max_http_bytes": 65536, + "recording_max_bytes": 16777216, + "replay_retention_s": 604800, + "upload_ttl_s": 300, + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "cell_capacity": 4, + "turns": 2, + "hold_ms": 0 + }, + "random_seed": 7, + "tenants": [ + {"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true}, + {"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true}, + {"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true} + ], + "tasks": [ + {"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1}, + {"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1}, + {"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1} + ], + "routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}], + "caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}], + "agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}], + "cells": [ + {"cell_id": "cell-a", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}, + {"cell_id": "cell-b", "capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"} + ], + "failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"] +} diff --git a/docs/contracts/mq-topology.md b/docs/contracts/mq-topology.md new file mode 100644 index 0000000..60f8501 --- /dev/null +++ b/docs/contracts/mq-topology.md @@ -0,0 +1,40 @@ +# agent-call MQ topology v1.0 + +This file is an implementation companion to the field authority in +`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`. + +| Element | Value | +| --- | --- | +| Namespace | `agent-call` | +| Command exchange | `agent-call.commands.v1`, durable `direct` | +| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding | +| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` | +| Event exchange | `agent-call.events.v1`, durable `topic` | +| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` | +| Event routing key | `agent-call.{event_type}` | +| Body limit | `262144` UTF-8 bytes in the Mock profile | +| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes | + +## Delivery rules + +1. SaaS persists the command publication record before publishing. A mandatory + publisher confirmation is required; an unroutable/full queue leaves the + original record retained for bounded retry. +2. The executor consumes only its trusted tenant queue. RabbitMQ messages are + acknowledged after durable SQLite acceptance or durable dead-lettering, not + when they are fetched. +3. Executor business events are written to the same database transaction as + the state transition. The outbox dispatcher publishes them durably and the + SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown + after broker confirmation; it does not trigger unbounded republishing. +4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and + routing key. It is not normalized, encoded, truncated or cleaned. A route + over the byte budget is retained and not sent. +5. Replay publishes the original event body and original `event_id` from a + fixed retention cutoff. It never creates a new business fact and never + includes events written after that cutoff. +6. HTTP has no call execution or redial endpoint. Control, query, replay and + recording metadata paths require bearer scope and tenant scope. + +The in-process broker is only for deterministic tests. Docker Compose uses the +same topology through the `pika` adapter and RabbitMQ durable queues. diff --git a/docs/contracts/mq.schema.json b/docs/contracts/mq.schema.json new file mode 100644 index 0000000..b5ab065 --- /dev/null +++ b/docs/contracts/mq.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}], + "$defs": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"}, + "tenantKey": {"type": "string", "minLength": 1}, + "time": {"type": "string", "format": "date-time"}, + "executeCommand": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "command_id": {"$ref": "#/$defs/id"}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, + "issued_at": {"$ref": "#/$defs/time"}, "not_after": {"$ref": "#/$defs/time"}, "payload": {"$ref": "#/$defs/executePayload"} + } + }, + "executePayload": { + "type": "object", "additionalProperties": false, + "required": ["execution_id", "task_id", "task_item_id", "task_revision", "callee", "route_policy_id", "caller_profile_id", "agent_version_id", "variables", "ring_timeout_ms", "max_call_duration_ms"], + "properties": { + "execution_id": {"$ref": "#/$defs/id"}, "task_id": {"$ref": "#/$defs/id"}, "task_item_id": {"$ref": "#/$defs/id"}, + "task_revision": {"type": "integer", "minimum": 1}, "callee": {"type": "string", "minLength": 1, "maxLength": 256}, + "route_policy_id": {"$ref": "#/$defs/id"}, "caller_profile_id": {"$ref": "#/$defs/id"}, "agent_version_id": {"$ref": "#/$defs/id"}, + "variables": {"type": "object", "additionalProperties": true}, "ring_timeout_ms": {"type": "integer", "minimum": 1}, "max_call_duration_ms": {"type": "integer", "minimum": 1} + } + }, + "event": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"} + } + } + } +} diff --git a/docs/contracts/saas.openapi.yaml b/docs/contracts/saas.openapi.yaml new file mode 100644 index 0000000..9e74ba1 --- /dev/null +++ b/docs/contracts/saas.openapi.yaml @@ -0,0 +1,169 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call SaaS Recording Handoff API + version: 1.0.0 + description: >- + Internal storage handshake. Business results still return through RabbitMQ. +servers: + - url: https://saas.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/recording-uploads: + post: + operationId: createRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UploadRequest'} + responses: + '201': + description: Upload session created or existing session returned + headers: {Cache-Control: {schema: {const: no-store}}} + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '200': + description: Existing upload session + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/v1/outbound/recording-uploads/{upload_id}/complete: + post: + operationId: completeRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - name: upload_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CompleteRequest'} + responses: + '200': + description: Object independently verified + content: + application/json: + schema: {$ref: '#/components/schemas/VerifiedUpload'} + '409': {$ref: '#/components/responses/Conflict'} + '410': {$ref: '#/components/responses/Expired'} + '422': {$ref: '#/components/responses/Unprocessable'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\\s/\\\\]+$' + UploadRequest: + type: object + additionalProperties: false + required: + - recording_id + - call_id + - content_type + - size_bytes + - checksum_algorithm + - checksum + - channels + - sample_rate_hz + - duration_ms + properties: + recording_id: {$ref: '#/components/schemas/Id'} + call_id: {$ref: '#/components/schemas/Id'} + content_type: {type: string, const: audio/wav} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {type: string, const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + channels: {type: integer, const: 1} + sample_rate_hz: {type: integer, minimum: 8000} + duration_ms: {type: integer, minimum: 1} + UploadSession: + type: object + required: + - upload_id + - recording_id + - expires_at + - upload_method + - upload_url + - required_headers + - constraints + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + expires_at: {type: string, format: date-time} + upload_method: {const: PUT} + upload_url: {type: string, format: uri} + required_headers: {type: object} + constraints: {type: object} + oss_id: {type: [string, 'null']} + CompleteRequest: + type: object + additionalProperties: false + required: [recording_id, size_bytes, checksum_algorithm, checksum] + properties: + recording_id: {$ref: '#/components/schemas/Id'} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + etag: {type: [string, 'null']} + VerifiedUpload: + type: object + required: [upload_id, recording_id, status, oss_id, verified_at] + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + status: {const: verified} + oss_id: {type: string} + verified_at: {type: string, format: date-time} + Problem: + type: object + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: {description: Unauthorized} + Forbidden: {description: Forbidden} + Conflict: {description: Idempotency conflict} + Expired: {description: Upload expired} + Unprocessable: {description: Object failed independent verification} diff --git a/docs/evidence/acceptance-gate-2026-09-11.txt b/docs/evidence/acceptance-gate-2026-09-11.txt new file mode 100644 index 0000000..e0a2991 --- /dev/null +++ b/docs/evidence/acceptance-gate-2026-09-11.txt @@ -0,0 +1 @@ +acceptance_gate_exit=1 diff --git a/docs/evidence/acceptance-mock-2026-09-11.json b/docs/evidence/acceptance-mock-2026-09-11.json new file mode 100644 index 0000000..e6246b3 --- /dev/null +++ b/docs/evidence/acceptance-mock-2026-09-11.json @@ -0,0 +1,121 @@ +{ + "finished_at": "2026-09-11T16:07:58.537Z", + "gate_status": "INCOMPLETE", + "probes": { + "V01": { + "duration_ms": 11.0, + "evidence": { + "http_execute_route": "absent", + "profile": "mock", + "schema_negative_cases": 3 + }, + "status": "PASS" + }, + "V02": { + "duration_ms": 95.403, + "evidence": { + "calls": 1, + "different_execution_command": "associated", + "same_command_replays": 1 + }, + "status": "PASS" + }, + "V03": { + "duration_ms": 204.088, + "evidence": { + "lease_duplicate_calls": 0, + "scheduler_instances": 2, + "tenants_admitted": [ + "tenant-demo", + "tenant-b", + "tenant-c" + ] + }, + "status": "PASS" + }, + "V04": { + "duration_ms": 28.609, + "evidence": { + "affected_commands": 1, + "stale_cas": "rejected", + "waiting_barrier": "applied" + }, + "status": "PASS" + }, + "V05": { + "duration_ms": 83.973, + "evidence": { + "dead_letters": 0, + "recovered": true, + "retained_id": "cmd_broker_retry" + }, + "status": "PASS" + }, + "V06/V07": { + "duration_ms": 2077.321, + "evidence": { + "asterisk_real": "not_run", + "fallback_redials": 0, + "mock_protocol": "pass", + "outcomes": { + "busy": "busy", + "disconnect": "unknown", + "no_answer": "no_answer" + } + }, + "status": "PASS" + }, + "V08": { + "duration_ms": 668.155, + "evidence": { + "old_tts_reused": false, + "segments": 200, + "turns": 100 + }, + "status": "PASS" + }, + "V09": { + "duration_ms": 1087.237, + "evidence": { + "bad_checksum": "failed", + "invalid_ready": 0, + "missing_object": "failed" + }, + "status": "PASS" + }, + "V10": { + "duration_ms": 82.427, + "evidence": { + "original_event_ids": true, + "pending_after_recovery": 0, + "pending_during_outage": 13 + }, + "status": "PASS" + }, + "V11/V12": { + "duration_ms": 88.53, + "evidence": { + "backup": "verified", + "metrics": "healthy", + "real_mode_mock_rejection": true, + "rollback_preserves_db": true + }, + "status": "PASS" + }, + "V13": { + "duration_ms": 0.003, + "evidence": { + "reason": "requires dedicated SCALE-MOCK host and sustained media load", + "required_hold_minutes": 60, + "simulated_calls_declared": 1200, + "status": "not_run" + }, + "status": "NOT_RUN" + } + }, + "production": { + "V14": "blocked_external_evidence" + }, + "profile": "mock", + "started_at": "2026-09-11T16:07:54.111Z" +} diff --git a/docs/evidence/acceptance-mock-2026-09-12.json b/docs/evidence/acceptance-mock-2026-09-12.json new file mode 100644 index 0000000..4b4953e --- /dev/null +++ b/docs/evidence/acceptance-mock-2026-09-12.json @@ -0,0 +1,124 @@ +{ + "finished_at": "2026-09-12T03:58:28.889Z", + "gate_status": "INCOMPLETE", + "probes": { + "V01": { + "duration_ms": 11.378, + "evidence": { + "http_execute_route": "absent", + "profile": "mock", + "schema_negative_cases": 3 + }, + "status": "PASS" + }, + "V02": { + "duration_ms": 103.751, + "evidence": { + "calls": 1, + "different_execution_command": "associated", + "same_command_replays": 1 + }, + "status": "PASS" + }, + "V03": { + "duration_ms": 228.406, + "evidence": { + "lease_duplicate_calls": 0, + "scheduler_instances": 2, + "tenants_admitted": [ + "tenant-demo", + "tenant-b", + "tenant-c" + ] + }, + "status": "PASS" + }, + "V04": { + "duration_ms": 27.291, + "evidence": { + "affected_commands": 1, + "stale_cas": "rejected", + "waiting_barrier": "applied" + }, + "status": "PASS" + }, + "V05": { + "duration_ms": 90.358, + "evidence": { + "dead_letters": 0, + "recovered": true, + "retained_id": "cmd_broker_retry" + }, + "status": "PASS" + }, + "V06/V07": { + "duration_ms": 2079.879, + "evidence": { + "asterisk_real": "not_run", + "fallback_redials": 0, + "mock_protocol": "pass", + "outcomes": { + "busy": "busy", + "disconnect": "unknown", + "no_answer": "no_answer" + } + }, + "status": "PASS" + }, + "V08": { + "duration_ms": 823.004, + "evidence": { + "old_tts_reused": false, + "segments": 200, + "turns": 100 + }, + "status": "PASS" + }, + "V09": { + "duration_ms": 1092.352, + "evidence": { + "bad_checksum": "failed", + "invalid_ready": 0, + "missing_object": "failed" + }, + "status": "PASS" + }, + "V10": { + "duration_ms": 78.156, + "evidence": { + "original_event_ids": true, + "pending_after_recovery": 0, + "pending_during_outage": 13 + }, + "status": "PASS" + }, + "V11/V12": { + "duration_ms": 76.478, + "evidence": { + "backup": "verified", + "metrics": "healthy", + "real_mode_mock_rejection": true, + "rollback_preserves_db": true + }, + "status": "PASS" + }, + "V13": { + "duration_ms": 0.507, + "evidence": { + "evidence_file": "docs/evidence/scale-mock-2026-09-12.json", + "minimum_answered_calls": 1004, + "observed_hold_seconds": 3600.0, + "production_claim": false, + "status": "observed", + "submitted_calls": 1200, + "tenants": 100 + }, + "status": "PASS" + } + }, + "production": { + "V14": "blocked_external_evidence" + }, + "profile": "mock", + "started_at": "2026-09-12T03:58:24.277Z" +} diff --git a/docs/evidence/asterisk-real-2026-09-12.md b/docs/evidence/asterisk-real-2026-09-12.md new file mode 100644 index 0000000..adf5634 --- /dev/null +++ b/docs/evidence/asterisk-real-2026-09-12.md @@ -0,0 +1,27 @@ +# R1 Asterisk/SIP 真实联调证据(2026-09-12) + +## 结果 + +- 新建北京竞价 ECS:`i-2zed29evhasbqzag63mg`,已按测试清理删除。 +- 固定 EIP:`123.56.71.98`,未释放、未手工解绑;清理后状态为 `Available`。 +- Asterisk:`22.10.1`,镜像使用固定 digest:`sha256:0f5e3cd0e9a86bc9dc4750929abee9ba1faae11ed39739f3e75343d39d837e41`。 +- 线路:UDP `61.132.228.221:5060`,IP 白名单免注册。 +- PJSIP contact:`Avail`,实测 RTT 约 `22–25 ms`。 +- Asterisk 对外信令/媒体地址均为 `123.56.71.98`;UDP 5060、RTP 10000–10800 已配置。 +- 已按确认规则使用原值主叫 `BD93205882`:`from_user` 与 `CALLERID` 均设置,未发送 P-Asserted-Identity。 + +## 单次真实外呼探针 + +- 原始被叫:`18601013734`。 +- 按线路规则构造:`7089` + 原始被叫 = `708918601013734`,未重复加前缀。 +- INVITE 已从 `123.56.71.98:5060` 发到供应商并收到 `100 trying`,说明公网出口、SIP 路由和白名单链路可达。 +- 供应商随后返回:`488 Not Acceptable Here`,`Reason: Q.850;cause=88;text="INCOMPATIBLE_DESTINATION"`。 +- 未收到 `180/183/200`,未建立通话或 RTP;未自动重试。 + +## 清理与遗留 + +- 测试 ECS 已停止并删除;未操作无关实例。 +- 专用安全组已删除;此前仅含供应商 `/32` 的 SIP/RTP 规则,临时 SSH `/32` 规则已先撤销。 +- 专用 SSH KeyPair 已删除,本地受限状态/密钥文件已清理,未写入代码、文档或日志。 +- 当前阻塞是供应商对目标号码/线路规则返回 `INCOMPATIBLE_DESTINATION`;需供应商确认被叫格式/路由、主叫域及编解码要求后才能再次发起测试。 +- 本次未验证真实 MQ、ASR/LLM/TTS、OSS 或完整 AI 通话。 diff --git a/docs/evidence/cloud-inventory-2026-09-11.md b/docs/evidence/cloud-inventory-2026-09-11.md new file mode 100644 index 0000000..20033b9 --- /dev/null +++ b/docs/evidence/cloud-inventory-2026-09-11.md @@ -0,0 +1,16 @@ +# Aliyun Beijing read-only inventory — 2026-09-11 + +Commands executed without create/associate/delete actions: + +```text +aliyun ecs DescribeInstances --RegionId cn-beijing --PageSize 100 +aliyun vpc DescribeEipAddresses --RegionId cn-beijing --PageSize 100 +``` + +Observed facts: + +- Region `cn-beijing` has one existing ECS instance, `ecs.e-c1m1.large`, status `Running`, charge type `PrePaid`, with public IP `39.105.111.158`. It is not reused or modified because it is not an authorized target spot test cell. +- The registered SIP egress EIP `123.56.71.98` exists in Beijing, allocation `eip-2zeevfsaxzwuue2szy7xb`, status `Available`, and is currently unassociated. +- No instance/EIP was created, rebound, stopped, deleted, or released. + +Creation remains gated on explicit test budget/spot price ceiling, disk/VSwitch/security-group selection, SSH key authorization, and the real SIP/AI/OSS acceptance window. The available EIP must be associated only through the approved cell plan; it must never be placed in a SIP trunk contact field or replaced with a newly allocated address. diff --git a/docs/evidence/mock-config-2026-09-11.txt b/docs/evidence/mock-config-2026-09-11.txt new file mode 100644 index 0000000..dd07479 --- /dev/null +++ b/docs/evidence/mock-config-2026-09-11.txt @@ -0,0 +1 @@ +mock profile clean diff --git a/docs/evidence/rabbitmq-compose-smoke-2026-09-11.txt b/docs/evidence/rabbitmq-compose-smoke-2026-09-11.txt new file mode 100644 index 0000000..a876074 --- /dev/null +++ b/docs/evidence/rabbitmq-compose-smoke-2026-09-11.txt @@ -0,0 +1,5 @@ +ready= ready +command_status= completed +command_reason= CALL_COMPLETED +rabbit_provider= agent_call_provider_mode_info{component="rabbitmq",mode="rabbit"} 1 +outbox_pending= agent_call_outbox_pending 0 diff --git a/docs/evidence/scale-mock-2026-09-12.json b/docs/evidence/scale-mock-2026-09-12.json new file mode 100644 index 0000000..10a3b9f --- /dev/null +++ b/docs/evidence/scale-mock-2026-09-12.json @@ -0,0 +1,65 @@ +{ + "acceptance": "V13", + "profile": "mock", + "status": "observed", + "run_started_at": "2026-09-12T02:50:20Z", + "run_finished_at_observed": "2026-09-12T03:51:31Z", + "host": { + "region": "cn-beijing", + "zone": "cn-beijing-g", + "instance_id": "i-2zeivenvp67lj4es3l62", + "instance_type": "ecs.hfg6.large", + "fixed_egress_ip": "123.56.71.98", + "image_id": "rockylinux_9_8_x64_20G_alibase_20260827.vhd", + "system_disk": { + "category": "cloud_essd", + "size_gib": 40, + "performance_level": "PL1" + }, + "spot_price_limit_cny_per_hour": 0.15 + }, + "load": { + "tenants": 100, + "submitted_calls": 1200, + "one_cell_disabled": true, + "required_hold_minutes": 60, + "threshold_reached_seconds": 51.479, + "observed_hold_seconds": 3600.0, + "answered_calls_at_end_before_teardown": 1200, + "minimum_answered_calls": 1004 + }, + "teardown": { + "stop_commands_requested": 100, + "stop_commands_applied": 100, + "active_calls_after_cleanup": 0, + "nonterminal_commands_after_cleanup": 0, + "outbox_pending_after_cleanup": 0, + "clean": true + }, + "remote_evidence": { + "result_sha256": "a5faf7adccfb77151e2fcc20efbd53fbdede7bf77354685fb55b641148de9db7", + "sqlite_sha256": "3fdc0461b7da8c0f14b6b0f186af3654082c7512d0991373dc7bf6f37b82ff8b", + "source_hashes": { + "scripts/scale_mock.py": "5b10afb2a741b440f3a73688cd5632c1c4890ff760629e1fbbce6dcc89073087", + "agent_call/core.py": "84f7085bfdecb337229c5415687c852fc3e639c6bbf08be2886f6b1ead50f590", + "docs/contracts/mq.schema.json": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a" + }, + "call_states_after_teardown": { + "failed": 1200 + }, + "command_states_after_teardown": { + "applied": 100, + "failed": 1200 + }, + "event_counts": { + "call.finished": 1200, + "call.status": 4800, + "command.result": 3830 + } + }, + "limitations": [ + "This is SCALE-MOCK protocol/load evidence only; continuous_media_evidence is false.", + "It does not verify real RTP, Asterisk, SIP, ASR, LLM, TTS, OSS, SaaS, production SLA, or N+1 capacity." + ], + "production_claim": false +} diff --git a/docs/evidence/scale-mock-preflight-2026-09-11.json b/docs/evidence/scale-mock-preflight-2026-09-11.json new file mode 100644 index 0000000..9c5c403 --- /dev/null +++ b/docs/evidence/scale-mock-preflight-2026-09-11.json @@ -0,0 +1,10 @@ +{ + "calls": 1200, + "calls_per_tenant": 12, + "cells": 101, + "reason": "pass --run only on a dedicated SCALE-MOCK host", + "required_hold_minutes": 60, + "safe_capacity_after_one_cell_loss": 1200, + "status": "not_run", + "tenants": 100 +} diff --git a/docs/最终开发部署监控与验收计划_v1.0.md b/docs/最终开发部署监控与验收计划_v1.0.md index 55397e7..271158d 100644 --- a/docs/最终开发部署监控与验收计划_v1.0.md +++ b/docs/最终开发部署监控与验收计划_v1.0.md @@ -50,7 +50,7 @@ OpenAPI 只描述 HTTP 请求/响应;MQ 用 JSON Schema 和真实 AMQP 语义 ### 3.2 契约交付与测试约束 -以下是 **D01 计划产物,当前未生成**;目录统一放在 docs/contracts/,不在本轮创建空文件占位: +以下是 **D01 已落地产物**;目录统一放在 docs/contracts/,由契约测试和 Mock 验收持续校验: - `executor.openapi.yaml`:呼出应用提供的控制、查询、两类补传;`saas.openapi.yaml`:SaaS 提供的上传授权/完成确认。使用 OpenAPI 3.1,固定幂等、鉴权、错误和版本语义。 - `mq.schema.json`:执行、事件外壳及分类型 payload;集中样例和正反例复用同一 Schema。标准 HTTP 响应/事件容许兼容的可选扩展,未知主版本隔离;拒绝非法命令字段,不把严格校验和任意扩展混为一谈。 @@ -289,7 +289,7 @@ M3产物 + C1~C6真实配置/权限(受控存放)+ R1/R2全部证据 + 实 ## 10. 本次文档验收与后续入口 -本次仅检查文档引用、门禁一致性、既有样例完整性和Markdown;当前所有M/R运行用例仍为待执行。当前已存在的回归命令可继续使用,但不能把它们当作新平台全部测试: +本轮已完成文档引用、契约/实现一致性、样例完整性、Mock开发回归及V01~V13验证;V14/R1/R2/R3仍因真实外部证据待执行。当前回归命令可继续使用,但不能把Mock结果当作生产验收: ```bash python3 -m unittest discover -s tests -v @@ -298,4 +298,12 @@ node --test tests/test_pcm.cjs bash -n deploy/asterisk.sh ``` -下一实施入口是 D01:生成并验证机器可读契约与Mock profile,再做D02;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。 +下一实施入口是 D12/R1:在取得真实SaaS、SIP/Asterisk、ASR/LLM/TTS、OSS、出口、容量和运维证据后逐项替换Mock并复验;无需再次讨论已接受的A类方案。遇到真实供应商协议/合规/资源事实不兼容,只对具体差异登记变更,不把整个计划退回重选,也不让Mock假成功掩盖差异。 + +### 10.1 当前执行记录(2026-09-12) + +已落地 D01 的 OpenAPI 3.1、共用 MQ Schema、集中样例和 Mock profile,以及 D02/D03 的 SQLite 持久状态、租户独立 RabbitMQ 拓扑、outbox/inbox、CAS 控制、重放、Mock SIP/ARI/RTP/AI/OSS 协议记录、监控/告警、迁移/备份和 Compose 部署入口。测试号码 `18601013734` 在 Mock 与明确授权的 R1 探针中均保持原值;当前线路目标按规则生成 `708918601013734`。 + +本轮可重复命令 `python3 -m unittest discover -s tests -v` 和 RabbitMQ Compose smoke 已通过;`python3 -m scripts.acceptance` 的 V01~V13 Mock 探针通过,因 V14 仍需外部真实证据而按门禁返回非零,证据归档于 `docs/evidence/acceptance-mock-2026-09-12.json`。SCALE-MOCK 在阿里云北京专用竞价 Cell `ecs.hfg6.large` 上完成:100 租户、1200 模拟呼叫、禁用1个Cell,最低1004路已回答并连续观察3600秒;详见 `docs/evidence/scale-mock-2026-09-12.json`。该证据仅证明 Mock 协议/持久状态负载基线,`continuous_media_evidence` 为 false,不替代真实媒体容量。 + +R1 已完成一次真实 Asterisk/SIP 单路探针:新建测试 ECS `i-2zed29evhasbqzag63mg` 使用固定 EIP `123.56.71.98` 和 Asterisk 22.10.1 digest,PJSIP contact/OPTIONS 可达,真实 INVITE 已送达供应商;目标呼叫收到 `488`、`Q.850 cause=88 INCOMPATIBLE_DESTINATION`,未接通、未建立 RTP,证据见 `docs/evidence/asterisk-real-2026-09-12.md`。该测试实例已停止并删除,专用安全组和KeyPair也已删除,EIP 未释放且回到 Available,临时 SSH 规则已撤销。真实接通/RTP、ASR/LLM/TTS/OSS、生产 1000 路完整 AI、N+1、实际CPS/SLO及副作用正确性仍阻塞,现有产物不得据此宣称生产完成。 diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md index 5eda333..b8c62fd 100644 --- a/docs/部署接入_运行说明.md +++ b/docs/部署接入_运行说明.md @@ -1,6 +1,6 @@ # 部署接入:本轮实现与运行说明 -> 本文只说明当前已存在的ASR/部署工具。后续完整执行平台按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)从契约与Mock开始;未来服务、Compose和运行命令须实现后补充,不把本运行说明当作完整平台已上线。 +> 本文同时说明现有 ASR/部署工具与已落地的 agent-call Mock 验证栈。完整执行平台仍按[最终开发部署监控与验收计划](最终开发部署监控与验收计划_v1.0.md)推进;Mock 通过不等于真实线路、供应商或生产容量验收。 ## 1. 实施边界 @@ -12,12 +12,13 @@ | ASR 连接与协议 | 已加固:断开清理、取消、写入/启动时限、最终结果背压、火山帧长度及gzip解压上限 | | 部署底座 | 已实现:非root/read-only ASR容器、回环端口、Asterisk配置生成和显式启动检查 | | 阿里云主机准备 | 已实现CLI驱动的只读计划、受控创建竞价实例、复用主机和绑定既有EIP;默认不修改云资源 | -| 云端实际操作 | 未执行;当前本机无aliyun CLI/云凭据,未核实固定IP归属,未创建实例或改绑IP | -| Asterisk真实接入 | 未执行;指定SIP地址/鉴权、VPC网络、镜像digest等仍需填写 | +| 云端实际操作 | 已完成V13一次性专用竞价Cell创建、绑定固定EIP、SCALE-MOCK及回收;测试实例已删除,固定EIP保留 | +| Asterisk真实接入 | 已完成R1单路底座与SIP探针;OPTIONS/contact可达,但真实INVITE被供应商以488/Q.850 cause=88拒绝,未通过外呼验收 | | LLM/TTS | 未实现、未启用,等待用户新的供应商/协议/参数规范 | -| ARI业务调度、自动FALLBACK、MQ/OSS回传 | 尚未实现;仍按开发计划推进,不能把两个trunk配置当成自动切换代码 | +| agent-call Mock 调度、MQ/OSS闭环 | 已实现:租户独立路由、DB状态、CAS控制、outbox/inbox、Mock SIP/ARI/RTP/AI和OSS校验;未替代真实Asterisk/供应商验收 | +| ARI业务调度、自动FALLBACK、真实MQ/OSS回传 | 未完成;现有主备trunk配置不能当成自动切换代码 | -当前目标是一台北京竞价ECS上的部署底座。ASR测试台与SIP媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。 +当前目标仍是一台北京竞价ECS上的部署底座。R1已验证固定EIP、UDP SIP链路和Asterisk配置;真实通话路由仍被供应商拒绝。ASR测试台与电话媒体尚未连通;浏览器识别不是电话外呼,也不是已完成SaaS业务验收。 参考:`sip-research@6a8064e53bb7eadd73f03373524953e68330976c`;ASR协议源自`voice_test@6772bf4`,仅导入`asr.go/asr_bailian.go/asr_volc.go`后进行加固,不导入原main、配置页、LLM或TTS代码。 @@ -166,7 +167,7 @@ python3 deploy/render_asterisk.py --config .local/asterisk.json ### 5.2 镜像与安全组 -`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。参考仓库使用latest、历史记录22.10.1,不代表该镜像当前版本已经被本项目验证;本轮没有拉取或启动真实Asterisk镜像。 +`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。本轮R1使用内部Registry中的固定digest镜像并实测Asterisk 22.10.1;该镜像验证仅覆盖单路底座,不等于生产镜像批准或容量验收。配置读取权限按镜像实际UID/GID修正为必要的组读权限,未使用chmod 777。 确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。 @@ -200,16 +201,49 @@ bash -n deploy/asterisk.sh - 自动测试:云只读/归属/固定IP、创建幂等、失败恢复、EIP绑定竞态、库存完整性;Asterisk配置验证;ASR鉴权、取消、模拟WebSocket结果、帧边界及PCM编码。 - 本地容器:ASR镜像构建、非root/read-only运行、回环HTTP与healthcheck;测试容器已清理。 - 浏览器:无效令牌拒绝、正常令牌读取未配置模型、模型禁用、LLM/TTS未启用。没有采集真实麦克风,也没有发送真实供应商请求。 -- 未执行:真实阿里云查询/创建/绑定、SIP通话、真实ASR、LLM/TTS、MQ/OSS、完整外呼和竞价回收恢复。 +- 已执行:阿里云只读盘点、一次V13专用竞价Cell创建/绑定、Mock规模运行、一次R1真实SIP/Asterisk探针、测试实例停止/删除和固定EIP保留验证。 +- 未执行或未通过:真实外呼接通/RTP、真实ASR、LLM/TTS、MQ/OSS、完整外呼以及生产容量/N+1验收;R1证据见`docs/evidence/asterisk-real-2026-09-12.md`。 本地模拟通过不等于生产可用。只有填齐资源和契约、执行既有验收文档相应场景并留存证据后,才可把阶段状态更新为真实环境验收通过。 -## 7. 下一步需要用户提供/确认 +## 7. agent-call Mock 验证栈 -1. 在本机通过安全方式配置阿里云CLI与Profile/RAM Role/STS,并确认123.56.71.98的实际归属及是否可迁移EIP。 -2. 北京实例规格、镜像、VSwitch、安全组、SSH KeyPair、竞价上限及磁盘/EIP/流量预算;现有主机是否允许复用。 -3. SIP主备真实地址、协议、鉴权、号码/主叫要求和接入限制。 -4. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。 -5. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。 +### 7.1 本地启动 -以上未确认前,不创建计费资源、不改绑白名单IP、不自动拨真实号码,也不声称完成整个平台。 +```bash +# 仅使用本地SQLite和严格的进程内Broker,适合单元/验收探针。 +python3 -m agent_call + +# RabbitMQ模式需要先注入受控环境变量;示例只含合成值。 +set -a; . deploy/mock.env.example; set +a +# 按实际密码/URL替换示例值后启动: +docker compose up -d --build rabbitmq agent-call +curl --fail http://127.0.0.1:18080/healthz/ready +``` + +业务执行命令只能经RabbitMQ租户队列进入;HTTP只提供控制、查询、重放和录音交接路径。测试被叫保留原值`18601013734`,Mock线路生成目标`708918601013734`,主叫显示保留`BD93205882`。 + +### 7.2 可重复检查 + +```bash +python3 -m unittest discover -s tests -v +python3 -m scripts.acceptance +python3 deploy/check_mock_config.py +python3 deploy/migrate.py data/agent-call.sqlite3 +``` + +`agent_call/core.py`使用真实SQLite表保存任务、执行、通话、尝试、事件、outbox/inbox、上传和重放状态;`deploy/backup_sqlite.py backup|restore`提供离线备份恢复。`ProtocolJournal`只记录合成的SIP/ARI/RTP/AI协议事实,不把它们伪装成真实供应商响应。 + +### 7.3 当前门禁 + +本地V01-V12及阿里云专用主机上的V13 Mock探针已通过;完整验收脚本仍因V14需要外部真实证据而按门禁返回非零,证据见`docs/evidence/acceptance-mock-2026-09-12.json`和`docs/evidence/scale-mock-2026-09-12.json`。V13使用100租户/1200模拟接通、禁用1个Cell,最低1004路已回答并持续观察3600秒;这是Mock协议/持久状态负载证据,不是RTP或生产容量证据。R1真实SIP证据见`docs/evidence/asterisk-real-2026-09-12.md`:固定EIP和SIP OPTIONS/INVITE链路可达,但目标呼叫收到488/Q.850 cause=88。测试ECS及本次专用安全组/KeyPair已回收,固定EIP`123.56.71.98`保持Available。真实接通/RTP、ASR/LLM/TTS/OSS、生产1000路/N+1尚未完成。正式模式拒绝当前Mock profile,不能把测试号码或Mock成功自动升级为真实外呼授权。 + +## 8. 下一步需要用户提供/确认 + +1. 供应商需确认`7089`前缀/目标号码路由、From域(当前实际出现私网域)及允许的编解码;R1证据中的`488/Q.850 cause=88 INCOMPATIBLE_DESTINATION`未通过外呼验收。 +2. 确认后再按单次授权重新创建测试Cell并只发起一通探针;不沿用旧测试实例、不自动重试、不改绑或释放固定EIP。 +3. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。 +4. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。 +5. 真实接通后仍需分别完成RTP、录音、MQ/OSS、ASR/LLM/TTS和生产1000路/N+1验收。 + +在供应商规则确认前,不再次拨打真实号码,也不声称完成整个平台。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e0fae9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +jsonschema>=4.20,<5 +pika>=1.3,<2 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..8fd4d1c --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Operational scripts for agent-call.""" diff --git a/scripts/acceptance.py b/scripts/acceptance.py new file mode 100644 index 0000000..89f6456 --- /dev/null +++ b/scripts/acceptance.py @@ -0,0 +1,492 @@ +"""Repeatable Mock acceptance probes for V01-V13. + +The script deliberately reports R1/R2 as blocked until real external evidence +exists. Passing Mock probes never upgrades the result to production readiness. +""" + +from __future__ import annotations + +import copy +import json +import tempfile +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from agent_call.core import ( + AgentCallService, + ConflictError, + InMemoryBroker, + ServiceError, + iso, + utcnow, +) +from deploy.backup_sqlite import copy_database +from deploy.check_mock_config import check as check_profile + +ROOT = Path(__file__).resolve().parents[1] + + +def load_fixture() -> dict[str, Any]: + try: + return json.loads( + (ROOT / "docs/contracts/examples/call.execute.json").read_text( + encoding="utf-8" + ) + ) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("contract fixture is unavailable") from exc + + +def service(directory: str, broker: InMemoryBroker | None = None) -> AgentCallService: + return AgentCallService( + db_path=Path(directory) / "executor.sqlite3", + object_dir=Path(directory) / "objects", + broker=broker or InMemoryBroker(), + ) + + +def command( + command_id: str, tenant_id: str = "tenant-demo", task_id: str = "task-demo" +) -> dict[str, Any]: + body = copy.deepcopy(load_fixture()) + keys = { + "tenant-demo": "tenant-demo-key", + "tenant-b": "tenant.b", + "tenant-c": "tenant#c", + } + body["command_id"] = command_id + body["trace_id"] = f"trace_{command_id}" + body["tenant_id"] = tenant_id + body["tenant_key"] = keys[tenant_id] + body["issued_at"] = iso(utcnow()) + body["payload"]["execution_id"] = f"exec_{command_id}" + body["payload"]["task_id"] = task_id + return body + + +def run_success(svc: AgentCallService, body: dict[str, Any]) -> dict[str, Any]: + svc.publish_execute(body) + svc.wait_for_idle() + return svc.get_command(body["tenant_id"], body["command_id"]) + + +def assert_raises(code: str, action: Callable[[], Any]) -> None: + try: + action() + except ServiceError as exc: + if exc.code != code: + raise AssertionError(f"expected {code}, got {exc.code}") from exc + return + raise AssertionError(f"expected {code}") + + +def probe_v01() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + problems = check_profile(ROOT / "docs/contracts/mock-profile.json") + if problems: + raise AssertionError("; ".join(problems)) + invalid = command("cmd_bad_version") + invalid["schema_version"] = "2.0" + assert_raises("SCHEMA_INVALID", lambda: svc.publish_execute(invalid)) + oversized = command("cmd_oversized") + oversized["payload"]["variables"] = {"blob": "x" * 300000} + assert_raises("MESSAGE_TOO_LARGE", lambda: svc.publish_execute(oversized)) + mismatch = command("cmd_mismatch") + mismatch["tenant_key"] = "tenant.b" + assert_raises("TENANT_BINDING_INVALID", lambda: svc.publish_execute(mismatch)) + return { + "profile": "mock", + "schema_negative_cases": 3, + "http_execute_route": "absent", + } + + +def probe_v02() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + first = command("cmd_idempotent", "tenant-demo") + svc.publish_execute(first) + svc.publish_execute(first) + svc.wait_for_idle() + duplicate = command("cmd_second", "tenant-demo") + duplicate["payload"]["execution_id"] = first["payload"]["execution_id"] + duplicate_snapshot = run_success(svc, duplicate) + if duplicate_snapshot["reason_code"] != "DUPLICATE_EXECUTION": + raise AssertionError( + "execution idempotency did not associate the original call" + ) + calls = svc.store.one("SELECT COUNT(*) AS n FROM calls")["n"] + if calls != 1: + raise AssertionError("duplicate execution dialed a second call") + with_body_change = copy.deepcopy(first) + with_body_change["payload"]["callee"] = "18601013735" + assert_raises( + "IDEMPOTENCY_CONFLICT", lambda: svc.publish_execute(with_body_change) + ) + return { + "calls": calls, + "same_command_replays": 1, + "different_execution_command": "associated", + } + + +def probe_v03() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + broker = InMemoryBroker() + first = service(directory, broker) + second = AgentCallService( + db_path=Path(directory) / "executor.sqlite3", + object_dir=Path(directory) / "objects-2", + broker=broker, + ) + try: + for tenant_id, task_id in ( + ("tenant-demo", "task-demo"), + ("tenant-b", "task-b"), + ("tenant-c", "task-c"), + ): + first.publish_execute(command(f"cmd_{tenant_id}", tenant_id, task_id)) + first.process_once() + second.process_once() + first.wait_for_idle() + tenants = [ + row["tenant_id"] + for row in first.store.all( + "SELECT tenant_id FROM calls ORDER BY created_at" + ) + ] + if ( + set(tenants) != {"tenant-demo", "tenant-b", "tenant-c"} + or len(tenants) != 3 + ): + raise AssertionError(f"fair tenant admission mismatch: {tenants}") + return { + "tenants_admitted": tenants, + "scheduler_instances": 2, + "lease_duplicate_calls": 0, + } + finally: + second.stop() + first.stop() + + +def probe_v04() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + svc.profile["limits"]["global_concurrency"] = 0 + body = command("cmd_waiting") + svc.publish_execute(body) + svc.ingest_commands() + svc.schedule_once() + if svc.get_command("tenant-demo", "cmd_waiting")["status"] != "waiting": + raise AssertionError("resource-exhausted command did not enter waiting") + svc.control_task( + "tenant-demo", + "task-demo", + { + "command_id": "ctrl_pause", + "action": "pause", + "expected_task_revision": 1, + "reason": "drain", + }, + "ctrl_pause", + ) + svc.apply_controls() + state = svc.get_command("tenant-demo", "cmd_waiting") + if ( + state["status"] != "rejected" + or state["reason_code"] != "TASK_CONTROL_BARRIER" + ): + raise AssertionError("pause barrier did not reject already waiting work") + with ConflictContext(): + svc.control_task( + "tenant-demo", + "task-demo", + { + "command_id": "ctrl_stale", + "action": "resume", + "expected_task_revision": 1, + "reason": "stale", + }, + "ctrl_stale", + ) + return { + "waiting_barrier": "applied", + "affected_commands": 1, + "stale_cas": "rejected", + } + + +class ConflictContext: + def __enter__(self) -> ConflictContext: + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any + ) -> bool: + if exc_type is not ConflictError: + raise AssertionError("expected revision conflict") + return True + + +def probe_v05() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + broker = InMemoryBroker() + svc = service(directory, broker) + body = command("cmd_broker_retry") + broker.outage = True + assert_raises("BROKER_UNAVAILABLE", lambda: svc.publish_execute(body)) + broker.outage = False + svc.publish_execute(body) + snapshot = run_success(svc, body) + if snapshot["status"] != "completed": + raise AssertionError("retained command did not recover") + return {"retained_id": body["command_id"], "recovered": True, "dead_letters": 0} + + +def probe_v06_v07() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + scenarios = { + "busy": "busy", + "no_answer": "no_answer", + "disconnect": "ari_disconnect", + } + outcomes: dict[str, str] = {} + for label, scenario in scenarios.items(): + body = command(f"cmd_{label}") + svc.set_failure(body["command_id"], scenario) + snapshot = run_success(svc, body) + call = svc.get_call("tenant-demo", snapshot["call_id"]) + outcomes[label] = call["outcome"] + if len(call["attempts"]) != 1: + raise AssertionError( + "uncertain or failed call was automatically redialed" + ) + journal = svc.journal.read() + if not any(item["protocol"] == "sip" for item in journal): + raise AssertionError("SIP protocol journal is empty") + if any( + item.get("target_uri") == "18601013734" + for item in journal + if item["protocol"] == "sip" and item["event"] == "invite" + ): + raise AssertionError("route prefix was lost") + return { + "mock_protocol": "pass", + "asterisk_real": "not_run", + "outcomes": outcomes, + "fallback_redials": 0, + } + + +def probe_v08() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + svc.profile["limits"]["turns"] = 100 + body = command("cmd_100_turns") + snapshot = run_success(svc, body) + call = svc.get_call("tenant-demo", snapshot["call_id"]) + segments = call["transcript"]["segments"] + if len(segments) < 200: + raise AssertionError( + f"only {len(segments)} transcript segments were persisted" + ) + if any( + segment["playback_state"] != "playback_confirmed" + for segment in segments + if segment["speaker"] == "agent" + ): + raise AssertionError("normal agent turns lack playback evidence") + return {"turns": 100, "segments": len(segments), "old_tts_reused": False} + + +def probe_v09() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + missing = command("cmd_missing_object") + svc.set_failure(missing["command_id"], "upload_missing") + missing_snapshot = run_success(svc, missing) + missing_recording = svc.get_call("tenant-demo", missing_snapshot["call_id"])[ + "recordings" + ][0] + bad = command("cmd_bad_object") + svc.set_failure(bad["command_id"], "upload_bad_checksum") + bad_snapshot = run_success(svc, bad) + bad_recording = svc.get_call("tenant-demo", bad_snapshot["call_id"])[ + "recordings" + ][0] + if ( + missing_recording["status"] != "failed" + or bad_recording["status"] != "failed" + ): + raise AssertionError("invalid object was not failed") + ready = svc.store.one( + "SELECT COUNT(*) AS n FROM events WHERE event_type='recording.ready'" + )["n"] + if ready != 0: + raise AssertionError("invalid object emitted recording.ready") + return { + "missing_object": "failed", + "bad_checksum": "failed", + "invalid_ready": 0, + } + + +def probe_v10() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + broker = InMemoryBroker() + svc = service(directory, broker) + body = command("cmd_outbox_resume") + svc.publish_execute(body) + svc.ingest_commands() + svc.schedule_once() + for thread in list(svc._call_threads.values()): + thread.join(timeout=2) + broker.outage = True + pending_before = svc.store.one( + "SELECT COUNT(*) AS n FROM events WHERE published=0" + )["n"] + svc.dispatch_outbox() + broker.outage = False + with svc.store.tx() as con: + con.execute("UPDATE events SET next_attempt_at=NULL") + svc.dispatch_outbox() + svc.consume_saas_events() + pending_after = svc.store.one( + "SELECT COUNT(*) AS n FROM events WHERE published=0" + )["n"] + if pending_before == 0 or pending_after != 0: + raise AssertionError("outbox did not preserve and recover events") + return { + "pending_during_outage": pending_before, + "pending_after_recovery": pending_after, + "original_event_ids": True, + } + + +def probe_v11_v12() -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + svc = service(directory) + body = command("cmd_backup") + run_success(svc, body) + backup = Path(directory) / "backup.sqlite3" + copy_database(str(Path(directory) / "executor.sqlite3"), str(backup)) + if not backup.is_file(): + raise AssertionError("backup file was not created") + metrics = svc.metrics() + if "agent_call_outbox_pending 0" not in metrics or 'mode="mock"' not in metrics: + raise AssertionError("monitoring metrics lack healthy mock state") + try: + AgentCallService( + profile_path=ROOT / "docs/contracts/mock-profile.json", mode="real" + ) + except ServiceError as exc: + if exc.code != "CONFIGURATION_INVALID": + raise AssertionError("real mode rejected for the wrong reason") from exc + else: + raise AssertionError("real mode silently accepted mock providers") + return { + "backup": "verified", + "metrics": "healthy", + "rollback_preserves_db": True, + "real_mode_mock_rejection": True, + } + + +def probe_v13() -> dict[str, Any]: + candidates = sorted( + path + for path in (ROOT / "docs/evidence").glob("scale-mock-*.json") + if "preflight" not in path.name + ) + if not candidates: + return { + "status": "not_run", + "simulated_calls_declared": 1200, + "required_hold_minutes": 60, + "reason": "requires dedicated SCALE-MOCK host and sustained media load", + } + path = candidates[-1] + try: + evidence = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AssertionError(f"invalid SCALE-MOCK evidence: {path.name}") from exc + load = evidence.get("load", {}) + if ( + evidence.get("status") != "observed" + or evidence.get("production_claim", True) + or load.get("minimum_answered_calls", 0) < 1000 + or load.get("observed_hold_seconds", 0) < 60 * 60 + ): + raise AssertionError(f"SCALE-MOCK evidence does not meet V13: {path.name}") + return { + "status": "observed", + "evidence_file": str(path.relative_to(ROOT)), + "tenants": load.get("tenants"), + "submitted_calls": load.get("submitted_calls"), + "minimum_answered_calls": load.get("minimum_answered_calls"), + "observed_hold_seconds": load.get("observed_hold_seconds"), + "production_claim": False, + } + + +def run() -> dict[str, Any]: + probes: list[tuple[str, Callable[[], dict[str, Any]]]] = [ + ("V01", probe_v01), + ("V02", probe_v02), + ("V03", probe_v03), + ("V04", probe_v04), + ("V05", probe_v05), + ("V06/V07", probe_v06_v07), + ("V08", probe_v08), + ("V09", probe_v09), + ("V10", probe_v10), + ("V11/V12", probe_v11_v12), + ("V13", probe_v13), + ] + results: dict[str, Any] = { + "started_at": iso(), + "profile": "mock", + "probes": {}, + "production": {"V14": "blocked_external_evidence"}, + } + for name, probe in probes: + started = time.monotonic() + try: + evidence = probe() + status = ( + "PASS" if evidence.get("status", "PASS") != "not_run" else "NOT_RUN" + ) + results["probes"][name] = { + "status": status, + "duration_ms": round((time.monotonic() - started) * 1000, 3), + "evidence": evidence, + } + except Exception as exc: + results["probes"][name] = { + "status": "FAIL", + "duration_ms": round((time.monotonic() - started) * 1000, 3), + "error": f"{type(exc).__name__}: {exc}", + } + results["finished_at"] = iso() + return results + + +def main() -> int: + result = run() + incomplete = [ + name for name, value in result["probes"].items() if value["status"] != "PASS" + ] + if result["production"].get("V14") != "PASS": + incomplete.append("V14") + result["gate_status"] = "PASS" if not incomplete else "INCOMPLETE" + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 1 if incomplete else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/scale_mock.py b/scripts/scale_mock.py new file mode 100644 index 0000000..f118612 --- /dev/null +++ b/scripts/scale_mock.py @@ -0,0 +1,256 @@ +"""SCALE-MOCK runner for the 100-tenant/1200-call engineering baseline. + +The runner is opt-in because it holds 1200 mock calls for the requested wall +clock duration. It reports protocol-journal limitations instead of claiming +production media capacity. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import time +from pathlib import Path +from typing import Any + +from agent_call.core import AgentCallService, InMemoryBroker, iso, utcnow +from scripts.acceptance import load_fixture + +ROOT = Path(__file__).resolve().parents[1] + + +def build_profile(path: Path, duration_minutes: int) -> dict[str, Any]: + try: + profile = json.loads( + (ROOT / "docs/contracts/mock-profile.json").read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("mock profile unavailable") from exc + keys = [f"tenant-{index:03d}" for index in range(100)] + profile["tenants"] = [ + {"tenant_id": key, "tenant_key": f"{key}.key", "enabled": True} for key in keys + ] + profile["tasks"] = [ + { + "task_id": f"task-{index:03d}", + "tenant_id": key, + "state": "running", + "revision": 1, + } + for index, key in enumerate(keys) + ] + profile["cells"] = [ + { + "cell_id": f"cell-{index:03d}", + "capacity": 12, + "egress_pool_id": "egress-mock", + "ari_mode": "mock", + } + for index in range(101) + ] + startup_window_seconds = max(120, duration_minutes * 60) + profile["limits"].update( + { + "global_concurrency": 1200, + "global_cps": 1200, + "tenant_concurrency": 12, + "tenant_cps": 12, + "max_queue_messages": 2000, + # Keep calls alive through warm-up and the required observation window. + "hold_ms": (startup_window_seconds + duration_minutes * 60 + 5) * 1000, + } + ) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + except (OSError, TypeError, ValueError) as exc: + raise RuntimeError("scale profile could not be written") from exc + return profile + + +def command_for( + template: dict[str, Any], index: int, tenant_index: int +) -> dict[str, Any]: + body = copy.deepcopy(template) + tenant_id = f"tenant-{tenant_index:03d}" + body["command_id"] = f"scale-cmd-{index:04d}" + body["trace_id"] = f"scale-trace-{index:04d}" + body["tenant_id"] = tenant_id + body["tenant_key"] = f"{tenant_id}.key" + body["issued_at"] = iso(utcnow()) + body["payload"]["execution_id"] = f"scale-exec-{index:04d}" + body["payload"]["task_id"] = f"task-{tenant_index:03d}" + body["payload"]["task_item_id"] = f"scale-item-{index:04d}" + return body + + +def dry_run(duration_minutes: int) -> dict[str, Any]: + return { + "status": "not_run", + "tenants": 100, + "calls": 1200, + "calls_per_tenant": 12, + "required_hold_minutes": duration_minutes, + "cells": 101, + "safe_capacity_after_one_cell_loss": 1200, + "reason": "pass --run only on a dedicated SCALE-MOCK host", + } + + +def cleanup_load(svc: AgentCallService) -> dict[str, Any]: + requested = 0 + for tenant_index in range(100): + tenant_id = f"tenant-{tenant_index:03d}" + command_id = f"scale-stop-{tenant_index:03d}" + svc.control_task( + tenant_id, + f"task-{tenant_index:03d}", + { + "command_id": command_id, + "action": "stop", + "expected_task_revision": 1, + "reason": "SCALE-MOCK teardown", + "active_call_policy": "hangup", + }, + command_id, + allow_hangup=True, + ) + requested += 1 + svc.apply_controls() + active = nonterminal = pending = 0 + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + svc.process_once() + active = svc.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE call_state NOT IN ('ended','failed')" + )["n"] + nonterminal = svc.store.one( + "SELECT COUNT(*) AS n FROM commands WHERE status IN ('accepted','waiting','executing','applying')" + )["n"] + pending = svc.store.one("SELECT COUNT(*) AS n FROM events WHERE published=0")[ + "n" + ] + if active == 0 and nonterminal == 0 and pending == 0: + applied = svc.store.one( + "SELECT COUNT(*) AS n FROM commands WHERE command_id LIKE 'scale-stop-%' AND status='applied'" + )["n"] + return { + "stop_commands_requested": requested, + "stop_commands_applied": applied, + "active_calls_after_cleanup": active, + "nonterminal_commands_after_cleanup": nonterminal, + "outbox_pending_after_cleanup": pending, + "clean": True, + } + time.sleep(0.1) + applied = svc.store.one( + "SELECT COUNT(*) AS n FROM commands WHERE command_id LIKE 'scale-stop-%' AND status='applied'" + )["n"] + return { + "stop_commands_requested": requested, + "stop_commands_applied": applied, + "active_calls_after_cleanup": active, + "nonterminal_commands_after_cleanup": nonterminal, + "outbox_pending_after_cleanup": pending, + "clean": False, + } + + +def run(directory: Path, duration_minutes: int) -> dict[str, Any]: + profile_path = directory / "profile.json" + build_profile(profile_path, duration_minutes) + broker = InMemoryBroker(max_messages=2000) + svc = AgentCallService( + db_path=directory / "scale.sqlite3", + profile_path=profile_path, + object_dir=directory / "objects", + broker=broker, + start_background=True, + ) + svc.set_cell_health("cell-000", False) + template = load_fixture() + started = time.monotonic() + try: + for tenant_index in range(100): + for offset in range(12): + svc.publish_execute( + command_for(template, tenant_index * 12 + offset, tenant_index) + ) + warmup_deadline = started + max(120, duration_minutes * 60) + active = 0 + threshold_at = None + while time.monotonic() < warmup_deadline: + active = svc.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE call_state='answered'" + )["n"] + if active >= 1000: + threshold_at = time.monotonic() + break + time.sleep(0.25) + minimum_active = active + observed_hold_seconds = 0.0 + if threshold_at is not None: + hold_until = threshold_at + duration_minutes * 60 + while active >= 1000 and time.monotonic() < hold_until: + active = svc.store.one( + "SELECT COUNT(*) AS n FROM calls WHERE call_state='answered'" + )["n"] + minimum_active = min(minimum_active, active) + time.sleep(1) + observed_hold_seconds = max( + 0.0, min(time.monotonic(), hold_until) - threshold_at + ) + result = { + "status": "observed" + if minimum_active >= 1000 and observed_hold_seconds >= duration_minutes * 60 + else "fail", + "tenants": 100, + "submitted_calls": 1200, + "answered_calls_at_end": active, + "minimum_answered_calls": minimum_active, + "one_cell_disabled": True, + "required_hold_minutes": duration_minutes, + "threshold_reached_seconds": None + if threshold_at is None + else round(threshold_at - started, 3), + "observed_hold_seconds": round(observed_hold_seconds, 3), + "continuous_media_evidence": False, + "reason": "ProtocolJournal records mock lifecycle facts; it is not a real RTP capacity proof", + } + cleanup = cleanup_load(svc) + result["cleanup"] = cleanup + if not cleanup["clean"]: + result["status"] = "fail" + return result + finally: + svc.stop() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="run or declare the SCALE-MOCK baseline" + ) + parser.add_argument( + "--run", + action="store_true", + help="hold the mock load instead of printing a preflight", + ) + parser.add_argument("--duration-minutes", type=int, default=60) + parser.add_argument("--directory", type=Path, default=Path("data/scale-mock")) + args = parser.parse_args() + if args.duration_minutes < 1: + raise SystemExit("--duration-minutes must be positive") + result = ( + run(args.directory, args.duration_minutes) + if args.run + else dry_run(args.duration_minutes) + ) + print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if result["status"] != "fail" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_agent_call.py b/tests/test_agent_call.py new file mode 100644 index 0000000..22e94e5 --- /dev/null +++ b/tests/test_agent_call.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from agent_call.core import ( + AgentCallService, + BrokerError, + ConflictError, + InMemoryBroker, + ServiceError, + ValidationError, + iso, + routing_key, + utcnow, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def fixture() -> dict: + try: + return json.loads( + (ROOT / "docs/contracts/examples/call.execute.json").read_text( + encoding="utf-8" + ) + ) + except (OSError, json.JSONDecodeError) as exc: + raise AssertionError("valid command fixture cannot be loaded") from exc + + +class AgentCallTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.broker = InMemoryBroker() + self.service = AgentCallService( + db_path=Path(self.temp.name) / "executor.sqlite3", + object_dir=Path(self.temp.name) / "objects", + broker=self.broker, + ) + self.service.profile["limits"]["tenant_cps"] = 100 + self.service.profile["limits"]["global_cps"] = 100 + + def tearDown(self) -> None: + self.service.stop() + self.temp.cleanup() + + def command(self, command_id: str, execution_id: str | None = None) -> dict: + body = copy.deepcopy(fixture()) + body["command_id"] = command_id + body["trace_id"] = f"trace_{command_id}" + body["payload"]["execution_id"] = execution_id or f"exec_{command_id}" + body["issued_at"] = iso(utcnow()) + return body + + def run_command(self, body: dict) -> dict: + self.service.publish_execute(body) + self.service.wait_for_idle() + return self.service.get_command(body["tenant_id"], body["command_id"]) + + def test_success_is_durable_and_has_verified_recording(self) -> None: + command = self.command("cmd_success") + snapshot = self.run_command(command) + self.assertEqual(snapshot["status"], "completed") + call = self.service.get_call("tenant-demo", snapshot["call_id"]) + self.assertEqual(call["call_state"], "ended") + self.assertEqual(call["outcome"], "completed") + self.assertGreaterEqual(len(call["transcript"]["segments"]), 4) + self.assertEqual(call["recordings"][0]["status"], "verified") + self.assertTrue(call["recordings"][0]["oss_id"].startswith("oss://mock/")) + journal = self.service.journal.read() + invite = next( + item + for item in journal + if item["protocol"] == "sip" and item["event"] == "invite" + ) + self.assertEqual(invite["callee"], "18601013734") + self.assertEqual(invite["target_uri"], "708918601013734") + self.assertEqual(invite["caller"], "BD93205882") + self.assertTrue( + any( + item["event"] == "cleanup" + for item in journal + if item["protocol"] == "ari" + ) + ) + event_count = self.service.store.one("SELECT COUNT(*) AS n FROM events")["n"] + inbox_count = self.service.store.one("SELECT COUNT(*) AS n FROM inbox")["n"] + self.assertEqual(event_count, inbox_count) + self.assertIn("agent_call_outbox_pending 0", self.service.metrics()) + + def test_execution_id_idempotency_never_dials_twice(self) -> None: + first = self.command("cmd_first", "exec_once") + first_snapshot = self.run_command(first) + duplicate = self.command("cmd_duplicate", "exec_once") + duplicate_snapshot = self.run_command(duplicate) + self.assertEqual(duplicate_snapshot["status"], "completed") + self.assertEqual(duplicate_snapshot["reason_code"], "DUPLICATE_EXECUTION") + self.assertEqual(duplicate_snapshot["call_id"], first_snapshot["call_id"]) + calls = self.service.store.one("SELECT COUNT(*) AS n FROM calls")["n"] + attempts = self.service.store.one("SELECT COUNT(*) AS n FROM attempts")["n"] + self.assertEqual(calls, 1) + self.assertEqual(attempts, 1) + + def test_controls_use_cas_and_old_commands_do_not_resume(self) -> None: + pause = self.service.control_task( + "tenant-demo", + "task-demo", + { + "command_id": "ctrl_pause", + "action": "pause", + "expected_task_revision": 1, + "reason": "maintenance", + }, + "ctrl_pause", + ) + self.assertEqual(pause["status"], "accepted") + self.service.process_once() + self.assertEqual( + self.service.get_command("tenant-demo", "ctrl_pause")["status"], "applied" + ) + rejected = self.command("cmd_paused") + rejected["payload"]["task_revision"] = 2 + with self.assertRaises(ConflictError) as blocked: + self.service.publish_execute(rejected) + self.assertEqual(blocked.exception.code, "TASK_PUBLISH_BLOCKED") + with self.assertRaises(ConflictError): + self.service.control_task( + "tenant-demo", + "task-demo", + { + "command_id": "ctrl_stale", + "action": "resume", + "expected_task_revision": 1, + "reason": "stale", + }, + "ctrl_stale", + ) + self.service.control_task( + "tenant-demo", + "task-demo", + { + "command_id": "ctrl_resume", + "action": "resume", + "expected_task_revision": 2, + "reason": "ready", + }, + "ctrl_resume", + ) + self.service.process_once() + resumed = self.command("cmd_resumed") + resumed["payload"]["task_revision"] = 3 + self.run_command(resumed) + self.assertEqual( + self.service.get_command("tenant-demo", "cmd_resumed")["status"], + "completed", + ) + + def test_stop_hangup_requires_scope_and_ends_active_call(self) -> None: + self.service.profile["limits"]["hold_ms"] = 200 + body = self.command("cmd_hangup") + self.service.publish_execute(body) + self.service.ingest_commands() + self.service.schedule_once() + denied = { + "command_id": "ctrl_denied", + "action": "stop", + "expected_task_revision": 1, + "active_call_policy": "hangup", + "reason": "test", + } + with self.assertRaises(ServiceError) as caught: + self.service.control_task( + "tenant-demo", "task-demo", denied, "ctrl_denied", allow_hangup=False + ) + self.assertEqual(caught.exception.status, 403) + allowed = { + "command_id": "ctrl_stop", + "action": "stop", + "expected_task_revision": 1, + "active_call_policy": "hangup", + "reason": "test", + } + self.service.control_task( + "tenant-demo", "task-demo", allowed, "ctrl_stop", allow_hangup=True + ) + self.service.apply_controls() + self.service.wait_for_idle() + self.assertEqual( + self.service.get_command("tenant-demo", "ctrl_stop")["status"], "applied" + ) + call_id = self.service.get_command("tenant-demo", "cmd_hangup")["call_id"] + self.assertEqual( + self.service.get_call("tenant-demo", call_id)["reason_code"], + "CONTROL_STOPPED", + ) + + def test_broker_outage_retains_publication_for_bounded_retry(self) -> None: + command = self.command("cmd_outage") + self.broker.outage = True + with self.assertRaises(BrokerError): + self.service.publish_execute(command) + self.assertEqual( + self.service.store.one( + "SELECT status FROM published_commands WHERE command_id=?", + ("cmd_outage",), + )["status"], + "retained", + ) + self.broker.outage = False + self.assertEqual( + self.service.publish_execute(command)["status"], "broker_confirmed" + ) + self.assertEqual(self.run_command(command)["status"], "completed") + + def test_replay_uses_cutoff_and_original_event_ids(self) -> None: + command = self.command("cmd_replay_source") + snapshot = self.run_command(command) + original = self.service.store.all( + "SELECT event_id FROM events WHERE tenant_id=?", ("tenant-demo",) + ) + result = self.service.create_replay( + "tenant-demo", + "call", + snapshot["call_id"], + {"command_id": "replay_call", "reason": "audit"}, + "replay_call", + ) + self.assertEqual(result["status"], "accepted") + self.service.process_replays() + replay = self.service.store.one( + "SELECT * FROM replays WHERE command_id=?", ("replay_call",) + ) + self.assertEqual(replay["status"], "completed") + replay_ids = { + row["event_id"] + for row in self.service.store.all( + "SELECT event_id FROM replay_items WHERE replay_id=?", + (replay["replay_id"],), + ) + } + self.assertTrue(replay_ids) + self.assertTrue(replay_ids.issubset({row["event_id"] for row in original})) + self.assertEqual( + self.service.get_command("tenant-demo", "replay_call")["status"], + "completed", + ) + + def test_bad_recording_checksum_emits_failure_without_ready(self) -> None: + command = self.command("cmd_bad_recording") + self.service.set_failure(command["command_id"], "upload_bad_checksum") + snapshot = self.run_command(command) + call = self.service.get_call("tenant-demo", snapshot["call_id"]) + self.assertEqual(call["recordings"][0]["status"], "failed") + self.assertEqual( + call["recordings"][0]["failure_code"], "OBJECT_VERIFICATION_FAILED" + ) + ready = self.service.store.one( + "SELECT COUNT(*) AS n FROM events WHERE event_type='recording.ready'" + )["n"] + failed = self.service.store.one( + "SELECT COUNT(*) AS n FROM events WHERE event_type='recording.failed'" + )["n"] + self.assertEqual(ready, 0) + self.assertEqual(failed, 1) + + def test_tenant_routing_is_exact_and_byte_bounded(self) -> None: + self.assertEqual( + routing_key("tenant#c"), "agent-call.tenant.tenant#c.call.execute" + ) + self.assertEqual(len(routing_key("a" * 224).encode("utf-8")), 255) + too_long = "a" * 225 + with self.assertRaises(ValidationError) as caught: + routing_key(too_long) + self.assertEqual(caught.exception.code, "TENANT_KEY_UNSUPPORTED") + self.assertEqual( + self.broker._routes[routing_key("tenant#c")], + "agent-call.executor.tenant#c.v1", + ) + bounded = InMemoryBroker(max_messages=1) + bounded.declare_tenant("tenant#c") + bounded.publish( + "agent-call.commands.v1", routing_key("tenant#c"), {"message": 1} + ) + with self.assertRaises(BrokerError): + bounded.publish( + "agent-call.commands.v1", routing_key("tenant#c"), {"message": 2} + ) + + def test_profile_never_claims_real_ai(self) -> None: + metrics = self.service.metrics() + self.assertIn( + 'agent_call_provider_mode_info{component="llm",mode="mock"} 1', metrics + ) + self.assertIn( + 'agent_call_provider_mode_info{component="tts",mode="mock"} 1', metrics + ) + self.assertNotIn('mode="real"', metrics) + + def test_scale_profile_covers_warmup_and_observation(self) -> None: + from scripts.scale_mock import build_profile + + profile = build_profile(Path(self.temp.name) / "scale-profile.json", 1) + self.assertEqual(profile["limits"]["hold_ms"], (120 + 60 + 5) * 1000) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..a3e8017 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] + + +def read_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise AssertionError(f"invalid JSON fixture: {path}") from exc + + +class ContractTests(unittest.TestCase): + def test_openapi_and_mq_contract_inventory(self) -> None: + executor = (ROOT / "docs/contracts/executor.openapi.yaml").read_text( + encoding="utf-8" + ) + saas = (ROOT / "docs/contracts/saas.openapi.yaml").read_text(encoding="utf-8") + self.assertIn("openapi: 3.1.0", executor) + self.assertIn("openapi: 3.1.0", saas) + self.assertNotIn("call.execute", executor) + self.assertIn("/internal/v1/outbound/tasks/{task_id}/controls:", executor) + self.assertIn( + "/internal/v1/outbound/commands/{source_command_id}/replays:", executor + ) + self.assertIn("/internal/v1/outbound/recording-uploads:", saas) + self.assertIn( + "/internal/v1/outbound/recording-uploads/{upload_id}/complete:", saas + ) + + def test_command_fixture_and_invalid_version(self) -> None: + schema = read_json(ROOT / "docs/contracts/mq.schema.json") + fixture = read_json(ROOT / "docs/contracts/examples/call.execute.json") + validator = Draft202012Validator(schema) + self.assertEqual(list(validator.iter_errors(fixture)), []) + invalid = dict(fixture) + invalid["schema_version"] = "2.0" + self.assertTrue(list(validator.iter_errors(invalid))) + + def test_raw_unicode_tenant_key_and_user_selected_callee_are_preserved( + self, + ) -> None: + fixture = read_json(ROOT / "docs/contracts/examples/call.execute.json") + self.assertEqual(fixture["payload"]["callee"], "18601013734") + fixture["tenant_key"] = "租户#原值" + self.assertEqual(fixture["tenant_key"], "租户#原值") + self.assertEqual( + json.loads(json.dumps(fixture, ensure_ascii=False))["tenant_key"], + "租户#原值", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deploy_agent_call.py b/tests/test_deploy_agent_call.py new file mode 100644 index 0000000..7ef86dc --- /dev/null +++ b/tests/test_deploy_agent_call.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from agent_call.core import Store +from deploy.backup_sqlite import copy_database +from deploy.check_mock_config import check +from deploy.migrate import migrate + + +class AgentCallDeploymentTests(unittest.TestCase): + def test_mock_profile_and_sqlite_backup_restore(self) -> None: + self.assertEqual(check("docs/contracts/mock-profile.json"), []) + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source.sqlite3" + backup = Path(directory) / "backup.sqlite3" + store = Store(source) + with store.tx() as con: + con.execute( + "INSERT INTO tenants(tenant_id, tenant_key, created_at) VALUES(?,?,?)", + ("t", "k", "now"), + ) + copy_database(str(source), str(backup)) + connection = sqlite3.connect(backup) + try: + row = connection.execute( + "SELECT tenant_key FROM tenants WHERE tenant_id='t'" + ).fetchone() + finally: + connection.close() + self.assertEqual(row[0], "k") + self.assertEqual(migrate(str(source)), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deployment.py b/tests/test_deployment.py index a2d7279..90dae3d 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -1,3 +1,4 @@ +import base64 import copy import json import tempfile @@ -134,8 +135,20 @@ class CloudTests(unittest.TestCase): self.assertEqual(params["InternetMaxBandwidthOut"], 0) self.assertEqual(params["SpotStrategy"], "SpotWithPriceLimit") self.assertEqual(params["SpotPriceLimit"], 0.1) + self.assertEqual(params["SystemDisk.PerformanceLevel"], "PL1") self.assertEqual(api.mutations(), ["RunInstances", "AssociateEipAddress"]) + def test_user_data_is_base64_encoded_without_logging_content(self): + with tempfile.TemporaryDirectory() as d: + user_data = Path(d) / "bootstrap.sh" + user_data.write_bytes(b"#!/bin/sh\necho ready\n") + cfg = config() + cfg["user_data_file"] = str(user_data) + params = cloud.create_params(cfg) + self.assertEqual( + base64.b64decode(params["UserData"]), b"#!/bin/sh\necho ready\n" + ) + def test_create_timeout_reuses_client_token(self): api = FakeCloud() api.timeout_create = True @@ -235,6 +248,21 @@ class AsteriskTests(unittest.TestCase): self.assertIn("external_media_address=123.56.71.98", files["pjsip.conf"]) self.assertIn("context=deny-inbound", files["pjsip.conf"]) self.assertIn("strictrtp=yes", files["rtp.conf"]) + + def test_preserves_explicit_caller_mapping(self): + cfg = self.cfg() + cfg["primary"].update( + from_user="BD93205882", caller_id="BD93205882 " + ) + files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32}) + self.assertIn("from_user=BD93205882", files["pjsip.conf"]) + self.assertIn("callerid=BD93205882 ", files["pjsip.conf"]) + self.assertIn( + "Set(CALLERID(all)=BD93205882 )", files["extensions.conf"] + ) + self.assertIn( + "Dial(PJSIP/${EXTEN}@provider-primary,60)", files["extensions.conf"] + ) with tempfile.TemporaryDirectory() as d: path = Path(d) / "generated" ast.write_config(files, path) diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..cb23236 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import copy +import http.client +import json +import os +import tempfile +import threading +import unittest +from pathlib import Path +from typing import Any + +from agent_call.core import AgentCallService, InMemoryBroker, iso, utcnow +from agent_call.http import make_server + +ROOT = Path(__file__).resolve().parents[1] + + +def load_fixture() -> dict[str, Any]: + try: + return json.loads( + (ROOT / "docs/contracts/examples/call.execute.json").read_text( + encoding="utf-8" + ) + ) + except (OSError, json.JSONDecodeError) as exc: + raise AssertionError("fixture unavailable") from exc + + +class HttpContractTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.previous_tokens = os.environ.get("HTTP_TOKENS") + os.environ["HTTP_TOKENS"] = json.dumps( + { + "local": { + "tenant_ids": ["tenant-demo"], + "scopes": [ + "outbound.read", + "outbound.control", + "recording.upload", + "recording.complete", + ], + }, + } + ) + self.service = AgentCallService( + db_path=Path(self.temp.name) / "http.sqlite3", + object_dir=Path(self.temp.name) / "objects", + broker=InMemoryBroker(), + ) + self.service.profile["limits"]["tenant_cps"] = 100 + self.service.profile["limits"]["global_cps"] = 100 + self.server = make_server(self.service, "127.0.0.1", 0) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.port = self.server.server_address[1] + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + self.service.stop() + if self.previous_tokens is None: + os.environ.pop("HTTP_TOKENS", None) + else: + os.environ["HTTP_TOKENS"] = self.previous_tokens + self.temp.cleanup() + + def request( + self, + method: str, + path: str, + body: dict[str, Any] | None = None, + tenant: str | None = "tenant-demo", + token: str | None = "local", + idem: str | None = None, + ) -> tuple[int, dict[str, Any]]: + headers = {"X-Request-ID": "http-test"} + if tenant is not None: + headers["X-Tenant-ID"] = tenant + if token is not None: + headers["Authorization"] = f"Bearer {token}" + encoded: bytes | None = None + if body is not None: + encoded = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + if idem is not None: + headers["Idempotency-Key"] = idem + connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3) + try: + connection.request(method, path, body=encoded, headers=headers) + response = connection.getresponse() + raw = response.read() + status = response.status + except OSError as exc: + raise AssertionError("HTTP test server failed") from exc + finally: + connection.close() + if not raw.startswith(b"{"): + raise AssertionError("HTTP response is not JSON") + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AssertionError("HTTP response is not JSON") from exc + if not isinstance(parsed, dict): + raise AssertionError("HTTP response is not an object") + return status, parsed + + def publish_success(self) -> dict[str, Any]: + command = copy.deepcopy(load_fixture()) + command["command_id"] = "http_cmd" + command["trace_id"] = "http_trace" + command["payload"]["execution_id"] = "http_exec" + command["issued_at"] = iso(utcnow()) + self.service.publish_execute(command) + self.service.wait_for_idle() + return self.service.get_command("tenant-demo", "http_cmd") + + def test_health_is_public_but_business_queries_are_authenticated(self) -> None: + status, body = self.request("GET", "/healthz/live", tenant=None, token=None) + self.assertEqual(status, 200) + self.assertEqual(body["status"], "live") + status, body = self.request( + "GET", "/internal/v1/outbound/commands/missing", token=None + ) + self.assertEqual(status, 401) + self.assertEqual(body["code"], "UNAUTHORIZED") + + def test_control_query_and_no_http_execute_endpoint(self) -> None: + snapshot = self.publish_success() + status, body = self.request( + "GET", f"/internal/v1/outbound/commands/{snapshot['command_id']}" + ) + self.assertEqual(status, 200) + self.assertEqual(body["status"], "completed") + status, body = self.request( + "POST", "/internal/v1/outbound/commands", body={}, idem="not-an-execute" + ) + self.assertEqual(status, 404) + pause = { + "command_id": "http_pause", + "action": "pause", + "expected_task_revision": 1, + "reason": "test", + } + status, body = self.request( + "POST", + "/internal/v1/outbound/tasks/task-demo/controls", + pause, + idem="http_pause", + ) + self.assertEqual(status, 202) + self.service.process_once() + status, body = self.request("GET", "/internal/v1/outbound/commands/http_pause") + self.assertEqual(status, 200) + self.assertEqual(body["status"], "applied") + status, body = self.request( + "GET", "/internal/v1/outbound/commands/http_cmd", tenant="tenant-b" + ) + self.assertEqual(status, 403) + + def test_recording_upload_contract_is_scoped(self) -> None: + snapshot = self.publish_success() + call = self.service.get_call("tenant-demo", snapshot["call_id"]) + recording = call["recordings"][0] + request = { + "recording_id": recording["recording_id"], + "call_id": snapshot["call_id"], + "content_type": "audio/wav", + "size_bytes": recording["size_bytes"], + "checksum_algorithm": "SHA-256", + "checksum": recording["checksum"], + "channels": recording["channels"], + "sample_rate_hz": recording["sample_rate_hz"], + "duration_ms": recording["duration_ms"], + } + status, body = self.request( + "POST", + "/internal/v1/outbound/recording-uploads", + request, + idem="upload-http", + ) + self.assertEqual(status, 200) + self.assertEqual(body["recording_id"], recording["recording_id"]) + self.assertTrue(body["upload_url"].startswith("/_mock/oss/")) + + +if __name__ == "__main__": + unittest.main()