feat(agent-call): add contract-driven mock executor
This commit is contained in:
@@ -9,6 +9,7 @@ __pycache__/
|
||||
.codegraph/
|
||||
.local/
|
||||
deploy/state/
|
||||
data/*.sqlite3*
|
||||
deploy/asterisk/generated/
|
||||
services/asr-web/asr-web
|
||||
*.test
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
| 被叫前缀 | `7089` |
|
||||
| 我方出口白名单 IP | `123.56.71.98` |
|
||||
|
||||
## 测试呼叫号码
|
||||
|
||||
- 用户指定测试被叫号码:`18601013734`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。
|
||||
|
||||
- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。
|
||||
- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。
|
||||
- 传输协议、IP/Digest 鉴权、是否注册、编解码及并发限制仍需供应商确认;当前生成器的 UDP/ulaw 基线不代表这些参数已经确认。
|
||||
|
||||
+19
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Contract-driven mock execution service for agent-call."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2690
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
-1
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
+47
-12
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
acceptance_gate_exit=1
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 通话。
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
mock profile clean
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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及副作用正确性仍阻塞,现有产物不得据此宣称生产完成。
|
||||
|
||||
+48
-14
@@ -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验收。
|
||||
|
||||
在供应商规则确认前,不再次拨打真实号码,也不声称完成整个平台。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
jsonschema>=4.20,<5
|
||||
pika>=1.3,<2
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational scripts for agent-call."""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 <BD93205882>"
|
||||
)
|
||||
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
||||
self.assertIn("from_user=BD93205882", files["pjsip.conf"])
|
||||
self.assertIn("callerid=BD93205882 <BD93205882>", files["pjsip.conf"])
|
||||
self.assertIn(
|
||||
"Set(CALLERID(all)=BD93205882 <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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user