Say which attached resources stopped running and why

The agent and workflow reads now return resource_states to people who may
edit them: every attached tool, source and prompt (and workflow node tool
and source) with active or stopped and the reason: deleted,
owner_lost_access, the sponsor reasons, connection_needs_reconnect,
connection_removed or connector_disabled. Each entry names the sponsor,
someone other than the reader who can fix it, the service for a connection
reason, and whether the reader may take it over or reconnect it. When
something can be taken over, sponsor_audience says who it would reach.

The state comes from the checks the run itself uses (ref_access,
resolve_holder_tool and the tool's connection as the run resolves it), so
the page and the run can't disagree. A run that leaves a resource out logs
resource_stopped with the holder, type, id and reason.

The workflow read gives sponsor details, run state and node resource names
only to people who may edit it, and names only resources the workflow runs,
someone sponsored, or the reader can see. Owner saves and new workflows
now refuse node tools and sources the owner can't use, like editor saves.
This commit is contained in:
arc53-machine committed 2026-09-29 17:11:58 +01:00
1 parent 90b385e9ea
commit 2359e4546b
11 files changed
+1165 -74

No files matched your search

+19 -11
View File
@@ -156,20 +156,27 @@ def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Option
pid = str(prompt_id)
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
return prompt_id
from docsgpt.api.user.resource_access import active_sponsor, resolve
from docsgpt.api.user.resource_access import (
REASON_OWNER_LOST_ACCESS,
log_stopped,
ref_access,
)
# The same check the agent page's run state uses: the principal, else a
# live sponsor on the agent.
holder = {**agent, "user_id": principal} if agent and agent.get("id") else {"user_id": principal}
try:
with db_readonly() as conn:
ra = resolve(conn, "prompt", pid, principal) if principal else None
usable = ra is not None and ra.can("use")
if not usable and agent and agent.get("id"):
usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
access = ref_access(conn, "agent", holder, "prompt", pid) if principal else None
except Exception:
logger.exception("Prompt access check failed for %s", pid)
usable = False
if usable:
access = None
if access is not None and access.principal:
return prompt_id
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
log_stopped(
"agent" if agent else "chat", holder, "prompt", pid,
access.reason if access is not None else REASON_OWNER_LOST_ACCESS,
)
return "default"
@@ -180,10 +187,11 @@ def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any)
editor who attached it while they still qualify. Read unscoped once
authorized: an owner-scoped read misses a team-shared source.
"""
from docsgpt.api.user.resource_access import ref_principal
from docsgpt.api.user.resource_access import log_stopped, ref_access
if not ref_principal(conn, "agent", agent, "source", str(source_id)):
logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
access = ref_access(conn, "agent", agent, "source", str(source_id))
if not access.principal:
log_stopped("agent", agent, "source", source_id, access.reason)
return None
return sources_repo.get_by_id(str(source_id))