Files
AgentLab/backend/app/apps/resolve.py
T
ThearchyHelios 5a29bcd8a6 口径试跑归入不参与「最近一次封存」的来源
加了来源没归类,test_shares 会先失败。试跑是探索运行,不该被看板当成最近一次正式运行。
2026-11-03 23:26:36 +08:00

2049 lines
121 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.
"""页面解析与视图模型 agentlab.view/1(§3.4–§3.6)。
界面层不放逻辑(P7):页面上的每个值都在这里按绑定从运行产物里取出来、渲染好,带着证据指针交给前端;前端只按组件名找
渲染器。不可见的组件不下发,遮罩值不出服务端(P4);取不到的照实放进 problems,不吞掉(P1)。
视图的数据有三种来源(A3):
- latest_sealed:钉住版本、同一 graph_hash 最近一次成功且已封存的正式运行(scheduler/auto_compare.sealed_now 判断这一次
结束时封存了)。from=schedule:<id> 时只看这个定时任务发起的。
- on_demand:按参数发起一次正式运行(来源 app_view),结果按 sha256(graph_hash, 规范化输入, 上传源的数据版本, 能力版本)
缓存。查活库的必须有时效;同一个键在进程内只运行一份;默认点「刷新」才发起,声明了 auto 且没有缓存时打开页面才自动发起;
每个应用每天有上限(设置 apps.on_demand_daily_limit)。
- instance:实例自己的那次运行。
印章(截至 / 口径 / 出具档位 / 来源)由这里算;定时任务改跑了新版本而应用还钉在旧版本这类错位照实写进 notes。
分享链接(docs/design/next-round/04-对外集成与触达.md C2,api/shares.py)是解析时的另一条旁路(resolve_page 的 share):
拿到链接、不用登录的人看到的就是使用者看到的那份遮罩视图,但只有定时出数的那部分——
- 只解析 latest_sealed 视图(fixed 模式取建分享时存下的那几次运行 id,SharedView.runs);按需、实例、个人应用的探索视图换成
写明原因的空态(SHARE_SEALED_ONLY),从不发起运行(按需视图不自动发起,因为它根本不按按需解析);
- 流程组件(入口按钮、表单、实例、进度、待办、会签、生效记录、记录列表)换成写明原因的说明块(SHARE_PLACEHOLDER),报告
组件也换成说明(正文和出处在「下载报告」里:分享页不开在线证据,那等于给匿名的人一个按运行读证据的身份);
- 印章照常算,界面上常显;页面缓存的键另加分享的标识。
"""
from __future__ import annotations
import asyncio
import hashlib
import re
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import Principal
from app.apps.pins import PinState, load_pins, needs_upgrade
from app.apps.spec import APP_ROOTS, VIEW_FORMAT
from app.apps.tree import bind_kind, page_of, resolve_form
from app.core.artifact_store import canonical_json
from app.db.base import utcnow
from app.db.models import Run, RunEvent, Schedule, ScheduleFire
from app.db.models_apps import AppViewCache, AppVersion
#: 趋势最多取几次封存运行
SERIES_MAX = 24
#: 表格最多下发几行(页面上只排序、不汇总;完整快照走证据接口)
TABLE_ROWS_MAX = 200
#: 进程内的页面缓存
PAGE_CACHE_SIZE = 256
#: 按新口径补算的运行来源(engine/origin.py)
BACKFILL_ORIGIN = "app_backfill"
#: 「最近一次封存」认哪几种来源的正式运行:latest_sealed 视图、趋势、分享链接(latest,以及建 fixed 时取的那几次)、视图
#: 数据接口、补算判断「新版本算过没有」都经 sealed_runs,这里改一处都跟上。按白名单认,以后加的来源默认不算,要算的在这里
#: 登记(tests/test_shares.py 核对 ORIGIN_KINDS 每种都归了类)。算的:定时、手动的正式运行、应用的入口和按需视图、入站
#: 钩子、直接调接口;加来源这一列之前的老运行(origin_kind 为空)也算。
SEALED_ORIGINS = ("launcher", "schedule", "app_entry", "app_view", "hook", "api")
#: 不算的,各有原因:
#: - app_backfill:按新口径补算的是现在才算的历史期,不是「最近一次」;趋势另经 trend_runs 按它对应的输入期排进去;
#: - ledger_issue:问数据的答案按已治理口径正式出具(docs/design/next-round/02-口径账本.md §5),参数是从某人的提问里
#: 绑出来的。取到它,看板、趋势、分享链接就悄悄换成了那个人问的那组数;
#: - 其余是探索运行(问数据、画布试跑、试运行、个人应用的刷新,评测、口径对账、口径召回、口径试跑),本来就不是正式运行。
NOT_SEALED_ORIGINS = (BACKFILL_ORIGIN, "ledger_issue", "chat", "canvas", "trial", "personal_app", "eval", "reconcile",
"recall", "caliber_trial")
VIEW_REFUSED = "app_view_refused"
DAILY_LIMIT = "app_view_daily_limit"
NEEDS_UPGRADE = "app_needs_upgrade"
STATUS_TEXT = {"queued": "排队中", "running": "运行中", "interrupted": "等待处理", "succeeded": "已完成",
"failed": "失败", "cancelled": "已取消"}
#: 被驳回、又说不出是哪一步时(没有节点可指)的说法
REJECTED_TEXT = "被驳回"
TIER_TEXT = {"formal": "完整出具", "degraded": "降档出具", "withheld": "不予出具"}
NO_TIER = "未声明出具契约"
#: 成果没配出具契约(印章上是「未声明出具契约」)时,本该降档的缺口照实写进 notes:不然实例页上看不出来
UNGATED_NO_DATA = "指标{names}所依据的查询区间里表里没有数据,这些 0 不代表真的是 0(成果没有配出具契约,没有按缺口降档)"
UNGATED_DUAL = "双盲复算有缺口:{gaps}(成果没有配出具契约,没有按缺口降档)"
def ungated_notes(cards: list[dict[str, Any]], dual: dict[str, Any] | None) -> list[str]:
"""没配出具契约的成果,印章上要照实写的缺口:口径卡里输入取自「查询区间里这张表没有数据」的列的指标(no_data),
双盲复算的缺口(产出的 issuance_gaps:无法复算、审核人没采用原流程……)。配了契约的这些已经按缺口降档,不另写。"""
notes: list[str] = []
names = list(dict.fromkeys(str(m.get("name") or m.get("id")) for card in cards for m in card.get("metrics") or []
if isinstance(m, dict) and m.get("no_data")))
if names:
shown = "".join(f"「{n}」" for n in names[:3]) + (f"等 {len(names)} 个" if len(names) > 3 else "")
notes.append(UNGATED_NO_DATA.format(names=shown))
gaps = [str(g) for g in (dual or {}).get("issuance_gaps") or [] if g]
if gaps:
notes.append(UNGATED_DUAL.format(gaps=";".join(gaps[:2]) + ("等" if len(gaps) > 2 else "")))
return notes
#: 蓝图试运行(docs/design/normal-user-apps.md N4):印章的来源、说明,趋势只有一个点时的说明,预览里入口按钮为什么点不了
TRIAL_FROM = "试运行"
TRIAL_NOTE = "数据是真的,但这不是正式结果:发布之后才会按设定出数"
TRIAL_SERIES = "试运行只有一次,没有趋势:发布之后每出一期数,这里多一个点"
TRIAL_ENTRY = "试运行预览里不能发起;要换一份示例再试,请回到蓝图重新试运行"
#: 钉住项还没有版本号(蓝图试运行派生的草稿):没有正式运行可看,趋势也画不出来
UNPUBLISHED_EMPTY = "还没有发布:发布之后有一次正式运行成功并封存,这里才有数"
UNPUBLISHED_SERIES = "还没有发布,没有趋势:发布之后每出一期数,这里多一个点"
#: 个人应用的视图(exploratory,N7):印章的来源,还没刷新过、最近一次没成功、写在共享应用里时写什么
EXPLORATORY_FROM = "探索 · 未经发布审核"
EXPLORATORY_EMPTY = "还没有数据:点「刷新」后按当前的图运行一次"
EXPLORATORY_FAILED = "最近一次刷新没有成功,这里是上一次成功刷新的结果"
EXPLORATORY_SHARED = "这个视图取的是探索运行,只有个人应用能这样取数:请管理应用的人改成取正式运行"
EXPLORATORY_SERIES = "每次刷新"
TIER_BY_METRICS = "(按指标)"
EMPTY_TEXT = "还没有数据"
_ACTIVE = ("queued", "running", "interrupted")
_PERIOD = re.compile(r"(period|week|month|quarter|year|date|day|期|周|月|季|年|日)", re.I)
_DIGIT = re.compile(r"\d")
#: 分享链接里(share 旁路)不是定时出数的视图、流程组件、报告写什么
SHARE_SEALED_ONLY = "分享链接只显示定时出数的视图:这一块的数要打开应用才看得到"
SHARE_PLACEHOLDER = {
"entry_button": "分享链接是只读的,不能在这里发起",
"form": "分享链接是只读的,不能在这里换参数",
"instance_list": "分享链接不列实例:要看实例请打开应用",
"instance_header": "分享链接不显示实例:要看实例请打开应用",
"instance_timeline": "分享链接不显示实例进度:要看请打开应用",
"task_list": "分享链接不列待办:待办只给派给的人,请打开应用处理",
"task_form": "分享链接不显示待办:待办只给派给的人,请打开应用处理",
"signoffs": "分享链接不显示会签进度:要看请打开应用",
"action_status": "分享链接不显示写入外部系统的记录:要看请打开应用",
"records_list": "分享链接不列登记的记录:要看请打开应用",
"report": "报告正文和每个数的出处在「下载报告」里:下载的文件离线也能点开出处",
}
SHARE_FIXED_NEW_VIEW = "这个视图是建分享之后才加的:固定在当时那几次运行的分享链接里没有它"
SHARE_FIXED_EMPTY = "建分享时这个视图还没有封存的运行"
SHARE_FIXED_GONE = "建分享时固定的那次运行已经不在了"
@dataclass(frozen=True)
class SharedView:
"""分享链接的解析方式(api/shares.py 建好交进来)。mode:latest 始终取最近一次封存;fixed 取 runs 里存的运行
({视图 id: 运行 id,建分享时还没有封存运行的是 None})。key 进页面缓存的键(分享的 id)。"""
mode: str
key: str
runs: dict[str, str | None] | None = None
class ViewRefused(Exception):
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
# --------------------------------------------------------------------------
# 来源
# --------------------------------------------------------------------------
async def latest_sealed_run(session: AsyncSession, state: PinState, origin: str | None) -> Run | None:
runs = await sealed_runs(session, state.pin.get("workflow_id"), state.pin.get("version"),
state.pin.get("graph_hash"), origin, 1)
return runs[0] if runs else None
async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, graph_hash: Any, origin: str | None,
n: int) -> list[Run]:
"""钉住版本(同一 graph_hash)最近 n 次成功且已封存的正式运行,新的在前。
sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。
来源只认 SEALED_ORIGINS(和老运行):按新口径补算的(app_backfill)、问数据按口径出具的(ledger_issue)都不是
「最近一次」,原因见 NOT_SEALED_ORIGINS。"""
from sqlalchemy import or_
from app.scheduler.auto_compare import sealed_now
if not workflow_id or not isinstance(version, int) or not graph_hash:
return []
query = select(Run).where(Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal",
Run.status == "succeeded", Run.version_hash == graph_hash,
or_(Run.origin_kind.is_(None), Run.origin_kind.in_(SEALED_ORIGINS)))
if isinstance(origin, str) and origin.startswith("schedule:"):
query = query.where(Run.schedule_id == origin[len("schedule:"):])
rows = list((await session.execute(query.order_by(Run.finished_at.desc(), Run.created_at.desc(), Run.id.desc())
.limit(max(n, 1) * 3 + 5))).scalars())
out: list[Run] = []
for run in rows:
if await sealed_now(session, run):
out.append(run)
if len(out) >= n:
break
return out
def input_key(inputs: Any) -> str:
"""一次运行的输入的指纹:补算时认「同一个输入算过没有」,趋势里认补算和正常运行是不是同一期。"""
return hashlib.sha256(canonical_json(inputs if isinstance(inputs, dict) else {}).encode()).hexdigest()
def _aware(moment: datetime | None) -> datetime | None:
if moment is None:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def period_of(run: Run) -> datetime | None:
"""这次运行对应的输入期,趋势按它排:正常运行就是它结束的时间;补算的运行是现在才算的,按它补的那一次原来的时间
(origin.period_at,补算发起时记下)。"""
if run.origin_kind == BACKFILL_ORIGIN:
raw = (run.origin or {}).get("period_at")
if isinstance(raw, str):
try:
return _aware(datetime.fromisoformat(raw))
except ValueError:
pass
return _aware(run.finished_at or run.created_at)
async def trend_runs(session: AsyncSession, app_id: str | None, workflow_id: Any, version: Any, graph_hash: Any,
origin: str | None, n: int) -> list[Run]:
"""趋势要画的那几次:同一来源最近 n 期,按输入期从早到晚。
来源本身的封存运行(sealed_runs),加上这个应用为同一来源按新口径补算的运行(app_backfill,origin.source 记着补的
是哪个来源)——补算的是新版本的点,按它补的那一期排进去(period_of)。同一个输入既有正常运行又有补算的,取正常
运行:补算只是替缺的那几期补上。"""
from app.scheduler.auto_compare import sealed_now
own = await sealed_runs(session, workflow_id, version, graph_hash, origin, n)
backfilled: list[Run] = []
if app_id and workflow_id and isinstance(version, int) and graph_hash:
rows = (await session.execute(select(Run).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.workflow_id == workflow_id,
Run.version == version, Run.version_hash == graph_hash, Run.run_class == "formal",
Run.status == "succeeded").order_by(Run.created_at.desc(), Run.id.desc()).limit(max(n, 1) * 3 + 5))).scalars()
seen = {input_key(r.input) for r in own}
for run in rows:
key = input_key(run.input)
if (run.origin or {}).get("source") != (origin or "formal") or key in seen:
continue
if await sealed_now(session, run):
seen.add(key)
backfilled.append(run)
floor = datetime.min.replace(tzinfo=timezone.utc)
merged = sorted([*own, *backfilled], key=lambda r: (period_of(r) or floor, _aware(r.created_at) or floor))
return merged[-n:] if n > 0 else []
@dataclass
class Source:
id: str
kind: str
state: str
run: Run | None = None
pin: PinState | None = None
sealed: Any = None
stamp: dict[str, Any] | None = None
message: str | None = None
origin: str | None = None
#: 蓝图试运行的预览(resolve_page 的 trial 旁路):不管视图声明的是哪种来源,都取这次试运行
trial: bool = False
_masks: Any = None
def out(self) -> dict[str, Any]:
# kind(latest_sealed / on_demand / instance):界面据此在按需视图的印章旁给「刷新」;试运行预览里一律是 trial
return {"state": self.state, "kind": "trial" if self.trial else self.kind,
"run_id": self.run.id if self.run else None,
"stamp": self.stamp, **({"message": self.message} if self.message else {})}
@property
def masks(self) -> Any:
from app.api.evidence import _Masks
if self._masks is None:
self._masks = _Masks(self.sealed)
return self._masks
# --------------------------------------------------------------------------
# 印章
# --------------------------------------------------------------------------
def _when(value: datetime | None) -> str:
if value is None:
return ""
moment = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return moment.astimezone().strftime("%Y-%m-%d %H:%M")
async def _from_text(session: AsyncSession, source: Source) -> str:
run = source.run
if source.trial:
return TRIAL_FROM
if source.kind == "exploratory":
return EXPLORATORY_FROM
if source.kind == "instance":
return "本实例"
if source.kind == "on_demand":
return "按需运行"
if run is not None and run.schedule_id:
schedule = await session.get(Schedule, run.schedule_id)
fire = (await session.execute(select(ScheduleFire).where(ScheduleFire.run_id == run.id))).scalars().first()
if fire is not None:
n = await session.scalar(select(func.count(ScheduleFire.id)).where(
ScheduleFire.schedule_id == run.schedule_id, ScheduleFire.status == "started",
ScheduleFire.created_at <= fire.created_at))
else:
n = await session.scalar(select(func.count(Run.id)).where(
Run.schedule_id == run.schedule_id, Run.created_at <= run.created_at))
name = schedule.name if schedule is not None else "已删除的定时任务"
# 由数据事件触发的(scheduler/outbox:运行来源里记着 trigger)照实写是什么事件;立即运行按示例事件发起的不算
trigger = (run.origin or {}).get("trigger") if isinstance(run.origin, dict) else None
event = isinstance(trigger, dict) and trigger.get("text") and not trigger.get("sample")
lead = f"由事件触发:{trigger['text']}," if event else ""
return f"{lead}定时任务「{name}」第 {int(n or 1)} 次"
return "正式运行"
async def stamp_of(session: AsyncSession, source: Source) -> dict[str, Any]:
"""截至(运行结束时间、输入里的统计期、数据版本)、口径(工作流名 vN · 哈希前 8 位,加口径卡的口径名和版本)、出具档位
(_issuance.tier)、来源。版本错位、已退役、没封存都照实写进 notes。"""
from app.api.evidence import _input_payloads, _issuance_of, _sealed_cards
run, sealed, pin = source.run, source.sealed, source.pin
assert run is not None and sealed is not None
parts = [f"截至 {_when(run.finished_at)}"] if run.finished_at else ["尚未结束"]
# 输入取入口节点封存的产出(补过默认值的那一份),不取运行记录上发起时交的
inputs = _input_payloads(sealed) or dict(run.input or {})
period = [f"{k} {v}" for k, v in inputs.items()
if isinstance(v, (str, int)) and not isinstance(v, bool) and _PERIOD.search(str(k))][:2]
if period:
parts.append("统计期 " + "、".join(period))
if run.data_versions:
parts.append("数据版本 " + "、".join(_version_text(v) for v in run.data_versions.values() if isinstance(v, dict)))
calibers: list[dict[str, str]] = []
cards = [card for _, _, card in _sealed_cards(sealed)]
for card in cards:
item = {"name": str(card.get("caliber") or ""), "version": str(card.get("caliber_version") or "")}
if item["name"] and item not in calibers:
calibers.append(item)
issuance = _issuance_of(sealed)
tier = issuance.get("tier") if isinstance(issuance, dict) else None
notes: list[str] = []
# 试运行跑的是还没发布的图:钉住项没有版本号,名字取运行上记的(蓝图名),版本错位一类的说明不适用
workflow_name = pin.name if pin is not None and not source.trial else run.workflow_name
if source.trial:
notes.append(TRIAL_NOTE)
elif pin is not None and pin.workflow is not None:
current = pin.workflow.published_version
if pin.retired:
notes.append(f"固定的 v{pin.pin.get('version')} 已退役,这里保留最后一次的数据;应用需要升级")
elif current and current != pin.pin.get("version"):
if run.schedule_id:
notes.append(f"定时任务已改为运行 v{current},本应用固定在 v{pin.pin.get('version')}")
else:
notes.append(f"工作流的当前发布版本是 v{current},本应用固定在 v{pin.pin.get('version')}")
if source.kind == "exploratory" and source.state == "ok" and source.message:
# 个人应用最近一次刷新没成功、这里给的是上一次的(_exploratory_source)
notes.append(source.message)
notes += [gap for entry in (run.data_versions or {}).values() if (gap := _records_gap(entry))]
if tier not in TIER_TEXT:
# 没配出具契约:双盲复算的缺口、查询区间里没有数据的指标不会降档,照实写在这里
notes += ungated_notes(cards, sealed.payload_of(sealed.nodes_of("dual_check")))
if not sealed.trusted:
notes.append("这次运行还没有封存(或封存核对不一致),数字仅供参考")
return {
"as_of": run.finished_at.isoformat() if run.finished_at else None,
"as_of_text": ";".join(parts),
"pin": f"{workflow_name} v{run.version}" if run.version else workflow_name,
"graph_hash": (run.version_hash or "")[:8],
"calibers": calibers,
"tier": tier if tier in TIER_TEXT else None,
# 纯指标看板按指标出具(issuance.metrics_only):印章上说清这一档核的是指标本身,不是没核
"tier_text": TIER_TEXT.get(str(tier), NO_TIER) + (TIER_BY_METRICS if tier in TIER_TEXT and isinstance(
issuance, dict) and issuance.get("mode") == "metrics" else ""),
"from": await _from_text(session, source),
"sealed": bool(sealed.trusted),
**({"trial": True} if source.trial else {}),
**({"notes": notes} if notes else {}),
}
def _version_text(entry: dict[str, Any]) -> str:
"""印章上一个数据源固定的版本:上传表格写快照 id 前 8 位;记录表写「截至第 N 条」(records/views.pin_entry)。"""
if entry.get("origin") == "records":
# 跨版本汇总并进来的旧版本(records/views.py)各有高水位,一并写上;数据文件不在了的没并进来,不写(缺了哪版见
# _records_gap)
older = "、".join(f"第 {o.get('version')} 版截至第 {o.get('seq') or 0} 条" for o in entry.get("older") or []
if isinstance(o, dict) and not o.get("missing"))
return f"{entry.get('name')} 截至第 {entry.get('seq') or 0} 条" + (f"(连同{older})" if older else "")
return f"{entry.get('name')} {str(entry.get('snapshot') or '')[:8]}"
def _records_gap(entry: Any) -> str | None:
"""记录表的跨版本汇总缺了哪几版(旧版本的数据文件不在了,records/views.pin_entry 记成 missing):印章上照实说数不全。"""
if not isinstance(entry, dict) or entry.get("origin") != "records":
return None
lost = [str(o.get("version")) for o in entry.get("older") or [] if isinstance(o, dict) and o.get("missing")]
if not lost:
return None
return (f"记录表「{entry.get('name')}」第 {'、'.join(lost)} 版的数据文件不在了,汇总里没有"
f"{'这一版' if len(lost) == 1 else '这几版'}的记录,数不全")
# --------------------------------------------------------------------------
# 按需视图:缓存键、单飞、每日上限
# --------------------------------------------------------------------------
_LOCKS: dict[str, asyncio.Lock] = {}
def _lock(key: str) -> Any:
"""按需视图的单飞锁(app/cluster/mutex,多进程之间也是一把):按缓存键(视图 + 参数)取,同一个键正在运行的那次接上、不另发。"""
from app.cluster import mutex
return mutex.lock(f"app-view:{key}")
def normalize_params(form: dict[str, Any] | None, params: Any) -> dict[str, Any]:
"""按参数表单收下的输入:表单里没有的键丢掉,空值丢掉(入口节点补默认值),按键排好。没有表单时原样(只去空值)。"""
if not isinstance(params, dict):
return {}
props = (form or {}).get("properties") if form else None
return {k: params[k] for k in sorted(params)
if params[k] not in (None, "") and (props is None or k in props)}
async def cache_key(session: AsyncSession, state: PinState, inputs: dict[str, Any]) -> str:
from app.capabilities.registry import capability_versions_for
from app.engine.launch import graph_tool_names
from app.engine.runner import _data_versions
from app.engine.schema import GraphSpec
from app.records.views import cache_token as records_cache_token
try:
versions = await _data_versions(session, GraphSpec.model_validate(state.graph))
except ValueError:
versions = {}
capabilities = await capability_versions_for(session, graph_tool_names(state.graph))
text = canonical_json({"graph_hash": state.hash, "input": inputs,
# 记录表按高水位(seq):有新的提交,缓存就不再算数
"data_versions": {k: (v or {}).get("snapshot") or records_cache_token(v)
for k, v in sorted(versions.items())},
"capabilities": capabilities})
return hashlib.sha256(text.encode()).hexdigest()
async def daily_limit(session: AsyncSession) -> int:
"""每个应用每天最多发起几次按需运行:设置里的 apps.on_demand_daily_limit(D-j,默认值和取值范围只在 api/settings.py
一处定,设置页改的就是它)。"""
from app.api.settings import apps_settings
return int((await apps_settings(session))["on_demand_daily_limit"])
async def _cached(session: AsyncSession, key: str) -> Run | None:
"""缓存里还算数的那次运行:没过期,而且没有失败、取消。失败的那一条删掉,下次刷新重新运行。"""
row = await session.get(AppViewCache, key)
if row is None:
return None
now = utcnow()
expires = row.expires_at if row.expires_at is None or row.expires_at.tzinfo else row.expires_at.replace(
tzinfo=timezone.utc)
run = await session.get(Run, row.run_id)
if (expires is not None and expires <= now) or run is None or run.status in ("failed", "cancelled"):
await session.delete(row)
await session.commit()
return None
return run
async def run_view(session: AsyncSession, *, app: Any, version: AppVersion, view_id: str, params: Any,
principal: Principal, actor: str | None) -> tuple[Run, bool]:
"""按需视图发起一次运行(或者命中缓存、或者接上同一个键正在运行的那次):(运行, 是否命中缓存)。"""
from app.apps.entries import FormRejected
from app.apps.forms import validate_form
from app.apps.lint import TTL_DEFAULT, TTL_MAX
from app.apps.pins import reads_live
from app.engine.launch import launch_run
spec = version.spec or {}
view = (spec.get("views") or {}).get(view_id)
source = (view or {}).get("source") or {}
if source.get("kind") != "on_demand":
raise ViewRefused(409, "这个视图不是按需运行的视图,没有刷新可做", VIEW_REFUSED)
pins = await load_pins(session, spec)
state = pins.get(source.get("pin"))
if state is None or not state.usable:
raise ViewRefused(409, "视图固定的工作流版本已退役或不可用,应用需要升级后才能刷新", NEEDS_UPGRADE)
form = resolve_form(spec, source.get("params")) if source.get("params") is not None else None
if form is not None:
if errors := validate_form(form, params if isinstance(params, dict) else {}):
raise FormRejected(errors)
inputs = normalize_params(form, params)
key = await cache_key(session, state, inputs)
# 锁按缓存键(这个视图、这组参数):管的是去重——同一个键正在运行的那次接上,不另算一遍。每日上限不靠它:发起之前在库里先占
# 一份这个应用今天的额度(app/cluster/quota,带条件的加一),几个键、几个进程同时刷新也正好到上限为止,发起不成退回
from app.cluster import quota
async with _lock(key):
run = await _cached(session, key)
if run is not None:
return run, run.status == "succeeded"
limit = await daily_limit(session)
async def used_today(s: AsyncSession) -> int:
# 「每天」按服务端所在时区的自然日算
start = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
return int(await s.scalar(select(func.count(Run.id)).where(
Run.app_id == app.id, Run.origin_kind == "app_view", Run.created_at >= start)) or 0)
day = await quota.take(f"app_view:{app.id}", limit, seed=used_today)
if day is None:
raise ViewRefused(429, f"这个应用今天的按需运行已达到上限({limit} 次),请明天再试,或请管理员调整上限",
DAILY_LIMIT)
try:
run = await launch_run(
session, workflow_id=state.pin["workflow_id"], run_class="formal", version=state.pin["version"],
expect_hash=state.pin.get("graph_hash"), input_payload=inputs, actor=actor, origin_kind="app_view",
app_id=app.id, app_version=version.version,
origin={"view": view_id, "spec_hash": version.spec_hash, "actor_verified": principal.verified,
"auth_mode": principal.mode, **_key_trace(principal)})
except BaseException:
await quota.give_back(f"app_view:{app.id}", day=day)
raise
ttl = source.get("ttl_s")
if ttl is None and await reads_live(session, state.graph):
ttl = TTL_DEFAULT
expires = utcnow() + timedelta(seconds=min(int(ttl), TTL_MAX)) if ttl else None
await session.execute(delete(AppViewCache).where(AppViewCache.key == key))
session.add(AppViewCache(key=key, app_id=app.id, view_id=view_id, run_id=run.id, expires_at=expires))
await session.commit()
return run, False
def _key_trace(principal: Any) -> dict[str, Any]:
"""用访问密钥刷新时 run.origin 另记的:via=key、哪一把、叫什么(auth/keys.trace)。"""
from app.auth.keys import trace
return trace(principal)
# --------------------------------------------------------------------------
# 视图数据 agentlab.viewdata/1(docs/design/next-round/04-对外集成与触达.md A5)
#
# 给外部系统(门户、BI)拉封存的数:按应用当前发布版解析这个视图取的是哪一次封存运行,交出印章、清单哈希和这次运行封存的
# 所有口径卡指标——原值 value、渲染好的 text、遮罩状态、证据指针。不让集成方解析页面视图模型:组件 id 和布局一改集成就坏,
# 而且那里只有渲染好的文字。数值直接取自口径卡封存的指标(叙述层无算术),口径名和版本来自口径卡;遮罩判断和页面用的是同一个
# 函数(_metric_value),遮住的 value 为 null、state 为 masked。查询表格不放进来:要的话按证据指针走证据接口或工件接口,
# 那两处已经按使用者遮过。只给正式运行(最近封存、按需两种视图);按需视图只读这组参数缓存的那次结果,不发起运行
# --------------------------------------------------------------------------
VIEWDATA_FORMAT = "agentlab.viewdata/1"
VIEW_NOT_FOUND = "app_view_not_found"
VIEWDATA_ON_DEMAND_EMPTY = "这组参数还没有算过:读视图数据不发起运行,请先刷新这个按需视图(访问密钥要有 apps.run)"
VIEWDATA_KINDS = ("latest_sealed", "on_demand")
def _ran(vid: str, kind: str, run: Run, state: PinState | None, origin: Any = None) -> Source:
"""一次已经挑出来的运行 → 来源(同 _source 的末尾:在跑、成功、没成功)。"""
if run.status in _ACTIVE:
return Source(id=vid, kind=kind, state="running", run=run, pin=state, message=RUNNING_TEXT)
source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state,
origin=origin)
if run.status != "succeeded":
source.message = f"这次运行{STATUS_TEXT.get(run.status, run.status)}"
return source
async def view_data(session: AsyncSession, app: Any, *, version: AppVersion, view_id: str, params: Any,
principal: Principal, role: str | None) -> dict[str, Any]:
"""一个视图的数据(agentlab.viewdata/1)。视图不存在 404;实例视图、个人应用的探索视图 409(只给正式运行)。"""
from app.api.evidence import _sealed_cards
spec = version.spec or {}
view = (spec.get("views") or {}).get(view_id)
if not isinstance(view, dict):
raise ViewRefused(404, f"应用里没有视图「{view_id}」", VIEW_NOT_FOUND)
src = view.get("source") or {}
kind = src.get("kind")
if kind not in VIEWDATA_KINDS:
raise ViewRefused(409, "视图数据只给取正式运行的视图(最近封存、按需运行):实例视图请按实例读", VIEW_REFUSED)
ctx = _Page(app=app, spec=spec, version=version, principal=principal, role=role, instance=None,
params=params if isinstance(params, dict) else {}, draft=False, actor=None)
ctx.pins = await load_pins(session, spec)
inputs: dict[str, Any] | None = None
if kind == "on_demand":
state = ctx.pins.get(src.get("pin"))
if state is None:
source = Source(id=view_id, kind=kind, state="error", message=f"视图「{view_id}」引用的工作流版本不在应用里")
else:
form = resolve_form(spec, src.get("params")) if src.get("params") is not None else None
inputs = normalize_params(form, ctx.params)
run = await _cached(session, await cache_key(session, state, inputs))
source = _ran(view_id, kind, run, state) if run is not None else Source(
id=view_id, kind=kind, state="empty", pin=state, message=VIEWDATA_ON_DEMAND_EMPTY)
else:
source = await _source(session, ctx, view_id)
if source.run is not None and source.run.run_class != "formal":
source = Source(id=view_id, kind=kind, state="error", message="这个视图取到的不是正式运行,视图数据只给正式运行")
await _load(session, source)
metrics: list[dict[str, Any]] = []
if source.state == "ok" and source.sealed is not None:
latest: dict[str, tuple[str, dict[str, Any]]] = {}
for node_id, artifact, card in _sealed_cards(source.sealed):
latest[node_id] = (artifact, card) # 同一张卡执行过几次(循环里)取最后一次,同页面上的绑定
labels = {str(n.get("id")): ((n.get("data") or {}).get("label") or n.get("id"))
for n in source.sealed.graph.get("nodes") or [] if isinstance(n, dict)}
for node_id, (artifact, card) in latest.items():
for item in card.get("metrics") or []:
if not isinstance(item, dict) or not item.get("id"):
continue
metric_id = str(item["id"])
metric, text, eid, held = await _metric_value(source, node_id, metric_id, None, (artifact, card))
if metric is None:
continue
value = None if held else metric.get("value")
metrics.append({
"node": node_id, "node_label": labels.get(node_id, node_id),
"caliber": str(card.get("caliber") or "") or None,
"caliber_version": str(card.get("caliber_version") or "") or None,
"id": metric_id, "name": metric.get("name") or metric_id, "unit": metric.get("unit") or "",
"value": value, "text": text,
"state": "masked" if held else ("ok" if value is not None else "missing"),
"evidence": {"run_id": source.run.id, "eid": eid, "node": node_id, "metric": metric_id}})
run = source.run
return {"format": VIEWDATA_FORMAT,
"app": {"id": app.id, "version": version.version},
"view": {"id": view_id, "kind": kind},
"state": source.state,
**({"params": inputs} if inputs is not None else {}),
"run_id": run.id if run is not None else None,
"manifest_hash": (source.sealed.manifest_hash if source.sealed is not None else None),
"stamp": source.stamp,
"metrics": metrics,
**({"message": source.message} if source.message else {})}
# --------------------------------------------------------------------------
# 解析
# --------------------------------------------------------------------------
@dataclass
class _Page:
app: Any
spec: dict[str, Any]
version: AppVersion | None
principal: Principal
role: str | None
instance: Run | None
params: dict[str, Any]
draft: bool
actor: str | None
#: 正在解析的这一页(组件之间要互相知道的:同一页上有没有绑同一条待办的会签组件)
page: dict[str, Any] = field(default_factory=dict)
#: 蓝图的试运行(trial 旁路):草稿里所有视图来源都解析到这次运行,流程组件也对着它
trial: Run | None = None
#: 分享链接(share 旁路):只解析定时出数的视图,流程组件换成说明
share: SharedView | None = None
pins: dict[str, PinState] = field(default_factory=dict)
sources: dict[str, Source] = field(default_factory=dict)
problems: list[dict[str, Any]] = field(default_factory=list)
forms: dict[str, Any] = field(default_factory=dict)
def problem(self, component: str, code: str, message: str) -> None:
self.problems.append({"component": component, "code": code, "message": message})
_PAGE_CACHE: OrderedDict[tuple[Any, ...], dict[str, Any]] = OrderedDict()
def clear_cache() -> None:
_PAGE_CACHE.clear()
async def _mask_fingerprint(session: AsyncSession) -> str:
"""遮罩设置的指纹:各数据源的遮罩列、各能力的遮罩字段。进页面缓存的键:管理员给数据源新加了遮罩列、改了能力的
遮罩之后,来源运行没换,缓存里的视图也不能再交出去(P4)。按库里现在的样子算,不靠各改动处记得清缓存——数据源、
能力登记的改动入口不止一处。"""
from app.data.engine import masked_columns
from app.db.models import Capability, DataSource
sources = sorted((name, sorted(masked_columns(options)))
for name, options in (await session.execute(select(DataSource.name, DataSource.options))).all())
caps = sorted((name, sorted(str(p) for p in masked)) for name, masked in
(await session.execute(select(Capability.name, Capability.masked))).all() if masked)
return hashlib.sha256(canonical_json({"sources": sources, "capabilities": caps}).encode()).hexdigest()
async def _backfilled(session: AsyncSession, app_id: str) -> int:
"""这个应用补算完成了几次:进页面缓存的键。补算的运行不是「最近一次」,来源取的运行不变,趋势却多了点,键得跟着变。"""
return int(await session.scalar(select(func.count(Run.id)).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.status == "succeeded")) or 0)
async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, principal: Principal, role: str | None,
instance: Run | None = None, params: dict[str, Any] | None = None, draft: bool = False,
version: AppVersion | None = None, actor: str | None = None,
trial: Run | None = None, share: SharedView | None = None) -> dict[str, Any]:
"""解析一页,返回 agentlab.view/1。draft 为真时按草稿解析:不发起任何运行,视图取钉住版本已有的封存运行。
trial 是蓝图的一次试运行(docs/design/normal-user-apps.md N4,只和 draft 一起用):草稿里所有视图来源(最近封存、
按需、本实例)都解析到这次运行,流程组件(实例进度、待办)也对着它;印章的来源写「试运行」、trial 为真、说明里写
「这不是正式结果」;趋势只有这一个点。不改 agentlab.app/1 的视图来源,只是解析时的旁路。
share 是分享链接(见模块说明,只和发布版一起用:草稿、个人应用、试运行都不能分享):只解析定时出数的视图,流程组件和
报告换成写明原因的说明,从不发起运行;out 里另给 share.runs(各视图取到的运行、有哪几份报告可下载)。
个人应用(N7)没有应用版本:草稿就是在用的那一份,不按草稿预览时也解析它(version 为空)。作者隐藏了的组件(hidden)
不下发;按草稿解析(编辑状态)时照常解析、带上 hidden,界面画成半透明。"""
import copy
from app.apps.tree import app_level, spec_hash
personal = getattr(app, "scope", "shared") == "personal"
spec = copy.deepcopy(app.draft if draft or personal else (version.spec if version is not None else {})) or {}
page = page_of(spec, page_id)
if page is None:
raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found")
trial = trial if draft else None
if share is not None and (draft or personal or version is None or instance is not None):
raise ViewRefused(409, "分享链接只解析已发布的共享应用", "personal.not_shareable" if personal else VIEW_REFUSED)
ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role,
instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial,
share=share)
ctx.pins = await load_pins(session, spec)
# 试运行的钉住项本来就没有版本(还没发布),不是「需要升级」
upgrade = needs_upgrade(ctx.pins) if trial is None else False
level = version.level if (version is not None and not draft) else app_level(
[{"level": p.level} for p in ctx.pins.values()])
wanted = _views_of(page, hidden=draft)
for vid in wanted:
ctx.sources[vid] = await _source(session, ctx, vid)
# 键:应用版本、页面、各来源取的那次运行、角色。另加各钉住项所在工作流的当前发布版和退役与否:印章上「定时任务已改为
# 运行 vN」「已退役」随它们变,运行没换也得重新算;遮罩设置的指纹同理(_mask_fingerprint)
# 个人应用没有版本号,草稿改了就生效:按草稿的指纹认
key = (app.id, "draft" if draft else (version.version if version else (spec_hash(spec) if personal else None)),
page_id,
tuple((v, s.run.id if s.run else None, s.state, s.message) for v, s in sorted(ctx.sources.items())),
tuple((a, p.workflow.published_version if p.workflow else None, p.retired)
for a, p in sorted(ctx.pins.items())),
role, principal.username, principal.role, await _mask_fingerprint(session),
await _backfilled(session, app.id), ("share", share.key) if share is not None else None)
# 分享链接里流程组件都换成了说明,页面上没有随人、随时变的东西:照样进缓存
cacheable = (not draft and instance is None and all(s.state in ("ok", "empty") for s in ctx.sources.values())
and (share is not None or not _dynamic(page)))
if cacheable and key in _PAGE_CACHE:
_PAGE_CACHE.move_to_end(key)
return copy.deepcopy(_PAGE_CACHE[key])
# 还没有数据(没有封存运行、按需视图没刷新、不在实例里)是正常的空态,不是解析出错:各块就地写明还没有数据、什么时候
# 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了
for source in ctx.sources.values():
await _load(session, source)
# 分享链接里不能发起:入口的表单不下发
for entry in (spec.get("entries") or []) if share is None else []:
form = resolve_form(spec, entry.get("form"))
if form is not None and entry.get("id"):
ctx.forms[str(entry["id"])] = form
tree = [n for c in page.get("body") or [] if (n := await _node(session, ctx, c)) is not None]
out = {
"format": VIEW_FORMAT,
"app": {"id": app.id, "name": spec.get("name") or app.name,
"version": (version.version if version is not None and not draft else (app.published_version or 0)),
"level": level, "needs_upgrade": upgrade},
"page": {"id": page.get("id"), "title": page.get("title") or ""},
"pages": [{"id": p.get("id"), "title": p.get("title") or ""} for p in spec.get("pages") or []
if isinstance(p, dict)],
"sources": {vid: s.out() for vid, s in ctx.sources.items()},
"tree": tree,
"forms": ctx.forms,
"problems": ctx.problems,
**({"trial": {"run_id": trial.id, "status": trial.status,
"status_text": STATUS_TEXT.get(trial.status, trial.status)}} if trial is not None else {}),
**({"share": {"mode": share.mode, "runs": _share_runs_out(ctx)}} if share is not None else {}),
}
if cacheable:
_PAGE_CACHE[key] = copy.deepcopy(out)
while len(_PAGE_CACHE) > PAGE_CACHE_SIZE:
_PAGE_CACHE.popitem(last=False)
return out
def _share_runs_out(ctx: _Page) -> list[dict[str, Any]]:
"""分享页上「下载报告」用:各视图取到的、成功封存的那次运行,带有哪几份没被改过的报告(节点 id)。同一次运行只列一次。"""
from app.api.evidence import _reports
out: list[dict[str, Any]] = []
seen: set[str] = set()
for vid, source in ctx.sources.items():
if source.state != "ok" or source.run is None or source.sealed is None or source.run.id in seen:
continue
seen.add(source.run.id)
reports = [r.node_id for r in _reports(source.sealed) if r.hash_ok is not False and r.doc is not None]
out.append({"view": vid, "run_id": source.run.id, "reports": reports,
"as_of_text": (source.stamp or {}).get("as_of_text")})
return out
async def share_runs(session: AsyncSession, spec: dict[str, Any], page_id: str, share: SharedView) -> dict[str, Run | None]:
"""这条分享链接现在解析得到的运行:{视图 id: 运行}(只有这一页上定时出数的视图,取不到的是 None)。下载报告只认这几次
(api/shares.py),和页面上 _source 的分享旁路同一个取法。"""
page = page_of(spec, page_id)
if page is None:
return {}
ctx = _Page(app=None, spec=spec, version=None, principal=Principal(username=None, role="user", via="share"),
role=None, instance=None, params={}, draft=False, actor=None, page=page, share=share)
ctx.pins = await load_pins(session, spec)
out: dict[str, Run | None] = {}
for vid in _views_of(page):
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") != "latest_sealed":
continue
source = await _source(session, ctx, vid)
out[vid] = source.run if source.state == "ok" else None
return out
def sealed_views(spec: dict[str, Any], page_id: str) -> list[str]:
"""这一页上取最近封存运行(定时出数)的视图:能不能建分享链接看它(一个都没有,分享出去什么都看不到)。"""
page = page_of(spec, page_id)
if page is None:
return []
return [vid for vid in _views_of(page)
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") == "latest_sealed"]
_DYNAMIC = frozenset({"instance_list", "task_list", "instance_header", "instance_timeline", "task_form", "signoffs",
"action_status", "entry_button", "records_list"})
def _dynamic(page: dict[str, Any]) -> bool:
"""页面上有随人、随时变的组件(实例列表、待办……):不进页面缓存。"""
from app.apps.tree import components
return any(c.get("type") in _DYNAMIC for c, _, _ in components({"pages": [page]}))
def _views_of(page: dict[str, Any], *, hidden: bool = False) -> list[str]:
"""这一页要解析哪些视图。hidden 为假时跳过作者隐藏了的组件(连同它里面的):只被它们绑定的视图不取运行,按需视图也
不会因为一块看不见的组件自动发起。"""
out: list[str] = []
for comp in _shown(page.get("body") or [], hidden=hidden):
for vid in ((comp.get("bind") or {}).get("view") if isinstance(comp.get("bind"), dict) else None,
comp.get("scope")):
if isinstance(vid, str) and vid not in out:
out.append(vid)
return out
def _shown(items: list[Any], *, hidden: bool) -> list[dict[str, Any]]:
"""深度优先列出组件;hidden 为假时隐藏了的组件和它里面的都不列。"""
out: list[dict[str, Any]] = []
for comp in items:
if not isinstance(comp, dict) or (comp.get("hidden") and not hidden):
continue
out.append(comp)
out += _shown(comp.get("children") or [], hidden=hidden)
return out
async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source:
"""这个视图取哪一次运行(还不读封存范围,见 _load)。"""
view = (ctx.spec.get("views") or {}).get(vid)
src = (view or {}).get("source") or {}
kind = src.get("kind")
if kind is None:
return Source(id=vid, kind="", state="error", message=f"视图「{vid}」不存在")
if ctx.trial is not None:
return await _trial_source(session, ctx, vid, kind, src)
if ctx.share is not None:
if kind != "latest_sealed":
# 分享链接只显示定时出数的视图:按需视图不发起、实例和探索视图不取
return Source(id=vid, kind=kind, state="empty", message=SHARE_SEALED_ONLY)
if ctx.share.mode == "fixed":
return await _fixed_source(session, ctx, vid, src)
state = ctx.pins.get(src.get("pin")) if kind != "instance" else None
run: Run | None = None
if kind == "instance":
run = ctx.instance
if run is None:
return Source(id=vid, kind=kind, state="empty", message="打开某个实例时才有数据")
state = next((p for p in ctx.pins.values() if p.pin.get("workflow_id") == run.workflow_id
and p.pin.get("version") == run.version), None)
elif state is None:
return Source(id=vid, kind=kind, state="error", message=f"视图「{vid}」引用的工作流版本不在应用里")
elif kind == "exploratory":
return await _exploratory_source(session, ctx, vid, state)
elif kind == "latest_sealed":
run = await latest_sealed_run(session, state, src.get("from"))
if run is None:
return Source(id=vid, kind=kind, state="empty", pin=state, origin=src.get("from"),
message=await _empty_text(session, state, src.get("from")))
else:
run = await _on_demand_run(session, ctx, vid, src, state)
if run is None:
return Source(id=vid, kind=kind, state="empty", pin=state,
message="按需运行的视图:点「刷新」后按当前参数运行一次")
if run.status in _ACTIVE:
return Source(id=vid, kind=kind, state="running", run=run, pin=state, message=await _active_text(session, run))
source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state,
origin=src.get("from"))
if run.status != "succeeded":
source.message = f"这次运行{STATUS_TEXT.get(run.status, run.status)}"
return source
async def _fixed_source(session: AsyncSession, ctx: _Page, vid: str, src: dict[str, Any]) -> Source:
"""fixed 模式的分享链接:取建分享时这个视图的那次运行(还得是成功封存的正式运行),之后封存的新运行不换上来。"""
assert ctx.share is not None
runs = ctx.share.runs or {}
state = ctx.pins.get(src.get("pin"))
if vid not in runs:
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_NEW_VIEW)
if runs[vid] is None:
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_EMPTY)
run = await session.get(Run, runs[vid])
if run is None or run.status != "succeeded" or run.run_class != "formal":
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_GONE)
return Source(id=vid, kind="latest_sealed", state="ok", run=run, pin=state, origin=src.get("from"))
async def _exploratory_source(session: AsyncSession, ctx: _Page, vid: str, state: PinState) -> Source:
"""个人应用的视图(docs/design/normal-user-apps.md N7):这个应用、这个拥有者、这张图下最近一次成功封存的刷新
(blueprints/personal.exploratory_runs)。只在个人应用里合法(应用检查报 app.view_source_personal):共享应用里写了的
照实报错、不取数。正在刷新的照实写;最近一次刷新没成功的,给上一次成功的结果并写明(印章的说明里也有)。"""
from app.blueprints.personal import exploratory_runs, latest_refresh
if getattr(ctx.app, "scope", "shared") != "personal":
return Source(id=vid, kind="exploratory", state="error", message=EXPLORATORY_SHARED)
hash_ = state.pin.get("graph_hash")
latest = await latest_refresh(session, ctx.app.id, hash_)
if latest is not None and latest.status in _ACTIVE:
return Source(id=vid, kind="exploratory", state="running", run=latest, pin=state, message=RUNNING_TEXT)
# 开了登录时刷新只能由拥有者发起,按人筛是多一道保险;关登录没有可信的身份,不按人筛
owner = ctx.app.owner if ctx.principal.mode != "off" else None
runs = await exploratory_runs(session, ctx.app.id, hash_, 1, owner)
if not runs:
if latest is not None:
return Source(id=vid, kind="exploratory", state="error", run=latest, pin=state,
message=f"最近一次刷新{STATUS_TEXT.get(latest.status, latest.status)}")
return Source(id=vid, kind="exploratory", state="empty", pin=state, message=EXPLORATORY_EMPTY)
source = Source(id=vid, kind="exploratory", state="ok", run=runs[0], pin=state)
if latest is not None and latest.id != runs[0].id and latest.status in ("failed", "cancelled"):
source.message = EXPLORATORY_FAILED
return source
async def _trial_source(session: AsyncSession, ctx: _Page, vid: str, kind: str, src: dict[str, Any]) -> Source:
"""试运行预览:这个视图声明的是哪种来源都取这次试运行。钉住项按视图引用的那个认(实例视图不引用,取第一个)。"""
run = ctx.trial
assert run is not None
state = ctx.pins.get(src.get("pin")) or next(iter(ctx.pins.values()), None)
if run.status in _ACTIVE:
return Source(id=vid, kind=kind, state="running", run=run, pin=state, trial=True,
message=await _active_text(session, run))
source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state,
trial=True)
if run.status != "succeeded":
source.message = f"这次试运行{STATUS_TEXT.get(run.status, run.status)}"
return source
#: 来源的运行还没结束时各块写什么。页面在跟着(应用页轮询、实例页轮询或接事件流),不用人去刷新
RUNNING_TEXT = "正在运行,完成后自动刷新"
async def _active_text(session: AsyncSession, run: Run) -> str:
"""来源的运行还没结束:在跑写「正在运行」;停着等人(interrupted)按在等的那一步说等什么(等待审批、等待确认发送、
等待核实写入结果……,同实例的状态 instance_state),不说「正在运行」——页头写着「等待审批」,这里不能对不上。"""
if run.status != "interrupted":
return RUNNING_TEXT
text = instance_state(run, (await _trails(session, [run]))[run.id])["text"]
return f"{text},流程走完后自动显示"
async def _empty_text(session: AsyncSession, state: PinState, origin: Any) -> str:
"""最近封存运行的视图还没有数据时说什么:还没有、什么时候会有(定时任务的下次运行时间,或者要等一次正式运行)。"""
if not isinstance(state.pin.get("version"), int):
return UNPUBLISHED_EMPTY
if isinstance(origin, str) and origin.startswith("schedule:"):
schedule = await session.get(Schedule, origin[len("schedule:"):])
if schedule is None:
return f"{EMPTY_TEXT}:取数的定时任务已删除,请管理应用的人换一个来源"
if not schedule.enabled:
return f"{EMPTY_TEXT}:定时任务「{schedule.name}」已停用,启用并运行完成一次后这里才有数"
if schedule.next_run_at is not None:
return f"{EMPTY_TEXT}:定时任务「{schedule.name}」下一次在 {_when(schedule.next_run_at)} 运行,完成并封存后这里就有数"
return f"{EMPTY_TEXT}:定时任务「{schedule.name}」运行完成并封存后这里就有数"
return f"{EMPTY_TEXT}:「{state.label}」有一次正式运行成功并封存后这里就有数"
async def _load(session: AsyncSession, source: Source) -> None:
"""读封存范围(核对清单哈希)、算印章。挑运行很便宜,这一步不便宜:页面缓存命中时不做。"""
from app.api.evidence import _sealed
if source.run is None or source.run.status in _ACTIVE:
return
source.sealed = await _sealed(source.run.id)
source.stamp = await stamp_of(session, source)
async def _on_demand_run(session: AsyncSession, ctx: _Page, vid: str, src: dict[str, Any], state: PinState) -> Run | None:
"""按需视图打开页面时:有缓存用缓存;声明了 auto、没有缓存、不是草稿预览时自动发起一次。"""
form = resolve_form(ctx.spec, src.get("params")) if src.get("params") is not None else None
inputs = normalize_params(form, ctx.params)
run = await _cached(session, await cache_key(session, state, inputs))
if run is not None or ctx.draft or not src.get("auto") or ctx.version is None or not state.usable:
return run
try:
run, _ = await run_view(session, app=ctx.app, version=ctx.version, view_id=vid, params=ctx.params,
principal=ctx.principal, actor=ctx.actor)
except Exception as e: # noqa: BLE001 - 自动发起不成(上限、参数不全)照实写进问题,页面照常给
ctx.problem(vid, "view.auto_refused", getattr(e, "message", None) or "自动发起按需运行未成功")
return None
return run
# --------------------------------------------------------------------------
# 组件
# --------------------------------------------------------------------------
async def _node(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> dict[str, Any] | None:
from app.apps.catalog import BY_NAME
if not isinstance(comp, dict):
return None
cid = str(comp.get("id"))
# 作者隐藏了的(N6)不下发,里面的也不下发;按草稿解析(编辑状态)时照常解析,带上 hidden 由界面画成半透明
if comp.get("hidden") and not ctx.draft:
return None
if comp.get("visible") and not await _visible(session, ctx, comp):
return None
if ctx.share is not None and (reason := SHARE_PLACEHOLDER.get(str(comp.get("type")))):
# 分享链接是只读的:流程组件、报告换成一块写明原因的说明(前端照说明块画),不取实例、待办、报告正文
return {"id": cid, "type": "notice", "props": {"text": reason, "tone": "info"},
"share_placeholder": comp.get("type")}
node: dict[str, Any] = {"id": cid, "type": comp.get("type"), "props": dict(comp.get("props") or {}),
**({"hidden": True} if comp.get("hidden") else {})}
entry = BY_NAME.get(str(comp.get("type")))
if entry is not None and entry["children"]:
node["children"] = [n for c in comp.get("children") or [] if (n := await _node(session, ctx, c)) is not None]
try:
data = await _data(session, ctx, comp)
except Exception as e: # noqa: BLE001 - 一个组件解析出错不拖垮整页,照实写进问题
import logging
logging.getLogger(__name__).exception("应用组件 %s 解析出错", cid)
ctx.problem(cid, "view.resolve_failed", f"解析出错:{type(e).__name__}")
data = {"state": "error"}
if data is not None:
node["data"] = data
return node
async def _visible(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> bool:
from app.engine.expressions import ExpressionError, eval_expression
scope = comp.get("scope") or ((comp.get("bind") or {}).get("view") if isinstance(comp.get("bind"), dict) else None)
source = ctx.sources.get(scope) if scope else None
try:
return bool(eval_expression(str(comp["visible"]), await _expr_ctx(session, ctx, source), roots=APP_ROOTS,
arithmetic=False))
except ExpressionError as e:
ctx.problem(str(comp.get("id")), "app.expr_invalid", f"可见条件无法求值,按不可见处理:{e}")
return False
async def _expr_ctx(session: AsyncSession, ctx: _Page, source: Source | None) -> dict[str, Any]:
from app.api.evidence import _issuance_of, _sealed_cards
run = source.run if source is not None else None
sealed = source.sealed if source is not None else None
issuance = _issuance_of(sealed) if sealed is not None else None
metrics: dict[str, dict[str, Any]] = {}
if sealed is not None:
for node_id, _, card in _sealed_cards(sealed):
metrics[node_id] = {str(m.get("id")): m.get("value") for m in card.get("metrics") or []
if isinstance(m, dict) and m.get("id")}
inst = ctx.instance
return {
"run": {"status": run.status if run else None, "tier": (issuance or {}).get("tier"),
"finished_at": run.finished_at.isoformat() if run and run.finished_at else None,
"version": run.version if run else None, "sealed": bool(sealed.trusted) if sealed else False},
"input": dict(run.input or {}) if run else {},
"metric": metrics,
"instance": {"status": inst.status, "started_by": inst.started_by,
"title": (inst.origin or {}).get("title")} if inst is not None else {},
"task": await _task_states(session, inst) if inst is not None else {},
"user": {"username": ctx.principal.username, "display_name": ctx.principal.display_name,
"role": ctx.principal.role, "groups": list(ctx.principal.groups), "app_role": ctx.role},
"view": {vid: {"state": s.state} for vid, s in ctx.sources.items()},
}
async def _task_states(session: AsyncSession, run: Run) -> dict[str, Any]:
from app.db.models_process import Task
rows = (await session.execute(select(Task.node_id, Task.status).where(Task.run_id == run.id)
.order_by(Task.created_at, Task.id))).all()
return {str(node): {"status": status} for node, status in rows if node}
async def _data(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> dict[str, Any] | None:
ctype = comp.get("type")
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else None
props = comp.get("props") or {}
cid = str(comp.get("id"))
if ctype == "entry_button":
entry = next((e for e in ctx.spec.get("entries") or [] if e.get("id") == props.get("entry")), None)
if ctx.trial is not None:
# 试运行预览:入口照常画出来(发布后长这样),发起不了——试运行的输入在蓝图上填
return {"entry": props.get("entry"), "label": props.get("label") or (entry or {}).get("label") or "",
"disabled": True, "reason": TRIAL_ENTRY}
blocked = needs_upgrade({a: p for a, p in ctx.pins.items() if entry and a == entry.get("pin")})
return {"entry": props.get("entry"), "label": props.get("label") or (entry or {}).get("label") or "",
"disabled": blocked or entry is None,
**({"reason": "固定的工作流版本已退役,应用需要升级后才能发起"} if blocked else {})}
if ctype == "form":
form = (ctx.spec.get("forms") or {}).get(props.get("form"))
if isinstance(form, dict):
ctx.forms[str(props.get("form"))] = form
return {"form": props.get("form")}
if ctype == "instance_list":
return await _instance_list(session, ctx, props)
if ctype == "records_list":
return await _records_list(session, ctx, props)
if ctype == "instance_header":
return await _instance_header(session, ctx)
if ctype == "instance_timeline":
return await _timeline(session, ctx)
if ctype == "task_list":
return await _task_list(session, ctx, props)
kind = bind_kind(bind)
if kind in ("task", "action"):
return await _process_data(session, ctx, ctype, kind, str(bind[kind]))
if bind is None:
return None
source = ctx.sources.get(str(bind.get("view")))
if source is None:
ctx.problem(cid, "app.binding_target_missing", f"绑定的视图「{bind.get('view')}」不存在")
return {"state": "error"}
if ctype == "stamp":
return {"view": source.id, "state": source.state, "stamp": source.stamp,
**({"message": source.message} if source.message else {})}
if source.state != "ok" or source.sealed is None:
return {"state": source.state if source.state != "ok" else "empty",
**({"message": source.message} if source.message else {})}
if kind == "metric":
return await _metric(ctx, source, cid, str(bind["metric"]), props.get("conv"))
if kind == "series":
return await _series(session, ctx, source, cid, str(bind["series"]), bind.get("last"))
if kind == "cell":
return await _cell(ctx, source, cid, bind["cell"], props.get("conv"))
if kind == "query":
return await _table(ctx, source, cid, bind["query"], props)
if kind == "report":
return _report(ctx, source, cid, str(bind["report"]))
if kind == "input":
return _input(ctx, source, cid, str(bind["input"]))
if kind == "run":
return _run_info(source, str(bind["run"]))
if kind == "output":
return _output(ctx, source, cid, str(bind["output"]))
return None
def _evidence(source: Source, kind: str, **extra: Any) -> dict[str, Any]:
assert source.run is not None
return {"run_id": source.run.id, "kind": kind, **{k: v for k, v in extra.items() if v is not None}}
def _card(source: Source, node_id: str) -> tuple[str, dict[str, Any]] | None:
"""这个口径卡节点最后一次执行封存的指标集:(工件, 指标集)。"""
from app.api.evidence import _sealed_cards
found = [(art, card) for n, art, card in _sealed_cards(source.sealed) if n == node_id]
return found[-1] if found else None
async def _metric_value(source: Source, node_id: str, metric_id: str, conv: Any,
found: tuple[str, dict[str, Any]] | None = None
) -> tuple[dict[str, Any] | None, str, str | None, str | None]:
"""(指标, 渲染出来的字, 证据 eid, 不给值时写的字)。found 是已经取出来的 (工件, 指标集),不给就按节点取最后一次的。"""
from app.api.evidence import MASKED, _metric_in, _metric_masks
from app.engine.evidence import MISSING, RenderError, make_eid, render_metric
if found is None:
found = _card(source, node_id)
if found is None:
return None, MISSING, None, None
artifact, card = found
metric = _metric_in(card, metric_id)
if metric is None:
return None, MISSING, None, None
_, word = await _metric_masks(metric, node_id, artifact, source.sealed, source.masks, card=card)
eid = make_eid("metric", artifact, {"metric": metric_id})
if word is not None:
return metric, word, eid, word
if metric.get("masked"):
return metric, MASKED, eid, MASKED
try:
return metric, render_metric(metric, conv if isinstance(conv, str) and conv else None), eid, None
except RenderError:
return metric, MISSING, eid, None
async def _metric(ctx: _Page, source: Source, cid: str, ref: str, conv: Any) -> dict[str, Any]:
node_id, _, metric_id = ref.partition(".")
metric, text, eid, held = await _metric_value(source, node_id, metric_id, conv)
if metric is None:
ctx.problem(cid, "app.binding_target_missing", f"这次运行封存的口径卡里没有指标「{ref}」")
return {"state": "missing", "text": text}
return {"state": "masked" if held else ("ok" if metric.get("value") is not None else "missing"), "text": text,
"name": metric.get("name") or metric_id, "unit": metric.get("unit") or "",
"evidence": _evidence(source, "metric", node=node_id, metric=metric_id, eid=eid)}
async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, ref: str, last: Any) -> dict[str, Any]:
"""趋势:同一来源最近 N 次封存运行的指标值,只排成一列、不算差值。跨钉住版本按处置来画:dual 两条线;incomparable
在升版处断开并标注;recompute 只取新版本(按历次输入补算另行发起,来源记 app_backfill)。"""
from app.api.evidence import _sealed
n = min(last, SERIES_MAX) if isinstance(last, int) and not isinstance(last, bool) and last > 0 else 12
node_id, _, metric_id = ref.partition(".")
if source.trial:
return await _trial_series(source, node_id, metric_id)
if source.kind == "exploratory":
return await _exploratory_series(session, ctx, source, node_id, metric_id, n)
pin = source.pin
if pin is None:
return {"state": "error"}
if not isinstance(pin.pin.get("version"), int):
# 钉住项还没有版本号(蓝图试运行派生的草稿按草稿预览):没有一期一期的封存运行可画,照实说,不算解析出错
return {"state": "empty", "mode": "single", "unit": "", "series": [], "breaks": [],
"message": UNPUBLISHED_SERIES}
origin = source.origin
segments: list[tuple[int, str, list[Run]]] = []
mode = "single"
previous, disposition = await _upgrade_of(session, ctx, pin.alias)
app_id = ctx.app.id if ctx.app is not None else None
if previous is not None and disposition in ("dual", "incomparable"):
mode = disposition
old_runs = await trend_runs(session, app_id, previous.get("workflow_id"), previous.get("version"),
previous.get("graph_hash"), origin, n)
segments.append((int(previous.get("version")), str(previous.get("graph_hash")), old_runs))
elif disposition == "recompute":
mode = "recompute"
# 新版本这一段带上按新口径补算的那几期(trend_runs):recompute 升版之后补算过,趋势就按新口径从头画
runs = await trend_runs(session, app_id, pin.pin.get("workflow_id"), pin.pin.get("version"),
pin.pin.get("graph_hash"), origin, n)
segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), runs))
if ctx.share is not None and ctx.share.mode == "fixed" and source.run is not None:
# 固定在建分享那一刻的分享链接:趋势也停在那一期,之后封存的不画进来
cutoff = period_of(source.run)
segments = [(v, h, [r for r in rows if cutoff is None or (period_of(r) or cutoff) <= cutoff])
for v, h, rows in segments]
series: list[dict[str, Any]] = []
unit = ""
for version, _, rows in segments:
points = []
for run in rows:
sealed = source.sealed if run.id == source.run.id else await _sealed(run.id)
point_source = Source(id=source.id, kind=source.kind, state="ok", run=run, pin=pin, sealed=sealed)
metric, text, eid, held = await _metric_value(point_source, node_id, metric_id, None)
if metric is not None:
unit = unit or str(metric.get("unit") or "")
period = period_of(run)
points.append({"run_id": run.id, "as_of": period.isoformat() if period else None,
"value": None if held or metric is None else metric.get("value"), "text": text,
**({"backfill": True} if run.origin_kind == BACKFILL_ORIGIN else {}),
"evidence": _evidence(point_source, "metric", node=node_id, metric=metric_id, eid=eid)
if metric is not None else None})
series.append({"version": version, "label": f"v{version}", "points": points})
breaks = []
if mode == "incomparable" and len(series) == 2 and series[0]["points"]:
breaks.append({"after_run": series[0]["points"][-1]["run_id"], "from": series[0]["version"],
"to": series[1]["version"],
"text": f"口径从 v{series[0]['version']} 升到 v{series[1]['version']},前后不可比"})
if mode == "recompute":
breaks.append({"from": None, "to": series[-1]["version"],
"text": f"升版到 v{series[-1]['version']} 时选择了按新口径重算:只画新版本的数"})
# 一个点都没有是空态(界面写「还没有数据点」),不是解析出错,不进 problems
return {"state": "ok", "mode": mode, "unit": unit, "series": series, "breaks": breaks}
async def _exploratory_series(session: AsyncSession, ctx: _Page, source: Source, node_id: str, metric_id: str,
n: int) -> dict[str, Any]:
"""个人应用的趋势:同一个应用、同一张图最近 n 次成功封存的刷新,按时间从早到晚排成一列(同 exploratory 视图取数的
规则)。图改过、重新存成个人应用之后从新图的第一次刷新画起,旧图的点不混进来。"""
from app.api.evidence import _sealed
from app.blueprints.personal import exploratory_runs
assert source.pin is not None
owner = ctx.app.owner if ctx.principal.mode != "off" else None
runs = list(reversed(await exploratory_runs(session, ctx.app.id, source.pin.pin.get("graph_hash"), n, owner)))
points = []
unit = ""
for run in runs:
sealed = source.sealed if source.run is not None and run.id == source.run.id else await _sealed(run.id)
point_source = Source(id=source.id, kind=source.kind, state="ok", run=run, pin=source.pin, sealed=sealed)
metric, text, eid, held = await _metric_value(point_source, node_id, metric_id, None)
if metric is not None:
unit = unit or str(metric.get("unit") or "")
period = period_of(run)
points.append({"run_id": run.id, "as_of": period.isoformat() if period else None,
"value": None if held or metric is None else metric.get("value"), "text": text,
"evidence": _evidence(point_source, "metric", node=node_id, metric=metric_id, eid=eid)
if metric is not None else None})
return {"state": "ok", "mode": "single", "unit": unit,
"series": [{"version": None, "label": EXPLORATORY_SERIES, "points": points}], "breaks": []}
async def _trial_series(source: Source, node_id: str, metric_id: str) -> dict[str, Any]:
"""试运行预览里的趋势:只有这一次试运行一个点,照实写「没有趋势」(note),不报错。"""
metric, text, eid, held = await _metric_value(source, node_id, metric_id, None)
run = source.run
assert run is not None
period = period_of(run)
point = {"run_id": run.id, "as_of": period.isoformat() if period else None,
"value": None if held or metric is None else metric.get("value"), "text": text,
"evidence": _evidence(source, "metric", node=node_id, metric=metric_id, eid=eid)
if metric is not None else None}
return {"state": "ok", "mode": "single", "unit": str((metric or {}).get("unit") or ""), "trial": True,
"series": [{"version": None, "label": TRIAL_FROM, "points": [point]}], "breaks": [], "note": TRIAL_SERIES}
async def _upgrade_of(session: AsyncSession, ctx: _Page, alias: str) -> tuple[dict[str, Any] | None, str | None]:
if ctx.version is None:
return None, None
return await pin_upgrade(session, ctx.app.id, ctx.version, alias)
async def pin_upgrade(session: AsyncSession, app_id: str, version: AppVersion,
alias: str) -> tuple[dict[str, Any] | None, str | None]:
"""这个钉住项最近一次升版:(升版前的钉住项, 当时选的处置)。从这个应用版本往回找第一次换了工作流版本的地方——之后
又发过几版(改布局、改文案)不影响趋势按那次处置来画、也不影响按新口径补算。没升过版的是 (None, None)。"""
if version.version <= 1:
return None, None
rows = list((await session.execute(select(AppVersion).where(
AppVersion.app_id == app_id, AppVersion.version <= version.version)
.order_by(AppVersion.version.desc()))).scalars())
if not rows:
return None, None
rows[0] = version
for newer, older in zip(rows, rows[1:]):
new = next((p for p in newer.pins or [] if p.get("alias") == alias), None)
old = next((p for p in older.pins or [] if p.get("alias") == alias), None)
if new is None or old is None or new.get("workflow_id") != old.get("workflow_id"):
return None, None
if new.get("version") != old.get("version"):
return old, (newer.dispositions or {}).get(alias)
return None, None
def _query_artifact(source: Source, node_id: str, exec_no: Any = None) -> str | None:
"""这个节点在封存范围内交回的查询快照:默认最后一次,exec 给了取第几次(从 1 数)。"""
found = [a for a, info in source.sealed.queries.items() if info.get("node_id") == node_id]
if not found:
return None
if isinstance(exec_no, int) and not isinstance(exec_no, bool) and 1 <= exec_no <= len(found):
return found[exec_no - 1]
return found[-1]
async def _snapshot(source: Source, artifact: str) -> tuple[dict[str, Any] | None, set[str]]:
"""查询快照和要遮的列(小写):查询当时记下的加数据源现在设的,按 SQL 认出改了名字的也遮(同证据面板)。端点快照
按列名遮(见下)。"""
from app.api.evidence import _fetch
snap, ok = _fetch(artifact)
if ok is not True or not isinstance(snap, dict) or not isinstance(snap.get("rows"), list):
return None, set()
if snap.get("capability"):
# 端点快照:按列名遮(落快照时记下的,加上能力现在的遮罩字段),证据面板用的是同一个函数
from app.capabilities.registry import snapshot_masked_columns
return snap, await snapshot_masked_columns(snap)
sets = await source.masks.sets(artifact, snap.get("source"), snap)
return snap, set(sets.hidden)
async def _cell(ctx: _Page, source: Source, cid: str, ref: Any, conv: Any) -> dict[str, Any]:
from app.api.evidence import MASKED
from app.engine.evidence import MISSING, RenderError, cell_eid, render_cell
ref = ref if isinstance(ref, dict) else {}
node_id, row, col = str(ref.get("node")), ref.get("row"), str(ref.get("col"))
artifact = _query_artifact(source, node_id, ref.get("exec"))
snap, hidden = await _snapshot(source, artifact) if artifact else (None, set())
if snap is None:
ctx.problem(cid, "app.binding_target_missing", f"这次运行没有封存「{node_id}」的查询结果")
return {"state": "missing", "text": MISSING}
columns = [str(c) for c in snap.get("columns") or []]
rows = snap["rows"]
if col not in columns or not isinstance(row, int) or not 0 <= row < len(rows):
ctx.problem(cid, "app.binding_target_missing", f"查询结果里没有第 {row} 行的「{col}」")
return {"state": "missing", "text": MISSING}
evidence = _evidence(source, "cell", node=node_id, artifact=artifact, eid=cell_eid(artifact, row, col))
if col.lower() in hidden:
return {"state": "masked", "text": MASKED, "evidence": evidence}
value = rows[row][columns.index(col)] if isinstance(rows[row], list) else None
kinds = snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}
try:
text = render_cell(value, conv if isinstance(conv, str) and conv else None, kind=kinds.get(col))
except RenderError:
text = MISSING
return {"state": "ok", "text": text, "evidence": evidence}
async def _table(ctx: _Page, source: Source, cid: str, ref: Any, props: dict[str, Any]) -> dict[str, Any]:
"""表格、图表:快照的列和行,遮罩列换成「已遮罩」;只排序、不出合计行(叙述层无算术)。"""
from app.api.evidence import MASKED
ref = ref if isinstance(ref, dict) else {}
node_id = str(ref.get("node"))
artifact = _query_artifact(source, node_id, ref.get("exec"))
snap, hidden = await _snapshot(source, artifact) if artifact else (None, set())
if snap is None:
ctx.problem(cid, "app.binding_target_missing", f"这次运行没有封存「{node_id}」的查询结果")
return {"state": "missing", "columns": [], "rows": []}
columns = [str(c) for c in snap.get("columns") or []]
wanted = [c for c in props.get("columns") or [] if c in columns] or columns
if props.get("x") or props.get("y"):
wanted = [c for c in [props.get("x"), *(props.get("y") or [])] if c in columns] or wanted
index = [columns.index(c) for c in wanted]
limit = props.get("limit") if isinstance(props.get("limit"), int) else TABLE_ROWS_MAX
rows = [[MASKED if columns[i].lower() in hidden else (r[i] if isinstance(r, list) and i < len(r) else None)
for i in index] for r in snap["rows"][:min(limit, TABLE_ROWS_MAX)]]
sort = props.get("sort")
if isinstance(sort, str) and sort.lstrip("-") in wanted:
at = wanted.index(sort.lstrip("-"))
rows.sort(key=lambda r: (r[at] is None, _sortable(r[at])), reverse=sort.startswith("-"))
kinds = snap.get("column_types") if isinstance(snap.get("column_types"), dict) else {}
return {"state": "ok", "columns": wanted, "rows": rows, "column_types": {c: kinds[c] for c in wanted if c in kinds},
"masked": [c for c in wanted if c.lower() in hidden], "total_rows": len(snap["rows"]),
"truncated": bool(snap.get("truncated")) or len(snap["rows"]) > len(rows),
"evidence": _evidence(source, "query", node=node_id, artifact=artifact)}
def _sortable(value: Any) -> tuple[int, Any]:
from app.engine.expressions import cell_value
value = cell_value(value)
if isinstance(value, (int, float)) and not isinstance(value, bool):
return (0, value)
return (1, str(value))
def _report(ctx: _Page, source: Source, cid: str, node_id: str) -> dict[str, Any]:
from app.api.evidence import _reports
report = next((r for r in _reports(source.sealed) if r.node_id == node_id), None)
if report is None:
ctx.problem(cid, "app.binding_target_missing", f"这次运行没有封存「{node_id}」写的报告")
return {"state": "missing"}
if report.hash_ok is False:
ctx.problem(cid, "view.report_tampered", "报告文档与哈希不一致,不能作为证据展示")
return {"state": "error"}
return {"state": "ok", "node": node_id, "doc_artifact": report.doc_artifact,
"evidence": _evidence(source, "report", node=node_id, artifact=report.doc_artifact)}
def _input(ctx: _Page, source: Source, cid: str, name: str) -> dict[str, Any]:
from app.api.evidence import _input_payloads
from app.engine.evidence import input_eid
inputs = _input_payloads(source.sealed) or dict(source.run.input or {})
if name not in inputs:
ctx.problem(cid, "app.binding_target_missing", f"这次运行的输入里没有「{name}」")
return {"state": "missing", "text": ""}
value = inputs[name]
return {"state": "ok", "text": value if isinstance(value, str) else canonical_json(value),
"evidence": _evidence(source, "input", eid=input_eid(name, value))}
def _run_info(source: Source, what: str) -> dict[str, Any]:
run = source.run
assert run is not None
if what == "status":
return {"state": "ok", "value": run.status, "text": STATUS_TEXT.get(run.status, run.status)}
if what == "tier":
tier = (source.stamp or {}).get("tier")
return {"state": "ok", "value": tier, "text": TIER_TEXT.get(str(tier), NO_TIER)}
return {"state": "ok", "value": run.finished_at.isoformat() if run.finished_at else None,
"text": _when(run.finished_at)}
def _output(ctx: _Page, source: Source, cid: str, name: str) -> dict[str, Any]:
"""成果字段只允许两种:逐字取自某份报告的(_evidence 里记着),或者不含数字的文本。"""
from app.api.evidence import _output_evidence
sealed = source.sealed
output = sealed.payload_of(sealed.nodes_of("output")) or {}
if name not in output or name.startswith("_"):
ctx.problem(cid, "app.binding_target_missing", f"这次运行的成果里没有「{name}」")
return {"state": "missing", "text": ""}
value = output[name]
for item in _output_evidence(sealed):
if name in (item.get("fields") or []):
return {"state": "ok", "text": value if isinstance(value, str) else canonical_json(value),
"evidence": _evidence(source, "report", node=item.get("report_node"),
artifact=item.get("doc_artifact"))}
text = value if isinstance(value, str) else canonical_json(value)
if _DIGIT.search(text):
ctx.problem(cid, "app.binding_not_evidenced", f"成果字段「{name}」里有数字,却不是逐字取自报告的文字,不展示")
return {"state": "withheld", "text": ""}
return {"state": "ok", "text": text}
# --------------------------------------------------------------------------
# 流程组件:实例、待办、动作
# --------------------------------------------------------------------------
def instance_title(run: Run) -> str:
return str((run.origin or {}).get("title") or run.workflow_name or "")
async def pending_counts(session: AsyncSession, run_ids: list[str]) -> dict[str, int]:
from app.db.models_process import Task
if not run_ids:
return {}
rows = await session.execute(select(Task.run_id, func.count(Task.id)).where(
Task.run_id.in_(run_ids), Task.status == "pending").group_by(Task.run_id))
return dict(rows.all())
#: 等人处理时,按在等的那条待办的种类说在等什么
WAITING_TEXT = {"approve": "等待审批", "tool": "等待审批", "code": "等待审批", "input": "等待补充输入",
"edit": "等待改稿", "dual_check": "等待复核", "action_confirm": "等待确认发送",
"reconcile": "等待核实写入结果", "signal": "等待外部系统"}
#: 写入外部系统的结果未知(已发出、没拿到确定的结果),等查询或人工核实
UNKNOWN_TEXT = "等待核实写入结果"
HELD_TEXT = "已挂起,等待继续运行"
@dataclass
class _Trail:
"""一次运行里说得清「办到哪了」的几样:待办(按开出的先后)、动作记录(按先后)、节点事件(按先后)。"""
tasks: list[Any] = field(default_factory=list)
actions: list[Any] = field(default_factory=list)
events: list[tuple[str, str]] = field(default_factory=list)
#: 走了「驳回」出口、而且最后一次执行就是走的驳回出口的关卡:节点 id → 那条 node.finished 的数据(outcome=rejected,
#: 带 to:转到哪几步。engine/compiler._rejected_exit)。退回修改之后又批准了的不算:结局是批准
rejected_out: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 终态事件(run.failed / run.cancelled)上记的出错、被驳回的那一步:节点 id → 名字。子工作流里的那一步(「sub/x」)
#: 不在运行的图上,名字只有这里有(runner._label_of 接好的「子工作流 › x」)
end_labels: dict[str, str] = field(default_factory=dict)
async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]:
from app.db.models_process import ActionRecord, Task
ids = [r.id for r in runs]
out = {run_id: _Trail() for run_id in ids}
if not ids:
return out
for row in (await session.execute(select(Task.run_id, Task.node_id, Task.kind, Task.status, Task.outcome)
.where(Task.run_id.in_(ids)).order_by(Task.created_at, Task.id))).all():
out[row.run_id].tasks.append(row)
for row in (await session.execute(select(ActionRecord).where(ActionRecord.run_id.in_(ids))
.order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars():
out[row.run_id].actions.append(row)
# 节点事件只有结束了的运行要看(走到了哪个成果节点、停在哪个节点上)。取消的只有被驳回的要看(停在哪一步)
wanted = [r.id for r in runs if r.status in ("succeeded", "failed")
or (r.status == "cancelled" and r.end_reason == "rejected")]
if wanted:
for run_id, etype, node_id in (await session.execute(
select(RunEvent.run_id, RunEvent.type, RunEvent.node_id).where(
RunEvent.run_id.in_(wanted), RunEvent.type.in_(("node.finished", "node.failed")))
.order_by(RunEvent.seq))).all():
if node_id:
out[run_id].events.append((etype, node_id))
await _rejected_exits(session, [r for r in runs if r.status == "succeeded"], out)
ended = [r.id for r in runs if r.status in ("failed", "cancelled")]
if ended:
for run_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_(ended), RunEvent.type.in_(("run.failed", "run.cancelled"))))).all():
if isinstance(data, dict) and data.get("node_id") and isinstance(data.get("label"), str) and data["label"]:
out[run_id].end_labels[str(data["node_id"])] = data["label"]
return out
async def end_label(session: AsyncSession, run: Run) -> str | None:
"""运行结束在哪一步的名字,照终态事件(run.failed / run.cancelled)上记的 label:子工作流里的那一步写「子工作流 › x」。
图上找不到这一步(id 是「sub/x」)的地方都用它,和实例页、通知(run_node_label)同一个说法;没记的为 None。"""
if not run.error_node_id:
return None
data = await session.scalar(select(RunEvent.data).where(
RunEvent.run_id == run.id, RunEvent.type.in_(("run.failed", "run.cancelled"))).order_by(RunEvent.seq.desc()).limit(1))
if isinstance(data, dict) and data.get("node_id") == run.error_node_id and isinstance(data.get("label"), str):
return data["label"] or None
return None
def run_node_label(run: Run, trail: _Trail, node_id: str) -> str:
"""运行里一个节点给人看的名字:运行的图上的标题;图上没有的(子工作流里的那一步「sub/x」)用终态事件上记的名字
(「子工作流 › x」);都没有的照旧写 id。实例页、通知(notify/service)说同一个名字。"""
from app.apps.tree import node_map
found = (node_map(run.graph).get(node_id) or {}).get("title")
return found or trail.end_labels.get(node_id) or node_id
async def _rejected_exits(session: AsyncSession, runs: list[Run], out: dict[str, _Trail]) -> None:
"""成功结束、途经驳回出口的运行(run.finished 带 rejected_via,engine/runner._rejected_via):每个走过驳回出口的关卡,
最后一次执行是不是走的驳回出口(退回修改之后又批准的,结局是批准)。只翻途经驳回的运行里那几个关卡的 node.finished。"""
ids = [r.id for r in runs]
if not ids:
return
gates: dict[str, set[str]] = {}
for run_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
RunEvent.run_id.in_(ids), RunEvent.type == "run.finished"))).all():
via = data.get("rejected_via") if isinstance(data, dict) else None
names = {str(v.get("node_id")) for v in via or [] if isinstance(v, dict) and v.get("node_id")}
if names:
gates[run_id] = names
for v in via or []:
# 子工作流里的关卡(「sub/x」)不在运行的图上:名字照终态事件上记的(「子工作流 › x」),不写内部 id
if isinstance(v, dict) and v.get("node_id") and isinstance(v.get("label"), str) and v["label"]:
out[run_id].end_labels.setdefault(str(v["node_id"]), v["label"])
if not gates:
return
wanted = sorted({n for names in gates.values() for n in names})
for run_id, node_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.node_id, RunEvent.data).where(
RunEvent.run_id.in_(list(gates)), RunEvent.type == "node.finished", RunEvent.node_id.in_(wanted))
.order_by(RunEvent.seq))).all():
if node_id not in gates[run_id]:
continue
if isinstance(data, dict) and data.get("outcome") == "rejected":
out[run_id].rejected_out[node_id] = data
else:
out[run_id].rejected_out.pop(node_id, None)
def rejected_exit_text(data: dict[str, Any]) -> str:
"""走驳回出口的那一步怎么说:「被驳回,转到「通知申请人」」;动作确认写「未确认(被驳回),转到…」,超时驳回写「超时驳回,
转到…」。转到哪几步取 node.finished 上记的 to(图上的名字)。"""
head = "未确认(被驳回)" if data.get("kind") == "action_confirm" and data.get("decision") == "reject" \
else "超时驳回" if data.get("decision") == "expired_reject" else "被驳回"
to = [str(t.get("label") or t.get("node_id")) for t in data.get("to") or [] if isinstance(t, dict)]
return f"{head},转到{'、'.join(f'「{t}」' for t in to)}" if to else head
def _decision(trail: _Trail, node_id: str) -> tuple[str | None, str | None]:
"""这个节点最近一条处理完的待办:(种类, 结论 approve / reject / expired_reject / submit)。"""
done = [t for t in trail.tasks if t.node_id == node_id and t.status in ("resolving", "resolved")]
if not done:
return None, None
return done[-1].kind, (done[-1].outcome or {}).get("decision")
#: 人工节点的「驳回」出口(engine/layout.py 的 approved / rejected)
REJECTED_HANDLE = "rejected"
_REJECTIONS = ("reject", "expired_reject")
def rejected_outputs(graph: Any) -> dict[str, set[str]]:
"""成果节点 → 从哪几个关卡(人工节点,以及接了「驳回」出口的调用工具、沙箱代码、双盲复算,engine/schema.GATE_TYPES)
的「驳回」出口一路走到它(中间不经过别的人工节点:再遇到人工节点,结局由那一个人的决定说了算)。按图认,不按成果
节点的标题猜。"""
from app.apps.tree import node_map
from app.engine.schema import GATE_TYPES
gate_types = {str(t) for t in GATE_TYPES}
nodes = node_map(graph)
edges = [e for e in (graph or {}).get("edges") or [] if isinstance(e, dict)]
out: dict[str, set[str]] = {}
for gate in (e for e in edges if e.get("sourceHandle") == REJECTED_HANDLE
and (nodes.get(str(e.get("source"))) or {}).get("type") in gate_types):
seen: set[str] = set()
todo = [str(gate.get("target"))]
while todo:
nid = todo.pop()
if nid in seen or nid not in nodes or nodes[nid]["type"] == "human":
continue
seen.add(nid)
if nodes[nid]["type"] == "output":
out.setdefault(nid, set()).add(str(gate["source"]))
todo += [str(e.get("target")) for e in edges if str(e.get("source")) == nid]
return out
def _rejection_text(trail: _Trail, node_id: str, title: str) -> str | None:
"""停在被人驳回的待办上时怎么说:动作确认写「「付款」未确认(被驳回)」,别的写「「x」被驳回」;不是驳回的为 None。"""
kind, decision = _decision(trail, node_id)
if kind == "action_confirm" and decision == "reject":
return f"「{title}」未确认(被驳回)"
if decision in _REJECTIONS:
return f"「{title}」被驳回"
return None
async def legacy_rejected(session: AsyncSession, run: Run) -> bool:
"""加 end_reason 之前被人驳回的运行记的是 failed(封存不改写):失败的那一步上最近一条处理完的待办是驳回。和实例页
认存量驳回(instance_state 的 failed 分支)是同一份判断;runner 据此不许它接着跑。"""
if run.status != "failed":
return False
trail = (await _trails(session, [run]))[run.id]
failed = [nid for etype, nid in trail.events if etype == "node.failed"]
return bool(failed) and _decision(trail, failed[-1])[1] in _REJECTIONS
def instance_state(run: Run, trail: _Trail) -> dict[str, str]:
"""实例给人看的状态 {code, text}:code 是界面画状态标记用的状态(运行的那几种,加 held),text 说这件事办到哪了、
结论是什么,不只是运行状态(P1)。
- 走完了:图里有几个成果节点(分支的几种结局,如「已通过」「已退回」)、恰好走到了其中一个的,写走到的那一个;只有
一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局。走到的是
否定的那种结局——这次运行里人工节点驳回了、从它的「驳回」出口一路走到的成果节点(rejected_outputs)——code 记
cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题;别的走了关卡「驳回」出口、
最后一次就是走的驳回出口的(_Trail.rejected_out),写「「x」被驳回,转到「y」」,code 同样记 cancelled;
- 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……);
- 被驳回(cancelled + end_reason=rejected,engine/context.NodeRejected):动作确认被驳回写「「付款」未确认(被驳回)」,
审批设了驳回即终止、补充输入被驳回等写「「x」被驳回」,code 记 cancelled;手动停止的写「已取消」;
- 失败:加 end_reason 之前被人驳回的运行记的是 failed(封存不改写),停在被驳回的待办上的照上一条说,和新形状说法
一样;核实为未生效、选了放弃的同理,code 记 cancelled。外部系统拒绝之类照写失败。
通知(notify/service.notify_run)的文字也取自这里:实例页和推送说同一句话。"""
from app.apps.tree import node_map
nodes = node_map(run.graph)
def title(node_id: str) -> str:
return (nodes.get(node_id) or {}).get("title") or trail.end_labels.get(node_id) or node_id
if run.status == "interrupted":
if any(a.state in ("sent", "unknown") for a in trail.actions):
return {"code": "waiting", "text": UNKNOWN_TEXT}
pending = [t for t in trail.tasks if t.status == "pending"]
if pending:
return {"code": "waiting", "text": WAITING_TEXT.get(pending[-1].kind, STATUS_TEXT["interrupted"])}
return {"code": "held", "text": HELD_TEXT}
if run.status == "succeeded":
outputs = {nid for nid, n in nodes.items() if n["type"] == "output"}
reached = [nid for etype, nid in trail.events if etype == "node.finished" and nid in outputs]
if len(outputs) > 1 and len(set(reached)) == 1:
gates = rejected_outputs(run.graph).get(reached[0], set())
# 驳回了:按待办的结论认(人工节点),或者这一步最后一次就是走的驳回出口(工具调用审批、代码执行审批没开待办)
negative = any(_decision(trail, gate)[1] in _REJECTIONS or gate in trail.rejected_out for gate in gates)
return {"code": "cancelled" if negative else "succeeded", "text": title(reached[0])}
if trail.rejected_out:
# 走了「驳回」出口(总方案 §3):运行照驳回那条路结束,照实说被驳回、转到了哪一步;是人的决定,不画成成功
node_id, data = list(trail.rejected_out.items())[-1]
return {"code": "cancelled", "text": f"「{title(node_id)}」{rejected_exit_text(data)}"}
return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]}
if run.status == "cancelled" and run.end_reason == "rejected":
failed = [nid for etype, nid in trail.events if etype == "node.failed"]
node_id = run.error_node_id or (failed[-1] if failed else None)
said = _rejection_text(trail, node_id, title(node_id)) if node_id else None
# 待办没留下(老的审批、没开出待办)时按驳回的那一步说,不退回「已取消」:这是人的决定,不是有人停了它
return {"code": "cancelled", "text": said or (f"「{title(node_id)}」被驳回" if node_id else REJECTED_TEXT)}
if run.status == "failed":
failed = [nid for etype, nid in trail.events if etype == "node.failed"]
if failed:
node_id = failed[-1]
if said := _rejection_text(trail, node_id, title(node_id)):
return {"code": "cancelled", "text": said}
record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None)
if record is not None and record.state == "failed":
if (record.resolution or {}).get("outcome") == "abandon":
return {"code": "cancelled", "text": f"「{title(node_id)}」核实为未生效,已放弃发送"}
return {"code": "failed", "text": f"「{title(node_id)}」写入没有生效"}
return {"code": "failed", "text": STATUS_TEXT["failed"]}
return {"code": run.status, "text": STATUS_TEXT.get(run.status, run.status)}
def instance_row(run: Run, pending: int = 0, state: dict[str, str] | None = None) -> dict[str, Any]:
output = run.output if isinstance(run.output, dict) else {}
issuance = output.get("_issuance") if isinstance(output.get("_issuance"), dict) else {}
state = state or {"code": run.status, "text": STATUS_TEXT.get(run.status, run.status)}
return {"run_id": run.id, "title": instance_title(run), "entry_id": (run.origin or {}).get("entry"),
"status": run.status, "status_code": state["code"], "status_text": state["text"],
"started_by": run.started_by,
"created_at": run.created_at.isoformat() if run.created_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"pending_tasks": pending, "tier": issuance.get("tier")}
async def instance_rows(session: AsyncSession, runs: list[Run]) -> list[dict[str, Any]]:
"""实例列表的那几行(列表、实例页的头):待处理的待办数和给人看的状态(instance_state)一次取齐。"""
counts = await pending_counts(session, [r.id for r in runs])
trails = await _trails(session, runs)
return [instance_row(r, counts.get(r.id, 0), instance_state(r, trails[r.id])) for r in runs]
async def list_instances(session: AsyncSession, app_id: str, *, mine: str | None, entry: str | None = None,
status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
"""应用的实例(入口、钩子发起的正式运行;按需视图的运行不算实例)。mine 给了只列这个人发起的。"""
query = select(Run).where(Run.app_id == app_id, Run.origin_kind.in_(("app_entry", "hook")))
if mine:
query = query.where(Run.started_by == mine)
if status:
query = query.where(Run.status == status)
rows = list((await session.execute(query.order_by(Run.created_at.desc(), Run.id.desc()).limit(max(1, min(limit, 200)) * 2)))
.scalars())
if entry:
rows = [r for r in rows if (r.origin or {}).get("entry") == entry]
return await instance_rows(session, rows[:max(1, min(limit, 200))])
async def _instance_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]:
from app.apps.access import at_least
who = props.get("who") if props.get("who") in ("mine", "all") else "mine"
if who == "all" and not at_least(ctx.role, "observe"):
who = "mine"
# 关登录、又没填署名时分不出谁是谁,「我发起的」就是全部(同待办箱,A4.6)
mine = (ctx.principal.username or ctx.actor) if who == "mine" else None
limit = props.get("limit") if isinstance(props.get("limit"), int) else 50
if ctx.trial is not None:
# 试运行预览:「我发起的」里就是这一次试运行
return {"who": who, "entry": props.get("entry"), "trial": True,
"items": await instance_rows(session, [ctx.trial])}
return {"who": who, "entry": props.get("entry"),
"items": [] if ctx.draft else await list_instances(session, ctx.app.id, mine=mine, entry=props.get("entry"),
limit=limit)}
async def _records_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]:
"""记录列表(docs/design/normal-user-apps.md N8):每次打开按看的人实时过滤(records/listing.py),不走封存,同实例列表。
草稿预览不列真实的记录:state=preview(不说成「还没有记录表」,形状同 RecordsPage)。"""
from app.records.listing import page
who = props.get("who") if props.get("who") in ("mine", "all") else None
limit = props.get("limit") if isinstance(props.get("limit"), int) else 50
if ctx.draft:
return {"table": props.get("table"), "version": 0, "columns": [], "items": [], "mine_only": who == "mine",
"state": "preview", "next_cursor": None}
return await page(session, ctx.app, principal=ctx.principal, role=ctx.role, table=props.get("table"), who=who,
limit=limit)
async def _instance_header(session: AsyncSession, ctx: _Page) -> dict[str, Any]:
run = ctx.instance
if run is None:
return {"state": "empty"}
stamp = next((s.stamp for s in ctx.sources.values() if s.kind == "instance" and s.stamp), None)
return {"state": "ok", **(await instance_rows(session, [run]))[0], "stamp": stamp}
#: 节点事件 → 进度里这一步的状态。人回复了(human.resolved)只说明这一步接着往下办:人工节点紧接着就结束,调用工具节点
#: 确认之后还要发送——以前把它记成「完成」,结果未知时进度上写「付款 完成」
_STEP_STATE = {"node.started": "running", "node.finished": "done", "node.failed": "failed", "node.skipped": "skipped",
"human.requested": "waiting", "human.resolved": "running"}
_STEP_TEXT = {"running": "进行中", "done": "完成", "failed": "失败", "skipped": "已跳过", "waiting": "等待处理",
"unknown": "结果未知,等待核实", "rejected": "被驳回"}
_DECISION_TEXT = {"approve": "已同意", "reject": "已驳回", "expired_reject": "超时驳回"}
async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]:
"""实例进度:服务端摘要,按节点给一行(标题、状态、给人看的说法、时间),不下发原始事件。
写入动作这一步按生效记录说(结果未知、核实为未生效);有待办的步骤按待办说(在等什么、同意还是驳回)。"""
from app.apps.tree import node_map
run = ctx.instance
if run is None:
return {"state": "empty", "steps": []}
titles = {k: v["title"] for k, v in node_map(run.graph).items()}
rows = (await session.execute(select(RunEvent.type, RunEvent.node_id, RunEvent.ts, RunEvent.data).where(
RunEvent.run_id == run.id, RunEvent.type.in_(tuple(_STEP_STATE))).order_by(RunEvent.seq))).all()
trail = (await _trails(session, [run]))[run.id]
steps: dict[str, dict[str, Any]] = {}
#: 被驳回的那一步:node.failed 上带着 reason=rejected 和结论(engine/compiler,总方案 X9)。工具调用审批、代码执行审批
#: 这类驳回没开待办,以前只能落成「失败」;老运行的 node.failed 没有 reason,照旧按待办认
rejected: dict[str, dict[str, Any]] = {}
#: 走了「驳回」出口的那一步:node.finished 带 outcome=rejected(engine/compiler._rejected_exit),节点照常结束
exits: dict[str, dict[str, Any]] = {}
for etype, node_id, ts, data in rows:
if not node_id or (node_id not in titles and "/" in node_id):
# 子工作流里的那几步(节点 id 带「子工作流节点 id/」前缀):实例进度只说这张图上的步骤,子工作流算一步
continue
step = steps.setdefault(node_id, {"node": node_id, "title": titles.get(node_id, node_id), "state": "running",
"started_at": datetime.fromtimestamp(ts, timezone.utc).isoformat()})
step["state"] = _STEP_STATE[etype]
step["at"] = datetime.fromtimestamp(ts, timezone.utc).isoformat()
if etype == "node.failed" and isinstance(data, dict) and data.get("reason") == "rejected":
rejected[node_id] = data
elif etype == "node.started":
rejected.pop(node_id, None)
exits.pop(node_id, None)
if etype == "node.finished" and isinstance(data, dict) and data.get("outcome") == "rejected":
exits[node_id] = data
for node_id, step in steps.items():
text = None
record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None)
kind, decision = _decision(trail, node_id)
pending = [t for t in trail.tasks if t.node_id == node_id and t.status == "pending"]
said = rejected.get(node_id) if step["state"] == "failed" else None
if record is not None and record.state in ("sent", "unknown"):
step["state"] = "unknown"
elif step["state"] == "done" and node_id in exits:
step["state"], text = "rejected", rejected_exit_text(exits[node_id])
elif said is not None:
step["state"] = "rejected"
text = "未确认(被驳回)" if said.get("kind") == "action_confirm" and said.get("decision") == "reject" \
else _DECISION_TEXT.get(str(said.get("decision") or ""), _STEP_TEXT["rejected"])
elif step["state"] == "waiting" and pending:
text = WAITING_TEXT.get(pending[-1].kind)
elif step["state"] == "failed" and kind == "action_confirm" and decision == "reject":
step["state"], text = "rejected", "未确认(被驳回)"
elif step["state"] in ("failed", "done") and decision in _REJECTIONS:
# 驳回了:驳回即终止的(节点失败)和从「驳回」出口接着走的(节点完成)都是人的决定,不画成成功
step["state"], text = "rejected", _DECISION_TEXT[decision]
elif step["state"] == "failed" and record is not None and (record.resolution or {}).get("outcome") == "abandon":
step["state"], text = "rejected", "核实为未生效,已放弃发送"
elif step["state"] == "done" and kind == "approve" and decision in _DECISION_TEXT:
text = _DECISION_TEXT[decision]
step["text"] = text or _STEP_TEXT[step["state"]]
state = instance_state(run, trail)
return {"state": "ok", "status": run.status, "status_code": state["code"], "status_text": state["text"],
"steps": list(steps.values())}
def _task_row(task: Any, instance: str | None = None) -> dict[str, Any]:
"""待办的摘要。instance 是所属实例的标题:同一个人工节点开出的待办标题一样,列表里靠它(和开出的时间)分开。"""
return {"id": task.id, "run_id": task.run_id, "node_id": task.node_id, "kind": task.kind, "title": task.title,
"status": task.status, "due_at": task.due_at.isoformat() if task.due_at else None,
"policy": task.policy, "escalation_level": task.escalation_level,
"created_at": task.created_at.isoformat() if task.created_at else None,
**({"instance_title": instance} if instance else {})}
async def _task_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]:
from app.db.models_process import Task, TaskAssignment
scope = props.get("scope") if props.get("scope") in ("mine", "instance") else "mine"
query = select(Task).where(Task.app_id == ctx.app.id)
if ctx.trial is not None:
# 试运行的待办不属于任何应用,都派给了发起试运行的人自己(process/tasks.py):列这次试运行的
query = select(Task).where(Task.run_id == ctx.trial.id)
elif scope == "instance":
if ctx.instance is None:
return {"scope": scope, "items": []}
query = query.where(Task.run_id == ctx.instance.id)
else:
query = query.where(Task.status == "pending")
p = ctx.principal
if p.mode != "off":
mine = select(TaskAssignment.task_id).where(TaskAssignment.status == "pending").where(
(TaskAssignment.principal_kind == "anyone")
| ((TaskAssignment.principal_kind == "user") & (TaskAssignment.principal == (p.username or "")))
| ((TaskAssignment.principal_kind == "group") & TaskAssignment.principal.in_(list(p.groups) or [""])))
query = query.where(Task.id.in_(mine))
rows = list((await session.execute(query.order_by(Task.created_at.desc(), Task.id.desc()).limit(100))).scalars())
runs = {run_id: str((origin or {}).get("title") or name or "") for run_id, origin, name in (await session.execute(
select(Run.id, Run.origin, Run.workflow_name).where(Run.id.in_({t.run_id for t in rows})))).all()}
return {"scope": scope, "items": [_task_row(t, runs.get(t.run_id)) for t in rows]}
def _assignment_row(a: Any) -> dict[str, Any]:
return {"id": a.id, "principal_kind": a.principal_kind, "principal": a.principal, "source": a.source,
"status": a.status, "responded_by": a.responded_by,
"responded_at": _aware(a.responded_at).isoformat() if a.responded_at else None,
"decision": (a.response or {}).get("decision") if isinstance(a.response, dict) else None,
"verified": bool(a.verified)}
def _action_row(record: Any, *, user: bool) -> dict[str, Any]:
"""一条生效记录给人看的样子。error 只放确定没生效的原因(失败原因);结果未知、事后核实的经过放 note:核实为已生效
之后还挂着第一次发送时的「ReadTimeout」当失败原因,看的人会以为钱没付出去。"""
resolution = record.resolution or {}
how = resolution.get("how")
who = "按幂等键查询" if how == "lookup" else f"「{resolution.get('by')}」" if resolution.get("by") else "人工"
out: dict[str, Any] = {"id": record.id, "state": record.state, "environment": record.environment,
"attempt": record.attempt, "payload_hash": (record.payload_hash or "")[:8]}
first = f"发送时没有拿到确定的结果({record.error})" if record.error else "发送时没有拿到确定的结果"
if record.state in ("sent", "unknown"):
out["note"] = f"{first},等待核实:在外部系统里可能已经生效,不会自动重发"
elif record.state == "confirmed" and how:
external = resolution.get("external_id")
out["note"] = f"{first},经{who}核实为已生效" + (f"(外部单号 {external})" if external else "")
elif record.state == "failed" and resolution.get("outcome") == "resend":
out["note"] = f"{first},经{who}核实为未生效,已重新发送"
elif record.state == "failed" and resolution.get("outcome") == "abandon":
out["note"] = f"{first},经{who}核实为未生效,已放弃发送"
elif record.state == "failed":
out["error"] = record.error or "没有生效"
# 回执是外部系统的原文:user 角色不给(P4),给哈希和状态就够核对
if not user:
out["receipt"] = record.receipt
return out
async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str, node_id: str) -> dict[str, Any]:
"""待办(task_form、signoffs)、动作(action_status)。
待办这里就判定读者是不是这条待办的处理人(process.tasks.handles):是的,界面按 id 取完整的待办画待办卡(能处理的
给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。
同一页上另有绑同一条待办的会签组件(signoffs)时,待办表单不再画会签进度(signoffs_on_page):同一页上「会签进度
0/2」不写两遍。人名给显示名(people:用户名 → 显示名),界面写显示名、悬停给用户名。"""
from app.apps.tree import components
from app.auth.directory import display_names
from app.db.models_process import ActionRecord, Task, TaskAssignment
from app.process.tasks import handles, my_response, people_of, principal_with_actor, progress
run = ctx.instance
if run is None:
return {"state": "empty"}
if kind == "task":
task = (await session.execute(select(Task).where(Task.run_id == run.id, Task.node_id == node_id)
.order_by(Task.created_at.desc(), Task.id.desc()))).scalars().first()
if task is None:
return {"state": "empty", "node": node_id}
rows = list((await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id)
.order_by(TaskAssignment.created_at, TaskAssignment.id))).scalars())
reader = principal_with_actor(ctx.principal, ctx.actor)
resolved_at = _aware(task.resolved_at).isoformat() if task.resolved_at else None
signoffs = ctype == "task_form" and any(
c.get("type") == "signoffs" and isinstance(c.get("bind"), dict) and c["bind"].get("task") == node_id
for c, _, _ in components({"pages": [ctx.page]}))
return {"state": "ok", "task": {**_task_row(task, instance_title(run)), "form": task.form,
"resolved_by": task.resolved_by, "resolved_at": resolved_at,
"outcome": {"decision": (task.outcome or {}).get("decision")}
if task.outcome else None},
"task_ui": (ctx.spec.get("task_ui") or {}).get(node_id) or {},
"handler": handles(rows, reader), "assignments": [_assignment_row(a) for a in rows],
"progress": progress(task, rows), "my_response": my_response(rows, reader),
"people": await display_names(session, people_of(task, rows)),
**({"signoffs_on_page": True} if signoffs else {}),
# 试运行的待办派给了发起试运行的人自己:界面上按钮写「以试运行身份同意 / 驳回」
**({"trial": True} if ctx.trial is not None else {})}
records = (await session.execute(select(ActionRecord).where(ActionRecord.run_id == run.id,
ActionRecord.node_id == node_id)
.order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars()
user = ctx.principal.mode != "off" and ctx.principal.role == "user"
return {"state": "ok", "node": node_id, "records": [_action_row(r, user=user) for r in records]}
def instance_page(spec: dict[str, Any]) -> str | None:
"""实例详情用哪一页:叫 instance 的页;没有的话,第一张放着实例组件或绑定实例视图的页。"""
from app.apps.tree import components
if page_of(spec, "instance") is not None:
return "instance"
instance_views = {vid for vid, v in (spec.get("views") or {}).items()
if ((v or {}).get("source") or {}).get("kind") == "instance"}
for page in spec.get("pages") or []:
for comp, _, _ in components({"pages": [page]}):
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else {}
if comp.get("type") in ("instance_header", "instance_timeline", "task_form", "signoffs", "action_status") \
or bind.get("view") in instance_views:
return page.get("id")
return None