aps-agent/server/agent_core/harness.py

1007 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 门禁 Harness v1(moduleId: core-harness, 可重生 ✅)
# plan.md §3.3:所有写操作的唯一入口。M1 实现:
# - 权力分级:P0/P1 放行;P2 生成确认卡(人工批准后才执行)
# - 待确认队列:confirmId → 待执行动作(会话内存)
# - 执行/驳回均写审计(§3.6)
# ============================================================
from __future__ import annotations # 前向类型引用
import copy # 冻结审批参数
import hashlib # 审批参数摘要
import hmac # 常量时间比较确认信封摘要
import json # 稳定序列化审批参数
import os # TTL 配置
import time # 审批/凭据到期时间
import uuid # 确认卡 ID
from collections.abc import Callable # 类型标注
from typing import Any
from server.agent_core.approval_store import (
ApprovalBackend,
ApprovalStore,
get_approval_store,
set_approval_store,
utc_now,
)
from server.contracts import UIAction, UIBlock # 确认卡的 UI 块契约
_approval_store = get_approval_store()
# Compatibility aliases for existing diagnostics and tests. All mutations are
# persisted by the public Harness operations below.
_pending = getattr(_approval_store, "pending", {})
_execution_grants = getattr(_approval_store, "grants", {})
def _params_fingerprint(params: dict[str, Any]) -> str:
payload = json.dumps(
params,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _confirmation_envelope_fingerprint(record: dict[str, Any]) -> str:
projection = {
key: copy.deepcopy(record.get(key))
for key in (
"sessionId",
"action",
"power",
"paramsHash",
"tenantUuid",
"projectId",
"worldKey",
"evidenceRefs",
"beforeSnapshot",
"beforeFingerprint",
"requester",
"requiredApprovals",
)
}
payload = json.dumps(
projection,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def verify_confirmation_envelope(record: dict[str, Any]) -> None:
params = record.get("params")
if not isinstance(params, dict):
raise PermissionError("确认信封参数无效,请重新发起确认")
params_hash = str(record.get("paramsHash") or "")
if not params_hash or not hmac.compare_digest(params_hash, _params_fingerprint(params)):
raise PermissionError("确认信封参数摘要不一致,请重新发起确认")
expected = str(record.get("envelopeHash") or "")
actual = _confirmation_envelope_fingerprint(record)
if not expected or not hmac.compare_digest(expected, actual):
raise PermissionError("确认信封完整性校验失败,请重新发起确认")
action = str(record.get("action") or "")
power = str(record.get("power") or "")
if not action or power != power_of(action):
raise PermissionError("确认信封动作或权力等级不一致,请重新发起确认")
# 世界指纹:只对业务主数据计算,排除 append-only 日志/审计/同步流水(审批与执行之间这些字段会合法增长)
_VOLATILE_WORLD_KEYS = frozenset({"auditEvents", "logs", "mesSyncJournal"})
def world_fingerprint(world: dict[str, Any]) -> str:
"""输入快照指纹:稳定投影的规范 JSON SHA256(漂移检测用)。
排除 append-only 字段(auditEvents/logs/mesSyncJournal),使「审批期间审计自然增长」
不会误报漂移;任何业务主数据变化都会改变指纹。
"""
projection = {
k: v for k, v in (world or {}).items() if k not in _VOLATILE_WORLD_KEYS
}
payload = json.dumps(
projection,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
_EVIDENCE_VERSION_PREFIX = "schedule-version:"
def evidence_refs_for(params: dict[str, Any]) -> list[str]:
"""版本证据协议:参数含 versionId 的动作,统一生成 schedule-version:<id> 证据引用。"""
version_id = (params or {}).get("versionId")
if version_id is None:
return []
return [f"{_EVIDENCE_VERSION_PREFIX}{version_id}"]
def _ttl_seconds(env_name: str, default: int) -> int:
try:
return max(1, int(os.environ.get(env_name) or default))
except ValueError:
return default
def _identity_ref() -> dict[str, Any]:
from server.auth.context import get_identity
identity = get_identity()
return {
"userId": str(identity.user_id),
"username": identity.username,
"fullname": identity.fullname,
"roles": list(identity.roles),
"authKind": identity.auth_kind,
}
def _approver_key(approver: dict[str, Any]) -> str:
return str(approver.get("userId") or approver.get("username") or "")
def _configured_roles(env_name: str, default: str) -> set[str]:
raw = os.environ.get(env_name) or default
return {value.strip().lower() for value in raw.split(",") if value.strip()}
def _can_approve_current(action: str, power: str) -> bool:
return _current_identity_has_approval_role("approve", action, power)
def _can_initiate_current(action: str, power: str) -> bool:
return _current_identity_has_approval_role("initiate", action, power)
def require_can_initiate(action: str) -> None:
"""Fail-fast approval-initiation gate for staged actions.
Call this before allocating checkpoints or performing any other staging-side
effect. The check uses the current authenticated identity and the action's
configured power level; denial is fail-closed and leaves staging untouched.
"""
power = power_of(action)
if not _can_initiate_current(action, power):
raise PermissionError("当前身份没有发起审批的角色权限")
def _can_view_approval_history(action: str, power: str) -> bool:
return _current_identity_has_approval_role("history", action, power)
def _effective_world_key(project_id: str, owner_user_id: int) -> str:
if project_id in ("default", "", "__personal__") and owner_user_id:
return f"personal-{owner_user_id}"
return project_id or "default"
def configure_approval_store(
path: str | None = None, *, store: ApprovalBackend | None = None
) -> ApprovalBackend:
"""Replace the process-local store, primarily for isolated runtimes/tests."""
global _approval_store, _pending, _execution_grants
if path is not None and store is not None:
raise ValueError("path and store are mutually exclusive")
_approval_store = set_approval_store(ApprovalStore(path) if path else store)
# Transitional diagnostics for the file backend only. Application code must
# use ApprovalBackend operations because database state is never a mapping.
_pending = getattr(_approval_store, "pending", {})
_execution_grants = getattr(_approval_store, "grants", {})
return _approval_store
# 动作 → 权力等级 映射(封闭表:未登记的动作一律拒绝)
# ⚠ 文档同步铁律(plan.md §12.9):改本表必同步 docs/architecture/harness.md 权力矩阵
_POWER_MAP: dict[str, str] = {
"schedule.run": "P1", # 试排:写草稿版本(沙盒语义)
"schedule.publish": "P2", # 发布:写主干世界状态 → 必须人工确认
"order.upsert": "P2", # 订单新增/编辑:写主干订单池 → 必须人工确认
"order.cancel": "P2", # 订单取消:影响后续排产输入 → 必须人工确认
"order.complete": "P2", # 订单完成:影响后续排产输入 → 必须人工确认
"order.delete": "P2", # 订单删除:物理移除订单池 → 必须人工确认
"order.clear": "P2", # 一键清空订单池 → 必须人工确认
"order.submit": "P1", # OR-03:提交审核(不写批准态)
"order.approve": "P2", # OR-03:批准进排产池
"order.reject": "P2", # OR-03:驳回
"order.pool": "P0", # OR-03:订单池摘要
"rush.evaluate": "P1", # OR-04:插单影响快评(沙盒)
"rush.apply": "P2", # OR-04:采用插单写主干+草稿排产
"forecast.query": "P0", # OR-05:预测订单只读
"forecast.upsert": "P2", # OR-05:新建/编辑预测
"forecast.delete": "P2", # OR-05:删除预测
"forecast.convert": "P2", # OR-05:预测转正为销售订单
"plan.buckets": "P0", # PL-01:时间分桶计划(只读)
"plan.rccp": "P0", # PL-02:有限/无限粗能力对照(只读)
"plan.feasibility": "P0", # PL-03:交期可行性(只读)
"plan.inventory": "P0", # PL-04:库存投影(只读)
"plan.leveling": "P0", # PL-05:产能平衡/削峰建议(只读)
"plan.supply": "P0", # PL-06:产供方向决策(只读)
"data.import": "P2", # 批量导入订单/物料:写主干 → 必须人工确认
"import.commit": "P2", # Excel/CSV 导入入库:写主干 → 必须人工确认
"folder.analyze": "P0", # 工程目录深度解析:只读
"folder.schedule": "P2", # 导入工程目录并试排
"flex.site.load": "P2", # 现场生产路线:替换 flex* 主数据 → 确认卡
"master.line.upsert": "P2", # 产线编辑/停启用:改后续新排产资源池 → 必须人工确认
"drawing.master.apply": "P2", # 图纸候选入库:解析候选写入物料/BOM/工艺主数据 → 必须人工确认
"master.material.upsert": "P2", # 物料新建/全字段/停用:改齐套与产品池 → 必须人工确认
"master.maintenance.upsert": "P2", # 维保新增/取消:改设备避让窗口 → 必须人工确认
"master.bom.upsert": "P2", # BOM 增删改:改齐套需求与采购建议 → 必须人工确认
"master.routing.upsert": "P2", # 工艺路线/步骤:改占槽工时与外协标记 → 必须人工确认
"master.operation.upsert": "P2", # 工序库:改路线可选步骤 → 必须人工确认
"master.lineProduct.upsert": "P2", # 产线-产品绑定:改选线依据 → 必须人工确认
"master.changeover.upsert": "P2", # MD-06:换型矩阵写入 → 必须人工确认
"master.workstation.upsert": "P2", # 工位新增/编辑/停用:改资源池 → 必须人工确认
"master.equipment.upsert": "P2", # 设备新增/编辑/停用:改资源池 → 必须人工确认
"master.bom.release": "P2", # BOM 版本发布:生成不可变快照 → 必须人工确认
"master.bom.rollback": "P2", # BOM 版本回滚:恢复已发布快照 → 必须人工确认
"master.routing.release": "P2", # 工艺路线版本发布:生成不可变快照 → 必须人工确认
"master.routing.rollback": "P2", # 工艺路线版本回滚:恢复已发布快照 → 必须人工确认
"master.calendar.holiday.upsert": "P2", # 节假日新增/编辑:改后续工作日历 → 必须人工确认
"master.calendar.template.create": "P2", # 日历周模板新建:定义班次/节假日 → 必须人工确认
"master.calendar.week.copy": "P2", # 复制周模板生成多周班次 → 必须人工确认
"master.clear": "P2", # 一键清理主数据板块(回种)→ 必须人工确认
"master.query": "P0", # 自然语言检索订单/主数据(只读)
"changeover.query": "P0", # MD-06:换型矩阵只读
"campaign.preview": "P0", # SC-08:战役合并预览(只读)
"params.update": "P2", # OR-02:排产参数/客户等级权重 → 必须人工确认
"params.query": "P0", # OR-02:查看当前排产参数(只读)
"param.optimize": "P1", # 矩阵 87:参数优化闭环 split→回放→候选(GRAY 提案,不写主干参数)
"param.experiment.promote": "P2", # 矩阵 87:参数实验升级 GRAY→ACTIVE/FULL(写 scheduleParams)→ 确认卡
"param.experiment.rollback": "P1", # 矩阵 87:参数实验手动回滚:恢复上一参数版本
"param.observation.record": "P1", # 矩阵 87:线上观测回调:观测落盘 + 连续劣化自动回滚
"constraint.profile.query": "P0", # SC-04:约束剖面只读
"constraint.profile.save": "P2", # SC-04:约束剖面保存
"governance.rule.create": "P2", # 治理规则创建:确认后写规则注册表
"governance.rule.update": "P2", # 治理规则更新:确认后替换规则定义
"governance.rule.delete": "P2", # 治理规则删除:确认后移除规则定义
"governance.rule.enable": "P2", # 治理规则启用:确认后参与自动化 tick
"governance.rule.disable": "P2", # 治理规则停用:确认后退出自动化 tick
"order.decompose": "P1", # 订单分解:产出采购/委外建议草稿,不碰主数据
"mesh.agent.create": "P1", # 多智能体:按需创建智能体(只写编排仓)
"mesh.goal.create": "P1", # 多智能体:挂 Goal + 任务清单(只写编排仓)
"mesh.dispatch": "P1", # 多智能体:分发执行(任务自带权力经 handle_intent)
"mesh.reset": "P1", # 多智能体:恢复起始状态(清空编排仓)
"mrp.release": "P2", # MRP 下达:草稿建议单转正式采购/委外 → 必须人工确认
"flex.schedule": "P1", # 柔性排产:能力池+虚拟产线,只写 flex* 草稿表
"flex.reschedule": "P2", # 分级重排 L2/L3/L4:影响面大 → 确认卡
"flex.swap": "P1", # L1 局部换机:只改正目标工单
"flex.capacity": "P0", # 瓶颈产能评估:只读能力池算日产能
"flex.simulate_due": "P0", # 交期承诺模拟:深拷贝沙盒试排,不碰主干
"flex.compare": "P0", # 三模式对比:沙盒试排,不碰主干
"flex.rush": "P1", # 紧急插单:写 flexOrders + 重排草稿
"flex.fault": "P1", # 设备故障:缩池 + 可选重排
"flex.resource.patch": "P1", # 柔性资源补丁:设备状态/区域、模具寿命/锁定
"flex.conflict.resolve": "P1", # EX-03:冲突安全修复(重排类另走 P2 stage)
"conflict.list": "P0", # EX-03:冲突中心只读
"flex.adjust.preview": "P0", # EX-04:拖拽调程预览(只读校验)
"flex.adjust.commit": "P2", # EX-04:拖拽调程提交(新草稿版本)
"schedule.adjust.preview": "P0", # EX-04:固定轨调程预览
"schedule.adjust.commit": "P2", # EX-04:固定轨调程提交
"sap.status": "P0", # MD-05:SAP 连接状态(只读)
"sap.sync.inbound": "P2", # MD-05:SAP→APS 拉单/库存
"sap.sync.outbound": "P3", # External SAP write: two approvers plus one-time grant
"mes.status": "P0", # EX-05:MES 连接状态
"mes.dispatch": "P3", # EX-05:下发车间(外部副作用)
"mes.report": "P1", # EX-09:报工回流写工单进度
"scenario.compare": "P1", # 方案对比:只写 Explore 沙盒(§5.1)
"scenario.sensitivity": "P1", # SC-06:敏感性分析沙盒 Tornado
"sop.compile": "P0", # IND-02:SOP 编译预览
"sop.apply": "P2", # IND-02:应用 SOP 规则包
"checkpoint.create": "P1", # 建档:只新增快照,不改世界
"checkpoint.rollback": "P2", # 回滚:整体替换主干世界 → 必须人工确认
"data.reset": "P2", # 重置数据:破坏性 → 必须人工确认
"readiness.query": "P0", # 数据齐备度检查:只读
"data.analyze": "P0", # 分析项目/文件数据与排产缺口:只读
"assistant.reply": "P0", # 通用助理:只读作答
"flex.time.update": "P2", # 工时维护:改柔性路线工时 → 确认卡
"knowledge.query": "P0", # 知识检索:只读(M3 §8.2)
"knowledge.import": "P2", # RAG:导入知识文档切块入库
"skill.list": "P0", # 外部算法 skill 清单
"skill.health": "P0", # skill 健康检查
"skill.register": "P2", # 登记/更新 skill 配置
"skill.enable": "P2", # 启停 skill
"rag.query": "P0", # RAG 查询:外部 skill 消费知识库(只读,带鉴权)
"routing.template.apply": "P2", # 行业模板生成工艺路线 → 确认卡
"schedule.wizard": "P0", # 引导式排产向导(对话态,本身只读)
"guidance.next": "P0", # AG-07:主动引导下一步建议(只读)
"project.create": "P1", # AG-08:新建项目(不改世界)
"project.delete": "P1", # AG-08:删项目元数据(不删世界)
"session.create": "P1", # AG-08:新建会话
"message.append": "P1", # AG-08:追加/同步会话消息
"message.replace": "P1", # AG-08:整包替换会话消息
"workspace.replace": "P1", # AG-08:整包同步工作区(迁移用)
"plan.trace": "P0", # 计划追溯:只读单据链
"report.generate": "P1", # 报告生成:产出文档并入知识库(M3 §9.10 规则3)
"viewport.*": "P0", # 视口命令:纯视图状态
"query.*": "P0", # 查询:只读
"agent.fallback.propose": "P1", # 智能兜底:Pi 只读分析草稿(不写主干)
"agent.fallback.execute": "P2", # 智能兜底:执行已批准计划(确认卡+checkpoint+计划锁+diff 验证)
"agent.fallback.execute.highrisk": "P3", # 智能兜底高危执行:默认拒绝(P2 轮不开放白名单)
}
# 各动作的中文说明(门禁管理台"权力矩阵"页签展示用;与 _POWER_MAP 键集合一致)
_POLICY_DESC: dict[str, str] = {
"schedule.run": "试排:只产草稿版本,不动已发布主干",
"schedule.publish": "发布:写主干并推进订单状态,影响下游执行",
"order.upsert": "新增/编辑销售订单:改后续排产输入",
"order.cancel": "取消销售订单:从后续排产输入中排除",
"order.complete": "完成销售订单:从后续排产输入中排除",
"order.delete": "删除销售订单:从订单池物理移除",
"order.clear": "一键清空全部销售订单与采购/委外建议",
"order.submit": "订单提交审核:DRAFT/CHANGED → SUBMITTED",
"order.approve": "订单批准:SUBMITTED/CHANGED → APPROVED(可排产)",
"order.reject": "订单驳回:SUBMITTED/CHANGED → REJECTED",
"order.pool": "订单池摘要:待审/已批/变更计数(只读)",
"rush.evaluate": "插单快评:沙盒对比受影响订单/延迟/冲突(不改主干)",
"rush.apply": "采用插单:写急单入池并生成 DRAFT 排产版本(写前自动建档)",
"forecast.query": "预测订单查询:长周期需求台账(只读)",
"forecast.upsert": "新建/编辑预测订单(不默认进正式排产)",
"forecast.delete": "删除预测订单台账",
"forecast.convert": "预测转正为 APPROVED 销售订单",
"plan.buckets": "时间分桶计划:日/周/月/混合粗能力对照(只读)",
"plan.rccp": "有限/无限产能粗评估对照(RCCP,只读)",
"plan.feasibility": "交期可行性:累计粗能力能否按期(前置于排产)",
"plan.inventory": "库存投影:物料随时间可用量曲线(只读)",
"plan.leveling": "产能平衡/削峰:超载桶挪到空档建议(只读)",
"plan.supply": "产供方向:加班/扩线/外协结构化建议(只读)",
"data.import": "自然语言批量导入订单或物料主数据",
"import.commit": "Excel/CSV 导入入库(校验通过行写入主干)",
"folder.analyze": "工程目录深度解析:读表内容、样例与排产齐备度",
"folder.schedule": "导入工程目录数据包并柔性试排",
"flex.site.load": "加载现场完整生产路线:清空 flex* 演示种子并写入现场订单/路线/BOM",
"master.line.upsert": "编辑/停启用产线:改后续新排产的资源池",
"drawing.master.apply": "工程图纸候选入库:写入物料、BOM 与工艺主数据",
"master.material.upsert": "新建/编辑/停用物料:改齐套检查与可选产品池",
"master.maintenance.upsert": "新增/取消维保:改设备避让窗口",
"master.bom.upsert": "BOM 头/明细增删改:改齐套需求与采购建议输入",
"master.routing.upsert": "工艺路线/步骤增删改:改占槽工时与外协标记",
"master.operation.upsert": "工序库新建/编辑:改路线可选步骤",
"master.lineProduct.upsert": "产线-产品绑定:改排产选线依据(排产前必需)",
"master.changeover.upsert": "换型矩阵:产品族切换准备时间(分钟)",
"master.workstation.upsert": "工位新增/编辑/停用:停用后新排产不再选择",
"master.equipment.upsert": "设备新增/编辑/停用:停用后新排产不再选择",
"master.bom.release": "BOM 版本发布:生成不可变版本快照",
"master.bom.rollback": "BOM 版本回滚:恢复已发布快照为默认",
"master.routing.release": "工艺路线版本发布:生成不可变版本快照",
"master.routing.rollback": "工艺路线版本回滚:恢复已发布快照为默认",
"master.calendar.holiday.upsert": "节假日新增/编辑:影响后续周模板复制与新排产",
"master.calendar.template.create": "日历周模板新建:班次/节假日模板",
"master.calendar.week.copy": "复制周模板生成多周班次(排除节假日)",
"master.clear": "一键清理主数据板块:按范围清空(非回种)",
"master.query": "查询订单/物料/工序/BOM/工艺路线/主数据概览(只读)",
"changeover.query": "换型矩阵只读查询",
"campaign.preview": "战役/批次合并预览(同产品交期窗口)",
"params.update": "排产参数:客户等级权重/目标权重/展望期(只影响新版本)",
"params.query": "排产参数查询:只读当前客户等级与目标权重",
"param.optimize": "参数优化闭环:训练/验证集隔离→回放→候选(产出 GRAY 灰度实验)",
"param.experiment.promote": "参数实验升级:GRAY→ACTIVE/FULL 全量生效(需确认)",
"param.experiment.rollback": "参数实验手动回滚:恢复上一参数版本",
"param.observation.record": "线上观测回调:生产 KPI 采样落盘,连续劣化自动回滚实验",
"constraint.profile.query": "约束配置查询:只读硬/软约束剖面",
"constraint.profile.save": "约束配置保存:启停/硬软切换(只影响新版本与发布门禁)",
"governance.rule.create": "创建治理规则:确认后写规则注册表",
"governance.rule.update": "更新治理规则:确认后替换规则定义",
"governance.rule.delete": "删除治理规则:确认后移除规则定义",
"governance.rule.enable": "启用治理规则:确认后参与自动化 tick",
"governance.rule.disable": "停用治理规则:确认后退出自动化 tick",
"order.decompose": "订单分解:按 BOM/工艺产出采购与委外建议(草稿)",
"mesh.agent.create": "多智能体编排:按需创建智能体(角色+能力边界)",
"mesh.goal.create": "多智能体编排:挂 Goal,任务清单进入看门狗监控",
"mesh.dispatch": "多智能体编排:按能力分发任务,缺人自动新建智能体",
"mesh.reset": "多智能体编排:恢复起始状态(清空 Goal/消息/非系统智能体)",
"mrp.release": "MRP 下达:草稿采购/委外建议单确认转为正式(RELEASED)",
"flex.schedule": "柔性排产:设备能力池动态组虚拟产线,只产草稿版本",
"flex.reschedule": "分级重排:L2短窗/L3日窗/L4全局(需确认)",
"flex.swap": "L1局部换机:池内备机接手,他单不动",
"flex.capacity": "瓶颈产能:按能力池实时算各工序日产能与限制性瓶颈",
"flex.simulate_due": "交期承诺:询单沙盒试排,给乐观/预计/悲观完工与缺口建议",
"flex.compare": "三模式对比:正排/倒排/瓶颈锚沙盒 KPI 对比",
"flex.rush": "紧急插单:高优先柔性订单写入并瓶颈锚重排",
"flex.fault": "设备故障:设备退出能力池并可选全量重排",
"flex.resource.patch": "柔性资源补丁:设备停复机/调区、模具寿命与锁定",
"flex.conflict.resolve": "冲突修复:L1换机/解锁模具等安全修复(重排类走确认卡)",
"conflict.list": "冲突中心:列出最新版本未解决冲突与建议",
"flex.adjust.preview": "甘特调程预览:边拖边校验,不改世界",
"flex.adjust.commit": "甘特调程提交:生成新草稿版本(需确认)",
"schedule.adjust.preview": "固定甘特调程预览:工位占槽/先后/维保校验",
"schedule.adjust.commit": "固定甘特调程提交:生成新草稿版本(需确认)",
"sap.status": "SAP 连接状态:Mock 桩只读探测",
"sap.sync.inbound": "SAP 入站:拉生产订单与库存写入柔性世界",
"sap.sync.outbound": "SAP 出站:工序开完工回写(Mock,幂等)",
"mes.status": "MES 连接状态:Mock 桩只读探测",
"mes.dispatch": "MES 下发:排产工单推送车间(外部副作用,幂等)",
"mes.report": "MES 报工:进度回流更新工单状态",
"scenario.compare": "方案对比:多策略试排全程在深拷贝沙盒",
"scenario.sensitivity": "敏感性分析:参数扰动 Tornado(沙盒不写主干)",
"sop.compile": "SOP→约束编译预览(不写主干)",
"sop.apply": "应用 SOP 规则包到约束剖面/换型策略",
"checkpoint.create": "建档:只新增成对快照文件",
"checkpoint.rollback": "回滚:整体替换主干世界(时间旅行)",
"data.reset": "重置:清空重播种子数据",
"readiness.query": "数据齐备度:产品→路线→工时→资源→日历逐项检查,输出缺失清单",
"data.analyze": "分析项目/附件数据:盘点现有主数据与订单,并列出排产缺口",
"assistant.reply": "通用排产助理:结合项目上下文与知识库作答",
"flex.time.update": "工时维护:更新产品×工序单件工时并标记来源(需确认)",
"knowledge.query": "知识检索:只读命中知识资产,回答强制带出处",
"knowledge.import": "导入知识文档:PDF/docx/md 切块入库(需确认)",
"skill.list": "外部算法 skill 清单(只读)",
"skill.health": "外部算法 skill 健康检查(只读)",
"skill.register": "登记/更新外部算法 skill 端点配置",
"skill.enable": "启用或停用外部算法 skill",
"rag.query": "RAG 查询:命中知识 chunk + 出处(按 manifest ragScopes 鉴权)",
"routing.template.apply": "行业工艺模板实例化为产品路线(工时标「模板」,需确认)",
"schedule.wizard": "引导式排产向导:readiness→缺口引导→模板推荐→试排",
"guidance.next": "主动引导:按空态/冲突/知识未命中给出下一步口令",
"project.create": "新建项目与默认会话(只写工作区元数据,不改排产世界)",
"project.delete": "删除项目及其会话/文件/消息(不删除世界状态)",
"session.create": "在项目下新建会话话题",
"message.append": "向会话追加一条消息",
"message.replace": "整包替换会话消息(前端防抖同步)",
"workspace.replace": "整包同步工作区(本地迁移到服务端)",
"plan.trace": "计划追溯/钉扎:固定 SO 链或柔性 FO→BOM→VL→WO→设备负荷(OR-06)",
"report.generate": "报告生成:冻结快照模板化产出,报告入知识库",
"viewport.*": "视口命令:纯前端视图状态(模式/过滤/聚焦/高亮)",
"query.*": "查询:只读(KPI/世界视图)",
"agent.fallback.propose": "智能兜底(Pi Agent):意图未识别时拉起 Pi 只读分析,产出草稿报告(不写世界)",
"agent.fallback.execute": "智能兜底执行:已批准计划逐步落主干(确认卡+成对快照+计划锁熔断回滚+diff 对账)",
"agent.fallback.execute.highrisk": "智能兜底高危执行:默认拒绝,P2 轮不开放白名单",
}
_APPROVAL_POLICY_ENV = "APS_APPROVAL_ROLE_POLICIES"
_APPROVAL_CAPABILITIES = ("initiate", "approve", "history")
_GLOBAL_APPROVAL_ROLES = {
"initiate": ("APS_APPROVAL_INITIATOR_ROLES", "planner,approver,admin"),
"approve": ("APS_APPROVAL_ROLES", "planner,approver,admin"),
"history": ("APS_APPROVAL_VIEW_ROLES", "planner,approver,admin,auditor"),
}
class ApprovalRolePolicyError(ValueError):
"""The scoped approval role configuration is malformed or inconsistent."""
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
parsed: dict[str, Any] = {}
for key, value in pairs:
if key in parsed:
raise ApprovalRolePolicyError(f"duplicate key {key!r}")
parsed[key] = value
return parsed
def _normalize_policy_rule(scope: str, raw_rule: Any) -> dict[str, tuple[str, ...]]:
if not isinstance(raw_rule, dict) or not raw_rule:
raise ApprovalRolePolicyError(f"{scope} must be a non-empty object")
unknown = set(raw_rule) - set(_APPROVAL_CAPABILITIES)
if unknown:
raise ApprovalRolePolicyError(f"{scope} has unknown fields: {sorted(unknown)}")
normalized: dict[str, tuple[str, ...]] = {}
for capability, raw_roles in raw_rule.items():
if not isinstance(raw_roles, list):
raise ApprovalRolePolicyError(f"{scope}.{capability} must be an array")
roles: list[str] = []
for raw_role in raw_roles:
if not isinstance(raw_role, str) or not raw_role.strip():
raise ApprovalRolePolicyError(
f"{scope}.{capability} roles must be non-empty strings"
)
role = raw_role.strip().lower()
if role in roles:
raise ApprovalRolePolicyError(f"{scope}.{capability} contains duplicate role {role!r}")
roles.append(role)
normalized[capability] = tuple(sorted(roles))
return normalized
def _approval_role_policy_config() -> dict[str, dict[str, dict[str, tuple[str, ...]]]]:
raw = os.environ.get(_APPROVAL_POLICY_ENV)
if raw is None or raw == "":
return {"actions": {}, "powers": {}}
if len(raw) > 64 * 1024:
raise ApprovalRolePolicyError(f"{_APPROVAL_POLICY_ENV} exceeds 64 KiB")
try:
document = json.loads(raw, object_pairs_hook=_reject_duplicate_json_keys)
except (json.JSONDecodeError, ApprovalRolePolicyError) as exc:
raise ApprovalRolePolicyError(f"invalid {_APPROVAL_POLICY_ENV}: {exc}") from exc
if not isinstance(document, dict):
raise ApprovalRolePolicyError(f"{_APPROVAL_POLICY_ENV} must be a JSON object")
unknown_root = set(document) - {"actions", "powers"}
if unknown_root:
raise ApprovalRolePolicyError(f"unknown policy sections: {sorted(unknown_root)}")
actions = document.get("actions", {})
powers = document.get("powers", {})
if not isinstance(actions, dict) or not isinstance(powers, dict):
raise ApprovalRolePolicyError("actions and powers must be JSON objects")
normalized_actions: dict[str, dict[str, tuple[str, ...]]] = {}
for action, rule in actions.items():
if action not in _POWER_MAP or action.endswith(".*"):
raise ApprovalRolePolicyError(f"unknown or non-exact approval action {action!r}")
if _POWER_MAP[action] not in ("P2", "P3"):
raise ApprovalRolePolicyError(f"action {action!r} does not require approval")
normalized_actions[action] = _normalize_policy_rule(f"actions.{action}", rule)
normalized_powers: dict[str, dict[str, tuple[str, ...]]] = {}
for power, rule in powers.items():
if power not in ("P2", "P3"):
raise ApprovalRolePolicyError(f"approval power must be P2 or P3, got {power!r}")
normalized_powers[power] = _normalize_policy_rule(f"powers.{power}", rule)
return {"actions": normalized_actions, "powers": normalized_powers}
def _resolve_approval_roles(
capability: str,
action: str,
power: str,
*,
config: dict[str, dict[str, dict[str, tuple[str, ...]]]] | None = None,
) -> tuple[tuple[str, ...], str, str]:
if capability not in _APPROVAL_CAPABILITIES:
raise ApprovalRolePolicyError(f"unknown approval capability {capability!r}")
canonical_power = power_of(action)
if power != canonical_power:
raise ApprovalRolePolicyError(
f"approval record power mismatch for {action!r}: {power!r} != {canonical_power!r}"
)
resolved = _approval_role_policy_config() if config is None else config
action_rule = resolved["actions"].get(action, {})
if capability in action_rule:
return action_rule[capability], "action", action
power_rule = resolved["powers"].get(power, {})
if capability in power_rule:
return power_rule[capability], "power", power
env_name, default = _GLOBAL_APPROVAL_ROLES[capability]
return tuple(sorted(_configured_roles(env_name, default))), "global", env_name
def _current_identity_has_approval_role(capability: str, action: str, power: str) -> bool:
from server.auth.context import get_identity
try:
allowed_roles, _source, _scope = _resolve_approval_roles(capability, action, power)
except ApprovalRolePolicyError:
return False
roles = {role.lower() for role in get_identity().roles}
return "system" in roles or bool(roles & set(allowed_roles))
def _approval_role_policy_projection(
action: str,
power: str,
*,
config: dict[str, dict[str, dict[str, tuple[str, ...]]]] | None = None,
error: ApprovalRolePolicyError | None = None,
) -> dict[str, Any]:
if error is not None:
return {
"valid": False,
"systemBypass": False,
"error": str(error),
**{
capability: {"roles": [], "source": "invalid", "scope": _APPROVAL_POLICY_ENV}
for capability in _APPROVAL_CAPABILITIES
},
}
projection: dict[str, Any] = {"valid": True, "systemBypass": True}
for capability in _APPROVAL_CAPABILITIES:
roles, source, scope = _resolve_approval_roles(
capability,
action,
power,
config=config,
)
projection[capability] = {
"roles": list(roles),
"source": source,
"scope": scope,
}
return projection
def list_policy() -> list[dict[str, Any]]:
"""权力矩阵投影(门禁管理台数据源;P0 只读,§6.10.2)。
Returns: 原矩阵字段加 rolePolicy 投影,顺序与 _POWER_MAP 声明一致。
"""
try:
config = _approval_role_policy_config()
policy_error = None
except ApprovalRolePolicyError as exc:
config = None
policy_error = exc
return [{
"action": action, # 动作名(支持前缀通配)
"power": power, # 权力等级 P0-P3
"desc": _POLICY_DESC.get(action, ""), # 中文说明
"confirm": power in ("P2", "P3"), # 是否需要人工确认
"rolePolicy": (
_approval_role_policy_projection(
action,
power,
config=config,
error=policy_error,
)
if power in ("P2", "P3")
else None
),
} for action, power in _POWER_MAP.items()]
def power_of(action: str) -> str:
"""查动作的权力等级(前缀通配;未登记按最高 P3 拒绝——白名单原则)。"""
if action in _POWER_MAP: # 精确命中
return _POWER_MAP[action]
prefix = action.split(".")[0] + ".*" # 前缀通配(viewport.* / query.*)
return _POWER_MAP.get(prefix, "P3") # 未登记 → P3(默认最严)
def needs_confirm(action: str) -> bool:
"""该动作是否需要人工确认(P2/P3 强制过卡,§3.2 权力边界)。"""
return power_of(action) in ("P2", "P3") # 写主干/外部副作用必须确认
def verify_pending_evidence(
pending: dict[str, Any],
*,
checkpoint_store: Any = None,
version_lookup: Any = None,
current_world: dict[str, Any] | None = None,
) -> None:
"""确认执行前的统一证据校验(§3.4 证据链 v1,fail closed)。
校验项:
- 版本证据绑定:普通版本动作必须携带 schedule-version:<id>;
SAP 出站必须携带其专用 sap-outbound-version:<id>;
- 前置快照存在:beforeSnapshot 指向的 checkpoint 必须存在(提供 checkpoint_store 时);
- 版本一致性:versionId 指向的版本仍可解析(对象域校验,避免全局指纹误伤)。
Raises PermissionError;调用方负责写 DENIED 审计并保证无世界写入。
"""
params = pending.get("params") or {}
evidence_refs = list(pending.get("evidenceRefs") or [])
version_id = params.get("versionId")
if version_id is not None:
expected_ref = (
f"sap-outbound-version:{version_id}"
if pending.get("action") == "sap.sync.outbound"
else f"{_EVIDENCE_VERSION_PREFIX}{version_id}"
)
if expected_ref not in evidence_refs:
raise PermissionError(
"确认动作缺少版本证据"
f"(期望 {expected_ref},实际 {evidence_refs or '无'})"
)
before_snapshot = pending.get("beforeSnapshot")
if (
before_snapshot
and checkpoint_store is not None
and checkpoint_store.get(before_snapshot) is None
):
raise PermissionError("确认动作的前置快照不存在")
if version_id is not None and version_lookup is not None and not version_lookup(version_id):
raise PermissionError("确认版本不存在或已失效,请重新发起确认")
masterdata_fingerprints = [
ref.removeprefix("masterdata-world:")
for ref in evidence_refs
if ref.startswith("masterdata-world:")
]
if len(set(masterdata_fingerprints)) > 1:
raise PermissionError("确认动作包含冲突的主数据指纹")
# Global beforeFingerprint is retained as audit evidence, but it is too broad
# for generic actions that legitimately stage several independent writes.
# Master-data actions bind an explicit stable projection hash and therefore
# opt into strict whole-world drift rejection here.
before_fingerprint = masterdata_fingerprints[0] if masterdata_fingerprints else None
if before_fingerprint and current_world is not None:
current_fingerprint = world_fingerprint(current_world)
if current_fingerprint != before_fingerprint:
raise PermissionError("确认动作输入世界已漂移,请重新发起确认")
def _capture_world_fingerprint(tenant_uuid: str, world_key: str) -> str | None:
"""出卡时捕获输入快照指纹(仅当 scoped store 已加载;否则 None 跳过漂移强制)。"""
try:
from server.state.store import peek_scoped_store
store = peek_scoped_store(tenant_uuid, world_key)
if store is None:
return None
return world_fingerprint(store.data)
except Exception: # noqa: BLE001 - optional fingerprint capture must not block staging
return None
def refresh_confirmation_world_fingerprint(confirm_id: str, fingerprint: str) -> bool:
"""把待确认记录的 beforeFingerprint 推进到指定值(仅当原值非 None)。
用途(FB-02 真实冒烟实测缺陷修复):出卡流程自身的落账(如 GATE 出卡审计)
会改变世界指纹,若指纹停留在 stage_confirmation 捕获时刻,执行端漂移比对
永远误报。出卡方在完成全部出卡期写入后调用本函数,把冻结指纹对齐到
「卡片就绪时刻」。审批窗口内的后续业务改动仍会被漂移检测正常拦截。
返回是否实际刷新(仓结构不符/记录缺失/原值为 None 时安全返回 False)。
"""
pending = getattr(_approval_store, "pending", None)
if not isinstance(pending, dict):
return False
rec = pending.get(confirm_id)
if not rec or not rec.get("beforeFingerprint"):
return False
rec["beforeFingerprint"] = fingerprint
save = getattr(_approval_store, "save", None)
if callable(save):
save()
return True
def stage_confirmation(session_id: str, action: str, params: dict[str, Any],
title: str, summary_lines: list[str],
*,
evidence_refs: list[str] | None = None,
before_snapshot: str | None = None,
delegate_user_id: int | None = None) -> UIBlock:
"""把 P2 动作压入待确认队列并生成确认卡 UI 块(动作此刻并未执行)。
权力等级:P1(只登记意图与出卡,不触发任何写)。
证据协议(§3.4 证据链 v1):出卡时捕获输入快照指纹(世界已加载时)、
版本证据引用(显式或按 versionId 派生)与前置快照 ID,随记录冻结;
执行端据此做漂移/缺项/版本一致性校验(fail closed)。
delegate_user_id:审批委托(可选)——P2 确认卡可指定被委托人,
委托人不改变 P3 双人职责分离语义(P3 仍强制两个不同用户)。
"""
now = time.time()
ttl = _ttl_seconds("APS_APPROVAL_TTL_SECONDS", 24 * 60 * 60)
frozen_params = copy.deepcopy(params)
power = power_of(action)
require_can_initiate(action)
from server.auth.context import get_identity
identity = get_identity()
if identity.user_id:
from server.state.projects import get_project_store
project_id = get_project_store().active_world_key()
else:
project_id = "default"
world_key = _effective_world_key(project_id, identity.user_id)
refs = list(evidence_refs or evidence_refs_for(frozen_params))
before_fingerprint = _capture_world_fingerprint(identity.tenant_uuid, world_key)
while True:
confirm_id = uuid.uuid4().hex[:12]
record = { # 登记待确认动作
"confirmId": confirm_id,
"sessionId": session_id,
"action": action,
"power": power,
"params": frozen_params,
"paramsHash": _params_fingerprint(frozen_params),
"tenantUuid": identity.tenant_uuid,
"ownerUserId": identity.user_id,
"delegateUserId": delegate_user_id,
"projectId": project_id,
"worldKey": world_key,
"evidenceRefs": refs,
"beforeSnapshot": before_snapshot,
"beforeFingerprint": before_fingerprint,
"requester": _identity_ref(),
"approvals": [],
"approvalStep": 0,
"requiredApprovals": 2 if power == "P3" else 1,
"createdAt": utc_now(now),
"createdAtEpoch": now,
"expiresAt": utc_now(now + ttl),
"expiresAtEpoch": now + ttl,
}
record["envelopeHash"] = _confirmation_envelope_fingerprint(record)
if _approval_store.stage(record, now_epoch=now):
break
# Agent 自动编排(矩阵 110 行):出卡时创建 L2 Plan 节点(尽力而为,不阻断门禁)
try:
from server.agent_core.plan_orchestration import stage_plan_node
stage_plan_node(
session_id=session_id, action=action, params=frozen_params,
confirm_id=confirm_id, power=power,
actor=str(identity.username if identity else "planner"),
)
except Exception: # noqa: BLE001, S110 - optional plan linkage is best-effort
pass
return UIBlock( # 确认卡:前端渲染为审批卡片
blockId=f"confirm-{confirm_id}", # 块 ID 复用确认 ID
type="confirm-card", # 块类型
props={ # 卡片内容
"confirmId": confirm_id, # 前端回传用
"title": title, # 卡片标题
"summary": summary_lines, # 影响面说明(逐行)
"power": power, # 展示权力等级
"action": action, # 待执行动作名
},
actions=[ # 批准/驳回两个动作
UIAction(actionId="confirm", label="批准执行", power=power, # type: ignore[arg-type]
payload={"confirmId": confirm_id}),
UIAction(actionId="reject", label="驳回", power="P0", payload={"confirmId": confirm_id}),
],
)
def _matches_current_scope(record: dict[str, Any]) -> bool:
from server.auth.context import get_identity
identity = get_identity()
if record.get("tenantUuid") != identity.tenant_uuid:
return False
project_id = record.get("projectId") or "default"
if project_id == "default":
if record.get("power") == "P3":
return True
allowed_ids = (record.get("ownerUserId"), record.get("delegateUserId"))
return identity.user_id in allowed_ids
if not identity.user_id:
return False
from server.state.projects import get_project_store
return get_project_store().active_world_key() == project_id
def _approval_record_can_approve(record: dict[str, Any]) -> bool:
return _matches_current_scope(record) and _can_approve_current(
str(record.get("action") or ""),
str(record.get("power") or ""),
)
def take_confirmation(confirm_id: str, approve: bool = True,
note: str | None = None) -> dict[str, Any] | None:
"""处理一重审批;P2 一次放行,P3 两次批准后才取出执行。
note:审批意见(可选),随批准/驳回记录进 approvals 与审批历史。
"""
now = time.time()
def allowed(record: dict[str, Any]) -> bool:
if not _approval_record_can_approve(record):
return False
verify_confirmation_envelope(record)
return True
return _approval_store.decide(
confirm_id,
approve=approve,
approver=_identity_ref(),
allowed=allowed,
now_epoch=now,
grant_ttl_seconds=_ttl_seconds("APS_EXECUTION_GRANT_TTL_SECONDS", 5 * 60),
note=note,
)
def transfer_confirmation(confirm_id: str, to_user_id: int) -> dict[str, Any] | None:
"""转派待确认项给指定用户(P2;仅当前可审批者发起;P3 拒绝保 SOD)。"""
now = time.time()
return _approval_store.transfer(
confirm_id,
to_user_id=to_user_id,
allowed=_approval_record_can_approve,
now_epoch=now,
actor=_identity_ref(),
)
def is_confirmation_pending(confirm_id: str) -> bool:
"""确认令牌是否仍等待下一重审批(供 API 控制 refresh 与 UI 状态)。"""
return _approval_store.pending_record(
confirm_id,
allowed=_approval_record_can_approve,
now_epoch=time.time(),
) is not None
def pending_confirmation(confirm_id: str) -> dict[str, Any] | None:
"""Return the current scoped record for trusted server-side action routing."""
return _approval_store.pending_record(
confirm_id,
allowed=_approval_record_can_approve,
now_epoch=time.time(),
)
def resolve_confirmation_store(confirm_id: str, fallback: Any) -> Any | None:
"""Resolve the immutable target world without trusting the approver workspace."""
pending = _approval_store.pending_record(
confirm_id,
allowed=_approval_record_can_approve,
now_epoch=time.time(),
)
if pending is None:
return None
tenant_uuid = str(pending.get("tenantUuid") or "platform")
world_key = str(
pending.get("worldKey")
or _effective_world_key(
str(pending.get("projectId") or "default"),
int(pending.get("ownerUserId") or 0),
)
)
if (
getattr(fallback, "tenant_uuid", tenant_uuid) == tenant_uuid
and getattr(fallback, "world_key", world_key) == world_key
):
return fallback
from server.state.store import _safe_scope, _scoped_store
return _scoped_store(tenant_uuid, _safe_scope(world_key, "default"))
def consume_execution_grant(
grant: str,
*,
confirm_id: str,
action: str,
params: dict[str, Any],
) -> bool:
"""Consume the scoped one-time receipt issued after a final P3 approval."""
return _approval_store.consume_grant(
grant,
confirm_id=confirm_id,
action=action,
params_hash=_params_fingerprint(params),
allowed=_matches_current_scope,
decided_by=_identity_ref(),
now_epoch=time.time(),
)
def list_pending() -> list[dict[str, Any]]:
"""列出全部待确认动作(门禁管理台"待审批"页签的数据源,§6.10.2)。"""
from server.auth.context import get_identity
identity = get_identity()
if identity.user_id:
from server.state.projects import get_project_store
project_id = get_project_store().active_world_key()
else:
project_id = "default"
return _approval_store.pending_items(
tenant_uuid=identity.tenant_uuid,
project_id=project_id,
allowed=_approval_record_can_approve,
now_epoch=time.time(),
)
def list_approval_history(limit: int = 100) -> list[dict[str, Any]]:
"""Return recent approval decisions visible in the current scope."""
from server.auth.context import get_identity
bounded = max(1, min(int(limit), 500))
identity = get_identity()
if identity.user_id:
from server.state.projects import get_project_store
project_id = get_project_store().active_world_key()
else:
project_id = "default"
return _approval_store.history_items(
tenant_uuid=identity.tenant_uuid,
project_id=project_id,
allowed=lambda record: _matches_current_scope(record)
and _can_view_approval_history(
str(record.get("action") or ""),
str(record.get("power") or ""),
),
limit=bounded,
now_epoch=time.time(),
)
def guard(action: str, params: dict[str, Any], executor: Callable[[], Any]):
"""P0/P1 动作的直通执行护栏:校验权力等级后执行(P2/P3 禁止走此通道)。
权力等级:随 action 而定;本函数强制 P2/P3 抛错(必须走 stage_confirmation)。
"""
if needs_confirm(action): # P2/P3 误入直通通道
raise PermissionError(f"动作 {action} 为 {power_of(action)},必须经确认卡执行") # 硬拒绝
return executor() # P0/P1 直接执行