mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 04:07:44 +02:00
[eric] 1.0.26 windows-only: fix subscription OAuth + perf pass
Connecting→Connect mid-flow on Windows: 9router callback hardcoded localhost:8324 (dies when backend lands on 8325+)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Exposes the OpenSwarm backend's dynamic port to the in-popup callback page
|
||||
// at /callback. The Python backend picks its port via
|
||||
// getPort.makeRange(8324, 8424) and exports it as OPENSWARM_PORT when it
|
||||
// spawns the 9Router subprocess, so we just surface that here.
|
||||
//
|
||||
// Needed because the callback page used to hardcode localhost:8324 in its
|
||||
// "direct exchange" fallback (Method 4 in page.js). On Windows, 8324 is
|
||||
// frequently held by other services, so the backend lands on 8325+ and the
|
||||
// hardcoded URL 404s — breaking subscription connect for anyone whose
|
||||
// postMessage path also fails (common on Windows due to COOP / popup-opener
|
||||
// quirks). Fetching this config first makes Method 4 work regardless of
|
||||
// which port the backend ended up on.
|
||||
export async function GET() {
|
||||
const raw = process.env.OPENSWARM_PORT;
|
||||
const port = raw ? parseInt(raw, 10) : 8324;
|
||||
return NextResponse.json(
|
||||
{ backendPort: Number.isFinite(port) && port > 0 ? port : 8324 },
|
||||
{
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -24,13 +24,10 @@ function CallbackContent() {
|
||||
fullUrl: window.location.href,
|
||||
};
|
||||
|
||||
let relayed = false;
|
||||
|
||||
// Method 1: postMessage to opener (popup mode)
|
||||
if (window.opener) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("postMessage failed:", e);
|
||||
}
|
||||
@@ -41,7 +38,6 @@ function CallbackContent() {
|
||||
const channel = new BroadcastChannel("oauth_callback");
|
||||
channel.postMessage(callbackData);
|
||||
channel.close();
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("BroadcastChannel failed:", e);
|
||||
}
|
||||
@@ -49,17 +45,48 @@ function CallbackContent() {
|
||||
// Method 3: localStorage event (fallback)
|
||||
try {
|
||||
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("localStorage failed:", e);
|
||||
}
|
||||
|
||||
// Method 4: Direct exchange via OpenSwarm backend (works even when postMessage fails)
|
||||
// Fetch pending OAuth data from OpenSwarm, then call 9Router's exchange endpoint
|
||||
if (!(code || error)) {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Method 4: Direct exchange via OpenSwarm backend. This is the path that
|
||||
// keeps the flow working when Method 1 silently fails (COOP severs
|
||||
// window.opener on Windows / newer Chromium, or Windows Defender blocks
|
||||
// cross-context postMessage from the popup back to the Electron parent).
|
||||
//
|
||||
// Two changes vs. the original implementation:
|
||||
// 1. Discover the backend port dynamically from /api/openswarm-config
|
||||
// (falling back to 8324). The backend lives on a dynamic port picked
|
||||
// from 8324-8424, and hardcoding 8324 broke Windows users whose
|
||||
// machines held that port.
|
||||
// 2. Defer window.close() until after the exchange completes. The old
|
||||
// 1.5s unconditional close cancelled the in-flight exchange whenever
|
||||
// token exchange with the upstream provider ran >1.5s (common on
|
||||
// slow networks), leaving 9Router with no connection saved.
|
||||
if (code && state) {
|
||||
(async () => {
|
||||
let backendPort = 8324;
|
||||
try {
|
||||
const pendingRes = await fetch(`http://localhost:8324/api/subscriptions/pending/${encodeURIComponent(state)}`);
|
||||
const cfgRes = await fetch("/api/openswarm-config", { cache: "no-store" });
|
||||
if (cfgRes.ok) {
|
||||
const cfg = await cfgRes.json();
|
||||
if (cfg && typeof cfg.backendPort === "number") backendPort = cfg.backendPort;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("config fetch failed, using 8324:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingRes = await fetch(
|
||||
`http://localhost:${backendPort}/api/subscriptions/pending/${encodeURIComponent(state)}`,
|
||||
);
|
||||
if (pendingRes.ok) {
|
||||
const pending = await pendingRes.json();
|
||||
if (pending.provider && pending.code_verifier) {
|
||||
@@ -81,19 +108,21 @@ function CallbackContent() {
|
||||
} catch (e) {
|
||||
console.log("Direct exchange fallback failed:", e);
|
||||
}
|
||||
|
||||
// Small grace period so any postMessage listeners on the parent
|
||||
// get their turn before the renderer is torn down.
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 500);
|
||||
})();
|
||||
} else {
|
||||
// Error-only callbacks (no code) — nothing to exchange, close quickly.
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
if (!(code || error)) {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
setTimeout(() => setStatus("done"), 500);
|
||||
}, 1500);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -330,6 +330,15 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
|
||||
closes itself in a background task. Safe to call even if 1455 is busy —
|
||||
logs the collision and returns None so start_oauth can still proceed and
|
||||
surface whatever error OpenAI returns.
|
||||
|
||||
Also performs the OAuth exchange server-side before serving the HTML.
|
||||
Relying on the frontend's postMessage path alone breaks on Windows where
|
||||
COOP / popup-opener quirks silently drop the message, leaving the user
|
||||
stuck on "Connecting…" until the 30s timeout fires. Exchanging here
|
||||
(the same pattern backend/main.py uses for the Gemini callback) makes
|
||||
the connection land in 9Router's DB regardless of whether the UI's
|
||||
postMessage listener ever gets notified — the Settings / OnboardingModal
|
||||
status pollers then pick it up within a couple seconds.
|
||||
"""
|
||||
|
||||
callback_served = asyncio.Event()
|
||||
@@ -352,6 +361,53 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
|
||||
method = parts[0] if parts else ""
|
||||
|
||||
if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH):
|
||||
# Parse code/state out of the query string and exchange
|
||||
# server-side before serving the HTML. Duplicate exchanges
|
||||
# are harmless (single-use auth codes fail the second call,
|
||||
# which we swallow) so racing with the frontend's
|
||||
# msgHandler-driven exchange is fine.
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(path)
|
||||
q = parse_qs(parsed.query)
|
||||
code = (q.get("code") or [""])[0]
|
||||
state = (q.get("state") or [""])[0]
|
||||
if code and state:
|
||||
try:
|
||||
from backend.main import _pending_oauth, _mark_oauth_completed
|
||||
except Exception:
|
||||
_pending_oauth = None
|
||||
_mark_oauth_completed = None
|
||||
|
||||
if _pending_oauth is not None:
|
||||
pending = _pending_oauth.pop(state, None)
|
||||
if pending:
|
||||
try:
|
||||
await exchange_oauth(
|
||||
pending["provider"],
|
||||
code,
|
||||
pending["redirect_uri"],
|
||||
pending["code_verifier"],
|
||||
state,
|
||||
)
|
||||
if _mark_oauth_completed is not None:
|
||||
_mark_oauth_completed(state)
|
||||
logger.info(
|
||||
f"Codex callback: server-side exchange succeeded for state {state[:8]}..."
|
||||
)
|
||||
except Exception as e:
|
||||
# Put the pending entry back so the
|
||||
# frontend's msgHandler retry via
|
||||
# /agents/subscriptions/exchange still
|
||||
# has a shot. Safe because we only popped
|
||||
# it a moment ago.
|
||||
_pending_oauth[state] = pending
|
||||
logger.debug(
|
||||
f"Codex callback: server-side exchange failed ({e}); leaving for frontend retry"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Codex callback listener pre-exchange error: {e}")
|
||||
|
||||
body = _CODEX_CALLBACK_HTML
|
||||
response = (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException, Query, UploadFile, File
|
||||
@@ -54,11 +57,63 @@ def load_settings() -> AppSettings:
|
||||
return AppSettings()
|
||||
|
||||
|
||||
def _save_settings(settings_obj: AppSettings):
|
||||
"""Persist settings to JSON file."""
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(settings_obj.model_dump(), f, indent=2)
|
||||
# Single threading.Lock guards every write to SETTINGS_FILE — protects against
|
||||
# corruption from two requests racing through the file system. Async callers
|
||||
# offload the actual write to the default thread pool (run_in_executor), so
|
||||
# the lock works for both sync and thread-pool execution paths.
|
||||
_settings_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def _atomic_write_settings(payload: dict) -> None:
|
||||
"""Internal: serialise payload to SETTINGS_FILE atomically.
|
||||
Always called via save_settings* — don't invoke directly."""
|
||||
with _settings_write_lock:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
# On Windows, os.replace can transiently fail with PermissionError
|
||||
# if Defender or another reader holds the destination open. One
|
||||
# retry after a short backoff handles every real-world case
|
||||
# without masking genuine permission bugs.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
os.replace(tmp, SETTINGS_FILE)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 1:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def save_settings(settings_obj: AppSettings) -> None:
|
||||
"""Synchronously persist settings atomically. Thread-safe.
|
||||
Use from sync paths (analytics collector, lifespans). Async callers should
|
||||
prefer save_settings_async to avoid blocking the event loop on Windows
|
||||
where Defender scans can stretch the write to 50-200ms."""
|
||||
_atomic_write_settings(settings_obj.model_dump())
|
||||
|
||||
|
||||
async def save_settings_async(settings_obj: AppSettings) -> None:
|
||||
"""Async-safe atomic save. Runs the file I/O in the default thread pool
|
||||
so the FastAPI event loop stays responsive while the write completes.
|
||||
Shares the threading.Lock with the sync variant for safe interleaving."""
|
||||
payload = settings_obj.model_dump()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _atomic_write_settings, payload)
|
||||
|
||||
|
||||
# Backward-compat alias. Existing sync callers (analytics collector, analytics
|
||||
# lifespan) continue to work; new async callers should use save_settings_async.
|
||||
def _save_settings(settings_obj: AppSettings) -> None:
|
||||
save_settings(settings_obj)
|
||||
|
||||
|
||||
@settings.router.get("")
|
||||
@@ -117,9 +172,7 @@ async def update_settings(body: AppSettings):
|
||||
if id_props:
|
||||
_identify(id_props)
|
||||
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(body.model_dump(), f, indent=2)
|
||||
await save_settings_async(body)
|
||||
return {"ok": True, "settings": body.model_dump()}
|
||||
|
||||
|
||||
@@ -132,9 +185,7 @@ async def get_default_system_prompt():
|
||||
async def reset_system_prompt():
|
||||
current = load_settings()
|
||||
current.default_system_prompt = DEFAULT_SYSTEM_PROMPT
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(current.model_dump(), f, indent=2)
|
||||
await save_settings_async(current)
|
||||
return {"ok": True, "settings": current.model_dump()}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
|
||||
from backend.apps.settings.settings import SETTINGS_FILE, load_settings
|
||||
from backend.apps.settings.settings import SETTINGS_FILE, load_settings, save_settings_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,15 +36,7 @@ def _proxy_url() -> str:
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _write_settings(settings_obj) -> None:
|
||||
"""Persist AppSettings to disk. Mirrors backend/apps/settings/settings.py
|
||||
_save_settings to avoid importing a private module member."""
|
||||
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(settings_obj.model_dump(), f, indent=2)
|
||||
|
||||
|
||||
def _clear_subscription(settings_obj) -> None:
|
||||
async def _clear_subscription(settings_obj) -> None:
|
||||
"""Revert to own_key mode and drop all OpenSwarm Pro state. Used by the
|
||||
explicit /disconnect endpoint and by /status when the cloud reports the
|
||||
bearer as revoked (401) or the subscription as past its grace period
|
||||
@@ -55,7 +47,7 @@ def _clear_subscription(settings_obj) -> None:
|
||||
settings_obj.openswarm_subscription_plan = None
|
||||
settings_obj.openswarm_subscription_expires = None
|
||||
settings_obj.openswarm_usage_cached = None
|
||||
_write_settings(settings_obj)
|
||||
await save_settings_async(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
|
||||
|
||||
@@ -153,7 +145,7 @@ async def activate(body: ActivateRequest):
|
||||
if isinstance(usage, dict):
|
||||
settings_obj.openswarm_usage_cached = usage
|
||||
|
||||
_write_settings(settings_obj)
|
||||
await save_settings_async(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
|
||||
|
||||
@@ -198,7 +190,7 @@ async def status():
|
||||
# Update cache for offline display.
|
||||
if isinstance(live_usage, dict):
|
||||
settings_obj.openswarm_usage_cached = live_usage
|
||||
_write_settings(settings_obj)
|
||||
await save_settings_async(settings_obj)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/status live fetch failed: %s", e)
|
||||
|
||||
@@ -207,7 +199,7 @@ async def status():
|
||||
# through a dead subscription. Settings UI sees connected=False and
|
||||
# falls back to the Subscribe CTA; chat reverts to own_key routing.
|
||||
if upstream_code in (401, 402):
|
||||
_clear_subscription(settings_obj)
|
||||
await _clear_subscription(settings_obj)
|
||||
return {
|
||||
"connected": False,
|
||||
"connection_mode": "own_key",
|
||||
@@ -266,7 +258,7 @@ async def sync():
|
||||
# the bearer is dead or the sub expired, clear local state so the app
|
||||
# reverts to own_key instead of hammering a useless token.
|
||||
if r.status_code in (401, 402):
|
||||
_clear_subscription(settings_obj)
|
||||
await _clear_subscription(settings_obj)
|
||||
reason = "revoked" if r.status_code == 401 else "expired"
|
||||
_record("subscription.sync_ran", {"reason": reason})
|
||||
return {
|
||||
@@ -294,7 +286,7 @@ async def sync():
|
||||
settings_obj.openswarm_subscription_expires = (
|
||||
datetime.fromtimestamp(period_end_ms / 1000, tz=timezone.utc).isoformat()
|
||||
)
|
||||
_write_settings(settings_obj)
|
||||
await save_settings_async(settings_obj)
|
||||
_sync_subscription_identity(settings_obj)
|
||||
_record("subscription.sync_ran", {
|
||||
"reason": "ok",
|
||||
@@ -343,5 +335,5 @@ async def disconnect():
|
||||
"""Clears local bearer + reverts to own_key mode. Does NOT cancel the
|
||||
Stripe subscription (use the portal for that). Useful when a user wants
|
||||
to temporarily route through their own API key."""
|
||||
_clear_subscription(load_settings())
|
||||
await _clear_subscription(load_settings())
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.25",
|
||||
"version": "1.0.26",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -312,7 +312,17 @@ const OnboardingModal: React.FC = () => {
|
||||
trackEvent('onboarding.education_started');
|
||||
};
|
||||
|
||||
// 500ms debounce on Next/Back during the video walkthrough. The video
|
||||
// element remounts on each step (key=walkthroughIdx), and on Windows the
|
||||
// DirectX decode + Defender file scan delays first-frame by 700-1500ms.
|
||||
// During that window the new video looks frozen so users click again,
|
||||
// skipping a step. The debounce drops anything that lands inside the
|
||||
// perceptual-freeze window so one human click = one step regardless.
|
||||
const lastWalkthroughClickRef = useRef(0);
|
||||
const advanceWalkthrough = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastWalkthroughClickRef.current < 500) return;
|
||||
lastWalkthroughClickRef.current = now;
|
||||
const next = walkthroughIdx + 1;
|
||||
const currentTitle = EDUCATION_STEPS[walkthroughIdx]?.title;
|
||||
if (next >= EDUCATION_STEPS.length) {
|
||||
@@ -326,6 +336,9 @@ const OnboardingModal: React.FC = () => {
|
||||
};
|
||||
|
||||
const backWalkthrough = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastWalkthroughClickRef.current < 500) return;
|
||||
lastWalkthroughClickRef.current = now;
|
||||
setWalkthroughIdx((i) => Math.max(0, i - 1));
|
||||
};
|
||||
|
||||
@@ -418,7 +431,12 @@ const OnboardingModal: React.FC = () => {
|
||||
} catch {}
|
||||
}, 5000);
|
||||
pollTimerRef.current = timer;
|
||||
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000);
|
||||
// 3-minute popup timeout (was 30s). OAuth with 2FA on Windows can
|
||||
// easily take >30s; the connectedProviders poller above still picks
|
||||
// up the connection after timeout, but a longer in-flow window
|
||||
// keeps the "Connecting…" indicator accurate instead of flipping
|
||||
// back to "Connect →" mid-auth.
|
||||
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 180000);
|
||||
|
||||
} else if (data.flow === 'authorization_code') {
|
||||
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
|
||||
@@ -476,7 +494,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
|
||||
setConnecting(null);
|
||||
}, 30000);
|
||||
}, 180000);
|
||||
|
||||
} else {
|
||||
setConnecting(null);
|
||||
|
||||
@@ -735,10 +735,15 @@ const SubscriptionCards: React.FC = () => {
|
||||
};
|
||||
if (!useExternal) window.addEventListener('message', msgHandler);
|
||||
|
||||
// Timeout: 30s for popup flow (user finishes auth in a few seconds),
|
||||
// 5 minutes for external-browser flow (user has to tab-switch, log
|
||||
// in, consent — takes much longer in practice).
|
||||
const timeoutMs = useExternal ? 300_000 : 30_000;
|
||||
// Timeout: 3 minutes for popup flow (was 30s — too short for 2FA /
|
||||
// slow networks, and on Windows postMessage from the callback popup
|
||||
// can silently fail due to COOP / opener severing, leaving the only
|
||||
// exit as this timeout firing mid-flow). 5 minutes for external-
|
||||
// browser flow (user has to tab-switch, log in, consent — takes
|
||||
// much longer in practice). The connecting-side poller (see the
|
||||
// useEffect below `handleDisconnect`) is the authoritative safety
|
||||
// net — this timeout just bounds the Connecting… indicator.
|
||||
const timeoutMs = useExternal ? 300_000 : 180_000;
|
||||
setTimeout(() => {
|
||||
clearInterval(statusPoller);
|
||||
setPollTimer(null);
|
||||
@@ -770,6 +775,41 @@ const SubscriptionCards: React.FC = () => {
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Safety-net poller that runs whenever a connect attempt is in flight.
|
||||
// The handleConnect flow's own statusPoller exits as soon as isActive is
|
||||
// seen, and its 3-minute timeout unconditionally clears `connecting` —
|
||||
// but on Windows the OAuth popup's postMessage path can fail silently
|
||||
// (COOP severs opener, Defender interferes, etc.), so the ONLY way out
|
||||
// of "Connecting…" becomes that timeout, which flips the card back to
|
||||
// "Connect" even when the backend exchange succeeded. This separate
|
||||
// poller watches the same status endpoint every 4s and clears the
|
||||
// Connecting state the moment 9Router reports the provider isActive,
|
||||
// whether that's via Method 1 (postMessage → frontend exchange), the
|
||||
// 9Router callback page's Method 4 (server-side exchange), or the Codex
|
||||
// listener's new server-side exchange.
|
||||
useEffect(() => {
|
||||
if (!connecting) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const d = await r.json();
|
||||
if (cancelled) return;
|
||||
const conns = d?.providers?.connections || [];
|
||||
if (conns.some((p: any) => p.provider === connecting && (p.isActive || p.testStatus === 'active'))) {
|
||||
setStatus(d);
|
||||
setConnecting(null);
|
||||
setUserCode('');
|
||||
refreshPickerModels();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
const id = setInterval(tick, 4000);
|
||||
return () => { cancelled = true; clearInterval(id); };
|
||||
// refreshPickerModels is stable (no deps), fetchStatus isn't used here
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connecting]);
|
||||
|
||||
if (!status) {
|
||||
// Initial loading — show skeleton cards
|
||||
return (
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -103,16 +103,24 @@ function Cleanup-All {
|
||||
|
||||
try {
|
||||
# --- Start backend (NoNewWindow so logs interleave into this terminal) ---
|
||||
# No --reload on Windows: uvicorn's reload mode forces use_subprocess=True
|
||||
# which pins the worker to WindowsSelectorEventLoop. That loop raises
|
||||
# NotImplementedError on asyncio.create_subprocess_exec — and the Claude
|
||||
# Agent SDK uses exactly that to spawn the `claude` CLI, so sending a
|
||||
# chat message crashes with "Failed to start Claude Code" under --reload.
|
||||
# Mac doesn't hit it (no Proactor/Selector split). Packaged Windows doesn't
|
||||
# hit it either (electron/main.js launches uvicorn without --reload).
|
||||
# Tradeoff: no backend hot-reload in dev on Windows — Ctrl+C and re-run
|
||||
# `.\run.ps1` after backend code changes. Frontend hot-reload is
|
||||
# unaffected (webpack-dev-server handles its own watching).
|
||||
Write-Host ""
|
||||
Write-Host "[backend] Starting uvicorn --reload on http://localhost:8324 ..." -ForegroundColor Blue
|
||||
Write-Host "[backend] Starting uvicorn on http://localhost:8324 ..." -ForegroundColor Blue
|
||||
$backend = Start-Process -PassThru -NoNewWindow `
|
||||
-FilePath $VenvPy `
|
||||
-WorkingDirectory $ScriptDir `
|
||||
-ArgumentList @(
|
||||
'-m', 'uvicorn', 'backend.main:app',
|
||||
'--host', '0.0.0.0', '--port', '8324', '--reload',
|
||||
'--reload-dir', (Join-Path $ScriptDir 'backend'),
|
||||
'--reload-exclude', '*.pyc'
|
||||
'--host', '127.0.0.1', '--port', '8324'
|
||||
)
|
||||
[void]$script:childPids.Add(@{ Pid = $backend.Id; Label = 'backend' })
|
||||
|
||||
@@ -122,7 +130,12 @@ try {
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ($backend.HasExited) { throw "Backend exited prematurely (code $($backend.ExitCode))" }
|
||||
try {
|
||||
Invoke-WebRequest -Uri 'http://localhost:8324/api/health/check' -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
|
||||
# Hit 127.0.0.1 (not `localhost`) so we don't waste time on the
|
||||
# IPv6 ::1 fallback — uvicorn is IPv4-only. TimeoutSec 5 because
|
||||
# PS 5.1's first Invoke-WebRequest call pays ~1-2s of .NET
|
||||
# network-stack warm-up, and Windows Defender adds scan latency
|
||||
# on the first localhost connect from a new process.
|
||||
Invoke-WebRequest -Uri 'http://127.0.0.1:8324/api/health/check' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {}
|
||||
@@ -146,7 +159,8 @@ try {
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ($frontend.HasExited) { throw "Frontend exited prematurely (code $($frontend.ExitCode))" }
|
||||
try {
|
||||
Invoke-WebRequest -Uri 'http://localhost:3000/' -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
|
||||
# See backend probe above — same 127.0.0.1 + 5s timeout reasoning.
|
||||
Invoke-WebRequest -Uri 'http://127.0.0.1:3000/' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {}
|
||||
|
||||
@@ -206,7 +206,7 @@ function Copy-Excluded($Source, $Dest, $Exclude) {
|
||||
|
||||
Copy-Excluded `
|
||||
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
|
||||
@{ Dirs = @('__pycache__','.venv','tools'); Files = @('*.pyc') }
|
||||
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc') }
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
|
||||
|
||||
Copy-Excluded `
|
||||
|
||||
@@ -172,6 +172,7 @@ rsync -a \
|
||||
--exclude='__pycache__' --exclude='**/__pycache__' \
|
||||
--exclude='*.pyc' --exclude='.venv' \
|
||||
--exclude='data/tools' \
|
||||
--exclude='tests' --exclude='**/tests' \
|
||||
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
||||
# Create empty tools directory so the app has a place to write
|
||||
mkdir -p "$STAGING_DIR/backend/data/tools"
|
||||
|
||||
Reference in New Issue
Block a user