open-swe/agent/dashboard/plan_store.py
Johannes du Plessis 009064011a
feat: editable plan mode + fix review-plan banner overlap (#1610)
* feat: editable plan mode + fix review-plan banner overlap

Lets the thread owner edit the plan markdown by hand from the plan-review
page (Edit -> textarea -> Save) via a new PUT /dashboard/api/plan/{id}
endpoint that re-publishes the plan and mirrors it into the sandbox
plan.md, so approve hands the edited plan to the agent as the source of
truth. Also fixes the collapsed git-panel's floating expand button
covering the "Review plan ->" banner by reserving space for it.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: abort plan approval when the published plan read fails

get_plan_content() swallowed store errors and returned None, so a
transient failure during approve would still mark the plan approved and
dispatch the generic fallback text — silently dropping an owner's edited
plan. Read the plan strictly (raise_on_error=True) so approval aborts
instead, matching the comment read.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-25 11:21:41 -07:00

184 lines
6.5 KiB
Python

"""Persistence for the plan-review feature.
The plan lives in two places:
- the agent's sandbox, as a real ``plan.md`` file (written by the ``save_plan``
tool — the source artifact the agent produces and can re-read), 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 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"]
# The plan is mirrored into the sandbox as a real file the agent can re-read.
PLAN_FILE_PATH = "plan.md"
# 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 _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 save_plan_content(
thread_id: str,
*,
markdown: str,
status: str = PLAN_STATUS_READY,
clear_comments: bool = True,
) -> 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()
await client.store.put_item(
PLAN_CONTENT_NAMESPACE,
thread_id,
{"markdown": markdown, "status": status},
)
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) -> str:
"""Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox
must not block publishing the plan to the review page."""
try:
from ..utils.sandbox_state import get_sandbox_backend
backend = await get_sandbox_backend(thread_id)
await backend.awrite(PLAN_FILE_PATH, content)
return PLAN_FILE_PATH
except Exception:
logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True)
return PLAN_FILE_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()
await client.store.put_item(
PLAN_CONTENT_NAMESPACE,
thread_id,
{"markdown": existing.get("markdown", ""), "status": status},
)
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