mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
发布单的问题照实分清挡不挡发布:平台能力的治理默认在没人治理过时一直生效(在默认写好之前登记的 records_append 会停在「不许无人值守、没有查询能力」,填报登记出发布单时报写入不经确认、结果不明没人核实、要找管理员登记查询能力;管理员改过的同步不动);发布摘要的三栏只列挡发布的问题,只是提醒的(视图要等第一次正式运行才有数之类)另列「提醒(不挡发布)」;确认按钮「每周一 09:00 自动出数」补上空格;检查脚本和测试跟上
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
| table_import_period | 写入 | 要人确认 | 按上传源已有的配方把一个文件导入成一期(files/imports.py) |
|
||||
|
||||
环境:正式运行写真的;探索运行(试运行、个人应用)在 actions.prepare 里就定成演练——这几项没有测试环境,一行都不写。
|
||||
治理默认只在登记里第一次出现这一行时写进去,之后管理员改了算管理员的。
|
||||
治理默认:管理员没改过的(版本还是 1、没有改动人)同步时一直按这里的默认,默认调整了也跟着走;管理员改过就听管理员的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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)] }
|
||||
|
||||
Reference in New Issue
Block a user