Skip to content
Open
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
67 changes: 67 additions & 0 deletions astrbot/core/computer/booters/shipyard_neo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any, cast

from astrbot.api import logger
from astrbot.core.db import BaseDatabase

from ..olayer import (
BrowserComponent,
Expand All @@ -20,6 +21,7 @@
try:
from shipyard_neo import BayClient
from shipyard_neo.sandbox import Sandbox
from shipyard_neo.errors import NotFoundError, BayError
except ImportError:
logger.warning(
"shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it."
Expand Down Expand Up @@ -354,12 +356,16 @@ def __init__(
endpoint_url: str,
access_token: str,
profile: str = "",
persist_id: str | None = None,
ttl: int = 3600,
db_helper: BaseDatabase | None = None,
) -> None:
self._endpoint_url = endpoint_url
self._access_token = access_token
self._profile = profile.strip() if profile else ""
self._persist_id = persist_id
self._ttl = ttl
self._db_helper = db_helper
self._client: BayClient | None = None
self._sandbox: Sandbox | None = None
self._bay_manager: Any = None # BayContainerManager when auto-started
Expand Down Expand Up @@ -430,6 +436,8 @@ async def boot(self, session_id: str) -> None:
access_token=self._access_token,
)
await self._client.__aenter__()

cargo_id = await self._resolve_cargo_id()

# Resolve profile: user-specified > smart selection > default.
# An empty profile means auto-select; any non-empty profile must be
Expand All @@ -439,6 +447,7 @@ async def boot(self, session_id: str) -> None:
self._sandbox = await self._client.create_sandbox(
profile=resolved_profile,
ttl=self._ttl,
cargo_id=cargo_id,
)

# --- Readiness gate: wait until sandbox session is READY ---
Expand Down Expand Up @@ -587,6 +596,64 @@ def _score(p: Any) -> tuple[int, int]:
)

return chosen

async def _resolve_cargo_id(self) -> str | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring _resolve_cargo_id into a short orchestration method that delegates DB lookups, Bay existence checks, cargo creation, and mapping persistence to focused helpers to reduce branching and nesting.

You can keep all the new behavior but simplify _resolve_cargo_id by:

  • Guard‑clause precondition checks.
  • Extracting the SDK-compat creation logic into a helper.
  • Extracting DB mapping logic into a helper.
  • Making _resolve_cargo_id a short orchestration function with minimal branching.

For example:

async def _resolve_cargo_id(self) -> str | None:
    if not self._persist_id:
        return None

    if self._db_helper is None:
        logger.warning(
            "[Computer] persist_id is set but no db_helper provided; "
            "file persistence will not work."
        )
        return None

    cargo_id = await self._load_persisted_cargo_id()

    if cargo_id:
        cargo_id = await self._ensure_cargo_exists_on_bay(cargo_id)

    if not cargo_id:
        cargo_id = await self._create_cargo_with_compat_fallback()
        if cargo_id:
            await self._save_cargo_mapping(cargo_id)

    return cargo_id

Then keep each responsibility in a small helper:

async def _load_persisted_cargo_id(self) -> str | None:
    ret = await self._db_helper.get_shipyard_neo_persist(self._persist_id)
    return ret.cargo_id if ret is not None else None
async def _ensure_cargo_exists_on_bay(self, cargo_id: str) -> str | None:
    try:
        await self._client.cargos.get(cargo_id)
        return cargo_id
    except NotFoundError:
        logger.info(
            "[Computer] No existing cargo found on Bay for persist_id=%s; "
            "a new cargo will be created.",
            self._persist_id,
        )
    except Exception as e:
        logger.warning(
            "[Computer] Error checking existing cargo for persist_id=%s: %s; "
            "a new cargo will be created.",
            self._persist_id,
            e,
        )
    return None
async def _create_cargo_with_compat_fallback(self) -> str | None:
    try:
        try:
            cargo = await self._client.cargos.create()
        except BayError:
            # 旧版 SDK 需要传入 size_limit_mb,暂时固定为 10GB
            cargo = await self._client.cargos.create(size_limit_mb=10240)
        return cargo.id
    except Exception as e:
        logger.error(
            "[Computer] Failed to create cargo for persist_id=%s: %s; "
            "file persistence will not work.",
            self._persist_id,
            e,
        )
        return None
