diff --git a/backend/app/capabilities/platform.py b/backend/app/capabilities/platform.py index eb3fda5a..c005e8b1 100644 --- a/backend/app/capabilities/platform.py +++ b/backend/app/capabilities/platform.py @@ -10,7 +10,7 @@ | table_import_period | 写入 | 要人确认 | 按上传源已有的配方把一个文件导入成一期(files/imports.py) | 环境:正式运行写真的;探索运行(试运行、个人应用)在 actions.prepare 里就定成演练——这几项没有测试环境,一行都不写。 -治理默认只在登记里第一次出现这一行时写进去,之后管理员改了算管理员的。 +治理默认:管理员没改过的(版本还是 1、没有改动人)同步时一直按这里的默认,默认调整了也跟着走;管理员改过就听管理员的。 """ from __future__ import annotations diff --git a/backend/app/capabilities/registry.py b/backend/app/capabilities/registry.py index 45e4314d..ea63d849 100644 --- a/backend/app/capabilities/registry.py +++ b/backend/app/capabilities/registry.py @@ -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(没有结构化回执)不算,不管登记里写了什么。""" diff --git a/backend/tests/test_capabilities_registry.py b/backend/tests/test_capabilities_registry.py index e1ba777b..0ba81d19 100644 --- a/backend/tests/test_capabilities_registry.py +++ b/backend/tests/test_capabilities_registry.py @@ -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() diff --git a/frontend/src/lib/termsBlueprint.ts b/frontend/src/lib/termsBlueprint.ts index 3488e295..5adf362f 100644 --- a/frontend/src/lib/termsBlueprint.ts +++ b/frontend/src/lib/termsBlueprint.ts @@ -414,12 +414,14 @@ export const PLAN_TEXT = { whoHint: { you: '只有你知道答案:在卡片上补齐或确认。', assistant: '交给助手按卡片改,改完再核对一次。', - admin: '这些要管理员处理,或改选已发布级。', + admin: '这些要管理员处理(只因受管级才有的,也可以改选已发布级)。', } as Record, 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: '工作流、应用、定时任务和授权都已就绪。', diff --git a/frontend/src/pages/blueprints/PublishSummary.tsx b/frontend/src/pages/blueprints/PublishSummary.tsx index e5e2be62..cf8dd987 100644 --- a/frontend/src/pages/blueprints/PublishSummary.tsx +++ b/frontend/src/pages/blueprints/PublishSummary.tsx @@ -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 && (

{PLAN_TEXT.problems}

- {!problems.length ?

{PLAN_TEXT.noProblems}

: ( + {!blocking.length ?

{PLAN_TEXT.noProblems}

: ( <> {blocked &&

{PLAN_TEXT.blocked}

} -
+
{WHO.map((who) => ( - whoOf(p) === who)} + 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 }: {
)} + {reminders.length > 0 && ( +
+
{PLAN_TEXT.reminders}
+
{PLAN_TEXT.remindersHint}
+
    + {reminders.map((p, i) => ( +
  • +
    {p.plain?.title ?? p.message}
    + {p.plain?.action &&
    {p.plain.action}
    } +
  • + ))} +
+
+ )}
)} diff --git a/scripts/check-blueprint.mjs b/scripts/check-blueprint.mjs index 84595ae6..306eada3 100644 --- a/scripts/check-blueprint.mjs +++ b/scripts/check-blueprint.mjs @@ -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)] }