Files
lobehub__lobehub/locales/ru-RU/editor.json
Rdmclin2 26dc379af9 🐛 fix(agent-group): keep group membership lifecycle intact on transfer & delete (#18126)
* 🐛 fix(agent-group): keep group membership lifecycle intact on transfer & delete

A chat group's roster mixes two kinds of member: agents the group itself
built (its synthetic supervisor, plus members made in the group builder)
and standalone agents merely linked in. Only `duplicateGroup` knew the
difference; delete, roster removal, group transfer and agent transfer
each acted on every member alike, which cost:

- deleting a group stranded its own supervisor/members forever (`agents`
  has no FK to `chat_groups`, and `virtual: true` hides them from every
  list), and the cleanup ran outside the delete's transaction
- transferring an agent silently tore it out of every group, and for a
  supervisor left the group headless — the next read minted a blank
  replacement and the configured systemRole/model were gone
- transferring a group dragged referenced members into another scope,
  and evicted them from every OTHER group they belonged to

Put the judgement in one place (`resolveGroupMembershipType`) and have
all four paths read it. A group transfer now leaves referenced members
where they are and takes a clone instead, remapping the group's own
topics/threads/messages onto it — `agent_id` is ON DELETE CASCADE, so a
stale pointer would have taken the moved history with it. The async
backfill applies the same remap on the same rows it rewrites.

Also surfaced up front rather than after the fact: two read endpoints
(`agent.getGroupMembershipImpact`, `group.listReferencedMembers`) so the
UI can say what a move will do, and `role` is now a closed enum instead
of free text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(test): use a role the closed set actually contains

`moderator` predates `GROUP_MEMBER_ROLES` and is not a member of it, so
these two cases stopped type-checking once `role` became an enum.
`assistant` is the valid non-default role, so they still prove a
non-default role round-trips rather than just re-asserting the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-group): gate referenced-member details on member visibility

`listReferencedMembers` scoped only on the GROUP, so a caller who could
see the group could read the title and avatar of a member whose owner
had since flipped it back to `private` — a member the roster itself
hides from them (`memberAgentVisibility`). The pre-transfer warning
would have been the one surface that handed it out.

Apply `agentOwnership()` as well, the same predicate
`checkAgentsBeforeRemoval` and the roster reads use. A hidden member is
now under-reported rather than exposed, which is the right direction:
it is invisible to this caller everywhere else too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-group): guard left-behind members for the drain's duration

A slow-path group transfer registers the target-scope roster — members
that travelled plus the clones — as the job's covered agents. The
referenced members it left behind are not covered, and deliberately so:
that junction is what the migration badge reads, and an agent that never
moved should not advertise a migration.

But being uncovered also left them unguarded. Until the drain reaches a
topic, that topic's rows still carry `messages.agent_id = sourceAgentId`,
and the column is ON DELETE CASCADE — so the owner deleting their own
agent mid-drain silently took the moved group history with it, the very
rows the remap was about to rescue.

Guard the delete without touching the badge:
`hasPendingRemapForSourceAgents` reads `payload.agentIdRemap` of pending
transfer jobs, and `AgentModel.delete` now refuses on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-group): refuse to clone a member the caller cannot see

A group transfer leaves referenced members behind and clones them into
the target scope — title, systemRole, model, params and plugins — via
`buildCopiedAgent`. When a member's owner had since flipped it back to
`private`, that clone handed the caller a full copy of an agent the
roster hides from them (`memberAgentVisibility`), and which
`listReferencedMembers` had just been fixed to withhold. Moving the
group was the way around both.

Pre-existing rather than new — the old path MOVED that agent into the
target scope outright, which is strictly worse — and only narrowed, not
closed, by the owner-only guard: `buildWorkspaceWhere` constrains
private rows to their author, so a workspace owner cannot see such a
member either.

Refuse, rather than clone a redacted shell (a member answering with a
blank systemRole is a silent behavior change) or drop the member
(leaving moved history pointed at an agent in the source scope, which
`messages.agent_id`'s cascade turns back into data loss). The error
names nobody: which member is hidden is the part being withheld.

`copyToWorkspace` duplicates members the same way and gets the same
guard. `duplicate` needs none — it re-links referenced members instead
of copying them, so no config crosses a scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔒 fix(agent-group): check member visibility on locked rows, not a snapshot

Both guards ran against a read taken before the row lock, so an owner
committing `visibility = 'private'` in between was checked as public and
then cloned anyway — from that same stale snapshot. A narrow window, but
it defeated the guard entirely when it hit.

- transfer: the check moves BELOW the existing `FOR UPDATE` on the member
  agents, and the clone now reads the locked row rather than `memberRows`
- copy: the check moves INSIDE the transaction and takes its own
  `FOR UPDATE` on the referenced members first, since it had no lock at
  all on them

Once those rows are locked, a concurrent visibility update blocks until
this transaction ends, so what is checked is what gets cloned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔒 fix(agent-group): enforce single-group ownership, withhold hidden group ids

Two guards that were resting on something weaker than they looked.

`addAgentsToGroup` validated visibility but never rejected a `virtual`
agent, so the "an owned member belongs to exactly one group" invariant
that `resolveGroupMembershipType` leans on was enforced by the member
picker's query rather than by the write. Sending a known group-built
member or supervisor id would link it to a second group — and then
deleting either group deletes an agent the other still uses. Reject it
at the write.

`getGroupMembershipImpact` set `groupVisible: false` for a group the
caller cannot see, and withheld its title and avatar, but still returned
its id — so the preflight disclosed the existence and identifier of
another member's private group. Withhold the id with the rest. Done
where the refs are built, so the transfer error's `groups` payload stops
carrying them too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-group): the invariant is one group, not zero

