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
151 changes: 150 additions & 1 deletion openhands/automation/preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from openhands.automation.auth import AuthenticatedUser, authenticate_request
Expand All @@ -28,8 +29,12 @@
from openhands.automation.models import Automation, TarballUpload, UploadStatus
from openhands.automation.schemas import AutomationResponse, Trigger
from openhands.automation.storage import FileStore, get_file_store
from openhands.automation.utils import utcnow
from openhands.automation.utils.model_profiles import resolve_model_profile_for_user
from openhands.automation.utils.tarball_validation import build_internal_url
from openhands.automation.utils.tarball_validation import (
build_internal_url,
parse_internal_upload_id,
)
from openhands.sdk.plugin import PluginSource
from openhands.workspace import RepoSource

Expand Down Expand Up @@ -207,6 +212,150 @@ def _build_storage_path(
return f"uploads/{org_id}/{user_id}/{upload_id}.tar"


def _replace_prompt_in_tarball(tarball_bytes: bytes, new_prompt: str) -> bytes | None:
"""Return a copy of a preset tarball with ``prompt.txt`` swapped for ``new_prompt``.

Every other member (``main.py``, ``setup.sh``, ``plugins_config.json``,
``repos_config.json``, ...) is copied through unchanged, so plugin and repo
configuration are preserved and the working template is untouched.

Returns ``None`` if the archive has no ``prompt.txt`` member — i.e. it is not a
regenerable preset tarball — so the caller can leave the tarball as-is.
"""
out_buffer = io.BytesIO()
found = False
with (
tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as src,
tarfile.open(fileobj=out_buffer, mode="w:gz") as dst,
):
for member in src.getmembers():
if member.name == "prompt.txt":
found = True
_add_file_to_tar(
dst, "prompt.txt", new_prompt, mode=member.mode or 0o644
)
continue
if member.isfile():
extracted = src.extractfile(member)
data = extracted.read() if extracted is not None else b""
info = tarfile.TarInfo(name=member.name)
info.size = len(data)
info.mode = member.mode
info.mtime = member.mtime
dst.addfile(info, io.BytesIO(data))
else:
dst.addfile(member)

if not found:
return None

out_buffer.seek(0)
return out_buffer.read()


async def regenerate_preset_prompt_tarball(
automation: Automation,
new_prompt: str,
session: AsyncSession,
) -> str | None:
"""Rebuild a preset automation's tarball with an updated prompt.

Preset automations bake the prompt into ``prompt.txt`` inside the tarball the
dispatcher executes; the stored ``prompt`` column is metadata only. When the
prompt is edited the tarball must be rewritten too, otherwise dispatching keeps
running the original prompt.

Reads the automation's current internal-upload tarball, swaps in ``new_prompt``
(leaving all other files untouched), uploads the result as a new internal upload,
and returns its ``oh-internal://`` URL for the caller to store on ``tarball_path``.

Returns ``None`` — leaving the tarball unchanged — when the automation is not a
regenerable preset: its ``tarball_path`` is an external URL, the referenced upload
is missing, or the archive contains no ``prompt.txt``. The file store is resolved
lazily so that updates to non-preset automations never construct one.
"""
upload_id = parse_internal_upload_id(automation.tarball_path)
if upload_id is None:
return None

file_store = get_file_store()
result = await session.execute(
select(TarballUpload).where(TarballUpload.id == upload_id)
)
source_upload = result.scalars().first()
if source_upload is None:
return None

try:
current_tarball = file_store.read(source_upload.storage_path)
except FileNotFoundError:
return None

new_tarball = _replace_prompt_in_tarball(current_tarball, new_prompt)
if new_tarball is None:
return None

new_upload_id = uuid.uuid4()
storage_path = _build_storage_path(
automation.org_id, automation.user_id, new_upload_id
)
upload = TarballUpload(
id=new_upload_id,
user_id=automation.user_id,
org_id=automation.org_id,
name=f"prompt-automation-{_safe_truncate(automation.name, 50)}-edit",
description=f"Prompt updated for: {_safe_truncate(automation.name, 100)}",
status=UploadStatus.UPLOADING,
storage_path=storage_path,
)
session.add(upload)
Comment thread
hieptl marked this conversation as resolved.
await session.flush()

try:
size_bytes = await file_store.write_stream(
path=storage_path,
stream=_bytes_to_async_iter(new_tarball),
content_type="application/x-tar",
)
upload.status = UploadStatus.COMPLETED
upload.size_bytes = size_bytes
except Exception as e:
# The session is rolled back when the HTTPException propagates (see
# get_session), so don't flush here — the in-memory status/error_message
# are only for log/debug context and won't be persisted.
logger.exception("Failed to upload regenerated tarball: %s", e)
upload.status = UploadStatus.FAILED
upload.error_message = f"Upload failed: {e!s}"
raise HTTPException(
Comment thread
hieptl marked this conversation as resolved.
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload regenerated tarball: {e!s}",
)

# The old tarball is now superseded. Remove its file and soft-delete the
# upload record so repeated prompt edits don't accumulate orphaned storage.
# Only soft-delete once the file is confirmed gone: if the delete fails the
# record stays live so the still-present file remains discoverable for a
# later retry/cleanup instead of becoming a hidden orphan (file on disk,
# record marked deleted).
file_removed = False
try:
file_store.delete(source_upload.storage_path)
file_removed = True
except FileNotFoundError:
file_removed = True
except Exception as e:
logger.exception(
"Failed to delete superseded tarball at %s: %s",
source_upload.storage_path,
e,
)
if file_removed:
source_upload.deleted_at = utcnow()

await session.flush()
return build_internal_url(new_upload_id)


@router.post("/prompt", status_code=status.HTTP_201_CREATED)
async def create_automation_from_prompt(
body: CreatePromptAutomationRequest,
Expand Down
18 changes: 18 additions & 0 deletions openhands/automation/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
AutomationRunStatus,
TarballUpload,
)
from openhands.automation.preset_router import regenerate_preset_prompt_tarball
from openhands.automation.schemas import (
AutomationListResponse,
AutomationResponse,
Expand Down Expand Up @@ -152,9 +153,26 @@ async def update_automation(
if "model" in update_data:
update_data["model"] = resolve_model_profile_for_user(body.model, user)

original_prompt = auto.prompt
for field, value in update_data.items():
setattr(auto, field, value)

# A preset automation bakes its prompt into the tarball the dispatcher
# executes; the `prompt` column is metadata only. When the prompt actually
# changes, rebuild the tarball so the next dispatch runs the new prompt
# instead of the original baked one. Skipped when the value is unchanged (a
# no-op edit), or for non-preset automations.
if (
"prompt" in update_data
and isinstance(auto.prompt, str)
and auto.prompt != original_prompt
):
new_tarball_path = await regenerate_preset_prompt_tarball(
auto, auto.prompt, session
)
if new_tarball_path is not None:
auto.tarball_path = new_tarball_path

# Note: updated_at is handled automatically by the model's onupdate=utcnow
await session.flush()
await session.refresh(auto)
Expand Down
53 changes: 53 additions & 0 deletions tests/test_preset_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from openhands.automation.preset_router import (
_generate_plugin_tarball,
_generate_tarball,
_replace_prompt_in_tarball,
)
from openhands.sdk.plugin import PluginSource
from openhands.workspace import RepoSource
Expand Down Expand Up @@ -210,6 +211,58 @@ def test_generate_tarball_with_repos(self):
assert repos_config[1]["ref"] == "main"


class TestReplacePromptInTarball:
"""Tests for swapping prompt.txt inside an existing preset tarball."""

def test_replaces_prompt_and_preserves_sibling_files(self):
"""The prompt is swapped while every other file is left byte-for-byte intact."""
# Arrange — a plugin preset tarball carries main.py, setup.sh, prompt.txt,
# plugins_config.json and repos_config.json; all but the prompt must survive.
original = _generate_plugin_tarball(
[PluginSource(source="github:owner/repo")],
"Original prompt",
repos=[RepoSource(url="owner/repo", provider="github")],
)

# Act
updated = _replace_prompt_in_tarball(original, "New prompt")

# Assert
assert updated is not None

def _read(tarball_bytes):
files = {}
with tarfile.open(fileobj=io.BytesIO(tarball_bytes), mode="r:gz") as tar:
for member in tar.getmembers():
if not member.isfile():
continue
extracted = tar.extractfile(member)
assert extracted is not None
files[member.name] = extracted.read()
return files, tar.getmember("setup.sh").mode

old_files, _ = _read(original)
new_files, new_setup_mode = _read(updated)

assert new_files["prompt.txt"].decode() == "New prompt"
for name in ("main.py", "setup.sh", "plugins_config.json", "repos_config.json"):
assert new_files[name] == old_files[name]
assert new_setup_mode & 0o100 # setup.sh stays executable

def test_returns_none_when_tarball_has_no_prompt(self):
"""A tarball without prompt.txt is not regenerable, so None is returned."""
# Arrange — an archive that has no prompt.txt member.
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
data = b"print('hi')"
info = tarfile.TarInfo(name="main.py")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))

# Act / Assert
assert _replace_prompt_in_tarball(buffer.getvalue(), "New prompt") is None


class TestRepoSource:
"""Tests for RepoSource model."""

Expand Down
Loading
Loading