recce-nightly 1.10.0.20250629__py3-none-any.whl → 1.25.0.20251112a20664__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 (168) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +116 -74
  4. recce/artifact.py +76 -3
  5. recce/cli.py +665 -69
  6. recce/config.py +2 -2
  7. recce/connect_to_cloud.py +1 -1
  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/JwV_pqetN5WamZZ7aGdfH/_buildManifest.js +11 -0
  15. recce/data/_next/static/JwV_pqetN5WamZZ7aGdfH/_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/67b1c6a62f19d429.js +110 -0
  21. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  22. recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
  23. recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
  24. recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
  25. recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -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 +1 -1
  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 +165 -11
  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 +14 -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.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/METADATA +31 -27
  100. recce_nightly-1.25.0.20251112a20664.dist-info/RECORD +178 -0
  101. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/top_level.txt +1 -0
  102. tests/adapter/dbt_adapter/test_dbt_cll.py +68 -17
  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_core.py +147 -0
  112. tests/test_mcp_server.py +332 -0
  113. tests/test_server.py +6 -6
  114. tests/test_summary.py +14 -6
  115. recce/data/_next/static/Mrb9CZ3toH6Q8xrzNzCrg/_buildManifest.js +0 -1
  116. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  117. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  118. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  119. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  120. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  121. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  122. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  123. recce/data/_next/static/chunks/41-f30276c289169376.js +0 -9
  124. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  125. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  126. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  127. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  128. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  129. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  130. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  131. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  132. recce/data/_next/static/chunks/92-68460b15fe448f33.js +0 -1
  133. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  134. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  135. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  136. recce/data/_next/static/chunks/app/layout-292f035bb0d2a98e.js +0 -1
  137. recce/data/_next/static/chunks/app/page-598f8acc82179d01.js +0 -1
  138. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  139. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  140. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  141. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  142. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  143. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  144. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  145. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  146. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  147. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  148. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  149. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  150. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  151. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  152. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  153. recce/data/_next/static/css/a2b12b4ba4227f0a.css +0 -3
  154. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  155. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  158. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  159. recce/state.py +0 -786
  160. recce_nightly-1.10.0.20250629.dist-info/RECORD +0 -154
  161. tests/test_state.py +0 -134
  162. /recce/data/_next/static/{Mrb9CZ3toH6Q8xrzNzCrg → JwV_pqetN5WamZZ7aGdfH}/_ssgManifest.js +0 -0
  163. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  164. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  165. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  166. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/WHEEL +0 -0
  167. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.dist-info}/entry_points.txt +0 -0
  168. {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a20664.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,7 +30,7 @@ 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
@@ -43,12 +44,30 @@ from .connect_to_cloud import (
43
44
  from .core import RecceContext, default_context, load_context
44
45
  from .event import get_recce_api_token, log_api_event, log_single_env_event
45
46
  from .exceptions import RecceException
47
+ from .github import is_github_codespace
46
48
  from .models.types import CllData
47
49
  from .run import load_preset_checks
48
50
  from .state import RecceShareStateManager, RecceStateLoader
49
51
 
50
52
  logger = logging.getLogger("uvicorn")
51
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
+
52
71
 
53
72
  @dataclass
54
73
  class AppState:
@@ -59,13 +78,19 @@ class AppState:
59
78
  auth_options: Optional[dict] = None
60
79
  lifetime: Optional[int] = None
61
80
  lifetime_expired_at: Optional[datetime] = None
81
+ idle_timeout: Optional[int] = None
82
+ last_activity: Optional[dict] = None
62
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
63
88
 
64
89
 
65
90
  def schedule_lifetime_termination(app_state):
66
91
  def terminating_server():
67
92
  pid = os.getpid()
68
- logger.info(f"Terminating server process [{pid}] manually")
93
+ logger.info(f"Terminating server process [{pid}] manually due to lifetime expiration")
69
94
  os.kill(pid, signal.SIGINT)
70
95
 
71
96
  # Terminate the server process after the specified lifetime
@@ -74,6 +99,56 @@ def schedule_lifetime_termination(app_state):
74
99
  asyncio.get_running_loop().call_later(app_state.lifetime, terminating_server)
75
100
 
76
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
+
77
152
  def setup_server(app_state: AppState) -> RecceContext:
78
153
  from rich.console import Console
79
154
 
@@ -103,39 +178,67 @@ def setup_server(app_state: AppState) -> RecceContext:
103
178
 
104
179
  log_load_state(command="server", single_env=single_env)
105
180
 
106
- if app_state.lifetime is not None and app_state.lifetime > 0:
107
- schedule_lifetime_termination(app_state)
108
-
109
181
  return ctx
110
182
 
111
183
 
112
184
  def teardown_server(app_state: AppState, ctx: RecceContext):
113
- 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)
114
190
  state_loader.export(ctx.export_state())
115
-
116
191
  ctx.stop_monitor_artifacts()
117
192
  if app_state.flag.get("single_env_onboarding", False):
118
193
  ctx.stop_monitor_base_env()
119
194
 
120
195
 
121
196
  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)
