test_agent_kestra_canary.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """Opt-in V54 Agent -> DataOps MCP -> Kestra -> Runner Canary acceptance."""
  2. import os
  3. import pytest
  4. import requests
  5. from neo4j import GraphDatabase
  6. from sqlalchemy import create_engine, text
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_source.credentials import (
  9. CredentialCodec,
  10. DataSourceCredentialRepository,
  11. )
  12. from app.core.data_source.definitions import DataSourceDefinitionRepository
  13. from app.core.data_source.models import (
  14. DataSourceCredential,
  15. DataSourceDefinition,
  16. )
  17. from app.core.mcp.canary import CanaryVerifier
  18. from app.core.mcp.context import ContextService
  19. from app.core.mcp.gateway import SchedulingGateway
  20. from app.core.mcp.identity import AgentIdentity
  21. from app.core.mcp.persistence import (
  22. PostgresAuditSink,
  23. PostgresSchedulingPlanStore,
  24. )
  25. from app.core.mcp.servers import create_context_mcp, create_scheduling_mcp
  26. from app.core.orchestration.agent.planner import SchedulingPlanner
  27. from app.core.orchestration.engines.kestra import KestraAdapter
  28. from app.runner.auth import TaskTokenIssuer
  29. pytestmark = pytest.mark.integration
  30. DATABASE_URL = "postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops"
  31. KESTRA_URL = "http://127.0.0.1:18080/api/v1"
  32. KESTRA_AUTH = ("admin@dataops.local", "DataOpsKestra1!")
  33. class RegisteredMcp:
  34. def __init__(self, name, **_kwargs):
  35. self.name = name
  36. self.tools = {}
  37. def tool(self):
  38. def register(function):
  39. self.tools[function.__name__] = function
  40. return function
  41. return register
  42. class ToolClient:
  43. def __init__(self, server):
  44. self.server = server
  45. def call_tool(self, name, arguments):
  46. return self.server.tools[name](**arguments)
  47. class ContextRepository:
  48. def __init__(self, source_uid):
  49. self.source_uid = source_uid
  50. def get_sla_constraints(self, _dataflow_uid):
  51. return {
  52. "timezone": "Asia/Shanghai",
  53. "deadline": "08:00",
  54. "max_duration_seconds": 120,
  55. "priority": "low",
  56. }
  57. def list_datasource_capabilities(self, _business_domain):
  58. return [
  59. {
  60. "uid": self.source_uid,
  61. "type": "postgresql",
  62. "purposes": ["read"],
  63. "health": "healthy",
  64. "capacity": {"available": 2, "max_concurrency": 2},
  65. }
  66. ]
  67. def get_execution_history(self, _dataflow_uid, _limit, _days):
  68. return [{"status": "success", "correlation_id": new_governance_uid()}]
  69. def estimate_schedule_capacity(self, _business_domain, _schedule_plan):
  70. return {
  71. "feasible": True,
  72. "required_slots": 1,
  73. "available_slots": 2,
  74. "warnings": [],
  75. }
  76. def simulate_schedule(self, _business_domain, _schedule_plan):
  77. return {
  78. "next_runs": [],
  79. "warnings": ["manual trigger has no deterministic next run"],
  80. }
  81. class DeterministicModel:
  82. provider = "offline-fixture"
  83. model_name = "v54-read-only-scenario"
  84. def __init__(self, dataflow_uid, source_uid):
  85. self.dataflow_uid = dataflow_uid
  86. self.source_uid = source_uid
  87. def generate(self, **_kwargs):
  88. return {
  89. "schema_version": "1.0",
  90. "workflow_spec": {
  91. "schema_version": "1.0",
  92. "dataflow_uid": self.dataflow_uid,
  93. "name": "V54 read-only agent Canary",
  94. "nodes": [
  95. {
  96. "id": "read_customers",
  97. "type": "sql.query",
  98. "data_source_uid": self.source_uid,
  99. "purpose": "read",
  100. "config": {
  101. "statement": (
  102. "SELECT customer_name FROM acceptance_customers "
  103. "WHERE id >= :minimum_id ORDER BY id"
  104. ),
  105. "parameters": {"minimum_id": "${parameters.minimum_id}"},
  106. },
  107. }
  108. ],
  109. "edges": [],
  110. "parameters": {"minimum_id": {"type": "integer", "required": True}},
  111. },
  112. "schedule_plan": {
  113. "schema_version": "1.0",
  114. "timezone": "Asia/Shanghai",
  115. "triggers": [{"type": "manual"}],
  116. "max_concurrency": 1,
  117. "conflict_policy": "skip",
  118. "timeout_seconds": 120,
  119. "retry": {"max_attempts": 1, "delay_seconds": 0},
  120. "backfill": {"max_days": 1, "max_runs": 1},
  121. },
  122. "decision_summary": "Run one read-only low-frequency Canary.",
  123. }
  124. class BaselineComparator:
  125. def compare(self, baseline_execution_id, candidate_execution_id):
  126. assert baseline_execution_id == "n8n-baseline-v54"
  127. assert candidate_execution_id
  128. return {
  129. "equivalent": True,
  130. "metrics": {
  131. "row_count_delta": 0,
  132. "output_hash_match": True,
  133. },
  134. }
  135. def test_agent_executes_one_read_only_kestra_canary_without_promotion():
  136. if os.environ.get("RUN_V54_AGENT_L3") != "1":
  137. pytest.skip("set RUN_V54_AGENT_L3=1 for the V54 Agent acceptance")
  138. engine = create_engine(DATABASE_URL, pool_pre_ping=True)
  139. graph = GraphDatabase.driver(
  140. "bolt://127.0.0.1:17687",
  141. auth=("neo4j", "Passw0rd"),
  142. )
  143. source_uid = new_governance_uid()
  144. dataflow_uid = new_governance_uid()
  145. correlation_id = new_governance_uid()
  146. candidate_id = None
  147. deployment = None
  148. codec = CredentialCodec.from_base64(
  149. "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
  150. "v1",
  151. )
  152. identity = AgentIdentity(
  153. subject="v54-planner-l3",
  154. roles=frozenset({"scheduler"}),
  155. business_domains=frozenset({"sales"}),
  156. environments=frozenset({"test"}),
  157. correlation_id=correlation_id,
  158. )
  159. store = PostgresSchedulingPlanStore(engine)
  160. kestra = KestraAdapter(
  161. base_url=KESTRA_URL,
  162. username=KESTRA_AUTH[0],
  163. password=KESTRA_AUTH[1],
  164. )
  165. try:
  166. with engine.begin() as connection:
  167. sealed = DataSourceCredentialRepository(codec).create_version(
  168. connection,
  169. data_source_uid=source_uid,
  170. credential=DataSourceCredential(
  171. "source_reader",
  172. "source-test-password",
  173. ),
  174. actor_uid=None,
  175. )
  176. with graph.session() as session:
  177. DataSourceDefinitionRepository(session).save(
  178. DataSourceDefinition(
  179. uid=source_uid,
  180. name_en="v54-agent-canary-source",
  181. database_type="postgresql",
  182. host="source-postgres",
  183. port=5432,
  184. database="acceptance",
  185. credential_ref=source_uid,
  186. credential_version=sealed.credential_version,
  187. pool_size=1,
  188. max_overflow=1,
  189. )
  190. )
  191. context_server = create_context_mcp(
  192. ContextService(ContextRepository(source_uid)),
  193. identity=identity,
  194. fastmcp_cls=RegisteredMcp,
  195. )
  196. scheduling_server = create_scheduling_mcp(
  197. SchedulingGateway(
  198. plans=store,
  199. audit=PostgresAuditSink(engine),
  200. engine=kestra,
  201. token_issuer=TaskTokenIssuer(
  202. "dataops-local-runner-task-token-secret-change-me",
  203. ttl_seconds=120,
  204. ),
  205. ),
  206. identity=identity,
  207. fastmcp_cls=RegisteredMcp,
  208. )
  209. planner = SchedulingPlanner(
  210. model=DeterministicModel(dataflow_uid, source_uid),
  211. context_tools=ToolClient(context_server),
  212. scheduling_tools=ToolClient(scheduling_server),
  213. canary_verifier=CanaryVerifier(
  214. plans=store,
  215. engine=kestra,
  216. comparator=BaselineComparator(),
  217. verifier_id="v54-agent-canary-verifier",
  218. ),
  219. audit=PostgresAuditSink(engine),
  220. canary_timeout_seconds=30,
  221. canary_poll_interval_seconds=0.25,
  222. )
  223. result = planner.run(
  224. identity,
  225. {
  226. "business_goal": "Validate the customer read path before SLA",
  227. "dataflow_uid": dataflow_uid,
  228. "business_domain": "sales",
  229. "environment": "test",
  230. "available_window": {
  231. "start": "2026-07-20T06:00:00+08:00",
  232. "end": "2026-07-20T08:00:00+08:00",
  233. },
  234. "authorized_resources": [source_uid],
  235. "canary_inputs": {"minimum_id": 1},
  236. "baseline_execution_id": "n8n-baseline-v54",
  237. },
  238. )
  239. candidate_id = result["candidate_id"]
  240. deployment = store.get_deployment(candidate_id)
  241. assert result["status"] == "canary_passed"
  242. assert result["promotion_recommendation"] == "shadow_only"
  243. assert result["formal_engine_changed"] is False
  244. assert (
  245. requests.get(
  246. f"{KESTRA_URL}/main/flows/"
  247. f"{deployment['namespace']}/{deployment['flow_id']}",
  248. auth=KESTRA_AUTH,
  249. timeout=20,
  250. ).json()["disabled"]
  251. is True
  252. )
  253. with engine.connect() as connection:
  254. runner_successes = connection.execute(
  255. text(
  256. "SELECT COUNT(*) FROM public.runner_task_executions "
  257. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  258. "AND status = 'success'"
  259. ),
  260. {"uid": dataflow_uid},
  261. ).scalar_one()
  262. audit_row = (
  263. connection.execute(
  264. text(
  265. "SELECT model_provider, model_name, prompt_version, "
  266. "schema_version, context_hash, decision, decision_detail "
  267. "FROM public.workflow_plan_audits "
  268. "WHERE action = 'ai_planning_closed_loop' "
  269. "AND correlation_id = CAST(:correlation_id AS uuid)"
  270. ),
  271. {"correlation_id": correlation_id},
  272. )
  273. .mappings()
  274. .one()
  275. )
  276. assert runner_successes == 1
  277. assert audit_row["model_provider"] == "offline-fixture"
  278. assert audit_row["schema_version"] == "v54"
  279. assert audit_row["decision"] == "allowed"
  280. assert audit_row["decision_detail"]["result"]["formal_engine_changed"] is False
  281. finally:
  282. if deployment is not None:
  283. requests.delete(
  284. f"{KESTRA_URL}/main/flows/"
  285. f"{deployment['namespace']}/{deployment['flow_id']}",
  286. auth=KESTRA_AUTH,
  287. timeout=20,
  288. )
  289. with graph.session() as session:
  290. DataSourceDefinitionRepository(session).delete(source_uid)
  291. with engine.begin() as connection:
  292. if candidate_id is not None:
  293. connection.execute(
  294. text(
  295. "DELETE FROM public.workflow_plan_audits "
  296. "WHERE workflow_version_id = CAST(:id AS uuid)"
  297. ),
  298. {"id": candidate_id},
  299. )
  300. connection.execute(
  301. text(
  302. "DELETE FROM public.dataflow_workflow_versions "
  303. "WHERE id = CAST(:id AS uuid)"
  304. ),
  305. {"id": candidate_id},
  306. )
  307. connection.execute(
  308. text(
  309. "DELETE FROM public.runner_task_executions "
  310. "WHERE dataflow_uid = CAST(:uid AS uuid)"
  311. ),
  312. {"uid": dataflow_uid},
  313. )
  314. connection.execute(
  315. text(
  316. "DELETE FROM public.datasource_credential_audit_events "
  317. "WHERE data_source_uid = CAST(:uid AS uuid)"
  318. ),
  319. {"uid": source_uid},
  320. )
  321. connection.execute(
  322. text(
  323. "DELETE FROM public.datasource_credentials "
  324. "WHERE data_source_uid = CAST(:uid AS uuid)"
  325. ),
  326. {"uid": source_uid},
  327. )
  328. graph.close()
  329. engine.dispose()