recce-nightly 1.9.0.20250623__py3-none-any.whl → 1.25.0.20251112a2066__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.
Files changed (169) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +318 -240
  4. recce/artifact.py +76 -3
  5. recce/cli.py +703 -71
  6. recce/config.py +3 -3
  7. recce/connect_to_cloud.py +138 -0
  8. recce/core.py +3 -3
  9. recce/data/404.html +1 -22
  10. recce/data/__next.__PAGE__.txt +10 -0
  11. recce/data/__next._full.txt +23 -0
  12. recce/data/__next._index.txt +8 -0
  13. recce/data/__next._tree.txt +12 -0
  14. recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
  15. recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_clientMiddlewareManifest.json +1 -0
  16. recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
  17. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  18. recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
  19. recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
  20. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  21. recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
  22. recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
  23. recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
  24. recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
  25. recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -0
  26. recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
  27. recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
  28. recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
  29. recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
  30. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  31. recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
  32. recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
  33. recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
  34. recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
  35. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  36. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  37. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  38. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  39. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  40. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  41. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  42. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  43. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  44. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  45. recce/data/_not-found/__next._full.txt +17 -0
  46. recce/data/_not-found/__next._index.txt +8 -0
  47. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  48. recce/data/_not-found/__next._not-found.txt +4 -0
  49. recce/data/_not-found/__next._tree.txt +10 -0
  50. recce/data/_not-found.html +1 -0
  51. recce/data/_not-found.txt +17 -0
  52. recce/data/auth_callback.html +68 -0
  53. recce/data/index.html +1 -27
  54. recce/data/index.txt +23 -8
  55. recce/event/__init__.py +9 -8
  56. recce/event/collector.py +6 -2
  57. recce/event/track.py +10 -0
  58. recce/github.py +1 -1
  59. recce/mcp_server.py +632 -0
  60. recce/models/types.py +23 -2
  61. recce/pull_request.py +1 -1
  62. recce/run.py +23 -16
  63. recce/server.py +194 -19
  64. recce/state/__init__.py +31 -0
  65. recce/state/cloud.py +632 -0
  66. recce/state/const.py +26 -0
  67. recce/state/local.py +56 -0
  68. recce/state/state.py +119 -0
  69. recce/state/state_loader.py +174 -0
  70. recce/summary.py +2 -1
  71. recce/tasks/dataframe.py +59 -2
  72. recce/tasks/rowcount.py +4 -1
  73. recce/tasks/schema.py +4 -1
  74. recce/tasks/valuediff.py +1 -1
  75. recce/util/api_token.py +11 -2
  76. recce/util/breaking.py +9 -0
  77. recce/util/cll.py +1 -2
  78. recce/util/io.py +2 -2
  79. recce/util/lineage.py +19 -18
  80. recce/util/perf_tracking.py +85 -0
  81. recce/util/recce_cloud.py +229 -5
  82. recce/yaml/__init__.py +2 -2
  83. recce_cloud/__init__.py +15 -0
  84. recce_cloud/api/__init__.py +17 -0
  85. recce_cloud/api/base.py +104 -0
  86. recce_cloud/api/client.py +150 -0
  87. recce_cloud/api/exceptions.py +26 -0
  88. recce_cloud/api/factory.py +63 -0
  89. recce_cloud/api/github.py +72 -0
  90. recce_cloud/api/gitlab.py +78 -0
  91. recce_cloud/artifact.py +57 -0
  92. recce_cloud/ci_providers/__init__.py +9 -0
  93. recce_cloud/ci_providers/base.py +82 -0
  94. recce_cloud/ci_providers/detector.py +147 -0
  95. recce_cloud/ci_providers/github_actions.py +136 -0
  96. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  97. recce_cloud/cli.py +303 -0
  98. recce_cloud/upload.py +213 -0
  99. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/METADATA +31 -27
  100. recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
  101. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/top_level.txt +1 -0
  102. tests/adapter/dbt_adapter/test_dbt_cll.py +412 -79
  103. tests/recce_cloud/__init__.py +0 -0
  104. tests/recce_cloud/test_ci_providers.py +351 -0
  105. tests/recce_cloud/test_cli.py +372 -0
  106. tests/recce_cloud/test_client.py +273 -0
  107. tests/recce_cloud/test_platform_clients.py +279 -0
  108. tests/test_cli.py +106 -3
  109. tests/test_cli_mcp_optional.py +45 -0
  110. tests/test_cloud_listing_cli.py +324 -0
  111. tests/test_connect_to_cloud.py +82 -0
  112. tests/test_core.py +148 -3
  113. tests/test_mcp_server.py +332 -0
  114. tests/test_server.py +6 -6
  115. tests/test_summary.py +14 -6
  116. recce/data/_next/static/WrRUb3nV8BhAZG_R8kVma/_buildManifest.js +0 -1
  117. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  118. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  119. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  120. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  121. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  122. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  123. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  124. recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
  125. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  126. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  127. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  128. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  129. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  130. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  131. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  132. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  133. recce/data/_next/static/chunks/92-7ab55ae02606193c.js +0 -1
  134. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  135. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  136. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  137. recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
  138. recce/data/_next/static/chunks/app/page-59241c42b7dd4fcf.js +0 -1
  139. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  140. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  141. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  142. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  143. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  144. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  145. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  146. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  147. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  148. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  149. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  150. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  151. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  152. recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
  153. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  154. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  155. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  158. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  159. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  160. recce/state.py +0 -785
  161. recce_nightly-1.9.0.20250623.dist-info/RECORD +0 -151
  162. tests/test_state.py +0 -134
  163. /recce/data/_next/static/{WrRUb3nV8BhAZG_R8kVma → 6LypcDXgyuSaiSCrsmUub}/_ssgManifest.js +0 -0
  164. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  165. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  166. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  167. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/WHEEL +0 -0
  168. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
  169. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.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
