recce-nightly 1.10.0.20250625__py3-none-any.whl → 1.30.0.20251221__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

Files changed (229) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +343 -245
  4. recce/apis/check_api.py +20 -14
  5. recce/apis/check_events_api.py +353 -0
  6. recce/apis/check_func.py +5 -5
  7. recce/apis/run_func.py +32 -3
  8. recce/artifact.py +76 -3
  9. recce/cli.py +705 -82
  10. recce/config.py +2 -2
  11. recce/connect_to_cloud.py +1 -1
  12. recce/core.py +3 -3
  13. recce/data/404/index.html +2 -0
  14. recce/data/404.html +2 -22
  15. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
  16. recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
  17. recce/data/__next.__PAGE__.txt +6 -0
  18. recce/data/__next._full.txt +32 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +14 -0
  21. recce/data/__next._tree.txt +8 -0
  22. recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
  23. recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
  24. recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
  25. recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
  26. recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
  27. recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
  28. recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
  29. recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
  30. recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
  31. recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
  32. recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
  33. recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
  34. recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
  35. recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
  36. recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
  37. recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
  38. recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
  39. recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
  40. recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
  41. recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
  42. recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
  43. recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
  44. recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
  45. recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
  46. recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
  47. recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
  48. recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
  49. recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
  50. recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
  51. recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
  52. recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
  53. recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
  54. recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
  55. recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
  56. recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
  57. recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
  58. recce/data/_next/static/chunks/turbopack-1fad664f62979b93.js +3 -0
  59. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  60. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  61. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  62. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  63. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  64. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  65. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  66. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  67. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  68. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  69. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
  70. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
  71. recce/data/_not-found/__next._full.txt +24 -0
  72. recce/data/_not-found/__next._head.txt +8 -0
  73. recce/data/_not-found/__next._index.txt +13 -0
  74. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  75. recce/data/_not-found/__next._not-found.txt +4 -0
  76. recce/data/_not-found/__next._tree.txt +6 -0
  77. recce/data/_not-found/index.html +2 -0
  78. recce/data/_not-found/index.txt +24 -0
  79. recce/data/auth_callback.html +1 -1
  80. recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
  81. recce/data/checks/__next._full.txt +39 -0
  82. recce/data/checks/__next._head.txt +8 -0
  83. recce/data/checks/__next._index.txt +14 -0
  84. recce/data/checks/__next._tree.txt +8 -0
  85. recce/data/checks/__next.checks.__PAGE__.txt +10 -0
  86. recce/data/checks/__next.checks.txt +4 -0
  87. recce/data/checks/index.html +2 -0
  88. recce/data/checks/index.txt +39 -0
  89. recce/data/index.html +2 -27
  90. recce/data/index.txt +32 -8
  91. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
  92. recce/data/lineage/__next._full.txt +39 -0
  93. recce/data/lineage/__next._head.txt +8 -0
  94. recce/data/lineage/__next._index.txt +14 -0
  95. recce/data/lineage/__next._tree.txt +8 -0
  96. recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
  97. recce/data/lineage/__next.lineage.txt +4 -0
  98. recce/data/lineage/index.html +2 -0
  99. recce/data/lineage/index.txt +39 -0
  100. recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
  101. recce/data/query/__next._full.txt +37 -0
  102. recce/data/query/__next._head.txt +8 -0
  103. recce/data/query/__next._index.txt +14 -0
  104. recce/data/query/__next._tree.txt +8 -0
  105. recce/data/query/__next.query.__PAGE__.txt +9 -0
  106. recce/data/query/__next.query.txt +4 -0
  107. recce/data/query/index.html +2 -0
  108. recce/data/query/index.txt +37 -0
  109. recce/event/CONFIG.bak +1 -0
  110. recce/event/__init__.py +9 -8
  111. recce/event/collector.py +6 -2
  112. recce/event/track.py +10 -0
  113. recce/github.py +1 -1
  114. recce/mcp_server.py +725 -0
  115. recce/models/check.py +433 -15
  116. recce/models/types.py +61 -2
  117. recce/pull_request.py +1 -1
  118. recce/run.py +37 -17
  119. recce/server.py +216 -21
  120. recce/state/__init__.py +31 -0
  121. recce/state/cloud.py +644 -0
  122. recce/state/const.py +26 -0
  123. recce/state/local.py +56 -0
  124. recce/state/state.py +119 -0
  125. recce/state/state_loader.py +174 -0
  126. recce/summary.py +25 -3
  127. recce/tasks/dataframe.py +63 -1
  128. recce/tasks/query.py +40 -3
  129. recce/tasks/rowcount.py +4 -1
  130. recce/tasks/schema.py +4 -1
  131. recce/tasks/utils.py +147 -0
  132. recce/tasks/valuediff.py +85 -57
  133. recce/util/api_token.py +11 -2
  134. recce/util/breaking.py +10 -1
  135. recce/util/cll.py +1 -2
  136. recce/util/cloud/__init__.py +15 -0
  137. recce/util/cloud/base.py +115 -0
  138. recce/util/cloud/check_events.py +190 -0
  139. recce/util/cloud/checks.py +242 -0
  140. recce/util/io.py +2 -2
  141. recce/util/lineage.py +19 -18
  142. recce/util/perf_tracking.py +85 -0
  143. recce/util/recce_cloud.py +254 -5
  144. recce/util/startup_perf.py +121 -0
  145. recce/yaml/__init__.py +2 -2
  146. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/METADATA +91 -71
  147. recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
  148. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
  149. recce/data/_next/static/abCX3x3UoIdRLEDWxx4xd/_buildManifest.js +0 -1
  150. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  151. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  152. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  153. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  154. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  155. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  156. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  157. recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
  158. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  159. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  160. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  161. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  162. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  163. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  164. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  165. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  166. recce/data/_next/static/chunks/92-607cd1af83c41f43.js +0 -1
  167. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  168. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  169. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  170. recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
  171. recce/data/_next/static/chunks/app/page-da6e046a8235dbfc.js +0 -1
  172. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  173. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  174. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  175. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  176. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  177. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  178. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  179. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  180. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  181. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  182. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  183. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  184. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  185. recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
  186. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  187. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  188. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  189. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  190. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  191. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  192. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  193. recce/data/_next/static/media/reload-image.79aabb7d.svg +0 -4
  194. recce/state.py +0 -786
  195. recce_nightly-1.10.0.20250625.dist-info/RECORD +0 -154
  196. recce_nightly-1.10.0.20250625.dist-info/top_level.txt +0 -2
  197. tests/__init__.py +0 -0
  198. tests/adapter/__init__.py +0 -0
  199. tests/adapter/dbt_adapter/__init__.py +0 -0
  200. tests/adapter/dbt_adapter/conftest.py +0 -17
  201. tests/adapter/dbt_adapter/dbt_test_helper.py +0 -298
  202. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -25
  203. tests/adapter/dbt_adapter/test_dbt_cll.py +0 -384
  204. tests/adapter/dbt_adapter/test_selector.py +0 -202
  205. tests/tasks/__init__.py +0 -0
  206. tests/tasks/conftest.py +0 -4
  207. tests/tasks/test_histogram.py +0 -129
  208. tests/tasks/test_lineage.py +0 -55
  209. tests/tasks/test_preset_checks.py +0 -64
  210. tests/tasks/test_profile.py +0 -397
  211. tests/tasks/test_query.py +0 -151
  212. tests/tasks/test_row_count.py +0 -135
  213. tests/tasks/test_schema.py +0 -122
  214. tests/tasks/test_top_k.py +0 -77
  215. tests/tasks/test_valuediff.py +0 -85
  216. tests/test_cli.py +0 -133
  217. tests/test_config.py +0 -43
  218. tests/test_connect_to_cloud.py +0 -82
  219. tests/test_core.py +0 -29
  220. tests/test_dbt.py +0 -36
  221. tests/test_pull_request.py +0 -130
  222. tests/test_server.py +0 -104
  223. tests/test_state.py +0 -134
  224. tests/test_summary.py +0 -65
  225. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  226. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  227. /recce/data/_next/static/{abCX3x3UoIdRLEDWxx4xd → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
  228. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
  229. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
recce/run.py CHANGED
@@ -2,7 +2,7 @@ import os
2
2
  import sys
3
3
  import time
4
4
  from datetime import datetime, timezone
5
- from typing import List
5
+ from typing import Dict, List, Tuple
6
6
 
7
7
  from deepdiff import DeepDiff
8
8
  from rich import box
@@ -17,6 +17,7 @@ from recce.apis.check_func import (
17
17
  from recce.apis.run_func import submit_run
18
18
  from recce.config import RecceConfig
19
19
  from recce.core import default_context
20
+ from recce.models import CheckDAO
20
21
  from recce.models.types import RunType
21
22
  from recce.summary import generate_markdown_summary
22
23
 
@@ -111,7 +112,7 @@ def run_should_be_approved(run):
111
112
  return False
112
113
 
113
114
 
114
- async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
115
+ async def execute_preset_checks(preset_checks: List, is_skip_query: bool) -> Tuple[int, List[Dict]]:
115
116
  """
116
117
  Execute the preset checks
117
118
  """
@@ -155,12 +156,18 @@ async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
155
156
  is_checked=is_check,
156
157
  )
157
158
  else:
158
- run, future = submit_run(check_type, params=check_params)
159
- await future
160
- is_check = run_should_be_approved(run)
161
- create_check_from_run(
162
- run.run_id, check_name, check_description, check_options, is_preset=True, is_checked=is_check
163
- )
159
+ if not is_skip_query:
160
+ run, future = submit_run(check_type, params=check_params)
161
+ await future
162
+ is_check = run_should_be_approved(run)
163
+ create_check_from_run(
164
+ run.run_id, check_name, check_description, check_options, is_preset=True, is_checked=is_check
165
+ )
166
+ else:
167
+ create_check_without_run(
168
+ check_name, check_description, check_type, check_params, check_options, is_preset=True
169
+ )
170
+ continue
164
171
 
165
172
  end = time.time()
166
173
  table.add_row(
@@ -200,7 +207,7 @@ async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
200
207
  return rc, failed_checks
201
208
 
202
209
 
203
- async def execute_state_checks(checks: list) -> (int, List[dict]):
210
+ async def execute_state_checks(checks: List, is_skip_query: bool) -> Tuple[int, List[Dict]]:
204
211
  """
205
212
  Execute the checks from loaded state
206
213
  """
@@ -232,7 +239,7 @@ async def execute_state_checks(checks: list) -> (int, List[dict]):
232
239
  raise ValueError(f"Invalid check type: {check_type}")
233
240
 
234
241
  start = time.time()
235
- if check_type not in ["schema_diff"]:
242
+ if check_type not in ["schema_diff", "lineage_diff"] and not is_skip_query:
236
243
  run, future = submit_run(check_type, params=check_params, check_id=check_id)
237
244
  await future
238
245
 
@@ -295,7 +302,7 @@ def process_failed_checks(failed_checks: List[dict], error_log=None):
295
302
  content += markdown_table(failed_check_table).set_params(quote=False, row_sep="markdown").get_markdown()
296
303
 
297
304
  if error_log:
298
- with open(error_log, "w") as f:
305
+ with open(error_log, "w", encoding="utf-8") as f:
299
306
  f.write(content)
300
307
  print(f"The failed checks are stored at '{error_log}'")
301
308
  else:
@@ -314,7 +321,17 @@ async def cli_run(output_state_file: str, **kwargs):
314
321
 
315
322
  ctx = load_context(**kwargs)
316
323
 
324
+ # Set up the checks if this is a session-based run
325
+ if kwargs.get("session_id") and kwargs.get("state_loader"):
326
+ state_loader = kwargs.get("state_loader")
327
+ try:
328
+ # Try to populate the checks from the database
329
+ state_loader.state.checks = CheckDAO().list()
330
+ except Exception as e:
331
+ console.print(f"[[red]Error[/red]] Failed to load checks from database: {e}")
332
+
317
333
  is_skip_query = kwargs.get("skip_query", False)
334
+ is_skip_check = kwargs.get("skip_check", False)
318
335
 
319
336
  # Prepare the artifact by collecting the lineage
320
337
  console.rule("DBT Artifacts")
@@ -327,23 +344,23 @@ async def cli_run(output_state_file: str, **kwargs):
327
344
  rc = 0
328
345
  if ctx.state_loader.state is None:
329
346
  preset_checks = RecceConfig().get("checks")
330
- if is_skip_query or preset_checks is None or len(preset_checks) == 0:
347
+ if is_skip_check or preset_checks is None or len(preset_checks) == 0:
331
348
  # Skip the preset checks
332
349
  pass
333
350
  else:
334
351
  console.rule("Preset checks")
335
- _, failed_checks = await execute_preset_checks(preset_checks)
352
+ _, failed_checks = await execute_preset_checks(preset_checks, is_skip_query)
336
353
  if failed_checks:
337
354
  console.print("[[yellow]Warning[/yellow]] Preset checks failed. Please see the failed reason.")
338
355
  process_failed_checks(failed_checks, error_log)
339
356
  else:
340
357
  state_checks = ctx.state_loader.state.checks
341
- if is_skip_query or state_checks is None or len(state_checks) == 0:
358
+ if is_skip_check or state_checks is None or len(state_checks) == 0:
342
359
  # Skip the checks in the state
343
360
  pass
344
361
  else:
345
362
  console.rule("Checks")
346
- _, failed_checks = await execute_state_checks(state_checks)
363
+ _, failed_checks = await execute_state_checks(state_checks, is_skip_query)
347
364
  if failed_checks:
348
365
  console.print("[[yellow]Warning[/yellow]] Checks failed. Please see the failed reason.")
349
366
  process_failed_checks(failed_checks, error_log)
@@ -356,14 +373,17 @@ async def cli_run(output_state_file: str, **kwargs):
356
373
  console.rule("Export state")
357
374
  ctx.state_loader.state_file = output_state_file
358
375
  msg = ctx.state_loader.export(ctx.export_state())
359
- console.print(msg)
376
+ if msg is not None:
377
+ console.print(msg)
378
+ else:
379
+ console.print("Export successful")
360
380
 
361
381
  summary_path = kwargs.get("summary")
362
382
  if summary_path:
363
383
  dirs = os.path.dirname(summary_path)
364
384
  if dirs:
365
385
  os.makedirs(dirs, exist_ok=True)
366
- with open(summary_path, "w") as f:
386
+ with open(summary_path, "w", encoding="utf-8") as f:
367
387
  f.write(generate_markdown_summary(ctx))
368
388
  console.print(f"The summary is stored at '{summary_path}'")
369
389
 
recce/server.py CHANGED
@@ -7,6 +7,7 @@ import uuid
7
7
  from contextlib import asynccontextmanager
8
8
  from dataclasses import dataclass
9
9
  from datetime import datetime, timedelta
10
+ from enum import Enum
10
11
  from pathlib import Path
11
12
  from typing import Annotated, Any, Literal, Optional, Set
12
13
 
@@ -29,8 +30,9 @@ from starlette.middleware.gzip import GZipMiddleware
29
30
  from starlette.middleware.sessions import SessionMiddleware
30
31
  from starlette.websockets import WebSocketDisconnect
31
32
 
32
- from . import __latest_version__, __version__, event
33
+ from . import __latest_version__, __version__, event, is_recce_cloud_instance
33
34
  from .apis.check_api import check_router
35
+ from .apis.check_events_api import check_events_router
34
36
  from .apis.run_api import run_router
35
37
  from .config import RecceConfig
36
38
  from .connect_to_cloud import (
@@ -43,12 +45,31 @@ from .connect_to_cloud import (
43
45
  from .core import RecceContext, default_context, load_context
44
46
  from .event import get_recce_api_token, log_api_event, log_single_env_event
45
47
  from .exceptions import RecceException
48
+ from .github import is_github_codespace
46
49
  from .models.types import CllData
47
50
  from .run import load_preset_checks
48
51
  from .state import RecceShareStateManager, RecceStateLoader
52
+ from .util.startup_perf import track_timing
49
53
 
50
54
  logger = logging.getLogger("uvicorn")
51
55
 
56
+ # Idle timeout check interval bounds (in seconds)
57
+ MAX_CHECK_INTERVAL = 30
58
+ MIN_CHECK_INTERVAL = 1
59
+
60
+
61
+ class RecceServerMode(str, Enum):
62
+ server = "server"
63
+ preview = "preview"
64
+ read_only = "read-only"
65
+
66
+ def __str__(self):
67
+ return self.value
68
+
69
+ @staticmethod
70
+ def available_members() -> Set[str]:
71
+ return ["server", "preview", "read-only"]
72
+
52
73
 
53
74
  @dataclass
54
75
  class AppState:
@@ -59,13 +80,19 @@ class AppState:
59
80
  auth_options: Optional[dict] = None
60
81
  lifetime: Optional[int] = None
61
82
  lifetime_expired_at: Optional[datetime] = None
83
+ idle_timeout: Optional[int] = None
84
+ last_activity: Optional[dict] = None
62
85
  share_url: Optional[str] = None
86
+ organization_name: Optional[str] = None
87
+ web_url: Optional[str] = None
88
+ host: Optional[str] = None
89
+ port: Optional[int] = None
63
90
 
64
91
 
65
92
  def schedule_lifetime_termination(app_state):
66
93
  def terminating_server():
67
94
  pid = os.getpid()
68
- logger.info(f"Terminating server process [{pid}] manually")
95
+ logger.info(f"Terminating server process [{pid}] manually due to lifetime expiration")
69
96
  os.kill(pid, signal.SIGINT)
70
97
 
71
98
  # Terminate the server process after the specified lifetime
@@ -74,6 +101,56 @@ def schedule_lifetime_termination(app_state):
74
101
  asyncio.get_running_loop().call_later(app_state.lifetime, terminating_server)
75
102
 
76
103
 
104
+ def schedule_idle_timeout_check(app_state):
105
+ """
106
+ Schedule periodic checks for idle timeout.
107
+ If the server has been idle for longer than idle_timeout, terminate it.
108
+ """
109
+ # Track last activity time in app_state
110
+ app_state.last_activity = {"time": datetime.now(utc)}
111
+
112
+ def terminating_server_idle():
113
+ pid = os.getpid()
114
+ logger.info(f"Terminating server process [{pid}] manually due to idle timeout")
115
+ os.kill(pid, signal.SIGINT)
116
+
117
+ async def check_idle_timeout():
118
+ """Periodically check if the server has been idle for too long"""
119
+ # Use smaller check interval if idle_timeout is very short
120
+ # Check at least every MAX_CHECK_INTERVAL seconds, but also check when idle_timeout is approaching
121
+ check_interval = min(MAX_CHECK_INTERVAL, max(MIN_CHECK_INTERVAL, app_state.idle_timeout // 3))
122
+
123
+ logger.debug(f"[Idle Timeout] Starting idle timeout checker with {check_interval}s check interval")
124
+
125
+ while True:
126
+ await asyncio.sleep(check_interval)
127
+
128
+ idle_seconds = (datetime.now(utc) - app_state.last_activity["time"]).total_seconds()
129
+ remaining_seconds = app_state.idle_timeout - idle_seconds
130
+
131
+ # Always log the countdown for debugging
132
+ if remaining_seconds > 0:
133
+ logger.debug(
134
+ f"[Idle Timeout] Server idle for {idle_seconds:.1f}s / {app_state.idle_timeout}s "
135
+ f"(remaining: {remaining_seconds:.1f}s)"
136
+ )
137
+
138
+ if idle_seconds >= app_state.idle_timeout:
139
+ logger.info(
140
+ f"[Idle Timeout] Threshold reached! Server has been idle for {idle_seconds:.0f} seconds "
141
+ f"(threshold: {app_state.idle_timeout} seconds)"
142
+ )
143
+ terminating_server_idle()
144
+ break
145
+
146
+ # Start the idle timeout check task
147
+ logger.info(f"[Configuration] The idle timeout of the server is {app_state.idle_timeout} seconds")
148
+
149
+ # Create task using asyncio.create_task which works in async context
150
+ task = asyncio.create_task(check_idle_timeout())
151
+ logger.debug(f"[Idle Timeout] Background task created: {task}")
152
+
153
+
77
154
  def setup_server(app_state: AppState) -> RecceContext:
78
155
  from rich.console import Console
79
156
 
@@ -103,46 +180,99 @@ def setup_server(app_state: AppState) -> RecceContext:
103
180
 
104
181
  log_load_state(command="server", single_env=single_env)
105
182
 
106
- if app_state.lifetime is not None and app_state.lifetime > 0:
107
- schedule_lifetime_termination(app_state)
108
-
109
183
  return ctx
110
184
 
111
185
 
112
186
  def teardown_server(app_state: AppState, ctx: RecceContext):
113
- state_loader = app_state.state_loader
187
+ # pull latest state, merge runs/checks and pick the newer artifacts
188
+ state_loader = ctx.state_loader
189
+ state_loader.refresh()
190
+ if state_loader.state:
191
+ ctx.import_state(state_loader.state, merge=True)
114
192
  state_loader.export(ctx.export_state())
115
-
116
193
  ctx.stop_monitor_artifacts()
117
194
  if app_state.flag.get("single_env_onboarding", False):
118
195
  ctx.stop_monitor_base_env()
119
196
 
120
197
 
121
198
  def setup_ready_only(app_state: AppState):
122
- if app_state.lifetime is not None and app_state.lifetime > 0:
123
- schedule_lifetime_termination(app_state)
199
+ pass
124
200
 
125
201
 
126
202
  def teardown_ready_only(app_state: AppState):
127
203
  pass
128
204
 
129
205
 
130
- @asynccontextmanager
131
- async def lifespan(fastapi: FastAPI):
132
- ctx = None
133
- app_state: AppState = app.state
206
+ def setup_preview(app_state: AppState):
207
+ state_loader = app_state.state_loader
208
+ kwargs = app_state.kwargs
209
+ ctx = load_context(**kwargs, state_loader=state_loader)
210
+ return ctx
211
+
212
+
213
+ def teardown_preview(app_state: AppState, ctx: RecceContext):
214
+ state_loader = app_state.state_loader
215
+ state_loader.export(ctx.export_state())
216
+ pass
134
217
 
218
+
219
+ @track_timing("server_setup")
220
+ def _do_lifespan_setup(app_state: AppState):
221
+ """Run server setup and return context for teardown."""
135
222
  if app_state.command == "server":
136
223
  ctx = setup_server(app_state)
137
- elif app_state.command == "read_only":
224
+ elif app_state.command == "read-only":
138
225
  setup_ready_only(app_state)
226
+ ctx = None
227
+ elif app_state.command == "preview":
228
+ ctx = setup_preview(app_state)
229
+ else:
230
+ ctx = None
231
+
232
+ if app_state.lifetime is not None and app_state.lifetime > 0:
233
+ schedule_lifetime_termination(app_state)
234
+
235
+ if app_state.idle_timeout is not None and app_state.idle_timeout > 0:
236
+ logger.debug(f"[Idle Timeout] Scheduling idle timeout check with {app_state.idle_timeout} seconds")
237
+ schedule_idle_timeout_check(app_state)
238
+
239
+ return ctx
240
+
241
+
242
+ @asynccontextmanager
243
+ async def lifespan(fastapi: FastAPI):
244
+ from recce.core import default_context
245
+ from recce.event import log_performance
246
+ from recce.util.startup_perf import clear_startup_tracker, get_startup_tracker
247
+
248
+ app_state: AppState = app.state
249
+
250
+ # Ensure logger is at DEBUG level if debug mode is enabled
251
+ if app_state.kwargs and app_state.kwargs.get("debug"):
252
+ logger.setLevel(logging.DEBUG)
253
+ logger.debug("Debug mode enabled - logger set to DEBUG level")
254
+
255
+ ctx = _do_lifespan_setup(app_state)
256
+
257
+ # Log startup performance metrics
258
+ if tracker := get_startup_tracker():
259
+ tracker.command = app_state.command
260
+ recce_ctx = default_context()
261
+ if recce_ctx and recce_ctx.adapter:
262
+ tracker.adapter_type = type(recce_ctx.adapter).__name__
263
+ if hasattr(recce_ctx.adapter, "curr_manifest") and recce_ctx.adapter.curr_manifest:
264
+ tracker.node_count = len(recce_ctx.adapter.curr_manifest.nodes)
265
+ log_performance("server_startup", tracker.to_dict())
266
+ clear_startup_tracker()
139
267
 
140
268
  yield
141
269
 
142
270
  if app_state.command == "server":
143
271
  teardown_server(app_state, ctx)
144
- elif app_state.command == "read_only":
272
+ elif app_state.command == "read-only":
145
273
  teardown_ready_only(app_state)
274
+ elif app_state.command == "preview":
275
+ teardown_preview(app_state, ctx)
146
276
 
147
277
 
148
278
  app = FastAPI(lifespan=lifespan)
@@ -150,7 +280,7 @@ app = FastAPI(lifespan=lifespan)
150
280
 
151
281
  def verify_json_file(file_path: str) -> bool:
152
282
  try:
153
- with open(file_path, "r") as f:
283
+ with open(file_path, "r", encoding="utf-8") as f:
154
284
  json.load(f)
155
285
  except Exception:
156
286
  return False
@@ -207,6 +337,27 @@ app.add_middleware(
207
337
  )
208
338
 
209
339
 
340
+ @app.middleware("http")
341
+ async def track_activity_for_idle_timeout(request: Request, call_next):
342
+ """Track activity time for idle timeout check"""
343
+ # Exclude paths that should not reset idle timer
344
+ # Health checks and monitoring endpoints don't count as user activity
345
+ excluded_paths = ["/api/health", "/api/ws"]
346
+
347
+ # Update last activity time BEFORE processing request if idle timeout is enabled
348
+ # This ensures long-running requests don't get terminated mid-execution
349
+ app_state: AppState = app.state
350
+ if app_state.last_activity is not None:
351
+ if request.url.path not in excluded_paths:
352
+ app_state.last_activity["time"] = datetime.now(utc)
353
+ logger.debug(f"[Idle Timeout] ✓ Activity detected: {request.method} {request.url.path} - Timer reset")
354
+ else:
355
+ logger.debug(f"[Idle Timeout] Excluded path (no timer reset): {request.method} {request.url.path}")
356
+
357
+ response = await call_next(request)
358
+ return response
359
+
360
+
210
361
  @app.middleware("http")
211
362
  async def set_context_by_cookie(request: Request, call_next):
212
363
  response = await call_next(request)
@@ -239,12 +390,30 @@ async def health_check(request: Request):
239
390
  return {"status": "ok"}
240
391
 
241
392
 
393
+ @app.post("/api/keep-alive")
394
+ async def keep_alive():
395
+ """Endpoint to keep the session alive and reset idle timeout"""
396
+ app_state: AppState = app.state
397
+ if app_state.last_activity is not None:
398
+ app_state.last_activity["time"] = datetime.now(utc)
399
+ logger.debug("[Idle Timeout] Keep-alive request received - Timer reset")
400
+ return {"status": "ok", "idle_timeout_enabled": True}
401
+ return {"status": "ok", "idle_timeout_enabled": False}
402
+
403
+
242
404
  class RecceInstanceInfoOut(BaseModel):
405
+ server_mode: RecceServerMode
243
406
  read_only: bool
407
+ preview: bool
244
408
  single_env: bool
245
409
  authed: bool
410
+ cloud_instance: bool
246
411
  lifetime_expired_at: Optional[datetime] = None
412
+ idle_timeout: Optional[int] = None
247
413
  share_url: Optional[str] = None
414
+ session_id: Optional[str] = None
415
+ organization_name: Optional[str] = None
416
+ web_url: Optional[str] = None
248
417
 
249
418
 
250
419
  @app.get("/api/instance-info", response_model=RecceInstanceInfoOut, response_model_exclude_none=True)
@@ -257,11 +426,18 @@ async def recce_instance_info():
257
426
  api_token = get_recce_api_token()
258
427
 
259
428
  return {
429
+ "server_mode": app_state.command,
260
430
  "read_only": read_only,
431
+ "preview": flag.get("preview", False),
261
432
  "single_env": single_env,
262
433
  "authed": True if api_token else False,
434
+ "cloud_instance": is_recce_cloud_instance(),
263
435
  "lifetime_expired_at": app_state.lifetime_expired_at, # UTC timezone
436
+ "idle_timeout": app_state.idle_timeout,
264
437
  "share_url": app_state.share_url,
438
+ "session_id": app_state.state_loader.session_id if app_state.state_loader else None,
439
+ "organization_name": app_state.organization_name,
440
+ "web_url": app_state.web_url,
265
441
  # TODO: Add more instance info which won't change during the instance lifecycle
266
442
  # review_mode
267
443
  # cloud_mode
@@ -289,6 +465,7 @@ async def get_info():
289
465
  """
290
466
  context = default_context()
291
467
  demo = os.environ.get("DEMO", False)
468
+ is_codespace = is_github_codespace()
292
469
 
293
470
  if demo:
294
471
  state = context.export_demo_state()
@@ -313,6 +490,7 @@ async def get_info():
313
490
  "pull_request": state.pull_request.to_dict() if state.pull_request else None,
314
491
  "lineage": lineage_diff,
315
492
  "demo": bool(demo),
493
+ "codespace": bool(is_codespace),
316
494
  "cloud_mode": context.state_loader.cloud_mode,
317
495
  "file_mode": context.state_loader.state_file is not None,
318
496
  "filename": filename,
@@ -337,9 +515,9 @@ class CllIn(BaseModel):
337
515
  node_id: Optional[str] = None
338
516
  column: Optional[str] = None
339
517
  change_analysis: Optional[bool] = False
340
- cll: Optional[bool] = False
341
- upstream: Optional[bool] = False
342
- downstream: Optional[bool] = False
518
+ no_cll: Optional[bool] = False
519
+ no_upstream: Optional[bool] = False
520
+ no_downstream: Optional[bool] = False
343
521
 
344
522
 
345
523
  class CllOutput(BaseModel):
@@ -355,8 +533,9 @@ async def column_level_lineage_by_node(cll_input: CllIn):
355
533
  node_id=cll_input.node_id,
356
534
  column=cll_input.column,
357
535
  change_analysis=cll_input.change_analysis,
358
- upstream=cll_input.upstream,
359
- downstream=cll_input.downstream,
536
+ no_upstream=cll_input.no_upstream,
537
+ no_downstream=cll_input.no_downstream,
538
+ no_cll=cll_input.no_cll,
360
539
  )
361
540
 
362
541
  return CllOutput(current=cll)
@@ -674,9 +853,25 @@ async def generate_connect_to_cloud_url(background_tasks: BackgroundTasks):
674
853
  }
675
854
 
676
855
 
856
+ @app.get("/api/users")
857
+ async def get_user_info():
858
+ from recce.connect_to_cloud import RecceCloud
859
+
860
+ context = default_context()
861
+ user_token = get_recce_api_token() or context.state_loader.token
862
+ cloud = RecceCloud(user_token)
863
+ try:
864
+ user_info = cloud.get_user_info()
865
+ return user_info
866
+ except Exception as e:
867
+ raise HTTPException(status_code=400, detail=str(e))
868
+
869
+
677
870
  api_prefix = "/api"
678
871
  app.include_router(check_router, prefix=api_prefix)
872
+ app.include_router(check_events_router, prefix=api_prefix)
679
873
  app.include_router(run_router, prefix=api_prefix)
680
874
 
681
875
  static_folder_path = Path(__file__).parent / "data"
876
+
682
877
  app.mount("/", StaticFiles(directory=static_folder_path, html=True), name="static")
@@ -0,0 +1,31 @@
1
+ from .cloud import (
2
+ CloudStateLoader,
3
+ RecceCloudStateManager,
4
+ RecceShareStateManager,
5
+ s3_sse_c_headers,
6
+ )
7
+ from .const import ErrorMessage
8
+ from .local import FileStateLoader
9
+ from .state import (
10
+ ArtifactsRoot,
11
+ GitRepoInfo,
12
+ PullRequestInfo,
13
+ RecceState,
14
+ RecceStateMetadata,
15
+ )
16
+ from .state_loader import RecceStateLoader
17
+
18
+ __all__ = [
19
+ "ArtifactsRoot",
20
+ "ErrorMessage",
21
+ "RecceCloudStateManager",
22
+ "RecceShareStateManager",
23
+ "RecceState",
24
+ "RecceStateLoader",
25
+ "CloudStateLoader",
26
+ "FileStateLoader",
27
+ "RecceStateMetadata",
28
+ "s3_sse_c_headers",
29
+ "GitRepoInfo",
30
+ "PullRequestInfo",
31
+ ]