Merge master into ishan/fix-activity-clerkprovider

This commit is contained in:
Omar (OpenClaw)
2026-02-07 11:30:34 +00:00
20 changed files with 3385 additions and 208 deletions

View File

@@ -45,11 +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: Run frontend checks
env:
# Keep CI builds deterministic.
NEXT_TELEMETRY_DISABLED: "1"
# Clerk env (wired from repo settings; values are not printed).
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
CLERK_JWKS_URL: ${{ vars.CLERK_JWKS_URL }}
run: |
make frontend-lint
make frontend-typecheck
make frontend-test
make frontend-build
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
if-no-files-found: ignore
path: |
backend/coverage.xml
frontend/coverage/**

View File

@@ -68,12 +68,20 @@ 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: 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
backend-coverage: ## Backend tests with coverage gate (100% stmt + branch on covered src)
cd $(BACKEND_DIR) && uv run pytest --cov=app --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml
.PHONY: frontend-test
frontend-test: ## Frontend tests (vitest)
cd $(FRONTEND_DIR) && npm run test
.PHONY: backend-migrate .PHONY: backend-migrate
backend-migrate: ## Apply backend DB migrations (alembic upgrade head) backend-migrate: ## Apply backend DB migrations (alembic upgrade head)
cd $(BACKEND_DIR) && uv run alembic upgrade head cd $(BACKEND_DIR) && uv run alembic upgrade head
@@ -95,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 test build ## Run lint + typecheck + tests + build check: lint typecheck backend-coverage frontend-test build ## Run lint + typecheck + tests + coverage + build

View File

@@ -5,7 +5,6 @@ from typing import Any, cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -43,13 +42,13 @@ from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboar
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 ( from app.schemas.gateway_coordination import (
GatewayBoardEnsureRequest,
GatewayBoardEnsureResponse,
GatewayLeadBroadcastBoardResult, GatewayLeadBroadcastBoardResult,
GatewayLeadBroadcastRequest, GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse, GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest, GatewayLeadMessageRequest,
GatewayLeadMessageResponse, 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
@@ -82,13 +81,6 @@ 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)
def _slugify(value: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "board"
async def _require_gateway_main( async def _require_gateway_main(
session: AsyncSession, session: AsyncSession,
agent: Agent, agent: Agent,
@@ -498,87 +490,97 @@ async def agent_heartbeat(
) )
@router.post("/gateway/boards/ensure", response_model=GatewayBoardEnsureResponse) @router.post(
async def ensure_gateway_board( "/boards/{board_id}/gateway/main/ask-user",
payload: GatewayBoardEnsureRequest, response_model=GatewayMainAskUserResponse,
)
async def ask_user_via_gateway_main(
payload: GatewayMainAskUserRequest,
board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayBoardEnsureResponse: ) -> GatewayMainAskUserResponse:
gateway, config = await _require_gateway_main(session, agent_ctx.agent) import json
requested_name = payload.name.strip() _guard_board_access(agent_ctx, board)
requested_slug = _slugify(payload.slug.strip() if payload.slug else requested_name) if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# Try slug match first, then case-insensitive name match. if not board.gateway_id:
existing = ( raise HTTPException(
await session.exec( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
select(Board) detail="Board is not attached to a gateway",
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == requested_slug)
) )
).first() gateway = await session.get(Gateway, board.gateway_id)
if existing is None: if gateway is None or not gateway.url:
existing = ( raise HTTPException(
await session.exec( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
select(Board) detail="Gateway is not configured for this board",
.where(col(Board.gateway_id) == gateway.id)
.where(func.lower(col(Board.name)) == requested_name.lower())
)
).first()
created = False
board = existing
if board is None:
slug = requested_slug
suffix = 2
while True:
conflict = (
await session.exec(
select(Board.id)
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == slug)
)
).first()
if conflict is None:
break
slug = f"{requested_slug}-{suffix}"
suffix += 1
board = Board(
name=requested_name,
slug=slug,
gateway_id=gateway.id,
board_type=payload.board_type,
objective=payload.objective.strip() if payload.objective else None,
success_metrics=payload.success_metrics,
target_date=payload.target_date,
goal_confirmed=False,
goal_source="gateway_main_agent",
) )
session.add(board) main_session_key = (gateway.main_session_key or "").strip()
await session.commit() if not main_session_key:
await session.refresh(board) raise HTTPException(
created = True status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway main session key is required",
)
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
lead, lead_created = await ensure_board_lead_agent( correlation = payload.correlation_id.strip() if payload.correlation_id else ""
session, correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
board=board, preferred_channel = (payload.preferred_channel or "").strip()
gateway=gateway, channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
config=config,
user=None, tags = payload.reply_tags or ["gateway_main", "user_reply"]
agent_name=payload.lead_agent_name.strip() if payload.lead_agent_name else None, tags_json = json.dumps(tags)
identity_profile=payload.lead_identity_profile, reply_source = payload.reply_source or "user_via_gateway_main"
action="provision", 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."
) )
return GatewayBoardEnsureResponse( try:
created=created, await ensure_session(main_session_key, config=config, label="Main Agent")
lead_created=lead_created, 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, board_id=board.id,
lead_agent_id=lead.id, main_agent_id=main_agent.id if main_agent else None,
board_name=board.name, main_agent_name=main_agent.name if main_agent else None,
board_slug=board.slug,
lead_agent_name=lead.name,
) )

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
@@ -9,29 +8,6 @@ from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr from app.schemas.common import NonEmptyStr
class GatewayBoardEnsureRequest(SQLModel):
name: NonEmptyStr
slug: str | None = None
board_type: Literal["goal", "general"] = "goal"
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
lead_agent_name: str | None = None
lead_identity_profile: dict[str, str] | None = None
class GatewayBoardEnsureResponse(SQLModel):
created: bool = False
lead_created: bool = False
board_id: UUID
lead_agent_id: UUID | None = None
# Convenience fields for callers that don't want to re-fetch.
board_name: str
board_slug: str
lead_agent_name: str | None = None
class GatewayLeadMessageRequest(SQLModel): class GatewayLeadMessageRequest(SQLModel):
kind: Literal["question", "handoff"] = "question" kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None correlation_id: str | None = None
@@ -72,3 +48,20 @@ class GatewayLeadBroadcastResponse(SQLModel):
sent: int = 0 sent: int = 0
failed: int = 0 failed: int = 0
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list) 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 agent_id
return default_id return _agent_id_from_session_key(fallback_session_key)
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
agent_id = first.get("id")
if isinstance(agent_id, str) and agent_id:
return agent_id
return None
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

@@ -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(
try: config: GatewayClientConfig,
payload = await openclaw_call("agents.list", config=config) *,
except OpenClawGatewayError: fallback_session_key: str | None = None,
return None ) -> str | None:
if not isinstance(payload, dict): last_error: OpenClawGatewayError | None = None
return None # Gateways may reject WS connects transiently under load (HTTP 503).
default_id = payload.get("defaultId") or payload.get("default_id") for attempt in range(3):
if isinstance(default_id, str) and default_id: try:
return default_id payload = await openclaw_call("agents.list", config=config)
agents = payload.get("agents") or [] agent_id = _extract_agent_id(payload)
if isinstance(agents, list) and agents: if agent_id:
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,16 +321,19 @@ async def sync_gateway_templates(
) )
try: try:
await provision_agent( async def _do_provision() -> None:
agent, await provision_agent(
board, agent,
gateway, board,
auth_token, gateway,
user, auth_token,
action="update", user,
force_bootstrap=force_bootstrap, action="update",
reset_session=reset_sessions, force_bootstrap=force_bootstrap,
) 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,25 +378,57 @@ 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:
result.errors.append( if rotate_tokens:
GatewayTemplatesSyncError( raw_token = generate_agent_token()
agent_id=main_agent.id, main_agent.agent_token_hash = hash_agent_token(raw_token)
agent_name=main_agent.name, main_agent.updated_at = utcnow()
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.", session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Skipping main agent: unable to read AUTH_TOKEN from TOOLS.md.",
)
)
return result
if main_agent.agent_token_hash and not verify_agent_token(
main_token, main_agent.agent_token_hash
):
if rotate_tokens:
raw_token = generate_agent_token()
main_agent.agent_token_hash = hash_agent_token(raw_token)
main_agent.updated_at = utcnow()
session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Warning: AUTH_TOKEN in TOOLS.md does not match backend token hash (main agent auth may be broken).",
)
) )
)
return result
try: try:
await provision_main_agent( async def _do_provision_main() -> None:
main_agent, await provision_main_agent(
gateway, main_agent,
main_token, gateway,
user, main_token,
action="update", user,
force_bootstrap=force_bootstrap, action="update",
reset_session=reset_sessions, force_bootstrap=force_bootstrap,
) 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,6 +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==6.0.0",
"coverage[toml]==7.6.10",
"ruff==0.6.9", "ruff==0.6.9",
] ]
@@ -51,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:

55
backend/uv.lock generated
View File

@@ -158,6 +158,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.6.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "44.0.1" version = "44.0.1"
@@ -535,12 +573,14 @@ 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" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -548,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" },
@@ -560,6 +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 = "==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" },
@@ -828,6 +870,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" },
] ]
[[package]]
name = "pytest-cov"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/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]]
name = "python-dotenv" name = "python-dotenv"
version = "1.0.1" version = "1.0.1"

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

@@ -342,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);
@@ -1713,6 +1714,7 @@ export default function BoardDetailPage() {
); );
} finally { } finally {
setIsPostingComment(false); setIsPostingComment(false);
taskCommentInputRef.current?.focus();
} }
}; };
@@ -2612,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"
/> />

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,25 +492,28 @@ 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>
</div> </div>
{extraContextOpen ? ( {extraContextOpen ? (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<Textarea <Textarea
className="min-h-[84px]" ref={extraContextRef}
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)" className="min-h-[84px]"
value={extraContext} placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
onChange={(event) => setExtraContext(event.target.value)} value={extraContext}
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,12 @@ 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 ## Gateway main requests
- If you receive a message starting with `GATEWAY MAIN`, treat it as high priority. - 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. - Do **not** reply in OpenClaw chat. Reply via Mission Control only.

View File

@@ -29,19 +29,17 @@ Do this immediately. Do not ask permission to read your workspace.
## Gateway Delegation (board leads) ## Gateway Delegation (board leads)
- You can message any board lead agent via Mission Control API (never OpenClaw chat). - You can message any board lead agent via Mission Control API (never OpenClaw chat).
- If the requested board does not exist, you must create it and provision its lead agent first. - 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 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). - 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).
Ensure (create if needed) a board + lead: List boards (to find `board_id`):
```bash ```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/ensure" \ curl -s -X GET "$BASE_URL/api/v1/agent/boards" \
-H "X-Agent-Token: $AUTH_TOKEN" \ -H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"<Board Name>","slug":"<optional-slug>","board_type":"goal","objective":"<optional>","success_metrics":null,"target_date":null}'
``` ```
Send a question or handoff to a board lead: Send a question or handoff to a board lead (auto-provisions the lead agent if missing):
```bash ```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \ curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
-H "X-Agent-Token: $AUTH_TOKEN" \ -H "X-Agent-Token: $AUTH_TOKEN" \
@@ -62,6 +60,11 @@ Board lead replies:
- Read replies via: - Read replies via:
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50` - 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: