recce-nightly 1.10.0.20250629__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.
- recce/VERSION +1 -1
- recce/__init__.py +5 -0
- recce/adapter/dbt_adapter/__init__.py +116 -74
- recce/artifact.py +76 -3
- recce/cli.py +665 -69
- recce/config.py +2 -2
- recce/connect_to_cloud.py +1 -1
- recce/core.py +3 -3
- recce/data/404.html +1 -22
- recce/data/__next.__PAGE__.txt +10 -0
- recce/data/__next._full.txt +23 -0
- recce/data/__next._index.txt +8 -0
- recce/data/__next._tree.txt +12 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_clientMiddlewareManifest.json +1 -0
- recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
- recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
- recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
- recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
- recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
- recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
- recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
- recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
- recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
- recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -0
- recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
- recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
- recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
- recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
- recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
- recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
- recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
- recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
- recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
- recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
- recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
- recce/data/_not-found/__next._full.txt +17 -0
- recce/data/_not-found/__next._index.txt +8 -0
- recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
- recce/data/_not-found/__next._not-found.txt +4 -0
- recce/data/_not-found/__next._tree.txt +10 -0
- recce/data/_not-found.html +1 -0
- recce/data/_not-found.txt +17 -0
- recce/data/auth_callback.html +1 -1
- recce/data/index.html +1 -27
- recce/data/index.txt +23 -8
- recce/event/__init__.py +9 -8
- recce/event/collector.py +6 -2
- recce/event/track.py +10 -0
- recce/github.py +1 -1
- recce/mcp_server.py +632 -0
- recce/models/types.py +23 -2
- recce/pull_request.py +1 -1
- recce/run.py +23 -16
- recce/server.py +165 -11
- recce/state/__init__.py +31 -0
- recce/state/cloud.py +632 -0
- recce/state/const.py +26 -0
- recce/state/local.py +56 -0
- recce/state/state.py +119 -0
- recce/state/state_loader.py +174 -0
- recce/summary.py +2 -1
- recce/tasks/dataframe.py +59 -2
- recce/tasks/rowcount.py +4 -1
- recce/tasks/schema.py +4 -1
- recce/tasks/valuediff.py +1 -1
- recce/util/api_token.py +11 -2
- recce/util/breaking.py +9 -0
- recce/util/cll.py +1 -2
- recce/util/io.py +2 -2
- recce/util/lineage.py +14 -18
- recce/util/perf_tracking.py +85 -0
- recce/util/recce_cloud.py +229 -5
- recce/yaml/__init__.py +2 -2
- recce_cloud/__init__.py +15 -0
- recce_cloud/api/__init__.py +17 -0
- recce_cloud/api/base.py +104 -0
- recce_cloud/api/client.py +150 -0
- recce_cloud/api/exceptions.py +26 -0
- recce_cloud/api/factory.py +63 -0
- recce_cloud/api/github.py +72 -0
- recce_cloud/api/gitlab.py +78 -0
- recce_cloud/artifact.py +57 -0
- recce_cloud/ci_providers/__init__.py +9 -0
- recce_cloud/ci_providers/base.py +82 -0
- recce_cloud/ci_providers/detector.py +147 -0
- recce_cloud/ci_providers/github_actions.py +136 -0
- recce_cloud/ci_providers/gitlab_ci.py +130 -0
- recce_cloud/cli.py +303 -0
- recce_cloud/upload.py +213 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/METADATA +31 -27
- recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/top_level.txt +1 -0
- tests/adapter/dbt_adapter/test_dbt_cll.py +68 -17
- tests/recce_cloud/__init__.py +0 -0
- tests/recce_cloud/test_ci_providers.py +351 -0
- tests/recce_cloud/test_cli.py +372 -0
- tests/recce_cloud/test_client.py +273 -0
- tests/recce_cloud/test_platform_clients.py +279 -0
- tests/test_cli.py +106 -3
- tests/test_cli_mcp_optional.py +45 -0
- tests/test_cloud_listing_cli.py +324 -0
- tests/test_core.py +147 -0
- tests/test_mcp_server.py +332 -0
- tests/test_server.py +6 -6
- tests/test_summary.py +14 -6
- recce/data/_next/static/Mrb9CZ3toH6Q8xrzNzCrg/_buildManifest.js +0 -1
- recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
- recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
- recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
- recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
- recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
- recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
- recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
- recce/data/_next/static/chunks/41-f30276c289169376.js +0 -9
- recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
- recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
- recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
- recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
- recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
- recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
- recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
- recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
- recce/data/_next/static/chunks/92-68460b15fe448f33.js +0 -1
- recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
- recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
- recce/data/_next/static/chunks/app/layout-292f035bb0d2a98e.js +0 -1
- recce/data/_next/static/chunks/app/page-598f8acc82179d01.js +0 -1
- recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
- recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
- recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
- recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
- recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
- recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
- recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
- recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
- recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
- recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
- recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
- recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
- recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
- recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
- recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
- recce/data/_next/static/css/a2b12b4ba4227f0a.css +0 -3
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
- recce/state.py +0 -786
- recce_nightly-1.10.0.20250629.dist-info/RECORD +0 -154
- tests/test_state.py +0 -134
- /recce/data/_next/static/{Mrb9CZ3toH6Q8xrzNzCrg → 6LypcDXgyuSaiSCrsmUub}/_ssgManifest.js +0 -0
- /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
- /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
- /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/WHEEL +0 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
- {recce_nightly-1.10.0.20250629.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:
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 == "
|
|
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)
|
recce/state/__init__.py
ADDED
|
@@ -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
|
+
]
|