bifroest/app/approvals/runner.py
2026-10-27 08:34:17 +01:00

87 lines
4.6 KiB
Python

"""Erneut beantragte Vorschläge (→ Konzept 4.5): ohne Modell, nur Snapshot neu bauen,
Freigabe abwarten, ausführen und das Ergebnis in den Chat schreiben."""
from sqlalchemy import select
from app.approvals import service as approvals
from app.approvals.models import ApprovalRequest
from app.core.crypto import Vault
from app.core.errors import Invalid
from app.data_sources.connectors.git_repo import RepoCache
from app.data_sources.models import DataSource
from app.mcp import service as mcp
from app.tasks import states
from app.tasks.models import TaskRun
from app.tasks.worker import RunContext, RunStopped, handler
from app.tools import service as tools
from app.tools.context import ToolContext
from app.tools.models import DENY
from app.tools.registry import ToolError
from app.workspaces.models import Workspace
TITLE = "**Erneut beantragte Änderung:** "
@handler("proposal")
def proposal(context: RunContext) -> str | None:
with context.db.session() as session:
run = session.get(TaskRun, context.run_id)
state = dict(run.state or {})
old = session.get(ApprovalRequest, context.input["replaces"])
workspace = session.get(Workspace, context.workspace_id)
sources = list(session.scalars(select(DataSource).where(DataSource.workspace_id == workspace.id,
DataSource.status == "ok")))
verdict = tools.evaluate(session, workspace, old.tool, old.source_id)
tool = tools.catalog(session).get(old.tool)
mcp_targets = mcp.targets(session, Vault(context.settings.secret_key), context.settings, workspace.id)
for source in sources:
session.expunge(source)
arguments, source_id, tool_name, old_id = dict(old.arguments), old.source_id, old.tool, old.id
tool_context = ToolContext(settings=context.settings, vault=Vault(context.settings.secret_key),
workspace_id=context.workspace_id, sources=sources, cache=RepoCache(context.settings),
run_id=context.run_id, user_id=context.created_by, mcp=mcp_targets,
db=context.db, chat_session_id=context.chat_session_id)
if "approval_id" in state:
try:
output, _ = approvals.carry_out(context.db, tool_context, state["approval_id"], for_model=False)
except approvals.Unclear as e:
raise RunStopped(states.NEEDS_RECONCILIATION, "Ob die Änderung im Zielsystem angekommen ist, ist "
"unklar. Ein Workspace-Admin muss den Abgleich starten.", detail=str(e)) from e
return complete(context, TITLE + output)
if tool is None or verdict.decision == DENY:
return complete(context, TITLE + "nicht möglich, das Tool ist in diesem Workspace nicht mehr erlaubt.")
try:
# Quelle kann umbenannt worden sein: maßgeblich ist die ID aus dem alten Antrag
if source_id is not None:
arguments["source"] = tool_context.by_id(source_id).name
built = tool.propose(tool_context, tool.validate(arguments))
except (ToolError, Invalid, ValueError) as e:
return complete(context, TITLE + f"lässt sich gegen den aktuellen Stand nicht neu bauen: {e}")
with context.db.session() as session:
context.guard(session)
approval = approvals.propose(session, run_id=context.run_id, workspace=session.get(Workspace, context.workspace_id),
user_id=context.created_by, call_id=f"rerequest-{old_id}", tool=tool_name,
proposal=built, verdict=verdict, hours=context.settings.approval_hours,
replaces_id=old_id)
session.get(TaskRun, context.run_id).state = {"approval_id": approval.id}
approval_id, pending = approval.id, approval.status == approvals.PENDING
session.flush()
context.events.emit("approval", {"call_id": approval.call_id, "approval_id": approval_id}, session=session)
if not pending:
output, _ = approvals.carry_out(context.db, tool_context, approval_id, for_model=False)
return complete(context, TITLE + output)
return states.WAITING_APPROVAL
def complete(context: RunContext, text: str) -> None:
context.events.flush()
with context.db.session() as session:
context.guard(session)
run = session.get(TaskRun, context.run_id)
run.state = {}
message = approvals.post_message(session, run, text)
if message is not None:
context.events.emit("message", {"message_id": message.id}, session=session)
return None