type: message

role: user

type: text

text: тест бродкаста


role: assistant

ls

type: toolCall

api: anthropic-messages

provider: anthropic

stopReason: toolUse


role: toolResult

toolName: ls

text:
.git/
agent-coder/
docs/
picogent/
runner/
sessions/
tracker/
web-client/
ws_test.py


path: runner


text:
.git/
.gitignore
node_modules/
package-lock.json
package.json
README.md
src/
tsconfig.json


toolName: read

text:
#!/usr/bin/env python3
"""
Direct WebSocket test for Team Board Tracker.

Usage:
  python ws_test.py [--url ws://localhost:8100/ws] [--token <token>]

Without --token: creates a test agent via REST API first.
Uses only stdlib + websockets (already in tracker venv).
"""

import asyncio
import json
import argparse
import urllib.request
import urllib.error
from datetime import datetime

import websockets

BASE_URL = "http://localhost:8100"
WS_URL = "ws://localhost:8100/ws"
TEST_SLUG = "ws-test-bot"
TEST_NAME = "WS Test Bot"

def ts():
    return datetime.now().strftime("%H:%M:%S.%f")[:-3]

def log(tag, msg, data=None):
    extra = f"  {json.dumps(data, ensure_ascii=False, indent=None)}" if data else ""
    print(f"[{ts()}] {tag:<12} {msg}{extra}")

# ── REST helpers ──────────────────────────────────────────────────────────────

API = "/api/v1"

def http_get(path: str) -> tuple[int, dict]:
    try:
        with urllib.request.urlopen(f"{BASE_URL}{path}") as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        return e.code, {}

def http_post(path: str, body: dict) -> tuple[int, dict]:
    data = json.dumps(body).encode()
    req = urllib.request.Request(
        f"{BASE_URL}{path}",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read()) if e.fp else {}

def ensure_test_agent() -> str:
    """Create or get test agent, return token."""
    code, data = http_get(f"{API}/members/{TEST_SLUG}")
    if code == 200:
        token = data.get("token")
        if token:
            log("SETUP", f"Found existing agent '{TEST_SLUG}'", {"token": token[:20] + "..."})
            return token
        # Regenerate
        code2, d2 = http_post(f"{API}/members/{TEST_SLUG}/regenerate-token", {})
        if code2 == 200:
            token = d2["token"]
            log("SETUP", "Regenerated token", {"token": token[:20] + "..."})
            return token
        raise RuntimeError(f"Failed to regenerate token: {code2} {d2}")

    # Create
    payload = {
        "name": TEST_NAME,
        "slug": TEST_SLUG,
        "type": "agent",
        "role": "member",
        "agent_config": {"chat_listen": "all", "task_listen": "all"},
    }
    code, data = http_post(f"{API}/members", payload)
    if code not in (200, 201):
        raise RuntimeError(f"Failed to create agent: {code} {data}")
    token = data["token"]
    log("SETUP", f"Created agent '{TEST_SLUG}'", {"token": token[:20] + "..."})
    return token

# ── WS test ───────────────────────────────────────────────────────────────────

