Files
AgentLab/backend/app/apps/spec.py
ThearchyHelios a59409cc85 使用者选人(后端):按用途给候选池,开登录时入口表单、待办表单的选人控件按同一个池核对
为什么:使用者在选人控件里没有候选(名录只给搭建者),服务端也不核对。填错的名字原样派下去,待办给了一个不存在的人,
没人能处理、也不报错(05-已知遗留 (b))。名录又不能整份放开给使用者(只读与最小可见)。

做了什么:
- auth/directory 新增候选池:form 用途缺省是「能用这个应用的人」——使用及以上的授权(人;组列出组本身并展开成员),
  加上管理它的人(管理员、建应用的人);应用授权给了所有人时池是全部启用的人(open),不带前缀 q 不列(needs_query)。
  字段可以声明 x-principal: {pool: app | group:<组> | slot:<岗位>}(slot 按这次用到的应用版本的 bindings 展开)。
  review 用途是启用的搭建者和管理员(同 personal._review_targets)。停用的人一律不列。
- GET /api/directory/candidates(任何登录的人;main.py 路由清单只加一行):purpose=form 带 app_id+entry 或 task_id、field,
  调用的人不能用这个应用 403;只回 {users:[{username, display_name}], groups, truncated, needs_query?},不给停用、角色、
  邮箱、组成员;q 按用户名或显示名开头筛,limit 最多 50。/api/directory 仍只给搭建者。
- 开了登录时 start_entry 和待办表单提交按同一个池核对,不在池里 422,errors 的 code=principal(FORM_ERROR_CODES 加一项),
  话写成「「x」不在可选的人里,请从候选中选择」(单点登录另说要先登录一次才进名录)。直接生效(遗留-Q4 按推荐)。
  关登录照旧手填、不核对;不属于应用的待办(试运行、画布)没有池,不核对。
- apps/forms:x-principal 进 _FIELD_KEYS,validate_form_schema 查写法(只能写在选人控件上、只能是三种池)。

搭建者没有授权时按 apps/access 只能「查看全部实例」,不算池里的人——池按「能用」算,这一点写进了 app_pool 的说明。

测试:test_directory_candidates(只给名字、停用的不列、前缀筛、没使用权 403、名录仍 403;授权给所有人要前缀、截断;review
只出搭建者和管理员;入口表单不在池 422、停用的 422、组名和池里的人放行;待办表单同一个池;声明 group:<组> 收窄;关登录
任何名字照收;x-principal 写法校验)。
2026-10-16 10:24:22 +08:00

174 lines
7.0 KiB
Python
Raw Permalink 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.
"""应用描述 agentlab.app/1 与表单 agentlab.form/1 的结构(docs/design/app-platform-and-look.md §3.2–§3.4)。
这里只管「形状」:pydantic 认得出字段、类型、必填。语义检查(钉住的版本存不存在、绑定的节点在不在、表达式合不合法、
数字有没有证据……)在 app/apps/lint.py,编号见设计文档 §3.13。
界面层不放逻辑(P7):绑定是带类型的对象,不是模板;可见性表达式只能用 APP_ROOTS 里的根名字,禁止算术,
由 engine/expressions.py 和画布校验、运行时同一个解析器求值,服务端求值、不可见的不下发。
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
APP_FORMAT = "agentlab.app/1"
FORM_FORMAT = "agentlab.form/1"
VIEW_FORMAT = "agentlab.view/1"
#: 可见性表达式能用的根名字(engine/expressions.py 的 roots=)
APP_ROOTS = frozenset({"run", "input", "metric", "instance", "task", "user", "view"})
#: 绑定的种类:bind 对象里出现哪个键就是哪种(§3.4)
BINDING_KINDS = ("metric", "series", "cell", "query", "report", "input", "run", "output", "task", "action")
#: 钉住项的用途。process 的版本按流程规则检查(process.*)
PIN_USES = ("view", "entry", "process")
#: 视图来源
SOURCE_KINDS = ("latest_sealed", "on_demand", "instance")
#: 钉住项升版的处置(同口径升版,engine/governance.py 的 UPGRADE_POLICIES)
DISPOSITIONS = ("recompute", "dual", "incomparable")
#: 表单控件(agentlab.form/1 的 x-widget)
FORM_WIDGETS = (
"text", "textarea", "number", "integer", "date", "datetime", "select", "radio", "multiselect", "checkbox",
"principal", "note", "json",
# 文件(docs/design/normal-user-apps.md N9):值是上传后拿回的引用 {file_id, name, size, sha256, type},
# 字段 type 为 object,只有这个控件能用 object
"file",
)
#: 表单字段能用的 type
FORM_TYPES = ("string", "number", "integer", "boolean", "array")
#: 文件控件(x-widget: file)的字段类型:只有这个控件能用 object(apps/forms.py 按控件放行)
FILE_FIELD_TYPE = "object"
#: 文件引用的形状:上传后拿回、提交时原样带回
FILE_REF_KEYS = ("file_id", "name", "size", "sha256", "type")
#: 表单字段能用的 format。不支持 pattern:JS 和 Python 的正则语义不同,会出现前端放行、后端拒收
FORM_FORMATS = ("date", "date-time", "email", "uri")
#: 表单校验错误的 code。file:文件控件的值不对(没上传、不是本人传的、已经用过、类型或大小不符,app/files/store.py);
#: principal:开了登录时选人控件的值不在候选池里(auth/directory.principal_errors)
FORM_ERROR_CODES = ("required", "type", "enum", "minimum", "maximum", "min_length", "max_length", "max_items", "format",
"file", "principal")
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class Pin(_Strict):
"""一个钉住项。共享应用钉工作流版本;还没发布的(蓝图试运行时派生的草稿)version 为空,应用检查按
app.pin_unpublished 拦发布。个人应用(N7)没有工作流:写 blueprint(蓝图 id),version 为空,图在蓝图上。
workflow_id 和 blueprint 恰好写一个。"""
alias: str
workflow_id: str | None = None
version: int | None = None
graph_hash: str
level: str | None = None
use: Literal["view", "entry", "process"] = "view"
blueprint: str | None = None
@model_validator(mode="after")
def _one_target(self) -> Pin:
if (self.workflow_id is None) == (self.blueprint is None):
raise ValueError("固定版本要写 workflow_id 或 blueprint 其中一个")
return self
class Slot(_Strict):
label: str
class InstanceTitle(_Strict):
"""实例标题:只能按名字引用入口字段,由服务端拼。例:{"pattern": "{applicant} 的报销", "fields": ["applicant"]}"""
pattern: str
fields: list[str] = Field(default_factory=list)
class Entry(_Strict):
id: str
label: str
pin: str
# 内联的 agentlab.form/1,或 {"$ref": "#/forms/<名字>"}
form: dict[str, Any]
# 入口字段 ← 表单值 / 当前用户 / 常量:{"amount": "form.amount", "applicant": "user.display_name", "kind": "'报销'"}
map: dict[str, str] = Field(default_factory=dict)
instance_title: InstanceTitle | None = None
class ViewSource(_Strict):
# exploratory 只在个人应用里合法(N7):取这个应用、这个拥有者、这张图下最近一次成功封存的探索运行
kind: Literal["latest_sealed", "on_demand", "instance", "exploratory"]
pin: str | None = None
# latest_sealed:schedule:<id> | formal
from_: str | None = Field(default=None, alias="from")
# on_demand:参数表单(agentlab.form/1 或 $ref)、缓存时效、打开页面时是否自动发起
params: dict[str, Any] | None = None
ttl_s: int | None = None
auto: bool = False
class View(_Strict):
source: ViewSource
class Component(_Strict):
id: str
type: str
props: dict[str, Any] = Field(default_factory=dict)
# 带类型的绑定(BINDING_KINDS 之一),不是模板
bind: dict[str, Any] | None = None
# 可见性表达式(APP_ROOTS,禁止算术)
visible: str | None = None
# 可见性表达式里 run / metric / input 指的是哪个视图
scope: str | None = None
# 作者隐藏了它(N6):解析时跳过,编辑状态下照常画成半透明。和可见性表达式不是一回事
hidden: bool = False
children: list[Component] = Field(default_factory=list)
class Page(_Strict):
id: str
title: str
body: list[Component] = Field(default_factory=list)
class Hook(_Strict):
name: str
kind: Literal["start", "signal"]
entry: str | None = None
class NotifySub(_Strict):
channel: str
events: list[str] = Field(default_factory=list)
class AppSpec(_Strict):
format: Literal["agentlab.app/1"] = "agentlab.app/1"
name: str
description: str = ""
icon: str = "app"
pins: list[Pin] = Field(default_factory=list)
slots: dict[str, Slot] = Field(default_factory=dict)
entries: list[Entry] = Field(default_factory=list)
forms: dict[str, dict[str, Any]] = Field(default_factory=dict)
# 按人工节点 id 覆盖待办的外观(标题、x-order、x-widget),不能改数据约束
task_ui: dict[str, dict[str, Any]] = Field(default_factory=dict)
views: dict[str, View] = Field(default_factory=dict)
pages: list[Page] = Field(default_factory=list)
hooks: list[Hook] = Field(default_factory=list)
notify: list[NotifySub] = Field(default_factory=list)
def parse_spec(data: Any) -> AppSpec:
"""按形状解析一份 spec;形状不对抛 pydantic.ValidationError(lint 把它翻成 app.format)。"""
return AppSpec.model_validate(data)
def empty_spec(name: str) -> dict[str, Any]:
return AppSpec(name=name).model_dump(mode="json", by_alias=True)