recce-nightly 0.62.0.20250417__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 (245) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +27 -22
  3. recce/adapter/base.py +11 -14
  4. recce/adapter/dbt_adapter/__init__.py +845 -461
  5. recce/adapter/dbt_adapter/dbt_version.py +3 -0
  6. recce/adapter/sqlmesh_adapter.py +24 -35
  7. recce/apis/check_api.py +59 -42
  8. recce/apis/check_events_api.py +353 -0
  9. recce/apis/check_func.py +41 -35
  10. recce/apis/run_api.py +25 -19
  11. recce/apis/run_func.py +64 -25
  12. recce/artifact.py +119 -51
  13. recce/cli.py +1301 -324
  14. recce/config.py +43 -34
  15. recce/connect_to_cloud.py +138 -0
  16. recce/core.py +55 -47
  17. recce/data/404/index.html +2 -0
  18. recce/data/404.html +2 -1
  19. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
  20. recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
  21. recce/data/__next.__PAGE__.txt +6 -0
  22. recce/data/__next._full.txt +32 -0
  23. recce/data/__next._head.txt +8 -0
  24. recce/data/__next._index.txt +14 -0
  25. recce/data/__next._tree.txt +8 -0
  26. recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
  27. recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
  28. recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
  29. recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
  30. recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
  31. recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
  32. recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
  33. recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
  34. recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
  35. recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
  36. recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
  37. recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
  38. recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
  39. recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
  40. recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
  41. recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
  42. recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
  43. recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
  44. recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
  45. recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
  46. recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
  47. recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
  48. recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
  49. recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
  50. recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
  51. recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
  52. recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
  53. recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
  54. recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
  55. recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
  56. recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
  57. recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
  58. recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
  59. recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
  60. recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
  61. recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
  62. recce/data/_next/static/chunks/turbopack-1fad664f62979b93.js +3 -0
  63. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  64. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  65. recce/data/_next/static/media/montserrat-cyrillic-800-normal.f9d58125.woff +0 -0
  66. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  67. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
  68. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  69. recce/data/_next/static/media/montserrat-latin-800-normal.d5761935.woff +0 -0
  70. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  71. recce/data/_next/static/media/montserrat-latin-ext-800-normal.b671449b.woff +0 -0
  72. recce/data/_next/static/media/montserrat-vietnamese-800-normal.9f7b8541.woff +0 -0
  73. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  74. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
  75. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
  76. recce/data/_not-found/__next._full.txt +24 -0
  77. recce/data/_not-found/__next._head.txt +8 -0
  78. recce/data/_not-found/__next._index.txt +13 -0
  79. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  80. recce/data/_not-found/__next._not-found.txt +4 -0
  81. recce/data/_not-found/__next._tree.txt +6 -0
  82. recce/data/_not-found/index.html +2 -0
  83. recce/data/_not-found/index.txt +24 -0
  84. recce/data/auth_callback.html +68 -0
  85. recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
  86. recce/data/checks/__next._full.txt +39 -0
  87. recce/data/checks/__next._head.txt +8 -0
  88. recce/data/checks/__next._index.txt +14 -0
  89. recce/data/checks/__next._tree.txt +8 -0
  90. recce/data/checks/__next.checks.__PAGE__.txt +10 -0
  91. recce/data/checks/__next.checks.txt +4 -0
  92. recce/data/checks/index.html +2 -0
  93. recce/data/checks/index.txt +39 -0
  94. recce/data/imgs/reload-image.svg +4 -0
  95. recce/data/index.html +2 -27
  96. recce/data/index.txt +32 -7
  97. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
  98. recce/data/lineage/__next._full.txt +39 -0
  99. recce/data/lineage/__next._head.txt +8 -0
  100. recce/data/lineage/__next._index.txt +14 -0
  101. recce/data/lineage/__next._tree.txt +8 -0
  102. recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
  103. recce/data/lineage/__next.lineage.txt +4 -0
  104. recce/data/lineage/index.html +2 -0
  105. recce/data/lineage/index.txt +39 -0
  106. recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
  107. recce/data/query/__next._full.txt +37 -0
  108. recce/data/query/__next._head.txt +8 -0
  109. recce/data/query/__next._index.txt +14 -0
  110. recce/data/query/__next._tree.txt +8 -0
  111. recce/data/query/__next.query.__PAGE__.txt +9 -0
  112. recce/data/query/__next.query.txt +4 -0
  113. recce/data/query/index.html +2 -0
  114. recce/data/query/index.txt +37 -0
  115. recce/diff.py +6 -12
  116. recce/event/CONFIG.bak +1 -0
  117. recce/event/__init__.py +86 -74
  118. recce/event/collector.py +33 -22
  119. recce/event/track.py +49 -27
  120. recce/exceptions.py +1 -1
  121. recce/git.py +7 -7
  122. recce/github.py +57 -53
  123. recce/mcp_server.py +725 -0
  124. recce/models/__init__.py +4 -1
  125. recce/models/check.py +438 -21
  126. recce/models/run.py +1 -0
  127. recce/models/types.py +134 -28
  128. recce/pull_request.py +27 -25
  129. recce/run.py +179 -122
  130. recce/server.py +394 -104
  131. recce/state/__init__.py +31 -0
  132. recce/state/cloud.py +644 -0
  133. recce/state/const.py +26 -0
  134. recce/state/local.py +56 -0
  135. recce/state/state.py +119 -0
  136. recce/state/state_loader.py +174 -0
  137. recce/summary.py +196 -149
  138. recce/tasks/__init__.py +19 -3
  139. recce/tasks/core.py +11 -13
  140. recce/tasks/dataframe.py +82 -18
  141. recce/tasks/histogram.py +69 -34
  142. recce/tasks/lineage.py +2 -2
  143. recce/tasks/profile.py +152 -86
  144. recce/tasks/query.py +180 -89
  145. recce/tasks/rowcount.py +37 -31
  146. recce/tasks/schema.py +18 -15
  147. recce/tasks/top_k.py +35 -35
  148. recce/tasks/utils.py +147 -0
  149. recce/tasks/valuediff.py +247 -155
  150. recce/util/__init__.py +3 -0
  151. recce/util/api_token.py +80 -0
  152. recce/util/breaking.py +105 -100
  153. recce/util/cll.py +274 -219
  154. recce/util/cloud/__init__.py +15 -0
  155. recce/util/cloud/base.py +115 -0
  156. recce/util/cloud/check_events.py +190 -0
  157. recce/util/cloud/checks.py +242 -0
  158. recce/util/io.py +22 -17
  159. recce/util/lineage.py +65 -16
  160. recce/util/logger.py +1 -1
  161. recce/util/onboarding_state.py +45 -0
  162. recce/util/perf_tracking.py +85 -0
  163. recce/util/recce_cloud.py +347 -72
  164. recce/util/singleton.py +4 -4
  165. recce/util/startup_perf.py +121 -0
  166. recce/yaml/__init__.py +7 -10
  167. recce_nightly-1.30.0.20251221.dist-info/METADATA +195 -0
  168. recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
  169. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
  170. recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
  171. recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
  172. recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
  173. recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
  174. recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
  175. recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
  176. recce/data/_next/static/chunks/500-e51c92a025a51234.js +0 -65
  177. recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
  178. recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
  179. recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
  180. recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
  181. recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
  182. recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
  183. recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
  184. recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
  185. recce/data/_next/static/chunks/app/page-9adc25782272ed2e.js +0 -1
  186. recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
  187. recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
  188. recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
  189. recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
  190. recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
  191. recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
  192. recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
  193. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  194. recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
  195. recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
  196. recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
  197. recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
  198. recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
  199. recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
  200. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  201. recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
  202. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +0 -0
  203. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  204. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  205. recce/data/_next/static/media/montserrat-latin-800-normal.97e20d5e.woff +0 -0
  206. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  207. recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
  208. recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
  209. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  210. recce/data/_next/static/qiyFlux77VkhxiceAJe_F/_buildManifest.js +0 -1
  211. recce/state.py +0 -753
  212. recce_nightly-0.62.0.20250417.dist-info/METADATA +0 -311
  213. recce_nightly-0.62.0.20250417.dist-info/RECORD +0 -139
  214. recce_nightly-0.62.0.20250417.dist-info/top_level.txt +0 -2
  215. tests/__init__.py +0 -0
  216. tests/adapter/__init__.py +0 -0
  217. tests/adapter/dbt_adapter/__init__.py +0 -0
  218. tests/adapter/dbt_adapter/conftest.py +0 -13
  219. tests/adapter/dbt_adapter/dbt_test_helper.py +0 -283
  220. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -40
  221. tests/adapter/dbt_adapter/test_dbt_cll.py +0 -102
  222. tests/adapter/dbt_adapter/test_selector.py +0 -177
  223. tests/tasks/__init__.py +0 -0
  224. tests/tasks/conftest.py +0 -4
  225. tests/tasks/test_histogram.py +0 -137
  226. tests/tasks/test_lineage.py +0 -42
  227. tests/tasks/test_preset_checks.py +0 -50
  228. tests/tasks/test_profile.py +0 -73
  229. tests/tasks/test_query.py +0 -151
  230. tests/tasks/test_row_count.py +0 -116
  231. tests/tasks/test_schema.py +0 -99
  232. tests/tasks/test_top_k.py +0 -73
  233. tests/tasks/test_valuediff.py +0 -74
  234. tests/test_cli.py +0 -122
  235. tests/test_config.py +0 -45
  236. tests/test_core.py +0 -27
  237. tests/test_dbt.py +0 -36
  238. tests/test_pull_request.py +0 -130
  239. tests/test_server.py +0 -98
  240. tests/test_state.py +0 -123
  241. tests/test_summary.py +0 -57
  242. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  243. /recce/data/_next/static/{qiyFlux77VkhxiceAJe_F → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
  244. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
  245. {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
recce/server.py CHANGED
@@ -2,49 +2,161 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  import os
5
+ import signal
5
6
  import uuid
6
7
  from contextlib import asynccontextmanager
7
8
  from dataclasses import dataclass
9
+ from datetime import datetime, timedelta
10
+ from enum import Enum
8
11
  from pathlib import Path
9
- from typing import Optional, Any, Set, Annotated, Literal, Dict
10
-
11
- from fastapi import FastAPI, HTTPException, Request, WebSocket, UploadFile, Response, BackgroundTasks, Form, Query
12
+ from typing import Annotated, Any, Literal, Optional, Set
13
+
14
+ from fastapi import (
15
+ BackgroundTasks,
16
+ FastAPI,
17
+ Form,
18
+ HTTPException,
19
+ Request,
20
+ Response,
21
+ UploadFile,
22
+ WebSocket,
23
+ )
12
24
  from fastapi.middleware.cors import CORSMiddleware
13
25
  from fastapi.responses import PlainTextResponse
14
26
  from fastapi.staticfiles import StaticFiles
15
- from pydantic import ValidationError, BaseModel
27
+ from pydantic import BaseModel, ValidationError
28
+ from pytz import utc
16
29
  from starlette.middleware.gzip import GZipMiddleware
17
30
  from starlette.middleware.sessions import SessionMiddleware
18
31
  from starlette.websockets import WebSocketDisconnect
19
32
 
20
- from . import __version__, event, __latest_version__
33
+ from . import __latest_version__, __version__, event, is_recce_cloud_instance
21
34
  from .apis.check_api import check_router
35
+ from .apis.check_events_api import check_events_router
22
36
  from .apis.run_api import run_router
23
37
  from .config import RecceConfig
24
- from .core import load_context, default_context
25
- from .event import log_api_event, log_single_env_event
38
+ from .connect_to_cloud import (
39
+ connect_to_cloud_background_task,
40
+ generate_key_pair,
41
+ get_connection_url,
42
+ is_callback_server_running,
43
+ prepare_connection_url,
44
+ )
45
+ from .core import RecceContext, default_context, load_context
46
+ from .event import get_recce_api_token, log_api_event, log_single_env_event
26
47
  from .exceptions import RecceException
48
+ from .github import is_github_codespace
49
+ from .models.types import CllData
27
50
  from .run import load_preset_checks
28
- from .state import RecceStateLoader, RecceShareStateManager
51
+ from .state import RecceShareStateManager, RecceStateLoader
52
+ from .util.startup_perf import track_timing
53
+
54
+ logger = logging.getLogger("uvicorn")
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"
29
65
 
30
- logger = logging.getLogger('uvicorn')
66
+ def __str__(self):
67
+ return self.value
68
+
69
+ @staticmethod
70
+ def available_members() -> Set[str]:
71
+ return ["server", "preview", "read-only"]
31
72
 
32
73
 
33
74
  @dataclass
34
75
  class AppState:
76
+ command: Optional[str] = None
35
77
  state_loader: Optional[RecceStateLoader] = None
36
78
  kwargs: Optional[dict] = None
37
79
  flag: Optional[dict] = None
38
80
  auth_options: Optional[dict] = None
81
+ lifetime: Optional[int] = None
82
+ lifetime_expired_at: Optional[datetime] = None
83
+ idle_timeout: Optional[int] = None
84
+ last_activity: Optional[dict] = None
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
39
90
 
40
91
 
41
- @asynccontextmanager
42
- async def lifespan(fastapi: FastAPI):
43
- from .core import load_context
92
+ def schedule_lifetime_termination(app_state):
93
+ def terminating_server():
94
+ pid = os.getpid()
95
+ logger.info(f"Terminating server process [{pid}] manually due to lifetime expiration")
96
+ os.kill(pid, signal.SIGINT)
97
+
98
+ # Terminate the server process after the specified lifetime
99
+ logger.info(f"[Configuration] The lifetime of the server is {app_state.lifetime} seconds")
100
+ app.state.lifetime_expired_at = datetime.now(utc) + timedelta(seconds=app_state.lifetime)
101
+ asyncio.get_running_loop().call_later(app_state.lifetime, terminating_server)
102
+
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
+
154
+ def setup_server(app_state: AppState) -> RecceContext:
44
155
  from rich.console import Console
45
156
 
157
+ from .core import load_context
158
+
46
159
  console = Console()
47
- app_state: AppState = app.state
48
160
  state_loader = app_state.state_loader
49
161
  kwargs = app_state.kwargs
50
162
  ctx = load_context(**kwargs, state_loader=state_loader)
@@ -57,31 +169,118 @@ async def lifespan(fastapi: FastAPI):
57
169
  log_single_env_event()
58
170
 
59
171
  # Initialize Recce Config
60
- config = RecceConfig(config_file=kwargs.get('config'))
172
+ config = RecceConfig(config_file=kwargs.get("config"))
61
173
  if state_loader.state is None:
62
- preset_checks = config.get('checks', [])
174
+ preset_checks = config.get("checks", [])
63
175
  if preset_checks and len(preset_checks) > 0:
64
176
  console.rule("Loading Preset Checks")
65
177
  load_preset_checks(preset_checks)
66
178
 
67
179
  from recce.event import log_load_state
68
- log_load_state(command='server', single_env=single_env)
69
180
 
70
- yield
181
+ log_load_state(command="server", single_env=single_env)
182
+
183
+ return ctx
71
184
 
72
- state_loader.export(ctx.export_state())
73
185
 
186
+ def teardown_server(app_state: AppState, ctx: RecceContext):
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)
192
+ state_loader.export(ctx.export_state())
74
193
  ctx.stop_monitor_artifacts()
75
194
  if app_state.flag.get("single_env_onboarding", False):
76
195
  ctx.stop_monitor_base_env()
77
196
 
78
197
 
198
+ def setup_ready_only(app_state: AppState):
199
+ pass
200
+
201
+
202
+ def teardown_ready_only(app_state: AppState):
203
+ pass
204
+
205
+
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
217
+
218
+
219
+ @track_timing("server_setup")
220
+ def _do_lifespan_setup(app_state: AppState):
221
+ """Run server setup and return context for teardown."""
222
+ if app_state.command == "server":
223
+ ctx = setup_server(app_state)
224
+ elif app_state.command == "read-only":
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()
267
+
268
+ yield
269
+
270
+ if app_state.command == "server":
271
+ teardown_server(app_state, ctx)
272
+ elif app_state.command == "read-only":
273
+ teardown_ready_only(app_state)
274
+ elif app_state.command == "preview":
275
+ teardown_preview(app_state, ctx)
276
+
277
+
79
278
  app = FastAPI(lifespan=lifespan)
80
279
 
81
280
 
82
281
  def verify_json_file(file_path: str) -> bool:
83
282
  try:
84
- with open(file_path, 'r') as f:
283
+ with open(file_path, "r", encoding="utf-8") as f:
85
284
  json.load(f)
86
285
  except Exception:
87
286
  return False
@@ -94,19 +293,15 @@ def dbt_artifacts_updated_callback(file_changed_event: Any):
94
293
  file_name = src_path.name
95
294
 
96
295
  if not verify_json_file(file_changed_event.src_path):
97
- logger.debug('Skip to refresh the artifacts because the file is not updated completely.')
296
+ logger.debug("Skip to refresh the artifacts because the file is not updated completely.")
98
297
  return
99
298
 
100
- logger.info(
101
- f'Detect {target_type} file {file_changed_event.event_type}: {file_name}')
299
+ logger.info(f"Detect {target_type} file {file_changed_event.event_type}: {file_name}")
102
300
  ctx = load_context()
103
301
  ctx.refresh_manifest(file_changed_event.src_path)
104
302
  broadcast_command = {
105
- 'command': 'refresh',
106
- 'event': {
107
- 'eventType': file_changed_event.event_type,
108
- 'srcPath': file_changed_event.src_path
109
- }
303
+ "command": "refresh",
304
+ "event": {"eventType": file_changed_event.event_type, "srcPath": file_changed_event.src_path},
110
305
  }
111
306
  payload = json.dumps(broadcast_command)
112
307
  asyncio.run(broadcast(payload))
@@ -115,7 +310,7 @@ def dbt_artifacts_updated_callback(file_changed_event: Any):
115
310
  def dbt_env_updated_callback():
116
311
  logger.info("Detect 'manifest.json' and 'catalog.json' are generated under 'target-base' directory")
117
312
  broadcast_command = {
118
- 'command': 'relaunch',
313
+ "command": "relaunch",
119
314
  }
120
315
  payload = json.dumps(broadcast_command)
121
316
  asyncio.run(broadcast(payload))
@@ -142,11 +337,32 @@ app.add_middleware(
142
337
  )
143
338
 
144
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
+
145
361
  @app.middleware("http")
146
362
  async def set_context_by_cookie(request: Request, call_next):
147
363
  response = await call_next(request)
148
364
 
149
- user_id_in_cookie = request.cookies.get('recce_user_id')
365
+ user_id_in_cookie = request.cookies.get("recce_user_id")
150
366
  user_id = event.get_user_id()
151
367
 
152
368
  if event.is_anonymous_tracking() is False:
@@ -154,7 +370,7 @@ async def set_context_by_cookie(request: Request, call_next):
154
370
  user_id = None
155
371
 
156
372
  if user_id_in_cookie is None or user_id_in_cookie != user_id:
157
- response.set_cookie(key='recce_user_id', value=user_id)
373
+ response.set_cookie(key="recce_user_id", value=user_id)
158
374
  return response
159
375
 
160
376
 
@@ -163,8 +379,8 @@ async def disable_cache(request: Request, call_next):
163
379
  response = await call_next(request)
164
380
 
165
381
  # disable cache for '/' and '/index.html'
166
- if request.url.path in ['/', '/index.html']:
167
- response.headers['Cache-Control'] = 'no-store'
382
+ if request.url.path in ["/", "/index.html"]:
383
+ response.headers["Cache-Control"] = "no-store"
168
384
 
169
385
  return response
170
386
 
@@ -174,18 +390,54 @@ async def health_check(request: Request):
174
390
  return {"status": "ok"}
175
391
 
176
392
 
177
- @app.get("/api/instance-info")
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
+
404
+ class RecceInstanceInfoOut(BaseModel):
405
+ server_mode: RecceServerMode
406
+ read_only: bool
407
+ preview: bool
408
+ single_env: bool
409
+ authed: bool
410
+ cloud_instance: bool
411
+ lifetime_expired_at: Optional[datetime] = None
412
+ idle_timeout: Optional[int] = None
413
+ share_url: Optional[str] = None
414
+ session_id: Optional[str] = None
415
+ organization_name: Optional[str] = None
416
+ web_url: Optional[str] = None
417
+
418
+
419
+ @app.get("/api/instance-info", response_model=RecceInstanceInfoOut, response_model_exclude_none=True)
178
420
  async def recce_instance_info():
179
421
  app_state: AppState = app.state
180
422
  flag = app_state.flag
181
- read_only = flag.get('read_only', False)
423
+ read_only = flag.get("read_only", False)
424
+ single_env = flag.get("single_env_onboarding", False)
182
425
 
183
- auth_options = app_state.auth_options
184
- api_token = auth_options.get('api_token')
426
+ api_token = get_recce_api_token()
185
427
 
186
428
  return {
429
+ "server_mode": app_state.command,
187
430
  "read_only": read_only,
431
+ "preview": flag.get("preview", False),
432
+ "single_env": single_env,
188
433
  "authed": True if api_token else False,
434
+ "cloud_instance": is_recce_cloud_instance(),
435
+ "lifetime_expired_at": app_state.lifetime_expired_at, # UTC timezone
436
+ "idle_timeout": app_state.idle_timeout,
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,
189
441
  # TODO: Add more instance info which won't change during the instance lifecycle
190
442
  # review_mode
191
443
  # cloud_mode
@@ -201,16 +453,9 @@ async def config_flag():
201
453
  return flag
202
454
 
203
455
 
204
- @app.post("/api/onboarding/completed", status_code=204)
205
- async def mark_onboarding_completed():
206
- context = default_context()
207
- context.mark_onboarding_completed()
208
- app.state.flag['show_onboarding_guide'] = False
209
-
210
-
211
456
  @app.post("/api/relaunch-hint/completed", status_code=204)
212
457
  async def mark_relaunch_hint_completed():
213
- app.state.flag['show_relaunch_hint'] = False
458
+ app.state.flag["show_relaunch_hint"] = False
214
459
 
215
460
 
216
461
  @app.get("/api/info")
@@ -219,7 +464,8 @@ async def get_info():
219
464
  Get the information of the current context.
220
465
  """
221
466
  context = default_context()
222
- demo = os.environ.get('DEMO', False)
467
+ demo = os.environ.get("DEMO", False)
468
+ is_codespace = is_github_codespace()
223
469
 
224
470
  if demo:
225
471
  state = context.export_demo_state()
@@ -232,28 +478,32 @@ async def get_info():
232
478
  else:
233
479
  filename = None
234
480
 
481
+ state_metadata = context.state_loader.state.metadata if context.state_loader.state else None
235
482
  lineage_diff = context.get_lineage_diff()
236
483
 
237
484
  try:
238
485
  info = {
239
- 'adapter_type': context.adapter_type,
240
- 'review_mode': context.review_mode,
241
- 'git': state.git.to_dict() if state.git else None,
242
- 'pull_request': state.pull_request.to_dict() if state.pull_request else None,
243
- 'lineage': lineage_diff,
244
- 'demo': bool(demo),
245
- 'cloud_mode': context.state_loader.cloud_mode,
246
- 'file_mode': context.state_loader.state_file is not None,
247
- 'filename': filename,
248
- 'support_tasks': support_tasks,
486
+ "state_metadata": state_metadata,
487
+ "adapter_type": context.adapter_type,
488
+ "review_mode": context.review_mode,
489
+ "git": state.git.to_dict() if state.git else None,
490
+ "pull_request": state.pull_request.to_dict() if state.pull_request else None,
491
+ "lineage": lineage_diff,
492
+ "demo": bool(demo),
493
+ "codespace": bool(is_codespace),
494
+ "cloud_mode": context.state_loader.cloud_mode,
495
+ "file_mode": context.state_loader.state_file is not None,
496
+ "filename": filename,
497
+ "support_tasks": support_tasks,
249
498
  }
250
499
 
251
- if context.adapter_type == 'sqlmesh':
500
+ if context.adapter_type == "sqlmesh":
252
501
  from recce.adapter.sqlmesh_adapter import SqlmeshAdapter
502
+
253
503
  sqlmesh_adapter: SqlmeshAdapter = context.adapter
254
- info['sqlmesh'] = {
255
- 'base_env': sqlmesh_adapter.base_env.name,
256
- 'current_env': sqlmesh_adapter.curr_env.name,
504
+ info["sqlmesh"] = {
505
+ "base_env": sqlmesh_adapter.base_env.name,
506
+ "current_env": sqlmesh_adapter.curr_env.name,
257
507
  }
258
508
 
259
509
  return info
@@ -262,32 +512,40 @@ async def get_info():
262
512
 
263
513
 
264
514
  class CllIn(BaseModel):
265
- params: Dict
515
+ node_id: Optional[str] = None
516
+ column: Optional[str] = None
517
+ change_analysis: Optional[bool] = False
518
+ no_cll: Optional[bool] = False
519
+ no_upstream: Optional[bool] = False
520
+ no_downstream: Optional[bool] = False
266
521
 
267
522
 
268
523
  class CllOutput(BaseModel):
269
- current: Dict
524
+ current: CllData
270
525
 
271
526
 
272
527
  @app.post("/api/cll", response_model=CllOutput)
273
528
  async def column_level_lineage_by_node(cll_input: CllIn):
274
529
  from recce.adapter.dbt_adapter import DbtAdapter
275
- dbt_adapter: DbtAdapter = default_context().adapter
276
530
 
277
- try:
278
- # TODO: Add support for by the node and column
279
- result = dbt_adapter.get_cll_by_node_id(cll_input.params.get('node_id'))
280
- except Exception as e:
281
- raise HTTPException(status_code=400, detail=str(e))
531
+ dbt_adapter: DbtAdapter = default_context().adapter
532
+ cll = dbt_adapter.get_cll(
533
+ node_id=cll_input.node_id,
534
+ column=cll_input.column,
535
+ change_analysis=cll_input.change_analysis,
536
+ no_upstream=cll_input.no_upstream,
537
+ no_downstream=cll_input.no_downstream,
538
+ no_cll=cll_input.no_cll,
539
+ )
282
540
 
283
- return CllOutput(current=result)
541
+ return CllOutput(current=cll)
284
542
 
285
543
 
286
544
  class SelectNodesInput(BaseModel):
287
545
  select: Optional[str] = None
288
546
  exclude: Optional[str] = None
289
547
  packages: Optional[list[str]] = None
290
- view_mode: Optional[Literal['all', 'changed_models']] = None
548
+ view_mode: Optional[Literal["all", "changed_models"]] = None
291
549
 
292
550
 
293
551
  class SelectNodesOutput(BaseModel):
@@ -298,8 +556,8 @@ class SelectNodesOutput(BaseModel):
298
556
  async def select_nodes(input: SelectNodesInput):
299
557
  context = default_context()
300
558
 
301
- if context.adapter_type != 'dbt':
302
- raise HTTPException(status_code=400, detail='Only dbt adapter is supported')
559
+ if context.adapter_type != "dbt":
560
+ raise HTTPException(status_code=400, detail="Only dbt adapter is supported")
303
561
 
304
562
  try:
305
563
  nodes = context.adapter.select_nodes(
@@ -308,7 +566,7 @@ async def select_nodes(input: SelectNodesInput):
308
566
  packages=input.packages,
309
567
  view_mode=input.view_mode,
310
568
  )
311
- nodes = [node for node in nodes if not node.startswith('test.')]
569
+ nodes = [node for node in nodes if not node.startswith("test.")]
312
570
  return SelectNodesOutput(nodes=nodes)
313
571
  except Exception as e:
314
572
  raise HTTPException(status_code=400, detail=str(e))
@@ -319,9 +577,9 @@ async def get_columns(model_id: str):
319
577
  context = default_context()
320
578
  try:
321
579
  return {
322
- 'model': {
323
- 'base': context.get_model(model_id, base=True),
324
- 'current': context.get_model(model_id, base=False)
580
+ "model": {
581
+ "base": context.get_model(model_id, base=True),
582
+ "current": context.get_model(model_id, base=False),
325
583
  }
326
584
  }
327
585
  except Exception as e:
@@ -336,12 +594,12 @@ async def save_handler():
336
594
  try:
337
595
  # Sync the state file
338
596
  context = default_context()
339
- log_api_event('save', dict(state_loader_mode=context.state_loader_mode()))
597
+ log_api_event("save", dict(state_loader_mode=context.state_loader_mode()))
340
598
  state_loader = context.state_loader
341
599
  if not state_loader.cloud_mode and state_loader.state_file is None:
342
- raise RecceException('Not file mode or cloud mode')
600
+ raise RecceException("Not file mode or cloud mode")
343
601
 
344
- context.sync_state('overwrite')
602
+ context.sync_state("overwrite")
345
603
  except RecceException as e:
346
604
  raise HTTPException(status_code=400, detail=e.message)
347
605
 
@@ -357,33 +615,33 @@ def saveas_or_rename(input: SaveAsOrRenameInput, rename: bool = False):
357
615
  context = default_context()
358
616
  state_loader = context.state_loader
359
617
  if state_loader.cloud_mode:
360
- raise RecceException('Cloud mode does not support rename')
618
+ raise RecceException("Cloud mode does not support rename")
361
619
 
362
620
  new_filename = input.filename
363
621
  if os.path.dirname(new_filename):
364
- raise RecceException('The new filename should not contain directory')
365
- if not new_filename.endswith('.json'):
366
- raise RecceException('The new filename should end with .json')
622
+ raise RecceException("The new filename should not contain directory")
623
+ if not new_filename.endswith(".json"):
624
+ raise RecceException("The new filename should end with .json")
367
625
 
368
626
  old_path = state_loader.state_file
369
627
  if old_path:
370
628
  old_dir = os.path.dirname(state_loader.state_file)
371
629
  old_filename = os.path.basename(state_loader.state_file)
372
630
  if old_filename == new_filename:
373
- raise RecceException('The new filename is the same as the current filename')
631
+ raise RecceException("The new filename is the same as the current filename")
374
632
  new_path = os.path.join(old_dir, new_filename)
375
633
  else:
376
634
  new_path = new_filename
377
635
 
378
636
  if os.path.exists(new_path):
379
637
  if os.path.isdir(new_path):
380
- raise HTTPException(status_code=400, detail=f'The file {new_path} exists and is a directory')
638
+ raise HTTPException(status_code=400, detail=f"The file {new_path} exists and is a directory")
381
639
 
382
640
  if not input.overwrite:
383
- raise HTTPException(status_code=409, detail=f'The file {new_filename} already exists')
641
+ raise HTTPException(status_code=409, detail=f"The file {new_filename} already exists")
384
642
 
385
643
  state_loader.state_file = new_path
386
- context.sync_state('overwrite')
644
+ context.sync_state("overwrite")
387
645
  if rename and os.path.exists(old_path):
388
646
  os.remove(old_path)
389
647
 
@@ -395,7 +653,7 @@ async def save_as_handler(input: SaveAsOrRenameInput):
395
653
  """
396
654
  context = default_context()
397
655
  try:
398
- log_api_event('saveas', dict(state_loader_mode=context.state_loader_mode()))
656
+ log_api_event("saveas", dict(state_loader_mode=context.state_loader_mode()))
399
657
  saveas_or_rename(input, rename=False)
400
658
  except RecceException as e:
401
659
  raise HTTPException(status_code=400, detail=e.message)
@@ -408,7 +666,7 @@ async def rename_handler(input: SaveAsOrRenameInput):
408
666
  """
409
667
  context = default_context()
410
668
  try:
411
- log_api_event('rename', dict(state_loader_mode=context.state_loader_mode()))
669
+ log_api_event("rename", dict(state_loader_mode=context.state_loader_mode()))
412
670
  saveas_or_rename(input, rename=True)
413
671
  except RecceException as e:
414
672
  raise HTTPException(status_code=400, detail=e.message)
@@ -421,7 +679,7 @@ async def export_handler():
421
679
  """
422
680
  context = default_context()
423
681
  try:
424
- log_api_event('export', dict(state_loader_mode=context.state_loader_mode()))
682
+ log_api_event("export", dict(state_loader_mode=context.state_loader_mode()))
425
683
  return context.export_state().to_json()
426
684
  except RecceException as e:
427
685
  raise HTTPException(status_code=400, detail=e.message)
@@ -429,17 +687,16 @@ async def export_handler():
429
687
 
430
688
  @app.post("/api/import", status_code=200)
431
689
  async def import_handler(
432
- file: Annotated[UploadFile, Form()],
433
- checks_only: Annotated[bool, Form()],
434
- background_tasks: BackgroundTasks
690
+ file: Annotated[UploadFile, Form()], checks_only: Annotated[bool, Form()], background_tasks: BackgroundTasks
435
691
  ):
436
692
  """
437
693
  Import the recce state from the client.
438
694
  """
439
695
  from recce.state import RecceState
696
+
440
697
  context = default_context()
441
698
  try:
442
- log_api_event('import', dict(state_loader_mode=context.state_loader_mode()))
699
+ log_api_event("import", dict(state_loader_mode=context.state_loader_mode()))
443
700
  content = await file.read()
444
701
  state = RecceState.from_json(content)
445
702
 
@@ -473,16 +730,19 @@ async def sync_handler(input: SyncStateInput, response: Response, background_tas
473
730
  context = default_context()
474
731
  state_loader = context.state_loader
475
732
  method = input.method
476
- log_api_event('sync', dict(
477
- state_loader_mode=context.state_loader_mode(),
478
- method=method,
479
- ))
733
+ log_api_event(
734
+ "sync",
735
+ dict(
736
+ state_loader_mode=context.state_loader_mode(),
737
+ method=method,
738
+ ),
739
+ )
480
740
 
481
741
  if not method:
482
742
  is_conflict = state_loader.check_conflict()
483
743
  if is_conflict:
484
- raise HTTPException(status_code=409, detail='Conflict detected')
485
- method = 'overwrite'
744
+ raise HTTPException(status_code=409, detail="Conflict detected")
745
+ method = "overwrite"
486
746
 
487
747
  is_syncing = state_loader.state_lock.locked()
488
748
  if is_syncing:
@@ -532,7 +792,7 @@ async def share_state():
532
792
  context = default_context()
533
793
  state_loader = context.state_loader
534
794
 
535
- file_name = 'recce_state.json'
795
+ file_name = "recce_state.json"
536
796
  if state_loader.state_file:
537
797
  file_name = os.path.basename(state_loader.state_file)
538
798
 
@@ -568,8 +828,8 @@ async def websocket_endpoint(websocket: WebSocket):
568
828
  try:
569
829
  while True:
570
830
  data = await websocket.receive_text()
571
- if data == 'ping':
572
- await websocket.send_text('pong')
831
+ if data == "ping":
832
+ await websocket.send_text("pong")
573
833
  except WebSocketDisconnect:
574
834
  clients.remove(websocket)
575
835
 
@@ -579,9 +839,39 @@ async def broadcast(data: str):
579
839
  await client.send_text(data)
580
840
 
581
841
 
582
- api_prefix = '/api'
842
+ @app.post("/api/connect")
843
+ async def generate_connect_to_cloud_url(background_tasks: BackgroundTasks):
844
+ if is_callback_server_running():
845
+ return {"connection_url": get_connection_url()}
846
+
847
+ private_key, public_key = generate_key_pair()
848
+ connection_url, callback_port = prepare_connection_url(public_key)
849
+
850
+ background_tasks.add_task(connect_to_cloud_background_task, private_key, callback_port, connection_url)
851
+ return {
852
+ "connection_url": connection_url,
853
+ }
854
+
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
+
870
+ api_prefix = "/api"
583
871
  app.include_router(check_router, prefix=api_prefix)
872
+ app.include_router(check_events_router, prefix=api_prefix)
584
873
  app.include_router(run_router, prefix=api_prefix)
585
874
 
586
- static_folder_path = Path(__file__).parent / 'data'
875
+ static_folder_path = Path(__file__).parent / "data"
876
+
587
877
  app.mount("/", StaticFiles(directory=static_folder_path, html=True), name="static")