async def run_test(ws_url: str, token: str):
    log("WS", f"Connecting → {ws_url}")

    async with websockets.connect(ws_url) as ws:
        log("WS", "Connected ✅")

        # ── 1. Auth ──
        auth = {"type": "auth", "token": token}
        log("SEND", "auth", auth)
        await ws.send(json.dumps(auth))

        raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
        msg = json.loads(raw)
        log("RECV", msg.get("type", "?"), msg)

        if msg.get("type") != "auth.ok":
            log("ERROR", f"Expected auth.ok, got '{msg.get('type')}'")
            return

        info = msg.get("data", {})
        lobby_id = info.get("lobby_chat_id")
        projects = info.get("projects", [])
        online = info.get("online", [])
        log("INFO", f"online={online}, lobby={lobby_id}, projects={[p['name'] for p in projects]}")

        # ── 2. Heartbeat ──
        hb = {"type": "heartbeat", "status": "idle"}
        log("SEND", "heartbeat", hb)
        await ws.send(json.dumps(hb))
        await asyncio.sleep(0.3)

        # ── 3. Subscribe to project ──
        if projects:
            p = projects[0]
            sub = {"type": "project.subscribe", "project_id": p["id"]}
            log("SEND", "project.subscribe", sub)
            await ws.send(json.dumps(sub))
            await asyncio.sleep(0.3)

        # ── 4. Send chat message ──
        if lobby_id:
            chat_msg = {
                "type": "chat.send",
                "chat_id": lobby_id,
                "content": f"[ws_test.py] ping @ {ts()}",
                "mentions": [],
            }
            log("SEND", "chat.send", chat_msg)
            await ws.send(json.dumps(chat_msg))
        else:
            log("WARN", "No lobby_chat_id — skipping chat.send")

        # ── 5. Drain incoming (3 s) ──
        log("INFO", "Listening 3 s for incoming events...")
        deadline = asyncio.get_event_loop().time() + 3.0
        while asyncio.get_event_loop().time() < deadline:
            try:
                raw = await asyncio.wait_for(ws.recv(), timeout=0.4)
                incoming = json.loads(raw)
                log("RECV", incoming.get("type", "?"), incoming)
            except asyncio.TimeoutError:
                pass

        # ── 6. Unknown event → expect error ──
        bad = {"type": "totally.unknown", "foo": "bar"}
        log("SEND", "bad event", bad)
        await ws.send(json.dumps(bad))
        try:
            raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
            err = json.loads(raw)
            log("RECV", err.get("type", "?"), err)
        except asyncio.TimeoutError:
            log("WARN", "No response to unknown event")

        log("WS", "Test complete ✅")

# ── main ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", default=WS_URL)
    parser.add_argument("--token", default=None, help="Skip agent creation, use this token")
    args = parser.parse_args()

    if args.token:
        token = args.token
        log("SETUP", f"Using provided token: {token[:20]}...")
    else:
        log("SETUP", f"No token — creating/fetching test agent via {BASE_URL}")
        token = ensure_test_agent()

    asyncio.run(run_test(args.url, token))

if __name__ == "__main__":
    main()


path: tracker/src


text: tracker/


path: tracker


text:
.env.dev
.git/
.gitea/
.gitignore
docker-compose.yml
Dockerfile
README.md
requirements.txt
src/


bash

command: find tracker/src -type f | head -50


toolName: bash

