context.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. """Bounded read-only business context for scheduling agents."""
  2. from app.core.orchestration.spec import (
  3. validate_schedule_plan as normalize_schedule_plan,
  4. )
  5. from app.core.orchestration.spec import (
  6. validate_workflow_spec as normalize_workflow_spec,
  7. )
  8. from app.core.orchestration.spec import workflow_spec_hash
  9. def _untrusted(value):
  10. if value is None:
  11. return None
  12. return {"trust": "untrusted", "value": str(value)[:2000]}
  13. def _page(items, maximum):
  14. bounded = list(items)[:maximum]
  15. return {
  16. "items": bounded,
  17. "count": len(bounded),
  18. "truncated": len(items) > maximum,
  19. }
  20. class ContextService:
  21. def __init__(self, repository):
  22. self.repository = repository
  23. def list_dataflows(self, identity, *, limit=50):
  24. if limit < 1 or limit > 50:
  25. raise ValueError("limit must be between 1 and 50")
  26. allowed = []
  27. for row in self.repository.list_dataflows():
  28. domain = row.get("business_domain")
  29. if (
  30. "*" not in identity.business_domains
  31. and domain not in identity.business_domains
  32. ):
  33. continue
  34. allowed.append(
  35. {
  36. "uid": row.get("uid"),
  37. "name": row.get("name"),
  38. "business_domain": domain,
  39. "description": _untrusted(row.get("description")),
  40. }
  41. )
  42. return _page(allowed, limit)
  43. def describe_dataflow(self, identity, dataflow_uid, *, business_domain):
  44. identity.require_domain(business_domain)
  45. row = self.repository.describe_dataflow(dataflow_uid)
  46. if row.get("business_domain") != business_domain:
  47. raise PermissionError("dataflow is outside identity scope")
  48. return {
  49. "uid": row.get("uid"),
  50. "name": row.get("name"),
  51. "business_domain": row.get("business_domain"),
  52. "status": row.get("status"),
  53. "description": _untrusted(row.get("description")),
  54. }
  55. def get_dataflow_dependencies(self, identity, dataflow_uid, *, business_domain):
  56. identity.require_domain(business_domain)
  57. safe = []
  58. for row in self.repository.get_dataflow_dependencies(dataflow_uid):
  59. if row.get("business_domain") != business_domain:
  60. continue
  61. safe.append(
  62. {
  63. "uid": row.get("uid"),
  64. "name": row.get("name"),
  65. "relation": row.get("relation"),
  66. "business_domain": row.get("business_domain"),
  67. }
  68. )
  69. return _page(safe, 100)
  70. def get_data_lineage(
  71. self,
  72. identity,
  73. dataflow_uid,
  74. *,
  75. business_domain,
  76. depth=3,
  77. ):
  78. identity.require_domain(business_domain)
  79. if (
  80. isinstance(depth, bool)
  81. or not isinstance(depth, int)
  82. or depth < 1
  83. or depth > 5
  84. ):
  85. raise ValueError("lineage depth must be between 1 and 5")
  86. safe = []
  87. for row in self.repository.get_data_lineage(dataflow_uid, depth):
  88. safe.append(
  89. {
  90. "from_uid": row.get("from_uid"),
  91. "to_uid": row.get("to_uid"),
  92. "relation": row.get("relation"),
  93. "description": _untrusted(row.get("description")),
  94. }
  95. )
  96. return _page(safe, 100)
  97. def list_datasource_capabilities(self, identity, business_domain):
  98. identity.require_domain(business_domain)
  99. safe = []
  100. for row in self.repository.list_datasource_capabilities(business_domain):
  101. safe.append(
  102. {
  103. "uid": row.get("uid"),
  104. "type": row.get("type"),
  105. "purposes": list(row.get("purposes") or [])[:10],
  106. "health": row.get("health"),
  107. "capacity": {
  108. key: value
  109. for key, value in dict(row.get("capacity") or {}).items()
  110. if key
  111. in {
  112. "available",
  113. "pool_size",
  114. "checked_out",
  115. "max_concurrency",
  116. }
  117. },
  118. }
  119. )
  120. return _page(safe, 50)
  121. def get_datasource_pool_health(
  122. self,
  123. identity,
  124. data_source_uid,
  125. *,
  126. business_domain,
  127. ):
  128. identity.require_domain(business_domain)
  129. row = self.repository.get_datasource_pool_health(data_source_uid)
  130. return {
  131. "uid": row.get("uid"),
  132. "health": row.get("health"),
  133. "capacity": {
  134. key: value
  135. for key, value in dict(row.get("capacity") or {}).items()
  136. if key
  137. in {
  138. "available",
  139. "pool_size",
  140. "checked_out",
  141. "max_concurrency",
  142. }
  143. },
  144. }
  145. def get_execution_history(
  146. self,
  147. identity,
  148. dataflow_uid,
  149. *,
  150. business_domain,
  151. limit=20,
  152. days=7,
  153. ):
  154. identity.require_domain(business_domain)
  155. if limit < 1 or limit > 100:
  156. raise ValueError("limit must be between 1 and 100")
  157. if days < 1 or days > 30:
  158. raise ValueError("days must be between 1 and 30")
  159. safe = []
  160. for row in self.repository.get_execution_history(
  161. dataflow_uid,
  162. limit,
  163. days,
  164. ):
  165. safe.append(
  166. {
  167. "status": row.get("status"),
  168. "correlation_id": row.get("correlation_id"),
  169. "started_at": row.get("started_at"),
  170. "finished_at": row.get("finished_at"),
  171. "safe_error": _untrusted(row.get("safe_error")),
  172. }
  173. )
  174. return _page(safe, limit)
  175. def get_sla_constraints(self, identity, dataflow_uid, *, business_domain):
  176. identity.require_domain(business_domain)
  177. row = self.repository.get_sla_constraints(dataflow_uid)
  178. return {
  179. key: row.get(key)
  180. for key in (
  181. "timezone",
  182. "max_duration_seconds",
  183. "deadline",
  184. "priority",
  185. )
  186. if key in row
  187. }
  188. def estimate_schedule_capacity(
  189. self,
  190. identity,
  191. *,
  192. business_domain,
  193. schedule_plan,
  194. ):
  195. identity.require_domain(business_domain)
  196. plan = normalize_schedule_plan(schedule_plan)
  197. row = self.repository.estimate_schedule_capacity(business_domain, plan)
  198. return {
  199. "feasible": bool(row.get("feasible", False)),
  200. "required_slots": row.get("required_slots"),
  201. "available_slots": row.get("available_slots"),
  202. "warnings": [
  203. _untrusted(value) for value in list(row.get("warnings") or [])[:20]
  204. ],
  205. }
  206. def validate_workflow_spec(self, identity, workflow_spec):
  207. del identity
  208. normalized = normalize_workflow_spec(workflow_spec)
  209. return {
  210. "valid": True,
  211. "workflow_spec": normalized,
  212. "workflow_hash": workflow_spec_hash(normalized),
  213. }
  214. def validate_schedule_plan(self, identity, schedule_plan):
  215. del identity
  216. return {
  217. "valid": True,
  218. "schedule_plan": normalize_schedule_plan(schedule_plan),
  219. }
  220. def simulate_schedule(
  221. self,
  222. identity,
  223. *,
  224. business_domain,
  225. schedule_plan,
  226. ):
  227. identity.require_domain(business_domain)
  228. plan = normalize_schedule_plan(schedule_plan)
  229. row = self.repository.simulate_schedule(business_domain, plan)
  230. return {
  231. "next_runs": list(row.get("next_runs") or [])[:20],
  232. "warnings": [
  233. _untrusted(value) for value in list(row.get("warnings") or [])[:20]
  234. ],
  235. }
  236. def compare_execution_results(
  237. self,
  238. identity,
  239. *,
  240. business_domain,
  241. baseline_execution_id,
  242. candidate_execution_id,
  243. ):
  244. identity.require_domain(business_domain)
  245. row = self.repository.compare_execution_results(
  246. baseline_execution_id,
  247. candidate_execution_id,
  248. )
  249. metrics = {
  250. key: value
  251. for key, value in dict(row.get("metrics") or {}).items()
  252. if key
  253. in {
  254. "row_count_delta",
  255. "duration_delta_seconds",
  256. "error_count_delta",
  257. "output_hash_match",
  258. }
  259. and (value is None or isinstance(value, (bool, int, float)))
  260. }
  261. return {
  262. "equivalent": bool(row.get("equivalent", False)),
  263. "metrics": metrics,
  264. "differences": [
  265. _untrusted(value) for value in list(row.get("differences") or [])[:50]
  266. ],
  267. }