Notifications follow the conversation's audience (users/access.audience):
whoever started it and the people it's shared with, plus the owner once they
follow a guest's conversation (writing in it, or PUT /api/conversation-follow),
minus whoever muted it (meta.follow). Pushes and asks fan out to each of them
on their own channels — the owner on the webhooks, a guest in-app + email —
and the feeds (/api/notifications, /api/notify-log, the phone's /unread)
list only what the caller hears, links rebased to the reader's host. Forms
carry forYou so "waiting for feedback" is per user.
/api/users carries spentUsd (this month), totalSpentUsd and conversations per
guest (users/runs.spend_by_user, the same sum the budget is checked against).
POST /api/users/{id}/impersonate opens a 4-hour guest session for the owner's
browser on the admin host (imp on the record: never extended, not counted in
the guest's sessions); /api/me says impersonating; logout ends it. A spawn
while impersonating skips the "guest started" ping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""
|
|
Conversation-level access for guests.
|
|
|
|
A conversation belongs to a guest when its metadata says ``owner: <user id>``
|
|
(stamped by the spawn — a conversation without one is the owner's), and is
|
|
**shared** with the ids in ``participants``. A guest sees exactly those two
|
|
sets; the owner sees everything.
|
|
|
|
Seeing a conversation is not hearing about it: its pushes, asks and forms
|
|
reach its **audience** (:func:`audience`) — whoever started it and the people
|
|
it's shared with, plus the owner once they follow a guest's conversation
|
|
(writing in it is enough), minus anyone who muted it (``meta.follow``). A conversation id can be a root transcript
|
|
(``<dir>/<sid>.jsonl``), a subagent's (``<dir>/<sid>/subagents/agent-….jsonl``)
|
|
or a bare session id — :func:`session_of` maps all three to the root session
|
|
whose metadata decides.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from users.caller import OWNER_ID, Caller
|
|
|
|
|
|
def session_of(conv_id: str) -> str:
|
|
parts = pathlib.PurePosixPath(conv_id or "").parts
|
|
if "subagents" in parts:
|
|
i = parts.index("subagents")
|
|
if i > 0:
|
|
return parts[i - 1]
|
|
name = parts[-1] if parts else ""
|
|
return name[:-6] if name.endswith(".jsonl") else name
|
|
|
|
|
|
def meta_visible(caller: Caller, meta: dict) -> bool:
|
|
if caller.is_owner:
|
|
return True
|
|
return (meta.get("owner") == caller.id
|
|
or caller.id in (meta.get("participants") or ()))
|
|
|
|
|
|
def visible(deps, caller: Caller, conv_id_or_sid: str) -> bool:
|
|
if caller.is_owner:
|
|
return True
|
|
return meta_visible(caller, deps.meta_store.peek(session_of(conv_id_or_sid)))
|
|
|
|
|
|
def ensure_visible(deps, caller: Caller, conv_id_or_sid: str) -> None:
|
|
"""404 — not 403 — so a guest can't probe which conversations exist."""
|
|
if not visible(deps, caller, conv_id_or_sid):
|
|
raise HTTPException(404, "not found")
|
|
|
|
|
|
def project_of(meta: dict) -> str | None:
|
|
ps = meta.get("projects") or []
|
|
return ps[0] if ps else None
|
|
|
|
|
|
def can_send(deps, caller: Caller, sid: str) -> bool:
|
|
"""May ``caller`` add a turn (or stop the run) of conversation ``sid``?
|
|
|
|
The owner always. A participant always — sharing a conversation *is* the
|
|
invitation to talk in it. The guest who started it while they still hold
|
|
``chat`` on its project (a revoked grant stops them)."""
|
|
if caller.is_owner:
|
|
return True
|
|
meta = deps.meta_store.peek(sid)
|
|
if caller.id in (meta.get("participants") or ()):
|
|
return True
|
|
return meta.get("owner") == caller.id and caller.can(project_of(meta), "chat")
|
|
|
|
|
|
def filter_cards(caller: Caller, cards: list[dict]) -> list[dict]:
|
|
if caller.is_owner:
|
|
return cards
|
|
return [c for c in cards if meta_visible(caller, c.get("meta") or {})]
|
|
|
|
|
|
def owner_of(meta: dict) -> str:
|
|
"""Whose conversation this is: a guest's id, else the owner's."""
|
|
return meta.get("owner") or OWNER_ID
|
|
|
|
|
|
def audience(meta: dict) -> list[str]:
|
|
"""Who hears about this conversation (pushes, asks, forms; the feed).
|
|
|
|
By default the person who started it and everyone it's shared with. The
|
|
owner sees every conversation but only *hears* about a guest's one once
|
|
they follow it — ``meta.follow`` (``{user id: bool}``) adds them, and lets
|
|
anyone mute a conversation they'd otherwise hear about. A guest can't
|
|
follow their way into a conversation they can't see: only the owner's
|
|
``True`` adds a name."""
|
|
pref = meta.get("follow") or {}
|
|
people = [owner_of(meta), *(meta.get("participants") or ())]
|
|
if pref.get(OWNER_ID):
|
|
people.append(OWNER_ID)
|
|
return [u for u in dict.fromkeys(people) if pref.get(u, True)]
|
|
|
|
|
|
def hears(caller: Caller, meta: dict) -> bool:
|
|
return caller.id in audience(meta)
|
|
|
|
|
|
def set_follow(deps, sid: str, uid: str, on: bool) -> None:
|
|
"""Record ``uid``'s choice to hear (or not) about conversation ``sid``."""
|
|
pref = dict(deps.meta_store.peek(sid).get("follow") or {})
|
|
pref[uid] = bool(on)
|
|
deps.meta_store.update(sid, {"follow": pref})
|
|
deps.hub.publish({"type": "meta", "id": sid})
|
|
|
|
|
|
def is_shared(meta: dict) -> bool:
|
|
"""Several people may write here: the thread labels who said what."""
|
|
return bool(meta.get("owner") or meta.get("participants"))
|