text:
tracker/src/tracker/ws/manager.py
tracker/src/tracker/ws/__pycache__/__init__.cpython-312.pyc
tracker/src/tracker/ws/__pycache__/handler.cpython-312.pyc
tracker/src/tracker/ws/__pycache__/manager.cpython-312.pyc
tracker/src/tracker/ws/__init__.py
tracker/src/tracker/ws/handler.py
tracker/src/tracker/models/project.py
tracker/src/tracker/models/base.py
tracker/src/tracker/models/member.py
tracker/src/tracker/models/task.py
tracker/src/tracker/models/__pycache__/label.cpython-312.pyc
tracker/src/tracker/models/__pycache__/chat.cpython-312.pyc
tracker/src/tracker/models/__pycache__/__init__.cpython-312.pyc
tracker/src/tracker/models/__pycache__/base.cpython-312.pyc
tracker/src/tracker/models/__pycache__/member.cpython-312.pyc
tracker/src/tracker/models/__pycache__/project.cpython-312.pyc
tracker/src/tracker/models/__pycache__/task.cpython-312.pyc
tracker/src/tracker/models/__pycache__/agent.cpython-312.pyc
tracker/src/tracker/models/chat.py
tracker/src/tracker/models/__init__.py
tracker/src/tracker/services/__init__.py
tracker/src/tracker/init_db.py
tracker/src/tracker/api/messages.py
tracker/src/tracker/api/projects.py
tracker/src/tracker/api/members.py
tracker/src/tracker/api/__pycache__/projects.cpython-312.pyc
tracker/src/tracker/api/__pycache__/auth.cpython-312.pyc
tracker/src/tracker/api/__pycache__/tasks.cpython-312.pyc
tracker/src/tracker/api/__pycache__/__init__.cpython-312.pyc
tracker/src/tracker/api/__pycache__/chats.cpython-312.pyc
tracker/src/tracker/api/__pycache__/messages.cpython-312.pyc
tracker/src/tracker/api/__pycache__/steps.cpython-312.pyc
tracker/src/tracker/api/__pycache__/members.cpython-312.pyc
tracker/src/tracker/api/__pycache__/labels.cpython-312.pyc
tracker/src/tracker/api/__pycache__/agents.cpython-312.pyc
tracker/src/tracker/api/__init__.py
tracker/src/tracker/api/steps.py
tracker/src/tracker/api/auth.py
tracker/src/tracker/api/tasks.py
tracker/src/tracker/__pycache__/app.cpython-312.pyc
tracker/src/tracker/__pycache__/init_db.cpython-312.pyc
tracker/src/tracker/__pycache__/__init__.cpython-312.pyc
tracker/src/tracker/__pycache__/config.cpython-312.pyc
tracker/src/tracker/__pycache__/database.cpython-312.pyc
tracker/src/tracker/__init__.py
tracker/src/tracker/database.py
tracker/src/tracker/app.py
tracker/src/tracker/config.py


text:
"""WebSocket connection manager with project subscriptions and filtering."""

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone

from fastapi import WebSocket

logger = logging.getLogger("tracker.ws")

@dataclass
class ConnectedClient:
    ws: WebSocket
    member_slug: str
    member_type: str  # human | agent | bridge
    chat_listen: str = "all"  # all | mentions | none
    task_listen: str = "all"  # all | mentions | none
    subscribed_projects: set[str] = field(default_factory=set)  # project_ids
    last_heartbeat: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

class ConnectionManager:
    def __init__(self):
        self.clients: dict[str, ConnectedClient] = {}  # slug → client

    async def connect(self, client: ConnectedClient):
        self.clients[client.member_slug] = client
        logger.info("WS connected: %s (%s)", client.member_slug, client.member_type)

    async def disconnect(self, slug: str):
        if slug in self.clients:
            del self.clients[slug]
            logger.info("WS disconnected: %s", slug)

    async def send_to(self, slug: str, data: dict):
        """Send to specific client."""
        client = self.clients.get(slug)
        if client:
            try:
                await client.ws.send_json(data)
            except Exception:
                await self.disconnect(slug)

    async def broadcast_message(self, project_id: str, message: dict, author_slug: str):
        """Broadcast message.new — filtered by chat_listen."""
        logger.info("broadcast_message: project=%s, author=%s, clients=%s", project_id, author_slug, list(self.clients.keys()))
        for slug, client in list(self.clients.items()):
            logger.info("  checking %s: subscribed=%s, chat_listen=%s", slug, client.subscribed_projects, client.chat_listen)
        mentions = message.get("mentions", [])
        for slug, client in list(self.clients.items()):
            if slug == author_slug:
                continue  # don't echo to sender
            if project_id not in client.subscribed_projects:
                continue
            # Filter by chat_listen
            if client.chat_listen == "none":
                continue  # don't send any chat messages
            if client.chat_listen == "mentions" and slug not in mentions:
                continue
            await self.send_to(slug, {"type": "message.new", "data": message})

    async def broadcast_task_event(self, project_id: str, event_type: str, data: dict):
        """Broadcast task events — filtered by task_listen + ownership."""
        assignee = data.get("assignee_slug")
        reviewer = data.get("reviewer_slug")
        watchers = data.get("watchers", [])

        for slug, client in list(self.clients.items()):
            if project_id not in client.subscribed_projects:
                continue

            # task_listen: none → skip all task events
            if client.task_listen == "none":
                continue

            # task_listen: all → get everything
            if client.task_listen == "all":
                await self.send_to(slug, {"type": event_type, "data": data})
                continue

            # task_listen: mentions → only if involved
            if slug in (assignee, reviewer) or slug in watchers:
                await self.send_to(slug, {"type": event_type, "data": data})

    async def broadcast_all(self, data: dict, exclude: str | None = None):
        """Broadcast to all connected clients."""
        for slug, client in list(self.clients.items()):
            if slug == exclude:
                continue
            await self.send_to(slug, data)