async def _save_cargo_mapping(self, cargo_id: str) -> None:
    await self._db_helper.upsert_shipyard_neo_persist(self._persist_id, cargo_id)
    logger.info(
        "[Computer] Created new cargo for persist_id=%s: cargo_id=%s",
        self._persist_id,
        cargo_id,
    )

This keeps all existing behavior (persistence, compatibility fallback, logging) but:

  • Reduces nesting and stateful reassignments.
  • Separates DB concerns from SDK concerns.
  • Makes the orchestration flow in _resolve_cargo_id easy to read and test.

if self._persist_id is None:
return None

if self._db_helper is None:
logger.warning(
"[Computer] persist_id is set but no db_helper provided; "
"file persistence will not work."
)
return None

# Check if cargo with the persist_id already exists
ret = await self._db_helper.get_shipyard_neo_persist(self._persist_id)
cargo_id: str | None = None
if ret is not None:
cargo_id = ret.cargo_id

if cargo_id is not None:
# Detect if the cargo exists on Bay
try:
await self._client.cargos.get(cargo_id)
except NotFoundError:
logger.info(
"[Computer] No existing cargo found on Bay for persist_id=%s; "
"a new cargo will be created.",
self._persist_id,
)
cargo_id = None
Comment on lines +621 to +627

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider cleaning up stale DB mappings when the cargo no longer exists on Bay.

In the NotFoundError branch you log and clear cargo_id, but the ShipyardNeoPersist row still points to the deleted cargo, leaving a stale mapping. To keep Bay and the DB consistent and avoid accumulation of stale rows, consider deleting that ShipyardNeoPersist entry (e.g., delete_shipyard_neo_persist(self._persist_id)) or otherwise updating it here.

Suggested implementation:

            # Detect if the cargo exists on Bay
            try:
                await self._client.cargos.get(cargo_id)
            except NotFoundError:
                logger.info(
                    "[Computer] No existing cargo found on Bay for persist_id=%s; "
                    "the stale DB mapping will be removed and a new cargo will be created.",
                    self._persist_id,
                )
                if self._db_helper is not None:
                    await self._db_helper.delete_shipyard_neo_persist(self._persist_id)
                cargo_id = None
            except Exception as e:
                logger.warning(

If BaseDatabase / the concrete DB helper does not yet implement delete_shipyard_neo_persist(persist_id: str), you should:

  1. Add an async delete_shipyard_neo_persist method to BaseDatabase (and any subclasses) that deletes the row for the given persist_id.
  2. Ensure all calls to this new method are awaited, as shown in the edit above.


if cargo_id is None:
# Create a new cargo and save the mapping
try:
try:
cargo = await self._client.cargos.create()
cargo_id = cargo.id
except BayError as e:
# 旧版 SDK 需要传入 size_limit_mb,暂时固定为 10GB
cargo = await self._client.cargos.create(size_limit_mb=10240)
cargo_id = cargo.id

if cargo_id is not None:
await self._db_helper.upsert_shipyard_neo_persist(self._persist_id, cargo_id)
logger.info(
"[Computer] Created new cargo for persist_id=%s: cargo_id=%s",
self._persist_id,
cargo_id,
)
except Exception as e:
logger.error(
"[Computer] Failed to create cargo for persist_id=%s: %s; "
"file persistence will not work.",
self._persist_id,
e,
)
cargo_id = None

return cargo_id

async def shutdown(self, *, delete_sandbox: bool = False) -> None:
if self._client is not None:
Expand Down
8 changes: 7 additions & 1 deletion astrbot/core/computer/computer_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,19 +599,25 @@ async def get_booter(
token = sandbox_cfg.get("shipyard_neo_access_token", "")
ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600)
profile = sandbox_cfg.get("shipyard_neo_profile", "python-default")
persist_id = sandbox_cfg.get("shipyard_neo_persist_id", "").strip() or None

# Inject db for persist_id lookup in ShipyardNeoBooter
db_helper = context.get_db()

# Auto-discover token from Bay's credentials.json if not configured
if not token:
token = _discover_bay_credentials(ep)

logger.info(
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}"
f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, persist_id={persist_id}, ttl={ttl}"
)
client = ShipyardNeoBooter(
endpoint_url=ep,
access_token=token,
profile=profile,
persist_id=persist_id,
ttl=ttl,
db_helper=db_helper,
)
elif booter_type == "cua":
from .booters.cua import CuaBooter, build_cua_booter_kwargs
Expand Down
10 changes: 10 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"shipyard_neo_endpoint": "",
"shipyard_neo_access_token": "",
"shipyard_neo_profile": "python-default",
"shipyard_neo_persist_id": "",
"shipyard_neo_ttl": 3600,
"cua_image": CUA_DEFAULT_CONFIG["image"],
"cua_os_type": CUA_DEFAULT_CONFIG["os_type"],
Expand Down Expand Up @@ -3404,6 +3405,15 @@
"provider_settings.sandbox.booter": "shipyard_neo",
},
},
"provider_settings.sandbox.shipyard_neo_persist_id": {
"description": "Shipyard Neo Persist ID",
"type": "string",
"hint": "具有相同 文件持久化 ID 的沙箱会复用同一个存储卷,从而实现文件持久化。适用于需要跨会话保存文件的场景。留空则不启用文件持久化。",
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
"provider_settings.sandbox.booter": "shipyard_neo",
},
},
"provider_settings.sandbox.shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox TTL",
"type": "int",
Expand Down
20 changes: 20 additions & 0 deletions astrbot/core/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Preference,
ProviderStat,
SessionProjectRelation,
ShipyardNeoPersist,
Stats,
UmoAlias,
WebChatThread,
Expand Down Expand Up @@ -826,6 +827,25 @@ async def get_umo_alias(self, umo: str) -> UmoAlias | None:
async def get_umo_aliases(self, umos: list[str] | None = None) -> list[UmoAlias]:
"""Get alias metadata, optionally restricted to the given UMO list."""
...

