Merge origin/master and fix CI lint

This commit is contained in:
Ishan (OpenClaw)
2026-02-07 11:28:58 +00:00
22 changed files with 3997 additions and 192 deletions

View File

@@ -45,19 +45,41 @@ jobs:
cache: npm cache: npm
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install dependencies - name: Install backend dependencies
run: make setup run: make backend-sync
- name: Run checks - name: Install frontend dependencies
run: make frontend-sync
- name: Run backend checks
env: env:
# Keep CI builds deterministic and secretless. # Keep CI builds deterministic and secretless.
NEXT_TELEMETRY_DISABLED: "1" 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() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: backend-coverage name: coverage
path: backend/coverage.xml if-no-files-found: ignore
if-no-files-found: warn path: |
backend/coverage.xml
frontend/coverage/**

View File

@@ -68,24 +68,19 @@ frontend-typecheck: ## Typecheck frontend (tsc)
cd $(FRONTEND_DIR) && npx tsc -p tsconfig.json --noEmit cd $(FRONTEND_DIR) && npx tsc -p tsconfig.json --noEmit
.PHONY: test .PHONY: test
test: backend-test ## Run tests test: backend-test frontend-test ## Run tests
.PHONY: coverage
coverage: backend-coverage ## Run tests with coverage + enforce thresholds
.PHONY: backend-test .PHONY: backend-test
backend-test: ## Backend tests (pytest) backend-test: ## Backend tests (pytest)
cd $(BACKEND_DIR) && uv run pytest cd $(BACKEND_DIR) && uv run pytest
.PHONY: backend-coverage .PHONY: backend-coverage
backend-coverage: ## Backend tests with coverage (fail under 100% statements+branches on covered src) backend-coverage: ## Backend tests with coverage gate (100% stmt + branch on covered src)
cd $(BACKEND_DIR) && uv run pytest \ cd $(BACKEND_DIR) && uv run pytest --cov=app --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml
--cov=app \
--cov-config=.coveragerc \ .PHONY: frontend-test
--cov-report=term-missing \ frontend-test: ## Frontend tests (vitest)
--cov-report=xml:coverage.xml \ cd $(FRONTEND_DIR) && npm run test
--cov-branch \
--cov-fail-under=10
.PHONY: backend-migrate .PHONY: backend-migrate
backend-migrate: ## Apply backend DB migrations (alembic upgrade head) 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) cd $(BACKEND_DIR) && uv run python scripts/sync_gateway_templates.py --gateway-id "$(GATEWAY_ID)" $(SYNC_ARGS)
.PHONY: check .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

View File

@@ -15,6 +15,7 @@ from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_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.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.agent_auth import AgentAuthContext, get_agent_auth_context
from app.core.config import settings
from app.db.pagination import paginate from app.db.pagination import paginate
from app.db.session import get_session from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig 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.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse 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.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.board_leads import ensure_board_lead_agent
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
dependency_status_by_id, 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) 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]) @router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
async def list_boards( async def list_boards(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -440,3 +488,268 @@ async def agent_heartbeat(
session=session, session=session,
actor=_actor(agent_ctx), 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,
)

View 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

View File

@@ -86,6 +86,49 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex 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: def _agent_key(agent: Agent) -> str:
session_key = agent.openclaw_session_id or "" session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"): if session_key.startswith("agent:"):
@@ -383,24 +426,18 @@ def _render_agent_files(
async def _gateway_default_agent_id( async def _gateway_default_agent_id(
config: GatewayClientConfig, config: GatewayClientConfig,
*,
fallback_session_key: str | None = None,
) -> str | None: ) -> str | None:
try: try:
payload = await openclaw_call("agents.list", config=config) payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError: except OpenClawGatewayError:
return None return _agent_id_from_session_key(fallback_session_key)
if not isinstance(payload, dict):
return None agent_id = _extract_agent_id(payload)
default_id = payload.get("defaultId") or payload.get("default_id") if agent_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 agent_id
return None return _agent_id_from_session_key(fallback_session_key)
async def _patch_gateway_agent_list( async def _patch_gateway_agent_list(
@@ -585,7 +622,10 @@ async def provision_main_agent(
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent") 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: if not agent_id:
raise OpenClawGatewayError("Unable to resolve gateway main agent id") raise OpenClawGatewayError("Unable to resolve gateway main agent id")

View 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

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import re import re
from collections.abc import Awaitable, Callable
from typing import TypeVar
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import col, select 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>.*)$") _TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
T = TypeVar("T")
def _slugify(value: str) -> str: def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex 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: def _gateway_agent_id(agent: Agent) -> str:
session_key = agent.openclaw_session_id or "" session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"): if session_key.startswith("agent:"):
@@ -94,24 +179,34 @@ async def _get_existing_auth_token(
return token or None return token or None
async def _gateway_default_agent_id(config: GatewayClientConfig) -> str | None: 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: try:
payload = await openclaw_call("agents.list", config=config) payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError: agent_id = _extract_agent_id(payload)
return None if agent_id:
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 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( async def sync_gateway_templates(
@@ -226,6 +321,7 @@ async def sync_gateway_templates(
) )
try: try:
async def _do_provision() -> None:
await provision_agent( await provision_agent(
agent, agent,
board, board,
@@ -236,6 +332,8 @@ async def sync_gateway_templates(
force_bootstrap=force_bootstrap, force_bootstrap=force_bootstrap,
reset_session=reset_sessions, reset_session=reset_sessions,
) )
await _with_gateway_retry(_do_provision)
result.agents_updated += 1 result.agents_updated += 1
except Exception as exc: # pragma: no cover - gateway/network dependent except Exception as exc: # pragma: no cover - gateway/network dependent
result.agents_skipped += 1 result.agents_skipped += 1
@@ -262,7 +360,10 @@ async def sync_gateway_templates(
) )
return result 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: if not main_gateway_agent_id:
result.errors.append( result.errors.append(
GatewayTemplatesSyncError( GatewayTemplatesSyncError(
@@ -277,6 +378,15 @@ async def sync_gateway_templates(
agent_gateway_id=main_gateway_agent_id, config=client_config agent_gateway_id=main_gateway_agent_id, config=client_config
) )
if not main_token: if not main_token:
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( result.errors.append(
GatewayTemplatesSyncError( GatewayTemplatesSyncError(
agent_id=main_agent.id, agent_id=main_agent.id,
@@ -286,7 +396,28 @@ async def sync_gateway_templates(
) )
return result 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).",
)
)
try: try:
async def _do_provision_main() -> None:
await provision_main_agent( await provision_main_agent(
main_agent, main_agent,
gateway, gateway,
@@ -296,6 +427,8 @@ async def sync_gateway_templates(
force_bootstrap=force_bootstrap, force_bootstrap=force_bootstrap,
reset_session=reset_sessions, reset_session=reset_sessions,
) )
await _with_gateway_retry(_do_provision_main)
result.main_updated = True result.main_updated = True
except Exception as exc: # pragma: no cover - gateway/network dependent except Exception as exc: # pragma: no cover - gateway/network dependent
result.errors.append( result.errors.append(

View File

@@ -38,7 +38,8 @@ dev = [
"mypy==1.11.2", "mypy==1.11.2",
"pytest==8.3.3", "pytest==8.3.3",
"pytest-asyncio==0.24.0", "pytest-asyncio==0.24.0",
"pytest-cov==5.0.0", "pytest-cov==6.0.0",
"coverage[toml]==7.6.10",
"ruff==0.6.9", "ruff==0.6.9",
] ]
@@ -52,3 +53,17 @@ show_error_codes = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function" 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

View File

@@ -1,13 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import sys
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from app.db.session import async_session_maker, init_db BACKEND_ROOT = Path(__file__).resolve().parents[1]
from app.models.agents import Agent sys.path.insert(0, str(BACKEND_ROOT))
from app.models.boards import Board
from app.models.gateways import Gateway from app.db.session import async_session_maker, init_db # noqa: E402
from app.models.users import User 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: async def run() -> None:

View File

@@ -2,11 +2,16 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import sys
from pathlib import Path
from uuid import UUID from uuid import UUID
from app.db.session import async_session_maker BACKEND_ROOT = Path(__file__).resolve().parents[1]
from app.models.gateways import Gateway sys.path.insert(0, str(BACKEND_ROOT))
from app.services.template_sync import sync_gateway_templates
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: def _parse_args() -> argparse.Namespace:

110
backend/uv.lock generated
View File

@@ -160,76 +160,40 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.3" version = "7.6.10"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
{ 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" },
] ]
[[package]] [[package]]
@@ -609,6 +573,7 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "coverage" },
{ name = "flake8" }, { name = "flake8" },
{ name = "httpx" }, { name = "httpx" },
{ name = "isort" }, { name = "isort" },
@@ -623,6 +588,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = "==1.13.2" }, { name = "alembic", specifier = "==1.13.2" },
{ name = "black", marker = "extra == 'dev'", specifier = "==24.10.0" }, { 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", specifier = "==0.128.0" },
{ name = "fastapi-clerk-auth", specifier = "==0.0.9" }, { name = "fastapi-clerk-auth", specifier = "==0.0.9" },
{ name = "fastapi-pagination", specifier = "==0.15.9" }, { name = "fastapi-pagination", specifier = "==0.15.9" },
@@ -635,7 +601,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = "==2.5.2" }, { name = "pydantic-settings", specifier = "==2.5.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.3" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==0.24.0" }, { 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 = "python-dotenv", specifier = "==1.0.1" },
{ name = "redis", specifier = "==5.1.1" }, { name = "redis", specifier = "==5.1.1" },
{ name = "rq", specifier = "==1.16.2" }, { name = "rq", specifier = "==1.16.2" },
@@ -906,15 +872,15 @@ wheels = [
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "5.0.0" version = "6.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "coverage" }, { name = "coverage" },
{ name = "pytest" }, { 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 = [ 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]] [[package]]

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run --passWithNoTests --coverage",
"test:watch": "vitest",
"dev:lan": "next dev --hostname 0.0.0.0 --port 3000", "dev:lan": "next dev --hostname 0.0.0.0 --port 3000",
"api:gen": "orval --config ./orval.config.ts" "api:gen": "orval --config ./orval.config.ts"
}, },
@@ -29,14 +31,19 @@
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "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/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"jsdom": "^25.0.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"orval": "^8.2.0", "orval": "^8.2.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -44,6 +51,7 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5" "typescript": "^5",
"vitest": "^2.1.8"
} }
} }

View File

@@ -50,7 +50,10 @@ import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals"; } 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 { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
import { import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost, createBoardMemoryApiV1BoardsBoardIdMemoryPost,
@@ -219,6 +222,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole, authorRole,
authorAvatar, authorAvatar,
onViewTask, onViewTask,
isNew,
}: { }: {
comment: TaskComment; comment: TaskComment;
taskTitle: string; taskTitle: string;
@@ -226,10 +230,18 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole?: string | null; authorRole?: string | null;
authorAvatar: string; authorAvatar: string;
onViewTask?: () => void; onViewTask?: () => void;
isNew?: boolean;
}) { }) {
const message = (comment.message ?? "").trim(); const message = (comment.message ?? "").trim();
return ( 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 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"> <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} {authorAvatar}
@@ -316,6 +328,11 @@ export default function BoardDetailPage() {
const openedTaskIdFromUrlRef = useRef<string | null>(null); const openedTaskIdFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]); const [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = 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] = const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] =
useState(false); useState(false);
const [liveFeedHistoryError, setLiveFeedHistoryError] = useState< const [liveFeedHistoryError, setLiveFeedHistoryError] = useState<
@@ -325,6 +342,7 @@ export default function BoardDetailPage() {
const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null); const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState(""); const [newComment, setNewComment] = useState("");
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false); const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(null); const [postCommentError, setPostCommentError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
@@ -359,7 +377,9 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const isLiveFeedOpenRef = useRef(false);
const pushLiveFeed = useCallback((comment: TaskComment) => { const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
setLiveFeed((prev) => { setLiveFeed((prev) => {
if (prev.some((item) => item.id === comment.id)) { if (prev.some((item) => item.id === comment.id)) {
return prev; return prev;
@@ -367,6 +387,28 @@ export default function BoardDetailPage() {
const next = [comment, ...prev]; const next = [comment, ...prev];
return next.slice(0, 50); 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(() => { useEffect(() => {
@@ -374,8 +416,26 @@ export default function BoardDetailPage() {
setIsLiveFeedHistoryLoading(false); setIsLiveFeedHistoryLoading(false);
setLiveFeedHistoryError(null); setLiveFeedHistoryError(null);
setLiveFeed([]); setLiveFeed([]);
setLiveFeedFlashIds({});
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
}, [boardId]); }, [boardId]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
};
}, []);
useEffect(() => { useEffect(() => {
if (!isLiveFeedOpen) return; if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
@@ -576,6 +636,14 @@ export default function BoardDetailPage() {
chatMessagesRef.current = chatMessages; chatMessagesRef.current = chatMessages;
}, [chatMessages]); }, [chatMessages]);
useEffect(() => {
liveFeedRef.current = liveFeed;
}, [liveFeed]);
useEffect(() => {
isLiveFeedOpenRef.current = isLiveFeedOpen;
}, [isLiveFeedOpen]);
useEffect(() => { useEffect(() => {
if (!isChatOpen) return; if (!isChatOpen) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -716,6 +784,125 @@ export default function BoardDetailPage() {
}; };
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]); }, [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(() => { useEffect(() => {
if (!isPageActive) return; if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
@@ -949,18 +1136,23 @@ export default function BoardDetailPage() {
payload.comment?.created_at, payload.comment?.created_at,
); );
if (prev.length === 0 || createdMs === null) { 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 last = prev[prev.length - 1];
const lastMs = apiDatetimeToMs(last?.created_at); const lastMs = apiDatetimeToMs(last?.created_at);
if (lastMs !== null && createdMs >= lastMs) { if (lastMs !== null && createdMs <= lastMs) {
return [...prev, payload.comment as TaskComment]; return [...prev, payload.comment as TaskComment];
} }
const next = [...prev, payload.comment as TaskComment]; const next = [...prev, payload.comment as TaskComment];
next.sort((a, b) => { next.sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0; const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return bTime - aTime;
}); });
return next; return next;
}); });
@@ -1421,7 +1613,7 @@ export default function BoardDetailPage() {
items.sort((a, b) => { items.sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0; const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return bTime - aTime;
}); });
setComments(items); setComments(items);
} catch (err) { } catch (err) {
@@ -1522,6 +1714,7 @@ export default function BoardDetailPage() {
); );
} finally { } finally {
setIsPostingComment(false); setIsPostingComment(false);
taskCommentInputRef.current?.focus();
} }
}; };
@@ -2421,8 +2614,18 @@ export default function BoardDetailPage() {
</p> </p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3"> <div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Textarea <Textarea
ref={taskCommentInputRef}
value={newComment} value={newComment}
onChange={(event) => setNewComment(event.target.value)} 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…" placeholder="Write a message for the assigned agent…"
className="min-h-[80px] bg-white" className="min-h-[80px] bg-white"
/> />
@@ -2573,6 +2776,7 @@ export default function BoardDetailPage() {
<LiveFeedCard <LiveFeedCard
key={comment.id} key={comment.id}
comment={comment} comment={comment}
isNew={Boolean(liveFeedFlashIds[comment.id])}
taskTitle={ taskTitle={
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task" taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -17,12 +17,22 @@ function BoardChatComposerImpl({
onSend, onSend,
}: BoardChatComposerProps) { }: BoardChatComposerProps) {
const [value, setValue] = useState(""); 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 () => { const send = useCallback(async () => {
if (isSending) return; if (isSending) return;
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return; if (!trimmed) return;
const ok = await onSend(trimmed); const ok = await onSend(trimmed);
shouldFocusAfterSendRef.current = true;
if (ok) { if (ok) {
setValue(""); setValue("");
} }
@@ -31,6 +41,7 @@ function BoardChatComposerImpl({
return ( return (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<Textarea <Textarea
ref={textareaRef}
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { import {
DialogFooter, DialogFooter,
@@ -120,16 +121,40 @@ export function BoardOnboardingChat({
const isPageActive = usePageActive(); const isPageActive = usePageActive();
const [session, setSession] = useState<BoardOnboardingRead | null>(null); const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false); 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 [otherText, setOtherText] = useState("");
const [extraContext, setExtraContext] = useState(""); const [extraContext, setExtraContext] = useState("");
const [extraContextOpen, setExtraContextOpen] = useState(false); const [extraContextOpen, setExtraContextOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]); const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const freeTextRef = useRef<HTMLTextAreaElement | null>(null);
const extraContextRef = useRef<HTMLTextAreaElement | null>(null);
const normalizedMessages = useMemo( const normalizedMessages = useMemo(
() => normalizeMessages(session?.messages), () => normalizeMessages(session?.messages),
[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( const question = useMemo(
() => parseQuestion(normalizedMessages), () => parseQuestion(normalizedMessages),
[normalizedMessages], [normalizedMessages],
@@ -137,11 +162,26 @@ export function BoardOnboardingChat({
const draft: BoardOnboardingAgentComplete | null = const draft: BoardOnboardingAgentComplete | null =
session?.draft_goal ?? null; session?.draft_goal ?? null;
const isAwaitingAgent = useMemo(() => {
if (!awaitingAssistantFingerprint) return false;
return lastAssistantFingerprint === awaitingAssistantFingerprint;
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const wantsFreeText = useMemo( const wantsFreeText = useMemo(
() => selectedOptions.some((label) => isFreeTextOption(label)), () => selectedOptions.some((label) => isFreeTextOption(label)),
[selectedOptions], [selectedOptions],
); );
useEffect(() => {
if (!wantsFreeText) return;
freeTextRef.current?.focus();
}, [wantsFreeText]);
useEffect(() => {
if (!extraContextOpen) return;
extraContextRef.current?.focus();
}, [extraContextOpen]);
useEffect(() => { useEffect(() => {
setSelectedOptions([]); setSelectedOptions([]);
setOtherText(""); setOtherText("");
@@ -194,8 +234,12 @@ export function BoardOnboardingChat({
const handleAnswer = useCallback( const handleAnswer = useCallback(
async (value: string, freeText?: string) => { async (value: string, freeText?: string) => {
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try { try {
const result = const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost( await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
@@ -208,6 +252,12 @@ export function BoardOnboardingChat({
if (result.status !== 200) throw new Error("Unable to submit answer."); if (result.status !== 200) throw new Error("Unable to submit answer.");
setSession(result.data); setSession(result.data);
setOtherText(""); setOtherText("");
setSelectedOptions([]);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("answer");
setLastSubmittedAnswer(
freeText ? `${value}: ${freeText}` : value,
);
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error ? err.message : "Failed to submit answer.", err instanceof Error ? err.message : "Failed to submit answer.",
@@ -216,7 +266,7 @@ export function BoardOnboardingChat({
setLoading(false); setLoading(false);
} }
}, },
[boardId], [boardId, lastAssistantFingerprint],
); );
const toggleOption = useCallback((label: string) => { const toggleOption = useCallback((label: string) => {
@@ -230,8 +280,12 @@ export function BoardOnboardingChat({
const submitExtraContext = useCallback(async () => { const submitExtraContext = useCallback(async () => {
const trimmed = extraContext.trim(); const trimmed = extraContext.trim();
if (!trimmed) return; if (!trimmed) return;
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try { try {
const result = const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, { await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
@@ -242,6 +296,10 @@ export function BoardOnboardingChat({
throw new Error("Unable to submit extra context."); throw new Error("Unable to submit extra context.");
setSession(result.data); setSession(result.data);
setExtraContext(""); setExtraContext("");
setExtraContextOpen(false);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("extra_context");
setLastSubmittedAnswer("Additional context");
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error ? err.message : "Failed to submit extra context.", err instanceof Error ? err.message : "Failed to submit extra context.",
@@ -249,7 +307,7 @@ export function BoardOnboardingChat({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [boardId, extraContext]); }, [boardId, extraContext, lastAssistantFingerprint]);
const submitAnswer = useCallback(() => { const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim(); const trimmedOther = otherText.trim();
@@ -259,6 +317,15 @@ export function BoardOnboardingChat({
void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined); void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined);
}, [handleAnswer, otherText, selectedOptions, wantsFreeText]); }, [handleAnswer, otherText, selectedOptions, wantsFreeText]);
useEffect(() => {
if (!awaitingAssistantFingerprint) return;
if (lastAssistantFingerprint !== awaitingAssistantFingerprint) {
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
}
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const confirmGoal = async () => { const confirmGoal = async () => {
if (!draft) return; if (!draft) return;
setLoading(true); setLoading(true);
@@ -303,6 +370,29 @@ export function BoardOnboardingChat({
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
Review the lead agent draft and confirm. Review the lead agent draft and confirm.
</p> </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"> <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="font-semibold text-slate-900">Objective</p>
<p className="text-slate-700">{draft.objective || "—"}</p> <p className="text-slate-700">{draft.objective || "—"}</p>
@@ -402,7 +492,7 @@ export function BoardOnboardingChat({
size="sm" size="sm"
type="button" type="button"
onClick={() => setExtraContextOpen((prev) => !prev)} onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading} disabled={loading || isAwaitingAgent}
> >
{extraContextOpen ? "Hide" : "Add"} {extraContextOpen ? "Hide" : "Add"}
</Button> </Button>
@@ -410,17 +500,20 @@ export function BoardOnboardingChat({
{extraContextOpen ? ( {extraContextOpen ? (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<Textarea <Textarea
ref={extraContextRef}
className="min-h-[84px]" className="min-h-[84px]"
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)" placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
value={extraContext} value={extraContext}
onChange={(event) => setExtraContext(event.target.value)} onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault(); event.preventDefault();
if (loading) return; if (loading || isAwaitingAgent) return;
void submitExtraContext(); void submitExtraContext();
}} }}
disabled={loading || isAwaitingAgent}
/> />
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button <Button
@@ -428,13 +521,13 @@ export function BoardOnboardingChat({
size="sm" size="sm"
type="button" type="button"
onClick={() => void submitExtraContext()} onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()} disabled={loading || isAwaitingAgent || !extraContext.trim()}
> >
{loading ? "Sending..." : "Send context"} {loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Send context"}
</Button> </Button>
</div> </div>
<p className="text-xs text-slate-500"> <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> </p>
</div> </div>
) : ( ) : (
@@ -445,7 +538,7 @@ export function BoardOnboardingChat({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={confirmGoal} disabled={loading}> <Button onClick={confirmGoal} disabled={loading || isAwaitingAgent} type="button">
Confirm goal Confirm goal
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -455,6 +548,29 @@ export function BoardOnboardingChat({
<p className="text-sm font-medium text-slate-900"> <p className="text-sm font-medium text-slate-900">
{question.question} {question.question}
</p> </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"> <div className="space-y-2">
{question.options.map((option) => { {question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label); const isSelected = selectedOptions.includes(option.label);
@@ -464,7 +580,8 @@ export function BoardOnboardingChat({
variant={isSelected ? "primary" : "secondary"} variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start" className="w-full justify-start"
onClick={() => toggleOption(option.label)} onClick={() => toggleOption(option.label)}
disabled={loading} disabled={loading || isAwaitingAgent}
type="button"
> >
{option.label} {option.label}
</Button> </Button>
@@ -474,20 +591,23 @@ export function BoardOnboardingChat({
{wantsFreeText ? ( {wantsFreeText ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
ref={freeTextRef}
className="min-h-[84px]" className="min-h-[84px]"
placeholder="Type your answer..." placeholder="Type your answer..."
value={otherText} value={otherText}
onChange={(event) => setOtherText(event.target.value)} onChange={(event) => setOtherText(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return; if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault(); event.preventDefault();
if (loading) return; if (loading || isAwaitingAgent) return;
submitAnswer(); submitAnswer();
}} }}
disabled={loading || isAwaitingAgent}
/> />
<p className="text-xs text-slate-500"> <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> </p>
</div> </div>
) : null} ) : null}
@@ -495,16 +615,22 @@ export function BoardOnboardingChat({
<Button <Button
variant="outline" variant="outline"
onClick={submitAnswer} onClick={submitAnswer}
type="button"
disabled={ disabled={
loading || loading ||
isAwaitingAgent ||
selectedOptions.length === 0 || selectedOptions.length === 0 ||
(wantsFreeText && !otherText.trim()) (wantsFreeText && !otherText.trim())
} }
> >
{loading ? "Sending..." : "Next"} {loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Next"}
</Button> </Button>
{loading ? ( {loading ? (
<p className="text-xs text-slate-500">Sending your answer</p> <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} ) : null}
</div> </div>
</div> </div>

View 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);
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

20
frontend/vitest.config.ts Normal file
View 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/**",
],
},
},
});

View File

@@ -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. - 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. - 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) ## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP. - All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}` - Always include: `X-Agent-Token: {{ auth_token }}`

View File

@@ -27,6 +27,44 @@ Do this immediately. Do not ask permission to read your workspace.
- You help with onboarding and gateway-wide requests. - You help with onboarding and gateway-wide requests.
- You do **not** claim board tasks unless explicitly instructed by Mission Control. - 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 ## Tools
- Skills are authoritative. Follow SKILL.md instructions exactly. - Skills are authoritative. Follow SKILL.md instructions exactly.
- Use TOOLS.md for environment-specific notes. - Use TOOLS.md for environment-specific notes.

View File

@@ -19,6 +19,7 @@ If any required input is missing, stop and request a provisioning update.
## Schedule ## Schedule
- If a heartbeat schedule is configured, send a lightweight checkin only. - If a heartbeat schedule is configured, send a lightweight checkin only.
- Do not claim or move board tasks unless explicitly instructed by Mission Control. - 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 ## Heartbeat checklist
1) Check in: 1) Check in: