Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions backend/alembic/versions/2b3c4d5e6f7g_add_shared_tasks_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add shared_tasks table for task sharing

Revision ID: 2b3c4d5e6f7g
Revises: add_subtask_attachments
Create Date: 2025-12-04 12:00:00.000000+08:00

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '2b3c4d5e6f7g'
down_revision: Union[str, Sequence[str], None] = 'add_subtask_attachments'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add shared_tasks table for task sharing functionality."""

# Create shared_tasks table
op.execute("""
CREATE TABLE IF NOT EXISTS shared_tasks (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
user_id INT NOT NULL DEFAULT 0 COMMENT '当前用户ID',
original_user_id INT NOT NULL DEFAULT 0 COMMENT '原始任务所有者用户ID',
original_task_id INT NOT NULL DEFAULT 0 COMMENT '原始任务ID',
copied_task_id INT NOT NULL DEFAULT 0 COMMENT '复制后的任务ID',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否激活',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_shared_tasks_id (id),
KEY idx_shared_tasks_user_id (user_id),
KEY idx_shared_tasks_original_user_id (original_user_id),
KEY idx_shared_tasks_original_task_id (original_task_id),
KEY idx_shared_tasks_copied_task_id (copied_task_id),
UNIQUE KEY uniq_user_original_task (user_id, original_task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")


def downgrade() -> None:
"""Remove shared_tasks table."""
op.execute("DROP TABLE IF EXISTS shared_tasks")
44 changes: 42 additions & 2 deletions backend/app/api/endpoints/adapter/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def _should_use_direct_chat(db: Session, team: Kind, user_id: int) -> bool:
return True


def _create_task_and_subtasks(
async def _create_task_and_subtasks(
db: Session,
user: User,
team: Kind,
Expand Down Expand Up @@ -341,6 +341,46 @@ def _create_task_and_subtasks(
db.refresh(task)
db.refresh(assistant_subtask)

# Initialize Redis chat history from existing subtasks if needed
# This is crucial for shared tasks that were copied with historical messages
if existing_subtasks:
from app.services.chat.session_manager import session_manager

# Check if history exists in Redis
redis_history = await session_manager.get_chat_history(task_id)

# If Redis history is empty but we have subtasks, rebuild history from DB
if not redis_history:
logger.info(f"Initializing chat history from DB for task {task_id} with {len(existing_subtasks)} existing subtasks")
history_messages = []

# Sort subtasks by message_id to ensure correct order
sorted_subtasks = sorted(existing_subtasks, key=lambda s: s.message_id)

for subtask in sorted_subtasks:
# Only include completed subtasks with results
if subtask.status == SubtaskStatus.COMPLETED:
if subtask.role == SubtaskRole.USER:
# User message - use prompt field
if subtask.prompt:
history_messages.append({
"role": "user",
"content": subtask.prompt
})
elif subtask.role == SubtaskRole.ASSISTANT:
# Assistant message - use result.value field
if subtask.result and isinstance(subtask.result, dict):
content = subtask.result.get("value", "")
if content:
history_messages.append({
"role": "assistant",
"content": content
})

# Save to Redis if we found any history
if history_messages:
await session_manager.save_chat_history(task_id, history_messages)
logger.info(f"Initialized {len(history_messages)} messages in Redis for task {task_id}")
return task, assistant_subtask


Expand Down Expand Up @@ -434,7 +474,7 @@ async def stream_chat(
)

# Create task and subtasks (use original message for storage, final_message for LLM)
task, assistant_subtask = _create_task_and_subtasks(
task, assistant_subtask = await _create_task_and_subtasks(
db, current_user, team, request.message, request, request.task_id
)

Expand Down
118 changes: 116 additions & 2 deletions backend/app/api/endpoints/adapter/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
from app.core.config import settings
from app.models.subtask import Subtask, SubtaskRole, SubtaskStatus
from app.models.user import User
from app.schemas.shared_task import (
JoinSharedTaskRequest,
JoinSharedTaskResponse,
PublicSharedTaskResponse,
TaskShareInfo,
TaskShareResponse,
)
from app.schemas.task import (
TaskCreate,
TaskDetail,
Expand All @@ -23,6 +30,7 @@
TaskUpdate,
)
from app.services.adapters.task_kinds import task_kinds_service
from app.services.shared_task import shared_task_service

router = APIRouter()
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -282,8 +290,7 @@ async def cancel_task(
except Exception as e:
logger.error(
f"Failed to update Chat Shell task {task_id} status: {str(e)}"
)

)
return {"message": "Chat stopped successfully", "status": "COMPLETED"}
else:
# No running subtask found, just mark task as completed
Expand Down Expand Up @@ -323,3 +330,110 @@ async def cancel_task(
background_tasks.add_task(call_executor_cancel, task_id)

return {"message": "Cancel request accepted", "status": "CANCELLING"}


@router.post("/{task_id}/share", response_model=TaskShareResponse)
def share_task(
task_id: int,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
):
"""
Generate a share link for a task.
The share link allows others to view the task history and copy it to their task list.
"""
# Validate that the task belongs to the current user
if not shared_task_service.validate_task_exists(
db=db, task_id=task_id, user_id=current_user.id
):
raise HTTPException(
status_code=404, detail="Task not found or you don't have permission"
)

return shared_task_service.share_task(
db=db, task_id=task_id, user_id=current_user.id
)


@router.get("/share/info", response_model=TaskShareInfo)
def get_task_share_info(
share_token: str = Query(..., description="Share token from URL"),
db: Session = Depends(get_db),
):
"""
Get task share information from share token.
This endpoint doesn't require authentication, so anyone with the link can view.
"""
return shared_task_service.get_share_info(db=db, share_token=share_token)


@router.get("/share/public", response_model=PublicSharedTaskResponse)
def get_public_shared_task(
token: str = Query(..., description="Share token from URL"),
db: Session = Depends(get_db),
):
"""
Get public shared task data for read-only viewing.
This endpoint doesn't require authentication - anyone with the link can view.
Only returns public data (no sensitive information like team config, bot details, etc.)
"""
return shared_task_service.get_public_shared_task(db=db, share_token=token)
Comment on lines +358 to +380
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "router\.(get|post)" backend/app/api/endpoints/adapter/tasks.py | head -40

Repository: wecode-ai/Wegent

Length of output: 828


🌐 Web query:

FastAPI route matching order path parameter vs literal routes

💡 Result:

Short answer: FastAPI (via Starlette) matches routes in the order they are added. More specific literal routes (e.g. /users/me) must be declared before general path-parameter routes (e.g. /users/{user_id}), otherwise the parameter route will capture the literal. The same applies to catch‑all patterns — place them last. [1][2]

Example: declare @app.get("/users/me") before @app.get("/users/{user_id}"). [1]

Sources: FastAPI Path Parameters docs and Starlette routing notes. [1][2]


🏁 Script executed:

# Verify the exact line numbers and structure to understand the full context
sed -n '150,160p' backend/app/api/endpoints/adapter/tasks.py

Repository: wecode-ai/Wegent

Length of output: 371


Move /share/* routes before /{task_id} route.

The routes /share/info (line 350), /share/public (line 362), and /share/join (line 375) must be defined before the /{task_id} route (line 155). FastAPI matches routes in definition order, and the parameterized /{task_id} route will capture "share" as a task_id before the literal /share/* routes are evaluated, preventing those endpoints from being reached.

🧰 Tools
🪛 Ruff (0.14.7)

353-353: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


365-365: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
In backend/app/api/endpoints/adapter/tasks.py around lines 350 to 372, the
literal routes /share/info and /share/public (and /share/join at ~line 375) are
defined after the parameterized /{task_id} route (around line 155), so FastAPI
will match /{task_id} first and capture "share" as a task_id; move the /share/*
route definitions to appear before the /{task_id} route definition so the
literal /share paths are matched first; update file order by cutting the three
/share route blocks and pasting them above the /{task_id} route, keeping imports
and dependencies intact.



@router.post("/share/join", response_model=JoinSharedTaskResponse)
def join_shared_task(
request: JoinSharedTaskRequest,
current_user: User = Depends(security.get_current_user),
db: Session = Depends(get_db),
):
"""
Copy a shared task to the current user's task list.
This creates a new task with all the subtasks (messages) from the shared task.
"""
from app.models.kind import Kind

# If team_id is provided, validate it belongs to the user
if request.team_id:
user_team = (
db.query(Kind)
.filter(
Kind.user_id == current_user.id,
Kind.kind == "Team",
Kind.id == request.team_id,
Kind.is_active == True,
)
.first()
)

if not user_team:
raise HTTPException(
status_code=400,
detail="Invalid team_id or team does not belong to you",
)
else:
# Get user's first active team if not specified
user_team = (
db.query(Kind)
.filter(
Kind.user_id == current_user.id,
Kind.kind == "Team",
Kind.is_active == True,
)
.first()
)

if not user_team:
raise HTTPException(
status_code=400,
detail="You need to have at least one team to copy a shared task",
)

return shared_task_service.join_shared_task(
db=db,
share_token=request.share_token,
user_id=current_user.id,
team_id=user_team.id,
model_id=request.model_id,
force_override_bot_model=request.force_override_bot_model or False,
)
Comment on lines +335 to +438
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add return type hints and avoid magic HTTP status numbers on new endpoints

The new sharing endpoints look logically correct and delegate cleanly to shared_task_service, but they currently lack explicit return type hints and use bare 400/404 literals in HTTPException, even though status is already imported.

To align with the Python coding guidelines (type hints + avoiding magic numbers), consider updating all four endpoints like this (pattern shown; apply similarly to the others):

-@router.post("/{task_id}/share", response_model=TaskShareResponse)
-def share_task(
+@router.post("/{task_id}/share", response_model=TaskShareResponse)
+def share_task(
     task_id: int,
     current_user: User = Depends(security.get_current_user),
     db: Session = Depends(get_db),
-):
+ ) -> TaskShareResponse:
@@
-    if not shared_task_service.validate_task_exists(
-        db=db, task_id=task_id, user_id=current_user.id
-    ):
-        raise HTTPException(
-            status_code=404, detail="Task not found or you don't have permission"
-        )
+    if not shared_task_service.validate_task_exists(
+        db=db, task_id=task_id, user_id=current_user.id
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Task not found or you don't have permission",
+        )
@@
-@router.get("/share/info", response_model=TaskShareInfo)
-def get_task_share_info(
+@router.get("/share/info", response_model=TaskShareInfo)
+def get_task_share_info(
     share_token: str = Query(..., description="Share token from URL"),
     db: Session = Depends(get_db),
-):
+ ) -> TaskShareInfo:
@@
-@router.get("/share/public", response_model=PublicSharedTaskResponse)
-def get_public_shared_task(
-    token: str = Query(..., description="Share token from URL"),
-    db: Session = Depends(get_db),
-):
+@router.get("/share/public", response_model=PublicSharedTaskResponse)
+def get_public_shared_task(
+    token: str = Query(..., description="Share token from URL"),
+    db: Session = Depends(get_db),
+) -> PublicSharedTaskResponse:
@@
-@router.post("/share/join", response_model=JoinSharedTaskResponse)
-def join_shared_task(
+@router.post("/share/join", response_model=JoinSharedTaskResponse)
+def join_shared_task(
     request: JoinSharedTaskRequest,
     current_user: User = Depends(security.get_current_user),
     db: Session = Depends(get_db),
-):
+ ) -> JoinSharedTaskResponse:
@@
-            raise HTTPException(
-                status_code=400,
-                detail="Invalid team_id or team does not belong to you",
-            )
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Invalid team_id or team does not belong to you",
+            )
@@
-            raise HTTPException(
-                status_code=400,
-                detail="You need to have at least one team to copy a shared task",
-            )
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="You need to have at least one team to copy a shared task",
+            )

As per coding guidelines, this keeps the API signatures explicit and removes magic status-code literals.

🧰 Tools
🪛 Ruff (0.14.7)

338-338: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


339-339: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


361-361: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


373-373: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


386-386: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


387-387: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


403-403: Avoid equality comparisons to True; use Kind.is_active: for truth checks

Replace with Kind.is_active

(E712)


420-420: Avoid equality comparisons to True; use Kind.is_active: for truth checks

Replace with Kind.is_active

(E712)

🤖 Prompt for AI Agents
In backend/app/api/endpoints/adapter/tasks.py around lines 335 to 438, the four
share endpoints should have explicit function return type hints and avoid magic
HTTP status numbers; add return type annotations matching each response_model
(e.g., -> TaskShareResponse, -> TaskShareInfo, -> PublicSharedTaskResponse, ->
JoinSharedTaskResponse) to the respective function signatures, and replace
literal status_code=400/404 in HTTPException calls with the status constants
(status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND) already available from
fastapi; keep behavior the same otherwise.


2 changes: 1 addition & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://127.0.0.1:6379/0"

# Team sharing configuration
TEAM_SHARE_BASE_URL: str = "http://localhost:3000"
TEAM_SHARE_BASE_URL: str = "http://localhost:3000/chat"
TEAM_SHARE_QUERY_PARAM: str = "teamShare"

# AES encryption configuration for share tokens
Expand Down
49 changes: 49 additions & 0 deletions backend/app/models/shared_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.base import Base


class SharedTask(Base):
"""
Shared Task model - Records task sharing relationships
When a user shares a task, the task's content (including subtasks/messages)
can be copied to another user's task list.
"""
__tablename__ = "shared_tasks"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)

# User who joined/copied the shared task
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)

# Original user who created/shared the task
original_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)

# Original task ID that was shared
original_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=False, index=True)

# New task ID created for the user who joined (copied task)
copied_task_id = Column(Integer, ForeignKey("kinds.id", ondelete="CASCADE"), nullable=True, index=True)

# Whether this share relationship is active
is_active = Column(Boolean, default=True, nullable=False)

# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
Comment on lines +34 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

datetime.utcnow deprecated Python 3.12

💡 Result:

Yes — in Python 3.12 datetime.datetime.utcnow() (and utcfromtimestamp()) is deprecated. Use timezone-aware UTC datetimes instead, e.g.:

from datetime import datetime, timezone
now = datetime.now(timezone.utc)

The deprecation is documented in Python 3.12 “What’s New” / deprecations notes. [1][2]

Sources:
[1] What’s New In Python 3.12 (datetime deprecation).
[2] Deprecations — Python 3.12 (utcnow / utcfromtimestamp).


🏁 Script executed:

# First, let's examine the shared_task.py file to understand the current state
find . -path "*/backend/app/models/shared_task.py" -type f

Repository: wecode-ai/Wegent

Length of output: 95


🏁 Script executed:

# Check if the file exists and view its content
cat -n backend/app/models/shared_task.py 2>/dev/null || echo "File not found at expected path, searching..."

Repository: wecode-ai/Wegent

Length of output: 2529


🏁 Script executed:

# Search for shared_task.py file
fd -t f "shared_task.py" backend/

Repository: wecode-ai/Wegent

Length of output: 164


🏁 Script executed:

# Search for datetime.utcnow usage in backend
rg "utcnow" backend/ --type py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 4168


Replace deprecated datetime.utcnow() with timezone-aware alternative.

datetime.utcnow() is deprecated as of Python 3.12. Use datetime.now(timezone.utc) wrapped in a lambda to ensure timezone-aware UTC datetimes. Add timezone to the import statement.

-from datetime import datetime
+from datetime import datetime, timezone
 from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
 from sqlalchemy.orm import relationship
 from app.db.base import Base


 class SharedTask(Base):
     # Timestamps
-    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
-    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
+    updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class SharedTask(Base):
# Timestamps
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
🤖 Prompt for AI Agents
In backend/app/models/shared_task.py around lines 34 to 35, the model uses the
deprecated naive datetime.utcnow(); replace it with timezone-aware calls by
importing timezone from datetime and using lambdas: set default=lambda:
datetime.now(timezone.utc) and onupdate=lambda: datetime.now(timezone.utc) so
SQLAlchemy stores UTC-aware datetimes; update the import line to include
timezone.


# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="shared_tasks")
original_user = relationship("User", foreign_keys=[original_user_id])
original_task = relationship("Kind", foreign_keys=[original_task_id])
copied_task = relationship("Kind", foreign_keys=[copied_task_id])

# Unique constraint: one user can only copy the same original task once
__table_args__ = (
UniqueConstraint('user_id', 'original_task_id', name='uq_user_original_task'),
)

def __repr__(self):
return f"<SharedTask(id={self.id}, user_id={self.user_id}, original_task_id={self.original_task_id})>"
3 changes: 3 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class User(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())

# Relationships
shared_tasks = relationship("SharedTask", foreign_keys="[SharedTask.user_id]", back_populates="user")

__table_args__ = (
{
"sqlite_autoincrement": True,
Expand Down
Loading