feat: make web threads readable by any org user (#1425)

* feat: make web threads viewable by any org user

Reads (get/stream) now allow any logged-in org user to view a thread
whose source is surfaced (dashboard/github/slack/linear/schedule),
instead of requiring ownership. Internal reviewer/analyzer threads stay
hidden via the same source filter. Writes (send/cancel/delete) remain
owner-only.

Adds GET /threads?all=true to list every surfaced thread; the default
list stays per-user.

* feat: make all web threads readable by any org user

Drop the per-thread source/owner gate on reads: any logged-in org user
can now view and stream any thread, including reviewer/analyzer threads.
GET /threads?all=true returns every thread regardless of source. Writes
(send/cancel/delete) stay owner-only. All routes remain behind the
session + org-login gate.
This commit is contained in:
Ramon Nogueira 2026-06-05 11:28:08 -04:00 • committed by GitHub
parent 3a91fbfabc
commit bbe449244a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 7 additions and 10 deletions

View file

@ -755,9 +755,10 @@ async def api_delete_schedule(
@router.get("/threads")
async def api_list_threads(
all: bool = False,
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
return await list_dashboard_threads(session["sub"], email=session.get("email"))
return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all)
@router.post("/threads")

View file

@ -205,11 +205,11 @@ async def _latest_run_status(thread_id: str) -> str | None:
async def list_dashboard_threads(
login: str, *, email: str | None = None, limit: int = 50
login: str, *, email: str | None = None, limit: int = 50, include_all: bool = False
) -> list[dict[str, Any]]:
client = langgraph_client()
searches: list[dict[str, Any]] = [{"github_login": login}]
if email and email.strip():
searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}]
if not include_all and email and email.strip():
searches.append({"triggering_user_email": email.strip().lower()})
seen: dict[str, dict[str, Any]] = {}
@ -224,7 +224,7 @@ async def list_dashboard_threads(
if not isinstance(thread, dict):
continue
meta = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
if not _user_owns_thread(meta, login, email):
if not include_all and not _user_owns_thread(meta, login, email):
continue
thread_id = thread.get("thread_id") or thread.get("id")
if isinstance(thread_id, str) and thread_id not in seen:
@ -246,7 +246,6 @@ async def get_dashboard_thread(
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
messages: list[dict[str, Any]] = []
try:
@ -480,13 +479,10 @@ async def stream_dashboard_thread(
thread_id: str, login: str, *, email: str | None = None, last_event_id: str | None = None
) -> AsyncIterator[str]:
try:
thread = await langgraph_client().threads.get(thread_id)
await langgraph_client().threads.get(thread_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
stream = await langgraph_client().threads.join_stream(
thread_id,
last_event_id=last_event_id,