manager = ConnectionManager()


text:
"""WebSocket handler — auth, heartbeat, chat.send, project subscriptions."""

import logging
import uuid

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from sqlalchemy.orm import selectinload

from tracker.database import async_session
from tracker.models import Member, AgentConfig, Chat, Message, Project
from tracker.ws.manager import ConnectedClient, manager

logger = logging.getLogger("tracker.ws")
router = APIRouter()

@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    slug = None

    try:
        # Wait for auth message
        auth_msg = await ws.receive_json()
        if auth_msg.get("type") != "auth":
            await ws.send_json({"type": "auth.error", "message": "First message must be auth"})
            await ws.close()
            return

        token = auth_msg.get("token", "")
        slug = await _authenticate(ws, token)
        if not slug:
            return

        # Main loop
        while True:
            data = await ws.receive_json()
            msg_type = data.get("type")

            if msg_type == "heartbeat":
                await _handle_heartbeat(slug, data)

            elif msg_type == "ack":
                pass  # acknowledged, no action needed

            elif msg_type == "chat.send":
                await _handle_chat_send(slug, data)

            elif msg_type == "project.subscribe":
                await _handle_subscribe(slug, data)

            elif msg_type == "project.unsubscribe":
                await _handle_unsubscribe(slug, data)

            else:
                await ws.send_json({"type": "error", "message": f"Unknown type: {msg_type}"})

    except WebSocketDisconnect:
        logger.info("WS disconnect: %s", slug)
    except Exception as e:
        logger.error("WS error for %s: %s", slug, e)
    finally:
        if slug:
            await manager.disconnect(slug)
            # Update status to offline
            async with async_session() as db:
                result = await db.execute(select(Member).where(Member.slug == slug))
                member = result.scalar_one_or_none()
                if member:
                    member.status = "offline"
                    await db.commit()
            # Notify others
            await manager.broadcast_all(
                {"type": "agent.status", "data": {"slug": slug, "status": "offline"}},
                exclude=slug,
            )