@@ -111,7 +111,7 @@ def run_should_be_approved(run):
111
111
  return False
112
112
 
113
113
 
114
- async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
114
+ async def execute_preset_checks(preset_checks: List, is_skip_query: bool) -> Tuple[int, List[Dict]]:
115
115
  """
116
116
  Execute the preset checks
117
117
  """
@@ -155,12 +155,18 @@ async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
155
155
  is_checked=is_check,
156
156
  )
157
157
  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
- )
158
+ if not is_skip_query:
159
+ run, future = submit_run(check_type, params=check_params)
160
+ await future
161
+ is_check = run_should_be_approved(run)
162
+ create_check_from_run(
163
+ run.run_id, check_name, check_description, check_options, is_preset=True, is_checked=is_check
164
+ )
165
+ else:
166
+ create_check_without_run(
167
+ check_name, check_description, check_type, check_params, check_options, is_preset=True
168
+ )
169
+ continue
164
170
 
165
171
  end = time.time()
166
172
  table.add_row(
@@ -200,7 +206,7 @@ async def execute_preset_checks(preset_checks: list) -> (int, List[dict]):
200
206
  return rc, failed_checks
201
207
 
202
208
 
203
- async def execute_state_checks(checks: list) -> (int, List[dict]):
209
+ async def execute_state_checks(checks: List, is_skip_query: bool) -> Tuple[int, List[Dict]]:
204
210
  """
205
211
  Execute the checks from loaded state
206
212
  """
@@ -232,7 +238,7 @@ async def execute_state_checks(checks: list) -> (int, List[dict]):
232
238
  raise ValueError(f"Invalid check type: {check_type}")
233
239
 
234
240
  start = time.time()
235
- if check_type not in ["schema_diff"]:
241
+ if check_type not in ["schema_diff", "lineage_diff"] and not is_skip_query:
236
242
  run, future = submit_run(check_type, params=check_params, check_id=check_id)
237
243
  await future
238
244
 
@@ -295,7 +301,7 @@ def process_failed_checks(failed_checks: List[dict], error_log=None):
295
301
  content += markdown_table(failed_check_table).set_params(quote=False, row_sep="markdown").get_markdown()
296
302
 
297
303
  if error_log:
298
- with open(error_log, "w") as f:
304
+ with open(error_log, "w", encoding="utf-8") as f:
299
305
  f.write(content)
300
306
  print(f"The failed checks are stored at '{error_log}'")
301
307
  else:
@@ -315,6 +321,7 @@ async def cli_run(output_state_file: str, **kwargs):
315
321
  ctx = load_context(**kwargs)
316
322
 
317
323
  is_skip_query = kwargs.get("skip_query", False)
324
+ is_skip_check = kwargs.get("skip_check", False)
318
325
 
319
326
  # Prepare the artifact by collecting the lineage
320
327
  console.rule("DBT Artifacts")
@@ -327,23 +334,23 @@ async def cli_run(output_state_file: str, **kwargs):
327
334
  rc = 0
328
335
  if ctx.state_loader.state is None:
329
336
  preset_checks = RecceConfig().get("checks")
330
- if is_skip_query or preset_checks is None or len(preset_checks) == 0:
337
+ if is_skip_check or preset_checks is None or len(preset_checks) == 0:
331
338
  # Skip the preset checks
332
339
  pass
333
340
  else:
334
341
  console.rule("Preset checks")
335
- _, failed_checks = await execute_preset_checks(preset_checks)
342
+ _, failed_checks = await execute_preset_checks(preset_checks, is_skip_query)
336
343
  if failed_checks:
337
344
  console.print("[[yellow]Warning[/yellow]] Preset checks failed. Please see the failed reason.")
338
345
  process_failed_checks(failed_checks, error_log)
339
346
  else:
340
347
  state_checks = ctx.state_loader.state.checks
341
- if is_skip_query or state_checks is None or len(state_checks) == 0:
348
+ if is_skip_check or state_checks is None or len(state_checks) == 0:
342
349
  # Skip the checks in the state
343
350
  pass
344
351
  else:
345
352
  console.rule("Checks")
346
- _, failed_checks = await execute_state_checks(state_checks)
353
+ _, failed_checks = await execute_state_checks(state_checks, is_skip_query)
347
354
  if failed_checks:
348
355
  console.print("[[yellow]Warning[/yellow]] Checks failed. Please see the failed reason.")
349
356
  process_failed_checks(failed_checks, error_log)
@@ -363,7 +370,7 @@ async def cli_run(output_state_file: str, **kwargs):
363
370
  dirs = os.path.dirname(summary_path)
364
371
  if dirs:
365
372
  os.makedirs(dirs, exist_ok=True)
366
- with open(summary_path, "w") as f:
373
+ with open(summary_path, "w", encoding="utf-8") as f:
367
374
  f.write(generate_markdown_summary(ctx))
368
375
  console.print(f"The summary is stored at '{summary_path}'")
369
376
 
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,19 +30,44 @@ 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
34
35
  from .apis.run_api import run_router
35
36
  from .config import RecceConfig
37
+ from .connect_to_cloud import (
38
+ connect_to_cloud_background_task,
39
+ generate_key_pair,
40
+ get_connection_url,
41
+ is_callback_server_running,
42
+ prepare_connection_url,
43
+ )
36
44
  from .core import RecceContext, default_context, load_context
37
- from .event import log_api_event, log_single_env_event
45
+ from .event import get_recce_api_token, log_api_event, log_single_env_event
38
46
  from .exceptions import RecceException
47
+ from .github import is_github_codespace
39
48
  from .models.types import CllData
40
49
  from .run import load_preset_checks
41
50
  from .state import RecceShareStateManager, RecceStateLoader
42
51
 
43
52
  logger = logging.getLogger("uvicorn")
44
53
 
54
+ # Idle timeout check interval bounds (in seconds)
55
+ MAX_CHECK_INTERVAL = 30
56
+ MIN_CHECK_INTERVAL = 1
57
+
58
+
59
+ class RecceServerMode(str, Enum):
60
+ server = "server"
61
+ preview = "preview"
62
+ read_only = "read-only"
63
+
64
+ def __str__(self):
65
+ return self.value
66
+
67
+ @staticmethod
68
+ def available_members() -> Set[str]:
69
+ return ["server", "preview", "read-only"]
70
+
45
71
 
46
72
  @dataclass
47
73
  class AppState:
@@ -52,13 +78,19 @@ class AppState:
52
78
  auth_options: Optional[dict] = None
53
79
  lifetime: Optional[int] = None
54
80
  lifetime_expired_at: Optional[datetime] = None
81
+ idle_timeout: Optional[int] = None
82
+ last_activity: Optional[dict] = None
55
83
  share_url: Optional[str] = None
84
+ organization_name: Optional[str] = None
85
+ web_url: Optional[str] = None
86
+ host: Optional[str] = None
87
+ port: Optional[int] = None
56
88
 
57
89
 
58
90
  def schedule_lifetime_termination(app_state):
59
91
  def terminating_server():
60
92
  pid = os.getpid()
61
- logger.info(f"Terminating server process [{pid}] manually")
93
+ logger.info(f"Terminating server process [{pid}] manually due to lifetime expiration")
62
94
  os.kill(pid, signal.SIGINT)
63
95
 
64
96
  # Terminate the server process after the specified lifetime
@@ -67,6 +99,56 @@ def schedule_lifetime_termination(app_state):
67
99
  asyncio.get_running_loop().call_later(app_state.lifetime, terminating_server)
68
100
 
69
101
 
102
+ def schedule_idle_timeout_check(app_state):
103
+ """
104
+ Schedule periodic checks for idle timeout.
105
+ If the server has been idle for longer than idle_timeout, terminate it.
106
+ """
107
+ # Track last activity time in app_state
108
+ app_state.last_activity = {"time": datetime.now(utc)}
109
+
110
+ def terminating_server_idle():
111
+ pid = os.getpid()
112
+ logger.info(f"Terminating server process [{pid}] manually due to idle timeout")
113
+ os.kill(pid, signal.SIGINT)
114
+
115
+ async def check_idle_timeout():
116
+ """Periodically check if the server has been idle for too long"""
117
+ # Use smaller check interval if idle_timeout is very short
118
+ # Check at least every MAX_CHECK_INTERVAL seconds, but also check when idle_timeout is approaching
119
+ check_interval = min(MAX_CHECK_INTERVAL, max(MIN_CHECK_INTERVAL, app_state.idle_timeout // 3))
120
+
121
+ logger.debug(f"[Idle Timeout] Starting idle timeout checker with {check_interval}s check interval")
122
+
123
+ while True:
124
+ await asyncio.sleep(check_interval)
125
+
126
+ idle_seconds = (datetime.now(utc) - app_state.last_activity["time"]).total_seconds()
127
+ remaining_seconds = app_state.idle_timeout - idle_seconds
128
+
129
+ # Always log the countdown for debugging
130
+ if remaining_seconds > 0:
131
+ logger.debug(
132
+ f"[Idle Timeout] Server idle for {idle_seconds:.1f}s / {app_state.idle_timeout}s "
133
+ f"(remaining: {remaining_seconds:.1f}s)"
134
+ )
135
+
136
+ if idle_seconds >= app_state.idle_timeout:
137
+ logger.info(
138
+ f"[Idle Timeout] Threshold reached! Server has been idle for {idle_seconds:.0f} seconds "
139
+ f"(threshold: {app_state.idle_timeout} seconds)"
140
+ )
141
+ terminating_server_idle()
142
+ break
143
+
144
+ # Start the idle timeout check task
145
+ logger.info(f"[Configuration] The idle timeout of the server is {app_state.idle_timeout} seconds")
146
+
147
+ # Create task using asyncio.create_task which works in async context
148
+ task = asyncio.create_task(check_idle_timeout())
149
+ logger.debug(f"[Idle Timeout] Background task created: {task}")
150
+
151
+
70
152
  def setup_server(app_state: AppState) -> RecceContext:
71
153
  from rich.console import Console
72
154
 
@@ -96,39 +178,67 @@ def setup_server(app_state: AppState) -> RecceContext:
96
178
 
97
179
  log_load_state(command="server", single_env=single_env)
98
180
 
99
- if app_state.lifetime is not None and app_state.lifetime > 0:
100
- schedule_lifetime_termination(app_state)
101
-
102
181
  return ctx
103
182
 
104
183
 
105
184
  def teardown_server(app_state: AppState, ctx: RecceContext):
106
- state_loader = app_state.state_loader
185
+ # pull latest state, merge runs/checks and pick the newer artifacts
186
+ state_loader = ctx.state_loader
187
+ state_loader.refresh()
188
+ if state_loader.state:
189
+ ctx.import_state(state_loader.state, merge=True)
107
190
  state_loader.export(ctx.export_state())
108
-
109
191
  ctx.stop_monitor_artifacts()
110
192
  if app_state.flag.get("single_env_onboarding", False):
111
193
  ctx.stop_monitor_base_env()
112
194
 
113
195
 
114
196
  def setup_ready_only(app_state: AppState):
115
- if app_state.lifetime is not None and app_state.lifetime > 0:
116
- schedule_lifetime_termination(app_state)
197
+ pass
117
198
 
118
199
 
119
200
  def teardown_ready_only(app_state: AppState):
120
201
  pass
121
202
 
122
203
 
204
+ def setup_preview(app_state: AppState):
205
+ state_loader = app_state.state_loader
206
+ kwargs = app_state.kwargs
207
+ ctx = load_context(**kwargs, state_loader=state_loader)
208
+ return ctx
209
+
210
+
211
+ def teardown_preview(app_state: AppState, ctx: RecceContext):
212
+ state_loader = app_state.state_loader
213
+ state_loader.export(ctx.export_state())
214
+ pass
215
+
216
+
123
217
  @asynccontextmanager
124
218
  async def lifespan(fastapi: FastAPI):
125
219
  ctx = None
126
220
  app_state: AppState = app.state
127
221
 
222
+ # Ensure logger is at DEBUG level if debug mode is enabled
223
+ # This is needed because uvicorn might reset logger configuration
224
+ if hasattr(app_state, "kwargs") and app_state.kwargs.get("debug"):
225
+ logger.setLevel(logging.DEBUG)
226
+ logger.debug("Debug mode enabled - logger set to DEBUG level")
227
+
128
228
  if app_state.command == "server":
129
229
  ctx = setup_server(app_state)
130
- elif app_state.command == "read_only":
230
+ elif app_state.command == "read-only":
131
231
  setup_ready_only(app_state)
232
+ elif app_state.command == "preview":
233
+ ctx = setup_preview(app_state)
234
+
235
+ if app_state.lifetime is not None and app_state.lifetime > 0:
236
+ schedule_lifetime_termination(app_state)
237
+
238
+ # Schedule idle timeout check if idle_timeout is set
239
+ if app_state.idle_timeout is not None and app_state.idle_timeout > 0:
240
+ logger.debug(f"[Idle Timeout] Scheduling idle timeout check with {app_state.idle_timeout} seconds")
241
+ schedule_idle_timeout_check(app_state)
132
242
 
133
243
  yield
134
244
 
@@ -136,6 +246,8 @@ async def lifespan(fastapi: FastAPI):
136
246
  teardown_server(app_state, ctx)
137
247
  elif app_state.command == "read_only":
138
248
  teardown_ready_only(app_state)
249
+ elif app_state.command == "preview":
250
+ teardown_preview(app_state, ctx)
139
251
 
140
252
 
141
253
  app = FastAPI(lifespan=lifespan)
@@ -143,7 +255,7 @@ app = FastAPI(lifespan=lifespan)
143
255
 
144
256
  def verify_json_file(file_path: str) -> bool:
145
257
  try:
146
- with open(file_path, "r") as f:
258
+ with open(file_path, "r", encoding="utf-8") as f:
147
259
  json.load(f)
148
260
  except Exception:
149
261
  return False
@@ -200,6 +312,27 @@ app.add_middleware(
200
312
  )
201
313
 
202
314
 
315
+ @app.middleware("http")
316
+ async def track_activity_for_idle_timeout(request: Request, call_next):
317
+ """Track activity time for idle timeout check"""
318
+ # Exclude paths that should not reset idle timer
319
+ # Health checks and monitoring endpoints don't count as user activity
320
+ excluded_paths = ["/api/health", "/api/ws"]
321
+
322
+ # Update last activity time BEFORE processing request if idle timeout is enabled
323
+ # This ensures long-running requests don't get terminated mid-execution
324
+ app_state: AppState = app.state
325
+ if app_state.last_activity is not None:
326
+ if request.url.path not in excluded_paths:
327
+ app_state.last_activity["time"] = datetime.now(utc)
328
+ logger.debug(f"[Idle Timeout] ✓ Activity detected: {request.method} {request.url.path} - Timer reset")
329
+ else:
330
+ logger.debug(f"[Idle Timeout] Excluded path (no timer reset): {request.method} {request.url.path}")
331
+
332
+ response = await call_next(request)
333
+ return response
334
+
335
+
203
336
  @app.middleware("http")
204
337
  async def set_context_by_cookie(request: Request, call_next):
205
338
  response = await call_next(request)
@@ -233,11 +366,17 @@ async def health_check(request: Request):
233
366
 
234
367
 
235
368
  class RecceInstanceInfoOut(BaseModel):
369
+ server_mode: RecceServerMode
236
370
  read_only: bool
371
+ preview: bool
237
372
  single_env: bool
238
373
  authed: bool
374
+ cloud_instance: bool
239
375
  lifetime_expired_at: Optional[datetime] = None
240
376
  share_url: Optional[str] = None
377
+ session_id: Optional[str] = None
378
+ organization_name: Optional[str] = None
379
+ web_url: Optional[str] = None
241
380
 
242
381
 
243
382
  @app.get("/api/instance-info", response_model=RecceInstanceInfoOut, response_model_exclude_none=True)
@@ -247,15 +386,20 @@ async def recce_instance_info():
247
386
  read_only = flag.get("read_only", False)
248
387
  single_env = flag.get("single_env_onboarding", False)
249
388
 
250
- auth_options = app_state.auth_options or {}
251
- api_token = auth_options.get("api_token")
389
+ api_token = get_recce_api_token()
252
390
 
253
391
  return {
392
+ "server_mode": app_state.command,
254
393
  "read_only": read_only,
394
+ "preview": flag.get("preview", False),
255
395
  "single_env": single_env,
256
396
  "authed": True if api_token else False,
397
+ "cloud_instance": is_recce_cloud_instance(),
257
398
  "lifetime_expired_at": app_state.lifetime_expired_at, # UTC timezone
258
399
  "share_url": app_state.share_url,
400
+ "session_id": app_state.state_loader.session_id if app_state.state_loader else None,
401
+ "organization_name": app_state.organization_name,
402
+ "web_url": app_state.web_url,
259
403
  # TODO: Add more instance info which won't change during the instance lifecycle
260
404
  # review_mode
261
405
  # cloud_mode
@@ -283,6 +427,7 @@ async def get_info():
283
427
  """
284
428
  context = default_context()
285
429
  demo = os.environ.get("DEMO", False)
430
+ is_codespace = is_github_codespace()
286
431
 
287
432
  if demo:
288
433
  state = context.export_demo_state()
@@ -307,6 +452,7 @@ async def get_info():
307
452
  "pull_request": state.pull_request.to_dict() if state.pull_request else None,
308
453
  "lineage": lineage_diff,
309
454
  "demo": bool(demo),
455
+ "codespace": bool(is_codespace),
310
456
  "cloud_mode": context.state_loader.cloud_mode,
311
457
  "file_mode": context.state_loader.state_file is not None,
312
458
  "filename": filename,
@@ -331,9 +477,9 @@ class CllIn(BaseModel):
331
477
  node_id: Optional[str] = None
332
478
  column: Optional[str] = None
333
479
  change_analysis: Optional[bool] = False
334
- cll: Optional[bool] = False
335
- upstream: Optional[bool] = False
336
- downstream: Optional[bool] = False
480
+ no_cll: Optional[bool] = False
481
+ no_upstream: Optional[bool] = False
482
+ no_downstream: Optional[bool] = False
337
483
 
338
484
 
339
485
  class CllOutput(BaseModel):
@@ -349,8 +495,9 @@ async def column_level_lineage_by_node(cll_input: CllIn):
349
495
  node_id=cll_input.node_id,
350
496
  column=cll_input.column,
351
497
  change_analysis=cll_input.change_analysis,
352
- upstream=cll_input.upstream,
353
- downstream=cll_input.downstream,
498
+ no_upstream=cll_input.no_upstream,
499
+ no_downstream=cll_input.no_downstream,
500
+ no_cll=cll_input.no_cll,
354
501
  )
355
502
 
356
503
  return CllOutput(current=cll)
@@ -654,6 +801,34 @@ async def broadcast(data: str):
654
801
  await client.send_text(data)
655
802
 
656
803
 
804
+ @app.post("/api/connect")
805
+ async def generate_connect_to_cloud_url(background_tasks: BackgroundTasks):
806
+ if is_callback_server_running():
807
+ return {"connection_url": get_connection_url()}
808
+
809
+ private_key, public_key = generate_key_pair()
810
+ connection_url, callback_port = prepare_connection_url(public_key)
811
+
812
+ background_tasks.add_task(connect_to_cloud_background_task, private_key, callback_port, connection_url)
813
+ return {
814
+ "connection_url": connection_url,
815
+ }
816
+
817
+
818
+ @app.get("/api/users")
819
+ async def get_user_info():
820
+ from recce.connect_to_cloud import RecceCloud
821
+
822
+ context = default_context()
823
+ user_token = get_recce_api_token() or context.state_loader.token
824
+ cloud = RecceCloud(user_token)
825
+ try:
826
+ user_info = cloud.get_user_info()
827
+ return user_info
828
+ except Exception as e:
829
+ raise HTTPException(status_code=400, detail=str(e))
830
+
831
+
657
832
  api_prefix = "/api"
658
833
  app.include_router(check_router, prefix=api_prefix)
659
834
  app.include_router(run_router, prefix=api_prefix)
@@ -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
+ ]