mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:31:59 +00:00
77fc0efa10
为什么:口径升版是被迫处置的事件(02 做法 7、拍板 L6),已交付的答案以前没人追;目录事实被驳回、表结构变了也没有地方标。
- ledger/uses.py(新):反查旧版本被谁用过——比对工件里等价已证明的问数回答(含轮次、对话主人)、W 的正式运行(定时任务、
应用各取最近 4 期)、固定了它的工作流版本发起的正式运行、固定这一版的应用、导出审计(导出人也是主人);预告、召回、
版本用量共用这一份
- ledger/recall.py(新):计划按(旧版本、新版本、input_key)去重成组,从已有召回运行重算(幂等、不建任务表);每组一次
召回运行(origin_kind=recall,探索运行),派生图经 derive.recall_graph(新旧两版取数和口径卡带前缀、召回卡里算两版之差);
不能重算照实记原因(数据部分不能复用、新版没这张卡、入口参数不齐、图里有人工节点或写入);每次发布最多 recall_limit 组,
超出的手动接着算;收尾时按渲染的旧值新值判数变没变:变了开 recall 待办(开登录按主人各一条、关登录派给任何人)、推
ledger.recall,没变在原答案上记 ledger.recalled;待办「按新版重新出具」走 issue.check_target 同一套判法、「知道了」
- 目录事实被驳回或修改(审阅、人工编辑、保存提案、批量审阅之后):冻结目录里看得到这一项的探索运行记 ledger.basis_changed,
按新目录多出错误级 SQL 检查问题的才开待办;表结构变动消费 source_changes(游标、处理时改了目录的当场交过去),标依据的
表结构已变,受影响的口径试算一次,算不出来才通知发布人
- 发布钩子在 commit_publish 提交之后排队(自动的几样和调度循环同一个开关),GET /api/workflows/{id}/recall-preview 给发布
弹窗预告;GET/POST /api/ledger/recall/{wf}/{v}[/continue] 给版本历史看批次、接着算;版本用量加问数回答、导出、召回
- ledger.recall 打开订阅(全局、个人渠道),同一渠道只推一次,消息不放数;新事件 ledger.recalled、ledger.basis_changed
登记为封存后追加的事件,可验证报告离线核对页认得;E4 每次发布召回了几组、数变了的比例
- runner 收尾时召回运行在后台收尾,启动时接着做没发完的批次
1278 lines
73 KiB
Python
1278 lines
73 KiB
Python
"""待办服务(附录 C,BE-2 提供)。
|
||
|
||
一次中断对应一条 Approval(给引擎)和一条 Task(给人)。所有人的决定都经 respond:按 policy 汇总,凑齐后在同一个
|
||
事务里用条件 UPDATE 把 Task 从 pending 改成 resolving,抢到的请求负责调 run_manager.resume。引擎和重放都不改。
|
||
|
||
- 种类按中断内容的 kind 认(人工节点按它的 mode),不看 Approval.mode:工具审批的中断里没有 mode,Approval.mode 一律是 approve。
|
||
- 派给谁:人工节点(和写入动作的调用工具节点)的 assignee。岗位按应用版本的 bindings 展开到人或组;派给组时在打开这一刻
|
||
展开成当时的成员(快照,写审计);没有应用绑定的运行(画布、定时任务)派给「任何人」,行为和以前一样。
|
||
- 会签只用于批准 / 驳回这一类:all 一票否决,quorum(m) 凑够 m 票通过、驳回超过 n − m 票否决。升级加派的那一份
|
||
(source=escalation)一人说了算:升级就是让上级拍板,不是再多一个要等的人。
|
||
- 关登录时谁都能回复(「都算」),回复人记自报的署名。开了登录:派给人的认用户名,派给组的认组,派给「任何人」的要
|
||
builder 以上(这类待办来自画布、定时任务,是给搭建者看的),等外部系统的只有管理员能代为处理。
|
||
- tick:提醒、升级(加派一份,escalation_level+1,只升一次)、超时驳回。永不自动批准。
|
||
- 蓝图的试运行(origin_kind=trial,docs/design/normal-user-apps.md N4):每一步都派给发起试运行的人自己,按真实设置显示
|
||
时限,但调度不对它们做提醒、升级、超时驳回——试运行不该把待办推到真实的处理人、上级那里。
|
||
- 个人应用提交审核(kind=review,N7):没有 Approval、不恢复运行,挂在个人应用最近一次成功的刷新上;派给选定的搭建者
|
||
或组。在待办里只能退回,同意要在共享副本上走发布单,发布完自动记为已发布(blueprints/personal.py)。
|
||
- 口径召回(kind=recall,docs/design/next-round/02-口径账本.md 做法 7):没有 Approval、不恢复运行。口径升版后数真变了的
|
||
挂在召回运行上,派给答案的主人(开登录时每人一条,关登录时任何人);动作「按新版重新出具」(走按口径出具同一套判法)
|
||
和「知道了」。答案依据的目录、表结构变了的另两种只有「知道了」。开、收尾、内容都在 app/ledger/recall.py。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass, replace
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import and_, exists, func, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.auth.directory import display_names
|
||
from app.core.events import EventType
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import (
|
||
App, Approval, AppVersion, AuditEvent, Group, GroupMember, Run, Task, TaskAssignment, User,
|
||
)
|
||
from app.engine.lint_process import normalize_policy, parse_duration
|
||
from app.process.events import record
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 中断内容的 kind → 待办种类。人工节点(human_node)按 mode,派给外部系统的是 signal
|
||
_KIND_OF = {"tool_approval": "tool", "code_approval": "code", "dual_check": "dual_check",
|
||
"action_confirm": "action_confirm"}
|
||
#: 结论只有批准 / 驳回两种、按会签策略汇总的种类。补填、改稿、双盲裁决、等外部系统只认第一份回复
|
||
VOTING = ("approve", "tool", "code", "action_confirm")
|
||
#: 核实待办的三种结论(同 actions.reconcile 的 outcome)
|
||
RECONCILE_OUTCOMES = ("confirmed", "not_applied_resend", "not_applied_abandon")
|
||
#: 超时驳回交给节点的那句备注
|
||
EXPIRED_NOTE = "超过时限无人处理,系统已自动驳回"
|
||
_TERMINAL_RUN = ("succeeded", "failed", "cancelled")
|
||
_STATUS_TEXT = {"resolving": "正在处理", "resolved": "已处理", "cancelled": "已关闭(所属的运行已结束)",
|
||
"expired": "已过期"}
|
||
|
||
|
||
class TaskRefused(Exception):
|
||
"""回复不被接受:不是派给你的、已经处理过了、哈希对不上……status_code 照 HTTP 语义(403 / 409 / 422)。"""
|
||
|
||
def __init__(self, message: str, *, status_code: int = 409, code: str | None = None) -> None:
|
||
super().__init__(message)
|
||
self.status_code = status_code
|
||
self.code = code
|
||
|
||
|
||
class FormInvalid(TaskRefused):
|
||
"""填写的值没有通过表单校验(422)。errors 的形状同 agentlab.form/1:{path, code, message}。"""
|
||
|
||
def __init__(self, errors: list[dict[str, Any]]) -> None:
|
||
first = errors[0].get("message") if errors else ""
|
||
super().__init__(f"表单没有通过校验:{first}" if first else "表单没有通过校验", status_code=422,
|
||
code="form_invalid")
|
||
self.errors = errors
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RawResponse:
|
||
"""老接口(/api/approvals/{id}/decide、/api/runs/{id}/resume)带来的原样回复:「始终允许」、改过的代码、双盲裁决的
|
||
选择都在里面,照原样交给节点。只当一票,结论按 read_decision 认。"""
|
||
|
||
response: Any
|
||
|
||
|
||
def kind_of(payload: dict[str, Any]) -> str:
|
||
kind = payload.get("kind")
|
||
if kind == "human_node":
|
||
if (payload.get("assignee") or {}).get("kind") == "hook":
|
||
return "signal"
|
||
mode = payload.get("mode") or "approve"
|
||
return mode if mode in ("approve", "input", "edit") else "approve"
|
||
return _KIND_OF.get(str(kind), "approve")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 打开
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def open_task(session: AsyncSession, approval: Any) -> Any:
|
||
"""为一条新的 Approval 建 Task 和派给谁(按人工节点配置 + 应用版本的岗位绑定)。runner._handle_updates 调。
|
||
|
||
和 Approval 同一个事务,调用方提交;task.opened 和通知在提交之后由 announce 发。同一条 Approval 已经有待办的直接返回。"""
|
||
existing = await session.scalar(select(Task).where(Task.approval_id == approval.id))
|
||
if existing is not None:
|
||
return existing
|
||
payload = approval.payload or {}
|
||
run = await session.get(Run, approval.run_id)
|
||
kind = kind_of(payload)
|
||
policy = (normalize_policy(payload.get("policy")) or {"kind": "any"}) if kind in VOTING else {"kind": "any"}
|
||
now = utcnow()
|
||
due = parse_duration(payload.get("due"))
|
||
timeout = payload.get("on_timeout") if isinstance(payload.get("on_timeout"), dict) else None
|
||
due_at = now + due if due else None
|
||
task = Task(
|
||
approval_id=approval.id, run_id=approval.run_id, node_id=approval.node_id or None,
|
||
app_id=run.app_id if run else None, app_version=run.app_version if run else None,
|
||
kind=kind, title=str(payload.get("title") or approval.title or "待处理")[:300],
|
||
form=payload.get("form") if isinstance(payload.get("form"), dict) else None,
|
||
policy=policy, due_at=due_at,
|
||
remind_at=due_at if timeout and timeout.get("action") == "remind" else None,
|
||
on_timeout=timeout if due_at else None,
|
||
correlation_key=(str(payload.get("correlation_key") or "")[:200] or None) if kind == "signal" else None,
|
||
)
|
||
session.add(task)
|
||
await session.flush()
|
||
await _assign(session, task, run, payload.get("assignee"))
|
||
return task
|
||
|
||
|
||
async def open_reconcile(session: AsyncSession, action: Any, *, title: str) -> Task:
|
||
"""结果未知、没有登记查询能力的动作:开一条核实待办,派给应用的 reconciler 岗位(没有就派给任何人)。
|
||
核实待办没有 Approval,按 correlation_key = action:<id> 找回动作。"""
|
||
run = await session.get(Run, action.run_id)
|
||
task = Task(run_id=action.run_id, node_id=action.node_id, app_id=run.app_id if run else None,
|
||
app_version=run.app_version if run else None, kind="reconcile", title=title[:300],
|
||
policy={"kind": "any"}, correlation_key=f"action:{action.id}")
|
||
session.add(task)
|
||
await session.flush()
|
||
await _assign(session, task, run, {"kind": "slot", "slot": "reconciler"})
|
||
return task
|
||
|
||
|
||
#: 个人应用提交审核的待办(docs/design/normal-user-apps.md N7)。没有 Approval,按 correlation_key = blueprint:<蓝图 id>
|
||
#: 找回提交审核的蓝图
|
||
REVIEW_KIND = "review"
|
||
|
||
|
||
async def open_review(session: AsyncSession, *, run: Run, app_id: str, blueprint_id: str, title: str,
|
||
to: list[tuple[str, str]]) -> Task:
|
||
"""个人应用提交审核:开一条 review 待办,派给选定的搭建者或组(组在这一刻展开成成员,同改派),来源记 review。
|
||
|
||
待办的 run_id 不能为空(带外键,开待办的事件、通知都按运行走):挂在个人应用最近一次成功的刷新上,只是外键挂靠——
|
||
个人应用的刷新只有拥有者读得到(auth/access.private_owner),处理人(搭建者、管理员也一样)读不到这次运行、打不开这个
|
||
个人应用;他要看结果,取共享副本(blueprints/personal.copy_for_review)在副本上试运行。这次运行已经结束,不会被恢复:
|
||
回复走 _respond_review,不调 run_manager.resume。调用方提交,提交后 announce。"""
|
||
task = Task(run_id=run.id, node_id=None, app_id=app_id, app_version=None, kind=REVIEW_KIND, title=title[:300],
|
||
policy={"kind": "any"}, correlation_key=f"blueprint:{blueprint_id}")
|
||
session.add(task)
|
||
await session.flush()
|
||
targets: list[tuple[str, str, str]] = []
|
||
for kind, name in dict.fromkeys(to):
|
||
targets += await _expand(session, task, kind, name, REVIEW_KIND)
|
||
for kind, name, source in dict.fromkeys(targets):
|
||
session.add(TaskAssignment(task_id=task.id, principal_kind=kind, principal=name, source=source))
|
||
await session.flush()
|
||
return task
|
||
|
||
|
||
#: 口径召回的待办(app/ledger/recall.py)。没有 Approval,按 correlation_key(来由:运行[:附加]:派给的种类:名字)认同一条
|
||
RECALL_KIND = "recall"
|
||
#: recall 待办的两种决定:按新版重新出具、知道了(目录、表结构那两种只有「知道了」)
|
||
RECALL_DECISIONS = ("reissue", "ack")
|
||
|
||
|
||
async def open_recall(session: AsyncSession, *, run: Run, title: str, owner: tuple[str, str],
|
||
correlation_key: str) -> Task:
|
||
"""开一条 recall 待办(挂在 run 上),派给 owner:人、组(在这一刻展开成成员,同改派)、任何人;来源记 recall。调用方
|
||
提交,提交后 announce。"""
|
||
task = Task(run_id=run.id, node_id=None, app_id=None, app_version=None, kind=RECALL_KIND, title=title[:300],
|
||
policy={"kind": "any"}, correlation_key=correlation_key[:200])
|
||
session.add(task)
|
||
await session.flush()
|
||
kind, name = owner
|
||
targets = await _expand(session, task, kind, name, RECALL_KIND) if kind in ("user", "group") and name \
|
||
else [("anyone", "", RECALL_KIND)]
|
||
for k, n, source in dict.fromkeys(targets):
|
||
session.add(TaskAssignment(task_id=task.id, principal_kind=k, principal=n, source=source))
|
||
await session.flush()
|
||
return task
|
||
|
||
|
||
async def review_handler(session: AsyncSession, task: Task, principal: Any) -> bool:
|
||
"""这个人是不是这条审核待办的处理人(派给他或他的组、没撤回,同 handles):只有他们能取个人蓝图的共享副本。"""
|
||
return handles((await _assignments(session, [task.id])).get(task.id, []), principal)
|
||
|
||
|
||
async def close_review(session: AsyncSession, blueprint_id: str, principal: Any, outcome: dict[str, Any], *,
|
||
require_handler: bool = False) -> str | None:
|
||
"""审核有了结果(副本发布了、或者退回了):把这个蓝图还在等的审核待办收掉(条件 UPDATE,和别人的回复抢同一行),
|
||
结论写进 outcome。require_handler 为真时只有处理人能收(退回),不是派给他的抛 403。返回待办 id,没有在等的返回
|
||
None。调用方提交。"""
|
||
task = await session.scalar(select(Task).where(Task.correlation_key == f"blueprint:{blueprint_id}",
|
||
Task.kind == REVIEW_KIND, Task.status == "pending"))
|
||
if task is None:
|
||
return None
|
||
rows = (await _assignments(session, [task.id])).get(task.id, [])
|
||
mine = next((a for a in rows if principal is not None and _matches(a, principal)), None)
|
||
if require_handler and mine is None:
|
||
raise TaskRefused("这条待办没有派给你", status_code=403, code="task_not_assigned")
|
||
now = utcnow()
|
||
username = getattr(principal, "username", None)
|
||
claimed = await session.execute(
|
||
update(Task).where(Task.id == task.id, Task.status == "pending")
|
||
.values(status="resolved", resolved_by=username, resolved_at=now, outcome=outcome))
|
||
if claimed.rowcount != 1:
|
||
return None
|
||
if mine is not None:
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.id == mine.id).values(
|
||
status="responded", responded_by=username, responded_at=now,
|
||
verified=bool(getattr(principal, "verified", False)),
|
||
response={"decision": outcome.get("decision"), "note": outcome.get("note") or ""}))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task.id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
return task.id
|
||
|
||
|
||
async def _assign(session: AsyncSession, task: Task, run: Run | None, assignee: Any, *,
|
||
source: str | None = None) -> list[TaskAssignment]:
|
||
rows = []
|
||
for kind, name, origin in await _targets(session, task, run, assignee):
|
||
row = TaskAssignment(task_id=task.id, principal_kind=kind, principal=name, source=source or origin)
|
||
session.add(row)
|
||
rows.append(row)
|
||
await session.flush()
|
||
return rows
|
||
|
||
|
||
#: 蓝图试运行的运行来源(engine/origin.py):待办派给发起人自己、调度不处理时限
|
||
TRIAL_ORIGIN = "trial"
|
||
#: 服务账号(users.kind,docs/design/next-round/04-对外集成与触达.md A1):只持有访问密钥、回复不了待办,所以不派给它——
|
||
#: 组展开时跳过、发起人是它时交给任何人、表达式里点了它的名字跳过、改派给它拒收
|
||
SERVICE_KIND = "service"
|
||
|
||
|
||
async def _service_accounts(session: AsyncSession, names: list[str]) -> set[str]:
|
||
"""这几个用户名里哪些是服务账号。"""
|
||
wanted = sorted({n for n in names if n})
|
||
if not wanted:
|
||
return set()
|
||
return set((await session.execute(select(User.username).where(
|
||
User.username.in_(wanted), User.kind == SERVICE_KIND))).scalars())
|
||
|
||
|
||
def key_trace(principal: Any) -> dict[str, Any]:
|
||
"""用访问密钥回复时 task.responded 另记的:via=key、哪一把、叫什么(auth/keys.trace)。别的认法什么都不加。"""
|
||
from app.auth.keys import trace
|
||
|
||
return trace(principal)
|
||
|
||
|
||
def _refuse_key(task: Task, principal: Any) -> None:
|
||
"""用访问密钥回复待办(04 细案 A4,集成-I3):只给个人访问密钥,只限审批、补充输入、编辑稿件三种。确认写入、核实写入
|
||
结果、双盲复算裁决、工具和代码审批要人在界面上看过确认卡再按,一律 403(key_task_kind)。表单里有文件控件的也不行
|
||
(文件要先在应用里上传,422 key_files_unsupported)。不是访问密钥认出来的不管。"""
|
||
if getattr(principal, "via", None) != "key":
|
||
return
|
||
from app.auth import keys
|
||
from app.files import store as files_store
|
||
|
||
if task.kind not in keys.TASK_KINDS:
|
||
raise TaskRefused("这种待办不能用访问密钥回复:确认写入、核实写入结果、双盲复算裁决、工具和代码审批要在界面上看过"
|
||
"确认卡再处理", status_code=403, code=keys.KEY_TASK_KIND)
|
||
if files_store.file_fields(task.form):
|
||
raise TaskRefused("这条待办的表单里要传文件,不能用访问密钥回复:请在应用里处理", status_code=422,
|
||
code=keys.KEY_FILES_UNSUPPORTED)
|
||
|
||
|
||
async def _targets(session: AsyncSession, task: Task, run: Run | None, assignee: Any) -> list[tuple[str, str, str]]:
|
||
"""(principal_kind, principal, source) 列表,去重。"""
|
||
spec = assignee if isinstance(assignee, dict) else {}
|
||
kind = spec.get("kind")
|
||
out: list[tuple[str, str, str]] = []
|
||
if run is not None and run.origin_kind == TRIAL_ORIGIN and kind != "hook":
|
||
# 试运行:不管图里派给谁(岗位、组、表达式),都派给发起试运行的人自己,来源记 trial。等外部系统的照旧等外部系统
|
||
return [("user", run.started_by, TRIAL_ORIGIN) if run.started_by else ("anyone", "", TRIAL_ORIGIN)]
|
||
if kind == "slot":
|
||
slot = str(spec.get("slot") or "")
|
||
bound = await _slot_binding(session, run, slot)
|
||
if bound:
|
||
for item in bound:
|
||
out += await _expand(session, task, str(item.get("kind") or "user"), str(item.get("name") or ""),
|
||
f"slot:{slot}")
|
||
else:
|
||
out.append(("anyone", "", f"slot:{slot}"))
|
||
elif kind == "initiator":
|
||
# 用服务账号的访问密钥发起的实例:服务账号不能被派待办(它回复不了),交给「任何人」(搭建者及以上能处理)
|
||
starter = run.started_by if run is not None and run.started_by \
|
||
and not await _service_accounts(session, [run.started_by]) else None
|
||
out.append(("user", starter, "initiator") if starter else ("anyone", "", "initiator"))
|
||
elif kind == "expression":
|
||
service = await _service_accounts(session, [str(n) for n in spec.get("names") or []])
|
||
for name in spec.get("names") or []:
|
||
if str(name) in service:
|
||
continue
|
||
is_group = await session.scalar(select(Group.id).where(Group.name == str(name)))
|
||
out += await _expand(session, task, "group" if is_group else "user", str(name), "expression")
|
||
elif kind == "hook":
|
||
out.append(("hook", "", "hook"))
|
||
out = [t for t in out if t[1] or t[0] in ("anyone", "hook")]
|
||
return list(dict.fromkeys(out)) or [("anyone", "", "")]
|
||
|
||
|
||
async def _slot_binding(session: AsyncSession, run: Run | None, slot: str) -> list[dict[str, Any]] | None:
|
||
"""应用版本里这个岗位绑定的人或组。没有应用(画布、定时任务)或岗位没绑定:None(派给任何人)。"""
|
||
if run is None or not run.app_id or run.app_version is None or not slot:
|
||
return None
|
||
bindings = await session.scalar(select(AppVersion.bindings).where(
|
||
AppVersion.app_id == run.app_id, AppVersion.version == run.app_version))
|
||
bound = ((bindings or {}).get("slots") or {}).get(slot)
|
||
return [b for b in bound if isinstance(b, dict)] if isinstance(bound, list) else None
|
||
|
||
|
||
async def _expand(session: AsyncSession, task: Task, kind: str, name: str, source: str) -> list[tuple[str, str, str]]:
|
||
"""组在这一刻展开成成员(快照):之后加入组的人不会补派。组里一个人都没有时仍派给组,开了登录后组里的人能处理。"""
|
||
if kind != "group":
|
||
return [("user", name, source)]
|
||
members = list((await session.execute(
|
||
select(User.username).join(GroupMember, GroupMember.user_id == User.id)
|
||
.join(Group, Group.id == GroupMember.group_id)
|
||
.where(Group.name == name, User.disabled.is_(False), func.coalesce(User.kind, "person") != SERVICE_KIND)
|
||
.order_by(User.username))).scalars())
|
||
if not members:
|
||
return [("group", name, source)]
|
||
session.add(AuditEvent(actor=None, verified=False, action="task.group_expanded", subject_kind="task",
|
||
subject_id=task.id, data={"group": name, "members": members, "source": source}))
|
||
return [("user", m, source) for m in members]
|
||
|
||
|
||
async def announce(task_id: str, event: EventType = EventType.TASK_OPENED) -> None:
|
||
"""待办提交之后:记 task.opened、按订阅推送通知。出错只记日志:开待办这件事已经成了。"""
|
||
try:
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
return
|
||
rows = await _assignments(session, [task.id])
|
||
await record(task.run_id, event, node_id=task.node_id, task_id=task.id, kind=task.kind, title=task.title,
|
||
assignees=[_who(a) for a in rows.get(task.id, [])], policy=task.policy,
|
||
due_at=_iso(task.due_at))
|
||
except Exception: # noqa: BLE001
|
||
logger.exception("待办 %s 打开后的事件没能记下", task_id)
|
||
return
|
||
from app.notify.service import spawn
|
||
|
||
users = [a.principal for a in rows.get(task_id, []) if a.principal_kind == "user"]
|
||
if task.kind == RECALL_KIND:
|
||
# 口径召回:口径升版、数变了的推 ledger.recall,订阅了 task.opened 的渠道另推一条(同一个渠道只推一次)
|
||
from app.notify.service import notify_recall
|
||
|
||
spawn(notify_recall(task_id, users))
|
||
return
|
||
# 推送放到后台:接收端挂住时超时加重试要几十秒,不能让运行停在「执行中」等它
|
||
spawn(_notify(task_id, "task.opened", users))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 回复
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _matches(a: TaskAssignment, principal: Any) -> bool:
|
||
return a.status == "pending" and _for(a, principal)
|
||
|
||
|
||
def _for(a: TaskAssignment, principal: Any) -> bool:
|
||
"""这一份派的是不是这个人(不看回没回复)。"""
|
||
if getattr(principal, "mode", "off") == "off":
|
||
return True
|
||
if a.principal_kind == "user":
|
||
return a.principal == principal.username
|
||
if a.principal_kind == "group":
|
||
return a.principal in (principal.groups or ())
|
||
if a.principal_kind == "anyone":
|
||
return principal.at_least("builder")
|
||
return a.principal_kind == "hook" and principal.at_least("admin")
|
||
|
||
|
||
def handles(rows: list[TaskAssignment], principal: Any) -> bool:
|
||
"""这个人是不是这条待办的处理人:有一份没撤回的派给他(已经回复过的也算)。关登录时人人都算。
|
||
|
||
实例页的待办组件按它分:处理人给待办卡(能处理的给表单),别人只看这一步的状态,不去取待办——待办里带着中断内容
|
||
(报告概况、动作预览),按 id 读只给处理人和搭建者以上(visible)。"""
|
||
return any(a.status != "withdrawn" and _for(a, principal) for a in rows)
|
||
|
||
|
||
def _pick(task: Task, rows: list[TaskAssignment], principal: Any) -> TaskAssignment:
|
||
"""这个人回复的是哪一份。同一个人在一条会签待办上只能投一票(关登录时按自报的署名认,没署名的认不出)。"""
|
||
name = principal.username
|
||
if name and task.kind in VOTING and any(a.status == "responded" and a.responded_by == name for a in rows):
|
||
raise TaskRefused("你已经回复过这条待办,正在等其他人处理", status_code=409, code="task_already_responded")
|
||
candidates = [a for a in rows if _matches(a, principal)]
|
||
if not candidates:
|
||
if not any(a.status == "pending" for a in rows):
|
||
raise TaskRefused("这条待办已经没有待回复的人,请刷新页面查看最新状态", status_code=409)
|
||
raise TaskRefused("这条待办没有派给你", status_code=403, code="task_not_assigned")
|
||
# 派给自己的那一份优先,其次是组,最后是「任何人」
|
||
order = {"user": 0, "group": 1, "hook": 2, "anyone": 3}
|
||
candidates.sort(key=lambda a: (0 if a.principal == name and a.principal_kind == "user" else 1,
|
||
order.get(a.principal_kind, 9)))
|
||
return candidates[0]
|
||
|
||
|
||
def _closed(status: str) -> str:
|
||
return f"这条待办{_STATUS_TEXT.get(status, '已处理')},请刷新页面查看最新状态"
|
||
|
||
|
||
def _verdict_of_raw(task: Task, response: Any) -> str:
|
||
from app.engine.approval import read_decision
|
||
|
||
decision = read_decision(response)
|
||
if task.kind in VOTING:
|
||
return "approve" if decision.approved else "reject"
|
||
# 补填、改稿只有明说了「不」才算驳回:那两种模式下裸字符串就是内容本身(同 human 节点)
|
||
return "reject" if isinstance(response, dict) and decision.rejected else "submit"
|
||
|
||
|
||
def _engine_response(task: Task, approval: Approval | None, decision: Any, values: dict[str, Any] | None,
|
||
note: str | None, payload_hash: str | None) -> tuple[Any, str]:
|
||
"""(交给节点的回复, 这一票的结论 approve / reject / submit)。"""
|
||
card = (approval.payload if approval is not None else None) or {}
|
||
if isinstance(decision, RawResponse):
|
||
response = decision.response
|
||
if task.kind == "action_confirm":
|
||
given = response.get("payload_hash") if isinstance(response, dict) else None
|
||
_check_hash(card, given, approving=_verdict_of_raw(task, response) == "approve")
|
||
return response, _verdict_of_raw(task, response)
|
||
|
||
values = dict(values or {})
|
||
text = note or ""
|
||
if task.kind in VOTING:
|
||
if decision not in ("approve", "reject"):
|
||
raise TaskRefused("这条待办只能批准或驳回", status_code=422, code="task_decision_invalid")
|
||
out: dict[str, Any] = {"approved": decision == "approve", "note": text}
|
||
if task.kind == "tool":
|
||
if isinstance(values.get("args"), dict):
|
||
out["args"] = values["args"]
|
||
if values.get("always") is True:
|
||
out["always"] = True
|
||
elif task.kind == "code" and isinstance(values.get("code"), str) and values["code"]:
|
||
out["code"] = values["code"]
|
||
elif task.kind == "action_confirm":
|
||
_check_hash(card, payload_hash, approving=decision == "approve")
|
||
out["payload_hash"] = payload_hash
|
||
elif task.form and decision == "approve" and values:
|
||
out["value"] = _validated(task.form, values)
|
||
return out, decision
|
||
|
||
if decision not in ("submit", "approve", "reject"):
|
||
raise TaskRefused("这条待办只能提交或驳回", status_code=422, code="task_decision_invalid")
|
||
if decision == "reject":
|
||
return {"approved": False, "note": text}, "reject"
|
||
if task.form:
|
||
value: Any = _validated(task.form, values)
|
||
elif set(values) == {"value"}:
|
||
value = values["value"] # 老写法:一个输入框的内容
|
||
else:
|
||
value = values or None
|
||
return {"approved": True, "note": text, "value": value}, "submit"
|
||
|
||
|
||
def _check_hash(card: dict[str, Any], given: Any, *, approving: bool) -> None:
|
||
"""动作确认按引用确认:确认卡上的哈希原样带回来,和这张卡对得上才算数(驳回不用带)。"""
|
||
if not approving:
|
||
return
|
||
if not given:
|
||
raise TaskRefused("确认发送需要带上确认卡上的内容指纹,请刷新页面后重新确认", status_code=422,
|
||
code="payload_hash_missing")
|
||
if given != card.get("payload_hash"):
|
||
raise TaskRefused("你确认的内容和现在待发送的不一致(内容指纹对不上),请刷新页面查看最新的确认卡",
|
||
status_code=422, code="payload_hash_mismatch")
|
||
|
||
|
||
async def _check_principals(session: AsyncSession, task: Task, principal: Any, values: dict[str, Any]) -> None:
|
||
"""待办表单里的选人控件:开了登录时按候选池核对(同入口表单,auth/directory.principal_errors)。不属于应用的待办
|
||
(试运行、画布)没有池,不核对。"""
|
||
from app.auth.directory import principal_errors
|
||
|
||
if not task.app_id or getattr(principal, "mode", "off") == "off":
|
||
return
|
||
app = await session.get(App, task.app_id)
|
||
if app is None:
|
||
return
|
||
bindings = await session.scalar(select(AppVersion.bindings).where(
|
||
AppVersion.app_id == task.app_id, AppVersion.version == task.app_version)) if task.app_version else None
|
||
if errors := await principal_errors(session, app, bindings, task.form, values, principal.mode):
|
||
raise FormInvalid(errors)
|
||
|
||
|
||
def _validated(form: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
|
||
from app.apps.forms import validate_form
|
||
|
||
if errors := validate_form(form, values):
|
||
raise FormInvalid([dict(e) for e in errors])
|
||
return values
|
||
|
||
|
||
def _tally(task: Task, rows: list[TaskAssignment], mine: TaskAssignment, verdict: str) -> str | None:
|
||
"""凑齐了没有:返回结论(approve / reject / submit),还要等别人返回 None。"""
|
||
policy = task.policy or {"kind": "any"}
|
||
if task.kind not in VOTING or policy.get("kind", "any") == "any" or mine.source == "escalation":
|
||
return verdict
|
||
voters = [a for a in rows if a.status != "withdrawn" and a.source != "escalation"]
|
||
n = len(voters)
|
||
said = [((a.response or {}).get("decision")) for a in voters if a.status == "responded"]
|
||
yes, no = said.count("approve"), said.count("reject")
|
||
if policy["kind"] == "all":
|
||
return "reject" if no else "approve" if yes >= n else None
|
||
m = min(int(policy.get("m") or 1), n)
|
||
return "approve" if yes >= m else "reject" if no > n - m else None
|
||
|
||
|
||
def progress(task: Task, rows: list[TaskAssignment]) -> dict[str, Any] | None:
|
||
"""会签进度(给人看的那一份,界面照着画):只算批准 / 驳回这一类。口径同 _tally——升级加派的那一份不算进会签人数,
|
||
它一人说了算;以前界面把它和原来的人算在一起,升级后写「0/3 已同意」,其实总监一人同意就通过。
|
||
|
||
voters 是会签的人数:待处理时按没撤回的算;判定之后剩下没回复的都撤回了,按判定那一刻记下的人数(outcome.voters),
|
||
老数据没记的按回复过的算。escalation 是升级加派的那几份:升级给的岗位、派给了谁、谁一人定了什么。"""
|
||
if task.kind not in VOTING:
|
||
return None
|
||
policy = task.policy or {"kind": "any"}
|
||
own = [a for a in rows if a.source != "escalation"]
|
||
said = [(a.response or {}).get("decision") for a in own if a.status == "responded"]
|
||
recorded = (task.outcome or {}).get("voters")
|
||
if task.status == "pending":
|
||
voters = len([a for a in own if a.status != "withdrawn"])
|
||
else:
|
||
voters = recorded if isinstance(recorded, int) else max(len(said), 1)
|
||
kind = policy.get("kind", "any")
|
||
need = voters if kind == "all" else min(int(policy.get("m") or 1), voters) if kind == "quorum" else 1
|
||
out: dict[str, Any] = {"policy": kind, "voters": voters, "need": need, "agreed": said.count("approve"),
|
||
"rejected": said.count("reject")}
|
||
escalated = [a for a in rows if a.source == "escalation"]
|
||
if escalated:
|
||
decider = next((a for a in escalated if a.status == "responded"), None)
|
||
out["escalation"] = {"slot": str((task.on_timeout or {}).get("to_slot") or "").strip() or None,
|
||
"assignees": [_who(a) for a in escalated],
|
||
"decided": (decider.response or {}).get("decision") if decider else None,
|
||
"by": decider.responded_by if decider else None}
|
||
return out
|
||
|
||
|
||
def _signoffs(rows: list[TaskAssignment]) -> list[dict[str, Any]]:
|
||
return [{"by": a.responded_by, "decision": (a.response or {}).get("decision"),
|
||
"note": (a.response or {}).get("note") or "", "at": _iso(a.responded_at), "verified": a.verified,
|
||
"source": a.source}
|
||
for a in sorted((a for a in rows if a.status == "responded"), key=lambda a: a.responded_at or utcnow())]
|
||
|
||
|
||
async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[str, Any] | None = None,
|
||
note: str | None = None, payload_hash: str | None = None) -> dict[str, Any]:
|
||
"""一个人的回复。返回 {task, resolved: bool, run?}。
|
||
|
||
decision:approve / reject(批准类)、submit / reject(补填、改稿、双盲裁决)、核实待办的三种结论;老接口传
|
||
RawResponse。values:表单的值,工具审批改过的参数(args)、「始终允许」(always),代码审批改过的代码(code),
|
||
核实「已生效」时的外部单号(external_id)。"""
|
||
from app.engine.inquiry import BUSY_DECIDE, INQUIRY_BUSY, inquiry_busy
|
||
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
raise TaskRefused("待办不存在,所属的运行可能已被删除", status_code=404)
|
||
_refuse_key(task, principal)
|
||
if task.kind == "reconcile":
|
||
return await _respond_reconcile(session, task, principal, decision, values, note)
|
||
if task.kind == REVIEW_KIND:
|
||
return await _respond_review(session, task, principal, decision, note)
|
||
if task.kind == RECALL_KIND:
|
||
return await _respond_recall(session, task, principal, decision, note)
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
if inquiry_busy(task.run_id):
|
||
raise TaskRefused(BUSY_DECIDE, code=INQUIRY_BUSY)
|
||
approval = await session.get(Approval, task.approval_id) if task.approval_id else None
|
||
rows = (await _assignments(session, [task.id])).get(task.id, [])
|
||
mine = _pick(task, rows, principal)
|
||
response, verdict = _engine_response(task, approval, decision, values, note, payload_hash)
|
||
if task.form and verdict in ("approve", "submit") and values:
|
||
await _check_principals(session, task, principal, values)
|
||
response = await _bind_files(session, task, principal, response)
|
||
now = utcnow()
|
||
marked = await session.execute(
|
||
update(TaskAssignment).where(TaskAssignment.id == mine.id, TaskAssignment.status == "pending")
|
||
.values(status="responded", responded_by=principal.username, responded_at=now,
|
||
verified=bool(principal.verified), response={"decision": verdict, "note": note or ""}))
|
||
if marked.rowcount != 1:
|
||
await session.rollback()
|
||
raise TaskRefused("这一份已经有人回复过,请刷新页面查看最新状态")
|
||
rows = (await _assignments(session, [task.id], fresh=True)).get(task.id, [])
|
||
mine = next(a for a in rows if a.id == mine.id)
|
||
decided = _tally(task, rows, mine, verdict)
|
||
voters = [a for a in rows if a.status != "withdrawn"]
|
||
responded = sum(1 for a in voters if a.status == "responded")
|
||
final: Any = None
|
||
if decided is not None:
|
||
final = response
|
||
if task.kind in VOTING and (task.policy or {}).get("kind", "any") != "any":
|
||
# 会签:交给节点的是汇总的结论和明细,human.resolved 里写得出谁、怎么决定的
|
||
signoffs = _signoffs(rows)
|
||
final = {"approved": decided == "approve",
|
||
"note": ";".join(s["note"] for s in signoffs if s["note"]),
|
||
"signoffs": signoffs, "policy": task.policy}
|
||
# 会签人数记在结论里:判定之后没回复的都撤回了,进度要按判定那一刻的人数说(progress)
|
||
claimed = await session.execute(
|
||
update(Task).where(Task.id == task.id, Task.status == "pending")
|
||
.values(status="resolving", resolved_by=principal.username,
|
||
outcome={"decision": decided, "by_assignment": mine.id, "signoffs": _signoffs(rows),
|
||
"voters": len([a for a in voters if a.source != "escalation"])}))
|
||
if claimed.rowcount != 1:
|
||
await session.rollback()
|
||
raise TaskRefused(_closed("resolving"))
|
||
await session.commit()
|
||
run_id, approval_id, node_id = task.run_id, task.approval_id, task.node_id
|
||
|
||
await record(run_id, EventType.TASK_RESPONDED, node_id=node_id, task_id=task_id, actor=principal.username,
|
||
verified=bool(principal.verified), decision=verdict, index=responded, of=len(voters),
|
||
**key_trace(principal))
|
||
if decided is None:
|
||
return {"task": await detail(task_id, principal), "resolved": False, "run": await _run(run_id)}
|
||
|
||
from app.engine.runner import run_manager
|
||
|
||
try:
|
||
run = await run_manager.resume(run_id, final, approval_id=approval_id, actor=principal.username)
|
||
except (ValueError, KeyError) as e:
|
||
await _unclaim(task_id, mine.id)
|
||
raise TaskRefused(str(e.args[0]) if e.args else str(e)) from e
|
||
except Exception as e: # noqa: BLE001 - 判定之后恢复不成:不管是什么错,待办都不能卡在 resolving
|
||
# 以前只接 ValueError / KeyError:别的异常(库被锁、检查点读不出……)直接冒上去,待办停在 resolving,再也没人
|
||
# 能回复,只有重启时 startup 才收拾。这里一律退回待处理、这一票撤回,原因写进日志、交回调用方
|
||
logger.exception("待办 %s 判定后恢复运行 %s 出错", task_id, run_id)
|
||
closed = await _unclaim(task_id, mine.id)
|
||
from app.core.errors import describe_exception
|
||
|
||
after = "运行已经结束,这条待办随之关闭" if closed else "你的回复没有生效,待办已退回待处理,请稍后重试"
|
||
raise TaskRefused(f"恢复运行时出错:{describe_exception(e)}。{after}", status_code=500,
|
||
code="task_resume_failed") from e
|
||
await _settle(task_id)
|
||
await record(run_id, EventType.TASK_RESOLVED, node_id=node_id, task_id=task_id, decision=decided,
|
||
actor=principal.username, signoffs=_signoffs(rows))
|
||
return {"task": await detail(task_id, principal), "resolved": True, "run": run}
|
||
|
||
|
||
async def _respond_reconcile(session: AsyncSession, task: Task, principal: Any, decision: Any,
|
||
values: dict[str, Any] | None, note: str | None) -> dict[str, Any]:
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
if decision not in RECONCILE_OUTCOMES:
|
||
raise TaskRefused("核实只能选已生效、未生效重新发送、未生效放弃之一", status_code=422,
|
||
code="task_decision_invalid")
|
||
rows = (await _assignments(session, [task.id])).get(task.id, [])
|
||
_pick(task, rows, principal)
|
||
action_id = (task.correlation_key or "").removeprefix("action:")
|
||
task_id = task.id
|
||
await session.rollback()
|
||
from app.process.actions import reconcile
|
||
|
||
out = await reconcile(action_id, principal, outcome=decision,
|
||
external_id=str((values or {}).get("external_id") or "") or None, note=note)
|
||
return {"task": await detail(task_id, principal), "resolved": True, "run": out.get("run")}
|
||
|
||
|
||
async def _bind_files(session: AsyncSession, task: Task, principal: Any, response: Any) -> Any:
|
||
"""回复里表单的文件(docs/design/normal-user-apps.md N9):核对是这个人在这条待办上传的、还没用过,绑到这次运行,
|
||
和回复一起提交;交给节点的值换成登记的那一份引用。引用不对按表单错误交回,落到那个字段上。"""
|
||
from app.files import store as files_store
|
||
|
||
value = response.get("value") if isinstance(response, dict) else None
|
||
if not isinstance(value, dict) or not files_store.file_fields(task.form):
|
||
return response
|
||
try:
|
||
bound = await files_store.bind_in(session, task.form, value, principal=principal, run_id=task.run_id,
|
||
context={"task_id": task.id})
|
||
except files_store.FileRefused as e:
|
||
await session.rollback()
|
||
raise FormInvalid([{"path": e.field or "", "code": files_store.FILE_ERROR_CODE, "message": e.message}]) from e
|
||
return {**response, "value": bound}
|
||
|
||
|
||
async def _respond_review(session: AsyncSession, task: Task, principal: Any, decision: Any,
|
||
note: str | None) -> dict[str, Any]:
|
||
"""审核待办(个人应用提交审核)在待办里只能退回:同意要取共享副本、在副本上走发布单,发布单做完自动记为已发布
|
||
(blueprints/personal.review_published)。退回的结论、蓝图状态和通知由 blueprints/personal.reject_review 做。"""
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
if decision != "reject":
|
||
raise TaskRefused("审核个人应用不在这里同意:请取一份共享副本,在副本上用真数试一次、走发布单,发布之后这条待办"
|
||
"自动记为已发布;不发布请退回", status_code=422, code="task_decision_invalid")
|
||
task_id, run_id = task.id, task.run_id
|
||
await session.rollback()
|
||
from app.blueprints.personal import reject_review
|
||
|
||
await reject_review(task_id, principal, note=note)
|
||
return {"task": await detail(task_id, principal), "resolved": True, "run": await _run(run_id)}
|
||
|
||
|
||
async def _respond_recall(session: AsyncSession, task: Task, principal: Any, decision: Any,
|
||
note: str | None) -> dict[str, Any]:
|
||
"""recall 待办:「按新版重新出具」(app/ledger/recall.reissue,走按口径出具同一套判法;不能出具照实拒绝,待办留着)或
|
||
「知道了」。处理完记成已处理(条件 UPDATE,同 close_review),结论和出具的那次正式运行写进 outcome,另记一条审计:运行
|
||
已经封存,事件记不进去。"""
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
reason = (task.correlation_key or "").split(":", 1)[0]
|
||
allowed = RECALL_DECISIONS if reason == "caliber" else ("ack",)
|
||
if decision not in allowed:
|
||
raise TaskRefused("这条待办只能「按新版重新出具」或「知道了」" if reason == "caliber"
|
||
else "这条待办只能「知道了」", status_code=422, code="task_decision_invalid")
|
||
rows = (await _assignments(session, [task.id])).get(task.id, [])
|
||
mine_id = _pick(task, rows, principal).id
|
||
task_id, run_id = task.id, task.run_id
|
||
await session.rollback()
|
||
outcome: dict[str, Any] = {"decision": decision, "note": (note or "").strip()[:2000]}
|
||
issued: Any = None
|
||
if decision == "reissue":
|
||
from app.ledger import recall
|
||
|
||
try:
|
||
issued = await recall.reissue(task_id, principal)
|
||
except recall.RecallRefused as e:
|
||
raise TaskRefused(e.message, status_code=e.status_code, code=e.code) from e
|
||
outcome["issued_run"] = issued.id
|
||
async with SessionLocal() as session:
|
||
now = utcnow()
|
||
claimed = await session.execute(
|
||
update(Task).where(Task.id == task_id, Task.status == "pending")
|
||
.values(status="resolved", resolved_by=principal.username, resolved_at=now, outcome=outcome))
|
||
if claimed.rowcount != 1:
|
||
await session.rollback()
|
||
raise TaskRefused(_closed("resolved"))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.id == mine_id).values(
|
||
status="responded", responded_by=principal.username, responded_at=now, verified=bool(principal.verified),
|
||
response={"decision": decision, "note": outcome["note"]}))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
session.add(AuditEvent(actor=principal.username, verified=bool(principal.verified),
|
||
action=f"ledger.recall.{decision}", subject_kind="task", subject_id=task_id,
|
||
data={"run_id": run_id, **({"issued_run": issued.id} if issued is not None else {}),
|
||
**key_trace(principal)}))
|
||
await session.commit()
|
||
return {"task": await detail(task_id, principal), "resolved": True, "run": await _run(run_id)}
|
||
|
||
|
||
async def close_reconcile(session: AsyncSession, action_id: str, principal: Any, outcome: str,
|
||
note: str | None) -> str | None:
|
||
"""核实之后把这个动作的核实待办收掉(条件 UPDATE,和 tick、别人的回复抢同一行)。返回待办 id,没有返回 None。"""
|
||
task = await session.scalar(select(Task).where(Task.correlation_key == f"action:{action_id}",
|
||
Task.kind == "reconcile", Task.status == "pending"))
|
||
if task is None:
|
||
return None
|
||
now = utcnow()
|
||
username = getattr(principal, "username", None)
|
||
claimed = await session.execute(
|
||
update(Task).where(Task.id == task.id, Task.status == "pending")
|
||
.values(status="resolved", resolved_by=username, resolved_at=now, outcome={"decision": outcome}))
|
||
if claimed.rowcount != 1:
|
||
return None
|
||
rows = (await _assignments(session, [task.id])).get(task.id, [])
|
||
mine = next((a for a in rows if principal is not None and _matches(a, principal)), None)
|
||
if mine is not None:
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.id == mine.id).values(
|
||
status="responded", responded_by=username, responded_at=now,
|
||
verified=bool(getattr(principal, "verified", False)), response={"decision": outcome, "note": note or ""}))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task.id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
return task.id
|
||
|
||
|
||
async def _unclaim(task_id: str, assignment_id: str) -> bool:
|
||
"""恢复没成:运行已经结束的,待办随之关闭;还停着的,退回 pending,这一票撤回、可以再回复。返回是不是关闭了。"""
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
status = await session.scalar(select(Run.status).where(Run.id == task.run_id)) if task else None
|
||
if task is None:
|
||
return True
|
||
if status in _TERMINAL_RUN or status is None:
|
||
task.status = "cancelled"
|
||
task.resolved_at = utcnow()
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
else:
|
||
task.status, task.outcome, task.resolved_by = "pending", None, None
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.id == assignment_id)
|
||
.values(status="pending", response=None, responded_by=None, responded_at=None,
|
||
verified=False))
|
||
await session.commit()
|
||
closed = status in _TERMINAL_RUN or status is None
|
||
logger.warning("待办 %s 判定后恢复运行没有成功,已%s", task_id, "随运行关闭" if closed else "退回待处理")
|
||
return closed
|
||
|
||
|
||
async def _settle(task_id: str) -> None:
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(Task).where(Task.id == task_id, Task.status == "resolving")
|
||
.values(status="resolved", resolved_at=utcnow()))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
await session.commit()
|
||
|
||
|
||
async def _run(run_id: str) -> Run | None:
|
||
async with SessionLocal() as session:
|
||
return await session.get(Run, run_id)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 改派、催办
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def _can_manage(session: AsyncSession, task: Task, principal: Any) -> bool:
|
||
"""改派、催办:关登录或管理员都行;builder 要有这个应用的 manage 授权(或者是应用的创建者);没有应用的待办 builder 就行。"""
|
||
if getattr(principal, "mode", "off") == "off" or principal.at_least("admin"):
|
||
return True
|
||
if not principal.at_least("builder"):
|
||
return False
|
||
if not task.app_id:
|
||
return True
|
||
from app.db.models import AppGrant
|
||
|
||
app = await session.get(App, task.app_id)
|
||
if app is not None and app.created_by and app.created_by == principal.username:
|
||
return True
|
||
grant = await session.scalar(select(AppGrant.id).where(
|
||
AppGrant.app_id == task.app_id, AppGrant.role == "manage",
|
||
or_(and_(AppGrant.principal_kind == "user", AppGrant.principal == principal.username),
|
||
and_(AppGrant.principal_kind == "group", AppGrant.principal.in_(list(principal.groups or ()))))))
|
||
return grant is not None
|
||
|
||
|
||
async def reassign(task_id: str, principal: Any, *, to: list[dict[str, str]], note: str | None = None) -> Any:
|
||
"""改派:撤回还没回复的那几份,派给新的人或组(组照样按当时的成员展开)。已经回复的票留着。返回待办详情。"""
|
||
targets = [(str(t.get("kind") or ""), str(t.get("name") or "").strip()) for t in to or []]
|
||
if not targets or any(k not in ("user", "group") or not n for k, n in targets):
|
||
raise TaskRefused("改派需要选择至少一位用户或一个组", status_code=422, code="reassign_empty")
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
raise TaskRefused("待办不存在,所属的运行可能已被删除", status_code=404)
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
if not await _can_manage(session, task, principal):
|
||
raise TaskRefused("只有管理员或这个应用的管理者可以改派待办", status_code=403)
|
||
if service := await _service_accounts(session, [n for k, n in targets if k == "user"]):
|
||
raise TaskRefused(f"「{'、'.join(sorted(service))}」是服务账号,不能被派待办:请改派给人或组", status_code=422,
|
||
code="reassign_service")
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id == task_id,
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
added: list[TaskAssignment] = []
|
||
for kind, name in dict.fromkeys(targets):
|
||
for k, n, _ in await _expand(session, task, kind, name, "reassign"):
|
||
row = TaskAssignment(task_id=task_id, principal_kind=k, principal=n, source="reassign")
|
||
session.add(row)
|
||
added.append(row)
|
||
await session.commit()
|
||
run_id, node_id = task.run_id, task.node_id
|
||
await record(run_id, EventType.TASK_REASSIGNED, node_id=node_id, task_id=task_id, actor=principal.username,
|
||
to=[_who(a) for a in added], note=note or "")
|
||
from app.notify.service import spawn
|
||
|
||
spawn(_notify(task_id, "task.reassigned", [a.principal for a in added if a.principal_kind == "user"]))
|
||
return await detail(task_id, principal)
|
||
|
||
|
||
async def remind(task_id: str, principal: Any) -> dict[str, Any]:
|
||
"""催办:给还没回复的人再推一次通知。返回 {sent: 推送了几条}。"""
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
raise TaskRefused("待办不存在,所属的运行可能已被删除", status_code=404)
|
||
if task.status != "pending":
|
||
raise TaskRefused(_closed(task.status))
|
||
if not await _can_manage(session, task, principal):
|
||
raise TaskRefused("只有管理员或这个应用的管理者可以催办", status_code=403)
|
||
rows = (await _assignments(session, [task_id])).get(task_id, [])
|
||
users = [a.principal for a in rows if a.status == "pending" and a.principal_kind == "user"]
|
||
await record(task.run_id, EventType.TASK_REMINDED, node_id=task.node_id, task_id=task_id,
|
||
actor=principal.username, to=[_who(a) for a in rows if a.status == "pending"])
|
||
return {"sent": await _notify(task_id, "task.reminded", users)}
|
||
|
||
|
||
async def _notify(task_id: str, event: str, users: list[str]) -> int:
|
||
try:
|
||
from app.notify.service import notify_task
|
||
|
||
return await notify_task(task_id, event, users=users)
|
||
except Exception: # noqa: BLE001 - 通知推不出去不影响待办本身
|
||
logger.exception("待办 %s 的通知(%s)没能推送", task_id, event)
|
||
return 0
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 时限:提醒、升级、超时驳回
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def tick(now: datetime | None = None) -> list[str]:
|
||
"""调度循环每一拍:提醒、升级、超时驳回(永不自动批准)。交回这一拍处理过的待办 id(生效可查:办了哪几条,
|
||
不只是几条;测试按自己造的那条断言,不受同一个库里别的待办影响)。试运行的待办不处理(只显示时限)。"""
|
||
now = now or utcnow()
|
||
action = func.json_extract(Task.on_timeout, "$.action")
|
||
trial = select(Run.id).where(Run.origin_kind == TRIAL_ORIGIN)
|
||
async with SessionLocal() as session:
|
||
due = list((await session.execute(select(Task.id).where(
|
||
Task.status == "pending", Task.run_id.not_in(trial), or_(
|
||
and_(Task.remind_at.is_not(None), Task.remind_at <= now),
|
||
and_(Task.due_at.is_not(None), Task.due_at <= now, action == "reject"),
|
||
and_(Task.due_at.is_not(None), Task.due_at <= now, action == "escalate", Task.escalation_level == 0),
|
||
)).order_by(Task.due_at))).scalars())
|
||
done: list[str] = []
|
||
for task_id in due:
|
||
try:
|
||
if await _on_timeout(task_id, now):
|
||
done.append(task_id)
|
||
except Exception: # noqa: BLE001 - 一条出错不拖住这一拍的其他待办
|
||
logger.exception("待办 %s 的超时处理失败,下一拍再试", task_id)
|
||
return done
|
||
|
||
|
||
async def _on_timeout(task_id: str, now: datetime) -> int:
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None or task.status != "pending":
|
||
return 0
|
||
timeout = task.on_timeout or {}
|
||
action = timeout.get("action")
|
||
if action == "remind" and task.remind_at is not None and _aware(task.remind_at) <= now:
|
||
every = parse_duration(timeout.get("every"))
|
||
claimed = await session.execute(update(Task).where(
|
||
Task.id == task_id, Task.status == "pending", Task.remind_at == task.remind_at)
|
||
.values(remind_at=now + every if every else None))
|
||
await session.commit()
|
||
if claimed.rowcount != 1:
|
||
return 0
|
||
rows = (await _assignments(session, [task_id])).get(task_id, [])
|
||
pending = [a for a in rows if a.status == "pending"]
|
||
await record(task.run_id, EventType.TASK_REMINDED, node_id=task.node_id, task_id=task_id, actor=None,
|
||
overdue=True, to=[_who(a) for a in pending])
|
||
from app.notify.service import spawn
|
||
|
||
spawn(_notify(task_id, "task.reminded", [a.principal for a in pending if a.principal_kind == "user"]))
|
||
return 1
|
||
if action == "escalate" and task.escalation_level == 0:
|
||
# 和派生、检查认的是同一个岗位名(apps/tree.slot_refs 去掉了首尾空白)
|
||
slot = str(timeout.get("to_slot") or "").strip()
|
||
claimed = await session.execute(update(Task).where(
|
||
Task.id == task_id, Task.status == "pending", Task.escalation_level == 0)
|
||
.values(escalation_level=Task.escalation_level + 1))
|
||
if claimed.rowcount != 1:
|
||
await session.rollback()
|
||
return 0
|
||
run = await session.get(Run, task.run_id)
|
||
added = await _assign(session, task, run, {"kind": "slot", "slot": slot}, source="escalation")
|
||
await session.commit()
|
||
await record(task.run_id, EventType.TASK_ESCALATED, node_id=task.node_id, task_id=task_id, slot=slot,
|
||
level=1, to=[_who(a) for a in added])
|
||
from app.notify.service import spawn
|
||
|
||
spawn(_notify(task_id, "task.escalated", [a.principal for a in added if a.principal_kind == "user"]))
|
||
return 1
|
||
if action != "reject" or task.due_at is None or _aware(task.due_at) > now:
|
||
return 0
|
||
rows = (await _assignments(session, [task_id])).get(task_id, [])
|
||
voters = len([a for a in rows if a.status != "withdrawn" and a.source != "escalation"])
|
||
claimed = await session.execute(update(Task).where(Task.id == task_id, Task.status == "pending")
|
||
.values(status="resolving",
|
||
outcome={"decision": "expired_reject", "voters": voters}))
|
||
await session.commit()
|
||
if claimed.rowcount != 1:
|
||
return 0
|
||
run_id, approval_id, node_id = task.run_id, task.approval_id, task.node_id
|
||
await record(run_id, EventType.TASK_EXPIRED, node_id=node_id, task_id=task_id, action="reject")
|
||
from app.engine.runner import run_manager
|
||
|
||
try:
|
||
# expired:节点据此把这次驳回记成超时驳回(engine/approval.Decision.expired → NodeRejected.decision)
|
||
await run_manager.resume(run_id, {"approved": False, "note": EXPIRED_NOTE, "expired": True},
|
||
approval_id=approval_id)
|
||
except (ValueError, KeyError):
|
||
logger.warning("待办 %s 超时驳回时运行已无法恢复", task_id)
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(Task).where(Task.id == task_id).values(status="cancelled",
|
||
resolved_at=utcnow()))
|
||
await session.commit()
|
||
return 1
|
||
except Exception: # noqa: BLE001 - 同 respond:别的错不能让待办卡在 resolving,退回待处理,下一拍再驳回
|
||
logger.exception("待办 %s 超时驳回时恢复运行出错,退回待处理,下一拍再试", task_id)
|
||
async with SessionLocal() as session:
|
||
await session.execute(update(Task).where(Task.id == task_id, Task.status == "resolving")
|
||
.values(status="pending", outcome=None))
|
||
await session.commit()
|
||
return 0
|
||
await _settle(task_id)
|
||
await record(run_id, EventType.TASK_RESOLVED, node_id=node_id, task_id=task_id, decision="expired_reject",
|
||
actor=None)
|
||
return 1
|
||
|
||
|
||
def _aware(moment: datetime) -> datetime:
|
||
from datetime import timezone
|
||
|
||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 启动时整理、运行结束时收尾
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def startup() -> int:
|
||
"""启动时:存量的待审批补建 Task(派给任何人,幂等);上次停在 resolving 的(判定之后、恢复之前进程没了)按 Approval
|
||
的状态收拾——还在等的退回 pending、撤回那一票,已经交给引擎的记成已处理。返回补建的条数。"""
|
||
async with SessionLocal() as session:
|
||
orphans = list((await session.execute(
|
||
select(Approval).outerjoin(Task, Task.approval_id == Approval.id)
|
||
.where(Approval.status == "pending", Task.id.is_(None)))).scalars())
|
||
for approval in orphans:
|
||
await open_task(session, approval)
|
||
stuck = list((await session.execute(select(Task).where(Task.status == "resolving"))).scalars())
|
||
for task in stuck:
|
||
waiting = task.approval_id and await session.scalar(
|
||
select(Approval.status).where(Approval.id == task.approval_id)) == "pending"
|
||
if waiting:
|
||
assignment = (task.outcome or {}).get("by_assignment")
|
||
task.status, task.outcome, task.resolved_by = "pending", None, None
|
||
if assignment:
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.id == assignment).values(
|
||
status="pending", response=None, responded_by=None, responded_at=None, verified=False))
|
||
else:
|
||
task.status, task.resolved_at = "resolved", task.resolved_at or utcnow()
|
||
await session.commit()
|
||
if orphans:
|
||
logger.info("为 %d 条存量的待审批补建了待办", len(orphans))
|
||
return len(orphans)
|
||
|
||
|
||
async def close_for_run(session: AsyncSession, run_id: str, now: datetime) -> int:
|
||
"""运行结束(失败、取消)时,它还没处理的待办一并关闭。和关审批同一个事务(runner._close_approvals 调)。"""
|
||
ids = list((await session.execute(select(Task.id).where(Task.run_id == run_id, Task.status == "pending")))
|
||
.scalars())
|
||
if not ids:
|
||
return 0
|
||
await session.execute(update(Task).where(Task.id.in_(ids)).values(status="cancelled", resolved_at=now))
|
||
await session.execute(update(TaskAssignment).where(TaskAssignment.task_id.in_(ids),
|
||
TaskAssignment.status == "pending")
|
||
.values(status="withdrawn"))
|
||
return len(ids)
|
||
|
||
|
||
async def task_for_approval(approval_id: str) -> str | None:
|
||
"""老接口用:这条审批对应的待办 id。还没有(开待办时出过错、老数据没补建)就现在补一条。"""
|
||
async with SessionLocal() as session:
|
||
task_id = await session.scalar(select(Task.id).where(Task.approval_id == approval_id))
|
||
if task_id:
|
||
return task_id
|
||
approval = await session.get(Approval, approval_id)
|
||
if approval is None:
|
||
return None
|
||
task = await open_task(session, approval)
|
||
await session.commit()
|
||
return task.id
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 读
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _who(a: TaskAssignment) -> dict[str, str]:
|
||
return {"kind": a.principal_kind, "name": a.principal}
|
||
|
||
|
||
def _iso(moment: datetime | None) -> str | None:
|
||
return _aware(moment).isoformat() if moment is not None else None
|
||
|
||
|
||
async def _assignments(session: AsyncSession, task_ids: list[str], *, fresh: bool = False
|
||
) -> dict[str, list[TaskAssignment]]:
|
||
if not task_ids:
|
||
return {}
|
||
stmt = select(TaskAssignment).where(TaskAssignment.task_id.in_(task_ids)).order_by(TaskAssignment.created_at)
|
||
if fresh:
|
||
stmt = stmt.execution_options(populate_existing=True)
|
||
out: dict[str, list[TaskAssignment]] = {}
|
||
for row in (await session.execute(stmt)).scalars():
|
||
out.setdefault(row.task_id, []).append(row)
|
||
return out
|
||
|
||
|
||
def _mine(principal: Any) -> Any:
|
||
"""「派给我的」:有一份没撤回的派给我(off 模式下全部都算)。"""
|
||
if getattr(principal, "mode", "off") == "off":
|
||
return None
|
||
conds = []
|
||
if principal.username:
|
||
conds.append(and_(TaskAssignment.principal_kind == "user", TaskAssignment.principal == principal.username))
|
||
if principal.groups:
|
||
conds.append(and_(TaskAssignment.principal_kind == "group",
|
||
TaskAssignment.principal.in_(list(principal.groups))))
|
||
if principal.at_least("builder"):
|
||
conds.append(TaskAssignment.principal_kind == "anyone")
|
||
if principal.at_least("admin"):
|
||
conds.append(TaskAssignment.principal_kind == "hook")
|
||
if not conds:
|
||
return Task.id.is_(None)
|
||
return exists().where(TaskAssignment.task_id == Task.id, TaskAssignment.status != "withdrawn", or_(*conds))
|
||
|
||
|
||
async def list_tasks(principal: Any, *, assigned: str = "me", statuses: list[str] | None = None,
|
||
app_id: str | None = None, run_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||
async with SessionLocal() as session:
|
||
stmt = select(Task).order_by(Task.created_at.desc()).limit(limit)
|
||
if statuses:
|
||
stmt = stmt.where(Task.status.in_(statuses))
|
||
if app_id:
|
||
stmt = stmt.where(Task.app_id == app_id)
|
||
if run_id:
|
||
stmt = stmt.where(Task.run_id == run_id)
|
||
if assigned == "me" and (clause := _mine(principal)) is not None:
|
||
stmt = stmt.where(clause)
|
||
tasks = list((await session.execute(stmt)).scalars())
|
||
return await _describe(session, tasks, principal)
|
||
|
||
|
||
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(), func.max(Task.created_at)).where(Task.status == "pending")
|
||
if (clause := _mine(principal)) is not None:
|
||
stmt = stmt.where(clause)
|
||
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:
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
return None
|
||
return (await _describe(session, [task], principal))[0]
|
||
|
||
|
||
async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> list[dict[str, Any]]:
|
||
"""Task 的接口形状(frontend/src/typesApp.ts 的 Task)。payload 原样是中断内容,核实待办换成那个动作的预览。"""
|
||
ids = [t.id for t in tasks]
|
||
assignments = await _assignments(session, ids)
|
||
approvals = {a.id: a for a in (await session.execute(
|
||
select(Approval).where(Approval.id.in_([t.approval_id for t in tasks if t.approval_id])))).scalars()}
|
||
runs = {r.id: r for r in (await session.execute(
|
||
select(Run.id, Run.workflow_name, Run.graph, Run.origin, Run.origin_kind)
|
||
.where(Run.id.in_({t.run_id for t in tasks})))).all()}
|
||
apps = dict((await session.execute(select(App.id, App.name).where(
|
||
App.id.in_({t.app_id for t in tasks if t.app_id})))).all())
|
||
names = await display_names(session, [u for t in tasks for u in people_of(t, assignments.get(t.id, []))])
|
||
out = []
|
||
for task in tasks:
|
||
rows = assignments.get(task.id, [])
|
||
payload = dict(approvals[task.approval_id].payload or {}) if task.approval_id in approvals else {}
|
||
if task.kind == "reconcile":
|
||
payload = await _reconcile_payload(session, task)
|
||
elif task.kind == REVIEW_KIND:
|
||
from app.blueprints.personal import review_payload
|
||
|
||
payload = await review_payload(session, task)
|
||
elif task.kind == RECALL_KIND:
|
||
from app.ledger.recall import task_payload
|
||
|
||
payload = await task_payload(session, task, principal)
|
||
run = runs.get(task.run_id)
|
||
out.append({
|
||
"id": task.id, "approval_id": task.approval_id, "run_id": task.run_id, "node_id": task.node_id,
|
||
"node_label": _node_label(run.graph if run else None, task.node_id),
|
||
"app_id": task.app_id, "app_name": apps.get(task.app_id), "app_version": task.app_version,
|
||
"workflow_name": run.workflow_name if run else None,
|
||
# 实例的标题(应用入口按 instance_title 起的):同一个人工节点的待办标题一样,列表里靠它分开
|
||
"instance_title": str((run.origin or {}).get("title") or "") or None if run else None,
|
||
"kind": task.kind, "title": task.title,
|
||
"form": task.form, "policy": task.policy or {"kind": "any"}, "status": task.status,
|
||
"due_at": _iso(task.due_at), "escalation_level": task.escalation_level, "payload": payload,
|
||
"assignments": [{
|
||
"id": a.id, "principal_kind": a.principal_kind, "principal": a.principal, "source": a.source,
|
||
"status": a.status, "responded_by": a.responded_by, "responded_at": _iso(a.responded_at),
|
||
"decision": (a.response or {}).get("decision"), "verified": a.verified} for a in rows],
|
||
"can_respond": task.status == "pending" and any(_matches(a, principal) for a in rows)
|
||
and not _voted(task, rows, principal),
|
||
"progress": progress(task, rows),
|
||
"my_response": my_response(rows, principal),
|
||
"outcome": task.outcome, "resolved_by": task.resolved_by, "resolved_at": _iso(task.resolved_at),
|
||
"created_at": _iso(task.created_at),
|
||
# 这条待办上出现的人的显示名(派给谁、谁回复、谁处理、升级给谁):界面写显示名,悬停给用户名
|
||
"people": {u: names[u] for u in people_of(task, rows) if u in names},
|
||
# 蓝图试运行的待办:派给了发起试运行的人自己,界面上写「以试运行身份同意 / 驳回」
|
||
**({"trial": True} if run is not None and run.origin_kind == TRIAL_ORIGIN else {}),
|
||
})
|
||
return out
|
||
|
||
|
||
def people_of(task: Task, rows: list[TaskAssignment]) -> set[str]:
|
||
"""待办上按用户名出现的人:派给的人、回复的人、处理的人。"""
|
||
return ({a.principal for a in rows if a.principal_kind == "user"} | {a.responded_by for a in rows if a.responded_by}
|
||
| ({task.resolved_by} if task.resolved_by else set()))
|
||
|
||
|
||
def visible(item: dict[str, Any], principal: Any) -> bool:
|
||
"""这条待办(_describe 的形状)这个人看不看得到:关登录、builder 以上都看得到;user 角色只看派给自己(或自己的组)的,
|
||
撤回了的不算。待办里带着中断内容(报告概况、动作预览),不能谁都能按 id 读。"""
|
||
if getattr(principal, "mode", "off") == "off" or principal.at_least("builder"):
|
||
return True
|
||
return any(a["status"] != "withdrawn" and (
|
||
(a["principal_kind"] == "user" and a["principal"] == principal.username)
|
||
or (a["principal_kind"] == "group" and a["principal"] in (principal.groups or ())))
|
||
for a in item["assignments"])
|
||
|
||
|
||
def my_response(rows: list[TaskAssignment], principal: Any) -> dict[str, Any] | None:
|
||
"""这个人在这条待办上的回复(决定、时间、是否登录核实):处理过的人再打开,写「你已处理:同意(时间)」,不写
|
||
「没有派给你」。认不出是谁(关登录又没署名)的是 None。"""
|
||
name = getattr(principal, "username", None)
|
||
mine = next((a for a in rows if name and a.status == "responded" and a.responded_by == name), None)
|
||
if mine is None:
|
||
return None
|
||
return {"decision": (mine.response or {}).get("decision"), "at": _iso(mine.responded_at), "verified": mine.verified}
|
||
|
||
|
||
def _voted(task: Task, rows: list[TaskAssignment], principal: Any) -> bool:
|
||
name = getattr(principal, "username", None)
|
||
return bool(name) and task.kind in VOTING and any(a.status == "responded" and a.responded_by == name
|
||
for a in rows)
|
||
|
||
|
||
def _node_label(graph: dict[str, Any] | None, node_id: str | None) -> str | None:
|
||
for n in (graph or {}).get("nodes") or []:
|
||
if isinstance(n, dict) and n.get("id") == node_id:
|
||
return ((n.get("data") or {}).get("label")) or node_id
|
||
return None
|
||
|
||
|
||
async def _reconcile_payload(session: AsyncSession, task: Task) -> dict[str, Any]:
|
||
from app.db.models import ActionRecord
|
||
|
||
action = await session.get(ActionRecord, (task.correlation_key or "").removeprefix("action:"))
|
||
if action is None:
|
||
return {"kind": "reconcile"}
|
||
return {"kind": "reconcile", "action_id": action.id, "capability": action.capability,
|
||
"environment": action.environment, "payload_hash": action.payload_hash, "preview": action.preview,
|
||
"attempt": action.attempt, "idem_key": action.idem_key, "state": action.state, "error": action.error,
|
||
"request_at": _iso(action.request_at), "options": list(RECONCILE_OUTCOMES)}
|
||
|
||
|
||
def principal_with_actor(principal: Any, actor: str | None) -> Any:
|
||
"""关登录时请求的身份没有名字:用自报的署名(X-Actor)补上,回复人一栏才写得出人。"""
|
||
if getattr(principal, "mode", "off") == "off" and not principal.username and actor:
|
||
return replace(principal, username=actor)
|
||
return principal
|