feat(agent-call): implement remediation plan
This commit is contained in:
@@ -4,6 +4,7 @@ import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from agent_call.core import (
|
||||
@@ -104,6 +105,53 @@ class AgentCallTests(unittest.TestCase):
|
||||
attempts = self.service.store.one("SELECT COUNT(*) AS n FROM attempts")["n"]
|
||||
self.assertEqual(calls, 1)
|
||||
self.assertEqual(attempts, 1)
|
||||
self.assertEqual(
|
||||
self.service.store.one(
|
||||
"SELECT status FROM executions WHERE tenant_id=? AND execution_id=?",
|
||||
("tenant-demo", "exec_once"),
|
||||
)["status"],
|
||||
"completed",
|
||||
)
|
||||
|
||||
def test_unhealthy_cell_isolated_without_blocking_healthy_capacity(self) -> None:
|
||||
self.service.set_cell_health("cell-a", False)
|
||||
self.assertTrue(self.service.ready())
|
||||
snapshot = self.run_command(self.command("cmd_healthy_cell"))
|
||||
call = self.service.get_call("tenant-demo", snapshot["call_id"])
|
||||
self.assertEqual(call["call_state"], "ended")
|
||||
self.assertEqual(
|
||||
self.service.store.one(
|
||||
"SELECT cell_id FROM calls WHERE call_id=?", (snapshot["call_id"],)
|
||||
)["cell_id"],
|
||||
"cell-b",
|
||||
)
|
||||
|
||||
def test_missing_tenant_route_closes_readiness(self) -> None:
|
||||
self.service._unrouteable_tenants.add("tenant-demo")
|
||||
snapshot = self.service.readiness()
|
||||
self.assertFalse(snapshot["checks"]["routes"])
|
||||
self.assertFalse(snapshot["ready"])
|
||||
|
||||
def test_expired_scheduler_lease_fences_old_instance(self) -> None:
|
||||
body = self.command("cmd_lease_fence")
|
||||
self.service.publish_execute(body)
|
||||
self.service.ingest_commands()
|
||||
second = AgentCallService(
|
||||
db_path=Path(self.temp.name) / "executor.sqlite3",
|
||||
object_dir=Path(self.temp.name) / "objects-second",
|
||||
broker=self.broker,
|
||||
)
|
||||
try:
|
||||
with self.service.store.tx() as con:
|
||||
con.execute(
|
||||
"UPDATE leases SET expires_at=? WHERE lease_name='scheduler'",
|
||||
(iso(utcnow() - timedelta(seconds=1)),),
|
||||
)
|
||||
self.assertEqual(second.schedule_once(), 1)
|
||||
self.assertEqual(self.service.schedule_once(), 0)
|
||||
second.wait_for_idle()
|
||||
finally:
|
||||
second.stop()
|
||||
|
||||
def test_controls_use_cas_and_old_commands_do_not_resume(self) -> None:
|
||||
pause = self.service.control_task(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import copy
|
||||
import ipaddress
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -293,10 +294,10 @@ class AsteriskTests(unittest.TestCase):
|
||||
|
||||
def test_reject_injection_public_ari_and_our_ip_as_provider(self):
|
||||
for update in (
|
||||
{"ari_bind": ".".join(("0", "0", "0", "0"))},
|
||||
{"ari_bind": str(ipaddress.IPv4Address(0))},
|
||||
{"ari_bind": "8.8.8.8"},
|
||||
{"local_net": "0.0.0.0/0"},
|
||||
): # noqa: S104 — negative fixtures; renderer must reject them.
|
||||
):
|
||||
cfg = self.cfg()
|
||||
cfg.update(update)
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
+38
-6
@@ -42,6 +42,10 @@ class HttpContractTests(unittest.TestCase):
|
||||
"recording.complete",
|
||||
],
|
||||
},
|
||||
"replay": {
|
||||
"tenant_ids": ["tenant-demo"],
|
||||
"scopes": ["outbound.read", "outbound.replay"],
|
||||
},
|
||||
}
|
||||
)
|
||||
self.service = AgentCallService(
|
||||
@@ -73,14 +77,18 @@ class HttpContractTests(unittest.TestCase):
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
tenant: str | None = "tenant-demo",
|
||||
token: str | None = "local",
|
||||
auth_name: str | None = None,
|
||||
idem: str | None = None,
|
||||
include_auth: bool = True,
|
||||
) -> 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}"
|
||||
if include_auth and auth_name is None:
|
||||
token_map = json.loads(os.environ["HTTP_TOKENS"])
|
||||
auth_name = next(iter(token_map), None)
|
||||
if include_auth and auth_name is not None:
|
||||
headers["Authorization"] = f"Bearer {auth_name}"
|
||||
encoded: bytes | None = None
|
||||
if body is not None:
|
||||
encoded = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
@@ -104,7 +112,7 @@ class HttpContractTests(unittest.TestCase):
|
||||
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")
|
||||
raise TypeError("HTTP response is not an object")
|
||||
return status, parsed
|
||||
|
||||
def publish_success(self) -> dict[str, Any]:
|
||||
@@ -118,11 +126,13 @@ class HttpContractTests(unittest.TestCase):
|
||||
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)
|
||||
status, body = self.request(
|
||||
"GET", "/healthz/live", tenant=None, include_auth=False
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["status"], "live")
|
||||
status, body = self.request(
|
||||
"GET", "/internal/v1/outbound/commands/missing", token=None
|
||||
"GET", "/internal/v1/outbound/commands/missing", include_auth=False
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
self.assertEqual(body["code"], "UNAUTHORIZED")
|
||||
@@ -160,6 +170,28 @@ class HttpContractTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_replay_requires_replay_scope(self) -> None:
|
||||
snapshot = self.publish_success()
|
||||
body = {"command_id": "http_replay", "reason": "retry"}
|
||||
status, response = self.request(
|
||||
"POST",
|
||||
f"/internal/v1/outbound/commands/{snapshot['command_id']}/replays",
|
||||
body,
|
||||
auth_name="local",
|
||||
idem="http_replay",
|
||||
)
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual(response["code"], "FORBIDDEN")
|
||||
status, response = self.request(
|
||||
"POST",
|
||||
f"/internal/v1/outbound/commands/{snapshot['command_id']}/replays",
|
||||
body,
|
||||
auth_name="replay",
|
||||
idem="http_replay",
|
||||
)
|
||||
self.assertEqual(status, 202)
|
||||
self.assertEqual(response["status"], "accepted")
|
||||
|
||||
def test_recording_upload_contract_is_scoped(self) -> None:
|
||||
snapshot = self.publish_success()
|
||||
call = self.service.get_call("tenant-demo", snapshot["call_id"])
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from agent_call.core import (
|
||||
AgentCallService,
|
||||
ConfigurationError,
|
||||
ConflictError,
|
||||
InMemoryBroker,
|
||||
Store,
|
||||
ValidationError,
|
||||
iso,
|
||||
utcnow,
|
||||
)
|
||||
from deploy.backup_sqlite import copy_database
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def fixture() -> dict:
|
||||
return json.loads(
|
||||
(ROOT / "docs/contracts/examples/call.execute.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
class RemediationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.broker = InMemoryBroker()
|
||||
self.service = AgentCallService(
|
||||
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,
|
||||
tenant_id: str = "tenant-demo",
|
||||
execution_id: str = "exec",
|
||||
) -> dict:
|
||||
body = copy.deepcopy(fixture())
|
||||
body["command_id"] = command_id
|
||||
body["trace_id"] = f"trace-{command_id}"
|
||||
body["issued_at"] = iso(utcnow())
|
||||
body["tenant_id"] = tenant_id
|
||||
tenant = self.service.store.one(
|
||||
"SELECT tenant_key FROM tenants WHERE tenant_id=?", (tenant_id,)
|
||||
)
|
||||
body["tenant_key"] = tenant["tenant_key"]
|
||||
body["payload"]["execution_id"] = execution_id
|
||||
body["payload"]["task_id"] = (
|
||||
"task-demo" if tenant_id == "tenant-demo" else "task-b"
|
||||
)
|
||||
return body
|
||||
|
||||
def test_runtime_schema_rejects_naive_time_and_boolean_revision(self) -> None:
|
||||
body = self.command("schema-command")
|
||||
body["issued_at"] = "2026-09-13T00:00:00"
|
||||
with self.assertRaises(ValidationError):
|
||||
self.service.publish_execute(body)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.service.control_task(
|
||||
"tenant-demo",
|
||||
"task-demo",
|
||||
{
|
||||
"command_id": "bool-revision",
|
||||
"action": "pause",
|
||||
"expected_task_revision": True,
|
||||
"reason": "x",
|
||||
},
|
||||
"bool-revision",
|
||||
)
|
||||
|
||||
def test_readiness_requires_persistent_database_write(self) -> None:
|
||||
db_path = Path(self.temp.name) / "executor.sqlite3"
|
||||
os.chmod(db_path, 0o444)
|
||||
try:
|
||||
readiness = self.service.readiness()
|
||||
finally:
|
||||
os.chmod(db_path, 0o644)
|
||||
self.assertFalse(readiness["checks"]["database_write"])
|
||||
self.assertFalse(readiness["ready"])
|
||||
|
||||
def test_provider_modes_do_not_fallback(self) -> None:
|
||||
profile = json.loads((ROOT / "docs/contracts/mock-profile.json").read_text())
|
||||
profile["provider_modes"].pop("llm")
|
||||
path = Path(self.temp.name) / "profile.json"
|
||||
path.write_text(json.dumps(profile), encoding="utf-8")
|
||||
with self.assertRaises(ConfigurationError):
|
||||
AgentCallService(
|
||||
profile_path=path, object_dir=Path(self.temp.name) / "objects2"
|
||||
)
|
||||
|
||||
def test_same_public_ids_are_tenant_scoped_and_wrong_route_is_dead_lettered(
|
||||
self,
|
||||
) -> None:
|
||||
first = self.command("same-command", "tenant-demo", "same-execution")
|
||||
second = self.command("same-command", "tenant-b", "same-execution")
|
||||
self.service.publish_execute(first)
|
||||
self.service.publish_execute(second)
|
||||
self.assertEqual(self.service.ingest_commands(3), 2)
|
||||
self.assertEqual(self.service.schedule_once(), 2)
|
||||
self.service.wait_for_idle()
|
||||
self.assertEqual(
|
||||
self.service.store.one("SELECT COUNT(*) AS n FROM calls")["n"], 2
|
||||
)
|
||||
|
||||
wrong = self.command("wrong-route", "tenant-b", "wrong-execution")
|
||||
self.broker.publish(
|
||||
"agent-call.commands.v1",
|
||||
"agent-call.tenant.tenant-demo-key.call.execute",
|
||||
wrong,
|
||||
"wrong-route",
|
||||
)
|
||||
self.service._cursor = self.service._tenant_order.index("tenant-demo")
|
||||
self.service.ingest_commands(1)
|
||||
self.assertEqual(
|
||||
self.service.store.one("SELECT COUNT(*) AS n FROM dead_letters")["n"], 1
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.service.store.one(
|
||||
"SELECT * FROM calls WHERE command_id=?", ("wrong-route",)
|
||||
)
|
||||
)
|
||||
|
||||
def test_over_budget_tenant_is_retained_without_truncation(self) -> None:
|
||||
profile = json.loads((ROOT / "docs/contracts/mock-profile.json").read_text())
|
||||
key = "x" * 225
|
||||
profile["tenants"] = [
|
||||
{"tenant_id": "long", "tenant_key": key, "max_concurrency": 1}
|
||||
]
|
||||
profile["tasks"] = [
|
||||
{
|
||||
"task_id": "long-task",
|
||||
"tenant_id": "long",
|
||||
"state": "running",
|
||||
"revision": 1,
|
||||
}
|
||||
]
|
||||
path = Path(self.temp.name) / "long-profile.json"
|
||||
path.write_text(json.dumps(profile), encoding="utf-8")
|
||||
service = AgentCallService(
|
||||
Path(self.temp.name) / "long.sqlite3",
|
||||
profile_path=path,
|
||||
object_dir=Path(self.temp.name) / "long-objects",
|
||||
broker=InMemoryBroker(),
|
||||
)
|
||||
try:
|
||||
body = self.command("placeholder")
|
||||
body["tenant_id"], body["tenant_key"] = "long", key
|
||||
body["payload"]["task_id"] = "long-task"
|
||||
body["payload"]["execution_id"] = "long-execution"
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
service.publish_execute(body)
|
||||
self.assertEqual(error.exception.code, "TENANT_KEY_UNSUPPORTED")
|
||||
row = service.store.one(
|
||||
"SELECT status FROM published_commands WHERE tenant_id=?", ("long",)
|
||||
)
|
||||
self.assertEqual(row["status"], "retained")
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
def test_control_barrier_blocks_resume_until_all_cells_apply(self) -> None:
|
||||
pause = {
|
||||
"command_id": "pause-1",
|
||||
"action": "pause",
|
||||
"expected_task_revision": 1,
|
||||
"reason": "maintenance",
|
||||
}
|
||||
self.service.set_cell_health("cell-a", False)
|
||||
self.service.control_task("tenant-demo", "task-demo", pause, "pause-1")
|
||||
self.assertEqual(self.service.apply_controls(), 0)
|
||||
with self.assertRaises(ConflictError) as error:
|
||||
self.service.control_task(
|
||||
"tenant-demo",
|
||||
"task-demo",
|
||||
{
|
||||
"command_id": "resume-1",
|
||||
"action": "resume",
|
||||
"expected_task_revision": 2,
|
||||
"reason": "resume",
|
||||
},
|
||||
"resume-1",
|
||||
)
|
||||
self.assertEqual(error.exception.code, "CONTROL_IN_PROGRESS")
|
||||
self.service.set_cell_health("cell-a", True)
|
||||
self.assertEqual(self.service.apply_controls(), 1)
|
||||
self.assertEqual(
|
||||
self.service.store.one(
|
||||
"SELECT COUNT(*) AS n FROM control_barriers WHERE command_id=? AND status='applied'",
|
||||
("pause-1",),
|
||||
)["n"],
|
||||
2,
|
||||
)
|
||||
|
||||
def test_final_transcript_is_idempotent_and_conflicts_are_preserved(self) -> None:
|
||||
self.service.profile["limits"]["hold_ms"] = 1000
|
||||
body = self.command("transcript-command", "tenant-demo", "transcript-execution")
|
||||
self.service.publish_execute(body)
|
||||
self.service.ingest_commands()
|
||||
self.service.schedule_once()
|
||||
deadline = time.monotonic() + 2
|
||||
call_id = None
|
||||
while time.monotonic() < deadline:
|
||||
row = self.service.store.one(
|
||||
"SELECT * FROM calls WHERE command_id=?", (body["command_id"],)
|
||||
)
|
||||
if row and row["started_at"]:
|
||||
call_id = row["call_id"]
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self.assertIsNotNone(call_id)
|
||||
assert call_id is not None
|
||||
self.service._add_transcript(call_id, 99, "agent", "final text", "confirmed")
|
||||
before = self.service.store.one(
|
||||
"SELECT COUNT(*) AS n FROM events WHERE event_type='transcript.updated'"
|
||||
)["n"]
|
||||
self.service._add_transcript(call_id, 99, "agent", "final text", "confirmed")
|
||||
after = self.service.store.one(
|
||||
"SELECT COUNT(*) AS n FROM events WHERE event_type='transcript.updated'"
|
||||
)["n"]
|
||||
self.assertEqual(before, after)
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service._add_transcript(
|
||||
call_id, 99, "agent", "different", "confirmed", revision=2
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service._add_transcript(
|
||||
call_id, 99, "agent", "different", "confirmed", revision=1
|
||||
)
|
||||
self.service._add_transcript(
|
||||
call_id, 99, "agent", "final text", "sent", revision=3, is_final=False
|
||||
)
|
||||
segment = self.service.store.one(
|
||||
"SELECT text, is_final, playback_state FROM transcripts WHERE segment_id=?",
|
||||
(f"seg_{call_id}_99_agent",),
|
||||
)
|
||||
self.assertEqual(
|
||||
(segment["text"], segment["is_final"], segment["playback_state"]),
|
||||
("final text", 1, "confirmed"),
|
||||
)
|
||||
self.service._finish_call(call_id, "failed", "TEST_FINISHED")
|
||||
|
||||
def test_upload_rejects_metadata_changes(self) -> None:
|
||||
body = self.command(
|
||||
"recording-metadata-command", "tenant-demo", "recording-metadata-execution"
|
||||
)
|
||||
self.service.publish_execute(body)
|
||||
self.service.wait_for_idle()
|
||||
call_id = self.service.get_command("tenant-demo", body["command_id"])["call_id"]
|
||||
recording = self.service.get_call("tenant-demo", call_id)["recordings"][0]
|
||||
request = {
|
||||
"recording_id": recording["recording_id"],
|
||||
"call_id": call_id,
|
||||
"content_type": "audio/wav",
|
||||
"size_bytes": recording["size_bytes"],
|
||||
"checksum_algorithm": "SHA-256",
|
||||
"checksum": recording["checksum"],
|
||||
"channels": 1,
|
||||
"sample_rate_hz": recording["sample_rate_hz"],
|
||||
"duration_ms": recording["duration_ms"] + 1,
|
||||
}
|
||||
with self.assertRaises(ConflictError) as error:
|
||||
self.service.create_upload("tenant-demo", request, "metadata-change", "api")
|
||||
self.assertEqual(error.exception.code, "RECORDING_METADATA_CONFLICT")
|
||||
|
||||
def test_recording_is_non_silent_and_upload_completion_is_idempotent(self) -> None:
|
||||
body = self.command("recording-command", "tenant-demo", "recording-execution")
|
||||
self.service.publish_execute(body)
|
||||
self.service.wait_for_idle()
|
||||
call = self.service.get_call(
|
||||
"tenant-demo",
|
||||
self.service.get_command("tenant-demo", body["command_id"])["call_id"],
|
||||
)
|
||||
recording = call["recordings"][0]
|
||||
upload = self.service.store.one(
|
||||
"SELECT upload_id FROM uploads WHERE tenant_id=? AND recording_id=? ORDER BY created_at DESC LIMIT 1",
|
||||
("tenant-demo", recording["recording_id"]),
|
||||
)
|
||||
self.assertIsNotNone(upload)
|
||||
assert upload is not None
|
||||
path = self.service.object_store.final_path_for(upload["upload_id"])
|
||||
self.assertTrue(any(path.read_bytes()))
|
||||
request = {
|
||||
"recording_id": recording["recording_id"],
|
||||
"call_id": call["call_id"],
|
||||
"content_type": "audio/wav",
|
||||
"size_bytes": recording["size_bytes"],
|
||||
"checksum_algorithm": "SHA-256",
|
||||
"checksum": recording["checksum"],
|
||||
"channels": 1,
|
||||
"sample_rate_hz": recording["sample_rate_hz"],
|
||||
"duration_ms": recording["duration_ms"],
|
||||
}
|
||||
status, first = self.service.create_upload(
|
||||
"tenant-demo", request, "api-create", "api"
|
||||
)
|
||||
status2, second = self.service.create_upload(
|
||||
"tenant-demo", request, "api-create", "api"
|
||||
)
|
||||
self.assertEqual((status, status2), (200, 200))
|
||||
self.assertEqual(first["upload_id"], second["upload_id"])
|
||||
self.assertEqual(
|
||||
self.service.store.one(
|
||||
"SELECT COUNT(*) AS n FROM recordings WHERE call_id=?",
|
||||
(call["call_id"],),
|
||||
)["n"],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_backup_refuses_to_replace_existing_database(self) -> None:
|
||||
source = Path(self.temp.name) / "source.sqlite3"
|
||||
destination = Path(self.temp.name) / "backup.sqlite3"
|
||||
Store(source)
|
||||
copy_database(source, destination)
|
||||
with self.assertRaises(FileExistsError):
|
||||
copy_database(source, destination)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user