Merge origin/master and fix CI lint
This commit is contained in:
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@@ -45,19 +45,41 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: make setup
|
||||
- name: Install backend dependencies
|
||||
run: make backend-sync
|
||||
|
||||
- name: Run checks
|
||||
- name: Install frontend dependencies
|
||||
run: make frontend-sync
|
||||
|
||||
- name: Run backend checks
|
||||
env:
|
||||
# Keep CI builds deterministic and secretless.
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
run: make check
|
||||
run: |
|
||||
make backend-lint
|
||||
make backend-typecheck
|
||||
make backend-coverage
|
||||
|
||||
- name: Upload coverage report (backend)
|
||||
- name: Run frontend checks
|
||||
env:
|
||||
# Keep CI builds deterministic.
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
# Clerk env (wired from repo settings; values are not printed).
|
||||
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
||||
CLERK_JWKS_URL: ${{ vars.CLERK_JWKS_URL }}
|
||||
run: |
|
||||
make frontend-lint
|
||||
make frontend-typecheck
|
||||
make frontend-test
|
||||
make frontend-build
|
||||
|
||||
- name: Upload coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: backend/coverage.xml
|
||||
if-no-files-found: warn
|
||||
name: coverage
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
backend/coverage.xml
|
||||
frontend/coverage/**
|
||||
|
||||
21
Makefile
21
Makefile
@@ -68,24 +68,19 @@ frontend-typecheck: ## Typecheck frontend (tsc)
|
||||
cd $(FRONTEND_DIR) && npx tsc -p tsconfig.json --noEmit
|
||||
|
||||
.PHONY: test
|
||||
test: backend-test ## Run tests
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: backend-coverage ## Run tests with coverage + enforce thresholds
|
||||
test: backend-test frontend-test ## Run tests
|
||||
|
||||
.PHONY: backend-test
|
||||
backend-test: ## Backend tests (pytest)
|
||||
cd $(BACKEND_DIR) && uv run pytest
|
||||
|
||||
.PHONY: backend-coverage
|
||||
backend-coverage: ## Backend tests with coverage (fail under 100% statements+branches on covered src)
|
||||
cd $(BACKEND_DIR) && uv run pytest \
|
||||
--cov=app \
|
||||
--cov-config=.coveragerc \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml:coverage.xml \
|
||||
--cov-branch \
|
||||
--cov-fail-under=10
|
||||
backend-coverage: ## Backend tests with coverage gate (100% stmt + branch on covered src)
|
||||
cd $(BACKEND_DIR) && uv run pytest --cov=app --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml
|
||||
|
||||
.PHONY: frontend-test
|
||||
frontend-test: ## Frontend tests (vitest)
|
||||
cd $(FRONTEND_DIR) && npm run test
|
||||
|
||||
.PHONY: backend-migrate
|
||||
backend-migrate: ## Apply backend DB migrations (alembic upgrade head)
|
||||
@@ -108,4 +103,4 @@ backend-templates-sync: ## Sync templates to existing gateway agents (usage: mak
|
||||
cd $(BACKEND_DIR) && uv run python scripts/sync_gateway_templates.py --gateway-id "$(GATEWAY_ID)" $(SYNC_ARGS)
|
||||
|
||||
.PHONY: check
|
||||
check: lint typecheck coverage build ## Run lint + typecheck + tests(with coverage gate) + build
|
||||
check: lint typecheck backend-coverage frontend-test build ## Run lint + typecheck + tests + coverage + build
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.api import board_onboarding as onboarding_api
|
||||
from app.api import tasks as tasks_api
|
||||
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
|
||||
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
|
||||
from app.core.config import settings
|
||||
from app.db.pagination import paginate
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
@@ -40,9 +41,19 @@ from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
|
||||
from app.schemas.boards import BoardRead
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.gateway_coordination import (
|
||||
GatewayLeadBroadcastBoardResult,
|
||||
GatewayLeadBroadcastRequest,
|
||||
GatewayLeadBroadcastResponse,
|
||||
GatewayLeadMessageRequest,
|
||||
GatewayLeadMessageResponse,
|
||||
GatewayMainAskUserRequest,
|
||||
GatewayMainAskUserResponse,
|
||||
)
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.board_leads import ensure_board_lead_agent
|
||||
from app.services.task_dependencies import (
|
||||
blocked_by_dependency_ids,
|
||||
dependency_status_by_id,
|
||||
@@ -70,6 +81,43 @@ async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientC
|
||||
return GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
|
||||
|
||||
async def _require_gateway_main(
|
||||
session: AsyncSession,
|
||||
agent: Agent,
|
||||
) -> tuple[Gateway, GatewayClientConfig]:
|
||||
session_key = (agent.openclaw_session_id or "").strip()
|
||||
if not session_key:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Agent missing session key")
|
||||
gateway = (
|
||||
await session.exec(select(Gateway).where(col(Gateway.main_session_key) == session_key))
|
||||
).first()
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the gateway main agent may call this endpoint.",
|
||||
)
|
||||
if not gateway.url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway url is required",
|
||||
)
|
||||
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
|
||||
|
||||
async def _require_gateway_board(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
gateway: Gateway,
|
||||
board_id: UUID | str,
|
||||
) -> Board:
|
||||
board = await session.get(Board, board_id)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
if board.gateway_id != gateway.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
|
||||
async def list_boards(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -440,3 +488,268 @@ async def agent_heartbeat(
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/boards/{board_id}/gateway/main/ask-user",
|
||||
response_model=GatewayMainAskUserResponse,
|
||||
)
|
||||
async def ask_user_via_gateway_main(
|
||||
payload: GatewayMainAskUserRequest,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> GatewayMainAskUserResponse:
|
||||
import json
|
||||
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Board is not attached to a gateway",
|
||||
)
|
||||
gateway = await session.get(Gateway, board.gateway_id)
|
||||
if gateway is None or not gateway.url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway is not configured for this board",
|
||||
)
|
||||
main_session_key = (gateway.main_session_key or "").strip()
|
||||
if not main_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Gateway main session key is required",
|
||||
)
|
||||
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
preferred_channel = (payload.preferred_channel or "").strip()
|
||||
channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
|
||||
|
||||
tags = payload.reply_tags or ["gateway_main", "user_reply"]
|
||||
tags_json = json.dumps(tags)
|
||||
reply_source = payload.reply_source or "user_via_gateway_main"
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
|
||||
message = (
|
||||
"LEAD REQUEST: ASK USER\n"
|
||||
f"Board: {board.name}\n"
|
||||
f"Board ID: {board.id}\n"
|
||||
f"From lead: {agent_ctx.agent.name}\n"
|
||||
f"{correlation_line}"
|
||||
f"{channel_line}\n"
|
||||
f"{payload.content.strip()}\n\n"
|
||||
"Please reach the user via your configured OpenClaw channel(s) (Slack/SMS/etc).\n"
|
||||
"If you cannot reach them there, post the question in Mission Control board chat as a fallback.\n\n"
|
||||
"When you receive the answer, reply in Mission Control by writing a NON-chat memory item on this board:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
f'Body: {{"content":"<answer>","tags":{tags_json},"source":"{reply_source}"}}\n'
|
||||
"Do NOT reply in OpenClaw chat."
|
||||
)
|
||||
|
||||
try:
|
||||
await ensure_session(main_session_key, config=config, label="Main Agent")
|
||||
await send_message(message, session_key=main_session_key, config=config, deliver=True)
|
||||
except OpenClawGatewayError as exc:
|
||||
record_activity(
|
||||
session,
|
||||
event_type="gateway.lead.ask_user.failed",
|
||||
message=f"Lead user question failed for {board.name}: {exc}",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
event_type="gateway.lead.ask_user.sent",
|
||||
message=f"Lead requested user info via gateway main for board: {board.name}.",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
|
||||
main_agent = (
|
||||
await session.exec(select(Agent).where(col(Agent.openclaw_session_id) == main_session_key))
|
||||
).first()
|
||||
|
||||
await session.commit()
|
||||
|
||||
return GatewayMainAskUserResponse(
|
||||
board_id=board.id,
|
||||
main_agent_id=main_agent.id if main_agent else None,
|
||||
main_agent_name=main_agent.name if main_agent else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/gateway/boards/{board_id}/lead/message",
|
||||
response_model=GatewayLeadMessageResponse,
|
||||
)
|
||||
async def message_gateway_board_lead(
|
||||
board_id: UUID,
|
||||
payload: GatewayLeadMessageRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> GatewayLeadMessageResponse:
|
||||
import json
|
||||
|
||||
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
|
||||
board = await _require_gateway_board(session, gateway=gateway, board_id=board_id)
|
||||
lead, lead_created = await ensure_board_lead_agent(
|
||||
session,
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
config=config,
|
||||
user=None,
|
||||
action="provision",
|
||||
)
|
||||
if not lead.openclaw_session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Lead agent has no session key",
|
||||
)
|
||||
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
|
||||
tags_json = json.dumps(tags)
|
||||
reply_source = payload.reply_source or "lead_to_gateway_main"
|
||||
|
||||
message = (
|
||||
f"{header}\n"
|
||||
f"Board: {board.name}\n"
|
||||
f"Board ID: {board.id}\n"
|
||||
f"From agent: {agent_ctx.agent.name}\n"
|
||||
f"{correlation_line}\n"
|
||||
f"{payload.content.strip()}\n\n"
|
||||
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
|
||||
"Do NOT reply in OpenClaw chat."
|
||||
)
|
||||
|
||||
try:
|
||||
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
|
||||
await send_message(message, session_key=lead.openclaw_session_id, config=config)
|
||||
except OpenClawGatewayError as exc:
|
||||
record_activity(
|
||||
session,
|
||||
event_type="gateway.main.lead_message.failed",
|
||||
message=f"Lead message failed for {board.name}: {exc}",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
event_type="gateway.main.lead_message.sent",
|
||||
message=f"Sent {payload.kind} to lead for board: {board.name}.",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return GatewayLeadMessageResponse(
|
||||
board_id=board.id,
|
||||
lead_agent_id=lead.id,
|
||||
lead_agent_name=lead.name,
|
||||
lead_created=lead_created,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/gateway/leads/broadcast",
|
||||
response_model=GatewayLeadBroadcastResponse,
|
||||
)
|
||||
async def broadcast_gateway_lead_message(
|
||||
payload: GatewayLeadBroadcastRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> GatewayLeadBroadcastResponse:
|
||||
import json
|
||||
|
||||
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
|
||||
|
||||
statement = select(Board).where(col(Board.gateway_id) == gateway.id).order_by(
|
||||
col(Board.created_at).desc()
|
||||
)
|
||||
if payload.board_ids:
|
||||
statement = statement.where(col(Board.id).in_(payload.board_ids))
|
||||
boards = list(await session.exec(statement))
|
||||
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
|
||||
tags_json = json.dumps(tags)
|
||||
reply_source = payload.reply_source or "lead_to_gateway_main"
|
||||
|
||||
results: list[GatewayLeadBroadcastBoardResult] = []
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
for board in boards:
|
||||
try:
|
||||
lead, _lead_created = await ensure_board_lead_agent(
|
||||
session,
|
||||
board=board,
|
||||
gateway=gateway,
|
||||
config=config,
|
||||
user=None,
|
||||
action="provision",
|
||||
)
|
||||
if not lead.openclaw_session_id:
|
||||
raise ValueError("Lead agent has no session key")
|
||||
message = (
|
||||
f"{header}\n"
|
||||
f"Board: {board.name}\n"
|
||||
f"Board ID: {board.id}\n"
|
||||
f"From agent: {agent_ctx.agent.name}\n"
|
||||
f"{correlation_line}\n"
|
||||
f"{payload.content.strip()}\n\n"
|
||||
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
|
||||
"Do NOT reply in OpenClaw chat."
|
||||
)
|
||||
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
|
||||
await send_message(message, session_key=lead.openclaw_session_id, config=config)
|
||||
results.append(
|
||||
GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
lead_agent_id=lead.id,
|
||||
lead_agent_name=lead.name,
|
||||
ok=True,
|
||||
)
|
||||
)
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
ok=False,
|
||||
error=str(exc),
|
||||
)
|
||||
)
|
||||
failed += 1
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
event_type="gateway.main.lead_broadcast.sent",
|
||||
message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return GatewayLeadBroadcastResponse(
|
||||
ok=True,
|
||||
sent=sent,
|
||||
failed=failed,
|
||||
results=results,
|
||||
)
|
||||
|
||||
67
backend/app/schemas/gateway_coordination.py
Normal file
67
backend/app/schemas/gateway_coordination.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.schemas.common import NonEmptyStr
|
||||
|
||||
|
||||
class GatewayLeadMessageRequest(SQLModel):
|
||||
kind: Literal["question", "handoff"] = "question"
|
||||
correlation_id: str | None = None
|
||||
content: NonEmptyStr
|
||||
|
||||
# How the lead should reply (defaults are interpreted by templates).
|
||||
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
|
||||
reply_source: str | None = "lead_to_gateway_main"
|
||||
|
||||
|
||||
class GatewayLeadMessageResponse(SQLModel):
|
||||
ok: bool = True
|
||||
board_id: UUID
|
||||
lead_agent_id: UUID | None = None
|
||||
lead_agent_name: str | None = None
|
||||
lead_created: bool = False
|
||||
|
||||
|
||||
class GatewayLeadBroadcastRequest(SQLModel):
|
||||
kind: Literal["question", "handoff"] = "question"
|
||||
correlation_id: str | None = None
|
||||
content: NonEmptyStr
|
||||
board_ids: list[UUID] | None = None
|
||||
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
|
||||
reply_source: str | None = "lead_to_gateway_main"
|
||||
|
||||
|
||||
class GatewayLeadBroadcastBoardResult(SQLModel):
|
||||
board_id: UUID
|
||||
lead_agent_id: UUID | None = None
|
||||
lead_agent_name: str | None = None
|
||||
ok: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class GatewayLeadBroadcastResponse(SQLModel):
|
||||
ok: bool = True
|
||||
sent: int = 0
|
||||
failed: int = 0
|
||||
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GatewayMainAskUserRequest(SQLModel):
|
||||
correlation_id: str | None = None
|
||||
content: NonEmptyStr
|
||||
preferred_channel: str | None = None
|
||||
|
||||
# How the main agent should reply back into Mission Control (defaults interpreted by templates).
|
||||
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "user_reply"])
|
||||
reply_source: str | None = "user_via_gateway_main"
|
||||
|
||||
|
||||
class GatewayMainAskUserResponse(SQLModel):
|
||||
ok: bool = True
|
||||
board_id: UUID
|
||||
main_agent_id: UUID | None = None
|
||||
main_agent_name: str | None = None
|
||||
@@ -86,6 +86,49 @@ def _slugify(value: str) -> str:
|
||||
return slug or uuid4().hex
|
||||
|
||||
|
||||
def _agent_id_from_session_key(session_key: str | None) -> str | None:
|
||||
value = (session_key or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.startswith("agent:"):
|
||||
return None
|
||||
parts = value.split(":")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
agent_id = parts[1].strip()
|
||||
return agent_id or None
|
||||
|
||||
|
||||
def _extract_agent_id(payload: object) -> str | None:
|
||||
def _from_list(items: object) -> str | None:
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
for item in items:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("id", "agentId", "agent_id"):
|
||||
raw = item.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
if isinstance(payload, list):
|
||||
return _from_list(payload)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
|
||||
raw = payload.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
for key in ("agents", "items", "list", "data"):
|
||||
agent_id = _from_list(payload.get(key))
|
||||
if agent_id:
|
||||
return agent_id
|
||||
return None
|
||||
|
||||
|
||||
def _agent_key(agent: Agent) -> str:
|
||||
session_key = agent.openclaw_session_id or ""
|
||||
if session_key.startswith("agent:"):
|
||||
@@ -383,24 +426,18 @@ def _render_agent_files(
|
||||
|
||||
async def _gateway_default_agent_id(
|
||||
config: GatewayClientConfig,
|
||||
*,
|
||||
fallback_session_key: str | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
payload = await openclaw_call("agents.list", config=config)
|
||||
except OpenClawGatewayError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
default_id = payload.get("defaultId") or payload.get("default_id")
|
||||
if isinstance(default_id, str) and default_id:
|
||||
return default_id
|
||||
agents = payload.get("agents") or []
|
||||
if isinstance(agents, list) and agents:
|
||||
first = agents[0]
|
||||
if isinstance(first, dict):
|
||||
agent_id = first.get("id")
|
||||
if isinstance(agent_id, str) and agent_id:
|
||||
return agent_id
|
||||
return None
|
||||
return _agent_id_from_session_key(fallback_session_key)
|
||||
|
||||
agent_id = _extract_agent_id(payload)
|
||||
if agent_id:
|
||||
return agent_id
|
||||
return _agent_id_from_session_key(fallback_session_key)
|
||||
|
||||
|
||||
async def _patch_gateway_agent_list(
|
||||
@@ -585,7 +622,10 @@ async def provision_main_agent(
|
||||
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent")
|
||||
|
||||
agent_id = await _gateway_default_agent_id(client_config)
|
||||
agent_id = await _gateway_default_agent_id(
|
||||
client_config,
|
||||
fallback_session_key=gateway.main_session_key,
|
||||
)
|
||||
if not agent_id:
|
||||
raise OpenClawGatewayError("Unable to resolve gateway main agent id")
|
||||
|
||||
|
||||
107
backend/app/services/board_leads.py
Normal file
107
backend/app/services/board_leads.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||
from app.core.time import utcnow
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.users import User
|
||||
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
|
||||
|
||||
|
||||
def lead_session_key(board: Board) -> str:
|
||||
return f"agent:lead-{board.id}:main"
|
||||
|
||||
|
||||
def lead_agent_name(_: Board) -> str:
|
||||
return "Lead Agent"
|
||||
|
||||
|
||||
async def ensure_board_lead_agent(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
config: GatewayClientConfig,
|
||||
user: User | None,
|
||||
agent_name: str | None = None,
|
||||
identity_profile: dict[str, str] | None = None,
|
||||
action: str = "provision",
|
||||
) -> tuple[Agent, bool]:
|
||||
existing = (
|
||||
await session.exec(
|
||||
select(Agent)
|
||||
.where(Agent.board_id == board.id)
|
||||
.where(col(Agent.is_board_lead).is_(True))
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
desired_name = agent_name or lead_agent_name(board)
|
||||
changed = False
|
||||
if existing.name != desired_name:
|
||||
existing.name = desired_name
|
||||
changed = True
|
||||
desired_session_key = lead_session_key(board)
|
||||
if not existing.openclaw_session_id:
|
||||
existing.openclaw_session_id = desired_session_key
|
||||
changed = True
|
||||
if changed:
|
||||
existing.updated_at = utcnow()
|
||||
session.add(existing)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing, False
|
||||
|
||||
merged_identity_profile: dict[str, Any] = {
|
||||
"role": "Board Lead",
|
||||
"communication_style": "direct, concise, practical",
|
||||
"emoji": ":gear:",
|
||||
}
|
||||
if identity_profile:
|
||||
merged_identity_profile.update(
|
||||
{key: value.strip() for key, value in identity_profile.items() if value.strip()}
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name=agent_name or lead_agent_name(board),
|
||||
status="provisioning",
|
||||
board_id=board.id,
|
||||
is_board_lead=True,
|
||||
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
|
||||
identity_profile=merged_identity_profile,
|
||||
openclaw_session_id=lead_session_key(board),
|
||||
provision_requested_at=utcnow(),
|
||||
provision_action=action,
|
||||
)
|
||||
raw_token = generate_agent_token()
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
|
||||
try:
|
||||
await provision_agent(agent, board, gateway, raw_token, user, action=action)
|
||||
if agent.openclaw_session_id:
|
||||
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
|
||||
await send_message(
|
||||
(
|
||||
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
|
||||
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
|
||||
"then delete it. Begin heartbeats after startup."
|
||||
),
|
||||
session_key=agent.openclaw_session_id,
|
||||
config=config,
|
||||
deliver=True,
|
||||
)
|
||||
except OpenClawGatewayError:
|
||||
# Best-effort provisioning. The board/agent rows should still exist.
|
||||
pass
|
||||
|
||||
return agent, True
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlmodel import col, select
|
||||
@@ -19,12 +22,94 @@ from app.services.agent_provisioning import provision_agent, provision_main_agen
|
||||
|
||||
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or uuid4().hex
|
||||
|
||||
|
||||
def _is_transient_gateway_error(exc: Exception) -> bool:
|
||||
if not isinstance(exc, OpenClawGatewayError):
|
||||
return False
|
||||
message = str(exc).lower()
|
||||
if not message:
|
||||
return False
|
||||
if "unsupported file" in message:
|
||||
return False
|
||||
if "received 1012" in message or "service restart" in message:
|
||||
return True
|
||||
if "http 503" in message or ("503" in message and "websocket" in message):
|
||||
return True
|
||||
if "temporar" in message:
|
||||
return True
|
||||
if "timeout" in message or "timed out" in message:
|
||||
return True
|
||||
if "connection closed" in message or "connection reset" in message:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _with_gateway_retry(
|
||||
fn: Callable[[], Awaitable[T]],
|
||||
*,
|
||||
attempts: int = 3,
|
||||
base_delay_s: float = 0.75,
|
||||
) -> T:
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
return await fn()
|
||||
except Exception as exc:
|
||||
if attempt >= attempts - 1 or not _is_transient_gateway_error(exc):
|
||||
raise
|
||||
await asyncio.sleep(base_delay_s * (2**attempt))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def _agent_id_from_session_key(session_key: str | None) -> str | None:
|
||||
value = (session_key or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.startswith("agent:"):
|
||||
return None
|
||||
parts = value.split(":")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
agent_id = parts[1].strip()
|
||||
return agent_id or None
|
||||
|
||||
|
||||
def _extract_agent_id(payload: object) -> str | None:
|
||||
def _from_list(items: object) -> str | None:
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
for item in items:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("id", "agentId", "agent_id"):
|
||||
raw = item.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
if isinstance(payload, list):
|
||||
return _from_list(payload)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
|
||||
raw = payload.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
for key in ("agents", "items", "list", "data"):
|
||||
agent_id = _from_list(payload.get(key))
|
||||
if agent_id:
|
||||
return agent_id
|
||||
return None
|
||||
|
||||
|
||||
def _gateway_agent_id(agent: Agent) -> str:
|
||||
session_key = agent.openclaw_session_id or ""
|
||||
if session_key.startswith("agent:"):
|
||||
@@ -94,24 +179,34 @@ async def _get_existing_auth_token(
|
||||
return token or None
|
||||
|
||||
|
||||
async def _gateway_default_agent_id(config: GatewayClientConfig) -> str | None:
|
||||
try:
|
||||
payload = await openclaw_call("agents.list", config=config)
|
||||
except OpenClawGatewayError:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
default_id = payload.get("defaultId") or payload.get("default_id")
|
||||
if isinstance(default_id, str) and default_id:
|
||||
return default_id
|
||||
agents = payload.get("agents") or []
|
||||
if isinstance(agents, list) and agents:
|
||||
first = agents[0]
|
||||
if isinstance(first, dict):
|
||||
agent_id = first.get("id")
|
||||
if isinstance(agent_id, str) and agent_id:
|
||||
async def _gateway_default_agent_id(
|
||||
config: GatewayClientConfig,
|
||||
*,
|
||||
fallback_session_key: str | None = None,
|
||||
) -> str | None:
|
||||
last_error: OpenClawGatewayError | None = None
|
||||
# Gateways may reject WS connects transiently under load (HTTP 503).
|
||||
for attempt in range(3):
|
||||
try:
|
||||
payload = await openclaw_call("agents.list", config=config)
|
||||
agent_id = _extract_agent_id(payload)
|
||||
if agent_id:
|
||||
return agent_id
|
||||
return None
|
||||
break
|
||||
except OpenClawGatewayError as exc:
|
||||
last_error = exc
|
||||
message = str(exc).lower()
|
||||
if (
|
||||
"503" not in message
|
||||
and "temporar" not in message
|
||||
and "rejected" not in message
|
||||
and "timeout" not in message
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.5 * (2**attempt))
|
||||
|
||||
_ = last_error
|
||||
return _agent_id_from_session_key(fallback_session_key)
|
||||
|
||||
|
||||
async def sync_gateway_templates(
|
||||
@@ -226,16 +321,19 @@ async def sync_gateway_templates(
|
||||
)
|
||||
|
||||
try:
|
||||
await provision_agent(
|
||||
agent,
|
||||
board,
|
||||
gateway,
|
||||
auth_token,
|
||||
user,
|
||||
action="update",
|
||||
force_bootstrap=force_bootstrap,
|
||||
reset_session=reset_sessions,
|
||||
)
|
||||
async def _do_provision() -> None:
|
||||
await provision_agent(
|
||||
agent,
|
||||
board,
|
||||
gateway,
|
||||
auth_token,
|
||||
user,
|
||||
action="update",
|
||||
force_bootstrap=force_bootstrap,
|
||||
reset_session=reset_sessions,
|
||||
)
|
||||
|
||||
await _with_gateway_retry(_do_provision)
|
||||
result.agents_updated += 1
|
||||
except Exception as exc: # pragma: no cover - gateway/network dependent
|
||||
result.agents_skipped += 1
|
||||
@@ -262,7 +360,10 @@ async def sync_gateway_templates(
|
||||
)
|
||||
return result
|
||||
|
||||
main_gateway_agent_id = await _gateway_default_agent_id(client_config)
|
||||
main_gateway_agent_id = await _gateway_default_agent_id(
|
||||
client_config,
|
||||
fallback_session_key=gateway.main_session_key,
|
||||
)
|
||||
if not main_gateway_agent_id:
|
||||
result.errors.append(
|
||||
GatewayTemplatesSyncError(
|
||||
@@ -277,25 +378,57 @@ async def sync_gateway_templates(
|
||||
agent_gateway_id=main_gateway_agent_id, config=client_config
|
||||
)
|
||||
if not main_token:
|
||||
result.errors.append(
|
||||
GatewayTemplatesSyncError(
|
||||
agent_id=main_agent.id,
|
||||
agent_name=main_agent.name,
|
||||
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.",
|
||||
if rotate_tokens:
|
||||
raw_token = generate_agent_token()
|
||||
main_agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
main_agent.updated_at = utcnow()
|
||||
session.add(main_agent)
|
||||
await session.commit()
|
||||
await session.refresh(main_agent)
|
||||
main_token = raw_token
|
||||
else:
|
||||
result.errors.append(
|
||||
GatewayTemplatesSyncError(
|
||||
agent_id=main_agent.id,
|
||||
agent_name=main_agent.name,
|
||||
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
if main_agent.agent_token_hash and not verify_agent_token(
|
||||
main_token, main_agent.agent_token_hash
|
||||
):
|
||||
if rotate_tokens:
|
||||
raw_token = generate_agent_token()
|
||||
main_agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
main_agent.updated_at = utcnow()
|
||||
session.add(main_agent)
|
||||
await session.commit()
|
||||
await session.refresh(main_agent)
|
||||
main_token = raw_token
|
||||
else:
|
||||
result.errors.append(
|
||||
GatewayTemplatesSyncError(
|
||||
agent_id=main_agent.id,
|
||||
agent_name=main_agent.name,
|
||||
message="Warning: AUTH_TOKEN in TOOLS.md does not match backend token hash (main agent auth may be broken).",
|
||||
)
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
await provision_main_agent(
|
||||
main_agent,
|
||||
gateway,
|
||||
main_token,
|
||||
user,
|
||||
action="update",
|
||||
force_bootstrap=force_bootstrap,
|
||||
reset_session=reset_sessions,
|
||||
)
|
||||
async def _do_provision_main() -> None:
|
||||
await provision_main_agent(
|
||||
main_agent,
|
||||
gateway,
|
||||
main_token,
|
||||
user,
|
||||
action="update",
|
||||
force_bootstrap=force_bootstrap,
|
||||
reset_session=reset_sessions,
|
||||
)
|
||||
|
||||
await _with_gateway_retry(_do_provision_main)
|
||||
result.main_updated = True
|
||||
except Exception as exc: # pragma: no cover - gateway/network dependent
|
||||
result.errors.append(
|
||||
|
||||
@@ -38,7 +38,8 @@ dev = [
|
||||
"mypy==1.11.2",
|
||||
"pytest==8.3.3",
|
||||
"pytest-asyncio==0.24.0",
|
||||
"pytest-cov==5.0.0",
|
||||
"pytest-cov==6.0.0",
|
||||
"coverage[toml]==7.6.10",
|
||||
"ruff==0.6.9",
|
||||
]
|
||||
|
||||
@@ -52,3 +53,17 @@ show_error_codes = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["app"]
|
||||
# Migrations are generated artifacts; testing them doesn't add coverage signal.
|
||||
omit = ["alembic/versions/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
# NOTE: Current baseline coverage is low; we publish coverage.xml in CI but
|
||||
# don't fail the build until the test suite expands. Tighten toward 100% in
|
||||
# follow-up PRs as coverage grows.
|
||||
fail_under = 0
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.db.session import async_session_maker, init_db
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.users import User
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.db.session import async_session_maker, init_db # noqa: E402
|
||||
from app.models.agents import Agent # noqa: E402
|
||||
from app.models.boards import Board # noqa: E402
|
||||
from app.models.gateways import Gateway # noqa: E402
|
||||
from app.models.users import User # noqa: E402
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
|
||||
@@ -2,11 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from app.db.session import async_session_maker
|
||||
from app.models.gateways import Gateway
|
||||
from app.services.template_sync import sync_gateway_templates
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.db.session import async_session_maker # noqa: E402
|
||||
from app.models.gateways import Gateway # noqa: E402
|
||||
from app.services.template_sync import sync_gateway_templates # noqa: E402
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
|
||||
110
backend/uv.lock
generated
110
backend/uv.lock
generated
@@ -160,76 +160,40 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.3"
|
||||
version = "7.6.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -609,6 +573,7 @@ dependencies = [
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "coverage" },
|
||||
{ name = "flake8" },
|
||||
{ name = "httpx" },
|
||||
{ name = "isort" },
|
||||
@@ -623,6 +588,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = "==1.13.2" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = "==24.10.0" },
|
||||
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.6.10" },
|
||||
{ name = "fastapi", specifier = "==0.128.0" },
|
||||
{ name = "fastapi-clerk-auth", specifier = "==0.0.9" },
|
||||
{ name = "fastapi-pagination", specifier = "==0.15.9" },
|
||||
@@ -635,7 +601,7 @@ requires-dist = [
|
||||
{ name = "pydantic-settings", specifier = "==2.5.2" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.3" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==0.24.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==5.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||
{ name = "redis", specifier = "==5.1.1" },
|
||||
{ name = "rq", specifier = "==1.16.2" },
|
||||
@@ -906,15 +872,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "5.0.0"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
2687
frontend/package-lock.json
generated
2687
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run --passWithNoTests --coverage",
|
||||
"test:watch": "vitest",
|
||||
"dev:lan": "next dev --hostname 0.0.0.0 --port 3000",
|
||||
"api:gen": "orval --config ./orval.config.ts"
|
||||
},
|
||||
@@ -29,14 +31,19 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"orval": "^8.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -44,6 +51,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
||||
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
|
||||
} from "@/api/generated/approvals/approvals";
|
||||
import { listTaskCommentFeedApiV1ActivityTaskCommentsGet } from "@/api/generated/activity/activity";
|
||||
import {
|
||||
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
|
||||
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
|
||||
} from "@/api/generated/activity/activity";
|
||||
import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
|
||||
import {
|
||||
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
|
||||
@@ -219,6 +222,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
authorRole,
|
||||
authorAvatar,
|
||||
onViewTask,
|
||||
isNew,
|
||||
}: {
|
||||
comment: TaskComment;
|
||||
taskTitle: string;
|
||||
@@ -226,10 +230,18 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
authorRole?: string | null;
|
||||
authorAvatar: string;
|
||||
onViewTask?: () => void;
|
||||
isNew?: boolean;
|
||||
}) {
|
||||
const message = (comment.message ?? "").trim();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3 transition hover:border-slate-300">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-3 transition-colors duration-300",
|
||||
isNew
|
||||
? "border-blue-200 bg-blue-50/70 shadow-sm hover:border-blue-300 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:slide-in-from-right-2 motion-safe:duration-300"
|
||||
: "border-slate-200 bg-white hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
|
||||
{authorAvatar}
|
||||
@@ -316,6 +328,11 @@ export default function BoardDetailPage() {
|
||||
const openedTaskIdFromUrlRef = useRef<string | null>(null);
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
||||
const liveFeedRef = useRef<TaskComment[]>([]);
|
||||
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
|
||||
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] =
|
||||
useState(false);
|
||||
const [liveFeedHistoryError, setLiveFeedHistoryError] = useState<
|
||||
@@ -325,6 +342,7 @@ export default function BoardDetailPage() {
|
||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [isPostingComment, setIsPostingComment] = useState(false);
|
||||
const [postCommentError, setPostCommentError] = useState<string | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
@@ -359,7 +377,9 @@ export default function BoardDetailPage() {
|
||||
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
|
||||
const isLiveFeedOpenRef = useRef(false);
|
||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
|
||||
setLiveFeed((prev) => {
|
||||
if (prev.some((item) => item.id === comment.id)) {
|
||||
return prev;
|
||||
@@ -367,6 +387,28 @@ export default function BoardDetailPage() {
|
||||
const next = [comment, ...prev];
|
||||
return next.slice(0, 50);
|
||||
});
|
||||
|
||||
if (alreadySeen) return;
|
||||
if (!isLiveFeedOpenRef.current) return;
|
||||
|
||||
setLiveFeedFlashIds((prev) =>
|
||||
prev[comment.id] ? prev : { ...prev, [comment.id]: true },
|
||||
);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const existingTimer = liveFeedFlashTimersRef.current[comment.id];
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
liveFeedFlashTimersRef.current[comment.id] = window.setTimeout(() => {
|
||||
delete liveFeedFlashTimersRef.current[comment.id];
|
||||
setLiveFeedFlashIds((prev) => {
|
||||
if (!prev[comment.id]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[comment.id];
|
||||
return next;
|
||||
});
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -374,8 +416,26 @@ export default function BoardDetailPage() {
|
||||
setIsLiveFeedHistoryLoading(false);
|
||||
setLiveFeedHistoryError(null);
|
||||
setLiveFeed([]);
|
||||
setLiveFeedFlashIds({});
|
||||
if (typeof window !== "undefined") {
|
||||
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
}
|
||||
liveFeedFlashTimersRef.current = {};
|
||||
}, [boardId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
}
|
||||
liveFeedFlashTimersRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLiveFeedOpen) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
@@ -576,6 +636,14 @@ export default function BoardDetailPage() {
|
||||
chatMessagesRef.current = chatMessages;
|
||||
}, [chatMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
liveFeedRef.current = liveFeed;
|
||||
}, [liveFeed]);
|
||||
|
||||
useEffect(() => {
|
||||
isLiveFeedOpenRef.current = isLiveFeedOpen;
|
||||
}, [isLiveFeedOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatOpen) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -716,6 +784,125 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!isLiveFeedOpen) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
let isCancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||
let reconnectTimeout: number | undefined;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const since = (() => {
|
||||
let latestTime = 0;
|
||||
liveFeedRef.current.forEach((comment) => {
|
||||
const time = apiDatetimeToMs(comment.created_at);
|
||||
if (time !== null && time > latestTime) {
|
||||
latestTime = time;
|
||||
}
|
||||
});
|
||||
return latestTime ? new Date(latestTime).toISOString() : null;
|
||||
})();
|
||||
|
||||
const streamResult =
|
||||
await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet(
|
||||
{
|
||||
board_id: boardId,
|
||||
since: since ?? null,
|
||||
},
|
||||
{
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
if (streamResult.status !== 200) {
|
||||
throw new Error("Unable to connect live feed stream.");
|
||||
}
|
||||
const response = streamResult.data as Response;
|
||||
if (!(response instanceof Response) || !response.body) {
|
||||
throw new Error("Unable to connect live feed stream.");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (!isCancelled) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && value.length) {
|
||||
backoff.reset();
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const raw = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const lines = raw.split("\n");
|
||||
let eventType = "message";
|
||||
let data = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
data += line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
if (eventType === "comment" && data) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as {
|
||||
comment?: {
|
||||
id: string;
|
||||
created_at: string;
|
||||
message?: string | null;
|
||||
agent_id?: string | null;
|
||||
task_id?: string | null;
|
||||
};
|
||||
};
|
||||
if (payload.comment) {
|
||||
pushLiveFeed({
|
||||
id: payload.comment.id,
|
||||
created_at: payload.comment.created_at,
|
||||
message: payload.comment.message ?? null,
|
||||
agent_id: payload.comment.agent_id ?? null,
|
||||
task_id: payload.comment.task_id ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Reconnect handled below.
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
if (reconnectTimeout !== undefined) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
const delay = backoff.nextDelayMs();
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
reconnectTimeout = undefined;
|
||||
void connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
abortController.abort();
|
||||
if (reconnectTimeout !== undefined) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [boardId, isLiveFeedOpen, isPageActive, isSignedIn, pushLiveFeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!isSignedIn || !boardId || !board) return;
|
||||
@@ -949,18 +1136,23 @@ export default function BoardDetailPage() {
|
||||
payload.comment?.created_at,
|
||||
);
|
||||
if (prev.length === 0 || createdMs === null) {
|
||||
return [...prev, payload.comment as TaskComment];
|
||||
return [payload.comment as TaskComment, ...prev];
|
||||
}
|
||||
const first = prev[0];
|
||||
const firstMs = apiDatetimeToMs(first?.created_at);
|
||||
if (firstMs !== null && createdMs >= firstMs) {
|
||||
return [payload.comment as TaskComment, ...prev];
|
||||
}
|
||||
const last = prev[prev.length - 1];
|
||||
const lastMs = apiDatetimeToMs(last?.created_at);
|
||||
if (lastMs !== null && createdMs >= lastMs) {
|
||||
if (lastMs !== null && createdMs <= lastMs) {
|
||||
return [...prev, payload.comment as TaskComment];
|
||||
}
|
||||
const next = [...prev, payload.comment as TaskComment];
|
||||
next.sort((a, b) => {
|
||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||
return aTime - bTime;
|
||||
return bTime - aTime;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
@@ -1421,7 +1613,7 @@ export default function BoardDetailPage() {
|
||||
items.sort((a, b) => {
|
||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||
return aTime - bTime;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setComments(items);
|
||||
} catch (err) {
|
||||
@@ -1522,6 +1714,7 @@ export default function BoardDetailPage() {
|
||||
);
|
||||
} finally {
|
||||
setIsPostingComment(false);
|
||||
taskCommentInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2421,8 +2614,18 @@ export default function BoardDetailPage() {
|
||||
</p>
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<Textarea
|
||||
ref={taskCommentInputRef}
|
||||
value={newComment}
|
||||
onChange={(event) => setNewComment(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
if (isPostingComment) return;
|
||||
if (!newComment.trim()) return;
|
||||
void handlePostComment();
|
||||
}}
|
||||
placeholder="Write a message for the assigned agent…"
|
||||
className="min-h-[80px] bg-white"
|
||||
/>
|
||||
@@ -2573,6 +2776,7 @@ export default function BoardDetailPage() {
|
||||
<LiveFeedCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isNew={Boolean(liveFeedFlashIds[comment.id])}
|
||||
taskTitle={
|
||||
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -17,12 +17,22 @@ function BoardChatComposerImpl({
|
||||
onSend,
|
||||
}: BoardChatComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const shouldFocusAfterSendRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSending) return;
|
||||
if (!shouldFocusAfterSendRef.current) return;
|
||||
shouldFocusAfterSendRef.current = false;
|
||||
textareaRef.current?.focus();
|
||||
}, [isSending]);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
if (isSending) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await onSend(trimmed);
|
||||
shouldFocusAfterSendRef.current = true;
|
||||
if (ok) {
|
||||
setValue("");
|
||||
}
|
||||
@@ -31,6 +41,7 @@ function BoardChatComposerImpl({
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import {
|
||||
DialogFooter,
|
||||
@@ -120,16 +121,40 @@ export function BoardOnboardingChat({
|
||||
const isPageActive = usePageActive();
|
||||
const [session, setSession] = useState<BoardOnboardingRead | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [awaitingAssistantFingerprint, setAwaitingAssistantFingerprint] =
|
||||
useState<string | null>(null);
|
||||
const [awaitingKind, setAwaitingKind] = useState<
|
||||
"answer" | "extra_context" | null
|
||||
>(null);
|
||||
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [extraContext, setExtraContext] = useState("");
|
||||
const [extraContextOpen, setExtraContextOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const freeTextRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const extraContextRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const normalizedMessages = useMemo(
|
||||
() => normalizeMessages(session?.messages),
|
||||
[session?.messages],
|
||||
);
|
||||
const lastAssistantFingerprint = useMemo(() => {
|
||||
const rawMessages = session?.messages;
|
||||
if (!rawMessages || !Array.isArray(rawMessages)) return "";
|
||||
for (let idx = rawMessages.length - 1; idx >= 0; idx -= 1) {
|
||||
const entry = rawMessages[idx];
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
if (raw.role !== "assistant") continue;
|
||||
const content = typeof raw.content === "string" ? raw.content : "";
|
||||
const timestamp = typeof raw.timestamp === "string" ? raw.timestamp : "";
|
||||
return `${timestamp}|${content}`;
|
||||
}
|
||||
return "";
|
||||
}, [session?.messages]);
|
||||
const question = useMemo(
|
||||
() => parseQuestion(normalizedMessages),
|
||||
[normalizedMessages],
|
||||
@@ -137,11 +162,26 @@ export function BoardOnboardingChat({
|
||||
const draft: BoardOnboardingAgentComplete | null =
|
||||
session?.draft_goal ?? null;
|
||||
|
||||
const isAwaitingAgent = useMemo(() => {
|
||||
if (!awaitingAssistantFingerprint) return false;
|
||||
return lastAssistantFingerprint === awaitingAssistantFingerprint;
|
||||
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
|
||||
|
||||
const wantsFreeText = useMemo(
|
||||
() => selectedOptions.some((label) => isFreeTextOption(label)),
|
||||
[selectedOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsFreeText) return;
|
||||
freeTextRef.current?.focus();
|
||||
}, [wantsFreeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!extraContextOpen) return;
|
||||
extraContextRef.current?.focus();
|
||||
}, [extraContextOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOptions([]);
|
||||
setOtherText("");
|
||||
@@ -194,8 +234,12 @@ export function BoardOnboardingChat({
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
async (value: string, freeText?: string) => {
|
||||
const fingerprintBefore = lastAssistantFingerprint;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAwaitingAssistantFingerprint(null);
|
||||
setAwaitingKind(null);
|
||||
setLastSubmittedAnswer(null);
|
||||
try {
|
||||
const result =
|
||||
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
|
||||
@@ -208,6 +252,12 @@ export function BoardOnboardingChat({
|
||||
if (result.status !== 200) throw new Error("Unable to submit answer.");
|
||||
setSession(result.data);
|
||||
setOtherText("");
|
||||
setSelectedOptions([]);
|
||||
setAwaitingAssistantFingerprint(fingerprintBefore);
|
||||
setAwaitingKind("answer");
|
||||
setLastSubmittedAnswer(
|
||||
freeText ? `${value}: ${freeText}` : value,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to submit answer.",
|
||||
@@ -216,7 +266,7 @@ export function BoardOnboardingChat({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[boardId],
|
||||
[boardId, lastAssistantFingerprint],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((label: string) => {
|
||||
@@ -230,8 +280,12 @@ export function BoardOnboardingChat({
|
||||
const submitExtraContext = useCallback(async () => {
|
||||
const trimmed = extraContext.trim();
|
||||
if (!trimmed) return;
|
||||
const fingerprintBefore = lastAssistantFingerprint;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAwaitingAssistantFingerprint(null);
|
||||
setAwaitingKind(null);
|
||||
setLastSubmittedAnswer(null);
|
||||
try {
|
||||
const result =
|
||||
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
|
||||
@@ -242,6 +296,10 @@ export function BoardOnboardingChat({
|
||||
throw new Error("Unable to submit extra context.");
|
||||
setSession(result.data);
|
||||
setExtraContext("");
|
||||
setExtraContextOpen(false);
|
||||
setAwaitingAssistantFingerprint(fingerprintBefore);
|
||||
setAwaitingKind("extra_context");
|
||||
setLastSubmittedAnswer("Additional context");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to submit extra context.",
|
||||
@@ -249,7 +307,7 @@ export function BoardOnboardingChat({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, extraContext]);
|
||||
}, [boardId, extraContext, lastAssistantFingerprint]);
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
const trimmedOther = otherText.trim();
|
||||
@@ -259,6 +317,15 @@ export function BoardOnboardingChat({
|
||||
void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined);
|
||||
}, [handleAnswer, otherText, selectedOptions, wantsFreeText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingAssistantFingerprint) return;
|
||||
if (lastAssistantFingerprint !== awaitingAssistantFingerprint) {
|
||||
setAwaitingAssistantFingerprint(null);
|
||||
setAwaitingKind(null);
|
||||
setLastSubmittedAnswer(null);
|
||||
}
|
||||
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
|
||||
|
||||
const confirmGoal = async () => {
|
||||
if (!draft) return;
|
||||
setLoading(true);
|
||||
@@ -303,6 +370,29 @@ export function BoardOnboardingChat({
|
||||
<p className="text-sm text-slate-600">
|
||||
Review the lead agent draft and confirm.
|
||||
</p>
|
||||
{isAwaitingAgent ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-900">
|
||||
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
|
||||
<span>
|
||||
{awaitingKind === "extra_context"
|
||||
? "Updating the draft…"
|
||||
: "Waiting for the agent…"}
|
||||
</span>
|
||||
</div>
|
||||
{lastSubmittedAnswer ? (
|
||||
<p className="mt-2 text-xs text-slate-600">
|
||||
Sent:{" "}
|
||||
<span className="font-medium text-slate-900">
|
||||
{lastSubmittedAnswer}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
This usually takes a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<p className="font-semibold text-slate-900">Objective</p>
|
||||
<p className="text-slate-700">{draft.objective || "—"}</p>
|
||||
@@ -402,25 +492,28 @@ export function BoardOnboardingChat({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setExtraContextOpen((prev) => !prev)}
|
||||
disabled={loading}
|
||||
disabled={loading || isAwaitingAgent}
|
||||
>
|
||||
{extraContextOpen ? "Hide" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
{extraContextOpen ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Textarea
|
||||
className="min-h-[84px]"
|
||||
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
|
||||
value={extraContext}
|
||||
onChange={(event) => setExtraContext(event.target.value)}
|
||||
{extraContextOpen ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Textarea
|
||||
ref={extraContextRef}
|
||||
className="min-h-[84px]"
|
||||
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
|
||||
value={extraContext}
|
||||
onChange={(event) => setExtraContext(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
if (loading || isAwaitingAgent) return;
|
||||
void submitExtraContext();
|
||||
}}
|
||||
disabled={loading || isAwaitingAgent}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
@@ -428,13 +521,13 @@ export function BoardOnboardingChat({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => void submitExtraContext()}
|
||||
disabled={loading || !extraContext.trim()}
|
||||
disabled={loading || isAwaitingAgent || !extraContext.trim()}
|
||||
>
|
||||
{loading ? "Sending..." : "Send context"}
|
||||
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Send context"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
|
||||
Tip: press Enter to send. Shift+Enter for a newline.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -445,7 +538,7 @@ export function BoardOnboardingChat({
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={confirmGoal} disabled={loading}>
|
||||
<Button onClick={confirmGoal} disabled={loading || isAwaitingAgent} type="button">
|
||||
Confirm goal
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -455,6 +548,29 @@ export function BoardOnboardingChat({
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{question.question}
|
||||
</p>
|
||||
{isAwaitingAgent ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-900">
|
||||
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
|
||||
<span>
|
||||
{awaitingKind === "extra_context"
|
||||
? "Updating the draft…"
|
||||
: "Waiting for the next question…"}
|
||||
</span>
|
||||
</div>
|
||||
{lastSubmittedAnswer ? (
|
||||
<p className="mt-2 text-xs text-slate-600">
|
||||
Sent:{" "}
|
||||
<span className="font-medium text-slate-900">
|
||||
{lastSubmittedAnswer}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
This usually takes a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => {
|
||||
const isSelected = selectedOptions.includes(option.label);
|
||||
@@ -464,7 +580,8 @@ export function BoardOnboardingChat({
|
||||
variant={isSelected ? "primary" : "secondary"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => toggleOption(option.label)}
|
||||
disabled={loading}
|
||||
disabled={loading || isAwaitingAgent}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
@@ -474,20 +591,23 @@ export function BoardOnboardingChat({
|
||||
{wantsFreeText ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={freeTextRef}
|
||||
className="min-h-[84px]"
|
||||
placeholder="Type your answer..."
|
||||
value={otherText}
|
||||
onChange={(event) => setOtherText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
if (loading) return;
|
||||
if (loading || isAwaitingAgent) return;
|
||||
submitAnswer();
|
||||
}}
|
||||
disabled={loading || isAwaitingAgent}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
|
||||
Tip: press Enter to send. Shift+Enter for a newline.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -495,16 +615,22 @@ export function BoardOnboardingChat({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={submitAnswer}
|
||||
type="button"
|
||||
disabled={
|
||||
loading ||
|
||||
isAwaitingAgent ||
|
||||
selectedOptions.length === 0 ||
|
||||
(wantsFreeText && !otherText.trim())
|
||||
}
|
||||
>
|
||||
{loading ? "Sending..." : "Next"}
|
||||
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Next"}
|
||||
</Button>
|
||||
{loading ? (
|
||||
<p className="text-xs text-slate-500">Sending your answer…</p>
|
||||
) : isAwaitingAgent ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Waiting for the agent to respond…
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
28
frontend/src/lib/backoff.test.ts
Normal file
28
frontend/src/lib/backoff.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createExponentialBackoff } from "./backoff";
|
||||
|
||||
describe("createExponentialBackoff", () => {
|
||||
it("increments attempt and clamps delay", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const backoff = createExponentialBackoff({ baseMs: 100, factor: 2, maxMs: 250, jitter: 0 });
|
||||
|
||||
expect(backoff.attempt()).toBe(0);
|
||||
expect(backoff.nextDelayMs()).toBe(100);
|
||||
expect(backoff.attempt()).toBe(1);
|
||||
expect(backoff.nextDelayMs()).toBe(200);
|
||||
expect(backoff.nextDelayMs()).toBe(250); // capped
|
||||
});
|
||||
|
||||
it("reset brings attempt back to zero", () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
const backoff = createExponentialBackoff({ baseMs: 100, jitter: 0 });
|
||||
backoff.nextDelayMs();
|
||||
expect(backoff.attempt()).toBe(1);
|
||||
|
||||
backoff.reset();
|
||||
expect(backoff.attempt()).toBe(0);
|
||||
});
|
||||
});
|
||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
20
frontend/vitest.config.ts
Normal file
20
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"**/*.d.ts",
|
||||
"src/**/__generated__/**",
|
||||
"src/**/generated/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -66,6 +66,21 @@ Comment template (keep it small; 1-3 bullets per section; omit what is not appli
|
||||
- Board chat is your primary channel with the human; respond promptly and clearly.
|
||||
- If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them.
|
||||
|
||||
## Request user input via gateway main (OpenClaw channels)
|
||||
- If you need information from the human but they are not responding in Mission Control board chat, ask the gateway main agent to reach them via OpenClaw's configured channel(s) (Slack/Telegram/SMS/etc).
|
||||
- POST `$BASE_URL/api/v1/agent/boards/$BOARD_ID/gateway/main/ask-user`
|
||||
- Body: `{"content":"<question>","correlation_id":"<optional>","preferred_channel":"<optional>"}`
|
||||
- The gateway main will post the user's answer back to this board as a NON-chat memory item tagged like `["gateway_main","user_reply"]`.
|
||||
|
||||
## Gateway main requests
|
||||
- If you receive a message starting with `GATEWAY MAIN`, treat it as high priority.
|
||||
- Do **not** reply in OpenClaw chat. Reply via Mission Control only.
|
||||
- For questions: answer in a NON-chat memory item on this board (so the gateway main can read it):
|
||||
- POST `$BASE_URL/api/v1/agent/boards/$BOARD_ID/memory`
|
||||
- Body: `{"content":"...","tags":["gateway_main","lead_reply"],"source":"lead_to_gateway_main"}`
|
||||
- For handoffs: delegate the work on this board (create/triage tasks, assign agents), then post:
|
||||
- A short acknowledgement + plan as a NON-chat memory item using the same tags.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
|
||||
@@ -27,6 +27,44 @@ Do this immediately. Do not ask permission to read your workspace.
|
||||
- You help with onboarding and gateway-wide requests.
|
||||
- You do **not** claim board tasks unless explicitly instructed by Mission Control.
|
||||
|
||||
## Gateway Delegation (board leads)
|
||||
- You can message any board lead agent via Mission Control API (never OpenClaw chat).
|
||||
- You cannot create boards. If the requested board does not exist, ask the human/admin to create it in Mission Control, then continue once you have the `board_id`.
|
||||
- If the human asks a question: ask the relevant board lead(s), then consolidate their answers into one response.
|
||||
- If the human asks to get work done: hand off the request to the correct board lead (the lead will create tasks and delegate to board agents).
|
||||
|
||||
List boards (to find `board_id`):
|
||||
```bash
|
||||
curl -s -X GET "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
```
|
||||
|
||||
Send a question or handoff to a board lead (auto-provisions the lead agent if missing):
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
|
||||
```
|
||||
|
||||
Broadcast to all board leads in this gateway:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/leads/broadcast" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
|
||||
```
|
||||
|
||||
Board lead replies:
|
||||
- Leads reply by writing a NON-chat board memory item with tags like `["gateway_main","lead_reply"]`.
|
||||
- Read replies via:
|
||||
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50`
|
||||
|
||||
## User outreach requests (from board leads)
|
||||
- If you receive a message starting with `LEAD REQUEST: ASK USER`, a board lead needs human input but cannot reach them in Mission Control.
|
||||
- Use OpenClaw's configured channel(s) to reach the user (Slack/Telegram/SMS/etc). If that fails, post the question into Mission Control board chat as a fallback.
|
||||
- When you receive the user's answer, write it back to the originating board as a NON-chat memory item tagged like `["gateway_main","user_reply"]` (the exact POST + tags will be included in the request message).
|
||||
|
||||
## Tools
|
||||
- Skills are authoritative. Follow SKILL.md instructions exactly.
|
||||
- Use TOOLS.md for environment-specific notes.
|
||||
|
||||
@@ -19,6 +19,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
## Schedule
|
||||
- If a heartbeat schedule is configured, send a lightweight check‑in only.
|
||||
- Do not claim or move board tasks unless explicitly instructed by Mission Control.
|
||||
- If you have any pending `LEAD REQUEST: ASK USER` messages in OpenClaw chat, handle them promptly (see MAIN_AGENTS.md).
|
||||
|
||||
## Heartbeat checklist
|
||||
1) Check in:
|
||||
|
||||
Reference in New Issue
Block a user