async def _authenticate(ws: WebSocket, token: str) -> str | None:
    """Authenticate by token, return slug or None."""
    async with async_session() as db:
        result = await db.execute(
            select(Member).where(Member.token == token).options(selectinload(Member.agent_config))
        )
        member = result.scalar_one_or_none()

        if not member:
            # Try JWT auth (for BFF/web client)
            from tracker.api.auth import decode_jwt
            try:
                payload = decode_jwt(token)
                result = await db.execute(
                    select(Member).where(Member.id == payload["sub"])
                    .options(selectinload(Member.agent_config))
                )
                member = result.scalar_one_or_none()
            except Exception:
                pass

        if not member:
            await ws.send_json({"type": "auth.error", "message": "Invalid token"})
            await ws.close()
            return None

        # Get listen modes
        chat_listen = "all"
        task_listen = "all"
        if member.agent_config:
            chat_listen = member.agent_config.chat_listen
            task_listen = member.agent_config.task_listen

        # Register connection
        client = ConnectedClient(
            ws=ws,
            member_slug=member.slug,
            member_type=member.type,
            chat_listen=chat_listen,
            task_listen=task_listen,
        )
        await manager.connect(client)

        # Update status
        member.status = "online"
        await db.commit()

        # Get lobby chat + projects with chat_id
        lobby = await db.execute(select(Chat).where(Chat.kind == "lobby"))
        lobby_chat = lobby.scalar_one_or_none()

        projects = await db.execute(select(Project).where(Project.status == "active"))
        project_list = []
        for p in projects.scalars():
            # Get project chat
            chat_result = await db.execute(
                select(Chat).where(Chat.project_id == p.id, Chat.kind == "project")
            )
            chat = chat_result.scalar_one_or_none()
            project_list.append({
                "id": str(p.id),
                "slug": p.slug,
                "name": p.name,
                "chat_id": str(chat.id) if chat else None,
            })

        online = list(manager.clients.keys())

        await ws.send_json({
            "type": "auth.ok",
            "data": {
                "slug": member.slug,
                "lobby_chat_id": str(lobby_chat.id) if lobby_chat else None,
                "projects": project_list,
                "online": online,
            },
        })

        # Notify others
        await manager.broadcast_all(
            {"type": "agent.status", "data": {"slug": member.slug, "status": "online"}},
            exclude=member.slug,
        )

        return member.slug

async def _handle_heartbeat(slug: str, data: dict):
    """Update member status from heartbeat."""
    from datetime import datetime, timezone

    status = data.get("status", "online")

    # Update last heartbeat timestamp
    client = manager.clients.get(slug)
    if client:
        client.last_heartbeat = datetime.now(timezone.utc)

    async with async_session() as db:
        result = await db.execute(select(Member).where(Member.slug == slug))
        member = result.scalar_one_or_none()
        if member:
            member.status = status
            await db.commit()

            # Broadcast status change if different
            await manager.broadcast_all(
                {"type": "agent.status", "data": {"slug": slug, "status": status}},
                exclude=slug,
            )

async def _handle_chat_send(slug: str, data: dict):
    """Handle chat message sent via WS."""
    chat_id = data.get("chat_id")
    task_id = data.get("task_id")
    content = data.get("content", "")
    mentions = data.get("mentions", [])

    if not content:
        return

    async with async_session() as db:
        # Get sender info
        result = await db.execute(select(Member).where(Member.slug == slug))
        member = result.scalar_one_or_none()
        if not member:
            return

        msg = Message(
            chat_id=uuid.UUID(chat_id) if chat_id else None,
            task_id=uuid.UUID(task_id) if task_id else None,
            author_type=member.type,
            author_slug=member.slug,
            content=content,
            mentions=mentions,
        )
        db.add(msg)
        await db.commit()
        await db.refresh(msg)

        msg_data = {
            "id": str(msg.id),
            "chat_id": chat_id,
            "task_id": task_id,
            "author_type": member.type,
            "author_slug": member.slug,
            "author_name": member.name,
            "content": content,
            "mentions": mentions,
            "created_at": msg.created_at.isoformat(),
        }

        # Determine project_id for filtering
        project_id = None
        if chat_id:
            chat_result = await db.execute(select(Chat).where(Chat.id == uuid.UUID(chat_id)))
            chat = chat_result.scalar_one_or_none()
            if chat and chat.project_id:
                project_id = str(chat.project_id)
            elif chat and chat.kind == "lobby":
                # Lobby — broadcast to all
                await manager.broadcast_all(
                    {"type": "message.new", "data": msg_data},
                    exclude=slug,
                )
                return

        if project_id:
            await manager.broadcast_message(project_id, msg_data, author_slug=slug)
        else:
            # Task comment or unlinked — broadcast to all
            await manager.broadcast_all(
                {"type": "message.new", "data": msg_data},
                exclude=slug,
            )

async def _handle_subscribe(slug: str, data: dict):
    """Subscribe to a project's events."""
    project_id = data.get("project_id")
    if not project_id:
        return
    client = manager.clients.get(slug)
    if client:
        client.subscribed_projects.add(project_id)
        logger.info("%s subscribed to project %s", slug, project_id)

