Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 146 additions & 1 deletion coreman/api/routers/bot_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@
from coreman.core.bots.events import notify_bot_changed
from coreman.core.bots.secrets import decrypt_json, mask_dict
from coreman.core.bus import tasks
from coreman.core.db.models import Bot, BotSkill, Skill, SkillApproval, SkillSource, User
from coreman.core.db.models import (
Bot,
BotSkill,
Skill,
SkillApproval,
SkillSource,
Task,
User,
)
from coreman.core.knowledge import installation as installs
from coreman.core.timeutils import utcnow

Expand Down Expand Up @@ -229,6 +237,143 @@ async def install(
return {"code": 0, "data": {"status": "installing", "task_id": task.id}}


async def last_data_source(
session: AsyncSession, request: Request, bot_id: uuid.UUID, skill_id: uuid.UUID
) -> str | None:
"""安装行不存数据源,取最近一次成功安装任务的输入;任务已被清理时回落到目录默认值。"""
task = await session.scalar(
select(Task)
.where(
Task.bot_id == bot_id,
Task.kind == "skill_install",
Task.status == "succeeded",
Task.payload["skill_id"].astext == str(skill_id),
)
.order_by(Task.id.desc())
.limit(1)
)
if task is None:
return None
inputs = json.loads(
request.app.state.cipher.decrypt(task.payload["inputs_enc"], installs.INPUTS_AAD)
)
source: str | None = inputs.get("data_source")
return source


async def upgrade_one(
session: AsyncSession, request: Request, actor: User, bot: Bot, skill: Skill, row: BotSkill
) -> Task:
"""按上次成功安装的配置重装代码并重算环境变量;内部技能沿用原批准范围与安全约束。"""
if await session.scalar(
select(SkillApproval.id).where(
SkillApproval.bot_id == bot.id,
SkillApproval.skill_id == skill.id,
SkillApproval.status == "pending",
)
):
raise ApiError(409, 409, "有待审申请")
cipher = request.app.state.cipher
groups = list(row.selected_env_groups)
inputs = await installs.input_snapshot(
session,
cipher,
bot,
skill,
selected=groups,
source=await last_data_source(session, request, bot.id, skill.id),
values=None,
requester=actor,
)
inputs.update(reinstall_code=True, upgrade=True)
approval = None
if skill.security_level == "internal":
policy = row.security_prompt
inputs.update(approved_databases=groups, security_prompt=policy)
# 由点击升级的委员会成员按原范围重新批准一次,安装任务仍走同一套审批校验。
approval = SkillApproval(
bot_id=bot.id,
skill_id=skill.id,
requested_databases=groups,
requested_security_prompt=policy or "",
reinstall_code=True,
skill_revision=skill.revision,
request_inputs_enc=cipher.encrypt(
json.dumps(inputs, ensure_ascii=False), installs.INPUTS_AAD
),
requested_by=actor.id,
approved_databases=groups,
approved_security_prompt=policy,
status="approved",
reviewed_by=actor.id,
reviewed_at=utcnow(),
review_comment="一键升级:沿用原批准范围",
)
session.add(approval)
await session.flush()
else:
inputs.update(approved_databases=[], security_prompt=None)
return await installs.queue(
session, cipher, bot=bot, skill=skill, actor=actor, inputs=inputs, approval=approval
)


@router.post("/skills/{skill_id}/upgrade")
async def upgrade(
skill_id: uuid.UUID,
request: Request,
actor: User = Depends(current_user),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""为已安装该技能的所有 AI 员工重装技能并刷新环境变量;不满足条件的员工跳过并说明原因。"""
if actor.role not in ("ai_committee", "platform_admin"):
raise forbidden()
skill = await session.get(Skill, skill_id, with_for_update={"read": True})
if skill is None or skill.deleted_at is not None:
raise not_found("技能不存在")
if not skill.enabled:
raise ApiError(409, 409, "技能已停用,请先启用")
bot_ids = list(
await session.scalars(
select(BotSkill.bot_id)
.where(
BotSkill.skill_id == skill_id,
BotSkill.status != "uninstalled",
BotSkill.installed_at.is_not(None),
)
.order_by(BotSkill.bot_id)
)
)
names = await bot_names(session, bot_ids)
queued: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for bot_id in bot_ids:
bot = await session.scalar(select(Bot).where(Bot.id == bot_id).with_for_update())
row = await session.get(BotSkill, (bot_id, skill_id))
if bot is None or row is None:
continue
item = {"bot_id": bot_id, "bot_name": names.get(bot_id)}
try:
async with session.begin_nested():
task = await upgrade_one(session, request, actor, bot, skill, row)
except ApiError as exc:
skipped.append({**item, "reason": exc.message})
continue
queued.append({**item, "task_id": task.id})
await record_audit(
session,
action="skill.upgrade",
actor_id=actor.id,
actor_login=actor.login_name,
target_type="skill",
target_id=str(skill_id),
diff={"queued": [None, len(queued)], "skipped": [None, len(skipped)]},
ip=client_ip(request),
)
await session.commit()
return {"code": 0, "data": {"queued": queued, "skipped": skipped}}


@router.delete("/bots/{bot_id}/skills/{skill_id}")
async def uninstall(
bot_id: uuid.UUID,
Expand Down
9 changes: 8 additions & 1 deletion coreman/runtime/worker/skill_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,16 @@ async def checked(
raise ValueError("installation_target_changed")
requester = await session.get(User, uuid.UUID(inputs["requester_id"]))
actor = await session.get(User, task.user_id) if task.user_id else None
# 目录页一键升级由委员会成员发起,不要求其是该机器人的管理员。
upgrader = (
inputs.get("upgrade")
and requester is not None
and requester.status == "active"
and requester.role in ("ai_committee", "platform_admin")
)
if (
requester is None
or not await installs.bot_admin(session, bot, requester)
or not (upgrader or await installs.bot_admin(session, bot, requester))
or actor is None
or actor.status != "active"
):
Expand Down
68 changes: 68 additions & 0 deletions tests/api/test_bot_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,71 @@ async def agent(relay, *args):
)
assert log["error_code"] == "installation_configuration_changed_during_execution"
assert log["remote"] is True


async def test_upgrade_reinstalls_with_previous_scope_and_fresh_env(
client, db_session, db_engine, monkeypatch
):
bot, skill, cipher = await prepare(client, db_session, internal=True)
bot_id, skill_id = bot.id, skill.id
result = await client.post(
f"/api/admin/bots/{bot_id}/skills/{skill_id}/install",
json={"selected_env_groups": ["erp"], "user_env_vars": {"API_KEY": "secret"}},
)
approval_id = result.json()["data"]["approval"]["id"]
assert (await client.post(f"/api/admin/skills/{skill_id}/upgrade")).status_code == 403
await login_as(client, db_session, role="ai_committee")
result = await client.post(
f"/api/admin/skill-approvals/{approval_id}/review",
json={
"decision": "approve",
"approved_databases": ["erp"],
"approved_security_prompt": "Only orders table",
},
headers={"If-Match": "1"},
)
calls = []

async def agent(relay, cipher, operation, payload):
calls.append(payload)
return {"success": True}

monkeypatch.setattr(skill_install, "call_agent", agent)
task = await execute(db_session, db_engine, result.json()["data"]["task_id"])
assert task.status == "succeeded", task.error_message
# 目录与预设都变了:旧审批对应旧修订号,升级要按原范围重新批准并重算环境变量。
skill.description, skill.version = "new release", "2.0.0"
preset = await db_session.get(EnvPreset, "erp")
preset.vars_enc = cipher.encrypt('{"DB_PASSWORD":"rotated"}', installs.PRESET_AAD)
await db_session.commit()
# 升级人是委员会成员但不是该机器人的管理员。
upgrader_id = (await login_as(client, db_session, role="ai_committee")).id
result = await client.post(f"/api/admin/skills/{skill_id}/upgrade")
assert result.status_code == 200, result.text
data = result.json()["data"]
assert data["skipped"] == [] and len(data["queued"]) == 1
again = (await client.post(f"/api/admin/skills/{skill_id}/upgrade")).json()["data"]
assert again["queued"] == [] and len(again["skipped"]) == 1
task = await execute(db_session, db_engine, data["queued"][0]["task_id"])
assert task.status == "succeeded", task.error_message
assert len(calls) == 2
db_session.expire_all()
row = await db_session.get(BotSkill, (bot_id, skill_id))
assert row.status == "installed" and row.version == "2.0.0"
assert row.selected_env_groups == ["erp"] and row.security_prompt == "Only orders table"
assert row.approved_by == upgrader_id
bot = await db_session.get(Bot, bot_id)
env = await installs.effective_env(db_session, cipher, bot)
assert env["DB_PASSWORD"] == "rotated" and env["API_KEY"] == "secret"
assert "Only orders table" in bot.merged_system_prompt


async def test_upgrade_skips_disabled_skill_and_uninstalled_bots(client, db_session):
bot, skill, _ = await prepare(client, db_session)
await login_as(client, db_session, role="platform_admin")
result = await client.post(f"/api/admin/skills/{skill.id}/upgrade")
assert result.status_code == 200, result.text
assert result.json()["data"] == {"queued": [], "skipped": []}
skill.enabled = False
await db_session.commit()
assert (await client.post(f"/api/admin/skills/{skill.id}/upgrade")).status_code == 409
2 changes: 2 additions & 0 deletions web/src/api/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Preset { group_key: string; label: string; vars: Record<string,
export interface Installed { skill_id: string; name: string; status: string; revision: number; version: string | null; selected_env_groups: string[]; user_env_vars: Record<string, string>; security_prompt: string | null; error_message: string | null }
export interface Approval { bot_name?: string | null; skill_name?: string | null; skill_revision?: number; id: string; bot_id: string; skill_id: string; requested_databases: string[]; requested_security_prompt: string; approved_databases: string[] | null; status: string; version: number; requested_at: string; requested_by: string; reviewed_by: string | null; review_comment: string | null }
export interface InstallInput { selected_env_groups: string[]; data_source: string | null; user_env_vars: Record<string, string>; requested_security_prompt: string | null; reinstall_code: boolean }
export interface UpgradeItem { bot_id: string; bot_name: string | null }
type Page<T> = { items: T[]; total: number }
const root = '/api/admin'
const match = (version: number) => ({ headers: { 'If-Match': String(version) } })
Expand All @@ -22,6 +23,7 @@ export const skills = {
sourceSave: (row: Source | null, body: Omit<Source, 'id' | 'version' | 'has_access_token'> & { access_token?: string; remove_access_token?: boolean }) => call<Source>(row ? http.put(`${root}/skill-sources/${row.id}`, body, match(row.version)) : http.post(`${root}/skill-sources`, body)),
save: (row: Skill | null, body: SkillInput) => call<Skill>(row ? http.put(`${root}/skills/${row.id}`, body, match(row.revision)) : http.post(`${root}/skills`, body)),
setEnabled: (row: Skill, enabled: boolean) => call<Skill>(http.patch(`${root}/skills/${row.id}`, { enabled }, match(row.revision))),
upgrade: (row: Skill) => call<{ queued: UpgradeItem[]; skipped: (UpgradeItem & { reason: string })[] }>(http.post(`${root}/skills/${row.id}/upgrade`)),
remove: (row: Skill) => call(http.delete(`${root}/skills/${row.id}`, match(row.revision))),
presets: () => call<Preset[]>(http.get(`${root}/env-presets`)),
presetSave: (body: Preset) => call<Preset>(http.put(`${root}/env-presets/${body.group_key}`, { group_key: body.group_key, label: body.label, vars: body.vars, tags: body.tags }, match(body.version))),
Expand Down
10 changes: 10 additions & 0 deletions web/src/i18n/skillEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const skillEditorZh = {
syncSkipped: '已删除的 {skipped} 个技能没有重新导入。',
deleteConfirm: '从目录删除「{name}」?删除后来源同步不会再导入它,已有的安装与审批记录保留。若仍有 AI 员工在用或正在申请,须先在员工的技能中停用 / 撤回。如需恢复,用同名新建技能即可。',
deleted: '已删除',
upgrade: '升级',
upgradeConfirm: '为已安装「{name}」的所有 AI 员工重新安装最新版本,并按原配置刷新环境变量(含预设的最新取值)?内部技能沿用原批准的数据库范围与安全约束,记为你批准。',
upgradeQueued: '已为 {count} 个 AI 员工提交升级,可在员工的技能页查看进度',
upgradeNone: '没有已安装该技能的 AI 员工',
upgradeSkipped: '跳过 {count} 个:{detail}',
presetTitle: '复用安装配置', presetHint: '集中维护环境变量,在技能的「固定环境预设」中关联;安装时按技能配置注入。',
newPreset: '新建环境预设', editPreset: '编辑环境预设', envValues: '环境变量',
basics: '基本信息', skillName: '技能标识', nameHint: '与仓库中的技能名称一致;支持字母、数字、短横线和下划线。',
Expand Down Expand Up @@ -57,6 +62,11 @@ export const skillEditorJa = {
syncSkipped: '削除済みのスキル {skipped} 件は再取り込みしていません。',
deleteConfirm: '「{name}」をカタログから削除しますか?削除後はソースを同期しても再取り込みされず、既存のインストールと承認の履歴は残ります。AI 社員が使用中または申請中の場合は、先にその社員のスキルで停止 / 取り下げてください。復元するには同じ名前でスキルを作成します。',
deleted: '削除しました',
upgrade: 'アップグレード',
upgradeConfirm: '「{name}」をインストール済みのすべての AI 社員に最新版を再インストールし、元の設定で環境変数(プリセットの最新値を含む)を更新しますか?内部スキルは承認済みのデータベース範囲と安全制約を引き継ぎ、あなたの承認として記録されます。',
upgradeQueued: '{count} 名の AI 社員のアップグレードを開始しました。進捗は各社員のスキル画面で確認できます',
upgradeNone: 'このスキルをインストールしている AI 社員はいません',
upgradeSkipped: '{count} 名をスキップ:{detail}',
presetTitle: 'インストール設定を再利用', presetHint: '環境変数を一元管理し、スキルの固定プリセットに関連付けてインストール時に使用します。',
newPreset: '環境プリセットを作成', editPreset: '環境プリセットを編集', envValues: '環境変数',
basics: '基本情報', skillName: 'スキル識別子', nameHint: 'リポジトリ内のスキル名と一致させてください。英数字・ハイフン・アンダースコアを使用できます。',
Expand Down
5 changes: 5 additions & 0 deletions web/src/i18n/skillEditorEn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const skillEditorEn = {
syncSkipped: '{skipped} deleted skill(s) were not imported again.',
deleteConfirm: 'Delete "{name}" from the catalog? Syncing its source will not import it again; existing install and approval history is kept. If an AI employee still uses or is requesting it, stop or withdraw it in that employee’s skills first. To restore it, create a skill with the same name.',
deleted: 'Deleted',
upgrade: 'Upgrade',
upgradeConfirm: 'Reinstall the latest "{name}" for every AI employee that has it, and refresh their environment variables from the original settings (including the latest preset values)? Internal skills keep their approved database scope and security constraints, recorded as approved by you.',
upgradeQueued: 'Upgrade started for {count} AI employee(s); track progress in each employee’s skills.',
upgradeNone: 'No AI employee has this skill installed.',
upgradeSkipped: 'Skipped {count}: {detail}',
presetTitle: 'Reuse installation settings', presetHint: 'Manage environment variables centrally and link them through a skill’s fixed environment presets. Variables are injected during installation.',
newPreset: 'Create environment preset', editPreset: 'Edit environment preset', envValues: 'Environment variables',
basics: 'Basic information', skillName: 'Skill identifier', nameHint: 'Must match the skill name in the repository. Letters, digits, hyphens, and underscores are supported.',
Expand Down
27 changes: 26 additions & 1 deletion web/src/views/SkillsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ async function remove(row: Skill) {
deleting.value = row.id
try { await skills.remove(row); ElMessage.success(t('skillEditor.deleted')); await load() } catch (e) { fail(e); await load() } finally { deleting.value = '' }
}
/** 为已安装该技能的员工排队重装并刷新环境变量;不满足条件的员工由后端跳过并给出原因。 */
const upgrading = ref('')
async function upgrade(row: Skill) {
try { await ElMessageBox.confirm(t('skillEditor.upgradeConfirm', { name: row.name }), t('skillEditor.upgrade'), { type: 'warning' }) } catch { return }
upgrading.value = row.id
try {
const { queued, skipped } = await skills.upgrade(row)
if (!queued.length && !skipped.length) { ElMessage.info(t('skillEditor.upgradeNone')); return }
if (queued.length) ElMessage.success(t('skillEditor.upgradeQueued', { count: queued.length }))
if (skipped.length) {
const detail = skipped.map(item => `${item.bot_name ?? item.bot_id}(${item.reason})`).join(';')
ElMessage({ type: 'warning', message: t('skillEditor.upgradeSkipped', { count: skipped.length, detail }), duration: 0, showClose: true })
}
} catch (e) { fail(e) } finally { upgrading.value = '' }
}
onMounted(load)
</script>
<template>
Expand Down Expand Up @@ -199,7 +214,7 @@ onMounted(load)
<el-table-column
v-if="manager"
:label="t('common.actions')"
width="130"
width="190"
fixed="right"
>
<template #default="{ row }">
Expand All @@ -211,6 +226,16 @@ onMounted(load)
>
{{ t('common.edit') }}
</el-button>
<el-button
link
type="primary"
:data-test="'upgrade-' + row.name"
:loading="upgrading === row.id"
:disabled="!row.enabled || !!upgrading && upgrading !== row.id"
@click="upgrade(row)"
>
{{ t('skillEditor.upgrade') }}
</el-button>
<el-button
link
type="danger"
Expand Down
Loading