The previous commit rejected every `virtual` agent at `addAgentsToGroup`,
which breaks the group agent builder: it creates `virtual: true` and adds
it through exactly this path, as that agent's first and only membership.

Reject only a virtual agent already on ANOTHER group's roster — which is
the actual invariant `resolveGroupMembershipType` needs, and the only
case where deleting one group would destroy an agent another still
lists.

Adds the test that should have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔒 fix(agent-group): keep copy and transfer taking locks in the same order

My own TOCTOU fix put the referenced-member `FOR UPDATE` at the top of
`copyToWorkspace`'s transaction — ahead of the source-group lock. That
inverted the order `transferToWorkspace` uses (group, then member
agents), so a concurrent copy and transfer of the same group could each
hold what the other wanted and deadlock, instead of meeting the
pending-job guards that exist to serialize them.

Move the agent lock below the group lock. Same protection, one order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔒 fix(agent-group): keep builtin agents off group rosters

Builtins (Inbox, the agent builders) are provisioned per user and carry
`virtual: true`, exactly like a group's own members — so the membership
rules classify one as group-OWNED the moment it lands on a roster. And
`removeAgentsFromGroup` deletes owned members with no reserved-slug
protection, unlike the group-delete path. Adding your Inbox to a group
and then leaving the group deleted the Inbox.

Narrowing the previous virtual-agent rejection to "already in another
group" is what let a builtin through the door, since a builtin normally
sits on no roster at all.

Two layers, matching how `ChatGroupModel.delete` already handles this:
- `addAgentsToGroup` refuses a reserved builtin slug outright
- the removal delete gains the same reserved-slug backstop, for a row
  that reached the roster some other way

Both regression tests verified failing first: without the backstop the
Inbox row is gone; without the door check the add resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔒 fix(agent-group): a builtin is never a group-owned member

The previous commit closed the two paths that DELETE an owned member.
It left the one that rehomes them: `transferToWorkspace` rewrites
`userId`/`workspaceId` for every owned member, so a builtin on a roster
would have someone's Inbox moved into another scope.

Rather than a third call-site backstop, put the carve-out in the shared
judgement — which is what this whole change is about. A reserved builtin
slug now resolves to `referenced` ahead of every other rule, including
`supervisor`, in both the TS resolver and its SQL twin. Every owned path
inherits it: delete, roster removal, group transfer, agent transfer.

Call sites that partition a roster now pass `slug`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🔨 chore(agent-group): drop the now-unused `not` import

Left over from the roster-eviction delete this branch removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 🐛 fix(agent-group): classify referenced members with the same inputs everywhere

`listReferencedMembers` fed `resolveGroupMembershipType` a row without
`slug`, so a builtin on a roster read as merely `virtual` — owned — and
dropped out of the warning. The transfer path does pass `slug`, treats
the same row as referenced, and clones it. The warning therefore omitted
the one row the move was about to act on.

Carry `slug` here too. The shared judgement only helps if every caller
hands it the same facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:19:41 -07:00

