mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:02:00 +00:00
2027 lines
119 KiB
Python
2027 lines
119 KiB
Python
"""页面解析与视图模型 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")
|
||
|
||
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 = "未声明出具契约"
|
||
#: 蓝图试运行(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]] = []
|
||
for _, _, card in _sealed_cards(sealed):
|
||
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 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
|