Compare commits

..
2 Commits
707 changed files with 376 additions and 128544 deletions
-36
View File
@@ -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
-27
View File
@@ -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
-35
View File
@@ -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`)
-258
View File
@@ -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
View File
@@ -1,25 +1,6 @@
# Python
__pycache__/
*.py[cod]
# Node / Next
node_modules/
.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
node_modules
.next
out
build
dist
*.log
-24
View File
@@ -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/**"
- "**/~/**"
-28
View File
@@ -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]
-39
View File
@@ -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.
-80
View File
@@ -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 its 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 youre 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 youve found a security vulnerability:
- **Do not** open a public issue.
- Prefer GitHubs private reporting flow:
- https://github.com/abhi1693/openclaw-mission-control/security/advisories/new
If thats 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).
-21
View File
@@ -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.
-167
View File
@@ -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
-147
View File
@@ -1,147 +0,0 @@
# OpenClaw Mission Control
[![CI](https://github.com/abhi1693/openclaw-mission-control/actions/workflows/ci.yml/badge.svg)](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
[![Star History Chart](https://api.star-history.com/svg?repos=abhi1693/openclaw-mission-control&type=date&legend=top-left)](https://www.star-history.com/#abhi1693/openclaw-mission-control&type=date&legend=top-left)
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
const DATA_DIR = '/app/data';
// Tasks API
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'tasks';
try {
if (type === 'tasks') {
const tasksDir = path.join(DATA_DIR, 'shared-context/jelena-neo-tasks/archive');
const tasks: any[] = [];
if (fs.existsSync(tasksDir)) {
const files = fs.readdirSync(tasksDir).filter(f => f.endsWith('.md'));
for (const file of files) {
const content = fs.readFileSync(path.join(tasksDir, file), 'utf-8');
const title = content.match(/^# Task: (.+)$/m)?.[1] || file;
const created = content.match(/Created: (.+)$/m)?.[1] || '';
const priority = content.match(/Priority: (.+)$/m)?.[1] || 'normal';
tasks.push({
id: file.replace('.md', ''),
title,
created,
priority,
status: 'done',
assignee: 'neo'
});
}
}
return NextResponse.json(tasks);
}
if (type === 'crons') {
const cronFile = path.join(DATA_DIR, 'cron/jobs.json');
if (fs.existsSync(cronFile)) {
const data = JSON.parse(fs.readFileSync(cronFile, 'utf-8'));
const jobs = (data.jobs || []).map((job: any) => ({
name: job.name,
enabled: job.enabled,
status: job.state?.lastStatus || 'unknown',
lastRun: job.state?.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null
}));
return NextResponse.json(jobs);
}
return NextResponse.json([]);
}
if (type === 'memory') {
const memoryDir = path.join(DATA_DIR, 'memory');
const memories: any[] = [];
if (fs.existsSync(memoryDir)) {
const files = fs.readdirSync(memoryDir).filter(f => f.endsWith('.md'));
for (const file of files.slice(0, 20)) {
const content = fs.readFileSync(path.join(memoryDir, file), 'utf-8');
const title = content.match(/^# (.+)$/m)?.[1] || file;
const preview = content.substring(0, 150).replace(/[#*]/g, '');
memories.push({
id: file.replace('.md', ''),
title,
date: file.substring(0, 10),
preview
});
}
}
return NextResponse.json(memories);
}
return NextResponse.json({ error: 'Unknown type' }, { status: 400 });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}
+10
View File
@@ -0,0 +1,10 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #0f0f23;
color: white;
}
+19
View File
@@ -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
View File
@@ -0,0 +1,229 @@
import { useState, useEffect } from 'react';
// Types
type TaskStatus = 'todo' | 'in-progress' | 'done';
type Task = { id: string; title: string; status: TaskStatus; assignee: 'me' | 'jelena' | 'neo'; priority?: string; created?: string };
type ContentStage = 'idea' | 'script' | 'thumbnail' | 'filming' | 'done';
type ContentItem = { id: string; title: string; stage: ContentStage };
type CronJob = { name: string; enabled: boolean; status: string; lastRun?: string };
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');
const [tasks, setTasks] = useState<Task[]>([]);
const [crons, setCrons] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
// Fetch tasks from mounted volume
const tasksRes = await fetch('/api/data?type=tasks');
const tasksData = await tasksRes.json();
setTasks(tasksData);
// Fetch cron jobs
const cronsRes = await fetch('/api/data?type=crons');
const cronsData = await cronsRes.json();
setCrons(cronsData);
} catch (e) {
console.error('Failed to fetch data:', e);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
return (
<div style={{ fontFamily: 'system-ui, sans-serif', minHeight: '100vh', background: '#0f0f23', color: 'white' }}>
{/* Header */}
<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', 'crons', 'team', 'memory'].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>
{/* Content */}
<main style={{ padding: '40px' }}>
{loading ? (
<p style={{ color: '#9ca3af' }}>Loading live data...</p>
) : (
<>
{activeTab === 'tasks' && <TasksBoard tasks={tasks} />}
{activeTab === 'crons' && <CronMonitor crons={crons} />}
{activeTab === 'team' && <Team />}
{activeTab === 'memory' && <Memory />}
</>
)}
</main>
</div>
);
}
// ============ TASKS BOARD ============
function TasksBoard({ tasks }: { tasks: Task[] }) {
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>
))}
{tasks.filter(t => t.status === col.status).length === 0 && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>No tasks</p>
)}
</div>
))}
</div>
);
}
// ============ CRON MONITOR ============
function CronMonitor({ crons }: { crons: CronJob[] }) {
return (
<div style={{ background: '#1a1a2e', borderRadius: '12px', padding: '30px' }}>
<h2 style={{ marginBottom: '30px' }}> Cron Jobs</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #2a2a4e' }}>
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Job</th>
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Status</th>
<th style={{ textAlign: 'left', padding: '15px', color: '#9ca3af' }}>Enabled</th>
</tr>
</thead>
<tbody>
{crons.map((cron, i) => (
<tr key={i} style={{ borderBottom: '1px solid #2a2a4e' }}>
<td style={{ padding: '15px' }}>{cron.name}</td>
<td style={{ padding: '15px' }}>
<span style={{
background: cron.status === 'ok' ? '#10b981' : '#ef4444',
padding: '4px 12px', borderRadius: '20px', fontSize: '0.8rem'
}}>
{cron.status}
</span>
</td>
<td style={{ padding: '15px' }}>
{cron.enabled ? '✅' : '❌'}
</td>
</tr>
))}
{crons.length === 0 && (
<tr>
<td colSpan={3} style={{ padding: '20px', textAlign: 'center', color: '#6b7280' }}>
No cron data available
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ============ TEAM ============
function Team() {
const members: TeamMember[] = [
{ id: '1', name: 'Jelena', role: 'Chief of Staff', status: 'working' },
{ id: '2', name: 'Neo', role: 'CTO / DevOps', status: 'working' },
];
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>
);
}
// ============ MEMORY ============
function Memory() {
const [search, setSearch] = useState('');
const [memories, setMemories] = useState<Memory[]>([]);
useEffect(() => {
fetch('/api/data?type=memory')
.then(r => r.json())
.then(setMemories)
.catch(() => {});
}, []);
const filtered = memories.filter(m => m.title.toLowerCase().includes(search.toLowerCase()));
return (
<div>
<input
type="text"
placeholder="Search memories..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
width: '100%', padding: '15px', borderRadius: '8px', border: 'none',
background: '#1a1a2e', color: 'white', marginBottom: '30px', fontSize: '1rem'
}}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '20px' }}>
{filtered.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>
))}
{filtered.length === 0 && (
<p style={{ color: '#6b7280' }}>No memories found</p>
)}
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
[run]
branch = True
source =
app
omit =
*/.venv/*
alembic/versions/*
[report]
show_missing = True
skip_covered = True
-27
View File
@@ -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
-10
View File
@@ -1,10 +0,0 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503, E501
exclude =
.venv,
backend/.venv,
migrations,
backend/migrations,
**/__pycache__,
**/*.pyc
-13
View File
@@ -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
-49
View File
@@ -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"]
-168
View File
@@ -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 **dont** 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 cant 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.
-36
View File
@@ -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
View File
@@ -1 +0,0 @@
"""OpenClaw Mission Control backend application package."""
-1
View File
@@ -1 +0,0 @@
"""API router modules for the OpenClaw Mission Control backend."""
-285
View File
@@ -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
-168
View File
@@ -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)
-485
View File
@@ -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]
-62
View File
@@ -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)
-641
View File
@@ -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)
-385
View File
@@ -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()
-306
View File
@@ -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
-474
View File
@@ -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
-525
View File
@@ -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,
)
-495
View File
@@ -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)
-208
View File
@@ -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
-148
View File
@@ -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,
)
-203
View File
@@ -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()
-484
View File
@@ -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,
)
-730
View File
@@ -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)
-79
View File
@@ -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
-88
View File
@@ -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,
)
-220
View File
@@ -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()
-343
View File
@@ -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
-292
View File
@@ -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
View File
@@ -1 +0,0 @@
"""Core utilities and configuration for the backend service."""
-170
View File
@@ -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)
-55
View File
@@ -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)
-516
View File
@@ -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,
)
-12
View File
@@ -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"
-98
View File
@@ -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()
-49
View File
@@ -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
-309
View File
@@ -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,
)
-322
View File
@@ -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)
-11
View File
@@ -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)
-4
View File
@@ -1,4 +0,0 @@
"""Application name and version constants."""
APP_NAME = "mission-control"
APP_VERSION = "0.1.0"
-1
View File
@@ -1 +0,0 @@
"""Database helpers and abstractions for backend persistence."""
-324
View File
@@ -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
-33
View File
@@ -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)
-90
View File
@@ -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)
-80
View File
@@ -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))
-93
View File
@@ -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.")
-549
View File
@@ -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))
-63
View File
@@ -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",
]
-26
View File
@@ -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)
-48
View File
@@ -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)
-32
View File
@@ -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)
-32
View File
@@ -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
-15
View File
@@ -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()
-28
View File
@@ -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)
-27
View File
@@ -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)
-28
View File
@@ -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)
-32
View File
@@ -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)
-27
View File
@@ -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)
-48
View File
@@ -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)
-28
View File
@@ -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)
-24
View File
@@ -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)
-88
View File
@@ -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)
-32
View File
@@ -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)
-36
View File
@@ -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)
-92
View File
@@ -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)
-37
View File
@@ -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)
-25
View File
@@ -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)
-46
View File
@@ -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)
-9
View File
@@ -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."""
-31
View File
@@ -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,
)
-112
View File
@@ -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",
]
-36
View File
@@ -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
-316
View File
@@ -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."],
)
-97
View File
@@ -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]
-35
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More