diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 98221793..feca296c 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -302,18 +302,22 @@ async def assistant_conversation(app_id: str, session: AsyncSession = Depends(ge lookup = (select(Conversation).where(Conversation.kind == "app", Conversation.app_id == app.id, Conversation.archived.is_(False), mine) .order_by(Conversation.last_active_at.desc()).limit(1)) - # 取或建在进程里按 (应用, 人) 串行:编辑器开发模式下 effect 执行两次、同一个人开两个标签页,都会同时来取。库里没有 - # 唯一约束,建完再查一遍:别处(另一个进程)抢先建了的,删掉刚建的这段(还没有轮次),用那一段——历史才不会被拆开 + # 取或建在进程里按 (应用, 人) 串行:编辑器开发模式下 effect 执行两次、同一个人开两个标签页,都会同时来取。库里有 + # (app_id, created_by) 的部分唯一索引(ux_conversations_app_owner):别处(另一个进程)抢先建了的,这次插入在保存点里 + # 被拒,改用那一段——历史才不会被拆开。没署名(关登录又没填)的不受索引约束,靠这把进程内的锁 async with _assistant_lock(app.id, who): row = await session.scalar(lookup) if row is None: - row = Conversation(kind="app", app_id=app.id, created_by=who, title=f"「{app.name}」的助手") - session.add(row) - await session.flush() - other = await session.scalar(lookup.where(Conversation.id != row.id)) - if other is not None: - await session.delete(row) - row = other + fresh = Conversation(kind="app", app_id=app.id, created_by=who, title=f"「{app.name}」的助手") + try: + async with session.begin_nested(): + session.add(fresh) + await session.flush() + row = fresh + except IntegrityError: + row = await session.scalar(lookup) + if row is None: + raise await session.commit() await session.refresh(row) turns = list((await session.execute(select(ConversationTurn).where( diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 76b312e1..e888f45c 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -2139,6 +2139,13 @@ async def _mask_sets(snap: dict[str, Any] | None, source: Any, masks: _Masks, *, artifact 是 snap 的工件 id:给了的,分析结果按快照记在进程里(_MEMO,键见 _Masks.memo_key),之后的请求不再分析。""" if snap is not None and snap.get("source") == MERGE_SOURCE: return await _merge_mask_sets(snap, masks, depth=depth) + if snap is not None and snap.get("capability"): + # 端点快照:列是能力登记的表格映射排出来的,没有 SQL 可认、也不该按数据源认(端点不是数据源,按数据源找不到会把 + # 整张表都当成「数据源不在了」遮掉)。按列名遮:落快照时记下的加能力现在的遮罩字段,确定就是这几列 + from app.capabilities.registry import snapshot_masked_columns + + base = await snapshot_masked_columns(snap) + return _MaskSets(set(base), set(base), base) name = source or (snap or {}).get("source") current, found = await masks.of(name) base = set(current) | (_recorded_masks(snap) if snap is not None else set()) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index eec9fc1a..8418558d 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -61,8 +61,8 @@ async def list_tasks( @router.get("/count") -async def count_tasks(mine: int = 1, x_actor: str | None = Header(default=None)) -> dict[str, int]: - """导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批)。""" +async def count_tasks(mine: int = 1, x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """导航徽标:派给我的、待处理的待办数,按来源分开(apps:应用里的;other:画布、定时任务的审批);latest 是最新一条的开出时间。""" return await task_service.count(principal_of(x_actor)) diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index fd09217c..124771c2 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -164,7 +164,7 @@ async def _lint_subgraph_pins(session: AsyncSession, spec: dict[str, Any], pins: except ValueError: continue for problem in await subgraph_pin_problems(session, graph): - out.error(problem.code, f"「{state.label}」里的子工作流「{problem.title}」固定的{problem.reason}:这一版的正式运行" + out.error(problem.code, f"「{state.label}」里的{problem.who}固定的{problem.reason}:这一版的正式运行" "发起时会被拒绝。请在那个工作流里改好并发布新版本,再把这里的引用换成新版本", f"pins[{i}]", node_id=problem.node_id) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 1aeb579e..55e27e88 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -869,36 +869,20 @@ def _query_artifact(source: Source, node_id: str, exec_no: Any = None) -> str | async def _snapshot(source: Source, artifact: str) -> tuple[dict[str, Any] | None, set[str]]: """查询快照和要遮的列(小写):查询当时记下的加数据源现在设的,按 SQL 认出改了名字的也遮(同证据面板)。端点快照 按列名遮(见下)。""" - from app.api.evidence import _fetch, _recorded_masks + 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"): - # 端点快照:列是能力登记的表格映射排出来的,没有 SQL 改名这回事,按列名遮就是准的——落快照时记下的,加上能力 - # 现在的遮罩字段。查询快照那一套(按 SQL 认改了名字的遮罩列)在这里认不出出处,会把整张表都遮掉 - return snap, _recorded_masks(snap) | await _capability_masks(str(snap["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 _capability_masks(name: str) -> set[str]: - """端点快照(capabilities/endpoints.py 落的 endpoint_snapshot)按能力现在的遮罩字段要遮的列(小写)。 - - 快照里的 mask_columns 是落快照那一刻的遮罩(调用方另外并进来);管理员事后给能力加了遮罩字段,来源运行没换, - 视图也得跟着遮(P4,同数据源事后加遮罩列)。遮罩字段是 JSON 指针,按能力登记的表格映射认它落在哪几列 - (masked_columns_of,落快照时记 mask_columns 用的同一个函数)。能力删掉了的只能按快照记下的遮。""" - from app.capabilities.endpoints import masked_columns_of - from app.capabilities.registry import get_capability, masked_paths - from app.db.base import SessionLocal - - async with SessionLocal() as session: - cap = await get_capability(session, name) - if cap is None: - return set() - return {c.lower() for c in masked_columns_of(cap.tabular or {}, masked_paths(cap))} - - 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 diff --git a/backend/app/apps/versions.py b/backend/app/apps/versions.py index 0bed9541..7742366d 100644 --- a/backend/app/apps/versions.py +++ b/backend/app/apps/versions.py @@ -80,10 +80,13 @@ async def version_row(session: AsyncSession, workflow_id: str, version: int) -> # 子工作流固定的版本(D-q) # -------------------------------------------------------------------------- -#: 子工作流节点固定的版本不能用的机器码。发起正式运行(engine/launch.py)、发布工作流与发布前检查(engine/lint_process.py -#: 的 lint_subgraph_pins)、应用检查(apps/lint.py)都按 subgraph_pin_problems 判,机器码相同 +#: 固定的上游版本不能用的机器码:子工作流节点(workflow_version)、口径卡转抄别处的口径(caliber_from)。发起正式运行 +#: (engine/launch.py)、发布工作流与发布前检查(engine/lint_process.py 的 lint_subgraph_pins)、应用检查(apps/lint.py) +#: 都按 subgraph_pin_problems 判,机器码相同 SUBGRAPH_UNPUBLISHED = "subgraph_version_unpublished" SUBGRAPH_RETIRED = "subgraph_version_retired" +CALIBER_UNPUBLISHED = "caliber_version_unpublished" +CALIBER_RETIRED = "caliber_version_retired" @dataclass(frozen=True) @@ -99,40 +102,84 @@ class SubgraphPinProblem: reason: str #: 怎么改 advice: str + #: 句子的主语:「子工作流「…」」或「口径卡「…」」 + who: str = "" + #: 节点上哪个配置项固定了版本(问题定位到它) + field: str = "workflow_version" + + +def pinned_upstream(node: Any) -> tuple[str, str, int] | None: + """节点固定的上游版本:(种类 subgraph / caliber, 工作流 id, 版本);没固定就是 None。子工作流写在 workflow_id / + workflow_version,口径卡转抄别处的口径写在 caliber_from 里。""" + from app.engine.schema import NodeType + + cfg = node.config + if node.type == NodeType.SUBGRAPH: + wid, pinned = cfg.get("workflow_id"), cfg.get("workflow_version") + kind = "subgraph" + elif node.type == NodeType.METRICS and isinstance(cfg.get("caliber_from"), dict): + wid, pinned = cfg["caliber_from"].get("workflow_id"), cfg["caliber_from"].get("workflow_version") + kind = "caliber" + else: + return None + if not wid or not str(pinned or "").isdigit(): + return None + return kind, str(wid), int(pinned) async def subgraph_pin_problems(session: AsyncSession, spec: Any) -> list[SubgraphPinProblem]: - """图里每个固定了版本的子工作流节点,固定的那一版必须「已发布且未退役」(is_published);不是的逐个交回。 - - 子工作流固定版本就是方法卡的机制:外层发布时认可的是「用它的 vN」。vN 只是一张保存出来的草稿快照、或者已经退役, - 正式运行照样执行它,就等于绕过了发布门禁、绕过了退役。发起时拦(launch.py),发布时也按同一条拦——不然钉在草稿上的 - 工作流照样发得出去,之后每一次正式运行(定时任务、应用入口、按需视图、补算)都在发起时才被拒。 - 没固定版本的子工作流跟随最新,那是受管级门禁管的事(governed.subgraph_unpinned),这里不管。""" - from app.engine.schema import NodeType + """图里每个固定了上游版本的节点(子工作流、转抄别处口径的口径卡),固定的那一版必须「已发布且未退役」 + (is_published);不是的逐个交回。 + 固定版本就是方法卡的机制:外层发布时认可的是「用它的 vN」。vN 只是一张保存出来的草稿快照、或者已经退役,正式运行 + 照样执行它、照样按它的口径算,就等于绕过了发布门禁、绕过了退役。发起时拦(launch.py),发布时也按同一条拦——不然钉在 + 草稿上的工作流照样发得出去,之后每一次正式运行(定时任务、应用入口、按需视图、补算)都在发起时才被拒。 + 没固定版本的跟随最新,那是受管级门禁管的事(governed.subgraph_unpinned),这里不管。""" out: list[SubgraphPinProblem] = [] for node in spec.nodes: - if node.type != NodeType.SUBGRAPH: + pin = pinned_upstream(node) + if pin is None: continue - wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version") - if not wid or not str(pinned or "").isdigit(): - continue - version = int(pinned) - workflow = await session.get(Workflow, str(wid)) - row = await version_row(session, str(wid), version) if workflow is not None else None + kind, wid, version = pin + caliber = kind == "caliber" + who = f"口径卡「{node.title}」" if caliber else f"子工作流「{node.title}」" + field = "caliber_from" if caliber else "workflow_version" + unpublished, retired = (CALIBER_UNPUBLISHED, CALIBER_RETIRED) if caliber else (SUBGRAPH_UNPUBLISHED, SUBGRAPH_RETIRED) + workflow = await session.get(Workflow, wid) + row = await version_row(session, wid, version) if workflow is not None else None if workflow is None or row is None: - out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_UNPUBLISHED, str(wid), version, - f"工作流版本 v{version} 不存在", "请在节点中重新选择版本")) + out.append(SubgraphPinProblem(node.id, node.title, unpublished, wid, version, + f"工作流版本 v{version} 不存在", "请在节点中重新选择版本", who, field)) elif row.retired_at is not None: - out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_RETIRED, str(wid), version, - f"「{workflow.name}」v{version} 已退役", "请把节点改为固定一个在用的发布版本")) + out.append(SubgraphPinProblem(node.id, node.title, retired, wid, version, + f"「{workflow.name}」v{version} 已退役", "请把节点改为固定一个在用的发布版本", + who, field)) elif not is_published(row, current=workflow.published_version): - out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_UNPUBLISHED, str(wid), version, + out.append(SubgraphPinProblem(node.id, node.title, unpublished, wid, version, f"「{workflow.name}」v{version} 没有发布过", - "请先发布那个工作流,或把节点改为固定一个已发布的版本")) + "请先发布那个工作流,或把节点改为固定一个已发布的版本", who, field)) return out +async def backfill_legacy_levels(session: AsyncSession) -> int: + """给记级别之前发布过的老版本补上 level(启动时跑一次,幂等)。 + + level 是后来才加的列:那之前发布过的版本都是空的,按 is_published 只认当前发布版——而子工作流、口径卡钉住的往往是 + 更早的某一版,新规则会把它们误判成「没发布过」。补的依据只有一条,有据可查:这一版发起过正式运行。老代码里正式运行 + 只能从当时的发布版发起,所以它一定发布过。级别记 published:当时是不是受管级查不出来,往低处记(应用的级别取最低, + 只会少给信任,不会多给)。没有正式运行凭据的照旧为空,不猜。""" + from sqlalchemy import exists, update + + from app.db.models import Run + + evidence = exists().where(Run.workflow_id == WorkflowVersion.workflow_id, Run.version == WorkflowVersion.version, + Run.run_class == "formal") + result = await session.execute(update(WorkflowVersion) + .where(WorkflowVersion.level.is_(None), WorkflowVersion.retired_at.is_(None), evidence) + .values(level="published").execution_options(synchronize_session=False)) + return result.rowcount or 0 + + # -------------------------------------------------------------------------- # 账本(L4):一个工作流版本被谁固定、生效了几次、正式运行了几次。退役确认、版本详情、删除约束、账本接口共用 # -------------------------------------------------------------------------- diff --git a/backend/app/capabilities/registry.py b/backend/app/capabilities/registry.py index fa1565f1..cc6e1406 100644 --- a/backend/app/capabilities/registry.py +++ b/backend/app/capabilities/registry.py @@ -305,6 +305,24 @@ async def endpoint_request(session: AsyncSession, capability: Any, args: dict[st run_id=run_id, node_id=node_id) +async def snapshot_masked_columns(snap: dict[str, Any]) -> set[str]: + """一份端点快照要遮的列(小写):落快照时记下的(mask_columns),加上能力现在的遮罩字段按表格映射落到的列。 + + 端点快照的列是能力登记的表格映射排出来的,没有 SQL 改名这回事,按列名遮就是准的。管理员事后给能力加了遮罩字段, + 来源运行没换,证据面板和视图也得跟着遮(P4,同数据源事后加遮罩列)。能力删掉了的只能按快照记下的遮。 + 证据面板(api/evidence._mask_sets)和应用视图(apps/resolve._snapshot)共用这一份。""" + from app.capabilities.endpoints import masked_columns_of + from app.db.base import SessionLocal + + recorded = snap.get("mask_columns") + out = {c.lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set() + async with SessionLocal() as session: + cap = await get_capability(session, str(snap.get("capability") or "")) + if cap is not None: + out |= {c.lower() for c in masked_columns_of(cap.tabular or {}, masked_paths(cap))} + return out + + def masked_paths(capability: Any) -> list[str]: """能力的遮罩字段(JSON 指针)。""" return list(getattr(capability, "masked", None) or []) diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 61a85d7f..f4f3ff18 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -252,6 +252,21 @@ _INDEX_MIGRATIONS: list[tuple[str, str, str | tuple[str, ...]]] = [ ] +#: 迁移补建的唯一索引:(索引名, 表, 列, 部分索引的条件, 建之前的去重语句)。只在索引还不存在时先去重再建——存量库里可能 +#: 已经有并发留下的重复行,直接建会失败。去重的规矩写在各自的语句里:多出的钩子行删掉(只留最早的那一行,它的密钥照旧 +#: 能用),多出的助手对话归档(不删,历史还在) +_UNIQUE_INDEX_MIGRATIONS: list[tuple[str, str, tuple[str, ...], str | None, str]] = [ + ("ux_app_hooks_app_name", "app_hooks", ("app_id", "name"), None, + "DELETE FROM app_hooks WHERE id NOT IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER " + "(PARTITION BY app_id, name ORDER BY created_at, id) AS rn FROM app_hooks) WHERE rn = 1)"), + ("ux_conversations_app_owner", "conversations", ("app_id", "created_by"), "kind = 'app' AND archived = 0", + "UPDATE conversations SET archived = 1 WHERE kind = 'app' AND archived = 0 AND created_by IS NOT NULL " + "AND id NOT IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY app_id, created_by " + "ORDER BY last_active_at DESC, id) AS rn FROM conversations WHERE kind = 'app' AND archived = 0 " + "AND created_by IS NOT NULL) WHERE rn = 1)"), +] + + def _migrate(conn) -> None: # pragma: no cover - 同步回调 from sqlalchemy import text @@ -264,6 +279,14 @@ def _migrate(conn) -> None: # pragma: no cover - 同步回调 cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))} if all(c in cols for c in wanted): conn.execute(text(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({', '.join(wanted)})")) + for name, table, columns, where, dedupe in _UNIQUE_INDEX_MIGRATIONS: + cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))} + exists = conn.execute(text("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = :n"), {"n": name}).first() + if exists or not all(c in cols for c in columns): + continue + conn.execute(text(dedupe)) + conn.execute(text(f"CREATE UNIQUE INDEX {name} ON {table} ({', '.join(columns)})" + + (f" WHERE {where}" if where else ""))) async def init_db() -> None: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 9e66bbe4..b0435b9a 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -803,6 +803,10 @@ class Conversation(Base, TimestampMixin): """ __tablename__ = "conversations" + # 一个人在一个应用里只有一段(未归档的)助手对话(D-e)。存量库由 db/base.py 的 _UNIQUE_INDEX_MIGRATIONS 补建。 + # created_by 为空(关登录又没填署名)的不受约束:SQLite 的唯一索引里 NULL 互不相等,那种情况靠接口里的进程内锁 + __table_args__ = (Index("ux_conversations_app_owner", "app_id", "created_by", unique=True, + sqlite_where=text("kind = 'app' AND archived = 0")),) id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) title: Mapped[str] = mapped_column(String(200), default="") diff --git a/backend/app/db/models_apps.py b/backend/app/db/models_apps.py index ff20e35f..a1c4d965 100644 --- a/backend/app/db/models_apps.py +++ b/backend/app/db/models_apps.py @@ -9,7 +9,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow @@ -86,6 +86,8 @@ class AppHook(Base, TimestampMixin): """入站钩子。start:按入口发起实例;signal:按 correlation 解决一条「等外部系统」的待办。""" __tablename__ = "app_hooks" + # 一个名字一行(D-g):并发「生成密钥」不会多出第二把能用的密钥。存量库由 db/base.py 的 _UNIQUE_INDEX_MIGRATIONS 补建 + __table_args__ = (Index("ux_app_hooks_app_name", "app_id", "name", unique=True),) id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True) diff --git a/backend/app/engine/launch.py b/backend/app/engine/launch.py index d87f50ff..27341d4d 100644 --- a/backend/app/engine/launch.py +++ b/backend/app/engine/launch.py @@ -421,7 +421,7 @@ async def _refuse_unpublished_subgraphs(session: AsyncSession, spec: GraphSpec) """正式运行里,每个固定了版本的子工作流节点,固定的那一版必须「已发布且未退役」(D-q)。规则在 apps/versions.subgraph_pin_problems,发布门禁按同一条拦;这里是发起这一刻的那一道(发布之后那一版才退役的)。""" for problem in await subgraph_pin_problems(session, spec): - raise LaunchRefused(409, f"子工作流「{problem.title}」固定的{problem.reason},不能发起正式运行。" + raise LaunchRefused(409, f"{problem.who}固定的{problem.reason},不能发起正式运行。" f"{problem.advice},再发布这个工作流", problem.code) diff --git a/backend/app/engine/lint_process.py b/backend/app/engine/lint_process.py index 5e95b271..e7ccac9d 100644 --- a/backend/app/engine/lint_process.py +++ b/backend/app/engine/lint_process.py @@ -264,16 +264,18 @@ def lint_process(spec: GraphSpec, flag: Callable[..., None], *, level: str, stri def lint_subgraph_pins(spec: GraphSpec, flag: Callable[..., None], *, level: str, strict: bool) -> None: """子工作流固定的版本不是「已发布且未退役」的(D-q):各级都报错(always),机器码和发起时拒绝的一样。 load_subgraphs 没读好、没放进来时不查(门禁本身不查库)。""" + from app.apps.versions import pinned_upstream + graphs = _SUBGRAPHS.get() nodes = spec.node_map() for problem in getattr(graphs, "pin_problems", None) or []: node = nodes.get(problem.node_id) # 读好的结果对的是读的那张图;拿去查改过的图(自动修复的预览)时,节点改了固定的版本就不再算它 - if node is None or str(node.config.get("workflow_id") or "") != problem.workflow_id \ - or str(node.config.get("workflow_version") or "") != str(problem.version): + if node is None or pinned_upstream(node) is None \ + or pinned_upstream(node)[1:] != (problem.workflow_id, problem.version): continue - flag(f"子工作流「{problem.title}」固定的{problem.reason}:正式运行发起时会被拒绝。{problem.advice}", - code=problem.code, node_id=problem.node_id, field="workflow_version", always=True) + flag(f"{problem.who}固定的{problem.reason}:正式运行发起时会被拒绝。{problem.advice}", + code=problem.code, node_id=problem.node_id, field=problem.field, always=True) def _register() -> None: diff --git a/backend/app/main.py b/backend/app/main.py index 46932c7d..5d1c961d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -42,6 +42,15 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.ensure_dirs() await init_db() await seed_defaults() + + # 记级别之前发布过的老版本:有正式运行凭据的补记为已发布,免得子工作流、口径卡钉住它们的正式运行被误判成「没发布过」 + from app.apps.versions import backfill_legacy_levels + from app.db.base import SessionLocal as _Sessions + + async with _Sessions() as _s: + if levelled := await backfill_legacy_levels(_s): + logger.info("给 %d 个老的发布版本补记了级别(有正式运行凭据)", levelled) + await _s.commit() await _platform_startup() # 把存的 embedding 配置读进来。不读的话重启后会悄悄退回本地哈希向量, diff --git a/backend/app/process/hooks.py b/backend/app/process/hooks.py index 0066ef85..f9ea52ab 100644 --- a/backend/app/process/hooks.py +++ b/backend/app/process/hooks.py @@ -340,8 +340,8 @@ async def rotate_hook(session: AsyncSession, app_id: str, name: str, *, kind: st """生成或轮换这个名字的密钥,返回 (行, 密钥明文, 是不是新生成的)。密钥只在这一次交回,库里存密文。 kind / entry 只是照声明记在行上给人看;收请求时以当前发布版的声明为准(published_declaration)。调用方提交。 - 新插入一行之后再查一遍:别人抢先插了(并发生成,hook_lock 管不到的另一个进程),删掉自己这行、改去轮换最早那一行, - 交回的仍是这一次的密钥——和两个人先后点「轮换」一样,后点的那把生效。""" + (app_id, name) 有唯一索引(ux_app_hooks_app_name):别人抢先插了这个名字(并发生成,hook_lock 管不到的另一个进程), + 这次插入在保存点里被库拒掉,改去轮换已有的那一行,交回的仍是这一次的密钥——和两个人先后点「轮换」一样,后点的那把生效。""" from app.core.crypto import encrypt if kind not in KINDS: @@ -350,13 +350,16 @@ async def rotate_hook(session: AsyncSession, app_id: str, name: str, *, kind: st row = await hook_row(session, app_id, name) created = row is None if row is None: - row = AppHook(app_id=app_id, name=name[:100], kind=kind, enabled=True, secret="") - session.add(row) - await session.flush() - first = await hook_row(session, app_id, name) - if first is not None and first.id != row.id: - await session.delete(row) - row, created = first, False + fresh = AppHook(app_id=app_id, name=name[:100], kind=kind, enabled=True, secret="") + try: + async with session.begin_nested(): + session.add(fresh) + await session.flush() + row = fresh + except IntegrityError: + row, created = await hook_row(session, app_id, name), False + if row is None: + raise row.kind, row.entry_id = kind, (entry if kind == "start" else None) row.secret = encrypt(secret) or "" await session.flush() diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index bd8ce31f..c3bc1ae4 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -825,15 +825,22 @@ async def list_tasks(principal: Any, *, assigned: str = "me", statuses: list[str return await _describe(session, tasks, principal) -async def count(principal: Any) -> dict[str, int]: - """导航徽标:派给我的、待处理的待办数。apps 是应用里的,other 是不属于应用的(画布、定时任务的审批)。""" +async def count(principal: Any) -> dict[str, Any]: + """导航徽标:派给我的、待处理的待办数。apps 是应用里的,other 是不属于应用的(画布、定时任务的审批)。 + + latest 是其中最新一条的开出时间:前端的新待办通知看它变没变,而不只看数——同一拍里一条处理掉、一条新来,数不变, + 新的那条照样要通知。""" async with SessionLocal() as session: - stmt = select(Task.app_id.is_not(None), func.count()).where(Task.status == "pending") + stmt = select(Task.app_id.is_not(None), func.count(), func.max(Task.created_at)).where(Task.status == "pending") if (clause := _mine(principal)) is not None: stmt = stmt.where(clause) - rows = dict((await session.execute(stmt.group_by(Task.app_id.is_not(None)))).all()) - apps, other = int(rows.get(True, 0)), int(rows.get(False, 0)) - return {"mine": apps + other, "apps": apps, "other": other} + rows = {has_app: (n, latest) for has_app, n, latest in + (await session.execute(stmt.group_by(Task.app_id.is_not(None)))).all()} + apps, other = int(rows.get(True, (0, None))[0]), int(rows.get(False, (0, None))[0]) + stamps = [latest for _, latest in rows.values() if latest is not None] + latest = max(stamps) if stamps else None + return {"mine": apps + other, "apps": apps, "other": other, + "latest": latest.isoformat() if hasattr(latest, "isoformat") else latest} async def detail(task_id: str, principal: Any) -> dict[str, Any] | None: diff --git a/backend/tests/test_app_versions.py b/backend/tests/test_app_versions.py index 859b93eb..17b02d3e 100644 --- a/backend/tests/test_app_versions.py +++ b/backend/tests/test_app_versions.py @@ -334,3 +334,52 @@ async def test_a_workflow_in_use_cannot_be_deleted(client): assert res.status_code == 409 and "v1 有 1 条生效记录" in res.json()["detail"] plain = await workflow({1: "published"}, current=1) assert (await client.delete(f"/api/workflows/{plain}")).status_code == 204 + + +async def test_caliber_pins_follow_the_same_rule_as_subgraphs(): + """D-q 延伸到口径卡:转抄别处口径的口径卡固定的那一版,同样必须已发布且未退役;机器码各用一套。""" + from types import SimpleNamespace + + from app.apps.versions import CALIBER_UNPUBLISHED, subgraph_pin_problems + from app.db.base import SessionLocal + from app.db.models import Workflow, WorkflowVersion + from app.engine.schema import GraphSpec + + async with SessionLocal() as session: + wf = Workflow(name="周销售口径", graph={}, published_version=2) + session.add(wf) + await session.flush() + session.add_all([WorkflowVersion(workflow_id=wf.id, version=1, graph={}), + WorkflowVersion(workflow_id=wf.id, version=2, graph={}, level="published")]) + await session.commit() + graph = GraphSpec.model_validate({"nodes": [ + {"id": "in", "type": "input", "data": {"label": "入口", "config": {}}}, + {"id": "kpi", "type": "metrics", "data": {"label": "指标", "config": { + "caliber_from": {"workflow_id": wf.id, "workflow_version": 1, "node_id": "card"}}}}, + ], "edges": [{"source": "in", "target": "kpi"}]}) + problems = await subgraph_pin_problems(session, graph) + assert [(p.code, p.field) for p in problems] == [(CALIBER_UNPUBLISHED, "caliber_from")] + assert problems[0].who == "口径卡「指标」" + graph.nodes[1].config["caliber_from"]["workflow_version"] = 2 + assert await subgraph_pin_problems(session, graph) == [] + + +async def test_legacy_versions_with_formal_runs_are_backfilled_as_published(): + """记级别之前发布过的老版本:有正式运行凭据的补记为 published(幂等),没有凭据的不猜。""" + from app.apps.versions import backfill_legacy_levels + from app.db.base import SessionLocal + from app.db.models import Run, Workflow, WorkflowVersion + + async with SessionLocal() as session: + wf = Workflow(name="老方法卡", graph={}, published_version=3) + session.add(wf) + await session.flush() + session.add_all([WorkflowVersion(workflow_id=wf.id, version=v, graph={}) for v in (1, 2, 3)]) + session.add(Run(workflow_id=wf.id, run_class="formal", version=1, graph={}, input={})) + await session.commit() + assert await backfill_legacy_levels(session) >= 1 + await session.commit() + levels = {v.version: v.level for v in (await session.execute( + WorkflowVersion.__table__.select().where(WorkflowVersion.workflow_id == wf.id))).all()} + assert levels == {1: "published", 2: None, 3: None} + assert await backfill_legacy_levels(session) == 0 diff --git a/backend/tests/test_caliber_from.py b/backend/tests/test_caliber_from.py index 294ed0f7..02792a46 100644 --- a/backend/tests/test_caliber_from.py +++ b/backend/tests/test_caliber_from.py @@ -67,7 +67,9 @@ async def publish(graph, *, name="口径库", versions=1, status="published", la session.add(wf) await session.flush() for v in range(1, versions + 1): - session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=later if later and v > 1 else graph)) + # 每一版都发布过(level 记着):口径卡固定的版本须「已发布且未退役」(D-q) + session.add(WorkflowVersion(workflow_id=wf.id, version=v, graph=later if later and v > 1 else graph, + level="published")) await session.commit() return wf.id diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index c75409ec..22560cd6 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -266,3 +266,14 @@ def test_run_compare_reads_endpoint_requests(): assert _compare_request(q("erp", "/orders", {"d": "1"}), q("erp", "/orders", {"d": "1"})).structure == "same" assert _compare_request(q("erp", "/orders", {"d": "1"}), q("erp", "/orders", {"d": "2"})).structure == "params_only" assert _compare_request(q("erp", "/orders", {}), q("erp", "/refunds", {})).structure == "changed" + + +async def test_evidence_panel_masks_an_endpoint_snapshot_by_column(): + """证据面板认端点快照:按列名遮(落快照时记下的加能力现在的遮罩字段),不按数据源找、不把整张表当成「数据源不在了」 + 遮掉。能力登记里查不到的只按快照记下的遮。""" + from app.api.evidence import _mask_sets, _Masks + + snap = {"capability": "erp_orders", "source": "erp", "columns": ["单号", "电话", "金额"], + "rows": [["A1", "13800000000", 12]], "mask_columns": ["电话"]} + sets = await _mask_sets(snap, "erp", _Masks()) + assert sets.hidden == {"电话"} and sets.sure == {"电话"} and not sets.gone diff --git a/backend/tests/test_process_hooks.py b/backend/tests/test_process_hooks.py index f45c9d8a..e5b6f861 100644 --- a/backend/tests/test_process_hooks.py +++ b/backend/tests/test_process_hooks.py @@ -252,8 +252,8 @@ async def test_hook_management_functions(): async def test_one_name_one_row_even_under_races(client, monkeypatch): - """库里还没有 (app_id, name) 的唯一约束:并发生成只留一行;插入后发现别人抢先插了,删掉自己、改去轮换那一行;万一 - 多出一行,收请求只认最早那一行,停用按名字改全部行。""" + """(app_id, name) 有唯一索引:并发生成只留一行;插入时发现别人抢先插了(库拒掉),改去轮换那一行;库里不可能 + 多出第二行,停用按名字改。""" from app.db.base import utcnow from app.db.models import AppHook @@ -269,7 +269,7 @@ async def test_one_name_one_row_even_under_races(client, monkeypatch): rows = list((await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars()) assert len(rows) == 1 - # 另一个进程在「读不到行」和「插入」之间抢先插好了:这一次插入后再查,删掉自己、轮换最早那一行 + # 另一个进程在「读不到行」和「插入」之间抢先插好了:这一次插入被唯一索引拒掉,改去轮换已有的那一行 real = hooks.hook_row calls = {"n": 0} @@ -286,21 +286,17 @@ async def test_one_name_one_row_even_under_races(client, monkeypatch): async with SessionLocal() as session: assert len(list((await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars())) == 1 - # 真的多出一行(多进程各自插入):它的密钥收不到请求;停用两行一起停 - from app.core.crypto import encrypt + # 库不许多出第二行:多进程各自插入时,后插的那一次被唯一索引拒掉 + from sqlalchemy.exc import IntegrityError async with SessionLocal() as session: - stray = AppHook(app_id=app_id, name="paid", kind="signal", enabled=True, secret=encrypt("stray-secret") or "", - created_at=utcnow()) - session.add(stray) - await session.commit() - stray_id = stray.id - headers, body = signed("stray-secret", {"correlation": "x"}, key="k-stray") - assert (await client.post(f"/api/hooks/{stray_id}", content=body, headers=headers)).status_code == 404 + session.add(AppHook(app_id=app_id, name="paid", kind="signal", enabled=True, secret="x", created_at=utcnow())) + with pytest.raises(IntegrityError): + await session.commit() assert (await client.patch(f"/api/apps/{app_id}/hooks/paid", json={"enabled": False})).status_code == 200 async with SessionLocal() as session: states = [r.enabled for r in (await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars()] - assert states == [False, False] + assert states == [False] async def test_a_hook_works_by_what_the_published_version_declares(client, monkeypatch): diff --git a/backend/tests/test_process_tasks.py b/backend/tests/test_process_tasks.py index 10213380..3bb85db9 100644 --- a/backend/tests/test_process_tasks.py +++ b/backend/tests/test_process_tasks.py @@ -443,7 +443,7 @@ async def test_count_and_list_api(client): await _wait(run.id, ("interrupted",)) task = await _task(run.id) count = (await client.get("/api/tasks/count?mine=1")).json() - assert set(count) == {"mine", "apps", "other"} and count["other"] >= 1 + assert set(count) == {"mine", "apps", "other", "latest"} and count["other"] >= 1 and count["latest"] listed = (await client.get(f"/api/tasks?assigned=me&run_id={run.id}")).json()["items"] assert [t["id"] for t in listed] == [task.id] item = listed[0] diff --git a/backend/tests/test_run_launch.py b/backend/tests/test_run_launch.py index a4e521e3..baf0935c 100644 --- a/backend/tests/test_run_launch.py +++ b/backend/tests/test_run_launch.py @@ -140,8 +140,9 @@ async def upstream_with_two_versions() -> str: lib = Workflow(name=f"口径库-{_tag()}", graph=library(), status="published", published_version=2, version=2) session.add(lib) await session.flush() - session.add(WorkflowVersion(workflow_id=lib.id, version=1, graph=library())) - session.add(WorkflowVersion(workflow_id=lib.id, version=2, graph=library(version="v4"))) + # 两版都发布过(level 记着):口径卡固定的版本须「已发布且未退役」(D-q) + session.add(WorkflowVersion(workflow_id=lib.id, version=1, graph=library(), level="published")) + session.add(WorkflowVersion(workflow_id=lib.id, version=2, graph=library(version="v4"), level="published")) await session.commit() return lib.id diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e1488645..02322d42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1155,7 +1155,7 @@ function watchApprovals(go: Go): () => void { /** * 派给自己的新待办:规则同待审批——拿到过一次真列表才记底,之后新来的、标签页在后台时发系统通知。不每拍都取列表: - * 心跳取的「派给我的」待办数(taskCounts.mine)变了才取一次派给我的待处理待办。搭建者以上已经按待审批发过通知的 + * 心跳取的「派给我的」待办数或最新一条的时间(taskCounts.mine / latest)变了才取一次派给我的待处理待办。搭建者以上已经按待审批发过通知的 * (待办带着那条审批)不再发第二条;核实、等外部系统这类没有审批的待办只在这里通知 */ function watchTasks(go: Go): () => void { @@ -1192,7 +1192,7 @@ function watchTasks(go: Go): () => void { } return useCatalog.subscribe((s, p) => { if (s.taskCounts === p.taskCounts || !s.taskCounts) return - if (seen && s.taskCounts.mine === p.taskCounts?.mine) return + if (seen && s.taskCounts.mine === p.taskCounts?.mine && s.taskCounts.latest === p.taskCounts?.latest) return if (needsLogin(useAuth.getState().me)) return void pull() }) diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 24f7bdc3..713e9ab5 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -5,8 +5,8 @@ * 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。 */ import type { - AppBindings, AppDetail, AppGrant, AppHookRow, AppSpec, AppSummary, AppVersionRow, ComponentCatalog, Disposition, - InstanceRow, LintIssue, PublishResult, ViewModel, + AppBindings, AppDetail, AppGrant, AppHookRow, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, + ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, } from '../typesApp' import type { ConversationDetail, EvidenceSegmentDetail } from '../types' import { del, get, patch, post, put, qs, type RequestOptions } from './http' @@ -14,85 +14,17 @@ import { del, get, patch, post, put, qs, type RequestOptions } from './http' const at = (id: string) => `/apps/${encodeURIComponent(id)}` const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}` -/** - * 钩子管理里的一行(D-g):草稿里声明的 ∪ 已经生成过密钥的,按名字合成一行。 - * - declared:草稿的 spec.hooks 里有这个名字;published:当前发布版里有(入站请求按发布版认种类和入口,没有就拒收); - * - has_secret:已经生成过密钥(app_hooks 里有这一行)。密钥本身只在生成、轮换时交回一次(AppHookRow.secret)。 - * 服务端几种写法都认(hookStateOf),缺的项按「没有」算 - */ -export interface AppHookState { - name: string - kind: 'start' | 'signal' | null - entry_id: string | null - declared: boolean - published: boolean - has_secret: boolean - enabled: boolean - /** app_hooks 那一行的 id:入站地址是 /api/hooks/{id}。还没生成密钥时为 null */ - id: string | null - /** 收请求的地址(服务端给的路径);没给时按 id 拼 */ - url_path: string | null - created_at: string | null -} - -function hookStateOf(raw: Record): AppHookState { - const id = typeof raw.id === 'string' && raw.id ? raw.id : null - const flag = (...keys: string[]) => keys.some((k) => raw[k] === true) - return { - name: String(raw.name ?? ''), - kind: raw.kind === 'start' || raw.kind === 'signal' ? raw.kind : null, - entry_id: raw.entry_id ?? raw.entry ?? null, - declared: flag('declared', 'in_draft'), - published: flag('published', 'in_published'), - has_secret: raw.has_secret != null || raw.secret_set != null ? flag('has_secret', 'secret_set') : !!id, - enabled: raw.enabled !== false, - id, - url_path: typeof raw.url_path === 'string' && raw.url_path ? raw.url_path : id ? `/api/hooks/${encodeURIComponent(id)}` : null, - created_at: raw.created_at ?? null, - } -} - -/** 按新口径补算的结果(D-f):发起了几次、跳过了几次(同一输入已经补算过) */ -export interface BackfillResult { - started: number - skipped: number - run_ids?: string[] - /** 发起到一半被拒(比如版本刚被退役):已经发起的照算,原因照实交回 */ - refused?: string | null -} - -/** 补算会发起几次(服务端 apps/backfill.Plan.out,不发起运行):这个视图、哪个固定版本从几升到几、趋势画几期 */ -export interface BackfillPlan { - view: string - alias: string - source: string - from_version: number - to_version: number - periods: number - will_launch: number - will_skip: number -} - -/** - * 应用详情。manage 才有草稿和岗位绑定(D-m);另带能按新口径补算的视图(backfill,只在取详情时算)。use / observe - * 只有概况和当前发布版的页面、入口目录(published) - */ -export type AppDetailOut = AppDetail & { - backfill?: BackfillPlan[] - published?: { version: number; pages: { id: string; title: string }[]; entries: { id: string; label: string }[] } | null -} - export const apps = { catalog: (opts?: RequestOptions) => get('/apps/catalog', opts), list: (opts?: RequestOptions) => get<{ items: AppSummary[] }>('/apps', opts), - get: (id: string, opts?: RequestOptions) => get(at(id), opts), + get: (id: string, opts?: RequestOptions) => get(at(id), opts), create: (body: { name: string; description?: string; spec?: AppSpec }) => post('/apps', body), /** 从工作流版本确定性派生一份应用草稿(不落库),编辑器拿去预览 / 新建 */ derive: (body: { workflow_id: string; version: number; name?: string }) => post<{ spec: AppSpec }>('/apps/derive', body), /** 改草稿:spec、岗位绑定、名称等,只带改了的字段 */ update: (id: string, body: { name?: string; description?: string; icon?: string; draft?: AppSpec; - draft_bindings?: AppBindings }) => patch(at(id), body), + draft_bindings?: AppBindings }) => patch(at(id), body), archive: (id: string) => post(`${at(id)}/archive`), lint: (id: string) => post<{ issues: LintIssue[]; level: string | null }>(`${at(id)}/lint`), publish: (id: string, body: { note?: string; dispositions?: Record }) => @@ -129,21 +61,14 @@ export const apps = { * 按新口径补算(D-f,manage):这个视图的固定版本相对上一个应用版本升了版、处置为按新口径重算时,取旧版本在同一来源上 * 最近几次封存的正式运行的输入,用新版本逐个发起正式运行(来源记为按新口径补算),同一输入补算过的跳过 */ - backfill: async (id: string, view: string): Promise => { - const raw = await post>(`${at(id)}/views/${encodeURIComponent(view)}/backfill`) - const n = (...keys: string[]) => Number(keys.map((k) => raw?.[k]).find((v) => typeof v === 'number') ?? 0) - return { started: n('launched', 'started'), skipped: n('skipped'), run_ids: Array.isArray(raw?.run_ids) ? raw.run_ids : undefined, - refused: typeof raw?.refused === 'string' ? raw.refused : null } - }, + backfill: (id: string, view: string) => + post(`${at(id)}/views/${encodeURIComponent(view)}/backfill`), /** 补算会发起几次、跳过几次(不发起运行):确认框按它写明次数 */ backfillPlan: (id: string, view: string) => get(`${at(id)}/views/${encodeURIComponent(view)}/backfill`), /** 钩子(D-g):草稿声明 ∪ 已有的行,按名字 */ - hooks: async (id: string): Promise => { - const raw = await get<{ items: Record[] } | Record[]>(`${at(id)}/hooks`) - return (Array.isArray(raw) ? raw : raw?.items ?? []).map(hookStateOf).filter((h) => h.name) - }, + hooks: async (id: string): Promise => (await get<{ items: AppHookRow[] }>(`${at(id)}/hooks`)).items, /** 生成或轮换签名密钥:secret 只在这一次交回;url_path 是收请求的地址 */ - rotateHook: (id: string, name: string) => post(`${hookAt(id, name)}/rotate`), + rotateHook: (id: string, name: string) => post(`${hookAt(id, name)}/rotate`), setHookEnabled: (id: string, name: string, enabled: boolean) => patch(hookAt(id, name), { enabled }), /** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */ deleteHook: (id: string, name: string) => del(hookAt(id, name)), diff --git a/frontend/src/api/capabilities.ts b/frontend/src/api/capabilities.ts index 810afd39..4cfe843c 100644 --- a/frontend/src/api/capabilities.ts +++ b/frontend/src/api/capabilities.ts @@ -3,7 +3,9 @@ * api.endpoints / api.notify 上。形状见 typesApp.ts。端点和渠道的密钥只回掩码;提交掩码值表示保持原值。 */ import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types' -import type { CapabilityEffect, CapabilityRow, EndpointRow, NotifyChannelRow } from '../typesApp' +import type { + CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow, +} from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' /** @@ -45,8 +47,6 @@ export interface CapabilityReviewResult { code?: string | null } -const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null) -const num = (...vs: unknown[]): number => Number(vs.find((v) => typeof v === 'number') ?? 0) /** 队列一项的键:字段@位置。字段只有几个固定的英文名,不含 @,拆的时候按第一个 @ 拆 */ export const reviewPathOf = (field: string, pointer: string) => `${field}@${pointer}` @@ -55,28 +55,24 @@ export function splitReviewPath(path: string): { field: string; pointer: string return at < 0 ? { field: '', pointer: path } : { field: path.slice(0, at), pointer: path.slice(at + 1) } } -function reviewItemOf(raw: Record): CapabilityReviewItem { - const pointer = String(raw.pointer ?? '') - const field = String(raw.field ?? '') - const short = String(raw.field_text ?? raw.field ?? '') +function reviewItemOf(raw: CapabilityReviewRow): CapabilityReviewItem { return { - name: String(raw.capability ?? raw.name ?? ''), - title: str(raw.title), - path: reviewPathOf(field, pointer), - pointer, - field, - // 服务端写好的位置说法;老写法只给了字段名时自己拼 - where: str(raw.field_label) ?? (pointer ? `${pointer} 的${short}` : short), + name: raw.capability, + title: raw.title, + path: reviewPathOf(raw.field, raw.pointer), + pointer: raw.pointer, + field: raw.field, + where: raw.field_label, value: raw.value, - display: typeof raw.display === 'string' ? raw.display : raw.value == null ? '' : String(raw.value), - source: raw.source, - status: raw.status ?? 'proposed', - note: str(raw.note), - priority: num(raw.priority), - reasons: Array.isArray(raw.reasons) ? raw.reasons.map(String) : [], - usage: num(raw.runs, raw.usage, raw.run_count), - effect: raw.effect ?? null, - version: num(raw.version), + display: raw.display, + source: raw.source as CatalogSource, + status: raw.status as CatalogStatus, + note: raw.note, + priority: raw.priority, + reasons: raw.reasons, + usage: raw.runs, + effect: raw.effect, + version: raw.version, } } @@ -91,25 +87,22 @@ export const capabilities = { sync: () => post<{ changed: number }>('/capabilities/sync'), /** 待审阅的能力字段(D-i):顺序服务端排好;capability 只看这一个能力 */ reviewQueue: async (params: { limit?: number; offset?: number; capability?: string | null }, opts?: RequestOptions) => { - const raw = await get<{ total?: number; items?: Record[] }>(`/capabilities/review-queue${qs({ + const raw = await get<{ total: number; items: CapabilityReviewRow[] }>(`/capabilities/review-queue${qs({ limit: params.limit, offset: params.offset, capability: params.capability || undefined, })}`, opts) - const items = (raw?.items ?? []).map(reviewItemOf).filter((i) => i.name && i.field) - return { total: typeof raw?.total === 'number' ? raw.total : items.length, items } + return { total: raw.total, items: raw.items.map(reviewItemOf) } }, /** * 成批审阅(只给管理员):逐个能力按治理版本做乐观锁,逐条交回结果(一个能力冲突只记在那几条上,code 为 * capability_conflict);写审计。path 是队列一项的键(字段@位置),这里拆回位置和字段 */ reviewBatch: async (items: { name: string; path: string; action: CatalogReviewAction; if_version: number }[]) => { - const raw = await post<{ results?: Record[] }>('/capabilities/review/batch', { + const raw = await post<{ results: CapabilityReviewOutcome[] }>('/capabilities/review/batch', { items: items.map((i) => ({ capability: i.name, ...splitReviewPath(i.path), action: i.action, if_version: i.if_version })), }) return { - results: (raw?.results ?? []).map((r): CapabilityReviewResult => ({ - name: String(r.capability ?? r.name ?? ''), path: reviewPathOf(String(r.field ?? ''), String(r.pointer ?? '')), - ok: r.ok === true, version: typeof r.version === 'number' ? r.version : null, - error: str(r.error) ?? str(r.message), code: str(r.code), + results: raw.results.map((r): CapabilityReviewResult => ({ + name: r.capability, path: reviewPathOf(r.field, r.pointer), ok: r.ok, version: r.version, error: r.error, code: r.code, })), } }, diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index f49a879c..14de4133 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -11,7 +11,8 @@ export const tasks = { list: (params?: { assigned?: 'me' | 'all'; status?: string; app_id?: string; run_id?: string; limit?: number }, opts?: RequestOptions) => get<{ items: Task[] }>(`/tasks${qs(params)}`, opts), /** 导航徽标:派给我的、待处理的待办数(按来源分:应用里的 / 不属于应用的) */ - count: (opts?: RequestOptions) => get<{ mine: number; apps: number; other: number }>('/tasks/count?mine=1', opts), + count: (opts?: RequestOptions) => + get<{ mine: number; apps: number; other: number; latest: string | null }>('/tasks/count?mine=1', opts), get: (id: string, opts?: RequestOptions) => get(at(id), opts), respond: (id: string, body: TaskRespond) => post<{ task: Task; resolved: boolean }>(`${at(id)}/respond`, body), reassign: (id: string, body: { to: { kind: 'user' | 'group'; name: string }[]; note?: string }) => diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 0d001237..54654413 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -107,6 +107,8 @@ function ProvidersTab() { const loaded = useCatalog((s) => s.loaded) const [catalog, setCatalog] = useState(null) const [editing, setEditing] = useState(null) + // 模型接入只有管理员能改(服务端 role_by_method(write=admin));开登录的搭建者只看,不出改动的按钮,免得点了才 403 + const canWrite = atLeast(usePrincipal(), 'admin') const loadCatalog = () => api.providers.catalog().then(setCatalog, () => {}) useEffect(() => { void loadCatalog() }, []) @@ -144,10 +146,13 @@ function ProvidersTab() { return (
- - + + {canWrite && ( + + )} {!loaded ? ( @@ -158,12 +163,14 @@ function ProvidersTab() { title="还没有配置模型" source="providers" body="接入 Anthropic、OpenAI 或任何 OpenAI 兼容的服务(DeepSeek、通义、本机 Ollama…)后,Agent 才能运行。" - action={} + action={canWrite + ? + : undefined} /> ) : (
{providers.map((p) => ( - setEditing(p)} onRemove={() => void remove(p)} /> ))}
@@ -189,8 +196,8 @@ function ProvidersTab() { ) } -function ProviderCard({ provider: p, kindLabel, onEdit, onRemove }: { - provider: Provider; kindLabel: string; onEdit: () => void; onRemove: () => void +function ProviderCard({ provider: p, kindLabel, canWrite, onEdit, onRemove }: { + provider: Provider; kindLabel: string; canWrite: boolean; onEdit: () => void; onRemove: () => void }) { const key = `provider:${p.id}` // 后端记着上次测的结果:换了浏览器、清了缓存也还在。本机刚测过的更新就用本机的 @@ -213,14 +220,16 @@ function ProviderCard({ provider: p, kindLabel, onEdit, onRemove }: { {!p.enabled && 已停用} -
- - - -
+ {canWrite && ( +
+ + + +
+ )}
{p.base_url && {p.base_url}} diff --git a/frontend/src/pages/apps/backfill.tsx b/frontend/src/pages/apps/backfill.tsx index f0e0b991..8bb0b749 100644 --- a/frontend/src/pages/apps/backfill.tsx +++ b/frontend/src/pages/apps/backfill.tsx @@ -10,7 +10,7 @@ import { useEffect, useState } from 'react' import { History } from 'lucide-react' import { api } from '../../api/client' -import type { BackfillPlan } from '../../api/apps' +import type { BackfillPlan } from '../../typesApp' import { Spinner, confirmDialog, toast } from '../../components/ui' import { APP_EDITOR_TEXT as T } from '../../lib/terms' import { useAtLeast } from '../../store/auth' @@ -121,7 +121,7 @@ export function BackfillButton({ appId, target, onDone }: { appId: string; targe setBusy(true) try { const res = await api.apps.backfill(appId, target.view) - toast.ok(T.backfillDone(res.started, res.skipped)) + toast.ok(T.backfillDone(res.launched, res.skipped)) if (res.refused) toast.warn(res.refused) onDone?.() } catch (e) { diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index d2bdc034..cb7def67 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { KeyRound, Plus } from 'lucide-react' import { api } from '../../../api/client' -import type { AppHookState } from '../../../api/apps' +import type { AppHookRow } from '../../../typesApp' import { CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast, } from '../../../components/ui' @@ -133,7 +133,7 @@ function Grants() { */ export function HooksSection() { const { appId, draft, setDraft, dirty, save } = useEditor() - const [rows, setRows] = useState(null) + const [rows, setRows] = useState(null) const [loadError, setLoadError] = useState(null) const [busy, setBusy] = useState(null) const [shown, setShown] = useState<{ name: string; secret: string; path: string | null } | null>(null) @@ -261,7 +261,7 @@ export function HooksSection() { /** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥 */ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, onRemove }: { name: string - state: AppHookState | undefined + state: AppHookRow | undefined declared: boolean busy: boolean loaded: boolean @@ -273,13 +273,13 @@ function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, o const has = !!state?.has_secret return (
+ data-app-hook-published={state?.in_published ? '1' : '0'}> {has ? T.hookSecretSet : T.hookSecretNone} {!declared ? {T.hookNotDeclared} - : state?.published ? {T.hookPublished} + : state?.in_published ? {T.hookPublished} : {T.hookNotPublished}} {has && ( diff --git a/frontend/src/pages/apps/edit/AppEditor.tsx b/frontend/src/pages/apps/edit/AppEditor.tsx index 5aa507ce..1411b9b6 100644 --- a/frontend/src/pages/apps/edit/AppEditor.tsx +++ b/frontend/src/pages/apps/edit/AppEditor.tsx @@ -20,8 +20,7 @@ import { matchShortcut } from '../../../lib/keys' import { useLeaveGuard } from '../../../lib/leave' import { APP_EDITOR_TEXT as T } from '../../../lib/terms' import { useCatalog } from '../../../store/catalog' -import type { AppDetailOut } from '../../../api/apps' -import type { AppBindings, AppSpec, ComponentCatalog } from '../../../typesApp' +import type { AppBindings, AppDetail, AppSpec, ComponentCatalog } from '../../../typesApp' import { EditorContext, type EditorState } from './context' import { PinsSection, PublishedVersionPicker } from './PinsSection' import { PagesSection } from './PagesSection' @@ -117,7 +116,7 @@ function EditApp({ appId }: { appId: string }) { const { section: raw } = useParams() const navigate = useNavigate() const section: Section = (SECTIONS as readonly string[]).includes(raw ?? '') ? raw as Section : 'pages' - const [detail, setDetail] = useState(null) + const [detail, setDetail] = useState(null) const [error, setError] = useState(null) const [draft, setDraftState] = useState(null) const [bindings, setBindingsState] = useState({ slots: {} }) @@ -136,7 +135,7 @@ function EditApp({ appId }: { appId: string }) { try { const d = await api.apps.get(appId) setDetail(d) - setDraftState(d.draft) + setDraftState(d.draft ?? null) setBindingsState(d.draft_bindings ?? { slots: {} }) setDirty(false) setError(null) diff --git a/frontend/src/pages/apps/edit/context.ts b/frontend/src/pages/apps/edit/context.ts index 00f1c0de..b561e433 100644 --- a/frontend/src/pages/apps/edit/context.ts +++ b/frontend/src/pages/apps/edit/context.ts @@ -1,12 +1,12 @@ import { createContext, useContext } from 'react' -import type { AppDetailOut } from '../../../api/apps' +import type { AppDetail } from '../../../typesApp' import type { AppBindings, AppSpec, ComponentCatalog } from '../../../typesApp' /** 应用编辑器各节共用的状态:草稿、岗位绑定、组件目录、保存、选中的组件、切到哪一节 */ export interface EditorState { appId: string /** 应用详情(manage):带着草稿、岗位绑定和能按新口径补算的视图 */ - detail: AppDetailOut + detail: AppDetail draft: AppSpec /** 改草稿:传函数拿到最新的那一份(助手的操作和人的编辑可能挨着到) */ setDraft: (fn: (spec: AppSpec) => AppSpec) => void diff --git a/frontend/src/store/catalog.ts b/frontend/src/store/catalog.ts index 3f1d1320..d65dc2ca 100644 --- a/frontend/src/store/catalog.ts +++ b/frontend/src/store/catalog.ts @@ -45,6 +45,8 @@ export interface TaskCounts { mine: number apps: number other: number + /** 其中最新一条的开出时间:数不变、却换了一条(一条处理掉、一条新来)时,新待办通知看它 */ + latest?: string | null } /** 心跳间隔:连着时复用 4 秒一次的待审批轮询,不另起请求 */ diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index b5ac4c40..4f8896ef 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -213,9 +213,36 @@ export interface AppSummary { } export interface AppDetail extends AppSummary { - draft: AppSpec - draft_bindings: AppBindings - created_by: string | null + /** 草稿和岗位绑定只给 manage(D-m);use / observe 拿到的详情里没有 */ + draft?: AppSpec + draft_bindings?: AppBindings + created_by?: string | null + /** 当前发布版的页面和入口目录:没有 manage 的人靠它找首页、入口名 */ + published?: { version: number; pages: { id: string; title: string }[]; entries: { id: string; label: string }[] } | null + /** 能按新口径补算的视图(只在 manage 取详情时算,D-f) */ + backfill?: BackfillPlan[] +} + +/** 按新口径补算会发起几次(GET /api/apps/{id}/views/{view}/backfill,不发起运行) */ +export interface BackfillPlan { + view: string + alias: string + /** schedule: 或 formal */ + source: string + from_version: number + to_version: number + /** 趋势画几期 */ + periods: number + will_launch: number + will_skip: number +} + +/** POST 同一路径的结果:发起了几次、跳过了几次(同一输入已经补算过);发起到一半被拒的原因照实交回 */ +export interface BackfillResult extends BackfillPlan { + launched: number + skipped: number + run_ids: string[] + refused?: string | null } export interface AppVersionRow { @@ -534,14 +561,23 @@ export interface LookEvent { // 入站钩子(§2.4)与助手流(§2.9 L1、§3.11) // --------------------------------------------------------------------------- +/** 钩子管理里的一行(D-g):草稿或发布版里声明的 ∪ 已经生成过密钥的,按名字一行 */ export interface AppHookRow { - id: string + /** app_hooks 那一行的 id;还没生成密钥时为 null */ + id: string | null name: string - kind: 'start' | 'signal' + kind: 'start' | 'signal' | null entry_id: string | null enabled: boolean - created_at: string - /** 只在新建和轮换时出现一次 */ + has_secret: boolean + /** 草稿的 spec.hooks 里有这个名字 */ + in_draft: boolean + /** 当前发布版里有(入站请求按发布版认种类和入口,没有就拒收) */ + in_published: boolean + /** 收请求的地址 /api/hooks/{id};还没生成密钥时为 null */ + url_path: string | null + created_at: string | null + /** 只在生成、轮换的回包里出现一次 */ secret?: string } @@ -568,3 +604,42 @@ export interface AppStreamFinal { issues: LintIssue[] explanation: string } + +// --------------------------------------------------------------------------- +// 能力字段语义的审阅(D-i,GET /api/capabilities/review-queue、POST /api/capabilities/review/batch) +// --------------------------------------------------------------------------- + +export interface CapabilityReviewRow { + capability: string + system: string + title: string + effect: CapabilityEffect + /** 返回里的位置(JSON 指针) */ + pointer: string + /** label / meaning / unit / measure / codes */ + field: string + field_text: string + /** 服务端写好的位置说法(「返回字段 /amount 的单位」) */ + field_label: string + value: unknown + display: string + source: string + status: string + note: string | null + priority: number + reasons: string[] + /** 用到这个能力的运行次数 */ + runs: number + /** 能力的治理版本:成批审阅时带上(乐观锁) */ + version: number +} + +export interface CapabilityReviewOutcome { + capability: string + pointer: string + field: string + ok: boolean + version: number | null + error: string | null + code: string | null +}