mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-19 02:54:28 -05:00
Compare commits
4 Commits
fix/flaky-
...
feat/fix-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
826ab6ad2a | ||
|
|
ba13606e3d | ||
|
|
c12894698e | ||
|
|
2f37aeec12 |
@@ -50,6 +50,7 @@ from backend.copilot.tools.models import (
|
|||||||
OperationPendingResponse,
|
OperationPendingResponse,
|
||||||
OperationStartedResponse,
|
OperationStartedResponse,
|
||||||
SetupRequirementsResponse,
|
SetupRequirementsResponse,
|
||||||
|
SuggestedGoalResponse,
|
||||||
UnderstandingUpdatedResponse,
|
UnderstandingUpdatedResponse,
|
||||||
)
|
)
|
||||||
from backend.copilot.tracking import track_user_message
|
from backend.copilot.tracking import track_user_message
|
||||||
@@ -984,6 +985,7 @@ ToolResponseUnion = (
|
|||||||
| AgentPreviewResponse
|
| AgentPreviewResponse
|
||||||
| AgentSavedResponse
|
| AgentSavedResponse
|
||||||
| ClarificationNeededResponse
|
| ClarificationNeededResponse
|
||||||
|
| SuggestedGoalResponse
|
||||||
| BlockListResponse
|
| BlockListResponse
|
||||||
| BlockDetailsResponse
|
| BlockDetailsResponse
|
||||||
| BlockOutputResponse
|
| BlockOutputResponse
|
||||||
|
|||||||
@@ -164,23 +164,21 @@ class CoPilotExecutor(AppProcess):
|
|||||||
self._cancel_thread, self.cancel_client, "[cleanup][cancel]"
|
self._cancel_thread, self.cancel_client, "[cleanup][cancel]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clean up worker threads (closes per-loop workspace storage sessions)
|
# Shutdown executor
|
||||||
if self._executor:
|
if self._executor:
|
||||||
from .processor import cleanup_worker
|
|
||||||
|
|
||||||
logger.info(f"[cleanup {pid}] Cleaning up workers...")
|
|
||||||
futures = []
|
|
||||||
for _ in range(self._executor._max_workers):
|
|
||||||
futures.append(self._executor.submit(cleanup_worker))
|
|
||||||
for f in futures:
|
|
||||||
try:
|
|
||||||
f.result(timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[cleanup {pid}] Worker cleanup error: {e}")
|
|
||||||
|
|
||||||
logger.info(f"[cleanup {pid}] Shutting down executor...")
|
logger.info(f"[cleanup {pid}] Shutting down executor...")
|
||||||
self._executor.shutdown(wait=False)
|
self._executor.shutdown(wait=False)
|
||||||
|
|
||||||
|
# Close async resources (workspace storage aiohttp session, etc.)
|
||||||
|
try:
|
||||||
|
from backend.util.workspace_storage import shutdown_workspace_storage
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
loop.run_until_complete(shutdown_workspace_storage())
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[cleanup {pid}] Error closing workspace storage: {e}")
|
||||||
|
|
||||||
# Release any remaining locks
|
# Release any remaining locks
|
||||||
for task_id, lock in list(self._task_locks.items()):
|
for task_id, lock in list(self._task_locks.items()):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -60,18 +60,6 @@ def init_worker():
|
|||||||
_tls.processor.on_executor_start()
|
_tls.processor.on_executor_start()
|
||||||
|
|
||||||
|
|
||||||
def cleanup_worker():
|
|
||||||
"""Clean up the processor for the current worker thread.
|
|
||||||
|
|
||||||
Should be called before the worker thread's event loop is destroyed so
|
|
||||||
that event-loop-bound resources (e.g. ``aiohttp.ClientSession``) are
|
|
||||||
closed on the correct loop.
|
|
||||||
"""
|
|
||||||
processor: CoPilotProcessor | None = getattr(_tls, "processor", None)
|
|
||||||
if processor is not None:
|
|
||||||
processor.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
# ============ Processor Class ============ #
|
# ============ Processor Class ============ #
|
||||||
|
|
||||||
|
|
||||||
@@ -110,28 +98,6 @@ class CoPilotProcessor:
|
|||||||
|
|
||||||
logger.info(f"[CoPilotExecutor] Worker {self.tid} started")
|
logger.info(f"[CoPilotExecutor] Worker {self.tid} started")
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Clean up event-loop-bound resources before the loop is destroyed.
|
|
||||||
|
|
||||||
Shuts down the workspace storage instance that belongs to this
|
|
||||||
worker's event loop, ensuring ``aiohttp.ClientSession.close()``
|
|
||||||
runs on the same loop that created the session.
|
|
||||||
"""
|
|
||||||
from backend.util.workspace_storage import shutdown_workspace_storage
|
|
||||||
|
|
||||||
try:
|
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
shutdown_workspace_storage(), self.execution_loop
|
|
||||||
)
|
|
||||||
future.result(timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[CoPilotExecutor] Worker {self.tid} cleanup error: {e}")
|
|
||||||
|
|
||||||
# Stop the event loop
|
|
||||||
self.execution_loop.call_soon_threadsafe(self.execution_loop.stop)
|
|
||||||
self.execution_thread.join(timeout=5)
|
|
||||||
logger.info(f"[CoPilotExecutor] Worker {self.tid} cleaned up")
|
|
||||||
|
|
||||||
@error_logged(swallow=False)
|
@error_logged(swallow=False)
|
||||||
def execute(
|
def execute(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -693,15 +693,11 @@ async def stream_chat_completion_sdk(
|
|||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
raw_transcript = read_transcript_file(captured_transcript.path)
|
raw_transcript = read_transcript_file(captured_transcript.path)
|
||||||
if raw_transcript:
|
if raw_transcript:
|
||||||
try:
|
task = asyncio.create_task(
|
||||||
async with asyncio.timeout(30):
|
_upload_transcript_bg(user_id, session_id, raw_transcript)
|
||||||
await _upload_transcript_bg(
|
)
|
||||||
user_id, session_id, raw_transcript
|
_background_tasks.add(task)
|
||||||
)
|
task.add_done_callback(_background_tasks.discard)
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
f"[SDK] Transcript upload timed out for {session_id}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.debug("[SDK] Stop hook fired but transcript not usable")
|
logger.debug("[SDK] Stop hook fired but transcript not usable")
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ Adapt flexibly to the conversation context. Not every interaction requires all s
|
|||||||
- Find reusable components with `find_block`
|
- Find reusable components with `find_block`
|
||||||
- Create custom solutions with `create_agent` if nothing suitable exists
|
- Create custom solutions with `create_agent` if nothing suitable exists
|
||||||
- Modify existing library agents with `edit_agent`
|
- Modify existing library agents with `edit_agent`
|
||||||
|
- **When `create_agent` returns `suggested_goal`**: Present the suggestion to the user and ask "Would you like me to proceed with this refined goal?" If they accept, call `create_agent` again with the suggested goal.
|
||||||
|
- **When `create_agent` returns `clarifying_questions`**: After the user answers, call `create_agent` again with the original description AND the answers in the `context` parameter.
|
||||||
|
|
||||||
5. **Execute**: Run automations immediately, schedule them, or set up webhooks using `run_agent`. Test specific components with `run_block`.
|
5. **Execute**: Run automations immediately, schedule them, or set up webhooks using `run_agent`. Test specific components with `run_block`.
|
||||||
|
|
||||||
@@ -164,6 +166,11 @@ Adapt flexibly to the conversation context. Not every interaction requires all s
|
|||||||
- Use `add_understanding` to capture valuable business context
|
- Use `add_understanding` to capture valuable business context
|
||||||
- When tool calls fail, try alternative approaches
|
- When tool calls fail, try alternative approaches
|
||||||
|
|
||||||
|
**Handle Feedback Loops:**
|
||||||
|
- When a tool returns a suggested alternative (like a refined goal), present it clearly and ask the user for confirmation before proceeding
|
||||||
|
- When clarifying questions are answered, immediately re-call the tool with the accumulated context
|
||||||
|
- Don't ask redundant questions if the user has already provided context in the conversation
|
||||||
|
|
||||||
## CRITICAL REMINDER
|
## CRITICAL REMINDER
|
||||||
|
|
||||||
You are NOT a chatbot. You are NOT documentation. You are a partner who helps busy business owners get value quickly by showing proof through working automations. Bias toward action over explanation."""
|
You are NOT a chatbot. You are NOT documentation. You are a partner who helps busy business owners get value quickly by showing proof through working automations. Bias toward action over explanation."""
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .models import (
|
|||||||
ClarificationNeededResponse,
|
ClarificationNeededResponse,
|
||||||
ClarifyingQuestion,
|
ClarifyingQuestion,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
SuggestedGoalResponse,
|
||||||
ToolResponseBase,
|
ToolResponseBase,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,26 +187,28 @@ class CreateAgentTool(BaseTool):
|
|||||||
if decomposition_result.get("type") == "unachievable_goal":
|
if decomposition_result.get("type") == "unachievable_goal":
|
||||||
suggested = decomposition_result.get("suggested_goal", "")
|
suggested = decomposition_result.get("suggested_goal", "")
|
||||||
reason = decomposition_result.get("reason", "")
|
reason = decomposition_result.get("reason", "")
|
||||||
return ErrorResponse(
|
return SuggestedGoalResponse(
|
||||||
message=(
|
message=(
|
||||||
f"This goal cannot be accomplished with the available blocks. "
|
f"This goal cannot be accomplished with the available blocks. {reason}"
|
||||||
f"{reason} "
|
|
||||||
f"Suggestion: {suggested}"
|
|
||||||
),
|
),
|
||||||
error="unachievable_goal",
|
suggested_goal=suggested,
|
||||||
details={"suggested_goal": suggested, "reason": reason},
|
reason=reason,
|
||||||
|
original_goal=description,
|
||||||
|
goal_type="unachievable",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if decomposition_result.get("type") == "vague_goal":
|
if decomposition_result.get("type") == "vague_goal":
|
||||||
suggested = decomposition_result.get("suggested_goal", "")
|
suggested = decomposition_result.get("suggested_goal", "")
|
||||||
return ErrorResponse(
|
reason = decomposition_result.get(
|
||||||
message=(
|
"reason", "The goal needs more specific details"
|
||||||
f"The goal is too vague to create a specific workflow. "
|
)
|
||||||
f"Suggestion: {suggested}"
|
return SuggestedGoalResponse(
|
||||||
),
|
message="The goal is too vague to create a specific workflow.",
|
||||||
error="vague_goal",
|
suggested_goal=suggested,
|
||||||
details={"suggested_goal": suggested},
|
reason=reason,
|
||||||
|
original_goal=description,
|
||||||
|
goal_type="vague",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Tests for CreateAgentTool response types."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.copilot.tools.create_agent import CreateAgentTool
|
||||||
|
from backend.copilot.tools.models import (
|
||||||
|
ClarificationNeededResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
SuggestedGoalResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._test_data import make_session
|
||||||
|
|
||||||
|
_TEST_USER_ID = "test-user-create-agent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tool():
|
||||||
|
return CreateAgentTool()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
return make_session(_TEST_USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_description_returns_error(tool, session):
|
||||||
|
"""Missing description returns ErrorResponse."""
|
||||||
|
result = await tool._execute(user_id=_TEST_USER_ID, session=session, description="")
|
||||||
|
assert isinstance(result, ErrorResponse)
|
||||||
|
assert result.error == "Missing description parameter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vague_goal_returns_suggested_goal_response(tool, session):
|
||||||
|
"""vague_goal decomposition result returns SuggestedGoalResponse, not ErrorResponse."""
|
||||||
|
vague_result = {
|
||||||
|
"type": "vague_goal",
|
||||||
|
"suggested_goal": "Monitor Twitter mentions for a specific keyword and send a daily digest email",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.decompose_goal",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=vague_result,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID,
|
||||||
|
session=session,
|
||||||
|
description="monitor social media",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, SuggestedGoalResponse)
|
||||||
|
assert result.goal_type == "vague"
|
||||||
|
assert result.suggested_goal == vague_result["suggested_goal"]
|
||||||
|
assert result.original_goal == "monitor social media"
|
||||||
|
assert result.reason == "The goal needs more specific details"
|
||||||
|
assert not isinstance(result, ErrorResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unachievable_goal_returns_suggested_goal_response(tool, session):
|
||||||
|
"""unachievable_goal decomposition result returns SuggestedGoalResponse, not ErrorResponse."""
|
||||||
|
unachievable_result = {
|
||||||
|
"type": "unachievable_goal",
|
||||||
|
"suggested_goal": "Summarize the latest news articles on a topic and send them by email",
|
||||||
|
"reason": "There are no blocks for mind-reading.",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.decompose_goal",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=unachievable_result,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID,
|
||||||
|
session=session,
|
||||||
|
description="read my mind",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, SuggestedGoalResponse)
|
||||||
|
assert result.goal_type == "unachievable"
|
||||||
|
assert result.suggested_goal == unachievable_result["suggested_goal"]
|
||||||
|
assert result.original_goal == "read my mind"
|
||||||
|
assert result.reason == unachievable_result["reason"]
|
||||||
|
assert not isinstance(result, ErrorResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clarifying_questions_returns_clarification_needed_response(
|
||||||
|
tool, session
|
||||||
|
):
|
||||||
|
"""clarifying_questions decomposition result returns ClarificationNeededResponse."""
|
||||||
|
clarifying_result = {
|
||||||
|
"type": "clarifying_questions",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question": "What platform should be monitored?",
|
||||||
|
"keyword": "platform",
|
||||||
|
"example": "Twitter, Reddit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"backend.copilot.tools.create_agent.decompose_goal",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=clarifying_result,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID,
|
||||||
|
session=session,
|
||||||
|
description="monitor social media and alert me",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ClarificationNeededResponse)
|
||||||
|
assert len(result.questions) == 1
|
||||||
|
assert result.questions[0].keyword == "platform"
|
||||||
@@ -33,6 +33,7 @@ query SearchFeatureRequests($term: String!, $filter: IssueFilter, $first: Int) {
|
|||||||
id
|
id
|
||||||
identifier
|
identifier
|
||||||
title
|
title
|
||||||
|
description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +205,7 @@ class SearchFeatureRequestsTool(BaseTool):
|
|||||||
id=node["id"],
|
id=node["id"],
|
||||||
identifier=node["identifier"],
|
identifier=node["identifier"],
|
||||||
title=node["title"],
|
title=node["title"],
|
||||||
|
description=node.get("description"),
|
||||||
)
|
)
|
||||||
for node in nodes
|
for node in nodes
|
||||||
]
|
]
|
||||||
@@ -237,11 +239,7 @@ class CreateFeatureRequestTool(BaseTool):
|
|||||||
"Create a new feature request or add a customer need to an existing one. "
|
"Create a new feature request or add a customer need to an existing one. "
|
||||||
"Always search first with search_feature_requests to avoid duplicates. "
|
"Always search first with search_feature_requests to avoid duplicates. "
|
||||||
"If a matching request exists, pass its ID as existing_issue_id to add "
|
"If a matching request exists, pass its ID as existing_issue_id to add "
|
||||||
"the user's need to it instead of creating a duplicate. "
|
"the user's need to it instead of creating a duplicate."
|
||||||
"IMPORTANT: Never include personally identifiable information (PII) in "
|
|
||||||
"the title or description — no names, emails, phone numbers, company "
|
|
||||||
"names, or other identifying details. Write titles and descriptions in "
|
|
||||||
"generic, feature-focused language."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -251,20 +249,11 @@ class CreateFeatureRequestTool(BaseTool):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "Title for the feature request.",
|
||||||
"Title for the feature request. Must be generic and "
|
|
||||||
"feature-focused — do not include any user names, emails, "
|
|
||||||
"company names, or other PII."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "Detailed description of what the user wants and why.",
|
||||||
"Detailed description of what the user wants and why. "
|
|
||||||
"Must not contain any personally identifiable information "
|
|
||||||
"(PII) — describe the feature need generically without "
|
|
||||||
"referencing specific users, companies, or contact details."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"existing_issue_id": {
|
"existing_issue_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -117,11 +117,13 @@ class TestSearchFeatureRequestsTool:
|
|||||||
"id": "id-1",
|
"id": "id-1",
|
||||||
"identifier": "FR-1",
|
"identifier": "FR-1",
|
||||||
"title": "Dark mode",
|
"title": "Dark mode",
|
||||||
|
"description": "Add dark mode support",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "id-2",
|
"id": "id-2",
|
||||||
"identifier": "FR-2",
|
"identifier": "FR-2",
|
||||||
"title": "Dark theme",
|
"title": "Dark theme",
|
||||||
|
"description": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
patcher, _ = _mock_linear_config(query_return=_search_response(nodes))
|
patcher, _ = _mock_linear_config(query_return=_search_response(nodes))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -50,6 +50,8 @@ class ResponseType(str, Enum):
|
|||||||
# Feature request types
|
# Feature request types
|
||||||
FEATURE_REQUEST_SEARCH = "feature_request_search"
|
FEATURE_REQUEST_SEARCH = "feature_request_search"
|
||||||
FEATURE_REQUEST_CREATED = "feature_request_created"
|
FEATURE_REQUEST_CREATED = "feature_request_created"
|
||||||
|
# Goal refinement
|
||||||
|
SUGGESTED_GOAL = "suggested_goal"
|
||||||
|
|
||||||
|
|
||||||
# Base response model
|
# Base response model
|
||||||
@@ -296,6 +298,22 @@ class ClarificationNeededResponse(ToolResponseBase):
|
|||||||
questions: list[ClarifyingQuestion] = Field(default_factory=list)
|
questions: list[ClarifyingQuestion] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestedGoalResponse(ToolResponseBase):
|
||||||
|
"""Response when the goal needs refinement with a suggested alternative."""
|
||||||
|
|
||||||
|
type: ResponseType = ResponseType.SUGGESTED_GOAL
|
||||||
|
suggested_goal: str = Field(description="The suggested alternative goal")
|
||||||
|
reason: str = Field(
|
||||||
|
default="", description="Why the original goal needs refinement"
|
||||||
|
)
|
||||||
|
original_goal: str = Field(
|
||||||
|
default="", description="The user's original goal for context"
|
||||||
|
)
|
||||||
|
goal_type: Literal["vague", "unachievable"] = Field(
|
||||||
|
default="vague", description="Type: 'vague' or 'unachievable'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Documentation search models
|
# Documentation search models
|
||||||
class DocSearchResult(BaseModel):
|
class DocSearchResult(BaseModel):
|
||||||
"""A single documentation search result."""
|
"""A single documentation search result."""
|
||||||
@@ -486,6 +504,7 @@ class FeatureRequestInfo(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
identifier: str
|
identifier: str
|
||||||
title: str
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FeatureRequestSearchResponse(ToolResponseBase):
|
class FeatureRequestSearchResponse(ToolResponseBase):
|
||||||
|
|||||||
@@ -93,15 +93,7 @@ from backend.data.user import (
|
|||||||
get_user_notification_preference,
|
get_user_notification_preference,
|
||||||
update_user_integrations,
|
update_user_integrations,
|
||||||
)
|
)
|
||||||
from backend.data.workspace import (
|
from backend.data.workspace import get_or_create_workspace
|
||||||
count_workspace_files,
|
|
||||||
create_workspace_file,
|
|
||||||
get_or_create_workspace,
|
|
||||||
get_workspace_file,
|
|
||||||
get_workspace_file_by_path,
|
|
||||||
list_workspace_files,
|
|
||||||
soft_delete_workspace_file,
|
|
||||||
)
|
|
||||||
from backend.util.service import (
|
from backend.util.service import (
|
||||||
AppService,
|
AppService,
|
||||||
AppServiceClient,
|
AppServiceClient,
|
||||||
@@ -282,13 +274,7 @@ class DatabaseManager(AppService):
|
|||||||
get_user_execution_summary_data = _(get_user_execution_summary_data)
|
get_user_execution_summary_data = _(get_user_execution_summary_data)
|
||||||
|
|
||||||
# ============ Workspace ============ #
|
# ============ Workspace ============ #
|
||||||
count_workspace_files = _(count_workspace_files)
|
|
||||||
create_workspace_file = _(create_workspace_file)
|
|
||||||
get_or_create_workspace = _(get_or_create_workspace)
|
get_or_create_workspace = _(get_or_create_workspace)
|
||||||
get_workspace_file = _(get_workspace_file)
|
|
||||||
get_workspace_file_by_path = _(get_workspace_file_by_path)
|
|
||||||
list_workspace_files = _(list_workspace_files)
|
|
||||||
soft_delete_workspace_file = _(soft_delete_workspace_file)
|
|
||||||
|
|
||||||
# ============ Understanding ============ #
|
# ============ Understanding ============ #
|
||||||
get_business_understanding = _(get_business_understanding)
|
get_business_understanding = _(get_business_understanding)
|
||||||
@@ -452,13 +438,7 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
|||||||
get_user_execution_summary_data = d.get_user_execution_summary_data
|
get_user_execution_summary_data = d.get_user_execution_summary_data
|
||||||
|
|
||||||
# ============ Workspace ============ #
|
# ============ Workspace ============ #
|
||||||
count_workspace_files = d.count_workspace_files
|
|
||||||
create_workspace_file = d.create_workspace_file
|
|
||||||
get_or_create_workspace = d.get_or_create_workspace
|
get_or_create_workspace = d.get_or_create_workspace
|
||||||
get_workspace_file = d.get_workspace_file
|
|
||||||
get_workspace_file_by_path = d.get_workspace_file_by_path
|
|
||||||
list_workspace_files = d.list_workspace_files
|
|
||||||
soft_delete_workspace_file = d.soft_delete_workspace_file
|
|
||||||
|
|
||||||
# ============ Understanding ============ #
|
# ============ Understanding ============ #
|
||||||
get_business_understanding = d.get_business_understanding
|
get_business_understanding = d.get_business_understanding
|
||||||
|
|||||||
@@ -164,23 +164,21 @@ async def create_workspace_file(
|
|||||||
|
|
||||||
async def get_workspace_file(
|
async def get_workspace_file(
|
||||||
file_id: str,
|
file_id: str,
|
||||||
workspace_id: str,
|
workspace_id: Optional[str] = None,
|
||||||
) -> Optional[WorkspaceFile]:
|
) -> Optional[WorkspaceFile]:
|
||||||
"""
|
"""
|
||||||
Get a workspace file by ID.
|
Get a workspace file by ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_id: The file ID
|
file_id: The file ID
|
||||||
workspace_id: Workspace ID for scoping (required)
|
workspace_id: Optional workspace ID for validation
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WorkspaceFile instance or None
|
WorkspaceFile instance or None
|
||||||
"""
|
"""
|
||||||
where_clause: UserWorkspaceFileWhereInput = {
|
where_clause: dict = {"id": file_id, "isDeleted": False}
|
||||||
"id": file_id,
|
if workspace_id:
|
||||||
"isDeleted": False,
|
where_clause["workspaceId"] = workspace_id
|
||||||
"workspaceId": workspace_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
file = await UserWorkspaceFile.prisma().find_first(where=where_clause)
|
file = await UserWorkspaceFile.prisma().find_first(where=where_clause)
|
||||||
return WorkspaceFile.from_db(file) if file else None
|
return WorkspaceFile.from_db(file) if file else None
|
||||||
@@ -270,7 +268,7 @@ async def count_workspace_files(
|
|||||||
Returns:
|
Returns:
|
||||||
Number of files
|
Number of files
|
||||||
"""
|
"""
|
||||||
where_clause: UserWorkspaceFileWhereInput = {"workspaceId": workspace_id}
|
where_clause: dict = {"workspaceId": workspace_id}
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
where_clause["isDeleted"] = False
|
where_clause["isDeleted"] = False
|
||||||
|
|
||||||
@@ -285,7 +283,7 @@ async def count_workspace_files(
|
|||||||
|
|
||||||
async def soft_delete_workspace_file(
|
async def soft_delete_workspace_file(
|
||||||
file_id: str,
|
file_id: str,
|
||||||
workspace_id: str,
|
workspace_id: Optional[str] = None,
|
||||||
) -> Optional[WorkspaceFile]:
|
) -> Optional[WorkspaceFile]:
|
||||||
"""
|
"""
|
||||||
Soft-delete a workspace file.
|
Soft-delete a workspace file.
|
||||||
@@ -295,7 +293,7 @@ async def soft_delete_workspace_file(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_id: The file ID
|
file_id: The file ID
|
||||||
workspace_id: Workspace ID for scoping (required)
|
workspace_id: Optional workspace ID for validation
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated WorkspaceFile instance or None if not found
|
Updated WorkspaceFile instance or None if not found
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from typing import (
|
|||||||
import httpx
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request, responses
|
from fastapi import FastAPI, Request, responses
|
||||||
from prisma.errors import DataError, UniqueViolationError
|
from prisma.errors import DataError
|
||||||
from pydantic import BaseModel, TypeAdapter, create_model
|
from pydantic import BaseModel, TypeAdapter, create_model
|
||||||
|
|
||||||
import backend.util.exceptions as exceptions
|
import backend.util.exceptions as exceptions
|
||||||
@@ -201,7 +201,6 @@ EXCEPTION_MAPPING = {
|
|||||||
UnhealthyServiceError,
|
UnhealthyServiceError,
|
||||||
HTTPClientError,
|
HTTPClientError,
|
||||||
HTTPServerError,
|
HTTPServerError,
|
||||||
UniqueViolationError,
|
|
||||||
*[
|
*[
|
||||||
ErrorType
|
ErrorType
|
||||||
for _, ErrorType in inspect.getmembers(exceptions)
|
for _, ErrorType in inspect.getmembers(exceptions)
|
||||||
@@ -417,9 +416,6 @@ class AppService(BaseAppService, ABC):
|
|||||||
self.fastapi_app.add_exception_handler(
|
self.fastapi_app.add_exception_handler(
|
||||||
DataError, self._handle_internal_http_error(400)
|
DataError, self._handle_internal_http_error(400)
|
||||||
)
|
)
|
||||||
self.fastapi_app.add_exception_handler(
|
|
||||||
UniqueViolationError, self._handle_internal_http_error(400)
|
|
||||||
)
|
|
||||||
self.fastapi_app.add_exception_handler(
|
self.fastapi_app.add_exception_handler(
|
||||||
Exception, self._handle_internal_http_error(500)
|
Exception, self._handle_internal_http_error(500)
|
||||||
)
|
)
|
||||||
@@ -482,7 +478,6 @@ def get_service_client(
|
|||||||
# Don't retry these specific exceptions that won't be fixed by retrying
|
# Don't retry these specific exceptions that won't be fixed by retrying
|
||||||
ValueError, # Invalid input/parameters
|
ValueError, # Invalid input/parameters
|
||||||
DataError, # Prisma data integrity errors (foreign key, unique constraints)
|
DataError, # Prisma data integrity errors (foreign key, unique constraints)
|
||||||
UniqueViolationError, # Unique constraint violations
|
|
||||||
KeyError, # Missing required data
|
KeyError, # Missing required data
|
||||||
TypeError, # Wrong data types
|
TypeError, # Wrong data types
|
||||||
AttributeError, # Missing attributes
|
AttributeError, # Missing attributes
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ from typing import Optional
|
|||||||
|
|
||||||
from prisma.errors import UniqueViolationError
|
from prisma.errors import UniqueViolationError
|
||||||
|
|
||||||
from backend.data.db_accessors import workspace_db
|
from backend.data.workspace import (
|
||||||
from backend.data.workspace import WorkspaceFile
|
WorkspaceFile,
|
||||||
|
count_workspace_files,
|
||||||
|
create_workspace_file,
|
||||||
|
get_workspace_file,
|
||||||
|
get_workspace_file_by_path,
|
||||||
|
list_workspace_files,
|
||||||
|
soft_delete_workspace_file,
|
||||||
|
)
|
||||||
from backend.util.settings import Config
|
from backend.util.settings import Config
|
||||||
from backend.util.virus_scanner import scan_content_safe
|
from backend.util.virus_scanner import scan_content_safe
|
||||||
from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage
|
from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage
|
||||||
@@ -118,9 +125,8 @@ class WorkspaceManager:
|
|||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If file doesn't exist
|
FileNotFoundError: If file doesn't exist
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
|
||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
file = await db.get_workspace_file_by_path(self.workspace_id, resolved_path)
|
file = await get_workspace_file_by_path(self.workspace_id, resolved_path)
|
||||||
if file is None:
|
if file is None:
|
||||||
raise FileNotFoundError(f"File not found at path: {resolved_path}")
|
raise FileNotFoundError(f"File not found at path: {resolved_path}")
|
||||||
|
|
||||||
@@ -140,8 +146,7 @@ class WorkspaceManager:
|
|||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If file doesn't exist
|
FileNotFoundError: If file doesn't exist
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
file = await get_workspace_file(file_id, self.workspace_id)
|
||||||
file = await db.get_workspace_file(file_id, self.workspace_id)
|
|
||||||
if file is None:
|
if file is None:
|
||||||
raise FileNotFoundError(f"File not found: {file_id}")
|
raise FileNotFoundError(f"File not found: {file_id}")
|
||||||
|
|
||||||
@@ -199,10 +204,8 @@ class WorkspaceManager:
|
|||||||
# For overwrite=True, we let the write proceed and handle via UniqueViolationError
|
# For overwrite=True, we let the write proceed and handle via UniqueViolationError
|
||||||
# This ensures the new file is written to storage BEFORE the old one is deleted,
|
# This ensures the new file is written to storage BEFORE the old one is deleted,
|
||||||
# preventing data loss if the new write fails
|
# preventing data loss if the new write fails
|
||||||
db = workspace_db()
|
|
||||||
|
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
existing = await db.get_workspace_file_by_path(self.workspace_id, path)
|
existing = await get_workspace_file_by_path(self.workspace_id, path)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ValueError(f"File already exists at path: {path}")
|
raise ValueError(f"File already exists at path: {path}")
|
||||||
|
|
||||||
@@ -229,7 +232,7 @@ class WorkspaceManager:
|
|||||||
# Create database record - handle race condition where another request
|
# Create database record - handle race condition where another request
|
||||||
# created a file at the same path between our check and create
|
# created a file at the same path between our check and create
|
||||||
try:
|
try:
|
||||||
file = await db.create_workspace_file(
|
file = await create_workspace_file(
|
||||||
workspace_id=self.workspace_id,
|
workspace_id=self.workspace_id,
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
name=filename,
|
name=filename,
|
||||||
@@ -243,12 +246,12 @@ class WorkspaceManager:
|
|||||||
# Race condition: another request created a file at this path
|
# Race condition: another request created a file at this path
|
||||||
if overwrite:
|
if overwrite:
|
||||||
# Re-fetch and delete the conflicting file, then retry
|
# Re-fetch and delete the conflicting file, then retry
|
||||||
existing = await db.get_workspace_file_by_path(self.workspace_id, path)
|
existing = await get_workspace_file_by_path(self.workspace_id, path)
|
||||||
if existing:
|
if existing:
|
||||||
await self.delete_file(existing.id)
|
await self.delete_file(existing.id)
|
||||||
# Retry the create - if this also fails, clean up storage file
|
# Retry the create - if this also fails, clean up storage file
|
||||||
try:
|
try:
|
||||||
file = await db.create_workspace_file(
|
file = await create_workspace_file(
|
||||||
workspace_id=self.workspace_id,
|
workspace_id=self.workspace_id,
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
name=filename,
|
name=filename,
|
||||||
@@ -311,9 +314,8 @@ class WorkspaceManager:
|
|||||||
List of WorkspaceFile instances
|
List of WorkspaceFile instances
|
||||||
"""
|
"""
|
||||||
effective_path = self._get_effective_path(path, include_all_sessions)
|
effective_path = self._get_effective_path(path, include_all_sessions)
|
||||||
db = workspace_db()
|
|
||||||
|
|
||||||
return await db.list_workspace_files(
|
return await list_workspace_files(
|
||||||
workspace_id=self.workspace_id,
|
workspace_id=self.workspace_id,
|
||||||
path_prefix=effective_path,
|
path_prefix=effective_path,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -330,8 +332,7 @@ class WorkspaceManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if deleted, False if not found
|
True if deleted, False if not found
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
file = await get_workspace_file(file_id, self.workspace_id)
|
||||||
file = await db.get_workspace_file(file_id, self.workspace_id)
|
|
||||||
if file is None:
|
if file is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -344,7 +345,7 @@ class WorkspaceManager:
|
|||||||
# Continue with database soft-delete even if storage delete fails
|
# Continue with database soft-delete even if storage delete fails
|
||||||
|
|
||||||
# Soft-delete database record
|
# Soft-delete database record
|
||||||
result = await db.soft_delete_workspace_file(file_id, self.workspace_id)
|
result = await soft_delete_workspace_file(file_id, self.workspace_id)
|
||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
async def get_download_url(self, file_id: str, expires_in: int = 3600) -> str:
|
async def get_download_url(self, file_id: str, expires_in: int = 3600) -> str:
|
||||||
@@ -361,8 +362,7 @@ class WorkspaceManager:
|
|||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If file doesn't exist
|
FileNotFoundError: If file doesn't exist
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
file = await get_workspace_file(file_id, self.workspace_id)
|
||||||
file = await db.get_workspace_file(file_id, self.workspace_id)
|
|
||||||
if file is None:
|
if file is None:
|
||||||
raise FileNotFoundError(f"File not found: {file_id}")
|
raise FileNotFoundError(f"File not found: {file_id}")
|
||||||
|
|
||||||
@@ -379,8 +379,7 @@ class WorkspaceManager:
|
|||||||
Returns:
|
Returns:
|
||||||
WorkspaceFile instance or None
|
WorkspaceFile instance or None
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
return await get_workspace_file(file_id, self.workspace_id)
|
||||||
return await db.get_workspace_file(file_id, self.workspace_id)
|
|
||||||
|
|
||||||
async def get_file_info_by_path(self, path: str) -> Optional[WorkspaceFile]:
|
async def get_file_info_by_path(self, path: str) -> Optional[WorkspaceFile]:
|
||||||
"""
|
"""
|
||||||
@@ -395,9 +394,8 @@ class WorkspaceManager:
|
|||||||
Returns:
|
Returns:
|
||||||
WorkspaceFile instance or None
|
WorkspaceFile instance or None
|
||||||
"""
|
"""
|
||||||
db = workspace_db()
|
|
||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
return await db.get_workspace_file_by_path(self.workspace_id, resolved_path)
|
return await get_workspace_file_by_path(self.workspace_id, resolved_path)
|
||||||
|
|
||||||
async def get_file_count(
|
async def get_file_count(
|
||||||
self,
|
self,
|
||||||
@@ -419,8 +417,7 @@ class WorkspaceManager:
|
|||||||
Number of files
|
Number of files
|
||||||
"""
|
"""
|
||||||
effective_path = self._get_effective_path(path, include_all_sessions)
|
effective_path = self._get_effective_path(path, include_all_sessions)
|
||||||
db = workspace_db()
|
|
||||||
|
|
||||||
return await db.count_workspace_files(
|
return await count_workspace_files(
|
||||||
self.workspace_id, path_prefix=effective_path
|
self.workspace_id, path_prefix=effective_path
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,14 +93,7 @@ class WorkspaceStorageBackend(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class GCSWorkspaceStorage(WorkspaceStorageBackend):
|
class GCSWorkspaceStorage(WorkspaceStorageBackend):
|
||||||
"""Google Cloud Storage implementation for workspace storage.
|
"""Google Cloud Storage implementation for workspace storage."""
|
||||||
|
|
||||||
Each instance owns a single ``aiohttp.ClientSession`` and GCS async
|
|
||||||
client. Because ``ClientSession`` is bound to the event loop on which it
|
|
||||||
was created, callers that run on separate loops (e.g. copilot executor
|
|
||||||
worker threads) **must** obtain their own ``GCSWorkspaceStorage`` instance
|
|
||||||
via :func:`get_workspace_storage` which is event-loop-aware.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, bucket_name: str):
|
def __init__(self, bucket_name: str):
|
||||||
self.bucket_name = bucket_name
|
self.bucket_name = bucket_name
|
||||||
@@ -344,73 +337,60 @@ class LocalWorkspaceStorage(WorkspaceStorageBackend):
|
|||||||
raise ValueError(f"Invalid storage path format: {storage_path}")
|
raise ValueError(f"Invalid storage path format: {storage_path}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Global storage backend instance
|
||||||
# Storage instance management
|
_workspace_storage: Optional[WorkspaceStorageBackend] = None
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ``aiohttp.ClientSession`` is bound to the event loop where it is created.
|
|
||||||
# The copilot executor runs each worker in its own thread with a dedicated
|
|
||||||
# event loop, so a single global ``GCSWorkspaceStorage`` instance would break.
|
|
||||||
#
|
|
||||||
# For **local storage** a single shared instance is fine (no async I/O).
|
|
||||||
# For **GCS storage** we keep one instance *per event loop* so every loop
|
|
||||||
# gets its own ``ClientSession``.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_local_storage: Optional[LocalWorkspaceStorage] = None
|
|
||||||
_gcs_storages: dict[int, GCSWorkspaceStorage] = {}
|
|
||||||
_storage_lock = asyncio.Lock()
|
_storage_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
async def get_workspace_storage() -> WorkspaceStorageBackend:
|
async def get_workspace_storage() -> WorkspaceStorageBackend:
|
||||||
"""Return a workspace storage backend for the **current** event loop.
|
|
||||||
|
|
||||||
* Local storage → single shared instance (no event-loop affinity).
|
|
||||||
* GCS storage → one instance per event loop to avoid cross-loop
|
|
||||||
``aiohttp`` errors.
|
|
||||||
"""
|
"""
|
||||||
global _local_storage
|
Get the workspace storage backend instance.
|
||||||
|
|
||||||
config = Config()
|
Uses GCS if media_gcs_bucket_name is configured, otherwise uses local storage.
|
||||||
|
"""
|
||||||
|
global _workspace_storage
|
||||||
|
|
||||||
# --- Local storage (shared) ---
|
if _workspace_storage is None:
|
||||||
if not config.media_gcs_bucket_name:
|
async with _storage_lock:
|
||||||
if _local_storage is None:
|
if _workspace_storage is None:
|
||||||
storage_dir = (
|
config = Config()
|
||||||
config.workspace_storage_dir if config.workspace_storage_dir else None
|
|
||||||
)
|
|
||||||
logger.info(f"Using local workspace storage: {storage_dir or 'default'}")
|
|
||||||
_local_storage = LocalWorkspaceStorage(storage_dir)
|
|
||||||
return _local_storage
|
|
||||||
|
|
||||||
# --- GCS storage (per event loop) ---
|
if config.media_gcs_bucket_name:
|
||||||
loop_id = id(asyncio.get_running_loop())
|
logger.info(
|
||||||
if loop_id not in _gcs_storages:
|
f"Using GCS workspace storage: {config.media_gcs_bucket_name}"
|
||||||
logger.info(
|
)
|
||||||
f"Creating GCS workspace storage for loop {loop_id}: "
|
_workspace_storage = GCSWorkspaceStorage(
|
||||||
f"{config.media_gcs_bucket_name}"
|
config.media_gcs_bucket_name
|
||||||
)
|
)
|
||||||
_gcs_storages[loop_id] = GCSWorkspaceStorage(config.media_gcs_bucket_name)
|
else:
|
||||||
return _gcs_storages[loop_id]
|
storage_dir = (
|
||||||
|
config.workspace_storage_dir
|
||||||
|
if config.workspace_storage_dir
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Using local workspace storage: {storage_dir or 'default'}"
|
||||||
|
)
|
||||||
|
_workspace_storage = LocalWorkspaceStorage(storage_dir)
|
||||||
|
|
||||||
|
return _workspace_storage
|
||||||
|
|
||||||
|
|
||||||
async def shutdown_workspace_storage() -> None:
|
async def shutdown_workspace_storage() -> None:
|
||||||
"""Shut down workspace storage for the **current** event loop.
|
|
||||||
|
|
||||||
Closes the ``aiohttp`` session owned by the current loop's GCS instance.
|
|
||||||
Each worker thread should call this on its own loop before the loop is
|
|
||||||
destroyed. The REST API lifespan hook calls it for the main server loop.
|
|
||||||
"""
|
"""
|
||||||
global _local_storage
|
Properly shutdown the global workspace storage backend.
|
||||||
|
|
||||||
loop_id = id(asyncio.get_running_loop())
|
Closes aiohttp sessions and other resources for GCS backend.
|
||||||
storage = _gcs_storages.pop(loop_id, None)
|
Should be called during application shutdown.
|
||||||
if storage is not None:
|
"""
|
||||||
await storage.close()
|
global _workspace_storage
|
||||||
|
|
||||||
# Clear local storage only when the last GCS instance is gone
|
if _workspace_storage is not None:
|
||||||
# (i.e. full shutdown, not just a single worker stopping).
|
async with _storage_lock:
|
||||||
if not _gcs_storages:
|
if _workspace_storage is not None:
|
||||||
_local_storage = None
|
if isinstance(_workspace_storage, GCSWorkspaceStorage):
|
||||||
|
await _workspace_storage.close()
|
||||||
|
_workspace_storage = None
|
||||||
|
|
||||||
|
|
||||||
def compute_file_checksum(content: bytes) -> str:
|
def compute_file_checksum(content: bytes) -> str:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "./components/ClarificationQuestionsCard";
|
} from "./components/ClarificationQuestionsCard";
|
||||||
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
|
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
|
||||||
import { MiniGame } from "./components/MiniGame/MiniGame";
|
import { MiniGame } from "./components/MiniGame/MiniGame";
|
||||||
|
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
|
||||||
import {
|
import {
|
||||||
AccordionIcon,
|
AccordionIcon,
|
||||||
formatMaybeJson,
|
formatMaybeJson,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
isOperationInProgressOutput,
|
isOperationInProgressOutput,
|
||||||
isOperationPendingOutput,
|
isOperationPendingOutput,
|
||||||
isOperationStartedOutput,
|
isOperationStartedOutput,
|
||||||
|
isSuggestedGoalOutput,
|
||||||
ToolIcon,
|
ToolIcon,
|
||||||
truncateText,
|
truncateText,
|
||||||
type CreateAgentToolOutput,
|
type CreateAgentToolOutput,
|
||||||
@@ -77,6 +79,13 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
|
|||||||
expanded: true,
|
expanded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isSuggestedGoalOutput(output)) {
|
||||||
|
return {
|
||||||
|
icon,
|
||||||
|
title: "Goal needs refinement",
|
||||||
|
expanded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
isOperationStartedOutput(output) ||
|
isOperationStartedOutput(output) ||
|
||||||
isOperationPendingOutput(output) ||
|
isOperationPendingOutput(output) ||
|
||||||
@@ -125,8 +134,13 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
isAgentPreviewOutput(output) ||
|
isAgentPreviewOutput(output) ||
|
||||||
isAgentSavedOutput(output) ||
|
isAgentSavedOutput(output) ||
|
||||||
isClarificationNeededOutput(output) ||
|
isClarificationNeededOutput(output) ||
|
||||||
|
isSuggestedGoalOutput(output) ||
|
||||||
isErrorOutput(output));
|
isErrorOutput(output));
|
||||||
|
|
||||||
|
function handleUseSuggestedGoal(goal: string) {
|
||||||
|
onSend(`Please create an agent with this goal: ${goal}`);
|
||||||
|
}
|
||||||
|
|
||||||
function handleClarificationAnswers(answers: Record<string, string>) {
|
function handleClarificationAnswers(answers: Record<string, string>) {
|
||||||
const questions =
|
const questions =
|
||||||
output && isClarificationNeededOutput(output)
|
output && isClarificationNeededOutput(output)
|
||||||
@@ -245,6 +259,16 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSuggestedGoalOutput(output) && (
|
||||||
|
<SuggestedGoalCard
|
||||||
|
message={output.message}
|
||||||
|
suggestedGoal={output.suggested_goal}
|
||||||
|
reason={output.reason}
|
||||||
|
goalType={output.goal_type ?? "vague"}
|
||||||
|
onUseSuggestedGoal={handleUseSuggestedGoal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isErrorOutput(output) && (
|
{isErrorOutput(output) && (
|
||||||
<ContentGrid>
|
<ContentGrid>
|
||||||
<ContentMessage>{output.message}</ContentMessage>
|
<ContentMessage>{output.message}</ContentMessage>
|
||||||
@@ -258,6 +282,22 @@ export function CreateAgentTool({ part }: Props) {
|
|||||||
{formatMaybeJson(output.details)}
|
{formatMaybeJson(output.details)}
|
||||||
</ContentCodeBlock>
|
</ContentCodeBlock>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSend("Please try creating the agent again.")}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSend("Can you help me simplify this goal?")}
|
||||||
|
>
|
||||||
|
Simplify goal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ContentGrid>
|
</ContentGrid>
|
||||||
)}
|
)}
|
||||||
</ToolAccordion>
|
</ToolAccordion>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { ArrowRightIcon, LightbulbIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
suggestedGoal: string;
|
||||||
|
reason?: string;
|
||||||
|
goalType: string;
|
||||||
|
onUseSuggestedGoal: (goal: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedGoalCard({
|
||||||
|
message,
|
||||||
|
suggestedGoal,
|
||||||
|
reason,
|
||||||
|
goalType,
|
||||||
|
onUseSuggestedGoal,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50/50 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<LightbulbIcon
|
||||||
|
size={20}
|
||||||
|
weight="fill"
|
||||||
|
className="mt-0.5 text-amber-600"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Text variant="body-medium" className="font-medium text-slate-900">
|
||||||
|
{goalType === "unachievable"
|
||||||
|
? "Goal cannot be accomplished"
|
||||||
|
: "Goal needs more detail"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="small" className="text-slate-600">
|
||||||
|
{reason || message}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-white p-3">
|
||||||
|
<Text variant="small" className="mb-1 font-semibold text-amber-800">
|
||||||
|
Suggested alternative:
|
||||||
|
</Text>
|
||||||
|
<Text variant="body-medium" className="text-slate-900">
|
||||||
|
{suggestedGoal}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onUseSuggestedGoal(suggestedGoal)}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Use this goal <ArrowRightIcon size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { OperationInProgressResponse } from "@/app/api/__generated__/models
|
|||||||
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
|
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
|
||||||
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
|
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
|
||||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||||
|
import type { SuggestedGoalResponse } from "@/app/api/__generated__/models/suggestedGoalResponse";
|
||||||
import {
|
import {
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -21,6 +22,7 @@ export type CreateAgentToolOutput =
|
|||||||
| AgentPreviewResponse
|
| AgentPreviewResponse
|
||||||
| AgentSavedResponse
|
| AgentSavedResponse
|
||||||
| ClarificationNeededResponse
|
| ClarificationNeededResponse
|
||||||
|
| SuggestedGoalResponse
|
||||||
| ErrorResponse;
|
| ErrorResponse;
|
||||||
|
|
||||||
function parseOutput(output: unknown): CreateAgentToolOutput | null {
|
function parseOutput(output: unknown): CreateAgentToolOutput | null {
|
||||||
@@ -43,6 +45,7 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
|
|||||||
type === ResponseType.agent_preview ||
|
type === ResponseType.agent_preview ||
|
||||||
type === ResponseType.agent_saved ||
|
type === ResponseType.agent_saved ||
|
||||||
type === ResponseType.clarification_needed ||
|
type === ResponseType.clarification_needed ||
|
||||||
|
type === ResponseType.suggested_goal ||
|
||||||
type === ResponseType.error
|
type === ResponseType.error
|
||||||
) {
|
) {
|
||||||
return output as CreateAgentToolOutput;
|
return output as CreateAgentToolOutput;
|
||||||
@@ -55,6 +58,7 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
|
|||||||
if ("agent_id" in output && "library_agent_id" in output)
|
if ("agent_id" in output && "library_agent_id" in output)
|
||||||
return output as AgentSavedResponse;
|
return output as AgentSavedResponse;
|
||||||
if ("questions" in output) return output as ClarificationNeededResponse;
|
if ("questions" in output) return output as ClarificationNeededResponse;
|
||||||
|
if ("suggested_goal" in output) return output as SuggestedGoalResponse;
|
||||||
if ("error" in output || "details" in output)
|
if ("error" in output || "details" in output)
|
||||||
return output as ErrorResponse;
|
return output as ErrorResponse;
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,14 @@ export function isClarificationNeededOutput(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSuggestedGoalOutput(
|
||||||
|
output: CreateAgentToolOutput,
|
||||||
|
): output is SuggestedGoalResponse {
|
||||||
|
return (
|
||||||
|
output.type === ResponseType.suggested_goal || "suggested_goal" in output
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isErrorOutput(
|
export function isErrorOutput(
|
||||||
output: CreateAgentToolOutput,
|
output: CreateAgentToolOutput,
|
||||||
): output is ErrorResponse {
|
): output is ErrorResponse {
|
||||||
@@ -139,6 +151,7 @@ export function getAnimationText(part: {
|
|||||||
if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`;
|
if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`;
|
||||||
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
|
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
|
||||||
if (isClarificationNeededOutput(output)) return "Needs clarification";
|
if (isClarificationNeededOutput(output)) return "Needs clarification";
|
||||||
|
if (isSuggestedGoalOutput(output)) return "Goal needs refinement";
|
||||||
return "Error creating agent";
|
return "Error creating agent";
|
||||||
}
|
}
|
||||||
case "output-error":
|
case "output-error":
|
||||||
|
|||||||
@@ -1052,6 +1052,7 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/ClarificationNeededResponse"
|
"$ref": "#/components/schemas/ClarificationNeededResponse"
|
||||||
},
|
},
|
||||||
|
{ "$ref": "#/components/schemas/SuggestedGoalResponse" },
|
||||||
{ "$ref": "#/components/schemas/BlockListResponse" },
|
{ "$ref": "#/components/schemas/BlockListResponse" },
|
||||||
{ "$ref": "#/components/schemas/BlockDetailsResponse" },
|
{ "$ref": "#/components/schemas/BlockDetailsResponse" },
|
||||||
{ "$ref": "#/components/schemas/BlockOutputResponse" },
|
{ "$ref": "#/components/schemas/BlockOutputResponse" },
|
||||||
@@ -10796,7 +10797,8 @@
|
|||||||
"bash_exec",
|
"bash_exec",
|
||||||
"operation_status",
|
"operation_status",
|
||||||
"feature_request_search",
|
"feature_request_search",
|
||||||
"feature_request_created"
|
"feature_request_created",
|
||||||
|
"suggested_goal"
|
||||||
],
|
],
|
||||||
"title": "ResponseType",
|
"title": "ResponseType",
|
||||||
"description": "Types of tool responses."
|
"description": "Types of tool responses."
|
||||||
@@ -11677,6 +11679,47 @@
|
|||||||
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
|
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
|
||||||
"title": "SubmissionStatus"
|
"title": "SubmissionStatus"
|
||||||
},
|
},
|
||||||
|
"SuggestedGoalResponse": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/components/schemas/ResponseType",
|
||||||
|
"default": "suggested_goal"
|
||||||
|
},
|
||||||
|
"message": { "type": "string", "title": "Message" },
|
||||||
|
"session_id": {
|
||||||
|
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||||
|
"title": "Session Id"
|
||||||
|
},
|
||||||
|
"suggested_goal": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Suggested Goal",
|
||||||
|
"description": "The suggested alternative goal"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Reason",
|
||||||
|
"description": "Why the original goal needs refinement",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"original_goal": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Original Goal",
|
||||||
|
"description": "The user's original goal for context",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"goal_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["vague", "unachievable"],
|
||||||
|
"title": "Goal Type",
|
||||||
|
"description": "Type: 'vague' or 'unachievable'",
|
||||||
|
"default": "vague"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": ["message", "suggested_goal"],
|
||||||
|
"title": "SuggestedGoalResponse",
|
||||||
|
"description": "Response when the goal needs refinement with a suggested alternative."
|
||||||
|
},
|
||||||
"SuggestionsResponse": {
|
"SuggestionsResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"otto_suggestions": {
|
"otto_suggestions": {
|
||||||
|
|||||||
@@ -69,11 +69,12 @@ test.describe("Marketplace Creator Page – Basic Functionality", () => {
|
|||||||
await marketplacePage.getFirstCreatorProfile(page);
|
await marketplacePage.getFirstCreatorProfile(page);
|
||||||
await firstCreatorProfile.click();
|
await firstCreatorProfile.click();
|
||||||
await page.waitForURL("**/marketplace/creator/**");
|
await page.waitForURL("**/marketplace/creator/**");
|
||||||
|
await page.waitForLoadState("networkidle").catch(() => {});
|
||||||
|
|
||||||
const firstAgent = page
|
const firstAgent = page
|
||||||
.locator('[data-testid="store-card"]:visible')
|
.locator('[data-testid="store-card"]:visible')
|
||||||
.first();
|
.first();
|
||||||
await firstAgent.waitFor({ state: "visible", timeout: 15000 });
|
await firstAgent.waitFor({ state: "visible", timeout: 30000 });
|
||||||
|
|
||||||
await firstAgent.click();
|
await firstAgent.click();
|
||||||
await page.waitForURL("**/marketplace/agent/**");
|
await page.waitForURL("**/marketplace/agent/**");
|
||||||
|
|||||||
@@ -115,11 +115,18 @@ test.describe("Marketplace – Basic Functionality", () => {
|
|||||||
const searchTerm = page.getByText("DummyInput").first();
|
const searchTerm = page.getByText("DummyInput").first();
|
||||||
await isVisible(searchTerm);
|
await isVisible(searchTerm);
|
||||||
|
|
||||||
await expect
|
await page.waitForLoadState("networkidle").catch(() => {});
|
||||||
.poll(() => marketplacePage.getSearchResultsCount(page), {
|
|
||||||
timeout: 15000,
|
await page
|
||||||
})
|
.waitForFunction(
|
||||||
.toBeGreaterThan(0);
|
() =>
|
||||||
|
document.querySelectorAll('[data-testid="store-card"]').length > 0,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
)
|
||||||
|
.catch(() => console.log("No search results appeared within timeout"));
|
||||||
|
|
||||||
|
const results = await marketplacePage.getSearchResultsCount(page);
|
||||||
|
expect(results).toBeGreaterThan(0);
|
||||||
|
|
||||||
console.log("Complete search flow works correctly test passed ✅");
|
console.log("Complete search flow works correctly test passed ✅");
|
||||||
});
|
});
|
||||||
@@ -128,9 +135,7 @@ test.describe("Marketplace – Basic Functionality", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Marketplace – Edge Cases", () => {
|
test.describe("Marketplace – Edge Cases", () => {
|
||||||
test("Search for non-existent item renders search page correctly", async ({
|
test("Search for non-existent item shows no results", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const marketplacePage = new MarketplacePage(page);
|
const marketplacePage = new MarketplacePage(page);
|
||||||
await marketplacePage.goto(page);
|
await marketplacePage.goto(page);
|
||||||
|
|
||||||
@@ -146,18 +151,9 @@ test.describe("Marketplace – Edge Cases", () => {
|
|||||||
const searchTerm = page.getByText("xyznonexistentitemxyz123");
|
const searchTerm = page.getByText("xyznonexistentitemxyz123");
|
||||||
await isVisible(searchTerm);
|
await isVisible(searchTerm);
|
||||||
|
|
||||||
// The search page should render either results or a "No results found" message
|
const results = await marketplacePage.getSearchResultsCount(page);
|
||||||
await page.waitForLoadState("networkidle").catch(() => {});
|
expect(results).toBe(0);
|
||||||
const hasResults =
|
|
||||||
(await page.locator('[data-testid="store-card"]').count()) > 0;
|
|
||||||
const hasNoResultsMsg = await page
|
|
||||||
.getByText("No results found")
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasResults || hasNoResultsMsg).toBe(true);
|
|
||||||
|
|
||||||
console.log(
|
console.log("Search for non-existent item shows no results test passed ✅");
|
||||||
"Search for non-existent item renders search page correctly test passed ✅",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,8 +125,16 @@ export class BuildPage extends BasePage {
|
|||||||
`[data-id="block-card-${blockCardId}"]`,
|
`[data-id="block-card-${blockCardId}"]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await blockCard.waitFor({ state: "visible", timeout: 10000 });
|
try {
|
||||||
await blockCard.click();
|
// Wait for the block card to be visible with a reasonable timeout
|
||||||
|
await blockCard.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await blockCard.click();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
`Block ${block.name} (display: ${displayName}) returned from the API but not found in block list`,
|
||||||
|
);
|
||||||
|
console.log(`Error: ${error}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasBlock(_block: Block) {
|
async hasBlock(_block: Block) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class LoginPage {
|
|||||||
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
await this.page.waitForLoadState("load", { timeout: 10_000 });
|
||||||
|
|
||||||
console.log("➡️ Navigating to /marketplace ...");
|
console.log("➡️ Navigating to /marketplace ...");
|
||||||
await this.page.goto("/marketplace", { timeout: 20_000 });
|
await this.page.goto("/marketplace", { timeout: 10_000 });
|
||||||
console.log("✅ Login process complete");
|
console.log("✅ Login process complete");
|
||||||
|
|
||||||
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions
|
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ export class MarketplacePage extends BasePage {
|
|||||||
|
|
||||||
async goto(page: Page) {
|
async goto(page: Page) {
|
||||||
await page.goto("/marketplace");
|
await page.goto("/marketplace");
|
||||||
await page
|
await page.waitForLoadState("networkidle").catch(() => {});
|
||||||
.locator(
|
|
||||||
'[data-testid="store-card"], [data-testid="featured-store-card"]',
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
.waitFor({ state: "visible", timeout: 20000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketplaceTitle(page: Page) {
|
async getMarketplaceTitle(page: Page) {
|
||||||
@@ -116,7 +111,7 @@ export class MarketplacePage extends BasePage {
|
|||||||
async getFirstFeaturedAgent(page: Page) {
|
async getFirstFeaturedAgent(page: Page) {
|
||||||
const { getId } = getSelectors(page);
|
const { getId } = getSelectors(page);
|
||||||
const card = getId("featured-store-card").first();
|
const card = getId("featured-store-card").first();
|
||||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
await card.waitFor({ state: "visible", timeout: 30000 });
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +119,14 @@ export class MarketplacePage extends BasePage {
|
|||||||
const card = this.page
|
const card = this.page
|
||||||
.locator('[data-testid="store-card"]:visible')
|
.locator('[data-testid="store-card"]:visible')
|
||||||
.first();
|
.first();
|
||||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
await card.waitFor({ state: "visible", timeout: 30000 });
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirstCreatorProfile(page: Page) {
|
async getFirstCreatorProfile(page: Page) {
|
||||||
const { getId } = getSelectors(page);
|
const { getId } = getSelectors(page);
|
||||||
const card = getId("creator-card").first();
|
const card = getId("creator-card").first();
|
||||||
await card.waitFor({ state: "visible", timeout: 15000 });
|
await card.waitFor({ state: "visible", timeout: 30000 });
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ export async function isEnabled(el: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hasMinCount(el: Locator, minCount: number) {
|
export async function hasMinCount(el: Locator, minCount: number) {
|
||||||
await expect
|
const count = await el.count();
|
||||||
.poll(async () => await el.count(), { timeout: 10000 })
|
expect(count).toBeGreaterThanOrEqual(minCount);
|
||||||
.toBeGreaterThanOrEqual(minCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function matchesUrl(page: Page, pattern: RegExp) {
|
export async function matchesUrl(page: Page, pattern: RegExp) {
|
||||||
|
|||||||
Reference in New Issue
Block a user