persistence.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359
  1. """PostgreSQL persistence adapters for the DataOps MCP control plane."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import uuid
  6. from datetime import UTC, datetime
  7. from zoneinfo import ZoneInfo
  8. from croniter import croniter
  9. from sqlalchemy import text
  10. from app.core.common.identifiers import new_governance_uid
  11. def _json(value):
  12. return json.dumps(
  13. value,
  14. sort_keys=True,
  15. separators=(",", ":"),
  16. ensure_ascii=False,
  17. )
  18. def _schedule_hash(schedule_plan):
  19. return hashlib.sha256(_json(schedule_plan).encode("utf-8")).hexdigest()
  20. def _required_string(value, label, maximum=500):
  21. if not isinstance(value, str) or not value.strip():
  22. raise ValueError(f"{label} is required")
  23. normalized = value.strip()
  24. if len(normalized) > maximum:
  25. raise ValueError(f"{label} exceeds {maximum} characters")
  26. return normalized
  27. class PostgresSchedulingPlanStore:
  28. def __init__(self, engine):
  29. self.engine = engine
  30. def create_candidate(self, record):
  31. dataflow_uid = _required_string(record.get("dataflow_uid"), "dataflow_uid", 100)
  32. business_domain = _required_string(
  33. record.get("business_domain"), "business_domain", 200
  34. )
  35. environment = _required_string(record.get("environment"), "environment", 20)
  36. created_by = _required_string(record.get("created_by"), "created_by", 200)
  37. workflow_spec = dict(record.get("workflow_spec") or {})
  38. schedule_plan = dict(record.get("schedule_plan") or {})
  39. workflow_hash = _required_string(
  40. record.get("workflow_hash"), "workflow_hash", 64
  41. )
  42. candidate_id = new_governance_uid()
  43. binding_id = new_governance_uid()
  44. schedule_id = new_governance_uid()
  45. placeholder = f"candidate_{candidate_id.replace('-', '')}"
  46. metadata = {
  47. "gateway_status": "candidate",
  48. "correlation_id": record.get("correlation_id"),
  49. }
  50. with self.engine.begin() as connection:
  51. connection.execute(
  52. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  53. {"key": f"{dataflow_uid}:{environment}"},
  54. )
  55. version_no = connection.execute(
  56. text(
  57. "SELECT COALESCE(MAX(version_no), 0) + 1 "
  58. "FROM public.dataflow_workflow_versions "
  59. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  60. "AND environment = :environment"
  61. ),
  62. {"uid": dataflow_uid, "environment": environment},
  63. ).scalar_one()
  64. connection.execute(
  65. text(
  66. "INSERT INTO public.dataflow_workflow_versions "
  67. "(id, dataflow_uid, environment, version_no, "
  68. "n8n_workflow_id, n8n_workflow_name, definition_hash, "
  69. "definition_snapshot, status, created_by, engine_type, "
  70. "engine_definition_id, engine_revision, "
  71. "deployment_metadata, workflow_spec, schedule_plan, "
  72. "business_domain, created_by_subject, write_authorized) "
  73. "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
  74. ":environment, :version_no, NULL, NULL, :definition_hash, "
  75. "CAST(:definition_snapshot AS jsonb), 'draft', NULL, "
  76. "'kestra', :engine_definition_id, NULL, "
  77. "CAST(:deployment_metadata AS jsonb), "
  78. "CAST(:workflow_spec AS jsonb), "
  79. "CAST(:schedule_plan AS jsonb), :business_domain, "
  80. ":created_by_subject, FALSE)"
  81. ),
  82. {
  83. "id": candidate_id,
  84. "dataflow_uid": dataflow_uid,
  85. "environment": environment,
  86. "version_no": version_no,
  87. "definition_hash": workflow_hash,
  88. "definition_snapshot": _json(workflow_spec),
  89. "engine_definition_id": placeholder,
  90. "deployment_metadata": _json(metadata),
  91. "workflow_spec": _json(workflow_spec),
  92. "schedule_plan": _json(schedule_plan),
  93. "business_domain": business_domain,
  94. "created_by_subject": created_by,
  95. },
  96. )
  97. connection.execute(
  98. text(
  99. "INSERT INTO public.workflow_engine_bindings "
  100. "(id, workflow_version_id, dataflow_uid, environment, "
  101. "engine_type, engine_definition_id, engine_revision, "
  102. "role, status, metadata) "
  103. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  104. "CAST(:dataflow_uid AS uuid), :environment, 'kestra', "
  105. ":engine_definition_id, NULL, 'standby', 'disabled', "
  106. "CAST(:metadata AS jsonb))"
  107. ),
  108. {
  109. "id": binding_id,
  110. "version_id": candidate_id,
  111. "dataflow_uid": dataflow_uid,
  112. "environment": environment,
  113. "engine_definition_id": placeholder,
  114. "metadata": _json(metadata),
  115. },
  116. )
  117. connection.execute(
  118. text(
  119. "INSERT INTO public.workflow_schedules "
  120. "(id, workflow_version_id, schedule_plan, schedule_hash, "
  121. "timezone, status, created_by) "
  122. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  123. "CAST(:schedule_plan AS jsonb), :schedule_hash, "
  124. ":timezone, 'draft', NULL)"
  125. ),
  126. {
  127. "id": schedule_id,
  128. "version_id": candidate_id,
  129. "schedule_plan": _json(schedule_plan),
  130. "schedule_hash": _schedule_hash(schedule_plan),
  131. "timezone": schedule_plan.get("timezone"),
  132. },
  133. )
  134. return {
  135. **record,
  136. "candidate_id": candidate_id,
  137. "version_no": int(version_no),
  138. "status": "candidate",
  139. }
  140. def get_candidate(self, candidate_id):
  141. with self.engine.connect() as connection:
  142. row = (
  143. connection.execute(
  144. text(
  145. "SELECT id::text AS candidate_id, "
  146. "dataflow_uid::text AS dataflow_uid, business_domain, "
  147. "environment, version_no, workflow_spec, schedule_plan, "
  148. "definition_hash AS workflow_hash, status, "
  149. "created_by_subject AS created_by, write_authorized, "
  150. "deployment_metadata "
  151. "FROM public.dataflow_workflow_versions "
  152. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  153. ),
  154. {"id": candidate_id},
  155. )
  156. .mappings()
  157. .one_or_none()
  158. )
  159. if row is None:
  160. raise KeyError(candidate_id)
  161. result = dict(row)
  162. metadata = dict(result.pop("deployment_metadata") or {})
  163. result["status"] = metadata.get("gateway_status", result["status"])
  164. result["trusted_write_authorized"] = bool(result.pop("write_authorized", False))
  165. return result
  166. def record_deployment(self, candidate_id, deployment):
  167. metadata = {
  168. "gateway_status": deployment["status"],
  169. "deployment": deployment,
  170. }
  171. with self.engine.begin() as connection:
  172. updated = connection.execute(
  173. text(
  174. "UPDATE public.dataflow_workflow_versions "
  175. "SET engine_definition_id = :flow_id, "
  176. "engine_revision = :revision, "
  177. "deployment_metadata = deployment_metadata || "
  178. "CAST(:metadata AS jsonb), updated_at = CURRENT_TIMESTAMP "
  179. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  180. ),
  181. {
  182. "id": candidate_id,
  183. "flow_id": deployment["flow_id"],
  184. "revision": deployment.get("engine_revision"),
  185. "metadata": _json(metadata),
  186. },
  187. )
  188. if updated.rowcount != 1:
  189. raise KeyError(candidate_id)
  190. connection.execute(
  191. text(
  192. "UPDATE public.workflow_engine_bindings "
  193. "SET engine_definition_id = :flow_id, "
  194. "engine_revision = :revision, metadata = CAST(:metadata AS jsonb), "
  195. "updated_at = CURRENT_TIMESTAMP "
  196. "WHERE workflow_version_id = CAST(:id AS uuid) "
  197. "AND engine_type = 'kestra'"
  198. ),
  199. {
  200. "id": candidate_id,
  201. "flow_id": deployment["flow_id"],
  202. "revision": deployment.get("engine_revision"),
  203. "metadata": _json(deployment),
  204. },
  205. )
  206. return dict(deployment)
  207. def get_deployment(self, candidate_id):
  208. with self.engine.connect() as connection:
  209. metadata = connection.execute(
  210. text(
  211. "SELECT deployment_metadata "
  212. "FROM public.dataflow_workflow_versions "
  213. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  214. ),
  215. {"id": candidate_id},
  216. ).scalar_one_or_none()
  217. if metadata is None or not dict(metadata).get("deployment"):
  218. raise KeyError(candidate_id)
  219. return dict(metadata)["deployment"]
  220. def get_active_deployment(self, candidate_id):
  221. with self.engine.connect() as connection:
  222. row = (
  223. connection.execute(
  224. text(
  225. "SELECT active.id::text AS candidate_id, "
  226. "active.deployment_metadata "
  227. "FROM public.dataflow_workflow_versions candidate "
  228. "JOIN public.dataflow_workflow_versions active "
  229. "ON active.dataflow_uid = candidate.dataflow_uid "
  230. "AND active.environment = candidate.environment "
  231. "AND active.engine_type = 'kestra' "
  232. "AND active.status = 'active' "
  233. "AND active.id <> candidate.id "
  234. "WHERE candidate.id = CAST(:id AS uuid) "
  235. "ORDER BY active.version_no DESC LIMIT 1"
  236. ),
  237. {"id": candidate_id},
  238. )
  239. .mappings()
  240. .one_or_none()
  241. )
  242. if row is None:
  243. return None
  244. deployment = dict(row["deployment_metadata"] or {}).get("deployment")
  245. if not isinstance(deployment, dict):
  246. raise ValueError("active deployment is incomplete")
  247. return {**deployment, "candidate_id": row["candidate_id"]}
  248. def record_canary_execution(self, candidate_id, evidence):
  249. evidence_id = new_governance_uid()
  250. with self.engine.begin() as connection:
  251. connection.execute(
  252. text(
  253. "INSERT INTO public.workflow_canary_evidence "
  254. "(id, workflow_version_id, engine_execution_id, status, "
  255. "sample_runs, verification_summary) "
  256. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  257. ":execution_id, 'started', 0, '{}'::jsonb)"
  258. ),
  259. {
  260. "id": evidence_id,
  261. "version_id": candidate_id,
  262. "execution_id": evidence["execution_id"],
  263. },
  264. )
  265. return dict(evidence)
  266. def get_canary_evidence(self, candidate_id):
  267. with self.engine.connect() as connection:
  268. row = (
  269. connection.execute(
  270. text(
  271. "SELECT engine_execution_id AS execution_id, status, "
  272. "sample_runs, verified_by, verification_summary "
  273. "FROM public.workflow_canary_evidence "
  274. "WHERE workflow_version_id = CAST(:id AS uuid) "
  275. "ORDER BY created_at DESC LIMIT 1"
  276. ),
  277. {"id": candidate_id},
  278. )
  279. .mappings()
  280. .one_or_none()
  281. )
  282. return dict(row) if row is not None else None
  283. def record_canary_verification(self, candidate_id, evidence):
  284. status = evidence.get("status")
  285. if status not in {"passed", "failed"}:
  286. raise ValueError("canary verification must be passed or failed")
  287. verified_by = _required_string(
  288. evidence.get("verified_by"), "canary verified_by", 200
  289. )
  290. summary = dict(evidence.get("verification_summary") or {})
  291. with self.engine.begin() as connection:
  292. updated = connection.execute(
  293. text(
  294. "UPDATE public.workflow_canary_evidence "
  295. "SET status = :status, sample_runs = :sample_runs, "
  296. "verified_by = :verified_by, "
  297. "verification_summary = CAST(:summary AS jsonb), "
  298. "verified_at = CURRENT_TIMESTAMP "
  299. "WHERE workflow_version_id = CAST(:version_id AS uuid) "
  300. "AND engine_execution_id = :execution_id "
  301. "AND status = 'started'"
  302. ),
  303. {
  304. "status": status,
  305. "sample_runs": int(evidence.get("sample_runs", 0)),
  306. "verified_by": verified_by,
  307. "summary": _json(summary),
  308. "version_id": candidate_id,
  309. "execution_id": evidence.get("execution_id"),
  310. },
  311. )
  312. if updated.rowcount != 1:
  313. raise ValueError("canary evidence is already finalized")
  314. return dict(evidence)
  315. def get_execution_record(self, execution_id):
  316. execution_id = _required_string(execution_id, "execution_id", 255)
  317. with self.engine.connect() as connection:
  318. row = (
  319. connection.execute(
  320. text(
  321. "SELECT run.id::text AS run_id, "
  322. "run.workflow_version_id::text AS workflow_version_id, "
  323. "run.schedule_id::text AS schedule_id, "
  324. "run.engine_execution_id AS execution_id, "
  325. "run.trigger_type, run.status, "
  326. "run.correlation_id::text AS correlation_id, "
  327. "run.attempt, run.run_metadata, "
  328. "version.business_domain, version.environment, "
  329. "(SELECT COUNT(*) FROM public.workflow_runs replay "
  330. "WHERE replay.parent_run_id = run.id) AS retry_count "
  331. "FROM public.workflow_runs run "
  332. "JOIN public.dataflow_workflow_versions version "
  333. "ON version.id = run.workflow_version_id "
  334. "WHERE run.engine_type = 'kestra' "
  335. "AND run.engine_execution_id = :execution_id"
  336. ),
  337. {"execution_id": execution_id},
  338. )
  339. .mappings()
  340. .one_or_none()
  341. )
  342. if row is None:
  343. raise KeyError(execution_id)
  344. return dict(row)
  345. def record_retry(self, execution_id, result):
  346. execution_id = _required_string(execution_id, "execution_id", 255)
  347. replay_execution_id = _required_string(
  348. (result or {}).get("id"), "replay execution id", 255
  349. )
  350. with self.engine.begin() as connection:
  351. original = (
  352. connection.execute(
  353. text(
  354. "SELECT id::text AS run_id, "
  355. "workflow_version_id::text AS workflow_version_id, "
  356. "schedule_id::text AS schedule_id, "
  357. "correlation_id::text AS correlation_id, attempt "
  358. "FROM public.workflow_runs "
  359. "WHERE engine_type = 'kestra' "
  360. "AND engine_execution_id = :execution_id "
  361. "FOR UPDATE"
  362. ),
  363. {"execution_id": execution_id},
  364. )
  365. .mappings()
  366. .one_or_none()
  367. )
  368. if original is None:
  369. raise KeyError(execution_id)
  370. next_attempt = connection.execute(
  371. text(
  372. "SELECT GREATEST(:original_attempt, "
  373. "COALESCE(MAX(attempt), 0)) + 1 "
  374. "FROM public.workflow_runs "
  375. "WHERE parent_run_id = CAST(:run_id AS uuid)"
  376. ),
  377. {
  378. "original_attempt": original["attempt"],
  379. "run_id": original["run_id"],
  380. },
  381. ).scalar_one()
  382. connection.execute(
  383. text(
  384. "INSERT INTO public.workflow_runs "
  385. "(id, workflow_version_id, schedule_id, engine_type, "
  386. "engine_execution_id, trigger_type, status, "
  387. "correlation_id, parent_run_id, attempt, run_metadata) "
  388. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  389. "CAST(:schedule_id AS uuid), 'kestra', :execution_id, "
  390. "'replay', 'queued', CAST(:correlation_id AS uuid), "
  391. "CAST(:parent_run_id AS uuid), :attempt, "
  392. "CAST(:metadata AS jsonb))"
  393. ),
  394. {
  395. "id": new_governance_uid(),
  396. "version_id": original["workflow_version_id"],
  397. "schedule_id": original["schedule_id"],
  398. "execution_id": replay_execution_id,
  399. "correlation_id": original["correlation_id"],
  400. "parent_run_id": original["run_id"],
  401. "attempt": int(next_attempt),
  402. "metadata": _json({"source_execution_id": execution_id}),
  403. },
  404. )
  405. return dict(result)
  406. @staticmethod
  407. def _parse_timestamp(value, label):
  408. value = _required_string(value, label, 100)
  409. try:
  410. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  411. except ValueError as exc:
  412. raise ValueError(f"{label} must be an ISO 8601 timestamp") from exc
  413. if parsed.tzinfo is None:
  414. raise ValueError(f"{label} must include a timezone")
  415. return parsed
  416. def estimate_backfill_runs(self, candidate_id, start, end):
  417. candidate = self.get_candidate(candidate_id)
  418. schedule_plan = dict(candidate.get("schedule_plan") or {})
  419. start_at = self._parse_timestamp(start, "backfill start")
  420. end_at = self._parse_timestamp(end, "backfill end")
  421. if end_at <= start_at:
  422. raise ValueError("backfill end must be after start")
  423. try:
  424. timezone = ZoneInfo(schedule_plan.get("timezone") or "UTC")
  425. except (KeyError, ValueError) as exc:
  426. raise ValueError("schedule timezone is invalid") from exc
  427. start_local = start_at.astimezone(timezone)
  428. end_local = end_at.astimezone(timezone)
  429. total = 0
  430. for trigger in schedule_plan.get("triggers") or []:
  431. if trigger.get("type") != "cron":
  432. continue
  433. expression = _required_string(
  434. trigger.get("expression"), "cron expression", 100
  435. )
  436. if len(expression.split()) != 5:
  437. raise ValueError("backfill estimation supports five-field cron only")
  438. iterator = croniter(expression, start_local)
  439. occurrence = iterator.get_next(datetime)
  440. while occurrence <= end_local:
  441. total += 1
  442. if total > 1000:
  443. return total
  444. occurrence = iterator.get_next(datetime)
  445. if total == 0:
  446. raise ValueError("candidate has no cron occurrences in window")
  447. return total
  448. def record_backfill(self, candidate_id, result):
  449. execution_id = _required_string(
  450. (result or {}).get("id"), "backfill execution id", 255
  451. )
  452. estimated_runs = int((result or {}).get("estimated_runs", 0))
  453. if estimated_runs < 1:
  454. raise ValueError("estimated_runs must be positive")
  455. with self.engine.begin() as connection:
  456. schedule_id = connection.execute(
  457. text(
  458. "SELECT id::text FROM public.workflow_schedules "
  459. "WHERE workflow_version_id = CAST(:id AS uuid) "
  460. "ORDER BY created_at DESC LIMIT 1"
  461. ),
  462. {"id": candidate_id},
  463. ).scalar_one_or_none()
  464. if schedule_id is None:
  465. raise KeyError(candidate_id)
  466. connection.execute(
  467. text(
  468. "INSERT INTO public.workflow_runs "
  469. "(id, workflow_version_id, schedule_id, engine_type, "
  470. "engine_execution_id, trigger_type, status, "
  471. "correlation_id, attempt, run_metadata) "
  472. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  473. "CAST(:schedule_id AS uuid), 'kestra', :execution_id, "
  474. "'backfill', 'queued', CAST(:correlation_id AS uuid), "
  475. "1, CAST(:metadata AS jsonb))"
  476. ),
  477. {
  478. "id": new_governance_uid(),
  479. "version_id": candidate_id,
  480. "schedule_id": schedule_id,
  481. "execution_id": execution_id,
  482. "correlation_id": new_governance_uid(),
  483. "metadata": _json({"estimated_runs": estimated_runs}),
  484. },
  485. )
  486. return dict(result)
  487. def _claim_operation(
  488. self,
  489. action,
  490. idempotency_key,
  491. candidate_id,
  492. *,
  493. actor_subject,
  494. correlation_id,
  495. result,
  496. ):
  497. operation_id = new_governance_uid()
  498. with self.engine.begin() as connection:
  499. inserted = connection.execute(
  500. text(
  501. "INSERT INTO public.workflow_gateway_operations "
  502. "(id, workflow_version_id, action, idempotency_key, status, "
  503. "result, actor_subject, correlation_id) "
  504. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  505. ":action, :key, 'claimed', CAST(:result AS jsonb), "
  506. ":actor, CAST(:correlation_id AS uuid)) "
  507. "ON CONFLICT (action, idempotency_key) DO NOTHING "
  508. "RETURNING id::text"
  509. ),
  510. {
  511. "id": operation_id,
  512. "version_id": candidate_id,
  513. "action": action,
  514. "key": idempotency_key,
  515. "result": _json(result),
  516. "actor": actor_subject,
  517. "correlation_id": correlation_id,
  518. },
  519. ).scalar_one_or_none()
  520. if inserted is not None:
  521. return True, result
  522. previous = (
  523. connection.execute(
  524. text(
  525. "SELECT workflow_version_id::text AS version_id, "
  526. "result FROM public.workflow_gateway_operations "
  527. "WHERE action = :action AND idempotency_key = :key"
  528. ),
  529. {"action": action, "key": idempotency_key},
  530. )
  531. .mappings()
  532. .one()
  533. )
  534. if previous["version_id"] != candidate_id:
  535. raise ValueError("idempotency key belongs to another workflow version")
  536. return False, dict(previous["result"])
  537. def claim_promotion(
  538. self,
  539. idempotency_key,
  540. candidate_id,
  541. *,
  542. actor_subject,
  543. correlation_id,
  544. ):
  545. deployment = self.get_deployment(candidate_id)
  546. result = {
  547. **deployment,
  548. "candidate_id": candidate_id,
  549. "status": "promoted",
  550. }
  551. return self._claim_operation(
  552. "promote_candidate",
  553. idempotency_key,
  554. candidate_id,
  555. actor_subject=actor_subject,
  556. correlation_id=correlation_id,
  557. result=result,
  558. )
  559. def _complete_operation(
  560. self, connection, action, idempotency_key, candidate_id, result
  561. ):
  562. updated = connection.execute(
  563. text(
  564. "UPDATE public.workflow_gateway_operations "
  565. "SET status = 'succeeded', result = CAST(:result AS jsonb), "
  566. "safe_error = NULL, updated_at = CURRENT_TIMESTAMP "
  567. "WHERE action = :action AND idempotency_key = :key "
  568. "AND workflow_version_id = CAST(:version_id AS uuid) "
  569. "AND status = 'claimed'"
  570. ),
  571. {
  572. "action": action,
  573. "key": idempotency_key,
  574. "version_id": candidate_id,
  575. "result": _json(result),
  576. },
  577. )
  578. if updated.rowcount != 1:
  579. raise ValueError("gateway operation is not in claimed state")
  580. def complete_promotion(self, idempotency_key, candidate_id, result):
  581. with self.engine.begin() as connection:
  582. row = connection.execute(
  583. text(
  584. "SELECT dataflow_uid::text, environment "
  585. "FROM public.dataflow_workflow_versions "
  586. "WHERE id = CAST(:id AS uuid) FOR UPDATE"
  587. ),
  588. {"id": candidate_id},
  589. ).one_or_none()
  590. if row is None:
  591. raise KeyError(candidate_id)
  592. dataflow_uid, environment = row
  593. connection.execute(
  594. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  595. {"key": f"{dataflow_uid}:{environment}"},
  596. )
  597. connection.execute(
  598. text(
  599. "UPDATE public.workflow_engine_bindings "
  600. "SET role = 'archived', status = 'disabled', "
  601. "updated_at = CURRENT_TIMESTAMP "
  602. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  603. "AND environment = :environment "
  604. "AND role = 'primary' AND status = 'enabled' "
  605. "AND workflow_version_id <> CAST(:id AS uuid)"
  606. ),
  607. {
  608. "dataflow_uid": dataflow_uid,
  609. "environment": environment,
  610. "id": candidate_id,
  611. },
  612. )
  613. connection.execute(
  614. text(
  615. "UPDATE public.dataflow_workflow_versions "
  616. "SET status = 'superseded', updated_at = CURRENT_TIMESTAMP "
  617. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  618. "AND environment = :environment AND status = 'active' "
  619. "AND id <> CAST(:id AS uuid)"
  620. ),
  621. {
  622. "dataflow_uid": dataflow_uid,
  623. "environment": environment,
  624. "id": candidate_id,
  625. },
  626. )
  627. connection.execute(
  628. text(
  629. "UPDATE public.workflow_schedules SET status = 'retired', "
  630. "updated_at = CURRENT_TIMESTAMP "
  631. "WHERE workflow_version_id IN ("
  632. "SELECT id FROM public.dataflow_workflow_versions "
  633. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  634. "AND environment = :environment "
  635. "AND id <> CAST(:id AS uuid)) "
  636. "AND status = 'active'"
  637. ),
  638. {
  639. "dataflow_uid": dataflow_uid,
  640. "environment": environment,
  641. "id": candidate_id,
  642. },
  643. )
  644. connection.execute(
  645. text(
  646. "UPDATE public.dataflow_workflow_versions "
  647. "SET status = 'active', "
  648. "deployment_metadata = deployment_metadata || "
  649. '\'{"gateway_status":"promoted"}\'::jsonb, '
  650. "updated_at = CURRENT_TIMESTAMP "
  651. "WHERE id = CAST(:id AS uuid)"
  652. ),
  653. {"id": candidate_id},
  654. )
  655. connection.execute(
  656. text(
  657. "UPDATE public.workflow_engine_bindings "
  658. "SET role = 'primary', status = 'enabled', "
  659. "updated_at = CURRENT_TIMESTAMP "
  660. "WHERE workflow_version_id = CAST(:id AS uuid)"
  661. ),
  662. {"id": candidate_id},
  663. )
  664. connection.execute(
  665. text(
  666. "UPDATE public.workflow_schedules "
  667. "SET status = 'active', updated_at = CURRENT_TIMESTAMP "
  668. "WHERE workflow_version_id = CAST(:id AS uuid)"
  669. ),
  670. {"id": candidate_id},
  671. )
  672. self._complete_operation(
  673. connection,
  674. "promote_candidate",
  675. idempotency_key,
  676. candidate_id,
  677. result,
  678. )
  679. return result
  680. def release_promotion(self, idempotency_key, candidate_id):
  681. self._release_operation("promote_candidate", idempotency_key, candidate_id)
  682. def mark_paused(self, candidate_id):
  683. with self.engine.begin() as connection:
  684. connection.execute(
  685. text(
  686. "UPDATE public.workflow_engine_bindings "
  687. "SET status = 'disabled', updated_at = CURRENT_TIMESTAMP "
  688. "WHERE workflow_version_id = CAST(:id AS uuid)"
  689. ),
  690. {"id": candidate_id},
  691. )
  692. connection.execute(
  693. text(
  694. "UPDATE public.workflow_schedules "
  695. "SET status = 'paused', updated_at = CURRENT_TIMESTAMP "
  696. "WHERE workflow_version_id = CAST(:id AS uuid)"
  697. ),
  698. {"id": candidate_id},
  699. )
  700. updated = connection.execute(
  701. text(
  702. "UPDATE public.dataflow_workflow_versions "
  703. "SET deployment_metadata = deployment_metadata || "
  704. '\'{"gateway_status":"paused"}\'::jsonb, '
  705. "updated_at = CURRENT_TIMESTAMP "
  706. "WHERE id = CAST(:id AS uuid)"
  707. ),
  708. {"id": candidate_id},
  709. )
  710. if updated.rowcount != 1:
  711. raise KeyError(candidate_id)
  712. def get_previous_deployment(self, candidate_id):
  713. with self.engine.connect() as connection:
  714. row = (
  715. connection.execute(
  716. text(
  717. "SELECT previous.id::text AS candidate_id, "
  718. "previous.deployment_metadata "
  719. "FROM public.dataflow_workflow_versions current "
  720. "JOIN LATERAL ("
  721. "SELECT id, deployment_metadata "
  722. "FROM public.dataflow_workflow_versions candidate "
  723. "WHERE candidate.dataflow_uid = current.dataflow_uid "
  724. "AND candidate.environment = current.environment "
  725. "AND candidate.engine_type = 'kestra' "
  726. "AND candidate.version_no < current.version_no "
  727. "ORDER BY candidate.version_no DESC LIMIT 1"
  728. ") previous ON TRUE "
  729. "WHERE current.id = CAST(:id AS uuid)"
  730. ),
  731. {"id": candidate_id},
  732. )
  733. .mappings()
  734. .one_or_none()
  735. )
  736. if row is None:
  737. raise ValueError("previous deployment is not available")
  738. deployment = dict(row["deployment_metadata"] or {}).get("deployment")
  739. if not isinstance(deployment, dict):
  740. raise ValueError("previous deployment is incomplete")
  741. return {**deployment, "candidate_id": row["candidate_id"]}
  742. def claim_rollback(
  743. self,
  744. idempotency_key,
  745. candidate_id,
  746. *,
  747. actor_subject,
  748. correlation_id,
  749. ):
  750. previous = self.get_previous_deployment(candidate_id)
  751. result = {
  752. "candidate_id": candidate_id,
  753. "status": "rolled_back",
  754. "active_candidate_id": previous["candidate_id"],
  755. }
  756. return self._claim_operation(
  757. "rollback_to_previous_version",
  758. idempotency_key,
  759. candidate_id,
  760. actor_subject=actor_subject,
  761. correlation_id=correlation_id,
  762. result=result,
  763. )
  764. def complete_rollback(self, idempotency_key, candidate_id, result):
  765. previous = self.get_previous_deployment(candidate_id)
  766. previous_id = previous["candidate_id"]
  767. with self.engine.begin() as connection:
  768. connection.execute(
  769. text(
  770. "UPDATE public.dataflow_workflow_versions "
  771. "SET status = 'superseded', "
  772. "deployment_metadata = deployment_metadata || "
  773. '\'{"gateway_status":"rolled_back"}\'::jsonb, '
  774. "updated_at = CURRENT_TIMESTAMP "
  775. "WHERE id = CAST(:id AS uuid)"
  776. ),
  777. {"id": candidate_id},
  778. )
  779. connection.execute(
  780. text(
  781. "UPDATE public.workflow_engine_bindings "
  782. "SET role = 'archived', status = 'disabled', "
  783. "updated_at = CURRENT_TIMESTAMP "
  784. "WHERE workflow_version_id = CAST(:id AS uuid)"
  785. ),
  786. {"id": candidate_id},
  787. )
  788. connection.execute(
  789. text(
  790. "UPDATE public.workflow_schedules "
  791. "SET status = 'retired', updated_at = CURRENT_TIMESTAMP "
  792. "WHERE workflow_version_id = CAST(:id AS uuid)"
  793. ),
  794. {"id": candidate_id},
  795. )
  796. connection.execute(
  797. text(
  798. "UPDATE public.dataflow_workflow_versions "
  799. "SET status = 'active', "
  800. "deployment_metadata = deployment_metadata || "
  801. '\'{"gateway_status":"promoted"}\'::jsonb, '
  802. "updated_at = CURRENT_TIMESTAMP "
  803. "WHERE id = CAST(:id AS uuid)"
  804. ),
  805. {"id": previous_id},
  806. )
  807. connection.execute(
  808. text(
  809. "UPDATE public.workflow_engine_bindings "
  810. "SET role = 'primary', status = 'enabled', "
  811. "updated_at = CURRENT_TIMESTAMP "
  812. "WHERE workflow_version_id = CAST(:id AS uuid)"
  813. ),
  814. {"id": previous_id},
  815. )
  816. connection.execute(
  817. text(
  818. "UPDATE public.workflow_schedules "
  819. "SET status = 'active', updated_at = CURRENT_TIMESTAMP "
  820. "WHERE workflow_version_id = CAST(:id AS uuid)"
  821. ),
  822. {"id": previous_id},
  823. )
  824. self._complete_operation(
  825. connection,
  826. "rollback_to_previous_version",
  827. idempotency_key,
  828. candidate_id,
  829. result,
  830. )
  831. return result
  832. def release_rollback(self, idempotency_key, candidate_id):
  833. self._release_operation(
  834. "rollback_to_previous_version", idempotency_key, candidate_id
  835. )
  836. def _release_operation(self, action, idempotency_key, candidate_id):
  837. with self.engine.begin() as connection:
  838. connection.execute(
  839. text(
  840. "DELETE FROM public.workflow_gateway_operations "
  841. "WHERE action = :action AND idempotency_key = :key "
  842. "AND workflow_version_id = CAST(:version_id AS uuid) "
  843. "AND status = 'claimed'"
  844. ),
  845. {
  846. "action": action,
  847. "key": idempotency_key,
  848. "version_id": candidate_id,
  849. },
  850. )
  851. class PostgresContextRepository:
  852. """Secret-free PostgreSQL read model for the Context MCP server."""
  853. def __init__(self, engine, *, max_concurrency=10, clock=None):
  854. self.engine = engine
  855. self.max_concurrency = int(max_concurrency)
  856. self.clock = clock or (lambda: datetime.now(UTC))
  857. if self.max_concurrency < 1 or self.max_concurrency > 1000:
  858. raise ValueError("context max concurrency is invalid")
  859. @staticmethod
  860. def _dataflow_row(row):
  861. if row is None:
  862. return None
  863. value = dict(row)
  864. spec = dict(value.pop("workflow_spec") or {})
  865. return {
  866. "uid": value["uid"],
  867. "name": spec.get("name"),
  868. "description": spec.get("description"),
  869. "business_domain": value.get("business_domain"),
  870. "status": value.get("status"),
  871. }
  872. def list_dataflows(self):
  873. with self.engine.connect() as connection:
  874. rows = (
  875. connection.execute(
  876. text(
  877. "SELECT DISTINCT ON (dataflow_uid) "
  878. "dataflow_uid::text AS uid, business_domain, status, "
  879. "workflow_spec "
  880. "FROM public.dataflow_workflow_versions "
  881. "WHERE engine_type = 'kestra' "
  882. "ORDER BY dataflow_uid, version_no DESC"
  883. )
  884. )
  885. .mappings()
  886. .all()
  887. )
  888. return [self._dataflow_row(row) for row in rows]
  889. def describe_dataflow(self, dataflow_uid):
  890. with self.engine.connect() as connection:
  891. row = (
  892. connection.execute(
  893. text(
  894. "SELECT dataflow_uid::text AS uid, business_domain, "
  895. "status, workflow_spec "
  896. "FROM public.dataflow_workflow_versions "
  897. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  898. "AND engine_type = 'kestra' "
  899. "ORDER BY version_no DESC LIMIT 1"
  900. ),
  901. {"uid": dataflow_uid},
  902. )
  903. .mappings()
  904. .one_or_none()
  905. )
  906. if row is None:
  907. raise KeyError(dataflow_uid)
  908. return self._dataflow_row(row)
  909. def _latest_spec(self, dataflow_uid):
  910. with self.engine.connect() as connection:
  911. row = (
  912. connection.execute(
  913. text(
  914. "SELECT workflow_spec, business_domain "
  915. "FROM public.dataflow_workflow_versions "
  916. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  917. "AND engine_type = 'kestra' "
  918. "ORDER BY version_no DESC LIMIT 1"
  919. ),
  920. {"uid": dataflow_uid},
  921. )
  922. .mappings()
  923. .one_or_none()
  924. )
  925. if row is None:
  926. raise KeyError(dataflow_uid)
  927. return dict(row["workflow_spec"] or {}), row["business_domain"]
  928. def get_dataflow_dependencies(self, dataflow_uid):
  929. spec, domain = self._latest_spec(dataflow_uid)
  930. dependencies = []
  931. seen = set()
  932. for node in spec.get("nodes") or []:
  933. if node.get("type") != "subflow":
  934. continue
  935. dependency_uid = (node.get("config") or {}).get("dataflow_uid")
  936. if not dependency_uid or dependency_uid in seen:
  937. continue
  938. seen.add(dependency_uid)
  939. try:
  940. dependency = self.describe_dataflow(dependency_uid)
  941. except (KeyError, ValueError):
  942. continue
  943. dependencies.append(
  944. {
  945. "uid": dependency["uid"],
  946. "name": dependency["name"],
  947. "relation": "subflow",
  948. "business_domain": domain,
  949. }
  950. )
  951. return dependencies
  952. def get_data_lineage(self, dataflow_uid, depth):
  953. del depth
  954. spec, _ = self._latest_spec(dataflow_uid)
  955. nodes = {node.get("id"): node for node in spec.get("nodes") or []}
  956. return [
  957. {
  958. "from_uid": (
  959. nodes.get(edge.get("from"), {}).get("data_source_uid")
  960. or f"{dataflow_uid}:{edge.get('from')}"
  961. ),
  962. "to_uid": (
  963. nodes.get(edge.get("to"), {}).get("data_source_uid")
  964. or f"{dataflow_uid}:{edge.get('to')}"
  965. ),
  966. "relation": "workflow_edge",
  967. "description": edge.get("condition"),
  968. }
  969. for edge in spec.get("edges") or []
  970. ]
  971. def list_datasource_capabilities(self, business_domain):
  972. with self.engine.connect() as connection:
  973. rows = (
  974. connection.execute(
  975. text(
  976. "WITH latest AS ("
  977. "SELECT DISTINCT ON (dataflow_uid) workflow_spec "
  978. "FROM public.dataflow_workflow_versions "
  979. "WHERE engine_type = 'kestra' "
  980. "AND business_domain = :domain "
  981. "ORDER BY dataflow_uid, version_no DESC"
  982. "), nodes AS ("
  983. "SELECT jsonb_array_elements("
  984. "COALESCE(workflow_spec->'nodes', '[]'::jsonb)) AS node "
  985. "FROM latest"
  986. "), sources AS ("
  987. "SELECT node->>'data_source_uid' AS uid, "
  988. "array_agg(DISTINCT COALESCE("
  989. "node->>'purpose', 'read')) AS purposes "
  990. "FROM nodes WHERE node ? 'data_source_uid' "
  991. "GROUP BY node->>'data_source_uid'"
  992. ") SELECT sources.uid, sources.purposes, "
  993. "credentials.status AS credential_status "
  994. "FROM sources LEFT JOIN "
  995. "public.datasource_credentials credentials "
  996. "ON credentials.data_source_uid = "
  997. "CAST(sources.uid AS uuid) "
  998. "AND credentials.status = 'active'"
  999. ),
  1000. {"domain": business_domain},
  1001. )
  1002. .mappings()
  1003. .all()
  1004. )
  1005. return [
  1006. {
  1007. "uid": row["uid"],
  1008. "type": "external",
  1009. "purposes": list(row["purposes"] or []),
  1010. "health": (
  1011. "configured"
  1012. if row["credential_status"] == "active"
  1013. else "credential_unavailable"
  1014. ),
  1015. "capacity": {"available": row["credential_status"] == "active"},
  1016. }
  1017. for row in rows
  1018. ]
  1019. def get_datasource_pool_health(self, data_source_uid):
  1020. with self.engine.connect() as connection:
  1021. configured = bool(
  1022. connection.execute(
  1023. text(
  1024. "SELECT EXISTS("
  1025. "SELECT 1 FROM public.datasource_credentials "
  1026. "WHERE data_source_uid = CAST(:uid AS uuid) "
  1027. "AND status = 'active')"
  1028. ),
  1029. {"uid": data_source_uid},
  1030. ).scalar_one()
  1031. )
  1032. return {
  1033. "uid": data_source_uid,
  1034. "health": "configured" if configured else "credential_unavailable",
  1035. "capacity": {"available": configured},
  1036. }
  1037. def get_execution_history(self, dataflow_uid, limit, days):
  1038. with self.engine.connect() as connection:
  1039. rows = (
  1040. connection.execute(
  1041. text(
  1042. "SELECT run.status, run.correlation_id::text "
  1043. "AS correlation_id, run.started_at, run.finished_at, "
  1044. "run.run_metadata->>'safe_error' AS safe_error "
  1045. "FROM public.workflow_runs run "
  1046. "JOIN public.dataflow_workflow_versions version "
  1047. "ON version.id = run.workflow_version_id "
  1048. "WHERE version.dataflow_uid = CAST(:uid AS uuid) "
  1049. "AND run.created_at >= "
  1050. "CURRENT_TIMESTAMP - make_interval(days => :days) "
  1051. "ORDER BY run.created_at DESC LIMIT :limit"
  1052. ),
  1053. {
  1054. "uid": dataflow_uid,
  1055. "days": int(days),
  1056. "limit": int(limit),
  1057. },
  1058. )
  1059. .mappings()
  1060. .all()
  1061. )
  1062. return [
  1063. {
  1064. **dict(row),
  1065. "started_at": (
  1066. row["started_at"].isoformat()
  1067. if row["started_at"] is not None
  1068. else None
  1069. ),
  1070. "finished_at": (
  1071. row["finished_at"].isoformat()
  1072. if row["finished_at"] is not None
  1073. else None
  1074. ),
  1075. }
  1076. for row in rows
  1077. ]
  1078. def get_sla_constraints(self, dataflow_uid):
  1079. with self.engine.connect() as connection:
  1080. plan = connection.execute(
  1081. text(
  1082. "SELECT schedule_plan "
  1083. "FROM public.dataflow_workflow_versions "
  1084. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  1085. "AND engine_type = 'kestra' "
  1086. "ORDER BY version_no DESC LIMIT 1"
  1087. ),
  1088. {"uid": dataflow_uid},
  1089. ).scalar_one_or_none()
  1090. if plan is None:
  1091. raise KeyError(dataflow_uid)
  1092. plan = dict(plan)
  1093. return {
  1094. "timezone": plan.get("timezone"),
  1095. "max_duration_seconds": plan.get("timeout_seconds"),
  1096. }
  1097. def estimate_schedule_capacity(self, business_domain, schedule_plan):
  1098. with self.engine.connect() as connection:
  1099. running = int(
  1100. connection.execute(
  1101. text(
  1102. "SELECT COUNT(*) FROM public.workflow_runs run "
  1103. "JOIN public.dataflow_workflow_versions version "
  1104. "ON version.id = run.workflow_version_id "
  1105. "WHERE version.business_domain = :domain "
  1106. "AND run.status IN ('queued', 'running')"
  1107. ),
  1108. {"domain": business_domain},
  1109. ).scalar_one()
  1110. )
  1111. available = max(0, self.max_concurrency - running)
  1112. required = int(schedule_plan.get("max_concurrency", 1))
  1113. return {
  1114. "feasible": required <= available,
  1115. "required_slots": required,
  1116. "available_slots": available,
  1117. "warnings": (
  1118. []
  1119. if required <= available
  1120. else ["requested concurrency exceeds current capacity"]
  1121. ),
  1122. }
  1123. def simulate_schedule(self, business_domain, schedule_plan):
  1124. del business_domain
  1125. now = self.clock()
  1126. timezone = ZoneInfo(schedule_plan.get("timezone") or "UTC")
  1127. local_now = now.astimezone(timezone)
  1128. next_runs = []
  1129. warnings = []
  1130. for trigger in schedule_plan.get("triggers") or []:
  1131. kind = trigger.get("type")
  1132. if kind == "cron":
  1133. expression = trigger.get("expression", "")
  1134. if len(str(expression).split()) != 5:
  1135. warnings.append("simulation supports five-field cron only")
  1136. continue
  1137. iterator = croniter(expression, local_now)
  1138. next_runs.extend(
  1139. iterator.get_next(datetime).isoformat() for _ in range(5)
  1140. )
  1141. elif kind == "at":
  1142. next_runs.append(str(trigger.get("at")))
  1143. elif kind in {"manual", "event"}:
  1144. warnings.append(f"{kind} trigger has no deterministic next run")
  1145. return {
  1146. "next_runs": sorted(next_runs)[:20],
  1147. "warnings": warnings,
  1148. }
  1149. def compare_execution_results(self, baseline_execution_id, candidate_execution_id):
  1150. with self.engine.connect() as connection:
  1151. rows = (
  1152. connection.execute(
  1153. text(
  1154. "SELECT engine_execution_id, status, started_at, "
  1155. "finished_at, run_metadata "
  1156. "FROM public.workflow_runs "
  1157. "WHERE engine_type = 'kestra' "
  1158. "AND engine_execution_id IN (:baseline, :candidate)"
  1159. ),
  1160. {
  1161. "baseline": baseline_execution_id,
  1162. "candidate": candidate_execution_id,
  1163. },
  1164. )
  1165. .mappings()
  1166. .all()
  1167. )
  1168. by_id = {row["engine_execution_id"]: dict(row) for row in rows}
  1169. if baseline_execution_id not in by_id or candidate_execution_id not in by_id:
  1170. raise KeyError("execution comparison input is unavailable")
  1171. baseline = by_id[baseline_execution_id]
  1172. candidate = by_id[candidate_execution_id]
  1173. def duration(row):
  1174. if row["started_at"] is None or row["finished_at"] is None:
  1175. return None
  1176. return (row["finished_at"] - row["started_at"]).total_seconds()
  1177. baseline_duration = duration(baseline)
  1178. candidate_duration = duration(candidate)
  1179. baseline_metadata = dict(baseline["run_metadata"] or {})
  1180. candidate_metadata = dict(candidate["run_metadata"] or {})
  1181. baseline_hash = baseline_metadata.get("output_hash")
  1182. candidate_hash = candidate_metadata.get("output_hash")
  1183. hash_match = (
  1184. baseline_hash == candidate_hash
  1185. if baseline_hash is not None and candidate_hash is not None
  1186. else None
  1187. )
  1188. duration_delta = (
  1189. candidate_duration - baseline_duration
  1190. if baseline_duration is not None and candidate_duration is not None
  1191. else None
  1192. )
  1193. equivalent = (
  1194. baseline["status"] == "success"
  1195. and candidate["status"] == "success"
  1196. and hash_match is not False
  1197. )
  1198. return {
  1199. "equivalent": equivalent,
  1200. "metrics": {
  1201. "duration_delta_seconds": duration_delta,
  1202. "output_hash_match": hash_match,
  1203. },
  1204. "differences": (
  1205. [] if equivalent else ["execution status or output hash differs"]
  1206. ),
  1207. }
  1208. class PostgresAuditSink:
  1209. def __init__(self, engine):
  1210. self.engine = engine
  1211. def record(self, event):
  1212. detail = dict(event.get("detail") or {})
  1213. candidate_id = detail.get("candidate_id")
  1214. try:
  1215. workflow_version_id = str(uuid.UUID(candidate_id)) if candidate_id else None
  1216. correlation_id = str(uuid.UUID(event["correlation_id"]))
  1217. except (TypeError, ValueError) as exc:
  1218. raise ValueError("audit identifiers must be UUIDs") from exc
  1219. decision = event.get("decision")
  1220. if decision not in {
  1221. "allowed",
  1222. "rejected",
  1223. "failed",
  1224. "recorded",
  1225. "idempotent_replay",
  1226. }:
  1227. raise ValueError("unsupported audit decision")
  1228. bounded_detail = {
  1229. **detail,
  1230. "roles": list(event.get("roles") or [])[:20],
  1231. "business_domain": event.get("business_domain"),
  1232. "environment": event.get("environment"),
  1233. }
  1234. encoded_detail = _json(bounded_detail)
  1235. if len(encoded_detail.encode("utf-8")) > 1048576:
  1236. raise ValueError("audit detail exceeds size limit")
  1237. def optional_string(name, maximum):
  1238. value = event.get(name)
  1239. return (
  1240. _required_string(value, f"audit {name}", maximum)
  1241. if value is not None
  1242. else None
  1243. )
  1244. def optional_hash(value, label):
  1245. if value is None:
  1246. return None
  1247. normalized = _required_string(value, label, 64).lower()
  1248. if len(normalized) != 64 or any(
  1249. character not in "0123456789abcdef" for character in normalized
  1250. ):
  1251. raise ValueError(f"{label} must be a SHA-256 hex digest")
  1252. return normalized
  1253. schema_version = _required_string(
  1254. event.get("schema_version", "v53"),
  1255. "audit schema_version",
  1256. 40,
  1257. )
  1258. context_hash = optional_hash(
  1259. event.get("context_hash"),
  1260. "audit context_hash",
  1261. )
  1262. candidate_hash = optional_hash(
  1263. detail.get("workflow_hash"),
  1264. "audit candidate_hash",
  1265. )
  1266. with self.engine.begin() as connection:
  1267. connection.execute(
  1268. text(
  1269. "INSERT INTO public.workflow_plan_audits "
  1270. "(id, workflow_version_id, actor_uid, actor_subject, action, "
  1271. "model_provider, model_name, prompt_version, schema_version, "
  1272. "context_hash, candidate_hash, decision, decision_detail, "
  1273. "correlation_id) "
  1274. "VALUES (CAST(:id AS uuid), "
  1275. "CAST(:workflow_version_id AS uuid), NULL, :actor_subject, "
  1276. ":action, :model_provider, :model_name, :prompt_version, "
  1277. ":schema_version, :context_hash, :candidate_hash, :decision, "
  1278. "CAST(:detail AS jsonb), CAST(:correlation_id AS uuid))"
  1279. ),
  1280. {
  1281. "id": new_governance_uid(),
  1282. "workflow_version_id": workflow_version_id,
  1283. "actor_subject": _required_string(
  1284. event.get("subject"), "audit subject", 200
  1285. ),
  1286. "action": _required_string(event.get("action"), "audit action", 80),
  1287. "model_provider": optional_string("model_provider", 80),
  1288. "model_name": optional_string("model_name", 120),
  1289. "prompt_version": optional_string("prompt_version", 80),
  1290. "schema_version": schema_version,
  1291. "context_hash": context_hash,
  1292. "candidate_hash": candidate_hash,
  1293. "decision": decision,
  1294. "detail": encoded_detail,
  1295. "correlation_id": correlation_id,
  1296. },
  1297. )