Files
AgentLab/backend/app/api/apps.py
T
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

907 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应用(BE-3):增删改查、派生、检查、发布、版本、授权、页面解析、入口、实例、视图刷新、组件目录。设计文档 §3.10。
应用级权限按 §3.12(app/apps/access.py):use 能用(入口、视图、自己的实例);observe 另能看全部实例;manage 能搭建、
发布、授权、管钩子。关登录时全部放行、等同 manage。
改 spec、改岗位绑定只改草稿,发布才生成不可变的应用版本;改授权、管钩子不发新版本,写审计(A1)。
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import Principal, actor_of, current_principal
from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of
from app.db.base import get_session
from app.db.models import Run
from app.db.models_apps import App, AppGrant, AppHook, AppVersion
router = APIRouter(prefix="/api/apps", tags=["apps"])
APP_GONE = "应用不存在,可能已被删除"
NO_ACCESS = "app_forbidden"
NOT_PUBLISHED = "app_unpublished"
PUBLISH_CONFLICT = "app_publish_conflict"
#: 归档了的应用不再改草稿、不再发布(没有恢复的接口:归档后这个应用只留着给进行中的实例走完、给人查)
APP_ARCHIVED = "app_archived"
@router.get("/catalog")
async def get_catalog() -> dict:
"""受信组件目录 v1。前端按名字找渲染器;助手生成时给模型看同一份。"""
from app.apps.catalog import catalog_payload
return catalog_payload()
# --------------------------------------------------------------------------
# 取应用、判权限
# --------------------------------------------------------------------------
def _principal(x_actor: str | None) -> Principal:
"""这次请求的身份。关登录时 username 换成自报的署名(X-Actor):发起人、我发起的都按它认。"""
principal = current_principal()
if principal.mode == "off" and principal.username is None:
return dataclasses.replace(principal, username=actor_of(x_actor))
return principal
async def _app(session: AsyncSession, app_id: str, principal: Principal, need: str) -> tuple[App, str]:
app = await session.get(App, app_id)
if app is None:
raise HTTPException(404, APP_GONE)
role = await app_role(session, principal, app)
if role is None:
# 没有任何授权的人不该知道这个应用在不在
raise HTTPException(404, APP_GONE)
if not at_least(role, need):
raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS)
return app, role
def _refuse_archived(app: App, what: str) -> None:
if app.status == "archived":
raise CodedHTTPException(409, f"「{app.name}」已归档,不能再{what}", APP_ARCHIVED)
#: 个人应用(docs/design/normal-user-apps.md N7)没有的那几样:发布、授权
PERSONAL_ONLY_MINE = "personal.not_shareable"
def _refuse_personal(app: App, what: str) -> None:
"""个人应用没有应用版本(草稿就是在用的那一份)、不能分享:发布、授权一律不收,说清想给别人用怎么办。"""
if getattr(app, "scope", "shared") == "personal":
raise CodedHTTPException(409, f"「{app.name}」是个人应用,只给你自己用,不能{what}。想给别人用,请提交审核,由搭建者"
"发布成正式应用", PERSONAL_ONLY_MINE)
def _require_builder(principal: Principal) -> None:
if not principal.at_least("builder"):
raise CodedHTTPException(403, "搭建应用需要搭建者权限", NO_ACCESS)
async def _current_version(session: AsyncSession, app: App) -> AppVersion:
if getattr(app, "scope", "shared") == "personal":
# 个人应用没有发布版本:不发起实例、没有按需视图,页面直接按存的样子显示,要新数点「刷新」
raise CodedHTTPException(409, f"「{app.name}」是个人应用,不发起实例、没有按需视图:要看最新的数请点「刷新」",
NOT_PUBLISHED)
if not app.published_version:
raise CodedHTTPException(409, f"「{app.name}」还没有发布", NOT_PUBLISHED)
return (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one()
async def _needs_upgrade(session: AsyncSession, version: AppVersion | None) -> bool:
from app.apps.pins import load_pins, needs_upgrade
if version is None:
return False
return needs_upgrade(await load_pins(session, version.spec or {}))
async def _pending(session: AsyncSession, app_id: str, principal: Principal) -> int:
"""这个人在这个应用里的待办数。关登录时所有待办都算「我的」。"""
from app.db.models_process import Task, TaskAssignment
query = select(func.count(Task.id)).where(Task.app_id == app_id, Task.status == "pending")
if principal.mode != "off":
mine = select(TaskAssignment.task_id).where(TaskAssignment.status == "pending").where(
(TaskAssignment.principal_kind == "anyone")
| ((TaskAssignment.principal_kind == "user") & (TaskAssignment.principal == (principal.username or "")))
| ((TaskAssignment.principal_kind == "group") & TaskAssignment.principal.in_(list(principal.groups) or [""])))
query = query.where(Task.id.in_(mine))
return int(await session.scalar(query) or 0)
async def _summary(session: AsyncSession, app: App, principal: Principal, role: str | None) -> dict[str, Any]:
version = None
if app.published_version:
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
return {"id": app.id, "name": app.name, "description": app.description, "icon": app.icon, "status": app.status,
"published_version": app.published_version, "level": version.level if version else None,
"needs_upgrade": await _needs_upgrade(session, version),
"pending_tasks": await _pending(session, app.id, principal), "my_role": role,
"updated_at": app.updated_at.isoformat() if app.updated_at else None,
# 个人应用(N7):scope=personal、拥有者;出自哪个蓝图(共享应用经发布单生成的也有)
"scope": app.scope or "shared", "owner": app.owner, "blueprint_id": app.blueprint_id}
def _personal_stub(app: App) -> dict[str, Any]:
"""管理员在应用列表里看到的别人的个人应用:在不在、谁的、叫什么、状态,能归档;说明、页面、数一概不给(P4)。"""
return {"id": app.id, "name": app.name, "description": "", "icon": "app", "status": app.status,
"published_version": None, "level": None, "needs_upgrade": False, "pending_tasks": 0, "my_role": None,
"updated_at": app.updated_at.isoformat() if app.updated_at else None,
"scope": "personal", "owner": app.owner, "blueprint_id": None}
def _published_outline(version: AppVersion | None) -> dict[str, Any] | None:
"""当前发布版里使用面要的那一点:有哪些页面、哪些入口(名字和标题)。页面内容走页面解析,表单走页面的 forms。"""
if version is None:
return None
spec = version.spec or {}
return {"version": version.version,
"pages": [{"id": p.get("id"), "title": p.get("title") or ""} for p in spec.get("pages") or []
if isinstance(p, dict) and p.get("id")],
"entries": [{"id": e.get("id"), "label": e.get("label") or ""} for e in spec.get("entries") or []
if isinstance(e, dict) and e.get("id")]}
async def _detail(session: AsyncSession, app: App, principal: Principal, role: str | None, *,
with_backfill: bool = False) -> dict[str, Any]:
"""应用详情。草稿和草稿里的岗位绑定只给 manage(D-m):草稿是还没发布、没过检查的东西,岗位绑定是这个部署里具体
的人和组;use / observe 只拿概况和当前发布版的页面、入口目录(published)。
with_backfill:另给管理者列出能按新口径补算的视图、各会发起几次(D-f)。要逐期查封存,只在打开详情时算;保存草稿、
归档这些改动的回包里不算(编辑器存一次草稿就算一遍太浪费,补算只看发布版,草稿改了也不变)。"""
from app.apps.spec import empty_spec
out = {**await _summary(session, app, principal, role),
"published": _published_outline(await _previous(session, app))}
if not at_least(role, "manage"):
return out
out.update(draft=app.draft or empty_spec(app.name), draft_bindings={"slots": {}, **(app.draft_bindings or {})},
created_by=app.created_by)
if app.scope == "personal":
# 个人应用给拥有者的那一块:今天还能刷新几次、蓝图改过没有、审核到哪了(blueprints/personal.personal_state)
from app.blueprints.personal import personal_state
out["personal"] = await personal_state(session, app, principal)
return out
if with_backfill:
from app.apps.backfill import backfillable
out["backfill"] = await backfillable(session, app)
return out
# --------------------------------------------------------------------------
# 增删改查
# --------------------------------------------------------------------------
@router.get("")
async def list_apps(include_archived: bool = False, scope: str | None = None,
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""应用列表。个人应用只列给拥有者;管理员另外看得到别人的个人应用在不在(_personal_stub,不含内容),能归档。
scope 给了只列那一种(shared / personal)。can_create 是这个人能建哪种应用:新建应用的入口按它给(共享要搭建者及以上,
个人看管理员的开关,blueprints/personal.allowed)。"""
from sqlalchemy import or_
from app.apps.access import is_personal
from app.blueprints.personal import allowed
principal = _principal(x_actor)
query = select(App).order_by(App.updated_at.desc())
if not include_archived:
query = query.where(App.status != "archived")
if scope == "personal":
query = query.where(App.scope == "personal")
elif scope == "shared":
query = query.where(or_(App.scope == "shared", App.scope.is_(None)))
items = []
for app in (await session.execute(query)).scalars():
role = await app_role(session, principal, app)
if role is None:
if is_personal(app) and principal.mode != "off" and principal.role == "admin":
items.append(_personal_stub(app))
continue
items.append(await _summary(session, app, principal, role))
return {"items": items, "can_create": {"shared": principal.mode == "off" or principal.at_least("builder"),
"personal": await allowed(session, principal)}}
class AppIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = ""
icon: str = Field(default="app", max_length=40)
spec: dict[str, Any] | None = None
@router.post("", status_code=201)
async def create_app(payload: AppIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
principal = _principal(x_actor)
_require_builder(principal)
app = await new_app(session, name=payload.name, description=payload.description, icon=payload.icon,
spec=payload.spec, actor=actor_of(x_actor))
return await _detail(session, app, principal, "manage")
async def new_app(session: AsyncSession, *, name: str, description: str = "", icon: str = "app",
spec: dict[str, Any] | None = None, bindings: dict[str, Any] | None = None,
actor: str | None, blueprint_id: str | None = None) -> App:
"""建一个应用(只有草稿,提交)。建应用的接口和发布单共用;发布单另带上岗位绑定和出自哪个蓝图。"""
from app.apps.spec import empty_spec
draft = dict(spec) if spec else empty_spec(name)
draft["name"] = name
app = App(name=name, description=description, icon=icon, draft=draft,
draft_bindings={"slots": {}, **(bindings or {})}, created_by=actor, updated_by=actor,
blueprint_id=blueprint_id)
session.add(app)
await session.commit()
await session.refresh(app)
return app
class DeriveIn(BaseModel):
workflow_id: str
version: int
name: str | None = None
@router.post("/derive")
async def derive(payload: DeriveIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""从工作流版本确定性派生一份应用草稿(不落库)。岗位留空,等人绑定。"""
from app.apps.derive import DeriveRefused, derive_app
_require_builder(_principal(x_actor))
try:
return {"spec": await derive_app(session, payload.workflow_id, payload.version, name=payload.name)}
except DeriveRefused as e:
raise HTTPException(e.status_code, e.message) from e
@router.get("/{app_id}")
async def get_app(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "use")
return await _detail(session, app, principal, role, with_backfill=True)
class AppPatch(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
icon: str | None = Field(default=None, max_length=40)
draft: dict[str, Any] | None = None
draft_bindings: dict[str, Any] | None = None
def _bindings_problem(bindings: dict[str, Any]) -> str | None:
"""岗位绑定的形状:{"slots": {岗位: [{kind: user|group, name}]}, "hooks"?, "channels"?}。"""
slots = bindings.get("slots", {})
if not isinstance(slots, dict):
return "岗位绑定需要按岗位列出人或组"
for slot, items in slots.items():
if not isinstance(items, list) or not all(
isinstance(i, dict) and i.get("kind") in ("user", "group") and isinstance(i.get("name"), str)
and i["name"].strip() for i in items):
return f"岗位「{slot}」的绑定需要是人或组的列表"
return None
@router.patch("/{app_id}")
async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""改草稿:spec、岗位绑定、名称等。草稿随便改,发布时才过检查(形状不对的检查里报 app.format)。"""
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "manage")
_refuse_archived(app, "修改")
if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)):
raise HTTPException(422, problem)
if app.scope == "personal" and payload.draft is not None:
# 个人应用的草稿就是在用的那一份:只许改呈现,固定的蓝图、视图来源只由「存成我的应用」改
from app.blueprints.personal import draft_problem
if problem := draft_problem(app.draft or {}, payload.draft):
raise HTTPException(422, problem)
for key in ("name", "description", "icon"):
if (value := getattr(payload, key)) is not None:
setattr(app, key, value)
if payload.draft is not None:
app.draft = payload.draft
if payload.draft_bindings is not None:
app.draft_bindings = payload.draft_bindings
app.updated_by = actor_of(x_actor)
await session.commit()
await session.refresh(app)
return await _detail(session, app, principal, role)
@router.post("/{app_id}/archive")
async def archive_app(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""归档:不再出现在应用列表里,入口拒绝发起、入站钩子一律拒收(apps/entries.py、process/hooks.py),不再改草稿、
不再发布;进行中的实例照常走完,版本、实例、生效记录都留着。没有恢复的接口。"""
from app.apps.access import can_archive
from app.apps.audit import record
principal = _principal(x_actor)
app = await session.get(App, app_id)
role = await app_role(session, principal, app) if app is not None else None
if app is None or not can_archive(principal, app, role):
# 管理员能归档别人的个人应用(看不到内容,能收起来);其余照旧要管理权限
app, role = await _app(session, app_id, principal, "manage")
app.status = "archived"
app.updated_by = actor_of(x_actor)
await record(session, action="app.archive", subject_kind="app", subject_id=app.id, actor=actor_of(x_actor),
data={"name": app.name, **({"owner": app.owner} if app.scope == "personal" else {})})
await session.commit()
await session.refresh(app)
if role is None:
return _personal_stub(app)
return await _detail(session, app, principal, role)
#: (应用 id, 人) → 取或建助手对话的锁。只增不减:编辑器打开过的应用 × 人
_ASSISTANT_LOCKS: dict[tuple[str, str | None], asyncio.Lock] = {}
def _assistant_lock(app_id: str, who: str | None) -> asyncio.Lock:
return _ASSISTANT_LOCKS.setdefault((app_id, who), asyncio.Lock())
@router.get("/{app_id}/assistant-conversation")
async def assistant_conversation(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""应用编辑器里助手的那段对话:没有就建(D-e)。kind=app、归这个应用、建立人是现在这个人——对话按人隔离,同一个应用
几个人轮流改,各自的历史不串。编辑器每轮落一条 turn(问题、说明、meta.looks),润色接口按 conversation_id 带历史。"""
from app.api.conversations import _detail
from app.db.models import Conversation, ConversationTurn
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
who = actor_of(x_actor)
mine = Conversation.created_by == who if who else Conversation.created_by.is_(None)
lookup = (select(Conversation).where(Conversation.kind == "app", Conversation.app_id == app.id,
Conversation.archived.is_(False), mine)
.order_by(Conversation.last_active_at.desc()).limit(1))
# 取或建在进程里按 (应用, 人) 串行:编辑器开发模式下 effect 执行两次、同一个人开两个标签页,都会同时来取。库里有
# (app_id, created_by) 的部分唯一索引(ux_conversations_app_owner):别处(另一个进程)抢先建了的,这次插入在保存点里
# 被拒,改用那一段——历史才不会被拆开。没署名(关登录又没填)的不受索引约束,靠这把进程内的锁
async with _assistant_lock(app.id, who):
row = await session.scalar(lookup)
if row is None:
fresh = Conversation(kind="app", app_id=app.id, created_by=who, title=f"「{app.name}」的助手")
try:
async with session.begin_nested():
session.add(fresh)
await session.flush()
row = fresh
except IntegrityError:
row = await session.scalar(lookup)
if row is None:
raise
await session.commit()
await session.refresh(row)
turns = list((await session.execute(select(ConversationTurn).where(
ConversationTurn.conversation_id == row.id).order_by(ConversationTurn.seq))).scalars())
# 形状同 GET /api/conversations/{id}(ConversationDetail):编辑器拿它渲染历史、接着落轮次
return {**_detail(row, turns).model_dump(mode="json"), "turn_count": len(turns),
"last_question": turns[-1].question if turns else ""}
# --------------------------------------------------------------------------
# 检查与发布
# --------------------------------------------------------------------------
async def _previous(session: AsyncSession, app: App) -> AppVersion | None:
if not app.published_version:
return None
return (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
@router.post("/{app_id}/lint")
async def lint(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.apps.lint import acl_issues, lint_app
from app.apps.pins import load_pins
from app.apps.tree import app_level
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "manage")
scope = app.scope or "shared"
issues = await lint_app(session, app.draft or {}, app.draft_bindings or {}, previous=await _previous(session, app),
scope=scope)
if scope != "personal":
# 个人应用不能授权给别人,「还没有授权」不是问题
issues += await acl_issues(session, app.id)
pins = await load_pins(session, app.draft or {})
return {"issues": issues, "level": app_level([{"level": p.level} for p in pins.values()]) if pins else None}
class PublishIn(BaseModel):
note: str = Field(default="", max_length=2000)
dispositions: dict[str, str] = Field(default_factory=dict)
@router.post("/{app_id}/publish")
async def publish(app_id: str, payload: PublishIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""把草稿冻结成一个不可变的应用版本。钉住项相对上一版升了版而没选处置的不发布(ok=false,needs_disposition 列出)。
返回 200 + ok/issues 而不是抛错,前端把问题列表渲染出来(同工作流发布)。"""
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "manage")
_refuse_archived(app, "发布")
_refuse_personal(app, "发布")
return await publish_draft(session, app, note=payload.note, dispositions=payload.dispositions,
actor=actor_of(x_actor))
async def publish_draft(session: AsyncSession, app: App, *, note: str, dispositions: dict[str, str],
actor: str | None) -> dict[str, Any]:
"""检查草稿,过了就冻结成新的应用版本并提交;没过交回 ok=false 和问题(不写库)。发布接口和发布单共用。
两个人同时发布时后到的那个抛 409(app_publish_conflict)。"""
from app.apps.audit import record
from app.apps.lint import acl_issues, lint_app, needs_disposition, pins_with_state
from app.apps.pins import load_pins
from app.apps.resolve import clear_cache
from app.apps.spec import parse_spec
from app.apps.tree import app_level, spec_hash
from app.db.base import utcnow
previous = await _previous(session, app)
draft = app.draft or {}
issues = await lint_app(session, draft, app.draft_bindings or {}, previous=previous, dispositions=dispositions)
issues += await acl_issues(session, app.id)
upgraded = needs_disposition(previous, draft.get("pins") or []) if isinstance(draft.get("pins"), list) else []
if any(i["level"] == "error" for i in issues):
return {"ok": False, "issues": issues,
"needs_disposition": [u for u in upgraded if dispositions.get(u["alias"]) is None]}
pins = await load_pins(session, draft)
spec = parse_spec(draft).model_dump(mode="json", by_alias=True)
spec["pins"] = pins_with_state(spec, pins)
number = int(await session.scalar(select(func.max(AppVersion.version)).where(AppVersion.app_id == app.id)) or 0) + 1
level = app_level(spec["pins"])
row = AppVersion(app_id=app.id, version=number, spec=spec, spec_hash=spec_hash(spec), pins=spec["pins"],
bindings={"slots": {}, **(app.draft_bindings or {})}, level=level,
dispositions={u["alias"]: dispositions[u["alias"]] for u in upgraded},
note=note, published_by=actor, published_at=utcnow())
session.add(row)
app.published_version = number
app.updated_by = actor
await record(session, action="app.publish", subject_kind="app", subject_id=app.id, actor=actor,
data={"version": number, "spec_hash": row.spec_hash, "level": level, "pins": spec["pins"],
"dispositions": row.dispositions, "note": note})
try:
await session.commit()
except IntegrityError as e:
# 两个人同时发布:(app_id, version) 唯一,后到的那个撞上,不另加锁
await session.rollback()
raise CodedHTTPException(409, "有人刚刚发布了这个应用,请刷新后再试", PUBLISH_CONFLICT) from e
clear_cache()
return {"ok": True, "version": number, "level": level, "spec_hash": row.spec_hash, "issues": issues}
def _version_row(row: AppVersion) -> dict[str, Any]:
return {"version": row.version, "spec_hash": row.spec_hash, "level": row.level, "pins": row.pins,
"dispositions": row.dispositions or {}, "note": row.note, "published_by": row.published_by,
"published_at": row.published_at.isoformat() if row.published_at else None}
@router.get("/{app_id}/versions")
async def list_versions(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
app, _ = await _app(session, app_id, _principal(x_actor), "observe")
rows = (await session.execute(select(AppVersion).where(AppVersion.app_id == app.id)
.order_by(AppVersion.version.desc()))).scalars()
return {"items": [_version_row(r) for r in rows]}
@router.get("/{app_id}/versions/{version}")
async def get_version(app_id: str, version: int, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
app, role = await _app(session, app_id, _principal(x_actor), "observe")
row = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == version))).scalar_one_or_none()
if row is None:
raise HTTPException(404, f"「{app.name}」没有 v{version}")
# 岗位绑定是这一个部署里的人和组:管理者才看得到
return {**_version_row(row), "spec": row.spec, "bindings": row.bindings if role == "manage" else {"slots": {}}}
# --------------------------------------------------------------------------
# 授权
# --------------------------------------------------------------------------
class GrantItem(BaseModel):
principal_kind: str
principal: str = ""
role: str = "use"
class GrantsIn(BaseModel):
items: list[GrantItem]
def _grant_out(g: AppGrant) -> dict[str, Any]:
return {"principal_kind": g.principal_kind, "principal": g.principal, "role": g.role}
@router.get("/{app_id}/grants")
async def get_grants(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
return {"items": [_grant_out(g) for g in await grants_of(session, app.id)]}
@router.put("/{app_id}/grants")
async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""整份替换授权。授权不进应用版本(改了不用重新发布),写审计。
给的人和组按名录核对(auth/directory.py,同选人控件、同应用检查):账号密码登录时名录里没有的、任何登录方式下停用了
的拒收,说清是哪一档、哪个名字——界面上选人时已经拦着,这里拦旧客户端和直接调接口的。单点登录的人第一次登录后才进
名录,名录里还没有的照收(检查里提醒);关登录不核对。"""
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
_refuse_personal(app, "授权给别人")
wanted, problem = await grants_problem(session, [(i.principal_kind, i.principal, i.role) for i in payload.items])
if problem:
raise HTTPException(422, problem)
return {"items": await replace_grants(session, app, wanted, actor=actor_of(x_actor))}
async def grants_problem(session: AsyncSession, items: list[tuple[str, str, str]]
) -> tuple[list[tuple[str, str, str]], str | None]:
"""要给的授权 [(principal_kind, principal, role)] → (去重规范化之后的, 收不下的说明),只读。形状不对、名录核对不过
(同上面的规则)时说明不为空。授权接口和发布单(预检只检查、最后一步照它授权)共用。"""
from app.api.deps import auth_mode
from app.auth.directory import load_directory, mismatch
from app.db.models_apps import GRANT_ROLES
wanted: list[tuple[str, str, str]] = []
for kind, name, role in items:
if kind not in ("user", "group", "everyone") or role not in GRANT_ROLES:
return wanted, "授权只能给人、组或所有人,权限只能是使用、查看全部实例或管理"
principal = "" if kind == "everyone" else (name or "").strip()
if kind != "everyone" and not principal:
return wanted, "授权给人或组时需要写明是谁"
key = (kind, principal, role)
if key not in wanted:
wanted.append(key)
if (mode := auth_mode()) != "off":
known = await load_directory(session)
refused = [f"「{ROLE_TEXT[role]}」里的{m.text}" for kind, who, role in wanted
if (m := mismatch(known, mode, kind, who)) is not None and m.blocking]
if refused:
return wanted, f"{';'.join(refused)}:授权只能给名录里在用的人和组,请移除或从候选中重新选择"
return wanted, None
async def replace_grants(session: AsyncSession, app: App, wanted: list[tuple[str, str, str]], *,
actor: str | None) -> list[dict[str, str]]:
"""整份替换授权(调用方先过了 grants_problem),写审计并提交。"""
from app.apps.audit import record
before = [_grant_out(g) for g in await grants_of(session, app.id)]
for g in await grants_of(session, app.id):
await session.delete(g)
await session.flush()
for kind, who, role in wanted:
session.add(AppGrant(app_id=app.id, principal_kind=kind, principal=who, role=role, granted_by=actor))
after = [{"principal_kind": k, "principal": p, "role": r} for k, p, r in wanted]
await record(session, action="app.grants", subject_kind="app", subject_id=app.id, actor=actor,
data={"before": before, "after": after})
await session.commit()
return after
# --------------------------------------------------------------------------
# 入站钩子的管理(收请求在 /api/hooks/{hook_id},process/hooks.py)
#
# 按名字认(D-g):spec.hooks 声明 {name, kind, entry},app_hooks 按 (应用, 名字) 一行,只存密钥和启用与否。钩子做
# 什么以当前发布版的声明为准:改草稿不改正在用的钩子,发布版里没声明的拒收。密钥只在生成、轮换时交回一次
# --------------------------------------------------------------------------
class HookPatch(BaseModel):
enabled: bool
async def _hook_state(session: AsyncSession, app: App) -> tuple[dict[str, Any], dict[str, Any], dict[str, AppHook]]:
"""(草稿里的声明, 当前发布版里的声明, 已有的行),都按名字。"""
from app.process.hooks import declarations
version = await _previous(session, app)
rows: dict[str, AppHook] = {}
for row in (await session.execute(select(AppHook).where(AppHook.app_id == app.id)
.order_by(AppHook.created_at))).scalars():
rows.setdefault(row.name, row)
return declarations(app.draft or {}), declarations(version.spec if version is not None else {}), rows
def _hook_item(name: str, draft: dict[str, Any], published: dict[str, Any], rows: dict[str, AppHook],
secret: str | None = None) -> dict[str, Any]:
from app.process.hooks import hook_out
return hook_out(rows.get(name), name=name, declared=draft.get(name) or published.get(name),
in_draft=name in draft, in_published=name in published, secret=secret)
@router.get("/{app_id}/hooks")
async def list_hooks(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""草稿里声明的 ∪ 已经有行的:每个名字一行,标出密钥生成过没有、当前发布版里有没有(没有的收不到请求)。"""
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
draft, published, rows = await _hook_state(session, app)
names = list(dict.fromkeys([*draft, *published, *rows]))
return {"items": [_hook_item(n, draft, published, rows) for n in names]}
@router.post("/{app_id}/hooks/{name}/rotate")
async def rotate_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""生成或轮换这个名字的签名密钥,密钥只在这里交回一次(库里存密文)。名字要在草稿或当前发布版里声明过。"""
from app.apps.audit import record
from app.process import hooks
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
draft, published, rows = await _hook_state(session, app)
declared = draft.get(name) or published.get(name)
if declared is None:
raise HTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它")
# 从读行锁到提交:两位管理者同时点「生成密钥」,后到的那次读得到前一次插入的行,只轮换它(见 process/hooks.py)
async with hooks.hook_lock(app.id, name):
row, secret, created = await hooks.rotate_hook(session, app.id, name, kind=declared["kind"],
entry=declared.get("entry"))
await record(session, action="app.hook_create" if created else "app.hook_rotate", subject_kind="app_hook",
subject_id=row.id, actor=actor_of(x_actor),
data={"app_id": app.id, "name": name, "kind": declared["kind"], "entry": declared.get("entry")})
await session.commit()
await session.refresh(row)
return _hook_item(name, draft, published, {**rows, name: row}, secret)
async def _hook_named(session: AsyncSession, app: App, name: str) -> AppHook:
from app.process.hooks import hook_row
row = await hook_row(session, app.id, name)
if row is None:
raise HTTPException(404, f"钩子「{name}」还没有生成密钥")
return row
@router.patch("/{app_id}/hooks/{name}")
async def update_hook(app_id: str, name: str, payload: HookPatch, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""启用、停用。停用的钩子收到请求一律拒收,密钥留着,重新启用就能接着用。"""
from app.apps.audit import record
from app.process.hooks import set_enabled
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
row = await _hook_named(session, app, name)
await set_enabled(session, app.id, name, payload.enabled)
await record(session, action="app.hook_update", subject_kind="app_hook", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": name, "enabled": payload.enabled})
await session.commit()
draft, published, rows = await _hook_state(session, app)
return _hook_item(name, draft, published, rows)
@router.delete("/{app_id}/hooks/{name}", status_code=204)
async def delete_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> None:
"""删掉这个名字的密钥和去重记录。声明留在草稿里(要去掉在编辑器里删);再生成就是一把新密钥、一个新地址。"""
from app.apps.audit import record
from app.process import hooks
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
row = await _hook_named(session, app, name)
await record(session, action="app.hook_delete", subject_kind="app_hook", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": name})
await hooks.delete_hook(session, app.id, name)
await session.commit()
# --------------------------------------------------------------------------
# 使用:页面、入口、实例、视图刷新
# --------------------------------------------------------------------------
def _params(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
value = json.loads(raw)
except ValueError as e:
raise HTTPException(422, "页面参数需要是 JSON 对象") from e
if not isinstance(value, dict):
raise HTTPException(422, "页面参数需要是 JSON 对象")
return value
async def _instance(session: AsyncSession, app: App, run_id: str, principal: Principal, role: str) -> Run:
"""实例要属于这个应用;只有 use 的人只能看自己发起的(关登录时都能看)。"""
run = await session.get(Run, run_id)
if run is None or run.app_id != app.id or run.origin_kind not in ("app_entry", "hook"):
raise HTTPException(404, "实例不存在")
if principal.mode != "off" and not at_least(role, "observe") and run.started_by != principal.username:
raise CodedHTTPException(403, "只能查看自己发起的实例", NO_ACCESS)
return run
def _view_refused(e: Any) -> HTTPException:
return CodedHTTPException(e.status_code, e.message, e.code) if e.code else HTTPException(e.status_code, e.message)
@router.get("/{app_id}/pages/{page_id}")
async def get_page(app_id: str, page_id: str, instance: str | None = None, params: str | None = None,
draft: int = Query(default=0), session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""页面解析:服务端解析好的视图模型(agentlab.view/1)。draft=1 按草稿解析(编辑器预览,要管理权限),不发起运行。"""
from app.apps.resolve import ViewRefused, resolve_page
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "manage" if draft else "use")
# 个人应用没有应用版本:草稿就是在用的那一份(resolve_page 按 scope 认)
version = None if draft or app.scope == "personal" else await _current_version(session, app)
run = await _instance(session, app, instance, principal, role) if instance else None
try:
return await resolve_page(session, app, page_id=page_id, principal=principal, role=role, instance=run,
params=_params(params), draft=bool(draft), version=version, actor=actor_of(x_actor))
except ViewRefused as e:
raise _view_refused(e) from e
class StartIn(BaseModel):
values: dict[str, Any] = Field(default_factory=dict)
@router.post("/{app_id}/entries/{entry_id}", status_code=201)
async def start(app_id: str, entry_id: str, payload: StartIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""发起一个实例。表单没通过校验回 422,带 {errors: [{path, code, message}]}。"""
from app.apps.entries import EntryRefused, FormRejected, start_entry
from app.apps.resolve import instance_title
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "use")
try:
run = await start_entry(app.id, entry_id, payload.values, principal)
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "errors": e.errors})
except (EntryRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "title": instance_title(run)}
@router.get("/{app_id}/instances")
async def list_instances(app_id: str, who: str = "mine", entry: str | None = None, status: str | None = None,
limit: int = Query(default=50, ge=1, le=200), session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""实例列表。看全部要 observe;只有 use 的只看自己发起的。"""
from app.apps.resolve import list_instances as instances
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "use")
if who == "all" and not at_least(role, "observe"):
raise CodedHTTPException(403, "查看全部实例需要这个应用的「查看全部实例」权限", NO_ACCESS)
mine = principal.username if who != "all" else None
return {"items": await instances(session, app.id, mine=mine, entry=entry, status=status, limit=limit)}
@router.get("/{app_id}/instances/{run_id}")
async def get_instance(app_id: str, run_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""一个实例:列表里的那一行,加上实例页的视图模型(应用里叫 instance 的页;没有就给一页默认的)。"""
from app.apps.resolve import ViewRefused, instance_page, instance_rows, resolve_page
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "use")
run = await _instance(session, app, run_id, principal, role)
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == (run.app_version or app.published_version))))\
.scalar_one_or_none() or await _current_version(session, app)
spec = version.spec or {}
page_id = instance_page(spec)
if page_id is None:
# 应用里没有实例页:给一页默认的(概况、进度、待办),不改应用版本本身
spec = {**spec, "pages": [*(spec.get("pages") or []), {"id": "__instance", "title": "实例", "body": [
{"id": "__header", "type": "instance_header"}, {"id": "__timeline", "type": "instance_timeline"},
{"id": "__tasks", "type": "task_list", "props": {"scope": "instance"}}]}]}
page_id = "__instance"
version = AppVersion(app_id=version.app_id, version=version.version, spec=spec, spec_hash=version.spec_hash,
pins=version.pins, bindings=version.bindings, level=version.level,
dispositions=version.dispositions)
try:
view = await resolve_page(session, app, page_id=page_id, principal=principal, role=role, instance=run,
version=version, actor=actor_of(x_actor))
except ViewRefused as e:
raise _view_refused(e) from e
return {**(await instance_rows(session, [run]))[0], "view": view}
class RefreshIn(BaseModel):
params: dict[str, Any] = Field(default_factory=dict)
@router.post("/{app_id}/views/{view_id}/refresh")
async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""按需视图:发起一次运行,或者命中缓存、接上同一个参数正在运行的那次。"""
from app.apps.entries import FormRejected
from app.apps.resolve import ViewRefused, run_view
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "use")
version = await _current_version(session, app)
try:
run, cached = await run_view(session, app=app, version=version, view_id=view_id, params=payload.params,
principal=principal, actor=actor_of(x_actor))
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "errors": e.errors})
except (ViewRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "cached": cached}
# --------------------------------------------------------------------------
# 按新口径补算(D-f)
# --------------------------------------------------------------------------
@router.get("/{app_id}/views/{view_id}/backfill")
async def backfill_plan(app_id: str, view_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""补算会发起几次、跳过几次(不发起运行):按钮上写明会发起几次运行用。"""
from app.apps.backfill import BackfillRefused, plan
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
version = await _current_version(session, app)
try:
return (await plan(session, app, version, view_id)).out()
except BackfillRefused as e:
raise _view_refused(e) from e
@router.post("/{app_id}/views/{view_id}/backfill")
async def backfill(app_id: str, view_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""按新口径补算:这个视图的钉住项最近一次升版选了 recompute 时,取旧版本在同一来源上最近几期的输入,用新版本逐个
发起正式运行(来源 app_backfill);新版本已经算过同一个输入的跳过。返回发起了几次、跳过几次。"""
from app.apps.backfill import BackfillRefused, run_backfill
from app.apps.resolve import clear_cache
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "manage")
version = await _current_version(session, app)
try:
out = await run_backfill(session, app, version, view_id, principal=principal, actor=actor_of(x_actor))
except BackfillRefused as e:
raise _view_refused(e) from e
except LaunchRefused as e:
raise _view_refused(e) from e
clear_cache()
return out