mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:31:58 +00:00
0b6cde5c0c
一、入口按钮写成了「发起accept 报销写回应用」:派生(apps/derive)、审批模板、自由形模板都是 f"发起{名字}" 直接拼。
加 apps/derive.start_label,名字以英文字母、数字开头时中间空一格(「发起 accept 报销写回应用」),三处都用它。
二、岗位标签显示原始名 finance:派生和自由形导入的图里只写了岗位名(画布上随手写的 id),应用里岗位的显示名就填成了它,
看着像起好的名字。加 apps/tree.slot_label:按用到这个岗位的节点在图上的名字拼(派给它的写节点名,几处用到用顿号连;
超时升级给它的写「…超时升级」,同蓝图模板 slot_labels 的叫法;动作确认派给它的写「…发送前确认」);节点没起名字、
没有节点用到它的照实用岗位名。派生和蓝图模板(app_base)共用:卡片上起了名字的(审批模板的步骤名)照旧用卡片上的,
卡片上就是岗位名本身的才按节点名拼。应用编辑器的岗位一节里,显示名和岗位名一样时写一句「还没有起显示名,现在用的是图里的
岗位名」(文案在 lib/terms 的 APP_EDITOR_TEXT.slotLabelRaw),不让人误以为那是起好的名字。
测试:test_blueprint_templates.py 的 test_entry_labels_keep_a_space_before_a_latin_name、
test_slot_labels_use_the_node_names_and_say_so_when_there_is_none、test_derived_apps_use_the_same_slot_labels_and_entry_label;
test_app_spec_tools.py::test_derive_a_process_leaves_slots_empty 原来钉的是旧行为(显示名 = finance),改成节点名「财务审批」。
去掉修复回跑四条都失败。
536 lines
30 KiB
Python
536 lines
30 KiB
Python
"""应用操作(ops.apply_app_op)、应用检查(lint.lint_app 的 app.* 编号)、派生(derive.derive_app)、给助手查看的版本
|
||
详情(describe.describe_version)、应用级权限(access.role_of)。
|
||
|
||
都是确定性的:不发起运行,工作流版本直接落库。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import uuid
|
||
|
||
import pytest
|
||
|
||
from app.api.deps import Principal
|
||
from app.apps import lint as lint_mod
|
||
from app.apps.access import role_of
|
||
from app.apps.derive import derive_app
|
||
from app.apps.describe import describe_version
|
||
from app.apps.lint import lint_app
|
||
from app.apps.ops import apply_app_op, polish_problem
|
||
from app.apps.spec import empty_spec, parse_spec
|
||
from app.core.artifact_store import graph_hash
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import DataSource, Schedule, Workflow, WorkflowVersion
|
||
|
||
|
||
def node(nid, ntype, label=None, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
||
|
||
|
||
def chain(*nodes):
|
||
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
def weekly(source="shop"):
|
||
return chain(
|
||
node("start", "input", "开始", fields=[{"name": "week", "required": True, "description": "统计周"},
|
||
{"name": "region", "default": "华东"}]),
|
||
node("fetch", "tool", "取数", tool=f"db_query__{source}", args={"sql": "SELECT 1 AS gmv"}),
|
||
node("card", "metrics", "周报口径卡", caliber="周销售口径", caliber_version="v1",
|
||
metrics=[{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"}]),
|
||
node("write", "report", "写周报"),
|
||
node("out", "output", "成果", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"},
|
||
{"name": "备注", "value": "本期 {{ input.week }}"},
|
||
{"name": "说明", "value": "按支付日统计"}]),
|
||
)
|
||
|
||
|
||
def approval():
|
||
return chain(
|
||
node("start", "input", "开始", fields=[{"name": "amount", "type": "number", "required": True},
|
||
{"name": "applicant"}]),
|
||
node("check", "human", "财务审批", mode="approve", assignee={"kind": "slot", "slot": "finance"}),
|
||
node("pay", "tool", "付款", tool="erp_pay", args={}),
|
||
node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]),
|
||
)
|
||
|
||
|
||
async def make_workflow(graph, *, versions=((1, "governed"),), current=1, status="governed") -> tuple[str, dict]:
|
||
async with SessionLocal() as session:
|
||
wf = Workflow(name=f"周报-{uuid.uuid4().hex[:6]}", graph=graph, status=status, published_version=current,
|
||
version=max(v for v, _ in versions))
|
||
session.add(wf)
|
||
await session.flush()
|
||
hashes = {}
|
||
for v, level in versions:
|
||
session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=graph, graph_hash=graph_hash(graph),
|
||
level=level, published_at=utcnow() if level else None))
|
||
hashes[v] = graph_hash(graph)
|
||
await session.commit()
|
||
return wf.id, {"name": wf.name, "hash": graph_hash(graph)}
|
||
|
||
|
||
def pin(wf_id, info, version=1, alias="main", use="view"):
|
||
return {"alias": alias, "workflow_id": wf_id, "version": version, "graph_hash": info["hash"], "use": use}
|
||
|
||
|
||
def base_spec(wf_id, info, **extra):
|
||
spec = empty_spec("周报看板")
|
||
spec["pins"] = [pin(wf_id, info)]
|
||
spec["views"] = {"wk": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}}
|
||
spec["pages"] = [{"id": "home", "title": "首页", "body": []}]
|
||
spec.update(extra)
|
||
return spec
|
||
|
||
|
||
def codes(issues, level=None):
|
||
return {i["code"] for i in issues if level is None or i["level"] == level}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 应用操作
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_the_six_operations():
|
||
spec = empty_spec("看板")
|
||
assert apply_app_op(spec, {"op": "app_page", "page": {"id": "home", "title": "首页"}}).changed
|
||
assert not apply_app_op(spec, {"op": "app_page", "page": {"id": "home", "title": "首页"}}).changed
|
||
assert apply_app_op(spec, {"op": "app_page", "page": {"id": "home", "title": "本周概览"}}).changed
|
||
add = {"op": "app_add", "parent": "home", "component": {"id": "cols", "type": "columns", "props": {"count": 2}}}
|
||
assert apply_app_op(spec, add).changed
|
||
# 同 id、同内容再写一遍:不算改动、不转发(look §2.5.5 的去重)
|
||
again = apply_app_op(spec, add)
|
||
assert (again.changed, again.forward, again.problem) == (False, False, None)
|
||
conflict = apply_app_op(spec, {**add, "component": {"id": "cols", "type": "divider"}})
|
||
assert conflict.problem and "已经存在" in conflict.problem
|
||
metric = {"id": "gmv", "type": "metric", "props": {"label": "销售额"}, "bind": {"view": "wk", "metric": "kpi.gmv"}}
|
||
assert apply_app_op(spec, {"op": "app_add", "parent": "cols", "index": 0, "component": metric}).changed
|
||
# 不能放子组件的组件不能当父
|
||
assert apply_app_op(spec, {"op": "app_add", "parent": "gmv", "component": {"id": "x", "type": "divider"}}).problem
|
||
up = apply_app_op(spec, {"op": "app_update", "id": "gmv", "props": {"unit": "元", "label": None},
|
||
"visible": "run.tier == 'formal'"})
|
||
assert up.changed
|
||
comp = spec["pages"][0]["body"][0]["children"][0]
|
||
assert comp["props"] == {"unit": "元"} and comp["visible"] == "run.tier == 'formal'"
|
||
assert apply_app_op(spec, {"op": "app_move", "id": "gmv", "parent": "home", "index": 0}).changed
|
||
assert [c["id"] for c in spec["pages"][0]["body"]] == ["gmv", "cols"]
|
||
assert apply_app_op(spec, {"op": "app_move", "id": "cols", "parent": "cols"}).problem
|
||
assert apply_app_op(spec, {"op": "app_remove", "id": "cols"}).changed
|
||
assert apply_app_op(spec, {"op": "app_remove", "id": "cols"}).problem
|
||
assert apply_app_op(spec, {"op": "app_set", "path": "views", "key": "wk",
|
||
"value": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}}).changed
|
||
assert spec["views"]["wk"]["source"]["from"] == "formal"
|
||
assert apply_app_op(spec, {"op": "app_set", "path": "forms", "key": "f",
|
||
"value": {"type": "object", "properties": {"x": {"type": "string", "pattern": "a"}}}}).problem
|
||
assert apply_app_op(spec, {"op": "app_set", "path": "entries", "key": "apply",
|
||
"value": {"label": "发起", "pin": "main", "form": {"$ref": "#/forms/f"}}}).changed
|
||
assert spec["entries"][0]["id"] == "apply"
|
||
assert apply_app_op(spec, {"op": "app_set", "path": "entries", "key": "apply", "value": None}).changed
|
||
assert spec["entries"] == []
|
||
assert apply_app_op(spec, {"op": "app_set", "path": "pins", "key": "x", "value": {}}).problem
|
||
assert apply_app_op(spec, {"op": "nope"}).problem
|
||
parse_spec(spec)
|
||
|
||
|
||
def test_update_hides_and_unhides_a_component():
|
||
"""隐藏(normal-user-apps N6)走 app_update:为真才写进组件,取消隐藏去掉这一项;写别的值拒收、组件不动。
|
||
应用页上直接改和编辑器的「隐藏」开关、助手润色用的都是这一个操作(前端 edit/appOps 同一套)。"""
|
||
spec = empty_spec("看板")
|
||
apply_app_op(spec, {"op": "app_page", "page": {"id": "home", "title": "首页"}})
|
||
apply_app_op(spec, {"op": "app_add", "parent": "home", "component": {"id": "tip", "type": "notice", "props": {"text": "说明"}}})
|
||
comp = spec["pages"][0]["body"][0]
|
||
assert apply_app_op(spec, {"op": "app_update", "id": "tip", "hidden": True}).changed
|
||
assert comp["hidden"] is True
|
||
parse_spec(spec)
|
||
again = apply_app_op(spec, {"op": "app_update", "id": "tip", "hidden": True})
|
||
assert (again.changed, again.problem) == (False, None)
|
||
bad = apply_app_op(spec, {"op": "app_update", "id": "tip", "hidden": "yes"})
|
||
assert bad.problem and comp["hidden"] is True
|
||
assert apply_app_op(spec, {"op": "app_update", "id": "tip", "hidden": False}).changed
|
||
assert "hidden" not in comp
|
||
# 润色可以隐藏:只改呈现
|
||
assert polish_problem(spec, {"op": "app_update", "id": "tip", "hidden": True}) is None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 应用检查
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_a_clean_spec_has_no_errors():
|
||
wf, info = await make_workflow(weekly())
|
||
spec = base_spec(wf, info, pages=[{"id": "home", "title": "首页", "body": [
|
||
{"id": "stamp", "type": "stamp", "bind": {"view": "wk"}},
|
||
{"id": "gmv", "type": "metric", "props": {"label": "销售额"}, "bind": {"view": "wk", "metric": "card.gmv"},
|
||
"visible": "run.tier != 'withheld'"},
|
||
{"id": "rep", "type": "report", "bind": {"view": "wk", "report": "write"}},
|
||
{"id": "weekly", "type": "field", "bind": {"view": "wk", "output": "周报"}},
|
||
{"id": "how", "type": "field", "bind": {"view": "wk", "output": "说明"}},
|
||
{"id": "tbl", "type": "table", "bind": {"view": "wk", "query": {"node": "fetch"}}},
|
||
]}])
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
assert codes(issues, "error") == set(), issues
|
||
# 还没有运行:只是警告
|
||
assert codes(issues) == {"app.view_source_empty"}
|
||
|
||
|
||
async def test_lint_codes():
|
||
wf, info = await make_workflow(weekly(), versions=((1, "governed"), (2, None), (3, "published")), current=3)
|
||
spec = base_spec(wf, info)
|
||
spec["pins"] += [pin(wf, info, 2, "draft"), {**pin(wf, info, 3, "stale"), "graph_hash": "0" * 64},
|
||
pin("gone", info, 1, "gone")]
|
||
spec["entries"] = [{"id": "e1", "label": "发起", "pin": "main", "form": {"$ref": "#/forms/f"},
|
||
"map": {"region": "form.region", "ghost": "form.x", "bad": "user.password"},
|
||
"instance_title": {"pattern": "{who} 的周报", "fields": []}}]
|
||
spec["forms"] = {"f": {"type": "object", "properties": {"region": {"type": "string"}}}}
|
||
spec["hooks"] = [{"name": "kick", "kind": "start", "entry": "nope"}]
|
||
spec["notify"] = [{"channel": "ch_missing", "events": ["task.opened"]}]
|
||
spec["pages"] = [{"id": "home", "title": "首页", "body": [
|
||
{"id": "a", "type": "sparkline"},
|
||
{"id": "b", "type": "metric", "props": {"colour": "red"}, "bind": {"view": "wk", "metric": "card.gmv"}},
|
||
{"id": "c", "type": "metric", "bind": {"view": "wk", "metric": "card.nope"}},
|
||
{"id": "d", "type": "metric", "bind": {"view": "wk", "report": "write"}},
|
||
{"id": "e", "type": "field", "bind": {"view": "wk", "output": "备注"}},
|
||
{"id": "f", "type": "notice", "props": {"text": "本期 3 家门店停业"}},
|
||
{"id": "g", "type": "divider", "visible": "metric.card.gmv * 2 > 1"},
|
||
{"id": "h", "type": "divider", "visible": "vars.x == 1"},
|
||
{"id": "i", "type": "divider", "visible": "run.tier =="},
|
||
{"id": "j", "type": "metric", "bind": {"view": "nope", "metric": "card.gmv"}},
|
||
{"id": "a", "type": "divider"},
|
||
]}]
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
found = codes(issues)
|
||
for code in ("app.component_unknown", "app.props_invalid", "app.pin_unpublished", "app.pin_hash",
|
||
"app.binding_target_missing", "app.binding_not_evidenced", "app.expr_arith", "app.expr_root",
|
||
"app.expr_invalid", "app.entry_form_mismatch", "app.entry_unmapped_required", "app.hook_entry_missing",
|
||
"app.notify_channel_missing", "app.format", "app.text_number"):
|
||
assert code in found, (code, issues)
|
||
# 一个钉住项是受管级、一个是已发布级:应用按最低的算,静态文字里的数字只是警告
|
||
assert next(i for i in issues if i["code"] == "app.text_number")["level"] == "warning"
|
||
arith = next(i for i in issues if i["code"] == "app.expr_arith")
|
||
assert arith["path"] == "pages[0].body[6].visible" and "算术" in arith["message"]
|
||
assert not [i for i in issues if "钉" in i["message"]]
|
||
|
||
|
||
async def test_text_numbers_block_a_governed_app():
|
||
wf, info = await make_workflow(weekly())
|
||
spec = base_spec(wf, info, pages=[{"id": "home", "title": "首页", "body": [
|
||
{"id": "t", "type": "text", "props": {"markdown": "同比增长 12%"}}]}])
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
assert next(i for i in issues if i["code"] == "app.text_number")["level"] == "error"
|
||
|
||
|
||
async def test_retired_pins_and_upgrades_need_disposition():
|
||
wf, info = await make_workflow(weekly(), versions=((1, "governed"), (2, "governed")), current=2)
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(WorkflowVersion.__table__.select().where(
|
||
WorkflowVersion.workflow_id == wf, WorkflowVersion.version == 1))).first()
|
||
await session.execute(WorkflowVersion.__table__.update().where(WorkflowVersion.id == row.id)
|
||
.values(retired_at=utcnow()))
|
||
await session.commit()
|
||
previous = type("Prev", (), {"pins": [pin(wf, info, 1)]})()
|
||
spec = base_spec(wf, info)
|
||
async with SessionLocal() as session:
|
||
assert "app.pin_retired" in codes(await lint_app(session, spec, {}))
|
||
spec["pins"] = [pin(wf, info, 2)]
|
||
issues = await lint_app(session, spec, {}, previous=previous)
|
||
assert "app.upgrade_policy" in codes(issues)
|
||
assert next(i for i in issues if i["code"] == "app.upgrade_policy")["alias"] == "main"
|
||
ok = await lint_app(session, spec, {}, previous=previous, dispositions={"main": "incomparable"})
|
||
assert "app.upgrade_policy" not in codes(ok)
|
||
assert lint_mod.needs_disposition(previous, spec["pins"]) == [{"alias": "main", "from": 1, "to": 2}]
|
||
|
||
|
||
async def test_process_pins(monkeypatch):
|
||
from app.capabilities import registry
|
||
from app.engine import governance
|
||
from app.engine.schema import ValidationIssue
|
||
|
||
wf, info = await make_workflow(approval())
|
||
spec = base_spec(wf, info)
|
||
spec["pins"] = [pin(wf, info, use="process")]
|
||
|
||
class Cap:
|
||
effect = "write"
|
||
lookup = None
|
||
|
||
async def get_capability(session, name):
|
||
return Cap() if name == "erp_pay" else None
|
||
|
||
real = governance.publish_issues
|
||
|
||
def publish_issues(spec, **kw):
|
||
return [*real(spec, **kw), ValidationIssue(level="warning", message="子工作流里有人工节点", code="process.human_in_subgraph",
|
||
node_id="check")]
|
||
|
||
monkeypatch.setattr(registry, "get_capability", get_capability)
|
||
monkeypatch.setattr(governance, "publish_issues", publish_issues)
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
assert {"app.slot_unbound", "app.reconciler_unbound", "app.process_unsafe"} <= codes(issues, "error")
|
||
unsafe = next(i for i in issues if i["code"] == "app.process_unsafe")
|
||
assert "子工作流里有人工节点" in unsafe["message"] and unsafe["node_id"] == "check"
|
||
spec["slots"] = {"finance": {"label": "财务审批"}}
|
||
bound = {"slots": {"finance": [{"kind": "group", "name": "财务"}], "reconciler": [{"kind": "user", "name": "a"}]}}
|
||
issues = await lint_app(session, spec, bound)
|
||
assert not {"app.slot_unbound", "app.reconciler_unbound"} & codes(issues)
|
||
|
||
|
||
async def test_on_demand_views_on_live_sources_need_a_ttl(tmp_path):
|
||
name = f"live_{uuid.uuid4().hex[:6]}"
|
||
async with SessionLocal() as session:
|
||
session.add(DataSource(name=name, kind="sqlite", database=str(tmp_path / "x.db"), enabled=True))
|
||
await session.commit()
|
||
wf, info = await make_workflow(weekly(name))
|
||
spec = base_spec(wf, info, views={"od": {"source": {"kind": "on_demand", "pin": "main"}}})
|
||
async with SessionLocal() as session:
|
||
assert "app.on_demand_ttl" in codes(await lint_app(session, spec, {}), "error")
|
||
spec["views"]["od"]["source"]["ttl_s"] = 600
|
||
assert "app.on_demand_ttl" not in codes(await lint_app(session, spec, {}))
|
||
spec["views"]["od"]["source"]["ttl_s"] = 100_000
|
||
assert "app.on_demand_ttl" in codes(await lint_app(session, spec, {}))
|
||
|
||
|
||
async def test_a_bad_shape_is_app_format():
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, {"format": "agentlab.app/1", "name": "x", "pins": [{"alias": "m"}]}, {})
|
||
assert codes(issues) == {"app.format"} and issues[0]["path"].startswith("pins")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 派生
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_derive_a_dashboard_prefers_the_schedule():
|
||
wf, info = await make_workflow(weekly())
|
||
async with SessionLocal() as session:
|
||
sch = Schedule(name="周一", workflow_id=wf, cron="0 9 * * 1", timezone="Asia/Shanghai", run_class="formal")
|
||
session.add(sch)
|
||
await session.commit()
|
||
spec = await derive_app(session, wf, 1)
|
||
assert spec["format"] == "agentlab.app/1" and spec["pins"][0]["use"] == "view"
|
||
assert spec["pins"][0]["graph_hash"] == info["hash"] and spec["pins"][0]["level"] == "governed"
|
||
assert spec["views"]["latest"]["source"] == {"kind": "latest_sealed", "pin": "main", "from": f"schedule:{sch.id}",
|
||
"params": None, "ttl_s": None, "auto": False}
|
||
home = spec["pages"][0]
|
||
types = [c["type"] for c in home["body"]]
|
||
assert types[:4] == ["stamp", "section", "report", "table"]
|
||
metric = home["body"][1]["children"][0]["children"][0]
|
||
assert metric["bind"] == {"view": "latest", "metric": "card.gmv"} and metric["props"]["label"] == "销售额"
|
||
assert spec["forms"]["start"]["required"] == ["week"]
|
||
assert spec["entries"][0]["map"] == {"week": "form.week", "region": "form.region"}
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
assert codes(issues, "error") == set(), issues
|
||
|
||
|
||
async def test_derive_a_process_leaves_slots_empty(monkeypatch):
|
||
from app.capabilities import registry
|
||
|
||
class Cap:
|
||
effect = "write"
|
||
lookup = None
|
||
|
||
async def get_capability(session, name):
|
||
return Cap() if name == "erp_pay" else None
|
||
|
||
monkeypatch.setattr(registry, "get_capability", get_capability)
|
||
wf, info = await make_workflow(approval())
|
||
async with SessionLocal() as session:
|
||
spec = await derive_app(session, wf, 1, name="报销审批")
|
||
assert spec["name"] == "报销审批" and spec["pins"][0]["use"] == "process"
|
||
# 岗位的显示名按用到它的节点名起(apps/tree.slot_label),不再拿岗位名 finance 充当显示名
|
||
assert spec["slots"] == {"finance": {"label": "财务审批"}, "reconciler": {"label": "动作核实"}}
|
||
# 流程的首页只放发起和我发起的:结果在实例页看,不派生「最近封存运行」(派生出来只会挂一块永远空着的印章)
|
||
assert "latest" not in spec["views"] and spec["views"]["inst"]["source"]["kind"] == "instance"
|
||
home = next(p for p in spec["pages"] if p["id"] == "home")
|
||
assert [c["type"] for c in home["body"]] == ["entry_button", "instance_list"]
|
||
inst = next(p for p in spec["pages"] if p["id"] == "instance")
|
||
assert {"task_form", "signoffs", "action_status", "instance_header"} <= {c["type"] for c in inst["body"]}
|
||
assert spec["task_ui"] == {"check": {"title": "财务审批"}}
|
||
async with SessionLocal() as session:
|
||
issues = await lint_app(session, spec, {})
|
||
# 岗位留空等人绑定:检查拦着
|
||
assert {"app.slot_unbound", "app.reconciler_unbound"} <= codes(issues, "error")
|
||
|
||
|
||
async def test_an_empty_dashboard_says_when_data_will_come():
|
||
"""看板还没有封存运行:不是解析出错,写明还没有数据、什么时候会有(定时任务下次运行的时间,或者要等一次正式运行)。"""
|
||
from datetime import datetime, timezone
|
||
|
||
from app.apps.pins import load_pins
|
||
from app.apps.resolve import _empty_text
|
||
|
||
wf, info = await make_workflow(weekly())
|
||
async with SessionLocal() as session:
|
||
on = Schedule(name="周一早上", workflow_id=wf, cron="0 9 * * 1", timezone="Asia/Shanghai", run_class="formal",
|
||
next_run_at=datetime(2026, 10, 19, 1, 0, tzinfo=timezone.utc))
|
||
paused = Schedule(name="停着的", workflow_id=wf, cron="0 9 * * 1", timezone="Asia/Shanghai",
|
||
run_class="formal", enabled=False)
|
||
session.add_all([on, paused])
|
||
await session.commit()
|
||
state = (await load_pins(session, base_spec(wf, info)))["main"]
|
||
assert "定时任务「周一早上」下一次在 2026-10-19" in await _empty_text(session, state, f"schedule:{on.id}")
|
||
assert "定时任务「停着的」已停用" in await _empty_text(session, state, f"schedule:{paused.id}")
|
||
assert "已删除" in await _empty_text(session, state, "schedule:gone")
|
||
assert (await _empty_text(session, state, "formal")).endswith(f"「{info['name']} v1」有一次正式运行成功并封存后这里就有数")
|
||
|
||
|
||
def test_slot_refs_cover_every_place_a_graph_names_a_slot():
|
||
"""派给谁、超时升级给谁、动作确认派给谁,加上节点上没写、取图级 defaults 的:派生和检查认的是同一份。调用工具节点只有
|
||
写入的(会开发送确认卡)才算用到岗位:只读的写了派给谁、或者从 defaults 继承来的,都不算。"""
|
||
from app.apps.tree import slot_refs
|
||
|
||
graph = chain(
|
||
node("a", "human", "财务会签", assignee={"kind": "slot", "slot": " finance "}, due="PT1H",
|
||
on_timeout={"action": "escalate", "to_slot": "director"}),
|
||
node("b", "human", "复核"),
|
||
node("pay", "tool", "付款", tool="erp_pay", assignee={"kind": "slot", "slot": "cashier"}),
|
||
node("fetch", "tool", "取数", tool="erp_orders", assignee={"kind": "slot", "slot": "clerk"}),
|
||
node("sync", "tool", "同步", tool="erp_sync"),
|
||
node("c", "human", "补材料", assignee={"kind": "initiator"}, due="PT1H",
|
||
on_timeout={"action": "remind", "to_slot": "不算"}),
|
||
)
|
||
graph["defaults"] = {"assignee": {"kind": "slot", "slot": "auditor"}}
|
||
assert [(r["slot"], r["node_id"], r["role"]) for r in slot_refs(graph, {"pay", "sync"})] == [
|
||
("finance", "a", "assignee"), ("director", "a", "escalation"), ("auditor", "b", "assignee"),
|
||
("cashier", "pay", "confirm"), ("auditor", "sync", "confirm")]
|
||
# 没有写入节点:调用工具节点一个都不算
|
||
assert [r["node_id"] for r in slot_refs(graph)] == ["a", "a", "b"]
|
||
|
||
|
||
async def test_read_only_tool_nodes_do_not_use_slots_and_writes_follow_the_runtime_rule(monkeypatch):
|
||
"""只读的调用工具节点从不开确认卡:它写的派给谁(或图级默认的)不派生岗位、检查也不说「发送确认派给它」。哪些是写入
|
||
和运行时同一个认法(writes_externally):绑定端点、视参数而定的也是写入,要动作状态、要人核实。"""
|
||
from types import SimpleNamespace
|
||
|
||
from app.capabilities import registry
|
||
|
||
caps = {"erp_pay": SimpleNamespace(effect="write", endpoint_id=None, lookup="erp_lookup"),
|
||
"erp_orders": SimpleNamespace(effect="read", endpoint_id="ep-1", lookup=None),
|
||
"erp_sync": SimpleNamespace(effect="dynamic", endpoint_id="ep-1", lookup=None)}
|
||
|
||
async def get_capability(session, name):
|
||
return caps.get(name)
|
||
|
||
monkeypatch.setattr(registry, "get_capability", get_capability)
|
||
graph = chain(
|
||
node("start", "input", "开始", fields=[{"name": "amount", "type": "number", "required": True}]),
|
||
node("fetch", "tool", "取数", tool="erp_orders", args={}, assignee={"kind": "slot", "slot": "clerk"}),
|
||
node("check", "human", "财务审批", mode="approve", assignee={"kind": "slot", "slot": "finance"}),
|
||
node("pay", "tool", "付款", tool="erp_pay", args={}),
|
||
node("sync", "tool", "同步", tool="erp_sync", args={}),
|
||
node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]),
|
||
)
|
||
graph["defaults"] = {"assignee": {"kind": "slot", "slot": "cashier"}}
|
||
wf, _ = await make_workflow(graph)
|
||
async with SessionLocal() as session:
|
||
spec = await derive_app(session, wf, 1)
|
||
assert set(spec["slots"]) == {"finance", "cashier", "reconciler"}
|
||
inst = next(p for p in spec["pages"] if p["id"] == "instance")
|
||
assert [c["bind"]["action"] for c in inst["body"] if c["type"] == "action_status"] == ["pay", "sync"]
|
||
issues = await lint_app(session, spec, {"slots": {"finance": [{"kind": "user", "name": "u"}]}})
|
||
unbound = [i for i in issues if i["code"] == "app.slot_unbound"]
|
||
cashier = next(i for i in unbound if i["path"] == "slots.cashier")
|
||
assert "调用工具节点「付款」的发送确认派给它" in cashier["message"] and "「同步」" in cashier["message"]
|
||
assert not any("取数" in i["message"] for i in issues), [i["message"] for i in issues]
|
||
[reconcile] = [i for i in issues if i["code"] == "app.reconciler_unbound"]
|
||
assert reconcile["node_id"] == "sync" and "「同步」会写外部系统" in reconcile["message"]
|
||
|
||
|
||
async def test_escalation_and_confirm_slots_are_derived_and_must_be_bound(monkeypatch):
|
||
"""超时升级的岗位、动作确认的岗位以前既不派生也不检查:发布后升级、确认悄悄派给了任何人。"""
|
||
from app.capabilities import registry
|
||
|
||
class Cap:
|
||
effect = "write"
|
||
lookup = "erp_lookup"
|
||
|
||
async def get_capability(session, name):
|
||
return Cap() if name == "erp_pay" else None
|
||
|
||
monkeypatch.setattr(registry, "get_capability", get_capability)
|
||
graph = approval()
|
||
graph["nodes"][1]["data"]["config"].update(due="PT1H", on_timeout={"action": "escalate", "to_slot": "director"})
|
||
graph["nodes"][2]["data"]["config"]["assignee"] = {"kind": "slot", "slot": "cashier"}
|
||
wf, info = await make_workflow(graph)
|
||
async with SessionLocal() as session:
|
||
spec = await derive_app(session, wf, 1)
|
||
assert set(spec["slots"]) == {"finance", "director", "cashier"}
|
||
everyone = {"slots": {s: [{"kind": "user", "name": "u"}] for s in ("finance", "director", "cashier")}}
|
||
assert not codes(await lint_app(session, spec, everyone)) & {"app.slot_unbound"}
|
||
|
||
# 绑了两个、升级给的总监没绑:报错,说清是哪一步要它
|
||
issues = await lint_app(session, spec, {"slots": {k: v for k, v in everyone["slots"].items() if k != "director"}})
|
||
[unbound] = [i for i in issues if i["code"] == "app.slot_unbound"]
|
||
assert unbound["level"] == "error" and unbound["path"] == "slots.director"
|
||
assert "人工节点「财务审批」超时升级给它" in unbound["message"]
|
||
|
||
# 应用里根本没声明:按引用它的那一步报,落到节点上
|
||
del spec["slots"]["cashier"]
|
||
issues = await lint_app(session, spec, everyone)
|
||
undeclared = next(i for i in issues if i["code"] == "app.slot_unbound" and i.get("node_id") == "pay")
|
||
assert "调用工具节点「付款」的发送确认派给岗位「cashier」" in undeclared["message"]
|
||
|
||
|
||
async def test_describe_a_version_has_no_secrets():
|
||
graph = copy.deepcopy(weekly())
|
||
graph["nodes"][1]["data"]["config"]["args"] = {"sql": "SELECT * FROM t WHERE phone = '19912345678'"}
|
||
wf, _ = await make_workflow(graph)
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(WorkflowVersion.__table__.select().where(WorkflowVersion.workflow_id == wf))).first()
|
||
text = describe_version(row, workflow_name="周报")
|
||
assert "工作流「周报」v1(受管级" in text and "card.gmv 销售额(元)" in text and "报告撰写「写周报」" in text
|
||
assert "week(string,必填):统计周" in text
|
||
assert "19912345678" not in text and "SELECT" not in text
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 权限
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class _App:
|
||
created_by = "alice"
|
||
|
||
|
||
class _Grant:
|
||
def __init__(self, kind, who, role):
|
||
self.principal_kind, self.principal, self.role = kind, who, role
|
||
|
||
|
||
@pytest.mark.parametrize("principal, grants, want", [
|
||
(Principal(username=None, mode="off"), [], "manage"),
|
||
(Principal(username="root", role="admin", mode="local", verified=True), [], "manage"),
|
||
(Principal(username="alice", role="builder", mode="local", verified=True), [], "manage"),
|
||
(Principal(username="bob", role="builder", mode="local", verified=True), [], "observe"),
|
||
(Principal(username="bob", role="builder", mode="local", verified=True), [_Grant("user", "bob", "manage")], "manage"),
|
||
(Principal(username="carol", role="user", mode="local", verified=True), [], None),
|
||
(Principal(username="carol", role="user", mode="local", verified=True, groups=("财务",)),
|
||
[_Grant("group", "财务", "observe"), _Grant("everyone", "", "use")], "observe"),
|
||
(Principal(username="dan", role="user", mode="oidc", verified=True), [_Grant("everyone", "", "use")], "use"),
|
||
])
|
||
def test_app_roles(principal, grants, want):
|
||
assert role_of(principal, _App(), grants) == want
|
||
|
||
|
||
async def test_text_numbers_from_the_pinned_process_have_a_source():
|
||
"""流程规则的数(金额线、时限)出处是固定版本里的节点配置:对得上就不报;流程里没有的规则写在说明里照样报——那句话是假的。"""
|
||
graph = approval()
|
||
graph["nodes"][1]["data"]["config"].update(due="PT48H", condition="input.amount > 5000")
|
||
wf, info = await make_workflow(graph)
|
||
text = lambda md: base_spec(wf, info, pages=[{"id": "home", "title": "首页", "body": [ # noqa: E731
|
||
{"id": "t", "type": "text", "props": {"markdown": md}}]}])
|
||
async with SessionLocal() as session:
|
||
ok = await lint_app(session, text("金额超过 5,000 元由财务审批,48 小时内处理"), {})
|
||
bad = await lint_app(session, text("金额超过 8000 元需要总监会签,48 小时内处理"), {})
|
||
assert "app.text_number" not in codes(ok)
|
||
issue = next(i for i in bad if i["code"] == "app.text_number")
|
||
# 只列对不上的那个数,48 小时在节点配置里写着
|
||
assert issue["level"] == "error" and "「8000」" in issue["message"] and "48" not in issue["message"]
|