diff --git a/agent_call/real_cell.py b/agent_call/real_cell.py index 8988349..5bc8798 100644 --- a/agent_call/real_cell.py +++ b/agent_call/real_cell.py @@ -11,6 +11,7 @@ from __future__ import annotations import base64 import contextlib import http.client +import io import ipaddress import json import os @@ -23,6 +24,7 @@ import struct import threading import time import uuid +import wave from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -618,6 +620,8 @@ class RealCellCall: ) self.target_channel_id = self._channel_id(target) self._add_when_ready(self.target_channel_id) + self._wait_answer(result) + result.connected = True self.ari.request( "POST", f"bridges/{self.bridge_id}/record", @@ -628,8 +632,6 @@ class RealCellCall: "beepEnabled": False, }, ) - self._wait_answer(result) - result.connected = True opening = str(self.engine.config["conversation"].get("opening", "")) if opening: opening_result = self.engine.speak(opening, f"opening_{self.call_id}") @@ -1016,6 +1018,13 @@ class RealCellCall: return None if not isinstance(raw, bytes) or not raw: return None + try: + with wave.open(io.BytesIO(raw), "rb") as source: + frames = source.getnframes() + except (EOFError, OSError, wave.Error): + return None + if not frames > 0: + return None path = Path(self.config.recording_dir) / f"{self.recording_name}.wav" try: if path.is_symlink(): diff --git a/docs/contracts/executor.openapi.yaml b/docs/contracts/executor.openapi.yaml index 00e95d9..a9f02fb 100644 --- a/docs/contracts/executor.openapi.yaml +++ b/docs/contracts/executor.openapi.yaml @@ -263,6 +263,18 @@ components: recordings: {type: array, items: {type: object}} delivery: {type: object} snapshot_at: {type: string, format: date-time} + 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} responses: Unauthorized: description: Unauthorized @@ -289,15 +301,3 @@ components: content: application/problem+json: schema: {$ref: '#/components/schemas/Problem'} - Problem: - type: object - additionalProperties: false - required: [type, title, status, code, detail, request_id, retryable] - properties: - type: {type: string, format: uri-reference} - title: {type: string} - status: {type: integer} - code: {type: string} - detail: {type: string} - request_id: {type: string} - retryable: {type: boolean} diff --git a/docs/contracts/sip-management.openapi.yaml b/docs/contracts/sip-management.openapi.yaml index 0bf0bd6..28fee2b 100644 --- a/docs/contracts/sip-management.openapi.yaml +++ b/docs/contracts/sip-management.openapi.yaml @@ -1,3 +1,4 @@ +--- # yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema openapi: 3.1.0 info: @@ -16,8 +17,12 @@ servers: description: SaaS read-only service network tags: - name: health + - name: admin-providers - name: admin-trunks - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit - name: saas-readonly paths: /healthz/live: @@ -31,11 +36,69 @@ paths: application/json: schema: $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} /admin/v1/trunks: get: tags: [admin-trunks] operationId: listAdminTrunks security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} responses: '200': description: All Trunks, including unpublished revisions @@ -88,6 +151,54 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '401': {$ref: '#/components/responses/Unauthorized'} '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} /admin/v1/trunks/{trunk_id}/publish: parameters: - {$ref: '#/components/parameters/TrunkId'} @@ -220,6 +331,18 @@ paths: /admin/v1/cells/{cell_id}: parameters: - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} put: tags: [admin-cells] operationId: registerCell @@ -245,6 +368,206 @@ paths: schema: {$ref: '#/components/schemas/Cell'} '401': {$ref: '#/components/responses/Unauthorized'} '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} + - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} /readonly/v1/sip/trunks: get: tags: [saas-readonly] @@ -290,6 +613,11 @@ components: Dedicated SaaS read-only credential. It cannot publish, modify, disable, rollback, or access Cell management. parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} TrunkId: name: trunk_id in: path @@ -306,6 +634,81 @@ components: required: true description: Exact latest revision required for CAS; quotes are accepted. schema: {type: integer, minimum: 0} + CellIdsFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} RequestId: name: X-Request-ID in: header @@ -341,6 +744,9 @@ components: Mode: type: string enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] Health: type: object additionalProperties: false @@ -374,10 +780,31 @@ components: writeOnly: true description: >- Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} TrunkConfig: type: object additionalProperties: false required: + - provider_id - display_name - enabled - sip @@ -388,6 +815,9 @@ components: - max_concurrency - max_cps properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' display_name: {type: string, minLength: 1, maxLength: 256} enabled: {type: boolean} sip: {$ref: '#/components/schemas/SipConfig'} @@ -432,6 +862,7 @@ components: properties: revision: {type: integer, minimum: 1} state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} created_at: {type: string, format: date-time} created_by: {type: string} AdminTrunk: @@ -439,8 +870,10 @@ components: required: - mode - trunk_id + - provider_id - latest_revision - active_revision + - active_status - status - compatible_cell_ids - latest @@ -449,8 +882,10 @@ components: properties: mode: {$ref: '#/components/schemas/Mode'} trunk_id: {type: string} + provider_id: {type: string} latest_revision: {type: integer, minimum: 1} active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} status: {type: string, enum: [draft, published, disabled]} updated_at: {type: string, format: date-time} compatible_cell_ids: {type: array, items: {type: string}} @@ -470,17 +905,20 @@ components: asterisk_allow: type: array items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} ReadonlyTrunk: type: object required: - mode - trunk_id + - provider_id - revision - status - config properties: mode: {$ref: '#/components/schemas/Mode'} trunk_id: {type: string} + provider_id: {type: string} revision: {type: integer, minimum: 1} status: {type: string, const: published} updated_at: {type: string, format: date-time} @@ -508,6 +946,10 @@ components: cell_id: {type: string} revision: {type: integer, minimum: 1} config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} updated_at: {type: string, format: date-time} updated_by: {type: string} Publication: @@ -519,6 +961,11 @@ components: cell_id: {type: string} status: {type: string, enum: [pending, applied, failed]} error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} updated_at: {type: string, format: date-time} AuditEntry: type: object @@ -552,6 +999,118 @@ components: request_id: {type: [string, 'null']} details_json: {type: string} created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} ErrorResponse: type: object required: [error] @@ -562,3 +1121,5 @@ components: properties: code: {type: string} message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/management/contracts/manifest.json b/management/contracts/manifest.json index e0001dd..71d18b0 100644 --- a/management/contracts/manifest.json +++ b/management/contracts/manifest.json @@ -3,7 +3,7 @@ "files": { "sip-management.openapi.yaml": { "source_path": "docs/contracts/sip-management.openapi.yaml", - "sha256": "2a4974e241fd55b4f72514c69bf75e18244bc1dca93797d8f9bb5ae00c9bd9aa" + "sha256": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281" }, "cell-agent.openapi.yaml": { "source_path": "docs/contracts/cell-agent.openapi.yaml", diff --git a/management/contracts/sip-management.openapi.yaml b/management/contracts/sip-management.openapi.yaml index 35b2dac..28fee2b 100644 --- a/management/contracts/sip-management.openapi.yaml +++ b/management/contracts/sip-management.openapi.yaml @@ -95,6 +95,10 @@ paths: tags: [admin-trunks] operationId: listAdminTrunks security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} responses: '200': description: All Trunks, including unpublished revisions @@ -327,6 +331,18 @@ paths: /admin/v1/cells/{cell_id}: parameters: - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} put: tags: [admin-cells] operationId: registerCell @@ -391,7 +407,8 @@ paths: operationId: getSipStatus security: [{SipAdminBearer: []}] parameters: - - {$ref: '#/components/parameters/CellFilter'} + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} - {$ref: '#/components/parameters/ProviderFilter'} responses: '200': @@ -408,6 +425,8 @@ paths: tags: [admin-status] operationId: getCellSipStatus security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} responses: '200': description: One Cell status @@ -442,7 +461,7 @@ paths: - {$ref: '#/components/parameters/StatsMode'} - {$ref: '#/components/parameters/ProviderFilter'} - {$ref: '#/components/parameters/TrunkFilter'} - - {$ref: '#/components/parameters/CellFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} - {$ref: '#/components/parameters/EgressFilter'} responses: '200': @@ -462,6 +481,9 @@ paths: - {$ref: '#/components/parameters/To'} - {$ref: '#/components/parameters/StatsMode'} - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} - {$ref: '#/components/parameters/EgressFilter'} responses: '200': @@ -480,6 +502,9 @@ paths: - {$ref: '#/components/parameters/From'} - {$ref: '#/components/parameters/To'} - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} - {$ref: '#/components/parameters/Limit'} - {$ref: '#/components/parameters/Cursor'} - {$ref: '#/components/parameters/EgressFilter'} @@ -515,7 +540,9 @@ paths: security: [{SipAdminBearer: []}] parameters: - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} responses: '200': description: Immutable audit entries @@ -607,11 +634,16 @@ components: required: true description: Exact latest revision required for CAS; quotes are accepted. schema: {type: integer, minimum: 0} - CellFilter: + CellIdsFilter: name: cell_ids in: query required: false schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} ProviderFilter: name: provider_id in: query @@ -622,6 +654,21 @@ components: in: query required: false schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} From: name: from in: query @@ -823,6 +870,7 @@ components: required: - mode - trunk_id + - provider_id - latest_revision - active_revision - active_status @@ -834,6 +882,7 @@ components: properties: mode: {$ref: '#/components/schemas/Mode'} trunk_id: {type: string} + provider_id: {type: string} latest_revision: {type: integer, minimum: 1} active_revision: {type: integer, minimum: 0} active_status: {type: string, enum: [draft, published, disabled]} @@ -862,12 +911,14 @@ components: required: - mode - trunk_id + - provider_id - revision - status - config properties: mode: {$ref: '#/components/schemas/Mode'} trunk_id: {type: string} + provider_id: {type: string} revision: {type: integer, minimum: 1} status: {type: string, const: published} updated_at: {type: string, format: date-time} @@ -895,6 +946,10 @@ components: cell_id: {type: string} revision: {type: integer, minimum: 1} config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} updated_at: {type: string, format: date-time} updated_by: {type: string} Publication: @@ -906,6 +961,11 @@ components: cell_id: {type: string} status: {type: string, enum: [pending, applied, failed]} error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} updated_at: {type: string, format: date-time} AuditEntry: type: object diff --git a/tests/test_real_cell.py b/tests/test_real_cell.py index e3162ab..d89e062 100644 --- a/tests/test_real_cell.py +++ b/tests/test_real_cell.py @@ -1,10 +1,12 @@ from __future__ import annotations +import io import json import socket import struct import tempfile import unittest +import wave from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -52,7 +54,106 @@ class FakeBroker: self.published.append(body) +def wav_bytes(pcm: bytes) -> bytes: + output = io.BytesIO() + with wave.open(output, "wb") as source: + source.setnchannels(1) + source.setsampwidth(2) + source.setframerate(8000) + source.writeframes(pcm) + return output.getvalue() + + class RealCellTests(unittest.TestCase): + def _build_lifecycle_call( + self, order: list[str], answer_error: CellCallError | None = None + ) -> tuple[RealCellCall, Any]: + config = CellCallConfig( + "http://127.0.0.1:8088", "u", "p", event_timeout_s=0.1 + ) + engine = SimpleNamespace( + config={ + "agent_version_id": "agent_v1", + "conversation": { + "opening": "hello", + "max_turns": 1, + "allow_interrupt": False, + }, + }, + history=[], + ) + engine.speak = lambda _text, _turn_id: ( + order.append("opening") or {"audio": b""} + ) + call = RealCellCall(config, cast(Any, engine), cast(Any, SimpleNamespace())) + + class FakeARI: + def __init__(self) -> None: + self.record_requests = 0 + + def events(self, _app: str) -> Any: + return SimpleNamespace(close=lambda: None) + + def request( + self, method: str, resource: str, _params=None, **_kwargs: object + ) -> dict[str, str] | dict: + if method == "POST" and resource.endswith("/record"): + self.record_requests += 1 + order.append("record") + if resource == "channels/externalMedia": + return {"id": "external"} + if resource == "channels": + return {"id": "target"} + return {} + + fake_ari = FakeARI() + call_any = cast(Any, call) + call_any.ari = fake_ari + call_any._start_event_reader = lambda: None + call_any._try_set_external_media_peer = lambda: False + call_any._add_when_ready = lambda _channel_id: True + + def wait_answer(_result: Any) -> None: + order.append("answer") + if answer_error is not None: + raise answer_error + + call_any._wait_answer = wait_answer + call_any._play = lambda _audio: order.append("play") + call_any._capture_turn = lambda _timeout: ( + order.append("capture") or b"" + ) + call_any._finish_recording = lambda: None + call_any._cleanup = lambda: None + return call, fake_ari + + def test_recording_starts_after_answer_before_opening_tts(self) -> None: + order: list[str] = [] + call, fake_ari = self._build_lifecycle_call(order) + try: + result = call.start_authorized_call("123") + finally: + if call.media is not None: + call.media.close() + self.assertTrue(result.connected) + self.assertEqual(fake_ari.record_requests, 1) + self.assertLess(order.index("answer"), order.index("record")) + self.assertLess(order.index("record"), order.index("opening")) + + def test_answer_failure_does_not_start_recording(self) -> None: + order: list[str] = [] + call, fake_ari = self._build_lifecycle_call( + order, CellCallError("CALL_NOT_ANSWERED", "not answered") + ) + try: + result = call.start_authorized_call("123") + finally: + if call.media is not None: + call.media.close() + self.assertFalse(result.connected) + self.assertEqual(fake_ari.record_requests, 0) + self.assertNotIn("opening", order) + def test_pcma_decode_and_rtp_payload(self) -> None: media = RTPMedia("127.0.0.1", 0) receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -202,7 +303,7 @@ class RealCellTests(unittest.TestCase): def request(self, method: str, resource: str, **_kwargs): self.calls.append((method, resource)) - return {} if method == "POST" else b"RIFFtest" + return {} if method == "POST" else wav_bytes(b"\x00\x00") with tempfile.TemporaryDirectory() as directory: config = CellCallConfig( @@ -226,6 +327,29 @@ class RealCellTests(unittest.TestCase): ("DELETE", fake_ari.calls[0][1].rsplit("/stop", 1)[0]), fake_ari.calls ) + def test_finish_recording_rejects_zero_frame_wav(self) -> None: + class FakeARI: + def request(self, method: str, resource: str, **_kwargs): + return {} if method == "POST" else wav_bytes(b"") + + with tempfile.TemporaryDirectory() as directory: + config = CellCallConfig( + "http://127.0.0.1:8088", "u", "p", recording_dir=directory + ) + call = cast( + Any, + RealCellCall( + config, + cast(Any, SimpleNamespace(config={})), + cast(Any, SimpleNamespace()), + ), + ) + call.ari = FakeARI() + self.assertIsNone(call._finish_recording()) + self.assertFalse( + (Path(directory) / f"{call.recording_name}.wav").exists() + ) + def test_cell_config_rejects_non_pcma_bad_port_and_prefix(self) -> None: with self.assertRaises(CellCallError): CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_format="ulaw")