发布单的问题照实分清挡不挡发布:平台能力的治理默认在没人治理过时一直生效(在默认写好之前登记的 records_append 会停在「不许无人值守、没有查询能力」,填报登记出发布单时报写入不经确认、结果不明没人核实、要找管理员登记查询能力;管理员改过的同步不动);发布摘要的三栏只列挡发布的问题,只是提醒的(视图要等第一次正式运行才有数之类)另列「提醒(不挡发布)」;确认按钮「每周一 09:00 自动出数」补上空格;检查脚本和测试跟上

This commit is contained in:
ThearchyHelios
2026-10-15 14:44:26 +08:00
parent be845bb823
commit f0b66c2551
6 changed files with 73 additions and 11 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
| table_import_period | 写入 | 要人确认 | 按上传源已有的配方把一个文件导入成一期(files/imports.py) |
环境:正式运行写真的;探索运行(试运行、个人应用)在 actions.prepare 里就定成演练——这几项没有测试环境,一行都不写。
治理默认只在登记里第一次出现这一行时写进去,之后管理员改了算管理员的。
治理默认:管理员没改过的(版本还是 1、没有改动人)同步时一直按这里的默认,默认调整了也跟着走;管理员改过就听管理员的。
"""
from __future__ import annotations
+16 -2
View File
@@ -142,11 +142,20 @@ async def sync_all(session: AsyncSession) -> int:
digest = _hash({**synced, "endpoint_id": item.get("endpoint_id")})
row = existing.get(name)
if row is None:
# 平台能力第一次进登记时带上治理默认(records_append 允许无人值守、查询能力是 records_lookup),之后照管理员改的
# 平台能力进登记时带上治理默认(records_append 允许无人值守、查询能力是 records_lookup)
fresh.append({"name": name, **synced, "source_hash": digest, "synced_at": now,
"endpoint_id": item.get("endpoint_id"), **_FRESH_DEFAULTS,
**GOVERNANCE_DEFAULTS.get(name, {})})
elif row.source_hash != digest or row.missing:
continue
if _platform_defaults_apply(row, name):
# 管理员没改过的平台能力一直跟着平台默认走:默认后来调整了(或者这一行是在默认写好之前登记的),不能永远停在
# 老样子——那样填报登记的每次提交都要等人确认、结果不明时找不到人核实。管理员改过就听管理员的
defaults = GOVERNANCE_DEFAULTS[name]
if any(getattr(row, k) != v for k, v in defaults.items()):
for key, value in defaults.items():
setattr(row, key, value)
changed += 1
if row.source_hash != digest or row.missing:
for key, value in synced.items():
setattr(row, key, value)
# 绑定端点由工具自己的配置定(自定义工具的 config.endpoint、MCP 服务的端点),登记里跟着它走
@@ -169,6 +178,11 @@ async def sync_all(session: AsyncSession) -> int:
return changed
def _platform_defaults_apply(row: Any, name: str) -> bool:
"""这一行平台能力是不是还没人治理过:治理一次版本加一、记下改动人(govern),没动过的才按平台默认。"""
return name in GOVERNANCE_DEFAULTS and row.version == 1 and row.updated_by is None
async def refresh_cache(session: AsyncSession) -> None:
"""重算登记的进程内快照。能作证的端点读取:绑定登记端点的 http 自定义工具,治理后效果为 read,声明了 tabular。
Python 自定义工具(用户代码在算)和 MCP(没有结构化回执)不算,不管登记里写了什么。"""
@@ -2,6 +2,7 @@
- 同步:主键是图里的工具名;同步得出的效果只说系统知道的(可写数据源为视参数而定,绑定端点的非 GET 为写入);
source_hash 没变不动;来源没了记 missing、不删行;
- 平台能力的治理默认:没人治理过的一直跟着默认走,管理员改过的同步不动;
- 治理:version 乐观锁、写审计;只读能力才能免审批;查询能力只能是同一端点上、绑定端点的只读能力;
- 写记忆这类改平台自身状态的内置工具不记成只读(不能免审批、不能当查询能力),照旧能给 Agent 用;
- is_platform_query:数据源查询,和绑定端点、声明了表格、治理后只读的 http 工具;Python 工具写了表格也不算;
@@ -19,6 +20,7 @@ from sqlalchemy import delete, select, update
from app.capabilities import registry
from app.db.base import SessionLocal
from app.db.models import AuditEvent, Capability, CustomTool, DataSource, Endpoint, McpServer
from app.records import RECORDS_APPEND, RECORDS_LOOKUP
from app.tools.registry import ToolBuildError, ToolContext, Versions, build_tools
from app.tools.trust import CONFIRM, EXEMPT, FORMAL_MARK, call_policy, run_snapshot
from tests.fakes import auth_env
@@ -85,6 +87,26 @@ async def test_sync_derives_effects_and_only_touches_what_changed(world):
assert (await _row("calc_py")).missing, "来源没了记 missing、不删行"
async def test_platform_defaults_follow_until_governed(world):
"""在平台默认写好之前就登记了的 records_append(不许无人值守、没有查询能力):没人治理过的,同步时改回平台默认,
不然填报登记的每次提交都要等人确认、结果不明时没人核实;管理员治理过的,同步不再动它。"""
async with SessionLocal() as s:
await s.execute(update(Capability).where(Capability.name == RECORDS_APPEND)
.values(unattended_write=False, lookup=None))
await s.commit()
assert await registry.sync_all(s) == 1
row = await _row(RECORDS_APPEND)
assert (row.unattended_write, row.lookup, row.version) == (True, RECORDS_LOOKUP, 1)
c = auth_env.client()
r = await c.put(f"/api/capabilities/{RECORDS_APPEND}/governance", json={"version": 1, "unattended_write": False})
assert r.status_code == 200, r.text
async with SessionLocal() as s:
assert await registry.sync_all(s) == 0, "管理员改过就听管理员的"
row = await _row(RECORDS_APPEND)
assert (row.unattended_write, row.lookup, row.version) == (False, RECORDS_LOOKUP, 2)
async def test_governance_lock_audit_and_rules(world):
c = auth_env.client()
row = (await c.get("/api/capabilities/mcp:kb/search")).json()
+5 -2
View File
@@ -414,12 +414,14 @@ export const PLAN_TEXT = {
whoHint: {
you: '只有你知道答案:在卡片上补齐或确认。',
assistant: '交给助手按卡片改,改完再核对一次。',
admin: '这些要管理员处理,或改选已发布级。',
admin: '这些要管理员处理(只因受管级才有的,也可以改选已发布级)。',
} as Record<ProblemWho, string>,
whoEmpty: '没有',
fixWithAssistant: '交给助手修',
fixText: (lines: string[]) => `发布前核对出这些问题,请按卡片改好:\n${lines.map((l, i) => `${i + 1}. ${l}`).join('\n')}`,
blocked: '还有问题没处理,暂时不能发布',
reminders: '提醒(不挡发布)',
remindersHint: '照常可以发布,知道一下就好。',
level: '发布级别',
levelPublished: '已发布',
levelGoverned: '受管',
@@ -427,7 +429,8 @@ export const PLAN_TEXT = {
firstRun: '发布后立即运行一次',
firstRunHint: '应用一打开就有数;不勾的话等定时任务第一次出数。',
confirm: '确认发布',
confirmWith: (when: string) => `发布并${when}自动出数`,
// 「每周一 09:00」以数字结尾时和后面的汉字隔一个空格
confirmWith: (when: string) => `发布并${when}${/\d$/.test(when) ? ' ' : ''}自动出数`,
running: '正在发布…',
done: '发布完成',
doneBody: '工作流、应用、定时任务和授权都已就绪。',
@@ -97,6 +97,9 @@ export function PublishSummary({ blueprintId, open, onClose, onFix }: {
}
const problems = plan?.problems ?? []
// 挡发布的(error)按谁来改分三栏;只是提醒的(warning,比如视图要等第一次正式运行才有数)另列一处,不混在「你来定」里
const blocking = problems.filter((p) => p.level === 'error')
const reminders = problems.filter((p) => p.level !== 'error')
// 能不能确认以服务端为准(ok:没有 error 级问题、也没停下),界面不另算
const blocked = !!plan && !plan.ok
const started = !!plan && plan.status !== 'planned'
@@ -202,12 +205,12 @@ export function PublishSummary({ blueprintId, open, onClose, onFix }: {
{!started && (
<section aria-labelledby="plan-problems">
<h3 id="plan-problems" className="mb-1.5 text-2xs font-semibold text-dim">{PLAN_TEXT.problems}</h3>
{!problems.length ? <p className="text-2xs text-faint" data-plan-no-problems="">{PLAN_TEXT.noProblems}</p> : (
{!blocking.length ? <p className="text-2xs text-faint" data-plan-no-problems="">{PLAN_TEXT.noProblems}</p> : (
<>
{blocked && <p className="mb-1.5 text-2xs" style={{ color: 'var(--st-failed)' }} data-plan-blocked="">{PLAN_TEXT.blocked}</p>}
<div className="grid grid-cols-1 gap-2 md:grid-cols-3" data-plan-problems={problems.length}>
<div className="grid grid-cols-1 gap-2 md:grid-cols-3" data-plan-problems={blocking.length}>
{WHO.map((who) => (
<ProblemColumn key={who} who={who} items={problems.filter((p) => whoOf(p) === who)}
<ProblemColumn key={who} who={who} items={blocking.filter((p) => whoOf(p) === who)}
onFix={(lines) => { onClose(); onFix(lines) }} busy={!!busy}
onDispose={(alias, d) => {
const next = { ...dispositions, [alias]: d }
@@ -218,6 +221,20 @@ export function PublishSummary({ blueprintId, open, onClose, onFix }: {
</div>
</>
)}
{reminders.length > 0 && (
<div className="mt-2 rounded-lg border bg-bg p-2" data-plan-reminders={reminders.length}>
<div className="text-2xs font-semibold text-fg">{PLAN_TEXT.reminders}</div>
<div className="mb-1.5 text-2xs leading-relaxed text-faint">{PLAN_TEXT.remindersHint}</div>
<ul className="space-y-1.5">
{reminders.map((p, i) => (
<li key={`${p.code}-${i}`} className="text-2xs leading-relaxed" data-plan-reminder={p.code ?? ''}>
<div style={{ color: 'var(--st-waiting)' }}>{p.plain?.title ?? p.message}</div>
{p.plain?.action && <div className="text-dim">{p.plain.action}</div>}
</li>
))}
</ul>
</div>
)}
</section>
)}
</>
+9 -3
View File
@@ -187,6 +187,8 @@ const PLAN_BLOCKED = {
plain: { title: '「销售额」对到的表还没确认', why: '这是助手按数据目录对的', who: 'you', action: '在卡片上点「确认」' } },
{ code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置', who: 'you',
source: 'app', plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } },
{ code: 'app.binding_target_missing', level: 'error', path: 'pages.home.c1', message: '绑定的视图不存在', who: 'assistant',
source: 'app', plain: { title: '页面上有一块绑的东西不存在', why: '', who: 'assistant', action: '交给助手按卡片改' } },
{ code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务', who: 'assistant',
source: 'app', plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } },
{ code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据', who: 'admin',
@@ -629,9 +631,12 @@ await section('发布摘要:三栏、处置、交给助手修', async () => {
const dlg = page.locator('[data-plan="planned"]')
await dlg.waitFor({ timeout: 15000 })
check('每一步一句白话', (await dlg.locator('[data-plan-step="publish_workflow"]').innerText()).includes('此后每周一 09:00 按这一版自动出数'))
check('问题分三栏:你来定、助手可以修、要找管理员', await dlg.locator('[data-plan-who="you"]').getAttribute('data-plan-who-count') === '2'
check('挡路的问题分三栏:你来定、助手可以修、要找管理员', await dlg.locator('[data-plan-who="you"]').getAttribute('data-plan-who-count') === '2'
&& await dlg.locator('[data-plan-who="assistant"]').getAttribute('data-plan-who-count') === '1'
&& await dlg.locator('[data-plan-who="admin"]').getAttribute('data-plan-who-count') === '1')
&& await dlg.locator('[data-plan-who="admin"]').getAttribute('data-plan-who-count') === '0')
check('只是提醒的(warning)另列,不进三栏', await dlg.locator('[data-plan-reminders]').getAttribute('data-plan-reminders') === '2'
&& await dlg.locator('[data-plan-reminder="app.view_unbound"]').count() === 1
&& await dlg.locator('[data-plan-reminder="governed.metric_untraced"]').count() === 1)
check('有 error 不能确认,写明为什么', await page.locator('[data-plan-confirm]').isDisabled() && await dlg.locator('[data-plan-blocked]').count() === 1)
check('关登录(管理员):可以选受管级', await dlg.locator('[data-plan-level]').count() === 1)
await dlg.locator('[data-plan-disposition="weekly"] button').first().click()
@@ -639,7 +644,8 @@ await section('发布摘要:三栏、处置、交给助手修', async () => {
&& state.planBodies[1].dispositions?.weekly === 'recompute', JSON.stringify(state.planBodies?.[1]))
await page.locator('[data-plan-fix]').click()
check('交给助手修:关掉摘要,把问题作为一轮发给助手', await until(() => state.chats.length === 1)
&& state.chats[0].instruction.includes('有一块看板没有对应的数'), JSON.stringify(state.chats[0]))
&& state.chats[0].instruction.includes('页面上有一块绑的东西不存在')
&& !state.chats[0].instruction.includes('有一块看板没有对应的数'), JSON.stringify(state.chats[0]))
// 搭建者(开了登录):没有受管级可选
const b = { me: BUILDER, bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_OK)] }