mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:41:59 +00:00
277982ead3
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表 - 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号 - 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period) - 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
787 lines
44 KiB
Python
787 lines
44 KiB
Python
"""应用的发布检查(§3.13 的 app.* 编号)。问题格式同 engine/schema.py 的 ValidationIssue:
|
||
{message, level: error|warning, code, path}。path 指向 spec 里的位置(如 pages[0].body[2].bind)。
|
||
|
||
编号是给程序认的(编辑器按它把问题落到属性面板、助手按它修),文案可以改,编号不改。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from pydantic import ValidationError
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.apps.spec import APP_ROOTS, DISPOSITIONS, parse_spec
|
||
from app.apps.tree import bind_kind, components, input_fields, node_map, pins_by_alias, resolve_form, slot_refs
|
||
|
||
#: 按需视图缓存时效:默认 600 秒,最多一天(§2.3)
|
||
TTL_DEFAULT = 600
|
||
TTL_MAX = 86_400
|
||
#: 运行信息绑定能取的几项
|
||
RUN_FIELDS = ("status", "tier", "finished_at")
|
||
#: 入口映射里能取的当前用户的几项
|
||
USER_FIELDS = ("username", "display_name")
|
||
|
||
_DIGIT = re.compile(r"\d")
|
||
#: 文字里的一个数:千分位的逗号算在数里(「5,000」就是 5000)
|
||
_NUMBER = re.compile(r"\d[\d,,]*(?:\.\d+)?")
|
||
#: 岗位在图里的用处(tree.SLOT_ROLES),检查的说明里写清是哪一步要它
|
||
_SLOT_USE = {"assignee": "人工节点「{title}」派给", "escalation": "人工节点「{title}」超时升级给",
|
||
"confirm": "调用工具节点「{title}」的发送确认派给"}
|
||
_REPORT_TEXT = re.compile(r"^\s*\{\{\s*nodes\.([A-Za-z0-9_-]+)\.text\s*\}\}\s*$")
|
||
_PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_㐀-鿿]+)\}")
|
||
|
||
|
||
class _Issues:
|
||
def __init__(self) -> None:
|
||
self.items: list[dict[str, Any]] = []
|
||
|
||
def error(self, code: str, message: str, path: str | None = None, **extra: Any) -> None:
|
||
self.items.append({"message": message, "level": "error", "code": code, "path": path, **extra})
|
||
|
||
def warning(self, code: str, message: str, path: str | None = None, **extra: Any) -> None:
|
||
self.items.append({"message": message, "level": "warning", "code": code, "path": path, **extra})
|
||
|
||
|
||
def parse_map_source(source: Any) -> tuple[str, Any] | None:
|
||
"""入口映射的一项:("form", 字段) / ("user", 属性) / ("const", 值);写法不对是 None。
|
||
|
||
常量写成 JSON 字面量(数、true / false、"文字")或单引号括起来的文字('报销')。"""
|
||
if not isinstance(source, str) or not source.strip():
|
||
return None
|
||
text = source.strip()
|
||
if text.startswith("form."):
|
||
return ("form", text[5:]) if text[5:] else None
|
||
if text.startswith("user."):
|
||
return ("user", text[5:]) if text[5:] in USER_FIELDS else None
|
||
if len(text) >= 2 and text[0] == text[-1] == "'":
|
||
return ("const", text[1:-1])
|
||
try:
|
||
return ("const", json.loads(text))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def needs_disposition(previous: Any | None, pins: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""钉住项相对上一个应用版本换了版本的:[{alias, from, to}]。按别名对上;别名换了的,按同一个工作流对上(唯一时)。"""
|
||
if previous is None:
|
||
return []
|
||
before = [p for p in (getattr(previous, "pins", None) or []) if isinstance(p, dict)]
|
||
out: list[dict[str, Any]] = []
|
||
for pin in pins:
|
||
if not isinstance(pin, dict):
|
||
continue
|
||
old = next((p for p in before if p.get("alias") == pin.get("alias")), None)
|
||
if old is None:
|
||
same = [p for p in before if p.get("workflow_id") == pin.get("workflow_id")]
|
||
old = same[0] if len(same) == 1 else None
|
||
if old is not None and old.get("workflow_id") == pin.get("workflow_id") and old.get("version") != pin.get("version"):
|
||
out.append({"alias": pin.get("alias"), "from": old.get("version"), "to": pin.get("version")})
|
||
return out
|
||
|
||
|
||
async def lint_app(session: AsyncSession, spec: dict[str, Any], bindings: dict[str, Any], *,
|
||
previous: Any | None = None, dispositions: dict[str, str] | None = None,
|
||
pins: dict[str, Any] | None = None, scope: str = "shared") -> list[dict[str, Any]]:
|
||
"""检查一份待发布的应用。previous 是上一个应用版本(判断钉住项升版要不要处置)。
|
||
|
||
pins 给了就按它认钉住项(别名 → PinState),不读库:发布单的预检按「将要发布的那一版」检查(版本已经存下、还没发布,
|
||
blueprints/publish_plan.py 把它当作已发布的样子交进来),其余调用方不给。
|
||
|
||
scope 是应用的范围(App.scope)。个人应用(personal,docs/design/normal-user-apps.md N7)固定的是它自己的蓝图、视图只取
|
||
探索运行、图要满足使用者的约束(_lint_personal);共享应用里出现蓝图、探索运行的照实报错。"""
|
||
from app.apps.pins import load_pins
|
||
|
||
out = _Issues()
|
||
try:
|
||
parse_spec(spec)
|
||
except ValidationError as e:
|
||
for err in e.errors()[:20]:
|
||
loc = ".".join(str(x) for x in err.get("loc") or ())
|
||
out.error("app.format", f"应用描述的格式不对:{loc}({err.get('msg')})", loc or None)
|
||
return out.items
|
||
pins = pins if pins is not None else await load_pins(session, spec)
|
||
level = _level(pins)
|
||
_lint_unique(spec, out)
|
||
_lint_pins(spec, pins, out, scope)
|
||
await _lint_subgraph_pins(session, spec, pins, out)
|
||
_lint_upgrade(spec, previous, dispositions or {}, out)
|
||
views = spec.get("views") or {}
|
||
await _lint_views(session, spec, pins, out, scope)
|
||
_lint_components(spec, pins, views, level, out)
|
||
await _lint_records_rows(session, spec, pins, out)
|
||
_lint_hidden(spec, out)
|
||
if scope == "personal":
|
||
_lint_personal(spec, pins, out)
|
||
_lint_entries(spec, pins, out)
|
||
await _lint_process(session, spec, pins, bindings or {}, out)
|
||
_lint_hooks(spec, out)
|
||
await _lint_notify(session, spec, out)
|
||
return out.items
|
||
|
||
|
||
def _level(pins: dict[str, Any]) -> str:
|
||
from app.apps.tree import app_level
|
||
|
||
return app_level([{"level": p.level} for p in pins.values()])
|
||
|
||
|
||
def _lint_unique(spec: dict[str, Any], out: _Issues) -> None:
|
||
seen: set[str] = set()
|
||
for i, page in enumerate(spec.get("pages") or []):
|
||
if page.get("id") in seen:
|
||
out.error("app.format", f"页面「{page.get('id')}」重名", f"pages[{i}].id")
|
||
seen.add(page.get("id"))
|
||
for comp, _, at in components(spec):
|
||
if comp.get("id") in seen:
|
||
out.error("app.format", f"组件「{comp.get('id')}」和别的组件或页面重名", f"{at}.id")
|
||
seen.add(comp.get("id"))
|
||
aliases: set[str] = set()
|
||
for i, pin in enumerate(spec.get("pins") or []):
|
||
if pin.get("alias") in aliases:
|
||
out.error("app.format", f"引用的工作流版本别名「{pin.get('alias')}」重复", f"pins[{i}].alias")
|
||
aliases.add(pin.get("alias"))
|
||
entries: set[str] = set()
|
||
for i, entry in enumerate(spec.get("entries") or []):
|
||
if entry.get("id") in entries:
|
||
out.error("app.format", f"入口「{entry.get('id')}」重名", f"entries[{i}].id")
|
||
entries.add(entry.get("id"))
|
||
|
||
|
||
def _lint_pins(spec: dict[str, Any], pins: dict[str, Any], out: _Issues, scope: str = "shared") -> None:
|
||
for i, pin in enumerate(spec.get("pins") or []):
|
||
state = pins.get(pin.get("alias"))
|
||
at = f"pins[{i}]"
|
||
if pin.get("blueprint") or scope == "personal":
|
||
# 个人应用的蓝图在 _lint_personal 里查;两种范围和两种固定版本对不上的在这里报
|
||
if pin.get("blueprint") and scope != "personal":
|
||
out.error("app.pin_unpublished", f"引用的「{pin.get('alias')}」是蓝图里还没发布的图:共享应用只能引用已发布的"
|
||
"工作流版本", at)
|
||
elif not pin.get("blueprint"):
|
||
out.error("personal.policy", "个人应用只能引用它自己的蓝图,不能引用工作流版本", at)
|
||
continue
|
||
if state is None or state.row is None:
|
||
out.error("app.pin_unpublished", f"引用的工作流版本「{pin.get('alias')}」不存在(工作流或 v{pin.get('version')} 已删除)", at)
|
||
elif state.retired:
|
||
out.error("app.pin_retired", f"引用的「{state.label}」已退役,请升级到一个在用的发布版本", at)
|
||
elif not state.published:
|
||
out.error("app.pin_unpublished", f"引用的「{state.label}」没有发布过,应用只能引用已发布的版本", at)
|
||
elif state.hash != pin.get("graph_hash"):
|
||
out.error("app.pin_hash", f"引用的「{state.label}」的内容指纹和应用里记的不一致,请重新选择这一版", at)
|
||
|
||
|
||
async def _lint_subgraph_pins(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None:
|
||
"""钉住的版本里,子工作流固定的版本不是「已发布且未退役」的(D-q,apps/versions.subgraph_pin_problems):各种用法都会
|
||
发起这一版的正式运行(入口、流程、按需视图、给最新封存视图供数的定时任务、补算),每一次都会在发起时被拒,所以报错。
|
||
钉住项本身不能用的(上面 _lint_pins 报过)不再往里看。"""
|
||
from app.apps.versions import subgraph_pin_problems
|
||
from app.engine.schema import GraphSpec
|
||
|
||
for i, pin in enumerate(spec.get("pins") or []):
|
||
state = pins.get(pin.get("alias"))
|
||
if state is None or state.row is None or not state.published:
|
||
continue
|
||
try:
|
||
graph = GraphSpec.model_validate(state.graph)
|
||
except ValueError:
|
||
continue
|
||
for problem in await subgraph_pin_problems(session, graph):
|
||
out.error(problem.code, f"「{state.label}」里的{problem.who}固定的{problem.reason}:这一版的正式运行"
|
||
"发起时会被拒绝。请在那个工作流里改好并发布新版本,再把这里的引用换成新版本", f"pins[{i}]",
|
||
node_id=problem.node_id)
|
||
|
||
|
||
def _lint_upgrade(spec: dict[str, Any], previous: Any | None, dispositions: dict[str, str], out: _Issues) -> None:
|
||
from app.engine.governance import UPGRADE_POLICY_LABELS
|
||
|
||
choices = "、".join(f"「{v}」" for v in UPGRADE_POLICY_LABELS.values())
|
||
for item in needs_disposition(previous, spec.get("pins") or []):
|
||
if dispositions.get(item["alias"]) not in DISPOSITIONS:
|
||
index = next(i for i, p in enumerate(spec.get("pins") or []) if p.get("alias") == item["alias"])
|
||
out.error("app.upgrade_policy",
|
||
f"引用的工作流版本「{item['alias']}」从 v{item['from']} 升到了 v{item['to']},口径升版必须处置:"
|
||
f"请选择 {choices} 中的一种", f"pins[{index}]", alias=item["alias"])
|
||
|
||
|
||
async def _lint_views(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], out: _Issues,
|
||
scope: str = "shared") -> None:
|
||
from app.apps.pins import reads_live
|
||
from app.apps.resolve import latest_sealed_run
|
||
from app.db.models import Schedule
|
||
|
||
for vid, view in (spec.get("views") or {}).items():
|
||
source = (view or {}).get("source") or {}
|
||
at = f"views.{vid}.source"
|
||
kind = source.get("kind")
|
||
if (kind == "exploratory") != (scope == "personal"):
|
||
# 探索运行只在个人应用里合法(N7):共享应用的视图取了它,等于把没发布的图的结果当正式结果给人看;个人应用
|
||
# 只有蓝图上的图、没有正式运行,取别的来源永远是空的
|
||
out.error("app.view_source_personal",
|
||
f"视图「{vid}」取的是探索运行:只有个人应用能这样取数,共享应用的视图要取正式运行(最近一次出数、"
|
||
"按条件查看或本实例)" if kind == "exploratory" else
|
||
f"视图「{vid}」:个人应用的视图只能取它自己的探索运行(每次刷新的结果)", f"{at}.kind")
|
||
continue
|
||
if kind == "instance":
|
||
continue
|
||
state = pins.get(source.get("pin"))
|
||
if state is None:
|
||
out.error("app.binding_target_missing", f"视图「{vid}」引用的工作流版本「{source.get('pin')}」不在应用里", at)
|
||
continue
|
||
if kind == "exploratory":
|
||
continue
|
||
if kind == "latest_sealed":
|
||
origin = source.get("from")
|
||
if origin not in (None, "formal") and not (isinstance(origin, str) and origin.startswith("schedule:")):
|
||
out.error("app.format", f"视图「{vid}」的来源只能是正式运行或某个定时任务", f"{at}.from")
|
||
continue
|
||
if isinstance(origin, str) and origin.startswith("schedule:") \
|
||
and await session.get(Schedule, origin[len("schedule:"):]) is None:
|
||
out.error("app.binding_target_missing", f"视图「{vid}」取数的定时任务已不存在", f"{at}.from")
|
||
continue
|
||
if state.row is not None and await latest_sealed_run(session, state, origin) is None:
|
||
out.warning("app.view_source_empty", f"视图「{vid}」暂时没有可读的封存运行:页面上会写明还没有数据", at)
|
||
elif kind == "on_demand":
|
||
ttl = source.get("ttl_s")
|
||
if ttl is not None and not (isinstance(ttl, int) and 0 < ttl <= TTL_MAX):
|
||
out.error("app.on_demand_ttl", f"视图「{vid}」的缓存时效需要在 1 到 {TTL_MAX:,} 秒之间", f"{at}.ttl_s")
|
||
elif ttl is None and state.row is not None and await reads_live(session, state.graph):
|
||
out.error("app.on_demand_ttl",
|
||
f"视图「{vid}」按需运行时会查活库,需要设置缓存时效(建议 {TTL_DEFAULT} 秒):不然同样的参数会一直用旧结果",
|
||
f"{at}.ttl_s")
|
||
if source.get("params") is not None:
|
||
form = resolve_form(spec, source.get("params"))
|
||
problems = ["引用的表单不存在"] if form is None else _form_problems(form)
|
||
for p in problems[:3]:
|
||
out.error("app.format", f"视图「{vid}」的参数表单:{p}", f"{at}.params")
|
||
|
||
|
||
def _form_problems(form: dict[str, Any]) -> list[str]:
|
||
from app.apps.forms import validate_form_schema
|
||
|
||
return validate_form_schema(form)
|
||
|
||
|
||
def _view_graphs(spec: dict[str, Any], pins: dict[str, Any], view_id: str | None) -> list[dict[str, Any]]:
|
||
"""视图背后的图:钉住项那一版的图;实例视图是各入口引用的那几版。"""
|
||
view = (spec.get("views") or {}).get(view_id or "") or {}
|
||
source = view.get("source") or {}
|
||
if source.get("kind") == "instance":
|
||
aliases = {e.get("pin") for e in spec.get("entries") or []} or set(pins)
|
||
return [pins[a].graph for a in aliases if a in pins and pins[a].row is not None]
|
||
state = pins.get(source.get("pin"))
|
||
# 个人应用的固定版本是蓝图:图在蓝图上(row 为空)
|
||
return [state.graph] if state is not None and (state.row is not None or state.blueprint is not None) else []
|
||
|
||
|
||
def _lint_components(spec: dict[str, Any], pins: dict[str, Any], views: dict[str, Any], level: str,
|
||
out: _Issues) -> None:
|
||
from jsonschema import Draft202012Validator
|
||
|
||
from app.apps.catalog import BY_NAME
|
||
|
||
constants = _graph_numbers(pins)
|
||
for comp, _, at in components(spec):
|
||
ctype = comp.get("type")
|
||
entry = BY_NAME.get(str(ctype))
|
||
if entry is None:
|
||
out.error("app.component_unknown", f"组件「{comp.get('id')}」的类型「{ctype}」不在组件目录里", f"{at}.type")
|
||
continue
|
||
for err in list(Draft202012Validator(entry["props"]).iter_errors(comp.get("props") or {}))[:3]:
|
||
where = ".".join(str(x) for x in err.absolute_path)
|
||
out.error("app.props_invalid", f"组件「{comp.get('id')}」({entry['label']})的属性{('「' + where + '」') if where else ''}不对",
|
||
f"{at}.props{('.' + where) if where else ''}")
|
||
if comp.get("children") and not entry["children"]:
|
||
out.error("app.props_invalid", f"组件「{comp.get('id')}」({entry['label']})不能放子组件", f"{at}.children")
|
||
_lint_text(comp, entry, at, level, out, constants)
|
||
_lint_bind(spec, comp, entry, pins, views, at, out)
|
||
if level == "governed":
|
||
_lint_metric_origin(spec, comp, pins, at, out)
|
||
_lint_visible(comp, views, at, out)
|
||
_lint_refs(spec, comp, at, out)
|
||
|
||
|
||
def _numbers(text: str) -> list[str]:
|
||
return [m.replace(",", "").replace(",", "").rstrip(".") for m in _NUMBER.findall(text)]
|
||
|
||
|
||
def _graph_numbers(pins: dict[str, Any]) -> set[str]:
|
||
"""固定的版本里节点配置写着的数:条件里的金额线、时限、会签人数、表单的上下限这些流程规则。只看 config,不看标题。"""
|
||
found: set[str] = set()
|
||
|
||
def walk(value: Any) -> None:
|
||
if isinstance(value, bool):
|
||
return
|
||
if isinstance(value, (int, float)):
|
||
found.update(_numbers(f"{value:g}" if isinstance(value, float) else str(value)))
|
||
elif isinstance(value, str):
|
||
found.update(_numbers(value))
|
||
elif isinstance(value, dict):
|
||
for v in value.values():
|
||
walk(v)
|
||
elif isinstance(value, list):
|
||
for v in value:
|
||
walk(v)
|
||
|
||
for state in pins.values():
|
||
if getattr(state, "row", None) is None:
|
||
continue
|
||
for node in node_map(state.graph).values():
|
||
walk(node.get("config"))
|
||
return found
|
||
|
||
|
||
def _lint_text(comp: dict[str, Any], entry: dict[str, Any], at: str, level: str, out: _Issues,
|
||
constants: set[str]) -> None:
|
||
"""静态文字里有数字:叙述层无算术、数字只来自证据。受管级报错,已发布级警告。
|
||
|
||
数据要绑定指标或单元格;流程规则(审批的金额线、时限、会签人数)的出处是固定版本里的节点配置——文字里的每个数都在
|
||
那里写着才算有出处。规则只写在说明里、流程并不照着执行的,照样报:那句话是假的。"""
|
||
props = comp.get("props") or {}
|
||
text = props.get("markdown") if comp.get("type") == "text" else props.get("text") if comp.get("type") == "notice" else None
|
||
if not isinstance(text, str) or not _DIGIT.search(text):
|
||
return
|
||
loose = [n for n in dict.fromkeys(_numbers(text)) if n not in constants]
|
||
if not loose:
|
||
return
|
||
add = out.error if level == "governed" else out.warning
|
||
add("app.text_number", f"组件「{comp.get('id')}」({entry['label']})的静态文字里写了数字「{'、'.join(loose[:5])}」,"
|
||
"没有出处:数据请改为绑定指标或单元格;流程规则(如审批的金额线、时限)要先写进工作流,文字里的数和工作流里的一致"
|
||
"才算有出处", f"{at}.props")
|
||
|
||
|
||
def _lint_bind(spec: dict[str, Any], comp: dict[str, Any], entry: dict[str, Any], pins: dict[str, Any],
|
||
views: dict[str, Any], at: str, out: _Issues) -> None:
|
||
bind = comp.get("bind")
|
||
if bind is None:
|
||
return
|
||
where = f"{at}.bind"
|
||
cid = comp.get("id")
|
||
kind = bind_kind(bind)
|
||
if kind is not None and kind not in entry["bindings"]:
|
||
allowed = "、".join(entry["bindings"]) or "任何数据"
|
||
out.error("app.props_invalid", f"组件「{cid}」({entry['label']})不能绑定这种数据,只能绑定:{allowed}", where)
|
||
return
|
||
if kind in ("task", "action"):
|
||
graphs = _view_graphs(spec, pins, next((v for v, d in views.items()
|
||
if ((d or {}).get("source") or {}).get("kind") == "instance"), None))
|
||
_lint_process_bind(cid, kind, bind[kind], graphs, where, out)
|
||
return
|
||
view_id = bind.get("view")
|
||
if view_id not in views:
|
||
out.error("app.binding_target_missing", f"组件「{cid}」绑定的视图「{view_id}」不存在", where)
|
||
return
|
||
if kind is None:
|
||
return
|
||
graphs = _view_graphs(spec, pins, view_id)
|
||
if not graphs:
|
||
return
|
||
target = bind[kind]
|
||
if not any(_target_ok(kind, target, g, out, cid, where) for g in graphs):
|
||
out.error("app.binding_target_missing", f"组件「{cid}」绑定的{_KIND_LABEL[kind]}在引用的工作流版本里不存在", where)
|
||
|
||
|
||
def _lint_metric_origin(spec: dict[str, Any], comp: dict[str, Any], pins: dict[str, Any], at: str, out: _Issues) -> None:
|
||
"""受管级应用里绑定的指标(指标块、趋势):表达式的输入追不到原始数据(查询快照里的格、运行输入)的给提示。
|
||
|
||
指标照样是口径卡确定性算出来的、面板上照样「有出处」,但点开追到某个节点就断了:受管级的应用要的是一路追到数据。
|
||
和运行时、证据面板同一套认法(engine/lineage.static_stops),只看图不看值。转抄别处口径卡的指标在上游那一版里,
|
||
不在这里追;目标不存在的由 _lint_bind 报。"""
|
||
from app.engine.labels import type_label
|
||
from app.engine.lineage import static_stops
|
||
|
||
bind = comp.get("bind")
|
||
kind = bind_kind(bind) if isinstance(bind, dict) else None
|
||
if kind not in ("metric", "series"):
|
||
return
|
||
node_id, _, metric_id = str(bind.get(kind) or "").partition(".")
|
||
for graph in _view_graphs(spec, pins, bind.get("view")):
|
||
nodes = node_map(graph)
|
||
card = nodes.get(node_id)
|
||
if card is None or card["type"] != "metrics" or isinstance(card["config"].get("caliber_from"), dict):
|
||
continue
|
||
metric = next((m for m in card["config"].get("metrics") or [] if isinstance(m, dict) and m.get("id") == metric_id),
|
||
None)
|
||
if metric is None:
|
||
continue
|
||
stops = static_stops(nodes, str(metric.get("expression") or ""))
|
||
if not stops:
|
||
continue
|
||
where = ";".join(
|
||
f"{s.path} 停在「{nodes[s.node_id]['title']}」({type_label(nodes[s.node_id]['type'])})" if s.node_id in nodes
|
||
else f"{s.path} 无法确定来自哪个节点" for s in stops[:3])
|
||
out.warning("app.metric_untraced",
|
||
f"组件「{comp.get('id')}」绑定的指标「{metric.get('name') or metric_id}」的输入没有追到原始数据({where}):"
|
||
"数是口径卡算出来的,但点开出处只能追到那里,看不到查询快照里的格。请在口径卡里用 cell() 按格取数,"
|
||
"或让数据整形只原样解析调用工具节点的结果", f"{at}.bind", node_id=node_id)
|
||
return
|
||
|
||
|
||
_KIND_LABEL = {"metric": "指标", "series": "指标", "cell": "单元格", "query": "查询结果", "report": "报告",
|
||
"input": "运行输入", "run": "运行信息", "output": "成果字段", "task": "待办", "action": "动作"}
|
||
|
||
|
||
def _target_ok(kind: str, target: Any, graph: dict[str, Any], out: _Issues, cid: Any, where: str) -> bool:
|
||
nodes = node_map(graph)
|
||
if kind in ("metric", "series"):
|
||
node_id, _, metric = str(target or "").partition(".")
|
||
node = nodes.get(node_id)
|
||
if node is None or node["type"] != "metrics" or not metric:
|
||
return False
|
||
listed = [m.get("id") for m in node["config"].get("metrics") or [] if isinstance(m, dict)]
|
||
# 转抄别处口径卡的(caliber_from)指标在上游那一版里,发布时引用的那一版不变,这里不去追
|
||
return metric in listed or (not listed and isinstance(node["config"].get("caliber_from"), dict))
|
||
if kind in ("cell", "query"):
|
||
node = nodes.get(str((target or {}).get("node") if isinstance(target, dict) else ""))
|
||
if kind == "cell" and isinstance(target, dict) and not (
|
||
isinstance(target.get("row"), int) and isinstance(target.get("col"), str)):
|
||
return False
|
||
return node is not None and node["type"] in ("tool", "agent", "merge")
|
||
if kind == "report":
|
||
node = nodes.get(str(target))
|
||
return node is not None and node["type"] == "report"
|
||
if kind == "input":
|
||
fields = input_fields(graph)
|
||
return not fields or any(f["name"].strip() == target for f in fields)
|
||
if kind == "run":
|
||
return target in RUN_FIELDS
|
||
if kind == "output":
|
||
for node in nodes.values():
|
||
if node["type"] != "output":
|
||
continue
|
||
for f in node["config"].get("fields") or []:
|
||
if isinstance(f, dict) and f.get("name") == target:
|
||
value = str(f.get("value") or "")
|
||
report = _REPORT_TEXT.match(value)
|
||
if report and (nodes.get(report.group(1)) or {}).get("type") == "report":
|
||
return True
|
||
if "{{" not in value and not _DIGIT.search(value):
|
||
return True
|
||
out.error("app.binding_not_evidenced",
|
||
f"组件「{cid}」绑定的成果字段「{target}」不是逐字取自报告的文字,里面的数字没有证据。"
|
||
"请改为绑定指标、单元格或报告", where)
|
||
return True
|
||
return False
|
||
return True
|
||
|
||
|
||
def _lint_process_bind(cid: Any, kind: str, target: Any, graphs: list[dict[str, Any]], where: str, out: _Issues) -> None:
|
||
want = "human" if kind == "task" else "tool"
|
||
if not any((node_map(g).get(str(target)) or {}).get("type") == want for g in graphs):
|
||
out.error("app.binding_target_missing", f"组件「{cid}」绑定的{_KIND_LABEL[kind]}节点「{target}」在入口引用的工作流版本里不存在",
|
||
where)
|
||
|
||
|
||
def _lint_visible(comp: dict[str, Any], views: dict[str, Any], at: str, out: _Issues) -> None:
|
||
from app.engine.expressions import ExpressionArithmetic, ExpressionError, parse_expression
|
||
|
||
if comp.get("scope") is not None and comp.get("scope") not in views:
|
||
out.error("app.binding_target_missing", f"组件「{comp.get('id')}」的可见条件引用的视图「{comp.get('scope')}」不存在",
|
||
f"{at}.scope")
|
||
expr = comp.get("visible")
|
||
if not expr:
|
||
return
|
||
where = f"{at}.visible"
|
||
try:
|
||
_, _, unknown = parse_expression(str(expr), roots=APP_ROOTS, arithmetic=False)
|
||
except ExpressionArithmetic as e:
|
||
out.error("app.expr_arith", f"组件「{comp.get('id')}」的可见条件:{e}", where)
|
||
return
|
||
except ExpressionError as e:
|
||
out.error("app.expr_invalid", f"组件「{comp.get('id')}」的可见条件写法不对:{e}", where)
|
||
return
|
||
if unknown:
|
||
roots = "、".join(sorted(APP_ROOTS))
|
||
out.error("app.expr_root", f"组件「{comp.get('id')}」的可见条件用了不认识的名字:{'、'.join(unknown)}。能用的有 {roots}",
|
||
where)
|
||
|
||
|
||
def _lint_refs(spec: dict[str, Any], comp: dict[str, Any], at: str, out: _Issues) -> None:
|
||
"""组件属性里按名字引用的入口、表单得在。"""
|
||
props = comp.get("props") or {}
|
||
if comp.get("type") in ("entry_button", "instance_list") and props.get("entry") is not None \
|
||
and not any(e.get("id") == props["entry"] for e in spec.get("entries") or []):
|
||
out.error("app.binding_target_missing", f"组件「{comp.get('id')}」引用的入口「{props['entry']}」不存在", f"{at}.props.entry")
|
||
if comp.get("type") == "form" and props.get("form") not in (spec.get("forms") or {}):
|
||
out.error("app.binding_target_missing", f"组件「{comp.get('id')}」引用的表单「{props.get('form')}」不存在", f"{at}.props.form")
|
||
|
||
|
||
#: SQL 里出现这些就算汇总过了:分组,或者聚合函数
|
||
_AGGREGATED = re.compile(r"\bgroup\s+by\b|\b(?:count|sum|avg|min|max|total|group_concat)\s*\(", re.I)
|
||
|
||
|
||
async def _lint_records_rows(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None:
|
||
"""表格组件绑定的查询直接列出记录表的逐行、没有汇总(app.records_rows_exposed,docs/design/normal-user-apps.md N8):
|
||
看板读的是正式运行的结果,正式运行看得到全部行,这样等于所有使用者都能看到别人的记录,绕过了行级可见。只提醒:
|
||
作者可能就是要公开(排行榜);逐行查看请用记录列表组件,它按看的人过滤。只看调用工具节点写在图里的 SQL。"""
|
||
from app.data.guard import _blank_quoted
|
||
from app.db.models import DataSource
|
||
from app.records import RECORDS_ORIGIN
|
||
from app.tools.datasource import QUERY_PREFIX
|
||
|
||
records: set[str] | None = None
|
||
for comp, _, at in components(spec):
|
||
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else None
|
||
if comp.get("type") != "table" or bind is None or bind_kind(bind) != "query":
|
||
continue
|
||
target = bind.get("query") if isinstance(bind.get("query"), dict) else {}
|
||
for graph in _view_graphs(spec, pins, bind.get("view")):
|
||
node = node_map(graph).get(str(target.get("node") or ""))
|
||
tool = str(((node or {}).get("config") or {}).get("tool") or "")
|
||
sql = (((node or {}).get("config") or {}).get("args") or {}).get("sql")
|
||
if node is None or node["type"] != "tool" or not tool.startswith(QUERY_PREFIX) or not isinstance(sql, str):
|
||
continue
|
||
if records is None:
|
||
records = set((await session.execute(select(DataSource.name).where(
|
||
DataSource.origin == RECORDS_ORIGIN))).scalars())
|
||
if tool[len(QUERY_PREFIX):] not in records or _AGGREGATED.search(_blank_quoted(sql, dialect="sqlite")):
|
||
continue
|
||
out.warning("app.records_rows_exposed",
|
||
f"组件「{comp.get('id')}」(表格)直接列出了记录表的逐行:看板的数据是正式运行查的,看得到全部行,"
|
||
"所有能用这个应用的人都会看到别人提交的记录。要逐行查看请改用「记录列表」组件(按看的人过滤);"
|
||
"看板上的表格请改成汇总", f"{at}.bind", node_id=str(target.get("node")))
|
||
break
|
||
|
||
|
||
def _lint_hidden(spec: dict[str, Any], out: _Issues) -> None:
|
||
"""隐藏了的组件是唯一绑定某个视图的组件(N6,app.hidden_bound):隐藏之后这个视图的数在页面上一处都看不到了——可能
|
||
隐藏错了,或者这个视图连同组件可以删掉。只提醒:作者也许就是暂时不想让人看。放在隐藏了的布局组件里的算隐藏。"""
|
||
binders: dict[str, list[tuple[bool, str, str]]] = {}
|
||
|
||
def walk(items: list[Any], hidden: bool, at: str) -> None:
|
||
for i, comp in enumerate(items):
|
||
if not isinstance(comp, dict):
|
||
continue
|
||
here = f"{at}[{i}]"
|
||
off = hidden or bool(comp.get("hidden"))
|
||
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else {}
|
||
if isinstance(bind.get("view"), str):
|
||
binders.setdefault(bind["view"], []).append((off, str(comp.get("id")), here))
|
||
walk(comp.get("children") or [], off, f"{here}.children")
|
||
|
||
for pi, page in enumerate(spec.get("pages") or []):
|
||
if isinstance(page, dict):
|
||
walk(page.get("body") or [], False, f"pages[{pi}].body")
|
||
for vid, items in binders.items():
|
||
if not all(off for off, _, _ in items):
|
||
continue
|
||
_, cid, at = items[0]
|
||
what = f"组件「{cid}」是唯一绑定视图「{vid}」的组件" if len(items) == 1 else f"绑定视图「{vid}」的组件都"
|
||
out.warning("app.hidden_bound", f"{what},隐藏之后页面上一处都看不到这个视图的数了。确实不要了可以删掉它",
|
||
f"{at}.hidden")
|
||
|
||
|
||
def _lint_personal(spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None:
|
||
"""个人应用(N7):固定版本是它自己的蓝图(还在、图的指纹和应用记的一样),图满足使用者的约束(同问数据的白名单,
|
||
blueprints/personal.policy_problem,personal.policy),不能有入口、钩子、通知订阅、岗位。"""
|
||
from app.blueprints.personal import NOT_IN_PERSONAL, policy_problem
|
||
|
||
for i, pin in enumerate(spec.get("pins") or []):
|
||
if not pin.get("blueprint"):
|
||
continue
|
||
state = pins.get(pin.get("alias"))
|
||
at = f"pins[{i}]"
|
||
if state is None or state.blueprint is None:
|
||
out.error("personal.policy", "个人应用出自的蓝图已经不在了:没法再刷新,可以归档这个应用", at)
|
||
continue
|
||
if state.hash != pin.get("graph_hash"):
|
||
out.error("app.pin_hash", "蓝图在存成个人应用之后又改过:请回到蓝图重新「存成我的应用」", at)
|
||
if problem := policy_problem(state.graph):
|
||
out.error("personal.policy", problem, at)
|
||
declared = [(key, label) for key, label in NOT_IN_PERSONAL if spec.get(key)]
|
||
if declared:
|
||
out.error("personal.policy", f"个人应用不发起实例、不派待办,不能有{'、'.join(label for _, label in declared)}:请删掉",
|
||
declared[0][0])
|
||
|
||
|
||
def _lint_entries(spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None:
|
||
for i, entry in enumerate(spec.get("entries") or []):
|
||
at = f"entries[{i}]"
|
||
label = f"入口「{entry.get('label') or entry.get('id')}」"
|
||
form = resolve_form(spec, entry.get("form"))
|
||
if form is None:
|
||
out.error("app.entry_form_mismatch", f"{label}引用的表单不存在", f"{at}.form")
|
||
continue
|
||
for p in _form_problems(form)[:3]:
|
||
out.error("app.entry_form_mismatch", f"{label}的表单:{p}", f"{at}.form")
|
||
state = pins.get(entry.get("pin"))
|
||
if state is None:
|
||
out.error("app.binding_target_missing", f"{label}引用的工作流版本「{entry.get('pin')}」不在应用里", f"{at}.pin")
|
||
continue
|
||
fields = {f["name"].strip(): f for f in input_fields(state.graph)} if state.row is not None else {}
|
||
mapping = entry.get("map") or {}
|
||
props = form.get("properties") or {}
|
||
for target, source in mapping.items():
|
||
parsed = parse_map_source(source)
|
||
if fields and target not in fields:
|
||
out.error("app.entry_form_mismatch", f"{label}映射的「{target}」不是「{state.label}」的入口字段", f"{at}.map.{target}")
|
||
if parsed is None:
|
||
out.error("app.entry_form_mismatch",
|
||
f"{label}映射「{target}」的取值写法不对:只能取表单字段、当前用户的用户名或显示名,或者写一个常量",
|
||
f"{at}.map.{target}")
|
||
elif parsed[0] == "form" and parsed[1] not in props:
|
||
out.error("app.entry_form_mismatch", f"{label}映射「{target}」取的表单字段「{parsed[1]}」不在表单里",
|
||
f"{at}.map.{target}")
|
||
for name, f in fields.items():
|
||
if f.get("required") and f.get("default") in (None, "") and name not in mapping:
|
||
out.error("app.entry_unmapped_required", f"{label}没有给「{state.label}」的必填字段「{name}」取值", f"{at}.map")
|
||
title = entry.get("instance_title")
|
||
if isinstance(title, dict):
|
||
listed = set(title.get("fields") or [])
|
||
used = set(_PLACEHOLDER.findall(str(title.get("pattern") or "")))
|
||
if bad := sorted((listed | used) - set(mapping)):
|
||
out.error("app.entry_form_mismatch", f"{label}的实例标题引用了没有映射的字段:{'、'.join(bad)}",
|
||
f"{at}.instance_title")
|
||
elif used - listed:
|
||
out.error("app.entry_form_mismatch", f"{label}的实例标题用到的字段要写在字段列表里:{'、'.join(sorted(used - listed))}",
|
||
f"{at}.instance_title")
|
||
|
||
|
||
async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], bindings: dict[str, Any],
|
||
out: _Issues) -> None:
|
||
"""流程钉住项:图里引用到的岗位(tree.slot_refs:派给谁、超时升级给谁、动作确认派给谁)都声明了、绑了人,写入动作
|
||
有人核实,图本身经得起升级(process.* 升成 app.process_unsafe)。岗位没人,流程走到那一步就派给了「任何人」:升级、
|
||
确认悄悄失效,所以报错。哪些调用工具节点是写入(开发送确认卡、要人核实)按 pins.write_nodes 认,和运行时、派生同一个
|
||
认法。"""
|
||
from app.apps.pins import write_nodes
|
||
from app.engine.governance import publish_issues
|
||
from app.engine.lint_process import load_subgraphs, with_subgraphs
|
||
from app.engine.schema import GraphSpec
|
||
|
||
process = [s for s in pins.values() if s.pin.get("use") == "process" and s.row is not None]
|
||
if not process:
|
||
return
|
||
bound = (bindings or {}).get("slots") or {}
|
||
slots = dict(spec.get("slots") or {})
|
||
used: dict[str, list[str]] = {}
|
||
writes = {state.alias: await write_nodes(session, state.graph) for state in process}
|
||
for state in process:
|
||
for ref in slot_refs(state.graph, writes[state.alias]):
|
||
slot, title = ref["slot"], ref["title"]
|
||
what = _SLOT_USE[ref["role"]].format(title=title)
|
||
used.setdefault(slot, [])
|
||
if f"{what}它" not in used[slot]:
|
||
used[slot].append(f"{what}它")
|
||
if slot not in slots:
|
||
out.error("app.slot_unbound", f"「{state.label}」里{what}岗位「{slot}」,应用里没有声明这个岗位",
|
||
"slots", node_id=ref["node_id"])
|
||
slots[slot] = {"label": slot}
|
||
for slot, info in slots.items():
|
||
if not bound.get(slot):
|
||
label = (info or {}).get("label") or slot
|
||
why = f"({';'.join(used[slot][:3])}):没有人,流程走到这一步就派给了任何人" if used.get(slot) else ""
|
||
out.error("app.slot_unbound", f"岗位「{label}」还没有绑定到人或组{why}", f"slots.{slot}")
|
||
await _lint_slot_principals(session, slots, bound, out)
|
||
for state in process:
|
||
nodes = node_map(state.graph)
|
||
for node_id, cap in writes[state.alias].items():
|
||
if not getattr(cap, "lookup", None) and not bound.get("reconciler"):
|
||
out.error("app.reconciler_unbound", f"「{state.label}」的「{nodes[node_id]['title']}」会写外部系统,写入的能力"
|
||
"没有登记查询,结果未知时要人核实:请绑定核实岗位", "slots", node_id=node_id)
|
||
try:
|
||
graph = GraphSpec.model_validate(state.graph)
|
||
except ValueError:
|
||
continue
|
||
# 子工作流里有没有人工节点要读库才知道(门禁本身不查库):先读好放进来,process.human_in_subgraph 才真正生效
|
||
with with_subgraphs(await load_subgraphs(session, graph)):
|
||
issues = publish_issues(graph, level=state.level or "published")
|
||
for issue in issues:
|
||
if (issue.code or "").startswith("process."):
|
||
out.error("app.process_unsafe", f"「{state.label}」作为流程不安全:{issue.message}",
|
||
f"pins[{_pin_index(spec, state.alias)}]", node_id=issue.node_id)
|
||
|
||
|
||
async def _lint_slot_principals(session: AsyncSession, slots: dict[str, Any], bound: dict[str, Any],
|
||
out: _Issues) -> None:
|
||
"""岗位绑定的人和组要在名录里(auth/directory.py,同选人控件的名录):绑了名录里没有的、已停用的,流程走到这一步就
|
||
派给了没人能处理的名字——界面上选人时拦着,旧客户端、直接调接口、绑好之后人被停用或删除的照样会漏过来,所以发布时
|
||
再核一次。账号密码登录报错;单点登录的人第一次登录后才进名录,名录里还没有的只提醒;关登录不核对。"""
|
||
from app.api.deps import auth_mode
|
||
from app.auth.directory import load_directory, mismatch
|
||
|
||
mode = auth_mode()
|
||
if mode == "off" or not any(bound.get(s) for s in slots):
|
||
return
|
||
known = await load_directory(session)
|
||
for slot, info in slots.items():
|
||
label = (info or {}).get("label") or slot
|
||
for item in bound.get(slot) or []:
|
||
m = mismatch(known, mode, item.get("kind"), str(item.get("name"))) if isinstance(item, dict) else None
|
||
if m is None:
|
||
continue
|
||
if m.blocking:
|
||
out.error("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}:派给这个名字的待办没有人能处理"
|
||
"(全体会签时整条待办办不完)。请从候选中重新选择,或从岗位里移除", f"slots.{slot}",
|
||
problem=m.problem)
|
||
else:
|
||
out.warning("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}", f"slots.{slot}",
|
||
problem=m.problem)
|
||
|
||
|
||
def _pin_index(spec: dict[str, Any], alias: str) -> int:
|
||
return next((i for i, p in enumerate(spec.get("pins") or []) if p.get("alias") == alias), 0)
|
||
|
||
|
||
def _lint_hooks(spec: dict[str, Any], out: _Issues) -> None:
|
||
entries = {e.get("id") for e in spec.get("entries") or []}
|
||
for i, hook in enumerate(spec.get("hooks") or []):
|
||
if hook.get("kind") == "start" and hook.get("entry") not in entries:
|
||
out.error("app.hook_entry_missing", f"钩子「{hook.get('name')}」要按入口发起实例,但入口「{hook.get('entry')}」不存在",
|
||
f"hooks[{i}].entry")
|
||
|
||
|
||
async def _lint_notify(session: AsyncSession, spec: dict[str, Any], out: _Issues) -> None:
|
||
from app.db.models_process import NotifyChannel
|
||
|
||
wanted = [n.get("channel") for n in spec.get("notify") or []]
|
||
if not wanted:
|
||
return
|
||
known = {r for (r,) in await session.execute(select(NotifyChannel.id).where(NotifyChannel.id.in_(wanted)))}
|
||
for i, channel in enumerate(wanted):
|
||
if channel not in known:
|
||
out.warning("app.notify_channel_missing", f"订阅的通知渠道「{channel}」不存在", f"notify[{i}].channel")
|
||
|
||
|
||
async def acl_issues(session: AsyncSession, app_id: str) -> list[dict[str, Any]]:
|
||
"""开了登录却没有任何 use 以上的授权:除了管理员和搭建者,谁都用不了这个应用。关登录时不报。
|
||
|
||
授权给的人和组要在名录里(同岗位绑定,见 _lint_slot_principals):授给名录里没有的名字,以后建了同名的人就直接拿到
|
||
这一档权限;授给停用的人,重新启用就又能用——都说清是哪一档、哪个名字。账号密码登录报错,单点登录名录里还没有的只提醒。"""
|
||
from app.api.deps import auth_mode
|
||
from app.apps.access import ROLE_TEXT, grants_of
|
||
from app.auth.directory import load_directory, mismatch
|
||
|
||
mode = auth_mode()
|
||
if mode == "off":
|
||
return []
|
||
grants = await grants_of(session, app_id)
|
||
if not grants:
|
||
return [{"message": "还没有授权给任何人或组使用这个应用:只有管理员和搭建者能打开它", "level": "warning",
|
||
"code": "app.acl_empty", "path": None}]
|
||
out = _Issues()
|
||
known = await load_directory(session)
|
||
for g in grants:
|
||
if (m := mismatch(known, mode, g.principal_kind, g.principal)) is None:
|
||
continue
|
||
what = f"「{ROLE_TEXT.get(g.role, g.role)}」授权给的{m.text}"
|
||
if m.blocking:
|
||
out.error("app.acl_principal_unknown", f"{what}:{_GRANT_RISK[m.problem]}", "grants", problem=m.problem)
|
||
else:
|
||
out.warning("app.acl_principal_unknown", what, "grants", problem=m.problem)
|
||
return out.items
|
||
|
||
|
||
#: 授权给了名录里对不上的名字,会出什么事
|
||
_GRANT_RISK = {"missing": "以后建了同名的人会直接拿到这一档权限。请从授权里移除,或从候选中重新选择",
|
||
"disabled": "重新启用后他又能用这个应用。请从授权里移除"}
|
||
|
||
|
||
def pins_with_state(spec: dict[str, Any], pins: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""发布时冻结的钉住项:按库里的那一版补上级别(不信 spec 里自报的)。"""
|
||
out = []
|
||
for alias, pin in pins_by_alias(spec).items():
|
||
state = pins.get(alias)
|
||
out.append({**pin, "level": state.level if state is not None else None})
|
||
return out
|