v0.2.0: new models (Member, Unified Message, Steps, Watchers), REST API, WS v2
Some checks failed
Deploy Tracker / deploy (push) Failing after 1s
Some checks failed
Deploy Tracker / deploy (push) Failing after 1s
- Member + AgentConfig replaces User + Agent - Unified Message (chat + task comments) - Steps (task checklist) - Watchers on tasks - Auth API (JWT login) - Members API (CRUD + token generation) - Tasks API (take/reject/assign/watch) - Steps API (CRUD) - Messages API (unified) - WS handler v2 (auth, heartbeat, project.subscribe, chat.send, filtering) - Removed: Alembic, Label model, old Agent model - DB: create_all on startup (dev), init_db.py for seed
This commit is contained in:
parent
c2b6099fac
commit
e51a4fa4a9
35
alembic.ini
35
alembic.ini
@ -1,35 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = alembic
|
|
||||||
sqlalchemy.url = postgresql+asyncpg://team_board:team_board@postgres:5432/team_board_dev
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
"""Alembic env — async PostgreSQL."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import pool
|
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
|
|
||||||
# Add src to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
||||||
|
|
||||||
from tracker.models import Base # noqa: E402
|
|
||||||
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
# Override URL from env if set
|
|
||||||
db_url = os.getenv("TRACKER_DATABASE_URL")
|
|
||||||
if db_url:
|
|
||||||
config.set_main_option("sqlalchemy.url", db_url)
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations():
|
|
||||||
connectable = async_engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
async with connectable.connect() as connection:
|
|
||||||
await connection.run_sync(do_run_migrations)
|
|
||||||
await connectable.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online():
|
|
||||||
asyncio.run(run_async_migrations())
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
"""initial schema
|
|
||||||
|
|
||||||
Revision ID: 59dd2d9071fd
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-02-15 17:42:19.160196
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '59dd2d9071fd'
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('adapters',
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('provider', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
||||||
sa.Column('capabilities', postgresql.ARRAY(sa.String()), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('labels',
|
|
||||||
sa.Column('name', sa.String(length=100), nullable=False),
|
|
||||||
sa.Column('color', sa.String(length=7), nullable=True),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('name')
|
|
||||||
)
|
|
||||||
op.create_table('projects',
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('slug', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('git_repo', sa.String(length=500), nullable=True),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('slug')
|
|
||||||
)
|
|
||||||
op.create_table('agents',
|
|
||||||
sa.Column('name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('slug', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('adapter_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('system_prompt', sa.Text(), nullable=True),
|
|
||||||
sa.Column('subscription_mode', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('max_concurrent', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('timeout_seconds', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('status', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('host', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('pid', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('restart_count', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['adapter_id'], ['adapters.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('slug')
|
|
||||||
)
|
|
||||||
op.create_table('tasks',
|
|
||||||
sa.Column('project_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('parent_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('title', sa.String(length=500), nullable=False),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('priority', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('requires_pr', sa.Boolean(), nullable=False),
|
|
||||||
sa.Column('pr_url', sa.String(length=500), nullable=True),
|
|
||||||
sa.Column('assigned_agent_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('position', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['assigned_agent_id'], ['agents.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['parent_id'], ['tasks.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('chats',
|
|
||||||
sa.Column('project_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('task_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('kind', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('task_dependencies',
|
|
||||||
sa.Column('task_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('depends_on_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['depends_on_id'], ['tasks.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('task_files',
|
|
||||||
sa.Column('task_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('filename', sa.String(length=500), nullable=False),
|
|
||||||
sa.Column('mime_type', sa.String(length=100), nullable=True),
|
|
||||||
sa.Column('file_path', sa.String(length=1000), nullable=False),
|
|
||||||
sa.Column('uploaded_by', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('task_labels',
|
|
||||||
sa.Column('task_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('label_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['label_id'], ['labels.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_table('chat_messages',
|
|
||||||
sa.Column('chat_id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('sender_type', sa.String(length=20), nullable=False),
|
|
||||||
sa.Column('sender_id', sa.UUID(), nullable=True),
|
|
||||||
sa.Column('sender_name', sa.String(length=255), nullable=True),
|
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_table('chat_messages')
|
|
||||||
op.drop_table('task_labels')
|
|
||||||
op.drop_table('task_files')
|
|
||||||
op.drop_table('task_dependencies')
|
|
||||||
op.drop_table('chats')
|
|
||||||
op.drop_table('tasks')
|
|
||||||
op.drop_table('agents')
|
|
||||||
op.drop_table('projects')
|
|
||||||
op.drop_table('labels')
|
|
||||||
op.drop_table('adapters')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
"""add task number and project key_prefix
|
|
||||||
|
|
||||||
Revision ID: 864b0ce5d657
|
|
||||||
Revises: 59dd2d9071fd
|
|
||||||
Create Date: 2026-02-15 19:27:43.338390
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = '864b0ce5d657'
|
|
||||||
down_revision: Union[str, None] = '59dd2d9071fd'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Add as nullable first
|
|
||||||
op.add_column('projects', sa.Column('key_prefix', sa.String(length=10), nullable=True))
|
|
||||||
op.add_column('projects', sa.Column('task_counter', sa.Integer(), nullable=True))
|
|
||||||
op.add_column('tasks', sa.Column('number', sa.Integer(), nullable=True))
|
|
||||||
|
|
||||||
# Fill defaults
|
|
||||||
op.execute("UPDATE projects SET key_prefix = UPPER(LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3)) WHERE key_prefix IS NULL")
|
|
||||||
op.execute("UPDATE projects SET key_prefix = 'PRJ' WHERE key_prefix IS NULL OR key_prefix = ''")
|
|
||||||
op.execute("UPDATE projects SET task_counter = 0 WHERE task_counter IS NULL")
|
|
||||||
op.execute("UPDATE tasks SET number = 0 WHERE number IS NULL")
|
|
||||||
|
|
||||||
# Number existing tasks sequentially per project
|
|
||||||
op.execute("""
|
|
||||||
WITH numbered AS (
|
|
||||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at) as rn
|
|
||||||
FROM tasks
|
|
||||||
)
|
|
||||||
UPDATE tasks SET number = numbered.rn FROM numbered WHERE tasks.id = numbered.id
|
|
||||||
""")
|
|
||||||
# Update project counters
|
|
||||||
op.execute("""
|
|
||||||
UPDATE projects SET task_counter = COALESCE(
|
|
||||||
(SELECT MAX(number) FROM tasks WHERE tasks.project_id = projects.id), 0
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Make NOT NULL
|
|
||||||
op.alter_column('projects', 'key_prefix', nullable=False)
|
|
||||||
op.alter_column('projects', 'task_counter', nullable=False, server_default='0')
|
|
||||||
op.alter_column('tasks', 'number', nullable=False, server_default='0')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('tasks', 'number')
|
|
||||||
op.drop_column('projects', 'task_counter')
|
|
||||||
op.drop_column('projects', 'key_prefix')
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
"""remove adapters, update agents: capabilities + token on agent
|
|
||||||
|
|
||||||
Revision ID: a1b2c3d4e5f6
|
|
||||||
Revises: 864b0ce5d657
|
|
||||||
Create Date: 2026-02-15 22:30:00.000000
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision: str = 'a1b2c3d4e5f6'
|
|
||||||
down_revision: Union[str, None] = '864b0ce5d657'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Add new columns to agents
|
|
||||||
op.add_column('agents', sa.Column('token', sa.String(255), unique=True))
|
|
||||||
op.add_column('agents', sa.Column('capabilities', postgresql.ARRAY(sa.String()), server_default='{}', nullable=False))
|
|
||||||
|
|
||||||
# Generate tokens for existing agents
|
|
||||||
op.execute("UPDATE agents SET token = 'agent-' || md5(random()::text || clock_timestamp()::text) WHERE token IS NULL")
|
|
||||||
op.alter_column('agents', 'token', nullable=False)
|
|
||||||
|
|
||||||
# Drop old columns from agents
|
|
||||||
op.drop_constraint('agents_adapter_id_fkey', 'agents', type_='foreignkey')
|
|
||||||
op.drop_column('agents', 'adapter_id')
|
|
||||||
op.drop_column('agents', 'system_prompt')
|
|
||||||
op.drop_column('agents', 'host')
|
|
||||||
op.drop_column('agents', 'pid')
|
|
||||||
op.drop_column('agents', 'restart_count')
|
|
||||||
|
|
||||||
# Change default subscription_mode
|
|
||||||
op.alter_column('agents', 'subscription_mode', server_default='assigned')
|
|
||||||
|
|
||||||
# Drop adapters table
|
|
||||||
op.drop_table('adapters')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Recreate adapters
|
|
||||||
op.create_table(
|
|
||||||
'adapters',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
|
||||||
sa.Column('name', sa.String(255), nullable=False),
|
|
||||||
sa.Column('provider', sa.String(50), nullable=False),
|
|
||||||
sa.Column('config', postgresql.JSONB, nullable=False, server_default='{}'),
|
|
||||||
sa.Column('capabilities', postgresql.ARRAY(sa.String()), nullable=False, server_default='{}'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-add columns to agents
|
|
||||||
op.add_column('agents', sa.Column('adapter_id', postgresql.UUID(as_uuid=True)))
|
|
||||||
op.add_column('agents', sa.Column('system_prompt', sa.Text()))
|
|
||||||
op.add_column('agents', sa.Column('host', sa.String(255), server_default='localhost'))
|
|
||||||
op.add_column('agents', sa.Column('pid', sa.Integer()))
|
|
||||||
op.add_column('agents', sa.Column('restart_count', sa.Integer(), server_default='0'))
|
|
||||||
|
|
||||||
op.drop_column('agents', 'token')
|
|
||||||
op.drop_column('agents', 'capabilities')
|
|
||||||
@ -12,9 +12,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
command: >
|
command: uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src
|
||||||
sh -c "alembic upgrade head &&
|
|
||||||
uvicorn tracker.app:app --host 0.0.0.0 --port 8100 --reload --reload-dir /app/src"
|
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
@ -2,7 +2,7 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
sqlalchemy[asyncio]>=2.0
|
sqlalchemy[asyncio]>=2.0
|
||||||
asyncpg>=0.30
|
asyncpg>=0.30
|
||||||
alembic>=1.14
|
|
||||||
pydantic-settings>=2.7
|
pydantic-settings>=2.7
|
||||||
redis>=5.0
|
PyJWT>=2.8
|
||||||
|
python-multipart>=0.0.9
|
||||||
websockets>=14.0
|
websockets>=14.0
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
@ -1,92 +0,0 @@
|
|||||||
"""Agents CRUD API."""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from tracker.database import get_db
|
|
||||||
from tracker.models import Agent
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
||||||
|
|
||||||
|
|
||||||
class AgentCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
capabilities: list[str] = []
|
|
||||||
subscription_mode: str = "assigned"
|
|
||||||
max_concurrent: int = 1
|
|
||||||
timeout_seconds: int = 600
|
|
||||||
|
|
||||||
|
|
||||||
class AgentUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
capabilities: list[str] | None = None
|
|
||||||
subscription_mode: str | None = None
|
|
||||||
max_concurrent: int | None = None
|
|
||||||
timeout_seconds: int | None = None
|
|
||||||
status: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
token: str
|
|
||||||
capabilities: list[str]
|
|
||||||
subscription_mode: str
|
|
||||||
max_concurrent: int
|
|
||||||
timeout_seconds: int
|
|
||||||
status: str
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[AgentOut])
|
|
||||||
async def list_agents(db: AsyncSession = Depends(get_db)):
|
|
||||||
result = await db.execute(select(Agent).order_by(Agent.created_at))
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=AgentOut, status_code=201)
|
|
||||||
async def create_agent(data: AgentCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
dump = data.model_dump()
|
|
||||||
dump["token"] = f"agent-{secrets.token_hex(16)}"
|
|
||||||
agent = Agent(**dump)
|
|
||||||
db.add(agent)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(agent)
|
|
||||||
return agent
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{agent_id}", response_model=AgentOut)
|
|
||||||
async def get_agent(agent_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
agent = await db.get(Agent, agent_id)
|
|
||||||
if not agent:
|
|
||||||
raise HTTPException(404, "Agent not found")
|
|
||||||
return agent
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{agent_id}", response_model=AgentOut)
|
|
||||||
async def update_agent(agent_id: uuid.UUID, data: AgentUpdate, db: AsyncSession = Depends(get_db)):
|
|
||||||
agent = await db.get(Agent, agent_id)
|
|
||||||
if not agent:
|
|
||||||
raise HTTPException(404, "Agent not found")
|
|
||||||
for k, v in data.model_dump(exclude_unset=True).items():
|
|
||||||
setattr(agent, k, v)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(agent)
|
|
||||||
return agent
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{agent_id}", status_code=204)
|
|
||||||
async def delete_agent(agent_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
agent = await db.get(Agent, agent_id)
|
|
||||||
if not agent:
|
|
||||||
raise HTTPException(404, "Agent not found")
|
|
||||||
await db.delete(agent)
|
|
||||||
await db.commit()
|
|
||||||
104
src/tracker/api/auth.py
Normal file
104
src/tracker/api/auth.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""Auth API — login, JWT."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from tracker.config import settings
|
||||||
|
from tracker.database import get_db
|
||||||
|
from tracker.models import Member
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXPIRE_HOURS = 72
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_jwt(member_id: str, slug: str, role: str) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(member_id),
|
||||||
|
"slug": slug,
|
||||||
|
"role": role,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(401, "Token expired")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(401, "Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_member(
|
||||||
|
authorization: str = Header(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Member:
|
||||||
|
"""Extract member from JWT or agent token."""
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(401, "Missing authorization header")
|
||||||
|
|
||||||
|
token = authorization.removeprefix("Bearer ").strip()
|
||||||
|
|
||||||
|
# Try JWT first
|
||||||
|
try:
|
||||||
|
payload = decode_jwt(token)
|
||||||
|
result = await db.execute(select(Member).where(Member.id == payload["sub"]))
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(401, "Member not found")
|
||||||
|
return member
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try agent token
|
||||||
|
result = await db.execute(select(Member).where(Member.token == token))
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if member:
|
||||||
|
return member
|
||||||
|
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
login: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
member_id: str
|
||||||
|
slug: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login", response_model=LoginResponse)
|
||||||
|
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Member).where(
|
||||||
|
(Member.slug == req.login) | (Member.name == req.login)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if not member or member.password_hash != hash_password(req.password):
|
||||||
|
raise HTTPException(401, "Invalid login or password")
|
||||||
|
|
||||||
|
token = create_jwt(str(member.id), member.slug, member.role)
|
||||||
|
return LoginResponse(
|
||||||
|
token=token,
|
||||||
|
member_id=str(member.id),
|
||||||
|
slug=member.slug,
|
||||||
|
role=member.role,
|
||||||
|
)
|
||||||
@ -1,114 +0,0 @@
|
|||||||
"""Chats and messages API."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from tracker.database import get_db
|
|
||||||
from tracker.models import Chat, ChatMessage, Project
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/chats", tags=["chats"])
|
|
||||||
|
|
||||||
|
|
||||||
class ChatOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
project_id: uuid.UUID | None
|
|
||||||
task_id: uuid.UUID | None
|
|
||||||
kind: str
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class MessageCreate(BaseModel):
|
|
||||||
content: str
|
|
||||||
sender_type: str = "human" # human, agent, system
|
|
||||||
sender_id: uuid.UUID | None = None
|
|
||||||
sender_name: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MessageOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
chat_id: uuid.UUID
|
|
||||||
sender_type: str
|
|
||||||
sender_id: uuid.UUID | None
|
|
||||||
sender_name: str | None
|
|
||||||
content: str
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lobby", response_model=ChatOut)
|
|
||||||
async def get_or_create_lobby(db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Get or create the global lobby chat."""
|
|
||||||
result = await db.execute(select(Chat).where(Chat.kind == "lobby"))
|
|
||||||
chat = result.scalar_one_or_none()
|
|
||||||
if not chat:
|
|
||||||
chat = Chat(kind="lobby")
|
|
||||||
db.add(chat)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(chat)
|
|
||||||
return chat
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/project/{project_id}", response_model=ChatOut)
|
|
||||||
async def get_or_create_project_chat(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Get or create a project chat."""
|
|
||||||
project = await db.get(Project, project_id)
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Chat).where(Chat.project_id == project_id, Chat.kind == "project")
|
|
||||||
)
|
|
||||||
chat = result.scalar_one_or_none()
|
|
||||||
if not chat:
|
|
||||||
chat = Chat(project_id=project_id, kind="project")
|
|
||||||
db.add(chat)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(chat)
|
|
||||||
return chat
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{chat_id}/messages", response_model=list[MessageOut])
|
|
||||||
async def list_messages(
|
|
||||||
chat_id: uuid.UUID,
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
before: uuid.UUID | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Get messages for a chat (newest first, paginated)."""
|
|
||||||
chat = await db.get(Chat, chat_id)
|
|
||||||
if not chat:
|
|
||||||
raise HTTPException(404, "Chat not found")
|
|
||||||
|
|
||||||
q = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
|
|
||||||
if before:
|
|
||||||
ref = await db.get(ChatMessage, before)
|
|
||||||
if ref:
|
|
||||||
q = q.where(ChatMessage.created_at < ref.created_at)
|
|
||||||
q = q.order_by(ChatMessage.created_at.desc()).limit(limit)
|
|
||||||
|
|
||||||
result = await db.execute(q)
|
|
||||||
messages = list(result.scalars().all())
|
|
||||||
messages.reverse() # return oldest-first
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chat_id}/messages", response_model=MessageOut, status_code=201)
|
|
||||||
async def create_message(chat_id: uuid.UUID, data: MessageCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Post a message to a chat (REST fallback — prefer WebSocket)."""
|
|
||||||
chat = await db.get(Chat, chat_id)
|
|
||||||
if not chat:
|
|
||||||
raise HTTPException(404, "Chat not found")
|
|
||||||
|
|
||||||
msg = ChatMessage(chat_id=chat_id, **data.model_dump())
|
|
||||||
db.add(msg)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(msg)
|
|
||||||
return msg
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"""Labels CRUD API."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from tracker.database import get_db
|
|
||||||
from tracker.models import Label
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/labels", tags=["labels"])
|
|
||||||
|
|
||||||
|
|
||||||
class LabelCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
color: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LabelOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
color: str | None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[LabelOut])
|
|
||||||
async def list_labels(db: AsyncSession = Depends(get_db)):
|
|
||||||
result = await db.execute(select(Label).order_by(Label.name))
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=LabelOut, status_code=201)
|
|
||||||
async def create_label(data: LabelCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
label = Label(**data.model_dump())
|
|
||||||
db.add(label)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(label)
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{label_id}", status_code=204)
|
|
||||||
async def delete_label(label_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
label = await db.get(Label, label_id)
|
|
||||||
if not label:
|
|
||||||
raise HTTPException(404, "Label not found")
|
|
||||||
await db.delete(label)
|
|
||||||
await db.commit()
|
|
||||||
204
src/tracker/api/members.py
Normal file
204
src/tracker/api/members.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""Members API — CRUD for members (humans + agents)."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from tracker.database import get_db
|
||||||
|
from tracker.models import Member, AgentConfig
|
||||||
|
|
||||||
|
router = APIRouter(tags=["members"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class AgentConfigSchema(BaseModel):
|
||||||
|
capabilities: list[str] = []
|
||||||
|
chat_listen: str = "mentions"
|
||||||
|
task_listen: str = "mentions"
|
||||||
|
prompt: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: str
|
||||||
|
role: str
|
||||||
|
status: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
agent_config: AgentConfigSchema | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MemberCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: str = "agent" # human | agent
|
||||||
|
role: str = "member"
|
||||||
|
agent_config: AgentConfigSchema | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberCreateResponse(BaseModel):
|
||||||
|
"""Includes token — shown only once at creation."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: str
|
||||||
|
role: str
|
||||||
|
token: str | None = None
|
||||||
|
agent_config: AgentConfigSchema | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemberUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
avatar_url: str | None = None
|
||||||
|
agent_config: AgentConfigSchema | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def _member_to_out(m: Member) -> dict:
|
||||||
|
d = {
|
||||||
|
"id": str(m.id),
|
||||||
|
"name": m.name,
|
||||||
|
"slug": m.slug,
|
||||||
|
"type": m.type,
|
||||||
|
"role": m.role,
|
||||||
|
"status": m.status,
|
||||||
|
"avatar_url": m.avatar_url,
|
||||||
|
}
|
||||||
|
if m.agent_config:
|
||||||
|
d["agent_config"] = AgentConfigSchema(
|
||||||
|
capabilities=m.agent_config.capabilities or [],
|
||||||
|
chat_listen=m.agent_config.chat_listen,
|
||||||
|
task_listen=m.agent_config.task_listen,
|
||||||
|
prompt=m.agent_config.prompt,
|
||||||
|
model=m.agent_config.model,
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("/members", response_model=list[MemberOut])
|
||||||
|
async def list_members(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Member).options(selectinload(Member.agent_config))
|
||||||
|
)
|
||||||
|
return [_member_to_out(m) for m in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/members/{slug}", response_model=MemberOut)
|
||||||
|
async def get_member(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(404, "Member not found")
|
||||||
|
return _member_to_out(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/members", response_model=MemberCreateResponse)
|
||||||
|
async def create_member(req: MemberCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Check slug uniqueness
|
||||||
|
existing = await db.execute(select(Member).where(Member.slug == req.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(409, f"Slug '{req.slug}' already taken")
|
||||||
|
|
||||||
|
token = None
|
||||||
|
if req.type in ("agent", "bridge"):
|
||||||
|
token = f"tb-{secrets.token_hex(16)}"
|
||||||
|
|
||||||
|
member = Member(
|
||||||
|
name=req.name,
|
||||||
|
slug=req.slug,
|
||||||
|
type=req.type,
|
||||||
|
role=req.role,
|
||||||
|
auth_method="token" if req.type in ("agent", "bridge") else "password",
|
||||||
|
token=token,
|
||||||
|
status="offline",
|
||||||
|
)
|
||||||
|
db.add(member)
|
||||||
|
await db.flush() # get member.id
|
||||||
|
|
||||||
|
if req.agent_config and req.type == "agent":
|
||||||
|
config = AgentConfig(
|
||||||
|
member_id=member.id,
|
||||||
|
capabilities=req.agent_config.capabilities,
|
||||||
|
chat_listen=req.agent_config.chat_listen,
|
||||||
|
task_listen=req.agent_config.task_listen,
|
||||||
|
prompt=req.agent_config.prompt,
|
||||||
|
model=req.agent_config.model,
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"id": str(member.id),
|
||||||
|
"name": member.name,
|
||||||
|
"slug": member.slug,
|
||||||
|
"type": member.type,
|
||||||
|
"role": member.role,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
if req.agent_config:
|
||||||
|
resp["agent_config"] = req.agent_config
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/members/{slug}", response_model=MemberOut)
|
||||||
|
async def update_member(slug: str, req: MemberUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Member).where(Member.slug == slug).options(selectinload(Member.agent_config))
|
||||||
|
)
|
||||||
|
member = result.scalar_one_or_none()
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(404, "Member not found")
|
||||||
|
|
||||||
|
if req.name is not None:
|
||||||
|
member.name = req.name
|
||||||
|
if req.role is not None:
|
||||||
|
member.role = req.role
|
||||||
|
if req.status is not None:
|
||||||
|
member.status = req.status
|
||||||
|
if req.avatar_url is not None:
|
||||||
|
member.avatar_url = req.avatar_url
|
||||||
|
|
||||||
|
if req.agent_config and member.type == "agent":
|
||||||
|
if not member.agent_config:
|
||||||
|
member.agent_config = AgentConfig(member_id=member.id)
|
||||||
|
ac = req.agent_config
|
||||||
|
if ac.capabilities is not None:
|
||||||
|
member.agent_config.capabilities = ac.capabilities
|
||||||
|
if ac.chat_listen is not None:
|
||||||
|
member.agent_config.chat_listen = ac.chat_listen
|
||||||
|
if ac.task_listen is not None:
|
||||||
|
member.agent_config.task_listen = ac.task_listen
|
||||||
|
if ac.prompt is not None:
|
||||||
|
member.agent_config.prompt = ac.prompt
|
||||||
|
if ac.model is not None:
|
||||||
|
member.agent_config.model = ac.model
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(member)
|
||||||
|
return _member_to_out(member)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/members/me/status")
|
||||||
|
async def update_my_status(status: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Quick status update (used by agents)."""
|
||||||
|
# TODO: get current member from auth
|
||||||
|
return {"status": status}
|
||||||
135
src/tracker/api/messages.py
Normal file
135
src/tracker/api/messages.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""Messages API — unified messages for chats and task comments."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from tracker.database import get_db
|
||||||
|
from tracker.models import Message, Chat, Attachment
|
||||||
|
|
||||||
|
router = APIRouter(tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class AttachmentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
filename: str
|
||||||
|
mime_type: str | None = None
|
||||||
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
chat_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
author_type: str
|
||||||
|
author_slug: str
|
||||||
|
content: str
|
||||||
|
mentions: list[str] = []
|
||||||
|
voice_url: str | None = None
|
||||||
|
attachments: list[AttachmentOut] = []
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
chat_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
author_type: str = "human"
|
||||||
|
author_slug: str = "admin"
|
||||||
|
content: str
|
||||||
|
mentions: list[str] = []
|
||||||
|
voice_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
def _message_out(m: Message) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"chat_id": str(m.chat_id) if m.chat_id else None,
|
||||||
|
"task_id": str(m.task_id) if m.task_id else None,
|
||||||
|
"parent_id": str(m.parent_id) if m.parent_id else None,
|
||||||
|
"author_type": m.author_type,
|
||||||
|
"author_slug": m.author_slug,
|
||||||
|
"content": m.content,
|
||||||
|
"mentions": m.mentions or [],
|
||||||
|
"voice_url": m.voice_url,
|
||||||
|
"attachments": [
|
||||||
|
{"id": str(a.id), "filename": a.filename, "mime_type": a.mime_type, "size": a.size}
|
||||||
|
for a in (m.attachments or [])
|
||||||
|
],
|
||||||
|
"created_at": m.created_at.isoformat() if m.created_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("/messages", response_model=list[MessageOut])
|
||||||
|
async def list_messages(
|
||||||
|
chat_id: Optional[str] = Query(None),
|
||||||
|
task_id: Optional[str] = Query(None),
|
||||||
|
parent_id: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
offset: int = Query(0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = select(Message).options(selectinload(Message.attachments))
|
||||||
|
|
||||||
|
if chat_id:
|
||||||
|
q = q.where(Message.chat_id == uuid.UUID(chat_id))
|
||||||
|
if task_id:
|
||||||
|
q = q.where(Message.task_id == uuid.UUID(task_id))
|
||||||
|
if parent_id:
|
||||||
|
q = q.where(Message.parent_id == uuid.UUID(parent_id))
|
||||||
|
|
||||||
|
# For top-level messages only (no threads), if no parent_id filter
|
||||||
|
if not parent_id and (chat_id or task_id):
|
||||||
|
q = q.where(Message.parent_id.is_(None))
|
||||||
|
|
||||||
|
q = q.order_by(Message.created_at).offset(offset).limit(limit)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return [_message_out(m) for m in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages", response_model=MessageOut)
|
||||||
|
async def create_message(req: MessageCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
if not req.chat_id and not req.task_id:
|
||||||
|
raise HTTPException(400, "Either chat_id or task_id must be provided")
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
chat_id=uuid.UUID(req.chat_id) if req.chat_id else None,
|
||||||
|
task_id=uuid.UUID(req.task_id) if req.task_id else None,
|
||||||
|
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
|
||||||
|
author_type=req.author_type,
|
||||||
|
author_slug=req.author_slug,
|
||||||
|
content=req.content,
|
||||||
|
mentions=req.mentions,
|
||||||
|
voice_url=req.voice_url,
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
await db.commit()
|
||||||
|
result2 = await db.execute(
|
||||||
|
select(Message).where(Message.id == msg.id).options(selectinload(Message.attachments))
|
||||||
|
)
|
||||||
|
msg = result2.scalar_one()
|
||||||
|
return _message_out(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages/{message_id}/replies", response_model=list[MessageOut])
|
||||||
|
async def list_replies(message_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get thread replies for a message."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.parent_id == uuid.UUID(message_id))
|
||||||
|
.options(selectinload(Message.attachments))
|
||||||
|
.order_by(Message.created_at)
|
||||||
|
)
|
||||||
|
return [_message_out(m) for m in result.scalars()]
|
||||||
@ -1,6 +1,4 @@
|
|||||||
"""Projects CRUD API."""
|
"""Projects API."""
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@ -8,81 +6,114 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from tracker.database import get_db
|
from tracker.database import get_db
|
||||||
from tracker.models import Project
|
from tracker.models import Project, Chat
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
slug: str
|
slug: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
key_prefix: str | None = None # auto-generated from name if not provided
|
repo_urls: list[str] = []
|
||||||
git_repo: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
git_repo: str | None = None
|
repo_urls: list[str] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectOut(BaseModel):
|
class ProjectOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: str
|
||||||
name: str
|
name: str
|
||||||
slug: str
|
slug: str
|
||||||
description: str | None
|
description: str | None = None
|
||||||
key_prefix: str
|
repo_urls: list[str] = []
|
||||||
|
status: str
|
||||||
task_counter: int
|
task_counter: int
|
||||||
git_repo: str | None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[ProjectOut])
|
def _project_out(p: Project) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"slug": p.slug,
|
||||||
|
"description": p.description,
|
||||||
|
"repo_urls": p.repo_urls or [],
|
||||||
|
"status": p.status,
|
||||||
|
"task_counter": p.task_counter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", response_model=list[ProjectOut])
|
||||||
async def list_projects(db: AsyncSession = Depends(get_db)):
|
async def list_projects(db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(Project).order_by(Project.created_at))
|
result = await db.execute(select(Project).where(Project.status == "active"))
|
||||||
return result.scalars().all()
|
return [_project_out(p) for p in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=ProjectOut, status_code=201)
|
@router.get("/projects/{slug}", response_model=ProjectOut)
|
||||||
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def get_project(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
dump = data.model_dump()
|
result = await db.execute(select(Project).where(Project.slug == slug))
|
||||||
# Auto-generate key_prefix from name (first 3 chars, uppercase)
|
project = result.scalar_one_or_none()
|
||||||
if not dump.get("key_prefix"):
|
if not project:
|
||||||
prefix = "".join(c for c in data.name.upper() if c.isalpha())[:3]
|
raise HTTPException(404, "Project not found")
|
||||||
dump["key_prefix"] = prefix or "PRJ"
|
return _project_out(project)
|
||||||
project = Project(**dump)
|
|
||||||
|
|
||||||
|
@router.post("/projects", response_model=ProjectOut)
|
||||||
|
async def create_project(req: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
existing = await db.execute(select(Project).where(Project.slug == req.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(409, f"Slug '{req.slug}' already taken")
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
name=req.name,
|
||||||
|
slug=req.slug,
|
||||||
|
description=req.description,
|
||||||
|
repo_urls=req.repo_urls,
|
||||||
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create project chat
|
||||||
|
chat = Chat(project_id=project.id, kind="project")
|
||||||
|
db.add(chat)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
return _project_out(project)
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectOut)
|
@router.patch("/projects/{slug}", response_model=ProjectOut)
|
||||||
async def get_project(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def update_project(slug: str, req: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
project = await db.get(Project, project_id)
|
result = await db.execute(select(Project).where(Project.slug == slug))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "Project not found")
|
||||||
return project
|
|
||||||
|
|
||||||
|
if req.name is not None:
|
||||||
|
project.name = req.name
|
||||||
|
if req.description is not None:
|
||||||
|
project.description = req.description
|
||||||
|
if req.repo_urls is not None:
|
||||||
|
project.repo_urls = req.repo_urls
|
||||||
|
if req.status is not None:
|
||||||
|
project.status = req.status
|
||||||
|
|
||||||
@router.patch("/{project_id}", response_model=ProjectOut)
|
|
||||||
async def update_project(project_id: uuid.UUID, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
|
||||||
project = await db.get(Project, project_id)
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
for k, v in data.model_dump(exclude_unset=True).items():
|
|
||||||
setattr(project, k, v)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
return _project_out(project)
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}", status_code=204)
|
@router.delete("/projects/{slug}")
|
||||||
async def delete_project(project_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_project(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
project = await db.get(Project, project_id)
|
result = await db.execute(select(Project).where(Project.slug == slug))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "Project not found")
|
||||||
await db.delete(project)
|
await db.delete(project)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
105
src/tracker/api/steps.py
Normal file
105
src/tracker/api/steps.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"""Steps API — CRUD for task steps (checklist)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from tracker.database import get_db
|
||||||
|
from tracker.models import Step, Task
|
||||||
|
|
||||||
|
router = APIRouter(tags=["steps"])
|
||||||
|
|
||||||
|
|
||||||
|
class StepCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class StepUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
done: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StepOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
task_id: str
|
||||||
|
title: str
|
||||||
|
done: bool
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
def _step_out(s: Step) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(s.id),
|
||||||
|
"task_id": str(s.task_id),
|
||||||
|
"title": s.title,
|
||||||
|
"done": s.done,
|
||||||
|
"position": s.position,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/steps", response_model=list[StepOut])
|
||||||
|
async def list_steps(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Step).where(Step.task_id == uuid.UUID(task_id)).order_by(Step.position)
|
||||||
|
)
|
||||||
|
return [_step_out(s) for s in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/steps", response_model=StepOut)
|
||||||
|
async def create_step(task_id: str, req: StepCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Verify task exists
|
||||||
|
task = await db.get(Task, uuid.UUID(task_id))
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
||||||
|
# Get max position
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.coalesce(func.max(Step.position), -1)).where(Step.task_id == task.id)
|
||||||
|
)
|
||||||
|
max_pos = result.scalar()
|
||||||
|
|
||||||
|
step = Step(
|
||||||
|
task_id=task.id,
|
||||||
|
title=req.title,
|
||||||
|
done=False,
|
||||||
|
position=max_pos + 1,
|
||||||
|
)
|
||||||
|
db.add(step)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(step)
|
||||||
|
return _step_out(step)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}/steps/{step_id}", response_model=StepOut)
|
||||||
|
async def update_step(task_id: str, step_id: str, req: StepUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Step).where(Step.id == uuid.UUID(step_id), Step.task_id == uuid.UUID(task_id))
|
||||||
|
)
|
||||||
|
step = result.scalar_one_or_none()
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(404, "Step not found")
|
||||||
|
|
||||||
|
if req.title is not None:
|
||||||
|
step.title = req.title
|
||||||
|
if req.done is not None:
|
||||||
|
step.done = req.done
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(step)
|
||||||
|
return _step_out(step)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}/steps/{step_id}")
|
||||||
|
async def delete_step(task_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Step).where(Step.id == uuid.UUID(step_id), Step.task_id == uuid.UUID(task_id))
|
||||||
|
)
|
||||||
|
step = result.scalar_one_or_none()
|
||||||
|
if not step:
|
||||||
|
raise HTTPException(404, "Step not found")
|
||||||
|
await db.delete(step)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@ -1,138 +1,275 @@
|
|||||||
"""Tasks CRUD API."""
|
"""Tasks API — CRUD + take/reject/assign/watch."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from tracker.database import get_db
|
from tracker.database import get_db
|
||||||
from tracker.models import Task, Project
|
from tracker.models import Task, Step, Project
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class StepOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
done: bool
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
project_id: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
type: str
|
||||||
|
status: str
|
||||||
|
priority: str
|
||||||
|
labels: list[str] = []
|
||||||
|
assignee_slug: str | None = None
|
||||||
|
reviewer_slug: str | None = None
|
||||||
|
watchers: list[str] = []
|
||||||
|
depends_on: list[str] = []
|
||||||
|
position: int
|
||||||
|
time_spent: int
|
||||||
|
steps: list[StepOut] = []
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
project_id: uuid.UUID
|
|
||||||
parent_id: uuid.UUID | None = None
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: str = "draft"
|
type: str = "task"
|
||||||
|
status: str = "backlog"
|
||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
requires_pr: bool = False
|
labels: list[str] = []
|
||||||
assigned_agent_id: uuid.UUID | None = None
|
parent_id: str | None = None
|
||||||
|
assignee_slug: str | None = None
|
||||||
|
reviewer_slug: str | None = None
|
||||||
|
depends_on: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
type: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
requires_pr: bool | None = None
|
labels: list[str] | None = None
|
||||||
assigned_agent_id: uuid.UUID | None = None
|
assignee_slug: str | None = None
|
||||||
|
reviewer_slug: str | None = None
|
||||||
position: int | None = None
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskOut(BaseModel):
|
class RejectRequest(BaseModel):
|
||||||
id: uuid.UUID
|
reason: str
|
||||||
project_id: uuid.UUID
|
|
||||||
parent_id: uuid.UUID | None
|
|
||||||
title: str
|
|
||||||
description: str | None
|
|
||||||
status: str
|
|
||||||
priority: str
|
|
||||||
requires_pr: bool
|
|
||||||
pr_url: str | None
|
|
||||||
assigned_agent_id: uuid.UUID | None
|
|
||||||
number: int
|
|
||||||
key: str | None = None # e.g. "TEA-1"
|
|
||||||
position: int
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_tasks(tasks: list[Task], db: AsyncSession) -> list[TaskOut]:
|
class AssignRequest(BaseModel):
|
||||||
"""Add key (e.g. TEA-1) to task outputs."""
|
assignee_slug: str
|
||||||
if not tasks:
|
|
||||||
return []
|
|
||||||
# Collect project ids
|
|
||||||
project_ids = {t.project_id for t in tasks}
|
|
||||||
projects = {}
|
|
||||||
for pid in project_ids:
|
|
||||||
p = await db.get(Project, pid)
|
|
||||||
if p:
|
|
||||||
projects[p.id] = p.key_prefix
|
|
||||||
result = []
|
|
||||||
for t in tasks:
|
|
||||||
out = TaskOut.model_validate(t)
|
|
||||||
prefix = projects.get(t.project_id, "???")
|
|
||||||
out.key = f"{prefix}-{t.number}" if t.number else None
|
|
||||||
result.append(out)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[TaskOut])
|
# --- Helpers ---
|
||||||
async def list_tasks(
|
|
||||||
project_id: uuid.UUID | None = None,
|
def _task_out(t: Task) -> dict:
|
||||||
status: str | None = None,
|
return {
|
||||||
db: AsyncSession = Depends(get_db),
|
"id": str(t.id),
|
||||||
):
|
"project_id": str(t.project_id),
|
||||||
q = select(Task).order_by(Task.position, Task.created_at)
|
"parent_id": str(t.parent_id) if t.parent_id else None,
|
||||||
if project_id:
|
"number": t.number,
|
||||||
q = q.where(Task.project_id == project_id)
|
"title": t.title,
|
||||||
if status:
|
"description": t.description,
|
||||||
q = q.where(Task.status == status)
|
"type": t.type,
|
||||||
result = await db.execute(q)
|
"status": t.status,
|
||||||
tasks = list(result.scalars().all())
|
"priority": t.priority,
|
||||||
return await _enrich_tasks(tasks, db)
|
"labels": t.labels or [],
|
||||||
|
"assignee_slug": t.assignee_slug,
|
||||||
|
"reviewer_slug": t.reviewer_slug,
|
||||||
|
"watchers": t.watchers or [],
|
||||||
|
"depends_on": [str(d) for d in (t.depends_on or [])],
|
||||||
|
"position": t.position,
|
||||||
|
"time_spent": t.time_spent,
|
||||||
|
"steps": [
|
||||||
|
{"id": str(s.id), "title": s.title, "done": s.done, "position": s.position}
|
||||||
|
for s in (t.steps or [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=TaskOut, status_code=201)
|
async def _get_task(task_id: str, db: AsyncSession) -> Task:
|
||||||
async def create_task(data: TaskCreate, db: AsyncSession = Depends(get_db)):
|
result = await db.execute(
|
||||||
# Get project and increment counter
|
select(Task).where(Task.id == uuid.UUID(task_id)).options(selectinload(Task.steps))
|
||||||
project = await db.get(Project, data.project_id)
|
)
|
||||||
if not project:
|
task = result.scalar_one_or_none()
|
||||||
raise HTTPException(404, "Project not found")
|
|
||||||
project.task_counter += 1
|
|
||||||
number = project.task_counter
|
|
||||||
|
|
||||||
task = Task(**data.model_dump(), number=number)
|
|
||||||
db.add(task)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(task)
|
|
||||||
# Attach key
|
|
||||||
task_out = TaskOut.model_validate(task)
|
|
||||||
task_out.key = f"{project.key_prefix}-{number}"
|
|
||||||
return task_out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=TaskOut)
|
|
||||||
async def get_task(task_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
task = await db.get(Task, task_id)
|
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
enriched = await _enrich_tasks([task], db)
|
|
||||||
return enriched[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{task_id}", response_model=TaskOut)
|
|
||||||
async def update_task(task_id: uuid.UUID, data: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
|
||||||
task = await db.get(Task, task_id)
|
|
||||||
if not task:
|
|
||||||
raise HTTPException(404, "Task not found")
|
|
||||||
for k, v in data.model_dump(exclude_unset=True).items():
|
|
||||||
setattr(task, k, v)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(task)
|
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_id}", status_code=204)
|
# --- Endpoints ---
|
||||||
async def delete_task(task_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
|
||||||
task = await db.get(Task, task_id)
|
@router.get("/tasks", response_model=list[TaskOut])
|
||||||
if not task:
|
async def list_tasks(
|
||||||
raise HTTPException(404, "Task not found")
|
project_id: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
assignee: Optional[str] = Query(None),
|
||||||
|
label: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = select(Task).options(selectinload(Task.steps))
|
||||||
|
if project_id:
|
||||||
|
q = q.where(Task.project_id == uuid.UUID(project_id))
|
||||||
|
if status:
|
||||||
|
q = q.where(Task.status == status)
|
||||||
|
if assignee:
|
||||||
|
q = q.where(Task.assignee_slug == assignee)
|
||||||
|
if label:
|
||||||
|
q = q.where(Task.labels.contains([label]))
|
||||||
|
q = q.order_by(Task.position, Task.created_at)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return [_task_out(t) for t in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=TaskOut)
|
||||||
|
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
return _task_out(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks", response_model=TaskOut)
|
||||||
|
async def create_task(
|
||||||
|
project_slug: str = Query(...),
|
||||||
|
req: TaskCreate = ...,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Find project
|
||||||
|
result = await db.execute(select(Project).where(Project.slug == project_slug))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
|
||||||
|
# Increment task counter
|
||||||
|
project.task_counter += 1
|
||||||
|
number = project.task_counter
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
project_id=project.id,
|
||||||
|
parent_id=uuid.UUID(req.parent_id) if req.parent_id else None,
|
||||||
|
number=number,
|
||||||
|
title=req.title,
|
||||||
|
description=req.description,
|
||||||
|
type=req.type,
|
||||||
|
status=req.status,
|
||||||
|
priority=req.priority,
|
||||||
|
labels=req.labels,
|
||||||
|
assignee_slug=req.assignee_slug,
|
||||||
|
reviewer_slug=req.reviewer_slug,
|
||||||
|
depends_on=[uuid.UUID(d) for d in req.depends_on] if req.depends_on else [],
|
||||||
|
watchers=[req.assignee_slug] if req.assignee_slug else [],
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
# Load steps
|
||||||
|
task_full = await _get_task(str(task.id), db)
|
||||||
|
return _task_out(task_full)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}", response_model=TaskOut)
|
||||||
|
async def update_task(task_id: str, req: TaskUpdate, db: AsyncSession = Depends(get_db)):
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
|
||||||
|
if req.title is not None:
|
||||||
|
task.title = req.title
|
||||||
|
if req.description is not None:
|
||||||
|
task.description = req.description
|
||||||
|
if req.type is not None:
|
||||||
|
task.type = req.type
|
||||||
|
if req.status is not None:
|
||||||
|
task.status = req.status
|
||||||
|
if req.priority is not None:
|
||||||
|
task.priority = req.priority
|
||||||
|
if req.labels is not None:
|
||||||
|
task.labels = req.labels
|
||||||
|
if req.assignee_slug is not None:
|
||||||
|
task.assignee_slug = req.assignee_slug or None
|
||||||
|
if req.reviewer_slug is not None:
|
||||||
|
task.reviewer_slug = req.reviewer_slug or None
|
||||||
|
if req.position is not None:
|
||||||
|
task.position = req.position
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return _task_out(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}")
|
||||||
|
async def delete_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
await db.delete(task)
|
await db.delete(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/take", response_model=TaskOut)
|
||||||
|
async def take_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Atomically take a task — only if not already assigned."""
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
if task.assignee_slug:
|
||||||
|
raise HTTPException(409, f"Task already assigned to {task.assignee_slug}")
|
||||||
|
task.assignee_slug = slug
|
||||||
|
task.status = "in_progress"
|
||||||
|
# Add to watchers
|
||||||
|
if slug not in (task.watchers or []):
|
||||||
|
task.watchers = (task.watchers or []) + [slug]
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return _task_out(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/reject")
|
||||||
|
async def reject_task(task_id: str, req: RejectRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Reject a task with reason — unassign and return to todo."""
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
old_assignee = task.assignee_slug
|
||||||
|
task.assignee_slug = None
|
||||||
|
task.status = "todo"
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "reason": req.reason, "old_assignee": old_assignee}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/assign", response_model=TaskOut)
|
||||||
|
async def assign_task(task_id: str, req: AssignRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Assign task to a member."""
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
task.assignee_slug = req.assignee_slug
|
||||||
|
if req.assignee_slug not in (task.watchers or []):
|
||||||
|
task.watchers = (task.watchers or []) + [req.assignee_slug]
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
return _task_out(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/watch")
|
||||||
|
async def watch_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
if slug not in (task.watchers or []):
|
||||||
|
task.watchers = (task.watchers or []) + [slug]
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "watchers": task.watchers}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}/watch")
|
||||||
|
async def unwatch_task(task_id: str, slug: str = Query(...), db: AsyncSession = Depends(get_db)):
|
||||||
|
task = await _get_task(task_id, db)
|
||||||
|
task.watchers = [w for w in (task.watchers or []) if w != slug]
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "watchers": task.watchers}
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from tracker.api import agents, chats, labels, projects, tasks
|
|
||||||
from tracker.config import settings
|
from tracker.config import settings
|
||||||
from tracker.ws.handler import router as ws_router
|
from tracker.database import engine
|
||||||
|
from tracker.models import Base
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if settings.env == "dev" else logging.INFO,
|
level=logging.DEBUG if settings.env == "dev" else logging.INFO,
|
||||||
@ -18,17 +20,28 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("tracker")
|
logger = logging.getLogger("tracker")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Create tables on startup (dev mode only)."""
|
||||||
|
if settings.env == "dev":
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables ensured.")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Team Board Tracker",
|
title="Team Board Tracker",
|
||||||
version="0.1.0",
|
version="0.2.0",
|
||||||
docs_url="/docs" if settings.env == "dev" else None,
|
docs_url="/docs" if settings.env == "dev" else None,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def log_requests(request: Request, call_next):
|
async def log_requests(request: Request, call_next):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
body = None
|
|
||||||
if request.method in ("POST", "PUT", "PATCH"):
|
if request.method in ("POST", "PUT", "PATCH"):
|
||||||
try:
|
try:
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
@ -48,6 +61,7 @@ async def log_requests(request: Request, call_next):
|
|||||||
logger.error("[ERR] %s %s → %s (%.0fms)\n%s", request.method, request.url.path, str(e), elapsed, traceback.format_exc())
|
logger.error("[ERR] %s %s → %s (%.0fms)\n%s", request.method, request.url.path, str(e), elapsed, traceback.format_exc())
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -56,17 +70,19 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# REST API
|
# Import and register routers (lazy to avoid circular imports)
|
||||||
|
from tracker.api import auth, members, projects, tasks, messages, steps # noqa: E402
|
||||||
|
from tracker.ws.handler import router as ws_router # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
app.include_router(members.router, prefix="/api/v1")
|
||||||
app.include_router(projects.router, prefix="/api/v1")
|
app.include_router(projects.router, prefix="/api/v1")
|
||||||
app.include_router(tasks.router, prefix="/api/v1")
|
app.include_router(tasks.router, prefix="/api/v1")
|
||||||
app.include_router(agents.router, prefix="/api/v1")
|
app.include_router(messages.router, prefix="/api/v1")
|
||||||
app.include_router(labels.router, prefix="/api/v1")
|
app.include_router(steps.router, prefix="/api/v1")
|
||||||
app.include_router(chats.router, prefix="/api/v1")
|
|
||||||
|
|
||||||
# WebSocket
|
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "version": "0.1.0"}
|
return {"status": "ok", "version": "0.2.0"}
|
||||||
|
|||||||
52
src/tracker/init_db.py
Normal file
52
src/tracker/init_db.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""Initialize database — drop all and create fresh tables + seed data."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from tracker.database import engine, async_session
|
||||||
|
from tracker.models import Base, Member, Chat
|
||||||
|
|
||||||
|
logger = logging.getLogger("tracker.init_db")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Drop all tables and recreate from models. Seed with admin + lobby."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
logger.info("Dropping all tables...")
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
logger.info("Creating all tables...")
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
# Create admin (owner)
|
||||||
|
admin = Member(
|
||||||
|
name="Admin",
|
||||||
|
slug="admin",
|
||||||
|
type="human",
|
||||||
|
role="owner",
|
||||||
|
auth_method="password",
|
||||||
|
password_hash=hash_password("teamboard"),
|
||||||
|
status="offline",
|
||||||
|
)
|
||||||
|
session.add(admin)
|
||||||
|
|
||||||
|
# Create lobby chat
|
||||||
|
lobby = Chat(
|
||||||
|
kind="lobby",
|
||||||
|
project_id=None,
|
||||||
|
)
|
||||||
|
session.add(lobby)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Seed data created: admin user + lobby chat (id=%s)", lobby.id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
asyncio.run(init_db())
|
||||||
@ -1,19 +1,19 @@
|
|||||||
|
"""Models package — import all models for metadata discovery."""
|
||||||
|
|
||||||
from tracker.models.base import Base
|
from tracker.models.base import Base
|
||||||
|
from tracker.models.member import AgentConfig, Member
|
||||||
from tracker.models.project import Project
|
from tracker.models.project import Project
|
||||||
from tracker.models.task import Task, TaskDependency, TaskFile, TaskLabel
|
from tracker.models.task import Step, Task
|
||||||
from tracker.models.agent import Agent
|
from tracker.models.chat import Attachment, Chat, Message
|
||||||
from tracker.models.label import Label
|
|
||||||
from tracker.models.chat import Chat, ChatMessage
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
"Member",
|
||||||
|
"AgentConfig",
|
||||||
"Project",
|
"Project",
|
||||||
"Task",
|
"Task",
|
||||||
"TaskDependency",
|
"Step",
|
||||||
"TaskFile",
|
|
||||||
"TaskLabel",
|
|
||||||
"Agent",
|
|
||||||
"Label",
|
|
||||||
"Chat",
|
"Chat",
|
||||||
"ChatMessage",
|
"Message",
|
||||||
|
"Attachment",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
"""Agent model."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Integer, String, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from tracker.models.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Agent(Base):
|
|
||||||
__tablename__ = "agents"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
||||||
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) # for WS auth
|
|
||||||
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
|
|
||||||
subscription_mode: Mapped[str] = mapped_column(String(20), default="assigned") # all, mentions, assigned
|
|
||||||
max_concurrent: Mapped[int] = mapped_column(Integer, default=1)
|
|
||||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=600)
|
|
||||||
status: Mapped[str] = mapped_column(String(20), default="offline") # online, offline, busy
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""SQLAlchemy base model with common fields."""
|
"""SQLAlchemy base model with common fields."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, func
|
from sqlalchemy import DateTime, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"""Chat and message models."""
|
"""Chat and Message models — unified message for chats and task comments."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String, Text
|
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from tracker.models.base import Base
|
from tracker.models.base import Base
|
||||||
@ -14,23 +14,54 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class Chat(Base):
|
class Chat(Base):
|
||||||
|
"""Chat room — lobby, project, or custom."""
|
||||||
__tablename__ = "chats"
|
__tablename__ = "chats"
|
||||||
|
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"))
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"))
|
||||||
task_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"))
|
kind: Mapped[str] = mapped_column(String(20), default="project") # lobby | project
|
||||||
kind: Mapped[str] = mapped_column(String(20), default="project") # lobby, project, task
|
|
||||||
|
|
||||||
project: Mapped["Project | None"] = relationship(back_populates="chats")
|
project: Mapped["Project | None"] = relationship(back_populates="chats")
|
||||||
messages: Mapped[list["ChatMessage"]] = relationship(back_populates="chat", cascade="all, delete-orphan")
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="chat", cascade="all, delete-orphan",
|
||||||
|
foreign_keys="Message.chat_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(Base):
|
class Message(Base):
|
||||||
__tablename__ = "chat_messages"
|
"""Unified message — works for chat messages AND task comments."""
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("chats.id"), nullable=False)
|
# Context: one of chat_id or task_id must be set
|
||||||
sender_type: Mapped[str] = mapped_column(String(20), nullable=False) # human, agent, system
|
chat_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chats.id"))
|
||||||
sender_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # agent_id or null
|
task_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"))
|
||||||
sender_name: Mapped[str | None] = mapped_column(String(255))
|
|
||||||
|
# Thread support
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"))
|
||||||
|
|
||||||
|
# Author
|
||||||
|
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system
|
||||||
|
author_slug: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Content
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
mentions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||||
|
voice_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
||||||
chat: Mapped["Chat"] = relationship(back_populates="messages")
|
# Relationships
|
||||||
|
chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id])
|
||||||
|
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
||||||
|
replies: Mapped[list["Message"]] = relationship(back_populates="parent")
|
||||||
|
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id")
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(Base):
|
||||||
|
"""File attachment on a message."""
|
||||||
|
__tablename__ = "attachments"
|
||||||
|
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"), nullable=False)
|
||||||
|
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
mime_type: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
size: Mapped[int] = mapped_column(Integer, default=0) # bytes
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
|
|
||||||
|
message: Mapped["Message"] = relationship(back_populates="attachments")
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
"""Label model."""
|
|
||||||
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from tracker.models.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Label(Base):
|
|
||||||
__tablename__ = "labels"
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
color: Mapped[str | None] = mapped_column(String(7)) # #rrggbb
|
|
||||||
43
src/tracker/models/member.py
Normal file
43
src/tracker/models/member.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Member model — unified human + agent."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from tracker.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Member(Base):
|
||||||
|
__tablename__ = "members"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False, default="human") # human | agent
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") # owner | member | observer | bridge
|
||||||
|
auth_method: Mapped[str] = mapped_column(String(20), nullable=False, default="password") # password | oauth | token
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
token: Mapped[str | None] = mapped_column(String(255), unique=True) # for agents/bridges
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="offline") # online | offline | busy
|
||||||
|
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
||||||
|
# Agent-specific config (nullable for humans)
|
||||||
|
agent_config: Mapped["AgentConfig | None"] = relationship(
|
||||||
|
back_populates="member", uselist=False, cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConfig(Base):
|
||||||
|
__tablename__ = "agent_configs"
|
||||||
|
|
||||||
|
member_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("members.id"), nullable=False, unique=True
|
||||||
|
)
|
||||||
|
capabilities: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||||
|
chat_listen: Mapped[str] = mapped_column(String(20), default="mentions") # all | mentions
|
||||||
|
task_listen: Mapped[str] = mapped_column(String(20), default="mentions") # all | mentions
|
||||||
|
prompt: Mapped[str | None] = mapped_column(Text)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|
||||||
|
member: Mapped["Member"] = relationship(back_populates="agent_config")
|
||||||
@ -1,9 +1,9 @@
|
|||||||
"""Project model."""
|
"""Project model."""
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Integer, String, Text
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from tracker.models.base import Base
|
from tracker.models.base import Base
|
||||||
@ -19,9 +19,9 @@ class Project(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, default="PRJ") # e.g. "TEA", "DIC"
|
repo_urls: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||||
task_counter: Mapped[int] = mapped_column(Integer, default=0) # auto-increment for task numbers
|
status: Mapped[str] = mapped_column(String(20), default="active") # active | archived
|
||||||
git_repo: Mapped[str | None] = mapped_column(String(500))
|
task_counter: Mapped[int] = mapped_column(Integer, default=0) # for sequential task numbers
|
||||||
|
|
||||||
tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
tasks: Mapped[list["Task"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||||
chats: Mapped[list["Chat"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
chats: Mapped[list["Chat"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
"""Task model with dependencies, labels, and files."""
|
"""Task model with steps, watchers, dependencies."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from tracker.models.base import Base
|
from tracker.models.base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from tracker.models.project import Project
|
from tracker.models.project import Project
|
||||||
from tracker.models.agent import Agent
|
|
||||||
|
|
||||||
|
|
||||||
class Task(Base):
|
class Task(Base):
|
||||||
@ -19,60 +18,32 @@ class Task(Base):
|
|||||||
|
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"))
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"))
|
||||||
|
number: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # project-scoped: TB-1, TB-2
|
||||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
status: Mapped[str] = mapped_column(String(20), default="draft") # draft, ready, in_progress, review, completed, blocked
|
type: Mapped[str] = mapped_column(String(20), default="task") # task | bug | epic | story
|
||||||
priority: Mapped[str] = mapped_column(String(20), default="medium") # low, medium, high, critical
|
status: Mapped[str] = mapped_column(String(20), default="backlog") # backlog | todo | in_progress | in_review | done
|
||||||
requires_pr: Mapped[bool] = mapped_column(Boolean, default=False)
|
priority: Mapped[str] = mapped_column(String(20), default="medium") # critical | high | medium | low
|
||||||
pr_url: Mapped[str | None] = mapped_column(String(500))
|
labels: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||||
assigned_agent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"))
|
assignee_slug: Mapped[str | None] = mapped_column(String(255))
|
||||||
number: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # project-scoped sequential number (TEA-1, TEA-2...)
|
reviewer_slug: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
watchers: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # slugs
|
||||||
|
depends_on: Mapped[list[uuid.UUID]] = mapped_column(ARRAY(UUID(as_uuid=True)), default=list)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
|
position: Mapped[int] = mapped_column(Integer, default=0) # ordering within column
|
||||||
|
time_spent: Mapped[int] = mapped_column(Integer, default=0) # minutes
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship(back_populates="tasks")
|
project: Mapped["Project"] = relationship(back_populates="tasks")
|
||||||
parent: Mapped["Task | None"] = relationship(remote_side="Task.id", back_populates="subtasks")
|
parent: Mapped["Task | None"] = relationship(remote_side="Task.id", back_populates="subtasks")
|
||||||
subtasks: Mapped[list["Task"]] = relationship(back_populates="parent")
|
subtasks: Mapped[list["Task"]] = relationship(back_populates="parent")
|
||||||
assigned_agent: Mapped["Agent | None"] = relationship()
|
steps: Mapped[list["Step"]] = relationship(back_populates="task", cascade="all, delete-orphan", order_by="Step.position")
|
||||||
labels: Mapped[list["TaskLabel"]] = relationship(back_populates="task", cascade="all, delete-orphan")
|
|
||||||
files: Mapped[list["TaskFile"]] = relationship(back_populates="task", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
# Dependencies (this task depends on...)
|
|
||||||
dependencies: Mapped[list["TaskDependency"]] = relationship(
|
|
||||||
foreign_keys="TaskDependency.task_id", back_populates="task", cascade="all, delete-orphan"
|
|
||||||
)
|
|
||||||
# Reverse: tasks that depend on this one
|
|
||||||
dependents: Mapped[list["TaskDependency"]] = relationship(
|
|
||||||
foreign_keys="TaskDependency.depends_on_id", back_populates="depends_on", cascade="all, delete-orphan"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskDependency(Base):
|
class Step(Base):
|
||||||
__tablename__ = "task_dependencies"
|
__tablename__ = "steps"
|
||||||
|
|
||||||
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
|
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
|
||||||
depends_on_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
done: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
task: Mapped["Task"] = relationship(foreign_keys=[task_id], back_populates="dependencies")
|
task: Mapped["Task"] = relationship(back_populates="steps")
|
||||||
depends_on: Mapped["Task"] = relationship(foreign_keys=[depends_on_id], back_populates="dependents")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskLabel(Base):
|
|
||||||
__tablename__ = "task_labels"
|
|
||||||
|
|
||||||
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
|
|
||||||
label_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("labels.id"), nullable=False)
|
|
||||||
|
|
||||||
task: Mapped["Task"] = relationship(back_populates="labels")
|
|
||||||
label = relationship("Label")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskFile(Base):
|
|
||||||
__tablename__ = "task_files"
|
|
||||||
|
|
||||||
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
|
|
||||||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
||||||
mime_type: Mapped[str | None] = mapped_column(String(100))
|
|
||||||
file_path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
|
||||||
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
|
||||||
|
|
||||||
task: Mapped["Task"] = relationship(back_populates="files")
|
|
||||||
|
|||||||
@ -1,109 +1,251 @@
|
|||||||
"""WebSocket endpoint handler."""
|
"""WebSocket handler — auth, heartbeat, chat.send, project subscriptions."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from tracker.database import async_session
|
from tracker.database import async_session
|
||||||
from tracker.models import Chat, ChatMessage
|
from tracker.models import Member, AgentConfig, Chat, Message, Project
|
||||||
from tracker.ws.manager import manager
|
from tracker.ws.manager import ConnectedClient, manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("tracker.ws")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(ws: WebSocket, client_type: str = "human", client_id: str | None = None):
|
async def websocket_endpoint(ws: WebSocket):
|
||||||
ws_id = await manager.connect(ws, client_type=client_type, client_id=client_id)
|
await ws.accept()
|
||||||
|
slug = None
|
||||||
|
|
||||||
try:
|
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:
|
while True:
|
||||||
raw = await ws.receive_text()
|
data = await ws.receive_json()
|
||||||
try:
|
msg_type = data.get("type")
|
||||||
msg = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
await manager.send(ws_id, "error", {"message": "Invalid JSON"})
|
|
||||||
continue
|
|
||||||
|
|
||||||
event = msg.get("event")
|
if msg_type == "heartbeat":
|
||||||
|
await _handle_heartbeat(slug, data)
|
||||||
|
|
||||||
if event == "auth":
|
elif msg_type == "ack":
|
||||||
# Agent auth — for now accept any token, send auth.ok
|
pass # acknowledged, no action needed
|
||||||
token = msg.get("token", "")
|
|
||||||
logger.info("WS auth from %s, token=%s...", ws_id, token[:8] if token else "none")
|
|
||||||
await manager.send(ws_id, "auth.ok", {"init": {}})
|
|
||||||
|
|
||||||
elif event == "agent.heartbeat":
|
elif msg_type == "chat.send":
|
||||||
await manager.send(ws_id, "agent.heartbeat.ack", {})
|
await _handle_chat_send(slug, data)
|
||||||
|
|
||||||
elif event == "chat.send":
|
elif msg_type == "project.subscribe":
|
||||||
await _handle_chat_send(ws_id, msg)
|
await _handle_subscribe(slug, data)
|
||||||
|
|
||||||
elif event == "chat.subscribe":
|
elif msg_type == "project.unsubscribe":
|
||||||
# Subscribe to a chat room
|
await _handle_unsubscribe(slug, data)
|
||||||
chat_id = msg.get("chat_id")
|
|
||||||
if chat_id:
|
|
||||||
await manager.subscribe(ws_id, f"chat:{chat_id}")
|
|
||||||
await manager.send(ws_id, "chat.subscribed", {"chat_id": chat_id})
|
|
||||||
|
|
||||||
elif event == "chat.unsubscribe":
|
|
||||||
chat_id = msg.get("chat_id")
|
|
||||||
if chat_id:
|
|
||||||
await manager.unsubscribe(ws_id, f"chat:{chat_id}")
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug("Unknown WS event: %s", event)
|
await ws.send_json({"type": "error", "message": f"Unknown type: {msg_type}"})
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
logger.info("WS disconnect: %s", slug)
|
||||||
finally:
|
|
||||||
await manager.disconnect(ws_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_chat_send(ws_id: str, msg: dict):
|
|
||||||
"""Save message to DB and broadcast to chat subscribers."""
|
|
||||||
chat_id = msg.get("chat_id")
|
|
||||||
content = msg.get("content", "").strip()
|
|
||||||
sender_type = msg.get("sender_type", "human")
|
|
||||||
sender_id = msg.get("sender_id")
|
|
||||||
sender_name = msg.get("sender_name")
|
|
||||||
|
|
||||||
if not chat_id or not content:
|
|
||||||
await manager.send(ws_id, "error", {"message": "chat_id and content required"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Save to DB
|
|
||||||
try:
|
|
||||||
async with async_session() as db:
|
|
||||||
chat = await db.get(Chat, chat_id)
|
|
||||||
if not chat:
|
|
||||||
await manager.send(ws_id, "error", {"message": "Chat not found"})
|
|
||||||
return
|
|
||||||
|
|
||||||
db_msg = ChatMessage(
|
|
||||||
chat_id=chat_id,
|
|
||||||
sender_type=sender_type,
|
|
||||||
sender_id=sender_id,
|
|
||||||
sender_name=sender_name,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
db.add(db_msg)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(db_msg)
|
|
||||||
|
|
||||||
# Broadcast to all subscribers of this chat
|
|
||||||
payload = {
|
|
||||||
"id": str(db_msg.id),
|
|
||||||
"chat_id": str(chat_id),
|
|
||||||
"sender_type": sender_type,
|
|
||||||
"sender_id": str(sender_id) if sender_id else None,
|
|
||||||
"sender_name": sender_name,
|
|
||||||
"content": content,
|
|
||||||
"created_at": str(db_msg.created_at),
|
|
||||||
}
|
|
||||||
await manager.broadcast_to_room(f"chat:{chat_id}", "chat.message", payload)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error saving chat message: %s", e)
|
logger.error("WS error for %s: %s", slug, e)
|
||||||
await manager.send(ws_id, "error", {"message": "Failed to save message"})
|
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
|
||||||
|
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 = [{"id": str(p.id), "slug": p.slug, "name": p.name} for p in projects.scalars()]
|
||||||
|
|
||||||
|
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."""
|
||||||
|
status = data.get("status", "online")
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -1,111 +1,83 @@
|
|||||||
"""WebSocket connection manager with room subscriptions."""
|
"""WebSocket connection manager with project subscriptions and filtering."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger("tracker.ws")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Connection:
|
class ConnectedClient:
|
||||||
ws: WebSocket
|
ws: WebSocket
|
||||||
client_type: str = "unknown" # human, agent
|
member_slug: str
|
||||||
client_id: str | None = None
|
member_type: str # human | agent | bridge
|
||||||
rooms: set[str] = field(default_factory=set) # subscribed rooms: "chat:<id>"
|
chat_listen: str = "all" # all | mentions
|
||||||
|
task_listen: str = "all" # all | mentions
|
||||||
|
subscribed_projects: set[str] = field(default_factory=set) # project_ids
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._connections: dict[str, Connection] = {}
|
self.clients: dict[str, ConnectedClient] = {} # slug → client
|
||||||
self._rooms: dict[str, set[str]] = {} # room -> set of ws_ids
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def connect(self, ws: WebSocket, client_type: str = "unknown", client_id: str | None = None) -> str:
|
async def connect(self, client: ConnectedClient):
|
||||||
await ws.accept()
|
self.clients[client.member_slug] = client
|
||||||
ws_id = f"{client_type}:{client_id or id(ws)}"
|
logger.info("WS connected: %s (%s)", client.member_slug, client.member_type)
|
||||||
conn = Connection(ws=ws, client_type=client_type, client_id=client_id)
|
|
||||||
async with self._lock:
|
|
||||||
self._connections[ws_id] = conn
|
|
||||||
logger.info("WS connected: %s", ws_id)
|
|
||||||
return ws_id
|
|
||||||
|
|
||||||
async def disconnect(self, ws_id: str):
|
async def disconnect(self, slug: str):
|
||||||
async with self._lock:
|
if slug in self.clients:
|
||||||
conn = self._connections.pop(ws_id, None)
|
del self.clients[slug]
|
||||||
if conn:
|
logger.info("WS disconnected: %s", slug)
|
||||||
for room in conn.rooms:
|
|
||||||
s = self._rooms.get(room)
|
|
||||||
if s:
|
|
||||||
s.discard(ws_id)
|
|
||||||
if not s:
|
|
||||||
del self._rooms[room]
|
|
||||||
logger.info("WS disconnected: %s", ws_id)
|
|
||||||
|
|
||||||
async def subscribe(self, ws_id: str, room: str):
|
async def send_to(self, slug: str, data: dict):
|
||||||
async with self._lock:
|
"""Send to specific client."""
|
||||||
conn = self._connections.get(ws_id)
|
client = self.clients.get(slug)
|
||||||
if conn:
|
if client:
|
||||||
conn.rooms.add(room)
|
|
||||||
self._rooms.setdefault(room, set()).add(ws_id)
|
|
||||||
|
|
||||||
async def unsubscribe(self, ws_id: str, room: str):
|
|
||||||
async with self._lock:
|
|
||||||
conn = self._connections.get(ws_id)
|
|
||||||
if conn:
|
|
||||||
conn.rooms.discard(room)
|
|
||||||
s = self._rooms.get(room)
|
|
||||||
if s:
|
|
||||||
s.discard(ws_id)
|
|
||||||
if not s:
|
|
||||||
del self._rooms[room]
|
|
||||||
|
|
||||||
async def send(self, ws_id: str, event: str, data: dict):
|
|
||||||
conn = self._connections.get(ws_id)
|
|
||||||
if conn:
|
|
||||||
try:
|
try:
|
||||||
await conn.ws.send_json({"event": event, **data})
|
await client.ws.send_json(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.disconnect(ws_id)
|
await self.disconnect(slug)
|
||||||
|
|
||||||
async def broadcast(self, event: str, data: dict, exclude: str | None = None):
|
async def broadcast_message(self, project_id: str, message: dict, author_slug: str):
|
||||||
msg = {"event": event, **data}
|
"""Broadcast message.new — filtered by chat_listen."""
|
||||||
dead = []
|
mentions = message.get("mentions", [])
|
||||||
for ws_id, conn in list(self._connections.items()):
|
for slug, client in list(self.clients.items()):
|
||||||
if ws_id == exclude:
|
if slug == author_slug:
|
||||||
|
continue # don't echo to sender
|
||||||
|
if project_id not in client.subscribed_projects:
|
||||||
continue
|
continue
|
||||||
try:
|
# Filter by chat_listen
|
||||||
await conn.ws.send_json(msg)
|
if client.chat_listen == "mentions" and slug not in mentions:
|
||||||
except Exception:
|
continue
|
||||||
dead.append(ws_id)
|
await self.send_to(slug, {"type": "message.new", "data": message})
|
||||||
for ws_id in dead:
|
|
||||||
await self.disconnect(ws_id)
|
|
||||||
|
|
||||||
async def broadcast_to_room(self, room: str, event: str, data: dict, exclude: str | None = None):
|
async def broadcast_task_event(self, project_id: str, event_type: str, data: dict):
|
||||||
"""Send to all connections subscribed to a room."""
|
"""Broadcast task events — filtered by task_listen + ownership."""
|
||||||
ws_ids = self._rooms.get(room, set())
|
assignee = data.get("assignee_slug")
|
||||||
msg = {"event": event, **data}
|
reviewer = data.get("reviewer_slug")
|
||||||
dead = []
|
watchers = data.get("watchers", [])
|
||||||
for ws_id in list(ws_ids):
|
|
||||||
if ws_id == exclude:
|
|
||||||
continue
|
|
||||||
conn = self._connections.get(ws_id)
|
|
||||||
if not conn:
|
|
||||||
dead.append(ws_id)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await conn.ws.send_json(msg)
|
|
||||||
except Exception:
|
|
||||||
dead.append(ws_id)
|
|
||||||
for ws_id in dead:
|
|
||||||
await self.disconnect(ws_id)
|
|
||||||
|
|
||||||
@property
|
for slug, client in list(self.clients.items()):
|
||||||
def active_count(self) -> int:
|
if project_id not in client.subscribed_projects:
|
||||||
return len(self._connections)
|
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()
|
manager = ConnectionManager()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user