Skip to content

Commit 182eb09

Browse files
committed
Fix OAuth status reporting and recovery wakeups
1 parent a543efc commit 182eb09

11 files changed

Lines changed: 334 additions & 42 deletions

‎app/platform/update_check.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
_GITHUB_RELEASES_API_URL = "https://api.github.com/repos/AuuCoder/gptGrok2api/releases?per_page=20"
1717
_GITHUB_CHANGELOG_URL = "https://raw.githubusercontent.com/AuuCoder/gptGrok2api/main/CHANGELOG.md"
18+
_GITHUB_VERSION_URL = "https://raw.githubusercontent.com/AuuCoder/gptGrok2api/main/VERSION"
1819
_RELEASE_PAGE_URL = "https://github.com/AuuCoder/gptGrok2api/releases"
1920
_CACHE_TTL_SECONDS = 86400.0
2021
_ERROR_TTL_SECONDS = 300.0
@@ -88,7 +89,7 @@ def _success_cache_ttl(payload: dict[str, Any]) -> float:
8889
current_version = str(payload.get("current_version") or "")
8990
latest_version = str(payload.get("latest_version") or "")
9091
changelog = str(payload.get("changelog") or "")
91-
release_pending = _is_newer(current_version, latest_version)
92+
release_pending = bool(payload.get("release_pending")) or _is_newer(current_version, latest_version)
9293
has_unreleased_notes = bool(re.search(r"^##\s+Unreleased\s*$", changelog, re.MULTILINE))
9394
if release_pending or has_unreleased_notes:
9495
return _ERROR_TTL_SECONDS
@@ -104,6 +105,7 @@ def _build_payload(release: dict[str, Any] | None = None, error: str = "") -> di
104105
published_at = str(release.get("published_at") or "").strip()
105106
release_notes = str(release.get("body") or "").strip()
106107
changelog = str(release.get("changelog") or "").strip()
108+
release_pending = bool(release.get("release_pending"))
107109
has_remote = bool(release)
108110
return {
109111
"current_version": current_version,
@@ -113,6 +115,7 @@ def _build_payload(release: dict[str, Any] | None = None, error: str = "") -> di
113115
"published_at": published_at,
114116
"release_notes": release_notes,
115117
"changelog": changelog,
118+
"release_pending": release_pending,
116119
"update_available": has_remote and bool(latest_version) and _is_newer(latest_version, current_version),
117120
"checked_at": _utc_now_iso(),
118121
"status": "error" if error else "ok",
@@ -152,16 +155,34 @@ async def _fetch_github_changelog(session: aiohttp.ClientSession) -> str:
152155
return await _fetch_github_text(session, _GITHUB_CHANGELOG_URL, "text/plain")
153156

154157

158+
async def _fetch_github_version(session: aiohttp.ClientSession) -> str:
159+
version = _normalize_version(
160+
await _fetch_github_text(session, _GITHUB_VERSION_URL, "text/plain")
161+
)
162+
if _parse_version(version) is None:
163+
raise RuntimeError("GitHub VERSION returned an invalid version")
164+
return version
165+
166+
155167
async def _fetch_latest_release() -> dict[str, Any]:
156168
timeout = aiohttp.ClientTimeout(total=10)
157169
async with aiohttp.ClientSession(timeout=timeout) as session:
158-
releases, changelog = await asyncio.gather(
170+
releases, changelog, repository_version = await asyncio.gather(
159171
_fetch_github_releases(session),
160172
_fetch_github_changelog(session),
173+
_fetch_github_version(session),
161174
)
162175
latest = _select_latest_release(releases)
163-
if latest is None:
164-
raise RuntimeError("GitHub Releases returned no published version")
176+
released_version = _normalize_version(
177+
str((latest or {}).get("tag_name") or (latest or {}).get("name") or "")
178+
)
179+
if latest is None or _is_newer(repository_version, released_version):
180+
latest = {
181+
"tag_name": f"v{repository_version}",
182+
"name": f"v{repository_version}",
183+
"html_url": _RELEASE_PAGE_URL,
184+
"release_pending": True,
185+
}
165186
return {**latest, "changelog": changelog}
166187

167188

‎app/platform/update_check_test.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def test_success_cache_uses_short_ttl_while_release_is_pending(self):
4545

4646
self.assertEqual(update_check._success_cache_ttl(payload), update_check._ERROR_TTL_SECONDS)
4747

48+
def test_success_cache_uses_short_ttl_for_repository_version_fallback(self):
49+
payload = {
50+
"current_version": "1.2.0",
51+
"latest_version": "1.2.0",
52+
"changelog": "# Changelog\n\n## 1.2.0 - 2026-07-25\n",
53+
"release_pending": True,
54+
}
55+
56+
self.assertEqual(update_check._success_cache_ttl(payload), update_check._ERROR_TTL_SECONDS)
57+
4858
def test_success_cache_uses_short_ttl_for_unreleased_changelog(self):
4959
payload = {
5060
"current_version": "1.0.4",
@@ -76,12 +86,45 @@ async def test_fetch_latest_release_combines_github_release_and_changelog(self):
7686
update_check,
7787
"_fetch_github_changelog",
7888
new=AsyncMock(return_value="# Changelog"),
89+
), patch.object(
90+
update_check,
91+
"_fetch_github_version",
92+
new=AsyncMock(return_value="1.0.3"),
7993
):
8094
release = await update_check._fetch_latest_release()
8195

8296
self.assertEqual(release["tag_name"], "v1.0.3")
8397
self.assertEqual(release["changelog"], "# Changelog")
8498

99+
async def test_fetch_latest_release_falls_back_to_repository_version(self):
100+
releases = [
101+
{
102+
"tag_name": "v1.1.0",
103+
"name": "v1.1.0",
104+
"draft": False,
105+
"html_url": "https://github.com/AuuCoder/gptGrok2api/releases/tag/v1.1.0",
106+
}
107+
]
108+
with patch.object(
109+
update_check,
110+
"_fetch_github_releases",
111+
new=AsyncMock(return_value=releases),
112+
), patch.object(
113+
update_check,
114+
"_fetch_github_changelog",
115+
new=AsyncMock(return_value="# Changelog\n\n## 1.2.0"),
116+
), patch.object(
117+
update_check,
118+
"_fetch_github_version",
119+
new=AsyncMock(return_value="1.2.0"),
120+
):
121+
release = await update_check._fetch_latest_release()
122+
123+
self.assertEqual(release["tag_name"], "v1.2.0")
124+
self.assertEqual(release["html_url"], update_check._RELEASE_PAGE_URL)
125+
self.assertTrue(release["release_pending"])
126+
self.assertEqual(release["changelog"], "# Changelog\n\n## 1.2.0")
127+
85128

86129
if __name__ == "__main__":
87130
unittest.main()

‎services/register_service.py‎

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -297,11 +297,23 @@ def _grok_oauth_display_status(value: object) -> str:
297297
return "normal"
298298
if probe_status == "limited":
299299
return "limited"
300-
if probe_status in {"invalid", "unknown"}:
300+
if probe_status == "invalid":
301301
return "invalid"
302+
if probe_status == "unknown":
303+
return "unknown"
302304
return "normal" if status == "active" else "invalid"
303305

304306

307+
def _grok_oauth_probe_recoverable(value: object) -> bool:
308+
probe = value if isinstance(value, dict) else {}
309+
if _clean_text(probe.get("status")).lower() != "invalid":
310+
return False
311+
code = _clean_text(probe.get("code")).lower()
312+
return code != "permission-denied" and not (
313+
code == "personal-team-blocked" or code.startswith("personal-team-blocked:")
314+
)
315+
316+
305317
def _grok_oauth_authorizable(item: object) -> bool:
306318
"""Return whether an SSO registration record is ready for OAuth."""
307319
if not isinstance(item, dict):
@@ -1548,7 +1560,7 @@ def _grok_status_matches(item: dict[str, Any], status: str) -> bool:
15481560
if oauth_status == "denied":
15491561
authorization = item.get("oauth_authorization") if isinstance(item.get("oauth_authorization"), dict) else {}
15501562
return not isinstance(item.get("oauth"), dict) and _clean_text(authorization.get("status")).lower() == "denied"
1551-
if oauth_status in {"unauthorized", "normal", "limited", "no_quota", "expired", "invalid"}:
1563+
if oauth_status in {"unauthorized", "normal", "limited", "no_quota", "expired", "invalid", "unknown"}:
15521564
if oauth_status == "unauthorized":
15531565
return _grok_oauth_authorizable(item) and not isinstance(item.get("oauth"), dict)
15541566
return _grok_oauth_display_status(item.get("oauth")) == oauth_status
@@ -1689,7 +1701,7 @@ def grok_accounts_view(self, *, keyword: str = "", status: str = "all") -> dict[
16891701
"oauth_linked": sum(1 for item in merged_items if isinstance(item.get("oauth"), dict)),
16901702
"oauth_status": {
16911703
status: sum(1 for value in oauth_display_statuses if value == status)
1692-
for status in ("unauthorized", "denied", "normal", "limited", "no_quota", "expired", "invalid")
1704+
for status in ("unauthorized", "denied", "normal", "limited", "no_quota", "expired", "invalid", "unknown")
16931705
},
16941706
"runtime_status": runtime_status,
16951707
"calls_total": calls_total,
@@ -2272,7 +2284,7 @@ def _recover_grok_oauth_account(
22722284
if not oauth_account_id or _clean_text(oauth_item.get("status")).lower() == "disabled":
22732285
return {"status": "deferred"}
22742286
probe = oauth_item.get("probe") if isinstance(oauth_item.get("probe"), dict) else {}
2275-
if _clean_text(probe.get("status")).lower() != "invalid":
2287+
if not _grok_oauth_probe_recoverable(probe):
22762288
return {"status": "deferred"}
22772289

22782290
now = datetime.now(timezone.utc)
@@ -2375,10 +2387,7 @@ def _grok_oauth_has_inflight_recovery() -> bool:
23752387
return False
23762388
return any(
23772389
_clean_text(item.get("status")).lower() != "disabled"
2378-
and _clean_text(
2379-
item.get("probe", {}).get("status") if isinstance(item.get("probe"), dict) else ""
2380-
).lower()
2381-
== "invalid"
2390+
and _grok_oauth_probe_recoverable(item.get("probe"))
23822391
and _clean_text(
23832392
item.get("recovery", {}).get("status") if isinstance(item.get("recovery"), dict) else ""
23842393
).lower()
@@ -2387,6 +2396,21 @@ def _grok_oauth_has_inflight_recovery() -> bool:
23872396
if isinstance(item, dict)
23882397
)
23892398

2399+
@staticmethod
2400+
def _grok_oauth_has_due_invalid_recovery() -> bool:
2401+
try:
2402+
items = xai_cli_oauth_store.list_accounts(redacted=True)
2403+
except Exception:
2404+
return False
2405+
now = datetime.now(timezone.utc)
2406+
return any(
2407+
_clean_text(item.get("status")).lower() != "disabled"
2408+
and _grok_oauth_probe_recoverable(item.get("probe"))
2409+
and RegisterService._grok_oauth_recovery_due(item, now=now)
2410+
for item in items
2411+
if isinstance(item, dict)
2412+
)
2413+
23902414
@staticmethod
23912415
def _grok_oauth_unlinked_sources() -> tuple[list[dict[str, Any]], int]:
23922416
oauth_emails = {
@@ -2422,6 +2446,19 @@ def _grok_oauth_has_unlinked_accounts(self) -> bool:
24222446
return False
24232447
return bool(sources)
24242448

2449+
def handle_grok_oauth_probe_results(self, results: list[dict[str, Any]]) -> None:
2450+
has_invalid = any(
2451+
isinstance(item, dict)
2452+
and _grok_oauth_probe_recoverable(item)
2453+
for item in results
2454+
)
2455+
if not has_invalid:
2456+
return
2457+
with self._lock:
2458+
enabled = bool(self._grok_probe_config_locked().get("enabled"))
2459+
if enabled:
2460+
self._grok_probe_wake_event.set()
2461+
24252462
def _queue_unlinked_grok_oauth_accounts(
24262463
self,
24272464
*,
@@ -2571,6 +2608,7 @@ def _run_grok_probe_scheduler(self, stop_event: threading.Event) -> None:
25712608
recovery_sweep_due = (
25722609
recovery_sweep_due
25732610
or self._grok_oauth_has_inflight_recovery()
2611+
or self._grok_oauth_has_due_invalid_recovery()
25742612
or self._grok_oauth_has_unlinked_accounts()
25752613
)
25762614
if recovery_sweep_due and not cycle_stop.is_set():
@@ -2583,6 +2621,12 @@ def _run_grok_probe_scheduler(self, stop_event: threading.Event) -> None:
25832621
permission_retry = self._run_grok_permission_retry_once(cycle_stop)
25842622
if cycle_stop.is_set():
25852623
continue
2624+
if self._grok_oauth_has_due_invalid_recovery():
2625+
self._run_grok_oauth_recovery_sweep(
2626+
include_backfill=False,
2627+
stop_event=cycle_stop,
2628+
)
2629+
continue
25862630
with self._lock:
25872631
probe = self._grok_probe_config_locked()
25882632
should_run = bool(probe.get("enabled")) and self._grok_probe_due_locked(probe)
@@ -2709,6 +2753,7 @@ def _run_grok_oauth_recovery_sweep(
27092753
*,
27102754
limit: int | None = None,
27112755
reclaim_inflight: bool = False,
2756+
include_backfill: bool = True,
27122757
stop_event: Any | None = None,
27132758
) -> dict[str, int]:
27142759
summary = {
@@ -2734,14 +2779,7 @@ def _run_grok_oauth_recovery_sweep(
27342779
item
27352780
for item in oauth_items
27362781
if _clean_text(item.get("status")).lower() != "disabled"
2737-
and _clean_text(
2738-
item.get("probe", {}).get("status") if isinstance(item.get("probe"), dict) else ""
2739-
).lower()
2740-
== "invalid"
2741-
and _clean_text(
2742-
item.get("probe", {}).get("code") if isinstance(item.get("probe"), dict) else ""
2743-
).lower()
2744-
!= "permission-denied"
2782+
and _grok_oauth_probe_recoverable(item.get("probe"))
27452783
]
27462784
candidates.sort(
27472785
key=lambda item: 0
@@ -2781,9 +2819,10 @@ def _run_grok_oauth_recovery_sweep(
27812819
summary["failed"] += 1
27822820
else:
27832821
summary["deferred"] += 1
2784-
backfill = self._queue_unlinked_grok_oauth_accounts(stop_event=stop_event)
2785-
for key, value in backfill.items():
2786-
summary[f"backfill_{key}"] = value
2822+
if include_backfill:
2823+
backfill = self._queue_unlinked_grok_oauth_accounts(stop_event=stop_event)
2824+
for key, value in backfill.items():
2825+
summary[f"backfill_{key}"] = value
27872826
except _GrokProbeCancelled:
27882827
cancelled = True
27892828
except Exception as error:
@@ -2996,7 +3035,7 @@ def _run_grok_probe_once(self, stop_event: Any | None = None) -> dict[str, int]:
29963035
status = _clean_text(result.get("status")).lower() if isinstance(result, dict) else "unknown"
29973036
key = status if status in {"valid", "limited", "invalid", "unknown"} else "unknown"
29983037
summary[f"oauth_{key}"] += 1
2999-
if status != "invalid" or not isinstance(result, dict):
3038+
if not isinstance(result, dict) or not _grok_oauth_probe_recoverable(result):
30003039
continue
30013040
oauth_account_id = _clean_text(result.get("account_id"))
30023041
candidate = oauth_by_id.get(oauth_account_id)
@@ -3005,7 +3044,14 @@ def _run_grok_probe_once(self, stop_event: Any | None = None) -> dict[str, int]:
30053044
continue
30063045
_raise_if_grok_probe_cancelled(stop_event)
30073046
recovery = self._recover_grok_oauth_account(
3008-
{**candidate, "status": "invalid", "probe": {"status": "invalid"}},
3047+
{
3048+
**candidate,
3049+
"status": "invalid",
3050+
"probe": {
3051+
"status": "invalid",
3052+
"code": _clean_text(result.get("code")),
3053+
},
3054+
},
30093055
source_by_email,
30103056
)
30113057
recovery_status = _clean_text(recovery.get("status")).lower()
@@ -4203,3 +4249,4 @@ def _start_xai_cli_oauth_protocol(
42034249
from services.xai_cli_oauth_service import xai_cli_oauth_service
42044250

42054251
xai_cli_oauth_service.protocol_event_sink = register_service.handle_grok_oauth_protocol_event
4252+
xai_cli_oauth_service.probe_event_sink = register_service.handle_grok_oauth_probe_results

0 commit comments

Comments
 (0)