mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
* feat: publish plans from sandbox files Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: avoid fixed plan filenames Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: virtualize local sandbox file paths Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: preserve plan_file_path across set_plan_status set_plan_status was rewriting the content record with only markdown and status, dropping plan_file_path. After a reject, the owner's dashboard edit would mirror to a different file than the agent's original, and the next save_plan could republish the stale file. Preserve plan_file_path when updating status. --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
215 lines
7.5 KiB
Python
215 lines
7.5 KiB
Python
"""Persistence for the plan-review feature.
|
|
|
|
The plan lives in two places:
|
|
- the agent's sandbox, as a real Markdown file the agent creates and edits, and
|
|
- the LangGraph store, as the published snapshot the dashboard renders.
|
|
|
|
Reviewers leave whole-document comments, stored one item per comment under
|
|
``["plan", "comments", thread_id]`` so listing and deletion are simple plain
|
|
store operations (no CRDT/WebSocket).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from langgraph_sdk import get_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PLAN_CONTENT_NAMESPACE = ["plan", "content"]
|
|
PLAN_COMMENTS_NAMESPACE = ["plan", "comments"]
|
|
|
|
# Plans are mirrored into the sandbox outside cloned repositories.
|
|
PLAN_FILE_DIRECTORY = "/workspace/plans"
|
|
|
|
# Plan lifecycle, stored on both the content record and the thread metadata.
|
|
PLAN_STATUS_PLANNING = "planning"
|
|
PLAN_STATUS_READY = "ready"
|
|
PLAN_STATUS_REVISING = "revising"
|
|
PLAN_STATUS_APPROVED = "approved"
|
|
PLAN_STATUS_CANCELLED = "cancelled"
|
|
|
|
|
|
def plan_file_path_for_thread(thread_id: str) -> str:
|
|
date = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
slug = re.sub(r"[^a-zA-Z0-9-]+", "-", thread_id).strip("-").lower()[:48]
|
|
return f"{PLAN_FILE_DIRECTORY}/{date}-{slug or 'plan'}.md"
|
|
|
|
|
|
def _client() -> Any:
|
|
return get_client()
|
|
|
|
|
|
def _item_value(item: Any) -> dict[str, Any] | None:
|
|
if item is None:
|
|
return None
|
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
|
return value if isinstance(value, dict) else None
|
|
|
|
|
|
async def _stored_plan_file_path(client: Any, thread_id: str) -> str | None:
|
|
try:
|
|
value = _item_value(await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id)) or {}
|
|
except Exception:
|
|
return None
|
|
path = value.get("plan_file_path")
|
|
return path if isinstance(path, str) and path else None
|
|
|
|
|
|
async def save_plan_content(
|
|
thread_id: str,
|
|
*,
|
|
markdown: str,
|
|
status: str = PLAN_STATUS_READY,
|
|
clear_comments: bool = True,
|
|
plan_file_path: str | None = None,
|
|
) -> None:
|
|
"""Publish the plan markdown + status for the dashboard to render.
|
|
|
|
A republished (revised) plan supersedes the prior revision, so comments left
|
|
on it are cleared — otherwise stale feedback would resurface on the new plan
|
|
and be fed back to the agent on the next approve/reject. A manual owner edit
|
|
passes ``clear_comments=False`` so reviewer feedback survives the edit."""
|
|
client = _client()
|
|
if plan_file_path is None:
|
|
plan_file_path = await _stored_plan_file_path(client, thread_id)
|
|
record = {"markdown": markdown, "status": status}
|
|
if plan_file_path:
|
|
record["plan_file_path"] = plan_file_path
|
|
await client.store.put_item(
|
|
PLAN_CONTENT_NAMESPACE,
|
|
thread_id,
|
|
record,
|
|
)
|
|
if clear_comments:
|
|
try:
|
|
await clear_plan_comments(thread_id)
|
|
except Exception:
|
|
# Best-effort: a failed cleanup must not block publishing the new plan.
|
|
pass
|
|
await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True})
|
|
|
|
|
|
async def write_plan_to_sandbox(
|
|
thread_id: str, content: str, *, plan_file_path: str | None = None
|
|
) -> str:
|
|
"""Mirror the dashboard plan edit into the thread's sandbox.
|
|
|
|
Best-effort: a missing sandbox must not block publishing the plan to the
|
|
review page.
|
|
"""
|
|
path = plan_file_path or plan_file_path_for_thread(thread_id)
|
|
try:
|
|
from ..utils.sandbox_state import get_sandbox_backend
|
|
|
|
backend = await get_sandbox_backend(thread_id)
|
|
await backend.awrite(path, content)
|
|
return path
|
|
except Exception:
|
|
logger.warning("Could not write plan file to sandbox for %s", thread_id, exc_info=True)
|
|
return path
|
|
|
|
|
|
async def get_plan_content(
|
|
thread_id: str, *, raise_on_error: bool = False
|
|
) -> dict[str, Any] | None:
|
|
"""The published plan record, or ``None`` when none exists.
|
|
|
|
With ``raise_on_error=True`` a store failure propagates instead of resolving
|
|
to ``None``. Approve uses this so a transient failure aborts the decision
|
|
rather than dispatching the agent without the (possibly edited) plan."""
|
|
client = _client()
|
|
try:
|
|
item = await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id)
|
|
except Exception:
|
|
if raise_on_error:
|
|
raise
|
|
return None
|
|
return _item_value(item)
|
|
|
|
|
|
async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None = None) -> None:
|
|
"""Update the plan lifecycle status on both the content record and metadata."""
|
|
existing = await get_plan_content(thread_id) or {}
|
|
client = _client()
|
|
record: dict[str, Any] = {"markdown": existing.get("markdown", ""), "status": status}
|
|
plan_file_path = existing.get("plan_file_path")
|
|
if isinstance(plan_file_path, str) and plan_file_path:
|
|
record["plan_file_path"] = plan_file_path
|
|
await client.store.put_item(
|
|
PLAN_CONTENT_NAMESPACE,
|
|
thread_id,
|
|
record,
|
|
)
|
|
metadata: dict[str, Any] = {"plan_status": status}
|
|
if plan_mode is not None:
|
|
metadata["plan_mode"] = plan_mode
|
|
await _merge_thread_metadata(thread_id, metadata)
|
|
|
|
|
|
def _comments_namespace(thread_id: str) -> list[str]:
|
|
return [*PLAN_COMMENTS_NAMESPACE, thread_id]
|
|
|
|
|
|
async def list_plan_comments(
|
|
thread_id: str, *, raise_on_error: bool = False
|
|
) -> list[dict[str, Any]]:
|
|
"""All comments on a plan, oldest first.
|
|
|
|
With ``raise_on_error=True`` a store/search failure propagates instead of
|
|
resolving to ``[]``. Approve/reject use this so a transient failure surfaces
|
|
(the decision endpoint errors) rather than silently feeding the agent an
|
|
empty comment set and dropping the reviewer's feedback."""
|
|
client = _client()
|
|
try:
|
|
items = await client.store.search_items(_comments_namespace(thread_id), limit=1000)
|
|
except Exception:
|
|
if raise_on_error:
|
|
raise
|
|
return []
|
|
raw = items.get("items", []) if isinstance(items, dict) else getattr(items, "items", [])
|
|
comments = [v for v in (_item_value(item) for item in raw) if v]
|
|
comments.sort(key=lambda c: str(c.get("created_at", "")))
|
|
return comments
|
|
|
|
|
|
async def clear_plan_comments(thread_id: str) -> None:
|
|
"""Delete every comment on a thread (called when a revised plan is published)."""
|
|
for comment in await list_plan_comments(thread_id):
|
|
comment_id = comment.get("id")
|
|
if isinstance(comment_id, str) and comment_id:
|
|
await delete_plan_comment(thread_id, comment_id)
|
|
|
|
|
|
async def add_plan_comment(
|
|
thread_id: str, *, author: str, author_login: str, body: str
|
|
) -> dict[str, Any]:
|
|
"""Append a whole-document comment; returns the stored comment."""
|
|
comment = {
|
|
"id": uuid.uuid4().hex,
|
|
"author": author,
|
|
"author_login": author_login,
|
|
"body": body,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
await _client().store.put_item(_comments_namespace(thread_id), comment["id"], comment)
|
|
return comment
|
|
|
|
|
|
async def delete_plan_comment(thread_id: str, comment_id: str) -> None:
|
|
await _client().store.delete_item(_comments_namespace(thread_id), comment_id)
|
|
|
|
|
|
async def _merge_thread_metadata(thread_id: str, metadata: dict[str, Any]) -> None:
|
|
client = _client()
|
|
try:
|
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
|
except Exception:
|
|
# The thread always exists by the time a plan is saved (the run created
|
|
# it); a transient update failure must not crash the agent mid-run.
|
|
pass
|