Merge remote-tracking branch 'origin/main' into feat/agent-v2
# Conflicts: # api/controllers/console/app/agent_drive_inspector.py # api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py # api/openapi/markdown/console-openapi.md # api/services/agent/skill_standardize_service.py # api/services/agent_drive_service.py # api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py # api/tests/unit_tests/services/agent/test_skill_standardize_service.py # packages/contracts/generated/api/console/agent/orpc.gen.ts # packages/contracts/generated/api/console/agent/types.gen.ts # packages/contracts/generated/api/console/agent/zod.gen.ts # packages/contracts/generated/api/console/apps/orpc.gen.ts # packages/contracts/generated/api/console/apps/types.gen.ts # packages/contracts/generated/api/console/apps/zod.gen.ts
This commit is contained in:
@@ -666,12 +666,16 @@ class AgentRosterService:
|
||||
@staticmethod
|
||||
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
|
||||
if agent.source == AgentSource.AGENT_APP:
|
||||
return {AgentConfigRevisionOperation.SAVE_NEW_VERSION}
|
||||
return {
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
return {
|
||||
AgentConfigRevisionOperation.CREATE_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_AGENT,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
|
||||
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
|
||||
@@ -764,6 +768,46 @@ class AgentRosterService:
|
||||
]
|
||||
return result
|
||||
|
||||
def restore_agent_version(
|
||||
self, *, tenant_id: str, agent_id: str, version_id: str, account_id: str
|
||||
) -> dict[str, Any]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent)
|
||||
visible_version_id = self._session.scalar(
|
||||
select(AgentConfigSnapshot.id)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == version_id,
|
||||
AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if not visible_version_id:
|
||||
raise AgentVersionNotFoundError()
|
||||
|
||||
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
if agent.active_config_snapshot_id == version.id:
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
previous_snapshot_id = agent.active_config_snapshot_id
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
|
||||
agent.updated_by = account_id
|
||||
self._session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
created_by=account_id,
|
||||
)
|
||||
)
|
||||
self._session.commit()
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
|
||||
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
|
||||
if roster_only:
|
||||
@@ -789,6 +833,17 @@ class AgentRosterService:
|
||||
raise AgentVersionNotFoundError()
|
||||
return version
|
||||
|
||||
def _next_revision(self, *, tenant_id: str, agent_id: str) -> int:
|
||||
return (
|
||||
self._session.scalar(
|
||||
select(func.max(AgentConfigRevision.revision)).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
|
||||
def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]:
|
||||
predicates = [
|
||||
and_(
|
||||
|
||||
@@ -92,7 +92,11 @@ class SkillStandardizeService:
|
||||
file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name=manifest.name, description=manifest.description),
|
||||
skill_metadata=DriveSkillMetadata(
|
||||
name=manifest.name,
|
||||
description=manifest.description,
|
||||
manifest_files=manifest.files,
|
||||
),
|
||||
),
|
||||
DriveCommitItem(
|
||||
key=archive_key,
|
||||
|
||||
@@ -76,6 +76,10 @@ class DriveSkillMetadata(BaseModel):
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
# Safe archive member paths captured during skill standardization. The drive
|
||||
# stores only canonical SKILL.md + full archive, so the UI uses this manifest
|
||||
# to show the original uploaded package contents.
|
||||
manifest_files: list[str] | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -110,6 +114,31 @@ class AgentDriveSkillInfo(TypedDict):
|
||||
created_at: int | None
|
||||
|
||||
|
||||
class AgentDriveSkillFileInfo(TypedDict):
|
||||
path: str
|
||||
name: str
|
||||
type: str
|
||||
drive_key: str | None
|
||||
available_in_drive: bool
|
||||
|
||||
|
||||
class AgentDriveSkillInspectInfo(TypedDict):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None
|
||||
mime_type: str | None
|
||||
hash: str | None
|
||||
created_at: int | None
|
||||
source: str
|
||||
files: list[AgentDriveSkillFileInfo]
|
||||
file_tree: list[dict[str, Any]]
|
||||
skill_md: dict[str, Any]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def decode_drive_mention_ref(ref_id: str) -> str:
|
||||
"""Decode the prompt token's URL-encoded drive-key field."""
|
||||
|
||||
@@ -219,6 +248,52 @@ class AgentDriveService:
|
||||
self._delete_storage(storage_key)
|
||||
return committed
|
||||
|
||||
def delete(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str | None = None,
|
||||
key: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5).
|
||||
|
||||
Drive-owned values get their backing record + storage object cleaned via
|
||||
the same ``_cleanup_value`` path commit-overwrite uses; shared values only
|
||||
lose the KV row. Idempotent: deleting nothing returns ``[]``.
|
||||
"""
|
||||
if (prefix is None) == (key is None):
|
||||
raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key")
|
||||
removed_keys: list[str] = []
|
||||
pending_storage_deletes: list[str] = []
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
stmt = select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
)
|
||||
if key is not None:
|
||||
stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key))
|
||||
else:
|
||||
stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or "")))
|
||||
rows = list(session.scalars(stmt))
|
||||
for row in rows:
|
||||
if row.value_owned_by_drive:
|
||||
self._cleanup_value(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
exclude_row_id=row.id,
|
||||
pending_storage_deletes=pending_storage_deletes,
|
||||
)
|
||||
removed_keys.append(row.key)
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
for storage_key in pending_storage_deletes:
|
||||
self._delete_storage(storage_key)
|
||||
return removed_keys
|
||||
|
||||
def list_skills(self, *, tenant_id: str, agent_id: str) -> list[AgentDriveSkillInfo]:
|
||||
"""Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows."""
|
||||
|
||||
@@ -248,13 +323,12 @@ class AgentDriveService:
|
||||
skills: list[AgentDriveSkillInfo] = []
|
||||
for row in skill_rows:
|
||||
metadata = self._parse_skill_metadata(row.key, row.skill_metadata)
|
||||
archive_key = self._skill_archive_key(row.key)
|
||||
skills.append(
|
||||
{
|
||||
"path": self._skill_path_from_key(row.key),
|
||||
"skill_md_key": row.key,
|
||||
"archive_key": (
|
||||
self._skill_archive_key(row.key) if self._skill_archive_key(row.key) in archive_keys else None
|
||||
),
|
||||
"archive_key": archive_key if archive_key in archive_keys else None,
|
||||
"name": metadata.name,
|
||||
"description": metadata.description,
|
||||
"size": row.size,
|
||||
@@ -265,6 +339,42 @@ class AgentDriveService:
|
||||
)
|
||||
return skills
|
||||
|
||||
def inspect_skill(self, *, tenant_id: str, agent_id: str, skill_path: str) -> AgentDriveSkillInspectInfo:
|
||||
"""Return the UI-facing skill inspect view for slash-menu hover/detail."""
|
||||
|
||||
skill_path = normalize_drive_key(skill_path)
|
||||
skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}"
|
||||
skill_path = self._skill_path_from_key(skill_md_key)
|
||||
catalog = next(
|
||||
(item for item in self.list_skills(tenant_id=tenant_id, agent_id=agent_id) if item["path"] == skill_path),
|
||||
None,
|
||||
)
|
||||
if catalog is None:
|
||||
raise AgentDriveError("skill_not_found", "no drive-backed skill for this path", status_code=404)
|
||||
|
||||
manifest_files = self._manifest_files_from_skill_metadata(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
skill_md_key=skill_md_key,
|
||||
)
|
||||
drive_items = self.manifest(tenant_id=tenant_id, agent_id=agent_id, prefix=f"{skill_path}/")
|
||||
drive_keys = {item["key"] for item in drive_items}
|
||||
preview = self.preview(tenant_id=tenant_id, agent_id=agent_id, key=skill_md_key)
|
||||
files, warnings = self._skill_file_entries(
|
||||
skill_path=skill_path,
|
||||
skill_md_key=skill_md_key,
|
||||
manifest_files=manifest_files,
|
||||
drive_keys=drive_keys,
|
||||
)
|
||||
return {
|
||||
**catalog,
|
||||
"source": "skill_md",
|
||||
"files": files,
|
||||
"file_tree": self._build_file_tree(files),
|
||||
"skill_md": preview,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
def _commit_one(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -325,8 +435,8 @@ class AgentDriveService:
|
||||
existing.is_skill = item.is_skill
|
||||
existing.skill_metadata = skill_metadata
|
||||
existing.size = size
|
||||
existing.mime_type = mime_type
|
||||
existing.hash = file_hash
|
||||
existing.mime_type = mime_type
|
||||
return self._row_dict(existing)
|
||||
|
||||
row = AgentDriveFile(
|
||||
@@ -437,7 +547,11 @@ class AgentDriveService:
|
||||
"skill metadata is required for canonical skill rows",
|
||||
status_code=400,
|
||||
)
|
||||
return json.dumps(item.skill_metadata.model_dump(mode="json"), separators=(",", ":"), sort_keys=True)
|
||||
return json.dumps(
|
||||
item.skill_metadata.model_dump(mode="json", exclude_none=True),
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata:
|
||||
@@ -456,6 +570,122 @@ class AgentDriveService:
|
||||
status_code=500,
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _manifest_files_from_skill_metadata(*, tenant_id: str, agent_id: str, skill_md_key: str) -> list[str] | None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key == skill_md_key,
|
||||
AgentDriveFile.is_skill.is_(True),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
metadata = AgentDriveService._parse_skill_metadata(row.key, row.skill_metadata)
|
||||
except Exception:
|
||||
logger.warning("drive skill inspect: malformed skill metadata for %s", skill_md_key, exc_info=True)
|
||||
return None
|
||||
return [str(item) for item in (metadata.manifest_files or []) if str(item).strip()] or None
|
||||
|
||||
@classmethod
|
||||
def _skill_file_entries(
|
||||
cls,
|
||||
*,
|
||||
skill_path: str,
|
||||
skill_md_key: str,
|
||||
manifest_files: list[str] | None,
|
||||
drive_keys: set[str],
|
||||
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
|
||||
warnings: list[str] = []
|
||||
if manifest_files:
|
||||
paths = sorted({normalize_drive_key(path) for path in manifest_files})
|
||||
else:
|
||||
paths = sorted(
|
||||
{
|
||||
key.removeprefix(f"{skill_path}/")
|
||||
for key in drive_keys
|
||||
if not key.endswith(f"/{_SKILL_ARCHIVE_NAME}")
|
||||
}
|
||||
)
|
||||
warnings.append("manifest_files_unavailable")
|
||||
|
||||
files: list[AgentDriveSkillFileInfo] = []
|
||||
for path in paths:
|
||||
if path == _SKILL_ARCHIVE_NAME:
|
||||
continue
|
||||
drive_key = f"{skill_path}/{path}"
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"type": "file",
|
||||
"drive_key": drive_key if drive_key in drive_keys else None,
|
||||
"available_in_drive": drive_key in drive_keys,
|
||||
}
|
||||
)
|
||||
if "SKILL.md" not in {file["path"] for file in files}:
|
||||
files.insert(
|
||||
0,
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"name": "SKILL.md",
|
||||
"type": "file",
|
||||
"drive_key": skill_md_key,
|
||||
"available_in_drive": skill_md_key in drive_keys,
|
||||
},
|
||||
)
|
||||
return files, warnings
|
||||
|
||||
@staticmethod
|
||||
def _build_file_tree(files: list[AgentDriveSkillFileInfo]) -> list[dict[str, Any]]:
|
||||
root: dict[str, Any] = {}
|
||||
for file in files:
|
||||
cursor = root
|
||||
parts = [part for part in file["path"].split("/") if part]
|
||||
path_parts: list[str] = []
|
||||
for part in parts[:-1]:
|
||||
path_parts.append(part)
|
||||
directory = cursor.setdefault(
|
||||
part,
|
||||
{
|
||||
"name": part,
|
||||
"path": "/".join(path_parts),
|
||||
"type": "directory",
|
||||
"children": {},
|
||||
},
|
||||
)
|
||||
cursor = directory["children"]
|
||||
leaf_name = parts[-1] if parts else file["name"]
|
||||
cursor[leaf_name] = {
|
||||
"name": leaf_name,
|
||||
"path": file["path"],
|
||||
"type": file["type"],
|
||||
"drive_key": file["drive_key"],
|
||||
"available_in_drive": file["available_in_drive"],
|
||||
}
|
||||
|
||||
def serialize(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in sorted(node.values(), key=lambda value: (value["type"] != "directory", value["name"])):
|
||||
if item["type"] == "directory":
|
||||
children = serialize(item["children"])
|
||||
result.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"path": item["path"],
|
||||
"type": "directory",
|
||||
"children": children,
|
||||
}
|
||||
)
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
return serialize(root)
|
||||
|
||||
@staticmethod
|
||||
def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None:
|
||||
try:
|
||||
@@ -679,7 +909,6 @@ class AgentDriveService:
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
"AgentDriveService",
|
||||
"AgentDriveSkillInfo",
|
||||
"DriveCommitItem",
|
||||
"DriveFileRef",
|
||||
"DriveSkillMetadata",
|
||||
|
||||
@@ -313,14 +313,24 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"plugin.debug",
|
||||
"credential.use",
|
||||
"credential.manage",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
"app.acl.preview",
|
||||
"app_library.access",
|
||||
"app.create_and_management",
|
||||
"app.tag.manage",
|
||||
"dataset.acl.preview",
|
||||
"dataset.create_and_management",
|
||||
"dataset.tag.manage",
|
||||
"dataset.external.connect",
|
||||
"dataset.api_key.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
@@ -335,14 +345,22 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"plugin.debug",
|
||||
"credential.use",
|
||||
"credential.manage",
|
||||
"billing.view",
|
||||
"billing.subscription.manage",
|
||||
"billing.manage",
|
||||
"app_library.access",
|
||||
"app.create_and_management",
|
||||
"app.tag.manage",
|
||||
"dataset.create_and_management",
|
||||
"dataset.tag.manage",
|
||||
"dataset.external.connect",
|
||||
"dataset.api_key.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"snippets.create_and_modify",
|
||||
"snippets.management",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
@@ -356,7 +374,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"dataset.create_and_management",
|
||||
"dataset.tag.manage",
|
||||
"dataset.external.connect",
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"snippets.create_and_modify",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
@@ -373,6 +393,7 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@@ -384,6 +405,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@@ -395,6 +417,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@@ -406,12 +429,14 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_APP_NORMAL_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.monitor",
|
||||
]
|
||||
|
||||
_LEGACY_DATASET_OWNER_KEYS: list[str] = [
|
||||
"dataset.acl.preview",
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.import_export_dsl",
|
||||
@@ -427,6 +452,7 @@ _LEGACY_DATASET_OWNER_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_DATASET_ADMIN_KEYS: list[str] = [
|
||||
"dataset.acl.preview",
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.import_export_dsl",
|
||||
@@ -442,6 +468,7 @@ _LEGACY_DATASET_ADMIN_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_DATASET_EDITOR_KEYS: list[str] = [
|
||||
"dataset.acl.preview",
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.import_export_dsl",
|
||||
@@ -492,6 +519,19 @@ _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = {
|
||||
}
|
||||
|
||||
|
||||
def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]:
|
||||
permissions = _LEGACY_MY_PERMISSIONS.get(role, {})
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*permissions.get("workspace", []),
|
||||
*permissions.get("app", []),
|
||||
*permissions.get("dataset", []),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse:
|
||||
if not account_id:
|
||||
return MyPermissionsResponse()
|
||||
@@ -1518,21 +1558,44 @@ class RBACService:
|
||||
)
|
||||
return AccessMatrixItem.model_validate(data or {})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles).
|
||||
# ------------------------------------------------------------------
|
||||
class MemberRoles:
|
||||
@staticmethod
|
||||
def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse:
|
||||
data = _inner_call(
|
||||
"GET",
|
||||
f"{_INNER_PREFIX}/members/rbac-roles",
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
params={"account_id": member_account_id},
|
||||
)
|
||||
rst = MemberRolesResponse.model_validate(data or {})
|
||||
return rst
|
||||
if dify_config.RBAC_ENABLED:
|
||||
data = _inner_call(
|
||||
"GET",
|
||||
f"{_INNER_PREFIX}/members/rbac-roles",
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
params={"account_id": member_account_id},
|
||||
)
|
||||
rst = MemberRolesResponse.model_validate(data or {})
|
||||
return rst
|
||||
else:
|
||||
with session_factory.create_session() as session:
|
||||
role = session.scalar(
|
||||
select(TenantAccountJoin.role).where(
|
||||
TenantAccountJoin.tenant_id == tenant_id,
|
||||
TenantAccountJoin.account_id == member_account_id,
|
||||
)
|
||||
)
|
||||
return MemberRolesResponse(
|
||||
account_id=member_account_id,
|
||||
roles=[
|
||||
RBACRole(
|
||||
id="",
|
||||
name=role,
|
||||
description="",
|
||||
is_builtin=True,
|
||||
type="",
|
||||
permission_keys=_legacy_role_permission_keys(role),
|
||||
role_tag="owner" if role == "owner" else role,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
]
|
||||
if role
|
||||
else [],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def batch_get(
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
|
||||
OPERATION_REQUEST_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
|
||||
|
||||
|
||||
class UtmInfo(TypedDict, total=False):
|
||||
"""Expected shape of the utm_info dict passed to record_utm.
|
||||
@@ -26,7 +28,9 @@ class OperationService:
|
||||
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
|
||||
|
||||
url = f"{cls.base_url}{endpoint}"
|
||||
response = httpx.request(method, url, json=json, params=params, headers=headers)
|
||||
response = httpx.request(
|
||||
method, url, json=json, params=params, headers=headers, timeout=OPERATION_REQUEST_TIMEOUT
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ from core.app.entities.task_entities import (
|
||||
WorkflowStartStreamResponse,
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
|
||||
from core.workflow.human_input_forms import load_form_tokens_by_form_id
|
||||
from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
enrich_human_input_pause_reasons,
|
||||
resolve_human_input_pause_reason_inputs,
|
||||
@@ -359,7 +362,7 @@ def _build_human_input_required_events(
|
||||
|
||||
expiration_times_by_form_id: dict[str, int] = {}
|
||||
display_in_ui_by_form_id: dict[str, bool] = {}
|
||||
form_tokens_by_form_id: dict[str, str] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
if human_input_form_ids and session_maker is not None:
|
||||
stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where(
|
||||
HumanInputForm.id.in_(human_input_form_ids)
|
||||
@@ -372,7 +375,7 @@ def _build_human_input_required_events(
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
definition_payload = {}
|
||||
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
|
||||
form_tokens_by_form_id = load_form_tokens_by_form_id(
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=human_input_surface,
|
||||
@@ -393,6 +396,7 @@ def _build_human_input_required_events(
|
||||
reason.inputs,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
disposition = dispositions_by_form_id.get(form_id)
|
||||
|
||||
response = HumanInputRequiredResponse(
|
||||
task_id=task_id,
|
||||
@@ -405,7 +409,8 @@ def _build_human_input_required_events(
|
||||
inputs=resolved_inputs,
|
||||
actions=reason.actions,
|
||||
display_in_ui=display_in_ui_by_form_id.get(form_id, False),
|
||||
form_token=form_tokens_by_form_id.get(form_id),
|
||||
form_token=disposition.form_token if disposition else None,
|
||||
approval_channels=list(disposition.approval_channels) if disposition else [],
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=expiration_time,
|
||||
),
|
||||
@@ -493,11 +498,11 @@ def _build_pause_event(
|
||||
for form_id in [reason.get("form_id")]
|
||||
if isinstance(form_id, str)
|
||||
]
|
||||
form_tokens_by_form_id: dict[str, str] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
expiration_times_by_form_id: dict[str, int] = {}
|
||||
if human_input_form_ids and session_maker is not None:
|
||||
with session_maker() as session:
|
||||
form_tokens_by_form_id = load_form_tokens_by_form_id(
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=human_input_surface,
|
||||
@@ -512,7 +517,7 @@ def _build_pause_event(
|
||||
# otherwise clients see schema drift after resume.
|
||||
reasons = enrich_human_input_pause_reasons(
|
||||
reasons,
|
||||
form_tokens_by_form_id=form_tokens_by_form_id,
|
||||
dispositions_by_form_id=dispositions_by_form_id,
|
||||
expiration_times_by_form_id=expiration_times_by_form_id,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user