async def _handle_unsubscribe(slug: str, data: dict):
    """Unsubscribe from a project."""
    project_id = data.get("project_id")
    if not project_id:
        return
    client = manager.clients.get(slug)
    if client:
        client.subscribed_projects.discard(project_id)
        logger.info("%s unsubscribed from project %s", slug, project_id)


text: Посмотрю, запущен ли трекер:

command: curl -s http://localhost:8100/api/v1/members 2>&1 | head -20


Admin

slug: admin

type: human

role: owner

status: offline

BFF Service

slug: bff

type: bridge

role: bridge

WS Test Bot

slug: ws-test-bot

type: agent

role: member

chat_listen: all

task_listen: all

Кодер

slug: coder

status: online

capabilities: coding review

chat_listen: all

task_listen: all

prompt: Ты опытный разработчик. Пишешь чистый, идиоматичный код. Отвечаешь кратко и по делу. Язык общения — русский.

model: sonnet


text: Трекер работает. Запускаю тест:

bash

command: cd /root/projects/team-board && python ws_test.py 2>&1


text:
/bin/bash: line 1: python: command not found

Command exited with code 127


command: cd /root/projects/team-board && python3 ws_test.py 2>&1


text:
[18:59:07.213] SETUP        No token — creating/fetching test agent via http://localhost:8100
[18:59:07.247] SETUP        Found existing agent 'ws-test-bot'  {"token": "tb-9500fb9ecaa514e70..."}
[18:59:07.247] WS           Connecting → ws://localhost:8100/ws
[18:59:07.267] WS           Connected ✅
[18:59:07.267] SEND         auth  {"type": "auth", "token": "tb-9500fb9ecaa514e70e4491b93c869a9fa81bf16ae7ffb670c8c219d1cc9b5457"}
[18:59:07.282] RECV         auth.ok  {"type": "auth.ok", "data": {"slug": "ws-test-bot", "lobby_chat_id": "637a43d0-f340-4326-9740-881811529d12", "projects": [{"id": "fa73e4a0-2c06-4b42-a53c-1f1ddca890df", "slug": "team-board", "name": "Team Board", "chat_id": "08422828-2de4-415c-a3a3-640bb15eafba"}], "online": ["coder", "ws-test-bot"]}}
[18:59:07.282] INFO         online=['coder', 'ws-test-bot'], lobby=637a43d0-f340-4326-9740-881811529d12, projects=['Team Board']
[18:59:07.282] SEND         heartbeat  {"type": "heartbeat", "status": "idle"}
[18:59:07.583] SEND         project.subscribe  {"type": "project.subscribe", "project_id": "fa73e4a0-2c06-4b42-a53c-1f1ddca890df"}
[18:59:07.884] SEND         chat.send  {"type": "chat.send", "chat_id": "637a43d0-f340-4326-9740-881811529d12", "content": "[ws_test.py] ping @ 18:59:07.884", "mentions": []}
[18:59:07.884] INFO         Listening 3 s for incoming events...
[18:59:11.090] SEND         bad event  {"type": "totally.unknown", "foo": "bar"}
[18:59:11.093] RECV         error  {"type": "error", "message": "Unknown type: totally.unknown"}
[18:59:11.093] WS           Test complete ✅


text:
Всё работает отлично:

- **auth.ok** — авторизация прошла, получены lobby_chat_id и проекты, online: `['coder', 'ws-test-bot']`
- **heartbeat** — отправлен, статус `idle`
- **project.subscribe** — подписка на проект Team Board
- **chat.send** — сообщение отправлено в лобби
- **bad event** → `error: Unknown type` — обработка неизвестных событий работает
- Бродкаст работает (сообщение уйдёт `coder`-у, который сейчас online)

stopReason: stop