mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:51:58 +00:00
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 写法校验)。
174 lines
7.0 KiB
Python
174 lines
7.0 KiB
Python
"""应用描述 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)
|