Compare commits
7
Commits
master
..
4150f57628
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4150f57628 | ||
|
|
9bf9d2327f | ||
|
|
06487f9fbc | ||
|
|
ba8d707532 | ||
|
|
4e2a062b4c | ||
|
|
8efcb73cc1 | ||
|
|
1299db0840 |
@@ -1,36 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.github
|
|
||||||
|
|
||||||
# Python
|
|
||||||
**/__pycache__
|
|
||||||
**/*.py[cod]
|
|
||||||
**/*.pyo
|
|
||||||
**/*.pyd
|
|
||||||
**/.pytest_cache
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.ruff_cache
|
|
||||||
**/.coverage
|
|
||||||
**/htmlcov
|
|
||||||
**/*.egg-info
|
|
||||||
.venv
|
|
||||||
**/.venv
|
|
||||||
|
|
||||||
# Node / Next
|
|
||||||
**/node_modules
|
|
||||||
**/.next
|
|
||||||
**/dist
|
|
||||||
**/build
|
|
||||||
**/.turbo
|
|
||||||
|
|
||||||
# OS / editor
|
|
||||||
.DS_Store
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# Local env files
|
|
||||||
**/.env
|
|
||||||
**/.env.*
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
**/*.log
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Root compose defaults (safe for local self-host / dev)
|
|
||||||
# Copy to .env to override.
|
|
||||||
|
|
||||||
# --- app ports (host) ---
|
|
||||||
FRONTEND_PORT=3000
|
|
||||||
BACKEND_PORT=8000
|
|
||||||
|
|
||||||
# --- database ---
|
|
||||||
POSTGRES_DB=mission_control
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=postgres
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
|
|
||||||
# --- backend settings (see backend/.env.example for full list) ---
|
|
||||||
CORS_ORIGINS=http://localhost:3000
|
|
||||||
DB_AUTO_MIGRATE=true
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
REQUEST_LOG_SLOW_MS=1000
|
|
||||||
AUTH_MODE=local
|
|
||||||
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
|
||||||
LOCAL_AUTH_TOKEN=
|
|
||||||
|
|
||||||
# --- frontend settings ---
|
|
||||||
# REQUIRED: Public URL used by the browser to reach the API.
|
|
||||||
# If this is missing/blank, frontend API calls (e.g. Activity feed) will break.
|
|
||||||
# Example (local dev / compose on your machine):
|
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
## Task / context
|
|
||||||
- Mission Control task: <link or id>
|
|
||||||
- Why: <what problem this PR solves>
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- <bullet 1>
|
|
||||||
- <bullet 2>
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- <explicitly list what is NOT included>
|
|
||||||
|
|
||||||
## Evidence / validation
|
|
||||||
- [ ] `make check` (or explain what you ran instead)
|
|
||||||
- [ ] E2E (if applicable): <cypress run / screenshots>
|
|
||||||
- Logs/links:
|
|
||||||
- <link to CI run>
|
|
||||||
|
|
||||||
## Screenshots (UI changes)
|
|
||||||
| Desktop | Mobile |
|
|
||||||
| --- | --- |
|
|
||||||
| <img src="..." width="600" /> | <img src="..." width="300" /> |
|
|
||||||
|
|
||||||
## Docs impact
|
|
||||||
- [ ] No user/operator docs changes required
|
|
||||||
- [ ] Docs updated: <paths/links>
|
|
||||||
|
|
||||||
## Risk / rollout notes
|
|
||||||
- Risk level: low / medium / high
|
|
||||||
- Rollback plan (if needed): <steps>
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] Branch created from `origin/master` (no unrelated commits)
|
|
||||||
- [ ] PR is focused (one theme)
|
|
||||||
- [ ] No secrets in code/logs/docs
|
|
||||||
- [ ] If API/behavior changes: docs updated (OpenAPI + `docs/reference/api.md`)
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: python -m pip install --upgrade pip uv
|
|
||||||
|
|
||||||
- name: Cache uv
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/uv
|
|
||||||
backend/.venv
|
|
||||||
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
id: setup-node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
|
||||||
run: make backend-sync
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: make frontend-sync
|
|
||||||
|
|
||||||
- name: Cache Next.js build cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
frontend/.next/cache
|
|
||||||
key: nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- name: Enforce one migration per PR
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
env:
|
|
||||||
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
run: |
|
|
||||||
./scripts/ci/one_migration_per_pr.sh
|
|
||||||
|
|
||||||
- name: Run migration integrity gate
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
|
||||||
HEAD_SHA="${{ github.sha }}"
|
|
||||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
|
||||||
else
|
|
||||||
BASE_SHA="${{ github.event.before }}"
|
|
||||||
HEAD_SHA="${{ github.sha }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
|
||||||
echo "Changed files:"
|
|
||||||
echo "$CHANGED_FILES"
|
|
||||||
|
|
||||||
if ! echo "$CHANGED_FILES" | grep -Eq '^backend/(app/models|db|migrations|alembic\.ini)'; then
|
|
||||||
echo "No migration-relevant backend changes detected; skipping migration gate."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$CHANGED_FILES" | grep -Eq '^backend/app/models/' && ! echo "$CHANGED_FILES" | grep -Eq '^backend/migrations/versions/'; then
|
|
||||||
echo "Model changes detected without a migration under backend/migrations/versions/."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
make backend-migration-check
|
|
||||||
|
|
||||||
- name: Run backend checks
|
|
||||||
env:
|
|
||||||
# Keep CI builds deterministic.
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
AUTH_MODE: "clerk"
|
|
||||||
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
|
|
||||||
run: |
|
|
||||||
make backend-lint
|
|
||||||
make backend-typecheck
|
|
||||||
make backend-coverage
|
|
||||||
|
|
||||||
- name: Run frontend checks
|
|
||||||
env:
|
|
||||||
# Keep CI builds deterministic.
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
NEXT_PUBLIC_AUTH_MODE: "clerk"
|
|
||||||
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
|
||||||
run: |
|
|
||||||
make frontend-lint
|
|
||||||
make frontend-typecheck
|
|
||||||
make frontend-test
|
|
||||||
make frontend-build
|
|
||||||
|
|
||||||
|
|
||||||
- name: Docs quality gates (lint + relative link check)
|
|
||||||
run: |
|
|
||||||
make docs-check
|
|
||||||
|
|
||||||
- name: Upload coverage artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: coverage
|
|
||||||
if-no-files-found: ignore
|
|
||||||
path: |
|
|
||||||
backend/coverage.xml
|
|
||||||
frontend/coverage/**
|
|
||||||
|
|
||||||
installer:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [check]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Validate installer shell syntax
|
|
||||||
run: bash -n install.sh
|
|
||||||
|
|
||||||
- name: Installer smoke test (docker mode)
|
|
||||||
run: |
|
|
||||||
./install.sh \
|
|
||||||
--mode docker \
|
|
||||||
--backend-port 18000 \
|
|
||||||
--frontend-port 13000 \
|
|
||||||
--public-host localhost \
|
|
||||||
--api-url http://localhost:18000 \
|
|
||||||
--token-mode generate
|
|
||||||
curl -fsS http://127.0.0.1:18000/healthz >/dev/null
|
|
||||||
curl -fsS http://127.0.0.1:13000 >/dev/null
|
|
||||||
|
|
||||||
- name: Cleanup docker stack after docker mode
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker compose -f compose.yml --env-file .env down -v --remove-orphans || true
|
|
||||||
|
|
||||||
- name: Installer smoke test (local mode)
|
|
||||||
run: |
|
|
||||||
./install.sh \
|
|
||||||
--mode local \
|
|
||||||
--backend-port 18001 \
|
|
||||||
--frontend-port 13001 \
|
|
||||||
--public-host localhost \
|
|
||||||
--api-url http://localhost:18001 \
|
|
||||||
--token-mode generate \
|
|
||||||
--db-mode docker \
|
|
||||||
--start-services yes
|
|
||||||
curl -fsS http://127.0.0.1:18001/healthz >/dev/null
|
|
||||||
curl -fsS http://127.0.0.1:13001 >/dev/null
|
|
||||||
|
|
||||||
- name: Cleanup local processes and docker resources
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -f .install-logs/backend.pid ]; then kill "$(cat .install-logs/backend.pid)" || true; fi
|
|
||||||
if [ -f .install-logs/frontend.pid ]; then kill "$(cat .install-logs/frontend.pid)" || true; fi
|
|
||||||
docker compose -f compose.yml --env-file .env down -v --remove-orphans || true
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [check]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
id: setup-node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: make frontend-sync
|
|
||||||
|
|
||||||
- name: Cache Next.js build cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
frontend/.next/cache
|
|
||||||
key: nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
nextjs-${{ runner.os }}-node-${{ steps.setup-node.outputs.node-version }}-
|
|
||||||
|
|
||||||
|
|
||||||
- name: Start frontend (dev server)
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
NEXT_PUBLIC_AUTH_MODE: "clerk"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm run dev -- --hostname 0.0.0.0 --port 3000 &
|
|
||||||
for i in {1..60}; do
|
|
||||||
if curl -sf http://localhost:3000/ > /dev/null; then exit 0; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Frontend did not start"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Run Cypress E2E
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
NEXT_PUBLIC_AUTH_MODE: "clerk"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
# Clerk testing tokens (official @clerk/testing Cypress integration)
|
|
||||||
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
|
|
||||||
# Also set for the app itself.
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm run e2e -- --browser chrome
|
|
||||||
|
|
||||||
- name: Upload Cypress artifacts
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: cypress-artifacts
|
|
||||||
if-no-files-found: ignore
|
|
||||||
path: |
|
|
||||||
frontend/cypress/screenshots/**
|
|
||||||
frontend/cypress/videos/**
|
|
||||||
+6
-25
@@ -1,25 +1,6 @@
|
|||||||
# Python
|
node_modules
|
||||||
__pycache__/
|
.next
|
||||||
*.py[cod]
|
out
|
||||||
|
build
|
||||||
# Node / Next
|
dist
|
||||||
node_modules/
|
*.log
|
||||||
.next/
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.runlogs/
|
|
||||||
|
|
||||||
# Worktrees
|
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# Accidental literal "~" directories (e.g. when a configured path contains "~" but isn't expanded)
|
|
||||||
backend/~/
|
|
||||||
backend/coverage.*
|
|
||||||
backend/.coverage
|
|
||||||
frontend/coverage
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# markdownlint-cli2 config
|
|
||||||
# Keep the ruleset intentionally tiny to avoid noisy churn.
|
|
||||||
|
|
||||||
config:
|
|
||||||
default: false
|
|
||||||
MD009: true # no trailing spaces
|
|
||||||
MD010: true # no hard tabs
|
|
||||||
MD012: true # no multiple consecutive blank lines
|
|
||||||
MD047: true # single trailing newline
|
|
||||||
|
|
||||||
globs:
|
|
||||||
- "**/*.md"
|
|
||||||
|
|
||||||
ignores:
|
|
||||||
- "**/node_modules/**"
|
|
||||||
- "**/.next/**"
|
|
||||||
- "**/dist/**"
|
|
||||||
- "**/build/**"
|
|
||||||
- "**/.venv/**"
|
|
||||||
- "**/__pycache__/**"
|
|
||||||
- "**/.pytest_cache/**"
|
|
||||||
- "**/.mypy_cache/**"
|
|
||||||
- "**/coverage/**"
|
|
||||||
- "**/~/**"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.6.0
|
|
||||||
hooks:
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 24.10.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
language_version: python3
|
|
||||||
files: ^backend/.*\.py$
|
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/isort
|
|
||||||
rev: 5.13.2
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
files: ^backend/.*\.py$
|
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/flake8
|
|
||||||
rev: 7.1.1
|
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
files: ^backend/.*\.py$
|
|
||||||
args: [--config=backend/.flake8]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
- `backend/`: FastAPI service. Main app code lives in `backend/app/` with API routes in `backend/app/api/`, data models in `backend/app/models/`, schemas in `backend/app/schemas/`, and service logic in `backend/app/services/`.
|
|
||||||
- `backend/migrations/`: Alembic migrations (`backend/migrations/versions/` for generated revisions).
|
|
||||||
- `backend/tests/`: pytest suite (`test_*.py` naming).
|
|
||||||
- `backend/templates/`: backend-shipped templates used by gateway flows.
|
|
||||||
- `frontend/`: Next.js app. Routes under `frontend/src/app/`, shared components under `frontend/src/components/`, utilities under `frontend/src/lib/`.
|
|
||||||
- `frontend/src/api/generated/`: generated API client; regenerate instead of editing by hand.
|
|
||||||
- `docs/`: contributor and operations docs (start at `docs/README.md`).
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
- `make setup`: install/sync backend and frontend dependencies.
|
|
||||||
- `make check`: closest CI parity run (lint, typecheck, tests/coverage, frontend build).
|
|
||||||
- `docker compose -f compose.yml --env-file .env up -d --build`: run full stack.
|
|
||||||
- Fast local loop:
|
|
||||||
- `docker compose -f compose.yml --env-file .env up -d db`
|
|
||||||
- `cd backend && uv run uvicorn app.main:app --reload --port 8000`
|
|
||||||
- `cd frontend && npm run dev`
|
|
||||||
- `make api-gen`: regenerate frontend API client (backend must be on `127.0.0.1:8000`).
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
- Python: Black + isort + flake8 + strict mypy. Max line length is 100. Use `snake_case`.
|
|
||||||
- TypeScript/React: ESLint + Prettier. Components use `PascalCase`; variables/functions use `camelCase`.
|
|
||||||
- For intentionally unused destructured TS variables, prefix with `_` to satisfy lint config.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
- Backend: pytest via `make backend-test`; coverage policy via `make backend-coverage` (writes `backend/coverage.xml` and `backend/coverage.json`).
|
|
||||||
- Frontend: vitest + Testing Library via `make frontend-test` (coverage in `frontend/coverage/`).
|
|
||||||
- Add or update tests whenever behavior changes.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
- Follow Conventional Commits (seen in history), e.g. `feat: ...`, `fix: ...`, `docs: ...`, `test(core): ...`.
|
|
||||||
- Keep PRs focused and based on latest `master`.
|
|
||||||
- Include: what changed, why, test evidence (`make check` or targeted commands), linked issue, and screenshots/logs when UI or operator workflow changes.
|
|
||||||
|
|
||||||
## Security & Configuration Tips
|
|
||||||
- Never commit secrets. Copy from `.env.example` and keep real values in local `.env`.
|
|
||||||
- Report vulnerabilities privately via GitHub security advisories, not public issues.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Contributing to OpenClaw Mission Control
|
|
||||||
|
|
||||||
Thanks for your interest in improving Mission Control.
|
|
||||||
|
|
||||||
This repo welcomes contributions in three broad categories:
|
|
||||||
|
|
||||||
- **Issues**: bug reports, feature requests, and design discussions
|
|
||||||
- **Documentation**: improvements to clarity, correctness, onboarding, and runbooks
|
|
||||||
- **Code**: fixes, features, tests, and refactors
|
|
||||||
|
|
||||||
## Where to start
|
|
||||||
|
|
||||||
- Docs landing page: [Docs landing](./docs/README.md)
|
|
||||||
- Development workflow: [Development workflow](./docs/03-development.md)
|
|
||||||
- Testing guide: [Testing guide](./docs/testing/README.md)
|
|
||||||
|
|
||||||
## Filing issues
|
|
||||||
|
|
||||||
When opening an issue, please include:
|
|
||||||
|
|
||||||
- What you expected vs what happened
|
|
||||||
- Steps to reproduce (commands, env vars, links)
|
|
||||||
- Logs and screenshots where helpful
|
|
||||||
- Your environment (OS, Docker version, Node/Python versions)
|
|
||||||
|
|
||||||
## Pull requests
|
|
||||||
|
|
||||||
### Branching hygiene (required)
|
|
||||||
|
|
||||||
Create feature branches from the latest `origin/master` to avoid unrelated commits in PRs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin
|
|
||||||
git checkout master
|
|
||||||
git reset --hard origin/master
|
|
||||||
git checkout -b <branch-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
If you accidentally based your branch off another feature branch, fix it by cherry-picking the intended commits onto a clean branch and force-pushing the corrected branch (or opening a new PR).
|
|
||||||
|
|
||||||
### Expectations
|
|
||||||
|
|
||||||
- Keep PRs **small and focused** when possible.
|
|
||||||
- Include a clear description of the change and why it’s needed.
|
|
||||||
- Add/adjust tests when behavior changes.
|
|
||||||
- Update docs when contributor-facing or operator-facing behavior changes.
|
|
||||||
|
|
||||||
### Local checks
|
|
||||||
|
|
||||||
From repo root, the closest “CI parity” command is:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make check
|
|
||||||
```
|
|
||||||
|
|
||||||
If you’re iterating on a specific area, the Makefile also provides targeted commands (lint, typecheck, unit tests, etc.). See `make help`.
|
|
||||||
|
|
||||||
## Docs contribution guidelines
|
|
||||||
|
|
||||||
- The numbered pages under `docs/` are **entrypoints**. Prefer linking to deeper pages instead of duplicating large blocks of content.
|
|
||||||
- Use concise language and concrete examples.
|
|
||||||
- When documenting operational behavior, call out risk areas (secrets, data loss, migrations).
|
|
||||||
|
|
||||||
## Security and vulnerability reporting
|
|
||||||
|
|
||||||
If you believe you’ve found a security vulnerability:
|
|
||||||
|
|
||||||
- **Do not** open a public issue.
|
|
||||||
- Prefer GitHub’s private reporting flow:
|
|
||||||
- https://github.com/abhi1693/openclaw-mission-control/security/advisories/new
|
|
||||||
|
|
||||||
If that’s not available in your environment, contact the maintainers privately.
|
|
||||||
|
|
||||||
## Code of conduct
|
|
||||||
|
|
||||||
If this repository adopts a Code of Conduct, we will link it here.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the MIT License. See [`LICENSE`](./LICENSE).
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 OpenClaw Mission Control
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
.DEFAULT_GOAL := help
|
|
||||||
|
|
||||||
SHELL := /usr/bin/env bash
|
|
||||||
.SHELLFLAGS := -euo pipefail -c
|
|
||||||
|
|
||||||
BACKEND_DIR := backend
|
|
||||||
FRONTEND_DIR := frontend
|
|
||||||
|
|
||||||
NODE_WRAP := bash scripts/with_node.sh
|
|
||||||
|
|
||||||
.PHONY: help
|
|
||||||
help: ## Show available targets
|
|
||||||
@grep -E '^[a-zA-Z0-9_.-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-26s %s\n", $$1, $$2}'
|
|
||||||
|
|
||||||
.PHONY: setup
|
|
||||||
setup: backend-sync frontend-sync ## Install/sync backend + frontend deps
|
|
||||||
|
|
||||||
.PHONY: all
|
|
||||||
all: setup format check ## Run everything (deps + format + CI-equivalent checks)
|
|
||||||
|
|
||||||
.PHONY: backend-sync
|
|
||||||
backend-sync: ## uv sync backend deps (includes dev extra)
|
|
||||||
cd $(BACKEND_DIR) && uv sync --extra dev
|
|
||||||
|
|
||||||
.PHONY: frontend-tooling
|
|
||||||
frontend-tooling: ## Verify frontend toolchain (node + npm)
|
|
||||||
@$(NODE_WRAP) --check
|
|
||||||
|
|
||||||
.PHONY: frontend-sync
|
|
||||||
frontend-sync: frontend-tooling ## npm install frontend deps
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm install
|
|
||||||
|
|
||||||
.PHONY: format
|
|
||||||
format: backend-format frontend-format ## Format backend + frontend
|
|
||||||
|
|
||||||
.PHONY: backend-format
|
|
||||||
backend-format: ## Format backend (isort + black)
|
|
||||||
cd $(BACKEND_DIR) && uv run isort .
|
|
||||||
cd $(BACKEND_DIR) && uv run black .
|
|
||||||
|
|
||||||
.PHONY: frontend-format
|
|
||||||
frontend-format: frontend-tooling ## Format frontend (prettier)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}"
|
|
||||||
|
|
||||||
.PHONY: format-check
|
|
||||||
format-check: backend-format-check frontend-format-check ## Check formatting (no changes)
|
|
||||||
|
|
||||||
.PHONY: backend-format-check
|
|
||||||
backend-format-check: ## Check backend formatting (isort + black)
|
|
||||||
cd $(BACKEND_DIR) && uv run isort . --check-only --diff
|
|
||||||
cd $(BACKEND_DIR) && uv run black . --check --diff
|
|
||||||
|
|
||||||
.PHONY: frontend-format-check
|
|
||||||
frontend-format-check: frontend-tooling ## Check frontend formatting (prettier)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}"
|
|
||||||
|
|
||||||
.PHONY: lint
|
|
||||||
lint: backend-lint frontend-lint ## Lint backend + frontend
|
|
||||||
|
|
||||||
.PHONY: backend-lint
|
|
||||||
backend-lint: ## Lint backend (flake8)
|
|
||||||
cd $(BACKEND_DIR) && uv run flake8 --config .flake8
|
|
||||||
|
|
||||||
.PHONY: frontend-lint
|
|
||||||
frontend-lint: frontend-tooling ## Lint frontend (eslint)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run lint
|
|
||||||
|
|
||||||
.PHONY: typecheck
|
|
||||||
typecheck: backend-typecheck frontend-typecheck ## Typecheck backend + frontend
|
|
||||||
|
|
||||||
.PHONY: backend-typecheck
|
|
||||||
backend-typecheck: ## Typecheck backend (mypy --strict)
|
|
||||||
cd $(BACKEND_DIR) && uv run mypy
|
|
||||||
|
|
||||||
.PHONY: frontend-typecheck
|
|
||||||
frontend-typecheck: frontend-tooling ## Typecheck frontend (tsc)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npx tsc -p tsconfig.json --noEmit
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test: backend-test frontend-test ## Run tests
|
|
||||||
|
|
||||||
.PHONY: backend-test
|
|
||||||
backend-test: ## Backend tests (pytest)
|
|
||||||
cd $(BACKEND_DIR) && uv run pytest
|
|
||||||
|
|
||||||
.PHONY: backend-coverage
|
|
||||||
backend-coverage: ## Backend tests with coverage gate (scoped 100% stmt+branch on selected modules)
|
|
||||||
# Policy: enforce 100% coverage only for the explicitly scoped, unit-testable backend modules.
|
|
||||||
# Rationale: overall API/DB coverage is currently low; we will expand the scope as we add tests.
|
|
||||||
cd $(BACKEND_DIR) && uv run pytest \
|
|
||||||
--cov=app.core.error_handling \
|
|
||||||
--cov=app.services.mentions \
|
|
||||||
--cov-branch \
|
|
||||||
--cov-report=term-missing \
|
|
||||||
--cov-report=xml:coverage.xml \
|
|
||||||
--cov-report=json:coverage.json \
|
|
||||||
--cov-fail-under=100
|
|
||||||
|
|
||||||
.PHONY: frontend-test
|
|
||||||
frontend-test: frontend-tooling ## Frontend tests (vitest)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run test
|
|
||||||
|
|
||||||
.PHONY: backend-migrate
|
|
||||||
backend-migrate: ## Apply backend DB migrations (uses backend/migrations)
|
|
||||||
cd $(BACKEND_DIR) && uv run alembic upgrade head
|
|
||||||
|
|
||||||
.PHONY: backend-migration-check
|
|
||||||
backend-migration-check: ## Validate migration graph + reversible path on clean Postgres
|
|
||||||
@set -euo pipefail; \
|
|
||||||
(cd $(BACKEND_DIR) && uv run python scripts/check_migration_graph.py); \
|
|
||||||
CONTAINER_NAME="mc-migration-check-$$RANDOM"; \
|
|
||||||
docker run -d --rm --name $$CONTAINER_NAME -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=migration_ci -p 55432:5432 postgres:16 >/dev/null; \
|
|
||||||
cleanup() { docker rm -f $$CONTAINER_NAME >/dev/null 2>&1 || true; }; \
|
|
||||||
trap cleanup EXIT; \
|
|
||||||
for i in $$(seq 1 30); do \
|
|
||||||
if docker exec $$CONTAINER_NAME pg_isready -U postgres -d migration_ci >/dev/null 2>&1; then break; fi; \
|
|
||||||
sleep 1; \
|
|
||||||
if [ $$i -eq 30 ]; then echo "Postgres did not become ready"; exit 1; fi; \
|
|
||||||
done; \
|
|
||||||
cd $(BACKEND_DIR) && \
|
|
||||||
AUTH_MODE=local \
|
|
||||||
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
|
||||||
uv run alembic upgrade head && \
|
|
||||||
AUTH_MODE=local \
|
|
||||||
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
|
||||||
uv run alembic downgrade base && \
|
|
||||||
AUTH_MODE=local \
|
|
||||||
LOCAL_AUTH_TOKEN=ci-local-token-ci-local-token-ci-local-token-ci-local-token \
|
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:55432/migration_ci \
|
|
||||||
uv run alembic upgrade head
|
|
||||||
|
|
||||||
.PHONY: build
|
|
||||||
build: frontend-build ## Build artifacts
|
|
||||||
|
|
||||||
.PHONY: frontend-build
|
|
||||||
frontend-build: frontend-tooling ## Build frontend (next build)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run build
|
|
||||||
|
|
||||||
.PHONY: api-gen
|
|
||||||
api-gen: frontend-tooling ## Regenerate TS API client (requires backend running at 127.0.0.1:8000)
|
|
||||||
$(NODE_WRAP) --cwd $(FRONTEND_DIR) npm run api:gen
|
|
||||||
|
|
||||||
.PHONY: rq-worker
|
|
||||||
rq-worker: ## Run background queue worker loop
|
|
||||||
cd $(BACKEND_DIR) && uv run python ../scripts/rq worker
|
|
||||||
|
|
||||||
.PHONY: backend-templates-sync
|
|
||||||
backend-templates-sync: ## Sync templates to existing gateway agents (usage: make backend-templates-sync GATEWAY_ID=<uuid> SYNC_ARGS="--reset-sessions --overwrite")
|
|
||||||
@if [ -z "$(GATEWAY_ID)" ]; then echo "GATEWAY_ID is required (uuid)"; exit 1; fi
|
|
||||||
cd $(BACKEND_DIR) && uv run python scripts/sync_gateway_templates.py --gateway-id "$(GATEWAY_ID)" $(SYNC_ARGS)
|
|
||||||
|
|
||||||
.PHONY: check
|
|
||||||
check: lint typecheck backend-coverage frontend-test build ## Run lint + typecheck + tests + coverage + build
|
|
||||||
|
|
||||||
|
|
||||||
.PHONY: docs-lint
|
|
||||||
docs-lint: frontend-tooling ## Lint markdown files (tiny ruleset; avoids noisy churn)
|
|
||||||
$(NODE_WRAP) npx [email protected] --config .markdownlint-cli2.yaml "**/*.md"
|
|
||||||
|
|
||||||
.PHONY: docs-link-check
|
|
||||||
docs-link-check: ## Check for broken relative links in markdown docs
|
|
||||||
python scripts/check_markdown_links.py
|
|
||||||
|
|
||||||
.PHONY: docs-check
|
|
||||||
docs-check: docs-lint docs-link-check ## Run all docs quality gates
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# OpenClaw Mission Control
|
|
||||||
|
|
||||||
[](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml)
|
|
||||||
|
|
||||||
OpenClaw Mission Control is the centralized operations and governance platform for running OpenClaw across teams and organizations, with unified visibility, approval controls, and gateway-aware orchestration.
|
|
||||||
It gives operators a single interface for work orchestration, agent and gateway management, approval-driven governance, and API-backed automation.
|
|
||||||
|
|
||||||
<img width="1896" height="869" alt="Mission Control dashboard" src="https://github.com/user-attachments/assets/49a3c823-6aaf-4c56-8328-fb1485ee940f" />
|
|
||||||
<img width="1896" height="858" alt="image" src="https://github.com/user-attachments/assets/2bfee13a-3dab-4f4a-9135-e47bb6949dcf" />
|
|
||||||
<img width="1890" height="865" alt="image" src="https://github.com/user-attachments/assets/84c2e867-5dc7-4a36-9290-e29179d2a659" />
|
|
||||||
<img width="1912" height="881" alt="image" src="https://github.com/user-attachments/assets/3bbd825c-9969-4bbf-bf31-987f9168f370" />
|
|
||||||
<img width="1902" height="878" alt="image" src="https://github.com/user-attachments/assets/eea09632-60e4-4d6d-9e6e-bdfa0ac97630" />
|
|
||||||
|
|
||||||
## Platform overview
|
|
||||||
|
|
||||||
Mission Control is designed to be the day-to-day operations surface for OpenClaw.
|
|
||||||
Instead of splitting work across multiple tools, teams can plan, execute, review, and audit activity in one system.
|
|
||||||
|
|
||||||
Core operational areas:
|
|
||||||
|
|
||||||
- Work orchestration: manage organizations, board groups, boards, tasks, and tags.
|
|
||||||
- Agent operations: create, inspect, and manage agent lifecycle from a unified control surface.
|
|
||||||
- Governance and approvals: route sensitive actions through explicit approval flows.
|
|
||||||
- Gateway management: connect and operate gateway integrations for distributed environments.
|
|
||||||
- Activity visibility: review a timeline of system actions for faster debugging and accountability.
|
|
||||||
- API-first model: support both web workflows and automation clients from the same platform.
|
|
||||||
|
|
||||||
## Use cases
|
|
||||||
|
|
||||||
- Multi-team agent operations: run multiple boards and board groups across organizations from a single control plane.
|
|
||||||
- Human-in-the-loop execution: require approvals before sensitive actions and keep decision trails attached to work.
|
|
||||||
- Distributed runtime control: connect gateways and operate remote execution environments without changing operator workflow.
|
|
||||||
- Audit and incident review: use activity history to reconstruct what happened, when it happened, and who initiated it.
|
|
||||||
- API-backed process integration: connect internal workflows and automation clients to the same operational model used in the UI.
|
|
||||||
|
|
||||||
## What makes Mission Control different
|
|
||||||
|
|
||||||
- Operations-first design: built for running agent work reliably, not just creating tasks.
|
|
||||||
- Governance built in: approvals, auth modes, and clear control boundaries are first-class.
|
|
||||||
- Gateway-aware orchestration: built to operate both local and connected runtime environments.
|
|
||||||
- Unified UI and API model: operators and automation act on the same objects and lifecycle.
|
|
||||||
- Team-scale structure: organizations, board groups, boards, tasks, tags, and users in one system of record.
|
|
||||||
|
|
||||||
## Who it is for
|
|
||||||
|
|
||||||
- Platform teams running OpenClaw in self-hosted or internal environments.
|
|
||||||
- Operations and engineering teams that need clear approval and auditability controls.
|
|
||||||
- Organizations that want API-accessible operations without losing a usable web UI.
|
|
||||||
|
|
||||||
## Get started in minutes
|
|
||||||
|
|
||||||
### Option A: One-command production-style bootstrap
|
|
||||||
|
|
||||||
If you haven't cloned the repo yet, you can run the installer in one line:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/abhi1693/openclaw-mission-control/master/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already cloned the repo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer is interactive and will:
|
|
||||||
|
|
||||||
- Ask for deployment mode (`docker` or `local`).
|
|
||||||
- Install missing system dependencies when possible.
|
|
||||||
- Generate and configure environment files.
|
|
||||||
- Bootstrap and start the selected deployment mode.
|
|
||||||
|
|
||||||
Installer support matrix: [`docs/installer-support.md`](./docs/installer-support.md)
|
|
||||||
|
|
||||||
### Option B: Manual setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Docker Engine
|
|
||||||
- Docker Compose v2 (`docker compose`)
|
|
||||||
|
|
||||||
### 1. Configure environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Before startup:
|
|
||||||
|
|
||||||
- Set `LOCAL_AUTH_TOKEN` to a non-placeholder value (minimum 50 characters) when `AUTH_MODE=local`.
|
|
||||||
- Ensure `NEXT_PUBLIC_API_URL` is reachable from your browser.
|
|
||||||
|
|
||||||
### 2. Start Mission Control
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose.yml --env-file .env up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Open the application
|
|
||||||
|
|
||||||
- Mission Control UI: http://localhost:3000
|
|
||||||
- Backend health: http://localhost:8000/healthz
|
|
||||||
|
|
||||||
### 4. Stop the stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose.yml --env-file .env down
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Mission Control supports two authentication modes:
|
|
||||||
|
|
||||||
- `local`: shared bearer token mode (default for self-hosted use)
|
|
||||||
- `clerk`: Clerk JWT mode
|
|
||||||
|
|
||||||
Environment templates:
|
|
||||||
|
|
||||||
- Root: [`.env.example`](./.env.example)
|
|
||||||
- Backend: [`backend/.env.example`](./backend/.env.example)
|
|
||||||
- Frontend: [`frontend/.env.example`](./frontend/.env.example)
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Complete guides for deployment, production, troubleshooting, and testing are in [`/docs`](./docs/).
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
|
|
||||||
Mission Control is under active development.
|
|
||||||
|
|
||||||
- Features and APIs may change between releases.
|
|
||||||
- Validate and harden your configuration before production use.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Issues and pull requests are welcome.
|
|
||||||
|
|
||||||
- [Contributing guide](./CONTRIBUTING.md)
|
|
||||||
- [Open issues](https://github.com/abhi1693/openclaw-mission-control/issues)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License. See [`LICENSE`](./LICENSE).
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#abhi1693/openclaw-mission-control&type=date&legend=top-left)
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #0f0f23;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Mission Control - NodeCrew",
|
||||||
|
description: "Track tasks, content, calendar, memory, team, and office",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Mission Control - Next.js App
|
||||||
|
// Track tasks, content, calendar, memory, team, and office
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type TaskStatus = 'todo' | 'in-progress' | 'done';
|
||||||
|
type Task = { id: string; title: string; status: TaskStatus; assignee: 'me' | 'jelena' | 'neo' };
|
||||||
|
|
||||||
|
type ContentStage = 'idea' | 'script' | 'thumbnail' | 'filming' | 'done';
|
||||||
|
type ContentItem = { id: string; title: string; stage: ContentStage; script?: string };
|
||||||
|
|
||||||
|
type CalendarEvent = { id: string; title: string; date: string; type: 'cron' | 'scheduled' };
|
||||||
|
|
||||||
|
type Memory = { id: string; title: string; date: string; preview: string };
|
||||||
|
|
||||||
|
type TeamMember = { id: string; name: string; role: string; status: 'working' | 'idle' };
|
||||||
|
|
||||||
|
export default function MissionControl() {
|
||||||
|
const [activeTab, setActiveTab] = useState('tasks');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'system-ui, sans-serif', minHeight: '100vh', background: '#0f0f23', color: 'white' }}>
|
||||||
|
<header style={{ padding: '20px 40px', borderBottom: '1px solid #1e1e3f', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: '1.8rem', fontWeight: 700 }}>🎯 Mission Control</h1>
|
||||||
|
<nav style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{['tasks', 'content', 'calendar', 'memory', 'team', 'office'].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: activeTab === tab ? '#e94560' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'capitalize'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main style={{ padding: '40px' }}>
|
||||||
|
{activeTab === 'tasks' && <TasksBoard />}
|
||||||
|
{activeTab === 'content' && <ContentPipeline />}
|
||||||
|
{activeTab === 'calendar' && <Calendar />}
|
||||||
|
{activeTab === 'memory' && <Memory />}
|
||||||
|
{activeTab === 'team' && <Team />}
|
||||||
|
{activeTab === 'office' && <Office />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksBoard() {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([
|
||||||
|
{ id: '1', title: 'Fix git backup push', status: 'in-progress', assignee: 'neo' },
|
||||||
|
{ id: '2', title: 'Deploy nodecrew landing page', status: 'done', assignee: 'neo' },
|
||||||
|
{ id: '3', title: 'Fix YouTube backup', status: 'todo', assignee: 'neo' },
|
||||||
|
{ id: '4', title: 'Setup Mission Control', status: 'in-progress', assignee: 'jelena' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns: { status: TaskStatus; label: string; color: string }[] = [
|
||||||
|
{ status: 'todo', label: 'To Do', color: '#6b7280' },
|
||||||
|
{ status: 'in-progress', label: 'In Progress', color: '#e94560' },
|
||||||
|
{ status: 'done', label: 'Done', color: '#10b981' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '20px' }}>
|
||||||
|
{columns.map(col => (
|
||||||
|
<div key={col.status} style={{ background: '#1a1a2e', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3 style={{ color: col.color, marginBottom: '20px' }}>{col.label}</h3>
|
||||||
|
{tasks.filter(t => t.status === col.status).map(task => (
|
||||||
|
<div key={task.id} style={{ background: '#16213e', padding: '15px', borderRadius: '8px', marginBottom: '10px' }}>
|
||||||
|
<p>{task.title}</p>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>@{task.assignee}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentPipeline() {
|
||||||
|
const [items, setItems] = useState<ContentItem[]>([
|
||||||
|
{ id: '1', title: 'ManoonOils Ad Copy', stage: 'script' },
|
||||||
|
{ id: '2', title: 'AI Agency Promo', stage: 'idea' },
|
||||||
|
{ id: '3', title: 'Product Launch Video', stage: 'thumbnail' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stages: { stage: ContentStage; label: string }[] = [
|
||||||
|
{ stage: 'idea', label: '💡 Ideas' },
|
||||||
|
{ stage: 'script', label: '📝 Script' },
|
||||||
|
{ stage: 'thumbnail', label: '🖼️ Thumbnail' },
|
||||||
|
{ stage: 'filming', label: '🎬 Filming' },
|
||||||
|
{ stage: 'done', label: '✅ Done' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '15px', overflowX: 'auto', paddingBottom: '20px' }}>
|
||||||
|
{stages.map(s => (
|
||||||
|
<div key={s.stage} style={{ minWidth: '250px', background: '#1a1a2e', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3 style={{ marginBottom: '15px' }}>{s.label}</h3>
|
||||||
|
{items.filter(i => i.stage === s.stage).map(item => (
|
||||||
|
<div key={item.id} style={{ background: '#16213e', padding: '15px', borderRadius: '8px', marginBottom: '10px' }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Calendar() {
|
||||||
|
const events: CalendarEvent[] = [
|
||||||
|
{ id: '1', title: 'Twitter Briefing', date: '2026-02-19 06:00', type: 'cron' },
|
||||||
|
{ id: '2', title: 'Infra Status Report', date: '2026-02-19 07:00', type: 'cron' },
|
||||||
|
{ id: '3', title: 'ManoonOils Analytics', date: '2026-02-19 08:00', type: 'cron' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#1a1a2e', borderRadius: '12px', padding: '30px' }}>
|
||||||
|
<h2 style={{ marginBottom: '30px' }}>📅 Scheduled Tasks & Cron Jobs</h2>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #2a2a4e' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Task</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Date/Time</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map(event => (
|
||||||
|
<tr key={event.id} style={{ borderBottom: '1px solid #2a2a4e' }}>
|
||||||
|
<td style={{ padding: '15px' }}>{event.title}</td>
|
||||||
|
<td style={{ padding: '15px', color: '#9ca3af' }}>{event.date}</td>
|
||||||
|
<td style={{ padding: '15px' }}>
|
||||||
|
<span style={{ background: event.type === 'cron' ? '#e94560' : '#10b981', padding: '4px 12px', borderRadius: '20px', fontSize: '0.8rem' }}>
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Memory() {
|
||||||
|
const memories: Memory[] = [
|
||||||
|
{ id: '1', title: 'ManoonOils Product Formula', date: '2026-02-07', preview: 'Professional formulation for anti-aging serum...' },
|
||||||
|
{ id: '2', title: 'Backup System Setup', date: '2026-02-05', preview: 'Storage Box mounted at /mnt/storagebox...' },
|
||||||
|
{ id: '3', title: 'Neo Agent Created', date: '2026-02-03', preview: 'Neo as CTO - infrastructure agent...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '20px' }}>
|
||||||
|
{memories.map(mem => (
|
||||||
|
<div key={mem.id} style={{ background: '#1a1a2e', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3>{mem.title}</h3>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.9rem', marginTop: '10px' }}>{mem.preview}</p>
|
||||||
|
<span style={{ color: '#6b7280', fontSize: '0.8rem' }}>{mem.date}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Team() {
|
||||||
|
const members: TeamMember[] = [
|
||||||
|
{ id: '1', name: 'Jelena', role: 'Chief of Staff', status: 'working' },
|
||||||
|
{ id: '2', name: 'Neo', role: 'CTO / DevOps', status: 'working' },
|
||||||
|
{ id: '3', name: 'Agent 3', role: 'Sales Agent', status: 'idle' },
|
||||||
|
{ id: '4', name: 'Agent 4', role: 'Ad Manager', status: 'idle' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
|
||||||
|
{members.map(member => (
|
||||||
|
<div key={member.id} style={{ background: '#1a1a2e', borderRadius: '12px', padding: '25px', display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||||
|
<div style={{ width: '50px', height: '50px', borderRadius: '50%', background: member.status === 'working' ? '#10b981' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1.5rem' }}>
|
||||||
|
{member.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>{member.name}</h3>
|
||||||
|
<p style={{ color: '#9ca3af', fontSize: '0.9rem' }}>{member.role}</p>
|
||||||
|
<span style={{ color: member.status === 'working' ? '#10b981' : '#6b7280', fontSize: '0.8rem' }}>
|
||||||
|
{member.status === 'working' ? '● Working' : '○ Idle'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Office() {
|
||||||
|
const agents = [
|
||||||
|
{ name: 'Jelena', area: 'Executive Suite', working: true, task: 'Managing operations' },
|
||||||
|
{ name: 'Neo', area: 'Server Room', working: true, task: 'Infrastructure monitoring' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#1a1a2e', borderRadius: '12px', padding: '30px' }}>
|
||||||
|
<h2 style={{ marginBottom: '30px' }}>🏢 Digital Office</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '20px' }}>
|
||||||
|
{agents.map(agent => (
|
||||||
|
<div key={agent.name} style={{ background: '#16213e', borderRadius: '12px', padding: '25px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h3>{agent.name}</h3>
|
||||||
|
<span style={{ width: '12px', height: '12px', borderRadius: '50%', background: agent.working ? '#10b981' : '#6b7280' }} />
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#9ca3af', marginTop: '10px' }}>📍 {agent.area}</p>
|
||||||
|
<p style={{ color: '#e94560', marginTop: '5px' }}>💻 {agent.task}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[run]
|
|
||||||
branch = True
|
|
||||||
source =
|
|
||||||
app
|
|
||||||
omit =
|
|
||||||
*/.venv/*
|
|
||||||
alembic/versions/*
|
|
||||||
|
|
||||||
[report]
|
|
||||||
show_missing = True
|
|
||||||
skip_covered = True
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
ENVIRONMENT=dev
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FORMAT=text
|
|
||||||
LOG_USE_UTC=false
|
|
||||||
REQUEST_LOG_SLOW_MS=1000
|
|
||||||
REQUEST_LOG_INCLUDE_HEALTH=false
|
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control
|
|
||||||
CORS_ORIGINS=http://localhost:3000
|
|
||||||
BASE_URL=
|
|
||||||
|
|
||||||
# Auth mode: clerk or local.
|
|
||||||
AUTH_MODE=local
|
|
||||||
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
|
||||||
LOCAL_AUTH_TOKEN=
|
|
||||||
# Clerk (auth only; used when AUTH_MODE=clerk)
|
|
||||||
CLERK_SECRET_KEY=
|
|
||||||
CLERK_API_URL=https://api.clerk.com
|
|
||||||
CLERK_VERIFY_IAT=true
|
|
||||||
CLERK_LEEWAY=10.0
|
|
||||||
# Database
|
|
||||||
DB_AUTO_MIGRATE=false
|
|
||||||
# Generic RQ queue / dispatch settings
|
|
||||||
RQ_REDIS_URL=redis://localhost:6379/0
|
|
||||||
RQ_QUEUE_NAME=default
|
|
||||||
RQ_DISPATCH_THROTTLE_SECONDS=15.0
|
|
||||||
RQ_DISPATCH_MAX_RETRIES=3
|
|
||||||
GATEWAY_MIN_VERSION=2026.02.9
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[flake8]
|
|
||||||
max-line-length = 100
|
|
||||||
extend-ignore = E203, W503, E501
|
|
||||||
exclude =
|
|
||||||
.venv,
|
|
||||||
backend/.venv,
|
|
||||||
migrations,
|
|
||||||
backend/migrations,
|
|
||||||
**/__pycache__,
|
|
||||||
**/*.pyc
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
.venv-tools/
|
|
||||||
.env
|
|
||||||
.runlogs/
|
|
||||||
|
|
||||||
# Generated on demand from uv.lock (single source of truth is pyproject.toml + uv.lock).
|
|
||||||
requirements.txt
|
|
||||||
requirements-dev.txt
|
|
||||||
|
|
||||||
# Generated for orval input (avoid needing a running backend/DB).
|
|
||||||
openapi.json
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM python:3.12-slim AS base
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# System deps (keep minimal)
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install uv (https://github.com/astral-sh/uv)
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
ENV PATH="/root/.local/bin:${PATH}"
|
|
||||||
|
|
||||||
# --- deps layer ---
|
|
||||||
FROM base AS deps
|
|
||||||
|
|
||||||
# Copy only dependency metadata first for better build caching
|
|
||||||
# NOTE: compose builds backend with repo-root context, so files live under /backend.
|
|
||||||
COPY backend/pyproject.toml backend/uv.lock ./
|
|
||||||
|
|
||||||
# Create venv and sync deps (including runtime)
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
# --- runtime ---
|
|
||||||
FROM base AS runtime
|
|
||||||
|
|
||||||
# Copy virtual environment from deps stage
|
|
||||||
COPY --from=deps /app/.venv /app/.venv
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
|
||||||
|
|
||||||
# Copy app source
|
|
||||||
COPY backend/migrations ./migrations
|
|
||||||
COPY backend/alembic.ini ./alembic.ini
|
|
||||||
COPY backend/app ./app
|
|
||||||
|
|
||||||
# Copy provisioning templates.
|
|
||||||
# In-repo these live at `backend/templates/`; runtime path is `/app/templates`.
|
|
||||||
COPY backend/templates ./templates
|
|
||||||
|
|
||||||
# Default API port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Run the API
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Mission Control Backend (FastAPI)
|
|
||||||
|
|
||||||
This directory contains the **Mission Control backend API** (FastAPI + SQLModel) and its database migrations (Alembic).
|
|
||||||
|
|
||||||
- Default API base URL: http://localhost:8000
|
|
||||||
- Health endpoints: `/healthz`, `/readyz`
|
|
||||||
- API routes: `/api/v1/*`
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python **3.12+**
|
|
||||||
- [`uv`](https://github.com/astral-sh/uv) (recommended; used by this repo)
|
|
||||||
- Postgres (local or Docker)
|
|
||||||
|
|
||||||
## Quick start (local backend + Docker Postgres)
|
|
||||||
|
|
||||||
From the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# start dependencies
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose -f compose.yml --env-file .env up -d db
|
|
||||||
|
|
||||||
# run backend
|
|
||||||
cd backend
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
uv sync --extra dev
|
|
||||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -f http://localhost:8000/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration / environment variables
|
|
||||||
|
|
||||||
Backend settings are defined in `app/core/config.py` via `pydantic-settings`.
|
|
||||||
|
|
||||||
The backend loads env files in this order:
|
|
||||||
|
|
||||||
1. `backend/.env` (preferred)
|
|
||||||
2. `.env` (current working directory)
|
|
||||||
|
|
||||||
A starter file exists at `backend/.env.example`.
|
|
||||||
|
|
||||||
### Core
|
|
||||||
|
|
||||||
- `ENVIRONMENT` (default: `dev`)
|
|
||||||
- In `dev`, if you **don’t** explicitly set `DB_AUTO_MIGRATE`, the backend defaults it to `true`.
|
|
||||||
- `LOG_LEVEL` (default: `INFO`)
|
|
||||||
- `DATABASE_URL`
|
|
||||||
- Default: `postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency`
|
|
||||||
- Recommended local/dev default (matches `backend/.env.example`):
|
|
||||||
`postgresql+psycopg://postgres:postgres@localhost:5432/mission_control`
|
|
||||||
- `CORS_ORIGINS` (comma-separated)
|
|
||||||
- Example: `http://localhost:3000`
|
|
||||||
- `BASE_URL` (optional)
|
|
||||||
|
|
||||||
### Database lifecycle
|
|
||||||
|
|
||||||
- `DB_AUTO_MIGRATE`
|
|
||||||
- If `true`: on startup, the backend attempts to run Alembic migrations (`alembic upgrade head`).
|
|
||||||
- If there are **no** Alembic revision files yet, it falls back to `SQLModel.metadata.create_all`.
|
|
||||||
|
|
||||||
### Auth (Clerk)
|
|
||||||
|
|
||||||
Clerk is used for user authentication (optional for local/self-host in many setups).
|
|
||||||
|
|
||||||
- `CLERK_SECRET_KEY` (required)
|
|
||||||
- Used to fetch user profile fields (email/name) from Clerk when JWT claims are minimal.
|
|
||||||
- `CLERK_API_URL` (default: `https://api.clerk.com`)
|
|
||||||
- `CLERK_VERIFY_IAT` (default: `true`)
|
|
||||||
- `CLERK_LEEWAY` (default: `10.0`)
|
|
||||||
|
|
||||||
## Database migrations (Alembic)
|
|
||||||
|
|
||||||
Migrations live in `backend/migrations/versions/*`.
|
|
||||||
|
|
||||||
Common commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# apply migrations
|
|
||||||
uv run alembic upgrade head
|
|
||||||
|
|
||||||
# create a new migration (example)
|
|
||||||
uv run alembic revision --autogenerate -m "add foo"
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- The backend can also auto-run migrations on startup when `DB_AUTO_MIGRATE=true`.
|
|
||||||
- The database URL is normalized so `postgresql://...` becomes `postgresql+psycopg://...`.
|
|
||||||
|
|
||||||
## Running tests / lint / typecheck
|
|
||||||
|
|
||||||
From repo root (recommended):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make backend-test
|
|
||||||
make backend-lint
|
|
||||||
make backend-typecheck
|
|
||||||
make backend-coverage
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from `backend/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run pytest
|
|
||||||
uv run flake8 --config .flake8
|
|
||||||
uv run mypy
|
|
||||||
```
|
|
||||||
|
|
||||||
Formatting:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make backend-format
|
|
||||||
make backend-format-check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
Backend scripts live in `backend/scripts/`:
|
|
||||||
|
|
||||||
- `export_openapi.py` – export OpenAPI schema
|
|
||||||
- `seed_demo.py` – seed demo data (if applicable)
|
|
||||||
- `sync_gateway_templates.py` – sync repo templates to an existing gateway
|
|
||||||
|
|
||||||
Run with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run python scripts/export_openapi.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend can’t connect to Postgres
|
|
||||||
|
|
||||||
- If you started Postgres via compose, make sure it is healthy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose.yml --env-file .env ps
|
|
||||||
docker compose -f compose.yml --env-file .env logs -f --tail=200 db
|
|
||||||
```
|
|
||||||
|
|
||||||
- If backend runs **locally** (not in compose), `DATABASE_URL` should usually point at `localhost`.
|
|
||||||
|
|
||||||
### CORS issues from the frontend
|
|
||||||
|
|
||||||
- Set `CORS_ORIGINS=http://localhost:3000` (or a comma-separated list) in `backend/.env`.
|
|
||||||
- Restart the backend after changing env vars.
|
|
||||||
|
|
||||||
### Alembic / migrations not applying
|
|
||||||
|
|
||||||
- If you want deterministic behavior, run migrations manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
- If `DB_AUTO_MIGRATE=false`, the backend may use `create_all` instead of Alembic.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = migrations
|
|
||||||
prepend_sys_path = .
|
|
||||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = INFO
|
|
||||||
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 +0,0 @@
|
|||||||
"""OpenClaw Mission Control backend application package."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API router modules for the OpenClaw Mission Control backend."""
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"""Activity listing and task-comment feed endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from collections import deque
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
||||||
from sqlalchemy import asc, desc, func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_or_agent, require_org_member
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import async_session_maker, get_session
|
|
||||||
from app.models.activity_events import ActivityEvent
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.organizations import (
|
|
||||||
OrganizationContext,
|
|
||||||
get_active_membership,
|
|
||||||
list_accessible_board_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator, Sequence
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/activity", tags=["activity"])
|
|
||||||
|
|
||||||
SSE_SEEN_MAX = 2000
|
|
||||||
STREAM_POLL_SECONDS = 2
|
|
||||||
TASK_COMMENT_ROW_LEN = 4
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
BOARD_ID_QUERY = Query(default=None)
|
|
||||||
SINCE_QUERY = Query(default=None)
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_since(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
normalized = normalized.replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(normalized)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is not None:
|
|
||||||
return parsed.astimezone(UTC).replace(tzinfo=None)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_role(agent: Agent | None) -> str | None:
|
|
||||||
if agent is None:
|
|
||||||
return None
|
|
||||||
profile = agent.identity_profile
|
|
||||||
if not isinstance(profile, dict):
|
|
||||||
return None
|
|
||||||
raw = profile.get("role")
|
|
||||||
if isinstance(raw, str):
|
|
||||||
role = raw.strip()
|
|
||||||
return role or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _feed_item(
|
|
||||||
event: ActivityEvent,
|
|
||||||
task: Task,
|
|
||||||
board: Board,
|
|
||||||
agent: Agent | None,
|
|
||||||
) -> ActivityTaskCommentFeedItemRead:
|
|
||||||
return ActivityTaskCommentFeedItemRead(
|
|
||||||
id=event.id,
|
|
||||||
created_at=event.created_at,
|
|
||||||
message=event.message,
|
|
||||||
agent_id=event.agent_id,
|
|
||||||
agent_name=agent.name if agent else None,
|
|
||||||
agent_role=_agent_role(agent),
|
|
||||||
task_id=task.id,
|
|
||||||
task_title=task.title,
|
|
||||||
board_id=board.id,
|
|
||||||
board_name=board.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_task_comment_rows(
|
|
||||||
items: Sequence[Any],
|
|
||||||
) -> list[tuple[ActivityEvent, Task, Board, Agent | None]]:
|
|
||||||
rows: list[tuple[ActivityEvent, Task, Board, Agent | None]] = []
|
|
||||||
for item in items:
|
|
||||||
first: Any
|
|
||||||
second: Any
|
|
||||||
third: Any
|
|
||||||
fourth: Any
|
|
||||||
|
|
||||||
if isinstance(item, tuple):
|
|
||||||
if len(item) != TASK_COMMENT_ROW_LEN:
|
|
||||||
msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
|
|
||||||
raise TypeError(msg)
|
|
||||||
first, second, third, fourth = item
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
row_len = len(item)
|
|
||||||
first = item[0]
|
|
||||||
second = item[1]
|
|
||||||
third = item[2]
|
|
||||||
fourth = item[3]
|
|
||||||
except (IndexError, KeyError, TypeError):
|
|
||||||
msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
|
|
||||||
raise TypeError(msg) from None
|
|
||||||
if row_len != TASK_COMMENT_ROW_LEN:
|
|
||||||
msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
|
|
||||||
raise TypeError(msg)
|
|
||||||
|
|
||||||
if (
|
|
||||||
isinstance(first, ActivityEvent)
|
|
||||||
and isinstance(second, Task)
|
|
||||||
and isinstance(third, Board)
|
|
||||||
and (isinstance(fourth, Agent) or fourth is None)
|
|
||||||
):
|
|
||||||
rows.append((first, second, third, fourth))
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg = "Expected (ActivityEvent, Task, Board, Agent | None) rows"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_task_comment_events(
|
|
||||||
session: AsyncSession,
|
|
||||||
since: datetime,
|
|
||||||
*,
|
|
||||||
board_id: UUID | None = None,
|
|
||||||
) -> Sequence[tuple[ActivityEvent, Task, Board, Agent | None]]:
|
|
||||||
statement = (
|
|
||||||
select(ActivityEvent, Task, Board, Agent)
|
|
||||||
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
|
||||||
.join(Board, col(Task.board_id) == col(Board.id))
|
|
||||||
.outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id))
|
|
||||||
.where(col(ActivityEvent.event_type) == "task.comment")
|
|
||||||
.where(col(ActivityEvent.created_at) >= since)
|
|
||||||
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
|
|
||||||
.order_by(asc(col(ActivityEvent.created_at)))
|
|
||||||
)
|
|
||||||
if board_id is not None:
|
|
||||||
statement = statement.where(col(Task.board_id) == board_id)
|
|
||||||
return _coerce_task_comment_rows(list(await session.exec(statement)))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead])
|
|
||||||
async def list_activity(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> LimitOffsetPage[ActivityEventRead]:
|
|
||||||
"""List activity events visible to the calling actor."""
|
|
||||||
statement = select(ActivityEvent)
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
|
|
||||||
elif actor.actor_type == "user" and actor.user:
|
|
||||||
member = await get_active_membership(session, actor.user)
|
|
||||||
if member is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
board_ids = await list_accessible_board_ids(session, member=member, write=False)
|
|
||||||
if not board_ids:
|
|
||||||
statement = statement.where(col(ActivityEvent.id).is_(None))
|
|
||||||
else:
|
|
||||||
statement = statement.join(
|
|
||||||
Task,
|
|
||||||
col(ActivityEvent.task_id) == col(Task.id),
|
|
||||||
).where(col(Task.board_id).in_(board_ids))
|
|
||||||
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
|
|
||||||
return await paginate(session, statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/task-comments",
|
|
||||||
response_model=DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead],
|
|
||||||
)
|
|
||||||
async def list_task_comment_feed(
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[ActivityTaskCommentFeedItemRead]:
|
|
||||||
"""List task-comment feed items for accessible boards."""
|
|
||||||
statement = (
|
|
||||||
select(ActivityEvent, Task, Board, Agent)
|
|
||||||
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
|
||||||
.join(Board, col(Task.board_id) == col(Board.id))
|
|
||||||
.outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id))
|
|
||||||
.where(col(ActivityEvent.event_type) == "task.comment")
|
|
||||||
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
|
|
||||||
.order_by(desc(col(ActivityEvent.created_at)))
|
|
||||||
)
|
|
||||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
|
||||||
if board_id is not None:
|
|
||||||
if board_id not in set(board_ids):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
statement = statement.where(col(Task.board_id) == board_id)
|
|
||||||
elif board_ids:
|
|
||||||
statement = statement.where(col(Task.board_id).in_(board_ids))
|
|
||||||
else:
|
|
||||||
statement = statement.where(col(Task.id).is_(None))
|
|
||||||
|
|
||||||
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
|
||||||
rows = _coerce_task_comment_rows(items)
|
|
||||||
return [_feed_item(event, task, board, agent) for event, task, board, agent in rows]
|
|
||||||
|
|
||||||
return await paginate(session, statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/task-comments/stream")
|
|
||||||
async def stream_task_comment_feed(
|
|
||||||
request: Request,
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
db_session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream task-comment events for accessible boards."""
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
|
||||||
board_ids = await list_accessible_board_ids(
|
|
||||||
db_session,
|
|
||||||
member=ctx.member,
|
|
||||||
write=False,
|
|
||||||
)
|
|
||||||
allowed_ids = set(board_ids)
|
|
||||||
if board_id is not None and board_id not in allowed_ids:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
seen_ids: set[UUID] = set()
|
|
||||||
seen_queue: deque[UUID] = deque()
|
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
|
||||||
last_seen = since_dt
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
async with async_session_maker() as stream_session:
|
|
||||||
if board_id is not None:
|
|
||||||
rows = await _fetch_task_comment_events(
|
|
||||||
stream_session,
|
|
||||||
last_seen,
|
|
||||||
board_id=board_id,
|
|
||||||
)
|
|
||||||
elif allowed_ids:
|
|
||||||
rows = await _fetch_task_comment_events(stream_session, last_seen)
|
|
||||||
rows = [row for row in rows if row[1].board_id in allowed_ids]
|
|
||||||
else:
|
|
||||||
rows = []
|
|
||||||
for event, task, board, agent in rows:
|
|
||||||
event_id = event.id
|
|
||||||
if event_id in seen_ids:
|
|
||||||
continue
|
|
||||||
seen_ids.add(event_id)
|
|
||||||
seen_queue.append(event_id)
|
|
||||||
if len(seen_queue) > SSE_SEEN_MAX:
|
|
||||||
oldest = seen_queue.popleft()
|
|
||||||
seen_ids.discard(oldest)
|
|
||||||
last_seen = max(event.created_at, last_seen)
|
|
||||||
payload = {
|
|
||||||
"comment": _feed_item(
|
|
||||||
event,
|
|
||||||
task,
|
|
||||||
board,
|
|
||||||
agent,
|
|
||||||
).model_dump(mode="json"),
|
|
||||||
}
|
|
||||||
yield {"event": "comment", "data": json.dumps(payload)}
|
|
||||||
await asyncio.sleep(STREAM_POLL_SECONDS)
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator(), ping=15)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
|||||||
"""Thin API wrappers for async agent lifecycle operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin
|
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.schemas.agents import (
|
|
||||||
AgentCreate,
|
|
||||||
AgentHeartbeat,
|
|
||||||
AgentHeartbeatCreate,
|
|
||||||
AgentRead,
|
|
||||||
AgentUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.openclaw.provisioning_db import AgentLifecycleService, AgentUpdateOptions
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
||||||
|
|
||||||
BOARD_ID_QUERY = Query(default=None)
|
|
||||||
GATEWAY_ID_QUERY = Query(default=None)
|
|
||||||
SINCE_QUERY = Query(default=None)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _AgentUpdateParams:
|
|
||||||
force: bool
|
|
||||||
auth: AuthContext
|
|
||||||
ctx: OrganizationContext
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_update_params(
|
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> _AgentUpdateParams:
|
|
||||||
return _AgentUpdateParams(force=force, auth=auth, ctx=ctx)
|
|
||||||
|
|
||||||
|
|
||||||
AGENT_UPDATE_PARAMS_DEP = Depends(_agent_update_params)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[AgentRead])
|
|
||||||
async def list_agents(
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
gateway_id: UUID | None = GATEWAY_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> LimitOffsetPage[AgentRead]:
|
|
||||||
"""List agents visible to the active organization admin."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.list_agents(
|
|
||||||
board_id=board_id,
|
|
||||||
gateway_id=gateway_id,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def stream_agents(
|
|
||||||
request: Request,
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream agent updates as SSE events."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.stream_agents(
|
|
||||||
request=request,
|
|
||||||
board_id=board_id,
|
|
||||||
since=since,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AgentRead)
|
|
||||||
async def create_agent(
|
|
||||||
payload: AgentCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> AgentRead:
|
|
||||||
"""Create and provision an agent."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.create_agent(payload=payload, actor=actor)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{agent_id}", response_model=AgentRead)
|
|
||||||
async def get_agent(
|
|
||||||
agent_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> AgentRead:
|
|
||||||
"""Get a single agent by id."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.get_agent(agent_id=agent_id, ctx=ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{agent_id}", response_model=AgentRead)
|
|
||||||
async def update_agent(
|
|
||||||
agent_id: str,
|
|
||||||
payload: AgentUpdate,
|
|
||||||
params: _AgentUpdateParams = AGENT_UPDATE_PARAMS_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> AgentRead:
|
|
||||||
"""Update agent metadata and optionally reprovision."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.update_agent(
|
|
||||||
agent_id=agent_id,
|
|
||||||
payload=payload,
|
|
||||||
options=AgentUpdateOptions(
|
|
||||||
force=params.force,
|
|
||||||
user=params.auth.user,
|
|
||||||
context=params.ctx,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{agent_id}/heartbeat", response_model=AgentRead)
|
|
||||||
async def heartbeat_agent(
|
|
||||||
agent_id: str,
|
|
||||||
payload: AgentHeartbeat,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> AgentRead:
|
|
||||||
"""Record a heartbeat for a specific agent."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.heartbeat_agent(agent_id=agent_id, payload=payload, actor=actor)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/heartbeat", response_model=AgentRead)
|
|
||||||
async def heartbeat_or_create_agent(
|
|
||||||
payload: AgentHeartbeatCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> AgentRead:
|
|
||||||
"""Heartbeat an existing agent or create/provision one if needed."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.heartbeat_or_create_agent(payload=payload, actor=actor)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{agent_id}", response_model=OkResponse)
|
|
||||||
async def delete_agent(
|
|
||||||
agent_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete an agent and clean related task state."""
|
|
||||||
service = AgentLifecycleService(session)
|
|
||||||
return await service.delete_agent(agent_id=agent_id, ctx=ctx)
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
"""Approval listing, streaming, creation, and update endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
||||||
from sqlalchemy import asc, func, or_
|
|
||||||
from sqlmodel import col, select
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
from app.api.deps import (
|
|
||||||
ActorContext,
|
|
||||||
get_board_for_actor_read,
|
|
||||||
get_board_for_actor_write,
|
|
||||||
get_board_for_user_write,
|
|
||||||
require_admin_or_agent,
|
|
||||||
)
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import async_session_maker, get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.approvals import Approval
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.activity_log import record_activity
|
|
||||||
from app.services.approval_task_links import (
|
|
||||||
load_task_ids_by_approval,
|
|
||||||
lock_tasks_for_approval,
|
|
||||||
normalize_task_ids,
|
|
||||||
pending_approval_conflicts_by_task,
|
|
||||||
replace_approval_task_links,
|
|
||||||
task_counts_for_board,
|
|
||||||
)
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator, Sequence
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.models.boards import Board
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"])
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
STREAM_POLL_SECONDS = 2
|
|
||||||
STATUS_FILTER_QUERY = Query(default=None, alias="status")
|
|
||||||
SINCE_QUERY = Query(default=None)
|
|
||||||
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
|
||||||
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
|
|
||||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_since(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
normalized = normalized.replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(normalized)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is not None:
|
|
||||||
return parsed.astimezone(UTC).replace(tzinfo=None)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _approval_updated_at(approval: Approval) -> datetime:
|
|
||||||
return approval.resolved_at or approval.created_at
|
|
||||||
|
|
||||||
|
|
||||||
async def _approval_task_ids_map(
|
|
||||||
session: AsyncSession,
|
|
||||||
approvals: Sequence[Approval],
|
|
||||||
) -> dict[UUID, list[UUID]]:
|
|
||||||
approval_ids = [approval.id for approval in approvals]
|
|
||||||
mapping = await load_task_ids_by_approval(session, approval_ids=approval_ids)
|
|
||||||
for approval in approvals:
|
|
||||||
if mapping.get(approval.id):
|
|
||||||
continue
|
|
||||||
if approval.task_id is not None:
|
|
||||||
mapping[approval.id] = [approval.task_id]
|
|
||||||
else:
|
|
||||||
mapping[approval.id] = []
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
|
|
||||||
async def _task_titles_by_id(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
task_ids: set[UUID],
|
|
||||||
) -> dict[UUID, str]:
|
|
||||||
if not task_ids:
|
|
||||||
return {}
|
|
||||||
rows = list(
|
|
||||||
await session.exec(
|
|
||||||
select(col(Task.id), col(Task.title)).where(col(Task.id).in_(task_ids)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {task_id: title for task_id, title in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def _approval_to_read(
|
|
||||||
approval: Approval,
|
|
||||||
*,
|
|
||||||
task_ids: list[UUID],
|
|
||||||
task_titles: list[str],
|
|
||||||
) -> ApprovalRead:
|
|
||||||
primary_task_id = task_ids[0] if task_ids else None
|
|
||||||
model = ApprovalRead.model_validate(approval, from_attributes=True)
|
|
||||||
return model.model_copy(
|
|
||||||
update={
|
|
||||||
"task_id": primary_task_id,
|
|
||||||
"task_ids": task_ids,
|
|
||||||
"task_titles": task_titles,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _approval_reads(
|
|
||||||
session: AsyncSession,
|
|
||||||
approvals: Sequence[Approval],
|
|
||||||
) -> list[ApprovalRead]:
|
|
||||||
mapping = await _approval_task_ids_map(session, approvals)
|
|
||||||
title_by_id = await _task_titles_by_id(
|
|
||||||
session,
|
|
||||||
task_ids={task_id for task_ids in mapping.values() for task_id in task_ids},
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
_approval_to_read(
|
|
||||||
approval,
|
|
||||||
task_ids=(task_ids := mapping.get(approval.id, [])),
|
|
||||||
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
|
|
||||||
)
|
|
||||||
for approval in approvals
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_approval(approval: ApprovalRead) -> dict[str, object]:
|
|
||||||
return approval.model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
def _pending_conflict_detail(conflicts: dict[UUID, UUID]) -> dict[str, object]:
|
|
||||||
ordered = sorted(conflicts.items(), key=lambda item: str(item[0]))
|
|
||||||
return {
|
|
||||||
"message": "Each task can have only one pending approval.",
|
|
||||||
"conflicts": [
|
|
||||||
{
|
|
||||||
"task_id": str(task_id),
|
|
||||||
"approval_id": str(approval_id),
|
|
||||||
}
|
|
||||||
for task_id, approval_id in ordered
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_no_pending_approval_conflicts(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
board_id: UUID,
|
|
||||||
task_ids: Sequence[UUID],
|
|
||||||
exclude_approval_id: UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
normalized_task_ids = list({*task_ids})
|
|
||||||
if not normalized_task_ids:
|
|
||||||
return
|
|
||||||
await lock_tasks_for_approval(session, task_ids=normalized_task_ids)
|
|
||||||
conflicts = await pending_approval_conflicts_by_task(
|
|
||||||
session,
|
|
||||||
board_id=board_id,
|
|
||||||
task_ids=normalized_task_ids,
|
|
||||||
exclude_approval_id=exclude_approval_id,
|
|
||||||
)
|
|
||||||
if conflicts:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=_pending_conflict_detail(conflicts),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _approval_resolution_message(
|
|
||||||
*,
|
|
||||||
board: Board,
|
|
||||||
approval: Approval,
|
|
||||||
task_ids: Sequence[UUID] | None = None,
|
|
||||||
) -> str:
|
|
||||||
status_text = "approved" if approval.status == "approved" else "rejected"
|
|
||||||
lines = [
|
|
||||||
"APPROVAL RESOLVED",
|
|
||||||
f"Board: {board.name}",
|
|
||||||
f"Approval ID: {approval.id}",
|
|
||||||
f"Action: {approval.action_type}",
|
|
||||||
f"Decision: {status_text}",
|
|
||||||
f"Confidence: {approval.confidence}",
|
|
||||||
]
|
|
||||||
normalized_task_ids = list(task_ids or [])
|
|
||||||
if not normalized_task_ids and approval.task_id is not None:
|
|
||||||
normalized_task_ids = [approval.task_id]
|
|
||||||
if len(normalized_task_ids) == 1:
|
|
||||||
lines.append(f"Task ID: {normalized_task_ids[0]}")
|
|
||||||
elif normalized_task_ids:
|
|
||||||
lines.append(f"Task IDs: {', '.join(str(value) for value in normalized_task_ids)}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Take action: continue execution using the final approval decision.")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_board_lead(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
board_id: UUID,
|
|
||||||
) -> Agent | None:
|
|
||||||
return (
|
|
||||||
await Agent.objects.filter_by(board_id=board_id)
|
|
||||||
.filter(col(Agent.is_board_lead).is_(True))
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_lead_on_approval_resolution(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
approval: Approval,
|
|
||||||
) -> None:
|
|
||||||
if approval.status not in {"approved", "rejected"}:
|
|
||||||
return
|
|
||||||
lead = await _resolve_board_lead(session, board_id=board.id)
|
|
||||||
if lead is None or not lead.openclaw_session_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
dispatch = GatewayDispatchService(session)
|
|
||||||
config = await dispatch.optional_gateway_config_for_board(board)
|
|
||||||
if config is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
task_ids_by_approval = await load_task_ids_by_approval(session, approval_ids=[approval.id])
|
|
||||||
message = _approval_resolution_message(
|
|
||||||
board=board,
|
|
||||||
approval=approval,
|
|
||||||
task_ids=task_ids_by_approval.get(approval.id, []),
|
|
||||||
)
|
|
||||||
error = await dispatch.try_send_agent_message(
|
|
||||||
session_key=lead.openclaw_session_id,
|
|
||||||
config=config,
|
|
||||||
agent_name=lead.name,
|
|
||||||
message=message,
|
|
||||||
deliver=False,
|
|
||||||
)
|
|
||||||
if error is None:
|
|
||||||
record_activity(
|
|
||||||
session,
|
|
||||||
event_type="approval.lead_notified",
|
|
||||||
message=f"Lead agent notified for {approval.status} approval {approval.id}.",
|
|
||||||
agent_id=lead.id,
|
|
||||||
task_id=approval.task_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
record_activity(
|
|
||||||
session,
|
|
||||||
event_type="approval.lead_notify_failed",
|
|
||||||
message=f"Lead notify failed for approval {approval.id}: {error}",
|
|
||||||
agent_id=lead.id,
|
|
||||||
task_id=approval.task_id,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_approval_events(
|
|
||||||
session: AsyncSession,
|
|
||||||
board_id: UUID,
|
|
||||||
since: datetime,
|
|
||||||
) -> list[Approval]:
|
|
||||||
statement = (
|
|
||||||
Approval.objects.filter_by(board_id=board_id)
|
|
||||||
.filter(
|
|
||||||
or_(
|
|
||||||
col(Approval.created_at) >= since,
|
|
||||||
col(Approval.resolved_at) >= since,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by(asc(col(Approval.created_at)))
|
|
||||||
)
|
|
||||||
return await statement.all(session)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
|
|
||||||
async def list_approvals(
|
|
||||||
status_filter: ApprovalStatus | None = STATUS_FILTER_QUERY,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
_actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> LimitOffsetPage[ApprovalRead]:
|
|
||||||
"""List approvals for a board, optionally filtering by status."""
|
|
||||||
statement = Approval.objects.filter_by(board_id=board.id)
|
|
||||||
if status_filter:
|
|
||||||
statement = statement.filter(col(Approval.status) == status_filter)
|
|
||||||
statement = statement.order_by(col(Approval.created_at).desc())
|
|
||||||
|
|
||||||
async def _transform(items: Sequence[object]) -> Sequence[ApprovalRead]:
|
|
||||||
approvals: list[Approval] = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, Approval):
|
|
||||||
msg = "Expected Approval items from approvals pagination query."
|
|
||||||
raise TypeError(msg)
|
|
||||||
approvals.append(item)
|
|
||||||
return await _approval_reads(session, approvals)
|
|
||||||
|
|
||||||
return await paginate(session, statement.statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def stream_approvals(
|
|
||||||
request: Request,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
_actor: ActorContext = ACTOR_DEP,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream approval updates for a board using server-sent events."""
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
|
||||||
last_seen = since_dt
|
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
|
||||||
nonlocal last_seen
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
approvals = await _fetch_approval_events(session, board.id, last_seen)
|
|
||||||
approval_reads = await _approval_reads(session, approvals)
|
|
||||||
pending_approvals_count = int(
|
|
||||||
(
|
|
||||||
await session.exec(
|
|
||||||
select(func.count(col(Approval.id)))
|
|
||||||
.where(col(Approval.board_id) == board.id)
|
|
||||||
.where(col(Approval.status) == "pending"),
|
|
||||||
)
|
|
||||||
).one(),
|
|
||||||
)
|
|
||||||
task_ids = {
|
|
||||||
task_id
|
|
||||||
for approval_read in approval_reads
|
|
||||||
for task_id in approval_read.task_ids
|
|
||||||
}
|
|
||||||
counts_by_task_id = await task_counts_for_board(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
task_ids=task_ids,
|
|
||||||
)
|
|
||||||
for approval, approval_read in zip(approvals, approval_reads, strict=True):
|
|
||||||
updated_at = _approval_updated_at(approval)
|
|
||||||
last_seen = max(updated_at, last_seen)
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"approval": _serialize_approval(approval_read),
|
|
||||||
"pending_approvals_count": pending_approvals_count,
|
|
||||||
}
|
|
||||||
task_counts = [
|
|
||||||
{
|
|
||||||
"task_id": str(task_id),
|
|
||||||
"approvals_count": total,
|
|
||||||
"approvals_pending_count": pending,
|
|
||||||
}
|
|
||||||
for task_id in approval_read.task_ids
|
|
||||||
if (counts := counts_by_task_id.get(task_id)) is not None
|
|
||||||
for total, pending in [counts]
|
|
||||||
]
|
|
||||||
if len(task_counts) == 1:
|
|
||||||
payload["task_counts"] = task_counts[0]
|
|
||||||
elif task_counts:
|
|
||||||
payload["task_counts"] = task_counts
|
|
||||||
yield {"event": "approval", "data": json.dumps(payload)}
|
|
||||||
await asyncio.sleep(STREAM_POLL_SECONDS)
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator(), ping=15)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApprovalRead)
|
|
||||||
async def create_approval(
|
|
||||||
payload: ApprovalCreate,
|
|
||||||
board: Board = BOARD_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
_actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> ApprovalRead:
|
|
||||||
"""Create an approval for a board."""
|
|
||||||
task_ids = normalize_task_ids(
|
|
||||||
task_id=payload.task_id,
|
|
||||||
task_ids=payload.task_ids,
|
|
||||||
payload=payload.payload,
|
|
||||||
)
|
|
||||||
task_id = task_ids[0] if task_ids else None
|
|
||||||
if payload.status == "pending":
|
|
||||||
await _ensure_no_pending_approval_conflicts(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
task_ids=task_ids,
|
|
||||||
)
|
|
||||||
approval = Approval(
|
|
||||||
board_id=board.id,
|
|
||||||
task_id=task_id,
|
|
||||||
agent_id=payload.agent_id,
|
|
||||||
action_type=payload.action_type,
|
|
||||||
payload=payload.payload,
|
|
||||||
confidence=payload.confidence,
|
|
||||||
rubric_scores=payload.rubric_scores,
|
|
||||||
status=payload.status,
|
|
||||||
)
|
|
||||||
session.add(approval)
|
|
||||||
await session.flush()
|
|
||||||
await replace_approval_task_links(
|
|
||||||
session,
|
|
||||||
approval_id=approval.id,
|
|
||||||
task_ids=task_ids,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(approval)
|
|
||||||
title_by_id = await _task_titles_by_id(session, task_ids=set(task_ids))
|
|
||||||
return _approval_to_read(
|
|
||||||
approval,
|
|
||||||
task_ids=task_ids,
|
|
||||||
task_titles=[title_by_id[task_id] for task_id in task_ids if task_id in title_by_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{approval_id}", response_model=ApprovalRead)
|
|
||||||
async def update_approval(
|
|
||||||
approval_id: str,
|
|
||||||
payload: ApprovalUpdate,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> ApprovalRead:
|
|
||||||
"""Update an approval's status and resolution timestamp."""
|
|
||||||
approval = await Approval.objects.by_id(approval_id).first(session)
|
|
||||||
if approval is None or approval.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
prior_status = approval.status
|
|
||||||
if "status" in updates:
|
|
||||||
target_status = updates["status"]
|
|
||||||
if target_status == "pending" and prior_status != "pending":
|
|
||||||
task_ids_by_approval = await load_task_ids_by_approval(
|
|
||||||
session, approval_ids=[approval.id]
|
|
||||||
)
|
|
||||||
approval_task_ids = task_ids_by_approval.get(approval.id)
|
|
||||||
if not approval_task_ids and approval.task_id is not None:
|
|
||||||
approval_task_ids = [approval.task_id]
|
|
||||||
await _ensure_no_pending_approval_conflicts(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
task_ids=approval_task_ids or [],
|
|
||||||
exclude_approval_id=approval.id,
|
|
||||||
)
|
|
||||||
approval.status = target_status
|
|
||||||
if approval.status != "pending":
|
|
||||||
approval.resolved_at = utcnow()
|
|
||||||
session.add(approval)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(approval)
|
|
||||||
if approval.status in {"approved", "rejected"} and approval.status != prior_status:
|
|
||||||
try:
|
|
||||||
await _notify_lead_on_approval_resolution(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
approval=approval,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"approval.lead_notify_unexpected board_id=%s approval_id=%s status=%s",
|
|
||||||
board.id,
|
|
||||||
approval.id,
|
|
||||||
approval.status,
|
|
||||||
)
|
|
||||||
reads = await _approval_reads(session, [approval])
|
|
||||||
return reads[0]
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Authentication bootstrap endpoints for the Mission Control API."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.schemas.errors import LLMErrorResponse
|
|
||||||
from app.schemas.users import UserRead
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/bootstrap",
|
|
||||||
response_model=UserRead,
|
|
||||||
summary="Bootstrap Authenticated User Context",
|
|
||||||
description=(
|
|
||||||
"Resolve caller identity from auth headers and return the canonical user profile. "
|
|
||||||
"This endpoint does not accept a request body."
|
|
||||||
),
|
|
||||||
responses={
|
|
||||||
status.HTTP_200_OK: {
|
|
||||||
"description": "Authenticated user profile resolved from token claims.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {
|
|
||||||
"id": "11111111-1111-1111-1111-111111111111",
|
|
||||||
"clerk_user_id": "user_2abcXYZ",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"name": "Alex Chen",
|
|
||||||
"preferred_name": "Alex",
|
|
||||||
"pronouns": "they/them",
|
|
||||||
"timezone": "America/Los_Angeles",
|
|
||||||
"notes": "Primary operator for board triage.",
|
|
||||||
"context": "Handles incident coordination and escalation.",
|
|
||||||
"is_super_admin": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status.HTTP_401_UNAUTHORIZED: {
|
|
||||||
"model": LLMErrorResponse,
|
|
||||||
"description": "Caller is not authenticated as a user actor.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"example": {
|
|
||||||
"detail": {"code": "unauthorized", "message": "Not authenticated"},
|
|
||||||
"code": "unauthorized",
|
|
||||||
"retryable": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def bootstrap_user(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
|
||||||
"""Return the authenticated user profile from token claims."""
|
|
||||||
if auth.actor_type != "user" or auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
return UserRead.model_validate(auth.user)
|
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
"""Board-group memory CRUD and streaming endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, cast
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
from app.api.deps import (
|
|
||||||
ActorContext,
|
|
||||||
get_board_for_actor_read,
|
|
||||||
get_board_for_actor_write,
|
|
||||||
require_admin_or_agent,
|
|
||||||
require_org_member,
|
|
||||||
)
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import async_session_maker, get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.board_group_memory import BoardGroupMemory
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.users import User
|
|
||||||
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
from app.services.organizations import (
|
|
||||||
is_org_admin,
|
|
||||||
list_accessible_board_ids,
|
|
||||||
member_all_boards_read,
|
|
||||||
member_all_boards_write,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
|
|
||||||
router = APIRouter(tags=["board-group-memory"])
|
|
||||||
group_router = APIRouter(
|
|
||||||
prefix="/board-groups/{group_id}/memory",
|
|
||||||
tags=["board-group-memory"],
|
|
||||||
)
|
|
||||||
board_router = APIRouter(
|
|
||||||
prefix="/boards/{board_id}/group-memory",
|
|
||||||
tags=["board-group-memory"],
|
|
||||||
)
|
|
||||||
MAX_SNIPPET_LENGTH = 800
|
|
||||||
STREAM_POLL_SECONDS = 2
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
|
||||||
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
IS_CHAT_QUERY = Query(default=None)
|
|
||||||
SINCE_QUERY = Query(default=None)
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
|
||||||
AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_group_memory_openapi_hints(
|
|
||||||
*,
|
|
||||||
intent: str,
|
|
||||||
when_to_use: list[str],
|
|
||||||
routing_examples: list[dict[str, object]],
|
|
||||||
required_actor: str = "any_agent",
|
|
||||||
when_not_to_use: list[str] | None = None,
|
|
||||||
routing_policy: list[str] | None = None,
|
|
||||||
negative_guidance: list[str] | None = None,
|
|
||||||
prerequisites: list[str] | None = None,
|
|
||||||
side_effects: list[str] | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"x-llm-intent": intent,
|
|
||||||
"x-when-to-use": when_to_use,
|
|
||||||
"x-when-not-to-use": when_not_to_use
|
|
||||||
or [
|
|
||||||
"Use a more specific endpoint when targeting a single actor or broadcast scope.",
|
|
||||||
],
|
|
||||||
"x-required-actor": required_actor,
|
|
||||||
"x-prerequisites": prerequisites
|
|
||||||
or [
|
|
||||||
"Authenticated actor token",
|
|
||||||
"Accessible board context",
|
|
||||||
],
|
|
||||||
"x-side-effects": side_effects
|
|
||||||
or ["Persisted memory visibility changes may be observable across linked boards."],
|
|
||||||
"x-negative-guidance": negative_guidance
|
|
||||||
or [
|
|
||||||
"Do not use as a replacement for direct task-specific commentary.",
|
|
||||||
"Do not assume infinite retention when group storage policies apply.",
|
|
||||||
],
|
|
||||||
"x-routing-policy": routing_policy
|
|
||||||
or [
|
|
||||||
"Use when board context requires shared memory discovery or posting.",
|
|
||||||
"Prefer narrow board endpoints for one-off lead/agent coordination needs.",
|
|
||||||
],
|
|
||||||
"x-routing-policy-examples": routing_examples,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_since(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
normalized = normalized.replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(normalized)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is not None:
|
|
||||||
return parsed.astimezone(UTC).replace(tzinfo=None)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_memory(memory: BoardGroupMemory) -> dict[str, object]:
|
|
||||||
return BoardGroupMemoryRead.model_validate(
|
|
||||||
memory,
|
|
||||||
from_attributes=True,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_memory_events(
|
|
||||||
session: AsyncSession,
|
|
||||||
board_group_id: UUID,
|
|
||||||
since: datetime,
|
|
||||||
is_chat: bool | None = None,
|
|
||||||
) -> list[BoardGroupMemory]:
|
|
||||||
statement = (
|
|
||||||
BoardGroupMemory.objects.filter_by(board_group_id=board_group_id)
|
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
|
||||||
# satisfy the NonEmptyStr response schema.
|
|
||||||
.filter(func.length(func.trim(col(BoardGroupMemory.content))) > 0)
|
|
||||||
)
|
|
||||||
if is_chat is not None:
|
|
||||||
statement = statement.filter(col(BoardGroupMemory.is_chat) == is_chat)
|
|
||||||
statement = statement.filter(col(BoardGroupMemory.created_at) >= since).order_by(
|
|
||||||
col(BoardGroupMemory.created_at),
|
|
||||||
)
|
|
||||||
return await statement.all(session)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_group_access(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
group_id: UUID,
|
|
||||||
ctx: OrganizationContext,
|
|
||||||
write: bool,
|
|
||||||
) -> BoardGroup:
|
|
||||||
group = await BoardGroup.objects.by_id(group_id).first(session)
|
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if group.organization_id != ctx.member.organization_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
if write and member_all_boards_write(ctx.member):
|
|
||||||
return group
|
|
||||||
if not write and member_all_boards_read(ctx.member):
|
|
||||||
return group
|
|
||||||
|
|
||||||
board_ids = [
|
|
||||||
board.id
|
|
||||||
for board in await Board.objects.filter_by(board_group_id=group_id).all(
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if not board_ids:
|
|
||||||
if is_org_admin(ctx.member):
|
|
||||||
return group
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
allowed_ids = await list_accessible_board_ids(
|
|
||||||
session,
|
|
||||||
member=ctx.member,
|
|
||||||
write=write,
|
|
||||||
)
|
|
||||||
if not set(board_ids).intersection(set(allowed_ids)):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
async def _group_read_access(
|
|
||||||
group_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> BoardGroup:
|
|
||||||
return await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
|
|
||||||
|
|
||||||
|
|
||||||
GROUP_READ_DEP = Depends(_group_read_access)
|
|
||||||
|
|
||||||
|
|
||||||
def _group_chat_targets(
|
|
||||||
*,
|
|
||||||
agents: list[Agent],
|
|
||||||
actor: ActorContext,
|
|
||||||
is_broadcast: bool,
|
|
||||||
mentions: set[str],
|
|
||||||
) -> dict[str, Agent]:
|
|
||||||
targets: dict[str, Agent] = {}
|
|
||||||
for agent in agents:
|
|
||||||
if not agent.openclaw_session_id:
|
|
||||||
continue
|
|
||||||
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
|
|
||||||
continue
|
|
||||||
if is_broadcast or agent.is_board_lead:
|
|
||||||
targets[str(agent.id)] = agent
|
|
||||||
continue
|
|
||||||
if mentions and matches_agent_mention(agent, mentions):
|
|
||||||
targets[str(agent.id)] = agent
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _group_actor_name(actor: ActorContext) -> str:
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
return actor.agent.name
|
|
||||||
if actor.user:
|
|
||||||
return actor.user.preferred_name or actor.user.name or "User"
|
|
||||||
return "User"
|
|
||||||
|
|
||||||
|
|
||||||
def _group_header(*, is_broadcast: bool, mentioned: bool) -> str:
|
|
||||||
if is_broadcast:
|
|
||||||
return "GROUP BROADCAST"
|
|
||||||
if mentioned:
|
|
||||||
return "GROUP CHAT MENTION"
|
|
||||||
return "GROUP CHAT"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _NotifyGroupContext:
|
|
||||||
session: AsyncSession
|
|
||||||
dispatch: GatewayDispatchService
|
|
||||||
group: BoardGroup
|
|
||||||
board_by_id: dict[UUID, Board]
|
|
||||||
mentions: set[str]
|
|
||||||
is_broadcast: bool
|
|
||||||
actor_name: str
|
|
||||||
snippet: str
|
|
||||||
base_url: str
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_group_target(
|
|
||||||
context: _NotifyGroupContext,
|
|
||||||
agent: Agent,
|
|
||||||
) -> None:
|
|
||||||
session_key = agent.openclaw_session_id
|
|
||||||
board_id = agent.board_id
|
|
||||||
if not session_key or board_id is None:
|
|
||||||
return
|
|
||||||
board = context.board_by_id.get(board_id)
|
|
||||||
if board is None:
|
|
||||||
return
|
|
||||||
config = await context.dispatch.optional_gateway_config_for_board(board)
|
|
||||||
if config is None:
|
|
||||||
return
|
|
||||||
header = _group_header(
|
|
||||||
is_broadcast=context.is_broadcast,
|
|
||||||
mentioned=matches_agent_mention(agent, context.mentions),
|
|
||||||
)
|
|
||||||
message = (
|
|
||||||
f"{header}\n"
|
|
||||||
f"Group: {context.group.name}\n"
|
|
||||||
f"From: {context.actor_name}\n\n"
|
|
||||||
f"{context.snippet}\n\n"
|
|
||||||
"Reply via group chat (shared across linked boards):\n"
|
|
||||||
f"POST {context.base_url}/api/v1/boards/{board.id}/group-memory\n"
|
|
||||||
'Body: {"content":"...","tags":["chat"]}'
|
|
||||||
)
|
|
||||||
error = await context.dispatch.try_send_agent_message(
|
|
||||||
session_key=session_key,
|
|
||||||
config=config,
|
|
||||||
agent_name=agent.name,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
if error is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_group_memory_targets(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
group: BoardGroup,
|
|
||||||
memory: BoardGroupMemory,
|
|
||||||
actor: ActorContext,
|
|
||||||
) -> None:
|
|
||||||
if not memory.content:
|
|
||||||
return
|
|
||||||
|
|
||||||
tags = set(memory.tags or [])
|
|
||||||
mentions = extract_mentions(memory.content)
|
|
||||||
is_broadcast = "broadcast" in tags or "all" in mentions
|
|
||||||
|
|
||||||
# Fetch group boards + agents.
|
|
||||||
boards = await Board.objects.filter_by(board_group_id=group.id).all(session)
|
|
||||||
if not boards:
|
|
||||||
return
|
|
||||||
board_by_id = {board.id: board for board in boards}
|
|
||||||
board_ids = list(board_by_id.keys())
|
|
||||||
agents = await Agent.objects.by_field_in("board_id", board_ids).all(session)
|
|
||||||
|
|
||||||
targets = _group_chat_targets(
|
|
||||||
agents=agents,
|
|
||||||
actor=actor,
|
|
||||||
is_broadcast=is_broadcast,
|
|
||||||
mentions=mentions,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not targets:
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_name = _group_actor_name(actor)
|
|
||||||
|
|
||||||
snippet = memory.content.strip()
|
|
||||||
if len(snippet) > MAX_SNIPPET_LENGTH:
|
|
||||||
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
|
|
||||||
|
|
||||||
base_url = settings.base_url or "http://localhost:8000"
|
|
||||||
|
|
||||||
context = _NotifyGroupContext(
|
|
||||||
session=session,
|
|
||||||
dispatch=GatewayDispatchService(session),
|
|
||||||
group=group,
|
|
||||||
board_by_id=board_by_id,
|
|
||||||
mentions=mentions,
|
|
||||||
is_broadcast=is_broadcast,
|
|
||||||
actor_name=actor_name,
|
|
||||||
snippet=snippet,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
for agent in targets.values():
|
|
||||||
await _notify_group_target(context, agent)
|
|
||||||
|
|
||||||
|
|
||||||
@group_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
|
|
||||||
async def list_board_group_memory(
|
|
||||||
group_id: UUID,
|
|
||||||
*,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardGroupMemoryRead]:
|
|
||||||
"""List board-group memory entries for a specific group."""
|
|
||||||
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
|
|
||||||
statement = (
|
|
||||||
BoardGroupMemory.objects.filter_by(board_group_id=group_id)
|
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
|
||||||
# satisfy the NonEmptyStr response schema.
|
|
||||||
.filter(func.length(func.trim(col(BoardGroupMemory.content))) > 0)
|
|
||||||
)
|
|
||||||
if is_chat is not None:
|
|
||||||
statement = statement.filter(col(BoardGroupMemory.is_chat) == is_chat)
|
|
||||||
statement = statement.order_by(col(BoardGroupMemory.created_at).desc())
|
|
||||||
return await paginate(session, statement.statement)
|
|
||||||
|
|
||||||
|
|
||||||
@group_router.get("/stream")
|
|
||||||
async def stream_board_group_memory(
|
|
||||||
request: Request,
|
|
||||||
group: BoardGroup = GROUP_READ_DEP,
|
|
||||||
*,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream memory entries for a board group via server-sent events."""
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
|
||||||
last_seen = since_dt
|
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
|
||||||
nonlocal last_seen
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
async with async_session_maker() as s:
|
|
||||||
memories = await _fetch_memory_events(
|
|
||||||
s,
|
|
||||||
group.id,
|
|
||||||
last_seen,
|
|
||||||
is_chat=is_chat,
|
|
||||||
)
|
|
||||||
for memory in memories:
|
|
||||||
last_seen = max(memory.created_at, last_seen)
|
|
||||||
payload = {"memory": _serialize_memory(memory)}
|
|
||||||
yield {"event": "memory", "data": json.dumps(payload)}
|
|
||||||
await asyncio.sleep(STREAM_POLL_SECONDS)
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator(), ping=15)
|
|
||||||
|
|
||||||
|
|
||||||
@group_router.post("", response_model=BoardGroupMemoryRead)
|
|
||||||
async def create_board_group_memory(
|
|
||||||
group_id: UUID,
|
|
||||||
payload: BoardGroupMemoryCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> BoardGroupMemory:
|
|
||||||
"""Create a board-group memory entry and notify chat recipients."""
|
|
||||||
group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True)
|
|
||||||
|
|
||||||
user = await User.objects.by_id(ctx.member.user_id).first(session)
|
|
||||||
actor = ActorContext(actor_type="user", user=user)
|
|
||||||
tags = set(payload.tags or [])
|
|
||||||
is_chat = "chat" in tags
|
|
||||||
mentions = extract_mentions(payload.content)
|
|
||||||
should_notify = is_chat or "broadcast" in tags or "all" in mentions
|
|
||||||
source = payload.source
|
|
||||||
if should_notify and not source:
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
source = actor.agent.name
|
|
||||||
elif actor.user:
|
|
||||||
source = actor.user.preferred_name or actor.user.name or "User"
|
|
||||||
memory = BoardGroupMemory(
|
|
||||||
board_group_id=group_id,
|
|
||||||
content=payload.content,
|
|
||||||
tags=payload.tags,
|
|
||||||
is_chat=is_chat,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
session.add(memory)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(memory)
|
|
||||||
if should_notify:
|
|
||||||
await _notify_group_memory_targets(
|
|
||||||
session=session,
|
|
||||||
group=group,
|
|
||||||
memory=memory,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
return memory
|
|
||||||
|
|
||||||
|
|
||||||
@board_router.get(
|
|
||||||
"",
|
|
||||||
response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead],
|
|
||||||
tags=AGENT_BOARD_ROLE_TAGS,
|
|
||||||
openapi_extra=_agent_group_memory_openapi_hints(
|
|
||||||
intent="agent_board_group_memory_discovery",
|
|
||||||
when_to_use=[
|
|
||||||
"Inspect shared group memory for cross-board context before making decisions.",
|
|
||||||
"Collect active chat snapshots for a linked group before coordination actions.",
|
|
||||||
],
|
|
||||||
routing_examples=[
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"intent": "recover recent team memory for task framing",
|
|
||||||
"required_privilege": "agent_lead_or_worker",
|
|
||||||
},
|
|
||||||
"decision": "agent_board_group_memory_discovery",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
side_effects=["No persisted side effects."],
|
|
||||||
routing_policy=[
|
|
||||||
"Use as a shared-context discovery step before decisioning.",
|
|
||||||
"Use board-specific memory endpoints for direct board persistence updates.",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def list_board_group_memory_for_board(
|
|
||||||
*,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardGroupMemoryRead]:
|
|
||||||
"""List shared memory for the board's linked group.
|
|
||||||
|
|
||||||
Use this for cross-board context and coordination signals.
|
|
||||||
"""
|
|
||||||
group_id = board.board_group_id
|
|
||||||
if group_id is None:
|
|
||||||
return await paginate(session, BoardGroupMemory.objects.by_ids([]).statement)
|
|
||||||
|
|
||||||
queryset = (
|
|
||||||
BoardGroupMemory.objects.filter_by(board_group_id=group_id)
|
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
|
||||||
# satisfy the NonEmptyStr response schema.
|
|
||||||
.filter(func.length(func.trim(col(BoardGroupMemory.content))) > 0)
|
|
||||||
)
|
|
||||||
if is_chat is not None:
|
|
||||||
queryset = queryset.filter(col(BoardGroupMemory.is_chat) == is_chat)
|
|
||||||
queryset = queryset.order_by(col(BoardGroupMemory.created_at).desc())
|
|
||||||
return await paginate(session, queryset.statement)
|
|
||||||
|
|
||||||
|
|
||||||
@board_router.get(
|
|
||||||
"/stream",
|
|
||||||
tags=AGENT_BOARD_ROLE_TAGS,
|
|
||||||
openapi_extra=_agent_group_memory_openapi_hints(
|
|
||||||
intent="agent_board_group_memory_stream",
|
|
||||||
when_to_use=[
|
|
||||||
"Track shared group memory updates in near-real-time for live coordination.",
|
|
||||||
"React to newly added group messages without polling.",
|
|
||||||
],
|
|
||||||
routing_examples=[
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"intent": "subscribe to group memory updates for routing",
|
|
||||||
"required_privilege": "agent_lead_or_worker",
|
|
||||||
},
|
|
||||||
"decision": "agent_board_group_memory_stream",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
side_effects=["No persisted side effects, streaming updates are read-only."],
|
|
||||||
routing_policy=[
|
|
||||||
"Use when coordinated decisions need continuous group context.",
|
|
||||||
"Prefer bounded history reads when a snapshot is sufficient.",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def stream_board_group_memory_for_board(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream linked-group memory via SSE for near-real-time coordination."""
|
|
||||||
group_id = board.board_group_id
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
|
||||||
last_seen = since_dt
|
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
|
||||||
nonlocal last_seen
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
if group_id is None:
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
continue
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
memories = await _fetch_memory_events(
|
|
||||||
session,
|
|
||||||
group_id,
|
|
||||||
last_seen,
|
|
||||||
is_chat=is_chat,
|
|
||||||
)
|
|
||||||
for memory in memories:
|
|
||||||
last_seen = max(memory.created_at, last_seen)
|
|
||||||
payload = {"memory": _serialize_memory(memory)}
|
|
||||||
yield {"event": "memory", "data": json.dumps(payload)}
|
|
||||||
await asyncio.sleep(STREAM_POLL_SECONDS)
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator(), ping=15)
|
|
||||||
|
|
||||||
|
|
||||||
@board_router.post(
|
|
||||||
"",
|
|
||||||
response_model=BoardGroupMemoryRead,
|
|
||||||
tags=AGENT_BOARD_ROLE_TAGS,
|
|
||||||
openapi_extra=_agent_group_memory_openapi_hints(
|
|
||||||
intent="agent_board_group_memory_record",
|
|
||||||
when_to_use=[
|
|
||||||
"Persist shared group memory for a linked group from board context.",
|
|
||||||
"Broadcast updates/messages to group-linked agents when chat or mention intent is present.",
|
|
||||||
],
|
|
||||||
routing_examples=[
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"intent": "share coordination signal in group memory",
|
|
||||||
"required_privilege": "board_agent",
|
|
||||||
},
|
|
||||||
"decision": "agent_board_group_memory_record",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
side_effects=[
|
|
||||||
"Persist new group-memory entries with optional agent notification dispatch."
|
|
||||||
],
|
|
||||||
routing_policy=[
|
|
||||||
"Use for shared memory writes that should be visible across linked boards.",
|
|
||||||
"Prefer direct board memory endpoints for board-local persistence.",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def create_board_group_memory_for_board(
|
|
||||||
payload: BoardGroupMemoryCreate,
|
|
||||||
board: Board = BOARD_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> BoardGroupMemory:
|
|
||||||
"""Create shared group memory from a board context.
|
|
||||||
|
|
||||||
When tags/mentions indicate chat or broadcast intent, eligible agents in the
|
|
||||||
linked group are notified.
|
|
||||||
"""
|
|
||||||
group_id = board.board_group_id
|
|
||||||
if group_id is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="Board is not in a board group",
|
|
||||||
)
|
|
||||||
group = await BoardGroup.objects.by_id(group_id).first(session)
|
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
tags = set(payload.tags or [])
|
|
||||||
is_chat = "chat" in tags
|
|
||||||
mentions = extract_mentions(payload.content)
|
|
||||||
should_notify = is_chat or "broadcast" in tags or "all" in mentions
|
|
||||||
source = payload.source
|
|
||||||
if should_notify and not source:
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
source = actor.agent.name
|
|
||||||
elif actor.user:
|
|
||||||
source = actor.user.preferred_name or actor.user.name or "User"
|
|
||||||
memory = BoardGroupMemory(
|
|
||||||
board_group_id=group_id,
|
|
||||||
content=payload.content,
|
|
||||||
tags=payload.tags,
|
|
||||||
is_chat=is_chat,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
session.add(memory)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(memory)
|
|
||||||
if should_notify:
|
|
||||||
await _notify_group_memory_targets(
|
|
||||||
session=session,
|
|
||||||
group=group,
|
|
||||||
memory=memory,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
return memory
|
|
||||||
|
|
||||||
|
|
||||||
router.include_router(group_router)
|
|
||||||
router.include_router(board_router)
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
"""Board group CRUD, snapshot, and heartbeat endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin, require_org_member
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.board_group_memory import BoardGroupMemory
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.schemas.board_group_heartbeat import (
|
|
||||||
BoardGroupHeartbeatApply,
|
|
||||||
BoardGroupHeartbeatApplyResult,
|
|
||||||
)
|
|
||||||
from app.schemas.board_groups import BoardGroupCreate, BoardGroupRead, BoardGroupUpdate
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.schemas.view_models import BoardGroupSnapshot
|
|
||||||
from app.services.board_group_snapshot import build_group_snapshot
|
|
||||||
from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG
|
|
||||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
|
||||||
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
|
|
||||||
from app.services.organizations import (
|
|
||||||
OrganizationContext,
|
|
||||||
board_access_filter,
|
|
||||||
get_member,
|
|
||||||
is_org_admin,
|
|
||||||
list_accessible_board_ids,
|
|
||||||
member_all_boards_read,
|
|
||||||
member_all_boards_write,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.models.organization_members import OrganizationMember
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
|
|
||||||
|
|
||||||
def _slugify(value: str) -> str:
|
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
||||||
return slug or uuid4().hex
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_group_access(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
group_id: UUID,
|
|
||||||
member: OrganizationMember,
|
|
||||||
write: bool,
|
|
||||||
) -> BoardGroup:
|
|
||||||
group = await BoardGroup.objects.by_id(group_id).first(session)
|
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if group.organization_id != member.organization_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
if write and member_all_boards_write(member):
|
|
||||||
return group
|
|
||||||
if not write and member_all_boards_read(member):
|
|
||||||
return group
|
|
||||||
|
|
||||||
board_ids = [
|
|
||||||
board.id for board in await Board.objects.filter_by(board_group_id=group_id).all(session)
|
|
||||||
]
|
|
||||||
if not board_ids:
|
|
||||||
if is_org_admin(member):
|
|
||||||
return group
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
allowed_ids = await list_accessible_board_ids(session, member=member, write=write)
|
|
||||||
if not set(board_ids).intersection(set(allowed_ids)):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
|
|
||||||
async def list_board_groups(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardGroupRead]:
|
|
||||||
"""List board groups in the active organization."""
|
|
||||||
if member_all_boards_read(ctx.member):
|
|
||||||
statement = select(BoardGroup).where(
|
|
||||||
col(BoardGroup.organization_id) == ctx.organization.id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
accessible_boards = select(Board.board_group_id).where(
|
|
||||||
board_access_filter(ctx.member, write=False),
|
|
||||||
)
|
|
||||||
statement = select(BoardGroup).where(
|
|
||||||
col(BoardGroup.organization_id) == ctx.organization.id,
|
|
||||||
col(BoardGroup.id).in_(accessible_boards),
|
|
||||||
)
|
|
||||||
statement = statement.order_by(func.lower(col(BoardGroup.name)).asc())
|
|
||||||
return await paginate(session, statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BoardGroupRead)
|
|
||||||
async def create_board_group(
|
|
||||||
payload: BoardGroupCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> BoardGroup:
|
|
||||||
"""Create a board group in the active organization."""
|
|
||||||
data = payload.model_dump()
|
|
||||||
if not (data.get("slug") or "").strip():
|
|
||||||
data["slug"] = _slugify(data.get("name") or "")
|
|
||||||
data["organization_id"] = ctx.organization.id
|
|
||||||
return await crud.create(session, BoardGroup, **data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{group_id}", response_model=BoardGroupRead)
|
|
||||||
async def get_board_group(
|
|
||||||
group_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> BoardGroup:
|
|
||||||
"""Get a board group by id."""
|
|
||||||
return await _require_group_access(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
member=ctx.member,
|
|
||||||
write=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
|
|
||||||
async def get_board_group_snapshot(
|
|
||||||
group_id: UUID,
|
|
||||||
*,
|
|
||||||
include_done: bool = False,
|
|
||||||
per_board_task_limit: int = 5,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> BoardGroupSnapshot:
|
|
||||||
"""Get a snapshot across boards in a group."""
|
|
||||||
group = await _require_group_access(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
member=ctx.member,
|
|
||||||
write=False,
|
|
||||||
)
|
|
||||||
if per_board_task_limit < 0:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
|
||||||
snapshot = await build_group_snapshot(
|
|
||||||
session,
|
|
||||||
group=group,
|
|
||||||
exclude_board_id=None,
|
|
||||||
include_done=include_done,
|
|
||||||
per_board_task_limit=per_board_task_limit,
|
|
||||||
)
|
|
||||||
if not member_all_boards_read(ctx.member) and snapshot.boards:
|
|
||||||
allowed_ids = set(
|
|
||||||
await list_accessible_board_ids(session, member=ctx.member, write=False),
|
|
||||||
)
|
|
||||||
snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids]
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
async def _authorize_heartbeat_actor(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
group_id: UUID,
|
|
||||||
group: BoardGroup,
|
|
||||||
actor: ActorContext,
|
|
||||||
) -> None:
|
|
||||||
if actor.actor_type == "user":
|
|
||||||
if actor.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
member = await get_member(
|
|
||||||
session,
|
|
||||||
user_id=actor.user.id,
|
|
||||||
organization_id=group.organization_id,
|
|
||||||
)
|
|
||||||
if member is None or not is_org_admin(member):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
await _require_group_access(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
member=member,
|
|
||||||
write=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
agent = actor.agent
|
|
||||||
if agent is None or agent.board_id is None or not agent.is_board_lead:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
board = await Board.objects.by_id(agent.board_id).first(session)
|
|
||||||
if board is None or board.board_group_id != group_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
|
|
||||||
async def _agents_for_group_heartbeat(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
group_id: UUID,
|
|
||||||
include_board_leads: bool,
|
|
||||||
) -> tuple[dict[UUID, Board], list[Agent]]:
|
|
||||||
boards = await Board.objects.filter_by(board_group_id=group_id).all(session)
|
|
||||||
board_by_id = {board.id: board for board in boards}
|
|
||||||
board_ids = list(board_by_id.keys())
|
|
||||||
if not board_ids:
|
|
||||||
return board_by_id, []
|
|
||||||
agents = await Agent.objects.by_field_in("board_id", board_ids).all(session)
|
|
||||||
if not include_board_leads:
|
|
||||||
agents = [agent for agent in agents if not agent.is_board_lead]
|
|
||||||
return board_by_id, agents
|
|
||||||
|
|
||||||
|
|
||||||
def _update_agent_heartbeat(
|
|
||||||
*,
|
|
||||||
agent: Agent,
|
|
||||||
payload: BoardGroupHeartbeatApply,
|
|
||||||
) -> None:
|
|
||||||
raw = agent.heartbeat_config
|
|
||||||
heartbeat: dict[str, Any] = DEFAULT_HEARTBEAT_CONFIG.copy()
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
heartbeat.update(raw)
|
|
||||||
heartbeat["every"] = payload.every
|
|
||||||
heartbeat["target"] = DEFAULT_HEARTBEAT_CONFIG.get("target", "last")
|
|
||||||
agent.heartbeat_config = heartbeat
|
|
||||||
agent.updated_at = utcnow()
|
|
||||||
|
|
||||||
|
|
||||||
async def _sync_gateway_heartbeats(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
board_by_id: dict[UUID, Board],
|
|
||||||
agents: list[Agent],
|
|
||||||
) -> list[UUID]:
|
|
||||||
agents_by_gateway_id: dict[UUID, list[Agent]] = {}
|
|
||||||
for agent in agents:
|
|
||||||
board_id = agent.board_id
|
|
||||||
if board_id is None:
|
|
||||||
continue
|
|
||||||
board = board_by_id.get(board_id)
|
|
||||||
if board is None or board.gateway_id is None:
|
|
||||||
continue
|
|
||||||
agents_by_gateway_id.setdefault(board.gateway_id, []).append(agent)
|
|
||||||
|
|
||||||
failed_agent_ids: list[UUID] = []
|
|
||||||
gateway_ids = list(agents_by_gateway_id.keys())
|
|
||||||
gateways = await Gateway.objects.by_ids(gateway_ids).all(session)
|
|
||||||
gateway_by_id = {gateway.id: gateway for gateway in gateways}
|
|
||||||
for gateway_id, gateway_agents in agents_by_gateway_id.items():
|
|
||||||
gateway = gateway_by_id.get(gateway_id)
|
|
||||||
if gateway is None or not gateway.url or not gateway.workspace_root:
|
|
||||||
failed_agent_ids.extend([agent.id for agent in gateway_agents])
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await OpenClawGatewayProvisioner().sync_gateway_agent_heartbeats(
|
|
||||||
gateway,
|
|
||||||
gateway_agents,
|
|
||||||
)
|
|
||||||
except OpenClawGatewayError:
|
|
||||||
failed_agent_ids.extend([agent.id for agent in gateway_agents])
|
|
||||||
return failed_agent_ids
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
|
|
||||||
async def apply_board_group_heartbeat(
|
|
||||||
group_id: UUID,
|
|
||||||
payload: BoardGroupHeartbeatApply,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> BoardGroupHeartbeatApplyResult:
|
|
||||||
"""Apply heartbeat settings to agents in a board group."""
|
|
||||||
group = await BoardGroup.objects.by_id(group_id).first(session)
|
|
||||||
if group is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
await _authorize_heartbeat_actor(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
group=group,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
board_by_id, agents = await _agents_for_group_heartbeat(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
include_board_leads=payload.include_board_leads,
|
|
||||||
)
|
|
||||||
if not agents:
|
|
||||||
return BoardGroupHeartbeatApplyResult(
|
|
||||||
board_group_id=group_id,
|
|
||||||
requested=payload.model_dump(mode="json"),
|
|
||||||
updated_agent_ids=[],
|
|
||||||
failed_agent_ids=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_agent_ids: list[UUID] = []
|
|
||||||
for agent in agents:
|
|
||||||
_update_agent_heartbeat(agent=agent, payload=payload)
|
|
||||||
session.add(agent)
|
|
||||||
updated_agent_ids.append(agent.id)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
failed_agent_ids = await _sync_gateway_heartbeats(
|
|
||||||
session,
|
|
||||||
board_by_id=board_by_id,
|
|
||||||
agents=agents,
|
|
||||||
)
|
|
||||||
|
|
||||||
return BoardGroupHeartbeatApplyResult(
|
|
||||||
board_group_id=group_id,
|
|
||||||
requested=payload.model_dump(mode="json"),
|
|
||||||
updated_agent_ids=updated_agent_ids,
|
|
||||||
failed_agent_ids=failed_agent_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{group_id}", response_model=BoardGroupRead)
|
|
||||||
async def update_board_group(
|
|
||||||
payload: BoardGroupUpdate,
|
|
||||||
group_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> BoardGroup:
|
|
||||||
"""Update a board group."""
|
|
||||||
group = await _require_group_access(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
member=ctx.member,
|
|
||||||
write=True,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
|
|
||||||
updates["slug"] = _slugify(updates.get("name") or group.name)
|
|
||||||
updates["updated_at"] = utcnow()
|
|
||||||
return await crud.patch(session, group, updates)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{group_id}", response_model=OkResponse)
|
|
||||||
async def delete_board_group(
|
|
||||||
group_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete a board group."""
|
|
||||||
await _require_group_access(
|
|
||||||
session,
|
|
||||||
group_id=group_id,
|
|
||||||
member=ctx.member,
|
|
||||||
write=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Boards reference groups, so clear the FK first to keep deletes simple.
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
Board,
|
|
||||||
col(Board.board_group_id) == group_id,
|
|
||||||
board_group_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroupMemory,
|
|
||||||
col(BoardGroupMemory.board_group_id) == group_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroup,
|
|
||||||
col(BoardGroup.id) == group_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
"""Board memory CRUD and streaming endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col
|
|
||||||
from sse_starlette.sse import EventSourceResponse
|
|
||||||
|
|
||||||
from app.api.deps import (
|
|
||||||
ActorContext,
|
|
||||||
get_board_for_actor_read,
|
|
||||||
get_board_for_actor_write,
|
|
||||||
require_admin_or_agent,
|
|
||||||
)
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import async_session_maker, get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.board_memory import BoardMemory
|
|
||||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.models.boards import Board
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"])
|
|
||||||
MAX_SNIPPET_LENGTH = 800
|
|
||||||
STREAM_POLL_SECONDS = 2
|
|
||||||
IS_CHAT_QUERY = Query(default=None)
|
|
||||||
SINCE_QUERY = Query(default=None)
|
|
||||||
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
|
||||||
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_since(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
normalized = value.strip()
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
normalized = normalized.replace("Z", "+00:00")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(normalized)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is not None:
|
|
||||||
return parsed.astimezone(UTC).replace(tzinfo=None)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_memory(memory: BoardMemory) -> dict[str, object]:
|
|
||||||
return BoardMemoryRead.model_validate(
|
|
||||||
memory,
|
|
||||||
from_attributes=True,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_memory_events(
|
|
||||||
session: AsyncSession,
|
|
||||||
board_id: UUID,
|
|
||||||
since: datetime,
|
|
||||||
is_chat: bool | None = None,
|
|
||||||
) -> list[BoardMemory]:
|
|
||||||
statement = (
|
|
||||||
BoardMemory.objects.filter_by(board_id=board_id)
|
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
|
||||||
# satisfy the NonEmptyStr response schema.
|
|
||||||
.filter(func.length(func.trim(col(BoardMemory.content))) > 0)
|
|
||||||
)
|
|
||||||
if is_chat is not None:
|
|
||||||
statement = statement.filter(col(BoardMemory.is_chat) == is_chat)
|
|
||||||
statement = statement.filter(col(BoardMemory.created_at) >= since).order_by(
|
|
||||||
col(BoardMemory.created_at),
|
|
||||||
)
|
|
||||||
return await statement.all(session)
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_control_command(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
actor: ActorContext,
|
|
||||||
dispatch: GatewayDispatchService,
|
|
||||||
config: GatewayClientConfig,
|
|
||||||
command: str,
|
|
||||||
) -> None:
|
|
||||||
pause_targets: list[Agent] = await Agent.objects.filter_by(
|
|
||||||
board_id=board.id,
|
|
||||||
).all(
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
for agent in pause_targets:
|
|
||||||
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
|
|
||||||
continue
|
|
||||||
if not agent.openclaw_session_id:
|
|
||||||
continue
|
|
||||||
error = await dispatch.try_send_agent_message(
|
|
||||||
session_key=agent.openclaw_session_id,
|
|
||||||
config=config,
|
|
||||||
agent_name=agent.name,
|
|
||||||
message=command,
|
|
||||||
deliver=True,
|
|
||||||
)
|
|
||||||
if error is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_targets(
|
|
||||||
*,
|
|
||||||
agents: list[Agent],
|
|
||||||
mentions: set[str],
|
|
||||||
actor: ActorContext,
|
|
||||||
) -> dict[str, Agent]:
|
|
||||||
targets: dict[str, Agent] = {}
|
|
||||||
for agent in agents:
|
|
||||||
if agent.is_board_lead:
|
|
||||||
targets[str(agent.id)] = agent
|
|
||||||
continue
|
|
||||||
if mentions and matches_agent_mention(agent, mentions):
|
|
||||||
targets[str(agent.id)] = agent
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
targets.pop(str(actor.agent.id), None)
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_display_name(actor: ActorContext) -> str:
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
return actor.agent.name
|
|
||||||
if actor.user:
|
|
||||||
return actor.user.preferred_name or actor.user.name or "User"
|
|
||||||
return "User"
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_chat_targets(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
memory: BoardMemory,
|
|
||||||
actor: ActorContext,
|
|
||||||
) -> None:
|
|
||||||
if not memory.content:
|
|
||||||
return
|
|
||||||
dispatch = GatewayDispatchService(session)
|
|
||||||
config = await dispatch.optional_gateway_config_for_board(board)
|
|
||||||
if config is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
normalized = memory.content.strip()
|
|
||||||
command = normalized.lower()
|
|
||||||
# Special-case control commands to reach all board agents.
|
|
||||||
# These are intended to be parsed verbatim by agent runtimes.
|
|
||||||
if command in {"/pause", "/resume"}:
|
|
||||||
await _send_control_command(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
actor=actor,
|
|
||||||
dispatch=dispatch,
|
|
||||||
config=config,
|
|
||||||
command=command,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
mentions = extract_mentions(memory.content)
|
|
||||||
targets = _chat_targets(
|
|
||||||
agents=await Agent.objects.filter_by(board_id=board.id).all(session),
|
|
||||||
mentions=mentions,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
if not targets:
|
|
||||||
return
|
|
||||||
actor_name = _actor_display_name(actor)
|
|
||||||
snippet = memory.content.strip()
|
|
||||||
if len(snippet) > MAX_SNIPPET_LENGTH:
|
|
||||||
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
|
|
||||||
base_url = settings.base_url or "http://localhost:8000"
|
|
||||||
for agent in targets.values():
|
|
||||||
if not agent.openclaw_session_id:
|
|
||||||
continue
|
|
||||||
mentioned = matches_agent_mention(agent, mentions)
|
|
||||||
header = "BOARD CHAT MENTION" if mentioned else "BOARD CHAT"
|
|
||||||
message = (
|
|
||||||
f"{header}\n"
|
|
||||||
f"Board: {board.name}\n"
|
|
||||||
f"From: {actor_name}\n\n"
|
|
||||||
f"{snippet}\n\n"
|
|
||||||
"Reply via board chat:\n"
|
|
||||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
|
||||||
'Body: {"content":"...","tags":["chat"]}'
|
|
||||||
)
|
|
||||||
error = await dispatch.try_send_agent_message(
|
|
||||||
session_key=agent.openclaw_session_id,
|
|
||||||
config=config,
|
|
||||||
agent_name=agent.name,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
if error is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
|
|
||||||
async def list_board_memory(
|
|
||||||
*,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
_actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardMemoryRead]:
|
|
||||||
"""List board memory entries, optionally filtering chat entries."""
|
|
||||||
statement = (
|
|
||||||
BoardMemory.objects.filter_by(board_id=board.id)
|
|
||||||
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
|
|
||||||
# satisfy the NonEmptyStr response schema.
|
|
||||||
.filter(func.length(func.trim(col(BoardMemory.content))) > 0)
|
|
||||||
)
|
|
||||||
if is_chat is not None:
|
|
||||||
statement = statement.filter(col(BoardMemory.is_chat) == is_chat)
|
|
||||||
statement = statement.order_by(col(BoardMemory.created_at).desc())
|
|
||||||
return await paginate(session, statement.statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream")
|
|
||||||
async def stream_board_memory(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
_actor: ActorContext = ACTOR_DEP,
|
|
||||||
since: str | None = SINCE_QUERY,
|
|
||||||
is_chat: bool | None = IS_CHAT_QUERY,
|
|
||||||
) -> EventSourceResponse:
|
|
||||||
"""Stream board memory events over server-sent events."""
|
|
||||||
since_dt = _parse_since(since) or utcnow()
|
|
||||||
last_seen = since_dt
|
|
||||||
|
|
||||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
|
||||||
nonlocal last_seen
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
break
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
memories = await _fetch_memory_events(
|
|
||||||
session,
|
|
||||||
board.id,
|
|
||||||
last_seen,
|
|
||||||
is_chat=is_chat,
|
|
||||||
)
|
|
||||||
for memory in memories:
|
|
||||||
last_seen = max(memory.created_at, last_seen)
|
|
||||||
payload = {"memory": _serialize_memory(memory)}
|
|
||||||
yield {"event": "memory", "data": json.dumps(payload)}
|
|
||||||
await asyncio.sleep(STREAM_POLL_SECONDS)
|
|
||||||
|
|
||||||
return EventSourceResponse(event_generator(), ping=15)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BoardMemoryRead)
|
|
||||||
async def create_board_memory(
|
|
||||||
payload: BoardMemoryCreate,
|
|
||||||
board: Board = BOARD_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> BoardMemory:
|
|
||||||
"""Create a board memory entry and notify chat targets when needed."""
|
|
||||||
is_chat = payload.tags is not None and "chat" in payload.tags
|
|
||||||
source = payload.source
|
|
||||||
if is_chat and not source:
|
|
||||||
if actor.actor_type == "agent" and actor.agent:
|
|
||||||
source = actor.agent.name
|
|
||||||
elif actor.user:
|
|
||||||
source = actor.user.preferred_name or actor.user.name or "User"
|
|
||||||
memory = BoardMemory(
|
|
||||||
board_id=board.id,
|
|
||||||
content=payload.content,
|
|
||||||
tags=payload.tags,
|
|
||||||
is_chat=is_chat,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
session.add(memory)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(memory)
|
|
||||||
if is_chat:
|
|
||||||
await _notify_chat_targets(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
memory=memory,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
return memory
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
"""Board onboarding endpoints for user/agent collaboration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from sqlmodel import col
|
|
||||||
|
|
||||||
from app.api.deps import (
|
|
||||||
ActorContext,
|
|
||||||
get_board_for_user_read,
|
|
||||||
get_board_for_user_write,
|
|
||||||
get_board_or_404,
|
|
||||||
require_admin_auth,
|
|
||||||
require_admin_or_agent,
|
|
||||||
)
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.board_onboarding import BoardOnboardingSession
|
|
||||||
from app.schemas.board_onboarding import (
|
|
||||||
BoardOnboardingAgentComplete,
|
|
||||||
BoardOnboardingAgentUpdate,
|
|
||||||
BoardOnboardingAnswer,
|
|
||||||
BoardOnboardingConfirm,
|
|
||||||
BoardOnboardingLeadAgentDraft,
|
|
||||||
BoardOnboardingRead,
|
|
||||||
BoardOnboardingStart,
|
|
||||||
BoardOnboardingUserProfile,
|
|
||||||
)
|
|
||||||
from app.schemas.boards import BoardRead
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
from app.services.openclaw.gateway_resolver import get_gateway_for_board
|
|
||||||
from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService
|
|
||||||
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
|
||||||
from app.services.openclaw.provisioning_db import (
|
|
||||||
LeadAgentOptions,
|
|
||||||
LeadAgentRequest,
|
|
||||||
OpenClawProvisioningService,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.core.auth import AuthContext
|
|
||||||
from app.models.boards import Board
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
|
||||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
||||||
BOARD_OR_404_DEP = Depends(get_board_or_404)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
ADMIN_AUTH_DEP = Depends(require_admin_auth)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_draft_user_profile(
|
|
||||||
draft_goal: object,
|
|
||||||
) -> BoardOnboardingUserProfile | None:
|
|
||||||
if not isinstance(draft_goal, dict):
|
|
||||||
return None
|
|
||||||
raw_profile = draft_goal.get("user_profile")
|
|
||||||
if raw_profile is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return BoardOnboardingUserProfile.model_validate(raw_profile)
|
|
||||||
except ValidationError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_draft_lead_agent(
|
|
||||||
draft_goal: object,
|
|
||||||
) -> BoardOnboardingLeadAgentDraft | None:
|
|
||||||
if not isinstance(draft_goal, dict):
|
|
||||||
return None
|
|
||||||
raw_lead = draft_goal.get("lead_agent")
|
|
||||||
if raw_lead is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return BoardOnboardingLeadAgentDraft.model_validate(raw_lead)
|
|
||||||
except ValidationError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_autonomy_token(value: object) -> str | None:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
text = value.strip().lower()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
return text.replace("_", "-")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fully_autonomous_choice(value: object) -> bool:
|
|
||||||
token = _normalize_autonomy_token(value)
|
|
||||||
if token is None:
|
|
||||||
return False
|
|
||||||
if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
|
|
||||||
return True
|
|
||||||
return "autonom" in token and "fully" in token
|
|
||||||
|
|
||||||
|
|
||||||
def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
|
|
||||||
"""Enable done-approval gate unless onboarding selected fully autonomous mode."""
|
|
||||||
if not isinstance(draft_goal, dict):
|
|
||||||
return True
|
|
||||||
raw_lead = draft_goal.get("lead_agent")
|
|
||||||
if not isinstance(raw_lead, dict):
|
|
||||||
return True
|
|
||||||
if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
|
|
||||||
return False
|
|
||||||
raw_identity_profile = raw_lead.get("identity_profile")
|
|
||||||
if isinstance(raw_identity_profile, dict):
|
|
||||||
for key in ("autonomy_level", "autonomy", "mode"):
|
|
||||||
if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_user_profile(
|
|
||||||
auth: AuthContext,
|
|
||||||
profile: BoardOnboardingUserProfile | None,
|
|
||||||
) -> bool:
|
|
||||||
if auth.user is None or profile is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if profile.preferred_name is not None:
|
|
||||||
auth.user.preferred_name = profile.preferred_name
|
|
||||||
changed = True
|
|
||||||
if profile.pronouns is not None:
|
|
||||||
auth.user.pronouns = profile.pronouns
|
|
||||||
changed = True
|
|
||||||
if profile.timezone is not None:
|
|
||||||
auth.user.timezone = profile.timezone
|
|
||||||
changed = True
|
|
||||||
if profile.notes is not None:
|
|
||||||
auth.user.notes = profile.notes
|
|
||||||
changed = True
|
|
||||||
if profile.context is not None:
|
|
||||||
auth.user.context = profile.context
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def _lead_agent_options(
|
|
||||||
lead_agent: BoardOnboardingLeadAgentDraft | None,
|
|
||||||
) -> LeadAgentOptions:
|
|
||||||
if lead_agent is None:
|
|
||||||
return LeadAgentOptions(action="provision")
|
|
||||||
|
|
||||||
lead_identity_profile: dict[str, str] = {}
|
|
||||||
if lead_agent.identity_profile:
|
|
||||||
lead_identity_profile.update(lead_agent.identity_profile)
|
|
||||||
if lead_agent.autonomy_level:
|
|
||||||
lead_identity_profile["autonomy_level"] = lead_agent.autonomy_level
|
|
||||||
if lead_agent.verbosity:
|
|
||||||
lead_identity_profile["verbosity"] = lead_agent.verbosity
|
|
||||||
if lead_agent.output_format:
|
|
||||||
lead_identity_profile["output_format"] = lead_agent.output_format
|
|
||||||
if lead_agent.update_cadence:
|
|
||||||
lead_identity_profile["update_cadence"] = lead_agent.update_cadence
|
|
||||||
if lead_agent.custom_instructions:
|
|
||||||
lead_identity_profile["custom_instructions"] = lead_agent.custom_instructions
|
|
||||||
|
|
||||||
return LeadAgentOptions(
|
|
||||||
agent_name=lead_agent.name,
|
|
||||||
identity_profile=lead_identity_profile or None,
|
|
||||||
action="provision",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=BoardOnboardingRead)
|
|
||||||
async def get_onboarding(
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardOnboardingSession:
|
|
||||||
"""Get the latest onboarding session for a board."""
|
|
||||||
onboarding = (
|
|
||||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
||||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if onboarding is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return onboarding
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", response_model=BoardOnboardingRead)
|
|
||||||
async def start_onboarding(
|
|
||||||
_payload: BoardOnboardingStart,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardOnboardingSession:
|
|
||||||
"""Start onboarding and send instructions to the gateway agent."""
|
|
||||||
onboarding = (
|
|
||||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
||||||
.filter(col(BoardOnboardingSession.status) == "active")
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if onboarding:
|
|
||||||
last_user_content: str | None = None
|
|
||||||
messages = onboarding.messages or []
|
|
||||||
if messages:
|
|
||||||
last_message = messages[-1]
|
|
||||||
if isinstance(last_message, dict):
|
|
||||||
last_role = last_message.get("role")
|
|
||||||
content = last_message.get("content")
|
|
||||||
if last_role == "user" and isinstance(content, str) and content:
|
|
||||||
last_user_content = content
|
|
||||||
|
|
||||||
if last_user_content:
|
|
||||||
# Retrigger the agent when the session is waiting on a response.
|
|
||||||
dispatcher = BoardOnboardingMessagingService(session)
|
|
||||||
await dispatcher.dispatch_answer(
|
|
||||||
board=board,
|
|
||||||
onboarding=onboarding,
|
|
||||||
answer_text=last_user_content,
|
|
||||||
correlation_id=f"onboarding.resume:{board.id}:{onboarding.id}",
|
|
||||||
)
|
|
||||||
onboarding.updated_at = utcnow()
|
|
||||||
session.add(onboarding)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(onboarding)
|
|
||||||
return onboarding
|
|
||||||
|
|
||||||
dispatcher = BoardOnboardingMessagingService(session)
|
|
||||||
base_url = settings.base_url or "http://localhost:8000"
|
|
||||||
prompt = (
|
|
||||||
"BOARD ONBOARDING REQUEST\n\n"
|
|
||||||
f"Board Name: {board.name}\n"
|
|
||||||
f"Board Description: {board.description or '(not provided)'}\n"
|
|
||||||
"You are the gateway agent. Ask the user 6-10 focused questions total:\n"
|
|
||||||
"- 3-6 questions to clarify the board goal.\n"
|
|
||||||
"- 1 question to choose a unique name for the board lead agent "
|
|
||||||
"(first-name style).\n"
|
|
||||||
"- 2-4 questions to capture the user's preferences for how the board "
|
|
||||||
"lead should work\n"
|
|
||||||
" (communication style, autonomy, update cadence, and output formatting).\n"
|
|
||||||
'- Always include a final question (and only once): "Anything else we '
|
|
||||||
'should know?"\n'
|
|
||||||
" (constraints, context, preferences). This MUST be the last question.\n"
|
|
||||||
' Provide an option like "Yes (I\'ll type it)" so they can enter free-text.\n'
|
|
||||||
" Do NOT ask for additional context on earlier questions.\n"
|
|
||||||
" Only include a free-text option on earlier questions if a typed "
|
|
||||||
"answer is necessary;\n"
|
|
||||||
' when you do, make the option label include "I\'ll type it" '
|
|
||||||
'(e.g., "Other (I\'ll type it)").\n'
|
|
||||||
'- If the user sends an "Additional context" message later, incorporate '
|
|
||||||
"it and resend status=complete\n"
|
|
||||||
" to update the draft (until the user confirms).\n"
|
|
||||||
"Do NOT respond in OpenClaw chat.\n"
|
|
||||||
"All onboarding responses MUST be sent to Mission Control via API.\n"
|
|
||||||
f"Mission Control base URL: {base_url}\n"
|
|
||||||
"Use the AUTH_TOKEN from USER.md or TOOLS.md and pass it as X-Agent-Token.\n"
|
|
||||||
"Onboarding response endpoint:\n"
|
|
||||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/onboarding\n"
|
|
||||||
"QUESTION example (send JSON body exactly as shown):\n"
|
|
||||||
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
|
|
||||||
'-H "X-Agent-Token: $AUTH_TOKEN" '
|
|
||||||
'-H "Content-Type: application/json" '
|
|
||||||
'-d \'{"question":"...","options":[{"id":"1","label":"..."},'
|
|
||||||
'{"id":"2","label":"..."}]}\'\n'
|
|
||||||
"COMPLETION example (send JSON body exactly as shown):\n"
|
|
||||||
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
|
|
||||||
'-H "X-Agent-Token: $AUTH_TOKEN" '
|
|
||||||
'-H "Content-Type: application/json" '
|
|
||||||
'-d \'{"status":"complete","board_type":"goal","objective":"...",'
|
|
||||||
'"success_metrics":{"metric":"...","target":"..."},'
|
|
||||||
'"target_date":"YYYY-MM-DD",'
|
|
||||||
'"user_profile":{"preferred_name":"...","pronouns":"...",'
|
|
||||||
'"timezone":"...","notes":"...","context":"..."},'
|
|
||||||
'"lead_agent":{"name":"Ava","identity_profile":{"role":"Board Lead",'
|
|
||||||
'"communication_style":"direct, concise, practical","emoji":":gear:"},'
|
|
||||||
'"autonomy_level":"balanced","verbosity":"concise",'
|
|
||||||
'"output_format":"bullets","update_cadence":"daily",'
|
|
||||||
'"custom_instructions":"..."}}\'\n'
|
|
||||||
"ENUMS:\n"
|
|
||||||
"- board_type: goal | general\n"
|
|
||||||
"- lead_agent.autonomy_level: ask_first | balanced | autonomous\n"
|
|
||||||
"- lead_agent.verbosity: concise | balanced | detailed\n"
|
|
||||||
"- lead_agent.output_format: bullets | mixed | narrative\n"
|
|
||||||
"- lead_agent.update_cadence: asap | hourly | daily | weekly\n"
|
|
||||||
"QUESTION FORMAT (one question per response, no arrays, no markdown, "
|
|
||||||
"no extra text):\n"
|
|
||||||
'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\n'
|
|
||||||
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
|
|
||||||
"When you have enough info, send one final response with status=complete.\n"
|
|
||||||
"The completion payload must include board_type. If board_type=goal, "
|
|
||||||
"include objective + success_metrics.\n"
|
|
||||||
"Also include user_profile + lead_agent to configure the board lead's "
|
|
||||||
"working style.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
session_key = await dispatcher.dispatch_start_prompt(
|
|
||||||
board=board,
|
|
||||||
prompt=prompt,
|
|
||||||
correlation_id=f"onboarding.start:{board.id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
onboarding = BoardOnboardingSession(
|
|
||||||
board_id=board.id,
|
|
||||||
session_key=session_key,
|
|
||||||
status="active",
|
|
||||||
messages=[
|
|
||||||
{"role": "user", "content": prompt, "timestamp": utcnow().isoformat()},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
session.add(onboarding)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(onboarding)
|
|
||||||
return onboarding
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/answer", response_model=BoardOnboardingRead)
|
|
||||||
async def answer_onboarding(
|
|
||||||
payload: BoardOnboardingAnswer,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardOnboardingSession:
|
|
||||||
"""Send a user onboarding answer to the gateway agent."""
|
|
||||||
onboarding = (
|
|
||||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
||||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if onboarding is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
dispatcher = BoardOnboardingMessagingService(session)
|
|
||||||
answer_text = payload.answer
|
|
||||||
if payload.other_text:
|
|
||||||
answer_text = f"{payload.answer}: {payload.other_text}"
|
|
||||||
|
|
||||||
messages = list(onboarding.messages or [])
|
|
||||||
messages.append(
|
|
||||||
{"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()},
|
|
||||||
)
|
|
||||||
|
|
||||||
await dispatcher.dispatch_answer(
|
|
||||||
board=board,
|
|
||||||
onboarding=onboarding,
|
|
||||||
answer_text=answer_text,
|
|
||||||
correlation_id=f"onboarding.answer:{board.id}:{onboarding.id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
onboarding.messages = messages
|
|
||||||
onboarding.updated_at = utcnow()
|
|
||||||
session.add(onboarding)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(onboarding)
|
|
||||||
return onboarding
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/agent", response_model=BoardOnboardingRead)
|
|
||||||
async def agent_onboarding_update(
|
|
||||||
payload: BoardOnboardingAgentUpdate,
|
|
||||||
board: Board = BOARD_OR_404_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> BoardOnboardingSession:
|
|
||||||
"""Store onboarding updates submitted by the gateway agent."""
|
|
||||||
if actor.actor_type != "agent" or actor.agent is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
agent = actor.agent
|
|
||||||
OpenClawAuthorizationPolicy.require_gateway_scoped_actor(actor_agent=agent)
|
|
||||||
|
|
||||||
gateway = await get_gateway_for_board(session, board)
|
|
||||||
if gateway is not None:
|
|
||||||
OpenClawAuthorizationPolicy.require_gateway_main_actor_binding(
|
|
||||||
actor_agent=agent,
|
|
||||||
gateway=gateway,
|
|
||||||
)
|
|
||||||
|
|
||||||
onboarding = (
|
|
||||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
||||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if onboarding is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if onboarding.status == "confirmed":
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
|
||||||
|
|
||||||
messages = list(onboarding.messages or [])
|
|
||||||
now = utcnow().isoformat()
|
|
||||||
payload_text = payload.model_dump_json(exclude_none=True)
|
|
||||||
payload_data = payload.model_dump(mode="json", exclude_none=True)
|
|
||||||
logger.info(
|
|
||||||
"onboarding.agent.update board_id=%s agent_id=%s payload=%s",
|
|
||||||
board.id,
|
|
||||||
agent.id,
|
|
||||||
payload_text,
|
|
||||||
)
|
|
||||||
if isinstance(payload, BoardOnboardingAgentComplete):
|
|
||||||
onboarding.draft_goal = payload_data
|
|
||||||
onboarding.status = "completed"
|
|
||||||
messages.append(
|
|
||||||
{"role": "assistant", "content": payload_text, "timestamp": now},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
messages.append(
|
|
||||||
{"role": "assistant", "content": payload_text, "timestamp": now},
|
|
||||||
)
|
|
||||||
|
|
||||||
onboarding.messages = messages
|
|
||||||
onboarding.updated_at = utcnow()
|
|
||||||
session.add(onboarding)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(onboarding)
|
|
||||||
logger.info(
|
|
||||||
"onboarding.agent.update stored board_id=%s messages_count=%s status=%s",
|
|
||||||
board.id,
|
|
||||||
len(onboarding.messages or []),
|
|
||||||
onboarding.status,
|
|
||||||
)
|
|
||||||
return onboarding
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/confirm", response_model=BoardRead)
|
|
||||||
async def confirm_onboarding(
|
|
||||||
payload: BoardOnboardingConfirm,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = ADMIN_AUTH_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Confirm onboarding results and provision the board lead agent."""
|
|
||||||
onboarding = (
|
|
||||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
||||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if onboarding is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
board.board_type = payload.board_type
|
|
||||||
board.objective = payload.objective
|
|
||||||
board.success_metrics = payload.success_metrics
|
|
||||||
board.target_date = payload.target_date
|
|
||||||
board.goal_confirmed = True
|
|
||||||
board.goal_source = "lead_agent_onboarding"
|
|
||||||
board.require_approval_for_done = _require_approval_for_done_from_draft(
|
|
||||||
onboarding.draft_goal,
|
|
||||||
)
|
|
||||||
|
|
||||||
onboarding.status = "confirmed"
|
|
||||||
onboarding.updated_at = utcnow()
|
|
||||||
|
|
||||||
user_profile = _parse_draft_user_profile(onboarding.draft_goal)
|
|
||||||
if _apply_user_profile(auth, user_profile) and auth.user is not None:
|
|
||||||
session.add(auth.user)
|
|
||||||
|
|
||||||
lead_agent = _parse_draft_lead_agent(onboarding.draft_goal)
|
|
||||||
lead_options = _lead_agent_options(lead_agent)
|
|
||||||
|
|
||||||
gateway, config = await GatewayDispatchService(session).require_gateway_config_for_board(board)
|
|
||||||
session.add(board)
|
|
||||||
session.add(onboarding)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(board)
|
|
||||||
await OpenClawProvisioningService(session).ensure_board_lead_agent(
|
|
||||||
request=LeadAgentRequest(
|
|
||||||
board=board,
|
|
||||||
gateway=gateway,
|
|
||||||
config=config,
|
|
||||||
user=auth.user,
|
|
||||||
options=lead_options,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return board
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
"""Board webhook configuration and inbound payload ingestion endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import get_board_for_user_read, get_board_for_user_write, get_board_or_404
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.board_memory import BoardMemory
|
|
||||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
|
||||||
from app.models.board_webhooks import BoardWebhook
|
|
||||||
from app.schemas.board_webhooks import (
|
|
||||||
BoardWebhookCreate,
|
|
||||||
BoardWebhookIngestResponse,
|
|
||||||
BoardWebhookPayloadRead,
|
|
||||||
BoardWebhookRead,
|
|
||||||
BoardWebhookUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
from app.services.webhooks.queue import QueuedInboundDelivery, enqueue_webhook_delivery
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.models.boards import Board
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards/{board_id}/webhooks", tags=["board-webhooks"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
|
||||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
||||||
BOARD_OR_404_DEP = Depends(get_board_or_404)
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_endpoint_path(board_id: UUID, webhook_id: UUID) -> str:
|
|
||||||
return f"/api/v1/boards/{board_id}/webhooks/{webhook_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_endpoint_url(endpoint_path: str) -> str | None:
|
|
||||||
base_url = settings.base_url.rstrip("/")
|
|
||||||
if not base_url:
|
|
||||||
return None
|
|
||||||
return f"{base_url}{endpoint_path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _to_webhook_read(webhook: BoardWebhook) -> BoardWebhookRead:
|
|
||||||
endpoint_path = _webhook_endpoint_path(webhook.board_id, webhook.id)
|
|
||||||
return BoardWebhookRead(
|
|
||||||
id=webhook.id,
|
|
||||||
board_id=webhook.board_id,
|
|
||||||
agent_id=webhook.agent_id,
|
|
||||||
description=webhook.description,
|
|
||||||
enabled=webhook.enabled,
|
|
||||||
endpoint_path=endpoint_path,
|
|
||||||
endpoint_url=_webhook_endpoint_url(endpoint_path),
|
|
||||||
created_at=webhook.created_at,
|
|
||||||
updated_at=webhook.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_payload_read(payload: BoardWebhookPayload) -> BoardWebhookPayloadRead:
|
|
||||||
return BoardWebhookPayloadRead.model_validate(payload, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_webhook_items(items: Sequence[object]) -> list[BoardWebhook]:
|
|
||||||
values: list[BoardWebhook] = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, BoardWebhook):
|
|
||||||
msg = "Expected BoardWebhook items from paginated query"
|
|
||||||
raise TypeError(msg)
|
|
||||||
values.append(item)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_payload_items(items: Sequence[object]) -> list[BoardWebhookPayload]:
|
|
||||||
values: list[BoardWebhookPayload] = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, BoardWebhookPayload):
|
|
||||||
msg = "Expected BoardWebhookPayload items from paginated query"
|
|
||||||
raise TypeError(msg)
|
|
||||||
values.append(item)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_webhook(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
board_id: UUID,
|
|
||||||
webhook_id: UUID,
|
|
||||||
) -> BoardWebhook:
|
|
||||||
webhook = (
|
|
||||||
await session.exec(
|
|
||||||
select(BoardWebhook)
|
|
||||||
.where(col(BoardWebhook.id) == webhook_id)
|
|
||||||
.where(col(BoardWebhook.board_id) == board_id),
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if webhook is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return webhook
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_webhook_payload(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
board_id: UUID,
|
|
||||||
webhook_id: UUID,
|
|
||||||
payload_id: UUID,
|
|
||||||
) -> BoardWebhookPayload:
|
|
||||||
payload = (
|
|
||||||
await session.exec(
|
|
||||||
select(BoardWebhookPayload)
|
|
||||||
.where(col(BoardWebhookPayload.id) == payload_id)
|
|
||||||
.where(col(BoardWebhookPayload.board_id) == board_id)
|
|
||||||
.where(col(BoardWebhookPayload.webhook_id) == webhook_id),
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if payload is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_payload(
|
|
||||||
raw_body: bytes,
|
|
||||||
*,
|
|
||||||
content_type: str | None,
|
|
||||||
) -> dict[str, object] | list[object] | str | int | float | bool | None:
|
|
||||||
if not raw_body:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
body_text = raw_body.decode("utf-8", errors="replace")
|
|
||||||
normalized_content_type = (content_type or "").lower()
|
|
||||||
should_parse_json = "application/json" in normalized_content_type
|
|
||||||
if not should_parse_json:
|
|
||||||
should_parse_json = body_text.startswith(("{", "[", '"')) or body_text in {"true", "false"}
|
|
||||||
|
|
||||||
if should_parse_json:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(body_text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return body_text
|
|
||||||
if isinstance(parsed, (dict, list, str, int, float, bool)) or parsed is None:
|
|
||||||
return parsed
|
|
||||||
return body_text
|
|
||||||
|
|
||||||
|
|
||||||
def _captured_headers(request: Request) -> dict[str, str] | None:
|
|
||||||
captured: dict[str, str] = {}
|
|
||||||
for header, value in request.headers.items():
|
|
||||||
normalized = header.lower()
|
|
||||||
if normalized in {"content-type", "user-agent"} or normalized.startswith("x-"):
|
|
||||||
captured[normalized] = value
|
|
||||||
return captured or None
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_preview(
|
|
||||||
value: dict[str, object] | list[object] | str | int | float | bool | None,
|
|
||||||
) -> str:
|
|
||||||
if isinstance(value, str):
|
|
||||||
preview = value
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
preview = json.dumps(value, indent=2, ensure_ascii=True)
|
|
||||||
except TypeError:
|
|
||||||
preview = str(value)
|
|
||||||
return preview
|
|
||||||
|
|
||||||
|
|
||||||
def _webhook_memory_content(
|
|
||||||
*,
|
|
||||||
webhook: BoardWebhook,
|
|
||||||
payload: BoardWebhookPayload,
|
|
||||||
) -> str:
|
|
||||||
preview = _payload_preview(payload.payload)
|
|
||||||
inspect_path = f"/api/v1/boards/{webhook.board_id}/webhooks/{webhook.id}/payloads/{payload.id}"
|
|
||||||
return (
|
|
||||||
"WEBHOOK PAYLOAD RECEIVED\n"
|
|
||||||
f"Webhook ID: {webhook.id}\n"
|
|
||||||
f"Payload ID: {payload.id}\n"
|
|
||||||
f"Instruction: {webhook.description}\n"
|
|
||||||
f"Inspect (admin API): {inspect_path}\n\n"
|
|
||||||
"Payload preview:\n"
|
|
||||||
f"{preview}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_lead_on_webhook_payload(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
webhook: BoardWebhook,
|
|
||||||
payload: BoardWebhookPayload,
|
|
||||||
) -> None:
|
|
||||||
target_agent: Agent | None = None
|
|
||||||
if webhook.agent_id is not None:
|
|
||||||
target_agent = await Agent.objects.filter_by(id=webhook.agent_id, board_id=board.id).first(
|
|
||||||
session
|
|
||||||
)
|
|
||||||
if target_agent is None:
|
|
||||||
target_agent = (
|
|
||||||
await Agent.objects.filter_by(board_id=board.id)
|
|
||||||
.filter(col(Agent.is_board_lead).is_(True))
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if target_agent is None or not target_agent.openclaw_session_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
dispatch = GatewayDispatchService(session)
|
|
||||||
config = await dispatch.optional_gateway_config_for_board(board)
|
|
||||||
if config is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
payload_preview = _payload_preview(payload.payload)
|
|
||||||
message = (
|
|
||||||
"WEBHOOK EVENT RECEIVED\n"
|
|
||||||
f"Board: {board.name}\n"
|
|
||||||
f"Webhook ID: {webhook.id}\n"
|
|
||||||
f"Payload ID: {payload.id}\n"
|
|
||||||
f"Instruction: {webhook.description}\n\n"
|
|
||||||
"Take action:\n"
|
|
||||||
"1) Triage this payload against the webhook instruction.\n"
|
|
||||||
"2) Create/update tasks as needed.\n"
|
|
||||||
f"3) Reference payload ID {payload.id} in task descriptions.\n\n"
|
|
||||||
"Payload preview:\n"
|
|
||||||
f"{payload_preview}\n\n"
|
|
||||||
"To inspect board memory entries:\n"
|
|
||||||
f"GET /api/v1/agent/boards/{board.id}/memory?is_chat=false"
|
|
||||||
)
|
|
||||||
await dispatch.try_send_agent_message(
|
|
||||||
session_key=target_agent.openclaw_session_id,
|
|
||||||
config=config,
|
|
||||||
agent_name=target_agent.name,
|
|
||||||
message=message,
|
|
||||||
deliver=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_agent_id(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
agent_id: UUID | None,
|
|
||||||
) -> None:
|
|
||||||
if agent_id is None:
|
|
||||||
return
|
|
||||||
agent = await Agent.objects.filter_by(id=agent_id, board_id=board.id).first(session)
|
|
||||||
if agent is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="agent_id must reference an agent on this board.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardWebhookRead])
|
|
||||||
async def list_board_webhooks(
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardWebhookRead]:
|
|
||||||
"""List configured webhooks for a board."""
|
|
||||||
statement = (
|
|
||||||
select(BoardWebhook)
|
|
||||||
.where(col(BoardWebhook.board_id) == board.id)
|
|
||||||
.order_by(col(BoardWebhook.created_at).desc())
|
|
||||||
)
|
|
||||||
|
|
||||||
def _transform(items: Sequence[object]) -> Sequence[object]:
|
|
||||||
webhooks = _coerce_webhook_items(items)
|
|
||||||
return [_to_webhook_read(value) for value in webhooks]
|
|
||||||
|
|
||||||
return await paginate(session, statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BoardWebhookRead)
|
|
||||||
async def create_board_webhook(
|
|
||||||
payload: BoardWebhookCreate,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardWebhookRead:
|
|
||||||
"""Create a new board webhook with a generated UUID endpoint."""
|
|
||||||
await _validate_agent_id(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
agent_id=payload.agent_id,
|
|
||||||
)
|
|
||||||
webhook = BoardWebhook(
|
|
||||||
board_id=board.id,
|
|
||||||
agent_id=payload.agent_id,
|
|
||||||
description=payload.description,
|
|
||||||
enabled=payload.enabled,
|
|
||||||
)
|
|
||||||
await crud.save(session, webhook)
|
|
||||||
return _to_webhook_read(webhook)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{webhook_id}", response_model=BoardWebhookRead)
|
|
||||||
async def get_board_webhook(
|
|
||||||
webhook_id: UUID,
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardWebhookRead:
|
|
||||||
"""Get one board webhook configuration."""
|
|
||||||
webhook = await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
return _to_webhook_read(webhook)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{webhook_id}", response_model=BoardWebhookRead)
|
|
||||||
async def update_board_webhook(
|
|
||||||
webhook_id: UUID,
|
|
||||||
payload: BoardWebhookUpdate,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardWebhookRead:
|
|
||||||
"""Update board webhook description or enabled state."""
|
|
||||||
webhook = await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
if updates:
|
|
||||||
await _validate_agent_id(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
agent_id=updates.get("agent_id"),
|
|
||||||
)
|
|
||||||
crud.apply_updates(webhook, updates)
|
|
||||||
webhook.updated_at = utcnow()
|
|
||||||
await crud.save(session, webhook)
|
|
||||||
return _to_webhook_read(webhook)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{webhook_id}", response_model=OkResponse)
|
|
||||||
async def delete_board_webhook(
|
|
||||||
webhook_id: UUID,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete a webhook and its stored payload rows."""
|
|
||||||
webhook = await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardWebhookPayload,
|
|
||||||
col(BoardWebhookPayload.webhook_id) == webhook.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await session.delete(webhook)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{webhook_id}/payloads", response_model=DefaultLimitOffsetPage[BoardWebhookPayloadRead]
|
|
||||||
)
|
|
||||||
async def list_board_webhook_payloads(
|
|
||||||
webhook_id: UUID,
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardWebhookPayloadRead]:
|
|
||||||
"""List stored payloads for one board webhook."""
|
|
||||||
await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
statement = (
|
|
||||||
select(BoardWebhookPayload)
|
|
||||||
.where(col(BoardWebhookPayload.board_id) == board.id)
|
|
||||||
.where(col(BoardWebhookPayload.webhook_id) == webhook_id)
|
|
||||||
.order_by(col(BoardWebhookPayload.received_at).desc())
|
|
||||||
)
|
|
||||||
|
|
||||||
def _transform(items: Sequence[object]) -> Sequence[object]:
|
|
||||||
payloads = _coerce_payload_items(items)
|
|
||||||
return [_to_payload_read(value) for value in payloads]
|
|
||||||
|
|
||||||
return await paginate(session, statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{webhook_id}/payloads/{payload_id}", response_model=BoardWebhookPayloadRead)
|
|
||||||
async def get_board_webhook_payload(
|
|
||||||
webhook_id: UUID,
|
|
||||||
payload_id: UUID,
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardWebhookPayloadRead:
|
|
||||||
"""Get a single stored payload for one board webhook."""
|
|
||||||
await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
payload = await _require_board_webhook_payload(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
payload_id=payload_id,
|
|
||||||
)
|
|
||||||
return _to_payload_read(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{webhook_id}",
|
|
||||||
response_model=BoardWebhookIngestResponse,
|
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
|
||||||
)
|
|
||||||
async def ingest_board_webhook(
|
|
||||||
request: Request,
|
|
||||||
webhook_id: UUID,
|
|
||||||
board: Board = BOARD_OR_404_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardWebhookIngestResponse:
|
|
||||||
"""Open inbound webhook endpoint that stores payloads and nudges the board lead."""
|
|
||||||
webhook = await _require_board_webhook(
|
|
||||||
session,
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook_id,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"webhook.ingest.received",
|
|
||||||
extra={
|
|
||||||
"board_id": str(board.id),
|
|
||||||
"webhook_id": str(webhook.id),
|
|
||||||
"source_ip": request.client.host if request.client else None,
|
|
||||||
"content_type": request.headers.get("content-type"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not webhook.enabled:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_410_GONE,
|
|
||||||
detail="Webhook is disabled.",
|
|
||||||
)
|
|
||||||
|
|
||||||
content_type = request.headers.get("content-type")
|
|
||||||
headers = _captured_headers(request)
|
|
||||||
payload_value = _decode_payload(
|
|
||||||
await request.body(),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
payload = BoardWebhookPayload(
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook.id,
|
|
||||||
payload=payload_value,
|
|
||||||
headers=headers,
|
|
||||||
source_ip=request.client.host if request.client else None,
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
session.add(payload)
|
|
||||||
memory = BoardMemory(
|
|
||||||
board_id=board.id,
|
|
||||||
content=_webhook_memory_content(webhook=webhook, payload=payload),
|
|
||||||
tags=[
|
|
||||||
"webhook",
|
|
||||||
f"webhook:{webhook.id}",
|
|
||||||
f"payload:{payload.id}",
|
|
||||||
],
|
|
||||||
source="webhook",
|
|
||||||
is_chat=False,
|
|
||||||
)
|
|
||||||
session.add(memory)
|
|
||||||
await session.commit()
|
|
||||||
logger.info(
|
|
||||||
"webhook.ingest.persisted",
|
|
||||||
extra={
|
|
||||||
"payload_id": str(payload.id),
|
|
||||||
"board_id": str(board.id),
|
|
||||||
"webhook_id": str(webhook.id),
|
|
||||||
"memory_id": str(memory.id),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
enqueued = enqueue_webhook_delivery(
|
|
||||||
QueuedInboundDelivery(
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook.id,
|
|
||||||
payload_id=payload.id,
|
|
||||||
received_at=payload.received_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"webhook.ingest.enqueued",
|
|
||||||
extra={
|
|
||||||
"payload_id": str(payload.id),
|
|
||||||
"board_id": str(board.id),
|
|
||||||
"webhook_id": str(webhook.id),
|
|
||||||
"enqueued": enqueued,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not enqueued:
|
|
||||||
# Preserve historical behavior by still notifying synchronously if queueing fails.
|
|
||||||
await _notify_lead_on_webhook_payload(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
webhook=webhook,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return BoardWebhookIngestResponse(
|
|
||||||
board_id=board.id,
|
|
||||||
webhook_id=webhook.id,
|
|
||||||
payload_id=payload.id,
|
|
||||||
)
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
"""Board CRUD and snapshot endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Literal, cast
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import (
|
|
||||||
get_board_for_actor_read,
|
|
||||||
get_board_for_user_read,
|
|
||||||
get_board_for_user_write,
|
|
||||||
require_org_admin,
|
|
||||||
require_org_member,
|
|
||||||
)
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
|
|
||||||
from app.services.activity_log import record_activity
|
|
||||||
from app.services.board_group_snapshot import build_board_group_snapshot
|
|
||||||
from app.services.board_lifecycle import delete_board as delete_board_service
|
|
||||||
from app.services.board_snapshot import build_board_snapshot
|
|
||||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
||||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
|
||||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
|
||||||
from app.services.organizations import OrganizationContext, board_access_filter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
|
||||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
||||||
BOARD_ACTOR_READ_DEP = Depends(get_board_for_actor_read)
|
|
||||||
GATEWAY_ID_QUERY = Query(default=None)
|
|
||||||
BOARD_GROUP_ID_QUERY = Query(default=None)
|
|
||||||
INCLUDE_SELF_QUERY = Query(default=False)
|
|
||||||
INCLUDE_DONE_QUERY = Query(default=False)
|
|
||||||
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
|
|
||||||
AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
|
|
||||||
_ERR_GATEWAY_MAIN_AGENT_REQUIRED = (
|
|
||||||
"gateway must have a gateway main agent before boards can be created or updated"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
|
|
||||||
main_agent = (
|
|
||||||
await Agent.objects.filter_by(gateway_id=gateway.id)
|
|
||||||
.filter(col(Agent.board_id).is_(None))
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if main_agent is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=_ERR_GATEWAY_MAIN_AGENT_REQUIRED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway(
|
|
||||||
session: AsyncSession,
|
|
||||||
gateway_id: object,
|
|
||||||
*,
|
|
||||||
organization_id: UUID | None = None,
|
|
||||||
) -> Gateway:
|
|
||||||
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
|
||||||
if gateway is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="gateway_id is invalid",
|
|
||||||
)
|
|
||||||
if organization_id is not None and gateway.organization_id != organization_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="gateway_id is invalid",
|
|
||||||
)
|
|
||||||
await _require_gateway_main_agent(session, gateway)
|
|
||||||
return gateway
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_gateway_for_create(
|
|
||||||
payload: BoardCreate,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> Gateway:
|
|
||||||
return await _require_gateway(
|
|
||||||
session,
|
|
||||||
payload.gateway_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_group(
|
|
||||||
session: AsyncSession,
|
|
||||||
board_group_id: object,
|
|
||||||
*,
|
|
||||||
organization_id: UUID | None = None,
|
|
||||||
) -> BoardGroup:
|
|
||||||
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
|
||||||
if group is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="board_group_id is invalid",
|
|
||||||
)
|
|
||||||
if organization_id is not None and group.organization_id != organization_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="board_group_id is invalid",
|
|
||||||
)
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_board_group_for_create(
|
|
||||||
payload: BoardCreate,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardGroup | None:
|
|
||||||
if payload.board_group_id is None:
|
|
||||||
return None
|
|
||||||
return await _require_board_group(
|
|
||||||
session,
|
|
||||||
payload.board_group_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
GATEWAY_CREATE_DEP = Depends(_require_gateway_for_create)
|
|
||||||
BOARD_GROUP_CREATE_DEP = Depends(_require_board_group_for_create)
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_board_update(
|
|
||||||
*,
|
|
||||||
payload: BoardUpdate,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
) -> Board:
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
if "gateway_id" in updates:
|
|
||||||
await _require_gateway(
|
|
||||||
session,
|
|
||||||
updates["gateway_id"],
|
|
||||||
organization_id=board.organization_id,
|
|
||||||
)
|
|
||||||
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
|
||||||
await _require_board_group(
|
|
||||||
session,
|
|
||||||
updates["board_group_id"],
|
|
||||||
organization_id=board.organization_id,
|
|
||||||
)
|
|
||||||
crud.apply_updates(board, updates)
|
|
||||||
if updates.get("board_type") == "goal" and (not board.objective or not board.success_metrics):
|
|
||||||
# Validate only when explicitly switching to goal boards.
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="Goal boards require objective and success_metrics",
|
|
||||||
)
|
|
||||||
if not board.gateway_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="gateway_id is required",
|
|
||||||
)
|
|
||||||
await _require_gateway(
|
|
||||||
session,
|
|
||||||
board.gateway_id,
|
|
||||||
organization_id=board.organization_id,
|
|
||||||
)
|
|
||||||
board.updated_at = utcnow()
|
|
||||||
return await crud.save(session, board)
|
|
||||||
|
|
||||||
|
|
||||||
def _board_group_change_message(
|
|
||||||
*,
|
|
||||||
action: Literal["join", "leave"],
|
|
||||||
changed_board: Board,
|
|
||||||
recipient_board: Board,
|
|
||||||
group: BoardGroup,
|
|
||||||
) -> str:
|
|
||||||
changed_label = "Joined Board" if action == "join" else "Left Board"
|
|
||||||
guidance = (
|
|
||||||
"1) Use cross-board discussion when work spans multiple boards.\n"
|
|
||||||
"2) Check related board activity before acting on shared concerns.\n"
|
|
||||||
"3) Explicitly coordinate ownership to avoid duplicate or conflicting work.\n"
|
|
||||||
)
|
|
||||||
if action == "leave":
|
|
||||||
guidance = (
|
|
||||||
"1) Treat cross-board coordination with the departed board as inactive.\n"
|
|
||||||
"2) Re-check dependencies and ownership that previously spanned this board.\n"
|
|
||||||
"3) Confirm no in-flight handoffs still rely on the prior group link.\n"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
"BOARD GROUP UPDATED\n"
|
|
||||||
f"{changed_label}: {changed_board.name}\n"
|
|
||||||
f"{changed_label} ID: {changed_board.id}\n"
|
|
||||||
f"Recipient Board: {recipient_board.name}\n"
|
|
||||||
f"Recipient Board ID: {recipient_board.id}\n"
|
|
||||||
f"Board Group: {group.name}\n"
|
|
||||||
f"Board Group ID: {group.id}\n\n"
|
|
||||||
"Coordination guidance:\n"
|
|
||||||
f"{guidance}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_agents_on_board_group_change(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
group: BoardGroup,
|
|
||||||
action: Literal["join", "leave"],
|
|
||||||
) -> None:
|
|
||||||
dispatch = GatewayDispatchService(session)
|
|
||||||
group_boards = await Board.objects.filter_by(board_group_id=group.id).all(session)
|
|
||||||
board_by_id = {item.id: item for item in group_boards}
|
|
||||||
board_by_id.setdefault(board.id, board)
|
|
||||||
board_ids = list(board_by_id.keys())
|
|
||||||
if not board_ids:
|
|
||||||
return
|
|
||||||
agents = await Agent.objects.by_field_in("board_id", board_ids).all(session)
|
|
||||||
if not agents:
|
|
||||||
return
|
|
||||||
|
|
||||||
config_by_board_id: dict[UUID, GatewayClientConfig] = {}
|
|
||||||
for group_board in board_by_id.values():
|
|
||||||
config = await dispatch.optional_gateway_config_for_board(group_board)
|
|
||||||
if config is None:
|
|
||||||
logger.warning(
|
|
||||||
"board.group.%s.notify_skipped board_id=%s group_id=%s target_board_id=%s "
|
|
||||||
"reason=no_gateway_config",
|
|
||||||
action,
|
|
||||||
board.id,
|
|
||||||
group.id,
|
|
||||||
group_board.id,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
config_by_board_id[group_board.id] = config
|
|
||||||
|
|
||||||
if not config_by_board_id:
|
|
||||||
logger.warning(
|
|
||||||
"board.group.%s.notify_skipped board_id=%s group_id=%s reason=no_gateway_config_any_board",
|
|
||||||
action,
|
|
||||||
board.id,
|
|
||||||
group.id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
message_by_board_id = {
|
|
||||||
recipient_board_id: _board_group_change_message(
|
|
||||||
action=action,
|
|
||||||
changed_board=board,
|
|
||||||
recipient_board=recipient_board,
|
|
||||||
group=group,
|
|
||||||
)
|
|
||||||
for recipient_board_id, recipient_board in board_by_id.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
notified = 0
|
|
||||||
failed = 0
|
|
||||||
skipped_missing_session = 0
|
|
||||||
skipped_missing_config = 0
|
|
||||||
skipped_missing_board = 0
|
|
||||||
for agent in agents:
|
|
||||||
if not agent.openclaw_session_id:
|
|
||||||
skipped_missing_session += 1
|
|
||||||
continue
|
|
||||||
if agent.board_id is None:
|
|
||||||
skipped_missing_board += 1
|
|
||||||
continue
|
|
||||||
config = config_by_board_id.get(agent.board_id)
|
|
||||||
message = message_by_board_id.get(agent.board_id)
|
|
||||||
recipient_board = board_by_id.get(agent.board_id)
|
|
||||||
if config is None or message is None or recipient_board is None:
|
|
||||||
skipped_missing_config += 1
|
|
||||||
continue
|
|
||||||
error = await dispatch.try_send_agent_message(
|
|
||||||
session_key=agent.openclaw_session_id,
|
|
||||||
config=config,
|
|
||||||
agent_name=agent.name,
|
|
||||||
message=message,
|
|
||||||
deliver=False,
|
|
||||||
)
|
|
||||||
if error is None:
|
|
||||||
notified += 1
|
|
||||||
record_activity(
|
|
||||||
session,
|
|
||||||
event_type=f"board.group.{action}.notified",
|
|
||||||
message=(
|
|
||||||
f"Board-group {action} notice sent to {agent.name} for board "
|
|
||||||
f"{recipient_board.name} related to {board.name} and {group.name}."
|
|
||||||
),
|
|
||||||
agent_id=agent.id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
record_activity(
|
|
||||||
session,
|
|
||||||
event_type=f"board.group.{action}.notify_failed",
|
|
||||||
message=(
|
|
||||||
f"Board-group {action} notify failed for {agent.name} on board "
|
|
||||||
f"{recipient_board.name}: {error}"
|
|
||||||
),
|
|
||||||
agent_id=agent.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if notified or failed:
|
|
||||||
await session.commit()
|
|
||||||
logger.info(
|
|
||||||
"board.group.%s.notify_complete board_id=%s group_id=%s boards_total=%s agents_total=%s "
|
|
||||||
"agents_notified=%s agents_failed=%s agents_skipped_no_session=%s "
|
|
||||||
"agents_skipped_no_gateway=%s agents_skipped_no_board=%s",
|
|
||||||
action,
|
|
||||||
board.id,
|
|
||||||
group.id,
|
|
||||||
len(board_by_id),
|
|
||||||
len(agents),
|
|
||||||
notified,
|
|
||||||
failed,
|
|
||||||
skipped_missing_session,
|
|
||||||
skipped_missing_config,
|
|
||||||
skipped_missing_board,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_agents_on_board_group_addition(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
group: BoardGroup,
|
|
||||||
) -> None:
|
|
||||||
await _notify_agents_on_board_group_change(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
group=group,
|
|
||||||
action="join",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _notify_agents_on_board_group_removal(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
board: Board,
|
|
||||||
group: BoardGroup,
|
|
||||||
) -> None:
|
|
||||||
await _notify_agents_on_board_group_change(
|
|
||||||
session=session,
|
|
||||||
board=board,
|
|
||||||
group=group,
|
|
||||||
action="leave",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
|
|
||||||
async def list_boards(
|
|
||||||
gateway_id: UUID | None = GATEWAY_ID_QUERY,
|
|
||||||
board_group_id: UUID | None = BOARD_GROUP_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[BoardRead]:
|
|
||||||
"""List boards visible to the current organization member."""
|
|
||||||
statement = select(Board).where(board_access_filter(ctx.member, write=False))
|
|
||||||
if gateway_id is not None:
|
|
||||||
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
|
||||||
if board_group_id is not None:
|
|
||||||
statement = statement.where(col(Board.board_group_id) == board_group_id)
|
|
||||||
statement = statement.order_by(
|
|
||||||
func.lower(col(Board.name)).asc(),
|
|
||||||
col(Board.created_at).desc(),
|
|
||||||
)
|
|
||||||
return await paginate(session, statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BoardRead)
|
|
||||||
async def create_board(
|
|
||||||
payload: BoardCreate,
|
|
||||||
_gateway: Gateway = GATEWAY_CREATE_DEP,
|
|
||||||
_board_group: BoardGroup | None = BOARD_GROUP_CREATE_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Create a board in the active organization."""
|
|
||||||
data = payload.model_dump()
|
|
||||||
data["organization_id"] = ctx.organization.id
|
|
||||||
return await crud.create(session, Board, **data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{board_id}", response_model=BoardRead)
|
|
||||||
def get_board(
|
|
||||||
board: Board = BOARD_USER_READ_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Get a board by id."""
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
|
||||||
async def get_board_snapshot(
|
|
||||||
board: Board = BOARD_ACTOR_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardSnapshot:
|
|
||||||
"""Get a board snapshot view model."""
|
|
||||||
return await build_board_snapshot(session, board)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{board_id}/group-snapshot",
|
|
||||||
response_model=BoardGroupSnapshot,
|
|
||||||
tags=AGENT_BOARD_ROLE_TAGS,
|
|
||||||
)
|
|
||||||
async def get_board_group_snapshot(
|
|
||||||
*,
|
|
||||||
include_self: bool = INCLUDE_SELF_QUERY,
|
|
||||||
include_done: bool = INCLUDE_DONE_QUERY,
|
|
||||||
per_board_task_limit: int = PER_BOARD_TASK_LIMIT_QUERY,
|
|
||||||
board: Board = BOARD_ACTOR_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> BoardGroupSnapshot:
|
|
||||||
"""Get a grouped snapshot across related boards.
|
|
||||||
|
|
||||||
Returns high-signal cross-board status for dependency and overlap checks.
|
|
||||||
"""
|
|
||||||
return await build_board_group_snapshot(
|
|
||||||
session,
|
|
||||||
board=board,
|
|
||||||
include_self=include_self,
|
|
||||||
include_done=include_done,
|
|
||||||
per_board_task_limit=per_board_task_limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{board_id}", response_model=BoardRead)
|
|
||||||
async def update_board(
|
|
||||||
payload: BoardUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Update mutable board properties."""
|
|
||||||
previous_group_id = board.board_group_id
|
|
||||||
updated = await _apply_board_update(payload=payload, session=session, board=board)
|
|
||||||
new_group_id = updated.board_group_id
|
|
||||||
if previous_group_id is not None and previous_group_id != new_group_id:
|
|
||||||
previous_group = await crud.get_by_id(session, BoardGroup, previous_group_id)
|
|
||||||
if previous_group is not None:
|
|
||||||
try:
|
|
||||||
await _notify_agents_on_board_group_removal(
|
|
||||||
session=session,
|
|
||||||
board=updated,
|
|
||||||
group=previous_group,
|
|
||||||
)
|
|
||||||
except (OpenClawGatewayError, OSError, RuntimeError, ValueError):
|
|
||||||
logger.exception(
|
|
||||||
"board.group.leave.notify_unexpected board_id=%s group_id=%s",
|
|
||||||
updated.id,
|
|
||||||
previous_group_id,
|
|
||||||
)
|
|
||||||
if new_group_id is not None and new_group_id != previous_group_id:
|
|
||||||
board_group = await crud.get_by_id(session, BoardGroup, new_group_id)
|
|
||||||
if board_group is not None:
|
|
||||||
try:
|
|
||||||
await _notify_agents_on_board_group_addition(
|
|
||||||
session=session,
|
|
||||||
board=updated,
|
|
||||||
group=board_group,
|
|
||||||
)
|
|
||||||
except (OpenClawGatewayError, OSError, RuntimeError, ValueError):
|
|
||||||
logger.exception(
|
|
||||||
"board.group.join.notify_unexpected board_id=%s group_id=%s",
|
|
||||||
updated.id,
|
|
||||||
new_group_id,
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{board_id}", response_model=OkResponse)
|
|
||||||
async def delete_board(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
board: Board = BOARD_USER_WRITE_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete a board and all dependent records."""
|
|
||||||
return await delete_board_service(session, board=board)
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""Reusable FastAPI dependencies for auth and board/task access.
|
|
||||||
|
|
||||||
These dependencies are the main "policy wiring" layer for the API.
|
|
||||||
|
|
||||||
They:
|
|
||||||
- resolve the authenticated actor (admin user vs agent)
|
|
||||||
- enforce organization/board access rules
|
|
||||||
- provide common "load or 404" helpers (board/task)
|
|
||||||
|
|
||||||
Why this exists:
|
|
||||||
- Keeping authorization logic centralized makes it easier to reason about (and
|
|
||||||
audit) permissions as the API surface grows.
|
|
||||||
- Some routes allow either admin users or agents; others require user auth.
|
|
||||||
|
|
||||||
If you're adding a new endpoint, prefer composing from these dependencies instead
|
|
||||||
of re-implementing permission checks in the router.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context_optional
|
|
||||||
from app.core.auth import AuthContext, get_auth_context, get_auth_context_optional
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.organizations import Organization
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.services.admin_access import require_admin
|
|
||||||
from app.services.organizations import (
|
|
||||||
OrganizationContext,
|
|
||||||
ensure_member_for_user,
|
|
||||||
get_active_membership,
|
|
||||||
is_org_admin,
|
|
||||||
require_board_access,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.users import User
|
|
||||||
|
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
|
||||||
AUTH_OPTIONAL_DEP = Depends(get_auth_context_optional)
|
|
||||||
AGENT_AUTH_OPTIONAL_DEP = Depends(get_agent_auth_context_optional)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin_auth(auth: AuthContext = AUTH_DEP) -> AuthContext:
|
|
||||||
"""Require an authenticated admin user."""
|
|
||||||
require_admin(auth)
|
|
||||||
return auth
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ActorContext:
|
|
||||||
"""Authenticated actor context for user or agent callers."""
|
|
||||||
|
|
||||||
actor_type: Literal["user", "agent"]
|
|
||||||
user: User | None = None
|
|
||||||
agent: Agent | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin_or_agent(
|
|
||||||
auth: AuthContext | None = AUTH_OPTIONAL_DEP,
|
|
||||||
agent_auth: AgentAuthContext | None = AGENT_AUTH_OPTIONAL_DEP,
|
|
||||||
) -> ActorContext:
|
|
||||||
"""Authorize either an admin user or an authenticated agent."""
|
|
||||||
if auth is not None:
|
|
||||||
require_admin(auth)
|
|
||||||
return ActorContext(actor_type="user", user=auth.user)
|
|
||||||
if agent_auth is not None:
|
|
||||||
return ActorContext(actor_type="agent", agent=agent_auth.agent)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
|
|
||||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_org_member(
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> OrganizationContext:
|
|
||||||
"""Resolve and require active organization membership for the current user."""
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
member = await get_active_membership(session, auth.user)
|
|
||||||
if member is None:
|
|
||||||
member = await ensure_member_for_user(session, auth.user)
|
|
||||||
if member is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
organization = await Organization.objects.by_id(member.organization_id).first(
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
if organization is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return OrganizationContext(organization=organization, member=member)
|
|
||||||
|
|
||||||
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_org_admin(
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> OrganizationContext:
|
|
||||||
"""Require organization-admin membership privileges."""
|
|
||||||
if not is_org_admin(ctx.member):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def get_board_or_404(
|
|
||||||
board_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Load a board by id or raise HTTP 404."""
|
|
||||||
board = await Board.objects.by_id(board_id).first(session)
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
async def get_board_for_actor_read(
|
|
||||||
board_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Load a board and enforce actor read access."""
|
|
||||||
board = await Board.objects.by_id(board_id).first(session)
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if actor.actor_type == "agent":
|
|
||||||
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return board
|
|
||||||
if actor.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await require_board_access(session, user=actor.user, board=board, write=False)
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
async def get_board_for_actor_write(
|
|
||||||
board_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
actor: ActorContext = ACTOR_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Load a board and enforce actor write access."""
|
|
||||||
board = await Board.objects.by_id(board_id).first(session)
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if actor.actor_type == "agent":
|
|
||||||
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return board
|
|
||||||
if actor.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await require_board_access(session, user=actor.user, board=board, write=True)
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
async def get_board_for_user_read(
|
|
||||||
board_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Load a board and enforce authenticated-user read access."""
|
|
||||||
board = await Board.objects.by_id(board_id).first(session)
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await require_board_access(session, user=auth.user, board=board, write=False)
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
async def get_board_for_user_write(
|
|
||||||
board_id: str,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> Board:
|
|
||||||
"""Load a board and enforce authenticated-user write access."""
|
|
||||||
board = await Board.objects.by_id(board_id).first(session)
|
|
||||||
if board is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await require_board_access(session, user=auth.user, board=board, write=True)
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_task_or_404(
|
|
||||||
task_id: UUID,
|
|
||||||
board: Board = BOARD_READ_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> Task:
|
|
||||||
"""Load a task for a board or raise HTTP 404."""
|
|
||||||
task = await Task.objects.by_id(task_id).first(session)
|
|
||||||
if task is None or task.board_id != board.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return task
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Thin gateway session-inspection API wrappers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin
|
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.gateway_api import (
|
|
||||||
GatewayCommandsResponse,
|
|
||||||
GatewayResolveQuery,
|
|
||||||
GatewaySessionHistoryResponse,
|
|
||||||
GatewaySessionMessageRequest,
|
|
||||||
GatewaySessionResponse,
|
|
||||||
GatewaySessionsResponse,
|
|
||||||
GatewaysStatusResponse,
|
|
||||||
)
|
|
||||||
from app.services.openclaw.gateway_rpc import GATEWAY_EVENTS, GATEWAY_METHODS, PROTOCOL_VERSION
|
|
||||||
from app.services.openclaw.session_service import GatewaySessionService
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
BOARD_ID_QUERY = Query(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_to_resolve_input(
|
|
||||||
board_id: str | None = Query(default=None),
|
|
||||||
gateway_url: str | None = Query(default=None),
|
|
||||||
gateway_token: str | None = Query(default=None),
|
|
||||||
) -> GatewayResolveQuery:
|
|
||||||
return GatewaySessionService.to_resolve_query(
|
|
||||||
board_id=board_id,
|
|
||||||
gateway_url=gateway_url,
|
|
||||||
gateway_token=gateway_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
RESOLVE_INPUT_DEP = Depends(_query_to_resolve_input)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=GatewaysStatusResponse)
|
|
||||||
async def gateways_status(
|
|
||||||
params: GatewayResolveQuery = RESOLVE_INPUT_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewaysStatusResponse:
|
|
||||||
"""Return gateway connectivity and session status."""
|
|
||||||
service = GatewaySessionService(session)
|
|
||||||
return await service.get_status(
|
|
||||||
params=params,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
user=auth.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions", response_model=GatewaySessionsResponse)
|
|
||||||
async def list_gateway_sessions(
|
|
||||||
board_id: str | None = BOARD_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewaySessionsResponse:
|
|
||||||
"""List sessions for a gateway associated with a board."""
|
|
||||||
service = GatewaySessionService(session)
|
|
||||||
return await service.get_sessions(
|
|
||||||
board_id=board_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
user=auth.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}", response_model=GatewaySessionResponse)
|
|
||||||
async def get_gateway_session(
|
|
||||||
session_id: str,
|
|
||||||
board_id: str | None = BOARD_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewaySessionResponse:
|
|
||||||
"""Get a specific gateway session by key."""
|
|
||||||
service = GatewaySessionService(session)
|
|
||||||
return await service.get_session(
|
|
||||||
session_id=session_id,
|
|
||||||
board_id=board_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
user=auth.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}/history", response_model=GatewaySessionHistoryResponse)
|
|
||||||
async def get_session_history(
|
|
||||||
session_id: str,
|
|
||||||
board_id: str | None = BOARD_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewaySessionHistoryResponse:
|
|
||||||
"""Fetch chat history for a gateway session."""
|
|
||||||
service = GatewaySessionService(session)
|
|
||||||
return await service.get_session_history(
|
|
||||||
session_id=session_id,
|
|
||||||
board_id=board_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
user=auth.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/message", response_model=OkResponse)
|
|
||||||
async def send_gateway_session_message(
|
|
||||||
session_id: str,
|
|
||||||
payload: GatewaySessionMessageRequest,
|
|
||||||
board_id: str | None = BOARD_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Send a message into a specific gateway session."""
|
|
||||||
service = GatewaySessionService(session)
|
|
||||||
await service.send_session_message(
|
|
||||||
session_id=session_id,
|
|
||||||
payload=payload,
|
|
||||||
board_id=board_id,
|
|
||||||
user=auth.user,
|
|
||||||
)
|
|
||||||
return OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/commands", response_model=GatewayCommandsResponse)
|
|
||||||
async def gateway_commands(
|
|
||||||
_auth: AuthContext = AUTH_DEP,
|
|
||||||
_ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewayCommandsResponse:
|
|
||||||
"""Return supported gateway protocol methods and events."""
|
|
||||||
return GatewayCommandsResponse(
|
|
||||||
protocol_version=PROTOCOL_VERSION,
|
|
||||||
methods=GATEWAY_METHODS,
|
|
||||||
events=GATEWAY_EVENTS,
|
|
||||||
)
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""Thin API wrappers for gateway CRUD and template synchronization."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlmodel import col
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin
|
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.models.skills import GatewayInstalledSkill
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.gateways import (
|
|
||||||
GatewayCreate,
|
|
||||||
GatewayRead,
|
|
||||||
GatewayTemplatesSyncResult,
|
|
||||||
GatewayUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.openclaw.admin_service import GatewayAdminLifecycleService
|
|
||||||
from app.services.openclaw.session_service import GatewayTemplateSyncQuery
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
INCLUDE_MAIN_QUERY = Query(default=True)
|
|
||||||
RESET_SESSIONS_QUERY = Query(default=False)
|
|
||||||
ROTATE_TOKENS_QUERY = Query(default=False)
|
|
||||||
FORCE_BOOTSTRAP_QUERY = Query(default=False)
|
|
||||||
OVERWRITE_QUERY = Query(default=False)
|
|
||||||
LEAD_ONLY_QUERY = Query(default=False)
|
|
||||||
BOARD_ID_QUERY = Query(default=None)
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID,)
|
|
||||||
|
|
||||||
|
|
||||||
def _template_sync_query(
|
|
||||||
*,
|
|
||||||
include_main: bool = INCLUDE_MAIN_QUERY,
|
|
||||||
lead_only: bool = LEAD_ONLY_QUERY,
|
|
||||||
reset_sessions: bool = RESET_SESSIONS_QUERY,
|
|
||||||
rotate_tokens: bool = ROTATE_TOKENS_QUERY,
|
|
||||||
force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY,
|
|
||||||
overwrite: bool = OVERWRITE_QUERY,
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
) -> GatewayTemplateSyncQuery:
|
|
||||||
return GatewayTemplateSyncQuery(
|
|
||||||
include_main=include_main,
|
|
||||||
lead_only=lead_only,
|
|
||||||
reset_sessions=reset_sessions,
|
|
||||||
rotate_tokens=rotate_tokens,
|
|
||||||
force_bootstrap=force_bootstrap,
|
|
||||||
overwrite=overwrite,
|
|
||||||
board_id=board_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
SYNC_QUERY_DEP = Depends(_template_sync_query)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
|
|
||||||
async def list_gateways(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> LimitOffsetPage[GatewayRead]:
|
|
||||||
"""List gateways for the caller's organization."""
|
|
||||||
statement = (
|
|
||||||
Gateway.objects.filter_by(organization_id=ctx.organization.id)
|
|
||||||
.order_by(col(Gateway.created_at).desc())
|
|
||||||
.statement
|
|
||||||
)
|
|
||||||
return await paginate(session, statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=GatewayRead)
|
|
||||||
async def create_gateway(
|
|
||||||
payload: GatewayCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> Gateway:
|
|
||||||
"""Create a gateway and provision or refresh its main agent."""
|
|
||||||
service = GatewayAdminLifecycleService(session)
|
|
||||||
await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token)
|
|
||||||
data = payload.model_dump()
|
|
||||||
gateway_id = uuid4()
|
|
||||||
data["id"] = gateway_id
|
|
||||||
data["organization_id"] = ctx.organization.id
|
|
||||||
gateway = await crud.create(session, Gateway, **data)
|
|
||||||
await service.ensure_main_agent(gateway, auth, action="provision")
|
|
||||||
return gateway
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{gateway_id}", response_model=GatewayRead)
|
|
||||||
async def get_gateway(
|
|
||||||
gateway_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> Gateway:
|
|
||||||
"""Return one gateway by id for the caller's organization."""
|
|
||||||
service = GatewayAdminLifecycleService(session)
|
|
||||||
gateway = await service.require_gateway(
|
|
||||||
gateway_id=gateway_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
return gateway
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{gateway_id}", response_model=GatewayRead)
|
|
||||||
async def update_gateway(
|
|
||||||
gateway_id: UUID,
|
|
||||||
payload: GatewayUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> Gateway:
|
|
||||||
"""Patch a gateway and refresh the main-agent provisioning state."""
|
|
||||||
service = GatewayAdminLifecycleService(session)
|
|
||||||
gateway = await service.require_gateway(
|
|
||||||
gateway_id=gateway_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
if "url" in updates or "token" in updates:
|
|
||||||
raw_next_url = updates.get("url", gateway.url)
|
|
||||||
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
|
|
||||||
next_token = updates.get("token", gateway.token)
|
|
||||||
if next_url:
|
|
||||||
await service.assert_gateway_runtime_compatible(url=next_url, token=next_token)
|
|
||||||
await crud.patch(session, gateway, updates)
|
|
||||||
await service.ensure_main_agent(gateway, auth, action="update")
|
|
||||||
return gateway
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{gateway_id}/templates/sync", response_model=GatewayTemplatesSyncResult)
|
|
||||||
async def sync_gateway_templates(
|
|
||||||
gateway_id: UUID,
|
|
||||||
sync_query: GatewayTemplateSyncQuery = SYNC_QUERY_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> GatewayTemplatesSyncResult:
|
|
||||||
"""Sync templates for a gateway and optionally rotate runtime settings."""
|
|
||||||
service = GatewayAdminLifecycleService(session)
|
|
||||||
gateway = await service.require_gateway(
|
|
||||||
gateway_id=gateway_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
return await service.sync_templates(gateway, query=sync_query, auth=auth)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{gateway_id}", response_model=OkResponse)
|
|
||||||
async def delete_gateway(
|
|
||||||
gateway_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete a gateway in the caller's organization."""
|
|
||||||
service = GatewayAdminLifecycleService(session)
|
|
||||||
gateway = await service.require_gateway(
|
|
||||||
gateway_id=gateway_id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
main_agent = await service.find_main_agent(gateway)
|
|
||||||
if main_agent is not None:
|
|
||||||
await service.clear_agent_foreign_keys(agent_id=main_agent.id)
|
|
||||||
await session.delete(main_agent)
|
|
||||||
|
|
||||||
duplicate_main_agents = await Agent.objects.filter_by(
|
|
||||||
gateway_id=gateway.id,
|
|
||||||
board_id=None,
|
|
||||||
).all(session)
|
|
||||||
for agent in duplicate_main_agents:
|
|
||||||
if main_agent is not None and agent.id == main_agent.id:
|
|
||||||
continue
|
|
||||||
await service.clear_agent_foreign_keys(agent_id=agent.id)
|
|
||||||
await session.delete(agent)
|
|
||||||
|
|
||||||
# NOTE: The migration declares `ondelete="CASCADE"` for gateway_installed_skills.gateway_id,
|
|
||||||
# but some backends/test environments (e.g. SQLite without FK pragma) may not
|
|
||||||
# enforce cascades. Delete rows explicitly to guarantee cleanup semantics.
|
|
||||||
installed_skills = await GatewayInstalledSkill.objects.filter_by(
|
|
||||||
gateway_id=gateway.id,
|
|
||||||
).all(session)
|
|
||||||
for installed_skill in installed_skills:
|
|
||||||
await session.delete(installed_skill)
|
|
||||||
|
|
||||||
await session.delete(gateway)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
"""Dashboard metric aggregation endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy import DateTime, case
|
|
||||||
from sqlalchemy import cast as sql_cast
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import require_org_member
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.activity_events import ActivityEvent
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.schemas.metrics import (
|
|
||||||
DashboardBucketKey,
|
|
||||||
DashboardKpis,
|
|
||||||
DashboardMetrics,
|
|
||||||
DashboardRangeKey,
|
|
||||||
DashboardRangeSeries,
|
|
||||||
DashboardSeriesPoint,
|
|
||||||
DashboardSeriesSet,
|
|
||||||
DashboardWipPoint,
|
|
||||||
DashboardWipRangeSeries,
|
|
||||||
DashboardWipSeriesSet,
|
|
||||||
)
|
|
||||||
from app.services.organizations import OrganizationContext, list_accessible_board_ids
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
|
||||||
|
|
||||||
ERROR_EVENT_PATTERN = "%failed"
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
|
|
||||||
RANGE_QUERY = Query(default="24h")
|
|
||||||
BOARD_ID_QUERY = Query(default=None)
|
|
||||||
GROUP_ID_QUERY = Query(default=None)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RangeSpec:
|
|
||||||
"""Resolved time-range specification for metric aggregation."""
|
|
||||||
|
|
||||||
key: DashboardRangeKey
|
|
||||||
start: datetime
|
|
||||||
end: datetime
|
|
||||||
bucket: DashboardBucketKey
|
|
||||||
duration: timedelta
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_range(range_key: DashboardRangeKey) -> RangeSpec:
|
|
||||||
now = utcnow()
|
|
||||||
specs: dict[DashboardRangeKey, tuple[timedelta, DashboardBucketKey]] = {
|
|
||||||
"24h": (timedelta(hours=24), "hour"),
|
|
||||||
"3d": (timedelta(days=3), "day"),
|
|
||||||
"7d": (timedelta(days=7), "day"),
|
|
||||||
"14d": (timedelta(days=14), "day"),
|
|
||||||
"1m": (timedelta(days=30), "day"),
|
|
||||||
"3m": (timedelta(days=90), "week"),
|
|
||||||
"6m": (timedelta(days=180), "week"),
|
|
||||||
"1y": (timedelta(days=365), "month"),
|
|
||||||
}
|
|
||||||
duration, bucket = specs[range_key]
|
|
||||||
return RangeSpec(
|
|
||||||
key=range_key,
|
|
||||||
start=now - duration,
|
|
||||||
end=now,
|
|
||||||
bucket=bucket,
|
|
||||||
duration=duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _comparison_range(range_spec: RangeSpec) -> RangeSpec:
|
|
||||||
return RangeSpec(
|
|
||||||
key=range_spec.key,
|
|
||||||
start=range_spec.start - range_spec.duration,
|
|
||||||
end=range_spec.end - range_spec.duration,
|
|
||||||
bucket=range_spec.bucket,
|
|
||||||
duration=range_spec.duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _bucket_start(value: datetime, bucket: DashboardBucketKey) -> datetime:
|
|
||||||
normalized = value.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
if bucket == "month":
|
|
||||||
return normalized.replace(day=1)
|
|
||||||
if bucket == "week":
|
|
||||||
return normalized - timedelta(days=normalized.weekday())
|
|
||||||
if bucket == "day":
|
|
||||||
return normalized
|
|
||||||
return value.replace(minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
|
|
||||||
def _next_bucket(cursor: datetime, bucket: DashboardBucketKey) -> datetime:
|
|
||||||
if bucket == "hour":
|
|
||||||
return cursor + timedelta(hours=1)
|
|
||||||
if bucket == "day":
|
|
||||||
return cursor + timedelta(days=1)
|
|
||||||
if bucket == "week":
|
|
||||||
return cursor + timedelta(days=7)
|
|
||||||
next_month = cursor.month + 1
|
|
||||||
next_year = cursor.year
|
|
||||||
if next_month > 12:
|
|
||||||
next_month = 1
|
|
||||||
next_year += 1
|
|
||||||
return cursor.replace(year=next_year, month=next_month, day=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_buckets(range_spec: RangeSpec) -> list[datetime]:
|
|
||||||
cursor = _bucket_start(range_spec.start, range_spec.bucket)
|
|
||||||
buckets: list[datetime] = []
|
|
||||||
while cursor <= range_spec.end:
|
|
||||||
buckets.append(cursor)
|
|
||||||
cursor = _next_bucket(cursor, range_spec.bucket)
|
|
||||||
return buckets
|
|
||||||
|
|
||||||
|
|
||||||
def _series_from_mapping(
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
mapping: dict[datetime, float],
|
|
||||||
) -> DashboardRangeSeries:
|
|
||||||
points = [
|
|
||||||
DashboardSeriesPoint(period=bucket, value=float(mapping.get(bucket, 0)))
|
|
||||||
for bucket in _build_buckets(range_spec)
|
|
||||||
]
|
|
||||||
return DashboardRangeSeries(
|
|
||||||
range=range_spec.key,
|
|
||||||
bucket=range_spec.bucket,
|
|
||||||
points=points,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _wip_series_from_mapping(
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
mapping: dict[datetime, dict[str, int]],
|
|
||||||
) -> DashboardWipRangeSeries:
|
|
||||||
points: list[DashboardWipPoint] = []
|
|
||||||
for bucket in _build_buckets(range_spec):
|
|
||||||
values = mapping.get(bucket, {})
|
|
||||||
points.append(
|
|
||||||
DashboardWipPoint(
|
|
||||||
period=bucket,
|
|
||||||
inbox=values.get("inbox", 0),
|
|
||||||
in_progress=values.get("in_progress", 0),
|
|
||||||
review=values.get("review", 0),
|
|
||||||
done=values.get("done", 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return DashboardWipRangeSeries(
|
|
||||||
range=range_spec.key,
|
|
||||||
bucket=range_spec.bucket,
|
|
||||||
points=points,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_throughput(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> DashboardRangeSeries:
|
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
|
||||||
statement = (
|
|
||||||
select(bucket_col, func.count())
|
|
||||||
.where(col(Task.status) == "review")
|
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return _series_from_mapping(range_spec, {})
|
|
||||||
statement = (
|
|
||||||
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
|
||||||
)
|
|
||||||
results = (await session.exec(statement)).all()
|
|
||||||
mapping = {row[0]: float(row[1]) for row in results}
|
|
||||||
return _series_from_mapping(range_spec, mapping)
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_cycle_time(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> DashboardRangeSeries:
|
|
||||||
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
|
|
||||||
in_progress = sql_cast(Task.in_progress_at, DateTime)
|
|
||||||
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
|
|
||||||
statement = (
|
|
||||||
select(bucket_col, func.avg(duration_hours))
|
|
||||||
.where(col(Task.status) == "review")
|
|
||||||
.where(col(Task.in_progress_at).is_not(None))
|
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return _series_from_mapping(range_spec, {})
|
|
||||||
statement = (
|
|
||||||
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
|
||||||
)
|
|
||||||
results = (await session.exec(statement)).all()
|
|
||||||
mapping = {row[0]: float(row[1] or 0) for row in results}
|
|
||||||
return _series_from_mapping(range_spec, mapping)
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_error_rate(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> DashboardRangeSeries:
|
|
||||||
bucket_col = func.date_trunc(
|
|
||||||
range_spec.bucket,
|
|
||||||
ActivityEvent.created_at,
|
|
||||||
).label("bucket")
|
|
||||||
error_case = case(
|
|
||||||
(
|
|
||||||
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
statement = (
|
|
||||||
select(bucket_col, func.sum(error_case), func.count())
|
|
||||||
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
|
||||||
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
|
||||||
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return _series_from_mapping(range_spec, {})
|
|
||||||
statement = (
|
|
||||||
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
|
|
||||||
)
|
|
||||||
results = (await session.exec(statement)).all()
|
|
||||||
mapping: dict[datetime, float] = {}
|
|
||||||
for bucket, errors, total in results:
|
|
||||||
total_count = float(total or 0)
|
|
||||||
error_count = float(errors or 0)
|
|
||||||
rate = (error_count / total_count) * 100 if total_count > 0 else 0.0
|
|
||||||
mapping[bucket] = rate
|
|
||||||
return _series_from_mapping(range_spec, mapping)
|
|
||||||
|
|
||||||
|
|
||||||
async def _query_wip(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> DashboardWipRangeSeries:
|
|
||||||
if not board_ids:
|
|
||||||
return _wip_series_from_mapping(range_spec, {})
|
|
||||||
|
|
||||||
inbox_bucket_col = func.date_trunc(range_spec.bucket, Task.created_at).label("inbox_bucket")
|
|
||||||
inbox_statement = (
|
|
||||||
select(inbox_bucket_col, func.count())
|
|
||||||
.where(col(Task.status) == "inbox")
|
|
||||||
.where(col(Task.created_at) >= range_spec.start)
|
|
||||||
.where(col(Task.created_at) <= range_spec.end)
|
|
||||||
.where(col(Task.board_id).in_(board_ids))
|
|
||||||
.group_by(inbox_bucket_col)
|
|
||||||
.order_by(inbox_bucket_col)
|
|
||||||
)
|
|
||||||
inbox_results = (await session.exec(inbox_statement)).all()
|
|
||||||
|
|
||||||
status_bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("status_bucket")
|
|
||||||
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
|
|
||||||
review_case = case((col(Task.status) == "review", 1), else_=0)
|
|
||||||
done_case = case((col(Task.status) == "done", 1), else_=0)
|
|
||||||
status_statement = (
|
|
||||||
select(
|
|
||||||
status_bucket_col,
|
|
||||||
func.sum(progress_case),
|
|
||||||
func.sum(review_case),
|
|
||||||
func.sum(done_case),
|
|
||||||
)
|
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
|
||||||
.where(col(Task.board_id).in_(board_ids))
|
|
||||||
.group_by(status_bucket_col)
|
|
||||||
.order_by(status_bucket_col)
|
|
||||||
)
|
|
||||||
status_results = (await session.exec(status_statement)).all()
|
|
||||||
|
|
||||||
mapping: dict[datetime, dict[str, int]] = {}
|
|
||||||
for bucket, inbox in inbox_results:
|
|
||||||
values = mapping.setdefault(bucket, {})
|
|
||||||
values["inbox"] = int(inbox or 0)
|
|
||||||
for bucket, in_progress, review, done in status_results:
|
|
||||||
values = mapping.setdefault(bucket, {})
|
|
||||||
values["in_progress"] = int(in_progress or 0)
|
|
||||||
values["review"] = int(review or 0)
|
|
||||||
values["done"] = int(done or 0)
|
|
||||||
return _wip_series_from_mapping(range_spec, mapping)
|
|
||||||
|
|
||||||
|
|
||||||
async def _median_cycle_time_for_range(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> float | None:
|
|
||||||
in_progress = sql_cast(Task.in_progress_at, DateTime)
|
|
||||||
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
|
|
||||||
statement = (
|
|
||||||
select(func.percentile_cont(0.5).within_group(duration_hours))
|
|
||||||
.where(col(Task.status) == "review")
|
|
||||||
.where(col(Task.in_progress_at).is_not(None))
|
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return None
|
|
||||||
statement = statement.where(col(Task.board_id).in_(board_ids))
|
|
||||||
value = (await session.exec(statement)).one_or_none()
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
value = value[0]
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
|
|
||||||
async def _error_rate_kpi(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> float:
|
|
||||||
error_case = case(
|
|
||||||
(
|
|
||||||
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
statement = (
|
|
||||||
select(func.sum(error_case), func.count())
|
|
||||||
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
|
|
||||||
.where(col(ActivityEvent.created_at) >= range_spec.start)
|
|
||||||
.where(col(ActivityEvent.created_at) <= range_spec.end)
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return 0.0
|
|
||||||
statement = statement.where(col(Task.board_id).in_(board_ids))
|
|
||||||
result = (await session.exec(statement)).one_or_none()
|
|
||||||
if result is None:
|
|
||||||
return 0.0
|
|
||||||
errors, total = result
|
|
||||||
total_count = float(total or 0)
|
|
||||||
error_count = float(errors or 0)
|
|
||||||
return (error_count / total_count) * 100 if total_count > 0 else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
async def _active_agents(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> int:
|
|
||||||
statement = select(func.count()).where(
|
|
||||||
col(Agent.last_seen_at).is_not(None),
|
|
||||||
col(Agent.last_seen_at) >= range_spec.start,
|
|
||||||
col(Agent.last_seen_at) <= range_spec.end,
|
|
||||||
)
|
|
||||||
if not board_ids:
|
|
||||||
return 0
|
|
||||||
statement = statement.where(col(Agent.board_id).in_(board_ids))
|
|
||||||
result = (await session.exec(statement)).one()
|
|
||||||
return int(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def _tasks_in_progress(
|
|
||||||
session: AsyncSession,
|
|
||||||
range_spec: RangeSpec,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> int:
|
|
||||||
if not board_ids:
|
|
||||||
return 0
|
|
||||||
statement = (
|
|
||||||
select(func.count())
|
|
||||||
.where(col(Task.status) == "in_progress")
|
|
||||||
.where(col(Task.updated_at) >= range_spec.start)
|
|
||||||
.where(col(Task.updated_at) <= range_spec.end)
|
|
||||||
.where(col(Task.board_id).in_(board_ids))
|
|
||||||
)
|
|
||||||
result = (await session.exec(statement)).one()
|
|
||||||
return int(result)
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_dashboard_board_ids(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
ctx: OrganizationContext,
|
|
||||||
board_id: UUID | None,
|
|
||||||
group_id: UUID | None,
|
|
||||||
) -> list[UUID]:
|
|
||||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
|
||||||
if not board_ids:
|
|
||||||
return []
|
|
||||||
allowed = set(board_ids)
|
|
||||||
|
|
||||||
if board_id is not None and board_id not in allowed:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
if group_id is None:
|
|
||||||
return [board_id] if board_id is not None else board_ids
|
|
||||||
|
|
||||||
group_board_ids = list(
|
|
||||||
await session.exec(
|
|
||||||
select(Board.id)
|
|
||||||
.where(col(Board.organization_id) == ctx.member.organization_id)
|
|
||||||
.where(col(Board.board_group_id) == group_id)
|
|
||||||
.where(col(Board.id).in_(board_ids)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if board_id is not None:
|
|
||||||
return [board_id] if board_id in set(group_board_ids) else []
|
|
||||||
return group_board_ids
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard", response_model=DashboardMetrics)
|
|
||||||
async def dashboard_metrics(
|
|
||||||
range_key: DashboardRangeKey = RANGE_QUERY,
|
|
||||||
board_id: UUID | None = BOARD_ID_QUERY,
|
|
||||||
group_id: UUID | None = GROUP_ID_QUERY,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> DashboardMetrics:
|
|
||||||
"""Return dashboard KPIs and time-series data for accessible boards."""
|
|
||||||
primary = _resolve_range(range_key)
|
|
||||||
comparison = _comparison_range(primary)
|
|
||||||
board_ids = await _resolve_dashboard_board_ids(
|
|
||||||
session,
|
|
||||||
ctx=ctx,
|
|
||||||
board_id=board_id,
|
|
||||||
group_id=group_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
throughput_primary = await _query_throughput(session, primary, board_ids)
|
|
||||||
throughput_comparison = await _query_throughput(session, comparison, board_ids)
|
|
||||||
throughput = DashboardSeriesSet(
|
|
||||||
primary=throughput_primary,
|
|
||||||
comparison=throughput_comparison,
|
|
||||||
)
|
|
||||||
cycle_time_primary = await _query_cycle_time(session, primary, board_ids)
|
|
||||||
cycle_time_comparison = await _query_cycle_time(session, comparison, board_ids)
|
|
||||||
cycle_time = DashboardSeriesSet(
|
|
||||||
primary=cycle_time_primary,
|
|
||||||
comparison=cycle_time_comparison,
|
|
||||||
)
|
|
||||||
error_rate_primary = await _query_error_rate(session, primary, board_ids)
|
|
||||||
error_rate_comparison = await _query_error_rate(session, comparison, board_ids)
|
|
||||||
error_rate = DashboardSeriesSet(
|
|
||||||
primary=error_rate_primary,
|
|
||||||
comparison=error_rate_comparison,
|
|
||||||
)
|
|
||||||
wip_primary = await _query_wip(session, primary, board_ids)
|
|
||||||
wip_comparison = await _query_wip(session, comparison, board_ids)
|
|
||||||
wip = DashboardWipSeriesSet(
|
|
||||||
primary=wip_primary,
|
|
||||||
comparison=wip_comparison,
|
|
||||||
)
|
|
||||||
|
|
||||||
kpis = DashboardKpis(
|
|
||||||
active_agents=await _active_agents(session, primary, board_ids),
|
|
||||||
tasks_in_progress=await _tasks_in_progress(session, primary, board_ids),
|
|
||||||
error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
|
|
||||||
median_cycle_time_hours_7d=await _median_cycle_time_for_range(
|
|
||||||
session,
|
|
||||||
primary,
|
|
||||||
board_ids,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return DashboardMetrics(
|
|
||||||
range=primary.key,
|
|
||||||
generated_at=utcnow(),
|
|
||||||
kpis=kpis,
|
|
||||||
throughput=throughput,
|
|
||||||
cycle_time=cycle_time,
|
|
||||||
error_rate=error_rate,
|
|
||||||
wip=wip,
|
|
||||||
)
|
|
||||||
@@ -1,730 +0,0 @@
|
|||||||
"""Organization management endpoints and membership/invite flows."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin, require_org_member
|
|
||||||
from app.core.auth import get_auth_context
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.activity_events import ActivityEvent
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.approval_task_links import ApprovalTaskLink
|
|
||||||
from app.models.approvals import Approval
|
|
||||||
from app.models.board_group_memory import BoardGroupMemory
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.board_memory import BoardMemory
|
|
||||||
from app.models.board_onboarding import BoardOnboardingSession
|
|
||||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
|
||||||
from app.models.board_webhooks import BoardWebhook
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.models.organization_board_access import OrganizationBoardAccess
|
|
||||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
|
||||||
from app.models.organization_invites import OrganizationInvite
|
|
||||||
from app.models.organization_members import OrganizationMember
|
|
||||||
from app.models.organizations import Organization
|
|
||||||
from app.models.task_dependencies import TaskDependency
|
|
||||||
from app.models.task_fingerprints import TaskFingerprint
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.models.users import User
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.organizations import (
|
|
||||||
OrganizationActiveUpdate,
|
|
||||||
OrganizationBoardAccessRead,
|
|
||||||
OrganizationCreate,
|
|
||||||
OrganizationInviteAccept,
|
|
||||||
OrganizationInviteCreate,
|
|
||||||
OrganizationInviteRead,
|
|
||||||
OrganizationListItem,
|
|
||||||
OrganizationMemberAccessUpdate,
|
|
||||||
OrganizationMemberRead,
|
|
||||||
OrganizationMemberUpdate,
|
|
||||||
OrganizationRead,
|
|
||||||
OrganizationUserRead,
|
|
||||||
)
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.services.organizations import (
|
|
||||||
OrganizationContext,
|
|
||||||
accept_invite,
|
|
||||||
apply_invite_board_access,
|
|
||||||
apply_invite_to_member,
|
|
||||||
apply_member_access_update,
|
|
||||||
get_active_membership,
|
|
||||||
get_member,
|
|
||||||
is_org_admin,
|
|
||||||
normalize_invited_email,
|
|
||||||
normalize_role,
|
|
||||||
set_active_organization,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app.core.auth import AuthContext
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def _member_to_read(
|
|
||||||
member: OrganizationMember,
|
|
||||||
user: User | None,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
model = OrganizationMemberRead.model_validate(member, from_attributes=True)
|
|
||||||
if user is not None:
|
|
||||||
model.user = OrganizationUserRead.model_validate(user, from_attributes=True)
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_org_member(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
organization_id: UUID,
|
|
||||||
member_id: UUID,
|
|
||||||
) -> OrganizationMember:
|
|
||||||
member = await OrganizationMember.objects.by_id(member_id).first(session)
|
|
||||||
if member is None or member.organization_id != organization_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return member
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_org_invite(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
organization_id: UUID,
|
|
||||||
invite_id: UUID,
|
|
||||||
) -> OrganizationInvite:
|
|
||||||
invite = await OrganizationInvite.objects.by_id(invite_id).first(session)
|
|
||||||
if invite is None or invite.organization_id != organization_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return invite
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=OrganizationRead)
|
|
||||||
async def create_organization(
|
|
||||||
payload: OrganizationCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> OrganizationRead:
|
|
||||||
"""Create an organization and assign the caller as owner."""
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
name = payload.name.strip()
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
|
||||||
existing = (
|
|
||||||
await session.exec(
|
|
||||||
select(Organization).where(
|
|
||||||
func.lower(col(Organization.name)) == name.lower(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if existing is not None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
|
||||||
|
|
||||||
now = utcnow()
|
|
||||||
org = Organization(name=name, created_at=now, updated_at=now)
|
|
||||||
session.add(org)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
member = OrganizationMember(
|
|
||||||
organization_id=org.id,
|
|
||||||
user_id=auth.user.id,
|
|
||||||
role="owner",
|
|
||||||
all_boards_read=True,
|
|
||||||
all_boards_write=True,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
session.add(member)
|
|
||||||
await session.flush()
|
|
||||||
await set_active_organization(session, user=auth.user, organization_id=org.id)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(org)
|
|
||||||
return OrganizationRead.model_validate(org, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/list", response_model=list[OrganizationListItem])
|
|
||||||
async def list_my_organizations(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> list[OrganizationListItem]:
|
|
||||||
"""List organizations where the current user is a member."""
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
await get_active_membership(session, auth.user)
|
|
||||||
db_user = await User.objects.by_id(auth.user.id).first(session)
|
|
||||||
active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id
|
|
||||||
|
|
||||||
statement = (
|
|
||||||
select(Organization, OrganizationMember)
|
|
||||||
.join(
|
|
||||||
OrganizationMember,
|
|
||||||
col(OrganizationMember.organization_id) == col(Organization.id),
|
|
||||||
)
|
|
||||||
.where(col(OrganizationMember.user_id) == auth.user.id)
|
|
||||||
.order_by(func.lower(col(Organization.name)).asc())
|
|
||||||
)
|
|
||||||
rows = list(await session.exec(statement))
|
|
||||||
return [
|
|
||||||
OrganizationListItem(
|
|
||||||
id=org.id,
|
|
||||||
name=org.name,
|
|
||||||
role=member.role,
|
|
||||||
is_active=org.id == active_id,
|
|
||||||
)
|
|
||||||
for org, member in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/active", response_model=OrganizationRead)
|
|
||||||
async def set_active_org(
|
|
||||||
payload: OrganizationActiveUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> OrganizationRead:
|
|
||||||
"""Set the caller's active organization."""
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
member = await set_active_organization(
|
|
||||||
session,
|
|
||||||
user=auth.user,
|
|
||||||
organization_id=payload.organization_id,
|
|
||||||
)
|
|
||||||
organization = await Organization.objects.by_id(member.organization_id).first(
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
if organization is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return OrganizationRead.model_validate(organization, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=OrganizationRead)
|
|
||||||
async def get_my_org(
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> OrganizationRead:
|
|
||||||
"""Return the caller's active organization."""
|
|
||||||
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me", response_model=OkResponse)
|
|
||||||
async def delete_my_org(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete the active organization and related entities."""
|
|
||||||
if ctx.member.role != "owner":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Only organization owners can delete organizations",
|
|
||||||
)
|
|
||||||
|
|
||||||
org_id = ctx.organization.id
|
|
||||||
board_ids = select(Board.id).where(col(Board.organization_id) == org_id)
|
|
||||||
task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
|
|
||||||
agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
|
|
||||||
member_ids = select(OrganizationMember.id).where(
|
|
||||||
col(OrganizationMember.organization_id) == org_id,
|
|
||||||
)
|
|
||||||
invite_ids = select(OrganizationInvite.id).where(
|
|
||||||
col(OrganizationInvite.organization_id) == org_id,
|
|
||||||
)
|
|
||||||
group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id)
|
|
||||||
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ActivityEvent,
|
|
||||||
col(ActivityEvent.task_id).in_(task_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ActivityEvent,
|
|
||||||
col(ActivityEvent.agent_id).in_(agent_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
TaskDependency,
|
|
||||||
col(TaskDependency.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
TaskFingerprint,
|
|
||||||
col(TaskFingerprint.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ApprovalTaskLink,
|
|
||||||
col(ApprovalTaskLink.approval_id).in_(
|
|
||||||
select(Approval.id).where(col(Approval.board_id).in_(board_ids))
|
|
||||||
),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Approval,
|
|
||||||
col(Approval.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardMemory,
|
|
||||||
col(BoardMemory.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardWebhookPayload,
|
|
||||||
col(BoardWebhookPayload.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardWebhook,
|
|
||||||
col(BoardWebhook.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardOnboardingSession,
|
|
||||||
col(BoardOnboardingSession.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInviteBoardAccess,
|
|
||||||
col(OrganizationInviteBoardAccess.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.organization_member_id).in_(member_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInviteBoardAccess,
|
|
||||||
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Task,
|
|
||||||
col(Task.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Agent,
|
|
||||||
col(Agent.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Board,
|
|
||||||
col(Board.organization_id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroupMemory,
|
|
||||||
col(BoardGroupMemory.board_group_id).in_(group_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroup,
|
|
||||||
col(BoardGroup.organization_id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Gateway,
|
|
||||||
col(Gateway.organization_id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInvite,
|
|
||||||
col(OrganizationInvite.organization_id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationMember,
|
|
||||||
col(OrganizationMember.organization_id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
User,
|
|
||||||
col(User.active_organization_id) == org_id,
|
|
||||||
active_organization_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Organization,
|
|
||||||
col(Organization.id) == org_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/member", response_model=OrganizationMemberRead)
|
|
||||||
async def get_my_membership(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
"""Get the caller's membership record in the active organization."""
|
|
||||||
user = await User.objects.by_id(ctx.member.user_id).first(session)
|
|
||||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
|
||||||
organization_member_id=ctx.member.id,
|
|
||||||
).all(session)
|
|
||||||
model = _member_to_read(ctx.member, user)
|
|
||||||
model.board_access = [
|
|
||||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
|
||||||
]
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/members",
|
|
||||||
response_model=DefaultLimitOffsetPage[OrganizationMemberRead],
|
|
||||||
)
|
|
||||||
async def list_org_members(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[OrganizationMemberRead]:
|
|
||||||
"""List members for the active organization."""
|
|
||||||
statement = (
|
|
||||||
select(OrganizationMember, User)
|
|
||||||
.join(User, col(User.id) == col(OrganizationMember.user_id))
|
|
||||||
.where(col(OrganizationMember.organization_id) == ctx.organization.id)
|
|
||||||
.order_by(func.lower(col(User.email)).asc(), col(User.name).asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
|
||||||
output: list[OrganizationMemberRead] = []
|
|
||||||
for member, user in items:
|
|
||||||
output.append(_member_to_read(member, user))
|
|
||||||
return output
|
|
||||||
|
|
||||||
return await paginate(session, statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
|
||||||
async def get_org_member(
|
|
||||||
member_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
"""Get a specific organization member by id."""
|
|
||||||
member = await _require_org_member(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
member_id=member_id,
|
|
||||||
)
|
|
||||||
if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
user = await User.objects.by_id(member.user_id).first(session)
|
|
||||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
|
||||||
organization_member_id=member.id,
|
|
||||||
).all(session)
|
|
||||||
model = _member_to_read(member, user)
|
|
||||||
model.board_access = [
|
|
||||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
|
||||||
]
|
|
||||||
return model
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
|
||||||
async def update_org_member(
|
|
||||||
member_id: UUID,
|
|
||||||
payload: OrganizationMemberUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
"""Update a member's role in the organization."""
|
|
||||||
member = await _require_org_member(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
member_id=member_id,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
if "role" in updates and updates["role"] is not None:
|
|
||||||
updates["role"] = normalize_role(updates["role"])
|
|
||||||
updates["updated_at"] = utcnow()
|
|
||||||
member = await crud.patch(session, member, updates)
|
|
||||||
user = await User.objects.by_id(member.user_id).first(session)
|
|
||||||
return _member_to_read(member, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/members/{member_id}/access", response_model=OrganizationMemberRead)
|
|
||||||
async def update_member_access(
|
|
||||||
member_id: UUID,
|
|
||||||
payload: OrganizationMemberAccessUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
"""Update board-level access settings for a member."""
|
|
||||||
member = await _require_org_member(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
member_id=member_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
board_ids = {entry.board_id for entry in payload.board_access}
|
|
||||||
if board_ids:
|
|
||||||
valid_board_ids = {
|
|
||||||
board.id
|
|
||||||
for board in await Board.objects.filter_by(
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
.filter(col(Board.id).in_(board_ids))
|
|
||||||
.all(session)
|
|
||||||
}
|
|
||||||
if valid_board_ids != board_ids:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
|
||||||
|
|
||||||
await apply_member_access_update(session, member=member, update=payload)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(member)
|
|
||||||
user = await User.objects.by_id(member.user_id).first(session)
|
|
||||||
return _member_to_read(member, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me/members/{member_id}", response_model=OkResponse)
|
|
||||||
async def remove_org_member(
|
|
||||||
member_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Remove a member from the active organization."""
|
|
||||||
member = await _require_org_member(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
member_id=member_id,
|
|
||||||
)
|
|
||||||
if member.user_id == ctx.member.user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="You cannot remove yourself from the organization",
|
|
||||||
)
|
|
||||||
if member.role == "owner" and ctx.member.role != "owner":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Only owners can remove owners",
|
|
||||||
)
|
|
||||||
if member.role == "owner":
|
|
||||||
owners = (
|
|
||||||
await OrganizationMember.objects.filter_by(
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
.filter(col(OrganizationMember.role) == "owner")
|
|
||||||
.all(session)
|
|
||||||
)
|
|
||||||
if len(owners) <= 1:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="Organization must have at least one owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.organization_member_id) == member.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
user = await User.objects.by_id(member.user_id).first(session)
|
|
||||||
if user is not None and user.active_organization_id == ctx.organization.id:
|
|
||||||
fallback_membership = (
|
|
||||||
await OrganizationMember.objects.filter(
|
|
||||||
col(OrganizationMember.user_id) == user.id,
|
|
||||||
col(OrganizationMember.organization_id) != ctx.organization.id,
|
|
||||||
)
|
|
||||||
.order_by(col(OrganizationMember.created_at).asc())
|
|
||||||
.first(session)
|
|
||||||
)
|
|
||||||
if isinstance(fallback_membership, UUID):
|
|
||||||
user.active_organization_id = fallback_membership
|
|
||||||
else:
|
|
||||||
user.active_organization_id = (
|
|
||||||
fallback_membership.organization_id if fallback_membership is not None else None
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
|
|
||||||
await crud.delete(session, member)
|
|
||||||
return OkResponse()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/invites",
|
|
||||||
response_model=DefaultLimitOffsetPage[OrganizationInviteRead],
|
|
||||||
)
|
|
||||||
async def list_org_invites(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> LimitOffsetPage[OrganizationInviteRead]:
|
|
||||||
"""List pending invites for the active organization."""
|
|
||||||
statement = (
|
|
||||||
OrganizationInvite.objects.filter_by(organization_id=ctx.organization.id)
|
|
||||||
.filter(col(OrganizationInvite.accepted_at).is_(None))
|
|
||||||
.order_by(col(OrganizationInvite.created_at).desc())
|
|
||||||
.statement
|
|
||||||
)
|
|
||||||
return await paginate(session, statement)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/invites", response_model=OrganizationInviteRead)
|
|
||||||
async def create_org_invite(
|
|
||||||
payload: OrganizationInviteCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OrganizationInviteRead:
|
|
||||||
"""Create an organization invite for an email address."""
|
|
||||||
email = normalize_invited_email(payload.invited_email)
|
|
||||||
if not email:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
|
||||||
|
|
||||||
existing_user = (
|
|
||||||
await session.exec(select(User).where(func.lower(col(User.email)) == email))
|
|
||||||
).first()
|
|
||||||
if existing_user is not None:
|
|
||||||
existing_member = await get_member(
|
|
||||||
session,
|
|
||||||
user_id=existing_user.id,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
if existing_member is not None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(24)
|
|
||||||
invite = OrganizationInvite(
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
invited_email=email,
|
|
||||||
token=token,
|
|
||||||
role=normalize_role(payload.role),
|
|
||||||
all_boards_read=payload.all_boards_read,
|
|
||||||
all_boards_write=payload.all_boards_write,
|
|
||||||
created_by_user_id=ctx.member.user_id,
|
|
||||||
created_at=utcnow(),
|
|
||||||
updated_at=utcnow(),
|
|
||||||
)
|
|
||||||
session.add(invite)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
board_ids = {entry.board_id for entry in payload.board_access}
|
|
||||||
if board_ids:
|
|
||||||
valid_board_ids = {
|
|
||||||
board.id
|
|
||||||
for board in await Board.objects.filter_by(
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
)
|
|
||||||
.filter(col(Board.id).in_(board_ids))
|
|
||||||
.all(session)
|
|
||||||
}
|
|
||||||
if valid_board_ids != board_ids:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
|
|
||||||
await apply_invite_board_access(
|
|
||||||
session,
|
|
||||||
invite=invite,
|
|
||||||
entries=payload.board_access,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(invite)
|
|
||||||
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead)
|
|
||||||
async def revoke_org_invite(
|
|
||||||
invite_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OrganizationInviteRead:
|
|
||||||
"""Revoke a pending invite from the active organization."""
|
|
||||||
invite = await _require_org_invite(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
invite_id=invite_id,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInviteBoardAccess,
|
|
||||||
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete(session, invite)
|
|
||||||
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/invites/accept", response_model=OrganizationMemberRead)
|
|
||||||
async def accept_org_invite(
|
|
||||||
payload: OrganizationInviteAccept,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_DEP,
|
|
||||||
) -> OrganizationMemberRead:
|
|
||||||
"""Accept an invite and return resulting membership."""
|
|
||||||
if auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
invite = await OrganizationInvite.objects.filter(
|
|
||||||
col(OrganizationInvite.token) == payload.token,
|
|
||||||
col(OrganizationInvite.accepted_at).is_(None),
|
|
||||||
).first(session)
|
|
||||||
if invite is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if (
|
|
||||||
invite.invited_email
|
|
||||||
and auth.user.email
|
|
||||||
and normalize_invited_email(invite.invited_email)
|
|
||||||
!= normalize_invited_email(auth.user.email)
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
existing = await get_member(
|
|
||||||
session,
|
|
||||||
user_id=auth.user.id,
|
|
||||||
organization_id=invite.organization_id,
|
|
||||||
)
|
|
||||||
if existing is None:
|
|
||||||
member = await accept_invite(session, invite, auth.user)
|
|
||||||
else:
|
|
||||||
await apply_invite_to_member(session, member=existing, invite=invite)
|
|
||||||
invite.accepted_by_user_id = auth.user.id
|
|
||||||
invite.accepted_at = utcnow()
|
|
||||||
invite.updated_at = utcnow()
|
|
||||||
session.add(invite)
|
|
||||||
await session.commit()
|
|
||||||
member = existing
|
|
||||||
|
|
||||||
user = await User.objects.by_id(member.user_id).first(session)
|
|
||||||
return _member_to_read(member, user)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""API-level thin wrapper around query-set helpers with HTTP conveniences."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from app.db.queryset import QuerySet, qs
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.orm import Mapped
|
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
from sqlmodel.sql.expression import SelectOfScalar
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class APIQuerySet(Generic[ModelT]):
|
|
||||||
"""Immutable query-set wrapper tailored for API-layer usage."""
|
|
||||||
|
|
||||||
queryset: QuerySet[ModelT]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def statement(self) -> SelectOfScalar[ModelT]:
|
|
||||||
"""Expose the underlying SQL statement for advanced composition."""
|
|
||||||
return self.queryset.statement
|
|
||||||
|
|
||||||
def filter(
|
|
||||||
self,
|
|
||||||
*criteria: ColumnElement[bool] | bool,
|
|
||||||
) -> APIQuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with additional SQL criteria applied."""
|
|
||||||
return APIQuerySet(self.queryset.filter(*criteria))
|
|
||||||
|
|
||||||
def order_by(
|
|
||||||
self,
|
|
||||||
*ordering: Mapped[Any] | ColumnElement[Any] | str,
|
|
||||||
) -> APIQuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with ordering clauses applied."""
|
|
||||||
return APIQuerySet(self.queryset.order_by(*ordering))
|
|
||||||
|
|
||||||
def limit(self, value: int) -> APIQuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with a row limit applied."""
|
|
||||||
return APIQuerySet(self.queryset.limit(value))
|
|
||||||
|
|
||||||
def offset(self, value: int) -> APIQuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with an offset applied."""
|
|
||||||
return APIQuerySet(self.queryset.offset(value))
|
|
||||||
|
|
||||||
async def all(self, session: AsyncSession) -> list[ModelT]:
|
|
||||||
"""Fetch all rows for the current queryset."""
|
|
||||||
return await self.queryset.all(session)
|
|
||||||
|
|
||||||
async def first(self, session: AsyncSession) -> ModelT | None:
|
|
||||||
"""Fetch the first row for the current queryset, if present."""
|
|
||||||
return await self.queryset.first(session)
|
|
||||||
|
|
||||||
async def first_or_404(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
detail: str | None = None,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Fetch the first row or raise HTTP 404 when no row exists."""
|
|
||||||
obj = await self.first(session)
|
|
||||||
if obj is not None:
|
|
||||||
return obj
|
|
||||||
if detail is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
|
||||||
|
|
||||||
|
|
||||||
def api_qs(model: type[ModelT]) -> APIQuerySet[ModelT]:
|
|
||||||
"""Create an APIQuerySet for a SQLModel class."""
|
|
||||||
return APIQuerySet(qs(model))
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
|||||||
"""API routes for searching and fetching souls-directory markdown entries."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_or_agent
|
|
||||||
from app.schemas.souls_directory import (
|
|
||||||
SoulsDirectoryMarkdownResponse,
|
|
||||||
SoulsDirectorySearchResponse,
|
|
||||||
SoulsDirectorySoulRef,
|
|
||||||
)
|
|
||||||
from app.services import souls_directory
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/souls-directory", tags=["souls-directory"])
|
|
||||||
ADMIN_OR_AGENT_DEP = Depends(require_admin_or_agent)
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
||||||
_SAFE_SLUG_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_segment(value: str, *, field: str) -> str:
|
|
||||||
cleaned = value.strip().strip("/")
|
|
||||||
if not cleaned:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"{field} is required",
|
|
||||||
)
|
|
||||||
if field == "handle":
|
|
||||||
ok = bool(_SAFE_SEGMENT_RE.match(cleaned))
|
|
||||||
else:
|
|
||||||
ok = bool(_SAFE_SLUG_RE.match(cleaned))
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"{field} contains unsupported characters",
|
|
||||||
)
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/search", response_model=SoulsDirectorySearchResponse)
|
|
||||||
async def search(
|
|
||||||
q: str = Query(default="", min_length=0),
|
|
||||||
limit: int = Query(default=20, ge=1, le=100),
|
|
||||||
_actor: ActorContext = ADMIN_OR_AGENT_DEP,
|
|
||||||
) -> SoulsDirectorySearchResponse:
|
|
||||||
"""Search souls-directory entries by handle/slug query text."""
|
|
||||||
refs = await souls_directory.list_souls_directory_refs()
|
|
||||||
matches = souls_directory.search_souls(refs, query=q, limit=limit)
|
|
||||||
items = [
|
|
||||||
SoulsDirectorySoulRef(
|
|
||||||
handle=ref.handle,
|
|
||||||
slug=ref.slug,
|
|
||||||
page_url=ref.page_url,
|
|
||||||
raw_md_url=ref.raw_md_url,
|
|
||||||
)
|
|
||||||
for ref in matches
|
|
||||||
]
|
|
||||||
return SoulsDirectorySearchResponse(items=items)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{handle}/{slug}.md", response_model=SoulsDirectoryMarkdownResponse)
|
|
||||||
@router.get("/{handle}/{slug}", response_model=SoulsDirectoryMarkdownResponse)
|
|
||||||
async def get_markdown(
|
|
||||||
handle: str,
|
|
||||||
slug: str,
|
|
||||||
_actor: ActorContext = ADMIN_OR_AGENT_DEP,
|
|
||||||
) -> SoulsDirectoryMarkdownResponse:
|
|
||||||
"""Fetch markdown content for a validated souls-directory handle and slug."""
|
|
||||||
safe_handle = _validate_segment(handle, field="handle")
|
|
||||||
safe_slug = _validate_segment(slug.removesuffix(".md"), field="slug")
|
|
||||||
try:
|
|
||||||
content = await souls_directory.fetch_soul_markdown(
|
|
||||||
handle=safe_handle,
|
|
||||||
slug=safe_slug,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
return SoulsDirectoryMarkdownResponse(
|
|
||||||
handle=safe_handle,
|
|
||||||
slug=safe_slug,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""Tag CRUD endpoints for organization-scoped task categorization."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin, require_org_member
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.pagination import paginate
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.tag_assignments import TagAssignment
|
|
||||||
from app.models.tags import Tag
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
from app.schemas.tags import TagCreate, TagRead, TagUpdate
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
from app.services.tags import slugify_tag, task_counts_for_tags
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_slug(slug: str | None, *, fallback_name: str) -> str:
|
|
||||||
source = (slug or "").strip() or fallback_name
|
|
||||||
return slugify_tag(source)
|
|
||||||
|
|
||||||
|
|
||||||
async def _require_org_tag(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
tag_id: UUID,
|
|
||||||
ctx: OrganizationContext,
|
|
||||||
) -> Tag:
|
|
||||||
tag = await Tag.objects.by_id(tag_id).first(session)
|
|
||||||
if tag is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
if tag.organization_id != ctx.organization.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
return tag
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_slug_available(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
organization_id: UUID,
|
|
||||||
slug: str,
|
|
||||||
exclude_tag_id: UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
existing = await Tag.objects.filter_by(organization_id=organization_id, slug=slug).first(
|
|
||||||
session
|
|
||||||
)
|
|
||||||
if existing is None:
|
|
||||||
return
|
|
||||||
if exclude_tag_id is not None and existing.id == exclude_tag_id:
|
|
||||||
return
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Tag slug already exists in this organization.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _tag_read_page(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
items: Sequence[Tag],
|
|
||||||
) -> list[TagRead]:
|
|
||||||
if not items:
|
|
||||||
return []
|
|
||||||
counts = await task_counts_for_tags(
|
|
||||||
session,
|
|
||||||
tag_ids=[item.id for item in items],
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
TagRead.model_validate(item, from_attributes=True).model_copy(
|
|
||||||
update={"task_count": counts.get(item.id, 0)},
|
|
||||||
)
|
|
||||||
for item in items
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DefaultLimitOffsetPage[TagRead])
|
|
||||||
async def list_tags(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> LimitOffsetPage[TagRead]:
|
|
||||||
"""List tags for the active organization."""
|
|
||||||
statement = (
|
|
||||||
select(Tag)
|
|
||||||
.where(col(Tag.organization_id) == ctx.organization.id)
|
|
||||||
.order_by(func.lower(col(Tag.name)).asc(), col(Tag.created_at).asc())
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _transform(items: Sequence[object]) -> Sequence[object]:
|
|
||||||
tags: list[Tag] = []
|
|
||||||
for item in items:
|
|
||||||
if not isinstance(item, Tag):
|
|
||||||
msg = "Expected Tag items from paginated query"
|
|
||||||
raise TypeError(msg)
|
|
||||||
tags.append(item)
|
|
||||||
return await _tag_read_page(session=session, items=tags)
|
|
||||||
|
|
||||||
return await paginate(session, statement, transformer=_transform)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TagRead)
|
|
||||||
async def create_tag(
|
|
||||||
payload: TagCreate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> TagRead:
|
|
||||||
"""Create a tag within the active organization."""
|
|
||||||
slug = _normalize_slug(payload.slug, fallback_name=payload.name)
|
|
||||||
await _ensure_slug_available(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
slug=slug,
|
|
||||||
)
|
|
||||||
tag = await crud.create(
|
|
||||||
session,
|
|
||||||
Tag,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
name=payload.name,
|
|
||||||
slug=slug,
|
|
||||||
color=payload.color,
|
|
||||||
description=payload.description,
|
|
||||||
)
|
|
||||||
return TagRead.model_validate(tag, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tag_id}", response_model=TagRead)
|
|
||||||
async def get_tag(
|
|
||||||
tag_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
) -> TagRead:
|
|
||||||
"""Get a single tag in the active organization."""
|
|
||||||
tag = await _require_org_tag(
|
|
||||||
session,
|
|
||||||
tag_id=tag_id,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
count = (
|
|
||||||
await session.exec(
|
|
||||||
select(func.count(col(TagAssignment.task_id))).where(
|
|
||||||
col(TagAssignment.tag_id) == tag.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
return TagRead.model_validate(tag, from_attributes=True).model_copy(
|
|
||||||
update={"task_count": int(count or 0)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{tag_id}", response_model=TagRead)
|
|
||||||
async def update_tag(
|
|
||||||
tag_id: UUID,
|
|
||||||
payload: TagUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> TagRead:
|
|
||||||
"""Update a tag in the active organization."""
|
|
||||||
tag = await _require_org_tag(
|
|
||||||
session,
|
|
||||||
tag_id=tag_id,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
|
|
||||||
if "slug" in payload.model_fields_set:
|
|
||||||
updates["slug"] = _normalize_slug(
|
|
||||||
updates.get("slug"),
|
|
||||||
fallback_name=str(updates.get("name") or tag.name),
|
|
||||||
)
|
|
||||||
if "slug" in updates and isinstance(updates["slug"], str):
|
|
||||||
await _ensure_slug_available(
|
|
||||||
session,
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
slug=updates["slug"],
|
|
||||||
exclude_tag_id=tag.id,
|
|
||||||
)
|
|
||||||
updates["updated_at"] = utcnow()
|
|
||||||
updated = await crud.patch(session, tag, updates)
|
|
||||||
return TagRead.model_validate(updated, from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}", response_model=OkResponse)
|
|
||||||
async def delete_tag(
|
|
||||||
tag_id: UUID,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete a tag and remove all associated tag links."""
|
|
||||||
tag = await _require_org_tag(
|
|
||||||
session,
|
|
||||||
tag_id=tag_id,
|
|
||||||
ctx=ctx,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
TagAssignment,
|
|
||||||
col(TagAssignment.tag_id) == tag.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await session.delete(tag)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"""Organization-level task custom field definition management."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.api.deps import require_org_admin, require_org_member
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.task_custom_fields import (
|
|
||||||
BoardTaskCustomField,
|
|
||||||
TaskCustomFieldDefinition,
|
|
||||||
TaskCustomFieldValue,
|
|
||||||
)
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.task_custom_fields import (
|
|
||||||
TaskCustomFieldDefinitionCreate,
|
|
||||||
TaskCustomFieldDefinitionRead,
|
|
||||||
TaskCustomFieldDefinitionUpdate,
|
|
||||||
validate_custom_field_definition,
|
|
||||||
)
|
|
||||||
from app.services.organizations import OrganizationContext
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations/me/custom-fields", tags=["custom-fields"])
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_definition_read_payload(
|
|
||||||
*,
|
|
||||||
definition: TaskCustomFieldDefinition,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> TaskCustomFieldDefinitionRead:
|
|
||||||
payload = TaskCustomFieldDefinitionRead.model_validate(definition, from_attributes=True)
|
|
||||||
payload.board_ids = board_ids
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
async def _board_ids_by_definition_id(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
definition_ids: list[UUID],
|
|
||||||
) -> dict[UUID, list[UUID]]:
|
|
||||||
if not definition_ids:
|
|
||||||
return {}
|
|
||||||
rows = (
|
|
||||||
await session.exec(
|
|
||||||
select(
|
|
||||||
col(BoardTaskCustomField.task_custom_field_definition_id),
|
|
||||||
col(BoardTaskCustomField.board_id),
|
|
||||||
).where(
|
|
||||||
col(BoardTaskCustomField.task_custom_field_definition_id).in_(definition_ids),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
board_ids_by_definition_id: dict[UUID, list[UUID]] = {
|
|
||||||
definition_id: [] for definition_id in definition_ids
|
|
||||||
}
|
|
||||||
for definition_id, board_id in rows:
|
|
||||||
board_ids_by_definition_id.setdefault(definition_id, []).append(board_id)
|
|
||||||
for definition_id in board_ids_by_definition_id:
|
|
||||||
board_ids_by_definition_id[definition_id].sort(key=str)
|
|
||||||
return board_ids_by_definition_id
|
|
||||||
|
|
||||||
|
|
||||||
async def _validated_board_ids_for_org(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
ctx: OrganizationContext,
|
|
||||||
board_ids: list[UUID],
|
|
||||||
) -> list[UUID]:
|
|
||||||
normalized_board_ids = list(dict.fromkeys(board_ids))
|
|
||||||
if not normalized_board_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail="At least one board must be selected.",
|
|
||||||
)
|
|
||||||
valid_board_ids = set(
|
|
||||||
(
|
|
||||||
await session.exec(
|
|
||||||
select(col(Board.id)).where(
|
|
||||||
col(Board.organization_id) == ctx.organization.id,
|
|
||||||
col(Board.id).in_(normalized_board_ids),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).all(),
|
|
||||||
)
|
|
||||||
missing_board_ids = sorted(
|
|
||||||
{board_id for board_id in normalized_board_ids if board_id not in valid_board_ids},
|
|
||||||
key=str,
|
|
||||||
)
|
|
||||||
if missing_board_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail={
|
|
||||||
"message": "Some selected boards are invalid for this organization.",
|
|
||||||
"invalid_board_ids": [str(value) for value in missing_board_ids],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return normalized_board_ids
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_org_definition(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
ctx: OrganizationContext,
|
|
||||||
definition_id: UUID,
|
|
||||||
) -> TaskCustomFieldDefinition:
|
|
||||||
definition = (
|
|
||||||
await session.exec(
|
|
||||||
select(TaskCustomFieldDefinition).where(
|
|
||||||
col(TaskCustomFieldDefinition.id) == definition_id,
|
|
||||||
col(TaskCustomFieldDefinition.organization_id) == ctx.organization.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if definition is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
return definition
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TaskCustomFieldDefinitionRead])
|
|
||||||
async def list_org_custom_fields(
|
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> list[TaskCustomFieldDefinitionRead]:
|
|
||||||
"""List task custom field definitions for the authenticated organization."""
|
|
||||||
definitions = list(
|
|
||||||
await session.exec(
|
|
||||||
select(TaskCustomFieldDefinition)
|
|
||||||
.where(col(TaskCustomFieldDefinition.organization_id) == ctx.organization.id)
|
|
||||||
.order_by(func.lower(col(TaskCustomFieldDefinition.label)).asc()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
board_ids_by_definition_id = await _board_ids_by_definition_id(
|
|
||||||
session=session,
|
|
||||||
definition_ids=[definition.id for definition in definitions],
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
_to_definition_read_payload(
|
|
||||||
definition=definition,
|
|
||||||
board_ids=board_ids_by_definition_id.get(definition.id, []),
|
|
||||||
)
|
|
||||||
for definition in definitions
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TaskCustomFieldDefinitionRead)
|
|
||||||
async def create_org_custom_field(
|
|
||||||
payload: TaskCustomFieldDefinitionCreate,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> TaskCustomFieldDefinitionRead:
|
|
||||||
"""Create an organization-level task custom field definition."""
|
|
||||||
board_ids = await _validated_board_ids_for_org(
|
|
||||||
session=session,
|
|
||||||
ctx=ctx,
|
|
||||||
board_ids=payload.board_ids,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
validate_custom_field_definition(
|
|
||||||
field_type=payload.field_type,
|
|
||||||
validation_regex=payload.validation_regex,
|
|
||||||
default_value=payload.default_value,
|
|
||||||
)
|
|
||||||
except ValueError as err:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(err),
|
|
||||||
) from err
|
|
||||||
definition = TaskCustomFieldDefinition(
|
|
||||||
organization_id=ctx.organization.id,
|
|
||||||
field_key=payload.field_key,
|
|
||||||
label=payload.label or payload.field_key,
|
|
||||||
field_type=payload.field_type,
|
|
||||||
ui_visibility=payload.ui_visibility,
|
|
||||||
validation_regex=payload.validation_regex,
|
|
||||||
description=payload.description,
|
|
||||||
required=payload.required,
|
|
||||||
default_value=payload.default_value,
|
|
||||||
)
|
|
||||||
session.add(definition)
|
|
||||||
await session.flush()
|
|
||||||
for board_id in board_ids:
|
|
||||||
session.add(
|
|
||||||
BoardTaskCustomField(
|
|
||||||
board_id=board_id,
|
|
||||||
task_custom_field_definition_id=definition.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
await session.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Field key already exists in this organization.",
|
|
||||||
) from err
|
|
||||||
|
|
||||||
await session.refresh(definition)
|
|
||||||
return _to_definition_read_payload(definition=definition, board_ids=board_ids)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{task_custom_field_definition_id}", response_model=TaskCustomFieldDefinitionRead)
|
|
||||||
async def update_org_custom_field(
|
|
||||||
task_custom_field_definition_id: UUID,
|
|
||||||
payload: TaskCustomFieldDefinitionUpdate,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> TaskCustomFieldDefinitionRead:
|
|
||||||
"""Update an organization-level task custom field definition."""
|
|
||||||
definition = await _get_org_definition(
|
|
||||||
session=session,
|
|
||||||
ctx=ctx,
|
|
||||||
definition_id=task_custom_field_definition_id,
|
|
||||||
)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
board_ids = updates.pop("board_ids", None)
|
|
||||||
validated_board_ids: list[UUID] | None = None
|
|
||||||
if board_ids is not None:
|
|
||||||
validated_board_ids = await _validated_board_ids_for_org(
|
|
||||||
session=session,
|
|
||||||
ctx=ctx,
|
|
||||||
board_ids=board_ids,
|
|
||||||
)
|
|
||||||
next_field_type = updates.get("field_type", definition.field_type)
|
|
||||||
next_validation_regex = (
|
|
||||||
updates["validation_regex"]
|
|
||||||
if "validation_regex" in updates
|
|
||||||
else definition.validation_regex
|
|
||||||
)
|
|
||||||
next_default_value = (
|
|
||||||
updates["default_value"] if "default_value" in updates else definition.default_value
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
validate_custom_field_definition(
|
|
||||||
field_type=next_field_type,
|
|
||||||
validation_regex=next_validation_regex,
|
|
||||||
default_value=next_default_value,
|
|
||||||
)
|
|
||||||
except ValueError as err:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(err),
|
|
||||||
) from err
|
|
||||||
for key, value in updates.items():
|
|
||||||
setattr(definition, key, value)
|
|
||||||
if validated_board_ids is not None:
|
|
||||||
bindings = list(
|
|
||||||
await session.exec(
|
|
||||||
select(BoardTaskCustomField).where(
|
|
||||||
col(BoardTaskCustomField.task_custom_field_definition_id) == definition.id,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
current_board_ids = {binding.board_id for binding in bindings}
|
|
||||||
target_board_ids = set(validated_board_ids)
|
|
||||||
for binding in bindings:
|
|
||||||
if binding.board_id not in target_board_ids:
|
|
||||||
await session.delete(binding)
|
|
||||||
for board_id in validated_board_ids:
|
|
||||||
if board_id in current_board_ids:
|
|
||||||
continue
|
|
||||||
session.add(
|
|
||||||
BoardTaskCustomField(
|
|
||||||
board_id=board_id,
|
|
||||||
task_custom_field_definition_id=definition.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
definition.updated_at = utcnow()
|
|
||||||
session.add(definition)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
except IntegrityError as err:
|
|
||||||
await session.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Field key already exists in this organization.",
|
|
||||||
) from err
|
|
||||||
|
|
||||||
await session.refresh(definition)
|
|
||||||
if validated_board_ids is None:
|
|
||||||
board_ids = (
|
|
||||||
await _board_ids_by_definition_id(
|
|
||||||
session=session,
|
|
||||||
definition_ids=[definition.id],
|
|
||||||
)
|
|
||||||
).get(definition.id, [])
|
|
||||||
else:
|
|
||||||
board_ids = validated_board_ids
|
|
||||||
return _to_definition_read_payload(definition=definition, board_ids=board_ids)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{task_custom_field_definition_id}", response_model=OkResponse)
|
|
||||||
async def delete_org_custom_field(
|
|
||||||
task_custom_field_definition_id: UUID,
|
|
||||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete an org-level definition when it has no persisted task values."""
|
|
||||||
definition = await _get_org_definition(
|
|
||||||
session=session,
|
|
||||||
ctx=ctx,
|
|
||||||
definition_id=task_custom_field_definition_id,
|
|
||||||
)
|
|
||||||
value_ids = (
|
|
||||||
await session.exec(
|
|
||||||
select(col(TaskCustomFieldValue.id)).where(
|
|
||||||
col(TaskCustomFieldValue.task_custom_field_definition_id) == definition.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
if value_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Cannot delete a custom field definition while task values exist.",
|
|
||||||
)
|
|
||||||
|
|
||||||
bindings = list(
|
|
||||||
await session.exec(
|
|
||||||
select(BoardTaskCustomField).where(
|
|
||||||
col(BoardTaskCustomField.task_custom_field_definition_id) == definition.id,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for binding in bindings:
|
|
||||||
await session.delete(binding)
|
|
||||||
await session.delete(definition)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,292 +0,0 @@
|
|||||||
"""User self-service API endpoints for profile retrieval and updates."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.core.auth import AuthContext, delete_clerk_user, get_auth_context
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.activity_events import ActivityEvent
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.approval_task_links import ApprovalTaskLink
|
|
||||||
from app.models.approvals import Approval
|
|
||||||
from app.models.board_group_memory import BoardGroupMemory
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.board_memory import BoardMemory
|
|
||||||
from app.models.board_onboarding import BoardOnboardingSession
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.models.organization_board_access import OrganizationBoardAccess
|
|
||||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
|
||||||
from app.models.organization_invites import OrganizationInvite
|
|
||||||
from app.models.organization_members import OrganizationMember
|
|
||||||
from app.models.organizations import Organization
|
|
||||||
from app.models.task_dependencies import TaskDependency
|
|
||||||
from app.models.task_fingerprints import TaskFingerprint
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.models.users import User
|
|
||||||
from app.schemas.common import OkResponse
|
|
||||||
from app.schemas.users import UserRead, UserUpdate
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
|
||||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
|
|
||||||
|
|
||||||
async def _delete_organization_tree(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
organization_id: UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Delete an organization and dependent rows without committing."""
|
|
||||||
board_ids = select(Board.id).where(col(Board.organization_id) == organization_id)
|
|
||||||
task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
|
|
||||||
agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
|
|
||||||
member_ids = select(OrganizationMember.id).where(
|
|
||||||
col(OrganizationMember.organization_id) == organization_id,
|
|
||||||
)
|
|
||||||
invite_ids = select(OrganizationInvite.id).where(
|
|
||||||
col(OrganizationInvite.organization_id) == organization_id,
|
|
||||||
)
|
|
||||||
group_ids = select(BoardGroup.id).where(
|
|
||||||
col(BoardGroup.organization_id) == organization_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ActivityEvent,
|
|
||||||
col(ActivityEvent.task_id).in_(task_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ActivityEvent,
|
|
||||||
col(ActivityEvent.agent_id).in_(agent_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
TaskDependency,
|
|
||||||
col(TaskDependency.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
TaskFingerprint,
|
|
||||||
col(TaskFingerprint.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
ApprovalTaskLink,
|
|
||||||
col(ApprovalTaskLink.approval_id).in_(
|
|
||||||
select(Approval.id).where(col(Approval.board_id).in_(board_ids))
|
|
||||||
),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Approval,
|
|
||||||
col(Approval.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardMemory,
|
|
||||||
col(BoardMemory.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardOnboardingSession,
|
|
||||||
col(BoardOnboardingSession.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInviteBoardAccess,
|
|
||||||
col(OrganizationInviteBoardAccess.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.organization_member_id).in_(member_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInviteBoardAccess,
|
|
||||||
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Task,
|
|
||||||
col(Task.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Agent,
|
|
||||||
col(Agent.board_id).in_(board_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Board,
|
|
||||||
col(Board.organization_id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroupMemory,
|
|
||||||
col(BoardGroupMemory.board_group_id).in_(group_ids),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
BoardGroup,
|
|
||||||
col(BoardGroup.organization_id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Gateway,
|
|
||||||
col(Gateway.organization_id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationInvite,
|
|
||||||
col(OrganizationInvite.organization_id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationMember,
|
|
||||||
col(OrganizationMember.organization_id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
User,
|
|
||||||
col(User.active_organization_id) == organization_id,
|
|
||||||
active_organization_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
Organization,
|
|
||||||
col(Organization.id) == organization_id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserRead)
|
|
||||||
async def get_me(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
|
||||||
"""Return the authenticated user's current profile payload."""
|
|
||||||
if auth.actor_type != "user" or auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
return UserRead.model_validate(auth.user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserRead)
|
|
||||||
async def update_me(
|
|
||||||
payload: UserUpdate,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_CONTEXT_DEP,
|
|
||||||
) -> UserRead:
|
|
||||||
"""Apply partial profile updates for the authenticated user."""
|
|
||||||
if auth.actor_type != "user" or auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
|
||||||
user: User = auth.user
|
|
||||||
for key, value in updates.items():
|
|
||||||
setattr(user, key, value)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
return UserRead.model_validate(user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me", response_model=OkResponse)
|
|
||||||
async def delete_me(
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
auth: AuthContext = AUTH_CONTEXT_DEP,
|
|
||||||
) -> OkResponse:
|
|
||||||
"""Delete the authenticated account and any personal-only organizations."""
|
|
||||||
if auth.actor_type != "user" or auth.user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
user: User = auth.user
|
|
||||||
await delete_clerk_user(user.clerk_user_id)
|
|
||||||
memberships = await OrganizationMember.objects.filter_by(user_id=user.id).all(session)
|
|
||||||
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
OrganizationInvite,
|
|
||||||
col(OrganizationInvite.created_by_user_id) == user.id,
|
|
||||||
created_by_user_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
OrganizationInvite,
|
|
||||||
col(OrganizationInvite.accepted_by_user_id) == user.id,
|
|
||||||
accepted_by_user_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.update_where(
|
|
||||||
session,
|
|
||||||
Task,
|
|
||||||
col(Task.created_by_user_id) == user.id,
|
|
||||||
created_by_user_id=None,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for member in memberships:
|
|
||||||
org_members = await OrganizationMember.objects.filter_by(
|
|
||||||
organization_id=member.organization_id,
|
|
||||||
).all(session)
|
|
||||||
if len(org_members) <= 1:
|
|
||||||
await _delete_organization_tree(
|
|
||||||
session,
|
|
||||||
organization_id=member.organization_id,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationBoardAccess,
|
|
||||||
col(OrganizationBoardAccess.organization_member_id) == member.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
OrganizationMember,
|
|
||||||
col(OrganizationMember.id) == member.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
await crud.delete_where(
|
|
||||||
session,
|
|
||||||
User,
|
|
||||||
col(User.id) == user.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return OkResponse()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Core utilities and configuration for the backend service."""
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"""Agent authentication helpers for token-backed API access.
|
|
||||||
|
|
||||||
This module is used for *agent-originated* API calls (as opposed to human users).
|
|
||||||
|
|
||||||
Key ideas:
|
|
||||||
- Agents authenticate with an opaque token presented as `X-Agent-Token: <token>`.
|
|
||||||
- For convenience, some deployments may also allow `Authorization: Bearer <token>`
|
|
||||||
for agents (controlled by caller/dependency).
|
|
||||||
- To reduce write-amplification, we only touch `Agent.last_seen_at` at a fixed
|
|
||||||
interval and we avoid touching it for safe/read-only HTTP methods.
|
|
||||||
|
|
||||||
This is intentionally separate from user authentication (Clerk/local bearer token)
|
|
||||||
so we can evolve agent policy independently.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
|
||||||
from sqlmodel import col, select
|
|
||||||
|
|
||||||
from app.core.agent_tokens import verify_agent_token
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.agents import Agent
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
_LAST_SEEN_TOUCH_INTERVAL = timedelta(seconds=30)
|
|
||||||
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AgentAuthContext:
|
|
||||||
"""Authenticated actor payload for agent-originated requests."""
|
|
||||||
|
|
||||||
actor_type: Literal["agent"]
|
|
||||||
agent: Agent
|
|
||||||
|
|
||||||
|
|
||||||
async def _find_agent_for_token(session: AsyncSession, token: str) -> Agent | None:
|
|
||||||
agents = list(
|
|
||||||
await session.exec(
|
|
||||||
select(Agent).where(col(Agent.agent_token_hash).is_not(None)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for agent in agents:
|
|
||||||
if agent.agent_token_hash and verify_agent_token(token, agent.agent_token_hash):
|
|
||||||
return agent
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_agent_token(
|
|
||||||
agent_token: str | None,
|
|
||||||
authorization: str | None,
|
|
||||||
*,
|
|
||||||
accept_authorization: bool = True,
|
|
||||||
) -> str | None:
|
|
||||||
if agent_token:
|
|
||||||
return agent_token
|
|
||||||
if not accept_authorization:
|
|
||||||
return None
|
|
||||||
if not authorization:
|
|
||||||
return None
|
|
||||||
value = authorization.strip()
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if value.lower().startswith("bearer "):
|
|
||||||
return value.split(" ", 1)[1].strip() or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _touch_agent_presence(
|
|
||||||
request: Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
agent: Agent,
|
|
||||||
) -> None:
|
|
||||||
"""Best-effort update of last_seen/status for any authenticated agent request.
|
|
||||||
|
|
||||||
Heartbeats are the primary presence mechanism, but agents may still make API
|
|
||||||
calls (task comments, memory updates, etc). Touch presence so the UI reflects
|
|
||||||
real activity even if the heartbeat loop isn't running.
|
|
||||||
"""
|
|
||||||
now = utcnow()
|
|
||||||
if agent.last_seen_at is not None and now - agent.last_seen_at < _LAST_SEEN_TOUCH_INTERVAL:
|
|
||||||
return
|
|
||||||
|
|
||||||
agent.last_seen_at = now
|
|
||||||
agent.updated_at = now
|
|
||||||
if agent.status not in {"updating", "deleting"}:
|
|
||||||
agent.status = "online"
|
|
||||||
session.add(agent)
|
|
||||||
|
|
||||||
# For safe HTTP methods, endpoints typically do not commit. Persist the touch
|
|
||||||
# so agents that only poll/read still show as online.
|
|
||||||
if request.method.upper() in _SAFE_METHODS:
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_agent_auth_context(
|
|
||||||
request: Request,
|
|
||||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
|
||||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> AgentAuthContext:
|
|
||||||
"""Require and validate agent auth token from request headers."""
|
|
||||||
resolved = _resolve_agent_token(
|
|
||||||
agent_token,
|
|
||||||
authorization,
|
|
||||||
accept_authorization=True,
|
|
||||||
)
|
|
||||||
if not resolved:
|
|
||||||
logger.warning(
|
|
||||||
"agent auth missing token path=%s x_agent=%s authorization=%s",
|
|
||||||
request.url.path,
|
|
||||||
bool(agent_token),
|
|
||||||
bool(authorization),
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
agent = await _find_agent_for_token(session, resolved)
|
|
||||||
if agent is None:
|
|
||||||
logger.warning(
|
|
||||||
"agent auth invalid token path=%s token_prefix=%s",
|
|
||||||
request.url.path,
|
|
||||||
resolved[:6],
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await _touch_agent_presence(request, session, agent)
|
|
||||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_agent_auth_context_optional(
|
|
||||||
request: Request,
|
|
||||||
agent_token: str | None = Header(default=None, alias="X-Agent-Token"),
|
|
||||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> AgentAuthContext | None:
|
|
||||||
"""Optionally resolve agent auth context from `X-Agent-Token` only."""
|
|
||||||
resolved = _resolve_agent_token(
|
|
||||||
agent_token,
|
|
||||||
authorization,
|
|
||||||
accept_authorization=False,
|
|
||||||
)
|
|
||||||
if not resolved:
|
|
||||||
if agent_token:
|
|
||||||
logger.warning(
|
|
||||||
"agent auth optional missing token path=%s x_agent=%s authorization=%s",
|
|
||||||
request.url.path,
|
|
||||||
bool(agent_token),
|
|
||||||
bool(authorization),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
agent = await _find_agent_for_token(session, resolved)
|
|
||||||
if agent is None:
|
|
||||||
logger.warning(
|
|
||||||
"agent auth optional invalid token path=%s token_prefix=%s",
|
|
||||||
request.url.path,
|
|
||||||
resolved[:6],
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
await _touch_agent_presence(request, session, agent)
|
|
||||||
return AgentAuthContext(actor_type="agent", agent=agent)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Token generation and verification helpers for agent authentication."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
ITERATIONS = 200_000
|
|
||||||
SALT_BYTES = 16
|
|
||||||
|
|
||||||
|
|
||||||
def generate_agent_token() -> str:
|
|
||||||
"""Generate a new URL-safe random token for an agent."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(value: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(value).decode("utf-8").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def _b64decode(value: str) -> bytes:
|
|
||||||
padding = "=" * (-len(value) % 4)
|
|
||||||
return base64.urlsafe_b64decode(value + padding)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_agent_token(token: str) -> str:
|
|
||||||
"""Hash an agent token using PBKDF2-HMAC-SHA256 with a random salt."""
|
|
||||||
salt = secrets.token_bytes(SALT_BYTES)
|
|
||||||
digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt, ITERATIONS)
|
|
||||||
return f"pbkdf2_sha256${ITERATIONS}${_b64encode(salt)}${_b64encode(digest)}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_agent_token(token: str, stored_hash: str) -> bool:
|
|
||||||
"""Verify a plaintext token against a stored PBKDF2 hash representation."""
|
|
||||||
try:
|
|
||||||
algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$")
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if algorithm != "pbkdf2_sha256":
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
iterations_int = int(iterations)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
salt = _b64decode(salt_b64)
|
|
||||||
expected_digest = _b64decode(digest_b64)
|
|
||||||
candidate = hashlib.pbkdf2_hmac(
|
|
||||||
"sha256",
|
|
||||||
token.encode("utf-8"),
|
|
||||||
salt,
|
|
||||||
iterations_int,
|
|
||||||
)
|
|
||||||
return hmac.compare_digest(candidate, expected_digest)
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
"""User authentication helpers for Clerk and local-token auth modes.
|
|
||||||
|
|
||||||
This module resolves an authenticated *user* from inbound HTTP requests.
|
|
||||||
|
|
||||||
Auth modes:
|
|
||||||
- `local`: a single shared bearer token (`LOCAL_AUTH_TOKEN`) for self-hosted
|
|
||||||
deployments.
|
|
||||||
- `clerk`: Clerk JWT authentication for multi-user deployments.
|
|
||||||
|
|
||||||
The public surface area is the `get_auth_context*` dependencies, which return an
|
|
||||||
`AuthContext` used across API routers.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- This file documents *why* some choices exist (e.g. claim extraction fallbacks)
|
|
||||||
so maintainers can safely modify auth behavior later.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from hmac import compare_digest
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from clerk_backend_api import Clerk
|
|
||||||
from clerk_backend_api.models.clerkerrors import ClerkErrors
|
|
||||||
from clerk_backend_api.models.sdkerror import SDKError
|
|
||||||
from clerk_backend_api.security.types import AuthenticateRequestOptions, AuthStatus, RequestState
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from pydantic import BaseModel, ValidationError
|
|
||||||
from starlette.concurrency import run_in_threadpool
|
|
||||||
|
|
||||||
from app.core.auth_mode import AuthMode
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
from app.db import crud
|
|
||||||
from app.db.session import get_session
|
|
||||||
from app.models.users import User
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from clerk_backend_api.models.user import User as ClerkUser
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
SECURITY_DEP = Depends(security)
|
|
||||||
SESSION_DEP = Depends(get_session)
|
|
||||||
LOCAL_AUTH_USER_ID = "local-auth-user"
|
|
||||||
LOCAL_AUTH_EMAIL = "[email protected]"
|
|
||||||
LOCAL_AUTH_NAME = "Local User"
|
|
||||||
|
|
||||||
|
|
||||||
class ClerkTokenPayload(BaseModel):
|
|
||||||
"""JWT claims payload shape required from Clerk tokens."""
|
|
||||||
|
|
||||||
sub: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AuthContext:
|
|
||||||
"""Authenticated user context resolved from inbound auth headers."""
|
|
||||||
|
|
||||||
actor_type: Literal["user"]
|
|
||||||
user: User | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_bearer_token(authorization: str | None) -> str | None:
|
|
||||||
if not authorization:
|
|
||||||
return None
|
|
||||||
value = authorization.strip()
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if not value.lower().startswith("bearer "):
|
|
||||||
return None
|
|
||||||
token = value.split(" ", maxsplit=1)[1].strip()
|
|
||||||
return token or None
|
|
||||||
|
|
||||||
|
|
||||||
def _non_empty_str(value: object) -> str | None:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return None
|
|
||||||
cleaned = value.strip()
|
|
||||||
return cleaned or None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_email(value: object) -> str | None:
|
|
||||||
text = _non_empty_str(value)
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
return text.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_claim_email(claims: dict[str, object]) -> str | None:
|
|
||||||
for key in ("email", "email_address", "primary_email_address"):
|
|
||||||
email = _normalize_email(claims.get(key))
|
|
||||||
if email:
|
|
||||||
return email
|
|
||||||
|
|
||||||
primary_email_id = _non_empty_str(claims.get("primary_email_address_id"))
|
|
||||||
email_addresses = claims.get("email_addresses")
|
|
||||||
if not isinstance(email_addresses, list):
|
|
||||||
return None
|
|
||||||
|
|
||||||
fallback_email: str | None = None
|
|
||||||
for item in email_addresses:
|
|
||||||
if isinstance(item, str):
|
|
||||||
normalized = _normalize_email(item)
|
|
||||||
if normalized and fallback_email is None:
|
|
||||||
fallback_email = normalized
|
|
||||||
continue
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
candidate = _normalize_email(item.get("email_address") or item.get("email"))
|
|
||||||
if not candidate:
|
|
||||||
continue
|
|
||||||
candidate_id = _non_empty_str(item.get("id"))
|
|
||||||
if primary_email_id and candidate_id == primary_email_id:
|
|
||||||
return candidate
|
|
||||||
if fallback_email is None:
|
|
||||||
fallback_email = candidate
|
|
||||||
return fallback_email
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_claim_name(claims: dict[str, object]) -> str | None:
|
|
||||||
for key in ("name", "full_name"):
|
|
||||||
text = _non_empty_str(claims.get(key))
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
first = _non_empty_str(claims.get("given_name")) or _non_empty_str(claims.get("first_name"))
|
|
||||||
last = _non_empty_str(claims.get("family_name")) or _non_empty_str(claims.get("last_name"))
|
|
||||||
parts = [part for part in (first, last) if part]
|
|
||||||
if not parts:
|
|
||||||
return None
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_clerk_profile(profile: ClerkUser | None) -> tuple[str | None, str | None]:
|
|
||||||
if profile is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
profile_email = _normalize_email(getattr(profile, "email_address", None))
|
|
||||||
primary_email_id = _non_empty_str(getattr(profile, "primary_email_address_id", None))
|
|
||||||
emails = getattr(profile, "email_addresses", None)
|
|
||||||
if not profile_email and isinstance(emails, list):
|
|
||||||
fallback_email: str | None = None
|
|
||||||
for item in emails:
|
|
||||||
candidate = _normalize_email(
|
|
||||||
getattr(item, "email_address", None),
|
|
||||||
)
|
|
||||||
if not candidate:
|
|
||||||
continue
|
|
||||||
candidate_id = _non_empty_str(getattr(item, "id", None))
|
|
||||||
if primary_email_id and candidate_id == primary_email_id:
|
|
||||||
profile_email = candidate
|
|
||||||
break
|
|
||||||
if fallback_email is None:
|
|
||||||
fallback_email = candidate
|
|
||||||
if profile_email is None:
|
|
||||||
profile_email = fallback_email
|
|
||||||
|
|
||||||
profile_name = (
|
|
||||||
_non_empty_str(getattr(profile, "full_name", None))
|
|
||||||
or _non_empty_str(getattr(profile, "name", None))
|
|
||||||
or _non_empty_str(getattr(profile, "first_name", None))
|
|
||||||
or _non_empty_str(getattr(profile, "username", None))
|
|
||||||
)
|
|
||||||
if not profile_name:
|
|
||||||
first = _non_empty_str(getattr(profile, "first_name", None))
|
|
||||||
last = _non_empty_str(getattr(profile, "last_name", None))
|
|
||||||
parts = [part for part in (first, last) if part]
|
|
||||||
if parts:
|
|
||||||
profile_name = " ".join(parts)
|
|
||||||
|
|
||||||
return profile_email, profile_name
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_clerk_server_url(raw: str) -> str | None:
|
|
||||||
server_url = raw.strip().rstrip("/")
|
|
||||||
if not server_url:
|
|
||||||
return None
|
|
||||||
if not server_url.endswith("/v1"):
|
|
||||||
server_url = f"{server_url}/v1"
|
|
||||||
return server_url
|
|
||||||
|
|
||||||
|
|
||||||
def _make_authenticate_request_options() -> AuthenticateRequestOptions:
|
|
||||||
# Follow the clerk-backend-api documented flow: authenticate_request() with a secret key.
|
|
||||||
return AuthenticateRequestOptions(
|
|
||||||
secret_key=settings.clerk_secret_key.strip(),
|
|
||||||
clock_skew_in_ms=int(settings.clerk_leeway * 1000),
|
|
||||||
accepts_token=["session_token"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _authenticate_clerk_request(request: Request) -> RequestState:
|
|
||||||
# The SDK docs use httpx.Request as the request object; build one from the ASGI request.
|
|
||||||
httpx_request = httpx.Request(
|
|
||||||
request.method,
|
|
||||||
str(request.url),
|
|
||||||
headers=dict(request.headers),
|
|
||||||
)
|
|
||||||
options = _make_authenticate_request_options()
|
|
||||||
sdk = Clerk(bearer_auth=options.secret_key or "")
|
|
||||||
return await run_in_threadpool(sdk.authenticate_request, httpx_request, options)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | None]:
|
|
||||||
secret = settings.clerk_secret_key.strip()
|
|
||||||
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
|
|
||||||
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
|
|
||||||
clerk_user_id_log = clerk_user_id[-6:] if clerk_user_id else ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with Clerk(
|
|
||||||
bearer_auth=secret,
|
|
||||||
server_url=server_url,
|
|
||||||
timeout_ms=5000,
|
|
||||||
) as clerk:
|
|
||||||
profile = await clerk.users.get_async(user_id=clerk_user_id)
|
|
||||||
email, name = _extract_clerk_profile(profile)
|
|
||||||
return email, name
|
|
||||||
except ClerkErrors as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=clerk_errors "
|
|
||||||
"secret_kind=%s error_type=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
secret_kind,
|
|
||||||
exc.__class__.__name__,
|
|
||||||
)
|
|
||||||
except SDKError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.profile.fetch_failed clerk_user_id=%s status=%s reason=sdk_error "
|
|
||||||
"server_url=%s secret_kind=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
exc.status_code,
|
|
||||||
server_url,
|
|
||||||
secret_kind,
|
|
||||||
)
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=timeout "
|
|
||||||
"server_url=%s secret_kind=%s error=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
server_url,
|
|
||||||
secret_kind,
|
|
||||||
str(exc) or exc.__class__.__name__,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.profile.fetch_failed clerk_user_id=%s reason=sdk_exception "
|
|
||||||
"error_type=%s error=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
exc.__class__.__name__,
|
|
||||||
str(exc)[:300],
|
|
||||||
)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_clerk_user(clerk_user_id: str) -> None:
|
|
||||||
"""Delete a Clerk user via the official Clerk SDK."""
|
|
||||||
if settings.auth_mode != AuthMode.CLERK:
|
|
||||||
return
|
|
||||||
|
|
||||||
secret = settings.clerk_secret_key.strip()
|
|
||||||
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
|
|
||||||
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
|
|
||||||
clerk_user_id_log = clerk_user_id[-6:] if clerk_user_id else ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with Clerk(
|
|
||||||
bearer_auth=secret,
|
|
||||||
server_url=server_url,
|
|
||||||
timeout_ms=5000,
|
|
||||||
) as clerk:
|
|
||||||
await clerk.users.delete_async(user_id=clerk_user_id)
|
|
||||||
logger.info("auth.clerk.user.delete clerk_user_id=%s", clerk_user_id_log)
|
|
||||||
except ClerkErrors as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.user.delete_failed clerk_user_id=%s reason=clerk_errors "
|
|
||||||
"secret_kind=%s error_type=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
secret_kind,
|
|
||||||
exc.__class__.__name__,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail="Failed to delete account from Clerk",
|
|
||||||
) from exc
|
|
||||||
except SDKError as exc:
|
|
||||||
if exc.status_code == 404:
|
|
||||||
logger.info("auth.clerk.user.delete_missing clerk_user_id=%s", clerk_user_id_log)
|
|
||||||
return
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.user.delete_failed clerk_user_id=%s status=%s reason=sdk_error "
|
|
||||||
"server_url=%s secret_kind=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
exc.status_code,
|
|
||||||
server_url,
|
|
||||||
secret_kind,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail="Failed to delete account from Clerk",
|
|
||||||
) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"auth.clerk.user.delete_failed clerk_user_id=%s reason=sdk_exception",
|
|
||||||
clerk_user_id_log,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail="Failed to delete account from Clerk",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_sync_user(
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
clerk_user_id: str,
|
|
||||||
claims: dict[str, object],
|
|
||||||
) -> User:
|
|
||||||
clerk_user_id_log = clerk_user_id[-6:] if clerk_user_id else ""
|
|
||||||
claim_email = _extract_claim_email(claims)
|
|
||||||
claim_name = _extract_claim_name(claims)
|
|
||||||
defaults: dict[str, object | None] = {
|
|
||||||
"email": claim_email,
|
|
||||||
"name": claim_name,
|
|
||||||
}
|
|
||||||
user, created = await crud.get_or_create(
|
|
||||||
session,
|
|
||||||
User,
|
|
||||||
clerk_user_id=clerk_user_id,
|
|
||||||
defaults=defaults,
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_email: str | None = None
|
|
||||||
profile_name: str | None = None
|
|
||||||
# Avoid a network roundtrip to Clerk on every request once core profile
|
|
||||||
# fields are present in our DB.
|
|
||||||
should_fetch_profile = created or not user.email or not user.name
|
|
||||||
if should_fetch_profile:
|
|
||||||
profile_email, profile_name = await _fetch_clerk_profile(clerk_user_id)
|
|
||||||
|
|
||||||
email = profile_email or claim_email
|
|
||||||
name = profile_name or claim_name
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if email and user.email != email:
|
|
||||||
user.email = email
|
|
||||||
changed = True
|
|
||||||
if not user.name and name:
|
|
||||||
user.name = name
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
logger.info(
|
|
||||||
"auth.user.sync clerk_user_id=%s updated=%s fetched_profile=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
changed,
|
|
||||||
should_fetch_profile,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"auth.user.sync.noop clerk_user_id=%s fetched_profile=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
should_fetch_profile,
|
|
||||||
)
|
|
||||||
if not user.email:
|
|
||||||
logger.warning(
|
|
||||||
"auth.user.sync.missing_email clerk_user_id=%s",
|
|
||||||
clerk_user_id_log,
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_local_user(session: AsyncSession) -> User:
|
|
||||||
defaults: dict[str, object] = {
|
|
||||||
"email": LOCAL_AUTH_EMAIL,
|
|
||||||
"name": LOCAL_AUTH_NAME,
|
|
||||||
}
|
|
||||||
user, _created = await crud.get_or_create(
|
|
||||||
session,
|
|
||||||
User,
|
|
||||||
clerk_user_id=LOCAL_AUTH_USER_ID,
|
|
||||||
defaults=defaults,
|
|
||||||
)
|
|
||||||
changed = False
|
|
||||||
if not user.email:
|
|
||||||
user.email = LOCAL_AUTH_EMAIL
|
|
||||||
changed = True
|
|
||||||
if not user.name:
|
|
||||||
user.name = LOCAL_AUTH_NAME
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user)
|
|
||||||
|
|
||||||
from app.services.organizations import ensure_member_for_user
|
|
||||||
|
|
||||||
await ensure_member_for_user(session, user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_local_auth_context(
|
|
||||||
*,
|
|
||||||
request: Request,
|
|
||||||
session: AsyncSession,
|
|
||||||
required: bool,
|
|
||||||
) -> AuthContext | None:
|
|
||||||
token = _extract_bearer_token(request.headers.get("Authorization"))
|
|
||||||
if token is None:
|
|
||||||
if required:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
return None
|
|
||||||
expected = settings.local_auth_token.strip()
|
|
||||||
if not expected or not compare_digest(token, expected):
|
|
||||||
if required:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
return None
|
|
||||||
user = await _get_or_create_local_user(session)
|
|
||||||
return AuthContext(actor_type="user", user=user)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_subject(claims: dict[str, object]) -> str | None:
|
|
||||||
payload = ClerkTokenPayload.model_validate(claims)
|
|
||||||
return payload.sub
|
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> AuthContext:
|
|
||||||
"""Resolve required authenticated user context for the configured auth mode."""
|
|
||||||
if settings.auth_mode == AuthMode.LOCAL:
|
|
||||||
local_auth = await _resolve_local_auth_context(
|
|
||||||
request=request,
|
|
||||||
session=session,
|
|
||||||
required=True,
|
|
||||||
)
|
|
||||||
if local_auth is None: # pragma: no cover
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
return local_auth
|
|
||||||
|
|
||||||
request_state = await _authenticate_clerk_request(request)
|
|
||||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
claims: dict[str, object] = {str(k): v for k, v in request_state.payload.items()}
|
|
||||||
try:
|
|
||||||
clerk_user_id = _parse_subject(claims)
|
|
||||||
except ValidationError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
|
||||||
|
|
||||||
if not clerk_user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
user = await _get_or_sync_user(
|
|
||||||
session,
|
|
||||||
clerk_user_id=clerk_user_id,
|
|
||||||
claims=claims,
|
|
||||||
)
|
|
||||||
from app.services.organizations import ensure_member_for_user
|
|
||||||
|
|
||||||
await ensure_member_for_user(session, user)
|
|
||||||
|
|
||||||
return AuthContext(
|
|
||||||
actor_type="user",
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context_optional(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
|
||||||
session: AsyncSession = SESSION_DEP,
|
|
||||||
) -> AuthContext | None:
|
|
||||||
"""Resolve user context if available, otherwise return `None`."""
|
|
||||||
if request.headers.get("X-Agent-Token"):
|
|
||||||
return None
|
|
||||||
if settings.auth_mode == AuthMode.LOCAL:
|
|
||||||
return await _resolve_local_auth_context(
|
|
||||||
request=request,
|
|
||||||
session=session,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
request_state = await _authenticate_clerk_request(request)
|
|
||||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
|
||||||
return None
|
|
||||||
claims: dict[str, object] = {str(k): v for k, v in request_state.payload.items()}
|
|
||||||
|
|
||||||
try:
|
|
||||||
clerk_user_id = _parse_subject(claims)
|
|
||||||
except ValidationError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not clerk_user_id:
|
|
||||||
return None
|
|
||||||
user = await _get_or_sync_user(
|
|
||||||
session,
|
|
||||||
clerk_user_id=clerk_user_id,
|
|
||||||
claims=claims,
|
|
||||||
)
|
|
||||||
from app.services.organizations import ensure_member_for_user
|
|
||||||
|
|
||||||
await ensure_member_for_user(session, user)
|
|
||||||
|
|
||||||
return AuthContext(
|
|
||||||
actor_type="user",
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""Shared auth-mode enum values."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class AuthMode(str, Enum):
|
|
||||||
"""Supported authentication modes for backend and frontend."""
|
|
||||||
|
|
||||||
CLERK = "clerk"
|
|
||||||
LOCAL = "local"
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""Application settings and environment configuration loading."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from pydantic import Field, model_validator
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
from app.core.auth_mode import AuthMode
|
|
||||||
|
|
||||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
DEFAULT_ENV_FILE = BACKEND_ROOT / ".env"
|
|
||||||
LOCAL_AUTH_TOKEN_MIN_LENGTH = 50
|
|
||||||
LOCAL_AUTH_TOKEN_PLACEHOLDERS = frozenset(
|
|
||||||
{
|
|
||||||
"change-me",
|
|
||||||
"changeme",
|
|
||||||
"replace-me",
|
|
||||||
"replace-with-strong-random-token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Typed runtime configuration sourced from environment variables."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
# Load `backend/.env` regardless of current working directory.
|
|
||||||
# (Important when running uvicorn from repo root or via a process manager.)
|
|
||||||
env_file=[DEFAULT_ENV_FILE, ".env"],
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
environment: str = "dev"
|
|
||||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency"
|
|
||||||
|
|
||||||
# Auth mode: "clerk" for Clerk JWT auth, "local" for shared bearer token auth.
|
|
||||||
auth_mode: AuthMode
|
|
||||||
local_auth_token: str = ""
|
|
||||||
|
|
||||||
# Clerk auth (auth only; roles stored in DB)
|
|
||||||
clerk_secret_key: str = ""
|
|
||||||
clerk_api_url: str = "https://api.clerk.com"
|
|
||||||
clerk_verify_iat: bool = True
|
|
||||||
clerk_leeway: float = 10.0
|
|
||||||
|
|
||||||
cors_origins: str = ""
|
|
||||||
base_url: str = ""
|
|
||||||
|
|
||||||
# Database lifecycle
|
|
||||||
db_auto_migrate: bool = False
|
|
||||||
|
|
||||||
# RQ queueing / dispatch
|
|
||||||
rq_redis_url: str = "redis://localhost:6379/0"
|
|
||||||
rq_queue_name: str = "default"
|
|
||||||
rq_dispatch_throttle_seconds: float = 15.0
|
|
||||||
rq_dispatch_max_retries: int = 3
|
|
||||||
rq_dispatch_retry_base_seconds: float = 10.0
|
|
||||||
rq_dispatch_retry_max_seconds: float = 120.0
|
|
||||||
|
|
||||||
# OpenClaw gateway runtime compatibility
|
|
||||||
gateway_min_version: str = "2026.02.9"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log_level: str = "INFO"
|
|
||||||
log_format: str = "text"
|
|
||||||
log_use_utc: bool = False
|
|
||||||
request_log_slow_ms: int = Field(default=1000, ge=0)
|
|
||||||
request_log_include_health: bool = False
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _defaults(self) -> Self:
|
|
||||||
if self.auth_mode == AuthMode.CLERK:
|
|
||||||
if not self.clerk_secret_key.strip():
|
|
||||||
raise ValueError(
|
|
||||||
"CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk.",
|
|
||||||
)
|
|
||||||
elif self.auth_mode == AuthMode.LOCAL:
|
|
||||||
token = self.local_auth_token.strip()
|
|
||||||
if (
|
|
||||||
not token
|
|
||||||
or len(token) < LOCAL_AUTH_TOKEN_MIN_LENGTH
|
|
||||||
or token.lower() in LOCAL_AUTH_TOKEN_PLACEHOLDERS
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.",
|
|
||||||
)
|
|
||||||
# In dev, default to applying Alembic migrations at startup to avoid
|
|
||||||
# schema drift (e.g. missing newly-added columns).
|
|
||||||
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
|
||||||
self.db_auto_migrate = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Utilities for parsing human-readable duration schedule strings."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
_DURATION_RE = re.compile(
|
|
||||||
r"^(?P<num>[1-9]\\d*)\\s*(?P<unit>[smhdw])$",
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
_MULTIPLIERS: dict[str, int] = {
|
|
||||||
"s": 1,
|
|
||||||
"m": 60,
|
|
||||||
"h": 60 * 60,
|
|
||||||
"d": 60 * 60 * 24,
|
|
||||||
"w": 60 * 60 * 24 * 7,
|
|
||||||
}
|
|
||||||
_MAX_SCHEDULE_SECONDS = 60 * 60 * 24 * 365 * 10
|
|
||||||
|
|
||||||
_ERR_SCHEDULE_REQUIRED = "schedule is required"
|
|
||||||
_ERR_SCHEDULE_INVALID = 'Invalid schedule. Expected format like "10m", "1h", "2d", "1w".'
|
|
||||||
_ERR_SCHEDULE_NONPOSITIVE = "Schedule must be greater than 0."
|
|
||||||
_ERR_SCHEDULE_TOO_LARGE = "Schedule is too large (max 10 years)."
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_every(value: str) -> str:
|
|
||||||
"""Normalize schedule string to lower-case compact unit form."""
|
|
||||||
normalized = value.strip().lower().replace(" ", "")
|
|
||||||
if not normalized:
|
|
||||||
raise ValueError(_ERR_SCHEDULE_REQUIRED)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def parse_every_to_seconds(value: str) -> int:
|
|
||||||
"""Parse compact schedule syntax into a number of seconds."""
|
|
||||||
normalized = normalize_every(value)
|
|
||||||
match = _DURATION_RE.match(normalized)
|
|
||||||
if not match:
|
|
||||||
raise ValueError(_ERR_SCHEDULE_INVALID)
|
|
||||||
num = int(match.group("num"))
|
|
||||||
unit = match.group("unit").lower()
|
|
||||||
seconds = num * _MULTIPLIERS[unit]
|
|
||||||
if seconds <= 0:
|
|
||||||
raise ValueError(_ERR_SCHEDULE_NONPOSITIVE)
|
|
||||||
# Prevent accidental absurd schedules (e.g. 999999999d).
|
|
||||||
if seconds > _MAX_SCHEDULE_SECONDS:
|
|
||||||
raise ValueError(_ERR_SCHEDULE_TOO_LARGE)
|
|
||||||
return seconds
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"""Global exception handlers and request-id middleware for FastAPI.
|
|
||||||
|
|
||||||
This module standardizes two operational behaviors:
|
|
||||||
|
|
||||||
1) **Request IDs**
|
|
||||||
- Every response includes an `X-Request-Id` header.
|
|
||||||
- Clients may supply their own request id; otherwise we generate one.
|
|
||||||
- The request id is propagated into logs via context vars.
|
|
||||||
|
|
||||||
2) **Error responses**
|
|
||||||
- Errors are returned as JSON with a stable top-level shape:
|
|
||||||
`{ "detail": ..., "request_id": ... }`
|
|
||||||
- Validation errors (`422`) return structured field errors.
|
|
||||||
- Unhandled errors are logged at ERROR and return a generic 500.
|
|
||||||
|
|
||||||
Design notes:
|
|
||||||
- The request-id middleware is installed *outermost* so it runs even when other
|
|
||||||
middleware returns early.
|
|
||||||
- Health endpoints are excluded from request logs by default to reduce noise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from time import perf_counter
|
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.logging import (
|
|
||||||
TRACE_LEVEL,
|
|
||||||
get_logger,
|
|
||||||
reset_request_id,
|
|
||||||
reset_request_route_context,
|
|
||||||
set_request_id,
|
|
||||||
set_request_route_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
REQUEST_ID_HEADER: Final[str] = "X-Request-Id"
|
|
||||||
_HEALTH_CHECK_PATHS: Final[frozenset[str]] = frozenset({"/health", "/healthz", "/readyz"})
|
|
||||||
|
|
||||||
ExceptionHandler = Callable[[Request, Exception], Response | Awaitable[Response]]
|
|
||||||
|
|
||||||
|
|
||||||
class RequestIdMiddleware:
|
|
||||||
"""ASGI middleware that ensures every request has a request-id."""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp, *, header_name: str = REQUEST_ID_HEADER) -> None:
|
|
||||||
"""Initialize middleware with app instance and header name."""
|
|
||||||
self._app = app
|
|
||||||
self._header_name = header_name
|
|
||||||
self._header_name_bytes = header_name.lower().encode("latin-1")
|
|
||||||
self._slow_request_ms = settings.request_log_slow_ms
|
|
||||||
self._include_health_logs = settings.request_log_include_health
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
"""Inject request-id into request state and response headers."""
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self._app(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
method = str(scope.get("method") or "UNKNOWN").upper()
|
|
||||||
path = str(scope.get("path") or "")
|
|
||||||
client = scope.get("client")
|
|
||||||
client_ip: str | None = None
|
|
||||||
if isinstance(client, tuple) and client and isinstance(client[0], str):
|
|
||||||
client_ip = client[0]
|
|
||||||
should_log = self._include_health_logs or path not in _HEALTH_CHECK_PATHS
|
|
||||||
started_at = perf_counter()
|
|
||||||
status_code: int | None = None
|
|
||||||
|
|
||||||
request_id = self._get_or_create_request_id(scope)
|
|
||||||
context_token = set_request_id(request_id)
|
|
||||||
route_context_tokens = set_request_route_context(method, path)
|
|
||||||
if should_log:
|
|
||||||
logger.log(
|
|
||||||
TRACE_LEVEL,
|
|
||||||
"http.request.start",
|
|
||||||
extra={
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
"client_ip": client_ip,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_with_request_id(message: Message) -> None:
|
|
||||||
nonlocal status_code
|
|
||||||
if message["type"] == "http.response.start":
|
|
||||||
# Starlette uses `list[tuple[bytes, bytes]]` here.
|
|
||||||
headers: list[tuple[bytes, bytes]] = message.setdefault("headers", [])
|
|
||||||
if not any(key.lower() == self._header_name_bytes for key, _ in headers):
|
|
||||||
request_id_bytes = request_id.encode("latin-1")
|
|
||||||
headers.append((self._header_name_bytes, request_id_bytes))
|
|
||||||
status = message.get("status")
|
|
||||||
status_code = status if isinstance(status, int) else 500
|
|
||||||
if should_log:
|
|
||||||
duration_ms = int((perf_counter() - started_at) * 1000)
|
|
||||||
extra = {
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
"status_code": status_code,
|
|
||||||
"duration_ms": duration_ms,
|
|
||||||
"client_ip": client_ip,
|
|
||||||
}
|
|
||||||
if status_code >= 500:
|
|
||||||
logger.error("http.request.complete", extra=extra)
|
|
||||||
elif status_code >= 400:
|
|
||||||
logger.warning("http.request.complete", extra=extra)
|
|
||||||
else:
|
|
||||||
logger.debug("http.request.complete", extra=extra)
|
|
||||||
if self._slow_request_ms and duration_ms >= self._slow_request_ms:
|
|
||||||
logger.warning(
|
|
||||||
"http.request.slow",
|
|
||||||
extra={
|
|
||||||
**extra,
|
|
||||||
"slow_threshold_ms": self._slow_request_ms,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await send(message)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._app(scope, receive, send_with_request_id)
|
|
||||||
finally:
|
|
||||||
if should_log and status_code is None:
|
|
||||||
logger.warning(
|
|
||||||
"http.request.incomplete",
|
|
||||||
extra={
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
"duration_ms": int((perf_counter() - started_at) * 1000),
|
|
||||||
"client_ip": client_ip,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
reset_request_route_context(route_context_tokens)
|
|
||||||
reset_request_id(context_token)
|
|
||||||
|
|
||||||
def _get_or_create_request_id(self, scope: Scope) -> str:
|
|
||||||
# Accept a client-provided request id if present.
|
|
||||||
request_id: str | None = None
|
|
||||||
for key, value in scope.get("headers", []):
|
|
||||||
if key.lower() == self._header_name_bytes:
|
|
||||||
candidate = value.decode("latin-1").strip()
|
|
||||||
if candidate:
|
|
||||||
request_id = candidate
|
|
||||||
break
|
|
||||||
|
|
||||||
if request_id is None:
|
|
||||||
request_id = uuid4().hex
|
|
||||||
|
|
||||||
# `Request.state` is backed by `scope["state"]`.
|
|
||||||
state = scope.setdefault("state", {})
|
|
||||||
state["request_id"] = request_id
|
|
||||||
return request_id
|
|
||||||
|
|
||||||
|
|
||||||
def install_error_handling(app: FastAPI) -> None:
|
|
||||||
"""Install middleware and exception handlers on the FastAPI app."""
|
|
||||||
# Important: add request-id middleware last so it's the outermost middleware.
|
|
||||||
# This ensures it still runs even if another middleware
|
|
||||||
# (e.g. CORS preflight) returns early.
|
|
||||||
app.add_middleware(RequestIdMiddleware)
|
|
||||||
|
|
||||||
app.add_exception_handler(
|
|
||||||
RequestValidationError,
|
|
||||||
_request_validation_exception_handler,
|
|
||||||
)
|
|
||||||
app.add_exception_handler(
|
|
||||||
ResponseValidationError,
|
|
||||||
_response_validation_exception_handler,
|
|
||||||
)
|
|
||||||
app.add_exception_handler(
|
|
||||||
StarletteHTTPException,
|
|
||||||
_http_exception_exception_handler,
|
|
||||||
)
|
|
||||||
app.add_exception_handler(Exception, _unhandled_exception_handler)
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_validation_exception_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: Exception,
|
|
||||||
) -> Response:
|
|
||||||
if not isinstance(exc, RequestValidationError):
|
|
||||||
msg = "Expected RequestValidationError"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return await _request_validation_handler(request, exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def _response_validation_exception_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: Exception,
|
|
||||||
) -> Response:
|
|
||||||
if not isinstance(exc, ResponseValidationError):
|
|
||||||
msg = "Expected ResponseValidationError"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return await _response_validation_handler(request, exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def _http_exception_exception_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: Exception,
|
|
||||||
) -> Response:
|
|
||||||
if not isinstance(exc, StarletteHTTPException):
|
|
||||||
msg = "Expected StarletteHTTPException"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return await _http_exception_handler(request, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_request_id(request: Request) -> str | None:
|
|
||||||
request_id = getattr(request.state, "request_id", None)
|
|
||||||
if isinstance(request_id, str) and request_id:
|
|
||||||
return request_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _error_payload(*, detail: object, request_id: str | None) -> dict[str, object]:
|
|
||||||
payload: dict[str, Any] = {"detail": _json_safe(detail)}
|
|
||||||
if request_id:
|
|
||||||
payload["request_id"] = request_id
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value: object) -> object:
|
|
||||||
"""Return a JSON-serializable representation for error payloads."""
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
return value.decode("utf-8", errors="replace")
|
|
||||||
if isinstance(value, (bytearray, memoryview)):
|
|
||||||
return bytes(value).decode("utf-8", errors="replace")
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return [_json_safe(item) for item in value]
|
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
|
||||||
return value
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_validation_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: RequestValidationError,
|
|
||||||
) -> JSONResponse:
|
|
||||||
# `RequestValidationError` is expected user input; don't log at ERROR.
|
|
||||||
request_id = _get_request_id(request)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=422,
|
|
||||||
content=_error_payload(detail=exc.errors(), request_id=request_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _response_validation_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: ResponseValidationError,
|
|
||||||
) -> JSONResponse:
|
|
||||||
request_id = _get_request_id(request)
|
|
||||||
logger.exception(
|
|
||||||
"response_validation_error",
|
|
||||||
extra={
|
|
||||||
"request_id": request_id,
|
|
||||||
"method": request.method,
|
|
||||||
"path": request.url.path,
|
|
||||||
"errors": exc.errors(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content=_error_payload(detail="Internal Server Error", request_id=request_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _http_exception_handler(
|
|
||||||
request: Request,
|
|
||||||
exc: StarletteHTTPException,
|
|
||||||
) -> JSONResponse:
|
|
||||||
request_id = _get_request_id(request)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content=_error_payload(detail=exc.detail, request_id=request_id),
|
|
||||||
headers=exc.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _unhandled_exception_handler(
|
|
||||||
request: Request,
|
|
||||||
_exc: Exception,
|
|
||||||
) -> JSONResponse:
|
|
||||||
request_id = _get_request_id(request)
|
|
||||||
logger.exception(
|
|
||||||
"unhandled_exception",
|
|
||||||
extra={
|
|
||||||
"request_id": request_id,
|
|
||||||
"method": request.method,
|
|
||||||
"path": request.url.path,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content=_error_payload(detail="Internal Server Error", request_id=request_id),
|
|
||||||
headers={REQUEST_ID_HEADER: request_id} if request_id else None,
|
|
||||||
)
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
"""Application logging configuration and formatter utilities."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from contextvars import ContextVar, Token
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from types import TracebackType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.version import APP_NAME, APP_VERSION
|
|
||||||
|
|
||||||
TRACE_LEVEL = 5
|
|
||||||
EXC_INFO_TUPLE_SIZE = 3
|
|
||||||
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
|
||||||
_REQUEST_ID_CONTEXT: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
||||||
_REQUEST_METHOD_CONTEXT: ContextVar[str | None] = ContextVar("request_method", default=None)
|
|
||||||
_REQUEST_PATH_CONTEXT: ContextVar[str | None] = ContextVar("request_path", default=None)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_exc_info(
|
|
||||||
value: object,
|
|
||||||
) -> (
|
|
||||||
bool
|
|
||||||
| tuple[type[BaseException], BaseException, TracebackType | None]
|
|
||||||
| tuple[None, None, None]
|
|
||||||
| BaseException
|
|
||||||
| None
|
|
||||||
):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, bool | BaseException):
|
|
||||||
return value
|
|
||||||
if not isinstance(value, tuple) or len(value) != EXC_INFO_TUPLE_SIZE:
|
|
||||||
return None
|
|
||||||
first, second, third = value
|
|
||||||
if first is None and second is None and third is None:
|
|
||||||
return (None, None, None)
|
|
||||||
if (
|
|
||||||
isinstance(first, type)
|
|
||||||
and issubclass(first, BaseException)
|
|
||||||
and isinstance(second, BaseException)
|
|
||||||
and (isinstance(third, TracebackType) or third is None)
|
|
||||||
):
|
|
||||||
return (first, second, third)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_extra(value: object) -> dict[str, object] | None:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return None
|
|
||||||
return {str(key): item for key, item in value.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _trace(self: logging.Logger, message: str, *args: object, **kwargs: object) -> None:
|
|
||||||
"""Log a TRACE-level message when the logger is TRACE-enabled."""
|
|
||||||
if self.isEnabledFor(TRACE_LEVEL):
|
|
||||||
exc_info = _coerce_exc_info(kwargs.get("exc_info"))
|
|
||||||
stack_info_raw = kwargs.get("stack_info")
|
|
||||||
stack_info = stack_info_raw if isinstance(stack_info_raw, bool) else False
|
|
||||||
stacklevel_raw = kwargs.get("stacklevel")
|
|
||||||
stacklevel = stacklevel_raw if isinstance(stacklevel_raw, int) else 1
|
|
||||||
extra = _coerce_extra(kwargs.get("extra"))
|
|
||||||
self.log(
|
|
||||||
TRACE_LEVEL,
|
|
||||||
message,
|
|
||||||
*args,
|
|
||||||
exc_info=exc_info,
|
|
||||||
stack_info=stack_info,
|
|
||||||
stacklevel=stacklevel,
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
logging.Logger.trace = _trace # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
def set_request_id(request_id: str | None) -> Token[str | None]:
|
|
||||||
"""Bind request-id to logging context for the current task."""
|
|
||||||
normalized = (request_id or "").strip() or None
|
|
||||||
return _REQUEST_ID_CONTEXT.set(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_request_id(token: Token[str | None]) -> None:
|
|
||||||
"""Reset request-id context to a previous token value."""
|
|
||||||
_REQUEST_ID_CONTEXT.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def get_request_id() -> str | None:
|
|
||||||
"""Return request-id currently bound to logging context."""
|
|
||||||
return _REQUEST_ID_CONTEXT.get()
|
|
||||||
|
|
||||||
|
|
||||||
def set_request_route_context(
|
|
||||||
method: str | None,
|
|
||||||
path: str | None,
|
|
||||||
) -> tuple[Token[str | None], Token[str | None]]:
|
|
||||||
"""Bind request method/path to logging context for the current task."""
|
|
||||||
normalized_method = (method or "").strip().upper() or None
|
|
||||||
normalized_path = (path or "").strip() or None
|
|
||||||
return (
|
|
||||||
_REQUEST_METHOD_CONTEXT.set(normalized_method),
|
|
||||||
_REQUEST_PATH_CONTEXT.set(normalized_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_request_route_context(tokens: tuple[Token[str | None], Token[str | None]]) -> None:
|
|
||||||
"""Reset request method/path context to previously-bound values."""
|
|
||||||
method_token, path_token = tokens
|
|
||||||
_REQUEST_METHOD_CONTEXT.reset(method_token)
|
|
||||||
_REQUEST_PATH_CONTEXT.reset(path_token)
|
|
||||||
|
|
||||||
|
|
||||||
def get_request_method() -> str | None:
|
|
||||||
"""Return request method currently bound to logging context."""
|
|
||||||
return _REQUEST_METHOD_CONTEXT.get()
|
|
||||||
|
|
||||||
|
|
||||||
def get_request_path() -> str | None:
|
|
||||||
"""Return request path currently bound to logging context."""
|
|
||||||
return _REQUEST_PATH_CONTEXT.get()
|
|
||||||
|
|
||||||
|
|
||||||
_STANDARD_LOG_RECORD_ATTRS = {
|
|
||||||
"args",
|
|
||||||
"asctime",
|
|
||||||
"created",
|
|
||||||
"exc_info",
|
|
||||||
"exc_text",
|
|
||||||
"filename",
|
|
||||||
"funcName",
|
|
||||||
"levelname",
|
|
||||||
"levelno",
|
|
||||||
"lineno",
|
|
||||||
"module",
|
|
||||||
"msecs",
|
|
||||||
"message",
|
|
||||||
"msg",
|
|
||||||
"name",
|
|
||||||
"pathname",
|
|
||||||
"process",
|
|
||||||
"processName",
|
|
||||||
"relativeCreated",
|
|
||||||
"stack_info",
|
|
||||||
"thread",
|
|
||||||
"threadName",
|
|
||||||
"taskName",
|
|
||||||
"app",
|
|
||||||
"version",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AppLogFilter(logging.Filter):
|
|
||||||
"""Inject app metadata into each log record."""
|
|
||||||
|
|
||||||
def __init__(self, app_name: str, version: str) -> None:
|
|
||||||
"""Initialize the filter with fixed app and version values."""
|
|
||||||
super().__init__()
|
|
||||||
self._app_name = app_name
|
|
||||||
self._version = version
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
"""Attach app metadata fields to each emitted record."""
|
|
||||||
record.app = self._app_name
|
|
||||||
record.version = self._version
|
|
||||||
if not getattr(record, "request_id", None):
|
|
||||||
request_id = get_request_id()
|
|
||||||
if request_id:
|
|
||||||
record.request_id = request_id
|
|
||||||
if not getattr(record, "method", None):
|
|
||||||
method = get_request_method()
|
|
||||||
if method:
|
|
||||||
record.method = method
|
|
||||||
if not getattr(record, "path", None):
|
|
||||||
path = get_request_path()
|
|
||||||
if path:
|
|
||||||
record.path = path
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class JsonFormatter(logging.Formatter):
|
|
||||||
"""Formatter that serializes log records as compact JSON."""
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
"""Render a single log record into a JSON string."""
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"timestamp": datetime.fromtimestamp(
|
|
||||||
record.created,
|
|
||||||
tz=UTC,
|
|
||||||
).isoformat(),
|
|
||||||
"level": record.levelname,
|
|
||||||
"logger": record.name,
|
|
||||||
"message": record.getMessage(),
|
|
||||||
"app": getattr(record, "app", APP_NAME),
|
|
||||||
"version": getattr(record, "version", APP_VERSION),
|
|
||||||
"module": record.module,
|
|
||||||
"function": record.funcName,
|
|
||||||
"line": record.lineno,
|
|
||||||
}
|
|
||||||
if record.exc_info:
|
|
||||||
payload["exception"] = self.formatException(record.exc_info)
|
|
||||||
if record.stack_info:
|
|
||||||
payload["stack"] = self.formatStack(record.stack_info)
|
|
||||||
for key, value in record.__dict__.items():
|
|
||||||
if key in _STANDARD_LOG_RECORD_ATTRS or key in payload:
|
|
||||||
continue
|
|
||||||
payload[key] = value
|
|
||||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
|
||||||
|
|
||||||
|
|
||||||
class KeyValueFormatter(logging.Formatter):
|
|
||||||
"""Formatter that appends extra fields as `key=value` pairs."""
|
|
||||||
|
|
||||||
# noinspection PyMethodMayBeStatic
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
"""Render a log line with appended non-standard record fields."""
|
|
||||||
base = super().format(record)
|
|
||||||
extras = {
|
|
||||||
key: value
|
|
||||||
for key, value in record.__dict__.items()
|
|
||||||
if key not in _STANDARD_LOG_RECORD_ATTRS
|
|
||||||
}
|
|
||||||
if not extras:
|
|
||||||
return base
|
|
||||||
extra_bits = " ".join(f"{key}={value}" for key, value in extras.items())
|
|
||||||
return f"{base} {extra_bits}"
|
|
||||||
|
|
||||||
|
|
||||||
class AppLogger:
|
|
||||||
"""Centralized logging setup utility for the backend process."""
|
|
||||||
|
|
||||||
_configured = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _resolve_level(cls) -> tuple[str, int]:
|
|
||||||
level_name = (settings.log_level or os.getenv("LOG_LEVEL", "INFO")).upper()
|
|
||||||
if level_name == "TRACE":
|
|
||||||
return level_name, TRACE_LEVEL
|
|
||||||
if level_name.isdigit():
|
|
||||||
return level_name, int(level_name)
|
|
||||||
levels = logging.getLevelNamesMapping()
|
|
||||||
return level_name, levels.get(level_name, logging.INFO)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def configure(cls, *, force: bool = False) -> None:
|
|
||||||
"""Configure root logging handlers, formatters, and library levels."""
|
|
||||||
if cls._configured and not force:
|
|
||||||
return
|
|
||||||
|
|
||||||
level_name, level = cls._resolve_level()
|
|
||||||
|
|
||||||
handler = logging.StreamHandler(sys.stdout)
|
|
||||||
handler.addFilter(AppLogFilter(APP_NAME, APP_VERSION))
|
|
||||||
format_name = (settings.log_format or "text").lower()
|
|
||||||
if format_name == "json":
|
|
||||||
formatter: logging.Formatter = JsonFormatter()
|
|
||||||
else:
|
|
||||||
formatter = KeyValueFormatter(
|
|
||||||
"%(asctime)s %(levelname)s %(name)s %(message)s " "app=%(app)s version=%(version)s",
|
|
||||||
)
|
|
||||||
if settings.log_use_utc:
|
|
||||||
formatter.converter = time.gmtime
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
root = logging.getLogger()
|
|
||||||
root.setLevel(level)
|
|
||||||
root.handlers.clear()
|
|
||||||
root.addHandler(handler)
|
|
||||||
|
|
||||||
# Uvicorn & HTTP clients
|
|
||||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
||||||
logging.getLogger(logger_name).setLevel(level)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
# SQL logs only at TRACE
|
|
||||||
sql_loggers = ("sqlalchemy", "sqlalchemy.engine", "sqlalchemy.pool")
|
|
||||||
if level_name == "TRACE":
|
|
||||||
for name in sql_loggers:
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
logger.disabled = False
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
else:
|
|
||||||
for name in sql_loggers:
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
logger.disabled = True
|
|
||||||
|
|
||||||
logging.getLogger(__name__).info(
|
|
||||||
"logging.configured level=%s format=%s use_utc=%s",
|
|
||||||
level_name,
|
|
||||||
format_name,
|
|
||||||
settings.log_use_utc,
|
|
||||||
)
|
|
||||||
logging.getLogger(__name__).debug(
|
|
||||||
"logging.libraries uvicorn_level=%s sql_enabled=%s",
|
|
||||||
level_name,
|
|
||||||
level_name == "TRACE",
|
|
||||||
)
|
|
||||||
|
|
||||||
cls._configured = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_logger(cls, name: str | None = None) -> logging.Logger:
|
|
||||||
"""Return a logger, ensuring logging has been configured."""
|
|
||||||
if not cls._configured:
|
|
||||||
cls.configure()
|
|
||||||
return logging.getLogger(name)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
|
||||||
"""Configure global application logging once during startup."""
|
|
||||||
AppLogger.configure()
|
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str | None = None) -> logging.Logger:
|
|
||||||
"""Return an app logger from the centralized logger configuration."""
|
|
||||||
return AppLogger.get_logger(name)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Time-related helpers shared across backend modules."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
"""Return a naive UTC datetime without using deprecated datetime.utcnow()."""
|
|
||||||
# Keep naive UTC values for compatibility with existing DB schema/queries.
|
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""Application name and version constants."""
|
|
||||||
|
|
||||||
APP_NAME = "mission-control"
|
|
||||||
APP_VERSION = "0.1.0"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Database helpers and abstractions for backend persistence."""
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
"""Generic asynchronous CRUD helpers for SQLModel entities."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, TypeVar
|
|
||||||
|
|
||||||
from sqlalchemy import delete as sql_delete
|
|
||||||
from sqlalchemy import update as sql_update
|
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
||||||
from sqlmodel import SQLModel, select
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable, Mapping
|
|
||||||
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
from sqlmodel.sql.expression import SelectOfScalar
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT", bound=SQLModel)
|
|
||||||
|
|
||||||
|
|
||||||
class DoesNotExistError(LookupError):
|
|
||||||
"""Raised when a query expected one row but found none."""
|
|
||||||
|
|
||||||
|
|
||||||
class MultipleObjectsReturnedError(LookupError):
|
|
||||||
"""Raised when a query expected one row but found many."""
|
|
||||||
|
|
||||||
|
|
||||||
DoesNotExist = DoesNotExistError
|
|
||||||
MultipleObjectsReturned = MultipleObjectsReturnedError
|
|
||||||
|
|
||||||
|
|
||||||
async def _flush_or_rollback(session: AsyncSession) -> None:
|
|
||||||
"""Flush changes and rollback on SQLAlchemy errors."""
|
|
||||||
try:
|
|
||||||
await session.flush()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def _commit_or_rollback(session: AsyncSession) -> None:
|
|
||||||
"""Commit transaction and rollback on SQLAlchemy errors."""
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_statement(
|
|
||||||
model: type[ModelT],
|
|
||||||
lookup: Mapping[str, Any],
|
|
||||||
) -> SelectOfScalar[ModelT]:
|
|
||||||
"""Build a select statement with equality filters from lookup values."""
|
|
||||||
stmt = select(model)
|
|
||||||
for key, value in lookup.items():
|
|
||||||
stmt = stmt.where(getattr(model, key) == value)
|
|
||||||
return stmt
|
|
||||||
|
|
||||||
|
|
||||||
async def get_by_id(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
obj_id: object,
|
|
||||||
) -> ModelT | None:
|
|
||||||
"""Fetch one model instance by id or return None."""
|
|
||||||
stmt = _lookup_statement(model, {"id": obj_id}).limit(1)
|
|
||||||
return (await session.exec(stmt)).first()
|
|
||||||
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
**lookup: object,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Fetch exactly one model instance by lookup values."""
|
|
||||||
stmt = _lookup_statement(model, lookup).limit(2)
|
|
||||||
items = (await session.exec(stmt)).all()
|
|
||||||
if not items:
|
|
||||||
message = f"{model.__name__} matching query does not exist."
|
|
||||||
raise DoesNotExist(message)
|
|
||||||
if len(items) > 1:
|
|
||||||
message = f"Multiple {model.__name__} objects returned for lookup {lookup!r}."
|
|
||||||
raise MultipleObjectsReturned(message)
|
|
||||||
return items[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_one_by(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
**lookup: object,
|
|
||||||
) -> ModelT | None:
|
|
||||||
"""Fetch the first model instance matching lookup values."""
|
|
||||||
stmt = _lookup_statement(model, lookup)
|
|
||||||
return (await session.exec(stmt)).first()
|
|
||||||
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*,
|
|
||||||
commit: bool = True,
|
|
||||||
refresh: bool = True,
|
|
||||||
**data: object,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Create, flush, optionally commit, and optionally refresh an object."""
|
|
||||||
obj = model.model_validate(data)
|
|
||||||
session.add(obj)
|
|
||||||
await _flush_or_rollback(session)
|
|
||||||
if commit:
|
|
||||||
await _commit_or_rollback(session)
|
|
||||||
if refresh:
|
|
||||||
await session.refresh(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
async def save(
|
|
||||||
session: AsyncSession,
|
|
||||||
obj: ModelT,
|
|
||||||
*,
|
|
||||||
commit: bool = True,
|
|
||||||
refresh: bool = True,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Persist an existing object with optional commit and refresh."""
|
|
||||||
session.add(obj)
|
|
||||||
await _flush_or_rollback(session)
|
|
||||||
if commit:
|
|
||||||
await _commit_or_rollback(session)
|
|
||||||
if refresh:
|
|
||||||
await session.refresh(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
async def delete(session: AsyncSession, obj: SQLModel, *, commit: bool = True) -> None:
|
|
||||||
"""Delete an object with optional commit."""
|
|
||||||
await session.delete(obj)
|
|
||||||
if commit:
|
|
||||||
await _commit_or_rollback(session)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_by(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*,
|
|
||||||
order_by: Iterable[Any] = (),
|
|
||||||
limit: int | None = None,
|
|
||||||
offset: int | None = None,
|
|
||||||
**lookup: object,
|
|
||||||
) -> list[ModelT]:
|
|
||||||
"""List objects by lookup values with optional ordering and pagination."""
|
|
||||||
stmt = _lookup_statement(model, lookup)
|
|
||||||
for ordering in order_by:
|
|
||||||
stmt = stmt.order_by(ordering)
|
|
||||||
if offset is not None:
|
|
||||||
stmt = stmt.offset(offset)
|
|
||||||
if limit is not None:
|
|
||||||
stmt = stmt.limit(limit)
|
|
||||||
return list(await session.exec(stmt))
|
|
||||||
|
|
||||||
|
|
||||||
async def exists(session: AsyncSession, model: type[ModelT], **lookup: object) -> bool:
|
|
||||||
"""Return whether any object exists for lookup values."""
|
|
||||||
return (await session.exec(_lookup_statement(model, lookup).limit(1))).first() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _criteria_statement(
|
|
||||||
model: type[ModelT],
|
|
||||||
criteria: tuple[Any, ...],
|
|
||||||
) -> SelectOfScalar[ModelT]:
|
|
||||||
"""Build a select statement from variadic where criteria."""
|
|
||||||
stmt = select(model)
|
|
||||||
if criteria:
|
|
||||||
stmt = stmt.where(*criteria)
|
|
||||||
return stmt
|
|
||||||
|
|
||||||
|
|
||||||
async def list_where(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*criteria: object,
|
|
||||||
order_by: Iterable[Any] = (),
|
|
||||||
) -> list[ModelT]:
|
|
||||||
"""List objects filtered by explicit SQL criteria."""
|
|
||||||
stmt = _criteria_statement(model, criteria)
|
|
||||||
for ordering in order_by:
|
|
||||||
stmt = stmt.order_by(ordering)
|
|
||||||
return list(await session.exec(stmt))
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_where(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*criteria: object,
|
|
||||||
commit: bool = False,
|
|
||||||
) -> int:
|
|
||||||
"""Delete rows matching criteria and return affected row count."""
|
|
||||||
stmt: Any = sql_delete(model)
|
|
||||||
if criteria:
|
|
||||||
stmt = stmt.where(*criteria)
|
|
||||||
result = await session.exec(stmt)
|
|
||||||
if commit:
|
|
||||||
await _commit_or_rollback(session)
|
|
||||||
rowcount = getattr(result, "rowcount", None)
|
|
||||||
return int(rowcount) if isinstance(rowcount, int) else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def update_where(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*criteria: object,
|
|
||||||
updates: Mapping[str, Any] | None = None,
|
|
||||||
**options: object,
|
|
||||||
) -> int:
|
|
||||||
"""Apply bulk updates by criteria and return affected row count."""
|
|
||||||
commit = bool(options.pop("commit", False))
|
|
||||||
exclude_none = bool(options.pop("exclude_none", False))
|
|
||||||
allowed_fields_raw = options.pop("allowed_fields", None)
|
|
||||||
allowed_fields = allowed_fields_raw if isinstance(allowed_fields_raw, set) else None
|
|
||||||
source_updates: dict[str, Any] = {}
|
|
||||||
if updates:
|
|
||||||
source_updates.update(dict(updates))
|
|
||||||
if options:
|
|
||||||
source_updates.update(options)
|
|
||||||
|
|
||||||
values: dict[str, Any] = {}
|
|
||||||
for key, value in source_updates.items():
|
|
||||||
if allowed_fields is not None and key not in allowed_fields:
|
|
||||||
continue
|
|
||||||
if exclude_none and value is None:
|
|
||||||
continue
|
|
||||||
values[key] = value
|
|
||||||
if not values:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
stmt: Any = sql_update(model).values(**values)
|
|
||||||
if criteria:
|
|
||||||
stmt = stmt.where(*criteria)
|
|
||||||
result = await session.exec(stmt)
|
|
||||||
if commit:
|
|
||||||
await _commit_or_rollback(session)
|
|
||||||
rowcount = getattr(result, "rowcount", None)
|
|
||||||
return int(rowcount) if isinstance(rowcount, int) else 0
|
|
||||||
|
|
||||||
|
|
||||||
def apply_updates(
|
|
||||||
obj: ModelT,
|
|
||||||
updates: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
exclude_none: bool = False,
|
|
||||||
allowed_fields: set[str] | None = None,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Apply a mapping of field updates onto an object."""
|
|
||||||
for key, value in updates.items():
|
|
||||||
if allowed_fields is not None and key not in allowed_fields:
|
|
||||||
continue
|
|
||||||
if exclude_none and value is None:
|
|
||||||
continue
|
|
||||||
setattr(obj, key, value)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
async def patch(
|
|
||||||
session: AsyncSession,
|
|
||||||
obj: ModelT,
|
|
||||||
updates: Mapping[str, Any],
|
|
||||||
**options: object,
|
|
||||||
) -> ModelT:
|
|
||||||
"""Apply partial updates and persist object."""
|
|
||||||
exclude_none = bool(options.pop("exclude_none", False))
|
|
||||||
allowed_fields_raw = options.pop("allowed_fields", None)
|
|
||||||
allowed_fields = allowed_fields_raw if isinstance(allowed_fields_raw, set) else None
|
|
||||||
commit = bool(options.pop("commit", True))
|
|
||||||
refresh = bool(options.pop("refresh", True))
|
|
||||||
apply_updates(
|
|
||||||
obj,
|
|
||||||
updates,
|
|
||||||
exclude_none=exclude_none,
|
|
||||||
allowed_fields=allowed_fields,
|
|
||||||
)
|
|
||||||
return await save(session, obj, commit=commit, refresh=refresh)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[ModelT],
|
|
||||||
*,
|
|
||||||
defaults: Mapping[str, Any] | None = None,
|
|
||||||
commit: bool = True,
|
|
||||||
refresh: bool = True,
|
|
||||||
**lookup: object,
|
|
||||||
) -> tuple[ModelT, bool]:
|
|
||||||
"""Get one object by lookup, or create it with defaults."""
|
|
||||||
stmt = _lookup_statement(model, lookup)
|
|
||||||
|
|
||||||
existing = (await session.exec(stmt)).first()
|
|
||||||
if existing is not None:
|
|
||||||
return existing, False
|
|
||||||
|
|
||||||
payload: dict[str, Any] = dict(lookup)
|
|
||||||
if defaults:
|
|
||||||
for key, value in defaults.items():
|
|
||||||
payload.setdefault(key, value)
|
|
||||||
|
|
||||||
obj = model.model_validate(payload)
|
|
||||||
session.add(obj)
|
|
||||||
try:
|
|
||||||
await session.flush()
|
|
||||||
if commit:
|
|
||||||
await session.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
# If another concurrent request inserted the same unique row, surface that row.
|
|
||||||
await session.rollback()
|
|
||||||
existing = (await session.exec(stmt)).first()
|
|
||||||
if existing is not None:
|
|
||||||
return existing, False
|
|
||||||
raise
|
|
||||||
except SQLAlchemyError:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
if refresh:
|
|
||||||
await session.refresh(obj)
|
|
||||||
return obj, True
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Typed wrapper around fastapi-pagination for backend query helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable, Sequence
|
|
||||||
from typing import TYPE_CHECKING, Any, TypeVar
|
|
||||||
|
|
||||||
from fastapi_pagination.ext.sqlalchemy import paginate as _paginate
|
|
||||||
|
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
from sqlmodel.sql.expression import Select, SelectOfScalar
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
Transformer = Callable[
|
|
||||||
[Sequence[Any]],
|
|
||||||
Sequence[Any] | Awaitable[Sequence[Any]],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def paginate(
|
|
||||||
session: AsyncSession,
|
|
||||||
statement: Select[Any] | SelectOfScalar[Any],
|
|
||||||
*,
|
|
||||||
transformer: Transformer | None = None,
|
|
||||||
) -> LimitOffsetPage[T]:
|
|
||||||
"""Execute a paginated query and cast to the project page type alias."""
|
|
||||||
page = await _paginate(session, statement, transformer=transformer)
|
|
||||||
return DefaultLimitOffsetPage[T].model_validate(page)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Model manager descriptor utilities for query-set style access."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
||||||
|
|
||||||
from sqlalchemy import false
|
|
||||||
from sqlmodel import SQLModel, col
|
|
||||||
|
|
||||||
from app.db.queryset import QuerySet, qs
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT", bound=SQLModel)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ModelManager(Generic[ModelT]):
|
|
||||||
"""Convenience query manager bound to a SQLModel class."""
|
|
||||||
|
|
||||||
model: type[ModelT]
|
|
||||||
id_field: str = "id"
|
|
||||||
|
|
||||||
def all(self) -> QuerySet[ModelT]:
|
|
||||||
"""Return an unfiltered queryset for the bound model."""
|
|
||||||
return qs(self.model)
|
|
||||||
|
|
||||||
def none(self) -> QuerySet[ModelT]:
|
|
||||||
"""Return a queryset that yields no rows."""
|
|
||||||
return qs(self.model).filter(false())
|
|
||||||
|
|
||||||
def filter(
|
|
||||||
self,
|
|
||||||
*criteria: ColumnElement[bool] | bool,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by SQL criteria expressions."""
|
|
||||||
return self.all().filter(*criteria)
|
|
||||||
|
|
||||||
def where(
|
|
||||||
self,
|
|
||||||
*criteria: ColumnElement[bool] | bool,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Alias for `filter`."""
|
|
||||||
return self.filter(*criteria)
|
|
||||||
|
|
||||||
def filter_by(self, **kwargs: object) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by model field equality values."""
|
|
||||||
queryset = self.all()
|
|
||||||
for field_name, value in kwargs.items():
|
|
||||||
queryset = queryset.filter(col(getattr(self.model, field_name)) == value)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def by_id(self, obj_id: object) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by primary identifier field."""
|
|
||||||
return self.by_field(self.id_field, obj_id)
|
|
||||||
|
|
||||||
def by_ids(
|
|
||||||
self,
|
|
||||||
obj_ids: Iterable[object],
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by a set/list/tuple of identifiers."""
|
|
||||||
return self.by_field_in(self.id_field, obj_ids)
|
|
||||||
|
|
||||||
def by_field(self, field_name: str, value: object) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by a single field equality check."""
|
|
||||||
return self.filter(col(getattr(self.model, field_name)) == value)
|
|
||||||
|
|
||||||
def by_field_in(
|
|
||||||
self,
|
|
||||||
field_name: str,
|
|
||||||
values: Iterable[object],
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Return queryset filtered by `field IN values` semantics."""
|
|
||||||
seq = tuple(values)
|
|
||||||
if not seq:
|
|
||||||
return self.none()
|
|
||||||
return self.filter(col(getattr(self.model, field_name)).in_(seq))
|
|
||||||
|
|
||||||
|
|
||||||
class ManagerDescriptor(Generic[ModelT]):
|
|
||||||
"""Descriptor that exposes a model-bound `ModelManager` as `.objects`."""
|
|
||||||
|
|
||||||
# noinspection PyMethodMayBeStatic
|
|
||||||
def __get__(self, instance: object, owner: type[ModelT]) -> ModelManager[ModelT]:
|
|
||||||
"""Return a fresh manager bound to the owning model class."""
|
|
||||||
return ModelManager(owner)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Lightweight immutable query-set wrapper for SQLModel statements."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
||||||
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.orm import Mapped
|
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
from sqlmodel.sql.expression import SelectOfScalar
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class QuerySet(Generic[ModelT]):
|
|
||||||
"""Composable immutable wrapper around a SQLModel scalar select statement."""
|
|
||||||
|
|
||||||
statement: SelectOfScalar[ModelT]
|
|
||||||
|
|
||||||
def filter(
|
|
||||||
self,
|
|
||||||
*criteria: ColumnElement[bool] | bool,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with additional SQL criteria."""
|
|
||||||
statement = self.statement.where(*criteria)
|
|
||||||
return replace(self, statement=statement)
|
|
||||||
|
|
||||||
def where(
|
|
||||||
self,
|
|
||||||
*criteria: ColumnElement[bool] | bool,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Alias for `filter` to mirror SQLAlchemy naming."""
|
|
||||||
return self.filter(*criteria)
|
|
||||||
|
|
||||||
def filter_by(self, **kwargs: object) -> QuerySet[ModelT]:
|
|
||||||
"""Return a new queryset filtered by keyword-equality criteria."""
|
|
||||||
statement = self.statement.filter_by(**kwargs)
|
|
||||||
return replace(self, statement=statement)
|
|
||||||
|
|
||||||
def order_by(
|
|
||||||
self,
|
|
||||||
*ordering: Mapped[Any] | ColumnElement[Any] | str,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with ordering clauses applied."""
|
|
||||||
statement = self.statement.order_by(*ordering)
|
|
||||||
return replace(self, statement=statement)
|
|
||||||
|
|
||||||
def limit(self, value: int) -> QuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with a SQL row limit."""
|
|
||||||
return replace(self, statement=self.statement.limit(value))
|
|
||||||
|
|
||||||
def offset(self, value: int) -> QuerySet[ModelT]:
|
|
||||||
"""Return a new queryset with a SQL row offset."""
|
|
||||||
return replace(self, statement=self.statement.offset(value))
|
|
||||||
|
|
||||||
async def all(self, session: AsyncSession) -> list[ModelT]:
|
|
||||||
"""Execute and return all rows for the current queryset."""
|
|
||||||
return list(await session.exec(self.statement))
|
|
||||||
|
|
||||||
async def first(self, session: AsyncSession) -> ModelT | None:
|
|
||||||
"""Execute and return the first row, if available."""
|
|
||||||
return (await session.exec(self.statement)).first()
|
|
||||||
|
|
||||||
async def one_or_none(self, session: AsyncSession) -> ModelT | None:
|
|
||||||
"""Execute and return one row or `None`."""
|
|
||||||
return (await session.exec(self.statement)).one_or_none()
|
|
||||||
|
|
||||||
async def exists(self, session: AsyncSession) -> bool:
|
|
||||||
"""Return whether the queryset yields at least one row."""
|
|
||||||
return await self.limit(1).first(session) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def qs(model: type[ModelT]) -> QuerySet[ModelT]:
|
|
||||||
"""Create a base queryset for a SQLModel class."""
|
|
||||||
return QuerySet(select(model))
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""Database engine, session factory, and startup migration helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from alembic.config import Config
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from app import models as _models
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.logging import get_logger
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
|
|
||||||
# Import model modules so SQLModel metadata is fully registered at startup.
|
|
||||||
_MODEL_REGISTRY = _models
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_database_url(database_url: str) -> str:
|
|
||||||
if "://" not in database_url:
|
|
||||||
return database_url
|
|
||||||
scheme, rest = database_url.split("://", 1)
|
|
||||||
if scheme == "postgresql":
|
|
||||||
return f"postgresql+psycopg://{rest}"
|
|
||||||
return database_url
|
|
||||||
|
|
||||||
|
|
||||||
async_engine: AsyncEngine = create_async_engine(
|
|
||||||
_normalize_database_url(settings.database_url),
|
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
|
||||||
async_session_maker = async_sessionmaker(
|
|
||||||
async_engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
)
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _alembic_config() -> Config:
|
|
||||||
alembic_ini = Path(__file__).resolve().parents[2] / "alembic.ini"
|
|
||||||
|
|
||||||
alembic_cfg = Config(str(alembic_ini))
|
|
||||||
alembic_cfg.attributes["configure_logger"] = False
|
|
||||||
return alembic_cfg
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations() -> None:
|
|
||||||
"""Apply Alembic migrations to the latest revision."""
|
|
||||||
from alembic import command
|
|
||||||
|
|
||||||
logger.info("Running database migrations.")
|
|
||||||
command.upgrade(_alembic_config(), "head")
|
|
||||||
logger.info("Database migrations complete.")
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
|
||||||
"""Initialize database schema, running migrations when configured."""
|
|
||||||
if settings.db_auto_migrate:
|
|
||||||
versions_dir = Path(__file__).resolve().parents[2] / "migrations" / "versions"
|
|
||||||
if any(versions_dir.glob("*.py")):
|
|
||||||
logger.info("Running migrations on startup")
|
|
||||||
await asyncio.to_thread(run_migrations)
|
|
||||||
return
|
|
||||||
logger.warning("No migration revisions found; falling back to create_all")
|
|
||||||
|
|
||||||
async with async_engine.connect() as conn, conn.begin():
|
|
||||||
await conn.run_sync(SQLModel.metadata.create_all)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""Yield a request-scoped async DB session with safe rollback on errors."""
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
in_txn = False
|
|
||||||
try:
|
|
||||||
in_txn = bool(session.in_transaction())
|
|
||||||
except SQLAlchemyError:
|
|
||||||
logger.exception("Failed to inspect session transaction state.")
|
|
||||||
if in_txn:
|
|
||||||
try:
|
|
||||||
await session.rollback()
|
|
||||||
except SQLAlchemyError:
|
|
||||||
logger.exception("Failed to rollback session after request error.")
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
"""FastAPI application entrypoint and router wiring for the backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, status
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
|
||||||
from fastapi_pagination import add_pagination
|
|
||||||
|
|
||||||
from app.api.activity import router as activity_router
|
|
||||||
from app.api.agent import router as agent_router
|
|
||||||
from app.api.agents import router as agents_router
|
|
||||||
from app.api.approvals import router as approvals_router
|
|
||||||
from app.api.auth import router as auth_router
|
|
||||||
from app.api.board_group_memory import router as board_group_memory_router
|
|
||||||
from app.api.board_groups import router as board_groups_router
|
|
||||||
from app.api.board_memory import router as board_memory_router
|
|
||||||
from app.api.board_onboarding import router as board_onboarding_router
|
|
||||||
from app.api.board_webhooks import router as board_webhooks_router
|
|
||||||
from app.api.boards import router as boards_router
|
|
||||||
from app.api.gateway import router as gateway_router
|
|
||||||
from app.api.gateways import router as gateways_router
|
|
||||||
from app.api.metrics import router as metrics_router
|
|
||||||
from app.api.organizations import router as organizations_router
|
|
||||||
from app.api.skills_marketplace import router as skills_marketplace_router
|
|
||||||
from app.api.souls_directory import router as souls_directory_router
|
|
||||||
from app.api.tags import router as tags_router
|
|
||||||
from app.api.task_custom_fields import router as task_custom_fields_router
|
|
||||||
from app.api.tasks import router as tasks_router
|
|
||||||
from app.api.users import router as users_router
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.error_handling import install_error_handling
|
|
||||||
from app.core.logging import configure_logging, get_logger
|
|
||||||
from app.db.session import init_db
|
|
||||||
from app.schemas.health import HealthStatusResponse
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
configure_logging()
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
OPENAPI_TAGS = [
|
|
||||||
{
|
|
||||||
"name": "auth",
|
|
||||||
"description": (
|
|
||||||
"Authentication bootstrap endpoints for resolving caller identity and session context."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "health",
|
|
||||||
"description": (
|
|
||||||
"Service liveness/readiness probes used by infrastructure and runtime checks."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agents",
|
|
||||||
"description": "Organization-level agent directory, lifecycle, and management operations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "activity",
|
|
||||||
"description": "Activity feed and audit timeline endpoints across boards and operations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "gateways",
|
|
||||||
"description": "Gateway management, synchronization, and runtime control operations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "metrics",
|
|
||||||
"description": "Aggregated operational and board analytics metrics endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "organizations",
|
|
||||||
"description": "Organization profile, membership, and governance management endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "souls-directory",
|
|
||||||
"description": "Directory and lookup endpoints for agent soul templates and variants.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "skills",
|
|
||||||
"description": "Skills marketplace, install, uninstall, and synchronization endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "board-groups",
|
|
||||||
"description": "Board group CRUD, assignment, and grouping workflow endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "board-group-memory",
|
|
||||||
"description": "Shared memory endpoints scoped to board groups and grouped boards.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boards",
|
|
||||||
"description": "Board lifecycle, configuration, and board-level management endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "board-memory",
|
|
||||||
"description": "Board-scoped memory read/write endpoints for persistent context.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "board-webhooks",
|
|
||||||
"description": "Board webhook registration, delivery config, and lifecycle endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "board-onboarding",
|
|
||||||
"description": "Board onboarding state, setup actions, and onboarding workflow endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "approvals",
|
|
||||||
"description": "Approval request, review, and status-tracking operations for board tasks.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "tasks",
|
|
||||||
"description": "Task CRUD, dependency management, and task workflow operations.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "custom-fields",
|
|
||||||
"description": "Organization custom-field definitions and board assignment endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "tags",
|
|
||||||
"description": "Tag catalog and task-tag association management endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "users",
|
|
||||||
"description": "User profile read/update operations and user-centric settings endpoints.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent",
|
|
||||||
"description": (
|
|
||||||
"Agent-scoped API surface. All endpoints require `X-Agent-Token` and are "
|
|
||||||
"constrained by agent board access policies."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent-lead",
|
|
||||||
"description": (
|
|
||||||
"Lead workflows: delegation, review orchestration, approvals, and "
|
|
||||||
"coordination actions."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent-worker",
|
|
||||||
"description": (
|
|
||||||
"Worker workflows: task execution, task comments, and board/group context "
|
|
||||||
"reads/writes used during heartbeat loops."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "agent-main",
|
|
||||||
"description": (
|
|
||||||
"Gateway-main control workflows that message board leads or broadcast "
|
|
||||||
"coordination requests."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
_JSON_SCHEMA_REF_PREFIX = "#/components/schemas/"
|
|
||||||
_OPENAPI_EXAMPLE_TAGS = {
|
|
||||||
"agents",
|
|
||||||
"activity",
|
|
||||||
"gateways",
|
|
||||||
"metrics",
|
|
||||||
"organizations",
|
|
||||||
"souls-directory",
|
|
||||||
"skills",
|
|
||||||
"board-groups",
|
|
||||||
"board-group-memory",
|
|
||||||
"boards",
|
|
||||||
"board-memory",
|
|
||||||
"board-webhooks",
|
|
||||||
"board-onboarding",
|
|
||||||
"approvals",
|
|
||||||
"tasks",
|
|
||||||
"custom-fields",
|
|
||||||
"tags",
|
|
||||||
"users",
|
|
||||||
}
|
|
||||||
_GENERIC_RESPONSE_DESCRIPTIONS = {"Successful Response", "Validation Error"}
|
|
||||||
_HTTP_RESPONSE_DESCRIPTIONS = {
|
|
||||||
"200": "Request completed successfully.",
|
|
||||||
"201": "Resource created successfully.",
|
|
||||||
"202": "Request accepted for processing.",
|
|
||||||
"204": "Request completed successfully with no response body.",
|
|
||||||
"400": "Request validation failed.",
|
|
||||||
"401": "Authentication is required or token is invalid.",
|
|
||||||
"403": "Caller is authenticated but not authorized for this operation.",
|
|
||||||
"404": "Requested resource was not found.",
|
|
||||||
"409": "Request conflicts with the current resource state.",
|
|
||||||
"422": "Request payload failed schema or field validation.",
|
|
||||||
"429": "Request was rate-limited.",
|
|
||||||
"500": "Internal server error.",
|
|
||||||
}
|
|
||||||
_METHOD_SUMMARY_PREFIX = {
|
|
||||||
"get": "List",
|
|
||||||
"post": "Create",
|
|
||||||
"put": "Replace",
|
|
||||||
"patch": "Update",
|
|
||||||
"delete": "Delete",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_schema_ref(
|
|
||||||
schema: dict[str, Any],
|
|
||||||
*,
|
|
||||||
components: dict[str, Any],
|
|
||||||
seen_refs: set[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Resolve local component refs for OpenAPI schema traversal."""
|
|
||||||
ref = schema.get("$ref")
|
|
||||||
if not isinstance(ref, str):
|
|
||||||
return schema
|
|
||||||
if not ref.startswith(_JSON_SCHEMA_REF_PREFIX):
|
|
||||||
return schema
|
|
||||||
if seen_refs is None:
|
|
||||||
seen_refs = set()
|
|
||||||
if ref in seen_refs:
|
|
||||||
return schema
|
|
||||||
seen_refs.add(ref)
|
|
||||||
schema_name = ref[len(_JSON_SCHEMA_REF_PREFIX) :]
|
|
||||||
schemas = components.get("schemas")
|
|
||||||
if not isinstance(schemas, dict):
|
|
||||||
return schema
|
|
||||||
target = schemas.get(schema_name)
|
|
||||||
if not isinstance(target, dict):
|
|
||||||
return schema
|
|
||||||
return _resolve_schema_ref(target, components=components, seen_refs=seen_refs)
|
|
||||||
|
|
||||||
|
|
||||||
def _example_from_schema(schema: dict[str, Any], *, components: dict[str, Any]) -> Any:
|
|
||||||
"""Generate an OpenAPI example from schema metadata with sensible fallbacks."""
|
|
||||||
resolved = _resolve_schema_ref(schema, components=components)
|
|
||||||
|
|
||||||
if "example" in resolved:
|
|
||||||
return resolved["example"]
|
|
||||||
examples = resolved.get("examples")
|
|
||||||
if isinstance(examples, list) and examples:
|
|
||||||
return examples[0]
|
|
||||||
|
|
||||||
for composite_key in ("anyOf", "oneOf", "allOf"):
|
|
||||||
composite = resolved.get(composite_key)
|
|
||||||
if isinstance(composite, list):
|
|
||||||
for branch in composite:
|
|
||||||
if not isinstance(branch, dict):
|
|
||||||
continue
|
|
||||||
branch_example = _example_from_schema(branch, components=components)
|
|
||||||
if branch_example is not None:
|
|
||||||
return branch_example
|
|
||||||
|
|
||||||
enum_values = resolved.get("enum")
|
|
||||||
if isinstance(enum_values, list) and enum_values:
|
|
||||||
return enum_values[0]
|
|
||||||
|
|
||||||
schema_type = resolved.get("type")
|
|
||||||
if schema_type == "object":
|
|
||||||
output: dict[str, Any] = {}
|
|
||||||
properties = resolved.get("properties")
|
|
||||||
if isinstance(properties, dict):
|
|
||||||
for key, property_schema in properties.items():
|
|
||||||
if not isinstance(property_schema, dict):
|
|
||||||
continue
|
|
||||||
property_example = _example_from_schema(property_schema, components=components)
|
|
||||||
if property_example is not None:
|
|
||||||
output[key] = property_example
|
|
||||||
if output:
|
|
||||||
return output
|
|
||||||
additional_properties = resolved.get("additionalProperties")
|
|
||||||
if isinstance(additional_properties, dict):
|
|
||||||
value_example = _example_from_schema(additional_properties, components=components)
|
|
||||||
if value_example is not None:
|
|
||||||
return {"key": value_example}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if schema_type == "array":
|
|
||||||
items = resolved.get("items")
|
|
||||||
if isinstance(items, dict):
|
|
||||||
item_example = _example_from_schema(items, components=components)
|
|
||||||
if item_example is not None:
|
|
||||||
return [item_example]
|
|
||||||
return []
|
|
||||||
|
|
||||||
if schema_type == "string":
|
|
||||||
return "string"
|
|
||||||
if schema_type == "integer":
|
|
||||||
return 0
|
|
||||||
if schema_type == "number":
|
|
||||||
return 0
|
|
||||||
if schema_type == "boolean":
|
|
||||||
return False
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_json_content_example(
|
|
||||||
*,
|
|
||||||
content: dict[str, Any],
|
|
||||||
components: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Attach an example to application/json content when one is missing."""
|
|
||||||
app_json = content.get("application/json")
|
|
||||||
if not isinstance(app_json, dict):
|
|
||||||
return
|
|
||||||
if "example" in app_json or "examples" in app_json:
|
|
||||||
return
|
|
||||||
schema = app_json.get("schema")
|
|
||||||
if not isinstance(schema, dict):
|
|
||||||
return
|
|
||||||
generated_example = _example_from_schema(schema, components=components)
|
|
||||||
if generated_example is not None:
|
|
||||||
app_json["example"] = generated_example
|
|
||||||
|
|
||||||
|
|
||||||
def _build_operation_summary(*, method: str, path: str) -> str:
|
|
||||||
"""Build a readable summary when an operation does not define one."""
|
|
||||||
prefix = _METHOD_SUMMARY_PREFIX.get(method.lower(), "Handle")
|
|
||||||
path_without_prefix = path.removeprefix("/api/v1/")
|
|
||||||
parts = [
|
|
||||||
part.replace("-", " ")
|
|
||||||
for part in path_without_prefix.split("/")
|
|
||||||
if part and not (part.startswith("{") and part.endswith("}"))
|
|
||||||
]
|
|
||||||
if not parts:
|
|
||||||
return prefix
|
|
||||||
return f"{prefix} {' '.join(parts)}".strip().title()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_operation_docs(
|
|
||||||
*,
|
|
||||||
operation: dict[str, Any],
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
) -> None:
|
|
||||||
"""Normalize summary/description/responses/request-body docs for tagged operations."""
|
|
||||||
summary = str(operation.get("summary", "")).strip()
|
|
||||||
if not summary:
|
|
||||||
summary = _build_operation_summary(method=method, path=path)
|
|
||||||
operation["summary"] = summary
|
|
||||||
|
|
||||||
description = str(operation.get("description", "")).strip()
|
|
||||||
if not description:
|
|
||||||
operation["description"] = f"{summary}."
|
|
||||||
|
|
||||||
request_body = operation.get("requestBody")
|
|
||||||
if isinstance(request_body, dict):
|
|
||||||
if not str(request_body.get("description", "")).strip():
|
|
||||||
request_body["description"] = "JSON request payload."
|
|
||||||
|
|
||||||
responses = operation.get("responses")
|
|
||||||
if not isinstance(responses, dict):
|
|
||||||
return
|
|
||||||
for status_code, response in responses.items():
|
|
||||||
if not isinstance(response, dict):
|
|
||||||
continue
|
|
||||||
existing_description = str(response.get("description", "")).strip()
|
|
||||||
if not existing_description or existing_description in _GENERIC_RESPONSE_DESCRIPTIONS:
|
|
||||||
response["description"] = _HTTP_RESPONSE_DESCRIPTIONS.get(
|
|
||||||
str(status_code),
|
|
||||||
"Request processed.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_tagged_operation_openapi_docs(openapi_schema: dict[str, Any]) -> None:
|
|
||||||
"""Ensure targeted-tag operations expose consistent OpenAPI docs and examples."""
|
|
||||||
components = openapi_schema.get("components")
|
|
||||||
if not isinstance(components, dict):
|
|
||||||
return
|
|
||||||
paths = openapi_schema.get("paths")
|
|
||||||
if not isinstance(paths, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
for path, path_item in paths.items():
|
|
||||||
if not isinstance(path_item, dict):
|
|
||||||
continue
|
|
||||||
for method, operation in path_item.items():
|
|
||||||
if not isinstance(operation, dict):
|
|
||||||
continue
|
|
||||||
tags = operation.get("tags")
|
|
||||||
if not isinstance(tags, list):
|
|
||||||
continue
|
|
||||||
if not _OPENAPI_EXAMPLE_TAGS.intersection(tags):
|
|
||||||
continue
|
|
||||||
|
|
||||||
_normalize_operation_docs(operation=operation, method=method, path=path)
|
|
||||||
|
|
||||||
request_body = operation.get("requestBody")
|
|
||||||
if isinstance(request_body, dict):
|
|
||||||
request_content = request_body.get("content")
|
|
||||||
if isinstance(request_content, dict):
|
|
||||||
_inject_json_content_example(content=request_content, components=components)
|
|
||||||
|
|
||||||
responses = operation.get("responses")
|
|
||||||
if isinstance(responses, dict):
|
|
||||||
for response in responses.values():
|
|
||||||
if not isinstance(response, dict):
|
|
||||||
continue
|
|
||||||
response_content = response.get("content")
|
|
||||||
if isinstance(response_content, dict):
|
|
||||||
_inject_json_content_example(
|
|
||||||
content=response_content, components=components
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_custom_openapi(fastapi_app: FastAPI) -> dict[str, Any]:
|
|
||||||
"""Generate OpenAPI schema with normalized docs/examples for targeted tags."""
|
|
||||||
if fastapi_app.openapi_schema:
|
|
||||||
return fastapi_app.openapi_schema
|
|
||||||
openapi_schema = get_openapi(
|
|
||||||
title=fastapi_app.title,
|
|
||||||
version=fastapi_app.version,
|
|
||||||
openapi_version=fastapi_app.openapi_version,
|
|
||||||
description=fastapi_app.description,
|
|
||||||
routes=fastapi_app.routes,
|
|
||||||
tags=fastapi_app.openapi_tags,
|
|
||||||
servers=fastapi_app.servers,
|
|
||||||
)
|
|
||||||
_inject_tagged_operation_openapi_docs(openapi_schema)
|
|
||||||
fastapi_app.openapi_schema = openapi_schema
|
|
||||||
return fastapi_app.openapi_schema
|
|
||||||
|
|
||||||
|
|
||||||
class MissionControlFastAPI(FastAPI):
|
|
||||||
"""FastAPI application with custom OpenAPI normalization."""
|
|
||||||
|
|
||||||
def openapi(self) -> dict[str, Any]:
|
|
||||||
return _build_custom_openapi(self)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
||||||
"""Initialize application resources before serving requests."""
|
|
||||||
logger.info(
|
|
||||||
"app.lifecycle.starting environment=%s db_auto_migrate=%s",
|
|
||||||
settings.environment,
|
|
||||||
settings.db_auto_migrate,
|
|
||||||
)
|
|
||||||
await init_db()
|
|
||||||
logger.info("app.lifecycle.started")
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
logger.info("app.lifecycle.stopped")
|
|
||||||
|
|
||||||
|
|
||||||
app = MissionControlFastAPI(
|
|
||||||
title="Mission Control API",
|
|
||||||
version="0.1.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
openapi_tags=OPENAPI_TAGS,
|
|
||||||
)
|
|
||||||
|
|
||||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
|
||||||
if origins:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
expose_headers=["X-Total-Count", "X-Limit", "X-Offset"],
|
|
||||||
)
|
|
||||||
logger.info("app.cors.enabled origins_count=%s", len(origins))
|
|
||||||
else:
|
|
||||||
logger.info("app.cors.disabled")
|
|
||||||
|
|
||||||
install_error_handling(app)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/health",
|
|
||||||
tags=["health"],
|
|
||||||
response_model=HealthStatusResponse,
|
|
||||||
summary="Health Check",
|
|
||||||
description="Lightweight liveness probe endpoint.",
|
|
||||||
responses={
|
|
||||||
status.HTTP_200_OK: {
|
|
||||||
"description": "Service is alive.",
|
|
||||||
"content": {"application/json": {"example": {"ok": True}}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def health() -> HealthStatusResponse:
|
|
||||||
"""Lightweight liveness probe endpoint."""
|
|
||||||
return HealthStatusResponse(ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/healthz",
|
|
||||||
tags=["health"],
|
|
||||||
response_model=HealthStatusResponse,
|
|
||||||
summary="Health Alias Check",
|
|
||||||
description="Alias liveness probe endpoint for platform compatibility.",
|
|
||||||
responses={
|
|
||||||
status.HTTP_200_OK: {
|
|
||||||
"description": "Service is alive.",
|
|
||||||
"content": {"application/json": {"example": {"ok": True}}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def healthz() -> HealthStatusResponse:
|
|
||||||
"""Alias liveness probe endpoint for platform compatibility."""
|
|
||||||
return HealthStatusResponse(ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/readyz",
|
|
||||||
tags=["health"],
|
|
||||||
response_model=HealthStatusResponse,
|
|
||||||
summary="Readiness Check",
|
|
||||||
description="Readiness probe endpoint for service orchestration checks.",
|
|
||||||
responses={
|
|
||||||
status.HTTP_200_OK: {
|
|
||||||
"description": "Service is ready.",
|
|
||||||
"content": {"application/json": {"example": {"ok": True}}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def readyz() -> HealthStatusResponse:
|
|
||||||
"""Readiness probe endpoint for service orchestration checks."""
|
|
||||||
return HealthStatusResponse(ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
api_v1 = APIRouter(prefix="/api/v1")
|
|
||||||
api_v1.include_router(auth_router)
|
|
||||||
api_v1.include_router(agent_router)
|
|
||||||
api_v1.include_router(agents_router)
|
|
||||||
api_v1.include_router(activity_router)
|
|
||||||
api_v1.include_router(gateway_router)
|
|
||||||
api_v1.include_router(gateways_router)
|
|
||||||
api_v1.include_router(metrics_router)
|
|
||||||
api_v1.include_router(organizations_router)
|
|
||||||
api_v1.include_router(souls_directory_router)
|
|
||||||
api_v1.include_router(skills_marketplace_router)
|
|
||||||
api_v1.include_router(board_groups_router)
|
|
||||||
api_v1.include_router(board_group_memory_router)
|
|
||||||
api_v1.include_router(boards_router)
|
|
||||||
api_v1.include_router(board_memory_router)
|
|
||||||
api_v1.include_router(board_webhooks_router)
|
|
||||||
api_v1.include_router(board_onboarding_router)
|
|
||||||
api_v1.include_router(approvals_router)
|
|
||||||
api_v1.include_router(tasks_router)
|
|
||||||
api_v1.include_router(task_custom_fields_router)
|
|
||||||
api_v1.include_router(tags_router)
|
|
||||||
api_v1.include_router(users_router)
|
|
||||||
app.include_router(api_v1)
|
|
||||||
|
|
||||||
add_pagination(app)
|
|
||||||
logger.debug("app.routes.registered count=%s", len(app.routes))
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Model exports for SQLAlchemy/SQLModel metadata discovery."""
|
|
||||||
|
|
||||||
from app.models.activity_events import ActivityEvent
|
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.models.approval_task_links import ApprovalTaskLink
|
|
||||||
from app.models.approvals import Approval
|
|
||||||
from app.models.board_group_memory import BoardGroupMemory
|
|
||||||
from app.models.board_groups import BoardGroup
|
|
||||||
from app.models.board_memory import BoardMemory
|
|
||||||
from app.models.board_onboarding import BoardOnboardingSession
|
|
||||||
from app.models.board_webhook_payloads import BoardWebhookPayload
|
|
||||||
from app.models.board_webhooks import BoardWebhook
|
|
||||||
from app.models.boards import Board
|
|
||||||
from app.models.gateways import Gateway
|
|
||||||
from app.models.organization_board_access import OrganizationBoardAccess
|
|
||||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
|
||||||
from app.models.organization_invites import OrganizationInvite
|
|
||||||
from app.models.organization_members import OrganizationMember
|
|
||||||
from app.models.organizations import Organization
|
|
||||||
from app.models.skills import GatewayInstalledSkill, MarketplaceSkill, SkillPack
|
|
||||||
from app.models.tag_assignments import TagAssignment
|
|
||||||
from app.models.tags import Tag
|
|
||||||
from app.models.task_custom_fields import (
|
|
||||||
BoardTaskCustomField,
|
|
||||||
TaskCustomFieldDefinition,
|
|
||||||
TaskCustomFieldValue,
|
|
||||||
)
|
|
||||||
from app.models.task_dependencies import TaskDependency
|
|
||||||
from app.models.task_fingerprints import TaskFingerprint
|
|
||||||
from app.models.tasks import Task
|
|
||||||
from app.models.users import User
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ActivityEvent",
|
|
||||||
"Agent",
|
|
||||||
"ApprovalTaskLink",
|
|
||||||
"Approval",
|
|
||||||
"BoardGroupMemory",
|
|
||||||
"BoardWebhook",
|
|
||||||
"BoardWebhookPayload",
|
|
||||||
"BoardMemory",
|
|
||||||
"BoardOnboardingSession",
|
|
||||||
"BoardGroup",
|
|
||||||
"Board",
|
|
||||||
"Gateway",
|
|
||||||
"GatewayInstalledSkill",
|
|
||||||
"MarketplaceSkill",
|
|
||||||
"SkillPack",
|
|
||||||
"Organization",
|
|
||||||
"BoardTaskCustomField",
|
|
||||||
"TaskCustomFieldDefinition",
|
|
||||||
"TaskCustomFieldValue",
|
|
||||||
"OrganizationMember",
|
|
||||||
"OrganizationBoardAccess",
|
|
||||||
"OrganizationInvite",
|
|
||||||
"OrganizationInviteBoardAccess",
|
|
||||||
"TaskDependency",
|
|
||||||
"Task",
|
|
||||||
"TaskFingerprint",
|
|
||||||
"Tag",
|
|
||||||
"TagAssignment",
|
|
||||||
"User",
|
|
||||||
]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Activity event model persisted for audit and feed use-cases."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityEvent(QueryModel, table=True):
|
|
||||||
"""Discrete activity event tied to tasks and agents."""
|
|
||||||
|
|
||||||
__tablename__ = "activity_events" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
event_type: str = Field(index=True)
|
|
||||||
message: str | None = None
|
|
||||||
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
|
|
||||||
task_id: UUID | None = Field(default=None, foreign_key="tasks.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Agent model representing autonomous actors assigned to boards."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, Text
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Agent(QueryModel, table=True):
|
|
||||||
"""Agent configuration and lifecycle state persisted in the database."""
|
|
||||||
|
|
||||||
__tablename__ = "agents" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID | None = Field(default=None, foreign_key="boards.id", index=True)
|
|
||||||
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
|
|
||||||
name: str = Field(index=True)
|
|
||||||
status: str = Field(default="provisioning", index=True)
|
|
||||||
openclaw_session_id: str | None = Field(default=None, index=True)
|
|
||||||
agent_token_hash: str | None = Field(default=None, index=True)
|
|
||||||
heartbeat_config: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSON),
|
|
||||||
)
|
|
||||||
identity_profile: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSON),
|
|
||||||
)
|
|
||||||
identity_template: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
soul_template: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
provision_requested_at: datetime | None = Field(default=None)
|
|
||||||
provision_confirm_token_hash: str | None = Field(default=None, index=True)
|
|
||||||
provision_action: str | None = Field(default=None, index=True)
|
|
||||||
delete_requested_at: datetime | None = Field(default=None)
|
|
||||||
delete_confirm_token_hash: str | None = Field(default=None, index=True)
|
|
||||||
last_seen_at: datetime | None = Field(default=None)
|
|
||||||
is_board_lead: bool = Field(default=False, index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Approval-task link model for many-to-many approval associations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalTaskLink(QueryModel, table=True):
|
|
||||||
"""Map an approval request to one task (many links per approval allowed)."""
|
|
||||||
|
|
||||||
__tablename__ = "approval_task_links" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"approval_id",
|
|
||||||
"task_id",
|
|
||||||
name="uq_approval_task_links_approval_id_task_id",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
approval_id: UUID = Field(foreign_key="approvals.id", index=True)
|
|
||||||
task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Approval model storing pending and resolved approval actions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, Float
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Approval(QueryModel, table=True):
|
|
||||||
"""Approval request and decision metadata for gated operations."""
|
|
||||||
|
|
||||||
__tablename__ = "approvals" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
task_id: UUID | None = Field(default=None, foreign_key="tasks.id", index=True)
|
|
||||||
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
|
|
||||||
action_type: str
|
|
||||||
payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
confidence: float = Field(sa_column=Column(Float, nullable=False))
|
|
||||||
rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
status: str = Field(default="pending", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
resolved_at: datetime | None = None
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Base model mixins and shared SQLModel abstractions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import ClassVar, Self
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from app.db.query_manager import ManagerDescriptor
|
|
||||||
|
|
||||||
|
|
||||||
class QueryModel(SQLModel, table=False):
|
|
||||||
"""Base SQLModel with a shared query manager descriptor."""
|
|
||||||
|
|
||||||
objects: ClassVar[ManagerDescriptor[Self]] = ManagerDescriptor()
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Board-group scoped memory entries for shared context."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupMemory(QueryModel, table=True):
|
|
||||||
"""Persisted memory items associated with a board group."""
|
|
||||||
|
|
||||||
__tablename__ = "board_group_memory" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_group_id: UUID = Field(foreign_key="board_groups.id", index=True)
|
|
||||||
content: str
|
|
||||||
tags: list[str] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
is_chat: bool = Field(default=False, index=True)
|
|
||||||
source: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Board group model used to organize boards inside organizations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroup(TenantScoped, table=True):
|
|
||||||
"""Logical grouping container for boards within an organization."""
|
|
||||||
|
|
||||||
__tablename__ = "board_groups" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
slug: str = Field(index=True)
|
|
||||||
description: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Board-level memory entries for persistent contextual state."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardMemory(QueryModel, table=True):
|
|
||||||
"""Persisted memory item attached directly to a board."""
|
|
||||||
|
|
||||||
__tablename__ = "board_memory" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
content: str
|
|
||||||
tags: list[str] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
is_chat: bool = Field(default=False, index=True)
|
|
||||||
source: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Board onboarding session model for guided setup state."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardOnboardingSession(QueryModel, table=True):
|
|
||||||
"""Persisted onboarding conversation and draft goal data for a board."""
|
|
||||||
|
|
||||||
__tablename__ = "board_onboarding_sessions" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
session_key: str
|
|
||||||
status: str = Field(default="active", index=True)
|
|
||||||
messages: list[dict[str, object]] | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSON),
|
|
||||||
)
|
|
||||||
draft_goal: dict[str, object] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Persisted webhook payloads received for board webhooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardWebhookPayload(QueryModel, table=True):
|
|
||||||
"""Captured inbound webhook payload with request metadata."""
|
|
||||||
|
|
||||||
__tablename__ = "board_webhook_payloads" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
webhook_id: UUID = Field(foreign_key="board_webhooks.id", index=True)
|
|
||||||
payload: dict[str, object] | list[object] | str | int | float | bool | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSON),
|
|
||||||
)
|
|
||||||
headers: dict[str, str] | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
source_ip: str | None = None
|
|
||||||
content_type: str | None = None
|
|
||||||
received_at: datetime = Field(default_factory=utcnow, index=True)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Board webhook configuration model."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardWebhook(QueryModel, table=True):
|
|
||||||
"""Inbound webhook endpoint configuration for a board."""
|
|
||||||
|
|
||||||
__tablename__ = "board_webhooks" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True)
|
|
||||||
description: str
|
|
||||||
enabled: bool = Field(default=True, index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Board model for organization workspaces and goal configuration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Board(TenantScoped, table=True):
|
|
||||||
"""Primary board entity grouping tasks, agents, and goal metadata."""
|
|
||||||
|
|
||||||
__tablename__ = "boards" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
slug: str = Field(index=True)
|
|
||||||
description: str = Field(default="")
|
|
||||||
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
|
||||||
board_group_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="board_groups.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
board_type: str = Field(default="goal", index=True)
|
|
||||||
objective: str | None = None
|
|
||||||
success_metrics: dict[str, object] | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSON),
|
|
||||||
)
|
|
||||||
target_date: datetime | None = None
|
|
||||||
goal_confirmed: bool = Field(default=False)
|
|
||||||
goal_source: str | None = None
|
|
||||||
require_approval_for_done: bool = Field(default=True)
|
|
||||||
require_review_before_done: bool = Field(default=False)
|
|
||||||
block_status_changes_with_pending_approval: bool = Field(default=False)
|
|
||||||
only_lead_can_change_status: bool = Field(default=False)
|
|
||||||
max_agents: int = Field(default=1)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Gateway model storing organization-level gateway integration metadata."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Gateway(QueryModel, table=True):
|
|
||||||
"""Configured external gateway endpoint and authentication settings."""
|
|
||||||
|
|
||||||
__tablename__ = "gateways" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
token: str | None = Field(default=None)
|
|
||||||
workspace_root: str
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Board-level access grants assigned to organization members."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationBoardAccess(QueryModel, table=True):
|
|
||||||
"""Member-specific board permissions within an organization."""
|
|
||||||
|
|
||||||
__tablename__ = "organization_board_access" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_member_id",
|
|
||||||
"board_id",
|
|
||||||
name="uq_org_board_access_member_board",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_member_id: UUID = Field(
|
|
||||||
foreign_key="organization_members.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
can_read: bool = Field(default=True)
|
|
||||||
can_write: bool = Field(default=False)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Board access grants attached to pending organization invites."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationInviteBoardAccess(QueryModel, table=True):
|
|
||||||
"""Invite-specific board permissions applied after invite acceptance."""
|
|
||||||
|
|
||||||
__tablename__ = "organization_invite_board_access" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_invite_id",
|
|
||||||
"board_id",
|
|
||||||
name="uq_org_invite_board_access_invite_board",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_invite_id: UUID = Field(
|
|
||||||
foreign_key="organization_invites.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
can_read: bool = Field(default=True)
|
|
||||||
can_write: bool = Field(default=False)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Organization invite model for email-based tenant membership flow."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationInvite(QueryModel, table=True):
|
|
||||||
"""Invitation record granting prospective organization access."""
|
|
||||||
|
|
||||||
__tablename__ = "organization_invites" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (UniqueConstraint("token", name="uq_org_invites_token"),)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
invited_email: str = Field(index=True)
|
|
||||||
token: str = Field(index=True)
|
|
||||||
role: str = Field(default="member", index=True)
|
|
||||||
all_boards_read: bool = Field(default=False)
|
|
||||||
all_boards_write: bool = Field(default=False)
|
|
||||||
created_by_user_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="users.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
accepted_by_user_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="users.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
accepted_at: datetime | None = None
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Organization membership model with role and board-access flags."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationMember(QueryModel, table=True):
|
|
||||||
"""Membership row linking a user to an organization and permissions."""
|
|
||||||
|
|
||||||
__tablename__ = "organization_members" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_id",
|
|
||||||
"user_id",
|
|
||||||
name="uq_organization_members_org_user",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
|
||||||
role: str = Field(default="member", index=True)
|
|
||||||
all_boards_read: bool = Field(default=False)
|
|
||||||
all_boards_write: bool = Field(default=False)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""Organization model representing top-level tenant entities."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Organization(QueryModel, table=True):
|
|
||||||
"""Top-level organization tenant record."""
|
|
||||||
|
|
||||||
__tablename__ = "organizations" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
name: str = Field(index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Skill-related SQLModel tables for marketplace, packs, and installations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class MarketplaceSkill(TenantScoped, table=True):
|
|
||||||
"""A marketplace skill entry that can be installed onto one or more gateways."""
|
|
||||||
|
|
||||||
__tablename__ = "marketplace_skills" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_id",
|
|
||||||
"source_url",
|
|
||||||
name="uq_marketplace_skills_org_source_url",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
description: str | None = Field(default=None)
|
|
||||||
category: str | None = Field(default=None)
|
|
||||||
risk: str | None = Field(default=None)
|
|
||||||
source: str | None = Field(default=None)
|
|
||||||
source_url: str
|
|
||||||
metadata_: dict[str, object] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
sa_column=Column("metadata", JSON, nullable=False),
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class SkillPack(TenantScoped, table=True):
|
|
||||||
"""A pack repository URL that can be synced into marketplace skills."""
|
|
||||||
|
|
||||||
__tablename__ = "skill_packs" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_id",
|
|
||||||
"source_url",
|
|
||||||
name="uq_skill_packs_org_source_url",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
description: str | None = Field(default=None)
|
|
||||||
source_url: str
|
|
||||||
branch: str = Field(default="main")
|
|
||||||
metadata_: dict[str, object] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
sa_column=Column("metadata", JSON, nullable=False),
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class GatewayInstalledSkill(QueryModel, table=True):
|
|
||||||
"""Marks that a marketplace skill is installed for a specific gateway."""
|
|
||||||
|
|
||||||
__tablename__ = "gateway_installed_skills" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"gateway_id",
|
|
||||||
"skill_id",
|
|
||||||
name="uq_gateway_installed_skills_gateway_id_skill_id",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
|
|
||||||
skill_id: UUID = Field(foreign_key="marketplace_skills.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Task/tag many-to-many link rows."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class TagAssignment(QueryModel, table=True):
|
|
||||||
"""Association row mapping one task to one tag."""
|
|
||||||
|
|
||||||
__tablename__ = "tag_assignments" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"task_id",
|
|
||||||
"tag_id",
|
|
||||||
name="uq_tag_assignments_task_id_tag_id",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
|
||||||
tag_id: UUID = Field(foreign_key="tags.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Tag model for organization-scoped task categorization."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(TenantScoped, table=True):
|
|
||||||
"""Organization-scoped tag used to classify and group tasks."""
|
|
||||||
|
|
||||||
__tablename__ = "tags" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_id",
|
|
||||||
"slug",
|
|
||||||
name="uq_tags_organization_id_slug",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
name: str
|
|
||||||
slug: str = Field(index=True)
|
|
||||||
color: str = Field(default="9e9e9e")
|
|
||||||
description: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Task custom field models and board binding helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, CheckConstraint, Column, UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskCustomFieldDefinition(TenantScoped, table=True):
|
|
||||||
"""Reusable custom field definition for task metadata."""
|
|
||||||
|
|
||||||
__tablename__ = "task_custom_field_definitions" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"organization_id",
|
|
||||||
"field_key",
|
|
||||||
name="uq_task_custom_field_definitions_org_id_field_key",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"field_type IN ('text','text_long','integer','decimal','boolean','date','date_time','url','json')",
|
|
||||||
name="ck_tcf_def_field_type",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"ui_visibility IN ('always','if_set','hidden')",
|
|
||||||
name="ck_tcf_def_ui_visibility",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
|
||||||
field_key: str = Field(index=True)
|
|
||||||
label: str
|
|
||||||
field_type: str = Field(default="text")
|
|
||||||
ui_visibility: str = Field(default="always")
|
|
||||||
validation_regex: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
required: bool = Field(default=False)
|
|
||||||
default_value: object | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardTaskCustomField(TenantScoped, table=True):
|
|
||||||
"""Board-level binding of a custom field definition."""
|
|
||||||
|
|
||||||
__tablename__ = "board_task_custom_fields" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"board_id",
|
|
||||||
"task_custom_field_definition_id",
|
|
||||||
name="uq_board_task_custom_fields_board_id_task_custom_field_definition_id",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
task_custom_field_definition_id: UUID = Field(
|
|
||||||
foreign_key="task_custom_field_definitions.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskCustomFieldValue(TenantScoped, table=True):
|
|
||||||
"""Stored task-level values for bound custom fields."""
|
|
||||||
|
|
||||||
__tablename__ = "task_custom_field_values" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"task_id",
|
|
||||||
"task_custom_field_definition_id",
|
|
||||||
name="uq_task_custom_field_values_task_id_task_custom_field_definition_id",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
|
||||||
task_custom_field_definition_id: UUID = Field(
|
|
||||||
foreign_key="task_custom_field_definitions.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
value: object | None = Field(default=None, sa_column=Column(JSON))
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Task dependency edge model for board-local dependency graphs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskDependency(TenantScoped, table=True):
|
|
||||||
"""Directed dependency edge between two tasks in the same board."""
|
|
||||||
|
|
||||||
__tablename__ = "task_dependencies" # pyright: ignore[reportAssignmentType]
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"task_id",
|
|
||||||
"depends_on_task_id",
|
|
||||||
name="uq_task_dependencies_task_id_depends_on_task_id",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"task_id <> depends_on_task_id",
|
|
||||||
name="ck_task_dependencies_no_self",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
|
||||||
depends_on_task_id: UUID = Field(foreign_key="tasks.id", index=True)
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Task fingerprint model for duplicate/task-linking operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskFingerprint(QueryModel, table=True):
|
|
||||||
"""Hashed task-content fingerprint associated with a board and task."""
|
|
||||||
|
|
||||||
__tablename__ = "task_fingerprints" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
|
||||||
fingerprint_hash: str = Field(index=True)
|
|
||||||
task_id: UUID = Field(foreign_key="tasks.id")
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Task model representing board work items and execution metadata."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.core.time import utcnow
|
|
||||||
from app.models.tenancy import TenantScoped
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
|
||||||
|
|
||||||
|
|
||||||
class Task(TenantScoped, table=True):
|
|
||||||
"""Board-scoped task entity with ownership, status, and timing fields."""
|
|
||||||
|
|
||||||
__tablename__ = "tasks" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
board_id: UUID | None = Field(default=None, foreign_key="boards.id", index=True)
|
|
||||||
|
|
||||||
title: str
|
|
||||||
description: str | None = None
|
|
||||||
status: str = Field(default="inbox", index=True)
|
|
||||||
priority: str = Field(default="medium", index=True)
|
|
||||||
due_at: datetime | None = None
|
|
||||||
in_progress_at: datetime | None = None
|
|
||||||
previous_in_progress_at: datetime | None = None
|
|
||||||
|
|
||||||
created_by_user_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="users.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
assigned_agent_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="agents.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
auto_created: bool = Field(default=False)
|
|
||||||
auto_reason: str | None = None
|
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"""Shared tenancy-scoped model base classes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
|
|
||||||
class TenantScoped(QueryModel, table=False):
|
|
||||||
"""Base class for models constrained to a tenant/organization scope."""
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""User model storing identity and profile preferences."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field
|
|
||||||
|
|
||||||
from app.models.base import QueryModel
|
|
||||||
|
|
||||||
|
|
||||||
class User(QueryModel, table=True):
|
|
||||||
"""Application user account and profile attributes."""
|
|
||||||
|
|
||||||
__tablename__ = "users" # pyright: ignore[reportAssignmentType]
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
clerk_user_id: str = Field(index=True, unique=True)
|
|
||||||
email: str | None = Field(default=None, index=True)
|
|
||||||
name: str | None = None
|
|
||||||
preferred_name: str | None = None
|
|
||||||
pronouns: str | None = None
|
|
||||||
timezone: str | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
context: str | None = None
|
|
||||||
is_super_admin: bool = Field(default=False)
|
|
||||||
active_organization_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
foreign_key="organizations.id",
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Public schema exports shared across API route modules."""
|
|
||||||
|
|
||||||
from app.schemas.activity_events import ActivityEventRead
|
|
||||||
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
|
|
||||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate
|
|
||||||
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
|
||||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
|
||||||
from app.schemas.board_onboarding import (
|
|
||||||
BoardOnboardingAnswer,
|
|
||||||
BoardOnboardingConfirm,
|
|
||||||
BoardOnboardingRead,
|
|
||||||
BoardOnboardingStart,
|
|
||||||
)
|
|
||||||
from app.schemas.board_webhooks import (
|
|
||||||
BoardWebhookCreate,
|
|
||||||
BoardWebhookIngestResponse,
|
|
||||||
BoardWebhookPayloadRead,
|
|
||||||
BoardWebhookRead,
|
|
||||||
BoardWebhookUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
|
||||||
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
|
|
||||||
from app.schemas.metrics import DashboardMetrics
|
|
||||||
from app.schemas.organizations import (
|
|
||||||
OrganizationActiveUpdate,
|
|
||||||
OrganizationCreate,
|
|
||||||
OrganizationInviteAccept,
|
|
||||||
OrganizationInviteCreate,
|
|
||||||
OrganizationInviteRead,
|
|
||||||
OrganizationListItem,
|
|
||||||
OrganizationMemberAccessUpdate,
|
|
||||||
OrganizationMemberRead,
|
|
||||||
OrganizationMemberUpdate,
|
|
||||||
OrganizationRead,
|
|
||||||
)
|
|
||||||
from app.schemas.skills_marketplace import (
|
|
||||||
MarketplaceSkillActionResponse,
|
|
||||||
MarketplaceSkillCardRead,
|
|
||||||
MarketplaceSkillCreate,
|
|
||||||
MarketplaceSkillRead,
|
|
||||||
SkillPackCreate,
|
|
||||||
SkillPackRead,
|
|
||||||
SkillPackSyncResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.souls_directory import (
|
|
||||||
SoulsDirectoryMarkdownResponse,
|
|
||||||
SoulsDirectorySearchResponse,
|
|
||||||
SoulsDirectorySoulRef,
|
|
||||||
)
|
|
||||||
from app.schemas.tags import TagCreate, TagRead, TagRef, TagUpdate
|
|
||||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
|
||||||
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ActivityEventRead",
|
|
||||||
"AgentCreate",
|
|
||||||
"AgentRead",
|
|
||||||
"AgentUpdate",
|
|
||||||
"ApprovalCreate",
|
|
||||||
"ApprovalRead",
|
|
||||||
"ApprovalUpdate",
|
|
||||||
"BoardGroupMemoryCreate",
|
|
||||||
"BoardGroupMemoryRead",
|
|
||||||
"BoardMemoryCreate",
|
|
||||||
"BoardMemoryRead",
|
|
||||||
"BoardWebhookCreate",
|
|
||||||
"BoardWebhookIngestResponse",
|
|
||||||
"BoardWebhookPayloadRead",
|
|
||||||
"BoardWebhookRead",
|
|
||||||
"BoardWebhookUpdate",
|
|
||||||
"BoardOnboardingAnswer",
|
|
||||||
"BoardOnboardingConfirm",
|
|
||||||
"BoardOnboardingRead",
|
|
||||||
"BoardOnboardingStart",
|
|
||||||
"BoardCreate",
|
|
||||||
"BoardRead",
|
|
||||||
"BoardUpdate",
|
|
||||||
"GatewayCreate",
|
|
||||||
"GatewayRead",
|
|
||||||
"GatewayUpdate",
|
|
||||||
"DashboardMetrics",
|
|
||||||
"OrganizationActiveUpdate",
|
|
||||||
"OrganizationCreate",
|
|
||||||
"OrganizationInviteAccept",
|
|
||||||
"OrganizationInviteCreate",
|
|
||||||
"OrganizationInviteRead",
|
|
||||||
"OrganizationListItem",
|
|
||||||
"OrganizationMemberAccessUpdate",
|
|
||||||
"OrganizationMemberRead",
|
|
||||||
"OrganizationMemberUpdate",
|
|
||||||
"OrganizationRead",
|
|
||||||
"SoulsDirectoryMarkdownResponse",
|
|
||||||
"SoulsDirectorySearchResponse",
|
|
||||||
"SoulsDirectorySoulRef",
|
|
||||||
"MarketplaceSkillActionResponse",
|
|
||||||
"MarketplaceSkillCardRead",
|
|
||||||
"MarketplaceSkillCreate",
|
|
||||||
"MarketplaceSkillRead",
|
|
||||||
"SkillPackCreate",
|
|
||||||
"SkillPackRead",
|
|
||||||
"SkillPackSyncResponse",
|
|
||||||
"TagCreate",
|
|
||||||
"TagRead",
|
|
||||||
"TagRef",
|
|
||||||
"TagUpdate",
|
|
||||||
"TaskCreate",
|
|
||||||
"TaskRead",
|
|
||||||
"TaskUpdate",
|
|
||||||
"UserCreate",
|
|
||||||
"UserRead",
|
|
||||||
"UserUpdate",
|
|
||||||
]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Response schemas for activity events and task-comment feed items."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityEventRead(SQLModel):
|
|
||||||
"""Serialized activity event payload returned by activity endpoints."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
event_type: str
|
|
||||||
message: str | None
|
|
||||||
agent_id: UUID | None
|
|
||||||
task_id: UUID | None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityTaskCommentFeedItemRead(SQLModel):
|
|
||||||
"""Denormalized task-comment feed item enriched with task and board fields."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
created_at: datetime
|
|
||||||
message: str | None
|
|
||||||
agent_id: UUID | None
|
|
||||||
agent_name: str | None = None
|
|
||||||
agent_role: str | None = None
|
|
||||||
task_id: UUID
|
|
||||||
task_title: str
|
|
||||||
board_id: UUID
|
|
||||||
board_name: str
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
"""Pydantic/SQLModel schemas for agent API payloads."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel._compat import SQLModelConfig
|
|
||||||
|
|
||||||
from app.schemas.common import NonEmptyStr
|
|
||||||
|
|
||||||
_RUNTIME_TYPE_REFERENCES = (datetime, UUID, NonEmptyStr)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_identity_profile(
|
|
||||||
profile: object,
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
if not isinstance(profile, Mapping):
|
|
||||||
return None
|
|
||||||
normalized: dict[str, str] = {}
|
|
||||||
for raw_key, raw in profile.items():
|
|
||||||
if raw is None:
|
|
||||||
continue
|
|
||||||
key = str(raw_key).strip()
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
if isinstance(raw, list):
|
|
||||||
parts = [str(item).strip() for item in raw if str(item).strip()]
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
normalized[key] = ", ".join(parts)
|
|
||||||
continue
|
|
||||||
value = str(raw).strip()
|
|
||||||
if value:
|
|
||||||
normalized[key] = value
|
|
||||||
return normalized or None
|
|
||||||
|
|
||||||
|
|
||||||
class AgentBase(SQLModel):
|
|
||||||
"""Common fields shared by agent create/read/update payloads."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_profile",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"Create or update canonical agent metadata",
|
|
||||||
"Inspect agent attributes for governance or delegation",
|
|
||||||
],
|
|
||||||
"x-when-not-to-use": [
|
|
||||||
"Task lifecycle operations (use task endpoints)",
|
|
||||||
"User-facing conversation content (not modeled here)",
|
|
||||||
],
|
|
||||||
"x-required-actor": "lead_or_worker_agent",
|
|
||||||
"x-prerequisites": [
|
|
||||||
"board_id if required by your board policy",
|
|
||||||
"identity templates should be valid JSON or text with expected markers",
|
|
||||||
],
|
|
||||||
"x-response-shape": "AgentRead",
|
|
||||||
"x-side-effects": [
|
|
||||||
"Reads or writes core agent profile fields",
|
|
||||||
"May impact routing or assignment decisions when persisted",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
board_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Board id that scopes this agent. Omit only when policy allows global agents.",
|
|
||||||
examples=["11111111-1111-1111-1111-111111111111"],
|
|
||||||
)
|
|
||||||
name: NonEmptyStr = Field(
|
|
||||||
description="Human-readable agent display name.",
|
|
||||||
examples=["Ops triage lead"],
|
|
||||||
)
|
|
||||||
status: str = Field(
|
|
||||||
default="provisioning",
|
|
||||||
description="Current lifecycle state used by coordinator logic.",
|
|
||||||
examples=["provisioning", "active", "paused", "retired"],
|
|
||||||
)
|
|
||||||
heartbeat_config: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Runtime heartbeat behavior overrides for this agent.",
|
|
||||||
examples=[{"interval_seconds": 30, "missing_tolerance": 120}],
|
|
||||||
)
|
|
||||||
identity_profile: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional profile hints used by routing and policy checks.",
|
|
||||||
examples=[{"role": "incident_lead", "skill": "triage"}],
|
|
||||||
)
|
|
||||||
identity_template: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Template that helps define initial intent and behavior.",
|
|
||||||
examples=["You are a senior incident response lead."],
|
|
||||||
)
|
|
||||||
soul_template: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Template representing deeper agent instructions.",
|
|
||||||
examples=["When critical blockers appear, escalate in plain language."],
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("identity_template", "soul_template", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_templates(cls, value: object) -> object | None:
|
|
||||||
"""Normalize blank template text to null."""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
return value or None
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("identity_profile", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_identity_profile(
|
|
||||||
cls,
|
|
||||||
value: object,
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
"""Normalize identity-profile values into trimmed string mappings."""
|
|
||||||
return _normalize_identity_profile(value)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentCreate(AgentBase):
|
|
||||||
"""Payload for creating a new agent."""
|
|
||||||
|
|
||||||
|
|
||||||
class AgentUpdate(SQLModel):
|
|
||||||
"""Payload for patching an existing agent."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_profile_update",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"Patch mutable agent metadata without replacing the full payload",
|
|
||||||
"Update status, templates, or heartbeat policy",
|
|
||||||
],
|
|
||||||
"x-when-not-to-use": [
|
|
||||||
"Creating an agent (use AgentCreate)",
|
|
||||||
"Hard deletes or archive actions (use lifecycle endpoints)",
|
|
||||||
],
|
|
||||||
"x-required-actor": "board_lead",
|
|
||||||
"x-prerequisites": [
|
|
||||||
"Target agent id must exist and be visible to actor context",
|
|
||||||
],
|
|
||||||
"x-side-effects": [
|
|
||||||
"Mutates agent profile state",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
board_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional new board assignment.",
|
|
||||||
examples=["22222222-2222-2222-2222-222222222222"],
|
|
||||||
)
|
|
||||||
is_gateway_main: bool | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Whether this agent is treated as the board gateway main.",
|
|
||||||
)
|
|
||||||
name: NonEmptyStr | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional replacement display name.",
|
|
||||||
examples=["Ops triage lead"],
|
|
||||||
)
|
|
||||||
status: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional replacement lifecycle status.",
|
|
||||||
examples=["active", "paused"],
|
|
||||||
)
|
|
||||||
heartbeat_config: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional heartbeat policy override.",
|
|
||||||
examples=[{"interval_seconds": 45}],
|
|
||||||
)
|
|
||||||
identity_profile: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional identity profile update values.",
|
|
||||||
examples=[{"role": "coordinator"}],
|
|
||||||
)
|
|
||||||
identity_template: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional replacement identity template.",
|
|
||||||
examples=["Focus on root cause analysis first."],
|
|
||||||
)
|
|
||||||
soul_template: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional replacement soul template.",
|
|
||||||
examples=["Escalate only after checking all known mitigations."],
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("identity_template", "soul_template", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_templates(cls, value: object) -> object | None:
|
|
||||||
"""Normalize blank template text to null."""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.strip()
|
|
||||||
return value or None
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("identity_profile", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def normalize_identity_profile(
|
|
||||||
cls,
|
|
||||||
value: object,
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
"""Normalize identity-profile values into trimmed string mappings."""
|
|
||||||
return _normalize_identity_profile(value)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRead(AgentBase):
|
|
||||||
"""Public agent representation returned by the API."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_profile_lookup",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"Inspect live agent state for routing and ownership decisions",
|
|
||||||
],
|
|
||||||
"x-required-actor": "board_lead_or_worker",
|
|
||||||
"x-interpretation": "This is a read model; changes here should use update/lifecycle endpoints.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID = Field(description="Agent UUID.")
|
|
||||||
gateway_id: UUID = Field(description="Gateway UUID that manages this agent.")
|
|
||||||
is_board_lead: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Whether this agent is the board lead.",
|
|
||||||
)
|
|
||||||
is_gateway_main: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Whether this agent is the primary gateway agent.",
|
|
||||||
)
|
|
||||||
openclaw_session_id: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional openclaw session token.",
|
|
||||||
examples=["sess_01J..."],
|
|
||||||
)
|
|
||||||
last_seen_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Last heartbeat timestamp.",
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(description="Creation timestamp.")
|
|
||||||
updated_at: datetime = Field(description="Last update timestamp.")
|
|
||||||
|
|
||||||
|
|
||||||
class AgentHeartbeat(SQLModel):
|
|
||||||
"""Heartbeat status payload sent by agents."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_health_signal",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"Send periodic heartbeat to indicate liveness",
|
|
||||||
],
|
|
||||||
"x-required-actor": "any_agent",
|
|
||||||
"x-response-shape": "AgentRead",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
status: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Agent health status string.",
|
|
||||||
examples=["healthy", "offline", "degraded"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentHeartbeatCreate(AgentHeartbeat):
|
|
||||||
"""Heartbeat payload used to create an agent lazily."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_bootstrap",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"First heartbeat from a non-provisioned worker should bootstrap identity.",
|
|
||||||
],
|
|
||||||
"x-required-actor": "agent",
|
|
||||||
"x-prerequisites": ["Agent auth token already validated"],
|
|
||||||
"x-response-shape": "AgentRead",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
name: NonEmptyStr = Field(
|
|
||||||
description="Display name assigned during first heartbeat bootstrap.",
|
|
||||||
examples=["Ops triage lead"],
|
|
||||||
)
|
|
||||||
board_id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Optional board context for bootstrap.",
|
|
||||||
examples=["33333333-3333-3333-3333-333333333333"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentNudge(SQLModel):
|
|
||||||
"""Nudge message payload for pinging an agent."""
|
|
||||||
|
|
||||||
model_config = SQLModelConfig(
|
|
||||||
json_schema_extra={
|
|
||||||
"x-llm-intent": "agent_nudge",
|
|
||||||
"x-when-to-use": [
|
|
||||||
"Prompt a specific agent to revisit or reprioritize work.",
|
|
||||||
],
|
|
||||||
"x-required-actor": "board_lead",
|
|
||||||
"x-response-shape": "AgentRead",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
message: NonEmptyStr = Field(
|
|
||||||
description="Short message to direct an agent toward immediate attention.",
|
|
||||||
examples=["Please update the incident triage status for task T-001."],
|
|
||||||
)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Schemas for approval create/update/read API payloads."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Literal, Self
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import model_validator
|
|
||||||
from sqlmodel import Field, SQLModel
|
|
||||||
|
|
||||||
ApprovalStatus = Literal["pending", "approved", "rejected"]
|
|
||||||
STATUS_REQUIRED_ERROR = "status is required"
|
|
||||||
LEAD_REASONING_REQUIRED_ERROR = "lead reasoning is required"
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalBase(SQLModel):
|
|
||||||
"""Shared approval fields used across create/read payloads."""
|
|
||||||
|
|
||||||
action_type: str
|
|
||||||
task_id: UUID | None = None
|
|
||||||
task_ids: list[UUID] = Field(default_factory=list)
|
|
||||||
payload: dict[str, object] | None = None
|
|
||||||
confidence: float = Field(ge=0, le=100)
|
|
||||||
rubric_scores: dict[str, int] | None = None
|
|
||||||
status: ApprovalStatus = "pending"
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def normalize_task_links(self) -> Self:
|
|
||||||
"""Keep task identifiers deduplicated and task_id aligned with task_ids."""
|
|
||||||
deduped: list[UUID] = []
|
|
||||||
seen: set[UUID] = set()
|
|
||||||
if self.task_id is not None:
|
|
||||||
deduped.append(self.task_id)
|
|
||||||
seen.add(self.task_id)
|
|
||||||
for task_id in self.task_ids:
|
|
||||||
if task_id in seen:
|
|
||||||
continue
|
|
||||||
seen.add(task_id)
|
|
||||||
deduped.append(task_id)
|
|
||||||
self.task_ids = deduped
|
|
||||||
self.task_id = deduped[0] if deduped else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalCreate(ApprovalBase):
|
|
||||||
"""Payload for creating a new approval request."""
|
|
||||||
|
|
||||||
agent_id: UUID | None = None
|
|
||||||
lead_reasoning: str | None = None
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_lead_reasoning(self) -> Self:
|
|
||||||
"""Ensure each approval request includes explicit lead reasoning."""
|
|
||||||
payload = self.payload
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
reason = payload.get("reason")
|
|
||||||
if isinstance(reason, str) and reason.strip():
|
|
||||||
return self
|
|
||||||
decision = payload.get("decision")
|
|
||||||
if isinstance(decision, dict):
|
|
||||||
nested_reason = decision.get("reason")
|
|
||||||
if isinstance(nested_reason, str) and nested_reason.strip():
|
|
||||||
return self
|
|
||||||
lead_reasoning = self.lead_reasoning
|
|
||||||
if isinstance(lead_reasoning, str) and lead_reasoning.strip():
|
|
||||||
self.payload = {
|
|
||||||
**(payload if isinstance(payload, dict) else {}),
|
|
||||||
"reason": lead_reasoning.strip(),
|
|
||||||
}
|
|
||||||
return self
|
|
||||||
raise ValueError(LEAD_REASONING_REQUIRED_ERROR)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalUpdate(SQLModel):
|
|
||||||
"""Payload for mutating approval status."""
|
|
||||||
|
|
||||||
status: ApprovalStatus | None = None
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_status(self) -> Self:
|
|
||||||
"""Ensure explicitly provided `status` is not null."""
|
|
||||||
if "status" in self.model_fields_set and self.status is None:
|
|
||||||
raise ValueError(STATUS_REQUIRED_ERROR)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalRead(ApprovalBase):
|
|
||||||
"""Approval payload returned from read endpoints."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
board_id: UUID
|
|
||||||
task_titles: list[str] = Field(default_factory=list)
|
|
||||||
agent_id: UUID | None = None
|
|
||||||
created_at: datetime
|
|
||||||
resolved_at: datetime | None = None
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Schemas for applying heartbeat settings to board-group agents."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (UUID,)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupHeartbeatApply(SQLModel):
|
|
||||||
"""Request payload for heartbeat policy updates."""
|
|
||||||
|
|
||||||
# Heartbeat cadence string understood by the OpenClaw gateway
|
|
||||||
# (e.g. "2m", "10m", "30m").
|
|
||||||
every: str
|
|
||||||
include_board_leads: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupHeartbeatApplyResult(SQLModel):
|
|
||||||
"""Result payload describing agents updated by a heartbeat request."""
|
|
||||||
|
|
||||||
board_group_id: UUID
|
|
||||||
requested: dict[str, Any]
|
|
||||||
updated_agent_ids: list[UUID]
|
|
||||||
failed_agent_ids: list[UUID]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Schemas for board-group memory create/read API payloads."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from app.schemas.common import NonEmptyStr
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupMemoryCreate(SQLModel):
|
|
||||||
"""Payload for creating a board-group memory entry."""
|
|
||||||
|
|
||||||
# For writes, reject blank/whitespace-only content.
|
|
||||||
content: NonEmptyStr
|
|
||||||
tags: list[str] | None = None
|
|
||||||
source: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupMemoryRead(SQLModel):
|
|
||||||
"""Serialized board-group memory entry returned from read endpoints."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
board_group_id: UUID
|
|
||||||
# For reads, allow legacy rows that may have empty content
|
|
||||||
# (avoid response validation 500s).
|
|
||||||
content: str
|
|
||||||
tags: list[str] | None = None
|
|
||||||
source: str | None = None
|
|
||||||
is_chat: bool = False
|
|
||||||
created_at: datetime
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Schemas for board-group create/update/read API operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupBase(SQLModel):
|
|
||||||
"""Shared board-group fields for create/read operations."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupCreate(BoardGroupBase):
|
|
||||||
"""Payload for creating a board group."""
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupUpdate(SQLModel):
|
|
||||||
"""Payload for partial board-group updates."""
|
|
||||||
|
|
||||||
name: str | None = None
|
|
||||||
slug: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGroupRead(BoardGroupBase):
|
|
||||||
"""Board-group payload returned from read endpoints."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user