Compare commits

..
820 Commits
Author SHA1 Message Date
Neo c56b173dcc Mission Control with OpenClaw hook - added simple API and updated configs
CI / check (push) Has been cancelled
CI / installer (push) Has been cancelled
CI / e2e (push) Has been cancelled
2026-02-20 12:13:36 +00:00
Abhimanyu Saharan 1c8a531f6a feat: update JSON key access syntax in BOARD_HEARTBEAT.md.j2 and BOARD_TOOLS.md.j2 2026-02-16 23:42:38 +05:30
Abhimanyu Saharan 522761bc26 feat: improve session polling logic in BoardOnboardingChat component 2026-02-16 01:46:12 +05:30
Abhimanyu Saharan 6c3c9913db feat: update agent heartbeat endpoint to require no request payload 2026-02-16 01:46:06 +05:30
Abhimanyu Saharan 7a3a2366da feat: update wakeup text to include reading BOOTSTRAP.md before AGENTS.md 2026-02-16 01:45:02 +05:30
Abhimanyu Saharan 5912048b85 feat: add validation for gateway main agent requirement on board mutations 2026-02-16 01:25:44 +05:30
Abhimanyu Saharan 1d8039e760 feat: update documentation to use template variables for BASE_URL and AUTH_TOKEN 2026-02-16 01:25:39 +05:30
Abhimanyu Saharan 47dfc1b52f feat: ensure deletion of custom fields and values when a board is deleted 2026-02-16 00:44:40 +05:30
Abhimanyu Saharan 1d63bd0148 feat: add health check endpoint for agent authentication status 2026-02-16 00:42:15 +05:30
Abhimanyu Saharan cd68446c42 feat: add BoardWebhook updates on agent deletion 2026-02-16 00:21:21 +05:30
Abhimanyu Saharan b702ade0cc fix: update HTTP status code from UNPROCESSABLE_ENTITY to UNPROCESSABLE_CONTENT 2026-02-15 16:06:06 +05:30
Abhimanyu Saharan 24731667d4 feat: add gateway runtime compatibility checks and minimum version enforcement 2026-02-15 15:59:55 +05:30
Abhimanyu Saharan 0b3fb04630 docs: add OpenClaw baseline configuration guide to README 2026-02-15 15:36:20 +05:30
Abhimanyu Saharan 06fb4a2885 refactor: update delivery status templates for clarity and consistency 2026-02-15 15:12:51 +05:30
Abhimanyu Saharan f1d8da7008 refactor: enhance user context handling and update template mappings 2026-02-15 14:21:49 +05:30
Abhimanyu Saharan 1996e21695 refactor: add overwrite option to various services and update documentation 2026-02-15 13:55:47 +05:30
Abhimanyu Saharan 2b96504712 refactor: update terminology for clarity in agent-templates.ts 2026-02-15 13:48:19 +05:30
Abhimanyu Saharan a5ef3d3dcd refactor: improve code formatting and enhance readability in page.tsx 2026-02-15 13:40:03 +05:30
Abhimanyu Saharan aebd487270 refactor: add agent_id to various interfaces and improve field organization 2026-02-15 13:36:57 +05:30
Abhimanyu Saharan aa825863c2 refactor: reorganize imports and improve code formatting for readability 2026-02-15 13:20:46 +05:30
Abhimanyu Saharan efad1d4afc refactor: increase dispatch throttle duration for improved queue processing 2026-02-15 13:09:22 +05:30
Abhimanyu Saharan 8f4a398839 refactor: implement generic queue worker with task-type dispatch and improved retry logic 2026-02-15 13:07:32 +05:30
Abhimanyu Saharan 7e76cd1f68 refactor: improve webhook processing with enhanced logging and retry mechanisms 2026-02-15 13:02:55 +05:30
Abhimanyu Saharan e28496245b refactor: enhance webhook delivery queue processing with configurable blocking 2026-02-15 13:02:55 +05:30
Abhimanyu Saharan 731fb9edac refactor: inline payload preview building in webhook message construction 2026-02-15 13:02:55 +05:30
Abhimanyu Saharan 7fe5ad5cba refactor: remove payload preview length limitation in webhook processing 2026-02-15 13:02:55 +05:30
Abhimanyu Saharan f9b14af477 refactor: migrate webhook queue to RQ with updated configuration 2026-02-15 13:02:55 +05:30
Abhimanyu SaharanandGitHub 1284820ec7 Merge pull request #137 from abhi1693/fix/agent-status-unassigned
fix(tasks): allow agents to change status for unassigned tasks when permitted
2026-02-15 12:18:11 +05:30
Abhimanyu Saharan 14a75d8697 Fix: allow agent status change on unassigned tasks 2026-02-15 05:20:32 +00:00
Abhimanyu SaharanandGitHub 3c8bf27a3f Merge pull request #130 from abhi1693/chore/webhook-rq-queue-throttle
feat: route webhook leads via RQ batch queue
2026-02-15 06:00:33 +05:30
Abhimanyu Saharan 72b36fdace compose: make rqscheduler redis URL configurable 2026-02-15 00:22:27 +00:00
Abhimanyu Saharan 370609ca29 webhooks: harden scheduler bootstrap + tidy public exports 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan da3d7c2e1e test: add webhook dispatch worker coverage for process loop 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan 9220d9982a chore: decouple webhook-worker from backend service dependency 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan 9a49768b52 chore: centralize webhook scheduler interval config 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan 130f2b36f0 fix: resolve mypy typing issues in webhook queue dispatch 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan 8aed721ce0 fix: remove unused UUID import in webhook dispatch test 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan f07b4d5ea4 feat: add RQ-based webhook dispatch queue and delayed worker 2026-02-14 23:46:07 +00:00
Abhimanyu Saharan fa3381e650 fix(api): correct actor context parameter name in task update test 2026-02-15 03:41:47 +05:30
Abhimanyu Saharan 663561e213 feat(api): reset assigned_agent_id during task status transitions 2026-02-15 03:40:42 +05:30
Abhimanyu Saharan f945d86025 feat(api): track previous in_progress_at during task status transitions 2026-02-15 03:37:19 +05:30
Abhimanyu Saharan 99081bbd87 feat(api): add previous_in_progress_at tracking and update task logic for review status 2026-02-15 03:31:55 +05:30
Abhimanyu Saharan 3c1f89d91d feat(api): add delete task endpoint for board leads with authorization checks 2026-02-15 03:19:45 +05:30
Abhimanyu Saharan 93d21c5bd7 feat(api): implement custom OpenAPI normalization in MissionControlFastAPI 2026-02-15 03:07:39 +05:30
Abhimanyu Saharan 3c92dd5279 style: format code for improved readability and consistency across multiple files 2026-02-15 03:02:54 +05:30
Abhimanyu Saharan 07df7d8962 feat(api): enhance OpenAPI documentation with additional endpoints and examples 2026-02-15 02:57:06 +05:30
Abhimanyu Saharan ae17facf88 feat(api): enhance authentication and health check endpoints with detailed responses and descriptions 2026-02-15 02:35:31 +05:30
Abhimanyu Saharan 7e147ee608 feat(api): add auth tag for authentication bootstrap endpoints in OpenAPI specification 2026-02-15 02:32:05 +05:30
Abhimanyu Saharan d906e9a770 feat(api): add health check endpoints with appropriate tags for service monitoring 2026-02-15 02:22:28 +05:30
Abhimanyu Saharan 3bfefeda9f refactor(models): replace ConfigDict with SQLModelConfig for model configuration 2026-02-15 02:16:17 +05:30
Abhimanyu Saharan 395d215dac fix(gateway): correct example format in board_ids field and update assertion logic in tests 2026-02-15 02:09:58 +05:30
Abhimanyu Saharan 730d80e422 feat(docs): enhance BOARD_HEARTBEAT.md.j2 and BOARD_TOOLS.md.j2 with additional endpoint selection criteria 2026-02-15 02:07:13 +05:30
Abhimanyu Saharan 0e8c47abb8 feat(skills): update Pydantic model configuration to use ConfigDict for validation 2026-02-15 02:07:13 +05:30
Abhimanyu Saharan ee1cf05d5d feat(api): enhance error handling and add structured hints for agent operations 2026-02-15 02:07:13 +05:30
Abhimanyu SaharanandGitHub ccdff4835d Merge pull request #136 from abhi1693/ci/one-migration-per-pr
ci(policy): enforce one DB migration per PR
2026-02-15 02:00:31 +05:30
Abhimanyu Saharan 0ac22dbd7a feat(heartbeat): change default target to 'last' and remove target option from UI 2026-02-15 01:37:12 +05:30
Abhimanyu Saharan 55af799223 feat(board): enhance task management guidelines in BOARD_AGENTS.md.j2 2026-02-15 01:33:01 +05:30
Abhimanyu Saharan 6f465d32fa feat(gateway): add lead_only option for syncing board lead agents 2026-02-15 01:23:36 +05:30
Abhimanyu Saharan e919987351 chore(ci): tighten migration pattern and fail if base SHA missing 2026-02-14 19:49:56 +00:00
Abhimanyu Saharan c89db7677d fix(ci): only count added migrations in one_migration_per_pr gate 2026-02-14 19:48:19 +00:00
Abhimanyu SaharanandGitHub 0a2dc2dbc0 Replace images in README with updated versions
Updated images in the README to reflect new assets.
2026-02-15 01:17:09 +05:30
Abhimanyu Saharan 0cc8795ddf feat(board): enhance specialist agent creation guidelines in BOARD_AGENTS.md.j2 2026-02-15 01:06:43 +05:30
Abhimanyu Saharan 1009ae474f fix(skills): update Config to use validate_by_name for field population 2026-02-15 01:05:01 +05:30
Abhimanyu Saharan a55664810c refactor(constants): initialize LEAD_TEMPLATE_MAP as an empty dictionary
refactor(page): clean up import statements for better readability
fix(tests): update assertions for clarity and consistency
2026-02-15 00:53:27 +05:30
Abhimanyu Saharan 2084405593 feat(board): introduce new board agent templates and restructure existing files 2026-02-15 00:45:28 +05:30
Abhimanyu Saharan 1cad57f6b5 ci(policy): enforce one migration per PR 2026-02-14 19:09:09 +00:00
Abhimanyu Saharan 313ce874f9 feat(memory): update MEMORY.md structure and consolidate identity preferences 2026-02-14 23:55:45 +05:30
Abhimanyu SaharanandGitHub eb8540751c Merge pull request #129 from abhi1693/feat/task-e07-agent-patch-permissions
Fix agent task patch auth and add permissions regression tests
2026-02-14 20:32:43 +05:30
Abhimanyu SaharanandGitHub a7758fb0c5 Merge pull request #97 from abhi1693/docs/frontend-code-doc-pass
docs(frontend): add inline docs for complex UI data-flow
2026-02-14 20:32:19 +05:30
Abhimanyu Saharan 691fd11d10 docs(frontend): document skills marketplace + packs data-flow 2026-02-14 14:26:04 +00:00
Abhimanyu Saharan 44e4499a26 docs(frontend): document SSE reconnect cursor + backoff reset rationale 2026-02-14 14:22:16 +00:00
Abhimanyu Saharan 8e58b123bf docs(frontend): add JSDoc for complex UI utilities 2026-02-14 14:22:16 +00:00
Abhimanyu Saharan f4c1da8f15 chore(deps): update qs package to version 6.14.2 2026-02-14 19:48:19 +05:30
Abhimanyu Saharan a7cfcf88f0 feat(comments): implement mergeCommentsById function for comment deduplication and sorting 2026-02-14 19:43:27 +05:30
Abhimanyu Saharan ae711909ff feat(boards): add max_agents field to board models and enforce limits 2026-02-14 19:43:16 +05:30
Abhimanyu Saharan d241455da6 feat(skills): consolidate skill-related models and update imports 2026-02-14 19:31:32 +05:30
Abhimanyu SaharanandGitHub 1b4021b31d Merge pull request #128 from abhi1693/skills
feat(skills): marketplace + packs management
2026-02-14 19:28:39 +05:30
Abhimanyu Saharan 823da1d143 feat(skills): improve marketplace filters and server pagination 2026-02-14 13:09:48 +05:30
Abhimanyu Saharan e7d47d9f8a feat(skills): implement pagination and total count headers for marketplace skills 2026-02-14 13:06:45 +05:30
Abhimanyu Saharan 755bbde4f5 feat(skills): add filtering options for category and risk in marketplace skills 2026-02-14 12:58:19 +05:30
Abhimanyu Saharan a4410373cb refactor(skills): reorganize imports and improve code formatting 2026-02-14 12:46:47 +05:30
Abhimanyu Saharan 40dcf50f4b feat(skills): add metadata and branch fields to skill packs and marketplace skills 2026-02-14 12:26:45 +05:30
Abhimanyu Saharan 4fe8db9649 Harden non-lead task patch auth and add regression tests 2026-02-14 06:35:21 +00:00
Abhimanyu Saharan 5b9e81aa6d migrations: update down_revision for skills marketplace tables 2026-02-14 11:34:15 +05:30
Abhimanyu Saharan 2bcbf767e3 Merge branch 'master' into skills 2026-02-14 11:33:12 +05:30
Abhimanyu Saharan 42a41f64bc migrations: update down_revision for activity_events event_type index 2026-02-14 11:30:49 +05:30
Abhimanyu Saharan dcdbbb7e5c fix(skills): re-validate pack URL before git clone 2026-02-13 23:21:47 +00:00
Abhimanyu Saharan 7e48f1a9e0 fix(skills): sanitize git-derived branch names 2026-02-13 23:00:46 +00:00
Abhimanyu Saharan 5d03a522b9 fix(frontend): avoid effect deps on loadSkillsByGateway 2026-02-13 22:57:49 +00:00
Abhimanyu Saharan 84cf22e42b fix(skills): address Copilot review nits (safety + UI guards) 2026-02-13 22:50:29 +00:00
Abhimanyu Saharan 294457e76b test(skills): expand pack URL validation matrix 2026-02-13 22:32:06 +00:00
Abhimanyu Saharan cb3aff5bb0 docs(frontend): note N+1 gateway skills fetch 2026-02-13 22:29:57 +00:00
Abhimanyu Saharan 86b5306f9d fix(skills): default empty git branch to main 2026-02-13 22:28:48 +00:00
Abhimanyu Saharan 501ed88faf migrations: merge heads after skills marketplace 2026-02-13 22:25:23 +00:00
Abhimanyu Saharan e0f05ff4c6 fix(skills): validate pack source URLs + git clone timeouts 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan 10748f71a8 feat: add skill packs management with support for category, risk, and source fields 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan da6cc2544b feat: implement cascading delete for gateway and associated installed skills 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan 577c0d2839 feat: add installed gateway management to skills table with dynamic updates 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan a7e1e5cbf4 feat: add skill pack management features including creation, editing, and syncing 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan 88565f4d69 feat: add conditional creation of skills marketplace and gateway installed skills tables with index checks 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandAbhimanyu Saharan e7b5df0bce feat: implement skills marketplace API with CRUD operations and gateway integration 2026-02-13 22:23:47 +00:00
Abhimanyu SaharanandGitHub db510a8612 Merge pull request #127 from abhi1693/fix/merge-alembic-heads-2766f27
Fix installer CI: merge Alembic heads to unblock DB_AUTO_MIGRATE
2026-02-14 03:21:27 +05:30
Abhimanyu Saharan 24eaa5df54 migrations: merge alembic heads (installer CI unblock) 2026-02-13 21:43:08 +00:00
Abhimanyu SaharanandGitHub 2766f271f0 Merge pull request #113 from abhi1693/perf/activity-events-eventtype-createdat
perf(db): index activity_events by (event_type, created_at)
2026-02-14 02:55:37 +05:30
Abhimanyu SaharanandGitHub a3ef1c1c07 Merge pull request #126 from abhi1693/fix/blocked-transition-409
Fix blocked task transitions: return 409 (no silent reset)
2026-02-14 02:12:32 +05:30
Abhimanyu Saharan 5ccdfc7a60 test: adapt blocked lead transition tests to custom_field_values 2026-02-13 20:20:02 +00:00
Abhimanyu Saharan bf4ccc8a81 ci: rerun checks 2026-02-13 20:19:27 +00:00
Abhimanyu Saharan f19f3106e3 chore(tests): fix lint (remove unused import) 2026-02-13 20:19:27 +00:00
Abhimanyu Saharan 8c10cef90f fix(backend): reject lead updates on blocked tasks (409)
- Remove silent blocked-task reset in _apply_lead_task_update\n- Include stable error code in blocked task payload\n- Add regression tests asserting 409 + DB unchanged
2026-02-13 20:19:27 +00:00
Abhimanyu Saharan 52f0ca79e0 feat: add conditional execution for migration integrity gate on pull requests 2026-02-14 00:28:52 +05:30
Abhimanyu SaharanandGitHub e0aef61fb5 Merge pull request #125 from abhi1693/installer
installer: merge portability scaffold into master
2026-02-14 00:20:17 +05:30
Abhimanyu SaharanandGitHub c772063280 Merge branch 'master' into installer 2026-02-14 00:20:07 +05:30
Abhimanyu Saharan 07a86d4604 feat: enhance task update validation for board leads with detailed error messages 2026-02-14 00:08:44 +05:30
Abhimanyu Saharan 68b6029ac3 feat: add GATEWAY_OPERATOR_SCOPES and update connection parameters for operator role 2026-02-13 23:37:10 +05:30
Abhimanyu Saharan 7b16b49218 feat: enhance user display name resolution and update related components 2026-02-13 22:37:08 +05:30
Abhimanyu Saharan 35a9471eb6 ci: restore push trigger to master only 2026-02-13 16:55:12 +00:00
Abhimanyu Saharan 552dbe0cd9 installer: move logs to XDG state dir 2026-02-13 16:53:35 +00:00
Abhimanyu Saharan 30c337d733 installer: remove unused id_like variable 2026-02-13 16:52:25 +00:00
Abhimanyu Saharan 80c4390dec installer: validate required flag values in parse_args 2026-02-13 16:49:42 +00:00
Abhimanyu Saharan 22a51cccfb docs: add curl|bash one-liner for install.sh 2026-02-13 16:40:13 +00:00
Abhimanyu Saharan c42e8484f8 refactor: remove redundant custom fields button for non-admin users 2026-02-13 22:07:17 +05:30
Abhimanyu Saharan 619f77286f feat: add task detail URL handling and utility functions for taskId management 2026-02-13 22:06:37 +05:30
Abhimanyu Saharan 372b4e191c feat: implement custom field form and utility functions for managing custom fields 2026-02-13 22:01:18 +05:30
Abhimanyu Saharan b98d6e2f83 feat: add lead_reasoning field to ApprovalCreate and update validation logic 2026-02-13 21:34:08 +05:30
Abhimanyu Saharan aea69f5118 refactor: improve code formatting and readability in page.tsx 2026-02-13 21:30:29 +05:30
Abhimanyu SaharanandGitHub a409358a9a installer: portability scaffold for non-debian distros
Merge installer portability scaffold
2026-02-13 21:29:44 +05:30
Abhimanyu Saharan 277bfcb33a feat: add custom-fields 2026-02-13 21:24:36 +05:30
Abhimanyu Saharan be11110620 installer: prefer /usr/bin/node after NodeSource install 2026-02-13 14:15:40 +00:00
Abhimanyu Saharan 01fc50a1d2 installer: require Node.js >= 22 2026-02-13 14:07:17 +00:00
Abhimanyu Saharan b032e94ca1 feat: integrate Markdown component for rendering webhook description 2026-02-13 17:14:55 +05:30
Abhimanyu Saharan 3a15a2759e migrations: merge heads after board lead rule 2026-02-13 11:02:37 +00:00
Abhimanyu Saharan 7cee3a6084 Merge remote-tracking branch 'origin/master' into perf/activity-events-eventtype-createdat 2026-02-13 11:01:56 +00:00
Abhimanyu Saharan aa121474c6 migrations: merge heads after activity_events index 2026-02-13 10:57:53 +00:00
Abhimanyu Saharan 9a5964a31c refactor: reorganize import statements and improve code formatting across multiple files 2026-02-13 16:22:06 +05:30
Abhimanyu Saharan ebb9c659d2 feat: add lead-only status change rule for boards and update related logic 2026-02-13 16:22:06 +05:30
Abhimanyu SaharanandGitHub de47174a2c Merge branch 'master' into perf/activity-events-eventtype-createdat 2026-02-13 16:18:43 +05:30
Abhimanyu SaharanandGitHub 366f5231ab ci: migration integrity gate
Merge PR #120 (migration integrity + reversible upgrade/downgrade gate).
2026-02-13 16:18:17 +05:30
Abhimanyu Saharan 59bdf03d05 refactor: improve code formatting and readability in page.tsx 2026-02-13 16:05:04 +05:30
Abhimanyu Saharan 92c079410e feat: enhance DependencyBanner to support resolved dependencies and update display logic 2026-02-13 15:57:27 +05:30
Abhimanyu Saharan 99da468124 fix(ci): make migration graph checker mypy-clean 2026-02-13 10:06:24 +00:00
Abhimanyu Saharan efa3587e77 feat: add DependencyBanner component and refactor dependency display logic in task view 2026-02-13 15:35:21 +05:30
Abhimanyu Saharan c84d79e084 fix(ci): remove unused import in migration graph checker 2026-02-13 09:49:56 +00:00
Abhimanyu Saharan bfd7d5b992 feat: add status_requested field to task updates and implement related logic in task approval process 2026-02-13 15:18:56 +05:30
Abhimanyu Saharan 645e620ae9 installer: scaffold package manager abstraction and support matrix 2026-02-13 09:40:54 +00:00
Abhimanyu SaharanandGitHub 9eb881c9ba Merge branch 'master' into ci/migration-integrity-gate 2026-02-13 15:10:06 +05:30
Abhimanyu SaharanandGitHub c7872c40c0 Merge branch 'master' into perf/activity-events-eventtype-createdat 2026-02-13 15:07:15 +05:30
Abhimanyu Saharan 6e8069fc2a chore: remove manual CI trigger for master branch in workflow configuration 2026-02-13 14:45:15 +05:30
Abhimanyu Saharan 8750335281 feat: add installer script and CI configuration for deployment modes 2026-02-13 14:41:27 +05:30
Abhimanyu Saharan 426326e2af ci(migrations): enforce graph + reversible cycle checks; fix FK downgrade naming 2026-02-13 09:05:21 +00:00
Abhimanyu Saharan 3ca6b150b7 docs: update AGENTS.md, HEARTBEAT_LEAD.md, MAIN_AGENTS.md, and MAIN_TOOLS.md for clarity and removal of redundant skill discovery instructions 2026-02-13 03:06:30 +05:30
Abhimanyu Saharan 303ce769a1 feat: improve agent deletion handling by ignoring missing gateway agents 2026-02-13 02:37:57 +05:30
Abhimanyu Saharan 8db9f25db2 feat: add tag assignment deletion and improve agent error handling in provisioning 2026-02-13 02:35:52 +05:30
Abhimanyu Saharan 4b1336534e refactor: enhance code comments for clarity and maintainability across multiple files 2026-02-13 02:35:32 +05:30
Abhimanyu Saharan 8cced3a034 docs: update AGENTS.md for improved clarity and structure 2026-02-13 02:12:00 +05:30
Abhimanyu Saharan a3148baca9 feat: enhance agent and board APIs with role-based tags and improved documentation 2026-02-13 02:08:33 +05:30
Abhimanyu Saharan df12116673 test(e2e): accept enriched task title or fallback in activity feed 2026-02-12 20:38:24 +00:00
Abhimanyu Saharan 0fb393c5ec test: cover validation wrapper success paths for coverage gate 2026-02-12 20:30:54 +00:00
Abhimanyu Saharan e60734f3e7 ci: add migration integrity gate for migration-relevant changes 2026-02-12 20:04:38 +00:00
Abhimanyu SaharanandGitHub 5695c0003d Merge pull request #118 from abhi1693/test/core-version-constants
test(backend): add unit tests for core/version.py
2026-02-13 00:54:54 +05:30
Abhimanyu Saharan 554d6256c8 docs: update README for clarity and improved structure 2026-02-13 00:41:32 +05:30
Abhimanyu Saharan 2e4739300c feat: add board webhook configuration and payload models 2026-02-13 00:31:36 +05:30
Abhimanyu Saharan c4d49d6a42 test(core): add unit tests for version constants 2026-02-12 18:34:18 +00:00
Abhimanyu SaharanandGitHub afc8de3c24 Merge pull request #112 from abhi1693/docs/backend-templates-readme
docs(templates): document backend/templates usage + safe changes
2026-02-13 00:02:51 +05:30
Abhimanyu Saharan 88a96bfb49 docs(templates): rewrite README to product-doc format 2026-02-12 18:18:25 +00:00
Abhimanyu Saharan ab01cf60c0 docs(templates): fix markdown trailing newline lint 2026-02-12 18:03:07 +00:00
Abhimanyu Saharan db0870e994 docs(templates): document backend/templates usage + variables 2026-02-12 17:59:32 +00:00
Abhimanyu SaharanandGitHub 5a3c8903ef Merge pull request #104 from abhi1693/mobile/tasks-board-list-views
feat(frontend): mobile-ready TaskBoard list views
2026-02-12 23:23:43 +05:30
Abhimanyu Saharan 3b6e2d98d3 feat(frontend): stack TaskBoard columns on mobile
Mobile-first layout for TaskBoard: stack Inbox/In Progress/Review/Done vertically to avoid horizontal scroll, while retaining the horizontal kanban layout on larger screens. Includes component tests and frontend README notes for mobile validation.
2026-02-12 17:38:24 +00:00
Abhimanyu Saharan 855885afaf feat: add board rule toggles for approval and review requirements 2026-02-12 23:05:37 +05:30
Abhimanyu SaharanandGitHub 8ff75f4c56 Merge pull request #114 from abhi1693/fix/mypy-approval-task-links-uuid
fix(mypy): unblock CI UUID|None task_id typing in approval_task_links
2026-02-12 22:41:31 +05:30
Abhimanyu Saharan 6cb5702a2b feat: enhance task management with due date handling and mention support 2026-02-12 21:46:22 +05:30
Abhimanyu Saharan 51716bc4e2 test: cover request/response validation handlers for 100% coverage 2026-02-12 15:37:40 +00:00
Abhimanyu Saharan 8e5fcd9243 feat(markdown): add mention rendering support in Markdown components 2026-02-12 20:18:19 +05:30
Abhimanyu SaharanandGitHub abfa25e464 Merge pull request #109 from abhi1693/ci/docs-quality-gates
CI: docs quality gates (broken link check)
2026-02-12 20:17:07 +05:30
Abhimanyu Saharan a51446113b docs: fix extra blank line for markdownlint (MD012) 2026-02-12 14:44:43 +00:00
Abhimanyu Saharan d6db63480e CI: add markdownlint + docs-check wrapper; extend link check scope 2026-02-12 14:44:43 +00:00
Abhimanyu Saharan 879b50199f ci(docs): scope link check to docs/ only (avoid legacy README links) 2026-02-12 14:44:43 +00:00
Abhimanyu Saharan d830918fd1 ci(docs): add markdown relative link check gate 2026-02-12 14:44:43 +00:00
Abhimanyu Saharan f329bc167a test(e2e): stub /activity bootstrap so activityList request fires 2026-02-12 14:42:25 +00:00
Abhimanyu Saharan 8e72b7e0bc feat(error_handling): improve error payload serialization for JSON compatibility 2026-02-12 20:10:30 +05:30
Abhimanyu Saharan 032b77afb8 feat(approvals): enhance approval model with task titles and confidence as float 2026-02-12 19:57:04 +05:30
Abhimanyu Saharan eb3dbfa50b test(e2e): ignore flaky Clerk hydration mismatch on /sign-in 2026-02-12 14:15:41 +00:00
Abhimanyu Saharan 70530dadd5 test(e2e): relax /activity error-state assertion 2026-02-12 14:15:41 +00:00
Abhimanyu Saharan 494ab324ec test(e2e): intercept absolute /api/v1/activity and wait longer 2026-02-12 14:15:31 +00:00
Abhimanyu Saharan 60c7024085 test(e2e): make activity_feed assertions deterministic 2026-02-12 14:14:47 +00:00
Abhimanyu Saharan 3da4a69b7e test(e2e): update /activity feed stubs for new /api/v1/activity 2026-02-12 14:14:47 +00:00
Abhimanyu Saharan 4601ddc0e9 perf(db): index activity_events by (event_type, created_at) 2026-02-12 14:13:54 +00:00
Abhimanyu Saharan 25d25ba796 docs: add backend/templates README 2026-02-12 14:13:46 +00:00
Abhimanyu Saharan dce2061491 fix(mypy): narrow legacy approval task_id before dict key 2026-02-12 14:13:46 +00:00
Abhimanyu Saharan 8bd606a8dc feat(metrics): extend time range options and update related metrics handling 2026-02-12 19:30:02 +05:30
Abhimanyu SaharanandGitHub a2ab8b43b3 Merge pull request #115 from abhi1693/ci/fix-e2e-stability
test(e2e): stabilize activity feed + Clerk auth flake
2026-02-12 19:28:57 +05:30
Abhimanyu Saharan 2c88f1e8cb feat(DashboardSidebar): reorganize navigation structure and improve accessibility 2026-02-12 18:55:49 +05:30
Abhimanyu Saharan 8d7d9da4e9 feat(tags): add tag management interfaces and update related schemas 2026-02-12 18:35:48 +05:30
Abhimanyu Saharan 2ebdead95b chore: update generated files to orval v8.3.0 and adjust related interfaces 2026-02-12 18:04:35 +05:30
Abhimanyu Saharan 10f12bd4a0 test(e2e): assert signed-out redirect on /activity 2026-02-12 11:21:53 +00:00
Abhimanyu Saharan 7903dc129c test(e2e): stabilize activity feed + Clerk helper timeouts 2026-02-12 11:08:51 +00:00
Abhimanyu Saharan c73103d5c9 feat: update activity feed to include various event types and improve messaging 2026-02-12 15:21:46 +05:30
Abhimanyu SaharanandGitHub 284f03f868 Merge pull request #108 from abhi1693/docs/backend-docstrings-pass
Docs: backend in-code documentation pass (auth/errors/deps)
2026-02-12 14:50:24 +05:30
Abhimanyu Saharan e93b1864e5 feat(approvals): implement task locking and conflict resolution for pending approvals 2026-02-12 14:46:34 +05:30
Abhimanyu Saharan c427a8240f feat: add provisioning indicator for board lead in task panel and implement related tests 2026-02-12 14:46:34 +05:30
Abhimanyu SaharanandGitHub d95a8ab3cd Merge pull request #110 from abhi1693/ci/nextjs-build-cache
CI: cache Next.js build artifacts
2026-02-12 14:44:18 +05:30
Abhimanyu SaharanandGitHub 68164dcf74 Merge pull request #98 from abhi1693/riya/backend-auth-bootstrap-tests
test(backend): cover /auth/bootstrap route handler
2026-02-12 14:43:49 +05:30
Abhimanyu SaharanandGitHub 1f45696f2d Merge pull request #101 from abhi1693/riya/security-agent-token-regression
test(backend): xfail regression sketch for agent token lookup DoS
2026-02-12 14:39:44 +05:30
Abhimanyu Saharan a887dd8893 test(backend): add negative auth/bootstrap cases 2026-02-12 09:08:55 +00:00
Abhimanyu Saharan f9bbd23812 test(backend): cover /auth/bootstrap route handler 2026-02-12 09:08:55 +00:00
Abhimanyu SaharanandGitHub 72ffa0b11b Merge pull request #105 from abhi1693/perf/memory-comment-indexes
perf(db): index board memory listing + task comments
2026-02-12 14:38:35 +05:30
Abhimanyu SaharanandGitHub 0e7ba6c86e Merge pull request #102 from abhi1693/perf/task-list-indexes
perf(db): add composite indexes for task listing
2026-02-12 14:21:16 +05:30
Abhimanyu Saharan cfd62abe66 test: refactor async context management in approval and dependency tests 2026-02-12 14:14:33 +05:30
Abhimanyu SaharanandGitHub 3116262a57 Merge pull request #111 from abhi1693/ci/pr-hygiene-template
Meta: PR hygiene template + branching guidelines
2026-02-12 14:02:33 +05:30
Abhimanyu Saharan 4f47cb6816 feat: implement user message redispatching in onboarding process 2026-02-12 14:02:24 +05:30
Abhimanyu Saharan ec8d126301 meta: add PR template and branching hygiene guidance 2026-02-12 08:27:36 +00:00
Abhimanyu Saharan 88e401368a ci: dedupe Next.js cache step in e2e job 2026-02-12 08:27:34 +00:00
Abhimanyu Saharan d812353656 ci: cache Next.js build cache (.next/cache) 2026-02-12 08:27:34 +00:00
Abhimanyu Saharan e9e0d6960a docs(backend): clarify auth/error-handling via docstrings 2026-02-12 08:27:31 +00:00
Abhimanyu Saharan a97fbc1e1d perf(db): index board_memory lists and task comments 2026-02-12 08:13:40 +00:00
Abhimanyu Saharan d5f4762341 backend: add composite task listing indexes 2026-02-12 07:55:17 +00:00
Abhimanyu Saharan 11ff84bd43 test(backend): add xfail regression sketch for agent token lookup complexity 2026-02-11 23:42:08 +00:00
Abhimanyu Saharan bd1e599ae5 feat: add endpoint to delete board agents and implement related service logic 2026-02-12 02:55:00 +05:30
Abhimanyu SaharanandGitHub bfcb695c6f Merge pull request #93 from abhi1693/docs/star-history-readme
docs: add Star History section to README
2026-02-12 02:52:27 +05:30
Abhimanyu Saharan 6548f738cd removed old docs 2026-02-12 02:16:43 +05:30
Abhimanyu Saharan 71c9e2d541 docs: add Star History section to README 2026-02-11 20:45:24 +00:00
Abhimanyu Saharan f64e9df120 chore: update dependencies in package.json 2026-02-12 01:19:04 +05:30
Abhimanyu Saharan 10ea95678b feat: implement global loading indicators and refactor activity feed tests 2026-02-12 01:19:04 +05:30
Abhimanyu SaharanandGitHub f2f4ee78a9 Replace image in README with updated version
Updated the image in the README to a new source.
2026-02-12 00:47:07 +05:30
Abhimanyu Saharan 848cfae11d chore: update Cypress version to 15.10.0 in package.json and package-lock.json 2026-02-12 00:38:48 +05:30
Abhimanyu Saharan 586502b407 fix(ci): update environment variables for Clerk integration in CI configuration 2026-02-12 00:24:22 +05:30
Abhimanyu SaharanandGitHub d5067e443b Update README with image and development status
Added an image to the README and updated the active development section.
2026-02-11 23:46:11 +05:30
Abhimanyu Saharan 6cd5ac295d chore: update Node.js version from 20 to 22 in CI configuration 2026-02-11 20:32:44 +05:30
Abhimanyu SaharanandGitHub 2b29439a00 Merge pull request #83 from abhi1693/docs/netbox-reader-journey
docs: NetBox-style reader journey rewrite
2026-02-11 20:31:17 +05:30
Abhimanyu Saharan 632e7b6487 refactor: update approval type annotations and improve code formatting 2026-02-11 20:30:14 +05:30
Abhimanyu Saharan af8a263c27 feat: add approval-task links model and related functionality for task associations 2026-02-11 20:27:07 +05:30
Abhimanyu Saharan 3d78ef1a0f fix(activity): avoid Clerk hydration mismatch during initial render 2026-02-11 14:55:19 +00:00
Abhimanyu Saharan f50588b20a docs: resolve merge conflicts with master 2026-02-11 14:41:30 +00:00
Abhimanyu SaharanandGitHub 3dfdfa3c3e Merge pull request #82 from abhi1693/docs/style-guide
docs: add docs style guide
2026-02-11 19:42:31 +05:30
Abhimanyu Saharan 2ecee4ed48 feat: refactor LocalAuthLogin and UserMenu components for improved readability 2026-02-11 19:40:33 +05:30
Abhimanyu Saharan 1a35fa1f21 feat: clear local authentication token when switching from local auth mode 2026-02-11 19:40:33 +05:30
Abhimanyu Saharan 571b4844d9 feat: update local authentication mode to require a non-placeholder token of at least 50 characters 2026-02-11 19:40:33 +05:30
Abhimanyu Saharan b87f56de7a feat: update authentication configuration and documentation for local and Clerk modes 2026-02-11 19:40:33 +05:30
Abhimanyu Saharan 06ff1a9720 feat: implement local authentication mode and update related components 2026-02-11 19:40:33 +05:30
Abhimanyu SaharanandGitHub 0ff645f795 Merge pull request #81 from abhi1693/docs/frontend-api-auth-modules
docs: backend API + core module guides
2026-02-11 18:54:49 +05:30
Abhimanyu Saharan 0f48d201dd test(e2e): reduce Clerk sign-in flake in activity feed spec 2026-02-11 13:24:19 +00:00
Abhimanyu Saharan 54f10fac4f docs: rewrite core reader journey pages (NetBox-style) 2026-02-11 12:59:37 +00:00
Abhimanyu Saharan a84d7d6cab docs: link style guide from docs landing 2026-02-11 12:55:43 +00:00
Abhimanyu Saharan d7e8d41c65 docs: add documentation style guide 2026-02-11 12:55:22 +00:00
Abhimanyu Saharan a2a4159a51 docs(backend): add core module guide (config/auth/deps/logging/errors) 2026-02-11 12:50:06 +00:00
Abhimanyu Saharan c6417bcffb feat: add description field to boards and update related components for onboarding 2026-02-11 18:19:29 +05:30
Abhimanyu Saharan cacb25abfa docs: add frontend API/auth module reference 2026-02-11 12:48:53 +00:00
Abhimanyu Saharan 32c9540d54 docs(api): add module-level index for backend/app/api layer 2026-02-11 12:48:18 +00:00
Abhimanyu Saharan 25eb45bf54 feat: simplify className prop in StatusDot and refactor conditional check in tasks.py 2026-02-11 18:03:42 +05:30
Abhimanyu Saharan 15c26277c5 feat: implement StatusDot component for status indicators in approvals and agents 2026-02-11 17:27:20 +05:30
Abhimanyu Saharan 162a3bbf40 feat: update task list items to use Link component for navigation 2026-02-11 17:23:44 +05:30
Abhimanyu Saharan a176bec996 feat: add notifications for board group changes and implement related tests 2026-02-11 17:06:52 +05:30
Abhimanyu Saharan a954a6ae99 feat: implement lead notification on approval resolution and enhance logging 2026-02-11 16:56:39 +05:30
Abhimanyu SaharanandGitHub c3f849ddb1 Merge pull request #79 from abhi1693/docs/ia-integrate
docs: add canonical docs IA pages + landing
2026-02-11 16:54:22 +05:30
Abhimanyu Saharan 8d0b2939a6 feat: enhance logging configuration and add request logging context 2026-02-11 16:49:43 +05:30
Abhimanyu Saharan 4c206fb97d docs: replace code-formatted doc paths with markdown links 2026-02-11 10:40:02 +00:00
Abhimanyu Saharan f523415fc8 docs: rename Non-goals to Out of scope 2026-02-11 10:36:45 +00:00
Abhimanyu Saharan 587f632b33 chore: update LICENSE copyright holder 2026-02-11 10:10:40 +00:00
Abhimanyu Saharan df2ef84b47 chore: add MIT LICENSE 2026-02-11 10:09:29 +00:00
Abhimanyu Saharan 93f52f9273 docs: clarify Clerk is required (remove optional wording) 2026-02-11 10:08:36 +00:00
Abhimanyu Saharan 89ad25b624 docs: add root CONTRIBUTING.md 2026-02-11 10:04:34 +00:00
Abhimanyu Saharan 4a34732e8d docs: rewrite root README with enterprise tone 2026-02-11 09:59:21 +00:00
Abhimanyu Saharan 5808aa8f59 docs: tone (remove first-pass phrasing) 2026-02-11 09:46:42 +00:00
Abhimanyu Saharan 8fbec579ea docs: tone pass (remove boilerplate evidence notes) 2026-02-11 09:46:17 +00:00
Abhimanyu Saharan 7be38140a1 docs: add missing context to overview/architecture/ops/troubleshooting 2026-02-11 09:45:29 +00:00
Abhimanyu Saharan 04c6822ea8 docs: add agent automation endpoint index 2026-02-11 09:02:48 +00:00
Abhimanyu Saharan 1839e2638c docs: tighten configuration reference (precedence + env var table) 2026-02-11 09:01:09 +00:00
Abhimanyu Saharan 89ff797626 docs: expand development workflow (CI parity + loops + commands) 2026-02-11 06:36:12 +00:00
Abhimanyu Saharan 3d394afd3c docs: expand API reference with route groups and key endpoints 2026-02-11 06:33:03 +00:00
Abhimanyu Saharan 73d2966332 docs: add deep-dive cross-links and trim duplication 2026-02-11 06:30:08 +00:00
Abhimanyu SaharanandGitHub a394ba4a48 Merge branch 'master' into docs/ia-integrate 2026-02-11 11:50:38 +05:30
Abhimanyu Saharan 56e97785d5 feat: add Clerk secret key to CI environment for enhanced security 2026-02-11 11:49:45 +05:30
Abhimanyu Saharan 783f4bef42 feat: integrate Markdown component for enhanced event message rendering 2026-02-11 11:48:26 +05:30
Abhimanyu Saharan 4d1190afb1 refactor: enhance row validation in activity and tasks APIs for better type safety 2026-02-11 11:46:52 +05:30
Abhimanyu Saharan f1a8ef074d docs: add canonical IA landing + first-pass core pages 2026-02-11 06:15:54 +00:00
Abhimanyu Saharan 18d958b3e3 feat: add cell formatters and tables for boards, agents, and member invites 2026-02-11 11:41:51 +05:30
Abhimanyu Saharan c3490630a4 refactor: clean up imports and improve session key assertion formatting 2026-02-11 10:47:32 +05:30
Abhimanyu Saharan c09116b8f0 refactor: update gateway agent prefix and maintain backwards compatibility for session keys 2026-02-11 02:47:08 +05:30
Abhimanyu Saharan 563e4d5c26 refactor: move board deletion logic to dedicated lifecycle service 2026-02-11 02:06:45 +05:30
Abhimanyu Saharan 77347534fd refactor: centralize session key generation with new helper functions 2026-02-11 01:56:16 +05:30
Abhimanyu Saharan 275cc6f473 refactor: centralize gateway resolution logic with new helper functions 2026-02-11 01:47:24 +05:30
Abhimanyu Saharan 9f4b38e86f removed dead code 2026-02-11 01:28:28 +05:30
Abhimanyu Saharan f1038acf44 refactor: streamline agent lifecycle management with new DB service helpers 2026-02-11 01:13:10 +05:30
Abhimanyu Saharan f4161494d9 refactor: update internal helpers and improve slugify function usage 2026-02-11 00:27:44 +05:30
Abhimanyu Saharan b038d0df4c refactor: update imports to use provisioning_db and gateway_rpc modules 2026-02-11 00:00:19 +05:30
Abhimanyu Saharan 997d21c913 refactor: update provisioning service references to use OpenClawGatewayProvisioner 2026-02-10 23:31:14 +05:30
Abhimanyu Saharan ad75871e61 refactor: replace direct calls to provisioning functions with OpenClawProvisioningService methods 2026-02-10 22:30:14 +05:30
Abhimanyu Saharan d9199f8d8d refactor(agent): enhance agent deletion logic and add cleanup for main agents 2026-02-10 20:10:36 +05:30
Abhimanyu Saharan 9bd34503d6 refactor: reorganize template files and update provisioning paths 2026-02-10 20:01:08 +05:30
Abhimanyu Saharan 6bc38f4997 refactor: remove unused local agent workspace logic and simplify provisioning 2026-02-10 19:20:50 +05:30
Abhimanyu Saharan ac3b4db89f docs: add base gateway configuration for local development 2026-02-10 19:03:53 +05:30
Abhimanyu Saharan 01888062a6 refactor(auth): streamline authentication flow and enhance error logging 2026-02-10 18:40:42 +05:30
Abhimanyu Saharan 5c25c4bb91 chore(backend): upgrade deps and remove redis/rq 2026-02-10 16:05:49 +05:30
Abhimanyu Saharan dcdc0a25b1 refactor: add gateway_id to agents and remove main_session_key from gateways 2026-02-10 15:55:26 +05:30
Abhimanyu Saharan 39eca909a2 refactor: centralize authorization checks in OpenClawAuthorizationPolicy 2026-02-10 15:44:49 +05:30
Abhimanyu Saharan e75b2844bb refactor: replace direct gateway message sending with safe dispatch helper 2026-02-10 15:18:39 +05:30
Abhimanyu Saharan 42b061f72d refactor: enforce architectural boundaries by updating OpenClaw service imports 2026-02-10 15:14:45 +05:30
Abhimanyu Saharan 6311418dcf refactor: update import paths for OpenClaw services to improve module organization 2026-02-10 15:08:14 +05:30
Abhimanyu Saharan 9aaf95715c refactor: reorganize imports and enhance module exports in lifecycle services 2026-02-10 15:05:32 +05:30
Abhimanyu Saharan e1d8cab600 refactor: introduce agent key and retry helpers for coordination flows 2026-02-10 15:03:27 +05:30
Abhimanyu Saharan 3008d03731 feat: add onboarding and session management services for gateway integration 2026-02-10 14:58:35 +05:30
Abhimanyu Saharan 82425edd69 refactor: reorganize OpenClaw services and enhance session management 2026-02-10 14:50:27 +05:30
Abhimanyu Saharan 6f070df74b refactor: remove main session key references and enhance agent-gateway associations 2026-02-10 02:58:58 +05:30
Abhimanyu Saharan 50f71960de refactor: enhance gateway agent handling with dedicated OpenClaw agent IDs 2026-02-10 01:33:01 +05:30
Abhimanyu Saharan 822b13e6eb refactor: enhance error handling and optimize user profile fetching logic 2026-02-10 00:50:43 +05:30
Abhimanyu Saharan ba73ce8bfd refactor: update gateway agent session key handling and improve related logic 2026-02-10 00:45:15 +05:30
Abhimanyu Saharan 79f7ad8ba3 refactor: add descriptive comments for various response payload interfaces 2026-02-10 00:17:21 +05:30
Abhimanyu Saharan 55d4c482bc refactor: implement user deletion functionality and enhance settings management 2026-02-10 00:17:06 +05:30
Abhimanyu Saharan d9f560ee0c refactor: streamline code formatting and enhance test organization mocks 2026-02-09 23:58:54 +05:30
Abhimanyu Saharan 3326100205 refactor: update Clerk authentication integration and improve organization handling 2026-02-09 23:55:52 +05:30
Abhimanyu Saharan 6f76e430f4 refactor: enhance onboarding logic and update default redirect path 2026-02-09 22:20:19 +05:30
Abhimanyu Saharan f8860bbc71 refactor: introduce TASK_SOUL.md for task-specific behavior and update related documentation 2026-02-09 22:16:57 +05:30
Abhimanyu Saharan 8f6347dc8d refactor: simplify code formatting and improve readability across multiple files 2026-02-09 20:44:05 +05:30
Abhimanyu Saharan 020d02fa22 refactor: replace DefaultLimitOffsetPage with LimitOffsetPage in multiple files and update timezone handling to use UTC 2026-02-09 20:40:17 +05:30
Abhimanyu Saharan 1f105c19ab refactor: configure mypy to ignore errors in test modules and update ruff linting rules for test files 2026-02-09 17:47:06 +05:30
Abhimanyu Saharan dddd1e9a7a refactor: improve type coercion functions and enhance type hints across multiple files 2026-02-09 17:43:42 +05:30
Abhimanyu Saharan f5d592f61a refactor: standardize runtime annotation types across multiple files 2026-02-09 17:24:21 +05:30
Abhimanyu Saharan 7706943209 refactor: enhance docstrings for clarity and consistency across multiple files 2026-02-09 16:23:41 +05:30
Abhimanyu Saharan 7ca1899d9f refactor: update module docstrings for clarity and consistency 2026-02-09 15:49:50 +05:30
Abhimanyu Saharan 78bb08d4a3 refactor: streamline access check logic in organizations.py 2026-02-09 02:59:09 +05:30
Abhimanyu Saharan 752cf48a0e refactor: add trace method to logging.Logger for enhanced logging capabilities 2026-02-09 02:58:59 +05:30
Abhimanyu Saharan 099415cca3 refactor: improve session key validation and add lead session key requirement in agent.py 2026-02-09 02:58:46 +05:30
Abhimanyu Saharan e2af067c85 refactor: enhance transient error handling and improve session key validation in template_sync.py 2026-02-09 02:56:43 +05:30
Abhimanyu Saharan 3660aa44d6 refactor: implement snippet truncation for task descriptions and comments 2026-02-09 02:54:52 +05:30
Abhimanyu Saharan 86cc0f087d refactor: simplify assigned agent retrieval logic in tasks.py 2026-02-09 02:54:11 +05:30
Abhimanyu Saharan 10d23777be refactor: improve session handling and streamline agent provisioning logic in agents.py 2026-02-09 02:53:53 +05:30
Abhimanyu Saharan 50e3ff38b2 refactor: specify exception handling for network and protocol errors in openclaw_gateway.py 2026-02-09 02:53:25 +05:30
Abhimanyu Saharan 872ae09512 refactor: replace generic Exception handling with specific exceptions in agents.py 2026-02-09 02:51:40 +05:30
Abhimanyu Saharan 9973b76463 refactor: replace generic Exception handling with specific exceptions in various files 2026-02-09 02:51:26 +05:30
Abhimanyu Saharan 9714c7c24e refactor: update async session generator usage for improved clarity 2026-02-09 02:28:46 +05:30
Abhimanyu Saharan 9d91e87190 refactor: remove unnecessary type casting in CRUD operations for delete and update methods 2026-02-09 02:24:49 +05:30
Abhimanyu Saharan 9340a74c42 refactor: replace generic Exception handling with SQLAlchemyError in CRUD and session management 2026-02-09 02:24:16 +05:30
Abhimanyu Saharan fafcac1e16 refactor: replace exec_dml with CRUD operations in various files and improve session handling 2026-02-09 02:17:34 +05:30
Abhimanyu Saharan 228b99bc9b refactor: replace SQLModel with QueryModel in various models and update query methods 2026-02-09 02:04:14 +05:30
Abhimanyu Saharan e19e47106b feat: add sign-out redirect URL and enhance sign-in redirect handling 2026-02-09 01:07:36 +05:30
Abhimanyu Saharan 5630f9b8e8 feat: add sign-out redirect URL and enhance sign-in redirect handling 2026-02-09 01:06:33 +05:30
Abhimanyu Saharan f6bcd1ca5f refactor: update migration paths and improve database operation handling 2026-02-09 00:51:26 +05:30
Abhimanyu Saharan 8c4bcca603 refactor: clean up code formatting and improve readability in various files 2026-02-09 00:29:26 +05:30
Abhimanyu Saharan bb5a8482f3 feat: add endpoint to remove organization members with access cleanup 2026-02-09 00:26:49 +05:30
Abhimanyu Saharan b9d2603fde feat: implement organization deletion with cascading cleanup of associated resources 2026-02-09 00:22:37 +05:30
Abhimanyu Saharan fd01320f1b feat: ensure board group deletion also removes associated board group memory 2026-02-09 00:17:25 +05:30
Abhimanyu Saharan 05c0f851f6 feat: replace DashboardShell with DashboardPageLayout for improved layout consistency 2026-02-09 00:16:48 +05:30
Abhimanyu Saharan 3a1cd654f1 feat: replace Dialog component with ConfirmActionDialog for improved confirmation handling 2026-02-09 00:12:58 +05:30
Abhimanyu Saharan 9a8fd3558d feat: replace DashboardShell and SignedOut/SignedIn components with DashboardPageLayout for improved structure and state handling 2026-02-09 00:10:35 +05:30
Abhimanyu Saharan 746b909ed6 feat: add TableLoadingRow and TableEmptyStateRow components for improved table state handling 2026-02-09 00:07:01 +05:30
Abhimanyu Saharan 1340e00b61 feat: extract timestamp formatting and text truncation into separate utility functions 2026-02-09 00:02:43 +05:30
Abhimanyu Saharan 5ea9719c13 feat: introduce DashboardPageLayout component to streamline page structure and improve layout consistency 2026-02-08 23:58:55 +05:30
Abhimanyu Saharan a4aced9a88 feat: refactor organization membership logic and simplify admin role checks 2026-02-08 23:49:34 +05:30
Abhimanyu Saharan cd732e312f test: add unit test for board deletion and organization access cleanup 2026-02-08 23:41:20 +05:30
Abhimanyu Saharan 840d5a050f feat: refactor sign-in prompts to use SignedOutPanel component 2026-02-08 23:40:11 +05:30
Abhimanyu Saharan 86d93d94fe feat: refactor authentication panels and add AdminOnlyNotice component 2026-02-08 23:30:24 +05:30
Abhimanyu Saharan 03317f0baf feat(gateway): refactor gateway form and connection check logic 2026-02-08 23:27:03 +05:30
Abhimanyu SaharanandGitHub cdda147feb Merge PR #71
Fix CI workflow creation failure on master.
2026-02-08 23:26:53 +05:30
openclaw-bot 9153e9cf4d ci: remove invalid hashFiles() job condition 2026-02-08 17:56:49 +00:00
Abhimanyu SaharanandGitHub 5ed4dc1f23 Merge PR #74
Fix failing CI after org_id became required on boards.
2026-02-08 23:26:16 +05:30
openclaw-bot bbe9523ea0 test: seed organization when creating boards in integration tests 2026-02-08 17:54:43 +00:00
Abhimanyu SaharanandGitHub df552a7286 Merge pull request #69 from abhi1693/tests/pr61-coverage
tests: cover org invite flow
2026-02-08 23:18:36 +05:30
Abhimanyu SaharanandGitHub 1407860588 Merge pull request #70 from abhi1693/anya/frontend-coverage-slice-1-fix
fix: make frontend test types pass in CI
2026-02-08 23:17:09 +05:30
Anya (OpenClaw) b1b4eeed41 fix(frontend): include vitest types for expect matchers 2026-02-08 17:45:51 +00:00
Abhimanyu SaharanandGitHub 33046b75b5 Merge pull request #68 from abhi1693/ishan/tests-pr61-orgs
test: add backend coverage for organizations/invites access control
2026-02-08 23:14:15 +05:30
Abhimanyu SaharanandGitHub 837a9ef43f Merge pull request #67 from abhi1693/recreate/pr63-org-invite-flow-tests
tests: cover PR #61 org invite flow
2026-02-08 23:13:50 +05:30
Abhimanyu SaharanandGitHub 45f74c3ec6 Merge pull request #66 from abhi1693/recreate/pr62-backend-org-invites-tests
test: add backend coverage for organizations/invites access control
2026-02-08 23:13:09 +05:30
Abhimanyu SaharanandGitHub 33971104f3 Merge pull request #52 from abhi1693/kunal/backend-coverage-slice-1
Backend coverage slice 1: task_dependencies to 100%
2026-02-08 23:12:54 +05:30
Mayaandopenclaw-bot 9b23ac3408 tests: make /organization story role-agnostic 2026-02-08 17:42:25 +00:00
Mayaandopenclaw-bot 76647b9873 tests: create org via OrgSwitcher combobox selector 2026-02-08 17:42:25 +00:00
Mayaandopenclaw-bot 38f2cbec19 tests: make org stories deterministic via org creation 2026-02-08 17:42:25 +00:00
Mayaandopenclaw-bot 7e993704a9 tests: assert org invite disabled for non-admin 2026-02-08 17:42:25 +00:00
Mayaandopenclaw-bot 68f9384a02 tests: cover PR #61 org invite flow 2026-02-08 17:42:25 +00:00
Ishaan (OpenClaw)andopenclaw-bot 103b481035 Fix flake8 in org service tests 2026-02-08 17:42:20 +00:00
Ishan (OpenClaw)andopenclaw-bot 5ab56e6d83 test(backend): cover organizations service access/invite flows 2026-02-08 17:42:20 +00:00
Abhimanyu SaharanandGitHub 8aef9207bd Merge pull request #65 from abhi1693/fix/pr48-test-types
Fix vitest matcher typings in frontend tests
2026-02-08 23:11:58 +05:30
Maya 73f428e44b tests: make /organization story role-agnostic 2026-02-08 17:41:40 +00:00
Maya 42bd48b827 tests: create org via OrgSwitcher combobox selector 2026-02-08 17:41:40 +00:00
Maya 435adabec0 tests: make org stories deterministic via org creation 2026-02-08 17:41:40 +00:00
Maya 6acf79ba8d tests: assert org invite disabled for non-admin 2026-02-08 17:41:40 +00:00
Maya 698e6fab60 tests: cover PR #61 org invite flow 2026-02-08 17:41:40 +00:00
Ishaan (OpenClaw)andMaya ce8d41b3b1 Fix flake8 in org service tests 2026-02-08 17:41:22 +00:00
KunalandOpenClaw Agent (Ishaan) 4cc6b4300e test(backend): 100% coverage for task_dependencies service 2026-02-08 17:41:09 +00:00
Abhimanyu Saharan 4ac600d4c6 docs: add dynamic skill discovery guidelines and enable auto-install mode 2026-02-08 23:10:10 +05:30
Ishan (OpenClaw)andMaya da21506431 test(backend): cover organizations service access/invite flows 2026-02-08 17:39:50 +00:00
openclaw-mission-control bot 0b21dadf0c test(frontend): import vitest to stabilize typings 2026-02-08 17:38:24 +00:00
Abhimanyu SaharanandGitHub 681730f3fd Merge pull request #48 from abhi1693/anya/frontend-coverage-slice-1
Frontend coverage slice 1: ActivityFeed component tests (loading/success/error/empty)
2026-02-08 23:07:44 +05:30
Mateo (OpenClaw) 7be0423d9f chore: trigger CI 2026-02-08 17:37:37 +00:00
OpenClaw Agent Omar 4c4c9a0944 fix(frontend): use jest-dom vitest types for matcher TS 2026-02-08 17:35:38 +00:00
Abhimanyu SaharanandGitHub 5a9f650ccf Merge pull request #53 from abhi1693/anya/ci-audit-test-suites
CI audit: ensure backend+frontend test suites run; add conditional e2e job
2026-02-08 23:05:10 +05:30
Abhimanyu SaharanandGitHub 21a33700ef Merge pull request #56 from abhi1693/docs/architecture-readme
docs: add architecture overview
2026-02-08 23:04:48 +05:30
Abhimanyu SaharanandGitHub 9b6f480638 Merge pull request #55 from abhi1693/docs/production-guide
docs: add production deployment guide
2026-02-08 23:04:45 +05:30
Abhimanyu SaharanandGitHub 3cdad74e41 Merge pull request #44 from abhi1693/docs/deployment-guide
docs: add deployment/self-host guide
2026-02-08 23:04:41 +05:30
Abhimanyu SaharanandGitHub e753838450 Merge pull request #61 from abhi1693/feat/organizations
feat(orgs): introduce organizations, invites, and board access controls
2026-02-08 23:03:24 +05:30
Anya 419ce1bcf2 ci: add conditional e2e job + document canonical test commands 2026-02-08 17:33:04 +00:00
Anya (OpenClaw) 5b16df9cd3 fix(frontend): add vitest globals types for TS typecheck 2026-02-08 17:31:30 +00:00
Abhimanyu SaharanandGitHub 5c2c4f72b5 Merge pull request #60 from abhi1693/docs/next-public-api-url
Merged via Mission Control triage.
2026-02-08 23:01:26 +05:30
Abhimanyu SaharanandGitHub 58db6a13b8 Merge pull request #64 from abhi1693/docs/testing-readme
Merged via Mission Control triage.
2026-02-08 23:01:22 +05:30
Sana (OpenClaw) 6a047f36ec docs: update architecture overview (Clerk required + cross-links) 2026-02-08 16:35:31 +00:00
Abhimanyu SaharanandGitHub e2f42a9acd Merge pull request #59 from abhi1693/dependabot/npm_and_yarn/frontend/npm_and_yarn-a9e5fb3b87
chore(deps): bump esbuild from 0.21.5 to 0.27.2 in /frontend in the npm_and_yarn group across 1 directory
2026-02-08 22:04:16 +05:30
Sana (OpenClaw) 27aaf47390 docs: clarify Clerk required + NEXT_PUBLIC_API_URL example 2026-02-08 16:33:38 +00:00
Arjun (OpenClaw) 7c9c64168d docs: make Clerk required + fix env var names in deployment guide 2026-02-08 16:32:51 +00:00
Sana (OpenClaw) 7f42ebbef8 docs: add canonical Cypress/Clerk testing guide 2026-02-08 16:23:17 +00:00
Abhimanyu Saharan 724b6601f3 fix(security): restrict local workspace writes to configured root 2026-02-08 21:49:26 +05:30
Abhimanyu Saharan aaeb3cf8f3 fix: replace deprecated 422 status constant 2026-02-08 21:44:29 +05:30
Abhimanyu Saharan f3d853fa2f feat: simplify list comprehension for board access model validation 2026-02-08 21:40:42 +05:30
Abhimanyu Saharan 7addc32ff9 feat: update context dependency to OrganizationContext in activity and agents modules 2026-02-08 21:39:02 +05:30
Abhimanyu Saharan 061563964d feat: refactor organization context usage in board and gateway endpoints 2026-02-08 21:37:20 +05:30
Abhimanyu Saharan 3f556802a9 Merge branch 'master' into feat/organizations 2026-02-08 21:30:25 +05:30
Abhimanyu Saharan 8c12add7d7 feat: enhance invite page with loading state and refactor access management logic 2026-02-08 21:27:19 +05:30
Abhimanyu Saharan 289452b341 refactor: streamline imports and clean up group access handling in board-related files 2026-02-08 21:18:37 +05:30
Abhimanyu Saharan 3244900d06 feat: update board form layout and improve input handling for better user experience 2026-02-08 21:18:12 +05:30
Sana (OpenClaw) b8cfdabea0 docs: clarify NEXT_PUBLIC_API_URL must be browser-reachable 2026-02-08 15:47:40 +00:00
Abhimanyu Saharan 60744ddfac refactor: clean up code formatting and improve readability across multiple files 2026-02-08 21:17:26 +05:30
Abhimanyu Saharan e03125a382 feat: add organization-related models and update schemas for organization management 2026-02-08 21:16:26 +05:30
Sana (OpenClaw) bbbba764e1 docs: add troubleshooting note for NEXT_PUBLIC_API_URL 2026-02-08 15:41:28 +00:00
Sana (OpenClaw) 2322e198bf docs: document NEXT_PUBLIC_API_URL requirement 2026-02-08 15:31:34 +00:00
dependabot[bot]andGitHub 0e02b9708c chore(deps): bump esbuild
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [esbuild](https://github.com/evanw/esbuild).


Updates `esbuild` from 0.21.5 to 0.27.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-08 15:27:11 +00:00
Abhimanyu SaharanandGitHub 698cb19dd8 E2E: remove auth bypass; use real Clerk sign-in
Merges PR #49. CI: https://github.com/abhi1693/openclaw-mission-control/actions/runs/21800456912
2026-02-08 20:56:01 +05:30
Kunal bd352c506b ci(e2e): set NEXT_PUBLIC_API_URL for frontend during Cypress 2026-02-08 15:17:16 +00:00
Kunal 26fdff6aa0 ci(e2e): always upload Cypress artifacts 2026-02-08 15:06:46 +00:00
Kunal dde0c1e27d test(e2e): register Clerk Cypress commands via addClerkCommands 2026-02-08 15:06:06 +00:00
Kunal d9570a0531 test(e2e): import Clerk Cypress support commands 2026-02-08 14:58:48 +00:00
Kunal bd9ee7883a test(e2e): migrate Cypress auth to @clerk/testing commands 2026-02-08 14:52:03 +00:00
Kunal 76dc011459 cypress: handle Clerk verification method step before OTP 2026-02-08 14:32:12 +00:00
Kunal ca5c5a2eea cypress: decide Clerk OTP step origin after email submit 2026-02-08 14:17:18 +00:00
Kunal cb7d09f330 e2e: activity feed tests login first to avoid cross-origin redirect flake 2026-02-08 14:11:14 +00:00
Kunal 6a3aae8a8c e2e: /activity smoke expects redirect to sign-in when signed out 2026-02-08 14:05:55 +00:00
Kunal 28ad695340 e2e: expect /activity to redirect to sign-in when signed out 2026-02-08 14:05:55 +00:00
Ishaan (OpenClaw) 5fde02165a Revert "Make /activity public so signed-out UI renders"
This reverts commit 5419f01d54.
2026-02-08 13:57:16 +00:00
Ishaan (OpenClaw) 7896cfcdc6 Fix Cypress Clerk OTP helper for same-origin SignIn 2026-02-08 13:52:17 +00:00
Ishaan (OpenClaw) 5419f01d54 Make /activity public so signed-out UI renders 2026-02-08 13:45:48 +00:00
Ishaan (OpenClaw) 4c4d707c32 Fix Clerk proxy middleware build 2026-02-08 13:39:39 +00:00
Kunal 6692ed3ba5 fix(frontend): remove middleware.ts (use proxy.ts only) 2026-02-07 21:00:36 +00:00
Kunal 0fe9d8f79a fix(frontend): await auth() in clerkMiddleware callback 2026-02-07 20:43:14 +00:00
Abhimanyu Saharan 8422b0ca01 feat: enhance approvals panel with board labels and improved empty state display 2026-02-08 01:39:23 +05:30
Abhimanyu Saharan e612b6e41c docs: update AGENTS.md to enhance project structure clarity and coding guidelines 2026-02-08 01:39:23 +05:30
Ishaan (OpenClaw) 7b4c40ae0b fix(auth): use redirectToSignIn in Clerk middleware 2026-02-07 20:04:23 +00:00
Arjun (OpenClaw) ed2556c871 fix(frontend): await auth() before protect
Fix TS2339 by awaiting Clerk auth() (returns Promise<SessionAuthWithRedirect>) before calling protect().
2026-02-07 19:55:29 +00:00
Kunal fce12698d8 fix(frontend): call auth().protect() in Clerk middleware 2026-02-07 19:41:37 +00:00
Arjun (OpenClaw) 9184ebed25 fix(frontend): satisfy Clerk auth() types in middleware
Avoid calling protect() on a Promise by awaiting auth() first.
2026-02-07 19:36:52 +00:00
Arjun (OpenClaw) 260e0815a8 fix(frontend): await auth().protect in middleware
Clerk middleware auth() is async in current types; await protect() to satisfy TS and avoid runtime issues.
2026-02-07 19:34:21 +00:00
Ishaan (OpenClaw) 05a83b765b fix(auth): enable Clerk middleware and make /sign-in public 2026-02-07 19:31:53 +00:00
abhi1693 a2627e36b0 E2E: make /sign-in catch-all and public in Clerk middleware 2026-02-07 19:30:21 +00:00
Arjun (OpenClaw) 52fcde5be8 Merge remote-tracking branch 'origin/master' into kunal/remove-e2e-auth-bypass 2026-02-07 19:25:41 +00:00
Abhimanyu SaharanandGitHub d0cc1c098d Merge pull request #58 from abhi1693/fix/lint-w391-souls
lint: fix W391 trailing blank lines
2026-02-08 00:55:13 +05:30
Abhimanyu SaharanandGitHub b5be71ce96 Merge pull request #57 from abhi1693/fix/next-allowed-dev-origins
fix(frontend): allow localhost dev origins
2026-02-08 00:53:02 +05:30
Abhimanyu Saharan e8600420b2 refactor: clean up imports and remove trailing whitespace in multiple files 2026-02-08 00:51:57 +05:30
abhi1693 1d0c11b69c lint: fix W391 trailing blank line 2026-02-07 19:20:04 +00:00
Arjun (OpenClaw) 1ccc4c478f fix: flake8 trailing blank lines
Remove trailing blank line at EOF to satisfy flake8 W391.
2026-02-07 19:19:40 +00:00
Arjun (OpenClaw) e73b6c0f8c Merge master into fix/next-allowed-dev-origins 2026-02-07 19:17:53 +00:00
Abhimanyu Saharan da1559fc98 feat: persist agent's soul_template to prevent overwriting on reprovision 2026-02-08 00:47:46 +05:30
Kunal 81a4135347 fix(e2e): provide Clerk test creds + derive origin from publishable key 2026-02-07 19:16:47 +00:00
Abhimanyu Saharan 460d4adddf feat: add souls directory integration with search and fetch functionality 2026-02-08 00:46:15 +05:30
Abhimanyu Saharan 527cc13c63 refactor: improve code formatting and readability across multiple files 2026-02-08 00:46:15 +05:30
Abhimanyu Saharan 1dfff39140 feat: add node wrapper script and update Makefile for frontend tooling 2026-02-08 00:46:15 +05:30
Abhimanyu Saharan fa5b7dd271 feat: remove unused lead agent attributes from onboarding chat 2026-02-08 00:46:15 +05:30
Abhimanyu Saharan 40b0be7540 feat: add board group integration to boards page 2026-02-08 00:46:15 +05:30
Abhimanyu Saharan 91e4c069cc feat: enhance agent identity profile with purpose and personality attributes 2026-02-08 00:46:15 +05:30
abhi1693 d860298046 E2E: derive Clerk origin from publishable key; default test creds 2026-02-07 19:15:02 +00:00
abhi1693 33b413ebde E2E: implement /sign-in page with Clerk SignIn 2026-02-07 19:10:10 +00:00
Arjun (OpenClaw) 4d898dbb59 docs(frontend): clarify dev origin restrictions
Update frontend README note to explain host/origin mismatch and allowedDevOrigins when seeing proxy ECONNRESET in next dev.
2026-02-07 19:10:05 +00:00
Arjun (OpenClaw) a50813931b fix(frontend): allow localhost dev origins
Expands Next.js allowedDevOrigins to include localhost/127.0.0.1 to reduce dev proxy ECONNRESET when binding next dev to 127.0.0.1.
2026-02-07 19:07:02 +00:00
abhi1693 8a2f792541 E2E: add /sign-in redirect; use it in Clerk Cypress login helper 2026-02-07 19:06:47 +00:00
Kunal 388402b834 fix(e2e): keep localhost baseUrl; bind dev server to 0.0.0.0 2026-02-07 19:02:17 +00:00
Kunal f2f1ac5bb2 fix(e2e): force IPv4 + 127.0.0.1 baseUrl to avoid localhost ::1 proxy hangups 2026-02-07 19:00:09 +00:00
Kunal f44f715e62 test(e2e): reuse shared cy.loginWithClerkOtp helper 2026-02-07 17:57:49 +00:00
Riya 6645b49a7c Merge master into kunal/remove-e2e-auth-bypass 2026-02-07 17:48:03 +00:00
OpenClaw Docs Bot 6a3b5da162 docs: add architecture overview 2026-02-07 17:46:53 +00:00
Kunal fd2c272824 chore(e2e): run Cypress in Chrome; use 127.0.0.1 baseUrl 2026-02-07 17:46:47 +00:00
Abhimanyu SaharanandGitHub 5a5bc697bf Merge pull request #54 from abhi1693/riya/clerk-otp-cypress-helper
E2E: Clerk OTP login helper (no bypass)
2026-02-07 23:14:43 +05:30
Kunal cacf6f27df fix(ci): export Clerk pk to Cypress via CYPRESS_ env var 2026-02-07 17:44:33 +00:00
Arjun (OpenClaw) 67cba01996 docs: add production deployment guide
Adds docs/production/README.md with single-VPS and multi-VPS deployment patterns, reverse proxy + TLS baseline, secrets strategy, and ops checklist.
2026-02-07 17:42:06 +00:00
Kunal 9eb6162771 fix(e2e): wrap Clerk redirect-origin interactions in cy.origin 2026-02-07 17:40:29 +00:00
Riya d556269fa9 Merge remote-tracking branch 'origin/master' into riya/clerk-otp-cypress-helper 2026-02-07 17:38:06 +00:00
Abhimanyu SaharanandGitHub 30d0ce1939 Merge pull request #43 from abhi1693/fix/compose-smoke-dockerfiles
fix: dockerfiles build in compose context
2026-02-07 23:05:44 +05:30
Kunal e31b2b180b test(e2e): use Clerk redirect flow for stable Cypress login 2026-02-07 17:34:45 +00:00
Riya 8f08a8e9dc test(e2e): add Clerk OTP login helper (cy.origin) 2026-02-07 17:33:52 +00:00
Kunal 211308ef13 fix(e2e): pass Clerk publishable key into Cypress env 2026-02-07 17:33:23 +00:00
abhi1693andKunal cad1cfbd09 E2E: pass Clerk publishable key into Cypress env 2026-02-07 17:33:09 +00:00
abhi1693andKunal b82845aa42 E2E: use cy.origin for Clerk modal sign-in 2026-02-07 17:33:09 +00:00
abhi1693andKunal ecee7ecaf5 E2E: remove Clerk bypass and sign in via Clerk in Cypress 2026-02-07 17:32:39 +00:00
Arjun (OpenClaw) 574c2fa93d Merge remote-tracking branch 'origin/master' into fix/compose-smoke-dockerfiles 2026-02-07 17:32:23 +00:00
Kunal c4a5d8dd48 test(e2e): add negative auth case (wrong OTP) 2026-02-07 17:31:42 +00:00
Kunal ac1c90c742 e2e: remove auth bypass; use real Clerk sign-in in Cypress 2026-02-07 17:31:37 +00:00
Abhimanyu SaharanandGitHub 7d407b073e Merge pull request #41 from abhi1693/riya/frontend-activity-auth-boundary-test
Frontend: regression test for /activity Clerk auth boundary
2026-02-07 22:55:25 +05:30
Abhimanyu SaharanandGitHub 8c94171c2a Merge pull request #40 from abhi1693/riya/backend-unit-tests-core-utils
Backend: unit tests for task_dependencies + provisioning helpers
2026-02-07 22:55:20 +05:30
Abhimanyu SaharanandGitHub 72cc1136f2 Merge pull request #45 from abhi1693/riya/cypress-e2e-scaffold
E2E: Cypress scaffold + smoke test + CI job
2026-02-07 22:54:40 +05:30
Riya 8660f0ffce ci(e2e): run Cypress in Chrome against 127.0.0.1 2026-02-07 17:21:11 +00:00
Riya 709cd07a72 test(e2e): make activity_feed spec compatible with signed-out CI 2026-02-07 17:17:45 +00:00
Riya 8a88b33805 chore: resolve package-lock conflict 2026-02-07 17:12:14 +00:00
Riya 6a9df88eb6 Merge remote-tracking branch 'origin/master' into riya/frontend-activity-auth-boundary-test 2026-02-07 17:11:22 +00:00
Riya 5c1dde391a Merge remote-tracking branch 'origin/master' into riya/backend-unit-tests-core-utils 2026-02-07 17:11:21 +00:00
Abhimanyu SaharanandGitHub e04a001a4d E2E: /activity feed happy/empty/error
Merging via admin after updating vs master and confirming CI green. Auth bypass is opt-in via NEXT_PUBLIC_E2E_AUTH_BYPASS and intended to be set only for E2E job.
2026-02-07 22:24:12 +05:30
Anya 15bc7ae833 test(frontend): add ActivityFeed component tests + expand coverage slice 2026-02-07 16:52:56 +00:00
Kunal b716422b73 chore(frontend): add cypress devDependency for TS typings 2026-02-07 16:51:12 +00:00
Kunal df732518f3 fix(e2e): add scoped Cypress typings for .cy.ts 2026-02-07 16:45:42 +00:00
Kunal 7856331377 Merge remote-tracking branch 'origin/master' into kunal/e2e-activity 2026-02-07 16:45:42 +00:00
Abhimanyu SaharanandGitHub 64581f4534 Merge PR #42: scoped 100% coverage gates
Merging via admin after updating branch against master and confirming CI green.
2026-02-07 22:13:22 +05:30
Riya c19378b676 Merge remote-tracking branch 'origin/master' into riya/cypress-e2e-scaffold 2026-02-07 16:43:15 +00:00
Kunal edce84a5b4 Merge remote-tracking branch 'origin/master' into kunal/coverage-gate-policy 2026-02-07 16:40:59 +00:00
Riya cef92ec207 Merge remote-tracking branch 'origin/master' into riya/frontend-activity-auth-boundary-test 2026-02-07 16:40:58 +00:00
Riya 028ab557bf Merge remote-tracking branch 'origin/master' into riya/backend-unit-tests-core-utils 2026-02-07 16:40:23 +00:00
Kunal c3f611f3f6 fix: remove unused import in request id middleware test 2026-02-07 16:35:14 +00:00
Kunal 0f9d040792 test(e2e): stubbed /activity feed scenarios (auth bypass) 2026-02-07 16:29:14 +00:00
Abhimanyu SaharanandGitHub 03803f21fb docs: add Star History section to README
Appends Star History chart embed to the end of the root README.
2026-02-07 21:45:15 +05:30
OpenClaw Docs Bot f78c7636ac docs: add Star History to README 2026-02-07 16:12:36 +00:00
Riya c1a580b16f Merge remote-tracking branch 'origin/master' into riya/frontend-activity-auth-boundary-test 2026-02-07 16:07:43 +00:00
Riya 94dd8e439d test(e2e): add Cypress scaffold + CI job 2026-02-07 16:01:13 +00:00
Arjun (OpenClaw) d10449f678 docs: add deployment/self-host guide
Adds docs/deployment/README.md with compose overview, canonical commands, env strategy, security notes, and troubleshooting.
2026-02-07 15:59:16 +00:00
Arjun (OpenClaw) c65b225ba7 fix: dockerfiles build in compose context
- backend: copy dependency lockfiles from backend/ when build context is repo root
- frontend: don't copy public/ when it doesn't exist
2026-02-07 15:57:25 +00:00
Abhimanyu SaharanandGitHub d4e5e86a20 docs: add active development note + CI badge
Adds an active development caution and CI badge to the root README.
2026-02-07 21:26:40 +05:30
OpenClaw Docs BotandAbhimanyu Saharan b166559e57 docs: add CI badge to README 2026-02-07 21:26:20 +05:30
OpenClaw Docs BotandAbhimanyu Saharan f7c8c688fc docs: add active development note to README 2026-02-07 21:26:20 +05:30
Riya f3ae10da26 chore(frontend-tests): fix lint in next/link mock 2026-02-07 15:55:25 +00:00
Kunal f87cc1f7fb ci: scoped 100% coverage gates + policy doc 2026-02-07 15:54:42 +00:00
Riya 16f58af0c2 test(frontend): add /activity auth-boundary regression test 2026-02-07 15:53:07 +00:00
Riya 449d081e16 chore(tests): fix flake8 unused import 2026-02-07 15:47:45 +00:00
Riya 892a36be09 test(backend): add unit tests for task dependencies + provisioning helpers 2026-02-07 15:45:49 +00:00
Abhimanyu Saharan 6fe7aaecf3 docs: add repository guidelines 2026-02-07 21:13:31 +05:30
Abhimanyu SaharanandGitHub 83820c97e0 Merge pull request #37 from abhi1693/docs/frontend-readme
docs(frontend): add contributor guide
2026-02-07 21:11:15 +05:30
Abhimanyu SaharanandGitHub 2f06cdefe8 Merge pull request #38 from abhi1693/docs/backend-readme
docs(backend): add backend README for local dev and ops
2026-02-07 21:10:54 +05:30
Nikhil (OpenClaw) e9d39ff476 docs(frontend): add contributor guide 2026-02-07 15:38:22 +00:00
Ishaan (OpenClaw) 1350f2e944 docs(backend): add backend README for local dev and ops 2026-02-07 15:38:03 +00:00
Abhimanyu SaharanandGitHub f4754210c2 Merge pull request #33 from abhi1693/ishan/fix-activity-clerkprovider
Fix /activity prerender crash when ClerkProvider is skipped
2026-02-07 20:43:10 +05:30
Abhimanyu SaharanandGitHub dd1e9e2b93 Merge pull request #32 from abhi1693/ishan/ci-coverage-gate
CI: backend coverage gate + fix mypy targets shadow
2026-02-07 20:43:06 +05:30
abhi1693 dbf070d5c5 Merge master into ishan/fix-activity-clerkprovider 2026-02-07 15:12:00 +00:00
Abhimanyu SaharanandGitHub f03fe837cd Merge pull request #36 from abhi1693/docs/root-readme-quickstarts
docs: add root README quickstarts
2026-02-07 20:36:21 +05:30
Abhimanyu Saharan 88a5075684 feat: add board group models and update related interfaces 2026-02-07 20:29:55 +05:30
Abhimanyu SaharanandGitHub 7b5ee230f5 Merge pull request #29 from abhi1693/chore/docker-compose-quickstart
Self-host: add Dockerfiles and compose quickstart
2026-02-07 19:53:20 +05:30
abhi1693 ece2cb8a2a chore: add root .dockerignore for backend build context 2026-02-07 14:22:41 +00:00
Ishaan (OpenClaw) 2ec23ee0b3 docs: add root README quickstarts 2026-02-07 13:41:05 +00:00
Abhimanyu SaharanandGitHub 2e329975be Merge pull request #35 from abhi1693/dependabot/uv/backend/uv-91ab757ed1
chore(deps): bump starlette from 0.41.3 to 0.49.1 in /backend in the uv group across 1 directory
2026-02-07 18:22:46 +05:30
Abhimanyu Saharan aac2f92c41 chore: update .gitignore to exclude coverage files 2026-02-07 17:40:23 +05:30
Omar (OpenClaw) 6bf7a72a62 Fix flake8 W391 (no trailing blank line) 2026-02-07 11:33:19 +00:00
Omar (OpenClaw) 31181e1f5d Fix flake8 W391 (no trailing blank line) 2026-02-07 11:32:56 +00:00
openclaw-agent 7ac9aa659d fix(compose): avoid Clerk placeholders; include templates in backend image 2026-02-07 11:31:11 +00:00
Omar (OpenClaw) ffb022ec05 Merge master into ishan/fix-activity-clerkprovider 2026-02-07 11:30:34 +00:00
Ishan (OpenClaw) dbd5cd4310 Merge origin/master and fix CI lint 2026-02-07 11:28:58 +00:00
Abhimanyu Saharan 92a3124cba feat(chat): enhance agent interaction feedback and loading states 2026-02-07 16:57:23 +05:30
Abhimanyu Saharan 117048d637 refactor(agent): remove unused board ensure functionality and related classes 2026-02-07 16:57:23 +05:30
Abhimanyu SaharanandGitHub 19869d98ae Merge pull request #34 from abhi1693/chore/ci-clerk-secrets
CI: wire Clerk env vars into frontend CI steps
2026-02-07 16:54:14 +05:30
Ishan (OpenClaw) 9fdb9ea836 ci: relax backend coverage gate to 10% 2026-02-07 11:18:53 +00:00
Abhimanyu Saharan 4cf68a4c87 feat(chat): update message sending behavior and input focus management 2026-02-07 16:32:49 +05:30
dependabot[bot]andGitHub 268c3ff63c chore(deps): bump starlette
Bumps the uv group with 1 update in the /backend directory: [starlette](https://github.com/Kludex/starlette).


Updates `starlette` from 0.41.3 to 0.49.1
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/0.41.3...0.49.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 0.49.1
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-07 10:55:32 +00:00
Abhimanyu Saharan 13b3701810 feat(gateway): add endpoint for user outreach via gateway main agent 2026-02-07 16:21:31 +05:30
Abhimanyu Saharan 0816fb6cd3 feat(agent): enhance agent ID extraction and fallback handling in gateway integration 2026-02-07 16:21:21 +05:30
Ishan (OpenClaw) 70bef66132 chore(lint): remove trailing blank line in new backend modules 2026-02-07 10:11:13 +00:00
Ishan (OpenClaw) 2884c2d962 Merge remote-tracking branch 'origin/master' into ishan/fix-activity-clerkprovider 2026-02-07 10:10:50 +00:00
Ishan (OpenClaw) fd954238ea refactor(frontend): share Clerk publishable-key heuristic across client+server 2026-02-07 10:09:21 +00:00
Abhimanyu Saharan 7ef1f3e2f8 feat(chat): improve focus management for chat input after sending messages 2026-02-07 15:30:07 +05:30
Abhimanyu Saharan 7ec6506d72 feat(comments): update comment sorting to display latest comments first 2026-02-07 15:27:42 +05:30
Abhimanyu Saharan 0ce2e1e91f feat(activity): enhance live feed with new comment streaming and flash effects 2026-02-07 15:20:43 +05:30
Abhimanyu Saharan a32c01e4a8 feat(gateway): implement gateway board management and messaging features 2026-02-07 15:20:36 +05:30
Mateo (OpenClaw) 1d140d30c5 ci: add minimal frontend tests, backend coverage tooling, and fix mypy 2026-02-07 09:37:08 +00:00
Mateo (OpenClaw) ad38fcf69c ci: wire Clerk env vars into frontend CI steps 2026-02-07 09:36:47 +00:00
Ishan (OpenClaw) 795f40e50b fix(typecheck): avoid targets shadowing in board memory notify 2026-02-07 09:22:41 +00:00
Ishan (OpenClaw) 71e291edd8 fix(frontend): keep Clerk enabled check in sync with AuthProvider 2026-02-07 09:05:39 +00:00
Ishan (OpenClaw) 25024223df fix(typecheck): avoid targets shadowing in board memory notify 2026-02-07 09:02:52 +00:00
Ishan (OpenClaw) bc2572633f Merge remote-tracking branch 'origin/master' into ishan/ci-coverage-gate 2026-02-07 09:02:44 +00:00
Ishan (OpenClaw) 43dd30768d ci: add backend coverage gate + upload report 2026-02-07 09:01:53 +00:00
Abhimanyu Saharan 2c13c5b5ce feat(landing): enhance landing page with new enterprise design and features 2026-02-07 14:20:43 +05:30
Abhimanyu Saharan a7175d9a6f feat(board): add agent control dialog to pause and resume agents 2026-02-07 14:20:42 +05:30
Abhimanyu Saharan 3079a92492 feat(board): implement usePageActive hook to manage session refresh based on tab visibility 2026-02-07 14:20:42 +05:30
Abhimanyu SaharanandGitHub f6e0a551e5 Compose: default DB name to mission_control
Merges PR #31 (compose defaults + Clerk-safe activity import).
2026-02-07 14:19:09 +05:30
Ishaan (OpenClaw) 26787d64fd fix(frontend): use Clerk-safe auth wrapper on activity page 2026-02-07 08:09:46 +00:00
Ishaan (OpenClaw) affce2aba8 chore(compose): default POSTGRES_DB to mission_control 2026-02-07 07:15:53 +00:00
Abhimanyu Saharan 175126a44b fix(board): refine final question and free-text options in onboarding 2026-02-07 12:33:06 +05:30
Abhimanyu SaharanandGitHub 24084b0467 ci: add PR validation workflows
Merging PR #30
2026-02-07 12:32:25 +05:30
Abhimanyu Saharan 0fbb4d2df8 chore(deps): update psycopg[binary] to version 3.3.2 2026-02-07 11:35:32 +05:30
Abhimanyu Saharan 240351bd31 fix(board): show live feed history 2026-02-07 05:39:51 +05:30
Abhimanyu Saharan b2109da88b Add global live feed for task comments 2026-02-07 05:26:15 +05:30
Ishaan (OpenClaw) 24a3a720d3 chore: remove noop workflow trigger file 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) 20878c1d41 frontend: type Clerk button wrappers via ComponentProps 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) 720c19f852 frontend: type Clerk button wrappers (no any) 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) ba21ca9b61 chore: noop commit to retrigger CI 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) 39d2e530a7 frontend: disable Clerk only when publishable key is absent 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) 527879b55e ci: add workflow_dispatch for manual reruns 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) e8e27fdcc3 frontend: force-dynamic on auth routes to avoid prerender in CI 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) 6de49310bc frontend: provide Clerk-safe wrappers for secretless CI prerender 2026-02-06 23:34:08 +00:00
Ishaan (OpenClaw) b88936ae9c ci: remove dummy Clerk key; tighten AuthProvider guard 2026-02-06 23:33:25 +00:00
Ishaan (OpenClaw) 9f0dd01b65 frontend: make Clerk optional during build when key missing/invalid 2026-02-06 23:33:25 +00:00
Ishaan (OpenClaw) eec790455f ci: set dummy Clerk key for Next build in CI 2026-02-06 23:33:25 +00:00
Ishaan (OpenClaw) 5682460e70 ci: add GitHub Actions workflow for PR validation 2026-02-06 23:33:25 +00:00
Ishaan (OpenClaw) 8c0e9b1931 Add Dockerfiles and compose quickstart 2026-02-06 23:33:25 +00:00
Abhimanyu Saharan 844b521d00 Touch agent presence on authenticated requests 2026-02-07 04:34:16 +05:30
Abhimanyu Saharan 999ec6d1bb Add gateway template sync for agents 2026-02-07 04:24:06 +05:30
Abhimanyu Saharan 9c965d0ff4 feat(ui): improve live feed cards 2026-02-07 04:04:34 +05:30
Abhimanyu Saharan f683eba02f docs: enforce dependency blocking workflow in agent templates 2026-02-07 03:52:08 +05:30
Abhimanyu Saharan 1754c465cb docs: clarify which channel to use (task comments vs board chat vs approvals) 2026-02-07 03:47:11 +05:30
Abhimanyu Saharan c7165b2021 docs: improve AGENTS templates (external vs internal, extra context, cron) 2026-02-07 03:46:16 +05:30
Abhimanyu Saharan decf1f8dc4 fix: expand ~ for workspace files and ignore backend tilde dir 2026-02-07 03:42:10 +05:30
Abhimanyu Saharan e71507e0bf feat: allow extra context during board onboarding 2026-02-07 03:40:28 +05:30
Abhimanyu Saharan 19323e25de feat: implement error handling middleware with request ID tracking for improved error responses 2026-02-07 03:14:30 +05:30
Abhimanyu Saharan a4442eb9d5 feat: enhance agent provisioning by adding AUTONOMY.md and ensuring task dependencies are handled correctly 2026-02-07 02:42:33 +05:30
Abhimanyu Saharan c1d63f8178 feat: improve date handling by introducing utility functions for API datetime parsing and formatting 2026-02-07 02:23:39 +05:30
Abhimanyu Saharan 0ab5a75641 feat: enforce unique agent names across gateway workspaces to prevent session collisions 2026-02-07 02:02:50 +05:30
Abhimanyu Saharan 737f755623 feat: enforce unique agent names within the board and gateway workspace to prevent conflicts 2026-02-07 02:02:27 +05:30
Abhimanyu Saharan ee1ae36d99 feat: add de-duplication guidelines for task creation and approvals to prevent parallel work 2026-02-07 01:53:02 +05:30
Abhimanyu Saharan 8da70d199b feat: enhance agent provisioning by adding back-compat for MEMORY.md and improving error handling for unsupported files 2026-02-07 01:49:13 +05:30
Abhimanyu Saharan 22f24630a2 feat: add task view button in comments and improve live feed rendering 2026-02-07 01:13:33 +05:30
Abhimanyu Saharan ff68dd15d3 feat: add review bucket filtering and status indication in task board 2026-02-07 01:05:12 +05:30
Abhimanyu Saharan 5ffbcedfbe feat: improve dialog component layout and manage overflow behavior during onboarding 2026-02-07 01:04:16 +05:30
Abhimanyu Saharan ceff47ba77 feat: enhance onboarding experience by managing overflow behavior and improving dialog close button accessibility 2026-02-07 00:45:34 +05:30
Abhimanyu Saharan 5f603b205a feat: remove skyll_enabled attribute and related functionality from gateways 2026-02-07 00:30:53 +05:30
Abhimanyu Saharan 4bab455912 feat: implement task dependencies with validation and update handling 2026-02-07 00:21:44 +05:30
Abhimanyu Saharan 8970ee6742 feat: update HEARTBEAT_LEAD.md to clarify agent delegation roles and naming conventions 2026-02-06 23:12:50 +05:30
Abhimanyu Saharan 583cd57159 feat: enhance TaskBoard component with drag-and-drop animation and refactor card handling for improved user experience 2026-02-06 23:12:06 +05:30
Abhimanyu Saharan 8401feb0e6 feat: standardize MEMORY.md references across documentation for consistency 2026-02-06 23:10:41 +05:30
Abhimanyu Saharan c238ae9876 feat: enhance collaboration guidelines in documentation and refactor mention handling for improved clarity and functionality 2026-02-06 22:52:18 +05:30
Abhimanyu Saharan 5611f8eb67 feat: reorder properties in various interfaces for improved consistency and readability 2026-02-06 21:56:16 +05:30
Abhimanyu Saharan bc6345978d feat: add remark-breaks package for improved markdown line break handling 2026-02-06 20:28:52 +05:30
Abhimanyu Saharan e7dc2d0f8b feat: refactor chat input handling and add BoardChatComposer component for improved chat functionality 2026-02-06 20:28:48 +05:30
Abhimanyu Saharan e786763250 feat: add onboarding parameter handling in EditBoardPage for improved user experience 2026-02-06 20:23:17 +05:30
Abhimanyu Saharan e934ab0d76 feat: implement exponential backoff for SSE reconnections 2026-02-06 20:04:04 +05:30
Abhimanyu Saharan eeec029ab4 feat: add remark-gfm for enhanced markdown support and implement custom table components 2026-02-06 19:48:11 +05:30
Abhimanyu Saharan c7f3681b9d feat: update agent status display to use role labels instead of status 2026-02-06 19:38:54 +05:30
Abhimanyu Saharan c8c03c2b0d feat: add initial migration with schema for gateways, boards, users, agents, tasks, activity events, board memory, approvals, onboarding sessions, and task fingerprints 2026-02-06 19:36:53 +05:30
Abhimanyu Saharan d5487b7ee2 feat: refactor loadBoard and loadComments to use useCallback for performance optimization 2026-02-06 19:17:37 +05:30
Abhimanyu Saharan 6c14af0451 feat: add is_chat field to board memory and task_id to approvals, update pagination and response models 2026-02-06 19:11:11 +05:30
Abhimanyu Saharan d86fe0a7a6 feat: add validation for minimum length on various fields and update type definitions 2026-02-06 16:12:04 +05:30
Abhimanyu Saharan ca614328ac feat: implement async lifespan for FastAPI and remove startup event 2026-02-06 11:57:29 +05:30
Abhimanyu SaharanandGitHub 32010880d5 Merge pull request #28 from abhi1693/feature/board-lead-orchestration
Board lead orchestration: goals, onboarding, approvals, and live comments
2026-02-06 11:50:34 +05:30
Abhimanyu Saharan 7c569c150d feat: enhance agent heartbeat handling and validate onboarding goal fields 2026-02-06 11:50:14 +05:30
Abhimanyu Saharan 814c041785 feat: add jinja2 as a dependency in pyproject.toml and update uv.lock 2026-02-06 02:44:55 +05:30
Abhimanyu Saharan f258b756e8 feat: refactor imports and clean up code formatting across multiple files 2026-02-06 02:43:08 +05:30
Abhimanyu Saharan 2d60de0900 feat: add live feed for real-time task comments in the board 2026-02-06 02:31:36 +05:30
Abhimanyu Saharan 574800e5a9 feat: enhance BoardApprovalsPanel with detailed approval views and chart integration 2026-02-06 02:17:32 +05:30
Abhimanyu Saharan fe3bfade92 feat: simplify allowed development origins in next.config.ts 2026-02-06 01:56:35 +05:30
Abhimanyu Saharan d2e468ed9f feat: enhance markdown formatting guidelines for comments and feedback in agent and lead documentation 2026-02-06 00:49:10 +05:30
Abhimanyu Saharan 69a6597936 feat: implement task creation endpoint for board leads and enhance board chat functionality 2026-02-06 00:44:03 +05:30
Abhimanyu Saharan f8f5849341 feat: add task mention handling for agents and leads to improve communication 2026-02-06 00:11:08 +05:30
Abhimanyu Saharan 0f251e28f8 feat: improve comment formatting guidelines for agents and leads to enhance clarity 2026-02-05 23:45:37 +05:30
Abhimanyu Saharan 93ff09f253 feat: enhance approval serialization and implement agent notification for new task comments 2026-02-05 23:38:53 +05:30
Abhimanyu Saharan 13c404547f feat: clarify heartbeat expectations for agents by defining concrete outputs and blocker reporting 2026-02-05 23:27:35 +05:30
Abhimanyu Saharan 5ed6b923a4 feat: update heartbeat guidelines for agents and leads to clarify task review and commenting rules 2026-02-05 23:27:09 +05:30
Abhimanyu Saharan 51b352df45 feat: implement comment posting functionality and notify lead on task unassignment 2026-02-05 23:19:46 +05:30
Abhimanyu Saharan fa91352430 feat: add agent name uniqueness check and enhance heartbeat guidelines for leads 2026-02-05 23:06:59 +05:30
Abhimanyu Saharan 94937f96b9 feat: update delegation guidelines to prioritize task assignment order 2026-02-05 22:53:14 +05:30
Abhimanyu Saharan e09460a881 feat: enhance agent creation with human-like naming and improve task assignment notifications 2026-02-05 22:51:46 +05:30
Abhimanyu Saharan cbf9fd1b0a feat: implement agent nudging functionality and enhance task assignment rules for board leads 2026-02-05 22:27:50 +05:30
Abhimanyu Saharan 77e37f73b3 feat: add lead notification for new task creation and improve comment ordering 2026-02-05 19:48:30 +05:30
Abhimanyu Saharan 28b4a5bba8 feat: implement gateway main agent functionality and update agent handling 2026-02-05 19:29:17 +05:30
Abhimanyu SaharanandGitHub d0d8b09d5d Delete .github/dependabot.yml 2026-02-05 19:14:34 +05:30
Abhimanyu SaharanandGitHub abcc871321 Create dependabot.yml 2026-02-05 19:12:41 +05:30
Abhimanyu SaharanandGitHub e8cd6f5206 Merge pull request #26 from abhi1693/dependabot/uv/backend/jinja2-3.1.6
chore(deps): bump jinja2 from 3.1.4 to 3.1.6 in /backend
2026-02-05 19:11:53 +05:30
Abhimanyu SaharanandGitHub bfc7fa856e Merge pull request #27 from abhi1693/dependabot/uv/backend/cryptography-44.0.1
chore(deps): bump cryptography from 43.0.1 to 44.0.1 in /backend
2026-02-05 19:11:41 +05:30
Abhimanyu Saharan 4cc6c42440 feat: add is_board_lead property to agent and board types 2026-02-05 19:06:32 +05:30
Abhimanyu Saharan 9e8ff0dadf fix: align main agent template filenames 2026-02-05 16:02:19 +05:30
Abhimanyu Saharan 146d536a78 fix: add explicit onboarding api instructions 2026-02-05 16:01:10 +05:30
Abhimanyu Saharan 4926cc1c59 fix: clarify onboarding response protocol 2026-02-05 15:58:46 +05:30
Abhimanyu Saharan d1241360c9 fix: notify main agent on gateway update 2026-02-05 15:48:54 +05:30
Abhimanyu Saharan 62a4696a57 fix: add main tools template 2026-02-05 15:47:35 +05:30
Abhimanyu Saharan 0187ea4207 feat: provision main agent on gateway 2026-02-05 15:42:07 +05:30
Abhimanyu Saharan 3f3e44eff8 fix: delete board onboarding and lead artifacts 2026-02-05 15:29:41 +05:30
Abhimanyu Saharan ad4a4c5767 feat: use main agent for onboarding and auto-create lead 2026-02-05 15:26:35 +05:30
Abhimanyu Saharan 8e3b1b9d0d chore: move eslint ignores and fix onboarding typing 2026-02-05 15:17:52 +05:30
Abhimanyu Saharan 05d52926fa chore: simplify heartbeat note in agents guide 2026-02-05 15:13:20 +05:30
Abhimanyu Saharan e02e1eeca2 feat: split heartbeat templates and allow lead agent creation 2026-02-05 15:11:27 +05:30
Abhimanyu Saharan d29a3f5cda fix: allow draft goal boards 2026-02-05 15:00:20 +05:30
Abhimanyu Saharan 8ed5036af1 feat: add approvals queue panel 2026-02-05 15:00:09 +05:30
Abhimanyu Saharan 0a1f4392e8 feat: add board goal editor and onboarding chat 2026-02-05 14:59:33 +05:30
Abhimanyu Saharan 56b898130b feat: add board goal context to agent templates 2026-02-05 14:44:50 +05:30
Abhimanyu Saharan 5471671dc4 feat: add board memory, approvals, onboarding APIs 2026-02-05 14:43:25 +05:30
Abhimanyu Saharan c58117c494 feat: add board goal schemas and validation 2026-02-05 14:40:48 +05:30
Abhimanyu Saharan 89d2428252 feat: add board goals, memory, approvals, onboarding models 2026-02-05 14:39:34 +05:30
Abhimanyu Saharan 78e89427ba feat: add lead policy helpers 2026-02-05 14:37:12 +05:30
Abhimanyu Saharan d0fb0a50f4 chore: ignore worktrees 2026-02-05 14:32:25 +05:30
Abhimanyu Saharan 9ea0d0c4a4 feat(agent): Update task assignments on agent deletion to reset statuses 2026-02-05 03:08:47 +05:30
Abhimanyu Saharan 5e342e6906 feat(tasks): Enhance task streaming and comment validation with markdown support 2026-02-05 03:05:14 +05:30
Abhimanyu Saharan af3c437c0a feat(agent): Add linked board display for agent profile with conditional rendering 2026-02-05 02:25:49 +05:30
Abhimanyu Saharan 467f022534 chore(user): Remove pronouns field from user profile 2026-02-05 02:24:16 +05:30
Abhimanyu Saharan 9df5d174ef feat(agent): Introduce identity profile for agents with normalization and default values 2026-02-05 02:21:38 +05:30
Abhimanyu Saharan d19e7c34f2 chore(gitignore): Add next-env.d.ts to ignore list 2026-02-05 01:48:15 +05:30
Abhimanyu Saharan 6c4c97d2ea feat(agent): Refactor agent cleanup and provisioning logic for improved clarity and functionality 2026-02-05 01:40:28 +05:30
Abhimanyu Saharan 2c24d8993f feat(agent): Refactor agent provisioning and cleanup logic for improved functionality 2026-02-05 01:27:48 +05:30
Abhimanyu Saharan 8eb74acb1e fix(types): Update routes type reference to use production path 2026-02-05 01:00:16 +05:30
dependabot[bot]andGitHub 5c53e0fb4d chore(deps): bump cryptography from 43.0.1 to 44.0.1 in /backend
Bumps [cryptography](https://github.com/pyca/cryptography) from 43.0.1 to 44.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/43.0.1...44.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 44.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-04 19:19:36 +00:00
dependabot[bot]andGitHub af8bf0b3e2 chore(deps): bump jinja2 from 3.1.4 to 3.1.6 in /backend
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.6.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.6)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-version: 3.1.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-04 19:19:33 +00:00
Abhimanyu Saharan 2a557af22d feat(page): Replace Select components with SearchableSelect for improved usability 2026-02-05 00:44:23 +05:30
Abhimanyu Saharan 5135887c3f feat(page): Refactor agent management UI for improved layout and accessibility 2026-02-05 00:35:46 +05:30
Abhimanyu Saharan 81ca340ba8 feat(page): Enhance agent management UI with detailed status and sorting 2026-02-05 00:33:03 +05:30
Abhimanyu Saharan 340c2e9678 fix(types): Update routes type reference to use development path 2026-02-05 00:23:44 +05:30
Abhimanyu Saharan 51313a9272 feat(activity): Simplify query statements and improve code readability 2026-02-05 00:21:33 +05:30
Abhimanyu Saharan 79155e9067 feat(tasks): Normalize task statuses and enhance UI for task management 2026-02-05 00:18:54 +05:30
Abhimanyu Saharan 8f224910b5 feat(page): Optimize data handling with useMemo for boards, agents, and gateways 2026-02-05 00:11:48 +05:30
Abhimanyu Saharan 1889ac997e feat(agents): Replace gateway selection with searchable dropdown and improve UI for board creation 2026-02-05 00:08:18 +05:30
Abhimanyu Saharan 28bc773f72 feat(gateways): Add Gateway detail page with connection status and agent listing 2026-02-04 23:54:31 +05:30
Abhimanyu Saharan 8452dc110e feat(dashboard): Implement system health check and enhance UI for agent management 2026-02-04 23:43:40 +05:30
Abhimanyu Saharan b6f31fe6ea feat(gateways): Introduce gateway model and update agent provisioning templates 2026-02-04 23:07:22 +05:30
Abhimanyu Saharan 1297c12a73 feat(agents): Refactor gateway configuration handling and enhance UI for agent creation and editing 2026-02-04 21:44:21 +05:30
Abhimanyu Saharan 1a1efc2d5a feat(agents): Enhance UI for agent and board management with improved styling and layout 2026-02-04 21:27:58 +05:30
Abhimanyu Saharan bf2a9452dc feat(dashboard): Revamp UI components for improved layout and styling 2026-02-04 21:23:11 +05:30
Abhimanyu Saharan 8a41ba3f77 feat(metrics): Implement dashboard metrics API and integrate metrics chart components 2026-02-04 20:49:25 +05:30
Abhimanyu Saharan c3357f92d9 feat(agents): Add identity and soul template fields to board creation 2026-02-04 20:21:33 +05:30
Abhimanyu Saharan 1c972edb46 feat(agents): Update heartbeat documentation with commenting rules and pre-flight checks 2026-02-04 19:15:51 +05:30
Abhimanyu Saharan 3a2bc5135e feat(agents): Update task comment requirements and add in_progress_at tracking 2026-02-04 19:08:14 +05:30
Abhimanyu Saharan 554ecc4c85 feat(agents): Enhance agent update confirmation and status handling 2026-02-04 18:25:13 +05:30
Abhimanyu Saharan d2f4d11508 feat(agents): Update documentation for task updates and logging mechanism 2026-02-04 18:17:24 +05:30
Abhimanyu Saharan 7892dfad7c feat(agents): Add task assignment and comments functionality 2026-02-04 18:13:17 +05:30
Abhimanyu Saharan 8078580996 feat(agents): Add agent provisioning confirmation mechanism and update message structure 2026-02-04 17:24:52 +05:30
Abhimanyu Saharan d3642a5efd feat(agents): Require gateway workspace root and main session key for agent provisioning 2026-02-04 17:14:47 +05:30
Abhimanyu Saharan 8aa96ca876 feat(agents): Add heartbeat configuration and delete confirmation for agents 2026-02-04 17:05:58 +05:30
Abhimanyu Saharan ddad2ddb72 feat(agents): Enhance agent update to include provisioning and wakeup message 2026-02-04 16:23:54 +05:30
Abhimanyu Saharan 14f7ea2aa2 fix(boards): Cascade board deletion
Delete board-owned tasks, agents, and activity events before\nremoving the board. Adds board edit page with gateway settings\nand edit/delete actions in the boards table.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 16:15:01 +05:30
Abhimanyu Saharan d80f08e042 feat(agents): Send wakeup message on provision
Send a hello/wakeup message to the new agent session after\nprovisioning to prompt startup, while keeping the main agent\nuntouched.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 16:08:28 +05:30
Abhimanyu Saharan 4dea771545 feat(boards): Store gateway config per board
Move gateway configuration into board settings and wire agent\nprovisioning, heartbeat templates, and gateway status lookups\nto use board-specific gateway settings. Adds board_id on agents\nand UI updates for board-scoped selection.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 16:04:52 +05:30
Abhimanyu Saharan 12698d0781 ref(heartbeat): Remove cron provisioning
Remove gateway cron helpers and startup provisioning, relying on\nheartbeat instructions instead. Also drop unused httpx dependency.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 15:44:21 +05:30
Abhimanyu Saharan 4979991de6 fix(gateway): Handle non-JSON cron responses
Add JSON parsing guards and path fallback for cron endpoints so\ncron list errors expose gateway text and retry /cron/jobs.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 15:40:37 +05:30
Abhimanyu Saharan b36f755470 feat(gateway): Add cron job provisioning
Create a mission control runner cron job via the gateway HTTP API\nand ensure it is present on startup. Adds cron helpers, job\nbuilder, and a startup hook.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 15:22:52 +05:30
Abhimanyu Saharan 2dd0d1f2cf fix(app): Normalize provisioning templates and Clerk props
Use autoescape for Jinja rendering to satisfy bandit checks and\nremove deprecated Clerk SignInButton props to restore type checks.\nAlso ignore tsbuildinfo artifacts and tidy boot instructions.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 15:16:28 +05:30
Abhimanyu Saharan b0e3208fa3 feat: enhance agent management with session handling and UI improvements 2026-02-04 14:58:20 +05:30
Abhimanyu Saharan a33c539860 feat: enhance agent management with session handling and UI improvements 2026-02-04 14:58:14 +05:30
Abhimanyu Saharan f6105fa0d2 feat: update templates and improve UI styling across components 2026-02-04 13:03:18 +05:30
Abhimanyu Saharan b24e3e1dcd ref(backend): Centralize deps and add mypy
Extract reusable API dependencies and activity logging helpers.\nAdd mypy configuration and dev dependency for type checking.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 03:57:19 +05:30
Abhimanyu Saharan 7fd079e4f1 feat(agents): Move templates into dedicated folder
Provisioning now reads template files from templates/ and includes the\nbase URL for agent runtime setup. Remove unused root orchestration\ndocs to keep the repo tidy.\n\nCo-Authored-By: Claude <[email protected]>
2026-02-04 03:46:46 +05:30
Abhimanyu Saharan 1abc8f68f3 feat: add boards and tasks management endpoints 2026-02-04 02:28:51 +05:30
Jarvis 23faa0865b Make openclaw_agent_id migration idempotent 2026-02-02 19:00:52 +00:00
Jarvis 6103fa5ea9 Full OpenClaw agent profiles per employee 2026-02-02 18:58:52 +00:00
Jarvis 7c0eefbba3 Dispatch review tasks to reviewer (not assignee) 2026-02-02 17:47:52 +00:00
Jarvis 254ee0dbad Enforce actor identity for task/comment authorship 2026-02-02 17:43:17 +00:00
Jarvis 4f7aa582ba Clarify reviewer agent responsibilities (no waiting for requester) 2026-02-02 17:37:07 +00:00
Jarvis a2c60da161 Fix review notifications BackgroundTasks args 2026-02-02 17:29:49 +00:00
Jarvis 191947b90a Silence SQLAlchemy logs 2026-02-02 17:19:15 +00:00
Jarvis 557e0bbf27 Fix notify_openclaw BackgroundTasks call signature 2026-02-02 17:15:50 +00:00
Jarvis da1a1cee59 Squash alembic migrations into single baseline 2026-02-02 17:05:25 +00:00
Abhimanyu SaharanandGitHub 13b9bf95fb Merge pull request #25 from abhi1693/jarvis/seed-human-id-1
Seed Abhimanyu as employee id 1
2026-02-02 22:30:03 +05:30
Jarvis 4088bec248 Seed Abhimanyu as employee id 1 2026-02-02 16:59:36 +00:00
Abhimanyu SaharanandGitHub 69ee11a65d Update seed_data.sql 2026-02-02 22:29:20 +05:30
Abhimanyu SaharanandGitHub 03971e3a7d Merge pull request #24 from abhi1693/jarvis/human-id-1
jarvis/human id 1
2026-02-02 22:28:54 +05:30
Jarvis b3e490103e Merge master into jarvis/human-id-1 2026-02-02 16:58:39 +00:00
Jarvis 3d2dc7b2b1 Ensure human employee id is 1 2026-02-02 16:53:42 +00:00
Abhimanyu SaharanandGitHub 50328c13d8 Merge pull request #23 from abhi1693/jarvis/seed-human-only
Seed: keep only human (remove seeded agent)
2026-02-02 22:19:40 +05:30
Abhimanyu Saharan e0a2102778 chore(seed): remove seeded agent; keep only human 2026-02-02 22:19:03 +05:30
Abhimanyu SaharanandGitHub a74d8c993d Merge pull request #22 from abhi1693/jarvis/provisioning-guards
AuthZ: agents can only create/provision agents
2026-02-02 22:16:20 +05:30
Abhimanyu Saharan a04448dbd9 fix(authz): agents can only create/provision agents 2026-02-02 22:15:46 +05:30
Abhimanyu SaharanandGitHub 7eeae84f8c Merge pull request #21 from abhi1693/jarvis/rollup
Mission Control rollup: teams + agent workflow + notifications
2026-02-02 22:05:03 +05:30
Abhimanyu Saharan 352eb27a52 fix(notify): more resilient OpenClaw sends (retries/backoff + longer timeout) 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 521cf1efab feat(review): improve reviewer notification with explicit expectations and review endpoint 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan c0b0f26984 fix(notify): isolate background notifications in new DB session + reduce timeout spam 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 4b31b4ada8 feat(notify): auto-provision reviewer session on review status so notifications deliver 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan b59a08f632 feat(workflow): enforce review-only done for agent assignees + reviewer decision endpoint 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 52b2797b1d fix(notify): clean up agent dispatch message formatting 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 056fe83edf fix(agents): give agents executable curl instructions + shared base URL helper 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 5877b4a17b fix(notify): increase gateway timeouts + retry OpenClaw sends 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 8cd32124cd chore(logging): stream backend logs to console + instrument dispatch/notify 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 0caaaa1cb4 fix(dispatch): return 503 when OpenClaw gateway env missing 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 6b4773130c feat(review): notify manager on review + UI approve/request changes 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan dcf6dde009 chore: remove stray __pycache__ 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 56dd0ee544 feat(ui): add Task Trigger button + backend dispatch endpoint 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 61aaee0214 feat(notify): dispatch actionable instructions to assigned agents 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan a8f097817d chore(backend): add black/isort/flake8 + pre-commit 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 002bd08f33 Agent prompt: use LAN base URL (no localhost) 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 3895d74aee Agent prompt: fix base URL + header formatting 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 1a64f21e7b Agent prompt: require status updates + comments until done 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 9b0e5cef5c Do not hardcode DB password in reset script 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 8d3153d21a Seed: keep Jarvis + Abhimanyu 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 8b4b6b8000 Fix reset_db.sh default DB password 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan aec551d104 Add DB reset script + seed_data.sql 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan 8429c02458 UI provisioning + restrict task assignment to provisioned agents 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan a5ec372fa4 Fix CORS example + switch to psycopg v3 2026-02-02 22:04:21 +05:30
Abhimanyu Saharan ef2676fa1c Add Teams (DB + API + UI) 2026-02-02 22:04:21 +05:30
Jarvis e283543ef1 Add teams + team ownership (schema + API) 2026-02-02 12:51:25 +00:00
Abhimanyu SaharanandGitHub dc8750353d Merge pull request #19 from abhi1693/jarvis/default-reviewer-to-manager
Default reviewer = assignee manager; remove reviewer from task create UI
2026-02-02 17:58:04 +05:30
Abhimanyu Saharan 8787efc50d Tasks: default reviewer to assignee manager; remove reviewer from create-task UI 2026-02-02 17:56:35 +05:30
Abhimanyu Saharan 899500249e Fix UI data loading: Orval fetcher returns {data,status,headers} 2026-02-02 17:04:18 +05:30
Abhimanyu SaharanandGitHub 2520dcdb55 Merge pull request #18 from abhi1693/jarvis/remove-hr-module
Remove HR module; simplify provisioning via /employees
2026-02-02 17:00:04 +05:30
Abhimanyu Saharan fbd7381166 People: better default agent prompt; remove HR UI; regen API types; fix list response handling 2026-02-02 16:56:07 +05:30
Abhimanyu Saharan a678180d07 Remove HR completely: delete hr API; baseline alembic without HR tables 2026-02-02 16:51:06 +05:30
Abhimanyu Saharan 1bbc65c983 Remove HR module; provision agent sessions via /employees 2026-02-02 16:48:17 +05:30
Abhimanyu SaharanandGitHub 8f8e3b7c67 Merge pull request #17 from abhi1693/jarvis/fix-alembic-baseline
Fix Alembic baseline migration (fresh DB works)
2026-02-02 16:39:28 +05:30
Abhimanyu Saharan 9945b619bd Fix alembic: replace broken no-op initial migration with baseline schema 2026-02-02 16:38:33 +05:30
Abhimanyu SaharanandGitHub e80905aee1 Merge pull request #16 from abhi1693/jarvis/hr-openclaw-provision
HR: provision/deprovision agents via OpenClaw tools/invoke (no cron)
2026-02-02 16:30:34 +05:30
Abhimanyu Saharan 9118fea9bf HR: provision/deprovision agent sessions via OpenClaw tools/invoke (no cron) 2026-02-02 16:28:17 +05:30
Abhimanyu Saharan 612e19036c Backend: add requests dependency for OpenClaw integration 2026-02-02 16:12:35 +05:30
Abhimanyu Saharan b6701e0a60 Fix: import Header in HR API 2026-02-02 16:11:52 +05:30
Abhimanyu SaharanandGitHub bb3c1c59f7 Merge pull request #15 from abhi1693/jarvis/openclaw-tools-invoke-notify
Mission Control → OpenClaw tools/invoke notifications (event-driven)
2026-02-02 16:08:00 +05:30
Abhimanyu Saharan 2bcd078c53 Mission Control: notify OpenClaw via tools/invoke on task events 2026-02-02 16:05:18 +05:30
Abhimanyu SaharanandGitHub 5f5a1ce7cb Merge pull request #14 from abhi1693/jarvis/hr-data-model
HR data model: link onboarding/headcount to employees + idempotent actions
2026-02-02 14:39:29 +05:30
Abhimanyu Saharan 63419a26f1 feat(hr): add employee links + fulfillment fields to schemas/models 2026-02-02 14:35:48 +05:30
Abhimanyu Saharan 9617f1fc3b feat(hr): link headcount/onboarding to employees + idempotent actions 2026-02-02 14:35:20 +05:30
Abhimanyu SaharanandGitHub fbc9af035b Merge pull request #13 from abhi1693/jarvis/fix-work-api-responses
Fix Mission Control: /tasks + /task-comments return full JSON (atomic writes)
2026-02-02 14:01:49 +05:30
Abhimanyu Saharan f1fe2127da fix(api): make /tasks + /task-comments atomic and return full JSON 2026-02-02 14:00:46 +05:30
Abhimanyu SaharanandGitHub d5f527f311 Merge pull request #12 from abhi1693/jarvis/realtime-polling
Kanban: optional live updates (5s polling)
2026-02-02 13:57:37 +05:30
Abhimanyu Saharan f07312d258 feat(kanban): optional live updates (5s polling) 2026-02-02 13:56:56 +05:30
Abhimanyu SaharanandGitHub 7b3c3eba6d Merge pull request #11 from abhi1693/jarvis/fix-projects-post
Fix Mission Control: POST /projects atomic + 409 on conflicts
2026-02-02 13:55:04 +05:30
Abhimanyu Saharan dffcd34a35 fix(api): make POST /projects atomic + 409 on conflicts 2026-02-02 13:54:07 +05:30
Abhimanyu SaharanandGitHub 8f9c312fa3 Merge pull request #10 from abhi1693/jarvis/task-conversations
Task comments: reply threading + show reply target
2026-02-02 13:48:39 +05:30
Abhimanyu Saharan 96f6b540fe feat(comments): add reply threading + UI 2026-02-02 13:47:36 +05:30
Abhimanyu SaharanandGitHub 452ac24686 Merge pull request #9 from abhi1693/jarvis/fix-departments-post
Fix Mission Control: POST /departments atomic + 409 on conflicts
2026-02-02 13:45:50 +05:30
Abhimanyu Saharan 9f4e65023f fix(api): make POST /departments atomic + handle integrity errors 2026-02-02 13:43:52 +05:30
Abhimanyu SaharanandGitHub d44e0acd50 Merge pull request #8 from abhi1693/jarvis/react-query-realtime
React Query: refetch on focus/reconnect (near-realtime feel)
2026-02-02 13:41:59 +05:30
Abhimanyu Saharan 67599029f1 feat(query): refetch on focus/reconnect for fresher data 2026-02-02 13:41:14 +05:30
Abhimanyu SaharanandGitHub c1bb6f4ce0 Merge pull request #7 from abhi1693/jarvis/kanban
Tasks: Kanban board page
2026-02-02 13:38:34 +05:30
Abhimanyu Saharan 23bf73a236 feat(tasks): add kanban board page 2026-02-02 13:37:58 +05:30
Abhimanyu SaharanandGitHub 6eabde847d Merge pull request #6 from abhi1693/jarvis/ux-audit-hr
HR page UX: loading/errors + refresh fixes
2026-02-02 13:32:47 +05:30
Abhimanyu Saharan a517f68249 feat(hr): loading/errors + safer refresh/onboarding 2026-02-02 13:31:47 +05:30
Abhimanyu SaharanandGitHub 2e785da8fa Merge pull request #5 from abhi1693/jarvis/ux-audit-departments
Departments UX: loading + safer head updates
2026-02-02 13:28:53 +05:30
Abhimanyu Saharan 2b222e66c7 feat(departments): better loading + safer head updates 2026-02-02 13:28:00 +05:30
Abhimanyu SaharanandGitHub cb40ad07d0 Merge pull request #4 from abhi1693/jarvis/ux-audit-project-detail
Project detail UX: loading/errors + safer actions
2026-02-02 13:27:07 +05:30
Abhimanyu Saharan e291efb144 feat(project-detail): loading/errors + safer member/actions 2026-02-02 13:26:09 +05:30
Abhimanyu SaharanandGitHub c85d52a133 Merge pull request #3 from abhi1693/jarvis/ux-audit-projects-list
Projects page UX: loading/error + safer create
2026-02-02 13:24:22 +05:30
Abhimanyu Saharan 4b6e5603c1 feat(projects): show load/error + safer create 2026-02-02 13:23:49 +05:30
Abhimanyu SaharanandGitHub cfac3dac4c Merge pull request #2 from abhi1693/jarvis/ux-audit-home
Home page UX: errors + links
2026-02-02 13:22:18 +05:30
Abhimanyu Saharan 4538951c4e feat(home): improve quick create UX + link projects 2026-02-02 13:18:48 +05:30
Abhimanyu SaharanandGitHub ee39f5caed Merge pull request #1 from abhi1693/jarvis/orval-types
Use Orval types directly + repo hygiene
2026-02-02 13:17:22 +05:30
Abhimanyu Saharan 6e519ed383 chore(frontend): fix PeoplePage memo deps lint 2026-02-02 13:11:20 +05:30
Abhimanyu Saharan ed33156148 chore(frontend): use Orval types directly; drop normalize hacks 2026-02-02 13:09:35 +05:30
Abhimanyu Saharan 6da3df27d8 Fix frontend types and normalize API responses 2026-02-02 01:56:12 +05:30
Abhimanyu Saharan 3d57a9a351 Fix HR onboarding hooks 2026-02-02 01:39:16 +05:30
Abhimanyu Saharan 886db3fa97 Add HR agent onboarding model and actor enforcement 2026-02-02 01:36:32 +05:30
Abhimanyu Saharan d18a38e666 Update TypeScript configuration: adjust library settings and include additional type definitions 2026-02-02 01:01:13 +05:30
Abhimanyu Saharan 829001e0da UI overhaul: dashboard + projects layout 2026-02-02 00:37:32 +05:30
Abhimanyu Saharan 7cfb3586ae Prevent duplicate project members; improve comments display 2026-02-02 00:08:25 +05:30
Abhimanyu Saharan 7efe2429ed Add dashboard activity feed and project member role editing 2026-02-01 23:51:40 +05:30
Abhimanyu Saharan f0e065abcd Add project staffing endpoints and project detail Kanban UI 2026-02-01 23:46:14 +05:30
Abhimanyu Saharan 5b4257ef33 Fix Orval config to generate React Query hooks 2026-02-01 23:31:42 +05:30
Abhimanyu Saharan 87d454852c Add Tailwind + shadcn UI primitives; add React Query + Orval client 2026-02-01 23:26:21 +05:30
Abhimanyu Saharan aa6b0c807b Refactor backend to SQLModel; reset schema; add Company OS endpoints 2026-02-01 23:16:56 +05:30
Abhimanyu Saharan b37e7dd841 Ignore local run logs 2026-02-01 23:12:52 +05:30
Abhimanyu Saharan 22fef51fb8 Make dev servers LAN-accessible (0.0.0.0) + update env examples 2026-02-01 22:31:44 +05:30
Abhimanyu Saharan 01c8a64a6d Add LAN-friendly dev scripts and env examples 2026-02-01 22:31:42 +05:30
Abhimanyu Saharan 3e4a6cf441 Stop tracking .idea and backend/.venv 2026-02-01 22:28:32 +05:30
Abhimanyu Saharan 1cd33f4209 Add .gitignore; remove venv/node_modules/IDE + secrets from git 2026-02-01 22:25:50 +05:30
Abhimanyu Saharan 8b6e8c8d07 Scaffold Next.js + FastAPI + Postgres tasks board (no auth) 2026-02-01 22:25:28 +05:30
710 changed files with 128544 additions and 334 deletions
+36
View File
@@ -0,0 +1,36 @@
.git
.gitignore
.github
# Python
**/__pycache__
**/*.py[cod]
**/*.pyo
**/*.pyd
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.coverage
**/htmlcov
**/*.egg-info
.venv
**/.venv
# Node / Next
**/node_modules
**/.next
**/dist
**/build
**/.turbo
# OS / editor
.DS_Store
*.swp
*.swo
# Local env files
**/.env
**/.env.*
# Logs
**/*.log
-1
View File
@@ -1 +0,0 @@
HOST=0.0.0.0
+27
View File
@@ -0,0 +1,27 @@
# 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
@@ -0,0 +1,35 @@
## 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
@@ -0,0 +1,258 @@
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/**
+25 -6
View File
@@ -1,6 +1,25 @@
node_modules # Python
.next __pycache__/
out *.py[cod]
build
dist # Node / Next
*.log 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
+24
View File
@@ -0,0 +1,24 @@
# 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
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,39 @@
# 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
@@ -0,0 +1,80 @@
# 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).
-8
View File
@@ -1,8 +0,0 @@
FROM node:20-alpine
WORKDIR /app
ENV HOST=0.0.0.0
ENV NODE_ENV=production
COPY . .
RUN npm install && npm run build
EXPOSE 3000
CMD ["node", ".next/standalone/server.js"]
+21
View File
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,167 @@
.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
@@ -0,0 +1,147 @@
# 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)
-143
View File
@@ -1,143 +0,0 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
const DATA_DIR = '/app/data';
// Types
type CronJob = { name: string; enabled: boolean; status: string };
type Task = { id: string; title: string; created: string; priority: string; status: string; assignee: string };
type Memory = { id: string; title: string; date: string; preview: string };
// Helper to read files
function readDir(dir: string, pattern: string = '.md'): string[] {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter(f => f.endsWith(pattern));
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'tasks';
try {
// TASKS
if (type === 'tasks') {
const tasksDir = path.join(DATA_DIR, 'shared-context/jelena-neo-tasks/archive');
const tasks: Task[] = [];
if (fs.existsSync(tasksDir)) {
for (const file of readDir(tasksDir).slice(0, 20)) {
const content = fs.readFileSync(path.join(tasksDir, file), 'utf-8');
tasks.push({
id: file.replace('.md', ''),
title: content.match(/^# Task: (.+)$/m)?.[1] || file,
created: content.match(/Created: (.+)$/m)?.[1] || '',
priority: content.match(/Priority: (.+)$/m)?.[1] || 'normal',
status: 'done',
assignee: 'neo'
});
}
}
return NextResponse.json(tasks);
}
// CRONS - from shared-context
if (type === 'crons') {
// Try multiple paths for cron jobs
const cronPaths = [
path.join(DATA_DIR, 'shared-context/.openclaw/cron/jobs.json'),
path.join(DATA_DIR, 'workspace-neo/.openclaw/cron/jobs.json'),
];
for (const cronFile of cronPaths) {
if (fs.existsSync(cronFile)) {
const data = JSON.parse(fs.readFileSync(cronFile, 'utf-8'));
const jobs: CronJob[] = (data.jobs || []).map((job: any) => ({
name: job.name,
enabled: job.enabled,
status: job.state?.lastStatus || 'unknown'
}));
return NextResponse.json(jobs);
}
}
return NextResponse.json([]);
}
// MEMORY
if (type === 'memory') {
const memoryDir = path.join(DATA_DIR, 'memory');
const memories: Memory[] = [];
if (fs.existsSync(memoryDir)) {
for (const file of readDir(memoryDir).slice(0, 20)) {
const content = fs.readFileSync(path.join(memoryDir, file), 'utf-8');
memories.push({
id: file.replace('.md', ''),
title: content.match(/^# (.+)$/m)?.[1] || file,
date: file.substring(0, 10),
preview: content.substring(0, 150).replace(/[#*]/g, '')
});
}
}
return NextResponse.json(memories);
}
// SERVER STATUS - basic data only
if (type === 'server') {
return NextResponse.json({
status: 'ok',
note: 'Full stats coming soon'
});
}
// BACKUP STATUS
if (type === 'backups') {
const backups: any[] = [];
// Git backup
const gitBackupDir = path.join(DATA_DIR, 'workspace-neo/.git');
if (fs.existsSync(gitBackupDir)) {
const stats = fs.statSync(gitBackupDir);
backups.push({ name: 'Git Config', status: 'ok', lastRun: 'Today 10:49' });
}
// Storage backup
const storageDir = path.join(DATA_DIR, '../openclaw/jelena/backups/config');
if (fs.existsSync(storageDir)) {
const files = fs.readdirSync(storageDir);
if (files.length > 0) {
const latest = files.sort().pop();
backups.push({ name: 'Storage Box', status: 'ok', lastRun: latest?.replace('.tar.gz', '') || 'unknown' });
}
}
return NextResponse.json(backups);
}
// AGENT STATUS
if (type === 'agents') {
return NextResponse.json([
{ name: 'Jelena', role: 'Chief of Staff', status: 'online' },
{ name: 'Neo', role: 'CTO / DevOps', status: 'online' }
]);
}
// WHATSAPP BACKUP
if (type === 'whatsapp') {
const waDir = path.join(DATA_DIR, '../openclaw/jelena/whatsapp-chats');
if (fs.existsSync(waDir)) {
const chats = fs.readdirSync(waDir);
return NextResponse.json({
status: 'ok',
chats: chats.length,
lastUpdate: 'Today'
});
}
return NextResponse.json({ status: 'not_configured', chats: 0, lastUpdate: 'Never' });
}
return NextResponse.json({ error: 'Unknown type' }, { status: 400 });
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
}
-53
View File
@@ -1,53 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
background: #0a0a0f;
color: #e4e4e7;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
body {
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255,255,255,0.02);
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.2);
}
/* Selection */
::selection {
background: rgba(255, 0, 80, 0.3);
color: #fff;
}
/* Animations */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 20px rgba(255, 0, 80, 0.3); }
50% { box-shadow: 0 0 40px rgba(255, 0, 80, 0.5); }
}
-19
View File
@@ -1,19 +0,0 @@
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>
);
}
-36
View File
@@ -1,36 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
export default function Page() {
const [tab, setTab] = useState('tasks');
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch('/api/data?type=' + tab)
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, [tab]);
const tabs = ['tasks','crons','server','backups','agents','whatsapp','memory'];
return (
<div style={{background:'#000',minHeight:'100vh',color:'#fff',fontFamily:'monospace',padding:'20px'}}>
<h1 style={{fontSize:'20px',marginBottom:'15px'}}>MISSION CONTROL</h1>
<div style={{display:'flex',gap:'5px',flexWrap:'wrap',marginBottom:'15px'}}>
{tabs.map(t => (
<button key={t} onClick={()=>setTab(t)} style={{
padding:'8px 12px',background:tab===t?'#f0f': '#333',
color:'#fff',border:'none',borderRadius:'4px',fontSize:'12px'
}}>{t}</button>
))}
</div>
<pre style={{background:'#111',padding:'15px',borderRadius:'8px',overflow:'auto'}}>
{loading ? 'Loading...' : JSON.stringify(data,null,2)}
</pre>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
[run]
branch = True
source =
app
omit =
*/.venv/*
alembic/versions/*
[report]
show_missing = True
skip_covered = True
+27
View File
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,10 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503, E501
exclude =
.venv,
backend/.venv,
migrations,
backend/migrations,
**/__pycache__,
**/*.pyc
+13
View File
@@ -0,0 +1,13 @@
__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
@@ -0,0 +1,49 @@
# 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
@@ -0,0 +1,168 @@
# 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
@@ -0,0 +1,36 @@
[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
@@ -0,0 +1 @@
"""OpenClaw Mission Control backend application package."""
+1
View File
@@ -0,0 +1 @@
"""API router modules for the OpenClaw Mission Control backend."""
+285
View File
@@ -0,0 +1,285 @@
"""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
@@ -0,0 +1,168 @@
"""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
@@ -0,0 +1,485 @@
"""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
@@ -0,0 +1,62 @@
"""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
@@ -0,0 +1,641 @@
"""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
@@ -0,0 +1,385 @@
"""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
@@ -0,0 +1,306 @@
"""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
@@ -0,0 +1,474 @@
"""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
@@ -0,0 +1,525 @@
"""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
@@ -0,0 +1,495 @@
"""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
@@ -0,0 +1,208 @@
"""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
@@ -0,0 +1,148 @@
"""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
@@ -0,0 +1,203 @@
"""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
@@ -0,0 +1,484 @@
"""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
@@ -0,0 +1,730 @@
"""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
@@ -0,0 +1,79 @@
"""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
@@ -0,0 +1,88 @@
"""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
@@ -0,0 +1,220 @@
"""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
@@ -0,0 +1,343 @@
"""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
@@ -0,0 +1,292 @@
"""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
@@ -0,0 +1 @@
"""Core utilities and configuration for the backend service."""
+170
View File
@@ -0,0 +1,170 @@
"""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
@@ -0,0 +1,55 @@
"""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
@@ -0,0 +1,516 @@
"""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
@@ -0,0 +1,12 @@
"""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
@@ -0,0 +1,98 @@
"""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
@@ -0,0 +1,49 @@
"""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
@@ -0,0 +1,309 @@
"""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
@@ -0,0 +1,322 @@
"""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
@@ -0,0 +1,11 @@
"""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
@@ -0,0 +1,4 @@
"""Application name and version constants."""
APP_NAME = "mission-control"
APP_VERSION = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""Database helpers and abstractions for backend persistence."""
+324
View File
@@ -0,0 +1,324 @@
"""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
@@ -0,0 +1,33 @@
"""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
@@ -0,0 +1,90 @@
"""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
@@ -0,0 +1,80 @@
"""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
@@ -0,0 +1,93 @@
"""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
@@ -0,0 +1,549 @@
"""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
@@ -0,0 +1,63 @@
"""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
@@ -0,0 +1,26 @@
"""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
@@ -0,0 +1,48 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,15 @@
"""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
@@ -0,0 +1,28 @@
"""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
@@ -0,0 +1,27 @@
"""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
@@ -0,0 +1,28 @@
"""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
@@ -0,0 +1,32 @@
"""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)
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,27 @@
"""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
@@ -0,0 +1,48 @@
"""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
@@ -0,0 +1,28 @@
"""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)
@@ -0,0 +1,38 @@
"""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)
@@ -0,0 +1,38 @@
"""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)
@@ -0,0 +1,42 @@
"""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)
@@ -0,0 +1,36 @@
"""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
@@ -0,0 +1,24 @@
"""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
@@ -0,0 +1,88 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,36 @@
"""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
@@ -0,0 +1,92 @@
"""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
@@ -0,0 +1,37 @@
"""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
@@ -0,0 +1,25 @@
"""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
@@ -0,0 +1,46 @@
"""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
@@ -0,0 +1,9 @@
"""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
@@ -0,0 +1,31 @@
"""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
@@ -0,0 +1,112 @@
"""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
@@ -0,0 +1,36 @@
"""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
@@ -0,0 +1,316 @@
"""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
@@ -0,0 +1,97 @@
"""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

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