197
+ pass
124
198
 
125
199
 
126
200
  def teardown_ready_only(app_state: AppState):
127
201
  pass
128
202
 
129
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
+
130
217
  @asynccontextmanager
131
218
  async def lifespan(fastapi: FastAPI):
132
219
  ctx = None
133
220
  app_state: AppState = app.state
134
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
+
135
228
  if app_state.command == "server":
136
229
  ctx = setup_server(app_state)
137
- elif app_state.command == "read_only":
230
+ elif app_state.command == "read-only":
138
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)
139
242
 
140
243
  yield
141
244
 
@@ -143,6 +246,8 @@ async def lifespan(fastapi: FastAPI):
143
246
  teardown_server(app_state, ctx)
144
247
  elif app_state.command == "read_only":
145
248
  teardown_ready_only(app_state)
249
+ elif app_state.command == "preview":
250
+ teardown_preview(app_state, ctx)
146
251
 
147
252
 
148
253
  app = FastAPI(lifespan=lifespan)
@@ -150,7 +255,7 @@ app = FastAPI(lifespan=lifespan)
150
255
 
151
256
  def verify_json_file(file_path: str) -> bool:
152
257
  try:
153
- with open(file_path, "r") as f:
258
+ with open(file_path, "r", encoding="utf-8") as f:
154
259
  json.load(f)
155
260
  except Exception:
156
261
  return False
@@ -207,6 +312,27 @@ app.add_middleware(
207
312
  )
208
313
 
209
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
+
210
336
  @app.middleware("http")
211
337
  async def set_context_by_cookie(request: Request, call_next):
212
338
  response = await call_next(request)
@@ -240,11 +366,17 @@ async def health_check(request: Request):
240
366
 
241
367
 
242
368
  class RecceInstanceInfoOut(BaseModel):
369
+ server_mode: RecceServerMode
243
370
  read_only: bool
371
+ preview: bool
244
372
  single_env: bool
245
373
  authed: bool
374
+ cloud_instance: bool
246
375
  lifetime_expired_at: Optional[datetime] = None
247
376
  share_url: Optional[str] = None
377
+ session_id: Optional[str] = None
378
+ organization_name: Optional[str] = None
379
+ web_url: Optional[str] = None
248
380
 
249
381
 
250
382
  @app.get("/api/instance-info", response_model=RecceInstanceInfoOut, response_model_exclude_none=True)
@@ -257,11 +389,17 @@ async def recce_instance_info():
257
389
  api_token = get_recce_api_token()
258
390
 
259
391
  return {
392
+ "server_mode": app_state.command,
260
393
  "read_only": read_only,
394
+ "preview": flag.get("preview", False),
261
395
  "single_env": single_env,
262
396
  "authed": True if api_token else False,
397
+ "cloud_instance": is_recce_cloud_instance(),
263
398
  "lifetime_expired_at": app_state.lifetime_expired_at, # UTC timezone
264
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,
265
403
  # TODO: Add more instance info which won't change during the instance lifecycle
266
404
  # review_mode
267
405
  # cloud_mode
@@ -289,6 +427,7 @@ async def get_info():
289
427
  """
290
428
  context = default_context()
291
429
  demo = os.environ.get("DEMO", False)
430
+ is_codespace = is_github_codespace()
292
431
 
293
432
  if demo:
294
433
  state = context.export_demo_state()
@@ -313,6 +452,7 @@ async def get_info():
313
452
  "pull_request": state.pull_request.to_dict() if state.pull_request else None,
314
453
  "lineage": lineage_diff,
315
454
  "demo": bool(demo),
455
+ "codespace": bool(is_codespace),
316
456
  "cloud_mode": context.state_loader.cloud_mode,
317
457
  "file_mode": context.state_loader.state_file is not None,
318
458
  "filename": filename,
@@ -675,6 +815,20 @@ async def generate_connect_to_cloud_url(background_tasks: BackgroundTasks):
675
815
  }
676
816
 
677
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
+
678
832
  api_prefix = "/api"
679
833
  app.include_router(check_router, prefix=api_prefix)
680
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
+ ]