103 lines
6.8 KiB
JSON
Raw Permalink 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.
{
"actionTag.category.agentSkill": "Навык агента",
"actionTag.category.command": "Команда",
"actionTag.category.projectSkill": "Навык проекта",
"actionTag.category.skill": "Навык",
"actionTag.category.tool": "Инструмент",
"actionTag.tooltip.agentSkill": "Загружает набор навыков из документов этого агента для запроса.",
"actionTag.tooltip.clickToView": "Нажмите, чтобы просмотреть детали",
"actionTag.tooltip.command": "Выполняет клиентскую slash-команду перед отправкой.",
"actionTag.tooltip.projectSkill": "Отправляется как вызов через слэш, чтобы CLI агента запускал соответствующий навык проекта.",
"actionTag.tooltip.skill": "Загружает для этого запроса переиспользуемый пакет навыков.",
"actionTag.tooltip.tool": "Помечает инструмент, который пользователь явно выбрал для этого запроса.",
"actions.expand.off": "Свернуть",
"actions.expand.on": "Развернуть",
"actions.typobar.off": "Скрыть панель форматирования",
"actions.typobar.on": "Показать панель форматирования",
"actions.typobar.title": "Инструмент форматирования",
"autoSave.failed": "Сохранение не удалось",
"autoSave.latest": "Загружена последняя версия",
"autoSave.retry": "Повторить",
"autoSave.saved": "Сохранено",
"autoSave.saving": "Автосохранение...",
"cancel": "Отмена",
"codemirror.copyFailed": "Не удалось скопировать",
"codemirror.copySuccess": "Код скопирован в буфер обмена",
"codemirror.selectLanguage": "Выбрать язык",
"codemirror.selectTheme": "Выбрать тему",
"codemirror.showLineNumbers": "Показать номера строк",
"codemirror.tabSize": "Размер табуляции",
"codemirror.theme": "Тема",
"codemirror.useTabs": "Использовать табуляцию",
"confirm": "Подтвердить",
"file.error": "Ошибка: {{message}}",
"file.preparing": "Подготовка к загрузке…",
"file.processing": "Обработка файла…",
"file.uploading": "Загрузка файла...",
"file.uploadingProgress": "Загрузка… {{progress}}%",
"image.broken": "Изображение повреждено",
"link.edit": "Редактировать ссылку",
"link.editLinkTitle": "Ссылка",
"link.editTextTitle": "Заголовок",
"link.open": "Открыть ссылку",
"link.placeholder": "Введите URL ссылки",
"link.unlink": "Удалить ссылку",
"markdown.cancel": "Отмена",
"markdown.confirm": "Преобразовать",
"markdown.parseMessage": "Преобразовать в формат Markdown. Существующее содержимое будет перезаписано. Вы уверены? (Окно закроется автоматически через 5 секунд)",
"markdown.parseTitle": "Форматировать как Markdown",
"math.placeholder": "Введите формулу в формате TeX",
"modifier.accept": "Оставить",
"modifier.acceptAll": "Оставить все",
"modifier.reject": "Отменить",
"modifier.rejectAll": "Отменить все",
"skillFrontmatter.edit": "Редактировать метаданные",
"skillFrontmatter.empty": "Нет метаданных",
"skillFrontmatter.invalid.descriptionInvalid": "Описание должно быть однострочным текстом.",
"skillFrontmatter.invalid.descriptionRequired": "Описание обязательно.",
"skillFrontmatter.invalid.mapping": "Фронтматтер должен быть YAML-отображением.",
"skillFrontmatter.invalid.nameInvalid": "Имя должно содержать только строчные буквы, цифры и дефисы.",
"skillFrontmatter.invalid.nameLocked": "Имя должно оставаться {{name}}. Переименуйте пакет навыков вместо этого.",
"skillFrontmatter.invalid.nameRequired": "Имя обязательно.",
"skillFrontmatter.invalid.required": "Фронтматтер обязателен.",
"skillFrontmatter.invalid.syntax": "Недопустимый синтаксис YAML.",
"skillFrontmatter.saveFailed": "Метаданные не были сохранены. Повторите попытку или продолжайте редактирование.",
"skillFrontmatter.title": "Метаданные навыка",
"slash.compact": "Сжать контекст",
"slash.goal": "Установить цель",
"slash.goal.desc": "Запланируйте и начните цель с редактируемыми критериями принятия",
"slash.h1": "Заголовок 1",
"slash.h2": "Заголовок 2",
"slash.h3": "Заголовок 3",
"slash.hr": "Разделитель",
"slash.newTopic": "Отправить в новую тему",
"slash.rewrite": "Переписать",
"slash.summarize": "Суммировать",
"slash.table": "Таблица",
"slash.tex": "Формула TeX",
"slash.translate": "Перевести",
"table.delete": "Удалить таблицу",
"table.deleteColumn": "Удалить столбец",
"table.deleteRow": "Удалить строку",
"table.insertColumnLeft": "Вставить {{count}} столбец(а) слева",
"table.insertColumnRight": "Вставить {{count}} столбец(а) справа",
"table.insertRowAbove": "Вставить {{count}} строку(и) выше",
"table.insertRowBelow": "Вставить {{count}} строку(и) ниже",
"typobar.blockquote": "Цитата",
"typobar.bold": "Жирный",
"typobar.bulletList": "Маркированный список",
"typobar.code": "Встроенный код",
"typobar.codeblock": "Блок кода",
"typobar.image": "Изображение",
"typobar.italic": "Курсив",
"typobar.link": "Ссылка",
"typobar.numberList": "Нумерованный список",
"typobar.redo": "Повторить",
"typobar.strikethrough": "Зачёркнутый",
"typobar.table": "Таблица",
"typobar.taskList": "Список задач",
"typobar.tex": "Формула TeX",
"typobar.underline": "Подчёркнутый",
"typobar.undo": "Отменить"
}