Harden coding A2A workspace bootstrap
This commit is contained in:
@@ -14,10 +14,10 @@ from typing import Any, AsyncGenerator, Optional
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from coding_a2a_agent.common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||
from coding_a2a_agent.agent import CodingA2ARuntime
|
||||
from coding_a2a_agent.agent import CodingA2ARuntime, CodingRuntimeError
|
||||
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, LiteLLMConfig
|
||||
|
||||
|
||||
@@ -138,6 +138,27 @@ class CodingA2AServer:
|
||||
|
||||
return None
|
||||
|
||||
def _jsonrpc_error(
|
||||
self,
|
||||
request_id: str,
|
||||
code: int,
|
||||
message: str,
|
||||
*,
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
status_code: int = 200,
|
||||
) -> JSONResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if data is not None:
|
||||
payload["error"]["data"] = data
|
||||
return JSONResponse(payload, status_code=status_code)
|
||||
|
||||
def _create_app(self) -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -213,10 +234,21 @@ class CodingA2AServer:
|
||||
auth_error = self._authorize_agent_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
rpc_request = A2ARequest(**(await request.json()))
|
||||
body = await request.json()
|
||||
try:
|
||||
rpc_request = A2ARequest(**body)
|
||||
except Exception as exc:
|
||||
return self._jsonrpc_error(
|
||||
str(body.get("id", "unknown")),
|
||||
-32600,
|
||||
"Invalid Request",
|
||||
data={"detail": str(exc)},
|
||||
)
|
||||
if rpc_request.method != "message/send":
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": rpc_request.id, "error": {"code": -32601, "message": f"Method not found: {rpc_request.method}"}}
|
||||
return self._jsonrpc_error(
|
||||
rpc_request.id,
|
||||
-32601,
|
||||
f"Method not found: {rpc_request.method}",
|
||||
)
|
||||
return await self._handle_message_send(rpc_request)
|
||||
|
||||
@@ -225,7 +257,22 @@ class CodingA2AServer:
|
||||
auth_error = self._authorize_agent_request(request)
|
||||
if auth_error:
|
||||
return auth_error
|
||||
rpc_request = A2ARequest(**(await request.json()))
|
||||
body = await request.json()
|
||||
try:
|
||||
rpc_request = A2ARequest(**body)
|
||||
except Exception as exc:
|
||||
return self._jsonrpc_error(
|
||||
str(body.get("id", "unknown")),
|
||||
-32600,
|
||||
"Invalid Request",
|
||||
data={"detail": str(exc)},
|
||||
)
|
||||
if rpc_request.method != "message/stream":
|
||||
return self._jsonrpc_error(
|
||||
rpc_request.id,
|
||||
-32601,
|
||||
f"Method not found: {rpc_request.method}",
|
||||
)
|
||||
return await self._handle_message_stream(rpc_request)
|
||||
|
||||
@app.get("/tasks/{task_id}")
|
||||
@@ -238,11 +285,15 @@ class CodingA2AServer:
|
||||
return self.tasks[task_id].model_dump()
|
||||
|
||||
async def _handle_message_send(self, request: A2ARequest) -> JSONResponse:
|
||||
task: Optional[A2ATask] = None
|
||||
try:
|
||||
params = request.params or {}
|
||||
message_text = self._extract_message_text(params.get("message", {}))
|
||||
if not message_text:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": request.id, "error": {"code": -32602, "message": "Invalid params: no text content found"}}
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32602,
|
||||
"Invalid params: no text content found",
|
||||
)
|
||||
|
||||
task_id = uuid.uuid4().hex
|
||||
@@ -254,7 +305,6 @@ class CodingA2AServer:
|
||||
api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key
|
||||
model = params.get("model") or runtime_config.model or self.llm_config.model
|
||||
|
||||
try:
|
||||
with CallbackContextManager(
|
||||
handler=self.callback_handler,
|
||||
user_id=params.get("user_id") or USER_ID,
|
||||
@@ -284,23 +334,57 @@ class CodingA2AServer:
|
||||
"role_name": self.metadata.role_name,
|
||||
"instruction_source": self.metadata.instruction_source,
|
||||
}
|
||||
self.tasks[task_id] = task
|
||||
self.tasks[task.id] = task
|
||||
return JSONResponse({"jsonrpc": "2.0", "id": request.id, "result": task.model_dump()})
|
||||
except Exception as exc:
|
||||
except ValidationError as exc:
|
||||
if task:
|
||||
task.status = A2ATaskStatus(state="failed", message="Invalid configuration")
|
||||
self.tasks[task.id] = task
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32602,
|
||||
"Invalid params: configuration validation failed",
|
||||
data={"stage": "configuration_validation", "errors": exc.errors()},
|
||||
)
|
||||
except CodingRuntimeError as exc:
|
||||
if task:
|
||||
task.status = A2ATaskStatus(state="failed", message=str(exc))
|
||||
self.tasks[task_id] = task
|
||||
return JSONResponse(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.id,
|
||||
"error": {"code": -32000, "message": f"Agent error: {exc}"},
|
||||
}
|
||||
self.tasks[task.id] = task
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32010,
|
||||
str(exc),
|
||||
data=exc.to_payload(),
|
||||
)
|
||||
except Exception as exc:
|
||||
if task:
|
||||
task.status = A2ATaskStatus(state="failed", message=str(exc))
|
||||
self.tasks[task.id] = task
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32000,
|
||||
f"Agent error: {exc}",
|
||||
data={"stage": "run_task"},
|
||||
)
|
||||
|
||||
async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse:
|
||||
async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse | JSONResponse:
|
||||
params = request.params or {}
|
||||
message_text = self._extract_message_text(params.get("message", {}))
|
||||
if not message_text:
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32602,
|
||||
"Invalid params: no text content found",
|
||||
)
|
||||
try:
|
||||
runtime_config = CodingRequestConfig.model_validate(params.get("configuration") or {})
|
||||
except ValidationError as exc:
|
||||
return self._jsonrpc_error(
|
||||
request.id,
|
||||
-32602,
|
||||
"Invalid params: configuration validation failed",
|
||||
data={"stage": "configuration_validation", "errors": exc.errors()},
|
||||
)
|
||||
api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key
|
||||
model = params.get("model") or runtime_config.model or self.llm_config.model
|
||||
task_id = uuid.uuid4().hex
|
||||
@@ -330,6 +414,17 @@ class CodingA2AServer:
|
||||
yield f"data: {json.dumps(artifact_event, ensure_ascii=False)}\n\n"
|
||||
finish_event = {"kind": "task-complete", "taskId": task_id, "contextId": context_id}
|
||||
yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
|
||||
except CodingRuntimeError as exc:
|
||||
error_event = {
|
||||
"kind": "task-failed",
|
||||
"taskId": task_id,
|
||||
"contextId": context_id,
|
||||
"data": {
|
||||
"message": str(exc),
|
||||
**exc.to_payload(),
|
||||
},
|
||||
}
|
||||
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
error_event = {
|
||||
"kind": "task-failed",
|
||||
|
||||
@@ -51,6 +51,35 @@ class CodingRunResult(BaseModel):
|
||||
resources_used: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CodingRuntimeError(Exception):
|
||||
"""Structured runtime error that can be returned through JSON-RPC."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "runtime_error",
|
||||
stage: str = "runtime",
|
||||
data: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.stage = stage
|
||||
self.data = data or {}
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
payload = {
|
||||
"code": self.code,
|
||||
"stage": self.stage,
|
||||
}
|
||||
payload.update(self.data)
|
||||
return payload
|
||||
|
||||
|
||||
class WorkspacePreparationError(CodingRuntimeError):
|
||||
"""Raised when the remote workspace cannot be prepared safely."""
|
||||
|
||||
|
||||
class CodingA2ARuntime:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -279,6 +308,7 @@ class CodingA2ARuntime:
|
||||
|
||||
agent = self._build_agent(llm_config)
|
||||
deps = CodingRunContext(workspace=request_config.workspace, resources=request_config.resources)
|
||||
self._prepare_workspace(request_config, deps)
|
||||
|
||||
initial_prompt = self._build_initial_prompt(prompt, request_config)
|
||||
|
||||
@@ -309,6 +339,121 @@ class CodingA2ARuntime:
|
||||
os.environ["OPENAI_API_KEY"] = llm_config.api_key
|
||||
os.environ["OPENAI_BASE_URL"] = llm_config.base_url
|
||||
|
||||
def _prepare_workspace(
|
||||
self,
|
||||
request_config: CodingRequestConfig,
|
||||
deps: CodingRunContext,
|
||||
) -> None:
|
||||
workspace_root = Path(request_config.workspace.root_dir).expanduser().resolve()
|
||||
request_config.workspace.root_dir = str(workspace_root)
|
||||
deps.workspace.root_dir = str(workspace_root)
|
||||
|
||||
if workspace_root.exists() and not workspace_root.is_dir():
|
||||
raise WorkspacePreparationError(
|
||||
"workspace root is not a directory",
|
||||
code="workspace_not_directory",
|
||||
stage="workspace_prepare",
|
||||
data={"workspace_root": str(workspace_root)},
|
||||
)
|
||||
|
||||
git = request_config.resources.git
|
||||
if git and git.repo_url:
|
||||
self._prepare_git_workspace(workspace_root, git, deps)
|
||||
return
|
||||
|
||||
workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
deps.tool_log.append(
|
||||
ToolEvent(
|
||||
tool="workspace_prepare",
|
||||
payload={"workspace_root": str(workspace_root), "mode": "empty_workspace"},
|
||||
)
|
||||
)
|
||||
|
||||
def _prepare_git_workspace(
|
||||
self,
|
||||
workspace_root: Path,
|
||||
git: GitResourceConfig,
|
||||
deps: CodingRunContext,
|
||||
) -> None:
|
||||
if (workspace_root / ".git").exists():
|
||||
deps.tool_log.append(
|
||||
ToolEvent(
|
||||
tool="git_prepare_workspace",
|
||||
payload={
|
||||
"workspace_root": str(workspace_root),
|
||||
"repo_url": git.repo_url,
|
||||
"mode": "existing_repository",
|
||||
},
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
workspace_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
if workspace_root.exists():
|
||||
if not workspace_root.is_dir():
|
||||
raise WorkspacePreparationError(
|
||||
"workspace root is not a directory",
|
||||
code="workspace_not_directory",
|
||||
stage="git_prepare_workspace",
|
||||
data={"workspace_root": str(workspace_root), "repo_url": git.repo_url},
|
||||
)
|
||||
if any(workspace_root.iterdir()):
|
||||
raise WorkspacePreparationError(
|
||||
"workspace already exists but is not a git repository",
|
||||
code="workspace_not_git_repository",
|
||||
stage="git_prepare_workspace",
|
||||
data={"workspace_root": str(workspace_root), "repo_url": git.repo_url},
|
||||
)
|
||||
workspace_root.rmdir()
|
||||
|
||||
auth_url = build_authenticated_repo_url(
|
||||
git.repo_url,
|
||||
username=git.username,
|
||||
password=git.password,
|
||||
token=git.token,
|
||||
)
|
||||
branch = git.default_branch or "main"
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "clone", "--branch", branch, auth_url, workspace_root.name],
|
||||
cwd=str(workspace_root.parent),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise WorkspacePreparationError(
|
||||
"git workspace preparation timed out",
|
||||
code="git_prepare_timeout",
|
||||
stage="git_prepare_workspace",
|
||||
data={
|
||||
"workspace_root": str(workspace_root),
|
||||
"repo_url": git.repo_url,
|
||||
"branch": branch,
|
||||
"timeout_seconds": exc.timeout,
|
||||
},
|
||||
) from exc
|
||||
|
||||
payload = {
|
||||
"workspace_root": str(workspace_root),
|
||||
"repo_url": git.repo_url,
|
||||
"branch": branch,
|
||||
"returncode": completed.returncode,
|
||||
}
|
||||
deps.tool_log.append(ToolEvent(tool="git_prepare_workspace", payload=payload))
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise WorkspacePreparationError(
|
||||
"git workspace preparation failed",
|
||||
code="git_prepare_failed",
|
||||
stage="git_prepare_workspace",
|
||||
data={
|
||||
**payload,
|
||||
"stdout": completed.stdout.strip(),
|
||||
"stderr": completed.stderr.strip(),
|
||||
},
|
||||
)
|
||||
|
||||
def _build_initial_prompt(self, prompt: str, request_config: CodingRequestConfig) -> str:
|
||||
workspace = request_config.workspace
|
||||
parts = [
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import unittest
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, GitProvider
|
||||
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, GitProvider, LiteLLMConfig
|
||||
from coding_a2a_agent.resources import (
|
||||
build_authenticated_repo_url,
|
||||
detect_git_provider,
|
||||
@@ -14,8 +17,29 @@ from coding_a2a_agent.resources import (
|
||||
safe_workspace_path,
|
||||
)
|
||||
|
||||
RUNTIME_IMPORT_ERROR = None
|
||||
try:
|
||||
from coding_a2a_agent.a2a_server import A2ARequest, CodingA2AServer
|
||||
from coding_a2a_agent.agent import CodingA2ARuntime, CodingRunContext, WorkspacePreparationError
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - depends on optional runtime deps in local env
|
||||
RUNTIME_IMPORT_ERROR = exc
|
||||
A2ARequest = None
|
||||
CodingA2AServer = None
|
||||
CodingA2ARuntime = None
|
||||
CodingRunContext = None
|
||||
WorkspacePreparationError = None
|
||||
|
||||
|
||||
class CodingA2AHelperTests(unittest.TestCase):
|
||||
def _make_runtime(self) -> CodingA2ARuntime:
|
||||
if RUNTIME_IMPORT_ERROR is not None:
|
||||
self.skipTest(f"runtime dependencies unavailable: {RUNTIME_IMPORT_ERROR}")
|
||||
with patch.object(CodingA2ARuntime, "_build_agent", return_value=MagicMock()):
|
||||
return CodingA2ARuntime(
|
||||
LiteLLMConfig(api_key="test-key", model="gpt-4o-mini"),
|
||||
AgentMetadata(),
|
||||
)
|
||||
|
||||
def test_detect_git_provider(self):
|
||||
self.assertEqual(detect_git_provider("https://github.com/org/repo.git"), GitProvider.github)
|
||||
self.assertEqual(detect_git_provider("https://gitlab.com/org/repo.git"), GitProvider.gitlab)
|
||||
@@ -124,6 +148,94 @@ class CodingA2AHelperTests(unittest.TestCase):
|
||||
self.assertEqual(cfg.resources.azure_blob.container_name, "request-container")
|
||||
self.assertEqual(cfg.resources.azure_blob.connection_string, "request-conn")
|
||||
|
||||
def test_prepare_workspace_creates_missing_root_without_git(self):
|
||||
runtime = self._make_runtime()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace_root = Path(tmp_dir) / "workspace"
|
||||
cfg = CodingRequestConfig.model_validate({"workspace": {"root_dir": str(workspace_root)}})
|
||||
deps = CodingRunContext(workspace=cfg.workspace, resources=cfg.resources)
|
||||
runtime._prepare_workspace(cfg, deps)
|
||||
|
||||
self.assertTrue(workspace_root.exists())
|
||||
self.assertTrue(workspace_root.is_dir())
|
||||
|
||||
def test_prepare_workspace_raises_structured_error_for_git_clone_failure(self):
|
||||
runtime = self._make_runtime()
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
workspace_root = Path(tmp_dir) / "repo"
|
||||
cfg = CodingRequestConfig.model_validate(
|
||||
{
|
||||
"workspace": {"root_dir": str(workspace_root)},
|
||||
"resources": {
|
||||
"git": {
|
||||
"repo_url": "https://github.com/acme/demo.git",
|
||||
"default_branch": "main",
|
||||
"token": "abc",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
deps = CodingRunContext(workspace=cfg.workspace, resources=cfg.resources)
|
||||
failed = subprocess.CompletedProcess(
|
||||
args=["git", "clone"],
|
||||
returncode=128,
|
||||
stdout="",
|
||||
stderr="fatal: repository not found",
|
||||
)
|
||||
with patch("coding_a2a_agent.agent.subprocess.run", return_value=failed):
|
||||
with self.assertRaises(WorkspacePreparationError) as ctx:
|
||||
runtime._prepare_workspace(cfg, deps)
|
||||
|
||||
self.assertEqual(ctx.exception.code, "git_prepare_failed")
|
||||
self.assertEqual(ctx.exception.stage, "git_prepare_workspace")
|
||||
self.assertIn("fatal: repository not found", ctx.exception.to_payload()["stderr"])
|
||||
|
||||
def test_message_send_returns_structured_jsonrpc_error_for_workspace_failures(self):
|
||||
if RUNTIME_IMPORT_ERROR is not None:
|
||||
self.skipTest(f"runtime dependencies unavailable: {RUNTIME_IMPORT_ERROR}")
|
||||
fake_runtime = MagicMock()
|
||||
fake_runtime.run_task = AsyncMock(
|
||||
side_effect=WorkspacePreparationError(
|
||||
"git workspace preparation failed",
|
||||
code="git_prepare_failed",
|
||||
stage="git_prepare_workspace",
|
||||
data={"repo_url": "https://github.com/acme/demo.git"},
|
||||
)
|
||||
)
|
||||
|
||||
class DummyContextManager:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.tool_log = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def add_tool(self, _tool: str):
|
||||
return None
|
||||
|
||||
with patch("coding_a2a_agent.a2a_server.CodingA2ARuntime", return_value=fake_runtime), patch(
|
||||
"coding_a2a_agent.a2a_server.AgentCallbackHandler", return_value=MagicMock()
|
||||
), patch("coding_a2a_agent.a2a_server.CallbackContextManager", DummyContextManager):
|
||||
server = CodingA2AServer(api_key="test-key", model="gpt-4o-mini")
|
||||
|
||||
request = A2ARequest(
|
||||
id="req-1",
|
||||
method="message/send",
|
||||
params={
|
||||
"message": {"role": "user", "parts": [{"kind": "text", "text": "hello"}]},
|
||||
"configuration": {"workspace": {"root_dir": "/tmp/demo"}},
|
||||
},
|
||||
)
|
||||
response = asyncio.run(server._handle_message_send(request))
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
|
||||
self.assertEqual(payload["error"]["code"], -32010)
|
||||
self.assertEqual(payload["error"]["data"]["code"], "git_prepare_failed")
|
||||
self.assertEqual(payload["error"]["data"]["stage"], "git_prepare_workspace")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -292,6 +292,8 @@ GET /.well-known/agent.json
|
||||
- `X-Agent-Access-Token` 负责“谁有权访问这个 agent”
|
||||
- A2A body 里的 `api_key` 负责“本次请求用谁的模型额度”
|
||||
- 两者职责分离,不互相替代
|
||||
- 未绑定代码仓库时,runtime 会自动创建空的 `workspace.root_dir`,不再因为远端缺少 `/workspace` 而直接崩溃
|
||||
- 绑定 git 资源时,runtime 会先自动准备工作区;如果 clone / branch / repo 本身失败,会返回结构化 JSON-RPC error,而不是直接冒成 uvicorn 500
|
||||
|
||||
### 7.1 同步调用
|
||||
|
||||
|
||||
@@ -265,3 +265,33 @@ X-Agent-Access-Token == AGENT_ACCESS_TOKEN
|
||||
2. 排障时再看 `GET /agents/{agent_name}/status`。
|
||||
3. 客户端直连前,先确认 HM 已拿到 `subdomain` 和 `access_token`。
|
||||
4. 若模板 Agent 对外公网暴露,建议始终注入 `AGENT_ACCESS_TOKEN`,不要依赖兼容放行。
|
||||
|
||||
## 8. 运行时健壮性说明
|
||||
|
||||
当前 `coding_a2a_agent` 已补充以下健壮性行为:
|
||||
|
||||
- 未绑定 git 资源时,如果远端工作区目录不存在,runtime 会先创建空的 `workspace.root_dir`
|
||||
- 绑定 git 资源时,runtime 会在真正执行任务前自动准备工作区
|
||||
- 如果 git clone、目标目录状态或分支切换失败,`POST /message/send` 会返回结构化 JSON-RPC error,而不是直接返回 uvicorn 500
|
||||
|
||||
典型错误形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "req-1",
|
||||
"error": {
|
||||
"code": -32010,
|
||||
"message": "git workspace preparation failed",
|
||||
"data": {
|
||||
"code": "git_prepare_failed",
|
||||
"stage": "git_prepare_workspace",
|
||||
"repo_url": "https://example.com/acme/demo.git",
|
||||
"branch": "main",
|
||||
"workspace_root": "/workspace",
|
||||
"returncode": 128,
|
||||
"stderr": "fatal: repository not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user