# ====
# Shipyard Neo Persist Management
# ====

@abc.abstractmethod
async def upsert_shipyard_neo_persist(self, persist_id: str, cargo_id: str) -> ShipyardNeoPersist:
"""Create or update the persistent mapping for a Shipyard Neo cargo."""
...

@abc.abstractmethod
async def get_shipyard_neo_persist(self, persist_id: str) -> ShipyardNeoPersist | None:
"""Get the persistent mapping for a Shipyard Neo cargo."""
...

@abc.abstractmethod
async def delete_shipyard_neo_persist(self, persist_id: str) -> None:
"""Delete the persistent mapping for a Shipyard Neo cargo."""
...

# ====
# ChatUI Project Management
Expand Down
12 changes: 12 additions & 0 deletions astrbot/core/db/po.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,18 @@ class CommandConflict(TimestampMixin, SQLModel, table=True):
)


class ShipyardNeoPersist(TimestampMixin, SQLModel, table=True):
"""Mapping persist_id to cargo_id for Shipyard Neo booters."""

__tablename__ = "shipyard_neo_persist" # type: ignore

id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
persist_id: str = Field(nullable=False, max_length=255, unique=True)
cargo_id: str = Field(nullable=False, max_length=255)


@dataclass
class Conversation:
"""LLM 对话类
Expand Down
51 changes: 51 additions & 0 deletions astrbot/core/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Preference,
ProviderStat,
SessionProjectRelation,
ShipyardNeoPersist,
SQLModel,
UmoAlias,
WebChatThread,
Expand Down Expand Up @@ -1867,6 +1868,56 @@ async def get_umo_aliases(self, umos: list[str] | None = None) -> list[UmoAlias]
result = await session.execute(query)
return list(result.scalars().all())

# ====
# Shipyard Neo Persist Management
# ====

async def upsert_shipyard_neo_persist(self, persist_id: str, cargo_id: str) -> ShipyardNeoPersist:
"""Create or update the persistent mapping for a Shipyard Neo cargo."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
result = await session.execute(
select(ShipyardNeoPersist).where(
col(ShipyardNeoPersist.persist_id) == persist_id,
),
)
persist = result.scalar_one_or_none()
if persist:
persist.cargo_id = cargo_id
persist.updated_at = datetime.now(timezone.utc)
else:
persist = ShipyardNeoPersist(
persist_id=persist_id,
cargo_id=cargo_id,
)
session.add(persist)
await session.flush()
await session.refresh(persist)
return persist

async def get_shipyard_neo_persist(self, persist_id: str) -> ShipyardNeoPersist | None:
"""Get the persistent mapping for a Shipyard Neo cargo."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ShipyardNeoPersist).where(
col(ShipyardNeoPersist.persist_id) == persist_id,
),
)
return result.scalar_one_or_none()

async def delete_shipyard_neo_persist(self, persist_id: str) -> None:
"""Delete the persistent mapping for a Shipyard Neo cargo."""
async with self.get_db() as session:
session: AsyncSession
await session.execute(
delete(ShipyardNeoPersist).where(
col(ShipyardNeoPersist.persist_id) == persist_id,
),
)
await session.commit()
Comment on lines +1910 to +1919

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

delete_shipyard_neo_persist 方法中直接使用了 await session.commit(),而没有像该文件中的其他删除/修改方法一样使用 async with session.begin(): 来管理事务。为了代码的一致性和事务安全性,建议使用 async with session.begin(): 块。

Suggested change
async def delete_shipyard_neo_persist(self, persist_id: str) -> None:
"""Delete the persistent mapping for a Shipyard Neo cargo."""
async with self.get_db() as session:
session: AsyncSession
await session.execute(
delete(ShipyardNeoPersist).where(
col(ShipyardNeoPersist.persist_id) == persist_id,
),
)
await session.commit()
async def delete_shipyard_neo_persist(self, persist_id: str) -> None:
"""Delete the persistent mapping for a Shipyard Neo cargo."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(ShipyardNeoPersist).where(
col(ShipyardNeoPersist.persist_id) == persist_id,
),
)


# ====
# ChatUI Project Management
# ====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
"description": "Shipyard Neo Profile",
"hint": "Sandbox profile for Shipyard Neo, e.g. python-default. Leave empty to auto-select the most capable profile."
},
"shipyard_neo_persist_id": {
"description": "Shipyard Neo Persist ID",
"hint": "When enabled, sandboxes with the same persist ID will reuse the same storage volume for file persistence. Suitable for scenarios that require saving files across sessions. Leave empty to disable file persistence."
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox TTL",
"hint": "Sandbox time-to-live in seconds."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
"description": "Профиль Shipyard Neo",
"hint": "Профиль песочницы, например, python-default. Оставьте пустым для автоматического выбора самого функционального профиля."
},
"shipyard_neo_persist_id": {
"description": "Идентификатор сохранения файла Shipyard Neo",
"hint": "При включении песочницы с одинаковым идентификатором сохранения будет использоваться один и тот же том для сохранения файлов, что обеспечивает сохранение файлов между сессиями. Подходит для сценариев, требующих сохранения файлов между сессиями. Оставьте пустым, чтобы отключить сохранение файлов."
},
"shipyard_neo_ttl": {
"description": "TTL песочницы Shipyard Neo",
"hint": "Время жизни песочницы в секундах."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@
"description": "Shipyard Neo Profile",
"hint": "Shipyard Neo 沙箱 profile,例如 python-default。留空时自动选择能力更完整的 profile。"
},
"shipyard_neo_persist_id": {
"description": "Shipyard Neo 文件持久化ID",
"hint": "具有相同文件持久化ID的沙箱会复用同一个存储卷,从而实现文件持久化。适用于需要跨会话保存文件的场景。留空则不启用文件持久化。"
},
"shipyard_neo_ttl": {
"description": "Shipyard Neo Sandbox 存活时间(秒)",
"hint": "Shipyard Neo 沙箱的生存时间(秒)。"
Expand Down
1 change: 1 addition & 0 deletions tests/test_shipyard_neo_booter.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ def _make_fake_context(self, booter_type: str = "shipyard_neo"):
"shipyard_neo_access_token": "sk-test",
"shipyard_neo_ttl": 3600,
"shipyard_neo_profile": "python-default",
"shipyard_neo_persist_id": "",
},
}
}
Expand Down