mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
4 commits
5b88dbbb6a
...
84d41c28f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d41c28f8 | ||
| f9cca1ec8f | |||
| 2a5c69211d | |||
|
|
a09b1c3bb6 |
22 changed files with 596 additions and 492 deletions
|
|
@ -80,21 +80,36 @@ def _origin_of(url: str) -> str:
|
|||
return f"{scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||
"""Return a safe post-login redirect URL.
|
||||
def _is_blocked_redirect_path(path: str) -> bool:
|
||||
return path in {"/login", "/dashboard/api", "/_serverFn"} or path.startswith(
|
||||
("/login/", "/login?", "/login#", "/dashboard/api/", "/_serverFn/")
|
||||
)
|
||||
|
||||
Falls back to DASHBOARD_BASE_URL when the supplied URL's origin isn't
|
||||
explicitly allowed. This blocks the open-redirect / phishing primitive
|
||||
where an attacker drops their own URL into `?redirect_to=`.
|
||||
"""
|
||||
|
||||
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||
"""Return a safe post-login redirect URL."""
|
||||
fallback = os.environ.get("DASHBOARD_BASE_URL", "").strip()
|
||||
if not redirect_to:
|
||||
return fallback
|
||||
candidate_origin = _origin_of(redirect_to)
|
||||
trimmed = redirect_to.strip()
|
||||
parsed = urlparse(trimmed)
|
||||
if (
|
||||
trimmed.startswith("/")
|
||||
and not trimmed.startswith("//")
|
||||
and not parsed.scheme
|
||||
and not parsed.netloc
|
||||
and not _is_blocked_redirect_path(parsed.path)
|
||||
):
|
||||
if fallback:
|
||||
return f"{fallback.rstrip('/')}{trimmed}"
|
||||
return trimmed
|
||||
if _is_blocked_redirect_path(parsed.path):
|
||||
return fallback
|
||||
candidate_origin = _origin_of(trimmed)
|
||||
if not candidate_origin:
|
||||
return fallback
|
||||
if candidate_origin in allowed_dashboard_origins():
|
||||
return redirect_to
|
||||
return trimmed
|
||||
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
|
||||
return fallback
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .plan_store import (
|
|||
PLAN_STATUS_CANCELLED,
|
||||
PLAN_STATUS_READY,
|
||||
PLAN_STATUS_REVISING,
|
||||
PLAN_STATUS_SHARED,
|
||||
add_plan_comment,
|
||||
delete_plan_comment,
|
||||
get_plan_content,
|
||||
|
|
@ -112,6 +113,7 @@ async def update_plan(
|
|||
if not markdown:
|
||||
raise HTTPException(422, "plan markdown cannot be empty")
|
||||
content = await get_plan_content(thread_id) or {}
|
||||
_reject_shared_content(content)
|
||||
status = content.get("status") or metadata.get("plan_status") or "planning"
|
||||
if status in (PLAN_STATUS_APPROVED, PLAN_STATUS_CANCELLED):
|
||||
raise HTTPException(409, f"cannot edit a {status} plan")
|
||||
|
|
@ -139,6 +141,7 @@ async def post_plan_comment(
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
_reject_shared_content(await get_plan_content(thread_id) or {})
|
||||
text = body.body.strip()
|
||||
if not text:
|
||||
raise HTTPException(422, "comment body cannot be empty")
|
||||
|
|
@ -155,6 +158,7 @@ async def remove_plan_comment(
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
_reject_shared_content(await get_plan_content(thread_id) or {})
|
||||
comments = await list_plan_comments(thread_id)
|
||||
target = next((c for c in comments if c.get("id") == comment_id), None)
|
||||
if target is None:
|
||||
|
|
@ -175,14 +179,16 @@ async def approve_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -
|
|||
# Read the published plan + comments BEFORE mutating state: a store failure
|
||||
# here aborts the decision (500) rather than dispatching without them. The
|
||||
# published markdown may have been edited by the owner, so it is the
|
||||
# source of truth handed to the agent (not its own stale history).
|
||||
content = await get_plan_content(thread_id, raise_on_error=True)
|
||||
status = (content or {}).get("status") or metadata.get("plan_status") or "planning"
|
||||
# source of truth handed to the agent (not its own stale history) — read it
|
||||
# strictly so a transient failure can't silently drop the edit.
|
||||
content = await get_plan_content(thread_id, raise_on_error=True) or {}
|
||||
_reject_shared_content(content)
|
||||
status = content.get("status") or metadata.get("plan_status") or "planning"
|
||||
if status == PLAN_STATUS_APPROVED:
|
||||
# Idempotent: a repeat approve (double-click / retry) must not dispatch a
|
||||
# second implementation run or post a duplicate Slack notice.
|
||||
raise HTTPException(409, "plan is already approved")
|
||||
plan_markdown = (content or {}).get("markdown", "")
|
||||
plan_markdown = str(content.get("markdown", "")).strip()
|
||||
comments = await list_plan_comments(thread_id, raise_on_error=True)
|
||||
feedback = _format_comments(comments)
|
||||
if plan_markdown:
|
||||
|
|
@ -215,6 +221,8 @@ async def reject_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) ->
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
content = await get_plan_content(thread_id, raise_on_error=True) or {}
|
||||
_reject_shared_content(content)
|
||||
feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True))
|
||||
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
|
||||
text = (
|
||||
|
|
@ -227,6 +235,11 @@ async def reject_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) ->
|
|||
return {"status": PLAN_STATUS_REVISING}
|
||||
|
||||
|
||||
def _reject_shared_content(content: dict[str, Any]) -> None:
|
||||
if content.get("status") == PLAN_STATUS_SHARED:
|
||||
raise HTTPException(409, "shared content is not an implementation plan")
|
||||
|
||||
|
||||
def _format_comments(comments: list[dict[str, Any]]) -> str:
|
||||
lines: list[str] = []
|
||||
index = 1
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ 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/share lifecycle, stored on both the content record and the thread metadata.
|
||||
PLAN_STATUS_PLANNING = "planning"
|
||||
PLAN_STATUS_READY = "ready"
|
||||
PLAN_STATUS_SHARED = "shared"
|
||||
PLAN_STATUS_REVISING = "revising"
|
||||
PLAN_STATUS_APPROVED = "approved"
|
||||
PLAN_STATUS_CANCELLED = "cancelled"
|
||||
|
|
@ -68,8 +69,9 @@ async def save_plan_content(
|
|||
status: str = PLAN_STATUS_READY,
|
||||
clear_comments: bool = True,
|
||||
plan_file_path: str | None = None,
|
||||
plan_mode: bool | None = True,
|
||||
) -> None:
|
||||
"""Publish the plan markdown + status for the dashboard to render.
|
||||
"""Publish 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
|
||||
|
|
@ -92,7 +94,10 @@ async def save_plan_content(
|
|||
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})
|
||||
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)
|
||||
|
||||
|
||||
async def write_plan_to_sandbox(
|
||||
|
|
@ -136,10 +141,16 @@ async def get_plan_content(
|
|||
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 {}
|
||||
entering_plan_after_share = (
|
||||
existing.get("status") == PLAN_STATUS_SHARED and status == PLAN_STATUS_PLANNING
|
||||
)
|
||||
client = _client()
|
||||
record: dict[str, Any] = {"markdown": existing.get("markdown", ""), "status": status}
|
||||
record: dict[str, Any] = {
|
||||
"markdown": "" if entering_plan_after_share else existing.get("markdown", ""),
|
||||
"status": status,
|
||||
}
|
||||
plan_file_path = existing.get("plan_file_path")
|
||||
if isinstance(plan_file_path, str) and plan_file_path:
|
||||
if not entering_plan_after_share and isinstance(plan_file_path, str) and plan_file_path:
|
||||
record["plan_file_path"] = plan_file_path
|
||||
await client.store.put_item(
|
||||
PLAN_CONTENT_NAMESPACE,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ WORKFLOW_APPROVAL_PENDING = "pending"
|
|||
WORKFLOW_APPROVAL_APPROVED = "approved"
|
||||
WORKFLOW_APPROVAL_REJECTED = "rejected"
|
||||
_MAX_APPROVAL_RECORDS = 20
|
||||
_TERMINAL_STATUSES = {WORKFLOW_APPROVAL_APPROVED, WORKFLOW_APPROVAL_REJECTED}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
|
|
@ -80,25 +81,38 @@ async def ensure_workflow_push_pending(
|
|||
base_sha: str,
|
||||
head_sha: str,
|
||||
files: list[str],
|
||||
diff_stats: Mapping[str, Any] | None = None,
|
||||
diff_preview: str | None = None,
|
||||
diff_preview_truncated: bool = False,
|
||||
approval_url: str | None = None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Store a pending approval unless a terminal record already exists."""
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
existing = approvals.get(fingerprint)
|
||||
if existing and existing.get("status") in {
|
||||
WORKFLOW_APPROVAL_PENDING,
|
||||
WORKFLOW_APPROVAL_APPROVED,
|
||||
WORKFLOW_APPROVAL_REJECTED,
|
||||
}:
|
||||
if existing and existing.get("status") in _TERMINAL_STATUSES:
|
||||
return existing, False
|
||||
|
||||
record = {
|
||||
"fingerprint": fingerprint,
|
||||
"status": WORKFLOW_APPROVAL_PENDING,
|
||||
review_fields = {
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"base_sha": base_sha,
|
||||
"head_sha": head_sha,
|
||||
"files": files,
|
||||
"files": list(files),
|
||||
"diff_stats": _normalize_diff_stats(diff_stats, len(files)),
|
||||
"diff_preview": diff_preview or "",
|
||||
"diff_preview_truncated": diff_preview_truncated,
|
||||
"approval_url": approval_url,
|
||||
}
|
||||
if existing and existing.get("status") == WORKFLOW_APPROVAL_PENDING:
|
||||
record = {**existing, **review_fields}
|
||||
approvals[fingerprint] = record
|
||||
await _save_approvals(thread_id, approvals)
|
||||
return record, False
|
||||
|
||||
record = {
|
||||
"fingerprint": fingerprint,
|
||||
"status": WORKFLOW_APPROVAL_PENDING,
|
||||
**review_fields,
|
||||
"requested_at": _now(),
|
||||
"notified": False,
|
||||
}
|
||||
|
|
@ -107,6 +121,58 @@ async def ensure_workflow_push_pending(
|
|||
return record, True
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_diff_stats(value: Mapping[str, Any] | None, file_count: int) -> dict[str, int]:
|
||||
if not isinstance(value, Mapping):
|
||||
return {"files": file_count, "additions": 0, "deletions": 0}
|
||||
return {
|
||||
"files": _safe_int(value.get("files"), file_count),
|
||||
"additions": _safe_int(value.get("additions")),
|
||||
"deletions": _safe_int(value.get("deletions")),
|
||||
}
|
||||
|
||||
|
||||
def workflow_push_approval_response(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
files = record.get("files")
|
||||
diff_stats = record.get("diff_stats")
|
||||
requested_at = record.get("requested_at")
|
||||
decided_at = record.get("decided_at")
|
||||
decided_by = record.get("decided_by")
|
||||
approval_url = record.get("approval_url")
|
||||
return {
|
||||
"fingerprint": str(record.get("fingerprint") or ""),
|
||||
"status": str(record.get("status") or WORKFLOW_APPROVAL_PENDING),
|
||||
"repo": str(record.get("repo") or ""),
|
||||
"branch": str(record.get("branch") or ""),
|
||||
"baseSha": str(record.get("base_sha") or ""),
|
||||
"headSha": str(record.get("head_sha") or ""),
|
||||
"files": [str(path) for path in files] if isinstance(files, list) else [],
|
||||
"diffStats": _normalize_diff_stats(
|
||||
diff_stats if isinstance(diff_stats, Mapping) else None,
|
||||
len(files) if isinstance(files, list) else 0,
|
||||
),
|
||||
"diffPreview": str(record.get("diff_preview") or ""),
|
||||
"diffPreviewTruncated": record.get("diff_preview_truncated") is True,
|
||||
"approvalUrl": approval_url if isinstance(approval_url, str) and approval_url else None,
|
||||
"requestedAt": requested_at if isinstance(requested_at, str) else None,
|
||||
"decidedAt": decided_at if isinstance(decided_at, str) else None,
|
||||
"decidedBy": decided_by if isinstance(decided_by, str) else None,
|
||||
}
|
||||
|
||||
|
||||
def workflow_push_approval_responses(
|
||||
approvals: Mapping[str, Mapping[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
ordered = sorted(approvals.values(), key=lambda r: str(r.get("requested_at", "")), reverse=True)
|
||||
return [workflow_push_approval_response(record) for record in ordered]
|
||||
|
||||
|
||||
async def mark_workflow_push_notified(thread_id: str, fingerprint: str) -> None:
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
record = approvals.get(fingerprint)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
|
||||
from .oauth import require_same_origin_for_mutations, require_session
|
||||
from .plan_api import _dispatch_followup, _thread_metadata
|
||||
from .thread_api import _user_owns_thread
|
||||
from .thread_api import _thread_is_readable, _user_owns_thread
|
||||
from .workflow_approval import (
|
||||
WORKFLOW_APPROVAL_PENDING,
|
||||
decide_workflow_push_approval,
|
||||
get_workflow_push_approvals,
|
||||
workflow_push_approval_responses,
|
||||
)
|
||||
|
||||
workflow_approval_router = APIRouter(
|
||||
|
|
@ -23,31 +23,20 @@ workflow_approval_router = APIRouter(
|
|||
_SESSION_DEP = Depends(require_session)
|
||||
|
||||
|
||||
def _approval_record_response(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"fingerprint": record.get("fingerprint"),
|
||||
"status": record.get("status"),
|
||||
"repo": record.get("repo"),
|
||||
"branch": record.get("branch"),
|
||||
"files": record.get("files"),
|
||||
"requested_at": record.get("requested_at"),
|
||||
}
|
||||
|
||||
|
||||
@workflow_approval_router.get("/{thread_id}")
|
||||
async def list_workflow_approvals_for_thread(
|
||||
async def list_workflow_push_approvals(
|
||||
thread_id: str, session: dict[str, Any] = _SESSION_DEP
|
||||
) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
|
||||
raise HTTPException(403, "only the thread owner can view workflow push approvals")
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
is_owner = _user_owns_thread(metadata, session["sub"], session.get("email"))
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
pending = [
|
||||
_approval_record_response(record)
|
||||
for record in approvals.values()
|
||||
if record.get("status") == WORKFLOW_APPROVAL_PENDING
|
||||
]
|
||||
return {"approvals": pending}
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"isOwner": is_owner,
|
||||
"approvals": workflow_push_approval_responses(approvals),
|
||||
}
|
||||
|
||||
|
||||
@workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import codecs
|
|||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import threading
|
||||
|
|
@ -29,12 +28,7 @@ from ..dashboard.workflow_approval import (
|
|||
workflow_push_rejected,
|
||||
)
|
||||
from ..tools.slack_thread_reply import build_workflow_approval_blocks
|
||||
from ..utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
from ..utils.github_proxy import refresh_proxy_token
|
||||
from ..utils.dashboard_links import dashboard_workflow_approval_url
|
||||
from ..utils.sandbox_state import SANDBOX_BACKENDS
|
||||
from ..utils.slack import post_slack_thread_reply_with_ts
|
||||
|
||||
|
|
@ -58,6 +52,8 @@ _GIT_WRAPPERS = {"command", "env", "nice", "sudo", "stdbuf", "nohup", "time", "i
|
|||
# Git global options that consume the following token as a separate value; skipped when
|
||||
# locating the git subcommand so `git -c k=v push` is still recognized as a push.
|
||||
_GIT_VALUE_OPTIONS = {"-C", "-c", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
|
||||
_DIFF_PREVIEW_MAX_CHARS = 20_000
|
||||
_DIFF_PREVIEW_MAX_LINES = 400
|
||||
|
||||
|
||||
class _BlockedGitPush:
|
||||
|
|
@ -90,6 +86,9 @@ class WorkflowPushChange:
|
|||
remote_ref: str
|
||||
fixed_command: str
|
||||
base_sha: str = ""
|
||||
diff_stats: dict[str, int] | None = None
|
||||
diff_preview: str = ""
|
||||
diff_preview_truncated: bool = False
|
||||
blocked: bool = False
|
||||
blocked_reason: str = ""
|
||||
|
||||
|
|
@ -445,6 +444,44 @@ def _fingerprint(payload: Mapping[str, Any]) -> str:
|
|||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _diff_preview(diff: str) -> tuple[str, bool]:
|
||||
if len(diff) <= _DIFF_PREVIEW_MAX_CHARS:
|
||||
lines = diff.splitlines()
|
||||
if len(lines) <= _DIFF_PREVIEW_MAX_LINES:
|
||||
return diff, False
|
||||
preview_lines: list[str] = []
|
||||
char_count = 0
|
||||
truncated = False
|
||||
for line in diff.splitlines():
|
||||
next_count = char_count + len(line) + 1
|
||||
if len(preview_lines) >= _DIFF_PREVIEW_MAX_LINES or next_count > _DIFF_PREVIEW_MAX_CHARS:
|
||||
truncated = True
|
||||
break
|
||||
preview_lines.append(line)
|
||||
char_count = next_count
|
||||
return "\n".join(preview_lines), truncated
|
||||
|
||||
|
||||
def _diff_stats(files: list[str], numstat: str) -> dict[str, int]:
|
||||
additions = 0
|
||||
deletions = 0
|
||||
for line in numstat.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
if parts[0].isdigit():
|
||||
additions += int(parts[0])
|
||||
if parts[1].isdigit():
|
||||
deletions += int(parts[1])
|
||||
return {"files": len(files), "additions": additions, "deletions": deletions}
|
||||
|
||||
|
||||
def _approval_url(thread_id: str | None, fingerprint: str) -> str | None:
|
||||
if not thread_id:
|
||||
return None
|
||||
return dashboard_workflow_approval_url(thread_id, fingerprint)
|
||||
|
||||
|
||||
def _run_coroutine_sync(coro: Awaitable[ToolMessage | Command]) -> ToolMessage | Command:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
|
|
@ -614,6 +651,29 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
|
|||
fixed_args.append("--set-upstream")
|
||||
fixed_args.extend([parsed.remote, fixed_refspec])
|
||||
fixed_command = _git_command(root, " ".join(shlex.quote(arg) for arg in fixed_args))
|
||||
|
||||
# Fetch diff preview for the workflow approval card.
|
||||
diff_range = f"{shlex.quote(base_ref)}...{shlex.quote(head)}" if base_ref else shlex.quote(head)
|
||||
diff = _run_git(backend, root, f"diff --binary --full-index {diff_range} -- .github/workflows")
|
||||
numstat = None
|
||||
diff_preview = ""
|
||||
diff_preview_truncated = False
|
||||
diff_stats_val: dict[str, int] = {"files": len(files), "additions": 0, "deletions": 0}
|
||||
base_sha_actual = base_ref if base_ref else ""
|
||||
if diff.ok and diff.output:
|
||||
diff_preview, diff_preview_truncated = _diff_preview(diff.output)
|
||||
numstat = _run_git(backend, root, f"diff --numstat {diff_range} -- .github/workflows")
|
||||
if numstat.ok:
|
||||
diff_stats_val = _diff_stats(files, numstat.output)
|
||||
elif base_ref and not diff.ok:
|
||||
diff_only_head = _run_git(
|
||||
backend,
|
||||
root,
|
||||
f"diff --binary --full-index --root {shlex.quote(head)} -- .github/workflows",
|
||||
)
|
||||
if diff_only_head.ok and diff_only_head.output:
|
||||
diff_preview, diff_preview_truncated = _diff_preview(diff_only_head.output)
|
||||
|
||||
content_payload = {
|
||||
"repo": repo,
|
||||
"branch": branch_name,
|
||||
|
|
@ -626,16 +686,21 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
|
|||
branch=branch_name,
|
||||
files=files,
|
||||
head_sha=head,
|
||||
base_sha=base_sha_actual,
|
||||
remote=parsed.remote,
|
||||
local_ref=parsed.local_ref,
|
||||
remote_ref=parsed.remote_ref,
|
||||
fixed_command=fixed_command,
|
||||
diff_stats=diff_stats_val,
|
||||
diff_preview=diff_preview,
|
||||
diff_preview_truncated=diff_preview_truncated,
|
||||
)
|
||||
|
||||
|
||||
def _blocked_message(
|
||||
change: WorkflowPushChange,
|
||||
*,
|
||||
approval_url: str | None = None,
|
||||
already_rejected: bool = False,
|
||||
stale: bool = False,
|
||||
blocked: bool = False,
|
||||
|
|
@ -657,7 +722,7 @@ def _blocked_message(
|
|||
error = (
|
||||
"This git push includes GitHub workflow file changes and requires human "
|
||||
"approval before Open SWE can push it. Retry the same standalone git push "
|
||||
"after the thread owner approves the workflow files."
|
||||
"after the thread owner approves the workflow diff in Slack or the web UI."
|
||||
)
|
||||
error_type = "WorkflowPushApprovalRequired"
|
||||
content = {
|
||||
|
|
@ -669,6 +734,11 @@ def _blocked_message(
|
|||
"files": change.files,
|
||||
"repo": change.repo,
|
||||
"branch": change.branch,
|
||||
"base_sha": change.base_sha,
|
||||
"head_sha": change.head_sha,
|
||||
"diff_stats": change.diff_stats,
|
||||
"diff_preview_truncated": change.diff_preview_truncated,
|
||||
"approval_url": approval_url,
|
||||
}
|
||||
return ToolMessage(content=json.dumps(content), tool_call_id="", status="error")
|
||||
|
||||
|
|
@ -687,20 +757,22 @@ def _override_execute_command(request: ToolCallRequest, command: str) -> ToolCal
|
|||
return request.override(tool_call={**dict(tool_call), "args": args})
|
||||
|
||||
|
||||
def _approval_slack_message(change: WorkflowPushChange) -> str:
|
||||
def _approval_slack_message(change: WorkflowPushChange, approval_url: str | None = None) -> str:
|
||||
files = "\n".join(f"• `{path}`" for path in change.files[:10])
|
||||
if len(change.files) > 10:
|
||||
files += f"\n• …and {len(change.files) - 10} more"
|
||||
repo = change.repo or "the repository"
|
||||
branch = change.branch or "the current branch"
|
||||
stats = change.diff_stats or {"files": len(change.files), "additions": 0, "deletions": 0}
|
||||
web_review = f"\n\n*Review diff:* <{approval_url}|Open in Web>" if approval_url else ""
|
||||
return (
|
||||
"*Workflow file approval required*\n"
|
||||
f"Open SWE is trying to push the workflow files below to `{repo}` on `{branch}`.\n\n"
|
||||
f"*Files at the pushed head:*\n{files}\n\n"
|
||||
f"*Fingerprint:* `{change.fingerprint}`\n\n"
|
||||
"Approval covers the exact workflow files and content listed above at the pushed head, "
|
||||
"including future rebases or amends that replay the same workflow tree. If the set of "
|
||||
"workflow files, the branch, or the workflow-file content at the pushed head changes, "
|
||||
f"Open SWE is trying to push changes to GitHub workflow files in `{repo}` on `{branch}`.\n\n"
|
||||
f"*Files:*\n{files}\n\n"
|
||||
f"*Diff stat:* {stats.get('files', len(change.files))} files, "
|
||||
f"+{stats.get('additions', 0)} / -{stats.get('deletions', 0)}\n"
|
||||
f"*Fingerprint:* `{change.fingerprint}`{web_review}\n\n"
|
||||
"Approve only if this exact workflow diff is expected. If the workflow files change, "
|
||||
"a new fingerprint will be required."
|
||||
)
|
||||
|
||||
|
|
@ -718,7 +790,9 @@ async def _post_slack_approval_if_needed(
|
|||
thread_ts = slack_thread.get("thread_ts")
|
||||
if not isinstance(channel_id, str) or not isinstance(thread_ts, str):
|
||||
return
|
||||
message = _approval_slack_message(change)
|
||||
message = _approval_slack_message(
|
||||
change, _approval_url(_thread_id(request), change.fingerprint)
|
||||
)
|
||||
message_ts, error = await post_slack_thread_reply_with_ts(
|
||||
channel_id,
|
||||
thread_ts,
|
||||
|
|
@ -736,6 +810,7 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
if not thread_id:
|
||||
return "missing_thread"
|
||||
try:
|
||||
approval_url = _approval_url(thread_id, change.fingerprint)
|
||||
if await workflow_push_approved(thread_id, change.fingerprint):
|
||||
return "approved"
|
||||
if await workflow_push_rejected(thread_id, change.fingerprint):
|
||||
|
|
@ -763,6 +838,10 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
base_sha=change.base_sha,
|
||||
head_sha=change.head_sha,
|
||||
files=change.files,
|
||||
diff_stats=change.diff_stats,
|
||||
diff_preview=change.diff_preview,
|
||||
diff_preview_truncated=change.diff_preview_truncated,
|
||||
approval_url=approval_url,
|
||||
)
|
||||
await _post_slack_approval_if_needed(request, change, record)
|
||||
return str(record.get("status") or "pending")
|
||||
|
|
@ -771,48 +850,6 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
return "approval_error"
|
||||
|
||||
|
||||
async def _run_with_workflow_token(
|
||||
thread_id: str,
|
||||
request: ToolCallRequest,
|
||||
run: Callable[[], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||
if sandbox_type != "langsmith":
|
||||
return await run()
|
||||
|
||||
elevated = await refresh_proxy_token(
|
||||
thread_id, permissions=WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
if not elevated:
|
||||
logger.error(
|
||||
"Workflow push approved for thread %s, but proxy token elevation to workflows:write "
|
||||
"failed; the sandbox cannot push workflow files without an elevated token.",
|
||||
thread_id,
|
||||
)
|
||||
error_message = ToolMessage(
|
||||
content=json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"error_type": "WorkflowPushElevationFailed",
|
||||
"error": (
|
||||
"Workflow push approved, but the sandbox could not obtain a "
|
||||
"workflows-scoped token. Please retry the push or check the "
|
||||
"GitHub proxy / token minting configuration."
|
||||
),
|
||||
}
|
||||
),
|
||||
tool_call_id="",
|
||||
status="error",
|
||||
)
|
||||
return _tool_message_for_request(error_message, request)
|
||||
try:
|
||||
return await run()
|
||||
finally:
|
||||
restored = await refresh_proxy_token(thread_id, permissions=RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
if not restored:
|
||||
await refresh_proxy_token(thread_id, permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
|
||||
|
||||
class WorkflowPushGuardMiddleware(AgentMiddleware):
|
||||
"""Require approval before pushing `.github/workflows` changes."""
|
||||
|
||||
|
|
@ -856,10 +893,10 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
|
|||
return _tool_message_for_request(_blocked_message(change, blocked=True), request)
|
||||
thread_id = _thread_id(request)
|
||||
state = await _approval_state(request, change)
|
||||
if state == "approved" and thread_id:
|
||||
if state == "approved":
|
||||
safe_request = _override_execute_command(request, change.fixed_command)
|
||||
return await _run_with_workflow_token(thread_id, request, lambda: handler(safe_request))
|
||||
if state == "stale_approval":
|
||||
return await handler(safe_request)
|
||||
if state == "stale_approval" and thread_id:
|
||||
record, _created = await ensure_workflow_push_pending(
|
||||
thread_id,
|
||||
fingerprint=change.fingerprint,
|
||||
|
|
@ -868,11 +905,16 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
|
|||
base_sha=change.base_sha,
|
||||
head_sha=change.head_sha,
|
||||
files=change.files,
|
||||
diff_stats=change.diff_stats,
|
||||
diff_preview=change.diff_preview,
|
||||
diff_preview_truncated=change.diff_preview_truncated,
|
||||
approval_url=_approval_url(thread_id, change.fingerprint),
|
||||
)
|
||||
await _post_slack_approval_if_needed(request, change, record)
|
||||
return _tool_message_for_request(
|
||||
_blocked_message(
|
||||
change,
|
||||
approval_url=_approval_url(thread_id, change.fingerprint),
|
||||
already_rejected=state == "rejected",
|
||||
stale=state == "stale_approval",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -83,9 +83,10 @@ OPEN_SWE_SHARED_BASE = """You are **Open SWE**, an open-source agent built on La
|
|||
### Communication
|
||||
|
||||
- Focus on the substance and keep summaries brief. Use light markdown (`###`/`####` headings, bold, code) — avoid `#`/`##` titles.
|
||||
- In Slack, keep every reply terse — a few sentences at most. Lead with the answer or outcome; skip preamble, restating the request, and step-by-step recaps. Do not paste long output, diffs, file listings, or multi-section write-ups into a Slack reply. When the response would genuinely be long — a detailed report, a design/analysis write-up, a large code or log excerpt — write that content to a Markdown file under `/workspace/plans/` and publish it with the `save_plan` tool, then post a terse Slack reply with a one-line summary and the plan-review link so the user can read the full version there. This non-plan share path does not enter plan mode; use it instead of splitting a long answer across multiple Slack messages.
|
||||
- In Slack, when a user asks to “break out,” “split out,” or “start a separate thread” for part of the work, summarize the requested aspect and relevant context into self-contained instructions, then call `slack_start_new_thread` instead of only replying in the current thread.
|
||||
- In Slack, when acknowledging a user follow-up while you continue working, prefer `slack_add_reaction` with the default `eyes` reaction over posting a perfunctory “Updating…” / “I’ll check…” confirmation reply.
|
||||
- When you post to Slack with `slack_thread_reply`, do not repeat that text in a later assistant message; the user can already see the Slack message.
|
||||
- For Slack-triggered information-only answers, post only a concise summary in the associated Slack thread with `slack_thread_reply`, then provide the complete answer inline in your final assistant response. For other Slack updates, keep thread replies brief and avoid duplicating the same text later.
|
||||
- When delegated work to a subagent: the calling agent only sees your final message, so make it the complete answer.
|
||||
|
||||
IMPORTANT: You must ALWAYS call a tool in EVERY SINGLE TURN. If you don't call a tool, the session will end and you won't be able to resume without the user manually restarting you.
|
||||
|
|
@ -310,7 +311,7 @@ Steps, in order:
|
|||
|
||||
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
|
||||
|
||||
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Any push that includes workflow files at the pushed head requires human approval before it can proceed. Approval is keyed to the repo, branch, and the exact workflow files and content present at the pushed head, so rebases or amends that replay the same workflow tree do not require a fresh approval; changing the branch, the set of workflow files, or the workflow-file content at the pushed head does require a new approval. Do not attempt to bypass it.**
|
||||
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Workflow-file pushes are approved by `WorkflowPushGuardMiddleware`: after committing, run the push as a standalone `git push origin <branch>` (or `git -C <repo> push origin <branch>`), never as part of a compound command. Do not manually ask for freeform fingerprint approval. If the push tool returns `WorkflowPushApprovalRequired`, stop retrying and wait for the generated Slack/Web approval; after approval, retry the same standalone push without changing workflow files. Approval is keyed to the repo, branch, and the exact workflow files and content present at the pushed head, so rebases or amends that replay the same workflow tree do not require a fresh approval; changing the branch, the set of workflow files, or the workflow-file content at the pushed head does require a new approval. Do not attempt to bypass it.**
|
||||
|
||||
4. **Notify the source** immediately after pushing and, when applicable, PR creation/update succeeds. Include a brief summary plus the PR link or branch URL:
|
||||
- Linear-triggered: use `linear_comment` with an `@mention` of the user who triggered the task
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
"""Tool: ``save_plan``. Publish the sandbox plan file for review.
|
||||
"""Tool: ``save_plan``. Publish sandbox Markdown for review or sharing.
|
||||
|
||||
Reads the Markdown plan file the agent created in the sandbox and publishes it to
|
||||
the plan-review page, where the user and collaborators read it, comment inline,
|
||||
and approve or request changes. Available in plan mode (it does not modify the
|
||||
repository under review).
|
||||
Reads the Markdown file the agent created in the sandbox and publishes it to the
|
||||
plan-review page. In plan mode it is an approvable implementation plan; outside
|
||||
plan mode it is read-only shared content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph.prebuilt import InjectedState
|
||||
|
||||
from ..dashboard.plan_store import PLAN_FILE_DIRECTORY, PLAN_STATUS_READY, save_plan_content
|
||||
from ..dashboard.plan_store import (
|
||||
PLAN_FILE_DIRECTORY,
|
||||
PLAN_STATUS_READY,
|
||||
PLAN_STATUS_SHARED,
|
||||
save_plan_content,
|
||||
)
|
||||
from ..utils.sandbox_state import get_sandbox_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -23,20 +28,22 @@ _MAX_PLAN_LINES = 20_000
|
|||
_MARKDOWN_EXTENSIONS = (".md", ".markdown")
|
||||
|
||||
|
||||
async def save_plan(plan_file_path: str) -> dict[str, Any]:
|
||||
async def save_plan(
|
||||
plan_file_path: str,
|
||||
state: Annotated[dict[str, Any] | None, InjectedState] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish a Markdown plan file from the sandbox for review.
|
||||
|
||||
Use this in plan mode once your plan is ready. First create a Markdown file
|
||||
under ``/workspace/plans/`` using a dated, descriptive filename, then pass
|
||||
that file path here. The file contents are published to the plan-review page
|
||||
linked in the conversation, where the user (the owner) and any collaborators
|
||||
can read it, leave inline comments, and then approve it or request changes.
|
||||
Call it again to publish a revised file when addressing feedback.
|
||||
Use this in plan mode once your plan is ready. Outside plan mode, use it to
|
||||
share long Slack responses without switching the thread into plan mode. First
|
||||
create a Markdown file under ``/workspace/plans/`` using a dated, descriptive
|
||||
filename, then pass that file path here. The file contents are published to
|
||||
the plan-review page linked in the conversation. In plan mode, the user can
|
||||
comment, approve, or request changes; outside plan mode, the page is read-only
|
||||
shared content.
|
||||
|
||||
Write the plan in standard Markdown — headings, bullet/numbered lists, and
|
||||
fenced code blocks all render. Keep it concise and high level, focusing on
|
||||
approach, decisions/tradeoffs, risks, and verification; avoid file/function
|
||||
details unless they are unusually tricky or controversial.
|
||||
Write the content in standard Markdown — headings, bullet/numbered lists, and
|
||||
fenced code blocks all render.
|
||||
|
||||
Args:
|
||||
plan_file_path: Path to the Markdown plan file in the sandbox.
|
||||
|
|
@ -68,19 +75,29 @@ async def save_plan(plan_file_path: str) -> dict[str, Any]:
|
|||
content = (await _read_plan_file(str(thread_id), path)).strip()
|
||||
if not content:
|
||||
return {"success": False, "error": "plan file cannot be empty"}
|
||||
await _save(str(thread_id), content, path)
|
||||
await _save(str(thread_id), content, path, plan_mode=_active_plan_mode(state, configurable))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("save_plan failed for thread %s", thread_id)
|
||||
return {"success": False, "error": f"failed to save plan: {exc}"}
|
||||
return {"success": True, "path": path}
|
||||
|
||||
|
||||
async def _save(thread_id: str, content: str, path: str) -> None:
|
||||
async def _save(thread_id: str, content: str, path: str, *, plan_mode: bool) -> None:
|
||||
await save_plan_content(
|
||||
thread_id, markdown=content, status=PLAN_STATUS_READY, plan_file_path=path
|
||||
thread_id,
|
||||
markdown=content,
|
||||
status=PLAN_STATUS_READY if plan_mode else PLAN_STATUS_SHARED,
|
||||
plan_file_path=path,
|
||||
plan_mode=plan_mode or None,
|
||||
)
|
||||
|
||||
|
||||
def _active_plan_mode(state: dict[str, Any] | None, configurable: Any) -> bool:
|
||||
if isinstance(state, dict) and state.get("plan_mode") is True:
|
||||
return True
|
||||
return isinstance(configurable, dict) and configurable.get("plan_mode") is True
|
||||
|
||||
|
||||
async def _read_plan_file(thread_id: str, path: str) -> str:
|
||||
backend = await get_sandbox_backend(thread_id)
|
||||
result = await backend.aread(path, offset=0, limit=_MAX_PLAN_LINES)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ def dashboard_plan_url(thread_id: str) -> str | None:
|
|||
return f"{base_url}/agents/{quote(thread_id, safe='')}/plan"
|
||||
|
||||
|
||||
def dashboard_workflow_approval_url(thread_id: str, fingerprint: str) -> str | None:
|
||||
"""Build the dashboard workflow approval URL for a thread/fingerprint."""
|
||||
thread_url = dashboard_thread_url(thread_id)
|
||||
if not thread_url or not fingerprint:
|
||||
return thread_url
|
||||
return f"{thread_url}?workflowApproval={quote(fingerprint, safe='')}"
|
||||
|
||||
|
||||
def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None:
|
||||
"""Build the dashboard review-detail URL for a PR."""
|
||||
base_url = _dashboard_base_url()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
{"sha": "209132d3", "pr": 1621, "subject": "durable interrupt dispatch + completion webhook", "disposition": "landed", "reason": "investigate first — may be applied", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:52Z"}
|
||||
{"sha": "02bb4dfd", "pr": 1658, "subject": "don't attach loopback run-complete webhooks", "disposition": "landed", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "29015fad", "pr": 1614, "subject": "gate workflow pushes with approval", "disposition": "landed", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "landed", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "ae04b72b", "pr": 1635, "subject": "publish plans from sandbox files", "disposition": "landed", "reason": "ported (adapted) in #128 (save_plan reads sandbox file)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "landed", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "96cceb74", "pr": 1632, "subject": "notify Slack on plan approval", "disposition": "landed", "reason": "ported (adapted) in #128", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "2f56d754", "pr": 1618, "subject": "omit plan link when no plan exists", "disposition": "landed", "reason": "already in dev via #81 (upstream-sync); ledger was stale (was: likely regression)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T23:25:48Z"}
|
||||
{"sha": "ee224d3e", "pr": 1650, "subject": "add Slack reaction tool", "disposition": "landed", "reason": "", "branch": "slack-tooling", "local_sha": "3c6077c418e4017acf1f04ebeb81846770f934c9", "updated": "2026-07-03T19:38:56Z"}
|
||||
|
|
@ -67,17 +67,17 @@
|
|||
{"sha": "e5dbc788", "pr": 1696, "subject": "fix: Harden durable agent runs (#1696)", "disposition": "landed", "reason": "large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "52fe2916", "pr": 1698, "subject": "feat: add PR review link route (#1698)", "disposition": "landed", "reason": "cherry-picked (-x) in #127 (PR review link route)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "5f7f5fbd", "pr": 1697, "subject": "fix: Reduce graph import and loader startup latency (#1697)", "disposition": "landed", "reason": "import-hygiene refactor; cross-cutting, references many deferred upstream-only modules", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "deferred", "reason": "FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "deferred", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "deferred", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "landed", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "deferred", "reason": "FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "deferred", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "deferred", "reason": "applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "deferred", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "landed", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "f53caff1", "pr": 1701, "subject": "fix: fall back to core GitHub App scope when optional grants missing (#1701)", "disposition": "deferred", "reason": "FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token", "branch": "github-app-scope", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "73a9e8b5", "pr": 1693, "subject": "chore(deps): bump the minor-and-patch group across 1 directory with 19 updates (#1693)", "disposition": "wont-merge", "reason": "dev at-or-ahead on 17/19; group fights dev's pinned langsmith==0.9.7 (#115) and carries an upstream plan-route test", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "9cd7e464", "pr": 1700, "subject": "Fix workflow approval visibility (#1700)", "disposition": "wont-merge", "reason": "superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `209132d3` | #1621 | durable interrupt dispatch + completion webhook | Landed | investigate first — may be applied | durable-dispatch |
|
||||
| `02bb4dfd` | #1658 | don't attach loopback run-complete webhooks | Landed | | durable-dispatch |
|
||||
| `29015fad` | #1614 | gate workflow pushes with approval | Landed | | durable-dispatch |
|
||||
| `546042a4` | #1652 | add workflow approval UI | Landed | | plan-approval |
|
||||
| `ae04b72b` | #1635 | publish plans from sandbox files | Landed | ported (adapted) in #128 (save_plan reads sandbox file) | plan-approval |
|
||||
| `c03a6be7` | #1634 | keep plan guidance high-level | Landed | | plan-approval |
|
||||
| `96cceb74` | #1632 | notify Slack on plan approval | Landed | ported (adapted) in #128 | plan-approval |
|
||||
| `2f56d754` | #1618 | omit plan link when no plan exists | Landed | already in dev via #81 (upstream-sync); ledger was stale (was: likely regression) | plan-approval |
|
||||
| `ee224d3e` | #1650 | add Slack reaction tool | Landed | | slack-tooling |
|
||||
|
|
@ -43,8 +45,12 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `e5dbc788` | #1696 | fix: Harden durable agent runs (#1696) | Landed | large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion | durable-dispatch |
|
||||
| `52fe2916` | #1698 | feat: add PR review link route (#1698) | Landed | cherry-picked (-x) in #127 (PR review link route) | reviewer-misc |
|
||||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Landed | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface) | plan-approval |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
|
||||
| `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Landed | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | |
|
||||
| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | |
|
||||
| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | |
|
||||
|
|
@ -62,8 +68,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `73a9e8b5` | #1693 | chore(deps): bump the minor-and-patch group across 1 directory with 19 updates (#1693) | Won't merge | dev at-or-ahead on 17/19; group fights dev's pinned langsmith==0.9.7 (#115) and carries an upstream plan-route test | |
|
||||
| `9cd7e464` | #1700 | Fix workflow approval visibility (#1700) | Won't merge | superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403 | |
|
||||
| `fd2541ce` | #1705 | fix: drop orphaned function_call items with stale OpenAI reasoning (#1705) | Won't merge | N/A — edits sanitize_openai_responses.py which dev deleted in the Bedrock/Fireworks migration (#62) | |
|
||||
| `546042a4` | #1652 | add workflow approval UI | Deferred | | plan-approval |
|
||||
| `c03a6be7` | #1634 | keep plan guidance high-level | Deferred | | plan-approval |
|
||||
| `4cd5fa5c` | #1629 | avoid recapping Slack replies | Deferred | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Deferred | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Deferred | | gateway-routing |
|
||||
|
|
@ -83,15 +87,11 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `fbc6de85` | #1667 | chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667) | Deferred | conflicts on pick — dev diverged to fireworks-ai a85; a86 still wanted, needs manual bump + uv lock | deps |
|
||||
| `c9f6dd86` | #1694 | fix: fall back on model stream transport errors (#1694) | Deferred | adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback | model-fallback |
|
||||
| `c9a9a7cd` | #1695 | fix: retry model fallback exhaustion (#1695) | Deferred | alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path | model-fallback |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Deferred | FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface) | plan-approval |
|
||||
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Deferred | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Deferred | FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate | fable-admin-toggle |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Deferred | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
|
||||
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user |
|
||||
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Deferred | applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR | dashboard-ui |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Deferred | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope |
|
||||
| `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking |
|
||||
|
||||
|
|
|
|||
|
|
@ -152,10 +152,19 @@ async def test_save_plan_reads_markdown_file_from_sandbox(
|
|||
return _Backend()
|
||||
|
||||
async def fake_save_content(
|
||||
thread_id: str, *, markdown: str, status: str, plan_file_path: str | None = None
|
||||
thread_id: str,
|
||||
*,
|
||||
markdown: str,
|
||||
status: str,
|
||||
plan_file_path: str | None = None,
|
||||
plan_mode: bool | None = True,
|
||||
) -> None:
|
||||
saved.update(
|
||||
thread_id=thread_id, markdown=markdown, status=status, plan_file_path=plan_file_path
|
||||
thread_id=thread_id,
|
||||
markdown=markdown,
|
||||
status=status,
|
||||
plan_file_path=plan_file_path,
|
||||
plan_mode=plan_mode,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -175,8 +184,9 @@ async def test_save_plan_reads_markdown_file_from_sandbox(
|
|||
assert saved == {
|
||||
"thread_id": "thread-1",
|
||||
"markdown": "# Plan\n\nDo it.",
|
||||
"status": "ready",
|
||||
"status": "shared",
|
||||
"plan_file_path": "/workspace/plans/2026-07-08-test-plan.md",
|
||||
"plan_mode": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -348,25 +347,15 @@ async def test_unapproved_workflow_push_blocks_and_posts_slack(
|
|||
assert posted["blocks"][1]["elements"][0]["value"]
|
||||
|
||||
|
||||
async def test_approved_workflow_push_elevates_and_restores(
|
||||
async def test_approved_workflow_push_runs_fixed_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
refreshed: list[dict[str, str]] = []
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_refresh(thread_id: str | None, *, permissions: dict[str, str]) -> bool:
|
||||
refreshed.append(dict(permissions))
|
||||
return True
|
||||
|
||||
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
pushed_command = ""
|
||||
|
||||
|
|
@ -383,40 +372,6 @@ async def test_approved_workflow_push_elevates_and_restores(
|
|||
pushed_command
|
||||
== "git -C /repo push origin aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:refs/heads/feature"
|
||||
)
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert "workflows" not in refreshed[1]
|
||||
assert refreshed[1]["actions"] == "read"
|
||||
|
||||
|
||||
async def test_workflow_push_restoration_falls_back_when_actions_read_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
refreshed: list[dict[str, str]] = []
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_refresh(thread_id: str | None, *, permissions: dict[str, str]) -> bool:
|
||||
refreshed.append(dict(permissions))
|
||||
return "actions" not in permissions
|
||||
|
||||
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
async def handler(_request: Any) -> ToolMessage:
|
||||
return ToolMessage(content="pushed", tool_call_id="call-1")
|
||||
|
||||
await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
|
||||
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert refreshed[1]["actions"] == "read"
|
||||
assert refreshed[2] == guard.BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "actions" not in refreshed[2]
|
||||
|
||||
|
||||
async def test_non_workflow_push_runs_without_approval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -710,7 +665,7 @@ async def test_exact_rejection_checked_before_stale_approval(
|
|||
assert payload["workflow_approval_status"] == "rejected"
|
||||
|
||||
|
||||
async def test_approved_workflow_push_aborts_when_elevation_fails(
|
||||
async def test_approved_workflow_push_passes_through(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
|
|
@ -718,14 +673,7 @@ async def test_approved_workflow_push_aborts_when_elevation_fails(
|
|||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
refresh_calls: list[dict[str, str]] = []
|
||||
|
||||
async def fake_refresh(*args: Any, **kwargs: Any) -> bool:
|
||||
refresh_calls.append(dict(kwargs.get("permissions", {})))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
called = False
|
||||
request = _Request()
|
||||
|
|
@ -737,51 +685,9 @@ async def test_approved_workflow_push_aborts_when_elevation_fails(
|
|||
|
||||
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(request, handler)
|
||||
|
||||
assert called is False
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.tool_call_id == "call-1"
|
||||
assert result.status == "error"
|
||||
payload = json.loads(str(result.content))
|
||||
assert payload["status"] == "error"
|
||||
assert payload["error_type"] == "WorkflowPushElevationFailed"
|
||||
assert "workflows-scoped token" in payload["error"]
|
||||
assert len(refresh_calls) == 1
|
||||
assert refresh_calls[0].get("workflows") == "write"
|
||||
|
||||
|
||||
async def test_approved_workflow_push_runs_on_non_langsmith_providers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
refresh_calls: list[dict[str, str]] = []
|
||||
|
||||
async def fake_refresh(*args: Any, **kwargs: Any) -> bool:
|
||||
refresh_calls.append(dict(kwargs.get("permissions", {})))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
monkeypatch.setattr(guard, "os", os)
|
||||
|
||||
called = False
|
||||
|
||||
async def handler(request: Any) -> ToolMessage:
|
||||
nonlocal called
|
||||
called = True
|
||||
return ToolMessage(content="pushed", tool_call_id=request.tool_call["id"])
|
||||
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setenv("SANDBOX_TYPE", "local")
|
||||
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
|
||||
|
||||
assert called is True
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.content == "pushed"
|
||||
assert refresh_calls == []
|
||||
|
||||
|
||||
def test_parse_git_push_guards_wrapped_and_optioned_forms() -> None:
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessa
|
|||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||
import { useIsMobile } from "@/lib/useIsMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
useApproveWorkflowPush,
|
||||
useRejectWorkflowPush,
|
||||
useWorkflowApprovals,
|
||||
} from "@/lib/agents/queries"
|
||||
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
|
||||
|
||||
interface AgentThreadViewProps {
|
||||
|
|
@ -132,31 +127,6 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
// Show a loading state during that one-time fetch instead of the empty state.
|
||||
const isHydrating = stream.isThreadLoading && !hasMessages
|
||||
|
||||
const approvalsQuery = useWorkflowApprovals(thread.id)
|
||||
const approveMutation = useApproveWorkflowPush(thread.id)
|
||||
const rejectMutation = useRejectWorkflowPush(thread.id)
|
||||
|
||||
const approvals = approvalsQuery.data
|
||||
const isApprovalOwner = thread.isOwner ?? false
|
||||
|
||||
const hasWorkflowApprovalError = useMemo(
|
||||
() =>
|
||||
baseMessages.some((message) =>
|
||||
message.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.kind === "tool-execution" &&
|
||||
chunk.output?.includes("WorkflowPushApprovalRequired")
|
||||
)
|
||||
),
|
||||
[baseMessages]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasWorkflowApprovalError) {
|
||||
void approvalsQuery.refetch()
|
||||
}
|
||||
}, [hasWorkflowApprovalError])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div
|
||||
|
|
@ -187,35 +157,25 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
|
||||
{thread.planStatus === "ready"
|
||||
? "A plan is ready for your review."
|
||||
: thread.planStatus === "revising"
|
||||
? "The agent is revising the plan."
|
||||
: "The agent is writing a plan."}
|
||||
: thread.planStatus === "shared"
|
||||
? "The agent shared a longer response."
|
||||
: thread.planStatus === "revising"
|
||||
? "The agent is revising the plan."
|
||||
: "The agent is writing a plan."}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--ui-accent)]">
|
||||
Review plan →
|
||||
{thread.planStatus === "shared"
|
||||
? "Open response →"
|
||||
: "Review plan →"}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<WorkflowApprovalCard
|
||||
threadId={thread.id}
|
||||
pollWhileActive={isStreaming}
|
||||
/>
|
||||
{hasConversation ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{approvals && approvals.length > 0 && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
{approvals.map((approval) => (
|
||||
<WorkflowApprovalCard
|
||||
key={approval.fingerprint}
|
||||
approval={approval}
|
||||
isOwner={isApprovalOwner}
|
||||
isPending={
|
||||
approveMutation.isPending || rejectMutation.isPending
|
||||
}
|
||||
onApprove={() =>
|
||||
approveMutation.mutate(approval.fingerprint)
|
||||
}
|
||||
onReject={() => rejectMutation.mutate(approval.fingerprint)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Messages
|
||||
messages={baseMessages}
|
||||
queuedMessages={queuedMessages}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type KeyboardEvent, useCallback, useEffect, useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { PlanComment, PlanData } from "@/lib/plan"
|
||||
|
|
@ -59,7 +59,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Locally track the displayed markdown so a manual edit shows immediately; the
|
||||
// route's query stops polling once a plan exists, so the prop won't refetch.
|
||||
// route's query stops polling once content exists, so the prop won't refetch.
|
||||
const [markdown, setMarkdown] = useState(plan.markdown)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editDraft, setEditDraft] = useState(plan.markdown)
|
||||
|
|
@ -70,8 +70,12 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
if (!editing) setMarkdown(plan.markdown)
|
||||
}, [plan.markdown, editing])
|
||||
|
||||
const isShared = plan.status === "shared"
|
||||
const canEdit =
|
||||
plan.isOwner && plan.status !== "approved" && plan.status !== "cancelled"
|
||||
plan.isOwner &&
|
||||
!isShared &&
|
||||
plan.status !== "approved" &&
|
||||
plan.status !== "cancelled"
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setEditDraft(markdown)
|
||||
|
|
@ -114,13 +118,14 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
/* transient; next tick retries */
|
||||
}
|
||||
}
|
||||
if (isShared) return
|
||||
void load()
|
||||
const timer = setInterval(load, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [plan.threadId])
|
||||
}, [isShared, plan.threadId])
|
||||
|
||||
const submitComment = useCallback(async () => {
|
||||
const body = draft.trim()
|
||||
|
|
@ -138,6 +143,16 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
}
|
||||
}, [draft, plan.threadId])
|
||||
|
||||
const handleCommentKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
if (posting || !draft.trim()) return
|
||||
void submitComment()
|
||||
},
|
||||
[draft, posting, submitComment]
|
||||
)
|
||||
|
||||
const removeComment = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
|
|
@ -192,10 +207,10 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
<div className="flex flex-col gap-3 border-b border-[var(--ui-border)] px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-4 md:px-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-[var(--ui-text)]">
|
||||
Implementation plan
|
||||
{isShared ? "Shared response" : "Implementation plan"}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||
Reviewing as {plan.user.name}
|
||||
{isShared ? "Viewing" : "Reviewing"} as {plan.user.name}
|
||||
{plan.isOwner ? " (owner)" : ""} · status:{" "}
|
||||
<span data-testid="plan-status">{plan.status}</span>
|
||||
</p>
|
||||
|
|
@ -227,7 +242,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{plan.isOwner && (
|
||||
{!isShared && plan.isOwner && (
|
||||
<Button
|
||||
data-testid="approve-plan"
|
||||
disabled={busy !== null || decision !== null}
|
||||
|
|
@ -236,6 +251,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{!isShared && (
|
||||
<Button
|
||||
data-testid="reject-plan"
|
||||
variant="secondary"
|
||||
|
|
@ -251,6 +267,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
>
|
||||
Request changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -296,7 +313,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
{!isShared && (<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--ui-text)]">
|
||||
Comments
|
||||
|
|
@ -347,6 +364,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
data-testid="comment-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleCommentKeyDown}
|
||||
placeholder="Leave a comment on the plan"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-[var(--ui-border)] bg-[var(--ui-bg)] px-2 py-1.5 text-sm text-[var(--ui-text)] outline-none focus:border-[var(--ui-accent)]"
|
||||
|
|
@ -362,7 +380,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,138 +1,177 @@
|
|||
import { Check, FileCode2, ShieldAlert, X } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
|
||||
import type { WorkflowApproval } from "@/lib/agents/api"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { WorkflowPushApproval } from "@/lib/agents/api"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
useWorkflowApprovalDecision,
|
||||
useWorkflowApprovals,
|
||||
} from "@/lib/agents/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ApprovalStatus = "pending" | "approved" | "rejected" | string
|
||||
|
||||
interface WorkflowApprovalCardProps {
|
||||
approval: WorkflowApproval
|
||||
isOwner: boolean
|
||||
isPending: boolean
|
||||
onApprove: (fingerprint: string) => void
|
||||
onReject: (fingerprint: string) => void
|
||||
function shortSha(value: string): string {
|
||||
return value ? value.slice(0, 7) : "unknown"
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
case "rejected":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
default:
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
}
|
||||
function pendingApprovals(
|
||||
approvals: Array<WorkflowPushApproval> | undefined
|
||||
): Array<WorkflowPushApproval> {
|
||||
return (approvals ?? []).filter((approval) => approval.status === "pending")
|
||||
}
|
||||
|
||||
function statusLabel(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "Approved"
|
||||
case "rejected":
|
||||
return "Rejected"
|
||||
default:
|
||||
return "Pending approval"
|
||||
}
|
||||
function fileLabel(count: number): string {
|
||||
return count === 1 ? "1 file" : `${count} files`
|
||||
}
|
||||
|
||||
export function WorkflowApprovalCard({
|
||||
approval,
|
||||
isOwner,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: WorkflowApprovalCardProps) {
|
||||
const actionable = isOwner && approval.status === "pending" && !isPending
|
||||
const decided = approval.status !== "pending"
|
||||
threadId,
|
||||
pollWhileActive = false,
|
||||
}: {
|
||||
threadId: string
|
||||
pollWhileActive?: boolean
|
||||
}) {
|
||||
const query = useWorkflowApprovals(threadId, { pollWhileActive })
|
||||
const decision = useWorkflowApprovalDecision(threadId)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const approvals = useMemo(
|
||||
() => pendingApprovals(query.data?.approvals),
|
||||
[query.data?.approvals]
|
||||
)
|
||||
|
||||
if (approvals.length === 0) return null
|
||||
|
||||
const isOwner = query.data?.isOwner === true
|
||||
const decide = async (
|
||||
approval: WorkflowPushApproval,
|
||||
kind: "approve" | "reject"
|
||||
) => {
|
||||
setError(null)
|
||||
try {
|
||||
await decision.mutateAsync({
|
||||
fingerprint: approval.fingerprint,
|
||||
decision: kind,
|
||||
})
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto mb-4 w-full max-w-3xl border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/10">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Workflow push approval required
|
||||
</CardTitle>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${statusBadgeClass(
|
||||
approval.status
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(approval.status)}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Open SWE is trying to push GitHub workflow file changes in{" "}
|
||||
<strong>{approval.repo}</strong> on <strong>{approval.branch}</strong>
|
||||
. Approve only if this exact workflow diff is expected.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--ui-text-muted)]">
|
||||
<FileCode2 className="size-3.5" />
|
||||
Changed workflow files
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{approval.files.map((file) => (
|
||||
<li
|
||||
key={file}
|
||||
className="font-mono text-[11px] break-all text-[var(--ui-text)]"
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-1 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Fingerprint:{" "}
|
||||
<code className="rounded bg-[var(--ui-panel)] px-1 py-0.5 text-[var(--ui-text)]">
|
||||
{approval.fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{actionable ? (
|
||||
<CardFooter className="gap-2 border-t border-[var(--ui-border)] pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApprove(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="mr-1 size-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="mr-1 size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : decided ? (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
This workflow push has been {approval.status}. If the workflow files
|
||||
change, a new fingerprint will be required.
|
||||
</CardFooter>
|
||||
) : null}
|
||||
{!isOwner && approval.status === "pending" && (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Only the thread owner can approve or reject this workflow push.
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
<div className="border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-3">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||
{approvals.map((approval) => {
|
||||
const busy = decision.isPending
|
||||
return (
|
||||
<section
|
||||
key={approval.fingerprint}
|
||||
data-testid="workflow-approval-card"
|
||||
className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-bg)] p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--ui-text)]">
|
||||
<ShieldCheck className="size-4 text-[var(--ui-accent)]" />
|
||||
Workflow file approval required
|
||||
</div>
|
||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||
{approval.repo || "Repository"} on{" "}
|
||||
{approval.branch || "current branch"} ·{" "}
|
||||
{shortSha(approval.baseSha)} → {shortSha(approval.headSha)}
|
||||
</p>
|
||||
<p className="font-mono text-[0.68rem] break-all text-[var(--ui-text-dim)]">
|
||||
fingerprint: {approval.fingerprint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{approval.approvalUrl && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.href = approval.approvalUrl ?? ""
|
||||
}}
|
||||
>
|
||||
Open in Web
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!isOwner || busy}
|
||||
onClick={() => void decide(approval, "approve")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isOwner || busy}
|
||||
onClick={() => void decide(approval, "reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOwner && (
|
||||
<p className="mt-3 text-xs text-[var(--ui-text-dim)]">
|
||||
Only the thread owner can approve or reject this workflow
|
||||
push.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-3 text-xs text-[color:var(--ui-danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-[var(--ui-text)]">
|
||||
{fileLabel(approval.files.length)} changed
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1 text-xs text-[var(--ui-text-dim)]">
|
||||
{approval.files.slice(0, 8).map((file) => (
|
||||
<li
|
||||
key={file}
|
||||
className="truncate font-mono"
|
||||
title={file}
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
{approval.files.length > 8 && (
|
||||
<li>…and {approval.files.length - 8} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--ui-border)] px-3 py-2 text-xs text-[var(--ui-text-dim)]">
|
||||
<span>{approval.diffStats.files} files</span>
|
||||
<span className="mx-2 text-[var(--ui-success)]">
|
||||
+{approval.diffStats.additions}
|
||||
</span>
|
||||
<span className="text-[var(--ui-danger)]">
|
||||
-{approval.diffStats.deletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{approval.diffPreview && (
|
||||
<details className="mt-3" open>
|
||||
<summary className="cursor-pointer text-xs font-medium text-[var(--ui-text)]">
|
||||
Diff preview
|
||||
{approval.diffPreviewTruncated ? " (truncated)" : ""}
|
||||
</summary>
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 max-h-72 overflow-auto rounded-md border border-[var(--ui-border)]",
|
||||
"bg-[var(--ui-panel)] p-3 text-[0.68rem] leading-relaxed text-[var(--ui-text)]"
|
||||
)}
|
||||
>
|
||||
{approval.diffPreview}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,17 +65,35 @@ export interface ThreadRecoveryPatch {
|
|||
filename: string
|
||||
}
|
||||
|
||||
export interface WorkflowApproval {
|
||||
fingerprint: string
|
||||
status: string
|
||||
repo: string
|
||||
branch: string
|
||||
files: Array<string>
|
||||
requested_at: string
|
||||
export type WorkflowApprovalStatus = "pending" | "approved" | "rejected"
|
||||
|
||||
export interface WorkflowDiffStats {
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalsPayload {
|
||||
approvals: Array<WorkflowApproval>
|
||||
export interface WorkflowPushApproval {
|
||||
fingerprint: string
|
||||
status: WorkflowApprovalStatus
|
||||
repo: string
|
||||
branch: string
|
||||
baseSha: string
|
||||
headSha: string
|
||||
files: Array<string>
|
||||
diffStats: WorkflowDiffStats
|
||||
diffPreview: string
|
||||
diffPreviewTruncated: boolean
|
||||
approvalUrl: string | null
|
||||
requestedAt: string | null
|
||||
decidedAt: string | null
|
||||
decidedBy: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowPushApprovalsResponse {
|
||||
threadId: string
|
||||
isOwner: boolean
|
||||
approvals: Array<WorkflowPushApproval>
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalDecision {
|
||||
|
|
@ -83,6 +101,12 @@ export interface WorkflowApprovalDecision {
|
|||
fingerprint: string
|
||||
}
|
||||
|
||||
// Legacy alias kept for backward-compatible prop typing.
|
||||
export type WorkflowApproval = WorkflowPushApproval
|
||||
export interface WorkflowApprovalsPayload {
|
||||
approvals: Array<WorkflowPushApproval>
|
||||
}
|
||||
|
||||
export interface ThreadsPageParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
|
|
@ -284,7 +308,7 @@ export const agentsApi = {
|
|||
streamUrl: (threadId: string) =>
|
||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
||||
listWorkflowApprovals: (threadId: string) =>
|
||||
agentsRequest<WorkflowApprovalsPayload>(
|
||||
agentsRequest<WorkflowPushApprovalsResponse>(
|
||||
`/workflow-approval/${encodeURIComponent(threadId)}`
|
||||
),
|
||||
approveWorkflowPush: (threadId: string, fingerprint: string) =>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import type {
|
|||
ScheduleUpdateRequest,
|
||||
SidebarThreads,
|
||||
ThreadsPageParams,
|
||||
WorkflowApproval,
|
||||
WorkflowPushApproval,
|
||||
WorkflowPushApprovalsResponse,
|
||||
} from "./api"
|
||||
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||
|
||||
|
|
@ -128,57 +129,43 @@ export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
|
|||
})
|
||||
}
|
||||
|
||||
export function useWorkflowApprovals(threadId: string) {
|
||||
export function useWorkflowApprovals(
|
||||
threadId: string,
|
||||
options: { pollWhileActive?: boolean } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: agentThreadKeys.workflowApprovals(threadId),
|
||||
queryFn: async () => {
|
||||
const { approvals } = await agentsApi.listWorkflowApprovals(threadId)
|
||||
return approvals
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
return data?.some((a) => a.status === "pending") ? 3000 : false
|
||||
},
|
||||
queryFn: () => agentsApi.listWorkflowApprovals(threadId),
|
||||
enabled: Boolean(threadId),
|
||||
refetchInterval: (query) =>
|
||||
options.pollWhileActive ||
|
||||
query.state.data?.approvals?.some(
|
||||
(approval) => approval.status === "pending"
|
||||
)
|
||||
? 3000
|
||||
: false,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveWorkflowPush(threadId: string) {
|
||||
export function useWorkflowApprovalDecision(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.approveWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "approved" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRejectWorkflowPush(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.rejectWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "rejected" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
mutationFn: (vars: {
|
||||
fingerprint: string
|
||||
decision: "approve" | "reject"
|
||||
}) =>
|
||||
vars.decision === "approve"
|
||||
? agentsApi.approveWorkflowPush(threadId, vars.fingerprint)
|
||||
: agentsApi.rejectWorkflowPush(threadId, vars.fingerprint),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentThreadKeys.workflowApprovals(threadId),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentThreadKeys.detail(threadId),
|
||||
})
|
||||
invalidateAgentThreadLists(queryClient)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -823,7 +823,10 @@ export const api = {
|
|||
|
||||
export function loginUrl(redirectTo?: string): string {
|
||||
const target =
|
||||
redirectTo ?? (typeof window !== "undefined" ? window.location.href : "")
|
||||
redirectTo ??
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
: "")
|
||||
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
||||
return `${API_BASE}/dashboard/api/auth/login${qs}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ export interface PlanUser {
|
|||
}
|
||||
|
||||
export type PlanStatus =
|
||||
| "planning"
|
||||
| "ready"
|
||||
| "revising"
|
||||
| "approved"
|
||||
| "cancelled"
|
||||
"planning" | "ready" | "shared" | "revising" | "approved" | "cancelled"
|
||||
|
||||
export interface PlanData {
|
||||
threadId: string
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { PlanReview } from "@/components/agents/PlanReview"
|
|||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { loginUrl } from "@/lib/api"
|
||||
import { authRedirectUrl, currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||
import { currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||
import { PlanApiError, getPlan } from "@/lib/plan"
|
||||
|
||||
export const Route = createFileRoute("/agents/$threadId_/plan")({
|
||||
|
|
@ -36,7 +36,7 @@ function BackLink({ threadId }: { threadId: string }) {
|
|||
}
|
||||
|
||||
export function planSignInHref(): string {
|
||||
return loginUrl(authRedirectUrl(currentAuthRedirectPath()))
|
||||
return loginUrl(currentAuthRedirectPath())
|
||||
}
|
||||
|
||||
export function PlanSignInButton() {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { loginUrl } from "@/lib/api";
|
||||
import {
|
||||
DEFAULT_AUTH_REDIRECT,
|
||||
authRedirectUrl,
|
||||
consumeAuthRedirect,
|
||||
getRememberedAuthRedirect,
|
||||
rememberAuthRedirect,
|
||||
|
|
@ -64,7 +63,7 @@ function Login() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href={loginUrl(authRedirectUrl(intendedPath))}
|
||||
href={loginUrl(intendedPath)}
|
||||
className={cn(buttonVariants({ size: "lg" }), "w-full")}
|
||||
>
|
||||
Continue with GitHub
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue